← 返回周历 入门教材 视频 DeepInfer 101
入门下篇

调试与测试:找到程序第一次走错的地方

浏览文字约 6 分钟 · 手算与练习请按正文分段完成 源文件:Markdown 本地渲染版 · 无需联网
本页目录 · 4 节

程序打印了一个数,不代表它算对了;测试亮了红灯,也不代表整段程序都错了。本章围绕一个问题展开:当你和程序给出的答案不同时,怎样找到第一个不符合预期的状态?我们先查一个只有一个变量的程序,再把同样的方法用到矩阵,最后检查测试本身是否可信。

如果你还不能独立写一个遍历列表的函数,先读第一章:Python 起步。以下命令都从能直接看到 course 文件夹的目录执行;在个人主页源码仓库里先进入 _courses/deepinfer。不需要 GPU 或第三方库。

1. 先区分「运行失败」和「答案不对」

python course/prep/demo.py
python course/tools/grade.py prep --question q0

第一条最后显示 result = 5,第二条应通过。如果 Python 提示找不到文件,它连要调试的算法都没有开始运行;先检查当前目录。若出现 traceback,先读末尾的异常类型与消息,再往上找自己代码的文件、行号和当时的输入。另一种失败更安静:程序正常退出,但算出的值不是题目要求的值。没有异常可以追,这时要自己制造一个容易判断对错的输入。

以寻找列表最大值为例。设程序先记下「目前最好的是 0」,然后遇到更大的数就替换它。用 [2, 5, 1] 试,答案是 5,看上去很合理。现在只试 [-4]:列表中唯一的元素显然也是最大值。可是 −4 不大于 0,程序就把一个从未出现在输入里的 0 当成了答案。此时增加循环次数或换一种比较符号,都没有抓到根因:在读第一个元素之前,「最好的是 0」已经是未经证明的假设。

输入正确期望这个输入能排除什么
单元素的负数列表这个负数本身把 0 当作无条件起点
两个相等的数任一个的值把相等误判为异常
空列表按本题规格抛出 ValueError编造一个不存在的最大值

修复前先说出你想维持的关系:「处理过至少一个元素后,记录值是已读部分的最大值。」再去看 prep.py 中的 maximum,判断哪一步可以第一次让这个关系成立。空输入需要单独履行规格,而不是让读取第一个元素的操作碰巧抛出另一种异常。

2. 一张矩阵里有两种不同的错误

列表套列表就能表示矩阵。假设你给某一批请求保存了两行测量值,想复制一份再做实验。复制之后,两份数据打印成相同的数字是好事,却不是复制正确的证据:如果它们实际上共用一行,打印也会相同。运行这段小实验,先猜最后一行:

original = [[1, 2], [3, 4]]
candidate = original[:]
candidate[0][1] = 99
print(original)

输出是 [[1, 99], [3, 4]]。切片新建了外层列表,但第一行仍是同一个可修改对象。要证明副本能独立使用,测试必须包含写入之后的观察:只改副本的一格,再读原矩阵的那一格。对本课由数字组成的二维矩阵,外层和各行都独立就够了,不必为此引入通用的对象复制框架。

矩阵乘法的另一类错误即使完全没有共享行也会发生。看一行两列的左矩阵 [[2, 3]] 与右矩阵 [[1, 4], [5, 2]]。左矩阵的同一行分别和右矩阵的两列配对。第一格是 2×1 + 3×5 = 17,第二格是 2×4 + 3×2 = 14。预期输出是一行两列的 [[17, 14]]。两格要用不同的累计过程:

要算的格子开始加入第一项加入第二项
第 0 行、第 0 列0217
第 0 行、第 1 列0814

如果程序给出 [[17, 31]],不要先怀疑乘法或转置:31 恰好等于 17 加 14。把第二格开始时的累计值打印出来;若它还是 17,就已经找到了错误,不用等到最终输出。累计值属于一格输出,因此应该在开始这一格时清零;若在每次乘法前清零,又会只留下最后一项。上章的边界计算恰好相反:它需要跨请求保留总和。判断在哪里初始化变量,不靠背缩进,靠问「这个量是在替谁累计?」

3. 好测试既能放过正确结果,也能抓住错误结果

有了纸上的期望,最后一步是把比较交给程序。整数索引应该严格相等;浮点计算却可能因为有限精度产生很小的差别。先在解释器里比较 0.1 + 0.2 == 0.3,再看 abs((0.1 + 0.2) - 0.3)。第一次得到 False,第二次得到约 5.55×10⁻¹⁷。这两个结果并不矛盾:问题从「是否逐位相等」变成了「差多少」。

本题的输入是有限数值,约定使用非负绝对容差。假定容差为 0.001,期望向量是 [1.0, 2.0]:[1.0004, 2.0] 应通过,[1.04, 2.0] 应失败,只有一个元素的 [1.0] 也应失败。最后一个例子与精度无关。如果只用 zip 逐对比较,它会在较短的列表结束时停止,根本看不到缺的第二项。先检查结构,再比较每一项,最后报告第一个超出容差的位置,这比看到红灯就放宽容差可靠得多。

试着为逐项向量加法设计自己的验收:[2, -1] 加 [3, 4] 应得到什么?输入列表还应是什么?换成长度不同的两个列表该怎么办?正常结果 [5, 3] 检查算术,事后再读输入检查是否被意外修改,不等长时按题目要求报错则检查了契约。这三项不能用同一条「结果看起来没问题」来代替。

4. 把一次修复做完整

Q0 运行示例用来确认起点;Q1 最大值练初始状态;Q2 矩阵分别练副本与累计值;Q3 测试与向量加法让你先修好比较工具,再写独立函数。代码只需修改 prep.py,无需改公开测试。

python course/tools/grade.py prep --question q2 --failfast
python course/tools/grade.py prep

遇到失败时,留下这样一条记录就足够了:「期望第二格开始时是 0,实际是 17;我把清零移到每一格的起点,再用不同的两列重复了测试。」这比记下「矩阵题已通过」更有用,因为换一个形状后,你仍然知道该检查哪里。如果你能对一个自己选的输入做出同样的解释,就可以进入第 1 讲:执行模型,用相同方法检查每个线程负责哪个元素。