🏠 总目录📚 本教程 11 · 训练调试手册
📑 本页目录(点开跳转)

11 · 训练调试手册

34 分钟 | ⭐⭐⭐ 最难自学、最值钱的一章

教科书教你模型怎么工作,没人教你模型不工作时该怎么办。 这一章就是那本没人写的手册。


🎯 一句话

调试深度学习的核心方法论只有一条:先证明模型能过拟合,再考虑泛化。 在你的模型能把 10 个样本背下来之前,讨论任何调参都是浪费时间。


🩺 一、黄金诊断流程(按顺序做,别跳)

   第 0 步:在【10 个样本】上训练,能不能把 loss 降到接近 0?
            │
            ├─ ❌ 不能  → 有 BUG,别调参了,去第二节查
            │
            └─ ✅ 能    → 模型和数据管线是通的
                          │
   第 1 步:全量数据训练,训练 loss 降吗?
            │
            ├─ ❌ 不降  → 欠拟合:加容量 / 加 lr / 减正则
            │
            └─ ✅ 降    → 看验证 loss
                          │
   第 2 步:验证 loss 跟着降吗?
            │
            ├─ ❌ 不降或上升 → 过拟合:加数据 / 加正则 / 早停
            │
            └─ ✅ 降       → 恭喜,现在才轮到调参提分

🔑 第 0 步是全章最重要的一步,却是最多人跳过的。 它能在 30 秒内把「模型有 bug」和「模型没调好」区分开——这两件事的解法完全不同。

# 第 0 步的标准写法
tiny_x, tiny_y = X[:10], y[:10]
for step in range(500):
    optimizer.zero_grad()
    loss = criterion(model(tiny_x), tiny_y)
    loss.backward(); optimizer.step()
    if step % 100 == 0: print(step, loss.item())
# 期望:loss 降到 < 0.01。降不下去 = 有 bug

⚠️ 做第 0 步时记得把 Dropout 和数据增强关掉—— 它们会让"背下 10 个样本"变得不可能,你会误判成有 bug。

🔢 还有一个 5 秒的自检:第一个 loss 应该是多少

   分类任务(C 类,初始时模型在均匀乱猜):
       第一个 loss ≈ ln(C)

       2 类  → 0.693
       10 类 → 2.303
       100 类→ 4.605

   ⭐ 明显偏离说明:
      · 大得多  → 初始化太大 / 标签错位 / 输出层有问题
      · 小得多  → 【标签泄漏】或者类别极度不平衡

💡 这是全章性价比最高的一个检查——一行 print,能挡掉一大类事故。 回归任务的对应版本:第一个 MSE 应该 ≈ 标签的方差(因为模型在预测均值附近)。


🐛 二、症状 → 病因速查表

loss 相关

症状 最可能的原因 怎么查/怎么修
loss = NaN ① lr 太大 ② log(0) ③ 除 0 ④ 数据里有 NaN/inf lr÷10;log 加 1e-9;torch.isnan(X).any() 查数据
loss 完全不动(逐位相同) ① 忘了 optimizer.step() ② 梯度断了(用了 .detach() / .item() / no_grad)③ 输入全是 0 ⚠️ 几乎不会是 lr 太小——逐位相同是 bug,不是超参第 9 章的诊断表专门把这两种病分开) 打印 param.grad.abs().mean() 看梯度是不是 0
loss 下降极慢但稳定 lr 太小 lr × 3;这一条才是超参问题
loss 剧烈震荡 lr 太大 / batch 太小 lr÷3;加大 batch;梯度裁剪
loss 一开始就很低 ⚠️ 标签泄漏或数据有问题 查特征里是不是混进了标签衍生物(第 5 章)
loss 降到某值就平 陷入平台 / lr 需要衰减 加 scheduler;换优化器
10 个样本都过拟合不了 有 bug 见下面「必查五项」

验证集相关

症状 诊断
训练降、验证升 典型过拟合 → 第 10 章
训练验证都不降 欠拟合或 bug → 回第 0 步
验证 loss 低于训练 loss 正常现象(Dropout 只在训练时开);若差距过大则可能验证集太简单或泄漏
验证集分数每次跑差很多 验证集太小 / 没固定随机种子
离线好、线上崩 数据分布漂移 / 泄漏 / 预处理不一致(推荐算法第 15 章)

🔍 三、「10 个样本过拟合不了」的必查五项

① 标签对齐了吗?
   打印几个 (输入, 标签) 对,肉眼确认没错位
   ⚠️ shuffle 时 X 和 y 分别 shuffle 是经典事故

