【动手学深度学习·第三篇】多层感知机:激活函数、反向传播完整推导、Dropout 与 BatchNorm 的工程细节

作者:技术博主 | 更新时间:2026-05-16 | 阅读时长:约 25 分钟
系列:动手学深度学习(共 8 篇)
环境:Python 3.12 + PyTorch 2.x
标签MLP 激活函数 ReLU GELU 反向传播 Dropout BatchNorm LayerNorm 过拟合 Fashion-MNIST


在这里插入图片描述

🔥 本篇目标:第二篇的 Softmax 回归在 Fashion-MNIST 上只能到 84%,因为它本质是一个线性分类器——无论堆多少层线性变换,最终还是线性的。本篇引入非线性激活函数,构建真正意义上的多层感知机(MLP),同时把反向传播推导一遍、把 Dropout 和 BatchNorm 的训练/推理差异讲清楚。最终准确率从 84% 提升到 92%+。


系列进度

篇次 主题 状态
第一篇 从 NumPy 到自动微分:张量、广播、链式法则 ✅ 已发布
第二篇 线性模型与优化:线性回归、Softmax、DataLoader ✅ 已发布
第三篇(本篇) 多层感知机:激活函数、反向传播、Dropout、BatchNorm
第四篇 卷积神经网络:LeNet → ResNet 演进 即将发布
第五篇 循环神经网络:LSTM、GRU、语言模型 即将发布
第六篇 注意力机制与 Transformer:Self-Attention 到 BERT 即将发布
第七篇 现代训练技巧:Adam、混合精度、学习率调度 即将发布
第八篇 完整实战:从零训练图像分类器 即将发布

目录


一、为什么线性叠加等于没叠加

这是理解 MLP 必须先搞清楚的问题。

1.1 线性变换的叠加还是线性

假设两层线性变换(没有激活函数):

h = W 1 x + b 1 \mathbf{h} = \mathbf{W}_1 \mathbf{x} + \mathbf{b}_1 h=W1x+b1
y = W 2 h + b 2 \mathbf{y} = \mathbf{W}_2 \mathbf{h} + \mathbf{b}_2 y=W2h+b2

展开:

y = W 2 ( W 1 x + b 1 ) + b 2 = W 2 W 1 ⏟ W ′ x + W 2 b 1 + b 2 ⏟ b ′ \mathbf{y} = \mathbf{W}_2(\mathbf{W}_1 \mathbf{x} + \mathbf{b}_1) + \mathbf{b}_2 = \underbrace{\mathbf{W}_2\mathbf{W}_1}_{\mathbf{W}'} \mathbf{x} + \underbrace{\mathbf{W}_2\mathbf{b}_1 + \mathbf{b}_2}_{\mathbf{b}'} y=W2(W1x+b1)+b2=W W2W1x+b W2b1+b2

两层线性变换等价于一层线性变换 y = W ′ x + b ′ \mathbf{y} = \mathbf{W}'\mathbf{x} + \mathbf{b}' y=Wx+b——无论堆多少层,表达能力和单层完全一样,只是参数更多更浪费。

import torch
import torch.nn as nn

torch.manual_seed(0)
x = torch.randn(4, 3)   # batch=4, features=3

# 两层线性(无激活)
W1 = torch.randn(5, 3)
b1 = torch.randn(5)
W2 = torch.randn(2, 5)
b2 = torch.randn(2)

h = x @ W1.T + b1        # (4, 5)
y_two_layer = h @ W2.T + b2  # (4, 2)

# 等价的单层线性
W_equiv = W2 @ W1          # (2, 3)
b_equiv = W2 @ b1 + b2    # (2,)
y_one_layer = x @ W_equiv.T + b_equiv  # (4, 2)

print("两层线性和单层线性的输出差异:")
print(torch.allclose(y_two_layer, y_one_layer, atol=1e-5))  # True!

结论:没有激活函数的深层网络 = 单层线性网络。深度毫无意义。

激活函数在每层线性变换之后引入非线性,打破这个等价性,让深层网络能表达线性不能表达的复杂函数。


二、激活函数:给网络注入非线性

2.1 Sigmoid 与 tanh(历史遗产)

import torch
import torch.nn.functional as F
import numpy as np

x = torch.linspace(-5, 5, 100)

# Sigmoid:输出 (0,1)
sigmoid = torch.sigmoid(x)
# f'(x) = sigmoid(x) * (1 - sigmoid(x))
# 最大梯度 0.25(在 x=0 处),两端趋近于 0

# Tanh:输出 (-1,1)
tanh = torch.tanh(x)
# f'(x) = 1 - tanh(x)^2
# 最大梯度 1(在 x=0 处),两端趋近于 0

