【动手学深度学习·第三篇】多层感知机:激活函数、反向传播完整推导、Dropout 与 BatchNorm 的工程细节
【动手学深度学习·第三篇】多层感知机:激活函数、反向传播完整推导、Dropout 与 BatchNorm 的工程细节
作者:技术博主 | 更新时间:2026-05-16 | 阅读时长:约 25 分钟
系列:动手学深度学习(共 8 篇)
环境:Python 3.12 + PyTorch 2.x
标签:MLP激活函数ReLUGELU反向传播DropoutBatchNormLayerNorm过拟合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 的前向传播:逐层推导
- 四、反向传播:完整的链式法则展开
- 五、过拟合:识别与诊断
- 六、Dropout:训练和推理的行为差异
- 七、BatchNorm:原理与使用陷阱
- 八、LayerNorm:BatchNorm 的替代方案
- 九、完整实战:Fashion-MNIST 准确率从 84% 到 92%+
- 十、面试高频问题
一、为什么线性叠加等于没叠加
这是理解 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=W′x+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}} ∂a∂L,用链式法则逐步求导:
∂ 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)} ∂z∂L=∂a∂L⊙1[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{(外积)} ∂W∂L=∂z∂L⋅x⊤(外积)
∂ L ∂ b = ∂ L ∂ z \frac{\partial L}{\partial \mathbf{b}} = \frac{\partial L}{\partial \mathbf{z}} ∂b∂L=∂z∂L
∂ L ∂ x = W ⊤ ⋅ ∂ L ∂ z (传给上一层) \frac{\partial L}{\partial \mathbf{x}} = \mathbf{W}^\top \cdot \frac{\partial L}{\partial \mathbf{z}} \quad \text{(传给上一层)} ∂x∂L=W⊤⋅∂z∂L(传给上一层)
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=1∑mxi,σB2=m1i=1∑m(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} Wn⋯W2W1x=W′x,表达能力与参数量和单层相同,额外的层只是浪费。解决方案是在每层线性变换后加入非线性激活函数(ReLU、GELU 等),打破线性复合的等价性,让网络能够表达非线性函数。通用近似定理(Universal Approximation Theorem)证明:含有一个隐层的 MLP,只要神经元数量足够,可以近似任意连续函数。
Q:Dropout 的训练和推理行为为什么必须不同?
训练时以概率 p p p 随机丢弃神经元,并对保留的激活值除以 ( 1 − p ) (1-p) (1−p) 缩放,确保期望不变——这叫 Inverted Dropout。推理时关闭 Dropout,所有神经元参与计算,不需要任何额外缩放。如果推理时也开启 Dropout,每次前向传播结果随机,同一个输入得到不同预测,模型不稳定;如果不缩放,训练时期望是原始值但推理时是原始值的 ( 1 − p ) (1-p) (1−p) 倍,权重系统性地被低估,准确率下降。这也是忘记调用
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
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)