ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI可复现性从“能跑”到“能复现”:环境、随机性与数据全锁定

AI可复现性从“能跑”到“能复现”:环境、随机性与数据全锁定

1. 可复现性到底是什么:从“能跑”到“能复现”

前阵子帮一个同学复现一篇图像分类论文的结果,代码直接从对方仓库里 clone,数据用的也是同一个公开数据集,理论上应该是“跑一遍就出结果”的事。结果我连续折腾了两天,训练曲线怎么都对不上,最终指标比论文里写明的数字低了约 1.2 个百分点。后来定位到原因,居然藏在三个不起眼的角落里:PyTorch 版本不一致、DataLoader 的随机顺序没固定、数据增强里随机裁剪的参数范围有出入。

这就是“AI 可复现性”最常见的打开方式——它往往不是那种高深到需要重新设计框架的问题,而是一堆看似“跑通就行”的具体细节在背后悄悄累积。干了几年 AI 工程,我对“复现”这件事一直保持敬畏。这篇文章想把我复现别人、以及被团队同事复现的过程中踩过的坑、总结出的方法,完整讲一遍。内容既适合还在跑实验写论文的学生,也适合要在团队里维护一份“别人能跑起来”代码库的算法研发,更欢迎被各种没头没尾的 README 折磨过的朋友来评论区一起吐槽。

1.1 可复现性的三个层次

很多人一提“可复现”,第一反应是“代码能跑就行”。这其实是把概念理解得太窄了。我习惯把可复现性拆成三层,一层比一层严格:

第一层是“代码可运行”。把仓库拉下来,装好依赖,能跑出 loss 曲线,不报错。这一层只证明代码本身是完整的,跟结果对不对完全没关系。

第二层是“环境可重建”。换一台机器、换一个人、隔三个月后再跑,依然能装好同样的环境,跑出同样的数值范围。这层需要依赖锁定、版本管理、硬件记录。很多论文给的代码卡在这一层就过不去。

第三层是“结果可对齐”。同一份代码、同一个环境、同一份数据,跑出来的核心指标差异控制在一个合理范围内,比如精度差不超过 0.1 到 0.2 个百分点,loss 曲线形态基本一致。这是工业级复现的要求,也是论文审稿人真正想看到的“复现”。

三层之间的差距,就是不同团队对实验严谨性的要求差距。一个只在单机单卡上跑过、要求不高的项目,能做到第一层就够用;但要支撑团队协作、模型迭代、甚至对外发布基准结果,至少要做到第二层,并向第三层努力。

1.2 “复现不出来”不等于“论文有问题”

这是我最想先纠正的一个心态。复现结果对不上时,很多人第一反应是“作者是不是造假了”,但实际经验里绝大多数情况不是这样。我见过太多次,人家论文没问题,只是某些细节没写清楚,或者说写清楚了但被忽略了。

比如论文只说“使用 SGD 优化器,初始学习率 0.01”,但没告诉你学习率在第几个 epoch 衰减、用什么衰减策略;论文说“用了数据增强”,但没说随机裁剪的 scale 范围是多少;论文说“batch size 为 128”,但没提梯度积累步数——这些信息在原文里可能只是一句话甚至一个小括号,但任何一个变了,最终指标都会飘。

所以“复现不上”更像一个排查信号,说明还有某个关键变量没对齐。把它当成论文造假,只会让你错过真正的 bug。冷静下来,按环境、数据、代码、超参四个维度去逐个排查,通常都能找到答案。

1.3 为什么 AI 在“复现”这件事上特别容易翻车

传统软件工程里,可复现性不是一个这么扎眼的话题。一段 C++ 排序代码,你在一台机器上测出 100 万条数据排 0.2 秒,换一台配置高点的机器,虽然时间变了,但排序结果应该完全一致。这是因为传统程序往往是确定性的:同一个输入,对应的就是同一个输出。

