1. 实验可复现为什么值得单独拎出来讲
但凡在PyTorch里跑过几个实验的人,大概都经历过这种场景:上周跑出来一个不错的结果,这周想再验证一遍,代码一行没改,指标却对不上。排查半天,最后发现是torch.manual_seed忘了设,或者某个依赖库悄悄升了个小版本,又或者当时用的学习率配置被覆盖了却没记录。这类问题不致命,但极其消耗精力,尤其是当你需要把结果交给别人复现、或者自己隔几个月回头再看的时候。
PyTorch实验可复现这件事,核心就三块:随机种子、依赖锁定、配置归档。听起来简单,但每一块都有不少细节容易翻车。随机种子不只是torch.manual_seed(42)一行就完事,CUDA、cuDNN、DataLoader的worker、numpy、Python内置random,这些都得管。依赖锁定也不只是pip freeze,PyTorch和CUDA的版本对应关系、Python版本、甚至操作系统层面的差异都会影响结果。配置归档更不是把参数存成JSON就万事大吉,你得考虑怎么存、存什么、怎么加载、怎么和代码版本关联。
这篇内容适合谁看?如果你正在做深度学习实验,需要反复调参、对比模型、写论文或者做项目交付,那这些经验大概率能帮你省下不少排查时间。如果你刚搭好PyTorch环境,还没被复现问题毒打过,那提前把这套流程建起来,后面会轻松很多。下面我按实际操作的顺序,把这三块拆开讲,穿插一些我踩过的坑和目前比较稳的做法。
2. 随机种子:不只是torch.manual_seed那一行
2.1 哪些随机源需要固定
很多人以为设了torch.manual_seed(42)就万事大吉,结果发现两次运行结果还是不一样。原因很简单,PyTorch实验里的随机性来源不止一个。我整理了一个清单,每次开新项目都会对着检查一遍:
- Python内置random:
random.seed(42),有些数据预处理库会用。 - NumPy:
np.random.seed(42),数据增强、划分数据集时常用。 - PyTorch CPU:
torch.manual_seed(42)。 - PyTorch CUDA:
torch.cuda.manual_seed(42)和torch.cuda.manual_seed_all(42),多卡时后者更保险。 - cuDNN:
torch.backends.cudnn.deterministic = True和torch.backends.cudnn.benchmark = False。 - DataLoader worker:如果
num_workers > 0,每个worker有自己的随机状态,需要通过worker_init_fn设置。 - 环境变量:
PYTHONHASHSEED,影响Python哈希随机化,某些场景下会影响结果。
我一般会写一个set_seed(seed)函数,把这些全包进去,放在项目工具模块里,每个实验脚本开头调用一次。这样至少保证单次运行内部的一致性。
2.2 一个可复用的set_seed实现
下面这个函数是我目前用得比较顺手的版本,你可以直接抄:
import os import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed(seed) torch.cuda.manual_seed_all(seed) os.environ['PYTHONHASHSEED'] = str(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False注意torch.backends.cudnn.benchmark = False这一行。benchmark设为True时,cuDNN会自动寻找最快的卷积算法,但不同运行可能选到不同算法,结果就不一致。设为False会牺牲一点速度,但换来确定性。如果你的实验对速度极其敏感,可以权衡一下,但做对比实验时我建议关掉。
2.3 DataLoader的worker种子问题
num_workers > 0时,每个worker进程会继承主进程的随机状态,但如果不额外设置,worker之间的随机行为可能不可控。标准做法是传一个worker_init_fn:
def worker_init_fn(worker_id): worker_seed = torch.initial_seed() % 2**32 np.random.seed(worker_seed) random.seed(worker_seed) loader = DataLoader(dataset, batch_size=32, num_workers=4, worker_init_fn=worker_init_fn)这里torch.initial_seed()会返回当前worker的种子,基于主进程的种子派生,所以整体还是可控的。我试过不设这个,在数据增强比较复杂的任务里,两次运行的数据顺序会有细微差异,最终指标能差零点几个百分点,做对比实验时很致命。
2.4 确定性带来的性能代价
把cuDNN设成deterministic、benchmark关掉之后,训练速度通常会下降,幅度取决于模型和硬件。我实测过ResNet50在单卡上的情况,大概慢5%到15%。另外有些操作本身就没有确定性实现,比如某些版本的torch.nn.functional.interpolate在CUDA上,或者torch.scatter_add。遇到这种情况,PyTorch会直接报错而不是静默给不确定结果,这其实是好事,至少你知道问题在哪。
提示:如果某个操作报"not deterministic"错误,可以先查PyTorch文档看有没有确定性替代实现,实在没有就接受那部分的不确定性,但在实验记录里标注清楚。
3. 依赖锁定:pip freeze远远不够
3.1 PyTorch版本与CUDA的对应关系
PyTorch和CUDA的版本对应是个老生常谈但每次都会有人踩的问题。比如PyTorch 1.11对应CUDA 10.2、11.3、11.6几个版本,PyTorch 2.x之后对应关系又变了。如果你用pip install torch不指定版本,装到的可能是CPU版或者和你的驱动不匹配的CUDA版。我一般会去PyTorch官网的previous versions页面查对应表,然后明确指定:
pip install torch==1.11.0+cu113 torchvision==0.12.0+cu113 -f https://download.pytorch.org/whl/torch_stable.html注意+cu113这个后缀,它表示编译时链接的CUDA版本。如果你在WSL里用7900XTX这类AMD卡跑PyTorch,情况又不一样,需要走ROCm路线,版本对应关系是另一套。这类环境下我建议先把rocminfo和python -c "import torch; print(torch.version.cuda)"的输出记下来,归档时一并保存。
3.2 用requirements.txt锁定完整环境
pip freeze > requirements.txt是最直接的做法,但它有个问题:会把所有间接依赖都列出来,包括那些你根本没直接用的包。这在复现时反而可能出问题,因为某些间接依赖的版本可能和你的PyTorch版本不兼容。我的做法是分两层:
- 直接依赖:手动维护一个
requirements.in,只写你明确用到的包和版本范围。 - 完整锁定:用
pip-compile或者pip freeze生成requirements.txt,记录精确版本。
如果不想引入额外工具,至少做到pip freeze之后人工检查一遍,把明显不相关的包删掉。另外,pip freeze不会记录Python版本和操作系统信息,这些得单独存。
3.3 conda环境的导出与复现
如果你用conda管理环境,conda env export > environment.yml会导出所有依赖,包括通过conda安装的底层库。但这里有个坑:environment.yml里会包含prefix字段,指向你本地的环境路径,别人拿到后直接conda env create -f environment.yml可能会失败。解决办法是导出时加--no-builds,或者手动删掉prefix行。
我一般会同时保留environment.yml和requirements.txt,前者用于快速重建conda环境,后者用于pip层面的精确锁定。如果项目要交付给别人,还会额外写一个setup.md,说明Python版本、CUDA版本、安装命令和验证步骤。
3.4 容器化:更彻底的锁定方式
如果条件允许,用Docker把整个环境打包是最稳的。Dockerfile里明确指定基础镜像、CUDA版本、Python版本、pip安装命令,构建出来的镜像在任何支持Docker的机器上行为一致。缺点是镜像体积大、构建慢,而且如果要用GPU,还需要nvidia-docker或者对应的容器运行时。
我自己的习惯是:日常开发用conda环境,快速迭代;到了要交付或者发论文的阶段,再花时间做一个Docker镜像,把环境彻底冻结。这样既不影响开发效率,又能保证最终结果可复现。
4. 配置归档:让每次实验都有据可查
4.1 配置该存什么
配置归档的核心目标是:给定一个实验结果,能追溯到当时用的所有参数和代码状态。我一般会存这几类信息:
- 模型超参数:层数、隐藏维度、注意力头数、dropout率等。
- 训练参数:学习率、batch size、优化器类型、权重衰减、学习率调度策略。
- 数据参数:数据集名称、划分方式、预处理步骤、数据增强配置。
- 随机种子:上面set_seed用的那个值。
- 环境信息:Python版本、PyTorch版本、CUDA版本、GPU型号。
- 代码版本:Git commit hash,如果工作区有未提交改动,还要记录diff。
这些信息如果散落在各个脚本里,时间一长根本找不到。我现在的做法是统一用一个配置类或者字典管理,训练开始前序列化成JSON或者YAML存到实验目录。
4.2 用dataclass管理配置
Python的dataclass很适合做这件事,类型清晰,还能嵌套:
from dataclasses import dataclass, asdict, field from typing import List @dataclass class TrainConfig: lr: float = 1e-3 batch_size: int = 32 epochs: int = 100 optimizer: str = "adam" weight_decay: float = 1e-4 seed: int = 42 @dataclass class ModelConfig: hidden_dim: int = 256 num_layers: int = 4 num_heads: int = 8 dropout: float = 0.1 @dataclass class ExperimentConfig: train: TrainConfig = field(default_factory=TrainConfig) model: ModelConfig = field(default_factory=ModelConfig) data_dir: str = "./data" output_dir: str = "./runs/exp001"训练脚本里实例化ExperimentConfig,然后asdict(config)转成字典存JSON。这样配置的默认值、类型、嵌套关系都一目了然,改起来也方便。
4.3 实验目录的组织方式
我习惯给每个实验建一个独立目录,结构大概是这样:
runs/ exp001/ config.json metrics.csv model_best.pt model_last.pt train.log git_info.txt env_info.txtconfig.json存完整配置,metrics.csv记录每个epoch的指标,git_info.txt存commit hash和diff,env_info.txt存pip freeze的输出和CUDA信息。这样即使过半年回头看,也能快速定位到当时的环境和参数。
4.4 配置加载与覆盖
实际调参时,经常需要在默认配置基础上改几个值。我一般用命令行参数覆盖的方式,比如用argparse或者hydra。argparse简单直接,适合小项目;hydra功能更强,支持配置文件组合和命令行覆盖,但学习成本稍高。
不管用哪种方式,关键原则是:最终生效的配置必须完整落盘。不能只存你改的那几个参数,因为默认值可能随代码版本变化。我见过有人只存了lr=0.01,结果复现时默认batch size已经从32变成了64,指标对不上还找不到原因。
5. 实操流程:从零搭建可复现实验模板
5.1 项目初始化步骤
假设你现在要开一个新实验,我建议按这个顺序来:
- 建Git仓库:
git init,先提交一个初始版本,包含.gitignore。 - 建conda环境:
conda create -n exp python=3.9,激活后装PyTorch和其他依赖。 - 写set_seed函数:放到
utils/seed.py,确保所有随机源都覆盖。 - 定义配置dataclass:放到
configs/目录,按实验类型分文件。 - 写训练脚本骨架:开头调用set_seed,加载配置,建实验目录,存配置和环境信息。
- 跑一个最小实验:用少量数据跑通流程,确认配置和日志都正常落盘。
- 提交代码:
git add . && git commit -m "init reproducible template"。
这套流程走下来大概半小时,但后面每次实验都能省下大量排查时间。
5.2 环境信息采集脚本
我写了一个小脚本,每次实验开始前自动采集环境信息:
import sys import subprocess import torch def collect_env_info(): info = {} info['python'] = sys.version info['pytorch'] = torch.__version__ info['cuda_available'] = torch.cuda.is_available() if torch.cuda.is_available(): info['cuda_version'] = torch.version.cuda info['gpu_name'] = torch.cuda.get_device_name(0) info['cudnn_version'] = torch.backends.cudnn.version() try: info['pip_freeze'] = subprocess.check_output( ['pip', 'freeze']).decode('utf-8') except Exception as e: info['pip_freeze'] = str(e) return info把返回的字典存成env_info.json,和配置放在一起。这样复现时先对比环境信息,能快速排除版本差异导致的问题。
5.3 Git信息记录
代码版本这块,我一般记录三样东西:当前commit hash、当前分支、工作区是否有未提交改动。命令很简单:
git rev-parse HEAD > git_info.txt git branch --show-current >> git_info.txt git diff >> git_info.txt git status --short >> git_info.txt如果git diff输出不为空,说明工作区有未提交改动,复现时需要注意。我一般会在实验记录里标注"dirty",提醒自己这个结果对应的代码状态不是干净的commit。
5.4 一个完整的训练脚本开头
把上面这些串起来,训练脚本的开头大概长这样:
import json import os from dataclasses import asdict from utils.seed import set_seed from utils.env import collect_env_info from configs.exp_config import ExperimentConfig def main(): config = ExperimentConfig() set_seed(config.train.seed) os.makedirs(config.output_dir, exist_ok=True) with open(os.path.join(config.output_dir, 'config.json'), 'w') as f: json.dump(asdict(config), f, indent=2) env_info = collect_env_info() with open(os.path.join(config.output_dir, 'env_info.json'), 'w') as f: json.dump(env_info, f, indent=2) # 后面接数据加载、模型定义、训练循环 ...这段代码不长,但把可复现的三个核心都覆盖了:种子设置、配置落盘、环境记录。后面训练循环里再注意DataLoader的worker种子和cuDNN确定性,基本就稳了。
6. 常见问题与排查技巧实录
6.1 两次运行结果不一致的排查顺序
遇到结果对不上,我一般按这个顺序查:
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 随机种子 | 确认set_seed在所有随机操作前调用 | 忘了设numpy或random种子 |
| DataLoader | 检查num_workers和worker_init_fn | worker随机状态未固定 |
| cuDNN | 确认deterministic=True, benchmark=False | 卷积算法不确定 |
| 依赖版本 | 对比env_info.json | PyTorch或CUDA版本不同 |
| 数据顺序 | 检查数据集是否shuffle、shuffle种子 | 数据加载顺序不一致 |
| 硬件差异 | 对比GPU型号 | 不同GPU浮点运算有细微差异 |
这个表我贴在工位上,每次出问题先过一遍,大部分情况能快速定位。
6.2 确定性开启后报错的应对
开启cudnn.deterministic = True后,某些操作会直接报错,提示没有确定性实现。常见的几个:
torch.nn.functional.interpolate的某些模式,在旧版本CUDA上不确定。torch.scatter_add在CUDA上的原子操作。- 某些自定义CUDA核函数。
应对方式分两种:如果这个操作对结果影响不大,可以接受不确定性,但在实验记录里标注;如果影响大,就找替代实现,比如用torch.nn.functional.interpolate的nearest模式替代bilinear,或者用CPU版本的操作。
6.3 跨机器复现的注意事项
在自己机器上复现没问题,换台机器就出问题,这种情况通常和硬件或驱动有关。我遇到过几次:
- GPU型号不同:浮点运算的舍入方式可能有差异,长时间训练后指标会漂移。
- CUDA驱动版本不同:即使PyTorch版本一样,驱动版本不同也可能影响结果。
- CPU指令集不同:某些CPU支持AVX512,某些不支持,影响CPU上的运算结果。
跨机器复现时,我建议先跑一个短实验对比指标,确认差异在可接受范围内再跑完整实验。如果差异大,优先统一GPU型号和驱动版本。
6.4 配置归档的常见遗漏
配置归档最容易漏的是默认值。比如你代码里batch_size默认是32,实验时没改,配置里就没存,结果后来代码默认值改成64,复现时就对不上。解决办法是:存最终生效的完整配置,而不是只存改动项。用dataclass的asdict就能做到这一点,所有字段都会序列化。
另一个容易漏的是数据预处理参数。比如归一化的均值和方差、图像resize的尺寸、文本截断长度,这些如果硬编码在代码里,复现时很容易忽略。我一般把它们也放进配置类,统一管理。
7. 一些个人体会和后续扩展方向
这套可复现流程我用了大概两年,最大的感受是:前期多花半小时,后期省下无数小时。尤其是做对比实验的时候,每个实验的配置和环境都清清楚楚,写论文或者做汇报时直接引用,不用回头翻聊天记录找参数。
后续还可以往几个方向扩展。一是把配置管理和实验跟踪工具结合,比如用MLflow或者Weights & Biases,自动记录配置、指标和产物,省去手动落盘的步骤。二是把环境锁定做到容器级别,用Docker Compose或者Kubernetes管理多实验环境,适合团队协作场景。三是把随机种子和配置的校验做成CI流程的一部分,每次提交代码自动跑一个短实验,确认可复现性没有被破坏。
最后分享一个小技巧:如果你的实验周期比较长,建议每隔一段时间用相同的种子和配置跑一个"回归实验",对比指标是否一致。这能帮你及早发现环境漂移或者代码引入的不确定性问题,而不是等到最后写论文时才发现结果对不上。