1995年,Christopher Bishop证明了具有输入噪声的训练等价于吉洪诺夫正则化(Tikhonov regularization)。这项工作用数学证明了”要求函数平滑“和”要求函数对输入的随机噪声具有适应性“之间的联系。
前言
造成过拟合的情况分为如下三类:
- 权重值的范围太大,导致模型极其不稳定。
- 对于简单的数据选取的模型过于复杂,比如隐藏层过多,隐藏层的神经元过多。
- 训练样本过少,导致模型对少样本完全拟合,对于新样本极其陌生。
针对第一类问题,我们采用权重衰退的方法优化,而第二类问题则需要对神经网络中的神经元进行必要的淘汰(即”暂退法“)。
暂退法(Dropout)是一种在深度学习中常用的正则化技术,它通过在训练过程中随机丢弃(置零)网络中的一部分神经元(包括它们的连接),来防止模型的过拟合。这种方法可以简单理解为每一次训练迭代时,网络都会以一定的概率随机地“瘦身”,只使用部分神经元进行前向传播和后向传播。通过这种方式,模型可以学习到更加鲁棒的特征表示。
保持中间期望值的不变性是暂退法的一个重要设计,原因包括:
避免放缩影响: 在使用暂退法时,由于一部分神经元被随机丢弃,直接导致了网络某一层的输出在数量级上减少了。如果在测试时仍以全量的神经元进行计算,而不对这种减少进行调整,就会导致测试时网络的行为与训练时不一致。为了解决这一问题,通常在训练时对那些未被丢弃的神经元的输出进行放缩(通常是乘以一个与丢弃概率相反的因子),以保证网络的输出期望不变。这样可以在训练和测试时保持网络的行为一致性。
维持网络性能: 暂退法通过随机丢弃神经元的方式来增强模型的泛化能力,但如果不通过调整输出以保持期望值不变,就可能导致网络的学习效率下降,因为网络的输出分布会随着丢弃率的变化而发生显著变化,这对于后续层的学习是不利的。通过保持输出期望值的不变,可以减少这种影响,帮助网络更稳定地学习。
简化模型评估: 如果训练和测试时网络行为大为不同,将很难评估模型的真实性能。保持中间期望值不变简化了从训练模式到测试模式的转换,因为在测试时不使用暂退法,但通过在训练时调整保持期望值不变,可以使得训练和测试时的网络行为更加一致,从而使得模型评估更为准确和简单。
在使用暂退法(Dropout)时,通常会关注两个关键的期望值:训练时保留的神经元的输出期望值 $h’$ 和在不使用暂退法(即不丢弃任何神经元)时神经元的输出期望值 $h$ 。为了确保模型在训练和测试时表现的一致性,需要在训练时对 $h’$ 进行适当的缩放,使得其期望值等于 $h$ 。
假设在训练时,每个神经元被保留(即不被丢弃)的概率是 $p$ (因此,被丢弃的概率是 $1-p$ )。那么,为了保持期望值不变,输出 $h’$ 应该被缩放一个因子,以补偿因丢弃神经元而减少的输出。这个缩放因子是 $1/p$ ,因为在期望意义下,只有 $p$ 比例的神经元在任何时间点被激活。
暂退法的原始论文提到了一个有关有性生殖的类比;神经网络过拟合与每一层都依赖前一层的激活值有关,这种情况称之为”共适应性“。共适应性(Co-adaptation)在机器学习领域通常指的是模型的不同部分在训练过程中相互适应,以优化整体性能的现象。这种相互适应可以是有益的,比如在深度学习中,网络的不同层次通过共适应学习到更有效的特征表示。然而,共适应性也可能导致模型对训练数据过度特化,这在一定程度上与过拟合的概念相关联。我们认为,暂退法会破坏共适应性,就像有性生殖会破坏共适应的基因一样。
暂退法的从头实现
导包
1 | import torch |
丢失过程的定义
1 | def dropout_layer(X, dropout): |
这个函数定义了一个名为 dropout_layer
的操作,它实现了在深度学习中常用的 Dropout 技术,用于防止模型过拟合。下面逐行解释这个函数的代码:
def dropout_layer(X, dropout):
- 这行定义了函数
dropout_layer
,它接受两个参数:X
和dropout
。X
是输入的数据(通常是一个张量),dropout
是一个介于 0 和 1 之间的浮点数,表示 dropout 率,即随机丢弃神经网络中某些节点的比例。
- 这行定义了函数
assert 0<= dropout <=1
- 这行代码是一个断言语句,用来确保传入的
dropout
参数值在 0 到 1 之间。如果dropout
不满足这个条件,程序将抛出异常。
- 这行代码是一个断言语句,用来确保传入的
if dropout == 1:
- 这行代码检查
dropout
是否等于 1。如果等于 1,意味着需要丢弃所有节点。
- 这行代码检查
return torch.zeros_like(X)
- 如果
dropout
等于 1,这行代码将返回一个与输入X
形状相同但所有元素都是 0 的张量。这表示所有的输入节点都被丢弃了。
- 如果
if dropout == 0:
- 这行代码检查
dropout
是否等于 0。如果等于 0,意味着不丢弃任何节点。
- 这行代码检查
return X
- 如果
dropout
等于 0,这行代码直接返回输入的张量X
,表示保留所有节点,不进行任何丢弃。
- 如果
mask = (torch.rand(X.shape) > dropout).float
- 这行代码首先使用
torch.rand(X.shape)
生成一个与X
形状相同的随机张量,其元素值在 0 到 1 之间。然后,这个随机张量与dropout
进行比较,得到一个布尔型张量,其中大于dropout
的元素被标记为True
(这些是将被保留的节点),否则为False
(这些节点将被丢弃)。最后,调用.float()
方法将布尔型张量转换为浮点型张量,True
转换为 1.0,False
转换为 0.0,生成了一个掩码(mask)张量。
- 这行代码首先使用
return mask*X/(1.0-dropout)
- 这行代码首先使用前面生成的掩码张量
mask
与输入X
进行元素乘法,这样被标记为 0(即应该被丢弃)的节点的值将变为 0,而被保留的节点值不变。然后,将结果除以(1.0-dropout)
进行缩放,以保持激活的总和的期望值不变。这是一种常见的做法,用以补偿因为部分节点的丢弃而可能导致的激活总量的减少。
- 这行代码首先使用前面生成的掩码张量
这个函数通过随机丢弃网络中的一部分节点,帮助防止模型在训练过程中过拟合。
可能你会注意到,这里丢弃的神经元比率似乎并不是dropout,事实上,所谓dropout指的是每个神经元被丢弃的概率。
参数和模型的定义
定义参数
1 | num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256 |
模型定义
在这里,我们继承了nn.Module
定义一个新的类Net
。继承自 nn.Module
,使用了 PyTorch 框架。
类定义与初始化方法
1 | class Net(nn.Module): |
- 定义了一个名为
Net
的新类,这个类继承自 PyTorch 的nn.Module
类。nn.Module
是所有神经网络模块的基类,你的网络应该也继承自这个类。
1 | def __init__(self, num_inputs, num_outputs, num_hiddens1, num_hiddens2, is_training = True): |
- 这是
Net
类的初始化方法,用于创建类的实例。它接受五个参数:输入层的大小 (num_inputs
)、输出层的大小 (num_outputs
)、两个隐藏层的大小 (num_hiddens1
和num_hiddens2
),以及一个标志is_training
指示网络是否处于训练模式。
1 | super(Net, self).__init__() |
- 这行代码调用了父类
nn.Module
的初始化方法。这是继承中常见的做法,用于确保父类被正确初始化。
1 | self.num_inputs = num_inputs self.num_outputs = num_outputs self.training = is_training |
- 这三行代码将传入的参数值分别赋给实例变量,以便后续使用。
1 | self.lin1 = nn.Linear(num_inputs,num_hiddens1) self.lin2 = nn.Linear(num_hiddens1,num_hiddens2) self.lin3 = nn.Linear(num_hiddens2,num_outputs) |
这三行定义了网络中的三个线性层(也称为全连接层)。
nn.Linear
创建一个线性转换层,它的参数分别指定了输入和输出特征的数量。1
self.relu = nn.ReLU()
创建了一个ReLU激活函数实例,用于在网络的隐藏层之后添加非线性。
前向传播方法
1 | def forward(self, X): |
- 定义了一个名为
forward
的方法,它覆盖了父类nn.Module
的forward
方法。这是模型定义数据如何通过网络的关键部分。
1 | H1 = self.relu(self.lin1(X.reshape((-1,self.num_inputs)))) |
- 首先,输入
X
被重塑为一个二维张量,其中第二维大小为num_inputs
。然后,数据通过第一个线性层lin1
,最后通过ReLU激活函数。结果是第一个隐藏层的输出。
1 | if self.training == True: |
- 如果
self.training
为真,则对第一个隐藏层应用dropout。这里似乎有一个小错误:dropout_layer
和drop1
没有在前面的代码中定义。
1 | H2 = self.relu(self.lin2(H1)) |
- 第一个隐藏层的输出
H1
通过第二个线性层lin2
,然后通过ReLU激活函数。结果是第二个隐藏层的输出。
1 | if self.training == True: |
- 同样地,如果
self.training
为真,则对第二个隐藏层应用dropout。这里同样存在一个未定义的dropout_layer
和drop2
的问题。
1 | out = self.lin3(H2) |
- 第二个隐藏层的输出
H2
通过第三个线性层lin3
,没有激活函数,结果是网络的最终输出。 - 返回网络的最终输出。
网络实例化
1 | net = Net(num_inputs,num_outputs,num_hiddens1,num_hiddens2) |
代码合并
1 | class Net(nn.Module): |
模型训练
从上面的推导我们可以发现,我们想要改动网络架构进行训练过程的优化,只需要在net上做文章即可,事实上,后面的高级API实现也证明了这一点。
1 | num_epochs, lr, batch_size = 10,0.5,256 |
暂退法的高级API实现
只需要单独定义net和初始化参数,训练过程等从前。事实上,PyTorch的优势在于把网络用连续过程进行定义,十分的直观。
1 | net = nn.Sequential(nn.Flatten(), |