AI可复现性实战:从随机种子到环境锁定的全套方案

📅 发布时间:2026/10/21 12:50:47
AI可复现性实战:从随机种子到环境锁定的全套方案
1. 从一个“跑不出来”的实验说起大概半年前我接到一个很折磨人的任务把团队半年前训练的一个图像分类模型完整复现一遍。当时的想法很简单代码在仓库里训练脚本也在数据没有动过照着 README 跑一遍不就完了结果第一轮训练结束后验证精度比原始记录低了差不多 1.7 个百分点。我以为是正常的随机波动又重跑了一次这次差距扩大到 2.3 个百分点。我第一反应是“代码是不是被人改坏了”排查了半天一无所获。后来在小群里问了一圈同行发现大家全被戳中了有人说自己的强化学习实验换个机器就不收敛有人说同样的 GAN 训练代码跑出来完全是两张不同的生成图还有人抱怨“论文里的 baseline 永远复现不出来”。说到底这就是 AI 领域一直存在却总被回避的“房间里的大象”——AI 的可复现性挑战。这篇内容想聊的就是我自己摸索出来的应对可复现性挑战的实战方案。适合正在做深度学习实验、需要与论文结果对比、或者长期和大模型训练打交道的人。我会先拆解问题根源再给一套可以直接“抄作业”的做法最后重点讲我踩过的坑和排查方法。2. 为什么AI实验难以复现根源拆解先说一个容易被忽视的事实绝大多数深度学习训练脚本默认情况下都不是确定性的。哪怕你什么都不改把同一个脚本提交到同一台机器上跑两遍结果也可能有细微差别。想让结果可复现先得搞清楚差异从哪里来。2.1 随机性看起来固定其实处处漂移深度学习的随机性远不止“初始化时随机一下”这么简单。从头到尾梳理一遍你会发现随机数参与的地方多得吓人参数初始化模型权重大多用正态分布或均匀分布做初始化这是最常见的第一处随机。数据加载顺序DataLoader 默认会做 shuffle训练样本每次进入模型的顺序不一样几乎必然导致梯度路径不同。数据增强随机裁剪、随机翻转、随机颜色扰动每个操作都是随机数生成器的一次消费。Dropout 与正则层训练阶段 dropout 的 mask 是随机的BatchNorm 在训练时对每个 mini-batch 统计均值方差batch 的组成顺序变了统计量也跟着变。优化器内部状态像 Adam 这类带动量、带二阶矩估计的优化器如果训练数据顺序变了状态的演化路径就会偏移。很多人以为“只要固定种子就行”但固定种子只是第一步。因为随机数生成器是一个全局状态机如果你在训练中途调用了别的库函数而这个函数内部也用了随机数全局随机状态就被“污染”了。比如你在 epoch 之间调用了一次 numpy 的随机操作那么接下来模型看到的增强数据跟严格的种子复现路线就会分道扬镳。还有一个细节PyTorch 里常见的torch.manual_seed(seed)只管了 torch 的随机状态numpy 的随机状态要单独设置Python 内置的random模块又是一个独立状态。只设了 torch 的种子另外两个还是乱的。像我最早调的实验固定种子后精度差距变小但没有完全对齐就是因为忘了锁 numpy。提示锁随机状态的第一原则是“所有用到随机数的库都要锁”而不是只锁 PyTorch。顺序也要固定比如先设置 Python random再设置 numpy random最后设置 torch random每次训练脚本保持同样的设置顺序。2.2 环境异动同一个模型不同的机器第二类差异来自运行环境。模型对你的机器“记性”可能比你以为的好得多。同一个脚本在 A 机器上训练换到 B 机器上训练哪怕代码数据种子全部一致仍然可能得到不同的结果。环境层面的差异主要有几处GPU 型号差异不同代际的 GPU 对浮点运算的实现细节不一样有些 GPU 会做 FMA融合乘加优化有些不会导致同一批算子算出来的数值末尾几位不同。更麻烦的是这个差异会随着训练迭代被逐步放大。cuDNN 的算法选择cuDNN 内部对不同规模、不同形状的张量会选择不同的卷积实现。同一版本下自动调优可能选到 A 算法换一台机器显存、架构变化后自动调优可能选到 B 算法。两种算法数值结果并不严格一致。CPU 指令集如果你的训练管线里有 CPU 算子比如数据预处理不同的 CPU 指令集AVX2、AVX512会导致浮点运算顺序不同。线程数DataLoader 的num_workers从 4 改成 8数据进入 GPU 的顺序就可能变化因为多进程预取数据的时序不受你控制。框架与 CUDA 版本PyTorch 1.10 和 1.13 之间的默认实现细节有变化cuDNN 8.0 和 8.9 之间的卷积 kernel 选择规则也可能不同。这一层的问题光靠“种子固定”完全解决不了。环境差异是藏在数值计算层面的肉眼看不见只能通过锁定环境来控制。2.3 数据与代码的“隐性版本”第三种差异更像项目管理问题但破坏力同样大。训练数据不是一成不变的它可能在前处理脚本更新之后悄悄变化比如图片归一化参数从 [0.485, 0.456, 0.406] 改成了 [0.5, 0.5, 0.5]或者某次清洗数据时删了一批相似样本甚至 CSV 标注文件的顺序被 Excel 重新排列过。这类改动如果不记录版本你会拿旧代码跑到新数据上最后得出一个“复现不了”的结论。代码仓库里的同一个 commit 才是真正意义的“同一版本代码”。很多团队只管把最新代码推到仓库训练脚本、依赖锁文件、数据处理脚本却各自为政。半年后提交你根本不知道训练脚本当时调用了哪一版预处理函数。可复现性本质上还是一项记录工程。3. 把复现变成默认动作一套可落地的方案既然问题复杂解决方案也不能只靠“当年记下了一个种子号”。我摸索了几轮之后总结出一套五步流程现在已经变成我们团队做实验的默认动作。每一步对应一个维度的问题随机性、环境、数据、配置、评估。3.1 全局随机种子管理第一步是写一个统一的set_seed()函数任何训练脚本第一件事就是调用它。这个函数要把三套随机状态全部锁住同时给 CUDA 相关逻辑做特殊处理。import random import os import numpy as np import torch def set_seed(seed: int 42, deterministic: bool True): random.seed(seed) os.environ[PYTHONHASHSEED] str(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 多卡场景 if deterministic: # 强制 cuDNN 使用确定性的卷积实现 torch.backends.cudnn.deterministic True # 关闭 cuDNN 的自动调优保证算法选择路径稳定 torch.backends.cudnn.benchmark False # 开启 PyTorch 自带的确定性模式覆盖更多算子 torch.use_deterministic_algorithms(True, warn_onlyTrue)关键点有两个。cudnn.deterministic True会让 cuDNN 由“优先选最快算法”切换为“优先选确定性算法”cudnn.benchmark False则关掉自动调优。如果你开着benchmarkTrue即使步数输入尺寸固定cuDNN 也可能在过程中换算法。注意torch.use_deterministic_algorithms(True)要放在模型初始化之前它会对接若干不支持确定性实现的算子给出警告或直接报错。3.2 锁定依赖与运行环境锁随机状态只能解决“同一环境下的重复运行”跨环境复现必须锁运行环境。我们团队现在默认两套方案并行依赖锁定文件无论是 pip 还是 conda 环境都要导出完整锁文件而不是只写“requirements 里的几个包”。pip freeze requirements.txt虽然粗暴但至少保留了每个包的具体版本。推荐的做法是把锁文件分成两部分requirements-base.txt或environment.yml记录直接依赖requirements-lock.txt或完整 freeze 结果记录所有传递依赖的精确版本。容器化训练把基础镜像版本、CUDA runtime、cuDNN、Python 解释器、依赖全部打进同一个镜像。训练时用该镜像跑能最大程度抹平宿主机环境差异。如果你不方便上容器至少在实验记录里写下框架版本、CUDA 版本、GPU 型号、驱动版本。我踩过的教训是装环境时顺手pip install torch它会带一个当前时间节点的最新版本可能和你同事的环境差一个大版本。跑了一个月实验发现复现不了根因就是 torch 版本不同。所以现在的习惯是建环境时用精确版本号安装第一方依赖然后立刻 freeze并把 freeze 结果提交到仓库。3.3 数据版本与快照管理对数据做版本管理不等于把数据放 git 里。数据规模通常很大更适合做快照 签名。我们的流程是训练前把原始数据复制进一个固定目录目录命名带时间戳和用途比如data/snapshot-2025-06-01-cls。对数据集里的关键文件计算哈希值大文件可以先算抽样块的哈希再合并成整体指纹保存为一个data_checksum.txt。训练脚本在启动时读取哈希文件自动校验当前数据目录是否匹配不匹配就拒绝训练。数据处理脚本每次修改后必须提升版本号训练脚本里记录使用的是哪个数据版本把版本号和超参数一起写进日志。这个方案不需要额外基础设施用 shell 命令和 Python 脚本就能实现。数据体积大的时候哈希计算可能耗时但一次性成本换来的确定性是值得的。至少你不会再遇到“同一个人同一段代码上周跑和这周跑结果不同”的情况。3.4 超参数与实验日志的关联训练脚本里的超参数不能只存在于 README也不能只存在于命令行历史。我们使用统一的配置模块把所有超参数放在一个字典或者配置文件里训练启动时自动序列化并把完整配置写进实验日志目录。import json from dataclasses import dataclass, asdict dataclass class TrainConfig: seed: int 42 batch_size: int 32 lr: float 1e-3 epochs: int 50 model: str resnet18 data_version: str snapshot-2025-06-01-cls num_workers: int 4 optimizer: str adamw def save_config(config: TrainConfig, save_dir: str): payload asdict(config) payload[meta] { torch_version: torch.__version__, cuda_version: torch.version.cuda, gpu_name: torch.cuda.get_device_name(0), } with open(f{save_dir}/config.json, w) as f: json.dump(payload, f, indent2, ensure_asciiFalse) print(fconfiguration saved to {save_dir}/config.json)跑实验时每个输出目录里都有一个config.json。事后想复现直接读配置文件就能知道全部关键信息。这一步最大的价值是把“我记得当时设了 lr0.001”变成机器可读的、可靠的证据。3.5 评估口径的标准化经常被忽略的一个细节是评估逻辑本身也有版本。你用sklearn的accuracy_score算精度还是手写一个对比函数测试集的预处理方式跟训练时一致吗是否用了 TTA测试时增强这些差异都会让“同模型”出现不同分数。我建议把评估代码独立成一个模块训练脚本和后续评测脚本都引用同一份实现不要各写各的。同时固定测试集顺序评估前先对测试集排序比如按文件名排序再做 batch 推理减少 batch 划分带来的统计波动。如果模型输出的是概率评估指标的计算最好用固定精度的累积方式避免浮点求和顺序导致末位波动。4. 实操案例三天后还能跑出同样结果讲完原理和方案我拿一个自己最近做的图像分类实验来演示完整流程。目标是训练一个 ResNet18 在自采的小数据集上做二分类要求三天后重新运行同一份脚本仍然能拿到完全一致的验证精度。4.1 实验环境清单先说环境这部分是我们预先锁定的输入GPU单张 NVIDIA GeForce RTX 3090操作系统Ubuntu 22.04CUDA11.8cuDNN8.9.7Python3.10.13PyTorch2.1.2安装命令固定为pip install torch2.1.2 --index-url https://download.pytorch.org/whl/cu118其他关键库torchvision 0.16.2, numpy 1.26.4, Pillow 10.3.0环境准备好后我立即执行pip freeze requirements-lock.txt并提交到了实验仓库的experiments/exp-001/目录。4.2 完整可复现训练脚本下面是我的训练脚本核心结构保留数据加载部分稍作简化import os import random import json import numpy as np import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from torchvision import models, transforms from PIL import Image SEED 20250601 def set_seed(seed: int 42, deterministic: bool True): random.seed(seed) os.environ[PYTHONHASHSEED] str(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) if deterministic: torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False torch.use_deterministic_algorithms(True, warn_onlyTrue) class SimpleDataset(Dataset): def __init__(self, root_dir, transformNone, shuffle_fixTrue): self.samples [] for cls in [cat, dog]: cls_dir os.path.join(root_dir, cls) for fname in sorted(os.listdir(cls_dir)): self.samples.append((os.path.join(cls_dir, fname), 0 if cls cat else 1)) # 用固定顺序打乱一次之后不再依赖 DataLoader 的 shuffle if shuffle_fix: rng random.Random(999) rng.shuffle(self.samples) self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path).convert(RGB) if self.transform: img self.transform(img) return img, label def get_transforms(): train_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) return train_tf, val_tf def evaluate(model, loader, device): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in loader: images images.to(device) labels labels.to(device) outputs model(images) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return correct / total config { seed: SEED, epochs: 10, batch_size: 32, lr: 1e-3, weight_decay: 1e-4, data_version: snapshot-2025-06-01-cls, num_workers: 2, device: cuda, } set_seed(config[seed]) train_tf, val_tf get_transforms() train_ds SimpleDataset(data/snapshot-2025-06-01-cls/train, transformtrain_tf) val_ds SimpleDataset(data/snapshot-2025-06-01-cls/val, transformval_tf) # 刻意关闭 shuffle因为前面已经用固定随机打乱过了 train_loader DataLoader(train_ds, batch_sizeconfig[batch_size], shuffleFalse, num_workersconfig[num_workers]) val_loader DataLoader(val_ds, batch_sizeconfig[batch_size], shuffleFalse, num_workersconfig[num_workers]) device torch.device(config[device]) model models.resnet18(weightsNone, num_classes2).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lrconfig[lr], weight_decayconfig[weight_decay]) os.makedirs(experiments/exp-001/runs, exist_okTrue) with open(experiments/exp-001/runs/config.json, w) as f: json.dump(config, f, indent2) best_acc 0.0 for epoch in range(config[epochs]): model.train() running_loss 0.0 for images, labels in train_loader: images images.to(device) labels labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) train_loss running_loss / len(train_ds) val_acc evaluate(model, val_loader, device) print(fEpoch {epoch 1}/{config[epochs]} | loss: {train_loss:.4f} | val_acc: {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save({model_state: model.state_dict(), config: config, epoch: epoch}, experiments/exp-001/runs/best.pth) print(fBest val acc: {best_acc:.4f})脚本里有几个设计细节值得说明数据集在构造阶段先用rng.shuffle打乱一次并把样本顺序固定下来。DataLoader 关闭 shuffle这样即使num_workers多进程预取时序有变化样本进入训练循环的顺序依然固定。RandomHorizontalFlip和ColorJitter这些随机增强依赖全局随机状态在 set_seed 之后调用并且 PyTorch 内部的随机数生成器已经锁定。验证阶段不做任何随机操作评估代码和训练共用同一个函数避免口径漂移。模型用weightsNone初始化不使用预训练权重因为预训练模型文件本身的下载版本可能变化。4.3 验证可复现性的流程光有脚本还不够。我在训练结束后立即做了一次“当轮重复”把同样的脚本在同样环境下重新跑了一遍。两次Best val acc完全一致比如都是 0.9231 这种精确值且不是只对到三位小数。三天后我重启容器、进入同样的目录、执行同样的命令第三次结果仍与原值一致。如果结果不一致我就会用一套二分查错法先比较 loss 曲线是否从第一个 step 就开始偏离如果一开始就偏说明随机状态没锁好如果前几步一致、后面才偏需要查数据顺序和 cuDNN如果脚本内部一致但换机器不一致那就是环境层问题。5. 常见问题排查实录这一节写我遇到过的典型问题整理成一张排查表。不少坑是在实际项目里踩了多次才摸清规律的。现象可能原因解决办法两次同机同参数运行结果仍有细微差别只设置了 torch 种子没有锁 numpy / random或训练中途调用了其他随机函数使用统一的set_seed模块把三个随机库全部锁住固定种子后多卡训练结果不稳定多卡环境下的数据分发顺序、梯度 allreduce 顺序不确定记录并使用固定顺序的 DistributedSampler固定多卡启动的 rank 映射加num_workers后结果变了多进程预取的数据时序变化在 Dataset 里预先固定样本顺序DataLoader 关闭 shuffle换了 GPU 型号后结果变了GPU 的浮点实现、cudnn 算法选择不同用容器锁定环境记录或者接受量化层面的微小差异以多次重复的统计指标为准benchmarkTrue时结果不稳定cuDNN 自动调优会换算法设置cudnn.deterministic True且cudnn.benchmark False数据目录被无意修改过数据版本没有管理建立数据快照目录 校验哈希启动时检查依赖版本漂移pip install没有固定版本安装时指定精确版本训练后立即pip freeze同样的代码在论文里跑不出同样的数缺评测细节TTA、batch 大小、预处理、指标计算方式不一致把评估代码和测试集顺序标准化记录所有评测相关参数再说一个高发问题torch.use_deterministic_algorithms(True)在很多算子下会直接报错而不是安静忽略。比如某些 GPU 上的插值算子、某些索引操作会提示“Deterministic mode was enabled but this operation is not deterministic”。遇到这种情况我的处理方式是先给warn_onlyTrue看警告逐个排查是哪些操作触发的。如果只是偶发的不支持算子可以在该操作前后临时关闭确定性模式但要在日志里打点记录说明这个点存在不确定性风险。还有一个隐形坑是PYTHONHASHSEED。Python 的字符串哈希默认带随机化如果你的代码里用了字符串做数据集划分或去重字符串的哈希顺序在不同进程间不一致。os.environ[PYTHONHASHSEED] str(seed)需要在你启动 Python 解释器之后、任何哈希相关操作之前设置更严格的做法是在 shell 层设置环境变量再启动训练脚本。6. 我对可复现性的一些体会做了大半年可复现性改造之后我的最大感受是这件事本质上不是技术攻坚而是工程习惯的转变。真正挡住你的不是某个算法层面解决不了的难题而是那些“当时没记下来”“应该没问题吧”“换个环境跑一下试试”的侥幸心理。我现在养成了一个习惯每次训练实验启动前把环境信息、数据校验、种子设置、配置日志这几步做成一个固定的“开机检查清单”。实验跑完后第一件事不是去看精度而是确认所有元信息已经落到磁盘。这个习惯帮我省下的排查时间远多于额外记录花费的三五分钟。如果你觉得自己项目里的复现问题已经很顽固我的建议是不要试图一次性把所有环节都锁死而是按优先级来先锁环境和依赖再锁数据和种子最后再处理评估口径。前两步能解决大多数“复现不出来”的问题第三步能解决“复现出来但数对不上”的问题。最后分享一个小技巧把set_seed、save_config、evaluate三个模块抽成统一的实验工具包所有项目都引用同一份代码。这样不仅你自己的项目能复现别人接手你的项目时也不会因为“各自实现了一套评价逻辑”而再踩一遍复现性的坑。技术上的确定性最终还是靠流程上的确定性来保证的。