5.6. 暂退法(Dropout)
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

让我们简单地想一想,我们对一个好的预测模型有什么要求。我们希望它在未见过的数据上表现良好。经典的泛化理论认为,为了缩小训练和测试性能之间的差距,我们应该以简单的模型为目标。简单性可能以维度数量较小的形式出现。我们在 第 3.6 节 中讨论线性模型的单项式基函数时探讨了这一点。此外,正如我们在讨论权重衰减(\(\ell_2\) 正则化)时在 第 3.7 节 中看到的那样,参数的(逆)范数也代表了一种有用的简单性度量。另一个有用的简单性概念是平滑性,即函数不应该对其输入的微小变化敏感。例如,当我们对图像进行分类时,我们期望向像素添加一些随机噪声应该基本上是无害的。

Bishop (1995) 在他证明了使用输入噪声进行训练等价于吉洪诺夫正则化时,将这个想法形式化了。这项工作在函数平滑(因此简单)的要求和它对输入扰动具有鲁棒性的要求之间建立了清晰的数学联系。

然后,Srivastava 等人 (2014) 提出了一个巧妙的想法,如何将 Bishop 的思想也应用于网络的内部层。他们的想法,称为*暂退法*(dropout),涉及在正向传播期间计算每个内部层时注入噪声,并且它已成为训练神经网络的标准技术。这种方法之所以被称为*暂退法*,是因为我们在训练过程中字面上*丢弃*了一些神经元。在整个训练过程中,在每次迭代中,标准的暂退法包括在计算后续层之前,将每一层中的一部分节点置零。

需要明确的是,我们将此与 Bishop 的工作联系起来,是我们自己的叙述方式。关于暂退法的原始论文通过一个令人惊讶的与有性繁殖的类比来提供直觉。作者认为,神经网络过拟合的特征是每一层都依赖于前一层激活的特定模式,他们称这种情况为*协同适应*(co-adaptation)。他们声称,暂退法破坏了协同适应,就像有性繁殖被认为可以破坏协同适应的基因一样。虽然这种理论的理由肯定有待商榷,但暂退法技术本身已被证明是持久的,各种形式的暂退法在大多数深度学习库中都有实现。

关键的挑战是如何注入这种噪声。一个想法是以*无偏*的方式注入它,以便每个层的期望值——在固定其他层的情况下——等于它在没有噪声的情况下会取的值。在 Bishop 的工作中,他向线性模型的输入添加了高斯噪声。在每次训练迭代中,他将从均值为零 \(\epsilon \sim \mathcal{N}(0,\sigma^2)\) 的分布中采样的噪声添加到输入 \(\mathbf{x}\) 中,从而得到一个扰动点 \(\mathbf{x}' = \mathbf{x} + \epsilon\)。在期望上,\(E[\mathbf{x}'] = \mathbf{x}\)