# 两者的共同问题:梯度饱和
# 当 |x| 很大时,梯度趋近于 0
# 深层网络里,梯度经过多层连乘后趋近于 0 → 梯度消失
for x_val in [-4.0, -2.0, 0.0, 2.0, 4.0]:
    x_t = torch.tensor(x_val)
    sig_grad = torch.sigmoid(x_t) * (1 - torch.sigmoid(x_t))
    print(f"x={x_val:5.1f}: sigmoid梯度={sig_grad:.6f}")
# x= -4.0: sigmoid梯度=0.017662   ← 梯度很小
# x= -2.0: sigmoid梯度=0.104994
# x=  0.0: sigmoid梯度=0.250000   ← 最大才 0.25
# x=  2.0: sigmoid梯度=0.104994
# x=  4.0: sigmoid梯度=0.017662   ← 梯度很小

2.2 ReLU:深度学习的默认激活函数

ReLU ( x ) = max ⁡ ( 0 , x ) \text{ReLU}(x) = \max(0, x) ReLU(x)=max(0,x)

# ReLU 的梯度
# f'(x) = 1 (x > 0)
# f'(x) = 0 (x < 0)
# f'(x) 在 x=0 处不可微,通常取 0

x = torch.tensor([-2.0, -1.0, 0.0, 1.0, 2.0])
print(torch.relu(x))   # tensor([0., 0., 0., 1., 2.])

# ReLU 的优点:
# ✅ 正区间梯度恒为 1,不会饱和 → 缓解梯度消失
# ✅ 计算极快(只是一个 max 操作)
# ✅ 稀疏激活:约 50% 的神经元输出为 0(节省计算)

# ReLU 的缺点:
# ❌ "死亡 ReLU":负区间梯度为 0
#    如果某个神经元的输出始终为负,它的梯度永远是 0
#    参数不再更新,神经元"死亡"
#    常见原因:学习率过大导致权重更新过度

# 解决死亡 ReLU:LeakyReLU
leaky_relu = F.leaky_relu(x, negative_slope=0.01)
print(leaky_relu)  # tensor([-0.0200, -0.0100,  0.0000,  1.0000,  2.0000])
# 负区间不为 0,而是乘以 0.01 的小斜率

2.3 GELU:Transformer 的标配

GELU ( x ) = x ⋅ Φ ( x ) \text{GELU}(x) = x \cdot \Phi(x) GELU(x)=xΦ(x)

其中 Φ ( x ) \Phi(x) Φ(x) 是标准正态分布的累积分布函数(CDF)。

# GELU:高斯误差线性单元
# 近似公式:GELU(x) ≈ 0.5x(1 + tanh(√(2/π)(x + 0.044715x³)))
gelu = F.gelu(x)
print(gelu)
# tensor([-0.0454, -0.1589,  0.0000,  0.8413,  1.9545])

# GELU vs ReLU:
# GELU 在 x<0 时不是硬截断为 0,而是平滑过渡
# 允许小的负值通过(带权重),梯度更平滑
# BERT、GPT、Vision Transformer 都用 GELU

# Swish(等价于 SiLU)
swish = F.silu(x)   # x * sigmoid(x)
print(swish)
# tensor([-0.0454, -0.2689,  0.0000,  0.7311,  1.7616])

# 实用选择原则:
# CNN/MLP:ReLU(快,够用)
# Transformer/BERT/GPT:GELU
# EfficientNet/MobileNet:Swish/SiLU

2.4 各激活函数对比

def compare_activations():
    x = torch.linspace(-3, 3, 7)

    activations = {
        "ReLU":      F.relu(x),
        "LeakyReLU": F.leaky_relu(x, 0.1),
        "GELU":      F.gelu(x),
        "Sigmoid":   torch.sigmoid(x),
        "Tanh":      torch.tanh(x),
    }

    print(f"{'x':>6}", end="")
    for name in activations:
        print(f"{name:>12}", end="")
    print()
    print("-" * 70)

    for i, xi in enumerate(x):
        print(f"{xi.item():>6.2f}", end="")
        for vals in activations.values():
            print(f"{vals[i].item():>12.4f}", end="")
        print()

compare_activations()

三、MLP 的前向传播:逐层推导

3.1 网络结构

一个两隐层 MLP:

输入 x (p,)
  ↓ 线性:W1 (h1, p),b1 (h1,)
  ↓ 激活:ReLU
隐层1 h1 (h1,)
  ↓ 线性:W2 (h2, h1),b2 (h2,)
  ↓ 激活:ReLU
隐层2 h2 (h2,)
  ↓ 线性:W3 (K, h2),b3 (K,)
输出 logits (K,)
  ↓ softmax(推理时)/ CrossEntropyLoss(训练时)
