AI

深度学习之暂退法

动手深度学习

Posted by LXG on March 12, 2026

几何理解

在深度学习中,暂退法(Dropout) 是一种非常经典且高效的正则化技术

dropout_fun

绿虚线(无 Dropout):你会发现它在数据点之间频繁地“上下跳跃”。在几何上,这意味着它的曲率(Curvature)非常大。它记住了每一个数据点的扰动,这在处理没见过的数据时表现会很差。

蓝实线(有 Dropout):虽然它没能 100% 穿过每一个点,但它的走势非常稳健、平滑。

  • 几何理解:因为训练时随机丢弃了神经元,模型无法利用“特定路径”去死记硬背。它被迫学习一种“平均特征”
  • 结果:流形表面的褶皱被磨平了,剩下的就是数据中最核心的线性规律。

偏差-方差权衡(Bias-Variance Tradeoff)

线性模型(高偏差,低方差):

  • 偏差(Bias):由于模型太简单(比如只能画直线),它对复杂规律有“成见”,根本学不会。
  • 方差(Variance):因为它简单,不管你换哪一批数据训练,它画出来的直线都差不多,表现很稳定。

神经网络(低偏差,高方差):

  • 偏差(Bias):几乎可以模拟任何函数,只要给它数据,它能学会最精细的细节。
  • 方差(Variance):由于太灵活,它会把特定数据集里的随机噪声也当成规律记下来。换一批数据,它学出来的规律可能完全不同。

为什么深度网络能学到“特征交互”?

线性模型像是在做加法:$y = w_1 x_1 + w_2 x_2 + \dots$。每个特征的贡献是独立的。

神经网络通过多层嵌套和激活函数(如 ReLU),实质上在做乘法或非线性逻辑判断:

  • 第一层:检测是否包含“尼日利亚”。
  • 第二层:检测是否包含“西联汇款”。
  • 第三层(交互):如果(第一层=1 且 第二层=1),则输出“垃圾邮件”。

数学表达

对于输入 $x$ 和丢弃概率 $p$(即代码中的 dropout),输出 $h$ 的计算如下:

\[h = \begin{cases} 0 & \text{以概率 } p \\ \frac{x}{1-p} & \text{以概率 } 1-p \end{cases}\]

为什么需要除以 $(1-p)$?

为了保持输出的期望值(平均值)和原始输入一致。

从零开始实现


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

def dropout_layer(X, dropout):
    """执行 dropout"""
    # dropout 概率必须在 [0, 1] 区间内
    # 例如 dropout=0.2 表示有 20% 的神经元输出会被随机置 0
    assert 0 <= dropout <= 1

    # 边界情况 1:dropout=1
    # 所有神经元都被丢弃,输出全 0(和输入形状一致)
    if dropout == 1:
        return torch.zeros_like(X)

    # 边界情况 2:dropout=0
    # 不做任何丢弃,直接返回原输入
    if dropout == 0:
        return X

    # 生成与输入 X 同形状的随机掩码 mask:
    # - torch.rand(X.shape) 会生成 [0,1) 的均匀随机数
    # - 大于 dropout 的位置保留(值为 1),其余位置丢弃(值为 0)
    # - 转成 float 后,mask 中元素只会是 0.0 或 1.0
    mask = (torch.rand(X.shape) > dropout).float()

    # 先用 mask 把一部分神经元输出置零,再除以 (1-dropout) 做缩放
    # 这叫“反向 dropout(inverted dropout)”:
    # 训练时做缩放,保证输出的期望值与不使用 dropout 时一致,
    # 这样推理阶段就不需要再额外缩放。
    return mask * X / (1.0 - dropout)

X = torch.arange(16, dtype=torch.float32).reshape((2, 8)) # 输入张量,形状为 (2, 8)
print('原始输入:\n', X)
print('丢弃概率为 0.0:\n', dropout_layer(X, 0.0))
print('丢弃概率为 0.5:\n', dropout_layer(X, 0.5))
print('丢弃概率为 1.0:\n', dropout_layer(X, 1.0))

