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

11 / 按问题查,不必顺读

先找当前症状,
一次只排查一个原因。

调试不是同时换模型、学习率和数据;先做一个能证伪猜测的小实验。

先抓住一个具体结果

先做 10 个样本的小实验

暂时关闭数据增强和强正则,检查模型能否拟合极少量训练样本。如果仍做不到,优先排查数据、损失与更新链路;症状表提供线索,不是唯一诊断。

接下来,按需要选一项

你现在想看什么?

这些入口是选择,不是必须按顺序完成的任务。

可以停在这里

调试不是同时换模型、学习率和数据;先做一个能证伪猜测的小实验。

需要更多细节时,继续看完整正文 →
继续探索

原有正文、图解和例子都在下方。按需跳转,不必一次读完。

11 · 训练调试手册

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

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


🎯 一句话

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


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

从上往下读问题;满足条件走右边,不满足再往下。右侧是选择入口,具体限制与原有说明保留在图下。按问题逐步判断10 个样本仍不能拟合?先检查实现与数据管线是否全量训练 loss 不下降?检查优化与模型容量是否验证 loss 不降或上升?检查过拟合与泛化是否通过这些检查后,再有记录地调参提分
从上往下读问题;满足条件走右边,不满足再往下。右侧是选择入口,具体限制与原有说明保留在图下。

图下说明

  • 第 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 应该是多少

流程图

分类任务(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} 个参数!"   # ⭐
📋 直接能跑的完整版(会当场触发那句 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

🧠 七、五条心法

  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_workers、pin_memory,或把预处理提前做好。
  8. 得到分数的自然波动幅度,才能判断一个改动带来的提升是真实的还是噪声。

🛑 可以停在这里

⚡ 走神救援

先记住这几件事

  • 先检查数据、标签、形状和损失,再怀疑网络结构。
  • 先让模型拟合很小一批样本;连这一步都失败时,不要急着扩大训练。
  • 观察损失、梯度和预测分布,一次只改一个因素。
  • 训练流程正确后,再按曲线判断学习率、容量与正则化的问题。

下一节 👉 12-CNN.md

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