但 AI 训练过程天然带着随机性。模型初始化是随机的,数据加载的 shuffle 顺序是随机的,Dropout 是随机的,多卡并行时梯度归约的顺序也会带来浮点误差。再加上训练本身就是一次次迭代优化的过程,一步顺序不同,后面的整个轨迹都可能不同。所以 AI 项目的复现,本质上是在“一条充满随机变量的时间线上”复现某一次轨迹,难度自然比传统程序高一个量级。

这也解释了为什么明明把 seed 固定了,结果有时候还是不一样。因为随机性的来源远不止一个np.random.seed()能管住的,后面还能看到更多案例。

2. 藏在训练细节里的“复现杀手”

聊完定义,进入正题:到底哪些东西在悄悄破坏可复现性?我把几年里踩过的坑归成四类,每一类都值得单独拉出来讲。

2.1 随机性:从种子到并行

先做个热身题:固定了random.seed(42)、np.random.seed(42)、torch.manual_seed(42),训练结果就一定完全一样吗?

答案是:不一定。

第一个问题是随机性来源太多。Python 自带的 random、NumPy 的随机、PyTorch 的 CPU/GPU 随机、操作系统的随机种子、还有给 DataLoader 单独设置的 generator,它们彼此独立,必须全部固定。任何一处漏掉,后续的数据顺序就可能不同。

第二个问题是硬件层面的不确定。GPU 上很多操作是并行计算的,比如矩阵乘法里多个计算单元各自算一部分,再按某种顺序加总。浮点数的加法不满足结合律,元素加总顺序不同,最后一位小数就会不同。这种误差会随着训练步数放大,导致曲线细微偏离。PyTorch 里torch.backends.cudnn.deterministic = True能解决一部分算子的问题,但不是所有算子都支持确定性版本,有些情况下只能接受“近似可复现”。

第三个问题是数据加载顺序。很多代码里 DataLoader 的 shuffle 用的随机序列,是在没有设置固定 generator 的情况下隐式产生的。同样的 seed,如果把num_workers从 4 改成 8,数据到达 GPU 的顺序都可能变化,训练效果就跟着变。这算是复现排查里最容易忽略的环节。

2.2 环境漂移:同一个代码,换了石头就跑偏

我见过一个特别折磨人的场景:同事在我的机器上复现一个模型,代码完全一样,数据完全一样,甚至 seed 都一样,但 loss 曲线就是差了一点。折腾了半天,最后发现他的 PyTorch 版本比我的新了两个大版本,某些底层算子的实现逻辑已经重构过了。

AI 项目对底层库的敏感程度远超普通应用。CUDA 驱动、cuDNN 版本、PyTorch 版本、甚至 NumPy 版本一变,浮点计算的顺序和精度就可能跟着变。很多开源项目写requirements.txt时习惯用torch>=1.9.0这种写法,看起来没问题,实际上一台机器解析出来的依赖树跟另一台机器能差出一大截。

环境漂移的隐蔽性在于,它不像代码报错那样直接可见。代码照样能跑,指标偶尔也没差多少,但它足以让“结果对齐”变得不稳定。等到你要复现一个月前自己跑的实验时,环境已经悄悄更新过好几轮,当时的曲线怎么都找不回来。

2.3 论文没写的“隐形超参”

这是复现论文时最让人头疼的部分。公开代码里往往只能看到模型结构和训练脚本,而真正影响结果的细节常常散落在论文的实验部分、插图标题、甚至附录的脚注里。我总结过几类最容易被忽略的“隐形超参”:

学习率调度策略。论文说“初始学习率 0.01”,但实际是每 30 个 epoch 乘以 0.1,还是余弦退火,还是带 warmup 的三阶段策略?这三者的收敛结果差异可能接近一个百分点。

数据增强参数。同一个“随机裁剪”,scale 范围到底是(0.08, 1.0)还是(0.5, 1.0)?有没有用RandomResizedCrop的默认值?这些默认值在不同版本框架里还可能被改动。

Batch Size 与梯度更新频率。有的论文写 batch size 512,实际因为显存限制用了 128 加 4 步梯度积累,但这跟真正的 512 批次在 BatchNorm 统计上是有差异的。

