一次复现翻车现场,我想所有做深度学习应用的人都经历过:论文里明明写了精度,你也按着它的结构搭好了模型,数据也是公开的,代码也跑通了,结果一验证,差了三个点。你怀疑是自己代码写得不对,反复检查;你又怀疑是硬件差异,换了个环境再跑;第二次跑出来跟第一次又不一样。你会开始怀疑,这篇论文到底是不是真的做的效果,还是作者隐瞒了什么。
这就是我要聊的“AI可复现性挑战”。这两年它已经成为应用算法圈里的高频话题,比模型效果本身更能决定一个项目的交付质量。模型效果差还能靠调参补救,不可复现的问题会直接让一个方向从源头变成沙滩上的城堡。我写这篇内容,不是为了给你灌鸡汤,而是把我踩过坑、总结出的一套判断标准和排查链路完整摊开,给正在被“别人论文跑不出来”折磨的研究者和工程师一个可执行的参考。
1. 一次复现翻车现场:同一份论文,三个精度
1.1 事情是怎么开始的
去年我拿到一篇做图像分类的工作,方法不复杂,就是一个主流的卷积网络加了一个轻量注意力模块,在标准的公开基准集上报告了大约94.2%的准确率。作者放了代码,README写得还挺详细,看起来是个能直接跑的项目。
我第一遍跑,训练到一半因为机器断电中断了。恢复后我把种子重新设了一遍继续跑,最后验证准确率是93.9%,跟论文差得不远,我当时觉得可能是我恢复断点的时候某些随机状态没处理好,属于正常范围内的小偏差。
第二遍我换了一张更大的显卡重新跑,没改任何超参,结果变成了92.1%。差了整整两个点。这时候我还乐观,觉得可能是显存变大以后批量大小对应调整的问题。但第三遍我回到原来的显卡,构建了一个干净的运行环境,从零开始跑,最终结果停在了91.6%。
同一份代码,同一个数据集,三次结果,三个精度。最大差距2.5个点。这已经不是“正常波动”能解释的范畴了。
1.2 复现性问题为什么会被低估
大多数人面对这种情况,第一反应是“我运气不好”,第二反应是“作者是不是有问题”。我跟作者邮件来回了几轮,对方也一脸无辜,说他们自己重跑也经常跟论文数字不一样,但都在一两个点以内,所以默认是正常的。
这句话让我琢磨了很久。后来我在自己的项目里做了个实验:同样的训练脚本,只是把训练框架从A版本升到B版本,结果验证集上的输出分布就明显变化了。不是框架有bug,而是底层卷积算法的实现细节变了。这让我意识到,所谓的复现性,根本不是一个“能不能跑通”的问题,而是一整套工程边界的问题。
我们必须先给“可复现”做一个严格定义,否则后面所有讨论都没有意义。我自己的标准分三档:
| 档次 | 含义 | 常见的验证方式 |
|---|---|---|
| 代码可运行 | 代码能跑通,不会中途崩溃 | 执行一次完整训练和评估 |
| 结果可复现 | 相同条件下重复实验,指标落在给定容差内 | 固定种子,换机器跑三次对比 |
| 结论可泛化 | 换个骨干网络、换一批数据,方法的有效性趋势不变 | 多个配置下的消融实验 |
论文报告的精度,本质上是“结果可复现”这一档。但大多数复现工作只做到了第一档,就宣称成功复现了。这是很多争议的根源。
2. 四个黑箱:环境、随机性、数据管线与评估口径
2.1 环境黑箱:版本号比超参更容易毁掉结果
深度学习代码里,环境相关的差异是第一个暗礁。同样是“安装依赖”,有的机器装了CUDA 11.8,有的装了12.0;有的机器驱动版本新,有的老;有的包管理器解析依赖时拿到了小版本不同的补丁包。这些差异在平时不会体现出任何报错,但会直接改变底层计算库的执行路径。
最典型的就是cuDNN这一类底层加速库。它内部对卷积算法的选择有随机性,同一个输入在两次运行中可能选中不同的优化算法,几何结构一样,但浮点计算顺序不同,结果就产生微妙差异。这种差异在训练早期可能只是百万分之一的噪声,但经过几百轮迭代会被逐步放大,最终体现在精度上就是一到两个百分点的差距。
我的建议是:任何严肃的实验项目,从第一天起就要锁定环境。至少要做两件事,第一是用版本锁定文件把依赖的精确版本记录下来,而不是只记主版本号;第二是尽量把实验跑在容器里,让团队内所有人在完全相同的系统镜像上工作。这一步看起来麻烦,但能消掉一半以上的复现问题。
2.2 随机性黑箱:不只是“设置种子”这么简单
大多数开发者都知道训练前要固定随机种子,但很多人没意识到,一个完整的深度学习训练过程涉及多个独立的随机数生成器。
- 数据加载阶段用到的随机数生成器,决定每个epoch的样本顺序
- 数据增强阶段用到的随机数生成器,决定裁剪、翻转、颜色抖动等操作的参数
- 模型初始化阶段用到的随机数生成器,决定参数的初始值
- 训练阶段某些操作(比如dropout)用到的随机数生成器
如果你只固定了框架层的种子,而没有同步固定数据加载层的种子和其他独立随机源,那么每次运行训练数据的顺序、增强方式都会不同。模型在同样的超参下,会因为看到不同顺序的样本,收敛到不同的局部最优点。
我现在的做法是按固定顺序初始化所有随机源,并封装成一个统一的seed函数,每次训练只调用一次。这个函数不仅要处理框架的随机接口,还要处理标准库的随机、科学计算库的随机,以及CUDA相关的随机状态。
def setup_seed(seed_value): # 固定标准库随机源 import random random.seed(seed_value) # 固定科学计算随机源 import numpy as np np.random.seed(seed_value) # 固定深度学习框架随机源(含CPU与GPU侧) dl_framework.manual_seed(seed_value) dl_framework.cuda.manual_seed_all(seed_value) # 关闭部分非确定性加速选项 dl_framework.backends.cudnn.deterministic = True dl_framework.backends.cudnn.benchmark = False注意这里有一个取舍:deterministic = True会牺牲一部分训练速度,因为底层不能用一些更快但非确定性的算法。我建议训练阶段明确区分“探索期”和“结果期”。探索期为了跑得快,可以关掉确定性选项;但一旦确定最终配置,正式训练必须开着确定性选项跑,否则实验结果根本经不起第二个人复跑。
2.3 数据黑箱:划分顺序和增强策略的隐藏影响
数据管线里最容易忽略的问题是“数据划分的稳定性”。如果一个项目里训练集、验证集、测试集是每次运行脚本时通过随机划分生成的,那换个种子就换了一批样本划分。这在本意上是公平的,但对于复现来讲是灾难。因为不同划分下,哪怕同一个模型,指标的波动也可能超过一两个点。
正确做法是把数据划分结果固化成文件,训练时直接加载这个固定的划分。划分文件本身带一个版本号,后续如果调整了划分策略,要把旧版本文件保留,方便回溯。
数据增强的随机性同样要留意。很多框架的数据增强是“在每次读取样本时动态随机执行”,如果增强逻辑里有原生随机接口直接跟全局随机状态挂钩,那么每次采样的增强结果是不可控的。需要在数据加载器内部维护一个独立的数据增强随机状态,并且这个状态在每次触发时都基于当前epoch和样本序号做种子推导,这样才能保证相同条件下的同一次epoch,看到相同的增强序列。
2.4 评估黑箱:保存哪个模型、用什么口径计算
最后一个容易被忽略的黑箱在评估环节。很多训练脚本是“每个epoch验证一次,把验证集上最好的模型保存下来”,当训练过程中验证曲线有微小震荡时,最后选出的模型其实取决于验证集的划分方式,以及验证指标的计算方式。
更隐蔽的是,有些论文里报告的指标是用“多个seed跑完,取平均中最高的那个”得到的。这个观测分数本身就有偏高的倾向。复现者如果老老实实只跑一次,跑出来的数字落在平均值附近,自然会比论文报告的最高值低。这不是作者造假,而是抽样偏差,但客观上造成了复现差异。
评估口径也很关键。比如目标检测里的mAP,不同项目算IoU阈值方式有差异;语义分割里的mIoU,有的算全局像素,有的按类平均。论文里通常不会把这种基础口径单独拿出来大讲特讲,但它直接决定最后小数点后两位的数值。复现之前,一定要先把论文在评估指标上的完整定义读懂,最好找到作者公开的评测脚本,用评测脚本做验证,不要自己重写。
3. 作者自己都复现不出来?藏在细节里的“隐形开关”
3.1 论文不写的超参与工程细节
我跟很多做研究的朋友聊过,大家的共识是:一篇论文里写出来的超参,通常只占实际训练配置的一半以下。学习率、批量大小、训练轮数会写,但学习率采用什么调度策略、前多少个epoch有没有warmup、梯度裁剪阈值是多少、权重衰减是不是只作用于某些层,这些细节往往散落在代码里,或者干脆没有固定下来,作者本人也只是“当时这么跑的”。
这不是作者故意隐瞒,而是论文写作长期形成的惯例:篇幅有限,只保留自认为对结果重要的决定性配置。问题是,很多看似“不重要的细节”,在超大模型、超长训练里会通过累积效应改变结果。
我复现某个对比方法时就遇到过:论文说优化器用Adam,学习率设成1e-4,但我复现之后精度一直上不去。后来翻了作者早期版本的代码,才发现它其实用了AdamW,而且针对骨干网络部分单独设了不同的学习率倍数,还做了两个epoch的线性warmup。这些加起来,对最终精度的影响超过一个点。
3.2 代码里的魔法常数和后处理
另一个复现重灾区是后处理环节。很多方法在生成最终预测结果之前,会有一些不明显的处理步骤。比如多尺度推理、水平翻转测试时增强、softmax温度缩放、类别权重调整。这些后处理逻辑通常分散在多个工具函数里,论文很难完整描述。
更麻烦的是,有些项目里残留了作者在调试过程中临时加入的“魔法常数”。我遇到过一个目标检测项目,最终输出的结果竟然要乘一个0.98的系数,才跟论文里的指标对得上。这个0.98很可能是作者在某个阶段为了适配验证集分布手动加的校准系数,后来没有删掉,也没有写进论文。复现者如果直接照搬“干净版”代码,就会差零点几到一两个点不等。
3.3 临时决策没有被记录:实验日志是复现性的根基
造成作者自己都复现不出来的一个核心原因,是实验过程里大量临时决策没有被记录。比如“在第二轮训练后觉得loss下降太慢,把学习率手动改小了一半”“发现验证集上某一类一直不准,就给这一类的损失加了权重”“换了一个更强的数据增强策略,但是忘了记在哪一轮生效的”。
这些决策在训练当时看起来是“临场判断”,但对最终结果有关键影响。我要求自己的所有实验项目都必须有一个标准化的训练日志目录,里面至少要包含三样东西:一是完整的超参配置快照,二是代码版本的Git哈希,三是每次关键变更的时间点和原因备注。这样三个月后回看,仍然能还原出“哪一行代码对应哪个实验结果”。
3.4 给复现一个合理的容差带宽
我必须强调一点:要求完全一致是反科学的。即使是同一个人,在同一台机器上跑同一个脚本,因为框架底层实现更新或者硬件状态变化,也可能出现零点几个点的偏差。所以复现成功的判定标准应该是“容差带宽”,而不是逐位小数相同。
我的经验是,分类任务在相同数据划分下,0.5个百分点以内都算强复现;1到1.5个百分点以内算弱复现;超过2个点,基本可以认为存在系统性差异,需要重新检查。这个标准对任务越复杂越要放宽,比如生成类任务的指标波动天然就更大。
4. 从源头让未来的自己受益:构建“可复现友好”的工作流
4.1 所有实验从第一天起就跑在“标准脚本”上
我在共享一些早期踩坑经验时,最常给出的建议是:不要为每个实验临时写训练脚本,而是维护一个统一的训练入口,所有参数都通过配置文件传入。这样做有三个直接好处。
第一,参数不会被散落在命令行和代码注释里。第二,配置文件本身就是实验记录的一部分,跑完实验自动备份。第三,换新任务时只需新增配置,不用复制整份代码,避免复制过程中把无关的随机状态改变带进去。
标准脚本里还需要内置一个自检功能:启动训练前,先跑一遍极短迭代的冒烟测试,验证数据能正确读取、loss能正常下降、指标能正常计算。这一步能在早期就把环境问题和数据问题暴露出来,而不是等训练到一半才爆雷。
4.2 锁环境:依赖精确锁定与容器化
前面提到环境差异是黑箱之一,对应的解决方案很明确:用精确版本的环境描述文件锁定所有依赖。只写包名不写版本号的习惯,是整个复现链路上最危险的做法。
容器化是目前最靠谱的解决方案。我会在项目一开始就写一份容器构建文件,把系统依赖、框架版本、底层加速库版本全部固定下来,并打上一个带日期的镜像标签。团队协作时,所有人共享这个镜像,不会再出现“我这边跑得好好的,你那边怎么不行”的经典争执。
这里有个容易被忽略的注意点:容器镜像也要建立标签版本,不能在同一个latest标签上反复覆盖。否则三个月后你无法确定当时实验用的是哪个版本的镜像。正确的做法是每次有依赖变更就生成新镜像标签,并在实验配置文件里注明镜像ID。
4.3 每次实验自动记录完整上下文
光有标准脚本还不够,还需要让标准脚本具备自动记录上下文的能力。我一般会在训练入口里做几件事:
- 记录代码仓库当前的提交哈希,并在训练启动前检查工作区是否干净
- 记录当前环境的核心依赖版本列表
- 记录本次训练的全部配置参数,并序列化成文件放到训练输出目录下
- 记录训练开始时间和结束时间、使用的机器标识、GPU型号
这些信息作为“实验元数据”整体随实验结果一起保存。以后不管是自己回看,还是把工作交给别人,都能迅速定位到“这个结果是哪个配置、哪版代码、哪个环境跑出来的”。
有些团队会引入实验追踪工具来做这件事,效果更好,它能自动把参数、指标、日志、产物关联到一次实验上,浏览器里就能横向对比多次运行。工具虽好,但核心还是流程纪律,不能因为有了工具就放松手动记录的要求。
4.4 发布前做一次“干净环境”验证
这是我在出过几次丑之后总结出来的硬规矩:论文或者项目公开之前,必须在干净环境里做一次完整跑通和一次结果验证。干净环境指的不是作者平时开发用的机器,而是一台新容器,从零安装依赖,完全按公开的安装文档来执行。
这套验证流程至少能发现三类问题:第一是忘了把某些私有依赖写进安装文档;第二是代码里隐式依赖了当前机器的绝对路径或环境变量;第三是训练脚本没有正确处理随机初始化,导致换机器后结果变动过大。
“干净验证”往往需要付出额外时间,但它能提前暴露的坑,远比你项目发布后被上百人轮番问“怎么复现不了”要便宜得多。
5. 复现他人工作的完整排查链路
5.1 从环境对齐开始,而不是从跑代码开始
很多人复现的第一步是直接执行作者的训练命令,跑出问题再慢慢查。这个顺序是反的。正确的第一步是先对齐环境,再谈代码。具体来说,我会按这样的顺序来:
- 通读论文和代码仓库,确认需要的宿主环境(操作系统、GPU驱动、框架版本)
- 构建一个同版本的运行环境,优先使用作者提供的容器镜像
- 加载作者提供的数据划分或数据预处理脚本,保证数据口径一致
- 用作者提供的最简配置跑一次正常训练,确认代码能跑通
- 设置固定种子,跑一次完整训练,记录结果
- 在第二步的基础上做偏差分析
每一步之间都是强依赖关系。前面没对齐,后面跑出来的任何差异都不能定位到代码还是环境。
5.2 差异源定位法:不要同时改多个变量
复现过程中最常见的错误是:发现精度不对,于是同时调整了批量大小、学习率、数据增强策略、模型实现细节,最后指标追上来了,但你根本不知道是哪一个调整起的作用。这种“蒙对”的复现没有价值,也无法迁移到自己的新任务里。
正确做法是每次只改一个变量,并且用表格记录所有变量的状态。比如第一次完整训练比论文低2个点,先检查数据划分是否完全一致;确认一致后再检查是否有魔法常数或后处理缺失;这些都对齐后,才轮到检查模型结构实现细节。一次只动一个嫌疑点,跑一次完整训练,记录结果,然后进入下一个嫌疑点。
这个排查过程非常耗时,但它是唯一能把“不可复现”变成“已定位偏差来源”的路径。
5.3 设置清晰的“复现成功”判定线
我在实验开始之前就会定义一个明确的容差范围,范围外的差异我不会指望靠调参硬凑回去,而是要找到根因后才会继续跑。把这个判定线写下来很重要,因为人在跑了几轮之后会有侥幸心理,觉得“差一点点了再调调就能上去了”。这会让你陷入无穷调参,反而远离了复现的本质。
我常用的判定模板是:在相同数据划分和固定种子的前提下,验证指标与论文报告的差异不超过某个阈值,并且三个不同种子下的指标波动范围不超过阈值。两个条件同时满足,才算复现成功。
5.4 一张我反复使用的排查对照表
复现过程中遇到的各种细节问题,我整理了一张高频排查对照表。它不一定覆盖所有场景,但能覆盖大多数论文工作:
| 排查项 | 典型症状 | 复现者需要确认的细节 |
|---|---|---|
| 数据划分 | 指标系统性偏低 | 训练/验证/测试的划分文件是否与作者一致 |
| 数据增强 | 训练集loss正常但验证指标波动大 | 增强策略、触发顺序、增强强度是否一致 |
| 学习率调度 | 收敛速度明显不同 | 是否有warmup、衰减策略、调度器参数设置 |
| 优化器差异 | 损失曲线形态不同 | Adam还是AdamW、是否解耦权重衰减、参数分组 |
| 损失函数 | 验证集指标差但loss低 | 损失项的权重、忽略某些像素/样本的mask逻辑 |
| 批量大小 | 小大批量下指标差异巨大 | 作者原实验的批量及对应的学习率缩放机制 |
| 后处理 | 精度差零点几到一点几 | 多尺度、测试时增强、温度缩放、校准系数 |
| 随机种子 | 重复运行波动超过容差 | 是否所有随机源都被固定、是否开启确定性模式 |
| 评估口径 | 差零点几但趋势稳定 | mAP/mIoU/P@K等指标的定义与评测脚本是否一致 |
| 框架版本 | 相似代码不同底层执行结果 | 底层算法的实现差异、算子选择差异 |
这张表的用法不是对照着一条条抱怨,而是在排查时作为结构化清单,每完成一项就在实验记录里打勾。它帮助我把“感觉有问题”转换成“已经定位到具体的差异源”。
收尾的一点体会
我花了很长时间才接受一个关于可复现性的真相:它不是一个学术诚意问题,而是一个工程质量问题。大部分论文作者不是故意隐瞒细节,而是研究过程中大量决策本身就未曾被固定过。作为复现者,我们的任务不是去指责谁,而是把复现当作一项需要方法论支撑的工程任务来对待。每次复现失败的体验,本质上都在帮你校准对陌生项目“口头报告数字”的信任程度。复现别人工作的收获,不仅在于拿到那个最终指标,更在于你彻底读懂了方法里每个环节对结果的真实影响。下次再看到一篇效果惊艳的论文,值得先问一句:它在多大程度上具备可复现的设计,再做投入。