多层感知机
多层感知机即MLP神经网络的定义已经在神经网络:表述所详细描述过了,这一章主要描述在Pytorch中的实现
激活函数
激活函数(activation function)通过计算加权和并加上偏置来确定神经元是否应该被激活, 它们将输入信号转换为输出的可微运算。 大多数激活函数都是非线性的。 由于激活函数是深度学习的基础,下面简要介绍一些常见的激活函数
ReLU函数
最受欢迎的激活函数是修正线性单元(Rectified linear unit,ReLU), 因为它实现简单,同时在各种预测任务中表现良好。 ReLU提供了一种非常简单的非线性变换。 给定元素$x$,ReLU函数被定义为该元素与0的最大值
通俗地说,ReLU函数通过将相应的活性值设为0,仅保留正元素并丢弃所有负元素。 为了直观感受一下,我们可以画出函数的曲线图。 正如从图中所看到,激活函数是分段线性的。
1 2 3
| x = torch.arange(-8.0, 8.0, 0.1, requires_grad=True) y = torch.relu(x) d2l.plot(x.detach(), y.detach(), 'x', 'relu(x)', figsize=(5, 2.5))
|
当输入为负时,ReLU函数的导数为0,而当输入为正时,ReLU函数的导数为1。 注意,当输入值精确等于0时,ReLU函数不可导。 在此时,我们默认使用左侧的导数,即当输入为0时导数为0。 我们可以忽略这种情况,因为输入可能永远都不会是0
使用ReLU的原因是,它求导表现得特别好:要么让参数消失,要么让参数通过。 这使得优化表现得更好,并且ReLU减轻了困扰以往神经网络的梯度消失问题
其他的激活函数,比如sigmoid函数之前已经介绍过了,所以略过
从零实现
1 2 3 4 5 6
| import torch from torch import nn from d2l import torch as d2l
batch_size = 256 train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
|
初始化模型参数
Fashion-MNIST中的每个图像由 28×28=784个灰度像素值组成。 所有图像共分为10个类别。 忽略像素之间的空间结构, 我们可以将每个图像视为具有784个输入特征 和10个类的简单分类数据集。 首先,我们将实现一个具有单隐藏层的多层感知机, 它包含256个隐藏单元。 注意,我们可以将这两个变量都视为超参数。 通常,我们选择2的若干次幂作为层的宽度。 因为内存在硬件中的分配和寻址方式,这么做往往可以在计算上更高效。
我们用几个张量来表示我们的参数。 注意,对于每一层我们都要记录一个权重矩阵和一个偏置向量。 跟以前一样,我们要为损失关于这些参数的梯度分配内存。
1 2 3 4 5 6 7 8 9 10 11
| num_inputs, num_outputs, num_hiddens = 784, 10, 256
W1 = nn.Parameter(torch.randn(num_inputs, num_hiddens, requires_grad=True) * 0.01)
b1 = nn.Parameter(torch.zeros(num_hiddens, requires_grad=True))
W2 = nn.Parameter(torch.randn(num_hiddens, num_outputs, requires_grad=True) * 0.01)
b2 = nn.Parameter(torch.zeros(num_outputs, requires_grad=True))
params = [W1, b1, W2, b2]
|
激活函数
RELU函数
1 2 3
| def relu(X): a = torch.zeros_like(X) return torch.max(X, a)
|
模型
因为我们忽略了空间结构, 所以我们使用reshape
将每个二维图像转换为一个长度为num_inputs
的向量。 只需几行代码就可以实现我们的模型。
1 2 3 4
| def net(X): X = X.reshape((-1, num_inputs)) H = relu(X@W1 + b1) return (H@W2 + b2)
|
损失函数
1
| loss = nn.CrossEntropyLoss(reduction='none')
|
训练
直接调用d2l
包的train_ch3
函数
1 2 3
| num_epochs, lr = 10, 0.1 updater = torch.optim.SGD(params, lr=lr) d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, updater)
|
简洁实现
1 2 3
| import torch from torch import nn from d2l import torch as d2l
|
与softmax回归的简洁实现相比, 唯一的区别是我们添加了2个全连接层(之前我们只添加了1个全连接层)。 第一层是隐藏层,它包含256个隐藏单元,并使用了ReLU激活函数。 第二层是输出层。
1 2 3 4 5 6 7 8 9 10
| net = nn.Sequential(nn.Flatten(), nn.Linear(784, 256), nn.ReLU(), nn.Linear(256, 10))
def init_weights(m): if type(m) == nn.Linear: nn.init.normal_(m.weight, std=0.01)
net.apply(init_weights);
|
nn.Sequential
是一个容器,按照在其中添加模块的顺序执行这些模块。这允许你快速地堆叠不同的层来构建神经网络。
nn.Flatten()
层将输入的二维图像张量自动展平成一维张量,以便后续的全连接层(nn.Linear
)可以处理它们。
nn.Linear(784, 256)
定义了一个全连接层,它接受784个输入特征并输出256个特征。这些特征数对应于隐藏层的神经元数量。
nn.ReLU()
是一个非线性激活函数,用于引入非线性,使得网络可以学习复杂的模式。
nn.Linear(256, 10)
定义了第二个全连接层,它将隐藏层的256个特征映射到10个输出特征,这10个特征对应于分类任务的类别数量。
1 2 3 4 5 6
| batch_size, lr, num_epochs = 256, 0.1, 10 loss = nn.CrossEntropyLoss(reduction='none') trainer = torch.optim.SGD(net.parameters(), lr=lr)
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size) d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
|
K折交叉验证
原则上,在我们确定所有的超参数之前,我们不希望用到测试集。 如果我们在模型选择过程中使用测试数据,可能会有过拟合测试数据的风险,那就麻烦大了。 如果我们过拟合了训练数据,还可以在测试数据上的评估来判断过拟合。 但是如果我们过拟合了测试数据,我们又该怎么知道呢?
因此,我们决不能依靠测试数据进行模型选择。 然而,我们也不能仅仅依靠训练数据来选择模型,因为我们无法估计训练数据的泛化误差。
在实际应用中,情况变得更加复杂。 虽然理想情况下我们只会使用测试数据一次, 以评估最好的模型或比较一些模型效果,但现实是测试数据很少在使用一次后被丢弃。 我们很少能有充足的数据来对每一轮实验采用全新测试集。
解决此问题的常见做法是将我们的数据分成三份, 除了训练和测试数据集之外,还增加一个验证数据集(validation dataset), 也叫验证集(validation set)。
当训练数据稀缺时,我们甚至可能无法提供足够的数据来构成一个合适的验证集。 这个问题的一个流行的解决方案是采用K折交叉验证。 这里,原始训练数据被分成$K$个不重叠的子集。 然后执行$K$次模型训练和验证,每次在$K−1$个子集上进行训练, 并在剩余的一个子集(在该轮中没有用于训练的子集)上进行验证。 最后,通过对$K$次实验的结果取平均来估计训练和验证误差。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| def get_k_fold_data(k, i, X, y):
""" 参数: - k (int): K折交叉验证中的“折”(fold)数,表示将数据集分割成多少份。 - i (int): 指定当前迭代使用哪一折作为验证集,范围从0到k-1。 - X (Tensor): 完整的特征数据集,假设其形状为 (样本数, 特征数)。 - y (Tensor): 与X对应的标签数据集,假设其形状为 (样本数,) 或 (样本数, 标签数)。 """ assert k > 1 fold_size = X.shape[0] // k X_train, y_train = None, None for j in range(k): idx = slice(j * fold_size, (j + 1) * fold_size) X_part, y_part = X[idx, :], y[idx] if j == i: X_valid, y_valid = X_part, y_part elif X_train is None: X_train, y_train = X_part, y_part else: X_train = torch.cat([X_train, X_part], 0) y_train = torch.cat([y_train, y_part], 0) return X_train, y_train, X_valid, y_valid
|
$K$折交叉验证过程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| def k_fold(k, X_train, y_train, num_epochs, learning_rate, weight_decay, batch_size): """ 执行K折交叉验证。 参数: - k (int): 折数,即将数据集分成多少份。 - X_train (Tensor): 完整的训练数据特征。 - y_train (Tensor): 完整的训练数据标签。 - num_epochs (int): 每次训练时的迭代轮数。 - learning_rate (float): 学习率。 - weight_decay (float): 权重衰减(L2惩罚)。 - batch_size (int): 批量大小。
返回: - train_l_sum / k (float): K折交叉验证的平均训练损失。 - valid_l_sum / k (float): K折交叉验证的平均验证损失。 """
train_l_sum, valid_l_sum = 0, 0
for i in range(k): data = get_k_fold_data(k, i, X_train, y_train) net = get_net() train_ls, valid_ls = train(net, *data, num_epochs, learning_rate, weight_decay, batch_size) train_l_sum += train_ls[-1] valid_l_sum += valid_ls[-1] if i == 0: d2l.plot(list(range(1, num_epochs + 1)), [train_ls, valid_ls], xlabel='epoch', ylabel='rmse', xlim=[1, num_epochs], legend=['train', 'valid'], yscale='log') print(f'折{i + 1},训练log rmse{float(train_ls[-1]):f}, ' f'验证log rmse{float(valid_ls[-1]):f}')
return train_l_sum / k, valid_l_sum / k
|
正则化
在训练参数化机器学习模型时, 权重衰减(weight decay)是最广泛使用的正则化的技术之一, 它通常也被称为$L_2$正则化。 这项技术通过函数与零的距离来衡量函数的复杂度, 因为在所有函数$f$中,函数$f=0$(所有输入都得到值0) 在某种意义上是最简单的
首先,我们将定义一个函数来随机初始化模型参数。
1 2 3 4
| def init_params(): w = torch.normal(0, 1, size=(num_inputs, 1), requires_grad=True) b = torch.zeros(1, requires_grad=True) return [w, b]
|
定义$L_2$范数惩罚
1 2
| def l2_penalty(w): return torch.sum(w.pow(2)) / 2
|
定义训练代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| def train(lambd): w, b = init_params() net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss num_epochs, lr = 100, 0.003 animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log', xlim=[5, num_epochs], legend=['train', 'test']) for epoch in range(num_epochs): for X, y in train_iter: l = loss(net(X), y) + lambd * l2_penalty(w) l.sum().backward() d2l.sgd([w, b], lr, batch_size) if (epoch + 1) % 5 == 0: animator.add(epoch + 1, (d2l.evaluate_loss(net, train_iter, loss), d2l.evaluate_loss(net, test_iter, loss))) print('w的L2范数是:', torch.norm(w).item())
|
先采用lambd=0
禁用权重衰减
w的L2范数是:12.963241577148438
然后使用lambd=3
来运行代码
w的L2范数是: 0.3556520938873291
简洁代码实现
由于权重衰减在神经网络优化中很常用, 深度学习框架为了便于我们使用权重衰减, 将权重衰减集成到优化算法中,以便与任何损失函数结合使用。 此外,这种集成还有计算上的好处, 允许在不增加任何额外的计算开销的情况下向算法中添加权重衰减。 由于更新的权重衰减部分仅依赖于每个参数的当前值, 因此优化器必须至少接触每个参数一次。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| def train_concise(wd): net = nn.Sequential(nn.Linear(num_inputs, 1)) for param in net.parameters(): param.data.normal_() loss = nn.MSELoss(reduction='none') num_epochs, lr = 100, 0.003 trainer = torch.optim.SGD([ {"params":net[0].weight,'weight_decay': wd}, {"params":net[0].bias}], lr=lr) animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log', xlim=[5, num_epochs], legend=['train', 'test']) for epoch in range(num_epochs): for X, y in train_iter: trainer.zero_grad() l = loss(net(X), y) l.mean().backward() trainer.step() if (epoch + 1) % 5 == 0: animator.add(epoch + 1, (d2l.evaluate_loss(net, train_iter, loss), d2l.evaluate_loss(net, test_iter, loss))) print('w的L2范数:', net[0].weight.norm().item())
|
这段代码演示了如何使用PyTorch的高级API来实现带有权重衰减的模型训练过程。通过torch.optim.SGD
配置中的weight_decay
参数,我们可以为模型的权重参数添加L2正则化,而通过将偏置参数单独列出并不为其设置weight_decay
,我们避免了对偏置参数应用权重衰减,这是因为偏置参数的正则化通常对控制模型复杂度的影响较小,而且可能会导致过度正则化的问题。整个训练过程中,我们定期评估模型在训练集和测试集上的损失,以监控模型的学习进度。
Dropout
原理
丢弃法(Dropout)是一种在深度学习中常用的正则化技术,由Hinton和他的学生在2012年提出。它被广泛应用于减少神经网络在训练过程中的过拟合。过拟合是机器学习模型面临的常见问题,发生在模型对训练数据学得太好,以至于失去了泛化到未见数据的能力。
丢弃法的工作原理相当直接:在训练过程中,通过随机选择忽略网络中的一部分神经元,即在每个训练批次中,每个神经元都有一定概率被暂时从网络中丢弃,不参与这次前向和后向传播过程。这个概率通常是一个超参数,由开发者设置。被丢弃的神经元不会在这次迭代中进行权重更新
标准暂退法正则化中,通过按保留(未丢弃)的节点的分数进行规范化来消除每一层的偏差。 换言之,每个中间活性值$ℎ$以暂退概率p由随机变量$ℎ’$替换,如下所示:
根据此模型的设计,其期望值保持不变
代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import torch from torch import nn from d2l import torch as d2l
def dropout_layer(X, dropout): assert 0 <= dropout <= 1 if dropout == 1: return torch.zeros_like(X) if dropout == 0: return X mask = (torch.rand(X.shape) > dropout).float() return mask * X / (1.0 - dropout)
|
mask = (torch.rand(X.shape) > dropout).float()
这行生成一个随机张量,其形状与X
相同,元素值在0到1之间。然后,将这个随机张量与dropout
概率进行比较,得到一个布尔张量(True表示元素值大于dropout
概率,即这个神经元应该保留;False表示元素值小于或等于dropout
概率,即这个神经元应该丢弃)。通过调用.float()
将布尔张量转换为浮点张量(1.0表示保留,0.0表示丢弃)。
由于mask
是随机生成的,所以并不能确定每一次的mask
刚好满足dropout
的概率
return mask * X / (1.0 - dropout)
使用生成的mask
与输入X
进行元素乘法,实现了随机丢弃神经元的效果。丢弃的神经元对应的元素会变为0。此外,对剩余的元素进行了缩放,即除以(1.0 - dropout)
,这是为了在训练过程中保持输入的总体期望值不变,因为在测试时,我们不会应用丢弃法,所有神经元都参与计算。
定义模型参数
使用引入的Fashion-MNIST数据集。 我们定义具有两个隐藏层的多层感知机,每个隐藏层包含256个单元。
1
| num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256
|
定义模型
我们可以将暂退法应用于每个隐藏层的输出(在激活函数之后), 并且可以为每一层分别设置暂退概率: 常见的技巧是在靠近输入层的地方设置较低的暂退概率。 下面的模型将第一个和第二个隐藏层的暂退概率分别设置为0.2和0.5, 并且暂退法只在训练期间有效。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| dropout1, dropout2 = 0.2, 0.5
class Net(nn.Module): def __init__(self, num_inputs, num_outputs, num_hiddens1, num_hiddens2, is_training = True): super(Net, self).__init__() self.num_inputs = num_inputs self.training = is_training self.lin1 = nn.Linear(num_inputs, num_hiddens1) self.lin2 = nn.Linear(num_hiddens1, num_hiddens2) self.lin3 = nn.Linear(num_hiddens2, num_outputs) self.relu = nn.ReLU()
def forward(self, X): H1 = self.relu(self.lin1(X.reshape((-1, self.num_inputs)))) if self.training == True: H1 = dropout_layer(H1, dropout1) H2 = self.relu(self.lin2(H1)) if self.training == True: H2 = dropout_layer(H2, dropout2) out = self.lin3(H2) return out
net = Net(num_inputs, num_outputs, num_hiddens1, num_hiddens2)
|
这个网络模型通过在两个隐藏层后应用丢弃法来防止过拟合。在训练过程中,每一层的某些神经元输出会以一定概率(dropout1
和dropout2
定义的)被随机丢弃
训练与测试
1 2 3 4 5
| num_epochs, lr, batch_size = 10, 0.5, 256 loss = nn.CrossEntropyLoss(reduction='none') train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size) trainer = torch.optim.SGD(net.parameters(), lr=lr) d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
|
简洁实现
对于深度学习框架的高级API,我们只需在每个全连接层之后添加一个Dropout
层, 将暂退概率作为唯一的参数传递给它的构造函数。 在训练时,Dropout
层将根据指定的暂退概率随机丢弃上一层的输出(相当于下一层的输入)。 在测试时,Dropout
层仅传递数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| net = nn.Sequential(nn.Flatten(), nn.Linear(784, 256), nn.ReLU(), nn.Dropout(dropout1), nn.Linear(256, 256), nn.ReLU(), nn.Dropout(dropout2), nn.Linear(256, 10))
def init_weights(m): if type(m) == nn.Linear: nn.init.normal_(m.weight, std=0.01)
net.apply(init_weights);
|
梯度爆炸和消失
梯度消失
梯度消失问题发生在深度网络的梯度在反向传播过程中变得非常小,几乎为零。这意味着网络权重的更新非常微小,甚至不会发生,使得网络很难学习和改进。梯度消失通常发生在使用了像Sigmoid
或Tanh
这样的激活函数的深度网络中,因为这些激活函数的导数在输入值非常高或非常低时趋于0。
梯度爆炸
梯度爆炸问题是指在网络的反向传播过程中,梯度变得异常大。这会导致权重更新过大,使得网络权重变得非常不稳定,最终可能导致模型无法收敛,或者在训练过程中出现数值计算上的溢出。梯度爆炸通常发生在深度网络中,尤其是当网络架构很复杂,或者使用了不适当的权重初始化策略时。
权重初始化
Xavier初始化,是一种特定的参数初始化方法,旨在解决梯度消失和梯度爆炸的问题,特别是在使用$Sigmoid$或$Tanh$激活函数时。
Xavier初始化的基本思想是保持输入和输出的方差一致,以确保所有层中的梯度都有适当的尺度。这通过根据前一层的连接数自动调整权重的尺度来实现。
具体来说,对于任意给定的层,Xavier初始化方法将权重初始化为从均匀分布或正态分布中抽取的值,其中均匀分布的范围是$([-a, a])$,正态分布的标准差是($\sigma$)。这里的(a)和($\sigma$)计算如下:
- 均匀分布的情况下,$(a = \sqrt{\frac{6}{n_{\text{in}} + n_{\text{out}}}})$,其中$(n_{\text{in}})$是层的输入单元数,$(n_{\text{out}})$是层的输出单元数。
- 正态分布的情况下,$\sigma = \sqrt{\frac{2}{n_{\text{in}} + n_{\text{out}}}}$。
这样的初始化方法可以在训练初期防止梯度过小或过大,有助于加快收敛速度,提高模型训练的稳定性。