EMA(指数移动平均)权重。很多自监督和半监督模型依赖 EMA 来平滑模型参数,但 EMA 的衰减系数往往只写在附录里,甚至只藏在代码的默认参数里。

混合精度开关。混精度训练会把前向和反向计算的精度降低到 FP16,同时保留 FP32 的主权重。这个开关是否开启、动态 loss scaling 的策略是什么,都会影响数值轨迹。

这些参数在论文里不显眼,但对结果的影响却是实打实的。复现时看到差异,往这些方向排查的命中率很高。

2.4 数据与预处理也是“代码的一部分”

我见过最“冤”的复现失败是这样:一个人下载了训练集,另一个人下载了训练集,目录结构一样,文件也一样,但下载工具的校验逻辑不同,图片文件顺序不同,导致训练时数据读取顺序完全不同。

另外,数据预处理脚本也是复现链路的一部分。归一化的均值/方差是(0.485, 0.456, 0.406)还是(0.5, 0.5, 0.5)?图像 resize 的方式是双线性还是双三次?这些细节写在脚本里,但脚本本身如果不放在版本控制里,很容易在某次“顺手改一下”之后彻底失忆,再也找不回原始处理流程。

把数据和预处理脚本纳入版本管理,跟锁代码同等重要。这一点在下一章会展开讲。

3. 一套可落地的可复现方案:锁环境、锁代码、锁数据

复现问题讨论再多,最终还是要落到行动上。下面这套方法是这几年实际验证过的,每一步都不难,难的是把它变成习惯。核心思路可以用一句话概括:把影响实验结果的每一个变量都记录下来,并且锁死。

3.1 环境锁定:requirements 只是起点,哈希锁零

很多项目会提供一个requirements.txt,但这里有个常见误区:这个文件往往只写了顶层依赖,没有锁定传递依赖的版本。也就是说,你在pip install -r requirements.txt的时候,依赖解析器会在你当前的机器上下载“当时最新”的某个子依赖版本,而这个版本可能跟上个星期已经不一样了。

我更推荐的做法是:在干净的环境中做完所有代码调试后,用pip freeze生成一份完整的锁定文件。

# 在训练用的虚拟环境里执行 python -m pip freeze > requirements-lock.txt

这份文件会把当前环境下所有通过 pip 安装的包以及精确版本全部记录下来。恢复环境时直接:

python -m pip install -r requirements-lock.txt

这样虽然不完美(Python 生态里还有系统级依赖、编译器版本、OpenBLAS 等细节没法被 pip 管住),但已经能解决 80% 的版本漂移问题。更进一步,可以把 lock 文件内容生成一个哈希值,跟实验结果一并记录下来。

sha256sum requirements-lock.txt

记录这个哈希值之后,任何时候想确认“当前环境跟当时是否一致”,一条命令就能验证。每次环境改动都要重新生成 lock 文件,并且同步更新哈希记录。

3.2 种子管理:一套“不打架”的种子方案

种子管理看似简单,但要做好需要把每个随机源都照顾到。我平时用的工具函数长这样:

import os import random import numpy as np import torch def set_global_seed(seed: int = 42): # 注意:这个 os.environ 设置必须在导入 numpy/torch 之前才完全生效, # 所以最好在脚本最开头调用,或者单独放在一个模块里最先导入。 os.environ["PYTHONHASHSEED"] = str(seed) random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 让 cuDNN 使用确定性算法,并关闭 benchmark 自动调优 torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False

有两个细节值得提醒。

第一,PYTHONHASHSEED只影响 Python 字符串哈希的随机化。如果你在代码里用字典或者集合时依赖键的顺序,就要设这个变量,而且必须在 Python 解释器启动时生效。所以在import之前就设置os.environ是必要的,靠函数内部临时设置已经来不及了。