在标准的暂退法正则化中,我们将每一层中的一部分节点置零,然后通过除以保留(未被丢弃)的节点比例来对每一层进行*去偏*。换句话说,以*暂退概率* \(p\),每个中间激活 \(h\) 被替换为一个随机变量 \(h'\),如下所示

(5.6.1)\[\begin{split}\begin{aligned} h' = \begin{cases} 0 & \textrm{ 概率为 } p \\ \frac{h}{1-p} & \textrm{ 其他情况} \end{cases} \end{aligned}\end{split}\]

根据设计,期望保持不变,即 \(E[h'] = h\)

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

npx.set_np()
from functools import partial
import jax
import optax
from flax import linen as nn
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.)
import tensorflow as tf
from d2l import tensorflow as d2l

5.6.1. 实践中的暂退法

回想一下 图 5.1.1 中具有一个隐藏层和五个隐藏单元的多层感知机。当我们将暂退法应用于一个隐藏层,以概率 \(p\) 将每个隐藏单元置零时,其结果可以被看作是一个只包含原始神经元子集的网络。在 图 5.6.1 中,\(h_2\)\(h_5\) 被移除了。因此,输出的计算不再依赖于 \(h_2\)\(h_5\),它们各自的梯度在进行反向传播时也消失了。这样,输出层的计算就不能过度依赖于 \(h_1, \ldots, h_5\) 中的任何一个元素。

../_images/dropout2.svg

图 5.6.1 暂退法前后的多层感知机。

通常,我们在测试时禁用暂退法。给定一个训练好的模型和一个新样本,我们不会丢弃任何节点,因此也不需要进行归一化。然而,也有一些例外:一些研究人员在测试时使用暂退法作为一种启发式方法来估计神经网络预测的*不确定性*:如果预测在许多不同的暂退法输出中保持一致,那么我们可以说网络更自信。

5.6.2. 从零开始实现

要实现单层的暂退法函数,我们必须从伯努利(二元)随机变量中抽取与我们层维度一样多的样本,其中随机变量取值 \(1\)(保留)的概率为 \(1-p\),取值 \(0\)(丢弃)的概率为 \(p\)。实现这一点的一个简单方法是首先从均匀分布 \(U[0, 1]\) 中抽取样本。然后我们可以保留那些对应样本大于 \(p\) 的节点,丢弃其余的。

在下面的代码中,我们实现了一个 dropout_layer 函数,它以概率 dropout 丢弃张量输入 X 中的元素,并如上所述重新缩放余下的部分:将幸存者除以 1.0-dropout

def dropout_layer(X, dropout):
    assert 0 <= dropout <= 1
    if dropout == 1: return torch.zeros_like(X)
    mask = (torch.rand(X.shape) > dropout).float()
    return mask * X / (1.0 - dropout)
def dropout_layer(X, dropout):
    assert 0 <= dropout <= 1
    if dropout == 1: return np.zeros_like(X)
    mask = np.random.uniform(0, 1, X.shape) > dropout
    return mask.astype(np.float32) * X / (1.0 - dropout)
def dropout_layer(X, dropout, key=d2l.get_key()):
    assert 0 <= dropout <= 1
    if dropout == 1: return jnp.zeros_like(X)
    mask = jax.random.uniform(key, X.shape) > dropout
    return jnp.asarray(mask, dtype=jnp.float32) * X / (1.0 - dropout)
def dropout_layer(X, dropout):
    assert 0 <= dropout <= 1
    if dropout == 1: return tf.zeros_like(X)
    mask = tf.random.uniform(
        shape=tf.shape(X), minval=0, maxval=1) < 1 - dropout
    return tf.cast(mask, dtype=tf.float32) * X / (1.0 - dropout)

我们可以用几个例子来测试 dropout_layer 函数。在下面的代码行中,我们分别以概率 0、0.5 和 1 将我们的输入 X 通过暂退法操作。

X = torch.arange(16, dtype = torch.float32).reshape((2, 8))
print('dropout_p = 0:', dropout_layer(X, 0))
print('dropout_p = 0.5:', dropout_layer(X, 0.5))
print('dropout_p = 1:', dropout_layer(X, 1))
dropout_p = 0: tensor([[ 0.,  1.,  2.,  3.,  4.,  5.,  6.,  7.],
        [ 8.,  9., 10., 11., 12., 13., 14., 15.]])
dropout_p = 0.5: tensor([[ 0.,  2.,  0.,  6.,  8.,  0.,  0.,  0.],
        [16., 18., 20., 22., 24., 26., 28., 30.]])
dropout_p = 1: tensor([[0., 0., 0., 0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0., 0., 0., 0.]])
X = np.arange(16).reshape(2, 8)
print('dropout_p = 0:', dropout_layer(X, 0))
print('dropout_p = 0.5:', dropout_layer(X, 0.5))
print('dropout_p = 1:', dropout_layer(X, 1))
dropout_p = 0: [[ 0.  1.  2.  3.  4.  5.  6.  7.]
 [ 8.  9. 10. 11. 12. 13. 14. 15.]]
dropout_p = 0.5: [[ 0.  0.  0.  0.  8. 10. 12.  0.]
 [16.  0. 20. 22.  0.  0.  0. 30.]]
dropout_p = 1: [[0. 0. 0. 0. 0. 0. 0. 0.]
 [0. 0. 0. 0. 0. 0. 0. 0.]]
[21:50:21] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
X = jnp.arange(16, dtype=jnp.float32).reshape(2, 8)
print('dropout_p = 0:', dropout_layer(X, 0))
print('dropout_p = 0.5:', dropout_layer(X, 0.5))
print('dropout_p = 1:', dropout_layer(X, 1))
dropout_p = 0: [[ 0.  1.  2.  3.  4.  5.  6.  7.]
 [ 8.  9. 10. 11. 12. 13. 14. 15.]]
dropout_p = 0.5: [[ 0.  0.  0.  6.  0.  0. 12.  0.]
 [ 0. 18. 20. 22.  0.  0. 28.  0.]]
dropout_p = 1: [[0. 0. 0. 0. 0. 0. 0. 0.]
 [0. 0. 0. 0. 0. 0. 0. 0.]]
X = tf.reshape(tf.range(16, dtype=tf.float32), (2, 8))
print('dropout_p = 0:', dropout_layer(X, 0))
print('dropout_p = 0.5:', dropout_layer(X, 0.5))
print('dropout_p = 1:', dropout_layer(X, 1))
dropout_p = 0: tf.Tensor(
[[ 0.  1.  2.  3.  4.  5.  6.  7.]
 [ 8.  9. 10. 11. 12. 13. 14. 15.]], shape=(2, 8), dtype=float32)
dropout_p = 0.5: tf.Tensor(
[[ 0.  0.  0.  6.  0.  0.  0. 14.]
 [ 0. 18. 20. 22.  0.  0. 28. 30.]], shape=(2, 8), dtype=float32)
dropout_p = 1: tf.Tensor(
[[0. 0. 0. 0. 0. 0. 0. 0.]
 [0. 0. 0. 0. 0. 0. 0. 0.]], shape=(2, 8), dtype=float32)

5.6.2.1. 定义模型

下面的模型将暂退法应用于每个隐藏层的输出(在激活函数之后)。我们可以为每个层分别设置暂退法概率。一个常见的选择是在靠近输入层的层设置较低的暂退法概率。我们确保暂退法只在训练期间激活。

class DropoutMLPScratch(d2l.Classifier):
    def __init__(self, num_outputs, num_hiddens_1, num_hiddens_2,
                 dropout_1, dropout_2, lr):
        super().__init__()
        self.save_hyperparameters()
        self.lin1 = nn.LazyLinear(num_hiddens_1)
        self.lin2 = nn.LazyLinear(num_hiddens_2)
        self.lin3 = nn.LazyLinear(num_outputs)
        self.relu = nn.ReLU()

    def forward(self, X):
        H1 = self.relu(self.lin1(X.reshape((X.shape[0], -1))))
        if self.training:
            H1 = dropout_layer(H1, self.dropout_1)
        H2 = self.relu(self.lin2(H1))
        if self.training:
            H2 = dropout_layer(H2, self.dropout_2)
        return self.lin3(H2)
class DropoutMLPScratch(d2l.Classifier):
    def __init__(self, num_outputs, num_hiddens_1, num_hiddens_2,
                 dropout_1, dropout_2, lr):
        super().__init__()
        self.save_hyperparameters()
        self.lin1 = nn.Dense(num_hiddens_1, activation='relu')
        self.lin2 = nn.Dense(num_hiddens_2, activation='relu')
        self.lin3 = nn.Dense(num_outputs)
        self.initialize()

    def forward(self, X):
        H1 = self.lin1(X)
        if autograd.is_training():
            H1 = dropout_layer(H1, self.dropout_1)
        H2 = self.lin2(H1)
        if autograd.is_training():
            H2 = dropout_layer(H2, self.dropout_2)
        return self.lin3(H2)
class DropoutMLPScratch(d2l.Classifier):
    num_hiddens_1: int
    num_hiddens_2: int
    num_outputs: int
    dropout_1: float
    dropout_2: float
    lr: float
    training: bool = True

    def setup(self):
        self.lin1 = nn.Dense(self.num_hiddens_1)
        self.lin2 = nn.Dense(self.num_hiddens_2)
        self.lin3 = nn.Dense(self.num_outputs)
        self.relu = nn.relu

    def forward(self, X):
        H1 = self.relu(self.lin1(X.reshape(X.shape[0], -1)))
        if self.training:
            H1 = dropout_layer(H1, self.dropout_1)
        H2 = self.relu(self.lin2(H1))
        if self.training:
            H2 = dropout_layer(H2, self.dropout_2)
        return self.lin3(H2)
class DropoutMLPScratch(d2l.Classifier):
    def __init__(self, num_outputs, num_hiddens_1, num_hiddens_2,
                 dropout_1, dropout_2, lr):
        super().__init__()
        self.save_hyperparameters()
        self.lin1 = tf.keras.layers.Dense(num_hiddens_1, activation='relu')
        self.lin2 = tf.keras.layers.Dense(num_hiddens_2, activation='relu')
        self.lin3 = tf.keras.layers.Dense(num_outputs)

    def forward(self, X):
        H1 = self.lin1(tf.reshape(X, (X.shape[0], -1)))
        if self.training:
            H1 = dropout_layer(H1, self.dropout_1)
        H2 = self.lin2(H1)
        if self.training:
            H2 = dropout_layer(H2, self.dropout_2)
        return self.lin3(H2)

5.6.2.2. 训练

这与之前描述的多层感知机训练类似。

hparams = {'num_outputs':10, 'num_hiddens_1':256, 'num_hiddens_2':256,
           'dropout_1':0.5, 'dropout_2':0.5, 'lr':0.1}
model = DropoutMLPScratch(**hparams)
data = d2l.FashionMNIST(batch_size=256)
trainer = d2l.Trainer(max_epochs=10)
trainer.fit(model, data)
../_images/output_dropout_1110bf_63_0.svg
hparams = {'num_outputs':10, 'num_hiddens_1':256, 'num_hiddens_2':256,
           'dropout_1':0.5, 'dropout_2':0.5, 'lr':0.1}
model = DropoutMLPScratch(**hparams)
data = d2l.FashionMNIST(batch_size=256)
trainer = d2l.Trainer(max_epochs=10)
trainer.fit(model, data)
../_images/output_dropout_1110bf_66_0.svg
hparams = {'num_outputs':10, 'num_hiddens_1':256, 'num_hiddens_2':256,
           'dropout_1':0.5, 'dropout_2':0.5, 'lr':0.1}
model = DropoutMLPScratch(**hparams)
data = d2l.FashionMNIST(batch_size=256)
trainer = d2l.Trainer(max_epochs=10)
trainer.fit(model, data)
../_images/output_dropout_1110bf_69_0.svg
hparams = {'num_outputs':10, 'num_hiddens_1':256, 'num_hiddens_2':256,
           'dropout_1':0.5, 'dropout_2':0.5, 'lr':0.1}
model = DropoutMLPScratch(**hparams)
data = d2l.FashionMNIST(batch_size=256)
trainer = d2l.Trainer(max_epochs=10)
trainer.fit(model, data)
../_images/output_dropout_1110bf_72_0.svg

5.6.3. 简洁实现

使用高级API,我们所需要做的就是在每个全连接层之后添加一个 Dropout 层,将暂退法概率作为其构造函数的唯一参数传入。在训练期间,Dropout 层将根据指定的暂退法概率随机丢弃前一层的输出(或者等价地,后一层的输入)。当不处于训练模式时,Dropout 层在测试时只是将数据通过。

class DropoutMLP(d2l.Classifier):
    def __init__(self, num_outputs, num_hiddens_1, num_hiddens_2,
                 dropout_1, dropout_2, lr):
        super().__init__()
        self.save_hyperparameters()
        self.net = nn.Sequential(
            nn.Flatten(), nn.LazyLinear(num_hiddens_1), nn.ReLU(),
            nn.Dropout(dropout_1), nn.LazyLinear(num_hiddens_2), nn.ReLU(),
            nn.Dropout(dropout_2), nn.LazyLinear(num_outputs))
class DropoutMLP(d2l.Classifier):
    def __init__(self, num_outputs, num_hiddens_1, num_hiddens_2,
                 dropout_1, dropout_2, lr):
        super().__init__()
        self.save_hyperparameters()
        self.net = nn.Sequential()
        self.net.add(nn.Dense(num_hiddens_1, activation="relu"),
                     nn.Dropout(dropout_1),
                     nn.Dense(num_hiddens_2, activation="relu"),
                     nn.Dropout(dropout_2),
                     nn.Dense(num_outputs))
        self.net.initialize()
class DropoutMLP(d2l.Classifier):
    num_hiddens_1: int
    num_hiddens_2: int
    num_outputs: int
    dropout_1: float
    dropout_2: float
    lr: float
    training: bool = True

    @nn.compact
    def __call__(self, X):
        x = nn.relu(nn.Dense(self.num_hiddens_1)(X.reshape((X.shape[0], -1))))
        x = nn.Dropout(self.dropout_1, deterministic=not self.training)(x)
        x = nn.relu(nn.Dense(self.num_hiddens_2)(x))
        x = nn.Dropout(self.dropout_2, deterministic=not self.training)(x)
        return nn.Dense(self.num_outputs)(x)

请注意,我们需要重新定义损失函数,因为使用 Module.apply() 时,带有暂退法层的网络需要一个 PRNGKey,并且这个 RNG 种子应该明确命名为 dropout。Flax 中的 dropout 层使用这个键在内部生成随机暂退法掩码。在训练循环中,每个轮次都使用唯一的 dropout_rng 键是很重要的,否则生成的暂退法掩码将不是随机的,并且在不同轮次之间会相同。这个 dropout_rng 可以作为属性存储在 TrainState 对象中(在 第 3.2.4 节 中定义的 d2l.Trainer 类中),并且每个轮次都会用一个新的 dropout_rng 替换它。我们已经在 第 3.4 节 中定义的 fit_epoch 方法中处理了这一点。

@d2l.add_to_class(d2l.Classifier)  #@save
@partial(jax.jit, static_argnums=(0, 5))
def loss(self, params, X, Y, state, averaged=True):
    Y_hat = state.apply_fn({'params': params}, *X,
                           mutable=False,  # To be used later (e.g., batch norm)
                           rngs={'dropout': state.dropout_rng})
    Y_hat = Y_hat.reshape((-1, Y_hat.shape[-1]))
    Y = Y.reshape((-1,))
    fn = optax.softmax_cross_entropy_with_integer_labels
    # The returned empty dictionary is a placeholder for auxiliary data,
    # which will be used later (e.g., for batch norm)
    return (fn(Y_hat, Y).mean(), {}) if averaged else (fn(Y_hat, Y), {})
class DropoutMLP(d2l.Classifier):
    def __init__(self, num_outputs, num_hiddens_1, num_hiddens_2,
                 dropout_1, dropout_2, lr):
        super().__init__()
        self.save_hyperparameters()
        self.net = tf.keras.models.Sequential([
            tf.keras.layers.Flatten(),
            tf.keras.layers.Dense(num_hiddens_1, activation=tf.nn.relu),
            tf.keras.layers.Dropout(dropout_1),
            tf.keras.layers.Dense(num_hiddens_2, activation=tf.nn.relu),
            tf.keras.layers.Dropout(dropout_2),
            tf.keras.layers.Dense(num_outputs)])

接下来,我们训练模型。

model = DropoutMLP(**hparams)
trainer.fit(model, data)
../_images/output_dropout_1110bf_95_0.svg
model = DropoutMLP(**hparams)
trainer.fit(model, data)
../_images/output_dropout_1110bf_98_0.svg
model = DropoutMLP(**hparams)
trainer.fit(model, data)
../_images/output_dropout_1110bf_101_0.svg
model = DropoutMLP(**hparams)
trainer.fit(model, data)
../_images/output_dropout_1110bf_104_0.svg

5.6.4. 总结

除了控制维度数量和权重向量的大小之外,暂退法是另一个避免过拟合的工具。通常这些工具会联合使用。请注意,暂退法仅在训练期间使用:它用一个期望值为 \(h\) 的随机变量替换激活 \(h\)

5.6.5. 练习

  1. 如果更改第一层和第二层的暂退法概率会发生什么?特别是,如果交换两层的概率会怎样?设计一个实验来回答这些问题,定量描述你的结果,并总结定性的结论。

  2. 增加轮次数,并比较使用暂退法和不使用暂退法得到的结果。

  3. 当应用和不应用暂退法时,每个隐藏层中激活的方差是多少?绘制一个图表,显示这个量在两种模型中随时间如何演变。

  4. 为什么暂退法通常不在测试时使用?

  5. 以本节中的模型为例,比较使用暂退法和权重衰减的效果。当同时使用暂退法和权重衰减时会发生什么?结果是累加的吗?是否存在收益递减(或更糟)的情况?它们会相互抵消吗?

  6. 如果我们将暂退法应用于权重矩阵的单个权重而不是激活,会发生什么?

  7. 发明另一种在每一层注入随机噪声的技术,它不同于标准的暂退法技术。你能开发一种在 Fashion-MNIST 数据集上(对于固定的架构)性能优于暂退法的方法吗?