运行结果


原始输入:
 tensor([[ 0.,  1.,  2.,  3.,  4.,  5.,  6.,  7.],
        [ 8.,  9., 10., 11., 12., 13., 14., 15.]])
丢弃概率为 0.0:
 tensor([[ 0.,  1.,  2.,  3.,  4.,  5.,  6.,  7.],
        [ 8.,  9., 10., 11., 12., 13., 14., 15.]])
丢弃概率为 0.5:
 tensor([[ 0.,  2.,  4.,  0.,  0.,  0., 12., 14.],
        [ 0., 18.,  0.,  0., 24., 26., 28.,  0.]])
丢弃概率为 1.0:
 tensor([[0., 0., 0., 0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0., 0., 0., 0.]])

看你运行结果中 dropout = 0.5 的部分:

  • 原始输入中的 1. 变成了 2.(因为 $1 / (1-0.5) = 2$)
  • 原始输入中的 9. 变成了 18.(因为 $9 / 0.5 = 18$)

这正是补偿机制的体现:活下来的神经元“变强了”一倍,以弥补死掉的那一半神经元留下的空位。

期望值不变


# 用多次随机实验验证:反向 dropout 后,输出的期望值(均值)与输入均值接近
def check_expectation(X, dropout, trials=5000):
    input_mean = X.mean().item()
    output_means = []
    for _ in range(trials):
        output_means.append(dropout_layer(X, dropout).mean().item())
    output_mean = sum(output_means) / trials
    diff = abs(output_mean - input_mean)
    print(f"\n[期望值检验] dropout={dropout}")
    print(f"输入均值: {input_mean:.4f}")
    print(f"输出均值的经验期望(试验{trials}次): {output_mean:.4f}")
    print(f"两者绝对差: {diff:.6f}")

check_expectation(X, dropout=0)
check_expectation(X, dropout=0.2)
check_expectation(X, dropout=0.5)
check_expectation(X, dropout=0.8)
check_expectation(X, dropout=1.0)

运行结果


[期望值检验] dropout=0
输入均值: 7.5000
输出均值的经验期望(试验5000次): 7.5000
两者绝对差: 0.000000

[期望值检验] dropout=0.2
输入均值: 7.5000
输出均值的经验期望(试验5000次): 7.5021
两者绝对差: 0.002094

[期望值检验] dropout=0.5
输入均值: 7.5000
输出均值的经验期望(试验5000次): 7.4755
两者绝对差: 0.024550

[期望值检验] dropout=0.8
输入均值: 7.5000
输出均值的经验期望(试验5000次): 7.5113
两者绝对差: 0.011250

[期望值检验] dropout=1.0
输入均值: 7.5000
输出均值的经验期望(试验5000次): 0.0000
两者绝对差: 7.500000

当 dropout = 1.0 时,意味着所有的神经元输出都会被丢弃(置为 0)。

定义模型


# =========================
# 下面开始定义一个多层感知机(MLP)用于分类任务
# =========================
# num_inputs: 输入特征维度(例如 Fashion-MNIST 的 28x28=784)
# num_outputs: 输出类别数(10 类)
# num_hiddens1/num_hiddens2: 两个隐藏层的神经元数量
num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256

# 两层 dropout 概率:
# 第一层隐藏层后丢弃 20%
# 第二层隐藏层后丢弃 50%
dropout1, dropout2 = 0.2, 0.5