第二,torch.backends.cudnn.benchmark = False很多人会忽略。benchmark 开启时,cuDNN 会在一些算法之间做自动调优,第一次跑的时候选择的算法可能跟第二次不同,导致不确定的结果。把它关掉,才能让卷积计算路径相对固定。

仅仅调用这个函数还不够,还必须确认 DataLoader 的 shuffle 也使用固定 generator。做法是在创建 DataLoader 时传入一个独立的 Generator:

from torch.utils.data import DataLoader import torch g = torch.Generator() g.manual_seed(42) loader = DataLoader( dataset, batch_size=128, shuffle=True, generator=g, num_workers=4, )

注意num_workers的变化会影响数据到达顺序,所以锁定时要连这个参数一起固定。多卡训练时,DistributedSampler也要设置set_epoch(seed),否则每个 epoch 的 shuffle 顺序会随系统时钟变化。

3.3 数据与预处理不可变

固定好环境和随机状态之后,下一步是确保数据本身不可变。

我给自己定的规矩是:原始数据集一旦下载完毕,就把它放到只读位置,并记录一个 SHA-256 校验值。每次训练脚本启动时,先验证数据目录的哈希,不一致就直接报错退出。

# 计算训练数据目录的校验值 find /path/to/train_data -type f -exec sha256sum {} \; | sort | sha256sum

这行命令会把目录下所有文件的哈希算出来再合并,最终得到一个“目录指纹”。虽然速度不快,但在每次启动训练前做一次验证,能彻底杜绝“数据被人不小心改了一个文件”这类事故。

预处理脚本同样要纳入版本控制。我的习惯是,把从“原始图片”到“训练输入 tensor”的全部变换封装成一个独立的 Python 模块,并把该模块的 commit hash 写进训练日志。这样任何时候回看一次训练,都能清楚知道这条数据流水线当时的状态。

3.4 用实验管理工具记录“谁在什么上跑过”

人脑的记忆是不可靠的。三个月前你调了一个实验,当时觉得肯定是记录下来了,但真的被人问起“你这个模型的超参是什么”时,很难从命令行历史里准确还原。

实验管理工具能把这个问题系统性解决。这类工具会自动记录每个实验的代码版本、依赖环境、超参数、指标曲线、模型权重和日志文件。我在团队里推动过几个项目使用开源实验管理工具,核心收益是两个:

一是自动记录。不需要手动维护一个 Markdown 表格,每个人跑训练时,工具自动把环境信息和参数抓走,形成一条可检索的实验记录。

二是方便对比。当你连续跑了十几个实验,工具会自动把每条曲线放在一起展示,指标优劣一目了然。比自己在 Excel 里复制粘贴要靠谱得多。

即使不想引入新工具,至少也得建立一个“每个实验一条记录”的习惯,内容包含:git commit hash、seed、超参配置、环境 lock 文件哈希、GPU 型号、启动时间、结束时间、关键指标。这份记录就是以后你的“数字考古系统”。

3.5 容器:把整台机器变成一枚“邮票”

环境锁定的终极形态是容器化。把 CUDA 驱动的内核模块暴露给宿主机之外的所有依赖都封进镜像,等于把“操作系统级别的环境”也固定下来。任何人拿到这个镜像,在同类 GPU 环境下运行,得到的环境就是同一套。

容器的好处众所周知,可控的限制也同样重要:容器不解决硬件差异问题。同一个镜像在 A100 和 RTX 3090 上跑,由于 GPU 架构不同、并行调度策略不同,结果依然可能出现微小偏差。所以容器里还要额外记录 GPU 型号和驱动版本。训练开始时把以下信息写进日志文件,成本很低但价值很高:

nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv

容器不是银弹,但配合前面几层措施,已经足以把可复现性从“概率性事件”变成“常规现象”。

4. 实操复盘:一个图像分类模型从“对不上”到“完全锁定”

方法论讲完,拿一个真实踩过的坑做完整复盘。为了让描述更通用,我隐去具体数据和模型名称,但整个排查过程完全按实际经历来写。

4.1 复现目标与最初的现象

