有件事我印象极深:去年跑一个图像分类实验,本地训练出来的准确率是91.2%,兴致勃勃把代码原样发到另一台服务器,结果成了88.7%,换到第三台机器又变成了90.1%。训练脚本一字没改,数据是同一份预处理好的,损失函数、优化器参数、学习率调度全部核对过,最后三个平台各出一个数。那阵子我被调参调到头秃,后来才明白问题根本不在模型,而在PyTorch实验的可复现性没有做扎实。
所谓可复现,不是"大致能对齐趋势"就行,而是要在可控条件下让结果能逐位复制。这件事涉及三个环节:随机种子管理、依赖锁定、配置归档。三个环节单独拿出来都有人讲过,但把它们作为一个完整体系来落地,是另一回事。这篇文章就把我在项目里实际用的一套方案完整拆开:每个环节为什么需要做、怎么做、踩过哪些坑,以及一套可以直接抄作业的实验模板。如果你也在为"同一个代码跑出不同结果"挠头,或者想把实验管理得更规范,这篇内容应该能帮你省下不少时间。
1. 为什么PyTorch实验总是复现不了:先看清随机性从哪里来
1.1 可复现的三个层次:完全一致、数值级一致和结论级一致
先说一个容易混淆的点:可复现不代表所有场景下都必须逐位相同。我在项目里一般把可复现分成三个层次。
第一层是完全复现,也叫逐位复现。同一条代码、同一个环境、同一份数据,在同一个硬件上跑两次,loss曲线、验证集指标、模型权重全都一模一样。这是最理想的,也是随机种子、依赖锁定、配置归档整套机制要追求的目标。
第二层是数值级一致。每次训练完的参数略有浮点尾数差异,loss曲线走势相同,最后几个epoch的验证集指标在几个小数点内波动。这种一般是因为GPU并行计算的浮点累加顺序不稳定导致的,后续分析结论不受影响。
第三层是趋势级一致。模型A和模型B谁好谁坏、哪个超参数更优,在不同机器上得出同样结论,但具体数值差异比较大。这种"结论可复现"在调参阶段勉强够用,但真到了发论文、部署模型、审计复现的时候,远远不够。
很多人的实验里,问题就卡在第二层和第三层之间。你以为自己设了随机种子,实际上只有一行torch.manual_seed(42),结果该变还是变。下面把随机性的来源彻底拆开看。
1.2 随机性的真正来源:全局随机数、并行浮点与依赖库行为
PyTorch实验里的随机性不是单一来源,而是多个随机源叠加在同一个全局随机数状态机上。
首先是模型初始化。模型的权重、偏置通常用正态分布或均匀分布初始化,这里会消耗PyTorch全局RNG的状态。如果你在代码里加了一行无关紧要的操作,比如提前跑了一次torch.randn热个身,那模型初始化的参数就跟上次完全不一样了——因为全局RNG的状态已经被往前推了一段。
其次是数据加载流程。DataLoader做shuffle时会创建RandomSampler,它内部要消耗PyTorch的随机数。还有数据增强操作,像RandomCrop、RandomHorizontalFlip这些transforms,只要没指定generator,吃的就是全局RNG。数据采样和增强顺序一变,喂给模型的batch就是另一份数据。
然后是并行浮点运算。这一条很多人忽略。GPU在做矩阵乘法、卷积这些算子时,内部是分块并行累加的。浮点数加法不满足结合律,1.0 + 2.0 + 3.0跟3.0 + 2.0 + 1.0的结果存在微小差异。每个线程块的聚合顺序不同,最终数值就可能差一个极小的尾数。A卡和N卡差、V100和A100差、同一块卡跑两次有时也差,就是因为硬件调度不完全有确定性。
最后是依赖库行为。numpy、pandas、scikit-learn这些库如果用C语言实现,底层随机算法在版本间调整过,生成的序列就可能不同。我之前踩过一个坑:numpy从1.23升到1.26,同一个numpy.random.seed(42)生成的随机排列完全不同——所以依赖锁定必须纳入可复现体系,它跟随机种子是配套关系。
1.3 随机序列的耦合问题:为什么"设置过一个种子"并不够
我在排查问题的过程中发现一个特别隐蔽的机制:代码中多个随机源共享同一个全局RNG时,它们之间是"耦合"的——任何一个随机操作消耗掉的状态量发生变化,后面所有随机操作的结果都会跟着变。
举个非常具体的例子。假设训练脚本的执行顺序是:
torch.manual_seed(42) # 步骤1:生成一组随机增强参数 aug_params = torch.randint(0, 2, (100,)) # 步骤2:模型初始化 model.apply(init_fn) # 步骤3:DataLoader取第一个batch for batch in dataloader: train_step(batch)步骤1消耗掉一部分随机状态。如果你在代码里把步骤1的torch.randint改成了torch.rand,参数范围相同但消耗的状态量不同,那么步骤2初始化出来的模型和步骤3抽到的batch就都会变。哪怕随机种子的值还是42,整个训练过程却不再是同一场实验。
很多人只在训练脚本开头调用一次set_seed,以为万事大吉,但训练中途可能有第三方库偷偷消耗了全局RNG,或者数据加载子进程的随机种子没处理好,全局状态在第一个epoch开始前就已经偏离了。正确的做法,是给每一类随机源独立分配种子状态,而不是所有人都挤在同一个全局状态上。这正好是下一节要展开的内容。
2. 随机种子管理:从一行代码到全链路固定
2.1 基础:一个标准的seed设置函数及其使用范围
先给一个我在项目里一直用的基础版本,它覆盖了Python标准库、numpy、PyTorch和CUDA几条随机通道。
import random import numpy as np import torch def set_seed(seed: int = 42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed(seed) torch.cuda.manual_seed_all(seed)注意到几个细节:torch.cuda.manual_seed(seed)只设置当前一块卡,torch.cuda.manual_seed_all(seed)是设置所有可见GPU,多卡环境两者都要写。此外,如果代码里用了Python的hash函数、set或str类型做遍历,最好在启动训练前设置环境变量PYTHONHASHSEED,否则每次进程启动时字符串的哈希随机种子都不同,某些依赖set遍历顺序的逻辑会变得不可复现。
不过,set_seed只是地基。它管住的是"主进程的全局RNG",数据加载里的子进程、模型初始化里的独立generator、cuDNN的算子选择,它统统管不到。所以下面的内容才是真正需要花时间的部分。
2.2 关键:DataLoader的worker与generator必须单独处理
DataLoader在多进程加载模式下(num_workers > 0),每个worker子进程会有自己独立的一份RNG。主进程设置的种子不会自动传给子进程,就导致一个问题:shuffle顺序每次可能不一样,或者说数据增强的随机性不可控。
PyTorch官方可复现文档里推荐的做法,是同时配置worker_init_fn和generator:
def seed_worker(worker_id): worker_seed = torch.initial_seed() % 2**32 np.random.seed(worker_seed) random.seed(worker_seed) g = torch.Generator() g.manual_seed(42) train_loader = DataLoader( train_dataset, batch_size=32, num_workers=4, shuffle=True, worker_init_fn=seed_worker, generator=g, )这里解释一下工作原理。torch.initial_seed()返回当前进程PyTorch RNG的初始种子。每个DataLoader worker在启动时会被分配一个基于base_seed + worker_id的种子,worker_init_fn的作用是在子进程内部把Python自带的random和numpy的随机状态也统一设置到同一个起跑线上,避免出现"torch的shuffle是确定的,但numpy增强随机了"这种割裂。
单独传入的g = torch.Generator()是为shuffle的RandomSampler提供独立的随机流。这么做的好处是不再占用全局RNG,随时手动调整generator种子就能控制数据顺序。如果你想做"相同模型参数、不同数据顺序"的对照实验,这个独立generator就是开关。
我再补充一点实测经验:num_workers从4改成8,会导致每个worker被分配的数据切片方式不一样,最终也可能影响训练数值。因此做可复现实验时,num_workers、batch_size、pin_memory、prefetch_factor这些数据加载参数都应该一并固定,不能只盯着种子。
2.3 关键:cuDNN、CUDA的确定性开关不能漏
光把进程里的RNG锁住还不够,PyTorch底层的CUDA算子选择也是个随机源。cuDNN在卷积层会针对不同的输入尺寸做自动调优,选择它认为最快的卷积算法。问题在于,算法选择结果在不同设备、不同驱动版本、不同cuDNN版本下可能不一样,用了不同算法,浮点累加顺序就不同,训练结果自然不同。
标准做法是关掉自动调优,强制走确定性算法:
torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False这里有个坑:benchmark = True和deterministic = True不能同时开。逻辑上它们就是矛盾的,一个追求最快、一个追求确定,同时开并不会有"又确定又快"的效果,反而可能报错。
从PyTorch 1.8开始,还有一套更严格的开关:
torch.use_deterministic_algorithms(True)它会让所有非确定性算子直接抛RuntimeError。比如某些插值操作、某些稀疏算子、atomicAdd相关的累加操作,一碰上就会报错。这个开关在开发阶段非常适合用来暴露问题:打开它跑一遍训练,哪里报错,哪里就是不兼容确定性的地方,然后逐个处理。但在正式训练时,不要盲目开启它,因为有些算子改成确定性版本之后性能损耗很大,如果不影响结论,在数值级一致这个标准下接受也可以。
2.4 进阶:模型初始化、分布式训练中的种子设计
除了全局种子,模型初始化和分布式训练还需要更细的颗粒度控制。
模型初始化方面,如果代码里用nn.init.kaiming_uniform_这类函数,可以显式传入自己的generator,让初始化随机流与数据加载随机流解耦:
init_generator = torch.Generator() init_generator.manual_seed(0) for name, param in model.named_parameters(): if param.dim() > 1: nn.init.kaiming_uniform_(param, generator=init_generator)这样做的好处是模型初始化永远只从init_generator里取随机数,跟训练过程的数据shuffle完全无关。哪怕你在训练前多跑了一些调试代码,也不会"污染"模型参数。
分布式训练场景则不太一样。每个进程需要拿到不同的初始种子,否则所有进程的shuffle顺序和初始化参数都一致,丧失多样性。常用做法是在基础种子上叠加rank偏移:
rank = dist.get_rank() set_seed(42 + rank)之后再按rank设置数据分片。分布式场景里还要注意DDP的广播、梯度聚合顺序。梯度AllReduce默认是异步多线程累加,聚合顺序在不同步数间变化也会引入微小数值抖动。要完全确定化,需要设置torch.distributed.barrier()配合固定rank顺序,代价是性能下降,实际项目中多数人会在这一步选择接受数值级一致。
3. 依赖锁定:锁定Python包、CUDA运行时与系统库
3.1 requirements.txt锁不住传递依赖:一个真实的翻车案例
我一开始做依赖管理时,习惯在项目根目录放一个requirements.txt,里面写死直接依赖的版本,比如torch==2.1.2、numpy==1.24.0。本以为版本号锁住了,就万事大吉。
后来有一次我把训练代码从自己的机器迁到另一台机器,结果损失曲线形状一样,但收敛点差了不少。排查了大半天,发现是numpy的间接依赖——底层BLAS库不同。第一台机器上是OpenBLAS,第二台机器环境里安装了Intel MKL,矩阵乘法的累加顺序不一样,前向传播的浮点结果就出现微小漂移,几十个epoch之后被放大了。
问题的根源在于:requirements.txt里只锁直接依赖,pip install时会按照依赖树去拉取当前能拿到的最新传递依赖版本。今天装可能是1.2.0,三个月后装就变成1.2.5,行为可能已经静默改变了。要真正锁定,必须把整棵依赖树的最终版本全部固定下来。
3.2 pip freeze与conda env export:正确使用和平台陷阱
最直接的完整锁定方式是pip freeze,它会列出当前环境所有已安装的包及精确版本。
pip freeze > requirements_frozen.txt使用时有两个经验。第一,最好在全新创建的虚拟环境里生成,避免把开发环境里的多余包一并记录下来。第二,这个文件只能用于相同平台。Windows、Linux、macOS下的包名和构建号不同,比如nvidia-cudnn-cu12这类包在不同系统上就不一样,直接拷贝过去安装会失败。
conda env export效果类似,但它连conda包的构建信息一起记录,比如numpy=1.24.0=py311hdd5b1c8_0这样的字符串。构建串的好处是连编译配置都锁死了,但这也意味着跨平台迁移非常脆弱。conda用户更推荐的另一个命令是:
conda list --explicit > spec-file.txt生成的spec-file.txt是纯URL列表形式,安装时用conda create --name myenv --file spec-file.txt,能精确恢复到同一套构建产物。
如果想在pip生态里拿到带哈希校验的完整锁定文件,可以用pip-tools:
pip-compile requirements.in --generate-hashes --output-file requirements_lock.txt这样生成的锁文件里每个包都带SHA256哈希,安装时加--require-hashes参数,可以防止包被替换或篡改,也保证不同时间安装出来的环境内容一致。
3.3 版本配套:PyTorch、CUDA运行时与GPU驱动的记录方法
PyTorch的安装方式会直接决定CUDA运行时版本。比如pip install torch==2.1.2+cu121和pip install torch==2.1.2+cu118,虽然是同一个Torch版本,但底层运行时和算子行为可能不同。所以锁定依赖时,必须把PyTorch的安装源和CUDA后缀也一起记下来。
我一般会把这些信息写进一个环境信息文件:
import platform import torch info = { "python_version": platform.python_version(), "torch_version": torch.__version__, "torch_cuda_version": torch.version.cuda, "cudnn_version": torch.backends.cudnn.version() if torch.backends.cudnn.is_available() else None, "cuda_available": torch.cuda.is_available(), "gpu_name": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, }注意区分两个概念:nvidia-smi显示的"Driver Version / CUDA Version"是GPU驱动支持的CUDA版本;torch.version.cuda是PyTorch包里实际链接的CUDA运行时版本。两者可以不同,而且很多情况下就是不同。真正的运行时版本由PyTorch安装包自带或conda里的cudatoolkit决定。所以做可复现笔记时,两条信息都要留:驱动版本决定能跑什么样的CUDA,运行时版本决定算子实际行为。
3.4 终极方案:Docker镜像下的隔离环境
依赖锁定的最终形态是Docker镜像。镜像不仅锁定Python包,还把操作系统、系统库、BLAS、cuDNN、CUDA运行时全部打包在一起。这样不管在哪台机器上跑,只要容器能启动,内部环境就完全一致。
我的做法是做一个基础训练镜像,Dockerfile大概长这样:
FROM pytorch/pytorch:2.1.2-cuda12.1-cudnn8.9-runtime WORKDIR /workspace COPY requirements_lock.txt . RUN pip install --require-hashes -r requirements_lock.txt COPY . .之后每次训练都从同一个镜像启动容器。宿主机的驱动版本由NVIDIA Container Runtime透传进来,一般要求驱动不低于镜像内CUDA版本的对应下限,否则容器起不来。这样哪怕宿主机之间驱动型号不同,只要都能拉起同一个CUDA镜像,训练环境也基本一致。
当然,Docker镜像构建本身也有"原地重建不等于相同结果"的问题——基础镜像的tag可能会被动更新,所以更严格的团队会给镜像数字摘要(digest),保证FROM pytorch/pytorch@sha256:xxxx。个人项目不一定要做到这一步,但至少基础镜像的tag要写成明确的版本号,不能写latest。
4. 配置归档:让每次实验都有完整的身份信息
4.1 argparse不够用:用YAML与dataclass管理配置
很多项目的配置管理是从argparse开始的。跑实验时敲一长串命令行参数,全靠一次性输入。问题在于,实验做完之后,想回看"当时跑的是什么配置"只能翻终端历史或者翻shell脚本,非常不可靠。
后来我转向了YAML配置文件加dataclass的方案。配置放到文件里,一份文件对应一次实验,天然可追踪。
from dataclasses import asdict, dataclass import yaml @dataclass class TrainConfig: lr: float = 1e-3 batch_size: int = 32 epochs: int = 100 model_name: str = "resnet18" optimizer: str = "adam" scheduler: str = "cosine" seed: int = 42 data_dir: str = "./data" exp_name: str = "baseline" @classmethod def from_yaml(cls, path): with open(path, "r") as f: data = yaml.safe_load(f) return cls(**data) def to_yaml(self, path): with open(path, "w") as f: yaml.dump(asdict(self), f, default_flow_style=False)dataclass的好处是默认值集中管理、类型明确,配合IDE的自动补全用起来舒服。如果项目配置更复杂,可以换pydantic,它自带类型校验,yaml里写错类型能立刻暴露。
4.2 实验目录设计:配置、元数据与模型必须同框
配置归档不只是"存一份yaml文件"那么简单,最核心的原则是:一次实验的所有信息必须落在同一个目录里,谁也别想跑。
我习惯的实验输出结构是这样:
experiments/ └── exp_20250101_120000/ ├── config.yaml ├── metadata.json ├── requirements_frozen.txt ├── model_best.pt ├── logs/ └── metrics.jsonconfig.yaml是本次实验最终生效的配置,注意不是训练过程中实时读取的那份,而是保存进输出目录的这份。如果后续手动调整过配置,就以输出目录里的为准。
metadata.json自动记录环境与代码状态:
import json import platform import subprocess import torch def make_metadata(): git_commit = subprocess.check_output( ["git", "rev-parse", "HEAD"] ).decode().strip() dirty = subprocess.check_output( ["git", "status", "--porcelain"] ).decode().strip() != "" return { "timestamp": __import__("datetime").datetime.now().isoformat(), "git_commit": git_commit, "git_dirty": dirty, "hostname": platform.node(), "python_version": platform.python_version(), "torch_version": torch.__version__, "cuda_version": torch.version.cuda, "cudnn_version": torch.backends.cudnn.version() if torch.backends.cudnn.is_available() else None, "gpu_name": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, } def check_git_clean(): dirty = subprocess.check_output(["git", "status", "--porcelain"]).decode().strip() if dirty: raise RuntimeError("Git working tree is not clean, commit your changes first.")这里git_dirty字段特别关键。训练实验一般持续很久,中途改代码是常有的事。如果不做检查,模型训练到一半代码变了,实验记录就失去了意义。我的项目里会在训练启动前强制调用check_git_clean(),有任何未提交改动直接中止训练。刚开始觉得这个流程很烦,后来发现它救过我很多次——有一次就是某个文件里多了一行调试代码,要不是有这个检查,整个实验结果又作废了。
4.3 代码与数据版本:git commit、脏状态检查与数据清单
模型权重存档的时候,不能只保存state_dict。我保存checkpoint时会打包整套上下文:
torch.save({ 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'epoch': epoch, 'best_metric': best_metric, 'config': asdict(config), 'metadata': metadata, }, f"{exp_dir}/model_best.pt")这样哪怕别人只拿到一个.pt文件,也能看到对应的配置、代码版本、运行环境。配合config.yaml和metadata.json,整个实验的"身份信息"才是完整的。
数据版本也要记录。最简单的方式是在配置里写data_dir,然后在metadata.json里额外保存一份数据目录的文件清单摘要,或者直接保存对应数据文件的哈希。如果用的是公开数据集,记录下下载日期和下载来源就行。数据一旦更新过,旧代码和新数据搭在一起,实验结果同样不具备可比性。
5. 实战模板:一套可复现实验的完整骨架
5.1 项目结构与入口脚本
前面几章分别讲了机制,现在把它们组合成一个可以直接抄作业的项目骨架。
my_experiment/ ├── train.py ├── configs/ │ └── baseline.yaml ├── src/ │ ├── data_loader.py │ ├── model.py │ └── utils/ │ ├── seed.py │ └── metadata.py ├── requirements_lock.txt ├── Dockerfile └── run.sh入口脚本run.sh负责创建实验目录并触发训练:
#!/bin/bash set -euo pipefail EXP_NAME="exp_$(date +%Y%m%d_%H%M%S)" EXP_DIR="experiments/$EXP_NAME" mkdir -p "$EXP_DIR" python train.py \ --config configs/baseline.yaml \ --exp_dir "$EXP_DIR"注意set -euo pipefail不要省,尤其是set -u,能防止未定义变量导致的脚本行为不一致,这在分布式环境里很常见。
5.2 核心代码:train.py 的确定性写法
训练主进程的核心代码,关键点按顺序来:
import argparse import random import numpy as np import torch from src.utils.metadata import check_git_clean, make_metadata, save_config from src.utils.seed import seed_worker def main(): parser = argparse.ArgumentParser() parser.add_argument("--config", required=True) parser.add_argument("--exp_dir", required=True) args = parser.parse_args() check_git_clean() config = TrainConfig.from_yaml(args.config) set_seed(config.seed) torch.use_deterministic_algorithms(True) generator = torch.Generator() generator.manual_seed(config.seed) train_loader = DataLoader( train_dataset, batch_size=config.batch_size, shuffle=True, num_workers=4, worker_init_fn=seed_worker, generator=generator, ) model = create_model(config.model_name) optimizer = build_optimizer(model, config) scheduler = build_scheduler(optimizer, config) save_config(args.exp_dir, config) metadata = make_metadata() with open(f"{args.exp_dir}/metadata.json", "w") as f: json.dump(metadata, f, indent=2) for epoch in range(config.epochs): train_one_epoch(train_loader, model, optimizer, scheduler) val_loss, val_acc = evaluate(model, val_loader) torch.save(..., f"{args.exp_dir}/model_best.pt") if __name__ == "__main__": main()这个代码里,随机种子的全链路是:set_seed管住主进程全局RNG,generator给数据排序一条独立随机流,seed_worker保证每个加载子进程内部一致,use_deterministic_algorithms(True)在训练早期把非确定性算子全部暴露出来。配置先加载、后验证、再保存,输出目录里留下了完整副本。
5.3 一键验证:两次实验自动比对结果
模板搭好之后,如何确认"可复现"真正生效?我的做法是连续跑两次完全相同的实验,然后自动比对。
写一个简单的校验脚本,读取两个实验目录里的metrics.json,比较每个epoch的验证损失和指标:
import json def load_metrics(exp_dir): with open(f"{exp_dir}/metrics.json") as f: return json.load(f) exp1 = load_metrics("experiments/exp_20250101_120000") exp2 = load_metrics("experiments/exp_20250102_120000") for (m1, m2) in zip(exp1["val_acc"], exp2["val_acc"]): if m1 != m2: print(f"epoch diff: {m1} vs {m2}") break else: print("metrics match exactly.")实际跑下来的观察是:只要环境完全相同、确定性开关全开、数据加载独立种子管理好,验证集指标可以做到完全一致,差一个小数点都不飘。如果只保证半套方案,比如漏了generator,很多场景下差别会出现在第一个epoch之后,损失的浮动量级在1e-4以内,但最终val指标可能差0.1%到0.5%不等。
有一个细节很多人忽略:即使torch.use_deterministic_algorithms(True)不会报错,也不代表所有数值都是确定性的。它只保证PyTorch和CUDA算子层面的确定性,但第三方自定义算子、部分autograd.Function、以及C++扩展里的原子操作,它管不到。所以验证这一步必须做,而且要在真实的训练流程上做,不能只在玩具代码上验证。
6. 踩坑实录:可复现性问题排查手册
6.1 常见问题速查表
| 现象 | 常见原因 | 处理建议 |
|---|---|---|
| 设置了seed,两次训练结果仍不同 | DataLoader的worker子进程没有接管种子,或shuffle的generator未固定 | 使用worker_init_fn和独立generator |
| 换了GPU型号,结果变了 | GPU并行浮点求和顺序不同,非确定性算子导致数值漂移 | 开启deterministic=True,仍不一致则接受数值级一致 |
| 同一台机器时隔一天结果不同 | 环境依赖被升级过,或代码有未提交修改 | 用pip freeze/Docker锁定环境,训练前check_git_clean() |
开了benchmark=True和deterministic=True报错 | 两者互相冲突 | 固定benchmark=False,仅用deterministic=True |
| 错误提示"operator is not deterministic" | 跑了非确定性算子,被use_deterministic_algorithms(True)拦住 | 替换非确定性实现,或对该部分绕行 |
| 换了numpy版本后随机序列变化 | 底层随机算法或实现变化 | 锁定numpy版本,并冻结完整依赖树 |
| 多卡训练结果不稳定 | DDP梯度聚合顺序不一致 | 固定rank顺序、限制并行归约,或接受数值级一致 |
Python里set遍历顺序每次不同 | PYTHONHASHSEED未固定 | 启动命令带PYTHONHASHSEED=0或固定值 |
6.2 排查技巧与实战中的经验教训
当复现失败时,我的排查顺序是从最外到最内、从环境到代码。
第一步,对比两次实验的requirements_frozen.txt和metadata.json。先排除代码版本不一致、依赖版本漂移、硬件状态不同这些外部因素。这一步能解决掉至少一半的问题。
第二步,打开torch.use_deterministic_algorithms(True)重新跑一次。让它把代码里所有非确定性算子直接暴露出来,然后逐个处理。之前有一个项目在数据预处理里用了torch.nn.functional.interpolate的align_corners=False模式,这个模式在CUDA上非确定性,开了严格模式之后立刻暴露,换了一版实现就稳定了。
第三步,如果严格模式下没有报错但结果仍然不一致,就要分模块排查。做法是把模型加载、数据加载、训练循环分别"冻结":先用手动固定好的初始权重,只跑数据加载并打印每个batch的统计量,看是否一致;再固定数据,只跑前向和反向,逐层打印中间张量是否一致。通过二分法缩范围,很快能定位到底是shuffle打乱了、还是某个算子数值波动、还是多进程调度造成了差异。
最后,我想讲一个在项目真正落地的建议:可复现性不是靠"自觉"维持的,而是靠流程。种子、依赖、配置这三件套必须天然嵌入项目的启动流程中——训练一开始就自动生成实验目录、自动保存依赖清单、自动记录代码commit、自动做脏状态检查。不做任何额外操作,全程零成本落地。只有这样,可复现性才不会在项目一忙起来的时候被忘到脑后。
根据我个人的实操体会,随机种子、依赖锁定和配置归档这套组合拳,最容易被忽略的其实是"配置与产物同步保存"这一步。种子和环境锁住了,丢失了配置文件一样等于白干。下次你再遇到"代码没变结果变了"的怪事,不妨先看一眼实验目录里有没有今天讲到的这三个文件——大概率能从里面直接找到答案。