【动手学深度学习】权重衰减(含Pytorch从零开始实现的源代码)

时间:2022-11-24 11:51:56

一、理论知识

前面我们已经介绍学习了过拟合的问题,这次我们来讲讲一个正则化模型的技术。

我们总是可以通过去收集更多的训练数据来缓解过拟合,但这可能成本很高,耗时颇多,或者完全超出我们的控制,因而在短期内不可能做到。

假设我们已经拥有了尽可能多的高质量的数据,我们便可以把重心放在正则化技术上。

在多项式回归的例子中,我们可以通过调整拟合多项式的阶数来限制模型的容量。

实际上,限制特征的数量是缓解过拟合的一种常用技术。然而,简单的丢弃特征对于这项工作来说可能过于生硬。我们继续思考多项式回归的例子,考虑高维输入可能发生的情况。 多项式对多变量数据的自然扩展称为单项式(monomials), 也可以说是变量幂的乘积。 单项式的阶数是幂的和。例如, x 1 2 x 2 x_1^2x_2 x12x2 x 3 x 5 2 x_3x_5^2 x3x52都是三次多项式。

注意,随着结束 d d d的增长,带有结束 d d d的项数迅速增加。给定 k k k个变量,阶数为 d d d的项的个数为 ( k − 1 + d k − 1 ) \binom{k-1+d}{k-1} (k1k1+d),即 C k − 1 + d k − 1 = ( k − 1 + d ) ! d ! ( k − 1 ) ! {C_{k-1+d}^{k-1}} =\frac{(k-1+d)!}{d!(k-1)!} Ck1+dk1=d!(k1)!(k1+d)!。因此,即便是阶数上的微小变化,比如从2到3,也会显著增加我们模型的复杂性。

在之前的学习中,我们已经接触了 L 2 L_2 L2范数和 L 1 L_1 L1范数,它们是更为一般的 L p L_p Lp范数的特殊情况。

在训练参数化机器学习模型时, 权重衰减(weight decay)是最广泛使用的正则化的技术之一, 它通常也被称为 L 2 L_2 L2正则化。 这项技术通过函数与零的距离来衡量函数的复杂度, 因为在所有函数 f f f中,函数 f = 0 f=0 f=0(所有输入都得到值0)在某种意义上是最简单的。 但是我们应该如何精确地测量一个函数和零之间的距离呢? 没有一个正确的答案。 事实上,函数分析和巴拿赫空间理论的研究,都在致力于回答这个问题。

一种简单的方法是通过线性函数 f ( X ) = w T X f(\textbf{X})=\textbf{w}^T\textbf{X} f(X)=wTX中的权重向量的某个范数来度量其复杂性, 例如 ∣ ∣ w ∣ ∣ 2 ||\textbf{w}||^2 w2。 要保证权重向量比较小, 最常用方法是将其范数作为惩罚项加到最小化损失的问题中。 将原来的训练目标最小化训练标签上的预测损失, 调整为最小化预测损失和惩罚项之和。 现在,如果我们的权重向量增长的太大, 我们的学习算法可能会更集中于最小化权重范数 ∣ ∣ w ∣ ∣ 2 ||\textbf{w}||^2 w2。这正是我们想要的。我们的损失由下式给出:
L ( w , b ) = 1 n ∑ i = 1 n 1 2 ( w T x ( i ) + b − y ( i ) ) L(\textbf{w},b)=\frac{1}{n}\sum_{i=1}^{n}\frac{1}{2}(\textbf{w}^T\textbf{x}^{(i)}+b-y^{(i)} ) L(w,b)=n1i=1n21(wTx(i)+by(i))
回想一下, x ( i ) \textbf{x}^{(i)} x(i)是样本 i i i的特征, y ( i ) y^{(i)} y(i)是样本 i i i的标签, ( w , b ) (\textbf{w},b) (w,b)是权重和偏置参数。 为了惩罚权重向量的大小, 我们必须以某种方式在损失函数中添加 ∣ ∣ w ∣ ∣ 2 ||\textbf{w}||^2 w2, 但是模型应该如何平衡这个新的额外惩罚的损失? 实际上,我们通过正则化常数 λ \lambda λ来描述这种权衡, 这是一个非负超参数,我们使用验证数据拟合:
L ( w , b ) + λ 2 ∣ ∣ w ∣ ∣ 2 L(\textbf{w},b)+\frac{\lambda }{2}||\textbf{w}||^2 L(w,b)+2λw2
对于 λ = 0 \lambda=0 λ=0,我们恢复了原来的损失函数。

对于 λ > 0 \lambda>0 λ>0,我们限制 ∣ ∣ w ∣ ∣ ||\textbf{w}|| w的大小。

这里我们仍然除以2,当我们取一个二次函数的导数时,2和1/2会抵消掉。以确保更新表达式看起来既漂亮又简单。 你可能会想知道为什么我们使用平方范数而不是标准范数(即欧几里得距离)? 我们这样做是为了便于计算。 通过平方 L 2 L_2 L2范数,我们去掉平方根,留下权重向量每个分量的平方和。 这使得惩罚的导数很容易计算:导数的和等于和的导数。