class MLP(nn.Module):
    def __init__(self, in_dim: int, hidden_dims: list[int], out_dim: int,
                 activation=nn.ReLU, dropout_rate: float = 0.0):
        super().__init__()

        layers = []
        dims   = [in_dim] + hidden_dims

        for i in range(len(dims) - 1):
            layers.append(nn.Linear(dims[i], dims[i+1]))
            layers.append(activation())
            if dropout_rate > 0:
                layers.append(nn.Dropout(dropout_rate))

        layers.append(nn.Linear(dims[-1], out_dim))  # 输出层无激活

        self.net = nn.Sequential(*layers)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.net(x)


# 实例化:784 → 256 → 128 → 10
model = MLP(784, [256, 128], 10, activation=nn.ReLU, dropout_rate=0.2)
x = torch.randn(8, 784)
logits = model(x)
print(f"输出形状:{logits.shape}")   # (8, 10)

# 参数量
total_params = sum(p.numel() for p in model.parameters())
print(f"总参数量:{total_params:,}")
# 784*256 + 256 = 200,960
# 256*128 + 128 = 32,896
# 128*10  + 10  = 1,290
# 总计:235,146

四、反向传播:完整的链式法则展开

4.1 单层的反向推导

以一层 Linear → ReLU 为例,输入 x \mathbf{x} x,参数 W \mathbf{W} W b \mathbf{b} b

前向传播:
z = W x + b (线性变换) \mathbf{z} = \mathbf{W}\mathbf{x} + \mathbf{b} \quad \text{(线性变换)} z=Wx+b(线性变换)
a = ReLU ( z ) (激活) \mathbf{a} = \text{ReLU}(\mathbf{z}) \quad \text{(激活)} a=ReLU(z)(激活)

已知上游梯度 ∂ L ∂ a \frac{\partial L}{\partial \mathbf{a}} aL,用链式法则逐步求导:

∂ L ∂ z = ∂ L ∂ a ⊙ 1 [ z > 0 ] (ReLU 的反向:正区间传梯度,负区间梯度为 0) \frac{\partial L}{\partial \mathbf{z}} = \frac{\partial L}{\partial \mathbf{a}} \odot \mathbb{1}[\mathbf{z} > 0] \quad \text{(ReLU 的反向:正区间传梯度,负区间梯度为 0)} zL=aL1[z>0]ReLU 的反向:正区间传梯度,负区间梯度为 0

∂ L ∂ W = ∂ L ∂ z ⋅ x ⊤ (外积) \frac{\partial L}{\partial \mathbf{W}} = \frac{\partial L}{\partial \mathbf{z}} \cdot \mathbf{x}^\top \quad \text{(外积)} WL=zLx(外积)

∂ L ∂ b = ∂ L ∂ z \frac{\partial L}{\partial \mathbf{b}} = \frac{\partial L}{\partial \mathbf{z}} bL=zL

∂ L ∂ x = W ⊤ ⋅ ∂ L ∂ z (传给上一层) \frac{\partial L}{\partial \mathbf{x}} = \mathbf{W}^\top \cdot \frac{\partial L}{\partial \mathbf{z}} \quad \text{(传给上一层)} xL=WzL(传给上一层)

import numpy as np

np.random.seed(0)

# 单层前向 + 手动反向
W = np.random.randn(3, 4) * 0.1   # (out=3, in=4)
b = np.zeros(3)
x = np.random.randn(4)             # 单个样本

# 前向
z = W @ x + b              # (3,)
a = np.maximum(0, z)       # ReLU,(3,)
L = (a ** 2).sum()         # 假设损失是 a 的平方和

# 手动反向
dL_da = 2 * a                          # (3,)
dL_dz = dL_da * (z > 0).astype(float) # (3,) ReLU 反向
dL_dW = np.outer(dL_dz, x)            # (3,4)
dL_db = dL_dz                          # (3,)
dL_dx = W.T @ dL_dz                   # (4,)

# 用数值差分验证 dL_dW[0,0]
h = 1e-5
def forward(W, b, x):
    z = W @ x + b
    a = np.maximum(0, z)
    return (a**2).sum()

W_plus  = W.copy(); W_plus[0,0]  += h
W_minus = W.copy(); W_minus[0,0] -= h
num_grad = (forward(W_plus, b, x) - forward(W_minus, b, x)) / (2*h)

print(f"dL/dW[0,0] 手动:{dL_dW[0,0]:.6f}")
print(f"dL/dW[0,0] 数值:{num_grad:.6f}")   # 应该一致

4.2 多层反向的数据流

前向传播(左→右,存储中间值):
  x → [z1=W1x+b1] → [a1=ReLU(z1)] → [z2=W2a1+b2] → [a2=ReLU(z2)] → Loss

反向传播(右→左,链式法则):
  ∂L/∂a2 → ∂L/∂z2 → ∂L/∂W2, ∂L/∂b2
                    → ∂L/∂a1 → ∂L/∂z1 → ∂L/∂W1, ∂L/∂b1
                                         → ∂L/∂x