当时要在某复现项目中跑一个公开骨干网络结构的图像分类实验。论文给出的 Top-1 精度是 77.5%,我用对方公开的代码和数据集,第一次跑出来只有 76.3%,差了 1.2 个百分点。这个差距不算特别大,但足够让所有后续实验失去可信度。

我做的第一件事不是调参,而是先检查硬环境。记录下 PyTorch 版本、CUDA 版本、GPU 型号、Python 版本,然后看对方仓库里有没有环境锁定文件。结果发现对方只提供了一个很粗的requirements.txt,没有哈希锁定,也没有容器镜像。这意味着环境变量完全处于“漂移”状态。

4.2 第一轮排查:锁定环境版本

我建了一个全新的虚拟环境,按对方的requirements.txt安装依赖,但把关键的 PyTorch 和 torchvision 版本按照仓库历史记录里的版本号重新指定,降级到当时实验所用的版本。装好后重跑训练,精度从 76.3% 提到了 76.7%,曲线形态也比之前平滑了一些。很明显,算子的底层实现差异确实是影响因素之一,但它只解释了一部分差距。

4.3 第二轮排查:锁定随机状态

环境对齐之后,我把目光转到随机性。仔细检查训练脚本,发现它只调用了一次torch.manual_seed(42),但有两处遗漏:一是 DataLoader 没有单独设置 generator,shuffle 顺序其实没被固定;二是torch.backends.cudnn.benchmark保持默认开启状态,同样的输入在不同时间甚至可能选到不同卷积算法。

我把这两个问题修掉,重新跑了三个不同随机种子下的实验,发现精度稳定到了 76.9%,比最初的 76.3% 提高了 0.6 个百分点。到这里,环境 + 随机这两个因素已经修正了大约 0.6 个百分点的差异。

4.4 第三轮排查:数据顺序与预处理细节

还差最后的 0.6 个百分点。我先把目标缩小到预处理流程,逐行对比数据集加载代码和论文描述,终于发现两个问题。

第一个是随机裁剪范围。我复现代码里写的RandomResizedCrop的 scale 参数是默认值(0.08, 1.0),但原作者的实现里实际用的是(0.5, 1.0)。这个差异导致训练时模型看到的图片裁剪区域普遍偏大,特征分布跟论文设定有偏差。

第二个是归一化参数。我用了通用的 ImageNet 统计量(0.485, 0.456, 0.406),但原作者在代码里把归一化参数改成了(0.5, 0.5, 0.5)。这个改动在数据预处理里不起眼,但对最终精度的影响实实在在。

修掉这两处之后,重新训练,精度直接跳到了 77.3%,加上前面已经修正的环境和随机项,最终达到了 77.4%,与论文的 77.5% 只差 0.1 个百分点。这个差距在合理浮动范围内。

4.5 最终方案:五个锁一起上

性能对齐之后,我开始把整个复现过程固化下来,目的就是让这个结果以后随时能被重新找回。最终的做法是五个锁同时生效:

第一,锁定代码版本。把复现仓库打上 commit hash,所有关键脚本冻结。

第二,锁定环境。用pip freeze导出完整 locked requirements,并记录哈希值。

第三,锁定随机状态。统一 seed 函数 + DataLoader generator + cudnn deterministic + benchmark off。

第四,锁定数据。计算训练目录的 SHA-256 指纹,训练脚本启动时自动校验。

第五,锁定硬件信息。记录 GPU 型号、驱动版本、CUDA 版本,并写进训练日志。

后来我把这套流程总结成团队内部的一份 Runbook,之后每次复现别人的模型或重新拉起自己的实验,都按这个清单走一遍,成功率明显提升。

5. 常见问题速查手册与几个“反直觉”经验

5.1 十类常见复现问题速查表

