Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

G. 常见错误与排查

模型没有学好时,最容易做的事是再调一次学习率。可如果目标和预测根本没有对齐,学习率调得再认真,也是在优化另一个问题。

排错可以从一个朴素的问题开始:程序实际算的,是不是我们以为它在算的东西? 先找一个小到能够手算的输入,再逐步扩大,往往比同时更换网络、数据和优化器更快。

1. 先缩小问题,不急着改变方法

保留出现问题的命令、输入、环境和完整报错。从 traceback 最后几行找到异常类型,再向上找到自己的第一处调用。错误出现在库内部,并不等于库本身出了问题,也可能是输入已经不符合约定。

然后尽量把问题缩成一条样本、一批数据或一次前向。把以下信息直接打印出来:

print(inputs.shape, inputs.dtype, inputs.device)
print(targets.shape, targets.dtype, targets.device)
print(torch.isfinite(inputs).all().item())

如果只是文件找不到,先打印 Path.cwd() 和解析后的文件路径。相对路径跟着当前工作目录走,不一定跟着脚本所在目录走;本书常用 Path(__file__).resolve().parent 定位随脚本分发的资源。

2. 程序没报错,也可能减错了答案

三个预测是 1、2、3,三个目标也正好是 1、2、3,正确 MSE 应为零。可是把预测排成 (3,1)、目标留成 (3,),相减会广播成 (3,3),得到的 MSE 约为 1.333333。

这个错误没有触发异常,因为广播规则允许它。修复前先确认哪条轴表示样本,再统一形状;不要盲目使用 squeeze(),把批量恰好为 1 时需要保留的轴也挤掉。

对于词元预测,还要检查输入位置与目标位置是否错开了一格、PAD 是否从损失中排除、掩码是否遮住未来。损失能下降,只说明某个目标被优化,不证明这个目标就是原来的任务。

完整广播图见附录 A。这类检查可以在训练开始前,用小数组完成。

3. 梯度不见了:沿着计算图找断点

item() 会把单元素张量取成普通 Python 数字。如果把它用于后续损失,再用 torch.tensor(...) 包回来,新张量不会自动记得原来的计算图。

例如,设 x=2x=2,希望对 x2x^2 求导,结果应为 4。若先取 x.item() 再平方、重建张量,反传会失败;直接计算 x ** 2 则能保留依赖关系。

detach()、转换到 NumPy、用不参与求导的新张量替换中间结果,也可能在你需要梯度的地方断开路径。它们本身并不是错误:记录日志、固定教师输出等场景本来就可能需要停止求导。关键在于放置的位置。

梯度为 None、梯度恰好为零、参数没有更新,也不是同一件事。先检查参数是否参与当前损失、是否需要梯度、是否交给了优化器,然后再检查数值。还要注意:backward() 默认累加梯度;eval() 不会自动关闭求导。PyTorch 自动求导文档可以帮助确认这些行为。

4. 出现 NaN:找到第一个不再有限的数

直接计算 exp⁡(1000)\exp(1000) 会超过常见浮点表示范围。若随后出现无穷除以无穷,结果就可能是 NaN。把 softmax 先减去最大分数,可以在这个例子里避免指数溢出,数学推导见附录 B。

但不是所有 NaN 都来自 softmax。还可能有除零、负数开方、无效标签、梯度爆炸,或低精度计算范围不足。沿前向与反向逐层检查有限性,找到最早出现问题的位置。

不要把最后的 NaN 一律替换为零就当作修复。这样也许能让程序继续运行,却可能隐藏了错误的目标或梯度。先恢复一个稳定的小输入,再检查学习率、归一化、损失实现与精度选择。

5. 成绩好得异常:也要怀疑数据

训练和测试图片文件名不同,不表示它们来自不同的人、不同原始文档或不同视频。重复的上游对象可能让测试变得过于容易。

本页用一个极小例子检查分组:训练包含 writer-a、writer-b,测试包含 writer-a、writer-c。两边共享 writer-a,就不能把这个划分当作完全未见书写者的评价。

发现交叉后,应回到目标决定划分单位,再重新建立开发流程。如果已经看过测试结果并按它改变了方案,要保留这段历史;不能通过换个目录名,把原来的测试数据重新变成“从未见过”。

还有一种常见泄漏是先在全部数据上拟合标准化、特征选择或词表,再划分数据。应先明确数据职责,让会学习统计量的步骤只使用被允许的部分。更完整的例子见第八章和第九章。

6. 运行四个真实的小故障

排错脚本使用现有 NumPy、PyTorch 环境。它在内存中实际触发误广播、断图反传、指数溢出和分组重叠,不读取外部业务数据。

python src/examples/debugging_lab.py

实际输出:

