2026 年 5 月,项目从一台 RTX 5090 开始。八天之后,362 篇文档被解析成题,500 道题跑过 9 个模型,第一版榜单出来了。当时的判断很直接:基准做完了,接下来是训练和跑分。
四个月后回看,真正决定结论的不是那张榜单,而是四次被推翻的判断。
第一次,30 步的短跑排名被当成方法优劣的证据;第二次,裁判被当成可以随手替换的服务;第三次,分数上涨被当成模型变好;第四次,连"裁判至少能分出好坏"这个底线也被一次锚点审计推翻。四次都不是靠更复杂的模型纠正的,而是靠把答案、题池和比较标准逐项冻住,让变化只剩一个解释。
时间线大致是这样:
这篇记录按这条线写下去,重点不是结论,而是每次判断错在哪里、怎么发现的、改了什么。
起点:先把 PDF 变成题
控制科学没有现成的中文评测集。教材是 PDF,论文是 arXiv 预印本,公式、表格、双栏排版混在一起。项目先用 MinerU 把 23 本教材和 339 篇 arXiv 论文转成结构化文本,再从里面抽题。题池最终登记 1,979 道,来自 361 个来源;第一轮实验只取其中 500 道,九个模型各跑一遍,形成最初的榜单。
解析这一关先花了两天。MinerU 的输出有公式、图片、表格三层,单篇跑通不等于批量可靠。第一批八本教材在解析中途反复中断,前后浪费约两小时。根因不在 MinerU,而在调用方式:脚本用 subprocess.run 启动解析进程,按下 Ctrl-C 之后,KeyboardInterrupt 沿着进程树连锁,父进程、子进程、Docker 容器一起退出。改成 Popen 并给子进程单独的进程组(CREATE_NEW_PROCESS_GROUP)之后,后续 339 篇论文零中断跑完。
还有一个不显眼但会累积的损耗:显存碎片。每批解析六到七本教材,容器占用会爬到 22–23 GB(总共 24 GB),速度衰减两到四倍,再跑就 OOM。当时的处理很土——批次之间 docker restart mineru,42 秒恢复,已保存的文件不受影响。339 篇论文就是在这个节奏下跑完的。它不是优雅的方案,但在单机、单卡、八天的约束里,重启比调参可靠。
题出来之后是生成。三家 API 同时出题,合并、去重、仲裁,最后留下 500 道核心题。这一段踩的坑比解析更多。
第一个是 checkpoint 不原子。断点文件直接覆盖写,进程在写一半时被杀,文件就坏了;API 偶尔超时,调用层没有重试,整批任务从头再来。500 道题的一轮里,约 20% 需要重跑。改成先写 .tmp 再 os.replace,调用层加三次重试,仲裁器记录 failed_ids 并支持从断点恢复,重跑率降到 3% 以下。
第二个是凭据泄露。API Key 通过命令行参数传给子进程,ps aux 能看到完整字符串。改成环境变量注入,Key 只存在于子进程的私有内存;同时审计了整个仓库的调用点,修了 10 个 Python 文件。另有几个模块在 import 时读取 Key,环境变量没设时不会报错,只会在真正调用时拿到空字符串,错误信息指向认证失败,排查成本很高。后来全部改成惰性加载。
第三个是大文件。嵌入分析生成过一个 278 MB 的缓存文件,超过了 Git 托管平台的单文件上限,推送被远端拒绝。处理方式是从全部提交历史里移除这个文件,把 *.npy 加进忽略列表,仓库从 262 MB 降到 4.62 MB。这件事的教训不是"记得忽略大文件",而是训练中间产物从第一天起就不该进版本库。
八天结束时,第一批交付完成:362 篇文档、500 道题、9 个模型的评分,以及两篇技术报告。现在回看,这一段的真正价值不是榜单,而是三个后来反复复用的习惯:长任务用独立进程组,写盘先写临时文件,凭据不进命令行。尤其是断点续跑——如果没有它,七月的 300 题评测会直接报废。
八天里的顺序
把八天拆开看,能更清楚地看到哪些决定是临时应付、哪些后来变成了基础设施。
第一天只做了一件事:跑通单篇解析。先拿一本教材验证公式、图片、表格三层输出,确认公式不是图片、表格没有串行、双栏没有读反,再进入批量。这个顺序后来救了很多时间——如果先批量再验质量,重跑的代价是几百篇。
第二天是采集与首轮解析。arXiv API 拉回 342 篇,去掉失效链接后剩 339 篇;教材 23 本。解析在中途被 Ctrl-C 打断五次,才引出进程组隔离的改动。同一天定下 5% 的补采余量,因为 arXiv 的限流不是均匀的,末尾几篇最容易缺。
第三天做生成与仲裁。三家 API 同时出题,按题意去重,分歧交给第三家仲裁,最后留下 500 道核心题。原子写、三次重试、failed_ids 都是这一天加的;如果没有这些,后面每扩一次题量都要从头再来。
第四天交付 4B 的 QLoRA 管线,同时发现 Key 走命令行的问题。安全审计把同类调用点全部改成环境变量注入,模块级读取改成惰性加载。这一天还做了一次目录重构,把训练、评测、报告三块分开,为后面的 25 条臂留位置。
第五天做跨模型自修正实验。三家模型各修正 20 道错题:MiniMax 成功 12 道(60%),MiMo 11 道(55%),DeepSeek 10 道(50%)。分维度看,概念回溯类(A 维)三家都在 80%–100%,多步推理类(B 维)三家都低于 40%。结论很直接:单条反馈能把概念错误拉回来,但拉不回断掉的推导链。这个结论后来影响了题池的维度配比。
第六天做仓库清理与更名,删掉 83 个内部文档,把 docs/ 改成白名单模式。白名单第一次提交把整个 docs/ 都排除了,因为规则顺序写反;逐条加到 11 个放行路径才稳定。同一天发现历史提交里有 API Key 字面量,工作树先替换,历史清洗留到公开前。
第七天做裁判抽检,也就是后文那六个裁判的实验。第八天收尾:嵌入分析生成 278 MB 缓存,推送被拒,filter-branch 从全部历史里移除,仓库降到 4.62 MB;图表重画三轮,最后用表格版而不是手绘卡片;README 的两个方向改成对等结构。
这段顺序里没有哪一步是"计划好了才做"的,更多是遇到问题再补。但补完之后都留下了机制:进程组、原子写、惰性加载、白名单、忽略列表。八天项目最容易留下的是结果,最难留下的是这些机制;而四个月后的复核,靠的正是它们。
训练矩阵:25 条臂,两个域,三个规模
题池稳定之后,项目进入训练阶段。矩阵固定为两个域(控制科学、医学)乘三个模型族(4B、9B、Gemma),一共 25 条臂,其中 6 条是原始或 SFT 基线,19 条是训练出来的 LoRA 适配器;Gemma 族内又分 E2B 与 E4B 两个尺寸,另有 11 条表面适配器,用来测同一方法在不同输出层上的稳定性。
训练用 QLoRA,控制域的训练集 1,408 条、验证集 176 条,医学域是 508 条和 63 条,批次大小 1、梯度累积 2,等效批次 2。探针阶段最多跑 250 步,每 125 步存一次;正式训练按配置跑满。不同架构的 target_modules 不能共用一份:4B 是 12 个模块(包含 in_proj_z),9B 是 11 个,Gemma 是 7 个。这些值从各自基线的适配器配置里精确复制,不隐式继承——一旦猜错,训练照样能跑,只是训的不是同一个东西。
评测协议在训练之前就定好了:
-
题池 1,979 道,每道带机器可读的许可证桶,分成可再分发 685、仅 arXiv 927、受限 241、教材待核 126。
-
正式实验从池里取 300 道,出题与打分分离,双裁判独立评分,分歧交给第三个模型仲裁。
-
每条臂在每个规模上生成三次,答案生成后立即冻结;三次之间的方差记为 σg,后续比较必须先跨过这个尺度。
-
晋级、淘汰和"什么算增益"的阈值在看分之前写死;漂移的对策是重新生成或重判,不是事后修正。
-
分母固定:SFT 的增益相对原始模型,强化学习臂的增益相对 SFT。
这套协议不是一开始就有的。它是被前面几次判断错误逼出来的:短跑排名靠不住,才有了"必须在目标步数上重跑";裁判尺度不一致,才有了"双裁判 + 仲裁 + 记录世代";分数会整体漂移,才有了"冻结答案 + 重判"。每一条都对应一次具体的失败。
第一次判断错:短跑排名可以筛方法
七月初,项目需要回答一个中间问题:几种强化学习方法里,哪一种值得跑完整训练。手头有 30 步的探针结果,5 条配置全部收敛,PPL 落在 3.84 到 3.87 之间。30 步的排名里,augment_mgpo 排第一。
这套短跑本来只打算用来确认训练没有崩,但排名太整齐,很容易被读成方向性证据:既然 30 步就能分开,何必花几倍时间跑长跑。于是又跑了一组 250 步。
250 步之后,5 条配置仍然全部收敛,PPL 降到 3.68 到 3.70,平均比 30 步低 4.3%。排名却变了:30 步的冠军 augment_mgpo 掉到第四,ftd_augment_mgpo 和 mgpo 并列第一。30 步看到的差距,在更长的训练里没有保持。
这不是随机波动。五条配置的收敛值都稳定,变化的是排序。短跑测到的是训练早期的动力学:谁先下降、谁下降得快。长跑测到的是渐近表现:谁最终停在哪里。两者不是同一个量,短跑的排名自然不能外推。
如果当时按 30 步选臂,会选错一条,而且错得很有说服力——曲线漂亮、收敛稳定、差距明显。后来这条教训被写进流程:短跑只能回答"有没有收敛",不能回答"谁更好";任何涉及方法排序的结论,必须在目标步数上重跑。
探针本身也留下了一个工程细节:5 条配置是串行跑的,每条通过独立子进程启动,进程退出时操作系统回收显存。这样虽然慢,但不需要手动清 GPU,也不会因为显存残留让下一条配置跑到一半 OOM。在单卡环境里,串行比并发可靠。
第二次判断错:裁判换一个就行
同一时期,项目的评分依赖 LLM 裁判。最初的直觉是参数量:更大的模型应该更可靠,35B 总比 4B 强。五月做了一次本地抽检,六个裁判各评同一批题,结果和直觉相反。
gemma3:4b 的评分是 0.872,qwen3.5:35b 是 0.233。六个裁判之间的 Fleiss κ 只有 0.003,几乎等于没有共同尺度。速度也倒过来:35B 每题约 128 秒,4B 约 6 秒。
0.872 不是准确率,是它和自己一致;0.233 也不是"错",是它每次给的分不一样。抽检暴露的问题不是"哪个裁判更准",而是"这批裁判之间没有可比较的尺度"。单个裁判给自己打高分,不能证明它有效;一个便宜的小模型完全可能因为输出稳定而显得可靠。
为了不让自动裁判自说自话,项目还做了一个人工评分界面:一道题显示题面、参考答案和待评答案,评分者一次只评一个维度,可以中途停下再继续;13 道题大约需要 35 到 55 分钟。界面是中文的,和实际施测时一致。它的作用不是替代裁判,而是提供一条独立的参照:当自动裁判给出反直觉的分数时,至少能找到人来看一眼。
这次抽检当时没有立刻改变评测流程。真正让它变成协议的一部分,是八月的那次重判:同一批答案,同一个裁判模型名,隔了三周,分数整体上移。名字没变,量表变了。到那时才确认,裁判不是可以随手替换的服务,而是需要被审计的仪器。
第三次判断错:分数上涨等于方法变好
八月初做重判时,问题已经不再是"哪个方法更好",而是"分数本身稳不稳"。
首轮判分集中在 2026-07-14,重判集中在 2026-08-07,文件名带 run1p 后缀,指向同一批冻结答案。两次记录的裁判模型名相同,都是 deepseek-v4-flash。控制域 4B 的四条臂,逐题配对后全部上移:
四条臂的增幅在 0.058 到 0.079 之间。控制域 11 条臂方向一致,已发表的聚合值是 +0.0606;用仓库交付的逐题分数按臂配对,重算约 +0.06。两个口径一致,说明聚合值不是某一条异常臂拉出来的。
医学域是另一幅图景。同一套重判流程下,聚合漂移只有 +0.0002,12 条臂里 5 条上移,方向和幅度都没有形成系统性偏移。
这个不对称是整次复核的第一个线索。如果只是判分服务整体升级,两个域应该同向移动;如果只是某一批答案坏了,移动应该集中在少数臂。实际是控制域整体上移、医学域基本不动,而且每条臂的逐题记录都能配对。
答案已经冻结,生成侧不构成变量。剩下能解释分数变化的,只有打分这一侧。
四个排除方向
排查按"最容易伪造的变量先排掉"的顺序展开。
生成侧先排除。每条臂的答案文件带模型路径、生成时间与内容指纹;逐文件核对后,run1 与 run1p 指向同一批答案。三次生成的代际方差也量过:如果生成侧变了,移动应该集中在个别臂,而不是 11 条臂同向。
数据侧其次。两次判分使用同一批 300 道题,逐题 ID 一一对应。如果题池被替换,配对会在连接阶段直接失败,不会得到一个平滑的整体位移。分母也稳定:SFT 相对原始模型,强化学习臂相对 SFT,重算时没有重新加权。
聚合侧再次。每份评测文件里有 300 条逐题记录,包含两个裁判的分数、是否分歧、是否仲裁。按原始规则重算均值,结果与文件自报的 overall_score 一致;配对差值在重算后仍然存在,聚合没有放大或缩小位移。
最后只剩判分侧。两次判分的裁判模型名相同,但服务在世代之间发生了变化。名字级的一致,恰恰说明只靠模型名做版本锁定不够:名字没变,量表变了。裁判是云端服务,API 调用记录不随仓库分发,无法从公开文件还原服务端改了什么;能还原的是影响——同一批输入,在后一个世代上得到了系统性更高的分数,而这个影响没有在医学域同等出现。
为什么标准要提前写死
协议要求在第一次计分比较之前,把晋级、淘汰和"什么算增益"的阈值全部落成文字。四项预注册比较里,只有一项因为这次漂移跨过了线。
如果没有提前写死标准,事后既可以把漂移解释成"裁判更准了",也可以解释成"需要校正",两种叙事后都能自圆其说。冻结标准把选择权从结果手里拿走了。这次漂移之所以能被处理,不是因为事后找到了更好的解释,而是因为解释的空间在事前就被压缩了。
第四次判断错:裁判至少能分出好坏
重判只证明了裁判的绝对分在漂移,没有回答一个更基本的问题:在同一代里,它能不能把明显好的答案排在明显坏的答案前面。
八月二十日做了一次锚点审计。做法是构造三种答案:好的(包含参考答案要点)、坏的(错误答案)、空转的(不答)。如果裁判有区分力,全序命中率——好于坏、坏于空转、好于空转三条同时成立——应该显著高于随机基线 1/6。
第一版审计有一个构造缺陷:为了去掉答案里的身份线索,脚本把上下文头部截掉了 800 字,结果 68% 的"好答案"丢掉了答案要点,裁判打 0.0 是正确行为,但整个锚点的语义塌了。发现后重写为以参考答案的数字 token 为锚做居中切片,并先把判据冻结,再点火重跑。
冻结的判据写得很难看,但很必要:
重跑四分钟结束,n=23(7 道没有数字 token 的题按先例排除),全序命中率 0.0。good 对 bad 的命中率也是 0.0;good 与 bad 打平的题有 21/23,三档全 0 的有 16/23,从来没有出现"好于坏"。唯一稳定的信号是空转:空转档稳定拿 0 分,bad 对空转、good 对空转的命中率都是 0.3043。
也就是说,裁判能识别"没答",但不能区分"答对"和"答错"。这个结果比漂移更难接受,因为它不是量表偏移,而是量表在这个任务上几乎没有分辨力。
审计没有只报一个总分,而是四个读数一起报:
只报全序命中率会得到一个"0.0"的结论,但看不出它是怎么来的。四个读数放在一起,才能区分三种失败:裁判完全没读(全零)、裁判读了但不敢排序(打平)、裁判只对"有答案/没答案"有微弱反应(0.3043),却分不出答案好坏。这三种失败对应不同的修法,混在一个总分里就丢了信息。
按冻结的判据,这次只能走"无区分性"分流:论文里不能写"裁判可靠",只能写"裁判在锚点三档上没有可测的区分能力",并把审计协议本身作为贡献。判据是在看数之前写的,所以结果再难看也不能改口径。这一条后来成了整个项目最重要的方法:负结果不是事故,是结论的一部分。
40 题的结论,到 300 题也不稳
七月中,40 题的评测已经跑完。当时的结论是 MGPO_EG 最强,综合分 0.488,C 维度的天花板被推翻,提升 +0.075。40 题能看出方向,但样本量不足以做统计检验,于是把题量加到 300,三个模型各跑三轮。
300 题的结果没有推翻 SFT 的增益,但把 MGPO 的结论压下去了:SFT 对原始模型的差异是 p=0.003,MGPO 对 SFT 只有 p=0.082,只能算边际显著。40 题里"MGPO 最强"的判断,在更大样本上变成了"方向一致,但证据不足"。
这一段还贡献了一次典型的工程事故。第一次启动在 22:04,Raw Qwen 生成到第 14 题时进程崩溃,退出码 3221225786,对应 Windows 的 STATUS_CONTROL_C_EXIT,GPU 利用率瞬间归零,日志里没有明确错误。把并发模型从 7 个缩到 3 个后重启,以为问题解决了。
第二次启动,三个模型全部在 180 分钟超时。根因是阈值:180 分钟是按 40 题估的,300 题需要约 7.5 倍时间。Raw Qwen 的生成速度是 0.8 题/分钟,300 题需要 375 分钟;SFT 约 170 分钟,MGPO 约 195 分钟。改代码里的超时阈值从 180 到 480 没有用——已经加载到内存的进程不会读新值。只能等主进程在次日 07:42 退出,前后 540 分钟全部作废。
07:44 用 --resume 重启。Raw Qwen 从第 151 题续跑,SFT 从第 271 题续跑,MGPO 已完成生成、直接进入评分。三次续跑之后,262.9 分钟完成,产出 9 个 JSON,SFT 与原始模型的差异得到 p=0.003。
监控链跑了 14 次,每小时一次。前三次就发现了超时:Raw Qwen 在 01:44 失败,SFT 在 04:44 失败,MGPO 在 06:44 接近超时线。恢复之后,Raw Qwen 以 0.8 题/分钟稳定推进,从 200 题到 300 题,GPU 利用率维持在 42%–52%。
这套自续链还有一个小插曲:第一次创建定时任务时把 23:44Z 当成了本地时间,实际是次日 07:44,等于白等八小时;修正为 15:44Z 才对上。时区错误不会让任务失败,只会让监控发生在错误的时刻。
300 题还暴露出评分分布的问题:A 维度的中位数全是 0.000,分布严重偏态,只能用均值口径报告。这不是裁判的问题,是题目难度与评分粒度的问题,但它在聚合层制造了另一种不稳定。
这一段留下的经验很具体:超时阈值按最慢的模型算,再留 25% 余量;改代码不会改变已经跑起来的进程;--resume 是长任务的生命线,每次运行都必须落 recovery 文件;监控要按最慢路径设计,而不是按平均值。
探针矩阵重建:先把仪器修好
重判之前还有一段不显眼的工作:把探针矩阵按统一数据版本重建。旧探针数据是分批跑的,批次之间数据版本不同,直接拿来比较等于把批次差当成方法差。八月三日用一天时间把这条线重做了一遍。
重建的规模不大,但改动面很宽:新增 10 个文件、修改 4 个文件,合计 +1503/−18 行。核心是把训练脚本从"只能加载已训练适配器"改成"支持原始基座 + 新 LoRA":base_adapter 为 null 时走 raw base 分支,PPL 评估新增 --base-model-id,消融脚本新增 --base-model 与 --lora-target-modules。六份格子配置覆盖 4B、9B、Gemma 乘控制、医学两个域,target_modules 从各自基线的适配器配置精确复制:4B 12 个(含 in_proj_z),9B 11 个,Gemma 7 个。
数据冻结是这一步的前提。四份训练/验证文件先生成 sha256 与行数清单:控制域 1,408/176,医学域 508/63;之后每次跑之前强制比对,不一致就拒绝启动。这个门看起来多余,直到有一次文件被重新生成、行数没变但内容变了——哈希比对把它拦住了。
重建里最典型的一个坑是 DoneMarker 的冒号。断点标记用 step_id 做文件名,而 step_id 里带冒号(比如 qwen4b_control:raw),Windows 文件名不允许冒号,写入时直接抛 OSError: [WinError 123] 文件名、目录名或卷标语法不正确。PPL 评估本身已经成功,只在最后标记完成时崩,日志看起来像评估失败。修复是加一层 _sanitize,把 :<>"/\|?* 这些字符统一替换掉,is_done 与 mark_done 都走同一个路径。这个问题 dry-run 发现不了,只有真跑才会暴露。
代码审查还抓到一个更隐蔽的问题:PPL 评估没有把 base_model_id 透传到底座加载器,4B 之外的所有格子都会在默认底座上算 PPL。结果不会报错,只会得到一条"看起来正常"的错误曲线。这类错误比崩溃危险,因为它产出的是可用但错误的数字。修复后,非 4B 底座的故事才真正成立。
重建完成后,编排器把六格拆成 108 步(每格 3 个 λ 加 15 个 probe),另外 25 项普查覆盖 7 组适配器的本域验证。每一步都有断点标记,失败可以从任意一步续跑。这套编排没有让实验变快,但让"重跑一次"从一天变成几分钟。
处置:重判,不校正
处置没有采用事后校正。原因是校正需要知道漂移的真实方向与幅度,而这两样东西在事后只能从同一批带漂移的分数里估计,等于用结果证明结果。
实际做的是两条线:重新生成,重新判分。
重新生成针对代际方差。每条臂在每个规模上生成三次,得到一个经验性的 σg;后续任何方法比较,先看差值是否跨过这个尺度。落在 σg 内的差异不进入结论,即使绝对值看起来不小。
重新判分针对仪器漂移。把首判答案冻结,在后一个判分世代上重判,保留 run1 与 run1p 两份文件,逐题、逐臂配对,报告位移。代价是判分调用量翻倍;收益是把"分数变了"拆成"答案变了"与"裁判变了"两种可能。
计算量集中在这一步。训练与评测合计约 350 到 400 GPU 小时,判分密集的部分使用租来的 A800;无人值守流水线累计 194 小时,分成 12 段记录。首判与重判各覆盖数百个文件,最终交付 237 份逐题评测文件,约 3.2 万条判分记录。
重跑的范围仅限判分,没有重训。这不是为了省算力,而是实验设计的一部分:答案冻结之后,重新训练会引入新的生成变量,反而无法把位移归因到裁判。换句话说,重训不是更严谨,而是把问题换掉了。
结论变化与适用边界
把漂移放回增益表之后,最重要的变化是 4B 与 9B 上的方法排序翻转。
在 4B,SFT 相对原始模型的增益是 +14.9%;两个强化学习臂都低于 SFT,RLVR-C v2 为 −0.9%,MGPO-EG 为 −5.1%。此时 RLVR-C v2 排在 MGPO-EG 前面。
在 9B,SFT 的增益是 +13.0%;RLVR-C v2 为 +2.0%,MGPO-EG 为 +7.6%。两个臂都高于 SFT,排序也反过来,MGPO-EG 领先。
同一对方法,在两个规模上给出相反的排序。这不是小数点后的抖动:4B 上两者相差 4.2 个百分点,9B 上相差 5.6 个百分点,都超过了代际方差的量级。
两个独立探针给出同方向的结果。换一个基座家族(Gemma E2B/E4B),SFT、RLVR、MGPO 三组增益变成 −5.0/+2.6/−15.7 与 −12.4/−0.5/−9.8,SFT 与 MGPO 都转为负值;换一个任务域(医学),9 项训练对基线的比较里 4 项是损失。增益的符号跟着基座身份走,而不是跟着方法名走。
这些结论有明确的边界。
评测池是每个实验 300 题,不是 1,979 道全池;全池是题目来源与许可证登记的母体。81 个模型标识里,这个仓库只能触达 23 个,其余证据留在未公开的开发仓。漂移聚合值本身属于"仅登记"级别:文件与指纹在,逐题原始判分也在,但裁判 API 的调用记录不在,所以不能从零重放整次判分。项目明确不声称端到端复现;能重算的是文件内聚合与逐题配对,不能重算的是服务端行为与训练过程。
还有一个边界容易被忽略:结论的适用范围是"这个题池、这套裁判、这两个域"。4B 与 9B 的翻转是实验事实,不是关于"规模与方法"的普遍定律。换一个题池、换一个裁判世代,方向可能再变;这正是要记录判分世代的原因。
发布:把证据逐项放出门
项目收尾时,公开面被压到三处互指:代码仓、题池镜像、两篇预印本。三者各自承担不同的证据责任。
代码仓只放评测与验证子集,不放训练脚本和适配器权重。公开仓只有 2 个提交、790 个跟踪文件;开发仓的规模是它的几百倍。这个选择有代价:别人能看到结论,但没法从零重训。仓库把这件事写进 ARTIFACT.md,逐项说明哪些读数可重算、哪些只能登记、哪些按设计失败关闭。
题池镜像单独发布。1,979 道题按许可证分桶:685 道可再分发、927 道仅 arXiv、241 道受限、126 道教材待核。镜像只剔除受限桶,收录 1,738 道;Hugging Face 回读与本地 sha256 一致,桶位分布也对得上。
匿名包是另一条线。补充材料需要一份不带身份信息的版本:629 个条目、5.92 MB,15 道闸逐项检查作者、路径、元数据与 .git 条目。.git 条目必须是零——仓库一旦被打包进去,提交历史里的作者邮箱会随之交出。
再分发的处理比预想复杂。最初按字段整体抹除,涉及 3,939 个字段;后来改成只抽 300 字符以上的逐字跨度,用引号配对、撇号判据和迭代到不动点的方式定位,最后只剩 261 处跨度、255 个字段,占全部证据字段的 4.2%。换句话说,91% 的自有文字可以留在公开面,只有真正逐字引用原文的片段需要撤回。规则写成机器判据:跨度未清零就中止构建,不靠人记。
发布当天还踩了几个具体的坑。
CI 第一次红是 ModuleNotFoundError: No module named 'scripts'。根因不是 CI 写法,而是调用方式:--import-mode=importlib 不会把仓库根目录插进 sys.path,所以本地 python -m pytest 能过,CI 的 pytest 控制台脚本导不进。加上 pythonpath = ["."] 修复,同时修掉了主仓一个同款潜伏问题——主仓没有配置远端,那行代码从来没被执行过。
CRLF 让文件指纹在 Windows 与 Linux 之间不一致。评测文件里记录了内容指纹,换行符一变,指纹就对不上。修复方式是给 benchmark/eval/** 加 -text -eol,让 Git 不做换行转换;其中的 -eol 是承重的,只写 -text 不够。
最险的一次是 git rm -r --cached benchmark/eval,本意是把目录从索引里摘出来重新生成,结果差点把 195 件已跟踪文件一并删掉。发现后立即 git reset 复位,没有内容丢失;如果当时直接提交,公开仓会少掉一整块评测件。
收尾时还有 28 份孤儿投影和 237 件未跟踪的评测件需要逐项判断:哪些是生成中间物,哪些是必须交付的证据。最终主仓 1076 passed / 4 skipped,公开仓 69 passed / 4 skipped,两个工作区都没有未提交改动。
这一段没有技术上的新东西,但它决定了前面所有数字的可信度。公开不是把仓库设成 public,而是逐项决定哪些证据能出门、哪些只能登记、哪些按设计失败关闭。
没有采用的做法
复盘时容易只写"做了什么",但这次真正决定走向的,是几条被否掉的路。
事后校正分数。 最直接的想法是把重判分数按比例调回首判,再做比较。问题是要校正就得知道漂移的真实幅度,而这个幅度只能从同一批已经漂移的分数里估。用结果估计结果,再用估计去修正结果,逻辑上闭不住。否决。
重训所有臂。 重训看起来更彻底,但答案冻结之后,重训会引入新的生成变量:模型变了、答案变了、分数也变了,最后无法判断变化来自裁判还是来自训练。否决。重判只动评分,不动生成,才能把变量收窄到一个。
换一个"更可靠"的裁判。 抽检已经证明六个裁判之间没有共同尺度,换一个只是换一套未知的尺度。更麻烦的是,中途换裁判会让前后两批分数不可比,等于把漂移问题变成版本问题。否决。最终选择保留同一个裁判名,把世代写进文件,用重判去测它。
按域分别校正。 控制域上移 0.06、医学域几乎不动,看起来可以各校各的。但域间差异本身是结论的一部分;一旦分别校正,这个差异就被抹掉了,剩下的是一个人为拉平的榜单。否决。
发布全部适配器权重。 19 条 LoRA 适配器的权重不在公开范围。原因不是体积,而是再分发许可与基座模型的条款没有逐条确认;在确认之前,公开权重的风险大于收益。这件事被列进公开仓的"不包含什么",而不是含糊带过。
手工删除 28 份孤儿投影。 收尾时发现 28 份投影没有被任何公开文件引用。最省事的做法是删掉,但删掉之后,下一次重建还会生成同样的 28 份,因为判据里写着"被主张引用的题级产物也要投影"。根因在判据耦合了 git 跟踪状态,不在文件本身。最后选择保留并在 ARTIFACT.md 里登记,等判据改完再一次性处理。
这些否决有一个共同点:它们都能让当下的数字更好看,但会让"为什么变好"变得不可回答。对一个以审计仪器为主题的项目来说,这等于把主题拆掉了。
留下的经验
这次复核最后能拿出来的,不是一个新的评测指标,而是几条可以复用的做法。
短跑证明收敛,不证明排序。任何涉及方法优劣的结论,必须在目标步数上重跑;30 步的冠军在 250 步掉到第四,这不是噪声,是两种不同的量。
小样本能给方向,不能给结论。40 题里 MGPO 领先,300 题里只剩 p=0.082;样本量一变,结论的强度就变了。
裁判名不是版本号。记录判分世代,比记录模型名重要;同一名字下的量表变化,足以让 11 条臂整体上移 0.06。
冻结答案,让分数变化只剩一个解释。答案、题池、聚合规则都冻住之后,仍然存在的系统性位移只能归到仪器;否则生成、数据和评分三种解释永远缠在一起。
按题配对,不要只比聚合。聚合值会把方向不同的臂平均掉;逐题配对才能看出是整体位移还是少数极端题拉动。
在看分之前写死比较标准。标准一旦跟着结果走,漂移可以被解释成任何东西。锚点审计的分流表就是例子:先把"无区分性"也写成可接受的结果,才敢让 0.0 留在报告里。
发布前先跑一遍"别人能否重算"。能重算的写进 ARTIFACT.md,只能登记的标明"仅登记",按设计失败关闭的写清触发条件。把缺口写明,比用"完整复现"概括更可信。
这些做法都不新。难的是同时做到:在八天的冲刺里留出断点续跑,在 30 步的漂亮曲线面前承认排名不可外推,在分数上涨时先怀疑仪器而不是庆祝方法,在锚点审计拿到 0.0 时按预注册的分流走完,而不是换一套判据重来。项目最终交付 237 份逐题文件、约 3.2 万条判分记录;未交付的 399 个产物,是两篇论文的图都不读取的那些。缺口和结论一样,都是记录的一部分。
如果重来一次
有些改动不需要更多算力,只需要更早做。按收益排序,大概是这样。
数据冻结放在第一天。 现在有 manifest,但它是八月补的;五月到七月的批次靠目录名和口头约定区分。如果第一天就有 sha256 与行数清单,后面至少能省掉两次"这批数据到底是哪版"的排查。
判分世代写进每一个评测文件。 现在重判文件有 run1p 后缀,首判文件只有时间;如果第一次判分就写入世代 ID,漂移检测会从"翻文件时间"变成"读一个字段"。代价是一行元数据,收益是把一次人工比对变成一次查询。
n=3 从第一条臂开始。 早期的 40 题评测只跑了一轮,代际方差是后来才补的。如果一开始就三轮,很多"看起来有差异"的臂会在第一轮就被方差挡住,不会进入后面的讨论。
失败运行也留逐题记录。 300 题第一次跑崩时,已经生成的答案散在 recovery 文件里,没有统一的逐题视图。恢复之后能续跑,但没法回答"崩溃前那 150 题的质量和后面有没有系统差异"。失败运行的记录和被丢弃的中间产物,后来都成了无法回看的空白。
发布生成器在第一次推送前就写好。 公开仓的第一次构建是手工拼的,第二次才改成生成器。手工拼的代价不是那次多花了时间,而是后面每次改动都要重新判断"这次改的是源件还是产物"。生成器早一天存在,公开面就早一天只有一个写入口。
负结果和正结果用同一套模板。 锚点审计的 0.0 一开始不知道该放在哪:它不是一个"发现",更像一次失败。后来按预注册分流写进 validity 段,才变成结论的一部分。如果模板里本来就有"负结果"的位置,这类结果不会在文档边缘徘徊那么久。
这些改动都不改变实验的科学性,只改变实验的可读性与可维护性。它们也是这次复核之后,最想留给下一个项目的东西。
数据与来源
-
公开仓库:sci-align-benchmark
-
可重算范围:ARTIFACT.md 的逐项说明;tools/verify_pack.py 从逐题记录重算每份文件的自报聚合并核对指纹
-
协议与应用两篇预印本:10.5281/zenodo.23409480、10.5281/zenodo.23409394
-
题池与许可证:SOURCES.md;公开评测文件清单:release/public-eval-files.txt
-
本文涉及的过程数字(八天冲刺、250 步探针、300 题超时、锚点审计、发布检查)按开发期记录转写,口径以公开仓库中的协议与逐题文件为准;无法从公开仓库重算的部分已在文中标明