返回博客

2026.09.14

我的三个检查脚本在同一天骗了我三次,两次是说我没通过

都知道要警惕通过得太轻松的检查。但我那天撞到的三次里,有两次是反过来的——检查报失败,被测的东西其实合格。一次让我白跑四十组训练,一次让我差点放弃一个正确方向。这篇给出识别这两种错法的具体判据和自检代码。

实验方法探针设计连接组

我那天的检查脚本报了一句:这批候选参数一个都不合格,实验没法做。

我信了,开始改实验设计。

后来我拿它自己的判据,把它落盘的六十行数据手算了一遍——十五行合格。 判据算出来是 True,脚本标记的是 False。没有报错,没有警告,就是安静地把十五个正确答案划掉了。

同一天我还撞到两次。一次是脚本说全部通过,其实我读错了列,六十六个通道的数字全是隔壁列的;一次是脚本说我有数据泄漏,其实那个检查看的字段根本不是我以为的那个。

三次里两次是假失败——检查报不合格,被测的东西其实是好的。

我以前只防一个方向。网上讲测试和验证的文章,讲的也几乎都是那个方向:小心那些通过得太容易的检查。但假失败的账更难算,因为它不会让你相信一个错误的结论,它会让你放弃一个正确的方向,然后照着错误的诊断去改设计,把本来对的东西改坏。

想直接拿自检代码的看第 4 章,那是四行。

1. 第一种:读错了列,而且它报通过

我在分析一份果蝇嗅觉受体的公开数据,想知道每个受体被测过多少种气味。

这个 CSV 有个坑:表头 78 列,数据行 79 列。 每条数据行的第一格是气味的化学标识符,而表头没给这一格取名字。

于是我用 row[j] 取第 j 个受体的数值,实际拿到的是第 j-1 个受体的。整体错位一格,66 个通道全错

脚本跑完,一切正常。我把结果发了出去,还特意夸了两个"漂亮的锚点":

Or22a  我报 497 种气味  →  实际 225
Or67d  我报 161 种      →  实际 0

Or67d 对应的是果蝇性信息素通路,我当时特意说这是"研究最透的一条"。那个通道在这份数据里一个格子都没有。 我把隔壁列的数字安在了它头上。

更糟的是我本来有机会当场发现。这份数据总共 691 种气味,而我报 Or22a 测过 497 种——单个受体覆盖了七成多的气味库。这个数字不合理,我看到了,没停。

修的时候我加了一行:

assert 0 <= coverage <= len(odor_rows), f'{r} coverage {coverage} 超出气味总数,列索引可能又错位了'

这行断言的成本是零,而它能抓住所有错位——错位一格之后,某些通道的计数必然跨出合法范围。

按位置取列之前,先拿一次真实输出对齐表头。 这是老生常谈,但我想强调的是另一半:给关键指标加一条数量级断言。 对齐表头依赖"我这次想到了要对齐",数量级断言不依赖任何预判——它在数据本身上设了个物理围栏。

2. 第二种:检查的字段不是我以为的那个

我在测一个学习模型有没有作弊。标准做法是把标签打乱重训一遍,如果它还能学好,说明有信息泄漏。

检查跑出来 0.94 到 1.00。这是灾难性的数字——标签都打乱了还能考 96 分,只能是泄漏。

但这个结论跟我对代码的理解对不上。去翻实现,发现落盘了两个字段:

shuffled_training_recall   0.94~1.00
original_label_accuracy    0.46~0.58

第一个是"用打乱的标签训练,然后在那套打乱的标签上测回忆"。学什么复现什么,这个数字本来就该高,它衡量的是模型的记忆容量,不是泄漏。

第二个才是泄漏检测:"用打乱的标签训练,拿原始的正确标签测"。如果模型能从被污染的训练里恢复出真实规律,那才叫泄漏。实测 0.46 到 0.58,正好是瞎猜——完美通过。

判据挂在了第一个字段上。一个完美通过的自检被判成失败,整轮实验的结论作废。

这个错法的隐蔽之处在于:两个字段都叫"shuffled 什么什么",都是合法的诊断量,都会被算出来落盘。选错的那一刻没有任何信号,因为两个都不是 bug,只是回答的问题不同

判据:写门禁的时候,把"这个字段回答的是哪个问题"写成一句话注释放在判据旁边。 如果写不出那句话,说明你还没想清楚在测什么。

3. 第三种:门禁比我要求的多加了一道

最贵的一次。

我要一个难度门禁:候选参数必须让两种输入的平均准确率都落在 0.55 到 0.95 之间——太高说明任务太简单看不出差别,太低说明全军覆没同样看不出差别。

