写论文、跑基线、做实验的时候,最烦的一件事是什么?不是模型不收敛,而是你明明昨天跑出一个结果,今天原封不动再执行一遍,得到的指标却对不上。更尴尬的场景是,你把代码发给同事,他在自己机器上跑了一轮,精度掉了两三个点,然后开始怀疑你的训练流程有Bug。这时候你才意识到,PyTorch实验的可复现并不只是一个锦上添花的工程问题,它在很多时候直接决定了结论是否可信。所谓可复现实战,核心其实就三件事:随机种子固定、依赖锁定、配置归档。这篇文章我会完整拆解我在实际项目中用的这套方案,包括具体代码、参数选择理由和踩过的坑,希望能让每个做深度学习实验的人都少走点弯路。
这篇文章适合谁看?如果你正在用PyTorch做模型训练、算法调优、论文复现,或者需要在团队里统一一套实验代码规范,那接下来的内容就是为你准备的。它不涉及复杂的分布式训练技巧,也不讨论AutoML之类的高阶话题,只聚焦在一个哪怕是单卡跑实验也会遇到的真实痛点:如何确保同一份代码在不同时间、不同环境下产出一致的结果。
1. 为什么实验会复现不了——先搞清楚随机性到底从哪来
很多初学者刚接触可复现这个概念时,第一反应是“在代码开头加几行torch.manual_seed(0)不就行了”。但实际做一次全流程复现就知道,光设一个种子远远不够。原因很简单:PyTorch训练流程里的随机性来源远比你想象的多,它们分布在数据加载、模型初始化、算子执行这几个层级里。
1.1 模型训练中主要的随机源
先把训练过程中“每个epoch结果不完全一致”的成因列出来:
- 参数初始化:
nn.Linear、nn.Conv2d这些模块在创建时会用类似Kaiming均匀分布或正态分布来初始化参数。PyTorch默认的初始化虽然在大多数时候不会出问题,但如果你不固定随机种子,每次创建模型都会是不同的起点。 - 数据顺序:只要
DataLoader里设置了shuffle=True,训练数据的读取顺序就是随机的。尤其做图像分类或NLP训练时,数据顺序会显著影响梯度走向。 - Dropout等正则层:这类层在训练状态下会按概率随机丢弃神经元,天然是随机的。
- cuDNN和cuBLAS的算法选择:卷积和矩阵乘法在GPU上有很多不同实现,cuDNN在做benchmark时会挑一个当前条件下最优的算法。不同批量大小、不同卷积参数,选出的算法可能不同,数值结果也会略有差异。
- 多进程DataLoader:当
num_workers>0时,每个worker进程内部都有自己的随机数状态,如果不对 worker 做专门处理,主进程固定了种子,子进程的随机状态依然可能是乱的。
这还只是训练阶段的情况。如果用了数据增强、Mixup、自监督的随机Mask、多卡分布式采样,随机源只会更多。
1.2 可复现的三层含义
做可复现不一定意味着“每次跑结果一模一样”。我倾向于把可复现分成三个层级:
- 严格复现:同一环境、同一代码、同一随机种子,多次运行结果完全一致。这是最理想的,也是本篇主要讨论的目标。
- 趋势复现:不同环境下(比如换了GPU型号或PyTorch小版本),模型收敛趋势、最终精度范围基本一致,误差在可接受区间内。
- 结论复现:实验对比时,A方案是否优于B方案的结论不会因为随机性而改变。这个在很多论文实验里其实是底线要求。做对比实验时,如果基线跑出来比方法本身还好,那你很难说服审稿人。
如果理解不了为什么要分层,可以类比烹饪:严格复现是“同一口锅、同一批调料、同一个火候,两道菜味道一模一样”;趋势复现是“换了口锅但大差不差”;结论复现是“不管怎么做,你都得承认红烧肉比清蒸肉更下饭”。在深度学习里,我们要保住的底线是第三条,但为了排查方便,也要尽量逼近第一条。
2. 随机种子固定——不是一行代码的事
这一节是整个可复现方案的基石。我会先给出一套可以直接复制使用的种子设置函数,再逐步解释每个调用为什么不能少。
2.1 一套完整的随机种子设置方案
下面是我通常在项目里用的set_seed函数:
import random import numpy as np import torch def set_seed(seed: int = 42): random.seed(seed) # Python内置随机模块 np.random.seed(seed) # NumPy torch.manual_seed(seed) # PyTorch CPU端 torch.cuda.manual_seed(seed) # PyTorch CUDA端(单卡) torch.cuda.manual_seed_all(seed) # PyTorch CUDA端(多卡) # 以下两项与cuDNN确定性算法相关,后面会详细说 torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False为什么要同时设置这么多?因为它们管理的是不同层面的随机数生成器。PyTorch的manual_seed只会设置PyTorch内部的生成器状态,管不到Python标准库的random,也管不到NumPy的全局状态。
举个例子:如果你用random模块决定某个数据样本是否做翻转,或者用np.random做数据增强的噪声添加,而这两者的种子没有固定,那么即使模型初始化完全一致,每次喂给模型的数据仍然不同。
还有个细节:torch.cuda.manual_seed_all表面上看起来只对多卡有效,但为了保险起见,单卡环境下我也会加上。它的作用是给当前设备以及未来所有可能用到的CUDA设备都设置随机数状态,避免你以后改成多卡实验时忘记补这一行。
如果你用到了其他库,比如numpy.random.default_rng()这种新式生成器,或者random.Random(seed)单独创建实例,也要留意它们的状态是否独立。一个取巧的做法是:代码里所有涉及随机数的地方都基于一个固定根种子派生,而不要在每个文件里各自seed一次。
2.2 cuDNN的deterministic与benchmark到底影响什么
很多人固定了种子之后发现GPU上结果还是对不齐,基本都是卡在这一项:cuDNN的确定性开关。torch.backends.cudnn.deterministic = True和torch.backends.cudnn.benchmark = False这两行,是让卷积等算子在算法选择上尽量走固定路径。
默认情况下,PyTorch会把benchmark设为False,但不少人在训练脚本里为了提速会主动打开torch.backends.cudnn.benchmark = True。这个开关的意思是:cuDNN在首次运行某组尺寸的卷积时,会做一个快速的基准测试,选出当前GPU上最快的卷积实现并缓存下来,后续同尺寸输入就复用这个算法。
问题在于,这个“最快算法”依赖具体GPU架构、输入尺寸、batch size等因素。换个显卡,甚至换个cuDNN版本,选出来的算法可能就不一样了。算法不一样,浮点加法顺序就会变,最终结果出现微小差异。
而deterministic = True会强制PyTorch只使用cuDNN中具有确定性实现的卷积算法,代价是性能可能下降。不同的模型和显卡上,这个性能损失幅度不一样,我也见过某些网络在开启确定性后训练速度掉20%以上的情况。如果你做实验只在意最终结论,不在意逐位完全一致,那么在某些场景下可以接受关掉它,但要明确知道这个取舍。我的建议是:定位问题、写论文做精确对比时开确定性;跑大模型探索性实验、性能敏感任务时,关闭确定性并固定好其他所有随机源。
这里必须提醒:开启deterministic = True后,个别算子可能会直接抛异常,提示“没有确定性的实现”。比如某些反卷积、部分pooling、甚至某些特殊配置的Attention算子就会这样。遇到这种问题,别慌,这不是你代码写错了,只是PyTorch在告诉你该算子没有可保证确定性的版本。解决思路是看能不能用等价算子替代,或者接受这一层的微扰。
2.3 DataLoader多进程引入的随机性问题
DataLoader的num_workers > 0是一个大坑。主进程里设了种子,但每个worker进程启动时都会继承主进程的随机状态,然后各自在数据加载过程中消费随机数。由于多进程调度顺序不同,每个worker实际消费随机数的时机可能不同,最终导致数据加载顺序不完全一致。
PyTorch官方推荐的做法是给DataLoader传入一个generator,并在每个epoch开始前手动重置它:
g = torch.Generator() g.manual_seed(42) for epoch in range(epochs): train_loader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=4, generator=g) for batch in train_loader: ...但这里有个细节:PyTorch在num_workers>0时,每个worker除了主生成器外还会用自己的worker_init_fn获得独立种子。想要达到严格可复现,官方推荐配合worker_init_fn使用:
def worker_init_fn(worker_id): # 保证每个worker内部状态也由统一种子派生 seed = 42 + worker_id random.seed(seed) np.random.seed(seed) torch.manual_seed(seed)如果你使用了torch.utils.data.random_split来划分训练集和验证集,记得也给它传一个固定的generator,否则每次划分的数据分布都可能不同。
2.4 固定了所有种子仍然无法复现的隐蔽因素
即使你照着上面的代码全部做了,还是可能发现两次运行结果在最后几位小数上不同。这种时候就要考虑几个更难缠的因素:
- CUDA原子操作:某些归约运算(尤其涉及小规模reduction时)在GPU上使用原子操作,原子操作的执行顺序不是确定的,可能导致极小的数值误差。
- 混合精度训练:
torch.cuda.amp.autocast下部分算子以低精度执行,精度截断导致的一点点差异会被后续梯度放大。 - 自定义CUDA算子与第三方库:你写了一个CUDA kernel,或者用了DeepSpeed、flash-attention之类的库,这些库是否保证确定性完全是另一回事。实际测试下来,flash-attention在某些实现里会引入细小的非确定性差异。
- CPU多线程:同一台机器上,
torch.set_num_threads不一致也可能导致结果漂移。瓜分线程的调度方式会影响计算顺序。
一个实用的判断方法:如果两次结果的差距在1e-6量级以内,通常不用太纠结,它们不影响训练趋势和最终精度;如果差距出现在有效数字的第3位甚至更大,那就要认真排查了。
3. 依赖锁定——版本漂移是复现失败的隐形杀手
很多团队保存了代码和随机种子,以为万事大吉,结果过了半年,重新跑实验发现指标对不上。去查pip list,发现PyTorch从1.13变成了2.1,NumPy大版本也换了。这就是依赖漂移的典型症状。
3.1 pip层面的锁定策略
最常见的做法是训练结束后直接执行pip freeze > requirements.txt。这个命令会把当前环境里所有pip安装的包全部列出来,包括版本号。它的优点是省事,缺点是所有包被拉平到一个文件里,而且会包含很多和项目无关的库,比如Jupyter、notebook插件这类。更关键的是,它锁的是“版本”,不锁“安装来源和构建方式”。
更精细的做法是使用pip-tools:
pip install pip-tools # 在requirements.in里只声明你的直接依赖 # torch>=1.13,<2.1 # numpy>=1.21 # transformers>=4.26 # 生成锁定文件 pip-compile requirements.in -o requirements.txtpip-compile会把所有传递依赖的精确版本一并解析出来。这样你既能控制直接依赖的上限,又能确保安装环境完全一致。到了部署阶段,用pip-sync requirements.txt可以把当前环境同步成锁定文件里的状态,多出来的包会被自动卸载。
如果想要更彻底,可以锁定包的哈希值:
pip freeze > requirements.txt pip download -r requirements.txt -d ./packages --only-binary=:all:把包提前下载到本地,再通过pip install --no-index --find-links=./packages离线安装。这种方式可以保证即使PyPI上的某个包被删掉或更新了,你仍然能还原实验环境。
3.2 conda环境怎么锁
用conda管理环境的同学,常见的操作是conda env export > environment.yml。这个命令会把conda和pip两部分的包都列出来,但有个坑:它生成的prefix字段会带上当前机器的绝对路径,换一台机器导入时会报路径错误。
conda env export --no-builds > environment.yml会去掉构建号,可移植性好一些,但代价是丢失了构建ID,将来在某些平台上可能安装到不同构建版本的包。另一个命令是conda env export --from-history > environment.yml,它只记录你手动指定的包名,相当于一个“意图文件”。
实际使用时,我的习惯是保持两个文件:一个environment.yml(用--from-history记录顶层依赖),一个requirements.txt(记录精确版本和哈希)。前者方便别人快速建环境,后者用于严格复现。
如果你想更进一步,可以上conda-lock:
conda install -n base conda-lock conda-lock -f environment.yml -p linux-64它会生成一个可重复解析的锁定文件,效果和pip-tools在pip领域的定位很像。
3.3 Python包之外还需要关注什么
依赖锁定如果只停留在Python包层面,还远远不够。PyTorch的底层数值行为在很大程度上依赖于系统里安装的CUDA Toolkit、cuDNN、NCCL,以及NVIDIA驱动版本。跑过几个环境你就会发现,同样版本的PyTorch,配合不同版本的cuDNN,哪怕只是小版本差异,卷积运算的结果都可能出现可感知的区别。
所以在记录依赖时,我会把以下几项全部记录下来:
- Python版本(
python --version) - PyTorch版本、torchvision / torchaudio版本
- CUDA Toolkit版本、cuDNN版本、显卡驱动版本(
nvidia-smi) - 操作系统类型与内核版本(比如Ubuntu 20.04、内核5.15)
- CPU架构相关信息(如果涉及CPU推理)
把这些信息组合成一个文本快照存入实验目录,比单纯存一个requirements.txt有价值得多。
3.4 Docker镜像作为终极环境锁定手段
如果你受够了每次在新机器上复现环境时的痛苦,我的建议是:直接上Docker。Docker并不是什么新鲜东西,但对深度学习实验来说,它实际上是“环境快照”最可靠的方案之一。
一个比较稳的实践路径是:
- 选择一个固定的基础镜像,比如
nvidia/cuda:11.8.0-cudnn8-devel-ubuntu20.04,不要用latest标签。 - 在Dockerfile里先安装Python和pip依赖,然后再安装你的项目依赖,并锁定版本。
- 把项目代码复制进去,运行训练脚本。
- 训练完成后,用
docker commit保存当前容器为一个新镜像,或者直接用docker save导出镜像文件。
这样,任何一台能跑Docker的机器上,你都能复现完全一致的环境。缺点也很明显,镜像文件通常很大,动辄几个GB甚至十几个GB,不适合长期保留太多镜像。我常用的策略是:核心实验跑完后保存一个精简镜像,日常探索性实验不存档。
4. 配置归档——让每次实验都有“身份证”
随机种子管住了随机性,依赖锁定管住了环境,接下来还有一个容易被忽略的问题:配置信息。很多时候你拿到别人的代码,看了半天不知道这组实验用的学习率是多少、batch size多大、数据怎么预处理。没有配置归档,代码本身只是半成品。
4.1 一份实验配置到底要存什么
我建议把下面这几类信息全部归档:
- 模型结构参数:层数、隐藏维度、dropout比例、激活函数等。
- 训练超参数:优化器类型、学习率、weight decay、batch size、epoch数。
- 数据相关参数:训练集、验证集路径,预处理方式(归一化、Resize、数据增强),数据版本或快照ID。
- 随机种子:项目根种子、数据划分种子等。
- 运行环境:Python版本、PyTorch版本、CUDA版本、GPU型号、操作系统。
- 代码版本:Git commit hash、分支名、当前代码是否有未提交改动。
这些内容如果散落在命令行参数、Python脚本、README不同地方,以后找起来极其痛苦。最好的方式是把它们聚合到一个或几个文件里,随实验结果一起保存。
4.2 用JSON和YAML统一管理配置
我强烈建议把配置写成YAML或者JSON,而不是直接硬编码在Python脚本里。两者各有优劣:YAML支持注释,适合人读;JSON通用性广,但没法写注释。
下面是我项目里常用的一份配置示例:
exp: name: "resnet50_cifar10_baseline" seed: 42 cudnn_deterministic: true cudnn_benchmark: false model: arch: "resnet50" num_classes: 10 pretrained: false data: train_path: "/data/cifar10/train" val_path: "/data/cifar10/val" batch_size: 128 num_workers: 8 shuffle: true augmentation: random_crop: true random_flip: true optim: name: "sgd" lr: 0.1 momentum: 0.9 weight_decay: 5e-4 train: epochs: 90 log_interval: 20 checkpoint_dir: "./checkpoints"然后在代码里通过一个简单函数读入配置:
import yaml with open("configs/exp.yml", "r") as f: config = yaml.safe_load(f) torch.manual_seed(config["exp"]["seed"]) model = build_model(config["model"]) dataloader = build_dataloader(config["data"])把随机种子写进配置的好处是你可以通过修改配置文件快速切换不同种子跑多次实验,而不用动代码。建议项目根目录下固定一个configs/文件夹,按实验命名存放,避免“改了一层配置又覆盖掉原来的”这种事故。
4.3 把Git提交信息和训练日志一并归档
配置信息里最容易被忽略的是“代码版本”。同样是“最后跑的版本”,改了10行代码和没改那10行代码,结果可能完全不同。所以我在每次训练开始时,都会用脚本自动把当前代码的Git信息写进实验输出目录:
import subprocess def get_git_info(): try: commit = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip() branch = subprocess.check_output(["git", "branch", "--show-current"]).decode().strip() dirty = subprocess.check_output(["git", "status", "--porcelain"]).decode().strip() return {"commit": commit, "branch": branch, "has_uncommitted_changes": bool(dirty)} except Exception: return {"commit": None, "branch": None, "has_uncommitted_changes": None} git_info = get_git_info()这样训练产出目录下:
experiments/ └── exp001/ ├── config.yml ├── git_info.json ├── metrics.csv ├── checkpoints/ └── env_snapshot.txt以后回看这组实验,打开config.yml知道超参数,打开git_info.json知道代码版本,打开env_snapshot.txt知道环境依赖,再配合metrics.csv里的训练指标,整个实验的闭环就完整了。
关于日志记录,除了TensorBoard和wandb之外,我习惯在每个实验目录下单独保存一份metrics.csv,每一行记录一个epoch的loss、accuracy、lr等指标。这样即使没有可视化工具,也能快速用pandas读出来分析,或者在后续做对比时直接脚本化处理。
4.4 自动归档脚本示例
如果你希望每次训练启动时“一键归档”,可以用下面这个极简流程:
#!/bin/bash EXP_DIR="experiments/exp_$(date +%Y%m%d_%H%M%S)" mkdir -p $EXP_DIR/checkpoints cp configs/exp.yml $EXP_DIR/config.yml git rev-parse HEAD > $EXP_DIR/commit.txt git diff --stat > $EXP_DIR/uncommitted.diff pip freeze > $EXP_DIR/requirements.txt python train.py --config $EXP_DIR/config.yml --output_dir $EXP_DIR把这段逻辑封装成一个shell脚本或者Makefile目标,每次启动训练前先跑一遍,归档就完成了。花一分钟做这件事,后续排查哪怕晚了三个月,也能快速还原现场。
5. 常见问题与复现排查实录
理论说了这么多,实际中大家遇见的坑往往都很相似。我把一些高频问题和排查思路整理一下,这部分算是我个人用的“快速诊断清单”。
5.1 经典症状与对应解法
| 症状 | 可能原因 | 推荐处理 |
|---|---|---|
| 加了种子但每次结果差很多 | 忘了设置NumPy或Pythonrandom种子;DataLoader未传generator | 用第2节的完整set_seed函数,并给DataLoader和random_split传generator |
| CPU上能复现,GPU上不行 | cuDNN的非确定性算法 | 打开cudnn.deterministic=True且benchmark=False |
| 换GPU型号后结果变了 | 不同GPU下cuDNN选算法不同,或驱动版本差异 | 记录GPU型号和驱动;接受微小差异,重点关注趋势复现 |
| 开了deterministic后训练变慢 | 确定性算法通常不是最快实现 | 权衡:实验阶段开确定性,纯探索阶段允许关闭 |
| 两个版本的PyTorch跑出不同结果 | 底层算子实现或数值计算顺序变了 | 用Docker或conda锁定环境;对比时先确认核心依赖版本一致 |
deterministic=True时报“no deterministic implementation” | 某个算子没有确定性实现 | 换等价算子;查PyTorch文档中支持的算子列表;考虑是否必须全开 |
5.2 一个比较高效的排查流程
第一步,先在CPU上跑一个小epoch数的全流程。CPU上的数值行为比GPU更容易确定。如果CPU上跑两次结果一致,说明问题大概率出在GPU相关层。
第二步,如果GPU上有差异,把torch.backends.cudnn.deterministic和torch.backends.cudnn.benchmark都设置好,再跑一次。很多时候这一步就能解决。
第三步,如果还是不一致,逐模块隔离排查。把模型拆成几个子模块,单独比较两次运行的输出。比较时可以考虑保存中间Tensor到文件,用差值分析找出差异开始出现的位置。
第四步,检查DataLoader的num_workers,把数字调到0试试。如果num_workers=0时完全可复现,说明是多进程的随机状态问题,用第2.3节里的worker_init_fn和generator修复。
另外,PyTorch在新版本中提供了torch.use_deterministic_algorithms(True),这是一个更严格的开关,会在检测到非确定性算子时直接抛错。它比cudnn.deterministic要求更高,适合在开发阶段帮你快速定位非确定性来自哪里。当然,它的报错也更频繁,很多第三方库算子没有声明支持确定性算法。我的做法是开发阶段开着它跑一次,定位问题;正式训练时再按需调整。
5.3 可复现与性能的取舍
没必要在任何场景都追求严格复现。我做实验时会区分两种模式:
- 严格复现模式:用于论文实验、审计、超参数对比。所有种子固定,
deterministic=True,benchmark=False,依赖全部锁定。 - 探索模式:用于快速验证想法、调模型结构。保留
benchmark=True换取速度,丢失一点确定性完全可接受。
如果你的任务允许存在微小随机波动,不必为了每次运行完全相同而牺牲大量训练时间。关键是训练脚本要有明确的配置控制这两者,而不是在代码里随意硬编码。
还有一个关于baseline对比的经验:当你比较两个方案时,如果只跑一次实验就下结论,是非常危险的。哪怕种子只差一点点,也可能让你得出完全相反的结论。稳妥的做法是用三到五个不同种子分别跑两个方案,观察精度均值和标准差。前后差距小、标准差小,结论才可靠;差一两个点但标准差波动大,那很可能就是随机噪声。
6. 一些最后的实操心得
说句实话,真正把这一套流程固定下来之后,我实验排查的时间少了很多。以前遇到“昨天结果跑不出来”这种问题,可能要花一整天去猜到底是哪儿变了;现在只要打开实验目录下的配置文件、环境快照、Git信息,基本几个小时内就能锁定范围。
有一件事我可以确定:做实验可复现越早落地越好。哪怕你现在只是跑一个非常小的CNN实验,也建议从第一天就加上种子固定和配置归档。因为当你积累了几十个实验以后,再来追溯“这个结果是怎么来的”会变得非常困难。这套东西不需要多复杂的架构,一个统一的set_seed函数、一个固定格式的配置文件、一个快速生成环境快照的脚本,加起来不过几十行代码,但它能让你的实验从“大概跑出来了”变成“可证明跑出来了”。
最后分享一个小技巧:如果你在团队里,把种子设置函数、环境快照脚本、配置模板放进一个公共工具模块,然后在新项目里强制调用,大家的实验规范就能自动统一。团队协作里最怕的不是某个人不守规矩,而是每个人有自己的一套习惯。这个模块就是那根“线”,把大家的实验风格都串起来。