如果你在深度学习这行待得足够久,大概率会撞上这么一件事:照着论文里附带的代码、数据集和超参数,在自己机器上老老实实跑一遍,结果却跟论文表里印的数字差出一大截。轻则差两三个点,重则直接训练崩掉。这就是圈子里常说的 AI 可复现性挑战(The AI Reproducibility Challenge)。
前阵子我在一个技术社区听某研究者做过一场同题分享,题目就叫《AI 的可复现性挑战》。内容并不高深,但台下共鸣特别强,几乎每个人都承认自己在复现别人的结果、或者被别人复现结果时,经历过大大小小的翻车。这场分享让我把那段时间踩过的坑重新梳理了一遍,也让我意识到:可复现性不是学术圈的“洁癖”,而是 AI 工程师每天都在面对的工程问题。
这篇文章就从那场分享出发,结合我自己在训练、调参、上线过程中的实操,把“可复现性”这件事讲讲透。既会拆解为什么这么难,也会给出可以直接照抄的固定流程,最后附一份排坑速查表。无论你是刚入门的研究生,还是在公司里被追着复现模型效果的工程师,这篇应该都能帮上忙。
1. 先搞清楚:可复现性到底在谈什么
1.1 可复现性不是“一模一样”
先说定义。一个实验结果可复现,是指别人(或者三个月后的我)在相同条件下,按照相同步骤,能够得到足够一致的结果。
这里的关键词是“足够一致”。不同场景要求的“一致程度”完全不一样。
- 做科学实验或数值计算复现,可能要求 bit 级完全一致。比如确定性的数值模拟,输入相同就必须得到完全相同的输出。
- 做模型训练,几乎不可能也不必要 bit 级一致。因为训练过程本质上是随机优化,同一套代码在不同硬件上出现小数点后几位的差异很正常。
- 做算法对比,需要的是“统计等价”:两次跑出来的准确率区间重叠,结论方向一致,就算复现成功。
拿做面包来类比。你今天烤的面包和上周烤的面包,配方一样、烤箱一样,但室温不同、面粉批次不同,成品就会在色泽和松软度上有细差别。可复现性要解决的不是“烤出一模一样的面包”,而是“照着同样的配方在任何一家厨房都能做出可接受的成品”。
1.2 随机性不是“噪声”,而是训练的一部分
模型训练里到处是随机源:权重初始化随机、每个 batch 的采样顺序随机、dropout 随机、数据增强随机、分布式训练中数据切分的随机、甚至多卡 all-reduce 的通信顺序也会带来随机。
这些随机性不是 bug,而是特性。随机梯度下降本身就依赖随机性去寻找更平坦的极小值;数据增强的随机性本质上是一种数据扩充;dropout 的随机性是一种正则化。如果把随机性全拿掉,模型性能通常会变差。
所以,可复现性挑战真正的问题在于:当随机性这么多、这么分散时,我们很难把“结果的可复现性”和“随机波动的自然起伏”区分开。你复现不出来,到底是因为环境没锁对,还是因为本来就应该从这个区间里抽出一个新结果?这是所有排查工作的起点。
1.3 可复现性的三个层次与成本
| 层次 | 要求 | 适用场景 | 成本 |
|---|---|---|---|
| bit 级复现 | 完全一致,连小数点后七八位都一样 | 数值算法验证、安全审计、监管合规 | 高:需要完全一致的机器、框架、驱动 |
| 数值近似 | 结果在可接受误差范围内 | 学术实验、一般工程验证 | 中:固定环境即可 |
| 统计等价 | 多次运行的分布等价,结论方向不变 | 模型对比、算法研究、上线评估 | 低:多跑几个随机种子做统计 |
这个分层非常关键。不是所有任务都值得追求 bit 级复现。安全相关的模型审计可能必须精确到字节,但日常模型迭代只需要确保“结论稳定”。把目标定成 bit 级,只会把自己折磨到崩溃。
2. 为什么 AI 训练这么难复现:拆开看四大因素
2.1 数据层:版本漂移比你想的更严重
数据集的版本漂移是可复现性的大敌。很多公开数据集事实上不是一个静态文件,而是“某年某月某日的快照”。数据集维护者修复一张坏图、清理一个标注错误,甚至换一种压缩格式,都会导致整个数据集的哈希发生变化。更别提有些数据集本来就是持续更新的版本。如果你的实验记录里只写了“用了某个数据集”,三个月后别人下载到的可能已经不是同一个版本。
另一个常见问题是数据划分。很多代码用 RandomSplit 每次运行重新划分训练集和验证集。固定 seed 跑了第一次后,第二次如果 seed 没固定或者框架版本变了,划分就整个变了。
怎么应对?固定一份 train/val/test 的文件名清单提交到代码仓库,把这份清单当作实验的一部分。这个动作成本最低,收益也最高,比任何花哨的框架都好使。
2.2 依赖层:环境里的“魔鬼”都在细节里
Python 环境下,依赖地狱是常态。某个包从 1.1.2 升到 1.1.3,可能底层某个 C 扩展换了编译参数,同样的调用结果就变了。比较典型的例子是图像处理库,不同版本之间的 resize 算法有差异,插值方式不同导致像素值在小数点后几位不同;数据增强库的随机数实现也可能不同。
更隐蔽的是 CUDA、cuDNN 这类底层库。cuDNN 的卷积实现有几十种算法,不同版本对同一算子的选择策略不同,跑出来的结果就不同。所以业界那么多人推荐用 Docker 镜像来锁环境,是有充分理由的。
依赖不可复现的另一个层面是“未提及的隐性依赖”。某个传递依赖被自然解析成某个版本,你跑实验时是 1.0,半年后新环境里自动解析成了 2.0,行为就变了。很多论文只说“用了某某框架”,很少写 pip freeze,别人复现时只能靠运气。
2.3 训练过程层:别忘了那些“默认值”
训练过程的随机性来源远不止初始化。优化器可能有默认的 weight_decay、Adam 有默认的 epsilon;BatchNorm 的 momentum 一般默认 0.1;很多框架在保存检查点时会保存 optimizer 状态,但不会保存 RNG state。如果你在训练中途恢复 checkpoint,没有恢复 RNG state,之后的随机序列就会分叉。
另一个很多人栽跟头的地方是 cudnn.benchmark。开启它以后,框架会在每次卷积前跑一次基准测试,选择对当前输入尺寸最快的算法。这个算法选择过程本身受 GPU 负载、缓存状态影响,并且不同算法数值不同。结果就是,同一份代码、同一个 seed,在不同时刻跑,可能因为 benchmark 选了不同算法而分叉。
2.4 硬件层:GPU 不是确定性的计算器
很多人以为 GPU 计算是确定性的,其实不是。
GPU 的并行归约操作里存在原子指令和浮点数累加顺序的差异。同一个矩阵乘法,不同的并行拆分方式会产生不同的舍入误差。再加上 TensorCore、混合精度优化器,字节级的差异很快就会被放大。所以,不同型号 GPU 之间不要追求 bit 级复现,数值能在可接受误差内就是成功。
分布式训练还会引入新的随机源:数据切分顺序、all-reduce 的拓扑、参数更新的通信延迟。这些都会让“同样代码、同样 seed”在不同卡数下得到不同的模型。
硬件层也不只是 GPU。CPU 的线程数会改变浮点数累加顺序,不同操作系统的数学库实现也可能不同。每次排查可复现性问题,先列一遍硬件环境,能省掉一半无头绪的怀疑。
3. 把实验做成可复现的四个关键步骤
3.1 环境上锁:从依赖到系统库
尽量用 Docker 镜像把整个实验环境锁下来。镜像里应该包含:
- 操作系统基础镜像版本
- Python 解释器和 pip / conda 版本
- 全部依赖的精确版本(requirements.txt 带哈希,或者 conda env export 里的 build 号)
- CUDA runtime、cuDNN 版本
注意,GPU 驱动不放在镜像里,驱动属于宿主机,实验记录里要单独记。
为什么一定要用容器?因为 conda 解决得了 Python 包依赖,解决不了系统库依赖。某些库可能依赖特定版本的 libgomp.so.1,不同系统镜像里这个文件就不一样。容器把操作系统层一并锁住,才是真“环境复现”。
如果实在无法使用 Docker,至少做到:
conda env export > environment.frozen.yml pip freeze > requirements.frozen.txt这样别人能重建一个足够接近的环境。但说实话,conda export 跨平台复现能力有限,因为里面带有当前平台的 build 号,在另一平台导入时经常不兼容。这也是我会优先选择 Docker 的原因。
3.2 随机种子:不是设了 seed 就万事大吉
固定随机种子是所有人都知道的第一步,但很多人只做了一半。一个能覆盖主要随机源的函数至少长这样:
import os import random import numpy as np import torch def set_all_seeds(seed: int): os.environ["PYTHONHASHSEED"] = str(seed) # 影响 dict/set 的迭代顺序 random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False # 若版本支持且算子允许,可以再开启: # torch.use_deterministic_algorithms(True)几个关键点:
os.environ["PYTHONHASHSEED"]要在 Python 启动早期设置,否则不生效。它影响字符串哈希顺序,在数据预处理中用到字典时会有影响。cudnn.deterministic让 cuDNN 选择确定性算法,但会牺牲部分性能。cudnn.benchmark必须关掉,否则算法选择不固定。
DataLoader 也要单独设置 generator:
train_loader = DataLoader( dataset, batch_size=32, shuffle=True, generator=torch.Generator().manual_seed(42), num_workers=4 )用了 DistributedSampler 时,还要在每个 epoch 调用set_epoch(),否则 epoch 间数据顺序不变化,分布式场景的随机性没有被正确固定。
3.3 数据与预处理:把“原料”也冻结
数据的确定性不只是固定数据集版本。可以用哈希校验:
sha256sum data/raw.tar.gz > data/raw.tar.gz.sha256把校验和文件与实验记录放在一起。当复现失败时,先跑校验和,一步就能判断是不是数据变了。
预处理还有一个容易被忽略的点:如果 DataLoader 里用了多进程预处理,需要给每个 worker 独立的随机种子。否则每个 worker 的随机增强序列完全相同,等于人为制造数据冗余。
def worker_init_fn(worker_id): seed = 42 + worker_id np.random.seed(seed) random.seed(seed)不建议只在全局random.seed(42)后就指望 worker 继承。多进程 fork 时,子进程的随机状态是从父进程复制出来的,所有 worker 会得到完全相同的初始随机序列。要“复制”出独立序列,必须自己加偏移。
3.4 实验全记录:让一切可回溯
一切能记录的都记录成文件,这是成本最低的保险。我实践下来至少需要这些信息:
- 代码 commit 哈希(
git rev-parse HEAD) - 代码 diff(如果是未提交状态)
- 运行命令(包括所有命令行参数)
- 超参数配置文件(完整展开,不要只写“用默认值”)
- PyTorch / CUDA / 系统版本信息
- GPU 型号、驱动版本(
nvidia-smi输出) - 数据集哈希
- 模型 checkpoint(包含 RNG state,
torch.random.get_rng_state) - 指标和 loss 曲线
形如“跑完就关终端”的实验,属于完全没有记录。现在用实验管理平台记录这些信息非常省事,以某个开源实验跟踪库为例,只需要在代码里加几行:
import mlflow with mlflow.start_run(): mlflow.log_params(config) mlflow.log_metrics({"train_loss": loss, "val_acc": acc}) mlflow.log_artifacts("checkpoints/") mlflow.log_artifact("config.yml")跑完之后,每个实验都有唯一 ID,指标曲线全在 Web 界面上,比自己在 Excel 里记可靠多了。没有人会坚持手动记录三个月,但平台自动记录可以。
4. 我踩过的坑:可复现性实战排查
4.1 固定 seed 后仍然分叉
有一次训练一个图像分类模型,代码里已经调用了set_seed(0),前几个 batch 的 loss 曲线完全重合,到了中间某个 epoch 突然分叉,最后模型准确率差了好几个点。
排查后发现,问题是卷积算子因为cudnn.benchmark在不同时刻选择了不同的实现,两者浮点累加顺序不同,中间层激活值在小数点后几位有差异。本来这种差异很小,但经过 BatchNorm 的 running_mean 持续累积后,最终在 loss 上体现出来。
解决方式就是关掉 benchmark 并打开 deterministic。从那以后,我遇到模型分叉,第一步永远检查这两个开关。
4.2 换了一张卡,结果天差地别
有一回帮同事排查,他的模型在一张卡上训练能得到 87.2% 验证集准确率,换到另一张卡上重新训练只有 83.6%,他怀疑是代码问题。
查到最后发现是两个平台的 cuDNN 算法库不同,即使版本号一样,GPU 对应的内核实现也不一样。第一张卡能走到某条计算路径,第二张卡触发了另一条路径。
这个坑本质上来自硬件差异,不是代码问题。解决方案是接受数值差异,把比较模型的方式改成“多次运行取均值”,而不是“单次运行对一个数字”。
4.3 DataLoader 多进程的种子陷阱
另一个经典案例:我固定了全局 seed,用 DataLoader 的num_workers=8加载数据做增广。结果每次重启训练,数据增广的随机模式都完全一样,增广形同虚设。
问题出在 worker 的随机状态通过 fork 继承。虽然父进程 seed 固定,但八个 worker 从同一个父进程复制了随机状态,于是每个 worker 生成的随机序列完全一样。同一个 batch 里的样本被增广的方式一样,数据多样性大打折扣。
正确的做法就是在worker_init_fn里把种子设置成base_seed + worker_id,保证每个 worker 相互独立。这是代码里最隐蔽的随机性来源之一。
4.4 环境重建时,pip 和 conda 打架
有一回要在一台干净机器上恢复半年前的实验环境。当时只保存了conda env export导出的文件,但有些包是通过 pip 装的,pip 装的包没有被完全锁定版本。最终环境能跑起来,但某个关键库被新版本解析,行为和旧版本完全不同,复现失败。
教训:不要把 conda 和 pip 环境混着管理。如果混了,一定要在 conda export 之外再额外保存一份pip freeze。更好的方案是导出 Docker 镜像,直接把环境打包成一个对象,彻底消灭环境重建问题。
4.5 可复现性排查速查表
| 现象 | 可能原因 | 检查项 |
|---|---|---|
| 固定 seed 后仍分叉 | cudnn.benchmark 或 deterministic 未设置 | 检查这两个开关 |
| 多卡结果不稳定 | 每个 rank 的 seed 未隔离 | 按 rank 设置不同 seed,并同步 sampler |
| 数据增强异常重复 | worker_init_fn 未设置 | 给 workers 设置独立种子 |
| 换机器后结果对不上 | 硬件 / 驱动 / CUDA 差异 | 记录 nvidia-smi、驱动版本,不要追求 bit 级复现 |
| 恢复 checkpoint 后结果不同 | RNG state 未恢复 | 保存并恢复 torch 的 RNG state |
| 环境重建后结果不同 | 依赖未锁定 | 用 Docker 镜像或 lockfile 锁死依赖 |
| 用了混合精度后间歇性分叉 | fp16 非确定性原子操作 | 对 fp16 只做统计级复现,或改用 fp32 |
5. 可复现性不是终极目的,结论稳健才是
5.1 多种子训练:对抗随机性的实用方式
费尽千辛万苦把环境锁死,如果数值还是对不上,怎么办?有一个务实的方法:不要拿单次运行的指标来比较模型,而是跑多个种子取均值和方差。
比如要比较 A、B 两个方案。各跑一次,A 准确率 88.2%,B 准确率 87.5%,这很可能是噪声导致的。如果各跑三到五个种子,A 是 88.2±0.3,B 是 87.4±0.5,区间不重叠,才有足够底气判断 A 真的更好。
多种子运行不只是为了复现别人的结果,更是为了自己实验结论的稳定性。这比把环境锁到 bit 级更经济,也符合统计学的直觉。
5.2 实验记录带来的工程红利
实验记录完整还有一个容易被忽略的价值:快速定位回归。
如果实验管理工具已经记录了每次实验的指标曲线和参数,当模型效果回退时,可以通过对比一次参数和指标,快速定位是哪次改动造成的回归。这种“可溯源”能力在团队协作中尤其重要——有人提交了一版代码,性能掉了两个点,没有实验记录就只能在 Git 历史里盲猜。
5.3 工具链成熟了,差的只是习惯
这些年可复现性的工具链已经比从前成熟不少:容器、依赖锁文件、实验管理平台、数据版本管理,成本已经大幅下降。真正的瓶颈往往是习惯——有没有在实验开始时顺手提交一个 commit,有没有在实验结束时顺手导出一份配置文件。
我见过一些团队买来最贵的设备,实验记录却一片空白,三个月后连自己都说不清当时跑的是什么配置。这不是工具问题,是流程意识问题。
5.4 我现在的固定流程与一点体会
在被可复现性问题折磨过好几轮之后,我现在跑实验已经有了固定流程:开跑前先 git commit,把运行命令写进 README;跑起来后,立刻记下环境信息和数据校验值;出结果后,把配置、指标、模型 checkpoint、日志统一同步到实验管理平台。这套流程花不了多少时间,却帮我省掉了大量“这是哪个版本跑出来的”“为什么换台机器就对不上”这类无意义的返工。
最后分享一个最近让我感触很深的小事。有位同事要复现我半年前做的一个 baseline,我把当年保存的 Docker 镜像标签、commit hash、数据哈希和运行命令一股脑发给他,大概半小时后,他跑出来的数字和我当时的记录几乎一致。他问我怎么什么都有记录,我说不是我记性好,是坑踩得多。做 AI 实验,顺手留个痕迹,长期来看真的很值。