3.7. 权重衰减
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

现在我们已经描述了过拟合的问题,我们可以介绍一些正则化方法。我们总是可以通过收集更多的训练数据来缓解过拟合。但这可能成本很高,耗时,或者完全超出我们的控制,因而在短期内不可能做到。现在,我们可以假设我们已经拥有尽可能多的高质量数据,我们将重点放在当数据集给定时我们可以使用的工具上。

回想一下,在我们的多项式回归例子中(第 3.6.2.1 节),我们可以通过调整拟合多项式的阶数来限制模型的容量。确实,限制特征的数量是缓解过拟合的一种常用技术。然而,简单地丢弃特征可能是一种过于生硬的手段。继续以多项式回归为例,考虑高维输入可能发生的情况。多项式对多变量数据的自然扩展称为*单项式*,也就是变量幂的乘积。单项式的阶数是所有幂的总和。例如,\(x_1^2 x_2\)\(x_3 x_5^2\) 都是3阶单项式。

注意,随着阶数 \(d\) 的增大,带有阶数 \(d\) 的项数迅速增加。给定 \(k\) 个变量,阶数为 \(d\) 的单项式个数为 \({k - 1 + d} \choose {k - 1}\)。即使是阶数上的小变化,比如从 \(2\)\(3\),也会显著增加我们模型的复杂性。因此,我们通常需要一个更细粒度的工具来调整函数复杂性。

%matplotlib inline
import torch
from torch import nn
from d2l import torch as d2l
%matplotlib inline
from mxnet import autograd, gluon, init, np, npx
from mxnet.gluon import nn
from d2l import mxnet as d2l

npx.set_np()
%matplotlib inline
import jax
import optax
from jax import numpy as jnp
from d2l import jax as d2l
No GPU/TPU found, falling back to CPU. (Set TF_CPP_MIN_LOG_LEVEL=0 and rerun for more info.)
%matplotlib inline
import tensorflow as tf
from d2l import tensorflow as d2l

3.7.1. 范数和权重衰减

权重衰减通过限制参数的取值来运作,而不是直接操纵参数的数量。当通过小批量随机梯度下降进行优化时,在深度学习圈子之外,它更常被称为 \(\ell_2\) 正则化,权重衰减可能是用于正则化参数化机器学习模型最广泛使用的技术。该技术是基于一个基本直觉,即在所有函数 \(f\) 中,函数 \(f = 0\)(将所有输入都赋值为 \(0\))在某种意义上是*最简单*的,我们可以通过其参数与零的距离来衡量一个函数的复杂性。但是我们应该如何精确地测量一个函数与零之间的距离呢?没有一个单一的正确答案。事实上,整个数学分支,包括泛函分析和巴拿赫空间理论的部分,都致力于解决这类问题。

一个简单的解释可能是通过其权重向量的某个范数来衡量线性函数 \(f(\mathbf{x}) = \mathbf{w}^\top \mathbf{x}\) 的复杂性,例如 \(\| \mathbf{w} \|^2\)。回想一下,我们在 第 2.3.11 节 中介绍了 \(\ell_2\) 范数和 \(\ell_1\) 范数,它们是更一般的 \(\ell_p\) 范数的特例。确保权重向量较小的最常用方法是将其范数作为惩罚项添加到最小化损失的问题中。因此,我们将原来的目标,*最小化训练标签上的预测损失*,替换为新的目标,*最小化预测损失和惩罚项之和*。现在,如果我们的权重向量增长得太大,我们的学习算法可能会专注于最小化权重范数 \(\| \mathbf{w} \|^2\) 而不是最小化训练误差。这正是我们想要的。为了在代码中说明这一点,我们回顾一下 第 3.1 节 中线性回归的例子。在那里,我们的损失由下式给出:

(3.7.1)\[L(\mathbf{w}, b) = \frac{1}{n}\sum_{i=1}^n \frac{1}{2}\left(\mathbf{w}^\top \mathbf{x}^{(i)} + b - y^{(i)}\right)^2.\]

回想一下,\(\mathbf{x}^{(i)}\) 是特征,\(y^{(i)}\) 是任何数据样本 \(i\) 的标签,\((\mathbf{w}, b)\) 分别是权重和偏置参数。为了惩罚权重向量的大小,我们必须以某种方式将 \(\| \mathbf{w} \|^2\) 添加到损失函数中,但模型应该如何权衡标准损失与这个新的加性惩罚呢?实际上,我们通过*正则化常数* \(\lambda\) 来描述这种权衡,这是一个非负的超参数,我们使用验证数据来拟合它:

