评测框架的九个 Bug 全都让我的结果显得更好
作者发现自建评测工具有九个 bug,个个都让实验数据更漂亮——因为人们对坏结果追查、对好结果直接接受,这种认知偏差导致测量工具系统性朝一个方向漂移。
作者发现自建评测工具有九个 bug,个个都让实验数据更漂亮——因为人们对坏结果追查、对好结果直接接受,这种认知偏差导致测量工具系统性朝一个方向漂移。
我写了一个评测工具链。在里面发现了九个 bug。每一个都会让我的结果看起来比实际更好。
九个 bug,全都对结果有利。这不是巧合,而且我不认为这只和我或我的项目有关。我认为这是你为自己的项目搭建评测体系时会碰到的结构性问题了。
先说机制,再说证据。
调试是由意外触发的
你不会去审查所有数字。你只会审查让你不舒服的数字。
当结果令人失望时,你会去寻找原因。检查配置、重跑、加日志、找 bug。Bug 修好之后数字就变了。
当结果很好时,这些都不会触发。没有任何不对劲的感觉。没有什么值得调查的意外。你直接写成报告。
所以,消除测量 bug 的过滤器是不均匀地施加的。对于不喜欢的结果很严格。对于喜欢的结果很宽松。每过一遍这个过滤器,去掉的不利 bug 比有利的 bug 更多。
这样跑上几天,你的仪器就会在一个方向上漂移,而你过程中没有任何设计来察觉这一点。存活到发表的 bug不成比例地都是对你有帮助的那些。不是因为有人不诚实。而是因为它们从未触发那件用来捕获 bug 的机制。
在开始之前,我对这个论点是知道的。并没有阻止我写出九个来。
只需要足够让我理解这些 bug 的上下文就够了。
变异测试会对源代码做小的改动。翻转一个比较语句。更改一个常量。删掉一个 raise。然后它会运行你的测试套件,检查是否有测试失败。
def withdraw(balance, amount):
- if amount <= 0:
+ if amount < 0:
raise ValueError("amount must be positive")
如果套件保持绿色,那就是你的测试检测不到的缺陷。
覆盖率告诉你一行代码运行了。变异测试告诉你,如果那行代码是错的,会不会有人抱怨。这是两个非常不同的问题。一个只有快乐路径测试的玩具模块,行覆盖率达到 47%,而变异杀死分数只有 9.5%。
这个工具链生成变异、对它们运行套件、打分有多少被捕获。大约 40 小时的工作量。
九个 bug,以及它们各自把结果推向哪个方向
可编辑安装使变异失效。pip install -e 对 src-layout 包会将 import 解析回原始 checkout,因此写入临时副本的变异从未被执行。三个目标的分数是 0.000。方向:读起来像是"这些测试套件很糟糕"而不是"我的工具链坏了"。这让我在解决的问题看起来更大了。
并行执行污染了一个目标。对 mutants 并发运行产生了在四个运行中对一个真正做异步 I/O 的目标得到三个不同的结果。记录的 0.27 到 0.77 的改进是噪音。虚假失败被计为检测到了变异,而检测正是我最大化的那个数字。方向:虚高了结果。
文件选择器为最难的目标选了一个无关的测试文件,在最需要上下文的精确位置给模型喂了无关的上下文。方向:让一个基准线看起来比实际更差。
分类器按批次而不是按单个测试分类。一批 69 个中有一个好的测试,就会把全部 69 个标记为好的。方向:虚高了质量。
重构步骤丢弃了共享的 import,制造了不存在的测试失败。方向:低估了基准线的能力。
提取器只扫描顶层函数,所以一个有效的 unittest.TestCase 响应被丢弃为"未找到测试"。重试循环随后收到的是工具链错误而不是真正的 pytest 输出,这禁用了正是我要测量的那个机制。方向:低估了 agent。
self.assertEqual(...) 被分类为"无断言"。当时我正在检验一个关于模型写无断言测试的假设。方向:会制造出我自己的假设然后还给我。
一个预注册的指标在 dunder 分派代码上无法计算。它被读成了一个真正的近零率而不是 undefined。方向:虚假信号。
一个已经经历了两轮审计的文档图表。其中一个分支记录为零次干净通过失败,而实际上有三次。方向:美化了一个对比项。
注意,它们并非都虚高了头条数字。其中三个低估了基准线或一个分支。这仍然算有利,因为更差的基准线让我做的东西看起来更好。"有利"的含义是有利于这个故事,而不是有利于某一个指标。
第九个 bug 是我最难释怀的一个。它已经被看过两次了。两轮审计都略过了它,因为图表与我们预期看到的内容一致,没有什么促使第三次审视。
没有一个是靠读代码发现的
这是我最希望别人记住的部分。
这九个里没有一个是靠重读函数发现的。我已经读过那些函数了。读自己写的代码,去找一个你还不相信存在的 bug,这几乎没有用。
每一个都是用同一种方式捕获的:运行一个我事先已经预测过结果的检查,然后得到了一个不同的答案。
最清楚的例子是 bug 5。我独立地、也从更早的输出中知道,一个特定的生成测试是坏的。所以预测很简单。删掉那一个测试,套件就会变绿。
我删掉了它。套件没有变绿。
正是这个矛盾让我在那个丢弃 import 的 bug 的数字进入任何东西之前就发现了它。没有任何其他信号。它产生的分数看起来完全合理。它们在一个我喜欢的方向上合理,所以没有其他东西会促使我去查看。
预测才是真正起作用的。你不带预测地去跑一个检查只是产生了另一个数字,而你会用和所有其他数字一样的方式去解读那个数字。
两个值得偷来的检查
两个都很便宜。两个都捕获到了东西。
金丝雀。在被测文件上覆盖不可解析的垃圾,然后断言套件应该失败。这证明了你的变异确实到达了解释器。如果文件在语法无效的情况下套件仍然通过,你就不是在测你以为你在测的东西。这就是在第一天就会捕获 bug 1 的方法。
确定性门。将相同的评分跑三遍,串行地,要求字节级别完全一致的输出。这证明了执行是隔离的。这捕获了 bug 2。
重要的一点是,它们证明了不同的属性,谁也替代不了谁。
金丝雀可以在并发悄悄破坏你的结果时快乐地通过。你的变异到达了解释器了,所以金丝雀满足了,数字仍然是垃圾。
确定性门可以在 import 解析到错误文件时快乐地通过。三次完全确定性地运行错误的东西仍然是完全确定性的。门满足了,分数却没有意义。
两个都需要,而且你需要写下每个实际上证明了什么,这样你就不会说服自己相信其中一个覆盖了另一个。
一个捕获了我自己目标之一的检查
截止日期前三小时,我做了一个干净克隆的复现运行来验证可复现性声明。
确定性门隔离了我自己的一个目标。十二个中的十一个精确复现了。第十二个有差异。
我在 README 中报告了它,而不是修复它。
一个从未捕获过任何东西的检查和一个捕获不了任何东西的检查是无法区分的。我的刚刚捕获了东西,而那个东西是我自己的。从记录中删除它会让项目看起来更好、而工具看起来更差,这正是整篇文章所讨论的权衡。
在你测量任何你搭建的东西之前:
写下你的仪器在对你撒谎时会是什么样子。然后搭建那个专门捕获它的检查,并在你还没有执着于任何结果之前运行它。
然后写下每一种可能的谎言会把你的结果推向哪个方向。
第二张列表才是重要的,因为它是你最没有动机去跑的检查的列表。这上面的每一个条目都是一个 bug 会感觉像一个发现的地方。你自己不会注意到那些。没有人会。这就是在整个九分之九中出现的全部原因。