ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyTorch实验可复现性全攻略:从随机种子到依赖锁定

PyTorch实验可复现性全攻略:从随机种子到依赖锁定 做深度学习实验的人谁没吃过“复现”的亏上周跑的模型明明收敛到0.92今天重跑一遍变成了0.89你查遍代码也没发现哪里改过。或者更头疼的是师兄把代码发给你你兴致勃勃地跑起来结果Loss曲线跟他论文里的图完全对不上人家还一脸无辜地说“我这边没问题啊”。PyTorch实验的不可复现几乎是每个炼丹人的必经之痛。它和算法水平无关单纯是“随机性”在捣鬼。这里说的随机性不光是模型初始化那点权重而是贯穿整个训练流程的Python的random模块、NumPy的numpy.random、PyTorch的torch.manual_seed、CUDA层面的随机数生成器、cuDNN的算法选择、DataLoader的shuffle顺序……每一个环节都在悄悄引入随机因素。你以为自己在做科学实验实际上每次跑都在开盲盒。这篇文章我打算从实操角度把PyTorch实验可复现这件事彻底讲清楚。核心就三块随机种子怎么种才对、依赖环境怎么锁才稳、配置参数怎么归档才全。内容适合两类人——正在被“结果不稳定”折磨的同学以及想给团队建立一套规范实验流程的算法工程师。看完你至少能把“单卡训练”的可复现性做到95%以上剩下的5%靠硬件和玄学后面会说。1. 先搞清楚随机性到底藏在哪在动手设置种子之前有必要先做一次“随机性来源盘点”。很多人以为只要在脚本开头加上torch.manual_seed(0)就万事大吉结果该飘还是飘。问题就在于PyTorch的随机性不是一个点而是一条链路。1.1 训练流程里的五层随机源我习惯把随机性来源分成五个层面每一层都需要对应处理Python原生层random.seed()控制的模块比如某些数据预处理里用了random.shuffle()、random.sample()。如果代码里用了第三方库内部调用了Python的random这块也需要种子。NumPy层np.random.seed()图像增强、数据合成、数据集划分经常用到NumPy随机数。NumPy和Python的random是两套独立的随机数系统各管各的。PyTorch CPU与GPU层torch.manual_seed()统一管理CPU上的torch随机数torch.cuda.manual_seed_all()管理所有CUDA设备上的随机数。模型初始化、Dropout、数据采样的shuffle都会用到。cuDNN层不是随机数生成器而是算法选择器。cuDNN对同一操作可能有多套实现默认情况下它会在每次运行时“择优”导致结果波动。系统并行层比如DataLoader开启num_workers0时多进程的数据加载顺序会受操作系统调度影响多GPU的AllReduce求和顺序也会带来累积误差。这五层里面前三层可以通过加种子解决第四层需要改cuDNN配置第五层得靠工程手段去规避。后面第二、三节会分别讲。1.2 两个最容易踩的“隐性随机”除了上面五层还有两个新手特别容易忽略的地方我单独拎出来说。第一个是DataLoader的worker线程。PyTorch的DataLoader一旦设置num_workers0PyTorch 2.0之前不会自动给每个worker设置合理的随机种子结果就是每次读取数据时数据增强的顺序和效果都不一样。你明明往主进程里塞了种子但worker子进程是fork出来的各自的RNG状态是独立的。这个问题在PyTorch 2.0之后得到了改善不过为了兼容性和确定性我依然建议自己写一个worker_init_fn后文会给代码。第二个是cuDNN的benchmark模式。torch.backends.cudnn.benchmark True是很多人为了提速会开启的选项开启后cuDNN会在运行时多次测试不同算法选出最快的那个。问题是“最快”的判定受GPU当前占用、缓存状态影响不完全稳定。卷积实现一变输出就是十的负几次方的差异累积起来结果就偏了。复现实验时这个开关一定要关掉改成False或者torch.backends.cudnn.deterministic True。从工程角度来看这个环节有一个核心认知即使所有代码逻辑相同只要这些隐性的随机源没管住结果就不可控。所以“可复现”不是靠运气是要一步步把链路里的随机性全部拧死。2. 随机种子设置的正确姿势这节是重头戏直接给通用模板。我以单卡图像分类训练为例展示一套从启动到每个batch都可控的种子方案。2.1 全局种子模板与逐层解释下面这套代码是被我在生产环境反复验证过的我把它整理成套件式写法复制就能用import random import numpy as np import torch def set_seed(seed: int): 全局统一设置随机种子 random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 关闭cuDNN的自动调优锁定算法选择 torch.backends.cudnn.benchmark False torch.backends.cudnn.deterministic True # 这几个环境变量可以加强确定性适合追求极致复现的场景 import os os.environ[PYTHONHASHSEED] str(seed) os.environ[CUBLAS_WORKSPACE_CONFIG] :4096:8 # PyTorch 2.x提供更严格的可复现保证 if hasattr(torch, use_deterministic_algorithms): torch.use_deterministic_algorithms(True, warn_onlyTrue)逐行说明一下为什么这么写random.seed(seed)和np.random.seed(seed)分别锁住Python和NumPy的两套随机系统虽然PyTorch官方教程里常常省略这两个但只要你代码里任意模块碰过它们就逃不掉。torch.manual_seed(seed)在一次调用中会同时为CPU和GPU RNG设置种子对所有CUDA设备生效的torch.cuda.manual_seed_all(seed)建议保留防止后续接入多卡时翻车。torch.backends.cudnn.benchmark False用来关闭算法搜索deterministic True让cuDNN在同一个算法下用确定性实现。注意这两行要放在训练循环之前最好在脚本入口处。有一点要注意deterministic True会损失一点训练速度通常5%以内为了可复现这代价值得。PYTHONHASHSEED是Python解释器层的hash随机种子对集合、字典的迭代顺序有影响设置成固定值可以避免个别代码因为哈希随机导致的顺序变化。CUBLAS_WORKSPACE_CONFIG是一个不那么常见但必须知道的环境变量。cuBLAS运算比如矩阵乘法内部会用工作空间缓冲区如果不固定工作空间的配置某些操作可能是非确定性的。配置成:4096:8代表限制每次分配最多4MB内存工作空间官方文档给出的这个取值适合绝大多数场景。torch.use_deterministic_algorithms(True, warn_onlyTrue)是PyTorch 1.8之后提供的全局确定性开关它比cudnn.deterministic检查得更全面会拦截例如torch.Tensor.index_put_这类非确定性算子。warn_onlyTrue的意思是遇到不支持的算子只报警告不中断运行一般调试阶段先开着确认干净之后再决定要不要改成True强制报错。写到这里顺便说一句os.environ的环境变量设置必须在任何相关C扩展库被import之前生效所以严谨的做法是把set_seed函数放在所有import的下方并且全局变量设置的顺序不要乱否则某些模块初始化时已经读了默认值后续你再改也晚了。这也是很多人把种子设置放在main函数里、结果完全无效的一个原因。2.2 DataLoader的种子隔离与种子策略DataLoader的多进程worker是最常见的“种子漏网之鱼”补上这一段你的复现率能显著提升。PyTorch官方在2.0之后提供了一种洗牌机制但更稳妥的做法是自己写初始化函数import torch def worker_init_fn(worker_id: int): 每个DataLoader worker进程设置独立的随机状态 seed torch.initial_seed() % 2**31 # 这个seed来自主进程的RNG状态不同worker是不同的 import numpy as np import random np.random.seed(seed worker_id) random.seed(seed worker_id) # PyTorch的worker内部会自动使用torch.initial_seed() # 但NumPy和Python的random需要手动隔离然后在DataLoader里传入train_loader torch.utils.data.DataLoader( dataset, batch_size64, shuffleTrue, num_workers4, worker_init_fnworker_init_fn, generatortorch.Generator().manual_seed(42) )解释一下torch.Generator().manual_seed(42)的作用DataLoader的shuffle依赖内部一个全局生成器如果你不显式传入generator它就用自己的默认生成器而这个默认生成器的状态受进程启动时间影响。显式传入一个手动设种子的generator之后DataLoader的索引分配和采样顺序就完全固定了。再聊一个可能被忽略的点如果你的数据预处理比较重比如几十万张图要算各种统计量用了独立的预处理脚本脚本里用的随机源也要在脚本入口统一设置否则就算训练代码全锁死预处理那步还是有浮动。最好的做法是把预处理阶段的确定性也纳入洁癖范围比如直接把set_seed抽成一个公共模块训练和预处理都调用它。还有个关于种子的“策略问题”到底是用固定种子还是动态种子我自己的习惯是基础实验用固定种子比如0、42、2024方便和别人对齐调参时用一个基准种子跑通逻辑最后再换3到5个不同种子跑稳定性验证。固定种子能消除随机差异但也会掩盖极端情况所以论文里常说的“seed0结果最好”没什么参考价值真正可靠应该是多个种子下统计结果。这个意识比技巧更重要。2.3 什么时候确定性会失效实话说种子不是万能的。即便你严格遵守上面的全部步骤依然存在一些无法完全确定的场景我列举几个我踩过的多GPU分布式训练分布式DataParallelDDP的后端在不同设备间通信时梯度AllReduce的求和顺序可能因设备返回顺序变化而不同导致浮点数累加结果不一致。PyTorch官方已经让大部分操作具备确定性但通信层的微小差异仍然存在。混合精度训练开启torch.cuda.amp后某些回退操作会依赖GPU的原子操作而原子操作本身的完成顺序是不确定的。想解决只能把一些算子切换为确定性实现或者接受“结果近似一致”。CPU型号差异不同CPU的SIMD指令集不同导致同样的PyTorch CPU算子输出有细微差别这跟种子无关纯硬件差异。GPU型号差异老生常谈同一套代码在4090和A100上跑结果几乎不可能完全一致因为底层kernel有差异。所谓跨设备可复现基本指“同型号设备集群内”可复现。明白这些之后你对“可复现”的预期要做一个校正一套种子方案能保证的是“同一环境、同一硬件、同一代码”下可复现跨环境相似复现已经很不错了。有了这个预期再看别人论文里“完全复现”的说法心里就有数了。3. 依赖锁定不是只锁PyTorch版本那么简单随机种子解决的是“运行时漂移”依赖锁定解决的是“环境漂移”。很多时候你从GitHub拉下来一个仓库README写了Python 3.8 torch 1.9但自己环境是Python 3.10 torch 2.1跑出来的结果跟README的指标不一样第一反应是“代码有bug”实际罪魁祸首是依赖版本不对。3.1 从requirements.txt到精确锁定很多项目的requirements.txt长这样torch1.9.0 numpy1.20 opencv-python4.5这种写法在哲学上没有问题——给出大致范围方便兼容但对复现是灾难。因为意味着pip会解析到当前环境最新的满足版本而PyTorch每个patch版本之间的浮点行为都可能变化你的实验结果自然跟着飘。真正可复现的依赖锁定要精细到“精确版本哈希值”。第一步用pip freeze导出现有环境的精确版本pip freeze requirements-lock.txt这个文件长这样numpy1.24.3 opencv-python4.8.0.74 torch2.1.2cu121 torchvision0.16.2cu121注意torch2.1.2cu121这个本地版本号它表明torch是PyTorch官方预编译的CUDA 12.1版本从download.pytorch.org安装的。如果用conda装的版本号会带py3.10_cuda12.1_cudnn8.9.2_0这样的后缀不同安装渠道的wheel内部编译选项有差异这也是锁定依赖时容易忽略的“隐性差异”。第二步如果想更彻底给pip加--require-hashes参数或者用pip-tools生成完整哈希锁定文件pip-compile --generate-hashes requirements.in -o requirements-lock-hashed.txt生成的锁定文件里每个包都附带sha256哈希pip安装时会校验哈希从源头杜绝“同版本不同artifact”的问题。这种做法适合生产环境或者团队协作个人实验可以跳过哈希但要做到精确版本至少不难。3.2 conda环境导出与跨平台坑如果你用的是conda我强烈建议把环境导出成两层一层是给跨平台共享的environment.yml一层是给本机精确保留的conda env export。# 生成跨平台环境文件含精确版本、不含具体构建号 conda env export --from-history environment.yml # 生成本机完整环境快照含channel、build、依赖树 conda list -e conda-spec-file.txt conda env export environment-full.yml--from-history生成的文件只包含你手动安装过的包不包含自动解析出来的传递依赖更适合跨平台重建。而conda env export会把所有包的具体构建版本都导出来拿到别的机器上能不能重建取决于对方的操作系统和CUDA环境是否一致直接复制容易踩坑。这里有一个常见的坑conda导出的环境文件如果包含conda-forge和默认channel混用重建时channel优先级不同会导致同名字的包解析到不同版本。建议在environment.yml里显式写清楚channel顺序并且用conda-lock工具把不同平台分别生成锁定文件。我用的conda-lock命令参考如下conda-lock -f environment.yml -p linux-64 -p osx-arm64它会生成几个平台各自的lock文件指向固定的构建ID和URL。这样无论谁、在哪台机器上conda create -n myenv --file conda-lock.yml都能拉取完全相同的包。这个工具可能没那么流行但解决的是实打实的痛点。3.3 从“环境锁定”到“环境快照”依赖锁定只是“软件层面”的复制真正稳的是把整个环境做成不可变快照。两种主流方式Docker镜像把基础镜像、PyTorch、依赖、代码全部打进去docker build之后你得到一个hash标识的镜像跑到任何装了Docker的机器上都是同一套环境。这是团队协作里最稳妥的做法没有之一。虚拟环境归档conda-pack可以把conda环境直接打包成tar.gz在目标机器解压即用不需要重新解析依赖适合内网无网环境。conda pack -n myenv -o myenv.tar.gzDocker和conda-pack不是二选一我通常会打包一个镜像做长期保存再用conda-pack做快速分发。镜像保存的不只是环境还有操作系统层的基础库比如glibc版本、libcuda的链接方式这些是纯Python级锁定管不到的。论“什么时候需要做到这个地步”我的经验判断是——如果你在写论文、复现KPI或者要给别人交付项目Docker镜像和精确锁定的优先级非常高如果只是自己调试、跑着玩pip freeze加上固定种子就够了。过度锁定有时候反而拖慢迭代速度这个度自己把握。4. 配置归档把“跑出结果的条件”完整记下来随机种子管住了随机性依赖锁定管住了环境接下来是一套很多人会忽略的“考古学问题”三个月后你翻出这个项目的checkpoint想重新跑一次还能不能回忆起当时的batch size、学习率schedule、优化器超参、数据集划分版本配置归档就是为了解决这个“事后无法追溯”的问题。4.1 从argparse到结构化配置起步阶段大多数人的超参数散落在代码里和命令行参数里比如python train.py --lr 0.001 --batch_size 64 --epochs 30问题在于命令行参数并不会自动保存你只能靠终端残留记录或者Shell历史猜命令。更合理的做法是从一开始就用结构化配置把训练的所有超参数集中到一个文件里比如YAML# configs/exp_001.yaml seed: 42 exp_name: resnet50_baseline dataset: name: imagenet_subset root: /data/datasets/imagenet_subset num_classes: 100 train_transform: - type: RandomResizedCrop size: 224 - type: RandomHorizontalFlip training: epochs: 30 batch_size: 64 optimizer: type: SGD lr: 0.001 momentum: 0.9 weight_decay: 0.0001 scheduler: type: CosineAnnealingLR T_max: 30 model: name: resnet50 pretrained: false然后在训练脚本里用yaml加载再配合argparse只做少量覆盖import yaml import argparse parser argparse.ArgumentParser() parser.add_argument(--config, typestr, requiredTrue) parser.add_argument(--override, nargs*, default[]) args parser.parse_args() with open(args.config, r) as f: config yaml.safe_load(f) # 支持类似 --override training.lr0.0001 的命令行覆盖 for item in args.override: key, _, value item.partition() keys key.split(.) node config for k in keys[:-1]: node node[k] node[keys[-1]] type(node[keys[-1]])(value)这种方式有几个好处每轮实验对应一个配置文件谁跑过什么一看就知道覆盖项记录在实验脚本里不会出现“悄悄改了参数忘了记”的问题换参数试比在命令行里堆参数直观得多。如果项目规模再大点可以上Hydra、OmegaConf或者mlflow的flavor结构上是一样的。4.2 运行时归档让每次实验都自带“身份证”配置写进文件只是第一步关键是要在每次训练启动时把“这次运行的真实条件”完整存档。我目前的做法是写一个实验初始化模块每次运行都自动生成一个实验目录import os import json import shutil import datetime def make_experiment_dir(config, root./runs): exp_id datetime.datetime.now().strftime(%Y%m%d_%H%M%S) save_dir os.path.join(root, f{config[exp_name]}_{exp_id}) os.makedirs(save_dir, exist_okTrue) # 归档配置文件 shutil.copy(config[_config_path], os.path.join(save_dir, config.yaml)) # 归档git信息 import subprocess git_commit subprocess.check_output([git, rev-parse, HEAD]).decode().strip() git_diff subprocess.check_output([git, diff, --stat]).decode().strip() with open(os.path.join(save_dir, git_info.txt), w) as f: f.write(fcommit: {git_commit}\n) f.write(fdiff:\n{git_diff}\n) # 归档依赖锁定文件 shutil.copy(./requirements-lock.txt, os.path.join(save_dir, requirements-lock.txt)) # 归档种子信息如果有多个随机种子取平均这里写主种子 json.dump({seed: config[seed]}, open(os.path.join(save_dir, meta.json), w)) return save_dir每次跑完你都会得到一个类似runs/resnet50_baseline_20250122_153647/的目录里面放着一份配置、一份git提交信息、一份依赖锁定。将来要复现或者排查进这个目录通通都能找到。尤其要注意的是git_info.txt里记录了git diff的统计信息哪怕你改了代码没来得及commit这里也能留下“这个实验有一次未提交的修改”的证据这一条救过我很多次。还得提一下模型checkpoint的元信息。保存模型时别只存state_dict最好把当前epoch、optimizer_state、scheduler_state、config、甚至当时的随机种子和PyTorch版本一起存进去torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), config: config, seed: seed, }, os.path.join(save_dir, checkpoint_last.pth))这么做的好处是你checkpoint文件本身就是自解释的别人拿到这个模型文件也能直接还原训练上下文不依赖额外的说明文档。4.3 与实验管理平台结合runs/目录手工管理其实已经很能打了但如果你经常跑消融实验、需要横向对比不同参数的指标建议再叠一个实验管理平台。我在用的思路是这样用mlflow或wandb记录每次运行但底层依然是前面说的配置文件加实验目录平台只是锦上添花。以MLflow为例在训练脚本里加几行import mlflow mlflow.set_experiment(resnet-baseline) with mlflow.start_run(run_nameconfig[exp_name]): mlflow.log_params(config[training]) mlflow.log_metric(train_loss, train_loss) mlflow.log_metric(val_acc, val_acc) mlflow.log_artifact(args.config) mlflow.log_artifact(./requirements-lock.txt)平台的核心价值是查询和对比自动生成曲线省去自己翻日志、画图的功夫。但请注意平台记录的是“指标和参数”它不能替代“配置归档”因为代码版本、数据版本、commit、diff这些信息依然需要你自己在实验目录里存档。配置归档这个主题归根结底解决的是一个“实验即产品”的问题你每次跑模型产出不只是checkpoint还应该有一个完整的、可追溯的“实验包”。有了这个包复盘、复现、交接都不再是鬼故事。5. 常见问题与排查技巧实录这节把我在实际过程中反复遇到的坑和排查思路整理成清单按频率排序碰到无法复现的情况可以按表索骥。5.1 高频问题速查症状可能原因排查与解决同一脚本两次运行结果差一点数据加载线程种子未隔离、NumPy种子未设置确认set_seed全覆盖添加worker_init_fn加了种子还是有轻微浮动cuDNN benchmark开着、CUBLAS工作空间未固定cudnn.benchmarkFalse设置CUBLAS_WORKSPACE_CONFIGGPU/CPU结果差异很大CPU与GPU算子实现不同数据加载顺序受num_workers影响尽量在同一设备类型上对比固定num_workers0做对照实验复现别人项目指标对不上依赖版本不同、数据预处理不同检查requirements-lock文件对照Dataset源码确认预处理顺序换台机器结果就飘硬件型号差异、cuDNN版本差异使用Docker镜像在同一规格GPU上验证部分算子报“非确定性”警告代码里使用了PyTorch标记的非确定性算子查看具体算子文档替换为确定性实现或关闭use_deterministic_algorithms5.2 一个从“飘”到“稳”的实战排查案例我举一个具体经历。之前帮团队排查一个语义分割模型的复现问题同事反馈“同一份代码在三台机器上分别跑了三次MIoU差了0.8个百分点”。我们做了这些操作第一轮先检查依赖锁定。发现同事的requirements.txt里写的是torch1.12.0三台机器的torch版本分别是1.12.0、1.13.1、2.0.1。这基本就是主要嫌疑了PyTorch 2.0的很多默认行为都变了比如torch.compile的影响和算子融合策略直接导致结果差异。解决方法是统一用requirements-lock.txt固定到同一个版本。第二轮统一后还有0.2个点的差异。检查随机种子发现有人在训练前忘记调用set_seed另一台设了、一台没设。补上全局种子之后差异缩小到0.05个点以内。第三轮剩下的0.05差异来自cudnn.benchmark默认值。因为torch安装时默认benchmarkFalse但如果代码里某处写了cudnn.benchmarkTrue提升性能就会引入算法选择的不确定性。把这些全部关掉之后三台机器在同一batch数据上的loss终于完全一致了。这个案例的教训挺朴素复现性问题往往是多个因素累加而不是某一个点放大的。你只锁依赖、不改种子结果还是飘只改种子、不锁依赖飘得更冤枉。所以最好按“依赖 - 种子 - 算子确定性”的顺序逐层排查。5.3 复盘我的复现流程清单最后给一个我目前在用的标准流程照着做基本不会翻车初始化代码仓库要求所有代码变更走git实验开始前至少commit一次记录commit hash。用pip freeze requirements-lock.txt或conda env export导出环境提交到仓库。脚本入口调用统一的set_seed(seed)全局确定性开关全开。数据加载器指定worker_init_fn和generator并固定num_workers。配置使用YAML文件集中管理不靠散装argparse。每次训练启动自动创建实验目录归档配置文件、git信息、依赖锁定、种子。模型checkpoint里写入完整上下文epoch、config、seed、版本。多折实验固定种子列表比如seeds[0, 1, 2]跑完统一汇总统计量。关键实验额外打一份Docker镜像防止环境被后续改动悄悄腐蚀。这套流程写出来看着简单真正做到位的人不多。说实话我刚开始做可复现时也觉得麻烦不就是跑个实验吗何必存这么多东西。但后来一次次被“复现不出来”坑到加班之后才明白前期多花那十分钟做归档后期至少省下几个小时的排查时间这是一笔非常划算的投入。再补充一个实际使用起来很顺手的小技巧如果你用的是Jupyter Notebook做实验尽量把每个cell的随机种子一起写进去并且不要把np.random.seed和torch.manual_seed写在不同cell里因为Notebook的cell执行顺序不可控你根本不知道最终谁先执行了。最稳的是整个Notebook的第一个cell放统一的种子设置后面所有cell都依赖它的状态。虽然这个习惯违反“每个cell自包含”的洁癖但在复现优先的场景下很实用。PyTorch实验可复现这件事本质上就是和“不确定性”对抗。我们把随机种子当第一道防线把依赖锁定当第二道防线把配置归档当第三道防线三层防线都拉起来你的实验结果才具备说服力自己在迭代的时候也才不会被互相对不上的数字消耗精力。实操中你会慢慢发现这套流程坚持下来除了数字稳定整个人的实验效率也会高一大截——再也不用为了“上次是怎么调出来的”这种问题烦躁了。
返回列表