(3.7.2)\[L(\mathbf{w}, b) + \frac{\lambda}{2} \|\mathbf{w}\|^2.\]

对于 \(\lambda = 0\),我们恢复了原始的损失函数。对于 \(\lambda > 0\),我们限制 \(\| \mathbf{w} \|\) 的大小。我们按照惯例除以 \(2\):当我们对一个二次函数求导时,\(2\)\(1/2\) 会相互抵消,确保更新的表达式看起来既漂亮又简单。敏锐的读者可能会想,为什么我们使用平方范数而不是标准范数(即欧几里得距离)。我们这样做是为了计算方便。通过对 \(\ell_2\) 范数进行平方,我们去掉了平方根,留下了权重向量每个分量平方的和。这使得惩罚项的导数很容易计算:导数的和等于和的导数。

此外,你可能会问为什么我们首先使用 \(\ell_2\) 范数而不是,比如说,\(\ell_1\) 范数。事实上,其他选择在统计学中也是有效和流行的。虽然 \(\ell_2\)-正则化的线性模型构成了经典的*岭回归*算法,但 \(\ell_1\)-正则化的线性回归在统计学中也是一个类似的基础方法,通常被称为*Lasso回归*。使用 \(\ell_2\) 范数的一个原因是它对权重向量的大分量施加了过大的惩罚。这使我们的学习算法偏向于将权重均匀分布在更多特征上的模型。在实践中,这可能会使它们对单个变量的测量误差更具鲁棒性。相比之下,\(\ell_1\) 惩罚会导致模型将权重集中在一小组特征上,将其他权重清零。这为我们提供了一种有效的*特征选择*方法,这可能出于其他原因而可取。例如,如果我们的模型只依赖于少数几个特征,那么我们可能不需要为其他(被丢弃的)特征收集、存储或传输数据。

使用 (3.1.11) 中的相同符号,\(\ell_2\)-正则化回归的小批量随机梯度下降更新如下:

(3.7.3)\[\begin{aligned} \mathbf{w} & \leftarrow \left(1- \eta\lambda \right) \mathbf{w} - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \mathbf{x}^{(i)} \left(\mathbf{w}^\top \mathbf{x}^{(i)} + b - y^{(i)}\right). \end{aligned}\]

和以前一样,我们根据我们的估计与观测值的差异量来更新 \(\mathbf{w}\)。然而,我们还把 \(\mathbf{w}\) 的大小向零收缩。这就是为什么这种方法有时被称为“权重衰减”:仅考虑惩罚项,我们的优化算法在训练的每一步都会*衰减*权重。与特征选择相比,权重衰减为我们提供了一种连续调整函数复杂性的机制。较小的 \(\lambda\) 值对应于约束较少的 \(\mathbf{w}\),而较大的 \(\lambda\) 值则对 \(\mathbf{w}\) 施加了更强的约束。是否包含相应的偏置惩罚 \(b^2\) 在不同实现中可能有所不同,并且在神经网络的不同层之间也可能不同。通常,我们不对偏置项进行正则化。此外,尽管对于其他优化算法,\(\ell_2\) 正则化可能不等同于权重衰减,但通过缩小权重大小进行正则化的思想仍然成立。

3.7.2. 高维线性回归

我们可以通过一个简单的合成例子来说明权重衰减的好处。

首先,我们像之前一样生成一些数据:

(3.7.4)\[y = 0.05 + \sum_{i = 1}^d 0.01 x_i + \epsilon \textrm{ 其中 } \epsilon \sim \mathcal{N}(0, 0.01^2).\]

在这个合成数据集中,我们的标签由输入的潜在线性函数给出,并受到均值为零、标准差为0.01的高斯噪声的干扰。为了说明问题,我们可以通过将问题的维度增加到 \(d = 200\) 并使用一个只有20个样本的小型训练集,来使过拟合的影响变得明显。

class Data(d2l.DataModule):
    def __init__(self, num_train, num_val, num_inputs, batch_size):
        self.save_hyperparameters()
        n = num_train + num_val
        self.X = torch.randn(n, num_inputs)
        noise = torch.randn(n, 1) * 0.01
        w, b = torch.ones((num_inputs, 1)) * 0.01, 0.05
        self.y = torch.matmul(self.X, w) + b + noise

    def get_dataloader(self, train):
        i = slice(0, self.num_train) if train else slice(self.num_train, None)
        return self.get_tensorloader([self.X, self.y], train, i)
