📑 本页目录(点开跳转)
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~8、pin_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
🧠 七、五条心法
- 一次只改一个变量。 同时改了 lr 和模型结构,涨了你也不知道是谁的功劳。
- 先让它跑通,再让它跑对,最后才让它跑好。 顺序不能反。
- 相信数字,不相信感觉。 「我觉得这样更好」→ 跑一遍看验证集。
- 最简单的解释通常是对的。 loss 不动,先怀疑
zero_grad()和标签对齐,别先怀疑优化器理论。 - 保留一个能跑通的 baseline。 改崩了能回退——这比什么都重要。
🔗 这五条和你在智能体工程第 14 章学的评测纪律是同一套思维方式。
✅ 检查点
- 黄金诊断流程的第 0 步是什么?为什么它最重要?做的时候要注意关掉什么?
- 分类任务第一个 loss 应该是多少?偏大和偏小分别说明什么?
- loss = NaN 有哪四个常见原因?
- loss 完全不动,最先该打印什么?
- 梯度断流有哪四个常见来源?还有一个更隐蔽的是什么?
CrossEntropyLoss前面该不该自己加 softmax?- GPU 利用率忽高忽低最可能是什么问题?
- 为什么要"同样配置跑 3 次"?
👀 答案
- 在 10 个样本上训练,看能否把 loss 降到接近 0。它能在 30 秒内区分"模型有 bug"和"模型没调好"——这两件事解法完全不同。做的时候要关掉 Dropout 和数据增强,否则"背下 10 个样本"不可能,会误判成 bug。
- ≈ ln(类别数)(2 类 0.693,10 类 2.303,100 类 4.605),因为初始时模型在均匀乱猜。偏大→初始化太大/标签错位/输出层有问题;偏小→标签泄漏或类别极度不平衡。回归任务:第一个 MSE ≈ 标签的方差。
- lr 太大梯度爆炸、log(0)、除 0、数据里本身有 NaN/inf。
- 梯度:
param.grad.abs().mean()。如果是 None 或 0,说明梯度断了或忘了optimizer.step()。 - ①
.detach()/.data②.item()/float()参与计算(张量变 Python 数字,图断了)③no_grad()包多了 ④原地操作破坏计算图。更隐蔽的:参数没被传给优化器——梯度算了但永远不更新。自检:比对优化器管的参数数量和模型可训参数总数。 - 不该。CrossEntropyLoss 内部已经做了 log_softmax,重复加会导致训练异常。同理 BCEWithLogitsLoss 不要自己加 sigmoid。
- 数据加载是瓶颈——GPU 算完在等数据。加
num_workers、pin_memory,或把预处理提前做好。 - 得到分数的自然波动幅度,才能判断一个改动带来的提升是真实的还是噪声。
🛑 可以停在这里
⚡ 走神救援
核心方法论:先证明模型能过拟合,再考虑泛化。 黄金流程:第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