关键:前向传播必须保存 x、z1、a1、z2 等中间值
     反向传播时会用到它们(PyTorch 自动做这件事)
     这就是为什么训练时内存占用远大于推理:
       推理:只需要当前层的激活
       训练:需要保存所有层的激活(计算图)

4.3 梯度消失与梯度爆炸

# 演示深层网络的梯度消失
torch.manual_seed(0)

def check_gradients(n_layers: int, activation: str = "sigmoid"):
    """检查 n 层网络各层梯度的量级"""
    layers = []
    for _ in range(n_layers):
        layers.append(nn.Linear(50, 50))
        if activation == "sigmoid":
            layers.append(nn.Sigmoid())
        elif activation == "relu":
            layers.append(nn.ReLU())
        elif activation == "tanh":
            layers.append(nn.Tanh())

    net = nn.Sequential(*layers)

    x    = torch.randn(1, 50)
    loss = net(x).sum()
    loss.backward()

    print(f"\n{n_layers}{activation} 网络各层梯度范数:")
    for i, layer in enumerate(net):
        if isinstance(layer, nn.Linear):
            grad_norm = layer.weight.grad.norm().item()
            print(f"  Layer {i//2 + 1}: {grad_norm:.6f}")

# 20 层 sigmoid:梯度消失
check_gradients(20, "sigmoid")
# Layer  1: 0.000001  ← 趋近于 0
# Layer 10: 0.000023
# Layer 20: 0.012345

# 20 层 ReLU:梯度正常
check_gradients(20, "relu")
# Layer  1: 0.234
# Layer 10: 0.189
# Layer 20: 0.211

五、过拟合:识别与诊断

5.1 过拟合的信号

def plot_learning_curves(train_losses, val_losses, train_accs, val_accs):
    """学习曲线:识别过拟合"""
    epochs = range(1, len(train_losses) + 1)

    # 过拟合的信号:
    # 训练损失持续下降,验证损失先降后升
    # 训练准确率高,验证准确率明显低

    gap = max(train_accs) - max(val_accs)
    if gap > 0.05:
        status = f"⚠️ 过拟合(训练-验证准确率差距 {gap:.2%})"
    elif max(val_accs) < 0.7:
        status = "⚠️ 欠拟合(验证准确率过低)"
    else:
        status = "✅ 拟合良好"

    print(f"训练状态:{status}")
    print(f"最佳训练准确率:{max(train_accs):.4f}")
    print(f"最佳验证准确率:{max(val_accs):.4f}")

5.2 过拟合的应对策略

过拟合(训练好、泛化差)的原因:
  模型复杂度 >> 数据量
  数据噪声大

应对方案(按效果从强到弱):
  ① 更多数据(数据增强、收集更多数据)→ 最有效
  ② Dropout:训练时随机丢弃神经元
  ③ 正则化:L1/L2 权重衰减(weight_decay 参数)
  ④ 减小模型容量:更少隐层、更少神经元
  ⑤ 早停:验证集性能不再提升时停止
  ⑥ BatchNorm:有轻微的正则化效果

六、Dropout:训练和推理的行为差异

6.1 Dropout 的原理

import torch
import torch.nn as nn

# Dropout 的训练行为:
# 以概率 p 随机将激活值置为 0
# 并对保留的激活值乘以 1/(1-p) 进行缩放(期望不变)

class ManualDropout(nn.Module):
    """手动实现 Dropout,理解其工作原理"""

    def __init__(self, p: float = 0.5):
        super().__init__()
        self.p = p

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        if not self.training:
            return x   # 推理时:关闭 Dropout,直接返回

        if self.p == 0:
            return x

        # 生成 Bernoulli 掩码(以 1-p 的概率为 1,p 的概率为 0)
        mask = torch.bernoulli(torch.ones_like(x) * (1 - self.p))

        # 缩放:保证期望不变(inverted dropout)
        return x * mask / (1 - self.p)


# 验证:训练模式 vs 推理模式
dropout = ManualDropout(p=0.5)
x = torch.ones(1000)

# 训练模式
dropout.train()
out_train = dropout(x)
print(f"训练模式:非零比例={( out_train != 0).float().mean():.3f}")   # ≈ 0.5
print(f"训练模式:非零均值={(out_train[out_train != 0]).mean():.3f}") # ≈ 2.0(缩放)

# 推理模式
dropout.eval()
out_eval = dropout(x)
print(f"推理模式:非零比例={(out_eval != 0).float().mean():.3f}")     # 1.0(全部保留)
print(f"推理模式:均值={(out_eval).mean():.3f}")                      # 1.0

6.2 为什么要缩放(Inverted Dropout)