② 梯度真的在流吗?
   for n,p in model.named_parameters():
       print(n, p.grad.abs().mean() if p.grad is not None else "None")
   → 出现 None 或 0:梯度断了

③ loss 函数用对了吗?
   · 多分类用 CrossEntropyLoss(输入是 logits,不要自己加 softmax!)
   · 二分类用 BCEWithLogitsLoss(不要自己加 sigmoid!)
   ⚠️ 重复加激活是最高频的错误之一

④ 输入的形状/数值对吗?
   print(X.shape, X.dtype, X.min(), X.max(), X.mean())
   → 全 0?没归一化?dtype 是 int 不是 float?

⑤ lr 是不是离谱?
   试 1e-2 / 1e-3 / 1e-4 三档,如果全都不动,那是 bug 不是 lr

🔨 一个能一次性查完的自检函数

def sanity_check(model, loader, criterion, device):
    xb, yb = next(iter(loader))
    xb, yb = xb.to(device), yb.to(device)

    # ① 输入长什么样
    print(f"X {tuple(xb.shape)} {xb.dtype} "
          f"min={xb.min():.3f} max={xb.max():.3f} mean={xb.mean():.3f}")
    print(f"y {tuple(yb.shape)} {yb.dtype} unique={yb.unique()[:10].tolist()}")

    # ② 第一个 loss 对不对
    out = model(xb)
    loss = criterion(out, yb)
    print(f"输出 {tuple(out.shape)} mean={out.mean():.3f} std={out.std():.3f}")
    print(f"首个 loss = {loss.item():.4f}  (分类应≈ln(类别数))")   # ⭐

    # ③ 梯度流通吗
    loss.backward()
    dead = [n for n, p in model.named_parameters()
            if p.grad is None or p.grad.abs().max() == 0]
    print(f"无梯度的参数: {dead if dead else '无 ✅'}")            # ⭐
    model.zero_grad()

💡 把它加进你每个训练脚本的开头。三行输出, 能挡掉「标签错位、没归一化、dtype 错、输出层维度错、梯度断流」五类事故。

🕵️ 「梯度断流」的四个常见来源

来源 长什么样
.detach() / .data 显式切断,通常是复制代码时抄来的
.item() / float() 参与了计算 张量变成了 Python 数字,计算图断了 ⭐
with torch.no_grad(): 包多了 把训练的前向也包进去了
原地操作(in-place) x += 1 有时会破坏计算图,改成 x = x + 1

⚠️ 还有一个更隐蔽的参数没被传给优化器

# ❌ 后来加的层没进优化器 → 它的梯度算了但永远不更新
import torch
import torch.nn as nn
optimizer = torch.optim.AdamW(model.encoder.parameters(), lr=1e-3)
model.head = nn.Linear(512, 10)      # 这层永远不会被训练 💀

# ✅ 自检:优化器管的参数数量对不对
n_opt = sum(p.numel() for g in optimizer.param_groups for p in g["params"])
n_all = sum(p.numel() for p in model.parameters() if p.requires_grad)
assert n_opt == n_all, f"优化器只管了 {n_opt}/{n_all} 个参数!"   # ⭐

📊 四、必须打印的六个数字

不看这些就是盲调:

# 每个 epoch 打印
print(f"epoch {e} | train {train_loss:.4f} | val {val_loss:.4f} "
      f"| lr {optimizer.param_groups[0]['lr']:.2e} "
      f"| grad {grad_norm:.3f} | 输出均值 {out.mean():.3f} | 用时 {t:.1f}s")
数字 异常信号
train loss 不降 → 欠拟合/bug
val loss 上升 → 过拟合
lr 忘了 scheduler 在动?值对吗?
梯度范数 ≈0 梯度消失;巨大 梯度爆炸
输出分布 全一样 → 模型退化;范围离谱 → 输出层有问题
每 epoch 用时 突然变慢 → 数据管线瓶颈
import torch
grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1e9).item()  # 只测量不裁剪

⚡ 五、性能问题:GPU 跑不满

症状 原因 解法
GPU 利用率忽高忽低 数据加载是瓶颈 ⭐ 最常见 num_workers=4~8pin_memory=True、预处理提前做好
显存 OOM batch 太大 / 没释放 减 batch;torch.cuda.empty_cache();梯度累积;混合精度
训练很慢 没用 GPU / 没用混合精度 确认 .to(device);开 AMP
# 梯度累积:用小显存模拟大 batch
for i, (xb, yb) in enumerate(loader):
    loss = criterion(model(xb), yb) / accum_steps
    loss.backward()
    if (i + 1) % accum_steps == 0:
        optimizer.step(); optimizer.zero_grad()

✅ 六、可复现性检查单