实现的时候,多加了一道:每一个单独的格子也必须大于 0.55。

平均值和每一格,差别看着不大,实际杀伤力完全不同。一组参数完全可能平均 0.62 合格,但其中有一格是 0.53。按我要的判据它通过,按实现的判据它被枪毙。

六十个候选里,十五个就是这么死的。脚本给出的结论是"该子实验在此设定下无法获得鉴别力"——听起来像是我的实验设计有根本问题,而实际上合格的工作点一直躺在那里。

我发现它是因为多做了一步:拿门禁自己的判据,把它落盘的数据手算一遍,跟它的标记值逐行对比。

重算判定合格: 15 行
落盘标记合格: 0 行
不一致: 15/60

十五行不一致,零个异常抛出。

4. 四行自检

上面三种错法,有一个共同的形状:门禁是代码,代码有 bug,而门禁的 bug 不报错。 它只是安静地给出一个跟数据不符的判断。

所以门禁必须自己被检查一次。方法很笨,但四行就够:

rows = json.load(open('calibration.json'))
mismatch = [r for r in rows
            if bool(gate_expr(r['measured'])) != bool(r['gate_flag'])]
assert not mismatch, f'{len(mismatch)}/{len(rows)} 行判据与标记不一致——门禁实现有 bug'

关键是 gate_expr重新写一遍,不要 import 门禁里那个函数。你要验的就是"实现有没有偏离意图",复用同一份实现等于自己证明自己。

触发时机:门禁报 FAIL 的时候。 报 PASS 的时候你会去看结果,自然有机会发现不对劲;报 FAIL 的时候你会直接去改设计,那个错误判断就永远没人复核了。

这四行的成本是几秒钟。我那次的代价是一整轮实验,外加一个差点被放弃的正确方向。

5. 顺带说,那个实验最后的结论

这些检查是为一个具体问题服务的:果蝇大脑里那套负责嗅觉学习的回路,它的具体接线方式有没有用?

做法是把真实连线换成随机重连——保住每个神经元连多少条边,只换"接给谁"——其他全不变,看性能掉不掉。

我跑了六轮。前五轮都是打平,但每次我都能给自己找个借口:任务选得太简单了,或者输入是我编的合成数据。

第六轮把借口堵死了。用的是一份公开的真实嗅觉受体响应数据,70 种真实气味在 34 个受体通道上的实测值;按文献把受体对应到解剖学正确的投射神经元(44 个嗅小球对上了,占 86%);学习规则用生物学本来那条局部多巴胺规则,不是梯度下降;难度也标定过,确认离开了天花板。

结果:

真实气味下,真实连线 vs 随机重连(多重校正后)
  记忆容量    +0.0457   不显著
  相似辨别    -0.0333   不显著
  抗噪能力    +0.0100   不显著

39 项检验只有 1 项显著,且与真实拓扑无关

还是打平。

这不意味着生物连接组没用。它意味着在这个模型、这套学习规则、这个任务上,那套具体接线的边际贡献测不出来。我能想到的最诚实的说法就是这句,再往前一步都是超纲。

6. 这轮实验有哪些地方不能信

那份嗅觉数据是跨实验室整合的共识值,不是某一次实验的原始测量。不同实验室的记录条件不同,整合本身带假设。

受体到嗅小球的映射来自文献,不是连接组数据集自带的标注,我没有独立验证过每一条。

686 个投射神经元里,实际被真实数据驱动的只有 155 个,另外 531 个我置了零。这是建模选择,不是生物学事实。

五组配对种子,n=5 的参数检验很脆弱,多重校正进一步降低了检出力。不显著不等于等价。

还有一条最重要的:这些检查脚本是我写的,我那天刚证明了我会写错。 第 4 章那四行自检只查了"实现有没有偏离意图",查不出"意图本身对不对"。

7. 下次写门禁按这个顺序来

第一,任何按位置取列的地方,先拿一次真实输出对齐表头,并把列序写进注释。

第二,给每个关键指标加一条数量级断言。覆盖数不能超过总数,准确率不能超过 1,延迟不能是负的。这条比对齐表头耐用,因为它不依赖你这次有没有数对。

第三,每个判据旁边用一句话写清楚"这个字段回答的是哪个问题"。写不出来就是还没想清楚。

第四,实现判据的时候,逐条对照需求念一遍,确认没有多加也没有少加。多加一道地板和少加一道天花板,都会让门禁变成另一个东西。

第五,门禁报 FAIL 的时候,用独立重写的判据把落盘数据手算一遍,对比标记值。四行代码。

第六,报 PASS 的时候也别省这一步,只是优先级低一点——PASS 至少还有下游结果帮你兜底。