
调试一个模型时我通常喜欢把同一个训练脚本连续跑三遍看结果稳不稳。如果三次跑出来的指标有明显波动那说明实验已经处于一种“随机态”里问题不一定在模型结构而在整个实验链路中掺杂了太多不受控变量。今天这篇想聊的是 PyTorch 里做可复现实验的一套组合拳随机种子、依赖锁定和配置归档。这三件事分别对应了三个层次的不可控——随机源、依赖环境、配置状态。把它们管住实验结果才能真正说清楚。这篇内容适合两类人一类是做论文复现或短期竞赛需要让别人能在同一套代码上得到相近结果另一类是项目进入稳定期想避免“改了一行代码训练效果说不清是被改动影响还是被环境波动影响”这种情况。你会看到的不只是几个 API 调用而是一条能落地的 SOP。1. 先认清可复现的敌人随机、环境和硬件1.1 随机性制造的“假性差异”PyTorch 训练流程里藏着大量随机源。最显眼的是模型初始化时nn.Module里的参数比如torch.nn.Linear的权重初始化默认就从随机分布里采样其次是DataLoader里对数据集顺序的 shuffle每个 epoch 的样本顺序都不同还有Dropout、DropPath这类自带随机性的算子训练阶段每次前向传播都会生成不同的 mask。很多人以为随机源只有模型初始化其实陷阱更多。优化器本身一般不引入随机但反向传播里的梯度累加顺序、CUDA 核函数的执行顺序会带来细微的数值差异。这些随机源加在一起很多时候只让指标在小数点后第三四位浮动看起来“差不多”但遇到小数据集、长训练、敏感的超参数组合波动可能大到让你误判某个 trick 的真实收益。给新手一个很直观的检测方法先写一个脚本打印torch.initial_seed()连续跑两次。如果两次打印出的 seed 不同说明连最基础的随机入口都没锁住。如果 seed 相同但训练结果仍不稳定再往下查别的随机源。很多“实验玄学”其实就是这个环节没做干净。1.2 环境依赖漂移是看不见的大头代码没变结果却变了很多时候问题出在依赖。你上个月用的torch1.13.0现在环境里装的是torch2.1.2某个算子的底层实现可能变了甚至默认的浮点累加策略都不同。这在 PyTorch 版本跨度大时尤其明显比如旧版torch.sort和torch.topk在 CUDA 上的行为和新版就存在细微差异。依赖不只是 PyTorch 本体。numpy的随机算法偶尔也会调整scikit-learn的某些评估函数在不同版本下处理边界的方式不完全一致transformers这类更新频繁的库更是“一天一个样”。更隐蔽的是系统层依赖CUDA、cuDNN、驱动版本都会影响 GPU 上卷积、矩阵乘法的实现选择最后体现在浮点结果上。打个比方你让两个厨师用同一份菜谱做番茄炒蛋食材一样、步骤一样但一个用猛火灶一个用平底锅出锅的熟度、汁水就会不一样。依赖锁定要做的就是把“菜谱”“食材”“灶具型号”全部记录下来。1.3 硬件的确定性边界即使种子、依赖都锁了不同机器之间也可能有差异。GPU 硬件的指令调度、L2 cache 行为、多核并行时浮点加法顺序都可能不同。比如同一份代码在 A100 和 RTX 4090 上跑结果在若干浮点位上不一致这并不代表你代码写错了。所以在谈“可复现”时得先定一个现实目标在同一台机器、同一个环境、同一份数据下结果要能稳定复现跨机器时我们追求的是“合理一致性”也就是偏差在可接受范围内方向性结论一致。把预期摆正后面每步操作才有意义。2. 随机种子管理不是调一个 set_seed(42) 就完事2.1 五个随机源缺一个都不行PyTorch 里至少存在五个独立的随机源Python 内置的random、NumPy 的np.random、PyTorch CPU 端的torch.initial_seed、CUDA 端的torch.cuda.manual_seed以及 cuDNN 内部的一些 heuristics。很多开源代码只写了一句torch.manual_seed(seed)这在 CPU 小模型上可能够用一旦换到 GPU 多卡或者用到第三方库照样复现不了。我一般在项目里放一段固定的工具函数每次训练入口先调用它import os import random import numpy as np import torch def set_all_seed(seed: int 42) - None: # 基础随机源 random.seed(seed) np.random.seed(seed) # PyTorch 的 CPU 与 GPU 随机源 torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 某些预处理库会读这一项避免哈希随机化 os.environ[PYTHONHASHSEED] str(seed)torch.cuda.manual_seed_all和torch.cuda.manual_seed的区别值得说明。前者会给所有当前可见的 GPU 设置种子适合多卡场景如果你只用单卡调用前者也属于顺手的事没有副作用。还有一个常被忽略的是os.environ[PYTHONHASHSEED]它必须在 Python 进程启动早期设置才生效因为你一旦 import 了某些容器类型字符串哈希顺序就会固定下来。所以更稳妥的做法是在训练脚本最顶部就完成这些赋值而不是等 import 完一堆库再设置。2.2 CUDA 与 cuDNN 的确定性开关只种种子还不够CUDA 上许多算子默认是不具备确定性执行语义的。cuDNN 为了性能会从多个卷积算法里动态挑选当前最快的也就是 benchmark 模式。torch.backends.cudnn.benchmark True时每次运行时可能选中不同算法结果自然有波动。可复现场景下建议在set_all_seed后追加torch.backends.cudnn.benchmark False torch.backends.cudnn.deterministic Truecudnn.deterministic True会让 cuDNN 选择确定性版本实现但这类实现往往不是最快的可能带来训练变慢。如果你的数据是固定分辨率、batch size 固定在成熟实验里开 benchmark 也没有太大问题但在追求可复现的论文实验或对比实验里优先牺牲一小部分速度去换确定性。从 PyTorch 1.8 之后还提供了一个更严格的接口torch.use_deterministic_algorithms(True, warn_onlyTrue)。它会检查当前执行的所有算子是否支持确定性模式遇到不支持的就抛异常或警告。try: torch.use_deterministic_algorithms(True, warn_onlyTrue) except TypeError: torch.use_deterministic_algorithms(True)个人建议是在正式实验里打开它但把warn_only设成True先把所有不支持的算子扫出来再做取舍。真要是某个自定义 CUDA 算子没有确定性实现硬开只会直接崩掉。2.3 DataLoader 的随机源要单独处理DataLoader的 shuffle 随机源独立于全局torch.manual_seed。它内部会创建一个torch.Generator每次 worker 进程会从全局随机数生成器里取一个初始种子。如果你希望同一个数据集顺序在每次实验里完全一致应该显式给DataLoader传 generatorg torch.Generator() g.manual_seed(42) dataloader DataLoader( dataset, batch_size64, shuffleTrue, num_workers4, generatorg, )另外num_workers 0时每个 worker 进程加载数据也会用到独立随机源。为了让每个 worker 的增强流程稳定一个稳妥做法是在worker_init_fn里重新设置随机种子种子用torch.initial_seed()衍生这样每个 worker 内部产生一组确定且互不冲突的伪随机序列。def worker_init_fn(worker_id): torch.manual_seed(torch.initial_seed() worker_id)这次设置有多重要我踩过一个大坑模型、优化器、所有能想的种子都设置了但num_workers8不固定结果每次跑数据增强的 mask 完全不同。后来发现是忘了给DataLoader传 generator。这个坑在文件名、shuffle 顺序、增强流程都涉及随机时影响会被放大。3. 依赖锁定把“在我这能跑”变成“在哪都能跑”3.1 先盘清依赖的层级依赖锁定不是pip freeze requirements.txt这么简单。它至少覆盖三层Python 包层、系统库层、GPU 驱动层。Python 包层最直接但很多人锁了个寂寞。pip freeze会输出当前环境中全部包其中一些包是你底层依赖传递上来的另一些包可能有平台标记或路径信息。更麻烦的是它的完整记录依赖于“当前环境是干净环境”如果这个环境里混着开发用的调试包锁出来的文件就含大量噪声。我的习惯是项目根目录维护两个文件一个requirements.in写顶层直接依赖与版本范围比如torch2.1.2、numpy1.24,2.0另一个requirements.lock记录经过解析后的完整依赖树。用pip-tools或uv就可以从.in生成.lock这样既能控制直接依赖也能冻结传递依赖。3.2 从 Python 包到系统库锁到哪一层才算完Python 包层固定后系统层还可能有变量。比如同一份torch2.1.2配合不同的 CUDA 运行时某些算子的执行行为并不完全一致。所以环境信息里至少要记层需要记录的内容示例Python 包全部顶层与传递依赖torch2.1.2, numpy1.26.4CUDA 驱动驱动版本、可用的 CUDA 版本driver550.54.14, CUDA12.4cuDNNcuDNN 版本cuDNN8907操作系统发行版与内核特征Ubuntu 22.04在 Linux 上可以用nvcc --version查看 CUDA 编译版本用nvidia-smi查看驱动版本但这两者不一定完全对应。PyTorch 官方预编译 wheel 里其实打包了自己依赖的 CUDA runtime不一定非要和系统 CUDA 完全一致这也是很多初学者容易困惑的地方。对于更严格的可复现建议直接用容器解决系统层。把基础镜像固定到带明确摘要的镜像比如pytorch/pytorch:2.1.2-cuda12.1-cudnn8-devel然后在镜像内再做pip install最后把镜像重新导出或推送。系统层、Python 层、GPU 驱动层全部固化。如果你在 Windows/WSL 这样的环境里容器的意义会更突出因为 WSL 里的显卡驱动映射和原生 Linux 还是有差异用容器能抹掉一部分环境特殊性。3.3 使用工具锁定时的几个隐藏问题用了pip freeze后你得到的是torch2.1.2cu121这样的版本号里面带cu121后缀这本身说明这个包是 CUDA 12.1 构建版本。直接把文件扔到另一台没有对应 CUDA 可用的机器上可能能装上但不建议这样赌。还有一类问题平台标记。比如某个依赖在 Windows 上是pywin32在 Linux 上根本不存在pip freeze会把当前平台上检出的包全部列出来不区分适用范围。跨平台复现时应该用pip-tools的--pip-args--platform manylinux2014_x86_64这种方式生成跨平台 lock 文件或者干脆只保证同一类操作系统内的复现。另一个经常被忽略的细节是包的安装来源。pip freeze有时会输出类似package file:///home/xxx/packages/pkg.whl的本地路径条目。这类路径只有在你当前机器上有效归档给同事或交给 CI 时会直接失败。所以锁定后要检查一旦出现 file://要么换成正式版本号要么把本地 wheel 一起归档进项目的third_party目录。4. 配置归档把实验的施工图纸留档4.1 一份最小可复现配置要覆盖哪些字段很多人记录实验只用一句话“试了一下 lr1e-4”。等回头想复现发现当时用的 batch size、数据增强、优化器参数全不记得。配置归档的第一原则是所有能影响模型行为的参数都要落到一个文件里。最小集合包括数据集描述数据集名称、版本、切分方式、数据文件校验值模型结构模型名称、参数量、初始化方式训练参数epoch、batch size、学习率、优化器与调度器配置随机种子所有种子值预处理与增强图像尺寸、归一化、翻转、裁剪、mixup软硬件环境PyTorch 版本、GPU 型号、CUDA 版本更严格的实验我还会额外记录数据集里每个文件的 SHA256 值清单或者至少记录打包后文件的哈希。如果你的实验涉及隐私或商业数据无法完整开源至少要保证内部可以复现。数据指纹的意义在于它能告诉你“现在这份数据和我当初跑实验时那份是否完全一致”而不是只靠文件名猜想。4.2 用 YAML 管配置比散落在 argparse 里靠谱argparse 适合命令行交互但不适合归档。命令行里敲的参数不会主动存下来除非你额外写逻辑。相比之下YAML 或 JSON 配置文件更直观也更容易和代码版本一起走评审。我惯用的结构是把默认配置写进configs/default.yaml实验时只更新需要覆盖的字段训练完成后自动把最终生效的配置写回输出目录seed: 42 dataset: name: cifar10 root: ./data train_val_split: 0.9 augmentation: hflip: true crop_size: 32 model: name: resnet18 pretrained: false optimizer: type: adamw lr: 0.001 weight_decay: 0.05 training: epochs: 90 batch_size: 256 num_workers: 4在代码里我习惯加载配置后立刻做三件事把它打印到 stdout、转成 JSON 保存到输出目录、算一个 SHA256 存起来。为什么同时做三件事打印是为了人眼快速看保存 JSON 是为了方便程序读取哈希是为了防止后期误改配置导致实验结果被污染。最后算出来的“配置指纹”可以写进实验名或者提交记录里比如resnet18_lr1e-3_cfg-ab12cd34。4.3 git commit 和数据指纹两样都不能少配置文件的确定性只是第一步代码版本不锁一样白搭。训练代码里应该自动记录当前代码仓库的 commit id 和 dirty 状态。dirty 状态表示工作区有没有未提交的修改这个信息很重要——教科书实验课上学 git 时最容易忽略的就是“本地改了几行但忘了 commit”结果那份运行代码和你仓库里的代码完全不是一回事。在训练脚本里可以加一句import subprocess def get_git_info(): commit subprocess.run( [git, rev-parse, HEAD], capture_outputTrue, textTrue ).stdout.strip() status subprocess.run( [git, status, --porcelain], capture_outputTrue, textTrue ).stdout.strip() return commit, dirty if status else clean配合配置指纹、依赖锁文件、随机种子这五项基本构成了一个“实验快照”。快照保存得越完整你三个星期后翻开训练日志越能判断出当时到底发生了什么。很多人只保存最佳模型权重文件却没有保存“这个权重是怎么来的”等于留了一堆结果却没有施工记录。5. 实操过程和核心环节实现一次较真的可复现训练5.1 环境搭建先选版本再锁环境新项目开工我的习惯不推荐直接pip install torch装最新版而是先确认目标框架与硬件再决定版本。常用做法是到 PyTorch 官网选择系统、包管理器、CUDA 版本它会生成相应的安装命令这时直接把生成的命令连同版本号写进项目文档。以 WSL 环境为例很多用户会在 Windows 下做开发训练时切到 WSL。这时要注意WSL 里nvidia-smi看到的驱动是 Windows 侧映射进来的驱动版本通常由 Windows 侧统一管理WSL 内部不能单独更换驱动。所以你在 WSL 里的环境锁定更多是固定 PyTorch wheel、CUDA runtime、cuDNN 这些运行库而驱动版本依赖 Windows 宿主。环境锁定的最小操作可以精简成四步用 Python 虚拟环境或 conda 建独立环境记录python --versionpip --version安装按官方命令安装选定版本的 torch验证并记录python -c import torch, torchvision; print(torch.__version__, torch.version.cuda)第 4 步的输出我会直接写进实验记录。很多人觉得这是废话但“装完没验证”恰恰是最常见的坑。等训练到一半发现 GPU 不可用或者torch.cuda.is_available()为 False再回头查环境已经浪费半天时间了。5.2 一个可复现的最小训练脚本这里用一个简单逻辑展示完整链路不是贴一个能刷 SOTA 的模型而是让你看到“种子、配置、依赖、结果”怎么串起来。import argparse import json import os import random import numpy as np import torch import torch.nn as nn from torch.utils.data import DataLoader, TensorDataset def set_all_seed(seed: int) - None: random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) class SimpleNet(nn.Module): def __init__(self): super().__init__() self.fc nn.Sequential( nn.Linear(16, 32), nn.ReLU(), nn.Linear(32, 2), ) def forward(self, x): return self.fc(x) def main(cfg): set_all_seed(cfg[seed]) torch.backends.cudnn.benchmark False torch.backends.cudnn.deterministic True device cuda if torch.cuda.is_available() else cpu # 固定输入数据保证数据层面一致 g torch.Generator() g.manual_seed(cfg[seed]) x torch.randn(256, 16, generatorg) y (x[:, 0] 0).long() dataset TensorDataset(x, y) loader DataLoader(dataset, batch_size32, shuffleTrue, generatorg) model SimpleNet().to(device) optim torch.optim.AdamW(model.parameters(), lrcfg[lr]) lossfn nn.CrossEntropyLoss() # 记录环境与配置信息 record { seed: cfg[seed], torch: torch.__version__, cuda: torch.version.cuda, device: torch.cuda.get_device_name(0) if torch.cuda.is_available() else cpu, config: cfg, } for epoch in range(3): model.train() total_loss 0.0 for bx, by in loader: bx, by bx.to(device), by.to(device) optim.zero_grad() out model(bx) loss lossfn(out, by) loss.backward() optim.step() total_loss loss.item() * bx.size(0) record[fepoch_{epoch}_loss] total_loss / len(dataset) os.makedirs(outputs, exist_okTrue) with open(outputs/run_record.json, w) as f: json.dump(record, f, indent2) torch.save(model.state_dict(), outputs/model.pt) print(json.dumps(record, indent2)) if __name__ __main__: parser argparse.ArgumentParser() parser.add_argument(--seed, typeint, default42) parser.add_argument(--lr, typefloat, default3e-4) args parser.parse_args() main({seed: args.seed, lr: args.lr})看起来简单但有三个细节值得反复琢磨第一数据生成用了显式Generator而不是直接torch.randn这样即使代码别处调用了随机数也不会轻易影响数据构建顺序。第二训练结束时保存的run_record.json同时包含了配置、环境和每个 epoch 的 loss这可以成为后续分析的第一手信息来源。第三DataLoader 的shuffleTrue如果不传同一个generator每次脚本启动时的 shuffle 顺序会和种子解耦。5.3 运行后立即归档的五个文件训练跑完我不会直接关终端而是按固定清单检查以下文件是否齐全文件/信息作用缺少时的后果outputs/run_record.json保存种子、loss、环境无法确定训练状态outputs/model.pt保存权重无法用于复现结果configs/default.yaml保存完整超参数无法重建实验条件requirements.lock锁定依赖环境漂移导致微调结果偏差git commit 记录锁定代码快照代码与结果对不上号在团队里我还会让 CI 或任务提交脚本自动执行这些归档动作避免人为遗漏。人手一件“上有政策下有对策”的事情只要不是自动化就注定有一天会被跳过。5.4 归档后如何验证归档完不能直接结束。我通常会在另一台干净机器上或直接用虚拟环境从头安装一遍 lock 文件再跑一次实验。这个验证步骤最好在训练当天做。而不要等到三周后发现指标对不上再去翻记录。因为当日验证的成本最低人还在上下文里临时发现的配置遗漏也容易补齐。初次做完整套验证时你会发现很多预料之外的问题。比如requirements.lock里包含了仅开发用的包或者某些包没有固定自己在 CPU 版本上的行为。遇到这种情况别焦虑这是好事。早发现早调整。6. 常见问题与排查技巧实录不是所有坑都能用 seed 解决6.1 设置了种子结果还是不一致这是最典型的问题。排查顺序我给一套自己的固定路径第一步确认代码是在进程启动早期设置种子而不是训练循环中间。第二步检查 DataLoader 是否自带 generator。第三步看是不是开了cudnn.benchmark。第四步检查模型中是否有不支持确定性算法的自定义算子。最后把这些全部排除后再考虑数值层面的浮点差异。其中 DataLoader 相关的问题约占一半比例。num_workers0时不会有 worker 进程的种子问题但很多人训练环境默认num_workers0。一旦把样本有序性和随机增强挂上多进程这些 worker 的初始种子就变得不可控。使用worker_init_fn后基本能解决这类不确定性。还有一类相对隐蔽的问题来自混合精度训练。AMP 自动混合精度下某些算子在前向、反向过程中使用 FP16 或 BF16精度截断对输入数据顺序、累加顺序更敏感。同一套种子逻辑在 FP32 下能复现切到 AMP 后会有细微差异。遇到这类问题别急着否定种子方案可以试着用torch.use_deterministic_algorithms(True)检查是否存在非确定性算子同时观察torch.backends.cuda.matmul的精度设置。6.2 换机器后表现忽上忽下跨机器复现时最容易翻车的是 GPU 架构差异。一边是 Ampere一边是 Ada Lovelace即使 CUDA 与 cuDNN 版本一致某些矩阵乘法运算在底层使用的核函数也可能不同。这种差异通常不影响趋势性结论但会让“精确复现指标”落空。这时我建议做两件事一是明确记录torch.cuda.get_device_name()、torch.cuda.get_device_capability()让差异显性化二是把实验结论控制在“趋势方向与相对差距”层面而不是逐字逐位地对比指标。另外要考虑算子里的原子操作。比如某些 PyTorch 操作在 CUDA 端使用了非确定性的原子加法运行顺序一变结果就变。这类情况往往不是你能控制的只能选确定性实现或更换算子写法。假如问题出在scatter_add、index_add_这类操作上先确认是否有deterministic的替代实现。6.3 排查顺序速查表症状首选排查项深挖方向常见解同机多次结果不同全局种子 cuDNN benchmarkDataLoader generatorset_all_seed cudnn.deterministic更换 worker 数后结果变化DataLoader worker_init_fn数据增强随机源固定 worker 数与生成器跨机器精度有差异GPU 型号、驱动算子累加顺序记录硬件信息、使用确定性实现打开 mixed precision 后不稳定AMP 精度策略非确定性算子设置 matmul 精度或回退 FP32修改代码后效果无法归因git status dirty未提交代码提交代码并记录 commit id新建环境后安装失败平台标记、本地路径依赖系统缺失库使用 lock 文件与容器固定系统这张表不能直接解决所有问题但它能帮你节省大量“不知道查哪”的时间。可复现的核心不是消除所有不确定性而是让人可以把不确定的变量逐一排除。7. 一些真实经验可复现工程的上限与心态走到这一步还要泼点冷水即使以上全部做到深度学习里的可复现也不是一种绝对保证。系统是复杂系统涉及并行、浮点、底层算子库、硬件调度任何一个环节出现我们不掌握的实现差异都可能让“精确复现”变成“趋势复现”。这不是放弃的理由而是边界认知。我的体会是可复现更像是一次工程素养的体检。种子管理是在规范随机性依赖锁定是在约束环境漂移配置归档是把实验交接给未来的自己或同伴。当你习惯把每次实验都做成一个自洽可查的快照你的科研效率并不只是“能复现”那么简单——更关键的是你在对比不同方案时敢对差异下结论不会被环境噪声牵着鼻子走。最后再分享一个小技巧我会给训练任务打一个唯一的EXPERIMENT_ID它由配置指纹、git commit 前缀和机器 GPU 型号共同拼接而成。这个 ID 会打进日志目录名、模型权重路径、WandB 或 TensorBoard 的 run 名。哪怕三个月后重新看到这个 ID我依然能回忆起当时环境的大致状态。可复现不是一个口号它只是一整套值得长期养成的动作做着做着就成了肌肉记忆。