☐ 固定所有随机种子(python / numpy / torch / cuda)
☐ DataLoader 的 shuffle 也受种子控制
☐ 记录:代码版本、超参、数据版本、库版本
☐ 同样配置跑 3 次,看方差有多大  ⭐
     └ 这个方差决定了「涨 0.3% 算不算真的涨」(第 5 章)
def set_seed(s=42):
    import random, numpy as np, torch
    random.seed(s); np.random.seed(s)
    torch.manual_seed(s); torch.cuda.manual_seed_all(s)
    torch.backends.cudnn.deterministic = True

🧠 七、五条心法

  1. 一次只改一个变量。 同时改了 lr 和模型结构,涨了你也不知道是谁的功劳。
  2. 先让它跑通,再让它跑对,最后才让它跑好。 顺序不能反。
  3. 相信数字,不相信感觉。 「我觉得这样更好」→ 跑一遍看验证集。
  4. 最简单的解释通常是对的。 loss 不动,先怀疑 zero_grad() 和标签对齐,别先怀疑优化器理论。
  5. 保留一个能跑通的 baseline。 改崩了能回退——这比什么都重要。

🔗 这五条和你在智能体工程第 14 章学的评测纪律是同一套思维方式。


✅ 检查点

  1. 黄金诊断流程的第 0 步是什么?为什么它最重要?做的时候要注意关掉什么?
  2. 分类任务第一个 loss 应该是多少?偏大和偏小分别说明什么?
  3. loss = NaN 有哪四个常见原因?
  4. loss 完全不动,最先该打印什么?
  5. 梯度断流有哪四个常见来源?还有一个更隐蔽的是什么?
  6. CrossEntropyLoss 前面该不该自己加 softmax?
  7. GPU 利用率忽高忽低最可能是什么问题?
  8. 为什么要"同样配置跑 3 次"?
👀 答案
  1. 在 10 个样本上训练,看能否把 loss 降到接近 0。它能在 30 秒内区分"模型有 bug"和"模型没调好"——这两件事解法完全不同。做的时候要关掉 Dropout 和数据增强,否则"背下 10 个样本"不可能,会误判成 bug。
  2. ≈ ln(类别数)(2 类 0.693,10 类 2.303,100 类 4.605),因为初始时模型在均匀乱猜。偏大→初始化太大/标签错位/输出层有问题;偏小→标签泄漏或类别极度不平衡。回归任务:第一个 MSE ≈ 标签的方差。
  3. lr 太大梯度爆炸、log(0)、除 0、数据里本身有 NaN/inf。
  4. 梯度:param.grad.abs().mean()。如果是 None 或 0,说明梯度断了或忘了 optimizer.step()
  5. .detach()/.data.item()/float() 参与计算(张量变 Python 数字,图断了)③no_grad() 包多了 ④原地操作破坏计算图。更隐蔽的:参数没被传给优化器——梯度算了但永远不更新。自检:比对优化器管的参数数量和模型可训参数总数。
  6. 不该。CrossEntropyLoss 内部已经做了 log_softmax,重复加会导致训练异常。同理 BCEWithLogitsLoss 不要自己加 sigmoid。
  7. 数据加载是瓶颈——GPU 算完在等数据。加 num_workerspin_memory,或把预处理提前做好。
  8. 得到分数的自然波动幅度,才能判断一个改动带来的提升是真实的还是噪声。

🛑 可以停在这里

走神救援

核心方法论:先证明模型能过拟合,再考虑泛化。 黄金流程:第0步在10个样本上训到loss≈0(30秒区分bug和调参问题;记得先关Dropout和数据增强)→全量训练loss降吗(不降=欠拟合)→验证loss跟着降吗(不降=过拟合)。⭐ 5秒自检:分类任务第一个loss应≈ln(类别数)(10类=2.303)——偏大=初始化/标签错位,偏小=标签泄漏;回归版是"第一个MSE≈标签方差"。NaN四因:lr太大/log(0)/除0/数据有NaN。loss不动先打印梯度(None或0=梯度断了);梯度断流四来源:detach、.item()参与计算、no_grad包多了、原地操作;⚠️更隐蔽的是"参数没传给优化器"——比对优化器管的参数数和模型可训参数总数就能查出。CrossEntropyLoss别自己加softmax(高频错误)。必打印六数字:train/val loss、lr、梯度范数、输出分布、用时。GPU忽高忽低=数据加载瓶颈。五心法:一次改一个变量、跑通>跑对>跑好、信数字、最简解释通常对、留个能跑的baseline。

下一节 👉 12-CNN.md

打卡记录保存在你的浏览器里,首页能看到总进度