L 2 L_2 L2正则化回归的小批量随机梯度下降更新如下式:
w ← ( 1 − η λ ) w − η ∣ β ∣ ∑ i ∈ β x ( i ) ( w T x ( i ) + b − y ( i ) ) \textbf{w}\leftarrow (1-\eta \lambda )\textbf{w}-\frac{\eta }{|\beta|}\sum_{i\in \beta}\textbf{x}^{(i)}(\textbf{w}^T\textbf{x}^{(i)}+b-y^{(i)}) w(1ηλ)wβηiβx(i)(wTx(i)+by(i))
根据之前章节所讲的,我们根据估计值与观测值之间的差异来更新 w \textbf{w} w。 然而,我们同时也在试图将 w \textbf{w} w的大小缩小到零。 这就是为什么这种方法有时被称为权重衰减。 我们仅考虑惩罚项,优化算法在训练的每一步衰减权重。 与特征选择相比,权重衰减为我们提供了一种连续的机制来调整函数的复杂度。 较小的 λ \lambda λ值对应较少约束的 w \textbf{w} w, 而较大的 λ \lambda λ值对 w \textbf{w} w的约束更大。

是否对相应的偏置 b b b进行惩罚在不同的实践中会有所不同, 在神经网络的不同层中也会有所不同。 通常,网络输出层的偏置项不会被正则化。

二、高维线性回归的实现步骤

我们通过一个简单的例子来演示权重衰减。

2.1 准备数据

import torch
from torch import nn
from d2l import torch as d2l

首先,我们像以前一样生成一些数据,生成公式如下:
y = 0.05 + ∑ i = 1 d 0.01 x i + ϵ w h e r e ϵ ∈ N ( 0 , 0.0 1 2 ) y=0.05+\sum_{i=1}^{d}0.01x_i+\epsilon \quad where\quad \epsilon \in N(0,0.01^2) y=0.05+i=1d0.01xi+ϵwhereϵN(0,0.012)
我们选择标签是关于输入的线性函数。 标签同时被均值为0,标准差为0.01高斯噪声破坏。 为了使过拟合的效果更加明显,我们可以将问题的维数增加到 d = 200 d=200 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) # 这里的is_train是表示是否打乱

2.2 初始化模型参数

首先,我们将定义一个函数来随机初始化模型参数。

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.3 定义 L 2 L_2 L2范数惩罚

实现这一惩罚最方便的方法是对所有项求平方后并将它们求和。

def l2_penalty(w):
    return torch.sum(w.pow(2) / 2)

2.4 定义训练代码实现

下面的代码将模型拟合训练数据集,并在测试数据集上进行评估。 线性网络和平方损失没有变化, 所以我们通过d2l.linreg和d2l.squared_loss导入它们。 唯一的变化是损失现在包括了惩罚项。

def train(lambd):
    w, b = init_params()
    net, loss = lambda X : d2l.linreg(X, w, b), d2l.squared_loss
    num_epochs, lr = 100, 0.01
    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))
    d2l.plt.show()

2.5 是否加入正则化

2.5.1 忽略正则化

train(0)

输出结果为:
【动手学深度学习】权重衰减(含Pytorch从零开始实现的源代码)
此时:

w的L2范数是: tensor(13.1169, grad_fn=<CopyBackwards>)

2.5.2 加入正则化

选择 λ = 1 \lambda = 1 λ=1

train(1)

【动手学深度学习】权重衰减(含Pytorch从零开始实现的源代码)
此时:

w的L2范数是: tensor(0.2394, grad_fn=<CopyBackwards>)

三、简单实现

由于权重衰减在神经网络优化中很常用, 深度学习框架为了便于我们使用权重衰减, 将权重衰减集成到优化算法中,以便与任何损失函数结合使用。 此外,这种集成还有计算上的好处, 允许在不增加任何额外的计算开销的情况下向算法中添加权重衰减。 由于更新的权重衰减部分仅依赖于每个参数的当前值, 因此优化器必须至少接触每个参数一次。

在下面的代码中,我们在实例化优化器时直接通过weight_decay指定weight decay超参数。 默认情况下,PyTorch同时衰减权重和偏移。 这里我们只为权重设置了weight_decay,所以偏置参数 b b b不会衰减。

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.01
    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) % 10 == 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())
    d2l.plt.show()

无正则化的时候:

train_concise(0)

【动手学深度学习】权重衰减(含Pytorch从零开始实现的源代码)

w的L2范数: tensor(13.3643, grad_fn=<CopyBackwards>)

加上正则化的时候:

train_concise(3)

【动手学深度学习】权重衰减(含Pytorch从零开始实现的源代码)

w的L2范数: tensor(0.1036, grad_fn=<CopyBackwards>)

四、源代码

# 通过一个例子来演示权重衰减
import torch
from torch import nn
from d2l import torch as d2l


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]


def l2_penalty(w):
    return torch.sum(w.pow(2) / 2)


def train(lambd):
    w, b = init_params()
    net, loss = lambda X : d2l.linreg(X, w, b), d2l.squared_loss
    num_epochs, lr = 100, 0.01
    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))
    d2l.plt.show()


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.01
    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) % 10 == 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())
    d2l.plt.show()


if __name__ == '__main__':
    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) # 这里的is_train是表示是否打乱
    train_concise(3)