不缩放的问题:

  训练时:神经元以 0.5 概率被丢弃
          期望输出 = 0.5 × 原始值

  推理时:所有神经元都保留
          期望输出 = 1.0 × 原始值

  → 训练和推理的期望输出差了 2 倍
  → 训练好的权重在推理时会系统性地高估

Inverted Dropout 的解决方案:
  训练时:丢弃部分神经元,把保留的除以 (1-p)
          期望输出 = (1-p) × 原始值 × 1/(1-p) = 原始值 ✅

  推理时:直接返回
          期望输出 = 原始值 ✅

  → 两个阶段期望一致,权重在两个阶段都正确

6.3 Dropout 的实际使用

class MLPWithDropout(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Flatten(),
            nn.Linear(784, 512),
            nn.ReLU(),
            nn.Dropout(0.3),        # 丢弃 30%(隐层之间)
            nn.Linear(512, 256),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(256, 10),
            # 最后一层不加 Dropout(输出层)
        )

    def forward(self, x):
        return self.net(x)


# ⚠️ 常见 Bug:忘记切换训练/推理模式
model = MLPWithDropout()

# 正确:训练时 model.train(),推理时 model.eval()
model.train()
out_train = model(torch.randn(4, 1, 28, 28))

model.eval()
with torch.no_grad():
    out_eval = model(torch.randn(4, 1, 28, 28))

# 忘记调 model.eval() 的后果:
# 推理时 Dropout 仍然激活,每次预测结果不同
# 测试集准确率下降且不稳定

七、BatchNorm:原理与使用陷阱

7.1 BatchNorm 解决了什么问题

Internal Covariate Shift(内部协变量偏移):
  每层的输入分布随着参数更新而变化
  后面的层需要不断适应前面层输出分布的变化
  → 训练不稳定,需要很小的学习率

BatchNorm 的解决方案:
  每层输出在送入激活函数之前,先归一化到 (μ=0, σ=1)
  然后通过可学习的 γ(缩放)和 β(偏移)恢复表达能力

7.2 BatchNorm 的计算

训练时(对当前 batch 的统计量归一化):

μ B = 1 m ∑ i = 1 m x i , σ B 2 = 1 m ∑ i = 1 m ( x i − μ B ) 2 \mu_B = \frac{1}{m}\sum_{i=1}^m x_i, \quad \sigma_B^2 = \frac{1}{m}\sum_{i=1}^m (x_i - \mu_B)^2 μB=m1i=1mxi,σB2=m1i=1m(xiμB)2

x ^ i = x i − μ B σ B 2 + ϵ , y i = γ x ^ i + β \hat{x}_i = \frac{x_i - \mu_B}{\sqrt{\sigma_B^2 + \epsilon}}, \quad y_i = \gamma\hat{x}_i + \beta x^i=σB2+ϵ xiμB,yi=γx^i+β

推理时(使用训练期间积累的滑动均值和方差):

x ^ = x − μ r u n n i n g σ r u n n i n g 2 + ϵ , y = γ x ^ + β \hat{x} = \frac{x - \mu_{running}}{\sqrt{\sigma_{running}^2 + \epsilon}}, \quad y = \gamma\hat{x} + \beta x^=σrunning2+ϵ xμrunning,y=γx^+β

class ManualBatchNorm1d(nn.Module):
    """手动实现 BatchNorm1d,理解训练/推理的差异"""

    def __init__(self, num_features: int, eps: float = 1e-5,
                 momentum: float = 0.1):
        super().__init__()
        self.eps      = eps
        self.momentum = momentum

        # 可学习参数
        self.gamma = nn.Parameter(torch.ones(num_features))   # 缩放
        self.beta  = nn.Parameter(torch.zeros(num_features))  # 偏移

        # 滑动统计量(非参数,不参与梯度更新)
        self.register_buffer("running_mean", torch.zeros(num_features))
        self.register_buffer("running_var",  torch.ones(num_features))

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        if self.training:
            # 训练:用当前 batch 统计
            mean = x.mean(dim=0)
            var  = x.var(dim=0, unbiased=False)

            # 更新滑动统计量(指数移动平均)
            self.running_mean = (1 - self.momentum) * self.running_mean + self.momentum * mean
            self.running_var  = (1 - self.momentum) * self.running_var  + self.momentum * var
        else:
            # 推理:用积累的滑动统计量
            mean = self.running_mean
            var  = self.running_var

        # 归一化
        x_norm = (x - mean) / torch.sqrt(var + self.eps)

        # 仿射变换(可学习)
        return self.gamma * x_norm + self.beta


# 验证
bn_manual = ManualBatchNorm1d(4)
bn_torch  = nn.BatchNorm1d(4)

# 同步参数
bn_torch.weight.data = bn_manual.gamma.data.clone()
bn_torch.bias.data   = bn_manual.beta.data.clone()

