我最早意识到这个问题有多严重是在某次提交了一个图像分类模型的实验结论之后。论文里写的准确率是 92.3%审稿人问我要训练代码我原封不动跑了一遍结果只有 90.8%。我又跑了一遍91.5%。第三遍甚至一模一样的环境结果又变成了 90.1%。那一刻我真的慌了不是因为被审稿人驳了面子而是我意识到我根本不知道自己那个 92.3% 是怎么跑出来的。这就是 AI 可复现性挑战最让人抓狂的地方——模型本身是确定的代码但每次运行却像薛定谔的猫一样结果悬在概率云里。所谓 AI 的可复现性Reproducibility指的是在给定相同数据、相同代码、相同环境的前提下重新训练或推理能够得到一致或高度接近的结果。听上去像是个基本要求对吧但现实是深度学习训练链路里几乎每个环节都埋着不确定性。这篇文章我想把这几年在复现问题上踩过的坑、总结出的方法论以及从 AI 可复现性挑战这类竞赛里学到的经验一次性摊开讲清楚。1. 问题根源解剖训练结果漂移不是玄学是一个个具体的技术环节很多人第一次遇到结果漂移时第一反应是代码有 bug第二反应是GPU 坏了第三反应开始怀疑人生。其实都不是深度学习训练的不确定性来源是有明确清单的而且每一条都可以追溯到具体的代码行。1.1 随机性源头一参数的随机初始化与数据顺序神经网络的训练几乎从来不是从空网络开始的。无论是 PyTorch 的torch.nn.init还是 TensorFlow 的默认初始化器都会依赖伪随机数生成器PRNG产生初始权重。伪随机数生成器本身是确定性的——只要你给相同的种子它就会给出相同序列的数字。问题在于深度学习框架里不止有一个随机数生成器。以 PyTorch 为例常见的随机源包括Python 内置random模块的全局生成器影响数据加载器中的shuffle顺序NumPy 的全局生成器影响很多预处理逻辑PyTorch 的 CUDA 生成器影响 GPU 上的权重初始化和 dropout一些算子内部使用的非确定性算法如某些 CUDA 上的atomicAdd归约操作数据加载顺序对训练的影响很多人低估了。即便shuffleTrue如果你不固定torch.manual_seed每次 epoch 拿到的小批量数据顺序都不同。模型权重更新路径被数据顺序改变后最终收敛到的局部最优解往往就不同。尤其在数据集不大、学习率不小时这种差异会非常显著。1.2 随机性源头二GPU 并行计算的非确定性这一条是最隐蔽的也是我在排查时花费时间最长的环节。GPU 上大量并行线程的叠加计算本身就会引入浮点数运算顺序的差异。同一批数据分成 8 个线程并行归约和分成 32 个线程并行归约累加顺序不同浮点加法结果就不同——不是错只是浮点舍入的微小差别。更麻烦的是PyTorch 某些 CUDA 算子为了性能使用了原子操作或动态并行算法例如torch.nn.functional.embedding的梯度计算、某些scatter_add操作。当 GPU 线程调度次序不固定时这些操作的执行顺序就会影响结果。你不能用同一个种子解决这类问题因为问题出在算法层而非随机数层。注意PyTorch 官方文档中专门说明设置torch.use_deterministic_algorithms(True)可以强制禁止非确定性算法但代价是部分算子性能明显下降且并非所有算子都支持确定性模式。在灰度测试时可以用在大规模训练时往往不可行。1.3 随机性源头三框架版本与硬件差异同一个 PyTorch 版本换了 patch 版本号运算实现都可能变。我从1.9.1升到1.10.0后同样的代码、同样的种子、同样的数据最终精度波动了大约 0.3%。查了半天发现是torch.nn.functional.grid_sample的实现被重写了插值算法变了。这种事在框架升级时极其常见。硬件层面也绕不开。CUDA 库版本、cuDNN 版本、GPU 架构Volta 和 Ampere都会影响浮点运算的融合方式。甚至同一块 GPU 上因为温度导致的显存频率波动理论上都可能造成极小概率的浮点差异。实际操作中我们无法控制到那个粒度但至少要意识到可复现性从来不是代码层面就能完全解决的问题。2. 我的复现失败案例三次成功复现背后的真实排查链路理论讲再多不如讲一段实际案例。我参与过一个模拟图像分类项目用 ResNet-50 在公开数据集上做迁移学习。目标很简单固定随机种子在单卡上训练 60 个 epoch目标是最优验证集准确率不低于 89%。第一次训练最终验证集准确率 89.4%。为了验证稳定性我原样重跑了一遍结果是 88.7%。差 0.7%。我开始排查。2.1 第一步排查随机种子是否真正全覆盖我检查代码后发现训练脚本里只设置了torch.manual_seed(2024)没有设置random.seed(2024)和numpy.random.seed(2024)。数据加载器虽然用的是 PyTorch 的 sampler但数据增强部分有一段基于 NumPy 的随机裁剪逻辑。NumPy 的种子没固定意味着每次训练的数据增强路径完全不同。修复方式很简单import random import numpy as np import torch def set_all_seeds(seed: int): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 使 cuDNN 使用确定性算法 torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False这里有个很多人忽略的细节torch.backends.cudnn.benchmark False必须设置。默认情况下cuDNN 会在首次运行某个卷积层时做 benchmark测试多种算法选最快的一种。这个选择过程依赖硬件随机状态不同时间启动 training选出的算法可能不同后续运算就有细微差别。设为False后cuDNN 会固定选择确定性算法速度可能有一点损失但结果可复现。2.2 第二步排查数据加载的 worker 进程加了上述代码后又跑了两遍结果分别是 89.1% 和 88.9%。还是没稳住。我后来怀疑问题出在DataLoader的num_workers上。PyTorch 数据加载器开启多进程后每个 worker 进程会继承主进程的随机种子状态但继承的种子状态并不总是同步推进。PyTorch 官方文档提供了固定 worker 种子重置函数def seed_worker(worker_id): worker_seed torch.initial_seed() % 2**32 np.random.seed(worker_seed) random.seed(worker_seed) # DataLoader 中传入 loader DataLoader( dataset, batch_size32, num_workers4, shuffleTrue, seed_workerseed_worker, generatortorch.Generator().manual_seed(42) )这个机制的原理是主进程的torch.Generator产生 epoch 级别的随机种子worker 拿到后自己再派生各自的种子。设置好之后数据加载顺序才真正可控。改完这一步我又跑三遍结果分别是 89.2%、89.0%、89.2%。波动范围缩小到 0.2% 以内。这算复现成功了吗严格说不算但对图像分类这个任务来说基本进入可接受范围。判断标准后面我会专门讲。2.3 第三步排查CUDA 环境矩阵这个案例还引出一个更隐蔽的坑torch.backends.cudnn.benchmark和torch.use_deterministic_algorithms的配合问题。torch.use_deterministic_algorithms(True)在 PyTorch 1.10 之后会强制拦截所有使用非确定性算子的操作一旦检测到就抛出 RuntimeError。我最初在训练脚本开头直接开启结果某次迭代时报错说scatter_add无法确定执行。这时你有两个选择改算子或用torch.backends.cudnn.deterministic True代替。我最终采取的方案是训练时cudnn.deterministic True不开use_deterministic_algorithms推理验证时use_deterministic_algorithms(True)确保评估结果可逐 bit 复现这样做的原因是训练阶段有小幅波动可以接受而模型推理阶段的评估指标比如精确率、召回率必须严格可复现否则线上评测都会抖动。3. 锁定复现环境从依赖版本到容器化的一整套清单代码层面的随机种子控制只是第一步。如果你希望复现一个实验光有代码是不够的环境本身必须被完整记录和重构。我在参与一次某跨平台系统实验复现时最大的教训就是环境差异对结果的影响甚至可能大于随机种子。3.1 版本管理范围要精确到传递依赖很多人做版本管理时只记录torch1.10.0、numpy1.20.0这种区间这等于没记录。正确做法是用pip freeze requirements.txt把全部包版本完整锁死或者直接使用poetry.lock/uv.lock这类锁文件。但requirements.txt只能锁 Python 包版本锁不了 CUDA、cuDNN、驱动版本。实践中我的做法是用一套环境标识四元组维度记录内容影响程度Python 版本3.8 vs 3.11中影响部分库的编译行为CUDA 版本11.3 vs 12.1高算子实现不同cuDNN 版本8.2 vs 8.6高卷积算法差异框架版本torch 1.10.0 vs 2.0.0极高API 行为可能变化这四项任何一个变化都不能声称环境一致。实际操作中我还会用torch.version.cuda、torch.backends.cudnn.version()在训练日志里输出实际生效版本防的就是系统装了多套 CUDA实际调用的是另一套这种情况。3.2 硬件的可复现性同一块 GPU 是底线跨硬件复现是比跨环境复现更难的事。同一型号的卡比如两块同品牌同型号 GPUTensor Core 的调度路径相同一般来说可以做到较接近的复现。但不同型号、不同代际的 GPU浮点计算路径差异很大得到的结果可能有 0.5% 以上的漂移。如果你的目标是把实验记录在案、供同团队复跑最保险的办法是训练和推理都固定在同一块物理 GPU 上并记录 GPU 型号、驱动版本、显存容量。在实验记录模板里加上GPU: Tesla-V100-SXM2-32GB Driver: 470.57.02 CUDA Toolkit: 11.4 cuDNN: 8.2.4别小看 Driver 版本。我遇到过一次驱动升级后某些算子的流水线被重新编排训练速度提升 20% 的同时loss 曲线产生了肉眼可见的偏差。3.3 容器化是复现的兜底方案我现在的标准做法每个实验项目从第一天就在 Docker 容器里跑。Dockerfile 里锁定基础镜像、框架版本、系统库版本配合docker commit和镜像标签能够保证同一份镜像 同一套环境。实际工作中我的习惯是准备两套容器training-env包含完整训练依赖不保证确定性强调性能eval-env包含推理评估依赖开启确定性算法保证评测结果可复现为什么分开因为训练环境里如果为了确定性故意降低速度整个项目的实验迭代效率会大打折扣。而评估环境只在验证结果时运行牺牲一点性能换取可靠度非常划算。# 训练容器简化示例 FROM pytorch/pytorch:1.13.1-cuda11.6-cudnn8-runtime RUN pip install -r requirements-train.txt RUN apt-get update apt-get install -y --no-install-recommends \ libgl1 libglib2.0-0 WORKDIR /project容器化还有一个附带好处团队成员之间不会因为各自的~/.bashrc、系统库冲突导致我这边能跑出 89%你那边只能跑出 87%的尴尬。3.4 完整复现配置文件比代码更重要的记录我后来从 AI 可复现性挑战这类公开活动里学到一个习惯每跑完一个实验除了保存模型权重、训练日志还会导出一份reproduce_config.json完整描述复现条件。模板大概长这样{ random_seed: 42, python_version: 3.9.12, framework: { name: pytorch, version: 1.13.1, cuda_version: 11.6, cudnn_version: 8.4.0 }, hardware: { gpu: RTX-3090-24GB, driver_version: 510.73.08 }, data: { dataset_name: 模拟数据集A, split_seed: 2023, augmentation_profile: v2.1, data_md5: 1f4d2d... }, optim: { name: AdamW, learning_rate: 3e-4, batch_size: 64, weight_decay: 0.02 }, train: { epochs: 60, total_steps: 187500, final_metric: { name: valid_acc, value: 0.892 } } }看到data_md5了吗这个也很关键。数据文件哪怕少了一个字节训练结果都可能天差地别。我在一次复现中查了很久最后发现是某同事把数据集里的一张图做旋转预处理后覆盖保存了文件哈希变了模型结果直接掉点 1.2%。从那之后所有实验记录里必须包含数据集的校验和。4. 可复现性的程度分级不是所有抖动都需要逐位一致讲到这里需要回到一个更本质的问题什么叫复现成功大多数人下意识认为复现就是结果完全一致最好小数点后四位都对得上。但实际操作中目标应该分级管理否则会把大量精力浪费在无意义的一致性追求上。4.1 三个复现等级的正确区分根据我的经验可复现性需要至少分成三个等级第一级是指标级复现。也就是重新训练后最终验证指标准确率、BLEU、F1落在第一次结果的合理波动区间内比如正负 0.3%。这个级别适合大部分实验结论判断你的方法是否比 baseline 好只要波动区间不覆盖彼此结论就是可靠的。第二级是行为级复现。要求 loss 曲线形态相似、收敛趋势一致不需要每个 epoch 的数字都相同。这个级别适用于复现论文方法的场景重点是验证方法有效性而不是验证代码逐 bit 一致。第三级才是逐位复现。要求多次运行结果完全一致包括浮点数。这通常只在推理阶段、或者需要严格审计的场景下才必须做到。一个比较务实的判断方法先看你复现的目的。如果是为了写论文、发开源代码做指标级复现就够了。如果是为了上线部署模型文件 推理评估脚本需要做到逐位复现因为你必须保证线上和线下评估的是同一个模型行为。4.2 多次运行统计量单次跑分只是样本参与 AI 可复现性挑战这类活动时我学到的一个关键做法是不要用单次跑分下结论。深度学习中一次训练结果就是一个随机变量单次结果只是其中一个采样点。所以我现在做对比实验的标准流程是对每个方法至少跑 3 次条件完全相同仅随机种子不同记录三组结果用均值 ± 标准差的方式报告如果两个方法的均值差小于合并标准差则不得宣称某个方法更优这个流程看似耗时实际上能避免大量无效争论。试过之后你会很清楚某些 0.1% 的精湛提升放在多次运行波动里根本站不住脚。这也是为什么很多公开榜单上分数咬得极紧的模型换个环境复现就翻转排名的深层原因。4.3 波动容忍度的量化思路那么什么算合理的波动区间我实践下来波动大小跟几个因素强相关数据集规模数据越小波动越大。CIFAR-10 规模的实验多次运行波动 0.3%~0.5% 很常见ImageNet 规模会缩到 0.1% 以内模型容量模型参数量越大初始化随机性对结果的影响越敏感训练 epoch训练越充分、收敛越彻底多次运行最终结果往往更接近一个比较粗糙的经验公式设定波动容忍度 三次运行的标准差 × 2如果新一次复现的结果落在该区间内就可以认为复现通过。注意这里用的标准差是你自己的多次运行统计出来的不要拍脑袋设。5. 训练之外的可复现性推理阶段同样要较真很多人把注意力都放在训练过程的复现上却忽略了推理阶段一样存在可复现性问题。尤其当你要把一个训练好的模型发布出去、让他人在自己的环境里做评估时推理阶段的不稳定性会直接产生换台机器分数就变了的投诉。5.1 推理阶段的非确定性来源推理阶段看似只是一次前向传播但同样存在几个隐藏的非确定性来源第一个是 BatchNorm 的 running_stats。训练模式中BatchNorm 会统计整个批次的均值方差但这个统计结果在多个 epoch 之间累积不同随机种子下训练得到的 running_mean 和 running_var 本来就不同。如果你用的是训练中途保存的 checkpoint这个影响就不只是推理稳定性而是一整套训练轨迹的问题。第二个是推理时的数据预处理。很多框架的预处理库在加载图像时有一些非确定性行为比如基于多线程的图像解码轮询顺序不同可能导致输入张量有微小差异。这个问题可以通过固定 torchvision.transforms 中的torchvision.transforms.functional和PIL.Image版本缓解但最彻底的办法是在保存模型的同时保存一份推理预处理脚本和若干样例输入输出作为回归基准。第三个是混合精度推理。autocastfp16模式下浮点数舍入路径本身就压缩了精度小于 1e-3 的差异不再可信。我通常建议在推理阶段直接使用 fp32除非你有十足的部署性能压力。5.2 构建黄金基准用于回归验证我现在维护了一个名为黄金基准的验证集固定十张样本图片 固定的预处理流程 固定的模型权重每次改动环境或代码后先跑一遍这个基准对比输出张量的逐位差异和最终推理结果。如果输出张量每个值都与上一次记录完全一致说明这次改动没有破坏推理确定性。如果出现任何位级差异就要检查差异出现的层、幅度大小再决定是否影响结论。这套方法帮我解决过一个非常典型的问题某个周末我把 GPU 驱动从 510.x 升级到了 530.x下周一团队成员报告评测脚本跑出了完全不同的结果。我花了两小时排查代码一无所获。最后是跑黄金基准时发现输出张量总共差了 0.3%顺藤摸瓜才定位到 SGEMM 算子的行为变了。从那以后驱动升级前后必跑黄金基准已经成为团队纪律。5.3 模型文件的元数据嵌入习惯保存模型权重时我建议把训练环境信息直接写入 checkpoint 文件。PyTorch 的torch.save支持保存任意 Python 字典我通常在这个字典里塞入checkpoint { model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), epoch: epoch, config: config, env_metadata: { torch: torch.__version__, cuda: torch.version.cuda, cudnn: torch.backends.cudnn.version(), numpy: np.__version__, }, seed: seed, }这样做的好处是任何拿到这个 checkpoint 的人即使没有训练日志也能从上手第一秒判断这个模型是用什么环境训练的而不需要到处翻记录。6. 从 AI 可复现性挑战中带回来的四件事AI 的可复现性挑战这个主题之所以每年被人反复提及是因为它戳中了 AI 领域最痛的软肋。参与相关挑战和讨论之后我把自己的体会浓缩成四件事也算是对全文的一个收束。注意这四件事不是从教科书抄来的全是实践里磨出来的。6.1 可复现性是一种工程纪律不是一种技术如果你把可复现性理解为调用某个函数、设置某个参数那注定失败。它更像一套覆盖代码、环境、数据、硬件的工程纪律。代码层面是种子管理环境层面是版本锁定数据层面是哈希校验硬件层面是记录追踪。哪一环松懈整体就会失守。我见过不少开发者花了 80% 的精力研究模型结构却从不肯花 20% 的时间把实验环境规范起来。等到论文被质疑、队友复现不出来再回来补环境配置代价往往是几周的无效返工。所以说可复现性的第一道关口不是技术而是习惯。6.2 记录实验意图比记录实验参数更难得在 AI 可复现性挑战中优秀的方案往往不只是数据脚本的堆叠而是包含清晰的实验日志。所谓实验意图就是你为什么选择这个学习率、为什么改了这个数据增强、你预期发生什么。参数可以被导出但意图只存在于实验者的脑子里如果不记录下来换一个人拿到你的代码根本无法理解某些看似奇怪的选择。我现在每次跑实验都会同步记录一个EXPERIMENT_NOTE.md内容很简单# Exp 0021 ## 变更点 - 将基础学习率从 3e-4 降到 1e-4 - 添加了 RandomResizedCrop 的尺度参数扰动 ## 动机 - 上一组实验在 epoch 20 后出现验证集振荡怀疑是大学习率下 BN 统计不稳定 - 希望增强几何不变性提高小物体识别 ## 复现风险 - 数据增强改动引入了新的随机源需确认 seed 覆盖这份笔记对团队协作的贡献甚至大于代码注释。6.3 不要混淆可复现与可信可复现代表同一个实验能再次得到相同结果可信代表这个结果能够准确反映模型在真实世界的表现。一个模型结果完全可复现但如果数据集有泄漏、评估指标不合理那它也只是精确地重复一个错误。我在做某图像处理 Demo时遇到过验证集准确率稳定在 99% 以上且每次运行完全一致的情况检查之后发现是数据预处理时发生了标签与图片错位模型实际上在学一个无效特征。那一刻我明白可复现性解决的问题是可靠性但它替代不了正确性。6.4 接受不能完全复现的现实最后一条体会可能有点丧但很真实深度学习领域绝对的逐位可复现在大规模训练中几乎不可能完全做到。GPU 集群上动辄几十上百张卡并行训练集合通信库的归约顺序、网络传输的时序变化都会带来不可控的随机性。所以我不再追求绝对一致而是追求受控波动。设定明确的波动容忍区间把环境锁到尽可能细的粒度把实验记录做得足够完整然后坦然接受 0.2% 以内的漂移。这才是在现实项目中可持续的做法。我在实际工作中养成的最后一个习惯是每当准备复现一个别人或自己的实验时先把全部日志打印出来对比第一条 loss 和最后一条 loss。如果首尾的 loss 曲线走势都对不上那就别对比什么最终精度了——那不是复现问题是环境或者代码路径根本不一致需要回头从数据加载和模型定义开始排查。先保证曲线行为一致再谈指标一致这比什么都重要。 SEO 优化官网定制响应式建站教育培训建站