class Data(d2l.DataModule):
    def __init__(self, num_train, num_val, num_inputs, batch_size):
        self.save_hyperparameters()
        n = num_train + num_val
        self.X = np.random.randn(n, num_inputs)
        noise = np.random.randn(n, 1) * 0.01
        w, b = np.ones((num_inputs, 1)) * 0.01, 0.05
        self.y = np.dot(self.X, w) + b + noise

    def get_dataloader(self, train):
        i = slice(0, self.num_train) if train else slice(self.num_train, None)
        return self.get_tensorloader([self.X, self.y], train, i)
class Data(d2l.DataModule):
    def __init__(self, num_train, num_val, num_inputs, batch_size):
        self.save_hyperparameters()
        n = num_train + num_val
        self.X = jax.random.normal(jax.random.PRNGKey(0), (n, num_inputs))
        noise = jax.random.normal(jax.random.PRNGKey(0), (n, 1)) * 0.01
        w, b = jnp.ones((num_inputs, 1)) * 0.01, 0.05
        self.y = jnp.matmul(self.X, w) + b + noise

    def get_dataloader(self, train):
        i = slice(0, self.num_train) if train else slice(self.num_train, None)
        return self.get_tensorloader([self.X, self.y], train, i)
class Data(d2l.DataModule):
    def __init__(self, num_train, num_val, num_inputs, batch_size):
        self.save_hyperparameters()
        n = num_train + num_val
        self.X = tf.random.normal((n, num_inputs))
        noise = tf.random.normal((n, 1)) * 0.01
        w, b = tf.ones((num_inputs, 1)) * 0.01, 0.05
        self.y = tf.matmul(self.X, w) + b + noise

    def get_dataloader(self, train):
        i = slice(0, self.num_train) if train else slice(self.num_train, None)
        return self.get_tensorloader([self.X, self.y], train, i)

3.7.3. 从零开始实现

现在,让我们尝试从头开始实现权重衰减。由于小批量随机梯度下降是我们的优化器,我们只需要将平方 \(\ell_2\) 惩罚项添加到原始损失函数中。

3.7.3.1. 定义 \(\ell_2\) 范数惩罚

实现这个惩罚最方便的方式可能是将所有项原地平方然后求和。

def l2_penalty(w):
    return (w ** 2).sum() / 2
def l2_penalty(w):
    return (w ** 2).sum() / 2
def l2_penalty(w):
    return (w ** 2).sum() / 2
def l2_penalty(w):
    return tf.reduce_sum(w**2) / 2

3.7.3.2. 定义模型

在最终模型中,线性回归和平方损失自 第 3.4 节 以来没有改变,所以我们只定义 d2l.LinearRegressionScratch 的一个子类。这里唯一的改变是我们的损失现在包含了惩罚项。

class WeightDecayScratch(d2l.LinearRegressionScratch):
    def __init__(self, num_inputs, lambd, lr, sigma=0.01):
        super().__init__(num_inputs, lr, sigma)
        self.save_hyperparameters()

    def loss(self, y_hat, y):
        return (super().loss(y_hat, y) +
                self.lambd * l2_penalty(self.w))
class WeightDecayScratch(d2l.LinearRegressionScratch):
    def __init__(self, num_inputs, lambd, lr, sigma=0.01):
        super().__init__(num_inputs, lr, sigma)
        self.save_hyperparameters()

    def loss(self, y_hat, y):
        return (super().loss(y_hat, y) +
                self.lambd * l2_penalty(self.w))
class WeightDecayScratch(d2l.LinearRegressionScratch):
    lambd: int = 0

    def loss(self, params, X, y, state):
        return (super().loss(params, X, y, state) +
                self.lambd * l2_penalty(params['w']))
class WeightDecayScratch(d2l.LinearRegressionScratch):
    def __init__(self, num_inputs, lambd, lr, sigma=0.01):
        super().__init__(num_inputs, lr, sigma)
        self.save_hyperparameters()

    def loss(self, y_hat, y):
        return (super().loss(y_hat, y) +
                self.lambd * l2_penalty(self.w))

下面的代码在有20个样本的训练集上拟合我们的模型,并在有100个样本的验证集上进行评估。

data = Data(num_train=20, num_val=100, num_inputs=200, batch_size=5)
trainer = d2l.Trainer(max_epochs=10)

def train_scratch(lambd):
    model = WeightDecayScratch(num_inputs=200, lambd=lambd, lr=0.01)
    model.board.yscale='log'
    trainer.fit(model, data)
    print('L2 norm of w:', float(l2_penalty(model.w)))
