前阵子整理本地模型训练脚本,发现一件让人后背发凉的事:一个两个月前跑通的实验,我用完全相同的代码、相同的训练数据、甚至相同的显卡重新执行,得到的指标却和当时记录的对不上,loss曲线更是差得明显。我反复检查了所有能想到的配置,最后发现症结藏在第三方数值库里一个几乎没人注意的默认参数变化上。
这个场景应该戳中了不少人的痛点。AI的可复现性挑战,指的就是这样一类问题:在深度学习、大模型、传统机器学习项目里,同样的输入代码、同样的训练流程,却因为各种细碎的原因得不到同样的结果。它不像“模型效果不好”那么显眼,却能让人排查到怀疑人生,也会让论文复现、团队协作、线上回滚这些正事频频受阻。这篇文章我想从实操出发,把可复现性难在哪、被什么因素破坏、以及我用过的逐层锁定方案完整拆一遍,适合那些正在被训练结果漂移折磨、或者想提前建立规范实验流程的人。
1. AI可复现性到底难在哪:一次“昨天还能跑通”的崩溃复盘
1.1 一次“昨天还能跑通”的崩溃复盘
最典型的不可复现体验,不是“完全跑不动”,而是“明明应该一样,结果却不一样”。
我之前做过一个图像分类任务,训练阶段在单卡上跑出来AUC是0.872。隔了一周,因为要提交实验记录,我原封不动地把训练脚本拉出来,在同一台机器、同一张卡上重新训练,结果AUC变成了0.861。这0.011的差距看起来不大,但对于一个已经定了型、准备进对比表格的结果来说,足够让人头皮发麻。我一开始怀疑是随机初始化不同,于是把torch.manual_seed和numpy的seed都补上了,再跑一遍,发现还是对不上。后来把训练日志里的每个细节逐项核对,才定位到数据加载环节:我之前用的数据读取库,接口没变,但内部用的是有损压缩的内存格式,版本一升级,同一张图片解码出来的像素值和旧版本就产生了肉眼不可见、模型却能感知的差异。
这种经历说明一件事:可复现性难,不是难在某个单一环节,而是难在整个深度学习训练链路里,几乎每一步都可能埋着非确定性的雷。从数据读取、数据增强、模型初始化,到前向计算、反向传播、梯度更新,再到分布式环境下的梯度同步,任何一环出现偏差,最后都会累积成指标上的漂移。
1.2 论文复现“永远差一个点”的经典困局
比自家实验漂移更普遍的现象,是复现别人论文时“永远差一点”。不少公开代码仓库的issue区都能看到类似的提问:作者官方跑出来F1是0.914,我按照README一步步来,只能跑到0.903,怎么调都上不去。
这种“差一点”往往不是模型结构抄错了,也不是数据下载错了,而是藏在预处理细节里。我见过一个经典的例子:某目标检测项目,原作者在预处理里用了双线性插值做图片缩放,而复现者用的深度学习框架那个接口默认是最近邻插值。就这么一个听起来微不足道的差异,最终反映到mAP上就是0.5到1个点的差距。更隐蔽的是,很多论文只写“图片resize到224x224”,完全不写用哪种插值方式、是否先做归一化、归一化用的mean和std来自哪个数据集、数据增强里各变换的执行顺序。这些细节在论文里不算创新点,作者往往默认读者能“自行领会”,结果就变成了一堵看不见的复现之墙。
1.3 可复现性的三个层级
顺着这些案例,我习惯把可复现性拆成三个层级来理解,排查的时候也按这个顺序来:
第一层是代码级可复现。代码本身的运行逻辑是否固定,随机种子是否真正控制了所有随机源,数据加载顺序是否稳定,模型实现是否等价。这一层最容易被工程师通过seed和固定流程解决,但也是最容易出现“以为解决了、其实没有”的地方。
第二层是环境级可复现。Python版本、深度学习框架版本、数值计算库版本、CUDA和cuDNN版本、GPU型号、甚至操作系统底层库,都会影响结果。很多人固定了seed仍然跑不对,问题就出在这一层。
第三层是流程级可复现。训练数据是否保存了哈希校验,数据切分时的随机顺序是否记录,超参数搜索和最终选型过程有没有留下完整档案,评估阶段用的是哪个checkpoint、哪套评测代码。这一层解决的是“过了半年,不仅代码能跑,连当时的决策上下文都能还原”的问题。
理解了这三个层级,再去看那些让人崩溃的“不可复现”,思路就清晰很多了。
2. 五个让结果跑偏的隐形杀手:从随机种子里挖出的真相
很多人的第一反应都是“固定随机种子不就行了吗?”,但实际上固定种子只是第一步,远不是全部。下面这五个因素,是我在多个项目里实际撞见过、并且逐一验证过的隐形杀手。
2.1 随机种子只种了一半
固定种子的最常见误区,是只设置了深度学习框架的seed,却漏掉了其它随机源。比如PyTorch生态里,很多人会写torch.manual_seed(42),但代码里如果还用到了numpy和标准库的random,那么这两个随机源仍然处于“失控”状态。数据增强、数据集shuffle、甚至部分采样逻辑都可能依赖它们,漏掉一个,结果就飘。
另一个非常隐蔽的地方是DataLoader。num_workers > 0时,每个子进程都会继承父进程的初始状态,但实际运行时每个worker消费样本的顺序并不完全由同一个全局seed决定。PyTorch官方提供了一种方案:给DataLoader传入一个设置好初始种子的torch.Generator,但很多人根本不知道有这个参数。我在自己的代码里就踩过这个坑——主进程的seed固定了,loss曲线前几个step还是一致,越往后越偏移,最后发现是worker之间的样本顺序产生了排列差异。
import random import numpy as np import torch def seed_everything(seed: int = 42): # PYTHONHASHSEED 需要尽量早地设置 import os os.environ["PYTHONHASHSEED"] = str(seed) 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 # 让 cuBLAS 使用固定大小的 workspace,减少不确定性 os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"2.2 框架与硬件版本漂移
版本漂移是环境级不可复现的头号元凶。深度学习框架的每个小版本都有可能改变算子内部的实现方式,比如卷积层的im2col排列、归一化层的epsilon默认值、优化器里某个数值稳定性的处理逻辑。这些变动通常不会写进release note,但它们会实实在在地改变最终的浮点运算结果。
硬件的影响更隐蔽。同一份代码,在消费级显卡和数据中心显卡上跑,即使显存、显存带宽不同导致batch size设置不同,也可能因为卷积算法选择策略不同而产生微小的数值差异。更麻烦的是,cuDNN在benchmark=True模式下会根据输入形状自动选择最快的卷积算法,而这个“最快算法”在不同显卡、不同驱动版本下可能是不同的,选出的算法不同,计算结果就有细微差别。
2.3 数据加载顺序和增强的随机性
数据顺序对训练结果的影响,经常被严重低估。同样是训练一个epoch,如果文件系统返回的文件顺序不同、或者shuffle时的随机排列不同,模型看到的样本流就不同,梯度更新轨迹自然不同。就算最终指标差异不大,某些对顺序敏感的模型(比如带动量、带BN的CNN,或者序列模型)就可能产生明显漂移。
更麻烦的是数据增强。Cutout、RandomCrop、AutoAugment这类增强操作依赖随机数生成,并且每个操作自己内部就有随机性。如果增强流水线里有一处没有接入全局seed,整条流水线的“随机状态”就会错位,导致每次跑数据增强得到的增广样本都不一样。有些增强库还依赖底层C++的随机数生成器,这类随机源往往不受Python层random.seed()控制,需要单独设置或者干脆绕开。
2.4 浮点数计算顺序:比想象中更敏感
不可复现还有一个纯数值层面的原因:浮点数加法不满足结合律。(a + b) + c和(a + c) + b的结果可能不一样,因为每一步加法都会产生舍入误差。在深度学习里,这个现象最典型的体现就是梯度累加和多卡训练。
单卡训练时,一个batch的梯度是逐样本累加的,累加顺序是确定的;但到了分布式训练,各个显卡先算各自的梯度,再把梯度通过AllReduce汇总,汇总时各卡梯度的累加顺序可能因为网络拓扑、通信库版本、甚至核函数调度而变化。结果就是:多卡训练同一个模型、同一个seed,不同次运行得到的loss曲线可能在个位甚至十位小数之后有差异,而这些差异经过成百上千个step的累积,就变成了明显的指标偏差。
混合精度训练也会放大这个问题。FP16的表示范围有限,梯度累加时如果顺序不同,舍入误差的累积路径就不同,更容易产生可见的数值漂移。
2.5 训练与评测细节缺失
最后一类杀手,是那些“论文里不写、代码里容易忘”的训练细节。常见的包括:学习率warmup的步数、学习率衰减的schedule、梯度裁剪的阈值、BN层momentum的取值、权重衰减是否作用在bias和BN参数上、是否用了EMA、ema_decay是多少。这些细节对最终指标的影响可能比模型结构还大,但大多数项目把它们当成“默认配置”,不会专门记录下来。
评测阶段也同样存在陷阱。训练时BN的running_mean/running_var会持续更新,推理时如果忘了切换到eval()模式,或者评估时batch size设置得过小导致单卡最后一个batch太小,结果就可能出现偏差。严格来说,评测时还需要固定数据的遍历顺序,否则不同次评估得到的指标波动就会干扰我们对“是否复现”的判断。
3. 逐层固定随机性:从seed到确定性算法的完整清单
了解了杀手在哪里,就可以对症下药。我把自己实践过、确认能提升可复现性的操作按层级整理成清单,每一层都有代码或命令可以直接抄。
3.1 一套seed everything函数,以及它到底做了什么
第一步是写一个seed_everything函数,在训练脚本的最开头调用。除了常规的random.seed、np.random.seed、torch.manual_seed、torch.cuda.manual_seed_all之外,有两件事容易被忽略:
第一是设置PYTHONHASHSEED环境变量。Python的字符串哈希会受这个变量影响,而哈希顺序一旦不同,依赖字符串做key的字典遍历顺序就不稳定。由于Python 3.7起字典保序,这个影响变得相对可控,但某些依赖集合或哈希排序的库仍可能因为它产生顺序差异。
第二是torch.backends.cudnn.deterministic = True和torch.backends.cudnn.benchmark = False的组合。前者让cuDNN选择确定性的卷积算法,后者关掉自动benchmark带来的算法选择随机性。这两个设置对卷积类的模型影响明显,配合使用才能让同一显卡上的两次运行结果按位一致或者至少高度接近。
from torch.utils.data import DataLoader, random_split import torch def build_dataloader(dataset, batch_size, seed): g = torch.Generator() g.manual_seed(seed) return DataLoader( dataset, batch_size=batch_size, shuffle=True, generator=g, num_workers=4, pin_memory=True, )DataLoader的generator参数是个关键,很多人会漏掉它。固定了它,才能保证shuffle产生的样本序列在不同次运行里一致。另外,如果你的num_workers大于0,还需要额外考虑worker_seed的问题。PyTorch在较新版本中支持在worker_init_fn里手动设置随机种子,否则不同worker消费数据的顺序可能仍然不受主进程seed完全控制。
3.2 打开确定性算法开关,但要清楚它的代价
如果你的场景允许,可以在训练脚本里加上torch.use_deterministic_algorithms(True)。这个API的行为是:遇到没有确定性实现的算子时直接报错,而不是安静地给你一个不确定性的结果。这对排查问题特别有用——它会把不可复现的隐患直接暴露在报错信息里,而不是让漂移悄悄发生。
但这里要提醒一句,这个开关的代价不小。部分算子的确定性实现比非确定性实现慢,个别场景下甚至慢几倍。另外一个常见麻烦:某些在CPU上只有非确定性实现的算子,开启后会直接抛异常。我在跑一些和第三方文本处理库集成的代码时就遇到过,排查到最后发现是库内部用了一个没有确定性版本的scatter类操作,只能绕开或者接受这种非确定性。
所以在实际项目里,我的策略是:正式实验、要写入报告或交付的结果,跑“确定性模式”;日常探索、调参阶段,跑“普通模式”,但保持随机种子一致。这样既保住了关键结果的可复现性,又不至于让每轮实验都慢得让人失去耐心。
3.3 环境快照:从pip freeze到完整容器
代码层面的确定性解决了,环境层面也不能糊弄。最简单的做法是训练结束或跑正式实验前,执行一份环境快照,并在结果归档时一起保存:
pip freeze > requirements.txt conda env export > environment.yml nvidia-smi > gpu_info.txt但pip freeze有一个明显的漏洞:某些依赖不是通过pip安装的,比如手动编译的CUDA扩展、源码安装的库,这些不会出现在freeze结果里。所以更彻底的做法,是直接用容器镜像。把训练代码、依赖、系统库、CUDA工具链全部固化到镜像里,给镜像打上一个稳定的tag,日后要复现,直接从这个镜像启动。这也是很多工业级机器学习平台的标准做法。
我个人的经验是:环境快照是“在做完实验之后最容易补、也最容易被拖延”的一步。因为实验成功的那一刻,所有人都急着写结果、开下一个实验,没人愿意去导出环境。但真正过了几个月要找回来的时候,又往往发现当时的驱动版本、某个底层库的精确版本已经再难确认了。所以我的习惯是,每次开始正式实验之前就把环境快照做了,而不是等实验结束之后。
3.4 验证一次“真的复现了”
固定好所有环节之后,一定要做一次验证性的复现实验,而不是直接默认“我已经固定好了”。具体操作是:用完全相同的代码、相同的seed、相同的环境,把同一个实验跑三遍,然后在测试集上评估每一轮的结果。如果指标的三次波动范围远远小于指标本身的提升幅度,说明这套配置基本可复现;如果三次的差异肉眼可见,说明还有漏网的随机源。
需要注意的是,即便开了deterministic,不同型号的GPU之间仍然无法保证按位一致,因为底层指令集和卷积算法库的实现不同。所以“可复现”在我这里的实际标准是:相同环境下结果稳定、不同环境下结果在统计意义上一致。理解这一点,能帮你少掉很多头发。
4. 一个实验要复现,必须保存这四类东西
可复现性不只是技术问题,也是记录习惯问题。哪怕代码和环境都锁死了,如果实验记录不完整,半年后一样复现不出当初的决策。我给自己定了四条记录铁律,每条都踩过不止一次坑。
4.1 代码版本:git commit不够,diff也要留
我见过不少团队,实验记录里写着“代码在仓库里”,结果那条commit后来被新功能覆盖,想回到当时的代码已经不可能了。更稳妥的做法是在训练脚本里自动把当前的commit hash写进结果文件:
import subprocess commit = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip() # 把 commit 写入实验日志或结果 json但这还不够。如果训练脚本里有任何未提交的改动,commit hash指向的代码和执行时使用的代码就是两码事。所以更严谨的方式是同时保存git diff的内容。我在跑正式实验时,会把git log -1 --format="%H"的输出和git diff的输出一起存到实验目录的code_version.txt里,确保“当时到底跑的哪一版代码”这个信息不会消失。
4.2 数据版本:hash是唯一可信的语言
数据一团乱,是复现失败最无语的原因。今天往目录里补了两张图、明天有人重新跑了一下切分脚本、后天数据增强的随机顺序变了,都会让“相同的数据”变成不同的数据。
我的做法是给数据做两层校验:一层是数据目录整体的哈希摘要,一层是数据文件数量和总大小。前者用于精确校验内容,后者用于快速筛查。大目录整体算MD5不现实,可以用find加sha256sum逐文件计算后汇总,也可以利用专门的数据版本管理工具做增量记录。关键是每次实验都要记录“这份数据对应的hash是什么”,而不是只记录数据路径。
数据切分的顺序也应该和超参数一样被记录。常见错误是:用sklearn.model_selection.train_test_split切分数据,但没固定random_state,导致每次跑出来的训练集和验证集构成都不同。这个问题在数据量小、标签分布不均衡的时候尤其致命。
4.3 训练配置:把超参数写进json,写进文件名,写进日志
超参数记录最好做到“一处保存、多处自动关联”。我先规定一个实验配置文件(可以是JSON或YAML),里面包含学习率、batch size、优化器参数、数据增强策略、随机种子、预训练权重路径、训练总步数等所有影响结果的字段。训练脚本启动时就读取这个文件,并把它的完整内容写进实验结果输出目录。
关键是要把配置和实验结果放在同一个目录下,命名上也带上足够多的信息。比如一个实验目录可以叫exp_lr3e-4_bs64_seed42,目录里再保存一份config.json。这样即使没有额外的数据库,翻目录也能猜到每个实验大概做了什么。如果再配合一套实验管理工具自动记录指标曲线,后面分析对比会轻松很多。
超参数里还有一类容易漏掉的是“隐性配置”:梯度累积步数、混合精度开关、EMA衰减系数、BN momentum、损失函数里的某个权重系数。这些我在配置文件里都会单独列出来,不依赖代码默认值。防止“代码里改了默认值但没更新实验记录”这种尴尬。
4.4 评测约定:让对比站在同一起跑线
训练结束后,评测环节本身也需要被记录和固定。包括:用的是哪个checkpoint——是最后一步的权重,还是验证集上指标最好的权重;评测数据是否shuffle;评测时batch size是多少;模型是否处于eval()模式;如果用了EMA权重,用的是原始权重还是EMA权重。
我在实际项目里经常遇到的情况是:训练日志里记录的验证集指标和后来单独跑评测得到的指标对不上,原因就是训练过程中用的是每隔几步在验证集上快速评测的结果,而正式评测时换了更大的batch size,BN层的统计特性发生变化,指标自然偏移。所以记录评测约定和记录训练配置同等重要。
下面这张表是我每个实验目录里都会放一份的“复现信息清单”,填完之后这个实验才算真正闭环:
| 类别 | 必须记录的内容 | 我通常保存的位置 |
|---|---|---|
| 代码版本 | commit hash、git diff、训练脚本入口 | code_version.txt |
| 数据版本 | 数据目录hash、文件数、切分seed | data_version.txt |
| 训练配置 | 全量超参、增强策略、随机种子、路径 | config.json |
| 环境快照 | pip freeze、conda env export、GPU信息 | environment/ 目录 |
| 评测约定 | checkpoint来源、eval batch size、BN状态 | eval_config.json |
| 结果输出 | 指标曲线、最终指标、日志、权重文件 | results/ 目录 |
这套清单看起来繁琐,但真正执行起来并不复杂——因为大部分内容都是脚本启动时自动生成的。前期多花半小时,后期能省下好几个通宵。
5. 需要警惕的代价:确定性、速度与质量之间的权衡
可复现性不是越高越好。追求“每次跑得完全一模一样”,在真实工程里会遇到明显的性能代价和适用边界。
5.1 确定性算法慢在哪里
我在自己的项目里简单测过:在图像分类模型上开启torch.use_deterministic_algorithms(True)之后,单卡训练速度大约下降了15%到30%,具体幅度取决于模型里卷积和矩阵运算的占比。而在某些更依赖cuDNN自动调优的场景里,下降幅度可能超过50%。
这说明一个很现实的问题:如果每一次实验迭代都要付出“确定性税”,整个团队的实验效率会明显下降。尤其是探索阶段,模型结构、超参数、数据增强策略每天都在变,这时候追求按位一致基本没有收益,反而拖慢节奏。
我的取舍原则是:探索期不开确定性模式,用固定seed保证“统计可复现”;进入正式评估、消融实验、要写结论或者交付客户时,开启确定性模式并锁死环境。这样兼顾了迭代速度和结论可靠度。
5.2 分布式训练里的“统计可复现”思路
在单卡场景下追求“按位可复现”已经需要付出不小的性能代价,到了多卡分布式训练,这个目标就更难实现。AllReduce梯度累加顺序、通信库的算子实现、甚至GPU之间的拓扑结构都会引入不确定性。强行要求按位一致,往往意味着要牺牲大量并行效率,而且不一定所有框架都支持。
我现在的处理方式是:分布式训练场景下不再追求“完全相同”,而是追求“分布相同”。具体来说,固定好seed和环境之后,跑几次实验,记录指标均值和方法,只要不同次运行的结果落在合理的统计波动范围内,就认为实验是可信的。对于论文复现或对比实验,把多次运行的结果范围画出来,比单独给一条最优曲线更有说服力。
5.3 生产环境里真正需要的可复现是什么
生产环境里的“可复现”,和科研环境里的含义还有一点不同。线上模型升级时,我需要回答的问题是:新模型和旧模型的行为差异,到底来自数据变化、特征变化,还是模型决策逻辑变化?这要求我能把某一天的模型服务精确复位到某个commit、某个权重文件、某个特征处理版本的组合上。这比“训练过程可复现”更严格,因为线上往往要求同样的请求、同样的上下文,必须输出同样的结果,否则用户侧就能感知到不稳定。
针对这一点,我在工程侧的落地方案是:线上模型必须保存完整的模型签名,包括预处理代码版本、模型结构文件、权重文件的hash、后处理逻辑版本。服务发布时把这些信息写进一个不可变的包里,每次请求带上模型版本号。回滚的时候,只需要切换到旧的版本包,就能恢复到发布前的行为。这套做法和训练阶段的可复现工程是互补的——训练阶段保证的是“我能重新训练出一个一样的模型”,线上阶段保证的是“我能让一个跑过的模型回到它当时的状态”。
5.4 回归工具链:把可复现性变成日常习惯
最后我想给一个比较实用的建议:可以把“可复现性检查”做成一个自动化工具,而不是等出问题才想起来补。比如在训练脚本里加一个--reproduce参数,启动时做三件事:检查git工作区是否干净并输出commit hash,检查数据目录hash是否和配置里的记录一致,导出当前环境快照到实验目录。这三步全部通过才允许正式实验跑起来。
我这样做了之后,最大的感受是:实验记录的质量提升了一个档次,因为不用再靠人肉记忆去回忆“当时用的什么环境、什么数据、什么代码”。每次的配置、数据、环境、结果都在同一个目录里,谁什么时候跑过什么实验,翻文件夹就能一目了然。可复现性挑战解决得越好,团队协作和长期迭代就越轻松。
说实话,我自己也是在接连遭遇好多次“复现不出来”之后,才真正把这些规则变成习惯的。AI可复现性这个题目听起来很学术,其实落到每一天的工作里,就是一些非常朴素的坚持:写全实验记录、固定随机状态、保存环境快照、让未来的自己和队友能够沿着清晰的路径回到任何一次实验结果。这不只是对论文负责,更是对时间负责。下了决心把这条链路打通之后,你会发现训练结果不再是一堆玄学,而是每一个关键选择都有据可查的踏实产物。