4.1 多层感知机
隐藏层
我们在 3.1.1.1节中描述了仿射变换, 它是一种带有偏置项的线性变换。 首先,回想一下如 图3.4.1中所示的softmax回归的模型架构。 该模型通过单个仿射变换将我们的输入直接映射到输出,然后进行softmax操作。 如果我们的标签通过仿射变换后确实与我们的输入数据相关,那么这种方法确实足够了。 但是,仿射变换中的线性是一个很强的假设。
我们可以通过在网络中加入一个或多个隐藏层来克服线性模型的限制, 使其能处理更普遍的函数关系类型。 要做到这一点,最简单的方法是将许多全连接层堆叠在一起。 每一层都输出到上面的层,直到生成最后的输出。 我们可以把前L-1层看作表示,把最后一层看作线性预测器。 这种架构通常称为多层感知机(multilayer perceptron),通常缩写为MLP。 下面,我们以图的方式描述了多层感知机
这个多层感知机有4个输入,3个输出,其隐藏层包含5个隐藏单元。 输入层不涉及任何计算,因此使用此网络产生输出只需要实现隐藏层和输出层的计算。 因此,这个多层感知机中的层数为2。 注意,这两个层都是全连接的。 每个输入都会影响隐藏层中的每个神经元, 而隐藏层中的每个神经元又会影响输出层中的每个神经元。
然而,正如 3.4.3节所说, 具有全连接层的多层感知机的参数开销可能会高得令人望而却步。 即使在不改变输入或输出大小的情况下, 可能在参数节约和模型有效性之间进行权衡 (Zhang et al., 2021)。
为了发挥多层架构的潜力, 我们还需要一个额外的关键要素: 在仿射变换之后对每个隐藏单元应用非线性的激活函数(activation function)。 激活函数的输出(例如,)被称为活性值(activations)。 一般来说,有了激活函数,就不可能再将我们的多层感知机退化成线性模型
激活函数(activation function)通过计算加权和并加上偏置来确定神经元是否应该被激活, 它们将输入信号转换为输出的可微运算。 大多数激活函数都是非线性的。 由于激活函数是深度学习的基础,下面简要介绍一些常见的激活函数
%matplotlib inline
import torch
from d2l import torch as d2l
1.ReLU函数
最受欢迎的激活函数是修正线性单元(Rectified linear unit,ReLU), 因为它实现简单,同时在各种预测任务中表现良好。 ReLU提供了一种非常简单的非线性变换。 给定元素x,ReLU函数被定义为该元素与0的最大值:
通俗地说,ReLU函数通过将相应的活性值设为0,仅保留正元素并丢弃所有负元素。 为了直观感受一下,我们可以画出函数的曲线图。 正如从图中所看到,激活函数是分段线性的。
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))
torch.arange(start, end, step):创建一个从 start 到 end(不包括 end)的张量,元素间隔为 step。
torch.arange(-8.0, 8.0, 0.1) 会生成一个从 -8.0 开始,到小于 8.0 的数结束的序列。由于步长是 0.1,生成的张量会有160个元素,
当输入为负时,ReLU函数的导数为0,而当输入为正时,ReLU函数的导数为1。 注意,当输入值精确等于0时,ReLU函数不可导。 在此时,我们默认使用左侧的导数,即当输入为0时导数为0。 我们可以忽略这种情况,因为输入可能永远都不会是0。 这里引用一句古老的谚语,“如果微妙的边界条件很重要,我们很可能是在研究数学而非工程”, 这个观点正好适用于这里。 下面我们绘制ReLU函数的导数。
y.backward(torch.ones_like(x), retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of relu', figsize=(5, 2.5))
使用ReLU的原因是,它求导表现得特别好:要么让参数消失,要么让参数通过。 这使得优化表现得更好,并且ReLU减轻了困扰以往神经网络的梯度消失问题(稍后将详细介绍)。
注意,ReLU函数有许多变体,包括参数化ReLU(Parameterized ReLU,pReLU) 函数 (He et al., 2015)。 该变体为ReLU添加了一个线性项,因此即使参数是负的,某些信息仍然可以通过:
大家一般使用relu而不使用sigmoid函数的原因:sigmoid使用了指数运算,而指数运算很贵,相当于一百次乘法运算(gpu上好一些)
2.sigmoid函数
对于一个定义域在R中的输入, sigmoid函数将输入变换为区间(0, 1)上的输出。 因此,sigmoid通常称为挤压函数(squashing function): 它将范围(-inf, inf)中的任意输入压缩到区间(0, 1)中的某个值:
当人们逐渐关注到到基于梯度的学习时, sigmoid函数是一个自然的选择,因为它是一个平滑的、可微的阈值单元近似。 当我们想要将输出视作二元分类问题的概率时, sigmoid仍然被广泛用作输出单元上的激活函数 (sigmoid可以视为softmax的特例)。 然而,sigmoid在隐藏层中已经较少使用, 它在大部分时候被更简单、更容易训练的ReLU所取代。 在后面关于循环神经网络的章节中,我们将描述利用sigmoid单元来控制时序信息流的架构。
下面,我们绘制sigmoid函数。 注意,当输入接近0时,sigmoid函数接近线性变换。
y = torch.sigmoid(x)
d2l.plot(x.detach(), y.detach(), 'x', 'sigmoid(x)', figsize=(5, 2.5))
sigmoid函数的导数为下面的公式:
sigmoid函数的导数图像如下所示。 注意,当输入为0时,sigmoid函数的导数达到最大值0.25; 而输入在任一方向上越远离0点时,导数越接近0。
# 清除以前的梯度
x.grad.data.zero_()
y.backward(torch.ones_like(x),retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of sigmoid', figsize=(5, 2.5))
3.tanh函数
与sigmoid函数类似, tanh(双曲正切)函数也能将其输入压缩转换到区间(-1, 1)上。 tanh函数的公式如下:
下面我们绘制tanh函数。 注意,当输入在0附近时,tanh函数接近线性变换。 函数的形状类似于sigmoid函数, 不同的是tanh函数关于坐标系原点中心对称。
y = torch.tanh(x)
d2l.plot(x.detach(), y.detach(), 'x', 'tanh(x)', figsize=(5, 2.5))
tanh函数的导数是:
tanh函数的导数图像如下所示。 当输入接近0时,tanh函数的导数接近最大值1。 与我们在sigmoid函数图像中看到的类似, 输入在任一方向上越远离0点,导数越接近0。
# 清除以前的梯度
x.grad.data.zero_()
y.backward(torch.ones_like(x),retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of tanh', figsize=(5, 2.5))
torch.ones_like(x):
在PyTorch中,当你调用 y.backward() 时,你是在对张量 y 进行反向传播,计算 y 对输入 x 的梯度。通常,如果 y 是一个标量(即单个值),你可以直接调用 y.backward(),它会自动计算该标量相对于 x 的梯度。然而,如果 y 是一个非标量张量(比如 y = torch.tanh(x) 是和 x 一样形状的张量),你需要显式地指定反向传播时的“初始梯度”或“梯度权重”。
默认情况下,PyTorch对标量进行反向传播时,会假设标量的梯度是1。但对于张量(如 y),你必须告诉PyTorch,张量 y 的每个元素y[i]是如何影响后续计算的。
torch.ones_like(x) 是一个与 x 形状相同的全1张量,它表示我们希望对 y 中的每个元素y[i]进行反向传播时,初始的梯度都为1。这个初始梯度会和链式法则一起传播回到 x,从而计算出 y 对 x 的梯度。
假设 x 是一个向量,y = torch.tanh(x)。那么每个 y[i] 的梯度就是 dy[i]/dx[i]。在调用 y.backward(torch.ones_like(x)) 时,我们告诉PyTorch:
- 对于 y 中的每个元素 y[i],假设其梯度的初始权重为1。
- 然后PyTorch会根据链式法则,计算 dy[i]/dx[i] 并将其乘以这个初始权重,最终计算出每个 x[i] 对应的梯度。
retain_graph=True:
retain_graph=True:表示在调用 backward() 后,保留计算图(即 计算图不会被释放),以便在之后的反向传播中再次使用这个计算图。
通常在 PyTorch 中**,调用 backward() 时,计算图会被自动释放(被垃圾回收),因为 PyTorch 默认假设你只需要一次反向传播。**
但是,如果你需要多次对同一个计算图进行反向传播,比如在需要多次计算梯度或在某些自定义训练过程中,设置 retain_graph=True 就非常重要,确保计算图不会被释放,从而允许你再次进行反向传播操作。
作用场景:
- 多次反向传播:在某些场景下,你需要对相同的计算图进行多次反向传播来计算不同的梯度。比如,你可能会想先计算一次损失的梯度,然后在同一图上再次进行其他梯度计算,这种情况就需要保留计算图。
- 梯度累积:有时在复杂的网络结构中,你需要将某个张量的梯度累积多次,例如多个损失函数对应同一个计算图。这种情况下,如果不保留计算图,第二次调用 backward() 时会发生错误,因为计算图已经被释放了。
示例:
import torchx = torch.tensor(2.0, requires_grad=True)
y = x ** 2 # y = x^2
z = y ** 2 # z = (x^2)^2=x^4# 第一次反向传播,计算 z 对 x 的梯度
z.backward(retain_graph=True)
print(x.grad) # 输出: tensor(32.)# 第二次反向传播,计算 y 对 x 的梯度
# 因为我们使用了 retain_graph=True,所以计算图没有被释放,可以继续计算 y 对 x 的梯度
y.backward() # 此时计算图已经被保留,才能继续使用
print(x.grad) # 输出: tensor(36.) 32+4
如果你没有指定 retain_graph=True,那么在第一次调用 backward() 后,计算图会被销毁,第二次尝试对同一张量进行反向传播时就会报错。报错的提示通常是:
RuntimeError: Trying to backward through the graph a second time, but the buffers have already been freed.
计算图是机器学习和深度学习框架(如 PyTorch)用于跟踪和记录张量操作的有向无环图(DAG)。它描述了在前向传播中,数据(张量)如何通过一系列操作被转换,并且在反向传播中,梯度是如何传递的。
每个节点代表一个操作或函数,而边则表示张量(数据)流向这些操作。通过这个计算图,框架能够使用链式法则(Chain Rule)计算梯度,进行自动微分。
简化的计算图如下:
x|(平方) ← 操作:y = x^2|y|(平方) ← 操作:z = y^2 = (x^2)^2|z
前向传播:
当执行 y = x * * 2 时,PyTorch 记录了这个操作,知道 y 是通过对 x 求平方得到的。
当执行 z = y * * 2 时,PyTorch 继续记录,知道 z 是通过对 y 求平方得到的。
反向传播:
在反向传播时,PyTorch 使用这个计算图根据链式法则计算梯度。我们调用 z.backward(),梯度的传递是从 z 反向传播到 x,如下所示:
- 计算 dz/dy:
dz/dy = 2 * y = 2 * (x^2) = 2 * 4 = 8 - 计算 dy/dx:
dy/dx = 2 * x = 2 * 2 = 4 - 根据链式法则计算 dz/dx:
dz/dx = dz/dy * dy/dx = 8 * 4 = 32
所以,第一次调用 z.backward() 时,x.grad 是 32。但是,在例子中调用 retain_graph=True,所以我们能够再次对 y 调用 backward() 来继续计算其他梯度。
这里倒数第二段代码(即训练代码)
在这段代码中,虽然多次调用 loss.backward() 进行反向传播,但并不需要设置 retain_graph=True,这是因为每次计算损失和反向传播时,计算图会被重新构建,并且只使用一次就被释放。这种方式是深度学习训练中最常见的模式。
每个 batch 都重新构建计算图
在训练过程中,计算图是动态构建的。每一次前向传播(outputs = tudui(imgs))都会基于当前的输入数据 imgs 重新构建一个新的计算图。这个计算图专门用于计算这个 batch 的损失 loss,然后通过调用 loss.backward() 计算梯度并释放这个计算图。
因此,每个训练 batch 的前向和反向传播都拥有自己独立的计算图。由于每个 batch 的计算图是独立的,并且只在一次反向传播中使用,所以不需要保留这个计算图。
总结:
- 多层感知机使用隐藏层和激活函数来得到非线性模型
- 使用softmax来处理多类分类
- 超参数为隐藏层数,和各个隐藏层大小
4.2 多层感知机的从零开始实现
为了与之前softmax回归( 3.6节 ) 获得的结果进行比较, 我们将继续使用Fashion-MNIST图像分类数据集 ( 3.5节)。
import torch
from torch import nn
from d2l import torch as d2lbatch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
初始化模型参数
回想一下,Fashion-MNIST中的每个图像由 28*28=784 个灰度像素值组成。 所有图像共分为10个类别。 忽略像素之间的空间结构, 我们可以将每个图像视为具有784个输入特征 和10个类的简单分类数据集。 首先,我们将实现一个具有单隐藏层的多层感知机, 它包含256个隐藏单元。 注意,我们可以将这两个变量都视为超参数。 通常,我们选择2的若干次幂作为层的宽度。 因为内存在硬件中的分配和寻址方式,这么做往往可以在计算上更高效。
我们用几个张量来表示我们的参数。 注意,对于每一层我们都要记录一个权重矩阵和一个偏置向量。 跟以前一样,我们要为损失关于这些参数的梯度分配内存。
num_inputs, num_outputs, num_hiddens = 784, 10, 256 # 输入层、输出层、隐藏层 的 神经元数量W1 = nn.Parameter(torch.randn(num_inputs, num_hiddens, requires_grad=True) * 0.01) # (784, 256)
b1 = nn.Parameter(torch.zeros(num_hiddens, requires_grad=True)) # (256,)
W2 = nn.Parameter(torch.randn(num_hiddens, num_outputs, requires_grad=True) * 0.01) # (256, 10)
b2 = nn.Parameter(torch.zeros(num_outputs, requires_grad=True)) # (10,)params = [W1, b1, W2, b2]
定义第一个全连接层的权重和偏置:
W1 是输入层到隐藏层的权重矩阵(Weight Matrix)。其形状为 (num_inputs, num_hiddens),即 (784, 256)。每个输入神经元都连接到隐藏层的每个神经元,因此这个矩阵表示了输入层与隐藏层之间的权重。
- torch.randn(num_inputs, num_hiddens, requires_grad=True) * 0.01:生成一个大小为 (784, 256) 的权重矩阵,初始化时使用标准正态分布(均值为 0,方差为 1)随机生成,再乘以 0.01 缩小初始权重的大小(常用于避免初始权重过大影响收敛性)。
- nn.Parameter(…):**将张量 torch.randn(…) 包装为 nn.Parameter,这使得它能够被 torch 优化器识别为需要优化的参数。通过 requires_grad=True,它会自动跟踪梯度,**从而在反向传播时计算梯度。
b1 是隐藏层的偏置(Bias),形状为 (num_hiddens,),即 (256,)。
torch.zeros(num_hiddens, requires_grad=True):初始化偏置为全零张量,形状为 (256,),并设置 requires_grad=True,表示这些偏置参数需要在训练时被优化。
激活函数
为了确保我们对模型的细节了如指掌, 我们将实现ReLU激活函数, 而不是直接调用内置的relu函数。
def relu(X):a = torch.zeros_like(X)return torch.max(X, a)
模型
因为我们忽略了空间结构, 所以我们使用reshape将每个二维图像转换为一个长度为num_inputs的向量。 只需几行代码就可以实现我们的模型。
def net(X):X = X.reshape((-1, num_inputs))H = relu(X@W1 + b1) # 这里“@”代表矩阵乘法return (H@W2 + b2)
在 Python 3.5 及之后的版本中,引入了 @ 作为矩阵乘法运算符。这个符号最初是在PEP 465 中引入的,目的是为了解决在数值计算中矩阵乘法的符号表示问题。在 Numpy 和 PyTorch 等库中,矩阵乘法常见,但是传统的 Python 中使用 ** * 表示的是逐元素相乘(element-wise multiplication),并不能用于矩阵乘法**。因此,@ 运算符被设计成专门用于矩阵乘法的符号。
传统的矩阵乘法问题
- 逐元素相乘:在 Numpy 或 PyTorch 中,A * B 表示两个张量 A 和 B 逐元素相乘,即两个矩阵的相同位置的元素两两相乘。
- 矩阵乘法的表示问题:在 Python 3.5 之前,要进行矩阵乘法只能使用 np.dot() 或 PyTorch 的 torch.mm(),这是基于函数调用的方式,不够简洁。
@ 符号的引入
为了解决这种不便,Python 的设计者决定引入 @ 运算符,用于矩阵乘法。这个符号能够直接表达矩阵之间的乘法运算,简化代码,提高可读性。
在 Numpy 和 PyTorch 中,A @ B 表示矩阵 A 和矩阵 B 的矩阵乘法运算。@ 操作符遵循线性代数中的矩阵乘法规则,即:若 A 的形状为 (m, n),B 的形状为 (n, p),则 A @ B 的结果是一个形状为 (m, p) 的矩阵。
损失函数
由于我们已经从零实现过softmax函数( 3.6节), 因此在这里我们直接使用高级API中的内置函数来计算softmax和交叉熵损失。 回想一下我们之前在 3.7.2节中 对这些复杂问题的讨论。 我们鼓励感兴趣的读者查看损失函数的源代码,以加深对实现细节的了解。
loss = nn.CrossEntropyLoss(reduction='none')
训练
幸运的是,多层感知机的训练过程与softmax回归的训练过程完全相同。 可以直接调用d2l包的train_ch3函数(参见 3.6节 ), 将迭代周期数设置为10,并将学习率设置为0.1.
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)
为了对学习到的模型进行评估,我们将在一些测试数据上应用这个模型。
d2l.predict_ch3(net, test_iter)
4.3 多层感知机的简洁实现
import torch
from torch import nn
from d2l import torch as d2l
模型
与softmax回归的简洁实现( 3.7节)相比, 唯一的区别是我们添加了2个全连接层(之前我们只添加了1个全连接层)。 第一层是隐藏层,它包含256个隐藏单元,并使用了ReLU激活函数。 第二层是输出层。
net = nn.Sequential(nn.Flatten(),nn.Linear(784, 256),nn.ReLU(),nn.Linear(256, 10))def init_weights(m):if type(m) == nn.Linear:# 用均值为0,标准差为0.01的正态分布对线性层的权重 m.weight 进行初始化。这意味着权重会随机初始化为接近零的小值。nn.init.normal_(m.weight, std=0.01)net.apply(init_weights);
训练过程的实现与我们实现softmax回归时完全相同, 这种模块化设计使我们能够将与模型架构有关的内容独立出来。
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)
- reduction=‘none’
返回:逐样本的损失值,不进行任何聚合,返回与输入大小一致的损失值张量。
输出形状:形状为 (batch_size,),每个样本对应一个损失值。
import torch
import torch.nn as nn# 定义输入和目标
input = torch.tensor([[2.0, 1.0, 0.1], [0.5, 2.0, 0.3]]) # 预测分数 (logits)
target = torch.tensor([0, 2]) # 真实类别标签# 定义交叉熵损失函数,reduction='none'
loss = nn.CrossEntropyLoss(reduction='none')
output = loss(input, target)print(output) # tensor([0.4076, 1.8406]) # 每个样本的损失值
- reduction=‘mean’
返回:所有样本损失的平均值。
输出形状:标量(scalar),即单一的数值。
当 reduction=‘mean’ 时,计算损失后对批次中的所有样本的损失值取平均值。
loss = nn.CrossEntropyLoss(reduction='mean')
output = loss(input, target)print(output) # tensor(1.1241) # 所有样本损失的平均值
- reduction=‘sum’
返回:所有样本损失的总和。
输出形状:标量(scalar),即单一的数值。
当 reduction=‘sum’ 时,计算所有样本的交叉熵损失后,直接求和返回结果。
loss = nn.CrossEntropyLoss(reduction='sum')
output = loss(input, target)print(output) # tensor(2.2482) # 所有样本损失的总和
对于相同的分类问题,多层感知机的实现与softmax回归的实现相同,只是多层感知机的实现里增加了带有激活函数的隐藏层。
4.4 模型选择、欠拟合和过拟合
将模型在训练数据上拟合的比在潜在分布中更接近的现象称为过拟合(overfitting), 用于对抗过拟合的技术称为正则化(regularization)。 在前面的章节中,有些读者可能在用Fashion-MNIST数据集做实验时已经观察到了这种过拟合现象。 在实验中调整模型架构或超参数时会发现: 如果有足够多的神经元、层数和训练迭代周期, 模型最终可以在训练集上达到完美的精度,此时测试集的准确性却下降了。
训练误差和泛化误差
训练误差(training error)是指, 模型在训练数据集上计算得到的误差。 泛化误差(generalization error)是指, 模型应用在同样从原始样本的分布中抽取的无限多数据样本时,模型误差的期望。
模型选择
解决此问题的常见做法是将我们的数据分成三份, 除了训练和测试数据集之外,还增加一个验证数据集(validation dataset), 也叫验证集(validation set)。 但现实是验证数据和测试数据之间的边界模糊得令人担忧。 除非另有明确说明,否则在这本书的实验中, 我们实际上是在使用应该被正确地称为训练数据和验证数据的数据集, 并没有真正的测试数据集。 因此,书中每次实验报告的准确度都是验证集准确度,而不是测试集准确度。
K折交叉验证
当训练数据稀缺时,我们甚至可能无法提供足够的数据来构成一个合适的验证集。 这个问题的一个流行的解决方案是采用K折交叉验证。 这里,原始训练数据被分成K个不重叠的子集。 然后执行K次模型训练和验证,每次在K-1个子集上进行训练, 并在剩余的一个子集(在该轮中没有用于训练的子集)上进行验证。 最后,通过对K次实验的结果取平均来估计训练和验证误差。
4.5 权重衰减
前一节我们描述了过拟合的问题,本节我们将介绍一些正则化模型的技术。 我们总是可以通过去收集更多的训练数据来缓解过拟合。 但这可能成本很高,耗时颇多,或者完全超出我们的控制,因而在短期内不可能做到。 假设我们已经拥有尽可能多的高质量数据,我们便可以将重点放在正则化技术上。
在训练参数化机器学习模型时, 权重衰减(weight decay)是最广泛使用的正则化的技术之一, 它通常也被称为L_2正则化。 这项技术通过函数与零的距离来衡量函数的复杂度, 因为在所有函数f中,函数f=0(所有输入都得到值0) 在某种意义上是最简单的。 但是我们应该如何精确地测量一个函数和零之间的距离呢? 没有一个正确的答案。 事实上,函数分析和巴拿赫空间理论的研究,都在致力于回答这个问题。
使用L_2范数的一个原因是它对权重向量的大分量施加了巨大的惩罚。 这使得我们的学习算法偏向于在大量特征上均匀分布权重的模型。 在实践中,这可能使它们对单个变量中的观测误差更为稳定。 相比之下,L_1惩罚会导致模型将权重集中在一小部分特征上, 而将其他权重清除为零。 这称为特征选择(feature selection),这可能是其他场景下需要的。
根据之前章节所讲的,我们根据估计值与观测值之间的差异来更新w。 然而,我们同时也在试图将w的大小缩小到零。 这就是为什么这种方法有时被称为权重衰减。 我们仅考虑惩罚项,优化算法在训练的每一步衰减权重。 与特征选择相比,权重衰减为我们提供了一种连续的机制来调整函数的复杂度。 较小的lambda值对应较少约束的w, 而较大的lambda值对w的约束更大。
是否对相应的偏置b^2进行惩罚在不同的实践中会有所不同, 在神经网络的不同层中也会有所不同。 通常,网络输出层的偏置项不会被正则化。
之前我们讲过如何控制模型的容量?方法1:把模型变得比较小,参数比较少;方法2:使得参数选值的范围比较小
总的来说正则化就是为了避免过拟合而在loss函数后面加上一项正则项作为惩罚项从而降低过拟合
作用: 权重衰减(L2正则化)可以避免模型过拟合问题。
思考: L2正则化项有让w变小的效果,但是为什么w变小可以防止过拟合呢?
原理: (1)从模型的复杂度上解释:更小的权值w,从某种意义上说,表示网络的复杂度更低,对数据的拟合更好(这个法则也叫做奥卡姆剃刀),而在实际应用中,也验证了这一点,L2正则化的效果往往好于未经正则化的效果。(2)从数学方面的解释:过拟合的时候,拟合函数的系数往往非常大,为什么?如下图所示,过拟合,就是拟合函数需要顾忌每一个点,最终形成的拟合函数波动很大。在某些很小的区间里,函数值的变化很剧烈。这就意味着函数在某些小区间里的导数值(绝对值)非常大,由于自变量值可大可小,所以只有系数足够大,才能保证导数值很大。而正则化是通过约束参数的范数使其不要太大,所以可以在一定程度上减少过拟合情况。
结合李沐的课程,正则项又可以理解为使得loss权重分散一部分到正则项上面,使得正则项和loss之间形成一个平衡的状态。(图中绿色即为没有加正则项的loss,黄色为正则项,最后整个式子会平衡在交点处)
(梯度是一个向量,表示函数在某个点的最大增长方向。它指向损失函数增大的方向)
可以看到参数更新法则中,“减去梯度的负方向乘以学习率”这部分没有变化,主要的变化在于前面w_t前的系数,也就是说每次更新前,先把w_t乘上了一个小于1的正常数,也就是先把它值变小一点,再沿着梯度的反方向走一点
这就是为什么叫 权重衰退 的原因,因为lambda的引入,使得每次更新前先把当前的权重做了一次放小
总结
- 权重衰退通过L2正则项使得模型参数不会过大,从而控制模型复杂度
- 正则项权重是控制模型复杂度的超参数
高维线性回归
我们通过一个简单的例子来演示权重衰减。
%matplotlib inline
import torch
from torch import nn
from d2l import torch as d2l
首先,我们像以前一样生成一些数据,生成公式如下:
我们选择标签是关于输入的线性函数。 标签同时被均值为0,标准差为0.01高斯噪声破坏。 为了使过拟合的效果更加明显,我们可以将问题的维数增加到d=200, 并使用一个只包含20个样本的小训练集。
n_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)
从零开始实现
下面我们将从头开始实现权重衰减,只需将L_2的平方惩罚添加到原始目标函数中。
1.初始化模型参数
首先,我们将定义一个函数来随机初始化模型参数。
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]
2.定义L_2范数惩罚
实现这一惩罚最方便的方法是对所有项求平方后并将它们求和。
def l2_penalty(w):return torch.sum(w.pow(2)) / 2
- 参数:
w:通常是神经网络的权重向量(或矩阵),也可以是任何需要正则化的参数。w 是一个 torch.Tensor 对象,它可以是模型中的权重张量。 - w.pow(2):
w.pow(2) 表示对张量 w 中的每个元素进行 平方运算。实际上,这等价于对张量 w 中每个权重求平方:w^2=w x w 这个操作是逐元素的,生成一个新的张量,其元素是原来w
中每个元素的平方。 - torch.sum(w.pow(2)):
torch.sum(w.pow(2))
是对w.pow(2)
结果的所有元素进行 求和。这个操作会返回一个标量,表示所有权重平方的和
这一步将权重平方求和,得到了 L2 正则化的基本形式。 - / 2:
最后,结果除以2
,得到 L2 正则化项的标准形式。这是因为在 L2 正则化的数学定义中,通常会将平方和除以 2,使得在后续进行梯度计算时,求导结果更加简洁
lambda这里不写,而是写在外边
3.定义训练代码实现
下面的代码将模型拟合训练数据集,并在测试数据集上进行评估。 从 3节以来,线性网络和平方损失没有变化, 所以我们通过d2l.linreg和d2l.squared_loss导入它们。 唯一的变化是损失现在包括了惩罚项。
def train(lambd):w, b = init_params()net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_lossnum_epochs, lr = 100, 0.003animator = 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:# 增加了L2范数惩罚项,# 广播机制使l2_penalty(w)成为一个长度为batch_size的向量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())
lambda是python匿名函数,用来定义函数的,相当于def
net = lambda X: d2l.linreg(X, w, b)
这个函数是一个 Lambda 函数,它定义了一个线性回归模型。d2l.linreg(X, w, b) 计算线性模型的输出,其中:
- X 是输入数据。
- w 是权重参数。
- b 是偏置参数。
线性回归模型的输出可以表示为:y=Xw+b
区别解析
- net = d2l.linreg(X, w, b):
这里,d2l.linreg(X, w, b) 会立即执行,net 会被赋值为执行结果。
换句话说,net 不是一个函数,而是直接存储了 d2l.linreg(X, w, b) 计算出来的值。
这意味着你在定义 net 的那一刻就需要有 X, w, 和 b 的具体值。 - net = lambda X: d2l.linreg(X, w, b):
这里,net 是一个函数,并没有立即执行 d2l.linreg(X, w, b)。它只是定义了一个函数,表示未来当传入 X 时,它将执行 d2l.linreg(X, w, b)。
这种方式更灵活,因为 X 可以在以后再传入,w 和 b 也是可以提前定义好的参数。
loss = d2l.squared_loss
这个定义表示使用平方损失(也称为均方误差)作为损失函数。
4.忽略正则化直接训练
我们现在用lambd = 0禁用权重衰减(也就是不加上L_2范数)后运行这个代码。 注意,这里训练误差有了减少,但测试误差没有减少, 这意味着出现了严重的过拟合。
train(lambd=0)
w的L2范数是: 12.963241577148438
可以看到,train和test之间有一个很大的差,因此是严重的过拟合
(这次用200维的样本,而且只选了20个,而且还带噪声。就是训练数据不足所以过拟合严重)
5.使用权重衰减
下面,我们使用权重衰减来运行代码。 注意,在这里训练误差增大,但测试误差减小。 这正是我们期望从正则化中得到的效果。
train(lambd=3)
w的L2范数是: 0.3556520938873291
简洁实现
由于权重衰减在神经网络优化中很常用, 深度学习框架为了便于我们使用权重衰减, 将权重衰减集成到优化算法中,以便与任何损失函数结合使用。 此外,这种集成还有计算上的好处, 允许在不增加任何额外的计算开销的情况下向算法中添加权重衰减。 由于更新的权重衰减部分仅依赖于每个参数的当前值, 因此优化器必须至少接触每个参数一次。
在下面的代码中,我们在实例化优化器(大部分优化器都有提供weight_decay)时直接通过weight_decay(就是前面那个lambda)指定weight decay超参数。 默认情况下,PyTorch同时衰减权重和偏移。 这里我们只为权重设置了weight_decay,所以偏置参数b不会衰减。
L_2这个罚既可以写在目标函数里面,也可以落在训练算法里面(也就是每一次更新之前把当前的w乘以一个这样小的值即可)
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())
这些图看起来和我们从零开始实现权重衰减时的图相同。 然而,它们运行得更快,更容易实现。 对于更复杂的问题,这一好处将变得更加明显。
train_concise(0)
w的L2范数: 13.727912902832031
train_concise(3)
w的L2范数: 0.3890590965747833
到目前为止,我们只接触到一个简单线性函数的概念。 此外,由什么构成一个简单的非线性函数可能是一个更复杂的问题。 例如,再生核希尔伯特空间(RKHS) 允许在非线性环境中应用为线性函数引入的工具。 不幸的是,基于RKHS的算法往往难以应用到大型、高维的数据。 在这本书中,我们将默认使用简单的启发式方法,即在深层网络的所有层上应用权重衰减。
4.6 暂退法(Dropout)
在 4.5节 中, 我们介绍了通过惩罚权重的L_2范数来正则化统计模型的经典方法。 在概率角度看,我们可以通过以下论证来证明这一技术的合理性: 我们已经假设了一个先验,即权重的值取自均值为0的高斯分布。 更直观的是,我们希望模型深度挖掘特征,即将其权重分散到许多特征中, 而不是过于依赖少数潜在的虚假关联。
重新审视过拟合
当面对更多的特征而样本不足时,线性模型往往会过拟合。 相反,当给出更多样本而不是特征,通常线性模型不会过拟合。
泛化性和灵活性之间的这种基本权衡被描述为偏差-方差权衡(bias-variance tradeoff)。 线性模型有很高的偏差:它们只能表示一小类函数。 然而,这些模型的方差很低:它们在不同的随机数据样本上可以得出相似的结果。
深度神经网络位于偏差-方差谱的另一端。 与线性模型不同,神经网络并不局限于单独查看每个特征,而是学习特征之间的交互。 例如,神经网络可能推断“尼日利亚”和“西联汇款”一起出现在电子邮件中表示垃圾邮件, 但单独出现则不表示垃圾邮件。
即使我们有比特征多得多的样本,深度神经网络也有可能过拟合
扰动的稳健性
在探究泛化性之前,我们先来定义一下什么是一个“好”的预测模型? 我们期待“好”的预测模型能在未知的数据上有很好的表现: 经典泛化理论认为,为了缩小训练和测试性能之间的差距,应该以简单的模型为目标。 简单性以较小维度的形式展现, 我们在 4.4节 讨论线性模型的单项式函数时探讨了这一点。 此外,正如我们在 4.5节 中讨论权重衰减(L_2正则化)时看到的那样, 参数的范数也代表了一种有用的简单性度量。
简单性的另一个角度是平滑性,即函数不应该对其输入的微小变化敏感。 例如,当我们对图像进行分类时,我们预计向像素添加一些随机噪声应该是基本无影响的。 1995年,克里斯托弗·毕晓普证明了 具有输入噪声的训练等价于Tikhonov正则化 (Bishop, 1995)。 这项工作用数学证实了“要求函数光滑”和“要求函数对输入的随机噪声具有适应性”之间的联系。
然后在2014年,斯里瓦斯塔瓦等人 (Srivastava et al., 2014) 就如何将毕晓普的想法应用于网络的内部层提出了一个想法: 在训练过程中,他们建议在计算后续层之前向网络的每一层注入噪声。 因为当训练一个有多层的深层网络时,注入噪声只会在输入-输出映射上增强平滑性。
这个想法被称为暂退法(dropout)。 暂退法在前向传播过程中,计算每一内部层的同时注入噪声,这已经成为训练神经网络的常用技术。 这种方法之所以被称为暂退法,因为我们从表面上看是在训练过程中丢弃(drop out)一些神经元。 在整个训练过程的每一次迭代中,标准暂退法包括在计算下一层之前将当前层中的一些节点置零。
需要说明的是,暂退法的原始论文提到了一个关于有性繁殖的类比: 神经网络过拟合与每一层都依赖于前一层激活值相关,称这种情况为“共适应性”。 作者认为,暂退法会破坏共适应性,就像有性生殖会破坏共适应的基因一样。
那么关键的挑战就是如何注入这种噪声。 一种想法是以一种无偏向(unbiased)的方式注入噪声。 这样在固定住其他层时,每一层的期望值等于没有噪音时的值。
在毕晓普的工作中,他将高斯噪声添加到线性模型的输入中。 在每次训练迭代中,他将从均值为零的分布
采样噪声添加到输入x, 从而产生扰动点, 预期是。
在标准暂退法正则化中,通过按保留(未丢弃)的节点的分数进行规范化来消除每一层的偏差。 换言之,每个中间活性值h以暂退概率p由随机变量替换,如下所示:
根据此模型的设计,其期望值保持不变,即
动机:
一个好的模型需要对输入数据的扰动鲁棒
- 使用有噪音的数据等价于Tikhonov正则(在数据里加入噪音等价于一个正则,这个噪音与之前讲的不一样,之前的是固定噪音,这个是随机噪音)
- 丢弃法:不在输入加噪音,而是在层之间加入噪音。丢弃法其实是一个正则
正则都是为了避免过拟合
实践中的暂退法
回想一下 图4.1.1中带有1个隐藏层和5个隐藏单元的多层感知机。 当我们将暂退法应用到隐藏层,以p的概率将隐藏单元置为零时, 结果可以看作一个只包含原始神经元子集的网络。 比如在 图4.6.1中,删除了h2和h5, 因此输出的计算不再依赖于h2或h5,并且它们各自的梯度在执行反向传播时也会消失。 这样,输出层的计算不能过度依赖于h1,…,h5的任何一个元素。
正则项只在训练中使用:它们影响模型参数的更新。通常,我们在测试时不用暂退法。 给定一个训练好的模型和一个新的样本,我们不会丢弃任何节点,因此不需要标准化。 然而也有一些例外:一些研究人员在测试时使用暂退法, 用于估计神经网络预测的“不确定性”: 如果通过许多不同的暂退法遮盖后得到的预测结果都是一致的,那么我们可以说网络发挥更稳定。
总结
- 丢弃法将一些输出项随机置0来控制模型复杂度
- 常作用在多层感知机的隐藏层输出上
- 丢弃概率是控制模型复杂度的超参数
从零开始实现
要实现单层的暂退法函数, 我们从均匀分布U[0, 1]中抽取样本,样本数与这层神经网络的维度一致。 然后我们保留那些对应样本大于p的节点,把剩下的丢弃。
在下面的代码中,我们实现 dropout_layer 函数, 该函数以dropout的概率丢弃张量输入X中的元素, 如上所述重新缩放剩余部分:将剩余部分除以1.0-dropout。
Dropout 是一种用于防止神经网络过拟合的技术。它通过随机丢弃一部分神经元(设置为零)来减少神经元之间的依赖,从而增强模型的泛化能力。
import torch
from torch import nn
from d2l import torch as d2ldef dropout_layer(X, dropout):assert 0 <= dropout <= 1# 在本情况中,所有元素都被丢弃if dropout == 1:return torch.zeros_like(X)# 在本情况中,所有元素都被保留if dropout == 0:return Xmask = (torch.rand(X.shape) > dropout).float()return mask * X / (1.0 - dropout)
生成掩码 (mask)
mask = (torch.rand(X.shape) > dropout).float()
torch.rand(X.shape):生成与输入 X 相同形状的张量,张量中的每个元素都是在 [0, 1) 范围内均匀分布的随机数。
(torch.rand(X.shape) > dropout):对随机数张量进行比较运算,结果是一个布尔张量。如果随机数大于 dropout,对应位置为 True,否则为 False。
.float():将布尔张量转换为浮点数张量,其中 True 被转换为 1.0,False 被转换为 0.0。因此,mask 是一个0或1的掩码矩阵,表示要保留和丢弃的神经元。
应用 Dropout 和归一化
return mask * X / (1.0 - dropout)
mask * X:利用掩码矩阵对输入 X 进行逐元素相乘。mask 为 0 的位置将丢弃神经元(结果为 0),mask 为 1 的位置保留原值。
/ (1.0 - dropout):在训练时,为了保持输出的期望值不变,模型会对保留的神经元进行归一化。如果有 p 比例的神经元被丢弃,那么剩下的 1 - p 部分需要被放大(除以 1 - dropout),以保持相同的输出范围和期望。
我们可以通过下面几个例子来测试dropout_layer函数。 我们将输入X通过暂退法操作,暂退概率分别为0、0.5和1。
X= torch.arange(16, dtype = torch.float32).reshape((2, 8))
print(X)
print(dropout_layer(X, 0.))
print(dropout_layer(X, 0.5))
print(dropout_layer(X, 1.))
1.定义模型参数
同样,我们使用 3.5节中引入的Fashion-MNIST数据集。 我们定义具有两个隐藏层的多层感知机,每个隐藏层包含256个单元。
num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256
2.定义模型
我们可以将暂退法应用于每个隐藏层的输出(在激活函数之后), 并且可以为每一层分别设置暂退概率: 常见的技巧是在靠近输入层的地方设置较低的暂退概率。 下面的模型将第一个和第二个隐藏层的暂退概率分别设置为0.2和0.5, 并且暂退法只在训练期间有效。
定义具有两个隐藏层的多层感知机,每个隐藏层包含256个单元
两个隐藏层,所以有三个线性层
dropout1, dropout2 = 0.2, 0.5class 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_inputsself.training = is_trainingself.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)))) # 第一个隐藏层的输出# 只有在训练模型时才使用dropoutif self.training == True:# 在第一个全连接层之后添加一个dropout层H1 = dropout_layer(H1, dropout1)H2 = self.relu(self.lin2(H1))if self.training == True:# 在第二个全连接层之后添加一个dropout层H2 = dropout_layer(H2, dropout2)out = self.lin3(H2)return outnet = Net(num_inputs, num_outputs, num_hiddens1, num_hiddens2)
- 参数解释
dropout1, dropout2 = 0.2, 0.5: 这两个变量定义了网络中两个隐藏层之后的 Dropout 概率,分别为 0.2 和 0.5。dropout1 = 0.2 表示在第一个隐藏层后,20% 的神经元会被丢弃。 - 网络结构
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_inputsself.training = is_trainingself.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() # ReLU 激活函数
- num_inputs: 输入层的神经元数量(输入的特征维度)。
- num_outputs: 输出层的神经元数量(通常是类别数或回归任务中的输出维度)。
- num_hiddens1: 第一隐藏层的神经元数量。
- num_hiddens2: 第二隐藏层的神经元数量。
- is_training: 该参数用于指示模型是否处于训练模式,默认为 True。当模型处于训练模式时,才会应用 Dropout,在评估或预测时则不使用 Dropout。
- self.lin1, self.lin2, self.lin3: 定义了三层线性变换,即全连接层。
- self.relu: 使用 ReLU 作为激活函数,它将输入中的负值置为零,保持正值不变。
- 前向传播
def forward(self, X):H1 = self.relu(self.lin1(X.reshape((-1, self.num_inputs))))# 只有在训练模型时才使用dropoutif self.training == True:# 在第一个全连接层之后添加一个dropout层H1 = dropout_layer(H1, dropout1)H2 = self.relu(self.lin2(H1))if self.training == True:# 在第二个全连接层之后添加一个dropout层H2 = dropout_layer(H2, dropout2)out = self.lin3(H2)return out
- X.reshape((-1, self.num_inputs)): 这一行将输入 X 重新调整为形状为 (-1, self.num_inputs) 的张量。-1 表示批次大小自适应,self.num_inputs 是输入的特征维度。这样做的目的是确保输入数据与第一个全连接层的输入维度匹配。
- 第一层传播:
H1 = self.relu(self.lin1(X.reshape((-1, self.num_inputs)))):将输入 X 传入第一个全连接层 lin1,并应用 ReLU 激活函数。
if self.training == True: H1 = dropout_layer(H1, dropout1):如果模型处于训练模式,会在第一个隐藏层后应用 Dropout,按照 dropout1 (0.2) 的概率随机丢弃神经元。 - 第二层传播:
H2 = self.relu(self.lin2(H1)):
通过第二个全连接层 lin2,然后应用 ReLU 激活函数。
if self.training == True: H2 = dropout_layer(H2, dropout2):
如果模型处于训练模式,会在第二个隐藏层后应用 Dropout,按照 dropout2 (0.5) 的概率随机丢弃神经元。 - 输出层:
out = self.lin3(H2):
最终的输出通过第三个全连接层 lin3。
call函数调用forward,call是通过模型+参数调用
3.训练和测试
这类似于前面描述的多层感知机训练和测试。
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层仅传递数据。
net = nn.Sequential(nn.Flatten(),nn.Linear(784, 256),nn.ReLU(),# 在第一个全连接层之后添加一个dropout层nn.Dropout(dropout1),nn.Linear(256, 256),nn.ReLU(),# 在第二个全连接层之后添加一个dropout层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);
这里的Dropout放在ReLU前后都可以
接下来,我们对模型进行训练和测试。
trainer = torch.optim.SGD(net.parameters(), lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
4.7 前向传播、反向传播和计算图
前向传播在神经网络定义的计算图中按顺序计算和存储中间变量,它的顺序是从输入层到输出层。
反向传播按相反的顺序(从输出层到输入层)计算和存储神经网络的中间变量和参数的梯度。
在训练神经网络时,前向传播和反向传播相互依赖。 对于前向传播,我们沿着依赖的方向遍历计算图并计算其路径上的所有变量。 然后将这些用于反向传播,其中计算顺序与计算图的相反。
以上述简单网络为例:一方面,在前向传播期间计算正则项 (4.7.5)取决于模型参数W(1)和W(2)的当前值。 它们是由优化算法根据最近迭代的反向传播给出的。 另一方面,反向传播期间参数 (4.7.11)的梯度计算, 取决于由前向传播给出的隐藏变量h的当前值。
因此,在训练神经网络时,在初始化模型参数后, 我们交替使用前向传播和反向传播,利用反向传播给出的梯度来更新模型参数。 注意,反向传播重复利用前向传播中存储的中间值,以避免重复计算。 带来的影响之一是我们需要保留中间值,直到反向传播完成。 这也是训练比单纯的预测需要更多的内存(显存)的原因之一。 此外,这些中间值的大小与网络层的数量和批量的大小大致成正比。 因此,使用更大的批量来训练更深层次的网络更容易导致内存不足(out of memory)错误。
“预测” 通常指的是推理过程(Inference)。这是模型在训练完成之后,接收新的输入数据并生成输出的过程。与训练不同,预测阶段不会进行梯度计算或权重更新,主要的目的是使用已训练好的模型来对新数据进行推断或分类。
4.8 数值稳定性和模型初始化
到目前为止,我们实现的每个模型都是根据某个预先指定的分布来初始化模型的参数。 有人会认为初始化方案是理所当然的,忽略了如何做出这些选择的细节。甚至有人可能会觉得,初始化方案的选择并不是特别重要。 相反,初始化方案的选择在神经网络学习中起着举足轻重的作用, 它对保持数值稳定性至关重要。 此外,这些初始化方案的选择可以与非线性激活函数的选择有趣的结合在一起。 我们选择哪个函数以及如何初始化参数可以决定优化算法收敛的速度有多快。 糟糕选择可能会导致我们在训练时遇到梯度爆炸或梯度消失。 本节将更详细地探讨这些主题,并讨论一些有用的启发式方法。 这些启发式方法在整个深度学习生涯中都很有用。
主要问题:做了太多次乘法,这就导致了以下两个问题出现:
梯度消失和梯度爆炸
因此,我们容易受到数值下溢问题的影响. 当将太多的概率乘在一起时,这些问题经常会出现。 在处理概率时,一个常见的技巧是切换到对数空间, 即将数值表示的压力从尾数转移到指数。 不幸的是,上面的问题更为严重: 最初,矩阵M(l)可能具有各种各样的特征值。 他们可能很小,也可能很大; 他们的乘积可能非常大,也可能非常小。
不稳定梯度带来的风险不止在于数值表示; 不稳定梯度也威胁到我们优化算法的稳定性。 我们可能面临一些问题。 要么是梯度爆炸(gradient exploding)问题: 参数更新过大,破坏了模型的稳定收敛; 要么是梯度消失(gradient vanishing)问题: 参数更新过小,在每次更新时几乎不会移动,导致模型无法学习。
1.梯度消失
曾经sigmoid函数( 4.1节提到过)很流行, 因为它类似于阈值函数。 由于早期的人工神经网络受到生物神经网络的启发, 神经元要么完全激活要么完全不激活(就像生物神经元)的想法很有吸引力。 然而,它却是导致梯度消失问题的一个常见的原因, 让我们仔细看看sigmoid函数为什么会导致梯度消失。
%matplotlib inline
import torch
from d2l import torch as d2lx = torch.arange(-8.0, 8.0, 0.1, requires_grad=True)
y = torch.sigmoid(x)
y.backward(torch.ones_like(x))d2l.plot(x.detach().numpy(), [y.detach().numpy(), x.grad.numpy()],legend=['sigmoid', 'gradient'], figsize=(4.5, 2.5))
正如上图,当sigmoid函数的输入很大或是很小时,它的梯度都会消失。 此外,当反向传播通过许多层时,除非我们在刚刚好的地方, 这些地方sigmoid函数的输入接近于零,否则整个乘积的梯度可能会消失。 当我们的网络有很多层时,除非我们很小心,否则在某一层可能会切断梯度。 事实上,这个问题曾经困扰着深度网络的训练。 因此,更稳定的ReLU系列函数已经成为从业者的默认选择(虽然在神经科学的角度看起来不太合理)。
2.梯度爆炸
相反,梯度爆炸可能同样令人烦恼。 为了更好地说明这一点,我们生成100个高斯随机矩阵,并将它们与某个初始矩阵相乘。 对于我们选择的尺度(方差),矩阵乘积发生爆炸。 当这种情况是由于深度网络的初始化所导致时,我们没有机会让梯度下降优化器收敛。
M = torch.normal(0, 1, size=(4,4))
print('一个矩阵 \n',M)
for i in range(100):M = torch.mm(M,torch.normal(0, 1, size=(4, 4)))print('乘以100个矩阵后\n', M)
总结:
- 当数值过大或过小时会导致数值问题
- 常发生在深度模型中,因为其会对n个数累乘
模型初始化和激活函数
解决(或至少减轻)上述问题的一种方法是进行参数初始化, 优化期间的注意和适当的正则化也可以进一步提高稳定性。
在前面的部分中,例如在 3.3节中, 我们使用正态分布来初始化权重值。如果我们不指定初始化方法, 框架将使用默认的随机初始化方法,对于中等难度的问题,这种方法通常很有效。
4.9 环境和分布偏移
本节参考资料
前面我们学习了许多机器学习的实际应用,将模型拟合各种数据集。 然而,我们从来没有想过数据最初从哪里来?以及我们计划最终如何处理模型的输出? 通常情况下,开发人员会拥有一些数据且急于开发模型,而不关注这些基本问题。
许多失败的机器学习部署(即实际应用)都可以追究到这种方式。 有时,根据测试集的精度衡量,模型表现得非常出色。 但是当数据分布突然改变时,模型在部署中会出现灾难性的失败。 更隐蔽的是,有时模型的部署本身就是扰乱数据分布的催化剂。 举一个有点荒谬却可能真实存在的例子。 假设我们训练了一个贷款申请人违约风险模型,用来预测谁将偿还贷款或违约。 这个模型发现申请人的鞋子与违约风险相关(穿牛津鞋申请人会偿还,穿运动鞋申请人会违约)。 此后,这个模型可能倾向于向所有穿着牛津鞋的申请人发放贷款,并拒绝所有穿着运动鞋的申请人。
这种情况可能会带来灾难性的后果。 首先,一旦模型开始根据鞋类做出决定,顾客就会理解并改变他们的行为。 不久,所有的申请者都会穿牛津鞋,而信用度却没有相应的提高。 总而言之,机器学习的许多应用中都存在类似的问题: 通过将基于模型的决策引入环境,我们可能会破坏模型。
虽然我们不可能在一节中讨论全部的问题,但我们希望揭示一些常见的问题, 并激发批判性思考,以便及早发现这些情况,减轻灾难性的损害。 有些解决方案很简单(要求“正确”的数据),有些在技术上很困难(实施强化学习系统), 还有一些解决方案要求我们完全跳出统计预测,解决一些棘手的、与算法伦理应用有关的哲学问题。
手里拿着锤子,看什么都像钉子。思维方式会受惯用工具或方法的限制,导致解决问题的思维固化。深度学习的工程实践中,开发者在拥有一些数据后往往急于实现模型,而没有充分考虑数据分布和模型部署的环境。这可能导致模型失效,甚至给模型的应用带来灾难:
- 在测试集上表现出色,但真实环境的数据分布不同于测试——在训练时提供更真实的数据;
- 模型的部署改变了数据的分布等——实施强化学习。
本节无法讨论或解决全部问题,旨在提出一些常见的方法并引导批判性思考。有些解决方案甚至要考虑与算法伦理相关的哲学问题。
分布偏移的类型
1.协变量偏移
协变量偏移 (covariate shift) 中,特征分布发生变化,但特征与标签的映射(条件分布 p ( y ∣ x ) p(y|\mathbf{x}) p(y∣x))保持不变。
在不同分布偏移中,协变量偏移可能是最为广泛研究的。 这里我们假设:虽然输入的分布可能随时间而改变, 但标签函数(即条件分布P(y|x))没有改变。 统计学家称之为协变量偏移(covariate shift), 因为这个问题是由于协变量(特征)分布的变化而产生的。 虽然有时我们可以在不引用因果关系的情况下对分布偏移进行推断, 但在我们认为x导致y的情况下,协变量偏移是一种自然假设。
训练集由真实照片组成,而测试集只包含卡通图片。 假设在一个与测试集的特征有着本质不同的数据集上进行训练, 如果没有方法来适应新的领域,可能会有麻烦。
在房价预测模型中,虽然决定房价的因素始终不变(面积、到闹市区的距离、基础设施的配置),但不同城市间这些因素的数据具有不同的分布特征。
可以使用重要性采样 (importance sampling) 或重新加权的方法校正协变量偏移。
2.标签偏移
标签偏移 (label shift) 中,标签分布发生变化,但条件分布 p ( y ∣ x ) p(y|\mathbf{x}) p(y∣x)保持不变。
对于一个垃圾邮件分类模型,测试数据比训练数据包含更多的垃圾邮件。
估计测试数据的标签分布,以调整模型的决策边界,校正标签偏移。
3.概念偏移
概念偏移 (concept shift) 指的是数据模式和关系随时间发生了变化,即条件分布 p ( y ∣ x ) p(y|\mathbf{x}) p(y∣x)改变。
我们也可能会遇到概念偏移(concept shift): 当标签的定义发生变化时,就会出现这种问题。 这听起来很奇怪——一只猫就是一只猫,不是吗? 然而,其他类别会随着不同时间的用法而发生变化。 精神疾病的诊断标准、所谓的时髦、以及工作头衔等等,都是概念偏移的日常映射。 事实证明,假如我们环游美国,根据所在的地理位置改变我们的数据来源, 我们会发现关于“软饮”名称的分布发生了相当大的概念偏移
广告推荐模型中,新产品的推出会导致旧产品逐渐失去吸引力,广告的效果将退化。
通过持续的模型更新、重新训练或引入新特征等矫正概念偏移。