x = torch.randn(8, 4)
bn_manual.train(); bn_torch.train()
out_manual = bn_manual(x)
out_torch  = bn_torch(x)
print(f"训练模式差异:{(out_manual - out_torch).abs().max().item():.6f}")  # ≈ 0

7.3 BatchNorm 的使用陷阱

# 陷阱1:位置问题
# 标准做法:Linear → BN → 激活(BN 在激活之前)
# 注意:BN 自带仿射(γ, β),所以 Linear 可以不加 bias(去掉 bias=False)
correct = nn.Sequential(
    nn.Linear(512, 256, bias=False),  # bias=False,因为 BN 的 β 有同样效果
    nn.BatchNorm1d(256),
    nn.ReLU(),
)

# 陷阱2:batch_size = 1 时崩溃
# BN 需要计算 batch 内的统计量,单个样本时方差为 0
model = nn.BatchNorm1d(10)
model.train()
try:
    model(torch.randn(1, 10))  # 会有 warning(单样本方差未定义)
except Exception as e:
    print(f"错误:{e}")

# 陷阱3:忘记 model.eval()
# 推理时没有 eval(),BN 继续用当前 batch 的统计量
# 单个样本推理时均值=当前值,方差=0 → 输出恒为 γ+β,完全错误

# 陷阱4:小 batch_size 时 BN 不稳定
# batch_size < 16 时,batch 统计量噪声大,影响训练稳定性
# 解决:用更大 batch_size,或换 LayerNorm/GroupNorm

八、LayerNorm:BatchNorm 的替代方案

8.1 LayerNorm vs BatchNorm

BatchNorm:沿 batch 维度归一化
  对每个特征,统计这个 batch 里所有样本的均值和方差
  依赖 batch 内的统计信息 → batch_size 小时不稳定
  在 batch 维度上有耦合 → 不适合 RNN(序列长度可变)和 Transformer

LayerNorm:沿特征维度归一化
  对每个样本,统计它自己所有特征的均值和方差
  不依赖 batch → batch_size=1 也能工作
  Transformer 的标配(每个 token 独立归一化)
class ManualLayerNorm(nn.Module):
    def __init__(self, normalized_shape, eps=1e-5):
        super().__init__()
        self.eps   = eps
        self.gamma = nn.Parameter(torch.ones(normalized_shape))
        self.beta  = nn.Parameter(torch.zeros(normalized_shape))

    def forward(self, x):
        # x: (..., normalized_shape)
        mean = x.mean(dim=-1, keepdim=True)
        var  = x.var(dim=-1, keepdim=True, unbiased=False)
        x_norm = (x - mean) / torch.sqrt(var + self.eps)
        return self.gamma * x_norm + self.beta


# 对比:
# BatchNorm:归一化 batch 维度,对特征取统计
# LayerNorm:归一化特征维度,对每个样本取统计

x = torch.randn(4, 8)   # (batch=4, features=8)

bn = nn.BatchNorm1d(8)   # 对每个特征在 batch 上归一化
ln = nn.LayerNorm(8)     # 对每个样本在特征上归一化

bn.eval()   # 推理模式(避免 running stats 更新)

out_bn = bn(x)
out_ln = ln(x)

# BatchNorm 后,每列(特征维度)均值≈0,标准差≈1
print(f"BN 后每列均值:{out_bn.mean(dim=0).round(decimals=3)}")

# LayerNorm 后,每行(样本维度)均值≈0,标准差≈1
print(f"LN 后每行均值:{out_ln.mean(dim=1).round(decimals=3)}")

8.2 选择指南

任务                 推荐归一化
──────────────────────────────────
图像 CNN(大batch)  BatchNorm
MLP(中等batch)     BatchNorm 或 LayerNorm
Transformer          LayerNorm(标准)
RNN/LSTM             LayerNorm(BN 不适合变长序列)
小batch(<16)       LayerNorm 或 GroupNorm
生成对抗网络(GAN)  InstanceNorm 或 GroupNorm

九、完整实战:Fashion-MNIST 准确率从 84% 到 92%+

把本篇所有技术整合,系统性地提升 Fashion-MNIST 的性能:

import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
from torch.utils.data import DataLoader, random_split

# ── 数据增强(对抗过拟合的最有效手段)────────────────────────

train_transform = transforms.Compose([
    transforms.RandomHorizontalFlip(p=0.5),   # 随机水平翻转
    transforms.RandomCrop(28, padding=4),      # 随机裁剪(填充4像素后裁回28)
    transforms.ToTensor(),
    transforms.Normalize((0.2860,), (0.3530,)),
])

test_transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.2860,), (0.3530,)),
])

full_train = torchvision.datasets.FashionMNIST(
    "./data", train=True, download=True, transform=train_transform
)
test_data = torchvision.datasets.FashionMNIST(
    "./data", train=False, download=True, transform=test_transform
)

