外观
调试与诊断
一句话定义:调试深度模型,核心是建立"分层证据链"——先证明数据管道没问题,再证明梯度没问题,最后才怀疑模型设计。绝大多数"模型不工作",问题出在前两层。
深度学习的一个残酷事实是:训练能"跑起来"(不报错)与训练"正确"是两回事。Loss 在下降也可能是数据泄漏在起作用;loss 不降也可能是代码 bug 而不是模型能力不足。本文给出一套按顺序执行的排查流程。诊断信号的统计含义可回看深度学习评估与实验与反向传播与自动微分。
一、第一定律:先在小样本上过拟合到 100%
任何新模型、新数据管道的第一个测试是:取 5–50 个样本(通常一个 batch),训练到训练 loss 逼近 0、训练准确率 100%。这是整个调试方法的基石。
python
import torch, torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
# 取训练集前 32 个样本
subset = torch.utils.data.Subset(train_set, list(range(32)))
loader = DataLoader(subset, batch_size=32, shuffle=True)
model = YourModel()
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
for epoch in range(100):
model.train()
for x, y in loader:
optimizer.zero_grad()
loss = criterion(model(x), y)
loss.backward()
optimizer.step()
print(epoch, f"loss={loss.item():.4f}")判断标准:训练 loss 应稳定降到接近 0(分类任务 0.001 量级)。
- 如果做不到:说明模型容量、数据形状、前向/损失/梯度链路中有 bug——这是最容易定位的阶段,因为只有 32 个样本、没有随机性干扰。
- 如果能做到:恭喜,管道和梯度链路基本正确,接下来才进入"泛化问题"(过拟合/欠拟合)阶段,那属于训练配方与调参的范畴。
别跳过这一步
很多人直接全量训练、跑 20 分钟才发现"loss 不降",然后开始怀疑各种玄学原因。小样本过拟合测试把同样的排查压缩到 30 秒,是所有深度调试的第一步。
二、Loss 不降排查表
假设小样本测试失败,按优先级逐项排查:
| 优先级 | 嫌疑 | 快速验证 |
|---|---|---|
| 1 | 数据标签错误 | 打印 x.shape、y 的范围与类型;画图肉眼核对标签(见从零构建一个深度学习项目) |
| 2 | 数据没归一化/取值范围异常 | 检查 x.min()/x.max();图像输入范围应为 [0,1] 或标准化后近 0 |
| 3 | 输出层与损失不匹配 | 分类用 logits + CrossEntropyLoss,不要重复 softmax;见损失函数与输出层 |
| 4 | 学习率错误 | 范围测试法:过大发散发散、过小 loss 缓慢到几乎不动 |
| 5 | 梯度没更新 | 对比 loss.backward() 前后 param.grad;检查是否忘记 optimizer.step()/zero_grad() |
| 6 | 随机基线太低 | 多分类 10 类时初始 loss 应约为 ln(10)≈2.30;严重偏离说明初始化或数据有问题 |
| 7 | 输入输出维度错 | 打印每层输出的 shape,定位第一个与预期不符的层 |
python
# 快速验证梯度确实在更新
for name, p in model.named_parameters():
if p.requires_grad:
print(name, "grad_ok" if p.grad is not None and p.grad.abs().max() > 0 else "NO_GRAD")三、梯度检查(gradcheck)
如果你的模型里有自定义算子或手写的前向/反向,用数值梯度对比解析梯度。PyTorch 内置了 torch.autograd.gradcheck:
python
from torch.autograd import gradcheck
# 包装成一个输入为 (x,) 的函数(测试单个样本,通常用双精度)
model_double = MyCustomOp().double()
x = torch.randn(1, 16, dtype=torch.double, requires_grad=True)
# gradcheck 会以数值差分近似梯度并与自动微分结果比较
assert gradcheck(lambda inp: model_double(inp), (x,), eps=1e-6, atol=1e-4)
print("gradcheck passed")使用注意:用双精度(数值差分在 float32 下误差太大);单样本或极小 batch;对 nn.Sequential 里的标准算子,PyTorch 已经验证过,不必重复检查——gradcheck 主要留给自定义层、自己写的损失、自己实现的反向传播。
四、NaN / Inf 排查
训练中途 loss 变 nan,按可能性从高到低排查:
| 原因 | 机制 | 解法 |
|---|---|---|
| 学习率过大 | 参数发散、中间值溢出 | 降低学习率;范围测试确认合理区间 |
| 除零/对零取对数 | 归一化层分母为 0;log(0) 在 NLL 中 | 给分母加 eps;数据中不要出现极端值 |
| 梯度爆炸 | 深层链式相乘放大(机制见反向传播) | 梯度裁剪 clip_grad_norm_ |
| 混合精度(AMP)溢出 | float16 上界约 65504,小值下溢/大值上溢 | 用 GradScaler;检查自定义算子是否支持 fp16 |
| 数据本身含 NaN | 上游数据清洗漏了 | torch.isnan(x).any() 检查输入 |
| 学习率调度的除零 | 调度器 step 参数错误 | 检查 LambdaLR/ReduceLROnPlateau 的入参 |
定位"第一个 NaN 出现在哪一步"的通用技巧是二分回放:记录每个 batch 的 loss,找到 NaN 出现的第一个 batch,缩小到那一批数据 + 当时的模型状态再单独复现。
python
# 梯度裁剪(NaN 的通用缓冲垫,但只能掩盖上游问题,不是根治)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)五、可视化诊断三件套
数字有上限,图形没有。三个高频可视化工具能定位绝大多数问题:
1. 训练曲线(loss/准确率 vs 步数)
- 训练 loss 与验证 loss 双双不降 → 欠拟合,加容量或调学习率。
- 训练 loss 降、验证不降或回升 → 过拟合,加正则化(见过拟合与正则化)。
- loss 剧烈震荡不收敛 → 学习率太大或 batch 太小。
- loss 跳崖式下降后平台 → 可能正在跨越损失面中的"窄谷",换优化器或调度再看。
2. 权重与激活分布
用直方图检查模型内部状态是否健康:
python
import matplotlib.pyplot as plt
# 挂一个 forward hook,抓取某层激活
activations = {}
def hook_fn(name):
def hook(module, inp, out):
activations[name] = out.detach().flatten().cpu()
return hook
model.layer2.register_forward_hook(hook_fn("layer2"))
# 训练若干步后
plt.hist(activations["layer2"].numpy(), bins=100)
plt.title("activation distribution of layer2")
plt.savefig("activations.png")健康信号:激活分布不塌缩(没有大片恒等值)、不饱和(对 ReLU 而言,死区比例不应过大)。大量神经元恒为 0 是"死亡 ReLU",通常与学习率过大或初始化不当有关(见初始化与归一化)。权重分布整体数值爆炸则是梯度爆炸的前兆。
3. 梯度范数
对每个参数组绘制 p.grad.norm(),观察是否出现爆炸(数量级飙升)或消失(趋近 0):
python
total_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=float("inf"))
print(f"grad norm: {total_norm:.4f}")六、单元测试:把已知 bug 变成永久的"围栏"
调试经验应该固化成回归测试。三个最值得写测试的维度:
python
# 1) 形状与维度
def test_output_shape():
model = YourModel()
out = model(torch.randn(4, 3, 32, 32)) # batch=4, CIFAR-10 输入
assert out.shape == (4, 10)
# 2) 掩码/注意力:掩码位置不应该有贡献
def test_mask_ignored():
x = torch.randn(2, 5, 16)
mask = torch.tensor([[1, 1, 1, 0, 0], [1, 1, 0, 0, 0]])
out_masked = YourModel()(x, mask=mask)
# 期望:被掩码的位置不参与聚合,结果应与"去掉那些位置再算"一致
out_ref = YourModel()(x * mask.unsqueeze(-1), mask=None)
assert torch.allclose(out_masked, out_ref, atol=1e-4)
# 3) 训练可推进:一个 step 后 loss 应该变小(在固定输入上)
def test_loss_decreases():
model = YourModel()
opt = torch.optim.SGD(model.parameters(), lr=0.01)
x, y = fixed_batch() # 固定数据与种子
loss0 = model.loss(model(x), y)
opt.zero_grad(); loss0.backward(); opt.step()
loss1 = model.loss(model(x), y)
assert loss1 < loss0用 pytest 组织,把每次踩的坑写成一个用例。测试不是产品化之后的奢侈品,而是调试方法的固化。
七、消融实验定位"坏在哪一层"
当整个系统跑通但指标不如预期时,用**消融(ablation)**逐个关掉组件来定位贡献。原则:一次只变一个变量。
一个典型消融矩阵(图像分类):
| 配置 | 验证准确率 | 结论 |
|---|---|---|
| 基线(增强+BN+余弦退火) | 78% | — |
| 去掉数据增强 | 70% | 增强贡献 8 个点 |
| 去掉 BatchNorm | 74% | BN 贡献 4 个点 |
| 去掉余弦退火(恒定 lr) | 75% | 调度贡献 3 个点 |
如果去掉某个组件后反而变好,不要慌,这同样是有效信息——它说明该组件在你的数据/模型配置下不匹配,值得单独深挖,而不是"标配必用"。消融的代价是训练次数线性增长,所以先想清楚哪个变量最可疑、收益最大。
八、常见 bug 清单(按频率排序)
| Bug | 症状 | 一针见血 |
|---|---|---|
忘记 optimizer.zero_grad() | loss 剧烈震荡 | 每个 batch 前清零 |
忘记 model.eval() / torch.no_grad() | 验证指标乱、显存涨 | 评估时切换 |
| 训练/推理模式不一致(BatchNorm/Dropout) | 训练好、推理崩 | 检查 eval() 时机 |
| 设备不一致 | Expected all tensors to be on the same device | 统一 .to(device) |
| 输入归一化统计量用错(见常见陷阱与反模式) | 结果偏但不报错 | 检查均值/标准差来源 |
| 数据泄漏(增强统计用测试集) | 指标虚高 | 只用训练集拟合统计量 |
| 手工实现梯度错误 | 训练不收敛但无报错 | gradcheck |
| 混精度 fp16 下自定义损失溢出 | 偶发 NaN | 转 fp32 或修算子 |
loss.backward() 后忘记 optimizer.step() | loss 不动 | 检查四步循环 |
| 索引/切片用错导致 mask 错位 | 结果诡异 | 单测掩码 |
调试心态
一次只改一个变量、改前先记录基线、每次改动保留证据(日志/图/数字)。"我改了好几个地方,现在好了"这种结果无法归因,等于没有调试。心态与方法论详见DL 设计原则。
延伸阅读
- 从零构建一个深度学习项目——调试的载体项目
- 训练配方与调参——loss 不降时的配方侧检查
- 常见陷阱与反模式——本文清单的展开版
- DL 设计原则——消融与实验纪律的元规则
- 反向传播与自动微分——梯度链路的理论
- 初始化与归一化——权重/激活分布异常的理论来源
参考资料
- Karpathy. A Recipe for Training Neural Networks——"先过拟合一个 batch"方法论的原始出处
- PyTorch. torch.autograd.gradcheck——梯度检查官方文档
- Schoenholz et al. Deep Information Propagation through Nonlinearities (ICLR 2017)——激活分布与网络可训练性分析