class Net(nn.Module):
    # 这是一个“线性层 + ReLU + Dropout + 线性层 + ReLU + Dropout + 输出层”的网络
    def __init__(self, num_inputs, num_outputs, num_hiddens1, num_hiddens2,
                 dropout1, dropout2):
        super(Net, self).__init__()
        # 保存 dropout 概率,forward 时会用到
        self.dropout1 = dropout1
        self.dropout2 = dropout2

        # 第1层:输入维度 -> 第1隐藏层
        self.linear1 = nn.Linear(num_inputs, num_hiddens1)

        # 第2层:第1隐藏层 -> 第2隐藏层
        self.linear2 = nn.Linear(num_hiddens1, num_hiddens2)

        # 输出层:第2隐藏层 -> 类别数
        self.linear3 = nn.Linear(num_hiddens2, num_outputs)

    def forward(self, X):
        # X.reshape(-1, self.linear1.in_features):
        # 把输入展平到 [batch_size, 784] 的二维张量
        H1 = torch.relu(self.linear1(X.reshape(-1, self.linear1.in_features)))

        # 第1个隐藏层后做 dropout
        H1 = dropout_layer(H1, self.dropout1)

        # 第2个隐藏层:线性变换 + ReLU
        H2 = torch.relu(self.linear2(H1))

        # 第2个隐藏层后做 dropout
        H2 = dropout_layer(H2, self.dropout2)

        # 输出层给出每个类别的打分(logits)
        out = self.linear3(H2)
        return out
    
# 实例化网络对象
net = Net(num_inputs, num_outputs, num_hiddens1, num_hiddens2, dropout1, dropout2)
print(net)

模型结构


输入层
784
   │
   ▼
Linear(784 → 256)
   │
   ▼
ReLU
   │
   ▼
Dropout(0.2)
   │
   ▼
Linear(256 → 256)
   │
   ▼
ReLU
   │
   ▼
Dropout(0.5)
   │
   ▼
Linear(256 → 10)
   │
   ▼
logits

参数
784→256 784×256 + 256 = 200960
256→256 256×256 + 256 = 65792
256→10 256×10 + 10 = 2570

总参数:≈ 269k

更接近论文/教材风格的 MLP 神经网络结构图


输入层 (784)

 ●   ●   ●   ●   ●        ⋯        ●
 x1  x2  x3  x4  x5                x784
  \   |   |   |   |               /
   \  |   |   |   |              /
    \ |   |   |   |             /
     \|   |   |   |            /
      \   |   |   |           /
       \  |   |   |          /
        \ |   |   |         /
         \|   |   |        /
          \   |   |       /
           \  |   |      /
            \ |   |     /
             \|   |    /
              \   |   /
               \  |  /
                \ | /
                 \|/

          第一隐藏层 (256)

      ●   ●   ●   ●        ⋯        ●
      h1  h2  h3  h4                h256

             ReLU
              │
              │
        Dropout (p=0.2)
              │
              │

          第二隐藏层 (256)

      ●   ●   ●   ●        ⋯        ●
      g1  g2  g3  g4                g256

             ReLU
              │
              │
        Dropout (p=0.5)
              │
              │

            输出层 (10)

      ●   ●   ●   ●   ●   ⋯   ●
      o1  o2  o3  o4  o5      o10

         (logits)

训练和测试