广播误差形状:(3, 3),错误 MSE=1.333333,正确 MSE=0.000000
item() 后重建张量的反传失败:True;保留计算图后梯度=4.0
直接指数概率全部有限:False;稳定概率和=1.000000
跨划分重复的书写者: ['writer-a']
修复范围:数值路径与分组检查;不等于模型已能泛化。

程序只检查出分组交叉,没有擅自生成一份新的真实数据划分。前三项展示了具体数值路径的修正,也不意味着整个训练系统已经可靠。

展开完整代码
"""附录 G:实际触发并定位四种常见错误,全部在内存中进行。"""

import numpy as np
import torch


def experiment():
    prediction = np.array([[1.], [2.], [3.]])
    target = np.array([1., 2., 3.])
    wrong_loss = ((prediction - target) ** 2).mean()
    right_loss = ((prediction[:, 0] - target) ** 2).mean()

    x = torch.tensor(2., requires_grad=True)
    detached = torch.tensor(x.item() ** 2)
    detached_error = False
    try:
        detached.backward()
    except RuntimeError:
        detached_error = True
    (x ** 2).backward()
    right_gradient = x.grad.item()

    values = np.array([1000., 1001.])
    with np.errstate(over="ignore", invalid="ignore"):
        raw = np.exp(values) / np.exp(values).sum()
    shifted = np.exp(values - values.max())
    stable = shifted / shifted.sum()

    train_groups, test_groups = ["writer-a", "writer-b"], ["writer-a", "writer-c"]
    overlap = sorted(set(train_groups) & set(test_groups))
    return dict(wrong_shape=(prediction - target).shape, wrong_loss=float(wrong_loss),
                right_loss=float(right_loss), detached_error=detached_error,
                right_gradient=right_gradient, raw_finite=bool(np.isfinite(raw).all()),
                stable=stable, overlapping_groups=overlap)


def main():
    r = experiment()
    print(f'广播误差形状:{r["wrong_shape"]},错误 MSE={r["wrong_loss"]:.6f},正确 MSE={r["right_loss"]:.6f}')
    print(f'item() 后重建张量的反传失败:{r["detached_error"]};保留计算图后梯度={r["right_gradient"]:.1f}')
    print(f'直接指数概率全部有限:{r["raw_finite"]};稳定概率和={r["stable"].sum():.6f}')
    print("跨划分重复的书写者:", r["overlapping_groups"])
    print("修复范围:数值路径与分组检查;不等于模型已能泛化。")


if __name__ == "__main__":
    main()

7. 按现象找到下一步

现象先看什么
明明安装了,仍提示找不到包当前解释器路径与 python -m pip 是否一致
矩阵乘法形状不匹配写出每条轴的含义,再检查内侧维度
训练损失完全不动梯度、优化器参数、学习率与真实目标是否正确
小批量也无法拟合先减少到几个已知标签,检查实现与数据对应
训练很好,验证很差数据差异、过拟合、预处理和标签约定
结果异常完美重复样本、分组、未来信息和目标泄漏
显存不断增长是否把带计算图的张量长期存入列表;是否存在未释放引用
生成文本只有输入、一直重复或突然结束输出切片、聊天模板、EOS、采样与长度上限
重跑无法一致数据/代码版本、随机数来源、设备与非确定性算子
速度测量忽快忽慢预热、同步、并发、计时范围与系统负载

这一列是调查入口,不是对所有情况通用的处方。比如减小批量可能降低显存,却不能解决不必要的计算图引用;设置随机种子也不能修复不同版本的预处理。

最后,把修复留下来:原始症状、最小输入、原因、改动、修复前后结果。若错误涉及核心计算或数据隔离,可以再留一个针对这个失败条件的小检查。这样,下次改动才不会悄悄把同一个问题带回来。

自测

  1. 为什么形状合法仍可能算错损失?
  2. 为日志调用 loss.item() 与用这个数字继续构造训练损失,有什么区别?
  3. 为什么在输出端屏蔽 NaN,不一定能修复梯度?
  4. 模型能把四条样本拟合到很低损失,是否证明它能泛化?
  5. 为自己遇到的一次错误写出“最小输入—预期结果—实际结果”,暂时不要先写修复方案。
参考答案
  1. 程序只检查运算规则,不知道样本应该怎样配对;广播就是典型例子。
  2. 取数用于显示可以是合理的;若训练依赖这个普通数字,原来的求导路径就丢失了。
  3. 无效运算可能早已进入计算图,反向仍会经过它。应从运算本身和最早异常处排查。
  4. 不能。它提供了局部实现检查,泛化需要合适的保留数据和评价设计。
  5. 例如:三个相同预测与目标,预期 MSE 为零,实际为 1.333333。这样的描述可以直接指导下一次检查。