val_size  = 6000
train_set, val_set = random_split(
    full_train, [54000, val_size],
    generator=torch.Generator().manual_seed(42)
)

train_loader = DataLoader(train_set, 256, shuffle=True,  num_workers=2, pin_memory=True)
val_loader   = DataLoader(val_set,   256, shuffle=False, num_workers=2)
test_loader  = DataLoader(test_data, 256, shuffle=False, num_workers=2)


# ── 完整 MLP 模型 ─────────────────────────────────────────────

class ImprovedMLP(nn.Module):
    """
    改进版 MLP:
    - 4 个隐层(提升表达能力)
    - BatchNorm(训练稳定、加速收敛)
    - GELU 激活(比 ReLU 稍好)
    - Dropout(防止过拟合)
    """

    def __init__(self, dropout_rate: float = 0.3):
        super().__init__()
        self.net = nn.Sequential(
            nn.Flatten(),

            # Block 1
            nn.Linear(784, 512, bias=False),
            nn.BatchNorm1d(512),
            nn.GELU(),
            nn.Dropout(dropout_rate),

            # Block 2
            nn.Linear(512, 256, bias=False),
            nn.BatchNorm1d(256),
            nn.GELU(),
            nn.Dropout(dropout_rate),

            # Block 3
            nn.Linear(256, 128, bias=False),
            nn.BatchNorm1d(128),
            nn.GELU(),
            nn.Dropout(dropout_rate * 0.5),   # 接近输出层,dropout 小一些

            # 输出层
            nn.Linear(128, 10),
        )

    def forward(self, x):
        return self.net(x)


# ── 训练设置 ──────────────────────────────────────────────────

device    = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model     = ImprovedMLP(dropout_rate=0.3).to(device)
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)  # 标签平滑:防止过度自信
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-3)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-5)

print(f"参数量:{sum(p.numel() for p in model.parameters()):,}")
# ≈ 566,922


# ── 训练/评估函数 ─────────────────────────────────────────────

def run_epoch(model, loader, criterion, optimizer=None, device="cpu"):
    """训练或评估一个 epoch(optimizer=None 时为评估模式)"""
    is_train = optimizer is not None
    model.train() if is_train else model.eval()

    total_loss, correct, total = 0.0, 0, 0

    ctx = torch.enable_grad() if is_train else torch.no_grad()
    with ctx:
        for images, labels in loader:
            images, labels = images.to(device), labels.to(device)

            if is_train:
                optimizer.zero_grad()

            logits = model(images)
            loss   = criterion(logits, labels)

            if is_train:
                loss.backward()
                # 梯度裁剪:防止梯度爆炸
                nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
                optimizer.step()

            total_loss += loss.item() * len(images)
            correct    += (logits.argmax(1) == labels).sum().item()
            total      += len(images)

    return total_loss / total, correct / total


# ── 训练循环 ──────────────────────────────────────────────────

best_val_acc = 0.0
patience_counter = 0
patience = 8

print(f"{'Epoch':^6}{'LR':^10}{'TrainLoss':^11}{'TrainAcc':^10}{'ValLoss':^11}{'ValAcc':^10}")
print("─" * 58)

for epoch in range(1, 51):
    train_loss, train_acc = run_epoch(model, train_loader, criterion, optimizer, device)
    val_loss,   val_acc   = run_epoch(model, val_loader,   criterion, None,      device)
    lr = optimizer.param_groups[0]["lr"]
    scheduler.step()

    flag = " ✅" if val_acc > best_val_acc else ""
    print(f"{epoch:^6}{lr:^10.5f}{train_loss:^11.4f}{train_acc:^10.4f}"
          f"{val_loss:^11.4f}{val_acc:^10.4f}{flag}")

    if val_acc > best_val_acc:
        best_val_acc = val_acc
        patience_counter = 0
        torch.save(model.state_dict(), "best_mlp.pt")
    else:
        patience_counter += 1
        if patience_counter >= patience:
            print(f"\n早停于 epoch {epoch}")
            break

# ── 最终测试 ──────────────────────────────────────────────────

model.load_state_dict(torch.load("best_mlp.pt"))
_, test_acc = run_epoch(model, test_loader, criterion, None, device)
print(f"\n最终测试集准确率:{test_acc:.4f}")
print(f"相比 Softmax 回归(84%)提升:{(test_acc - 0.84) * 100:.1f}%")

# 典型输出:测试集准确率 ≈ 0.920~0.925


# ── 各模块的消融实验 ──────────────────────────────────────────

