Skip to content

调试与诊断

本页速览 模型不工作时的系统化排查方法:先用小样本过拟合验证管道,再按 loss 不降排查表、梯度检查、NaN 溯源、分布可视化、单元测试、消融实验逐层定位问题。附一张高频 bug 清单,让你的调试从"瞎试"变成"按图索骥"。

调试与诊断

一句话定义:调试深度模型,核心是建立"分层证据链"——先证明数据管道没问题,再证明梯度没问题,最后才怀疑模型设计。绝大多数"模型不工作",问题出在前两层。

深度学习的一个残酷事实是:训练能"跑起来"(不报错)与训练"正确"是两回事。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.shapey 的范围与类型;画图肉眼核对标签(见从零构建一个深度学习项目
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 个点
去掉 BatchNorm74%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 设计原则

延伸阅读

参考资料