def train_ch3_compat(net, train_iter, test_iter, loss_fn, num_epochs, optimizer):
    """兼容版 train_ch3:完成训练并返回过程指标。"""
    # 用于记录每个 epoch 的指标,后续可用于画图
    train_losses, train_accs, test_accs = [], [], []

    # 逐轮训练
    for epoch in range(num_epochs):
        # 切换到训练模式(会启用 dropout 等训练行为)
        net.train()
        # 统计一个 epoch 内的损失总和、预测正确数、样本总数
        loss_sum, acc_sum, n = 0.0, 0.0, 0

        # 遍历一个 epoch 中的所有小批量
        for X, y in train_iter:
            # 清空上一步的梯度,避免梯度累计
            optimizer.zero_grad()

            # 前向计算预测值
            y_hat = net(X)

            # 计算每个样本的损失(因为 loss_fn 使用了 reduction='none')
            l = loss_fn(y_hat, y)

            # 反向传播:这里用 mean() 聚合后再求梯度,训练更稳定
            l.mean().backward()

            # 参数更新
            optimizer.step()

            # 累计该批次的总损失
            loss_sum += float(l.sum())

            # 累计该批次预测正确的样本数
            acc_sum += float((y_hat.argmax(dim=1) == y).type(torch.float32).sum())

            # 累计样本总数
            n += y.numel()

        # 计算该 epoch 的平均训练损失和训练准确率
        train_loss = loss_sum / n
        train_acc = acc_sum / n

        # 在测试集上评估准确率(函数内部会切到 eval 模式)
        test_acc = evaluate_accuracy(net, test_iter)

        # 保存本轮指标,用于后续可视化
        train_losses.append(train_loss)
        train_accs.append(train_acc)
        test_accs.append(test_acc)

        # 打印当前 epoch 的训练过程信息
        print(f"epoch {epoch + 1}/{num_epochs}, "
              f"train loss {train_loss:.4f}, "
              f"train acc {train_acc:.4f}, "
              f"test acc {test_acc:.4f}")

    # 返回三条曲线数据:训练损失、训练准确率、测试准确率
    return train_losses, train_accs, test_accs

# 训练配置:
# num_epochs: 训练轮数(完整遍历训练集的次数)
# lr: 学习率(每次参数更新的步长)
# batch_size: 每个小批量的样本数
num_epochs, lr, batch_size = 10, 0.5, 256

# 交叉熵损失(分类任务常用)
# reduce='none' 表示先保留每个样本的损失,后续由训练函数再做聚合
loss = nn.CrossEntropyLoss(reduce='none')

# 加载 Fashion-MNIST 训练集和测试集迭代器
# train_iter: 训练数据迭代器
# test_iter: 测试数据迭代器
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

# 随机梯度下降优化器,负责根据梯度更新网络参数
trainer = torch.optim.SGD(net.parameters(), lr=lr)

# 使用 d2l 提供的通用训练流程:
# 每轮会执行训练、在测试集评估,并输出训练损失/训练准确率/测试准确率
train_ch3_compat(net, train_iter, test_iter, loss, num_epochs, trainer)

训练过程曲线

training_curve_01

Dropout 本质

原始完整网络(没有 Dropout)


输入层        隐藏层1          隐藏层2        输出层

 ●  ●  ●  ●    ●  ●  ●  ●      ●  ●  ●  ●      ●  ●
  \ |  | /     |\ |\ |\ |      |\ |\ |\ |      |  |
   \|  |/      | X| X| X|      | X| X| X|      |  |
   /|  |\      |/ |/ |/ |      |/ |/ |/ |      |  |
  / |  | \     ●  ●  ●  ●      ●  ●  ●  ●      ●  ●

  • 所有神经元都参与计算
  • 某些神经元会形成“依赖关系”
  • 模型可能只依赖少数几个特征

第一次训练(Dropout 随机关闭部分神经元)

Dropout = 0.5 随机关闭一部分神经元


输入层        隐藏层1          隐藏层2        输出层

 ●  ●  ●  ●    ●  X  ●  X      ●  ●  X  ●      ●  ●
  \ |  | /     |\   |\         |\   |         |  |
   \|  |/      | X  X|         | X  |         |  |
   /|  |\      |/   |/         |/   |         |  |
  / |  | \     ●  X  ●  X      ●  ●  X  ●      ●  ●

X 表示:该神经元被关闭

784 → 128 → 128 → 10

第二次训练(新的子网络)


输入层        隐藏层1          隐藏层2        输出层

 ●  ●  ●  ●    X  ●  ●  X      ●  X  ●  ●      ●  ●
  \ |  | /        |\ |\        |    |\        |  |
   \|  |/         | X|         |    |         |  |
   /|  |\         |/ |         |    |         |  |
  / |  | \        X  ●  ●  X   ●  X  ●  ●      ●  ●

又是完全不同的网络

如果训练 10000 step

实际训练的是:10000 个不同的子网络, 但它们:共享参数

