📑 本页目录(点开跳转)
11 / 按问题查,不必顺读
先找当前症状,
一次只排查一个原因。
调试不是同时换模型、学习率和数据;先做一个能证伪猜测的小实验。
先抓住一个具体结果
先做 10 个样本的小实验
暂时关闭数据增强和强正则,检查模型能否拟合极少量训练样本。如果仍做不到,优先排查数据、损失与更新链路;症状表提供线索,不是唯一诊断。
接下来,按需要选一项
你现在想看什么?
这些入口是选择,不是必须按顺序完成的任务。
可以停在这里
调试不是同时换模型、学习率和数据;先做一个能证伪猜测的小实验。
需要更多细节时,继续看完整正文 →原有正文、图解和例子都在下方。按需跳转,不必一次读完。
11 · 训练调试手册
⏱ 42 分钟 | ⭐⭐ 最难自学、最值钱的一章
教科书教你模型怎么工作,没人教你模型不工作时该怎么办。 这一章就是那本没人写的手册。
🎯 一句话
调试深度学习的核心方法论只有一条:先证明模型能过拟合,再考虑泛化。 在你的模型能把 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
📋 直接能跑的完整版(补上一个最小模型和随机数据)
import torch
import torch.nn as nn
torch.manual_seed(0)
X = torch.randn(256, 20)
y = torch.randint(0, 10, (256,)) # ⭐ 标签是纯随机的,正好说明问题
model = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Linear(64, 10))
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
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, round(loss.item(), 4))
print("最终 loss:", round(loss.item(), 6))
实跑输出:2.2905 → 0.167 → 0.0264 → 0.0112 → 0.0063,最终 0.004136。
⭐ 注意标签是 randint 随机生成的、和输入毫无关系,它照样背了下来 ——
这正是这一步的意义:它验证的是「这条链路通不通」,不是「模型学到了东西」。
⚠️ 反过来说,这一步过了也不代表模型有用;但这一步过不了,后面全都不用看。
⚠️ 做第 0 步时记得把 Dropout 和数据增强关掉—— 它们会让"背下 10 个样本"变得不可能,你会误判成有 bug。
🔢 还有一个 5 秒的自检:第一个 loss 应该是多少
流程图
💡 这是全章性价比最高的一个检查——一行 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 个样本过拟合不了」的必查五项
结果对照
🔨 一个能一次性查完的自检函数
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} 个参数!" # ⭐
📋 直接能跑的完整版(会当场触发那句 assert —— 那就是它该做的事)
import torch
import torch.nn as nn
class Net(nn.Module):
def __init__(self):
super().__init__()
self.encoder = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Linear(64, 512))
self.head = nn.Linear(512, 10)
def forward(self, x):
return self.head(self.encoder(x))
model = Net()
optimizer = torch.optim.AdamW(model.encoder.parameters(), lr=1e-3) # ❌ 只交了 encoder
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)
print(f"优化器管 {n_opt} 个参数,模型一共 {n_all} 个,差 {n_all - n_opt}")
assert n_opt == n_all, f"优化器只管了 {n_opt}/{n_all} 个参数!"
实跑输出:优化器管 34624 个参数,模型一共 39754 个,差 5130,然后断言炸掉。
⭐ 那个 5130 正好是 head 层的 512×10 + 10 —— 自检把漏掉的那一层精确指了出来。
📊 四、必须打印的六个数字
不看这些就是盲调:
# 🧩 骨架:`e` 来自你自己的代码,这一段只看写法
# 每个 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 用时 | 突然变慢 → 数据管线瓶颈 |
# 🧩 骨架:`model` 来自你自己的代码,这一段只看写法
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 |
# 🧩 骨架:`loader` 来自你自己的代码,这一段只看写法
# 梯度累积:用小显存模拟大 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,或把预处理提前做好。 - 得到分数的自然波动幅度,才能判断一个改动带来的提升是真实的还是噪声。
🛑 可以停在这里
⚡ 走神救援
先记住这几件事
- 先检查数据、标签、形状和损失,再怀疑网络结构。
- 先让模型拟合很小一批样本;连这一步都失败时,不要急着扩大训练。
- 观察损失、梯度和预测分布,一次只改一个因素。
- 训练流程正确后,再按曲线判断学习率、容量与正则化的问题。
下一节 👉 12-CNN.md