
1. 实验可复现为什么值得单独拎出来讲但凡在PyTorch里跑过几个实验的人大概都经历过这种场景上周跑出来一个不错的结果这周想再验证一遍代码一行没改指标却对不上。排查半天最后发现是torch.manual_seed忘了设或者某个依赖库悄悄升了个小版本又或者当时用的学习率配置被覆盖了却没记录。这类问题不致命但极其消耗精力尤其是当你需要把结果交给别人复现、或者自己隔几个月回头再看的时候。PyTorch实验可复现这件事核心就三块随机种子、依赖锁定、配置归档。听起来简单但每一块都有不少细节容易翻车。随机种子不只是torch.manual_seed(42)一行就完事CUDA、cuDNN、DataLoader的worker、numpy、Python内置random这些都得管。依赖锁定也不只是pip freezePyTorch和CUDA的版本对应关系、Python版本、甚至操作系统层面的差异都会影响结果。配置归档更不是把参数存成JSON就万事大吉你得考虑怎么存、存什么、怎么加载、怎么和代码版本关联。这篇内容适合谁看如果你正在做深度学习实验需要反复调参、对比模型、写论文或者做项目交付那这些经验大概率能帮你省下不少排查时间。如果你刚搭好PyTorch环境还没被复现问题毒打过那提前把这套流程建起来后面会轻松很多。下面我按实际操作的顺序把这三块拆开讲穿插一些我踩过的坑和目前比较稳的做法。2. 随机种子不只是torch.manual_seed那一行2.1 哪些随机源需要固定很多人以为设了torch.manual_seed(42)就万事大吉结果发现两次运行结果还是不一样。原因很简单PyTorch实验里的随机性来源不止一个。我整理了一个清单每次开新项目都会对着检查一遍Python内置randomrandom.seed(42)有些数据预处理库会用。NumPynp.random.seed(42)数据增强、划分数据集时常用。PyTorch CPUtorch.manual_seed(42)。PyTorch CUDAtorch.cuda.manual_seed(42)和torch.cuda.manual_seed_all(42)多卡时后者更保险。cuDNNtorch.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(seed42): 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_fndef 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_size32, num_workers4, worker_init_fnworker_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 torch1.11.0cu113 torchvision0.12.0cu113 -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_factoryTrainConfig) model: ModelConfig field(default_factoryModelConfig) 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和diffenv_info.txt存pip freeze的输出和CUDA信息。这样即使过半年回头看也能快速定位到当时的环境和参数。4.4 配置加载与覆盖实际调参时经常需要在默认配置基础上改几个值。我一般用命令行参数覆盖的方式比如用argparse或者hydra。argparse简单直接适合小项目hydra功能更强支持配置文件组合和命令行覆盖但学习成本稍高。不管用哪种方式关键原则是最终生效的配置必须完整落盘。不能只存你改的那几个参数因为默认值可能随代码版本变化。我见过有人只存了lr0.01结果复现时默认batch size已经从32变成了64指标对不上还找不到原因。5. 实操流程从零搭建可复现实验模板5.1 项目初始化步骤假设你现在要开一个新实验我建议按这个顺序来建Git仓库git init先提交一个初始版本包含.gitignore。建conda环境conda create -n exp python3.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_okTrue) with open(os.path.join(config.output_dir, config.json), w) as f: json.dump(asdict(config), f, indent2) env_info collect_env_info() with open(os.path.join(config.output_dir, env_info.json), w) as f: json.dump(env_info, f, indent2) # 后面接数据加载、模型定义、训练循环 ...这段代码不长但把可复现的三个核心都覆盖了种子设置、配置落盘、环境记录。后面训练循环里再注意DataLoader的worker种子和cuDNN确定性基本就稳了。6. 常见问题与排查技巧实录6.1 两次运行结果不一致的排查顺序遇到结果对不上我一般按这个顺序查排查项检查方法常见原因随机种子确认set_seed在所有随机操作前调用忘了设numpy或random种子DataLoader检查num_workers和worker_init_fnworker随机状态未固定cuDNN确认deterministicTrue, benchmarkFalse卷积算法不确定依赖版本对比env_info.jsonPyTorch或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流程的一部分每次提交代码自动跑一个短实验确认可复现性没有被破坏。最后分享一个小技巧如果你的实验周期比较长建议每隔一段时间用相同的种子和配置跑一个回归实验对比指标是否一致。这能帮你及早发现环境漂移或者代码引入的不确定性问题而不是等到最后写论文时才发现结果对不上。