我那天的检查脚本报了一句:这批候选参数一个都不合格,实验没法做。
我信了,开始改实验设计。
后来我拿它自己的判据,把它落盘的六十行数据手算了一遍——十五行合格。 判据算出来是 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 至少还有下游结果帮你兜底。