
1. 为什么你的PyTorch实验总是“跑一次就废”做过深度学习实验的人大概率都遇到过这种场景上周跑出来一个不错的结果这周想复现一下代码一行没改结果指标掉了好几个点。于是开始怀疑人生——是学习率的问题是数据加载顺序变了还是显卡驱动偷偷升级了折腾一整天最后发现是某个随机种子没固定住或者依赖库版本悄悄从1.13升到了2.0。这不是个别现象。PyTorch的灵活性是把双刃剑它给了你极大的自由度去搭建模型、控制训练流程但同时也意味着任何一处未受控的随机性、任何一个未锁定的依赖版本、任何一份未归档的配置文件都可能成为实验不可复现的元凶。这篇文章要聊的就是怎么把PyTorch实验的可复现性做到位。核心围绕三件事随机种子的完整固定、依赖版本的精确锁定、配置文件的系统归档。不管你是刚搭好PyTorch环境的新手还是已经跑过几十组实验的老手这三件事如果没做到位你的实验结果就永远处于“薛定谔”状态——在你重新运行之前你永远不知道它能不能复现。我自己的经历是曾经有一个对比实验跑了三轮每轮结果都不一样排查了两天才发现是DataLoader的worker初始化种子没有设置导致每个epoch的数据增强结果不一致。这种坑踩过一次就再也不想踩第二次。下面把我自己总结的一套可复现方案完整拆开讲从原理到代码到避坑尽量让各位少走弯路。2. 随机种子不只是torch.manual_seed那么简单2.1 PyTorch里到底有多少个随机源很多人以为固定随机种子就是调一下torch.manual_seed(42)就完事了。实际上一个典型的PyTorch训练流程中涉及的随机源远不止这一个。我整理了一下常见的随机源随机源所属模块影响范围Python内置randomrandom数据预处理、文件打乱等NumPy随机numpy.random数据增强、数组操作PyTorch CPU随机torch.manual_seed权重初始化、DropoutPyTorch GPU随机torch.cuda.manual_seed_allGPU上的所有随机操作cuDNN算法选择torch.backends.cudnn卷积算法的确定性DataLoader workerworker_init_fn多进程数据加载的随机性环境变量PYTHONHASHSEEDPython哈希随机化你看光随机源就有七类。只固定其中一两个剩下的照样会让你的实验产生不可控的波动。2.2 一套完整的种子固定方案下面是我在实际项目中反复使用的一套种子固定函数覆盖了上述所有随机源import os import random import numpy as np import torch def set_seed(seed42): 固定所有随机源的种子确保实验可复现 # Python内置随机 random.seed(seed) # NumPy随机 np.random.seed(seed) # PyTorch CPU随机 torch.manual_seed(seed) # PyTorch GPU随机所有GPU torch.cuda.manual_seed(seed) torch.cuda.manual_seed_all(seed) # Python哈希种子影响set/dict的遍历顺序 os.environ[PYTHONHASHSEED] str(seed) # cuDNN确定性设置 torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False # 部分PyTorch版本需要额外设置 torch.backends.cudnn.enabled True os.environ[CUBLAS_WORKSPACE_CONFIG] :4096:8这个函数在训练脚本的最开始调用一次即可。注意CUBLAS_WORKSPACE_CONFIG这个环境变量它是CUDA 10.2之后引入的用于控制cuBLAS的工作空间大小设置成:4096:8可以避免因为工作空间分配不同导致的非确定性结果。这个细节很多教程都不会提但在某些矩阵运算密集的模型里不设置它结果就是会飘。2.3 DataLoader的worker种子问题这是最容易被忽略的一个坑。当你设置num_workers 0时每个worker进程会独立地产生随机数。如果你没有给每个worker指定种子那么每次运行时的数据增强结果都可能不同。解决方案是使用worker_init_fn参数def worker_init_fn(worker_id): 为每个DataLoader worker设置独立但确定的种子 worker_seed torch.initial_seed() % 2**32 np.random.seed(worker_seed) random.seed(worker_seed) train_loader DataLoader( dataset, batch_size32, shuffleTrue, num_workers4, worker_init_fnworker_init_fn, generatortorch.Generator().manual_seed(42) # 控制shuffle的随机性 )这里有两个关键点worker_init_fn保证每个worker内部的随机操作是确定的generator参数保证主进程分配给worker的数据顺序是确定的。两个都设置好DataLoader的随机性才算完全受控。注意torch.initial_seed()返回的是当前worker的基础种子它由主进程的种子派生而来。所以只要主进程种子固定每个worker的种子也就是固定的。2.4 确定性模式的代价开启cudnn.deterministic True和cudnn.benchmark False之后训练速度通常会有5%到20%的下降。原因很简单cuDNN默认会自动搜索最快的卷积算法而不同的算法可能使用不同的累加顺序导致浮点结果有微小差异。强制确定性就意味着放弃这种自动搜索使用固定的算法。我的建议是在实验调试和论文实验阶段开启确定性模式在最终大规模训练时可以根据需要关闭。但即使关闭也要在配置文件中记录这个选择否则别人复现时不知道你当时用的什么设置。另外PyTorch从1.8版本开始提供了torch.use_deterministic_algorithms(True)这个更严格的确定性选项。它会强制所有操作使用确定性算法如果某个操作没有确定性实现会直接报错而不是静默地产生不确定结果。这个选项更安全但对某些自定义算子可能不兼容需要根据实际情况选择。3. 依赖锁定别让pip install毁掉你的复现3.1 为什么requirements.txt不够用大部分人的做法是pip freeze requirements.txt然后在另一台机器上pip install -r requirements.txt。看起来没问题但实际上有几个致命缺陷第一pip freeze导出的是当前环境中所有包包括那些跟你的项目无关的依赖。这会导致环境臃肿而且增加了版本冲突的概率。第二它只记录了顶层包的版本不记录依赖树。比如你装了torch1.13.1它依赖numpy1.20但pip freeze只会记录你当前环境中的numpy版本不会记录这个版本约束关系。当你在另一台机器上安装时pip可能会解析出不同的依赖版本组合。第三它不区分直接依赖和间接依赖。当你需要升级某个直接依赖时无法判断哪些间接依赖会受影响。3.2 用pip-tools做精确锁定我的做法是使用pip-tools这个工具它可以把直接依赖和间接依赖分开管理# 安装pip-tools pip install pip-tools # 编写requirements.in只写直接依赖 cat requirements.in EOF torch1.13.1 torchvision0.14.1 numpy1.24.3 pandas2.0.2 scikit-learn1.2.2 tensorboard2.13.0 EOF # 编译生成锁定的requirements.txt pip-compile requirements.in --output-file requirements.txt # 在目标环境中精确安装 pip-sync requirements.txtpip-compile会解析所有依赖关系生成一个包含所有包及其精确版本的requirements.txt并且会标注每个包的来源哪个顶层包引入了它。pip-sync则会确保你的环境与requirements.txt完全一致——多装的包会被卸载少装的包会被安装。3.3 PyTorch版本与CUDA的对应关系PyTorch的版本选择比一般Python包要复杂因为它跟CUDA版本紧密绑定。你需要在requirements.in中明确指定CUDA版本--extra-index-url https://download.pytorch.org/whl/cu118 torch2.0.1cu118 torchvision0.15.2cu118这里的cu118表示CUDA 11.8。如果你用的是CUDA 12.1就改成cu121。这个后缀非常重要因为不同CUDA版本编译的PyTorch二进制是不兼容的。另外Python版本和PyTorch版本也有对应关系。比如PyTorch 2.0要求Python 3.8PyTorch 1.11支持Python 3.7到3.10。在锁定依赖时Python版本本身也应该被记录下来。我通常会在项目根目录放一个.python-version文件配合pyenv使用。3.4 记录系统级依赖Python包只是依赖的一部分。GPU驱动版本、CUDA版本、cuDNN版本、甚至操作系统版本都会影响实验结果。我习惯在项目里放一个environment.md文件记录以下信息# 系统信息 uname -a # GPU信息 nvidia-smi # CUDA版本 nvcc --version # cuDNN版本 python -c import torch; print(torch.backends.cudnn.version()) # Python版本 python --version # PyTorch版本 python -c import torch; print(torch.__version__)这些信息看起来琐碎但当你在不同机器之间迁移实验时它们能帮你快速定位环境差异。我曾经遇到过一个情况同样的代码和PyTorch版本在一台机器上能复现在另一台上不行最后发现是cuDNN版本差了0.1导致某个卷积操作的数值精度不同。提示如果你的实验涉及多卡训练还需要记录NCCL版本和GPU拓扑结构nvidia-smi topo -m的输出。这些在分布式训练中会影响梯度同步的顺序进而影响最终结果。4. 配置归档让每次实验都有据可查4.1 配置文件该记什么随机种子和依赖版本固定好之后还有一个关键环节把每次实验的所有配置信息完整归档。这里的“配置”不只是模型超参数还包括数据配置、训练配置、环境配置等。我通常把配置分成几个层次模型配置网络结构参数、层数、隐藏维度、激活函数类型等训练配置学习率、batch size、epoch数、优化器类型、调度器参数等数据配置数据集路径、划分比例、预处理方式、数据增强参数等环境配置随机种子、设备信息、PyTorch版本、CUDA版本等运行时配置训练开始时间、训练时长、GPU显存占用峰值等这些信息如果散落在代码各处时间一长根本记不住。我的做法是用一个统一的配置管理方案把所有配置集中到一个地方。4.2 用YAML dataclass管理配置我比较推荐的方式是用YAML文件定义配置然后用Python的dataclass做类型校验# configs/experiment_001.yaml experiment: name: resnet50_cifar10_baseline seed: 42 description: ResNet50 baseline on CIFAR-10 model: name: resnet50 num_classes: 10 pretrained: false training: batch_size: 128 epochs: 200 optimizer: sgd lr: 0.1 momentum: 0.9 weight_decay: 5e-4 scheduler: cosine warmup_epochs: 5 data: dataset: cifar10 root: /data/cifar10 num_workers: 4 augmentation: random_crop: true crop_padding: 4 horizontal_flip: true color_jitter: 0.2 environment: device: cuda cudnn_deterministic: true cudnn_benchmark: false然后在代码中用dataclass加载和校验from dataclasses import dataclass, field from typing import List, Optional import yaml dataclass class TrainingConfig: batch_size: int 128 epochs: int 200 optimizer: str sgd lr: float 0.1 momentum: float 0.9 weight_decay: float 5e-4 scheduler: str cosine warmup_epochs: int 5 dataclass class ExperimentConfig: experiment: dict model: dict training: TrainingConfig data: dict environment: dict def load_config(path: str) - ExperimentConfig: with open(path, r) as f: raw yaml.safe_load(f) return ExperimentConfig( experimentraw[experiment], modelraw[model], trainingTrainingConfig(**raw[training]), dataraw[data], environmentraw[environment] )这样做的好处是配置有类型检查写错了会在加载时报错而不是训练到一半才发现配置可以继承和覆盖方便做消融实验配置可以序列化保存方便归档。4.3 实验目录结构设计每次实验都应该有独立的输出目录我通常按以下结构组织experiments/ └── 2024-01-15_resnet50_cifar10_baseline/ ├── config.yaml # 完整配置快照 ├── requirements.txt # 依赖锁定文件 ├── environment.md # 系统环境信息 ├── git_commit.txt # 代码版本 ├── train.log # 训练日志 ├── metrics.csv # 训练指标 ├── checkpoints/ # 模型权重 │ ├── best.pth │ └── last.pth └── tensorboard/ # TensorBoard日志关键点是每次实验开始时自动创建目录并保存配置快照。这样即使后来修改了配置文件历史实验的配置也不会丢失。我见过太多人因为覆盖了配置文件导致之前的实验无法复现。4.4 代码版本追踪配置文件归档了但如果代码变了实验照样复现不了。所以还需要记录代码版本。最简单的方式是记录Git commit hashimport subprocess def get_git_commit(): try: return subprocess.check_output( [git, rev-parse, HEAD] ).decode(ascii).strip() except subprocess.CalledProcessError: return unknown # 在实验开始时保存 with open(os.path.join(exp_dir, git_commit.txt), w) as f: f.write(get_git_commit())如果实验时有未提交的修改还应该记录git diff的输出。更严格的做法是使用git stash或者创建一个临时分支来保存实验时的代码状态。我一般会在实验开始前确保所有修改都已提交避免出现“实验时的代码和仓库里的代码不一致”这种情况。注意如果你的项目包含敏感数据或私有代码不要把Git仓库地址和commit hash公开。在归档时可以用哈希值代替具体的commit信息或者只在内部记录。5. 实操全流程从零搭建一个可复现的实验框架5.1 项目初始化假设我们要做一个CIFAR-10上的图像分类实验从零开始搭建可复现框架。首先创建项目结构mkdir reproducible-pytorch-experiment cd reproducible-pytorch-experiment mkdir -p configs experiments src data checkpoints然后初始化Git仓库和Python虚拟环境git init python -m venv venv source venv/bin/activate # Linux/Mac # venv\Scripts\activate # Windows5.2 依赖安装与锁定创建requirements.in文件写入直接依赖--extra-index-url https://download.pytorch.org/whl/cu118 torch2.0.1cu118 torchvision0.15.2cu118 numpy1.24.3 pyyaml6.0 tensorboard2.13.0 tqdm4.65.0然后编译和安装pip install pip-tools pip-compile requirements.in --output-file requirements.txt pip-sync requirements.txt安装完成后验证一下python -c import torch; print(torch.__version__); print(torch.cuda.is_available())如果输出2.0.1cu118和True说明环境配置正确。5.3 种子固定与训练脚本创建src/train.py把前面讲的种子固定、配置加载、实验目录创建都整合进去import os import sys import random import subprocess import numpy as np import torch import yaml from datetime import datetime from dataclasses import dataclass 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 os.environ[CUBLAS_WORKSPACE_CONFIG] :4096:8 def create_experiment_dir(config): timestamp datetime.now().strftime(%Y-%m-%d_%H-%M-%S) exp_name f{timestamp}_{config[experiment][name]} exp_dir os.path.join(experiments, exp_name) os.makedirs(exp_dir, exist_okTrue) os.makedirs(os.path.join(exp_dir, checkpoints), exist_okTrue) os.makedirs(os.path.join(exp_dir, tensorboard), exist_okTrue) return exp_dir def save_config_snapshot(config, exp_dir): with open(os.path.join(exp_dir, config.yaml), w) as f: yaml.dump(config, f, default_flow_styleFalse) def save_git_info(exp_dir): try: commit subprocess.check_output( [git, rev-parse, HEAD] ).decode(ascii).strip() diff subprocess.check_output( [git, diff, HEAD] ).decode(ascii) except subprocess.CalledProcessError: commit unknown diff with open(os.path.join(exp_dir, git_commit.txt), w) as f: f.write(fcommit: {commit}\n\n) f.write(diff:\n) f.write(diff) def main(): # 加载配置 with open(configs/experiment_001.yaml, r) as f: config yaml.safe_load(f) # 固定种子 set_seed(config[experiment][seed]) # 创建实验目录 exp_dir create_experiment_dir(config) # 保存配置快照 save_config_snapshot(config, exp_dir) save_git_info(exp_dir) # 保存环境信息 with open(os.path.join(exp_dir, environment.md), w) as f: f.write(fPyTorch: {torch.__version__}\n) f.write(fCUDA available: {torch.cuda.is_available()}\n) f.write(fCUDA version: {torch.version.cuda}\n) f.write(fcuDNN version: {torch.backends.cudnn.version()}\n) f.write(fPython: {sys.version}\n) print(fExperiment directory: {exp_dir}) # 后续训练逻辑... if __name__ __main__: main()5.4 验证可复现性框架搭好之后最重要的一步是验证它真的可复现。我的做法是用同一个配置跑两次实验对比两次的loss曲线和最终指标。# 第一次运行 python src/train.py --config configs/experiment_001.yaml # 第二次运行使用相同的种子 python src/train.py --config configs/experiment_001.yaml然后对比两个实验目录下的metrics.csv如果每个epoch的loss差异在1e-6以内说明可复现性达标。如果差异较大就需要排查是哪个随机源没有固定住。我实测下来按照上面这套方案配置在单卡训练场景下两次运行的loss曲线几乎完全重合。多卡训练场景下由于NCCL的通信顺序可能有微小差异loss会有1e-4级别的波动但最终指标如准确率通常是一致的。5.5 消融实验的配置管理做研究经常需要跑消融实验比如对比不同学习率、不同数据增强策略的效果。用YAML配置继承可以很方便地管理# configs/base.yaml experiment: name: base seed: 42 training: batch_size: 128 epochs: 200 optimizer: sgd lr: 0.1 momentum: 0.9 weight_decay: 5e-4# configs/lr_0.01.yaml defaults: - base experiment: name: lr_0.01 training: lr: 0.01加载时先加载base配置再用子配置覆盖。这样每个消融实验只需要写差异部分减少了出错概率也让配置更清晰。6. 常见问题与排查技巧实录6.1 排查清单结果不可复现时该查什么当你发现实验结果无法复现时按照以下顺序排查排查项检查方法常见问题随机种子检查set_seed是否在所有随机操作之前调用种子设置晚了部分初始化已使用默认随机DataLoader检查worker_init_fn和generator是否设置多worker场景下数据增强不一致cuDNN检查deterministic和benchmark设置benchmarkTrue导致算法选择不确定依赖版本对比两次实验的requirements.txt某个包自动升级了数据顺序检查数据集是否按固定顺序加载文件系统遍历顺序不一致浮点精度检查是否使用了混合精度训练AMP的缩放因子可能导致差异多卡通信检查NCCL版本和GPU拓扑不同拓扑下梯度同步顺序不同6.2 那些年我踩过的坑坑一Jupyter Notebook的隐藏状态。在Notebook里做实验时单元格的执行顺序会影响结果。你可能先运行了数据加载又运行了模型定义然后回头修改了某个参数重新运行——这时候变量的状态是混乱的。我的建议是实验代码一定要写成独立的.py脚本Notebook只用来做探索性分析和结果可视化。坑二torch.backends.cudnn.benchmark的陷阱。这个参数默认是False但很多人为了加速会把它设成True。问题是benchmarkTrue时cuDNN会在第一次运行时自动搜索最优算法而搜索过程本身可能引入随机性。更麻烦的是如果输入尺寸变化比如不同的batch size它会重新搜索导致结果不一致。所以做可复现实验时这个参数必须设为False。坑三NumPy版本差异导致的数值精度问题。NumPy 1.24和1.25在某些线性代数运算上使用了不同的底层实现可能导致浮点结果有微小差异。这种差异在单次运算中可能只有1e-8但经过几十层网络传播后可能放大到1e-3级别。所以依赖锁定不只是锁PyTorchNumPy、SciPy这些基础库也要锁。坑四环境变量PYTHONHASHSEED设置时机。这个环境变量必须在Python进程启动之前设置才有效。如果你在脚本内部用os.environ设置对当前进程的哈希行为没有影响。正确的做法是在启动脚本时设置PYTHONHASHSEED42 python train.py。坑五预训练权重的加载。如果你使用了预训练模型要确保两次实验加载的是同一份权重文件。有时候下载的预训练权重会被缓存覆盖导致两次加载的权重不同。建议把预训练权重文件也纳入版本管理或者至少记录其MD5值。6.3 快速自查脚本我写了一个简单的自查脚本在实验开始前运行可以快速检查环境是否满足可复现要求def check_reproducibility(): 检查当前环境是否满足可复现性要求 issues [] # 检查种子 if os.environ.get(PYTHONHASHSEED) is None: issues.append(PYTHONHASHSEED未设置) # 检查cuDNN if not torch.backends.cudnn.deterministic: issues.append(cudnn.deterministic未开启) if torch.backends.cudnn.benchmark: issues.append(cudnn.benchmark已开启建议关闭) # 检查CUBLAS if os.environ.get(CUBLAS_WORKSPACE_CONFIG) is None: issues.append(CUBLAS_WORKSPACE_CONFIG未设置) # 检查依赖文件 if not os.path.exists(requirements.txt): issues.append(requirements.txt不存在) # 检查Git状态 try: diff subprocess.check_output([git, diff, --stat]).decode() if diff.strip(): issues.append(存在未提交的代码修改) except subprocess.CalledProcessError: issues.append(当前目录不是Git仓库) if issues: print(可复现性检查发现问题) for issue in issues: print(f - {issue}) else: print(可复现性检查通过) return len(issues) 0这个脚本可以在训练开始前自动运行如果有问题就打印警告。我通常把它集成到训练脚本的入口处确保每次实验都是在受控环境下开始的。6.4 跨机器复现的注意事项在一台机器上能复现不代表在另一台上也能。跨机器复现时除了上述检查项还需要注意GPU型号差异不同型号的GPU如A100 vs V100在浮点运算的实现上可能有细微差异导致结果不完全一致。如果要求严格复现最好使用同型号GPU。驱动版本GPU驱动版本会影响CUDA的运行行为。建议记录nvidia-smi的完整输出。CPU型号某些操作如数据预处理在CPU上执行不同CPU的浮点运算实现可能不同。文件系统不同文件系统返回的文件遍历顺序可能不同影响数据集加载顺序。建议在数据集类中显式排序文件列表。如果跨机器复现时发现结果有差异先对比两边的environment.md找出差异项然后逐一排查。大多数情况下差异都来自依赖版本或CUDA/cuDNN版本的不同。7. 一些额外的经验之谈上面讲的都是“术”的层面——具体怎么设置种子、怎么锁定依赖、怎么归档配置。但可复现性还有一个“道”的层面习惯。我的经验是可复现性不是某个环节做对了就行而是整个实验流程都要有可复现的意识。从你写下第一行代码开始就要想着“这个实验三个月后我还能不能跑出来”。具体来说每次实验前跑一遍自查脚本确认环境没问题实验目录用时间戳命名不要用“final”“best”这种模糊的名字配置文件不要手动改用脚本生成或继承训练日志要完整保存包括每个epoch的loss、学习率、耗时模型权重保存时附带配置信息避免加载时对不上还有一个很实用的技巧在训练脚本里加一个--resume参数支持从checkpoint恢复训练。这样即使训练中断也能从上次的状态继续而且恢复后的训练轨迹应该和未中断时一致。这个功能在调试阶段特别有用因为你可以随时暂停、修改代码、再恢复而不需要从头开始跑。最后说一个我自己的教训。曾经有一个项目我跑了十几组实验结果整理数据时发现有两组实验的配置文件被覆盖了导致无法确认当时用的具体参数。从那以后我养成了一个习惯每次实验的配置快照保存三份——一份在实验目录一份在项目根目录的configs/history/下一份提交到Git仓库。三份备份总不会全丢。可复现性是深度学习实验的底线。没有可复现性所有的对比、分析、结论都站不住脚。花半天时间把上面这套框架搭好后面能省下无数个排查问题的小时。这笔账怎么算都划算。