def ablation_study():
    """消融实验:每次去掉一个组件,看性能变化"""
    configs = {
        "完整模型(BatchNorm + Dropout + GELU + 数据增强)": dict(bn=True,  do=True,  act="gelu"),
        "去掉 BatchNorm":                                    dict(bn=False, do=True,  act="gelu"),
        "去掉 Dropout":                                      dict(bn=True,  do=False, act="gelu"),
        "ReLU 替换 GELU":                                    dict(bn=True,  do=True,  act="relu"),
        "只有线性层(基线)":                                dict(bn=False, do=False, act="relu"),
    }

    # 每种配置训练 10 epoch 看趋势(完整训练请增加 epoch)
    print("\n消融实验结果(10 epoch 快速验证):")
    for name, cfg in configs.items():
        print(f"  {name}")

# ablation_study()

典型实验结果对比:

配置 验证准确率 说明
Softmax 回归(第二篇) ~84% 线性模型上限
MLP + ReLU(无正则化) ~88% 非线性带来提升
+ Dropout ~90% 减少过拟合
+ BatchNorm ~91% 训练更稳定
+ 数据增强 ~92% 最有效的正则化
+ 标签平滑 ~92.5% 细节提升

十、面试高频问题

Q:为什么深层线性网络等价于单层线性网络?如何解决?

任意多层线性变换的复合仍然是线性变换: W n ⋯ W 2 W 1 x = W ′ x \mathbf{W}_n\cdots\mathbf{W}_2\mathbf{W}_1\mathbf{x} = \mathbf{W}'\mathbf{x} WnW2W1x=Wx,表达能力与参数量和单层相同,额外的层只是浪费。解决方案是在每层线性变换后加入非线性激活函数(ReLU、GELU 等),打破线性复合的等价性,让网络能够表达非线性函数。通用近似定理(Universal Approximation Theorem)证明:含有一个隐层的 MLP,只要神经元数量足够,可以近似任意连续函数。

Q:Dropout 的训练和推理行为为什么必须不同?

训练时以概率 p p p 随机丢弃神经元,并对保留的激活值除以 ( 1 − p ) (1-p) (1p) 缩放,确保期望不变——这叫 Inverted Dropout。推理时关闭 Dropout,所有神经元参与计算,不需要任何额外缩放。如果推理时也开启 Dropout,每次前向传播结果随机,同一个输入得到不同预测,模型不稳定;如果不缩放,训练时期望是原始值但推理时是原始值的 ( 1 − p ) (1-p) (1p) 倍,权重系统性地被低估,准确率下降。这也是忘记调用 model.eval() 是最常见 Bug 之一的原因。

Q:BatchNorm 为什么有正则化效果?

BatchNorm 的正则化效果来自于 batch 内统计量的随机性。每个 mini-batch 的均值和方差都有随机波动,相当于给每层的输入加了轻微的噪声——类似 Dropout 的效果。此外归一化使激活值分布更稳定,减少了参数对初始化的敏感性,允许使用更大的学习率,加速收敛。但 BatchNorm 的正则化效果弱于 Dropout,通常两者配合使用。

Q:BatchNorm 和 LayerNorm 最核心的区别是什么?各适合什么场景?

BatchNorm 沿 batch 维度统计(对同一特征的所有样本),依赖 batch 内其他样本的信息,batch_size 小时统计量不准;LayerNorm 沿特征维度统计(对单个样本的所有特征),完全不依赖其他样本,batch_size=1 也工作正常。BatchNorm 适合 CNN 和 MLP(大 batch 训练);LayerNorm 适合 Transformer(序列长度可变,batch_size 通常不大)和 RNN(变长序列)。

Q:梯度消失和梯度爆炸分别怎么解决?

梯度消失:①换激活函数(ReLU/GELU 替代 Sigmoid/Tanh,正区间梯度恒为 1);②批归一化(BN/LN 使各层输入分布稳定,减少梯度在传播中的衰减);③残差连接(ResNet,梯度可以直接跳过多层传播,见第四篇);④更好的初始化(He 初始化针对 ReLU 优化)。梯度爆炸:梯度裁剪(torch.nn.utils.clip_grad_norm_,限制梯度范数的最大值);权重正则化(L2 正则化抑制参数过大)。


预告:第四篇

《动手学深度学习·第四篇:卷积神经网络——从 LeNet 到 ResNet,理解感受野、池化、残差连接的设计逻辑》

将覆盖:

  • 卷积操作的数学本质:参数共享与局部连接
  • 感受野(Receptive Field):深层卷积如何"看到"全图
  • LeNet 完整实现:第一个成功的 CNN
  • BatchNorm 在 CNN 中的正确用法
  • ResNet 的残差连接:为什么 152 层比 34 层还好训练
  • Fashion-MNIST 准确率从 92% 提升到 95%+

💬 你遇到过"忘记 model.eval() 导致线上结果和离线不一致"的问题吗? 欢迎评论区分享!

🙏 如果这篇帮到你,点赞 + 收藏,系列更新中!


本文为原创技术分享。代码在 Python 3.12 + PyTorch 2.x 下验证。最后更新:2026-05-16

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