现象排查方向处理建议
结果与论文有可观察差距环境版本不一致 / 随机种子没锁锁定依赖 + 统一 seed + 对比版本
同一个代码两次结果就不同DataLoader 无固定 generator / benchmark 开启固定 DataLoader generator、关闭 benchmark
换一台机器指标变化GPU 型号 / 驱动版本 / 库实现差异容器化 + 记录 nvidia-smi 信息
固定 seed 后仍有细微波动并行浮点累加顺序 / 非确定性算子开启 cudnn deterministic,接受微小浮动
训练后精度接近但曲线对不上学习率调度策略不一致核对 warmup、decay、scheduler 细节
图片数据能跑但结果偏预处理参数不一致检查 resize、归一化、裁剪范围
依赖装完跑不起来lock 文件缺失 / 传递依赖漂移用 pip freeze 生成完整锁定文件
找不到某次实验的记录实验日志不系统引入实验管理工具或建立固定记录模板
目录数据被改动影响结果数据盘可写设置只读 + 启动时校验目录指纹
多卡训练结果不稳定分布式采样随机 / 归约顺序固定 DistributedSampler epoch、统一进程数

5.2 我跑实验时定的几条硬规矩

这些规矩不一定适合所有人,但每一招都是从真实教训里换来的。

第一,git commit 必须走在训练启动之前。哪怕只改了一行配置,也要先提交再跑实验。不然过了几天,自己都分不清某个结果是对应哪份代码。

第二,所有依赖锁定文件都提交到版本控制里。requirements-lock.txt、环境描述文件、容器构建脚本,必须和训练代码放在同一个仓库。禁止把环境配置单独放在某个不靠谱的个人笔记里。

第三,每个新实验先跑一个小规模冒烟测试。用 100 个样本、2 个 epoch、一个 batch,验证代码能跑通、日志能落地、随机状态生效,再开正式实验。这个习惯能省掉大量“跑了一天发现脚本早该报错”的悲剧。

第四,实验报告的“复现信息”一栏永远留出来。记录 git hash、seed、环境哈希、数据指纹、GPU 型号五个字段,缺任何一个都要在报告里说明原因。

5.3 警惕“伪可复现”:比不复现更麻烦

接触过不少项目,表面上看已经做了可复现工作,但深入一查全是漏洞,比完全不做还麻烦。举几个常见的“伪可复现”例子。

只锁了直接依赖,没锁传递依赖。requirements.txt里有torch==1.12.0,但 torch 自身的依赖没锁,过一段时间numpy或pillow可能已经被更新到一个奇怪版本,训练时某些算子自动走新路径,结果就变了。

固定了 seed,但没有固定数据的读取顺序。同一台机器上,文件系统遍历顺序可能因为目录内容变化而改变;用 glob 匹配文件时,不同平台的排序规则也不同。seed 只影响混排算法,混排的输入序列如果是乱的,输出自然也是乱的。

容器镜像打了 tag,但没有记录 digest。很多人以为“镜像 tag 不变就万事大吉”,但 tag 可能被覆盖,镜像内容已经悄悄换了。可靠的记录方式是记录镜像的 digest(sha256),而不是只写一个v1.0这种符号名。

少做一个环节的后果是,出了问题反而更难排查,因为你以为已经全都锁死了,潜意识里不会再去怀疑环境层面。所以我一直强调,可复现是一个“全链路”的概念,任何一环的缺失都会让整个链条的可靠性归零。

我把可复现性当成一种“实验卫生”而不是“额外负担”。它不产出新的模型效果,不提升 accuracy,但它确保你的每一次努力都能被追溯、被信任、被延续。我个人的体会有两点:第一,别追求一次做到完美,可以先从“每次训练前打一个 commit”这种小动作开始,慢慢把整个流程补齐;第二,把可复现当成团队协作的接口协议,而不是某个人的个人习惯,大家一起遵守,才能让知识在所有实验之间流动起来。最后送你一个小技巧:给自己维护的所有训练仓库统一放一个REPRODUCE.md文件,把环境、数据、随机、硬件四类信息怎么记录写清楚。下次无论谁来跑这份代码,都不会再来敲你的门问“这个参数到底是多少”。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进