data = Data(num_train=20, num_val=100, num_inputs=200, batch_size=5)
trainer = d2l.Trainer(max_epochs=10)

def train_scratch(lambd):
    model = WeightDecayScratch(num_inputs=200, lambd=lambd, lr=0.01)
    model.board.yscale='log'
    trainer.fit(model, data)
    print('L2 norm of w:', float(l2_penalty(model.w)))
[22:08:21] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
data = Data(num_train=20, num_val=100, num_inputs=200, batch_size=5)
trainer = d2l.Trainer(max_epochs=10)

def train_scratch(lambd):
    model = WeightDecayScratch(num_inputs=200, lambd=lambd, lr=0.01)
    model.board.yscale='log'
    trainer.fit(model, data)
    print('L2 norm of w:',
          float(l2_penalty(trainer.state.params['w'])))
data = Data(num_train=20, num_val=100, num_inputs=200, batch_size=5)
trainer = d2l.Trainer(max_epochs=10)

def train_scratch(lambd):
    model = WeightDecayScratch(num_inputs=200, lambd=lambd, lr=0.01)
    model.board.yscale='log'
    trainer.fit(model, data)
    print('L2 norm of w:', float(l2_penalty(model.w)))

3.7.3.3. 不使用正则化进行训练

我们现在用 lambd = 0 运行此代码,禁用权重衰减。注意,我们严重过拟合,训练误差下降但验证误差没有下降——这是一个典型的过拟合案例。

train_scratch(0)
L2 norm of w: 0.009948714636266232
../_images/output_weight-decay_63fc98_78_1.svg
train_scratch(0)
L2 norm of w: 0.009325511753559113
../_images/output_weight-decay_63fc98_81_1.svg
train_scratch(0)
L2 norm of w: 0.010942053981125355
../_images/output_weight-decay_63fc98_84_1.svg
train_scratch(0)
L2 norm of w: 0.010886103846132755
../_images/output_weight-decay_63fc98_87_1.svg

3.7.3.4. 使用权重衰减

下面,我们使用显著的权重衰减来运行。注意,训练误差增加了,但验证误差减少了。这正是我们期望从正则化中看到的效果。

train_scratch(3)
L2 norm of w: 0.0017270983662456274
../_images/output_weight-decay_63fc98_93_1.svg
train_scratch(3)
L2 norm of w: 0.0012076478451490402
../_images/output_weight-decay_63fc98_96_1.svg
train_scratch(3)
L2 norm of w: 0.0014895361382514238
../_images/output_weight-decay_63fc98_99_1.svg
train_scratch(3)
L2 norm of w: 0.0017435649642720819
../_images/output_weight-decay_63fc98_102_1.svg

3.7.4. 简洁实现

由于权重衰减在神经网络优化中无处不在,深度学习框架使其特别方便,将权重衰减整合到优化算法本身中,以便与任何损失函数轻松结合使用。此外,这种整合还带来了计算上的好处,允许通过实现技巧将权重衰减添加到算法中,而无需任何额外的计算开销。由于更新的权重衰减部分仅取决于每个参数的当前值,因此优化器无论如何都必须接触每个参数一次。

下面,我们在实例化优化器时通过 weight_decay 直接指定权重衰减超参数。默认情况下,PyTorch同时对权重和偏置进行衰减,但我们可以配置优化器以根据不同策略处理不同参数。这里,我们只为权重(net.weight 参数)设置 weight_decay,因此偏置(net.bias 参数)不会衰减。

class WeightDecay(d2l.LinearRegression):
    def __init__(self, wd, lr):
        super().__init__(lr)
        self.save_hyperparameters()
        self.wd = wd

    def configure_optimizers(self):
        return torch.optim.SGD([
            {'params': self.net.weight, 'weight_decay': self.wd},
            {'params': self.net.bias}], lr=self.lr)

下面,我们在实例化我们的 Trainer 时通过 wd 直接指定权重衰减超参数。默认情况下,Gluon 同时衰减权重和偏置。注意,在更新模型参数时,超参数 wd 将乘以 wd_mult。因此,如果我们将 wd_mult 设置为零,偏置参数 \(b\) 将不会衰减。

class WeightDecay(d2l.LinearRegression):
    def __init__(self, wd, lr):
        super().__init__(lr)
        self.save_hyperparameters()
        self.wd = wd

    def configure_optimizers(self):
        self.collect_params('.*bias').setattr('wd_mult', 0)
        return gluon.Trainer(self.collect_params(),
                             'sgd',
                             {'learning_rate': self.lr, 'wd': self.wd})