Dropout 的真正本质

Dropout ≈ 模型集成 (Ensemble)


model1
model2
model3
...
model10000

最后相当于:把很多模型平均

集成学习本身就是防止过拟合最强的方法之一

有无dropout的对比

权重分布直方图

weight_distribution

Layer1 Weight

dropout 让参数 稍微更分散, 但差异 非常小

这是正常的,因为 第一层通常受 dropout 影响最小

Layer2 Weight

这一层的差异开始稍微明显:

  • dropout 分布 略微更平
  • no-dropout 更集中

Layer3 Weight(最明显)

三层你可以看到:

  • dropout 右尾更长
  • 分布 更宽

说明:

  • 最后一层更依赖上层特征
  • dropout 造成 特征不稳定
  • 权重需要 补偿

总结

观察 含义
均值几乎相同 dropout 保持期望
分布略微更宽 dropout 引入噪声
高层差异更大 特征不稳定传播

Activation Sparsity Plot(激活稀疏度图)

一层神经元的输出有多少是 0(或接近 0)

什么叫 “稀疏”(Sparsity)

假设某一层有 1000 个神经元输出:


[0, 0, 0.3, 1.2, 0, 0.8, 0, 0, 0.5 ...]

如果:

  • 600 个是 0
  • 400 个非 0

稀疏(sparsity) = 600/1000​ = 0.6

也就是 60% 的神经元没激活

activation_sparsity

Hidden1 层

模型 sparsity
no-dropout ≈ 0.79
dropout ≈ 0.82
  • 本来就有 79% 神经元为 0
  • dropout 让它增加到 82%

Hidden2 层(差异更明显)

模型 sparsity
no-dropout ≈ 0.63
dropout ≈ 0.80

这是 非常典型的 Dropout 现象,Dropout 让网络中更多神经元不参与计算

为什么 Output 层没有?

Output 层通常: Softmax/Sigmoid 这些函数:几乎不会产生 0

简洁实现



import torch
import os
import matplotlib.pyplot as plt
from torch import nn
from d2l import torch as d2l

# =========================
# 下面开始定义一个多层感知机(MLP)用于分类任务
# =========================
# num_inputs: 输入特征维度(例如 Fashion-MNIST 的 28x28=784)
# num_outputs: 输出类别数(10 类)
# num_hiddens1/num_hiddens2: 两个隐藏层的神经元数量
num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256

# 两层 dropout 概率:
# 第一层隐藏层后丢弃 20%
# 第二层隐藏层后丢弃 50%
dropout1, dropout2 = 0.2, 0.5

net = nn.Sequential(
    # 把输入从 [batch, 1, 28, 28] 展平为 [batch, 784]
    nn.Flatten(),

    # 第1层全连接:784 -> 256
    nn.Linear(num_inputs, num_hiddens1),

    # 非线性激活,提升模型表达能力
    nn.ReLU(),

    # 第1个 dropout 层,训练时随机失活一部分神经元
    nn.Dropout(dropout1),

    # 第2层全连接:256 -> 256
    nn.Linear(num_hiddens1, num_hiddens2),

    # 第2层激活函数
    nn.ReLU(),

    # 第2个 dropout 层
    nn.Dropout(dropout2),

    # 输出层:256 -> 10,对应 10 个类别的 logits
    nn.Linear(num_hiddens2, num_outputs)
)

def init_weights(m):
    """初始化模型参数"""
    # 仅对线性层做初始化;偏置沿用 PyTorch 默认初始化
    if type(m) == nn.Linear:
        # 用标准差为 0.01 的正态分布初始化权重
        nn.init.normal_(m.weight, std=0.01)

# 对网络中每个子模块执行初始化函数
net.apply(init_weights)

# 使用随机梯度下降优化器,学习率设为 0.1
trainer = torch.optim.SGD(net.parameters(), lr=0.1)

# 训练入口(取消注释后可直接训练)
# d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)