作者发现自己在测量工具中埋了 10 个 bug,全都是让结果看起来更好的方向;总结出一套校验清单防止自欺。
我写了一个测量 harness,在里面发现了十个 bug。每一个都让我的结果看起来比实际更好,而且没有一个是通过读代码发现的。
以下是由此产生的检查清单。它的目的是供实际使用而非供人欣赏,因此每项检查都附带它能证明什么、不能证明什么,以及大致的编写成本。
调试是由意外触发的。令人满意的结果不算意外。
所以过滤测量 bug 的机制是不均匀的。遇到不喜欢的结果时过滤很严,遇到喜欢的结果时就很松。每一次排查,去掉的不利 bug 都比有利 bug 多,几天后你的工具就朝一个方向漂移了,而你整个流程里没有任何环节负责发现这一点。
这就是全部机制。它不需要任何人作弊,这也是为什么小心谨慎并不能解决这个问题。
我把完整的论证过程(包含全部十个 bug 以及每个 bug 推动的方向)写在了这里。这篇文章的其余部分是检查清单,而非论证过程。
把被测对象替换成一段无法解析的内容,然后断言你的 harness 能注意到它。
def test_canary_reaches_interpreter(target):
with swapped_source(target, "this is not valid python"):
result = run_suite(target)
assert result.failed, "harness did not notice a broken module"
能证明:你的变更确实到达了解释器。
不能证明:变更是否被隔离、你的评分是否正确,以及一个有效的变更是否到达了解释器。我的测试愉快地通过了,但与此同时一个等长的变异正被悄悄忽略——因为无效代码的字节长度不同,会使 Python 的 bytecode cache 失效。Vinh Nguyen(@vinhnguyenthanhdn)发现了这一点。保持字节大小不变的金丝雀变体可以堵住这个漏洞。
在我的项目中抓到的:可编辑安装。pip install -e 对 src-layout 包会直接把 import 解析回原始 checkout,所以写到临时副本的修改从未真正执行过。三个 target 得分 0.000,看起来像是"这些测试套件太差了",而不是"我的 harness 坏了"。
在任何测量代码写出来之前就先写这个。它是清单里成本最低的检查,而且能抓到最丢人一类失败。
连续运行三次相同评分,并要求输出完全一致(字节级相同)。
runs = [score(target) for _ in range(3)]
assert runs[0] == runs[1] == runs[2], f"nondeterministic: {runs}"
能证明:两次运行之间执行是隔离的。
不能证明:你在执行正确的东西。对同一个错误文件跑三次完全一致,结果仍然是完全确定性的。
在我的项目中抓到的:并行执行。在真正的异步 I/O target 上并发运行 mutants,四次运行产生了三个不同结果。我已经从那份数据里记录了一个改进。它是噪声。
重要的是,检查 1 和检查 2 不能互相替代。金丝雀愉快通过的同时并发正在破坏你的结果。确定性门愉快通过的同时你的 import 正在解析到错误的文件。两样你都需要,而且你需要写下每个实际上能证明什么,这样你才不会说服自己相信一个可以覆盖另一个。
构造一个得分应该接近零的案例。
# a suite that imports, calls, and asserts nothing meaningful
def test_negative_control():
score = run_scoring(vacuous_suite, target)
assert score < THRESHOLD, f"vacuous suite scored {score}"
能证明:你的评分不会在你不注意的地方过于大方。
不能证明:范围上限的任何信息。
这是我原本没有、来自 Ahmet Özel(@ahmetozel)的一条。逻辑值得仔细说明。在 harness 的其他地方,得分高是好的、得分低才需要调查。在负向对照上,得分高才是警报。这给了你一个唯一的地点,在那里一个讨人喜欢的失败反而是令人意外的——而这正是调试能被可靠触发的唯一条件。
有一个关键的设计约束。对照如果因为一个无聊的原因通过了,必须大声失败。接近零的得分也是什么都没加载时会得到的,而这正是我的第一个 bug 产生的结果。所以对照必须分别断言:套件在干净源码上通过、模块确实加载并执行了,以及你的其他检查仍然触发。
我的对照没有回到零。它回来了 7/51,解释是一个真正的校准事实:assert x is not None 是一个真正的检测器,只是极其窄。它恰好捕获一类 fault。全部七次 kill 都是那一个操作符,其余为零。
所以一个空测试套件的非零基线而不是零基线。一个干净回来的对照什么也没教给我。
写回归测试时,检查你真正关心的东西,而不是与它相关的信号。
# proxy: correlated with correctness, until it isn't
assert count_cache_files(workdir) == 0
# property: the thing you actually need to be true
before = observe(target)
with mutated_source(target):
after = observe(target)
assert after != before, "source changed but behaviour did not"
能证明:你依赖的行为确实成立。
不能证明:你选对了属性。但它免费消除了代理的一个失效模式。
在我的项目中抓到的:我对 bytecode 问题的修复在临时副本中断言了缓存文件计数为零。设置 PYTHONPYCACHEPREFIX 后,bytecode 进入一个以副本绝对路径为键的中央树。副本里收到零个缓存文件,而过期读取仍然发生了。我的测试本可以通过而 harness 在撒谎。Vinh 也发现了这一点,并在自己的机器上跑了三个分支以隔离哪个变量才是关键。
代理在我的配置下成立,在一个支持的环境变量下失败。断言属性的成本相同,但没有那个失效模式。这就是全部论证。
加上一个复现 broken case 的元测试,这样你就知道这个回归测试有牙齿。一个从未有人见过失败的回归测试属于和金丝雀无法检测它声称能检测的东西同一类别。
这不是你写的一条检查。这是一个习惯,它帮我抓到了十个中的四个。
在运行任何诊断之前,先写下你期望看到什么。一行就够了。然后运行它,把任何矛盾当作"停下来调查"而不是"当场合理化"。
这一条没有代码,而这恰恰是它容易被跳过的原因。预测必须在输出之前存在,你的工具链里没有任何东西会提醒你。
自身不能证明什么。
但它做到了其他检查都做不到的事:在本来不会产生意外的地方制造意外。一条没有预测就运行的检查只是产生了另一个数字,而你解读那个数字的方式和你解读其他所有数字的方式是一样的。
在我的项目中抓到的:我独立地知道某一个生成的测试是坏的。所以我预测移除它会让套件变绿。它没有。这个矛盾是我找到一个 bug 的唯一原因——一个重建步骤正在丢弃共享 import 并制造不真实的失败。它产生的分数看起来完全合理,而且合理在我喜欢的方向上。
安装到一个全新的环境里,不在你自己的代码树下,指向一个你并非围绕它构建的仓库。
cd $(mktemp -d)
pip install <your-tool>
<your-tool> run --target some-repo-you-never-tested-on
能证明:这东西对你以外的人也能工作。
不能证明:正确性。它证明的是你的正确性可以被别人达到,这是另一个问题。你的所有检查都可能 sound,但用户永远无法运行,因为他们在你的工具里走的路径和你走的不同。
在我的项目中抓到的:我的第十个 bug,也是我觉得最富有启发性的一个。我在一个精心挑选的十二个仓库上修了一个问题。然后我交付了一个 CLI,其中捕获该问题的检查没有接入我自己的 quickstart 告诉人们最先运行的命令。一个陌生人把它指向一个普通项目,会得到一个自信的 0.0000 而没有任何警告。
从我在其中开发的仓库内部看,一切都是正确的。这就是关键。你的开发环境是你偶然优化过的唯一配置,而它是你的用户最不可能复现的。
上面每一项检查都验证执行路径。你的变更是否到达了解释器、运行之间是否隔离、行为是否真的有差异。它们都没有验证 scorer:读取结果并决定其含义、然后将其跨所有内容聚合的代码。
这个缺口不是理论性的。我的十个 bug 中有三个是 scorer 级别的。分类器运行在错误的单元上、重建步骤丢弃共享 import、分类器看不到的一种断言风格。没有一个被这个清单上的检查抓到。三个都来自检查 5——预测结果然后遇到矛盾——而这是一个习惯,不是一个对照。
Zain Dana Harper(@zaindanaharper)把这个问题的一般版本说得比我好:一个完整的 artifact 不能告诉你解释它的东西是否正确。一份报告可以格式完美、每个字节都经过验证,却携带了一个错误的数字。
我正在为此构建三个检查。通过构造已知结果的 fixture 来断言管道报告你知道答案的答案。守恒不变量使计数在每个阶段都必须协调。任何带计数的东东上都标有明确的单位元数据,这样消费者可以断言它被交付了什么而不是假设。
第一个在第一次运行时就发现了一个 bug。我的 outcome 分类器判断一个结果是否是集合错误,只在输出包含特定短语时才这么做——而我用的 pytest 版本根本不发出那个短语。所以那个分支从未触发过,对任何 target 都是。更糟的是:,我把空桶作为一条发现发表了。"十二个 target 全部零错误 outcome" 被读作对数据质量的安心,而实际上是死代码的签名。十二个独立 target 上均匀的零本身就值得怀疑。真实数据很少那么干净。
这一个不符合这篇文章其余部分的模式,值得说明原因。它没有膨胀一个指标,主要数字不受影响。它所做的只是让一个空结果看起来像证据。在 scorer 检查完成之前,把这份清单当作只覆盖执行,把解释留给检查 5。
金丝雀第一,在任何测量存在之前。
确定性门在你信任任何输出数字之前。
负向对照在你解释一个好结果之前。
全新环境测试在任何其他人运行你的工具之前。
每次写回归测试时都选属性而非代理。
永远在每个诊断上先预测后运行。
顺序重要,因为每一条在你有了依赖的结果之后都更难诚实地执行。
这些是评分决策,不是检查。上面清单里没有任何一条能抓到它们,所以你必须主动决定它们是什么然后说出你决定的是什么。
批量 vs 逐条评分。如果一个坏项使整个批量失效,你的数字就是一个下限而非一次测量。两者都可以辩护。在暗示另一个的同时发表一个是不可辩护的。说清楚你有什么。
预算匹配。当比较资源消耗方式不同的方案时,没有中立的单位。匹配调用数会让一臂饿死在输出上。匹配输出 token 实际上重建了不同的臂。每种选择都偏向某一方。报告完整的资源向量,并坦白说明单位是在看到结果之前选择的,假设确实是之前选的。
对照在 n 小时死亡。我构建了两个版本的 held-out 对照,两个都放弃了。一个最终分母为 1。正确的做法是声明你没有对照,而不是把更弱的东西打扮成一个。更弱的对照没人标出来比承认缺失更糟糕,因为它转移了你没有挣到的信心。
十个 bug,全部是讨人喜欢的方向,没有一个是通过检查代码发现的。
然后两个读者通过指向我的检查而非我的数字又发现了两个。没有一次结果移动。没有人质疑任何一条发现。所有审查都落在了仪器上,而两次仪器都在阅读代码无法发现的方式上错了。
这就是发布仪器而非发现结果的理由。结果是人们可以接受或拒绝的主张。仪器是人们可以攻击的东西,而攻击才是告诉你它是否工作的东西。
我正在考虑把完整版本整理出来,包含作为可直接使用代码的检查、预注册和提交排序模板,以及作为实例的 eval 集。
如果你构建 evaluation:你想要这里没有的什么,你不嫌麻烦的是什么?第二个问题对我更有用。