class WeightDecay(d2l.LinearRegression):
    wd: int = 0

    def configure_optimizers(self):
        # Weight Decay is not available directly within optax.sgd, but
        # optax allows chaining several transformations together
        return optax.chain(optax.additive_weight_decay(self.wd),
                           optax.sgd(self.lr))

下面,我们创建一个带有权重衰减超参数 wd\(\ell_2\) 正则化器,并通过 kernel_regularizer 参数将其应用于层的权重。

class WeightDecay(d2l.LinearRegression):
    def __init__(self, wd, lr):
        super().__init__(lr)
        self.save_hyperparameters()
        self.net = tf.keras.layers.Dense(
            1, kernel_regularizer=tf.keras.regularizers.l2(wd),
            kernel_initializer=tf.keras.initializers.RandomNormal(0, 0.01)
        )

    def loss(self, y_hat, y):
        return super().loss(y_hat, y) + self.net.losses

该图看起来与我们从头开始实现权重衰减时的图相似。然而,这个版本运行得更快,也更容易实现,这些好处在你处理更大的问题并且这项工作变得更加常规时会变得更加明显。

model = WeightDecay(wd=3, lr=0.01)
model.board.yscale='log'
trainer.fit(model, data)

print('L2 norm of w:', float(l2_penalty(model.get_w_b()[0])))
L2 norm of w: 0.013779522851109505
../_images/output_weight-decay_63fc98_126_1.svg
model = WeightDecay(wd=3, lr=0.01)
model.board.yscale='log'
trainer.fit(model, data)

print('L2 norm of w:', float(l2_penalty(model.get_w_b()[0])))
L2 norm of w: 0.0013100637588649988
../_images/output_weight-decay_63fc98_129_1.svg
model = WeightDecay(wd=3, lr=0.01)
model.board.yscale='log'
trainer.fit(model, data)

print('L2 norm of w:', float(l2_penalty(model.get_w_b(trainer.state)[0])))
L2 norm of w: 0.0014690541429445148
../_images/output_weight-decay_63fc98_132_1.svg
model = WeightDecay(wd=3, lr=0.01)
model.board.yscale='log'
trainer.fit(model, data)

print('L2 norm of w:', float(l2_penalty(model.get_w_b()[0])))
L2 norm of w: 0.0009644521633163095
../_images/output_weight-decay_63fc98_135_1.svg

到目前为止,我们已经触及了一个构成简单线性函数的概念。然而,即使对于简单的非线性函数,情况也可能复杂得多。要理解这一点,再生核希尔伯特空间 (RKHS) 的概念允许人们将为线性函数引入的工具应用于非线性上下文中。不幸的是,基于 RKHS 的算法往往难以扩展到大型、高维的数据。在本书中,我们将经常采用一种常见的启发式方法,即将权重衰减应用于深度网络的所有层。

3.7.5. 小结

正则化是处理过拟合的常用方法。经典的正则化技术是在损失函数(训练时)中添加一个惩罚项,以降低学习模型的复杂性。保持模型简单的一个特殊选择是使用 \(\ell_2\) 惩罚。这导致在小批量随机梯度下降算法的更新步骤中出现权重衰减。在实践中,权重衰减功能由深度学习框架的优化器提供。在同一个训练循环中,不同的参数集可以有不同的更新行为。

3.7.6. 练习

  1. 在本节的估计问题中,实验 \(\lambda\) 的值。绘制训练和验证准确率作为 \(\lambda\) 的函数。你观察到了什么?

  2. 使用验证集找到 \(\lambda\) 的最优值。它真的是最优值吗?这重要吗?

  3. 如果我们用 \(\sum_i |w_i|\)\(\ell_1\) 正则化)作为我们的惩罚选择,而不是 \(\|\mathbf{w}\|^2\),那么更新方程会是什么样子?

  4. 我们知道 \(\|\mathbf{w}\|^2 = \mathbf{w}^\top \mathbf{w}\)。你能为矩阵找到一个类似的方程吗(参见 第 2.3.11 节 中的 Frobenius 范数)?

  5. 回顾训练误差和泛化误差之间的关系。除了权重衰减、增加训练和使用适当复杂度的模型之外,还有哪些方法可以帮助我们处理过拟合?

  6. 在贝叶斯统计中,我们使用先验和似然的乘积通过 \(P(w \mid x) \propto P(x \mid w) P(w)\) 得到后验。你如何将 \(P(w)\) 与正则化联系起来?