训练调优实战
本章问题: loss 一直震荡不下降怎么办? 学习率一开始大后面小, 怎么设? 怎么判断模型是真没收敛还是已经过拟合? 学完这一章, 训练中遇到的 90% 问题都能自己诊断。
1. 训练中的 7 大症状 + 处方
1.1 症状速查表
| 症状 | 可能原因 | 第一招 |
|---|---|---|
| loss 不下降, 一直 NaN | lr 太大 | 砍 lr 10 倍 |
| loss 震荡, 不收敛 | lr 太大 / batch 太小 | 砍 lr / 加 batch |
| loss 卡住, 长时间不动 | lr 太小 / 卡鞍点 | 加 lr / 换 Adam |
| train loss 降, test loss 涨 | 过拟合 | 加正则 / Early stop |
| train loss 降得慢 | 模型/优化器不够 | 调大模型 / 换 Adam |
| loss 曲线像台阶 | batch_size 太小 | 加 batch_size |
| train/test loss 都很高 | 特征工程 / 模型问题 | 检查数据 + 模型 |
# 一键诊断函数
def diagnose(history):
train = history['train_loss']
test = history.get('test_loss')
print(f"最终 train loss: {train[-1]:.4f}")
if test:
print(f"最终 test loss: {test[-1]:.4f}")
print(f"过拟合程度: {(test[-1] - train[-1]):.4f}")
if train[-1] > train[0] * 0.99:
print("❌ loss 没降 → 学习率或模型问题")
elif train[-1] < train[0] * 0.5 and test and test[-1] > test[0] * 0.95:
print("⚠️ train 降, test 涨 → 过拟合")
else:
print("✓ 训练正常")
2. 学习率调度 (Learning Rate Schedule)
2.1 为什么需要调度?
训练初期: 用大学习率快速逼近最优解 训练后期: 用小学习率精细调整
2.2 5 种常用 schedule
import torch
import matplotlib.pyplot as plt
import numpy as np
# 5 种学习率调度对比
epochs = 100
eta_0 = 1.0
# 1) Step (阶梯衰减)
step = [eta_0 * (0.1 ** (i // 30)) for i in range(epochs)]
# 2) MultiStep
ms = [eta_0 * (0.1 ** sum(1 for m in [30, 60, 80] if i >= m)) for i in range(epochs)]
# 3) Exponential (指数衰减)
exp = [eta_0 * 0.95**i for i in range(epochs)]
# 4) Cosine (余弦退火, 论文常用)
cos = [eta_0 * 0.5 * (1 + np.cos(np.pi * i / epochs)) for i in range(epochs)]
# 5) Linear warmup + cosine
warmup = 10
warmup_cos = []
for i in range(epochs):
if i < warmup:
warmup_cos.append(eta_0 * i / warmup)
else:
warmup_cos.append(eta_0 * 0.5 * (1 + np.cos(np.pi * (i-warmup) / (epochs-warmup))))
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(step, label='Step')
ax.plot(ms, label='MultiStep')
ax.plot(exp, label='Exponential')
ax.plot(cos, label='Cosine')
ax.plot(warmup_cos, label='Warmup + Cosine', lw=3)
ax.set_xlabel('epoch'); ax.set_ylabel('learning rate')
ax.legend(); ax.grid(alpha=0.3)
ax.set_title('5 种学习率调度')
plt.show()
2.3 PyTorch 实现
# 5 种 schedule 对应的 PyTorch API
from torch.optim.lr_scheduler import (
StepLR, MultiStepLR, ExponentialLR, CosineAnnealingLR,
LinearLR, CosineAnnealingWarmRestarts
)
# 用法 (Adam + Cosine + Warmup)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
# 1) warmup 5 epoch: lr 从 0 → 1e-3
# 2) cosine 95 epoch: lr 从 1e-3 → 0
warmup = torch.optim.lr_scheduler.LinearLR(optimizer, start_factor=0, end_factor=1, total_iters=5)
cosine = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=95)
scheduler = torch.optim.lr_scheduler.SequentialLR(optimizer, [warmup, cosine], [5])
for epoch in range(100):
train(...)
scheduler.step()
2.4 Warmup 为什么必要?
Transformer 训练早期, Adam 的二阶矩估计不准 (样本太少)。前几步用很小 lr "暖机", 让统计量先稳定。
# ResNet/Transformer 训练标配
# - 前 5-10 epoch: lr 从 0 线性涨到目标值
# - 之后: cosine 慢慢降到 0
3. Loss 震荡的 5 个原因 + 解决
3.1 原因 1: 学习率太大 (最常见)
# 症状: loss 像心电图一样跳
# 解决: lr 砍 10 倍
import torch
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) # 从 1e-3 改成 1e-4
3.2 原因 2: batch_size 太小
# 症状: loss 上下跳动大
# 解决: batch_size 翻倍, lr 也翻倍 (线性缩放)
from torch.utils.data import DataLoader
loader = DataLoader(dataset, batch_size=64, shuffle=True) # 从 32 改 64
3.3 原因 3: 数据噪声大 (label 错误 / 异常值)
# 检查: 画 label 分布, 看有没有错误标注
import matplotlib.pyplot as plt
labels = [dataset[i][1] for i in range(len(dataset))]
plt.hist(labels, bins=10); plt.title('Label distribution'); plt.show()
# 解决: 清洗数据 / 用 robust loss (Huber loss)
import torch.nn as nn
loss = nn.HuberLoss(delta=1.0) # 比 MSE 更抗噪声
3.4 原因 4: 网络结构问题 (梯度爆炸)
# 症状: loss 突然 NaN
# 解决 1: 梯度裁剪
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# 解决 2: Batch Normalization (让激活值分布稳定)
self.bn = nn.BatchNorm1d(256)
# 解决 3: 残差连接 (让梯度能回流)
self.skip = nn.Identity() # x + self.conv(x)
3.5 原因 5: 优化器陷入局部最优
# 症状: loss 卡在某个值很久不动
# 解决: 换 optimizer / 加 momentum / 用 SGD 跳出
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
3.6 综合诊断脚本
def diagnose_loss_curve(losses, window=10):
"""看 loss 曲线诊断"""
if not losses or len(losses) < window:
return "数据不够"
avg_first = np.mean(losses[:window])
avg_last = np.mean(losses[-window:])
std_last = np.std(losses[-window:])
if avg_last > avg_first * 0.99:
return "❌ loss 没降: lr/模型问题"
if std_last > avg_last * 0.1:
return "⚠️ 震荡大: 砍 lr / 加 batch"
if avg_last < avg_first * 0.5:
return "✓ 正常下降"
return "⚠️ 下降慢: 考虑 lr / 模型"
# 用法
losses = [1.0, 0.9, 0.8, ...] # 训练过程记录的 loss
print(diagnose_loss_curve(losses))
4. Early Stopping (早停)
4.1 原理
训练时监控验证集 loss, 如果连续 N 个 epoch 不下降, 就停。
class EarlyStopping:
def __init__(self, patience=5, min_delta=1e-4):
self.patience = patience
self.min_delta = min_delta
self.counter = 0
self.best_loss = float('inf')
def step(self, val_loss):
if val_loss < self.best_loss - self.min_delta:
self.best_loss = val_loss
self.counter = 0
return False # 不停
else:
self.counter += 1
return self.counter >= self.patience # 达到耐心值就停
# 训练循环
early_stop = EarlyStopping(patience=10)
for epoch in range(1000):
train(...)
val_loss = validate(model, val_loader)
if early_stop.step(val_loss):
print(f"Early stop at epoch {epoch}")
break
4.2 跟 checkpoint 配合
# 保存验证集 loss 最低的模型
best_val = float('inf')
for epoch in range(1000):
train(...)
val_loss = validate(...)
if val_loss < best_val:
best_val = val_loss
torch.save(model.state_dict(), 'best.pt')
5. 梯度裁剪 (Gradient Clipping)
5.1 解决梯度爆炸
RNN / 深层网络里梯度可能爆炸 (梯度值突然巨大), 导致参数更新飞出去。
# 方法 1: 按范数裁剪 (最常用)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# 方法 2: 按值裁剪
torch.nn.utils.clip_grad_value_(model.parameters(), clip_value=0.5)
# 训练循环
for X, y in loader:
optimizer.zero_grad()
loss = criterion(model(X), y)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # ← 加这行
optimizer.step()
5.2 max_norm 怎么选?
| max_norm | 效果 |
|---|---|
| 0.1 | 强裁剪, 训练很稳但慢 |
| 1.0 | 常用默认 |
| 5.0 | 弱裁剪, 接近不裁剪 |
| None | 不裁剪 (小心梯度爆炸) |
6. 训练监控 (看什么图)
6.1 必备的 3 张图
import matplotlib.pyplot as plt
def plot_training(history):
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
# 1. Loss 曲线 (看收敛)
axes[0].plot(history['train_loss'], label='train')
axes[0].plot(history['val_loss'], label='val')
axes[0].set_title('Loss'); axes[0].set_xlabel('epoch'); axes[0].legend()
axes[0].grid(alpha=0.3)
# 2. 准确率曲线 (看泛化)
axes[1].plot(history['train_acc'], label='train')
axes[1].plot(history['val_acc'], label='val')
axes[1].set_title('Accuracy'); axes[1].set_xlabel('epoch'); axes[1].legend()
axes[1].grid(alpha=0.3)
# 3. 学习率曲线 (确认 schedule)
axes[2].plot(history['lr'])
axes[2].set_title('Learning Rate'); axes[2].set_xlabel('epoch')
axes[2].grid(alpha=0.3)
plt.tight_layout()
plt.show()
# TensorBoard 更强大
from torch.utils.tensorboard import SummaryWriter
writer = SummaryWriter('runs/exp1')
for epoch in range(100):
writer.add_scalar('train/loss', train_loss, epoch)
writer.add_scalar('val/loss', val_loss, epoch)
writer.add_scalar('lr', current_lr, epoch)
6.2 异常曲线诊断
train ↓ val ↓ = ✓ 正常训练
train ↓ val ↑ = ⚠️ 过拟合, 加正则/早停
train ↑ val ↑ = ❌ 学习率太大
train ― val ― = ❌ 学习率太小 / 模型没能力
train ↓ val ― = ⚠️ 验证集和训练集分布不一样
7. 权重初始化 (避免初始 loss 震荡)
# 错误的初始化 (容易梯度消失/爆炸)
layer = nn.Linear(100, 100) # 默认是 Kaiming uniform
# 深层网络 → 激活值方差爆炸或消失
# 正确的初始化
nn.init.kaiming_normal_(layer.weight, mode='fan_in', nonlinearity='relu') # ReLU 网络
nn.init.xavier_normal_(layer.weight) # tanh/sigmoid
# 实战: 模型定义后加这一段
def init_weights(m):
if isinstance(m, nn.Linear):
nn.init.kaiming_normal_(m.weight, nonlinearity='relu')
nn.init.zeros_(m.bias)
model.apply(init_weights)
8. 实战检查清单
训练前
- 数据归一化/标准化 (尤其 SGD)
- 权重初始化 (kaiming / xavier)
- 选优化器 + 默认 lr
- 写训练循环 (含梯度裁剪、early stop、checkpoint)
训练中
- 看 loss 曲线, 确认收敛趋势
- 看 train-val gap, 评估过拟合
- 监控学习率, 确认 schedule 生效
- 记录 best checkpoint
出问题时
- NaN → lr 砍 10 倍 + 梯度裁剪
- 震荡 → lr 砍 10 倍 / batch 翻倍
- 卡住 → 加 lr / 换 Adam
- 过拟合 → 加正则 / early stop / 数据增强
9. 实战: PyTorch 完整训练循环模板
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
def train_model(model, train_loader, val_loader, epochs=100, lr=1e-3):
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
criterion = nn.CrossEntropyLoss()
# 学习率调度 (warmup + cosine)
warmup = torch.optim.lr_scheduler.LinearLR(optimizer, start_factor=0.01, end_factor=1.0, total_iters=5)
cosine = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs-5)
scheduler = torch.optim.lr_scheduler.SequentialLR(optimizer, [warmup, cosine], [5])
# 早停
early_stop = EarlyStopping(patience=10)
best_val_loss = float('inf')
history = {'train_loss': [], 'val_loss': [], 'val_acc': [], 'lr': []}
for epoch in range(epochs):
# 训练
model.train()
train_loss = 0
for X, y in train_loader:
X, y = X.to(device), y.to(device)
optimizer.zero_grad()
loss = criterion(model(X), y)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
train_loss += loss.item() * len(y)
train_loss /= len(train_loader.dataset)
# 验证
model.eval()
val_loss, correct = 0, 0
with torch.no_grad():
for X, y in val_loader:
X, y = X.to(device), y.to(device)
pred = model(X)
val_loss += criterion(pred, y).item() * len(y)
correct += (pred.argmax(1) == y).sum().item()
val_loss /= len(val_loader.dataset)
val_acc = correct / len(val_loader.dataset)
# 记录
history['train_loss'].append(train_loss)
history['val_loss'].append(val_loss)
history['val_acc'].append(val_acc)
history['lr'].append(optimizer.param_groups[0]['lr'])
# 更新 lr + 保存 best + 早停
scheduler.step()
if val_loss < best_val_loss:
best_val_loss = val_loss
torch.save(model.state_dict(), 'best.pt')
if early_stop.step(val_loss):
print(f"Early stop at epoch {epoch}")
break
print(f'Epoch {epoch}: train={train_loss:.4f} val={val_loss:.4f} acc={val_acc:.2%}')
return model, history
10. 本章小结
| 工具 | 解决什么 | 一行代码 |
|---|---|---|
| LR schedule | 后期精细调整 | CosineAnnealingLR |
| Early stop | 过拟合 | EarlyStopping(patience=10) |
| 梯度裁剪 | 梯度爆炸 | clip_grad_norm_(max_norm=1) |
| AdamW | 通用训练 | AdamW(lr=1e-3, wd=0.01) |
| Warmup | 训练初期不稳定 | LinearLR(start_factor=0) |
| 混合精度 | 训练加速 | torch.cuda.amp.autocast() |
| 梯度累积 | 小显存模拟大 batch | 手动累加 loss |
| BN / LayerNorm | 训练稳定 | nn.BatchNorm1d() |
🎯 一句话总结: 训练出问题, 90% 是 lr 错了。先砍 10 倍, 还不行就加 warmup + 梯度裁剪 + 早停, 三件套搞定。
练习
- 复现: 在 MNIST 上跑完整训练循环, 故意设 lr=1.0, 看 NaN; 再改 lr=1e-3, 跑通。
- 实验: 对比 SGD vs Adam vs AdamW 在 CIFAR-10 上的 test accuracy。
- 思考: 为什么 Transformer 必须用 warmup, CNN 经常不用?
- 实战: 找一个你之前的训练脚本, 加上 early stopping 和梯度裁剪, 看会不会更好。
拓展阅读
- D2L 第 11 章 (李沐等): 本章主要参考
- Loshchilov & Hutter (2017) SGDR: Stochastic Gradient Descent with Warm Restarts (Cosine Annealing)
- Smith (2017) Cyclical Learning Rates for Training Neural Networks
- PyTorch 官方教程: https://pytorch.org/tutorials/beginner/blitz/cifar10_tutorial.html
章末小测验
检验你对《训练调优实战》的掌握程度。
1
training-tuning 的核心概念是?
还有疑问? 问问 AI (v19.5)
基于全站 19 门课 68 章内容检索 + LLM 总结, 会引用具体章节作为出处
讨论区(0)
加载评论中...