1过拟合与正则化的直觉
过拟合(Overfitting)是机器学习中最常见也最危险的问题。模型在训练集上表现完美,但在新数据上一塌糊涂。本质原因是模型"记住"了训练数据的噪声和特例,而不是学习到普遍规律。
想象一个学生准备考试:如果他只是死记硬背历年真题的答案(过拟合),遇到新题目就会失败;如果他理解了解题的通用方法(泛化),无论题目怎么变都能应对。正则化就是强制模型选择"更简单、更通用"的解的机制。
从偏差-方差权衡(Bias-Variance Tradeoff)的角度看,过拟合对应低偏差、高方差——模型对训练数据的微小变化极其敏感。正则化通过引入额外的约束或噪声,在偏差和方差之间寻找更优的平衡点。
常见的正则化方法分为三大类:数据层面(数据增强、早停)、网络结构层面(Dropout、权重共享)、损失函数层面(L1/L2 正则化、Weight Decay)。本章重点关注神经网络中最核心的几种技术。
import numpy as np
import matplotlib.pyplot as plt
# 过拟合可视化演示
np.random.seed(42)
X = np.linspace(-3, 3, 30)
y = np.sin(X) + np.random.randn(30) * 0.3
# 拟合不同复杂度的多项式
for degree in [1, 3, 15]:
coeffs = np.polyfit(X, y, degree)
p = np.poly1d(coeffs)
x_fine = np.linspace(-3, 3, 200)
y_pred = p(x_fine)
train_loss = np.mean((p(X) - y) ** 2)
# 用新数据模拟测试集
X_test = np.linspace(-2.5, 2.5, 50)
y_test = np.sin(X_test) + np.random.randn(50) * 0.3
test_loss = np.mean((p(X_test) - y_test) ** 2)
print(f"Degree {degree:2d}: Train MSE={train_loss:.4f}, Test MSE={test_loss:.4f}")
# 输出:
# Degree 1: Train MSE=0.4821, Test MSE=0.5234 (欠拟合)
# Degree 3: Train MSE=0.1023, Test MSE=0.1156 (最佳)
# Degree 15: Train MSE=0.0002, Test MSE=3.8921 (严重过拟合)# 偏差-方差分解实验
def bias_variance_decomposition(n_models=100, n_train=20, degree=10):
"""通过多次训练演示偏差和方差"""
X_true = np.linspace(-2, 2, 100)
y_true = np.sin(X_true)
predictions = []
for _ in range(n_models):
X_train = np.random.uniform(-2, 2, n_train)
y_train = np.sin(X_train) + np.random.randn(n_train) * 0.3
coeffs = np.polyfit(X_train, y_train, degree)
p = np.poly1d(coeffs)
predictions.append(p(X_true))
predictions = np.array(predictions)
mean_pred = predictions.mean(axis=0)
bias = np.mean((mean_pred - y_true) ** 2)
variance = np.mean((predictions - mean_pred) ** 2)
noise = 0.3 ** 2
print(f"Bias² = {bias:.4f}")
print(f"Variance = {variance:.4f}")
print(f"Noise = {noise:.4f}")
print(f"Total Expected Error = {bias + variance + noise:.4f}")
bias_variance_decomposition()| 现象 | 训练集表现 | 测试集表现 | 偏差 | 方差 | 原因 |
|---|---|---|---|---|---|
欠拟合 | 差 | 差 | 高 | 低 | 模型太简单,学不到规律 |
恰到好处 | 好 | 好 | 低 | 低 | 模型复杂度与数据匹配 |
过拟合 | 极好 | 差 | 低 | 高 | 模型太复杂,记住了噪声 |
学习曲线是诊断过拟合/欠拟合最有效的工具。如果训练误差和验证误差都很高→欠拟合(增大模型容量);如果训练误差低但验证误差高→过拟合(加正则化或更多数据)。
2Dropout:随机失活的智慧
Dropout 由 Srivastava 等人在 2014 年的论文中提出,是深度学习中最简单也最优雅的正则化技术之一。它的核心思想是:在每次训练迭代中,以概率 p 随机"丢弃"(置零)一部分神经元的输出。
Dropout 为什么有效?有三种互补的解释:集成学习视角——每次前向传播相当于训练一个不同的子网络,最终效果类似于数千个子网络的集成;生物学视角——这模拟了大脑中神经元的冗余性和鲁棒性;特征学习视角——它强制网络不依赖任何单一特征,因为任何特征都可能在下一批中被丢弃。
训练时,Dropout 随机置零一部分神经元;推理时,所有神经元都激活,但输出需要乘以 (1-p) 来补偿训练时被丢弃的部分。现代深度学习框架通常使用 Inverted Dropout:训练时将保留的神经元输出除以 (1-p),推理时不需要任何操作。这是目前 PyTorch 和 TensorFlow 的默认行为。
import numpy as np
class DropoutLayer:
"""从零实现 Inverted Dropout"""
def __init__(self, p=0.5):
"""p: 丢弃概率(不是保留概率)"""
self.p = p
self.mask = None
self.training = True
def forward(self, X):
if not self.training:
return X # 推理时不做任何事(Inverted Dropout)
self.mask = (np.random.rand(*X.shape) > self.p).astype(float)
return X * self.mask / (1 - self.p) # Inverted 缩放
def backward(self, dout):
return dout * self.mask / (1 - self.p)
# 演示 Dropout 的效果
np.random.seed(42)
X = np.array([[1.0, 2.0, 3.0, 4.0, 5.0]])
print("原始输出:", X)
dropout = DropoutLayer(p=0.4)
print(" 训练模式 (多次运行,观察不同子集):")
for i in range(5):
out = dropout.forward(X.copy())
print(f" Run {i+1}: {out}")
print(" 推理模式 (确定性输出):")
dropout.training = False
out = dropout.forward(X.copy())
print(f" Inference: {out}")# 对比 Dropout 对网络泛化的影响
class SimpleNet:
"""对比有/无 Dropout 的训练效果"""
def __init__(self, input_dim=10, hidden_dim=64, output_dim=2,
dropout_rate=0.0):
self.W1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(2/input_dim)
self.b1 = np.zeros(hidden_dim)
self.W2 = np.random.randn(hidden_dim, output_dim) * np.sqrt(2/hidden_dim)
self.b2 = np.zeros(output_dim)
self.dropout = DropoutLayer(p=dropout_rate)
def forward(self, X, training=True):
self.X = X
z1 = X @ self.W1 + self.b1
self.a1 = np.maximum(0, z1)
self.dropout.training = training
self.a1_drop = self.dropout.forward(self.a1)
self.out = self.a1_drop @ self.W2 + self.b2
return self.out
def train_epoch(self, X, y, lr=0.01):
logits = self.forward(X, training=True)
# softmax + cross-entropy 简化版
exp_logits = np.exp(logits - logits.max(axis=1, keepdims=True))
probs = exp_logits / exp_logits.sum(axis=1, keepdims=True)
loss = -np.mean(np.log(probs[np.arange(len(y)), y] + 1e-8))
grad_out = probs.copy()
grad_out[np.arange(len(y)), y] -= 1
grad_out /= len(y)
dW2 = self.a1_drop.T @ grad_out
db2 = grad_out.sum(axis=0)
da1 = grad_out @ self.W2.T
dz1 = da1 * (self.a1 > 0)
dW1 = self.X.T @ self.dropout.backward(dz1)
db1 = self.dropout.backward(dz1).sum(axis=0)
for param, grad in [(self.W1, dW1), (self.b1, db1),
(self.W2, dW2), (self.b2, db2)]:
param -= lr * grad
return loss
np.random.seed(42)
X_train = np.random.randn(500, 10)
y_train = np.random.randint(0, 2, 500)
X_val = np.random.randn(200, 10)
y_val = np.random.randint(0, 2, 200)
for drop_rate in [0.0, 0.3, 0.5]:
net = SimpleNet(dropout_rate=drop_rate)
for epoch in range(200):
net.train_epoch(X_train, y_train)
# 评估
net.dropout.training = False
val_logits = net.forward(X_val, training=False)
val_pred = np.argmax(val_logits, axis=1)
acc = np.mean(val_pred == y_val)
print(f"Dropout {drop_rate:.1f}: Val Accuracy = {acc:.3f}")| Dropout 率 p | 典型使用场景 | 训练时保留比例 | 对训练速度的影响 |
|---|---|---|---|
0.0(关闭) | 推理阶段 / 小型网络 | 100% | 无影响 |
0.2-0.3 | 浅层隐藏层 | 70-80% | 轻微减慢 |
0.5 | 深层全连接层(标准值) | 50% | 明显减慢(需更多 epoch) |
0.7-0.8 | 大型网络的输出层前 | 20-30% | 显著减慢 |
不适用 | 卷积层(通常用 Spatial Dropout) | — | — |
Dropout 不要在 BatchNorm 之后使用!因为 BatchNorm 已经提供了正则化效果,二者叠加会导致训练不稳定。如果同时使用,Dropout 应放在 BatchNorm 之前。
3Batch Normalization:让每一层保持健康
Batch Normalization(BN)由 Ioffe 和 Szegedy 在 2015 年提出,是深度学习发展史上最具影响力的技术之一。它不仅加速了训练(允许使用更大的学习率),还提供了意外的正则化效果(有时可以替代 Dropout)。
BN 的核心操作非常简单:对一个 mini-batch 的数据,先减去均值、除以标准差进行标准化,然后通过学习到的缩放参数 γ 和偏移参数 β 进行线性变换。公式为:y = γ · (x - μ_B) / √(σ²_B + ε) + β。
BN 的关键洞察是:内部协变量偏移(Internal Covariate Shift)——网络每层的输入分布随着训练不断变化,迫使后续层不断适应新的分布,导致训练缓慢。BN 强制每层的输出保持稳定的均值和方差,解决了这个问题。
但 BN 有一个重大陷阱:训练和推理模式的行为完全不同。训练时用当前 mini-batch 的均值和方差;推理时用整个训练集上的移动平均(Running Mean/Var)。如果混淆了这两种模式,推理结果会完全错误。
class BatchNormLayer:
"""从零实现 Batch Normalization"""
def __init__(self, dim, momentum=0.1, eps=1e-5):
self.gamma = np.ones(dim)
self.beta = np.zeros(dim)
self.eps = eps
self.momentum = momentum
# 推理时用的移动平均
self.running_mean = np.zeros(dim)
self.running_var = np.ones(dim)
self.training = True
# 缓存用于反向传播
self.x_norm = None
self.x_mu = None
self.std_inv = None
def forward(self, X):
if self.training:
mu = X.mean(axis=0)
var = X.var(axis=0)
self.x_mu = X - mu
self.std_inv = 1.0 / np.sqrt(var + self.eps)
self.x_norm = self.x_mu * self.std_inv
# 更新移动平均
self.running_mean = (1 - self.momentum) * self.running_mean + self.momentum * mu
self.running_var = (1 - self.momentum) * self.running_var + self.momentum * var
else:
self.x_norm = (X - self.running_mean) / np.sqrt(self.running_var + self.eps)
return self.gamma * self.x_norm + self.beta
def backward(self, dout):
m = dout.shape[0]
dgamma = np.sum(dout * self.x_norm, axis=0)
dbeta = np.sum(dout, axis=0)
dx_norm = dout * self.gamma
dvar = np.sum(dx_norm * self.x_mu * -0.5 * self.std_inv**3, axis=0)
dmu = np.sum(dx_norm * -self.std_inv, axis=0) + dvar * np.mean(-2 * self.x_mu, axis=0)
dx = dx_norm * self.std_inv + dvar * 2 * self.x_mu / m + dmu / m
return dx, dgamma, dbeta
# 演示 BN 对数据分布的影响
np.random.seed(42)
X = np.random.randn(128, 64) * 5 + 10 # 均值10, 标准差5
print(f"原始: mean={X.mean():.4f}, std={X.std():.4f}")
bn = BatchNormLayer(64)
bn.training = True
X_norm = bn.forward(X)
print(f"BN后: mean={X_norm.mean():.6f}, std={X_norm.mean(axis=0).std():.6f}")
print(f"γ range: [{bn.gamma.min():.2f}, {bn.gamma.max():.2f}]")# 对比使用 BN 前后的训练速度
def train_with_and_without_bn():
"""演示 BatchNorm 如何加速训练"""
np.random.seed(42)
# 生成非线性数据
X = np.random.randn(2000, 20)
y = (np.sin(X[:, 0]) * X[:, 1] + X[:, 2]**2 > 0.5).astype(int)
for use_bn in [False, True]:
layers = []
dims = [20, 64, 64, 64, 1]
for i in range(len(dims)-1):
W = np.random.randn(dims[i], dims[i+1]) * np.sqrt(2/dims[i])
b = np.zeros(dims[i+1])
layers.append((W, b))
if use_bn and i < len(dims)-2:
layers.append(('bn', BatchNormLayer(dims[i+1])))
lr = 0.1 if use_bn else 0.01 # BN 允许更大的学习率
losses = []
for epoch in range(100):
# 简化训练循环
z = X.copy()
for layer in layers[:-1]:
if isinstance(layer, tuple) and layer[0] == 'bn':
layer[1].training = True
z = layer[1].forward(z)
else:
W, b = layer
z = z @ W + b
z = np.maximum(0, z) # ReLU
# 最后一层
W, b = layers[-1]
logits = z @ W + b
loss = np.mean((logits.flatten() - y) ** 2)
losses.append(loss)
speed = f"快 {speedup:.1f}x" if (speedup := (100-losses[-1])/(100-losses[-1]+1e-8)) else "基准"
print(f"BN={use_bn}: 最终 Loss={losses[-1]:.4f}, 初始LR={lr}")
train_with_and_without_bn()| 维度 | 训练时统计量 | 推理时统计量 | γ 和 β 可学习 | 适用场景 |
|---|---|---|---|---|
Mini-batch 维度 | 当前 batch 的均值和方差 | 移动平均(Running Mean/Var) | 是 | 全连接层 |
[N, C, H, W] | 在 N×H×W 维度统计 | 移动平均 | 是 | 卷积层(对每个通道分别归一化) |
[N, T, D] | 在 N×T 维度统计 | 移动平均 | 是 | RNN/Transformer 序列 |
Batch Size 太小(< 8)时 BN 效果很差!因为 mini-batch 的统计量估计不准确。小 batch 场景下应使用 LayerNorm 或 GroupNorm。另外,BN 对 RNN 不友好,因为序列长度可变。
4LayerNorm vs BatchNorm:归一化的路线之争
Layer Normalization(LN)由 Ba 等人在 2016 年提出,最初是为了解决 RNN 中 BatchNorm 不适用的问题。两者的根本区别在于统计量的计算维度不同:BN 在 batch 维度上统计(对同一个特征,跨样本计算均值和方差);LN 在特征维度上统计(对同一个样本,跨特征计算均值和方差)。
这个看似微小的差异带来了深远的影响。BN 的性能依赖于 batch size——batch 越大,统计量越准确;LN 完全不受 batch size 影响,因为它对每个样本单独归一化。这就是为什么 LN 成为了 Transformer 的标准配置:Transformer 训练时 batch size 经常变化,而且推理时 batch size = 1 也很常见。
另一个关键差异:BN 有移动平均机制(训练和推理行为不同),LN 没有(训练和推理行为完全一致)。这使得 LN 的实现更简单,也不容易出现训练/推理不一致的 bug。
在实践中,卷积网络首选 BN,Transformer/RNN 首选 LN,这是一个已经被广泛验证的经验法则。
class LayerNorm:
"""从零实现 Layer Normalization"""
def __init__(self, dim, eps=1e-5):
self.gamma = np.ones(dim)
self.beta = np.zeros(dim)
self.eps = eps
def forward(self, X):
# 在最后一个维度上统计(特征维度)
mu = X.mean(axis=-1, keepdims=True)
var = X.var(axis=-1, keepdims=True)
x_norm = (X - mu) / np.sqrt(var + self.eps)
return self.gamma * x_norm + self.beta
def backward(self, dout):
x = dout # 简化版,完整实现需要缓存 forward 的输入
mu = x.mean(axis=-1, keepdims=True)
var = x.var(axis=-1, keepdims=True)
std = np.sqrt(var + self.eps)
x_norm = (x - mu) / std
dgamma = np.sum(dout * x_norm, axis=-1, keepdims=True)
dbeta = np.sum(dout, axis=-1, keepdims=True)
# 简化梯度计算
m = x.shape[-1]
dx_norm = dout * self.gamma
dx = (dx_norm - dx_norm.mean(axis=-1, keepdims=True) -
x_norm * (dx_norm * x_norm).mean(axis=-1, keepdims=True)) / std
return dx, dgamma.squeeze(-1), dbeta.squeeze(-1)
# 对比 BN 和 LN 的行为差异
np.random.seed(42)
X = np.random.randn(4, 3, 8) # batch=4, seq_len=3, features=8
print(f"输入形状: {X.shape}")
bn = BatchNormLayer(8)
bn.training = True
bn_out = bn.forward(X.reshape(-1, 8)).reshape(X.shape)
ln = LayerNorm(8)
ln_out = ln.forward(X)
print(f" BN: 输出均值 = {bn_out.mean(axis=0).mean():.6f} (应接近 0)")
print(f"LN: 输出均值 = {ln_out.mean():.6f}")
print(f" BN 在样本间一致性: std across batch = {bn_out.std(axis=0).mean():.4f}")
print(f"LN 在样本间一致性: std across batch = {ln_out.std(axis=0).mean():.4f}")# Transformer 中 Pre-LN vs Post-LN 架构对比
def transformer_block_pre_ln(x, attn, ffn, ln1, ln2):
"""Pre-LayerNorm: 先归一化再变换(现代 Transformer 标准)"""
x = x + attn(ln1.forward(x))
x = x + ffn(ln2.forward(x))
return x
def transformer_block_post_ln(x, attn, ffn, ln1, ln2):
"""Post-LayerNorm: 先变换再归一化(原始 Transformer 论文)"""
x = ln1.forward(x + attn(x))
x = ln2.forward(x + ffn(x))
return x
# 演示 Pre-LN 的训练稳定性
np.random.seed(42)
dim = 64
x = np.random.randn(32, dim)
# 简化的子层
class Identity:
def forward(self, x): return x
attn = Identity()
ffn = Identity()
ln1 = LayerNorm(dim)
ln2 = LayerNorm(dim)
print("梯度流对比(模拟 10 层 Transformer 块):")
for mode in ["Pre-LN", "Post-LN"]:
x_in = np.random.randn(32, dim)
grads = [x_in.std()]
for _ in range(10):
if mode == "Pre-LN":
x_out = transformer_block_pre_ln(x_in, attn, ffn, ln1, ln2)
else:
x_out = transformer_block_post_ln(x_in, attn, ffn, ln1, ln2)
grads.append(x_out.std())
x_in = x_out
ratio = grads[-1] / grads[0] if grads[0] > 0 else 0
print(f" {mode}: 初始 std={grads[0]:.3f}, 最终 std={grads[-1]:.3f}, "
f"比率={ratio:.3f} (接近 1 表示梯度流稳定)")| 特性 | BatchNorm | LayerNorm | 对比说明 |
|---|---|---|---|
统计维度 | Batch 维度 (N, H, W) | 特征维度 (C/D) | BN 跨样本,LN 跨特征 |
依赖 Batch Size | 是(batch 小则效果差) | 否 | LN 更适合小 batch |
训练/推理差异 | 是(移动平均) | 否(行为一致) | LN 实现更简单 |
RNN 适用性 | 差(序列长度可变) | 好 | LN 是 RNN 的标准选择 |
CNN 适用性 | 极好(ImageNet 基准) | 可用但略逊 | BN 在 CNN 上仍占优 |
Transformer 适用性 | 差(需固定 batch) | 极好(标准配置) | LN 是 Transformer 的标准 |
Transformer 中使用 Pre-LayerNorm 而非 Post-LayerNorm 可以大幅提升训练稳定性。原始论文用的是 Post-LN,但后续研究发现 Pre-LN 几乎消除了梯度消失问题,允许训练更深的模型。
5GroupNorm 与 InstanceNorm:归一化的其他变体
Group Normalization(GN)由吴育昕和何恺明在 2018 年提出,直接解决了 BatchNorm 在小 batch size 下性能下降的问题。GN 将通道分成若干组,在每组内对空间维度(H×W)进行归一化。当组数 = 1 时,GN 退化为 LayerNorm;当组数 = 通道数时,GN 退化为 InstanceNorm。
Instance Normalization(IN)由 Ulyanov 等人在 2016 年为风格迁移任务提出。它对每个样本的每个通道单独归一化,完全消除了 batch 维度和通道间的信息。在风格迁移中,这恰好是我们想要的——每张图像的风格应该是独立的。
选择归一化方法的经验法则:大 batch 的图像分类 → BatchNorm;小 batch 或目标检测/分割 → GroupNorm;风格迁移 → InstanceNorm;序列模型/Transformer → LayerNorm;ResNeXt 等大通道网络 → GroupNorm。
class GroupNorm:
"""从零实现 Group Normalization"""
def __init__(self, num_channels, num_groups=32, eps=1e-5):
assert num_channels % num_groups == 0, "通道数必须能被组数整除"
self.num_groups = num_groups
self.num_channels = num_channels
self.gamma = np.ones((1, num_channels, 1, 1))
self.beta = np.zeros((1, num_channels, 1, 1))
self.eps = eps
def forward(self, X):
"""X shape: [N, C, H, W]"""
N, C, H, W = X.shape
G = self.num_groups
# 分组: [N, G, C//G, H, W]
x_reshaped = X.reshape(N, G, C // G, H, W)
mu = x_reshaped.mean(axis=(2, 3, 4), keepdims=True)
var = x_reshaped.var(axis=(2, 3, 4), keepdims=True)
x_norm = (x_reshaped - mu) / np.sqrt(var + self.eps)
return (x_norm.reshape(N, C, H, W) * self.gamma + self.beta)
# 演示不同归一化方法的分组方式
np.random.seed(42)
X = np.random.randn(2, 64, 8, 8) # N=2, C=64, H=8, W=8
norm_methods = {
"BatchNorm": "沿 N×H×W 统计,每通道独立",
"LayerNorm": "沿 C×H×W 统计,每样本独立",
"InstanceNorm": "沿 H×W 统计,每样本每通道独立",
"GroupNorm(8)": "每 8 通道一组,沿 H×W 统计",
"GroupNorm(64)": "每 1 通道一组 = InstanceNorm",
}
print("归一化方法对比 (X shape=[2, 64, 8, 8]):")
print(f"{'方法':<18} {'统计维度':<20} {'组数'}")
print("-" * 50)
print(f"{'BatchNorm':<18} {'N×H×W':<20} {'64 (每通道)'}")
print(f"{'LayerNorm':<18} {'C×H×W':<20} {'2 (每样本)'}")
print(f"{'InstanceNorm':<18} {'H×W':<20} {'2×64=128'}")
print(f"{'GroupNorm(8)':<18} {'每组 C/8×H×W':<20} {'8×2=16'}")
print(f"{'GroupNorm(64)':<18} {'每组 1×H×W':<20} {'64×2=128'}")# 小 Batch Size 下不同归一化方法的性能对比
def test_small_batch_norm():
"""模拟不同 batch size 下各归一化方法的效果"""
batch_sizes = [2, 4, 8, 16, 32, 64]
# 模拟的相对精度(基于论文数据的近似)
bn_perf = {2: 71.2, 4: 73.8, 8: 75.5, 16: 76.3, 32: 76.8, 64: 77.0}
gn_perf = {2: 75.8, 4: 76.0, 8: 76.2, 16: 76.4, 32: 76.5, 64: 76.5}
ln_perf = {2: 74.5, 4: 74.8, 8: 75.0, 16: 75.1, 32: 75.1, 64: 75.1}
print(f"{'Batch Size':>10} | {'BN':>6} | {'GN(32)':>7} | {'LN':>6}")
print("-" * 40)
for bs in batch_sizes:
print(f"{bs:>10} | {bn_perf[bs]:>6.1f} | {gn_perf[bs]:>7.1f} | {ln_perf[bs]:>6.1f}")
print(" 关键观察:")
print(f" BN 从 BS=2→64 提升了 {bn_perf[64]-bn_perf[2]:.1f}%")
print(f" GN 从 BS=2→64 仅提升了 {gn_perf[64]-gn_perf[2]:.1f}%")
print(f" BS=2 时 GN 比 BN 高 {gn_perf[2]-bn_perf[2]:.1f}%")
test_small_batch_norm()
# 目标检测中的 GroupNorm 应用
print(" GroupNorm 在目标检测中的优势:")
print(" - 检测/分割任务 batch size 通常较小(显存限制)")
print(" - GN 在小 batch 下性能稳定")
print(" - Mask R-CNN + GN 比 + BN 在 COCO 上 mAP 高 2-3%")| 方法 | 统计维度 | 对小 Batch | CNN 性能 | 主要应用场景 |
|---|---|---|---|---|
BatchNorm | N, H, W(跨样本) | 敏感(BS<8 差) | ⭐⭐⭐⭐⭐ | 图像分类(大 batch) |
LayerNorm | C, H, W(跨特征) | 不敏感 | ⭐⭐⭐ | RNN, Transformer |
InstanceNorm | H, W(单通道) | 不敏感 | ⭐⭐ | 风格迁移, 图像生成 |
GroupNorm | C/G, H, W(组内) | 不敏感 | ⭐⭐⭐⭐ | 检测, 分割, 小 batch |
SyncBN | 全局 N, H, W | 不敏感 | ⭐⭐⭐⭐⭐ | 分布式训练(多 GPU) |
如果你在训练目标检测或语义分割模型,batch size 受显存限制无法增大——直接上 GroupNorm(32 组是默认值),比死磕 BatchNorm 效果更好。
6Weight Decay:损失函数中的正则化
Weight Decay(权重衰减)是最古老也最简单的正则化方法。它的核心思想是在损失函数中加上权重的 L2 范数:L_total = L_data + λ/2 · ||w||²。这个简单的惩罚项迫使权重保持较小的值,从而限制模型的复杂度。
从数学上看,L2 正则化等价于给权重施加了一个以零为中心的高斯先验分布。权重越大,先验概率越低。这种先验偏好使得模型倾向于使用多个小权重而非少数大权重来表示同样的函数,提高了泛化能力。
一个重要的工程细节:Weight Decay 和 L2 正则化在 SGD 优化器中是等价的,但在 Adam 等自适应优化器中是不同的。AdamW(Adam with decoupled Weight Decay)由 Loshchilov 和 Hutter 在 2019 年提出,将权重衰减与梯度更新解耦,是 Adam 的正确正则化方式。实验表明,AdamW 比 Adam + L2 的泛化性能更好。
在实际训练中,通常会对不同的参数组使用不同的 weight decay:对权重使用标准 decay,对偏置(bias)和归一化层的 γ/β 不使用 decay(因为它们不应该被收缩到零)。
import numpy as np
# L2 正则化 vs Weight Decay 的区别
def compare_l2_vs_weight_decay():
"""展示在 SGD 和 Adam 中 L2 和 WD 的差异"""
print("=== SGD 优化器 ===")
print("L2 正则化: w := w - lr * (grad + λ*w)")
print("Weight Decay: w := w - lr * grad - lr * λ * w")
print("→ 两者在 SGD 中完全等价 ✓")
print(" === Adam 优化器 ===")
print("L2 正则化: w := w - lr * m_hat / (√v_hat + ε) - lr * λ * w")
print(" ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 自适应学习率调整梯度")
print("Weight Decay (AdamW): w := w - lr * m_hat / (√v_hat + ε) - lr * λ * w")
print(" 梯度项不变 权重衰减独立")
print("→ 两者在 Adam 中不同!AdamW 是正确的方式 ✓")
compare_l2_vs_weight_decay()
# Weight Decay 效果演示
np.random.seed(42)
X = np.random.randn(100, 5)
y = X[:, 0] * 2 + X[:, 1] * 0.5 + np.random.randn(100) * 0.1
for wd in [0.0, 0.001, 0.01, 0.1]:
w = np.zeros(5)
lr = 0.1
for _ in range(500):
pred = X @ w
grad = X.T @ (pred - y) / 100 + wd * w # L2 正则化
w -= lr * grad
train_loss = np.mean((X @ w - y) ** 2)
print(f"WD={wd:.4f}: weights={np.round(w, 3)}, ||w||={np.linalg.norm(w):.4f}, loss={train_loss:.4f}")# 手动实现 AdamW
class AdamW:
"""Adam with Decoupled Weight Decay"""
def __init__(self, params, lr=1e-3, betas=(0.9, 0.999),
eps=1e-8, weight_decay=1e-2):
self.params = params # list of (param_array, grad_array)
self.lr = lr
self.betas = betas
self.eps = eps
self.weight_decay = weight_decay
self.t = 0
# 状态
self.m = [np.zeros_like(p) for p, _ in params]
self.v = [np.zeros_like(p) for p, _ in params]
def step(self):
self.t += 1
for i, (param, grad) in enumerate(self.params):
# 1. 先做梯度更新(与标准 Adam 相同)
self.m[i] = self.betas[0] * self.m[i] + (1 - self.betas[0]) * grad
self.v[i] = self.betas[1] * self.v[i] + (1 - self.betas[1]) * grad**2
m_hat = self.m[i] / (1 - self.betas[0]**self.t)
v_hat = self.v[i] / (1 - self.betas[1]**self.t)
# 2. 参数更新(Adam 部分)
param -= self.lr * m_hat / (np.sqrt(v_hat) + self.eps)
# 3. 解耦的权重衰减(独立于梯度)
param -= self.lr * self.weight_decay * param
# 验证 AdamW 与 Adam + L2 的差异
np.random.seed(42)
w1 = np.random.randn(10) # AdamW
w2 = w1.copy() # Adam + L2
grad = np.random.randn(10) * 0.5
# AdamW 更新
adamw = AdamW([(w1, grad.copy())], lr=0.1, weight_decay=0.01)
adamw.step()
# Adam + L2 更新(grad 中已包含 L2 项)
grad_l2 = grad + 0.01 * w2
adam_l2 = AdamW([(w2, grad_l2)], lr=0.1, weight_decay=0.0)
adam_l2.step()
print("AdamW vs Adam+L2 差异:")
print(f" AdamW: ||w|| = {np.linalg.norm(w1):.6f}")
print(f" Adam+L2:||w|| = {np.linalg.norm(w2):.6f}")
print(f" 差异: {np.linalg.norm(w1 - w2):.6f}")| 优化器 | L2 正则化 | Weight Decay | 推荐使用 | 原因 |
|---|---|---|---|---|
SGD | 等价 | 等价 | 两者皆可 | 数学上完全相同 |
Adam | 不等价(次优) | 解耦后更优 | AdamW | L2 被自适应学习率缩放 |
SGDW | 不等价 | 解耦 | SGDW | 即使 SGD 也有细微差异 |
AdamW | — | 原生支持 | AdamW | 2019 年论文推荐的标准做法 |
偏置(bias)和归一化层的参数(BN/LN 的 γ, β)不应该加 Weight Decay!偏置本身就不容易过拟合(每个样本共享),归一化层的 γ 控制输出尺度,收缩它会导致表达能力下降。
7实战:正则化组合策略对比实验
在实际项目中,我们很少只使用一种正则化方法。Dropout、BatchNorm、Weight Decay 经常组合使用,但组合策略并非简单的"越多越好"。
Dropout + BatchNorm 是一个经典的争议话题。BN 本身已经有一定的正则化效果(mini-batch 统计量引入了噪声),再加上 Dropout 可能导致训练困难。经验法则是:如果用了 BN,可以大幅降低 Dropout 率(从 0.5 降到 0.1-0.2),或者在 BN 之前的层使用 Dropout。
对于现代 ResNet 架构,标准的正则化组合是:BN(内置)+ Weight Decay(0.0001-0.0005)+ 数据增强。Dropout 在全连接分类层之前使用(0.5),但卷积层中通常不用。
对于 Transformer,标准组合是:LayerNorm(Pre-LN 结构)+ Weight Decay(0.01-0.1,通过 AdamW)+ Dropout(attention dropout + residual dropout,通常 0.1)+ 学习率 Warmup。
# 正则化组合对比实验框架
class RegularizationExperiment:
"""系统对比不同正则化组合的效果"""
def __init__(self, input_dim=784, hidden_dim=256, num_classes=10):
self.configs = {
"baseline": {"dropout": 0.0, "bn": False, "wd": 0.0},
"dropout": {"dropout": 0.5, "bn": False, "wd": 0.0},
"batchnorm": {"dropout": 0.0, "bn": True, "wd": 0.0},
"wd_only": {"dropout": 0.0, "bn": False, "wd": 0.001},
"bn+wd": {"dropout": 0.0, "bn": True, "wd": 0.001},
"drop+wd": {"dropout": 0.5, "bn": False, "wd": 0.001},
"all": {"dropout": 0.5, "bn": True, "wd": 0.001},
"bn+drop0.2": {"dropout": 0.2, "bn": True, "wd": 0.001},
}
self.results = {}
def run(self, X_train, y_train, X_val, y_val, epochs=50):
for name, cfg in self.configs.items():
# 模拟训练(用简化模型)
np.random.seed(42)
W1 = np.random.randn(X_train.shape[1], 256) * np.sqrt(2/X_train.shape[1])
b1 = np.zeros(256)
W2 = np.random.randn(256, 10) * np.sqrt(2/256)
b2 = np.zeros(10)
best_val_acc = 0.0
lr = 0.01
for epoch in range(epochs):
# 简化的训练步骤
z = X_train @ W1 + b1
if cfg["bn"]:
z = (z - z.mean()) / (z.std() + 1e-5)
a = np.maximum(0, z)
if cfg["dropout"] > 0:
mask = (np.random.rand(*a.shape) > cfg["dropout"]).astype(float)
a = a * mask / (1 - cfg["dropout"])
logits = a @ W2 + b2
# 梯度更新(简化)
grad_W1 = np.random.randn(*W1.shape) * 0.1
grad_W2 = np.random.randn(*W2.shape) * 0.1
W1 -= lr * (grad_W1 + cfg["wd"] * W1)
W2 -= lr * (grad_W2 + cfg["wd"] * W2)
# 模拟验证集精度
val_acc = 0.6 + 0.3 * (1 - np.exp(-epoch/10))
if cfg["bn"]: val_acc += 0.03
if cfg["dropout"] > 0.3: val_acc += 0.02
if cfg["dropout"] > 0.3 and cfg["bn"]: val_acc -= 0.01 # 冲突
val_acc += np.random.randn() * 0.01
best_val_acc = max(best_val_acc, val_acc)
self.results[name] = {
"val_acc": round(best_val_acc, 3),
"config": cfg
}
return self.results
exp = RegularizationExperiment()
results = exp.run(None, None, None, None)
print(f"{'组合策略':<15} | {'Dropout':>7} | {'BN':>4} | {'WD':>6} | {'验证精度':>8}")
print("-" * 55)
for name, r in results.items():
cfg = r["config"]
print(f"{name:<15} | {cfg['dropout']:>7.1f} | {str(cfg['bn']):>4} | {cfg['wd']:>6.4f} | {r['val_acc']:>8.3f}")# 不同架构的正则化最佳实践推荐
def get_best_practice(architecture):
"""根据架构类型推荐正则化策略"""
best_practices = {
"ResNet (CNN)": {
"layers": "BN 在每层卷积后",
"dropout": "仅在最终 FC 层前 (p=0.5)",
"weight_decay": "0.0001-0.0005 (SGD)",
"data_aug": "RandomCrop + HorizontalFlip + ColorJitter",
"lr_schedule": "Cosine Annealing + Warmup (5 epochs)",
},
"Transformer (NLP)": {
"layers": "Pre-LayerNorm (每个子层前)",
"dropout": "attention=0.1, residual=0.1, embedding=0.1",
"weight_decay": "0.01-0.1 (AdamW)",
"data_aug": "不适用",
"lr_schedule": "Linear Warmup (10% steps) + Cosine Decay",
},
"Vision Transformer": {
"layers": "Pre-LayerNorm",
"dropout": "attention=0.0, projection=0.0 (大模型可不用)",
"weight_decay": "0.05 (AdamW)",
"data_aug": "Mixup + CutMix + RandAugment",
"lr_schedule": "Cosine Decay + 5 epoch Warmup",
},
"RNN/LSTM": {
"layers": "LayerNorm (在 LSTM 内部)",
"dropout": "input=0.2, hidden=0.2 (层间 Dropout)",
"weight_decay": "0.001 (Adam)",
"data_aug": "不适用",
"lr_schedule": "ReduceLROnPlateau",
},
}
if architecture in best_practices:
print(f" {'='*50}")
print(f" {architecture} 最佳正则化策略")
print(f"{'='*50}")
for key, value in best_practices[architecture].items():
print(f" {key:>15}: {value}")
else:
print(f"未知架构: {architecture}")
# 打印所有最佳实践
for arch in ["ResNet (CNN)", "Transformer (NLP)", "Vision Transformer", "RNN/LSTM"]:
get_best_practice(arch)| 策略组合 | 训练速度 | 验证精度 | 过拟合控制 | 适用场景 |
|---|---|---|---|---|
Baseline(无正则化) | 最快 | 基准 | 差 | 仅用于对照实验 |
仅 Dropout (0.5) | 慢(需更多 epoch) | +2-3% | 好 | 小型全连接网络 |
仅 BatchNorm | 快(可用大 LR) | +3-5% | 中等 | CNN 图像分类 |
BN + WD (0.001) | 快 | +4-6% | 好 | ResNet 标准配置 |
BN + Dropout (0.5) | 慢且不稳定 | +1-2% | 过度 | ⚠️ 不推荐同时用 |
BN + Dropout (0.2) | 中等 | +3-4% | 好 | BN 后使用小 Dropout |
LN + WD + Dropout | 中等 | +5-8% | 极好 | Transformer 标准配置 |
正则化不是越多越好!BN + Dropout 同时使用时,Dropout 率从 0.5 降到 0.1-0.2。大模型(如 ViT-Huge)甚至可以去掉 Dropout,依靠 Weight Decay 和数据增强就够了。记住:奥卡姆剃刀——最简单的有效方案就是最好的方案。