李沐动手学深度学习(PyTorch)课程学习笔记第三章:多层感知机。
1. 多层感知机的从零实现
FashionMNIST 数据集的读取与第二章第四节一样,此处不再放上代码。
初始化模型参数,我们将实现一个具有单隐藏层的多层感知机,它包含256个隐藏单元。注意,我们可以将这两个变量都视为超参数。通常,我们选择2的若干次幂作为层的宽度。因为内存在硬件中的分配和寻址方式,这么做往往可以在计算上更高效。
1 2 3 4 5 6 7 8 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 激活函数,而不是直接调用内置的 relu
函数:
1 2 3 def relu (X ): a = torch.zeros_like(X) return torch.max (X, a)
接下来定义模型:
1 2 3 def net (X ): H = relu(torch.matmul(X.reshape((-1 , num_inputs)), W1) + b1) return torch.matmul(H, W2) + b2
训练函数的代码也与2.4节基本一样,只需将 net.to(device)
与 net.train()
等与 nn.Module
相关的代码去掉即可,因此不放出完整代码:
1 2 3 4 5 6 7 8 9 10 11 def train_classifier (net, train_iter, test_iter, num_epochs, lr ): print (f'---------- Training on cpu ----------' ) loss_function = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(params, lr=lr) for epoch in range (num_epochs): lr, num_epochs = 0.1 , 10 train_classifier(net, train_iter, train_iter, num_epochs, lr)
2. 多层感知机的简洁实现
与 Softmax 回归的简洁实现(第二章第四节)相比,唯一的区别是我们添加了2个全连接层(之前我们只添加了1个全连接层)。第一层是隐藏层,它包含256个隐藏单元,并使用了 ReLU 激活函数。第二层是输出层,因此我们只需要重点看一下模型即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import torchfrom torch import nnfrom util.functions import train_classifiernum_inputs, num_outputs, num_hiddens = 784 , 10 , 256 net = nn.Sequential(nn.Flatten(), nn.Linear(num_inputs, num_hiddens), nn.ReLU(), nn.Linear(num_hiddens, num_outputs)) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu' ) lr, num_epochs = 0.1 , 10 train_classifier(net, train_iter, test_iter, num_epochs, lr, device)
3. 模型选择、欠拟合和过拟合
首先我们需要了解训练误差 和泛化误差 :
训练误差(training error):模型在训练数据集上计算得到的误差。
泛化误差(generalization error):模型应用在同样从原始样本的分布中抽取的无限多数据样本时,模型误差的期望。
问题是,我们永远不能准确地计算出泛化误差。这是因为无限多的数据样本是一个虚构的对象。在实际中,我们只能通过将模型应用于一个独立 的测试集来估计泛化误差,该测试集由随机选取的、未曾在训练集中出现的数据样本构成。
在机器学习中,我们通常在评估几个候选模型后选择最终的模型。这个过程叫做模型选择。有时,需要进行比较的模型在本质上是完全不同的(比如,决策树与线性模型)。又有时,我们需要比较不同的超参数设置 下的同一类模型。
例如,训练多层感知机模型时,我们可能希望比较具有不同数量的隐藏层、不同数量的隐藏单元以及不同的激活函数组合的模型。为了确定候选模型中的最佳模型,我们通常会使用验证集 。
验证数据集:一个用来评估模型好坏的数据集,训练数据集用来训练模型参数,验证数据集用来选择模型超参数。
例如在数据集中拿出50%的训练数据作为验证数据集。
不要跟训练数据混在一起(常犯错误)。
测试数据集:只用一次的数据集。
接下来我们需要了解一下模型容量 的概念:
模型容量表示模型拟合各种函数的能力。
低容量的模型难以拟合复杂的训练数据。
高容量的模型可以记住所有的训练数据。
是否过拟合或欠拟合主要取决于模型复杂性 和可用训练数据集的大小 。当我们比较训练和验证误差时,我们要注意两种常见的情况:
训练误差和验证误差都很差,但它们之间仅有一点差距。如果模型不能降低训练误差,这可能意味着模型过于简单(即表达能力不足),无法捕获试图学习的模式。此外,由于我们的训练和验证误差之间的泛化误差很小,我们有理由相信可以用一个更复杂的模型降低训练误差。这种现象被称为欠拟合(underfitting)。
当我们的训练误差明显低于 验证误差时要小心,这表明严重的过拟合(overfitting)。注意,过拟合并不总是一件坏事。特别是在深度学习领域,众所周知,最好的预测模型在训练数据上的表现往往比在保留(验证)数据上好得多。最终,我们通常更关心验证误差,而不是训练误差和验证误差之间的差距。
4. 权重衰减
在训练参数化机器学习模型时,权重衰减(weight decay)是最广泛使用的正则化的技术之一,它通常也被称为L2正则化。
通过限制参数值的选择范围来控制模型容量,通常不限制偏移 b
。
首先生成一些数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 import torchfrom torch import nnfrom torch.utils import datafrom torch.utils.tensorboard import SummaryWriterfrom d2l import torch as d2ln_train, n_test, num_inputs, batch_size = 20 , 100 , 200 , 5 true_w, true_b = torch.ones((num_inputs, 1 )) * 0.01 , 0.05 train_data = d2l.synthetic_data(true_w, true_b, n_train) train_iter = d2l.load_array(train_data, batch_size) test_data = d2l.synthetic_data(true_w, true_b, n_test) test_iter = d2l.load_array(test_data, batch_size, is_train=False )
定义网络模型与损失函数:
1 2 net = nn.Sequential(nn.Linear(num_inputs, 1 )) loss_function = nn.MSELoss(reduction='none' )
定义优化算法,我们在实例化优化器时直接通过 weight_decay
指定 weight decay
超参数。默认情况下,PyTorch 同时衰减权重和偏移。这里我们只为权重设置了 weight_decay
,所以偏置参数不会衰减:
1 2 3 4 num_epochs, lr, wd = 100 , 0.003 , 0 optimizer = torch.optim.SGD([{'params' :net[0 ].weight, 'weight_decay' :wd}, {'params' :net[0 ].bias}], lr=lr)
然后进行训练:
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 writer = SummaryWriter('../logs/WeightDecay_train_log' ) for param in net.parameters(): param.data.normal_() for epoch in range (num_epochs): train_loss = 0.0 test_loss = 0.0 net.train() for X, y in train_iter: y_hat = net(X) loss = loss_function(y_hat, y) optimizer.zero_grad() loss.mean().backward() optimizer.step() train_loss += loss.sum () train_loss /= n_train net.eval () with torch.no_grad(): for X, y in test_iter: y_hat = net(X) loss = loss_function(y_hat, y) test_loss += loss.sum () test_loss /= n_test writer.add_scalar('train_loss' , train_loss, epoch + 1 ) writer.add_scalar('test_loss' , test_loss, epoch + 1 ) print ('w的L2范数:' , net[0 ].weight.norm().item())
通过结果可以看到模型很快就过拟合了,可以通过修改超参数 wd
的值应用权重衰减的方式来缓解过拟合的现象。
5. 暂退法(Dropout)
一个好的模型需要对输入数据的扰动鲁棒。在训练过程中,建议在计算后续层之前向网络的每一层注入噪声 ,因为当训练一个有多层的深层网络时,注入噪声只会在输入-输出映射上增强平滑性。
这个想法被称为暂退法(dropout)。暂退法在前向传播过程中,计算每一内部层的同时注入噪声,这已经成为训练神经网络的常用技术。这种方法之所以被称为暂退法,因为我们从表面上看是在训练过程中丢弃(drop out)一些神经元 。在整个训练过程的每一次迭代中,标准暂退法包括在计算下一层之前将当前层中的一些节点置零 。
在标准暂退法正则化中,通过按保留(未丢弃)的节点的分数进行规范化来消除每一层的偏差。换言之,每个中间活性值 h
以暂退概率 p
由随机变量替换。
Dropout 说的简单一点就是:我们在前向传播的时候,让某个神经元的激活值以一定的概率 p
停止工作(将一些输出项随机置0),这样可以使模型泛化性更强,因为它不会太依赖某些局部的特征。
我们可以将暂退法应用于每个隐藏层的输出(在激活函数之后),并且可以为每一层分别设置暂退概率:常见的技巧是在靠近输入层的地方设置较低的暂退概率。下面的模型将第一个和第二个隐藏层的暂退概率分别设置为0.2和0.5,并且暂退法只在训练期间有效 。
Dropout 的从零实现核心代码如下:
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 def dropout_layer (X, p ): assert 0 <= p <= 1 if p == 1 : return torch.zeros_like(X) if p == 0 : return X mask = (torch.rand(X.shape) > p).float () return mask * X / (1.0 - p) num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784 , 10 , 256 , 256 p1, p2 = 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, p1) H2 = self.relu(self.lin2(H1)) if self.training == True : H2 = dropout_layer(H2, p2) out = self.lin3(H2) return out net = Net(num_inputs, num_outputs, num_hiddens1, num_hiddens2)
Dropout 的简洁实现及完整训练代码如下:
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 import torchimport torchvisionfrom torch import nnfrom torch.utils import datafrom torchvision import transformsfrom util.functions import train_classifiermnist_train = torchvision.datasets.FashionMNIST(root="../data" , train=True , transform=transforms.ToTensor(), download=True ) mnist_test = torchvision.datasets.FashionMNIST(root="../data" , train=False , transform=transforms.ToTensor(), download=True ) train_iter = data.DataLoader(mnist_train, batch_size=256 , shuffle=True , num_workers=0 ) test_iter = data.DataLoader(mnist_test, batch_size=256 , shuffle=False , num_workers=0 ) net = nn.Sequential(nn.Flatten(), nn.Linear(784 , 256 ), nn.ReLU(), nn.Dropout(0.2 ), nn.Linear(256 , 256 ), nn.ReLU(), nn.Dropout(0.5 ), nn.Linear(256 , 10 )) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu' ) lr, num_epochs = 0.1 , 10 writer_path = '../logs/Dropout_train_log' train_classifier(net, train_iter, test_iter, num_epochs, lr, device, writer_path)
6. 数值稳定性和模型初始化
我们可能面临一些问题,要么是梯度爆炸 (gradient exploding)问题:参数更新过大,破坏了模型的稳定收敛;要么是梯度消失 (gradient vanishing)问题:参数更新过小,在每次更新时几乎不会移动,导致模型无法学习。
例如当 Sigmoid 函数的输入很大或是很小时,它的梯度都会消失。此外,当反向传播通过许多层时,除非我们在刚刚好的地方,这些地方 Sigmoid 函数的输入接近于零,否则整个乘积的梯度可能会消失。
Xavier 初始化:从均值为零,方差为 2 / (n_in + n_out)
的高斯分布中采样权重(n_in
为输入的数量,n_out
为输出的数量)。
下一章:PyTorch 神经网络基础 。