ML 学习站
跳到正文

训练调优实战

Loss 震荡诊断 + 学习率调度 + Early stop + 梯度裁剪 + PyTorch 完整训练模板。

40 分钟3 / 32,893
加载中...

训练调优实战

本章问题: loss 一直震荡不下降怎么办? 学习率一开始大后面小, 怎么设? 怎么判断模型是真没收敛还是已经过拟合? 学完这一章, 训练中遇到的 90% 问题都能自己诊断。

1. 训练中的 7 大症状 + 处方

1.1 症状速查表

症状可能原因第一招
loss 不下降, 一直 NaNlr 太大砍 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 + 梯度裁剪 + 早停, 三件套搞定。

练习

  1. 复现: 在 MNIST 上跑完整训练循环, 故意设 lr=1.0, 看 NaN; 再改 lr=1e-3, 跑通。
  2. 实验: 对比 SGD vs Adam vs AdamW 在 CIFAR-10 上的 test accuracy。
  3. 思考: 为什么 Transformer 必须用 warmup, CNN 经常不用?
  4. 实战: 找一个你之前的训练脚本, 加上 early stopping 和梯度裁剪, 看会不会更好。

拓展阅读

章末小测验

检验你对《训练调优实战》的掌握程度。

1

training-tuning 的核心概念是?

学完这章?

🎉 你已经学完这门课!

回到课程目录, 或者生成证书 / 推荐下一门课。

还有疑问? 问问 AI (v19.5)

基于全站 19 门课 68 章内容检索 + LLM 总结, 会引用具体章节作为出处

讨论区(0)

加载评论中...