
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时习惯用torch1.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 时传入一个独立的 Generatorfrom torch.utils.data import DataLoader import torch g torch.Generator() g.manual_seed(42) loader DataLoader( dataset, batch_size128, shuffleTrue, generatorg, num_workers4, )注意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-gpuname,driver_version,memory.total --formatcsv容器不是银弹但配合前面几层措施已经足以把可复现性从“概率性事件”变成“常规现象”。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 没有单独设置 generatorshuffle 顺序其实没被固定二是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里有torch1.12.0但 torch 自身的依赖没锁过一段时间numpy或pillow可能已经被更新到一个奇怪版本训练时某些算子自动走新路径结果就变了。固定了 seed但没有固定数据的读取顺序。同一台机器上文件系统遍历顺序可能因为目录内容变化而改变用 glob 匹配文件时不同平台的排序规则也不同。seed 只影响混排算法混排的输入序列如果是乱的输出自然也是乱的。容器镜像打了 tag但没有记录 digest。很多人以为“镜像 tag 不变就万事大吉”但 tag 可能被覆盖镜像内容已经悄悄换了。可靠的记录方式是记录镜像的 digestsha256而不是只写一个v1.0这种符号名。少做一个环节的后果是出了问题反而更难排查因为你以为已经全都锁死了潜意识里不会再去怀疑环境层面。所以我一直强调可复现是一个“全链路”的概念任何一环的缺失都会让整个链条的可靠性归零。我把可复现性当成一种“实验卫生”而不是“额外负担”。它不产出新的模型效果不提升 accuracy但它确保你的每一次努力都能被追溯、被信任、被延续。我个人的体会有两点第一别追求一次做到完美可以先从“每次训练前打一个 commit”这种小动作开始慢慢把整个流程补齐第二把可复现当成团队协作的接口协议而不是某个人的个人习惯大家一起遵守才能让知识在所有实验之间流动起来。最后送你一个小技巧给自己维护的所有训练仓库统一放一个REPRODUCE.md文件把环境、数据、随机、硬件四类信息怎么记录写清楚。下次无论谁来跑这份代码都不会再来敲你的门问“这个参数到底是多少”。