拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

PyTorch实验可复现性指南:随机种子、依赖锁定与配置归档实战

PyTorch实验可复现性指南:随机种子、依赖锁定与配置归档实战

跑深度学习实验的人大概都经历过这种崩溃时刻:上周跑出来一个不错的结果,这周想复现一下,同样的代码、同样的数据,结果就是差了几个点。更离谱的是,换台机器跑,连loss曲线都长得不一样。你开始怀疑人生,怀疑模型,怀疑数据,最后发现——原来是忘了固定随机种子,或者某个依赖库悄悄升了个小版本。

这不是什么罕见的事。PyTorch实验的可复现性,是每个做深度学习的人都绕不开的坎。不管你是发论文、做项目交付,还是单纯想让自己三个月后还能看懂当时的实验,随机种子、依赖锁定、配置归档这三件事都必须做到位。这篇内容就是围绕这三个核心环节,把我在实际实验中踩过的坑、总结出来的操作流程,完整地分享出来。适合所有用PyTorch做实验的人,不管你是刚入门的新手,还是已经跑过几十组实验的老手,都能从中找到可以直接抄作业的方案。

1. 为什么你的PyTorch实验总是复现不了

1.1 随机性到底藏在哪些角落

很多人以为固定一个torch.manual_seed(42)就万事大吉了,实际上PyTorch实验里的随机源远比你想象的多。我刚开始做实验的时候也是这么想的,直到有一次发现两次运行的dataloader顺序完全不同,才意识到问题没那么简单。

PyTorch的随机性至少来自以下几个地方:

  • Python内置的random模块:数据预处理、文件读取顺序等都可能用到
  • NumPy的随机数生成器:很多数据增强库底层用的是NumPy
  • PyTorch的CPU随机数生成器:参数初始化、dropout等
  • PyTorch的GPU随机数生成器:CUDA层面的随机操作
  • cuDNN的算法选择:某些卷积算法本身带有非确定性
  • DataLoader的worker进程:多进程加载数据时,每个worker有自己的随机状态
  • 第三方库的内部随机性:比如某些数据增强库、初始化库

这还只是常见的,实际项目中可能还有更多隐蔽的随机源。比如你用了一些自定义的CUDA kernel,或者调用了某些没有文档说明的底层函数,都可能引入不可控的随机性。

注意:固定随机种子不是一劳永逸的事。你需要在实验开始前把所有能想到的随机源都固定住,而且在实验过程中要注意不要引入新的随机源。

1.2 依赖版本漂移:最隐蔽的复现杀手

比起随机种子,依赖版本的问题更隐蔽,也更致命。我曾经遇到过这样一个情况:同一个实验,在同一台机器上,隔了两周跑,结果差了将近2个百分点。排查了半天,最后发现是transformers库从4.28升到了4.29,某个层的默认初始化方式变了。

这种问题之所以难排查,是因为它不会报错,不会警告,就是安安静静地让你的结果不一样。而且很多时候你根本不会注意到依赖库升级了,特别是用pip install不带版本号的时候,或者用conda自动解决依赖的时候。

常见的依赖漂移场景包括:

场景典型表现影响程度
框架版本升级PyTorch 1.x升到2.x高,API和行为都可能变
工具库小版本升级transformers 4.28到4.29中高,默认参数可能变
CUDA/cuDNN版本变化驱动更新导致高,数值精度可能变
系统库更新glibc、OpenMP等中,可能影响并行行为
Python小版本变化3.9到3.10低到中,某些行为可能变

我现在的习惯是,每个实验项目都单独建一个conda环境,用pip freeze导出精确的依赖列表,并且在实验记录里写清楚每个包的版本号。虽然麻烦一点,但比起复现不出来时的抓狂,这点麻烦完全值得。

1.3 配置散落各处:三个月后你自己都看不懂

第三个常见问题是配置管理混乱。超参数写在代码里、命令行参数写在shell脚本里、数据路径写在环境变量里、模型配置写在yaml文件里,还有一些临时改的参数直接硬编码在某个函数里。三个月后你想复现这个实验,光是把这些配置找齐就要花半天时间。

更糟糕的是,有些配置你当时改了但没记录,比如临时把学习率从1e-4调到了5e-5,或者把batch size从32改成了16。这些改动可能只存在于你的终端历史里,或者根本没有任何记录。

我见过最极端的例子是,一个同学把关键的超参数写在了Jupyter Notebook的某个cell里,然后那个cell被删掉了,结果整个实验无法复现。这种问题不是技术问题,是流程问题,但它的杀伤力一点不比技术问题小。

2. 随机种子固定的完整操作方案

2.1 一个函数搞定所有随机源

与其每次手动设置各种随机种子,不如写一个统一的函数,把所有能想到的随机源都固定住。下面这个函数是我在实际项目中反复打磨出来的,可以直接用:

import os import random import numpy as np import torch def set_seed(seed=42, deterministic=True): """ 固定所有随机源,确保实验可复现。 Args: seed: 随机种子值 deterministic: 是否启用确定性模式(会略微降低性能) """ # 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) # 环境变量控制 os.environ['PYTHONHASHSEED'] = str(seed) if deterministic: # cuDNN确定性设置 torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False # 设置CUDA环境变量,确保卷积算法确定性 os.environ['CUBLAS_WORKSPACE_CONFIG'] = ':4096:8' # PyTorch 1.8+ 的确定性算法设置 try: torch.use_deterministic_algorithms(True) except AttributeError: pass # 老版本PyTorch没有这个API

这个函数里几个关键点需要解释一下。PYTHONHASHSEED控制的是Python的哈希随机化,影响字典和集合的遍历顺序,在某些数据处理流程中会影响结果。CUBLAS_WORKSPACE_CONFIG是CUDA 10.2之后引入的,不设置的话某些矩阵运算可能不确定。torch.use_deterministic_algorithms(True)会让PyTorch在遇到不确定的操作时直接报错,而不是静默地产生不确定结果。

提示:deterministic=True会禁用cuDNN的benchmark模式,这意味着PyTorch不会自动寻找最快的卷积算法,训练速度可能会慢10%到20%。如果对速度要求高,可以在调试阶段开启,最终跑结果时再关掉。

2.2 DataLoader的worker种子问题

即使你固定了全局随机种子,DataLoader在使用多进程加载时仍然可能产生不同的数据顺序。这是因为每个worker进程会继承父进程的随机状态,但继承的时机和方式可能导致不一致。

解决方案是给DataLoader的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) # 使用方式 dataloader = DataLoader( dataset, batch_size=32, shuffle=True, num_workers=4, worker_init_fn=worker_init_fn, generator=torch.Generator().manual_seed(42) # 控制shuffle的随机性 )

这里有个细节需要注意:generator参数控制的是DataLoader的shuffle顺序,而worker_init_fn控制的是每个worker内部的随机状态。两者都需要设置,缺一不可。

另外,如果你在dataset的__getitem__里用了随机数据增强,那么每个worker的随机种子必须不同,否则所有worker会产生相同的增强结果。worker_init_fn里用worker_id来区分就是这个目的。

2.3 那些容易被忽略的随机源

除了上面提到的,还有一些随机源容易被忽略:

自定义初始化:如果你自己写了参数初始化逻辑,确保用的是PyTorch的随机函数,而不是Python的random或者NumPy的random。因为PyTorch的随机函数受torch.manual_seed控制,而Python和NumPy的受各自的种子控制。

第三方增强库:像albumentations、imgaug这些库,它们有自己的随机状态。需要查文档看它们是否支持传入随机种子,或者是否受NumPy全局种子的控制。

CUDA原子操作:某些自定义CUDA kernel使用了原子操作,这些操作本身是非确定性的。如果必须使用,需要在实验记录里注明。

多GPU训练:使用DataParallel或DistributedDataParallel时,每个GPU上的随机状态需要单独设置。torch.cuda.manual_seed_all可以覆盖所有GPU,但如果你在训练过程中动态创建了新的CUDA上下文,可能需要重新设置。

混合精度训练:AMP(自动混合精度)本身不引入随机性,但它可能改变数值计算的顺序,从而影响结果。开启AMP和关闭AMP的结果可能不同,这需要在实验记录里注明。

我的一般做法是,在实验开始前打印一份完整的随机状态检查清单,确认所有随机源都已经被固定。这个清单包括:Python random状态、NumPy random状态、PyTorch CPU状态、PyTorch GPU状态、cuDNN确定性标志、环境变量。每次实验前过一遍,养成习惯。

3. 依赖锁定:从"大概能跑"到"精确复现"

3.1 为什么pip freeze不够用

很多人用pip freeze > requirements.txt来锁定依赖,这确实比不锁要好,但远远不够。pip freeze有几个致命问题:

第一,它只记录pip安装的包,不记录conda安装的包。如果你用conda装了PyTorch和CUDA相关的库,pip freeze是看不到的。

第二,它不记录系统级的依赖,比如CUDA版本、cuDNN版本、gcc版本、glibc版本。这些系统级依赖对实验结果的影响可能比Python包更大。

第三,它不区分直接依赖和间接依赖。pip freeze会把所有包都列出来,包括那些你根本没直接用过、只是被其他包依赖的。这导致requirements.txt很长,但关键信息反而不突出。

第四,它不记录安装来源。同一个包从PyPI装和从conda-forge装,可能编译选项不同,行为也不同。

我现在的做法是分层锁定:

  • Python包层:用pip freeze导出完整列表,同时用pip list --format=json导出更结构化的信息
  • Conda环境层:用conda env export --no-builds导出环境配置
  • 系统层:记录CUDA版本(nvcc --version)、cuDNN版本(torch.backends.cudnn.version())、gcc版本、操作系统版本
  • PyTorch层:记录torch.__version__、torch.version.cuda、torch.backends.cudnn.version()

3.2 用conda env export做精确锁定

conda env export是比pip freeze更完整的方案,因为它能记录conda安装的包和pip安装的包。但默认的conda env export会包含build string,这在不同平台上可能不兼容。所以推荐用--no-builds参数:

# 导出环境(不含build string,跨平台兼容性更好) conda env export --no-builds > environment.yml # 如果需要完全精确的锁定(含build string,仅限同平台) conda env export > environment_exact.yml

导出的environment.yml大概长这样:

name: my_experiment channels: - pytorch - nvidia - conda-forge - defaults dependencies: - python=3.10.13 - pytorch=2.1.0 - torchvision=0.16.0 - torchaudio=2.1.0 - pytorch-cuda=12.1 - numpy=1.26.2 - pip: - transformers==4.36.2 - datasets==2.16.1 - wandb==0.16.1

这个文件的好处是,别人拿到之后可以直接conda env create -f environment.yml重建环境。但要注意,--no-builds导出的文件在不同平台上重建时,conda会自己解决build string,可能装到不同的build版本。如果要求完全精确,需要用不含--no-builds的版本,但那样就只能在相同平台上重建。

3.3 记录那些conda管不到的东西

conda能管Python包和部分系统库,但有些东西它管不到,需要手动记录:

CUDA驱动版本:nvidia-smi显示的驱动版本,这个影响CUDA兼容性。驱动版本决定了你能用的最高CUDA版本。

系统CUDA版本:nvcc --version显示的版本,这个影响编译自定义CUDA kernel时的行为。

cuDNN版本:可以通过torch.backends.cudnn.version()获取,这个影响卷积算法的选择。

gcc/g++版本:gcc --version,这个影响C++扩展的编译。

操作系统版本:cat /etc/os-release,不同发行版和版本可能有不同的系统库行为。

GPU型号和数量:不同型号的GPU可能有不同的数值精度行为,特别是混合精度训练时。

我一般会写一个collect_env.py脚本,自动收集这些信息并保存到实验目录:

import platform import subprocess import torch def collect_env_info(): info = {} info['python_version'] = platform.python_version() info['pytorch_version'] = torch.__version__ info['cuda_version'] = torch.version.cuda info['cudnn_version'] = torch.backends.cudnn.version() info['os'] = platform.platform() info['gpu_count'] = torch.cuda.device_count() info['gpu_names'] = [torch.cuda.get_device_name(i) for i in range(torch.cuda.device_count())] try: info['nvidia_driver'] = subprocess.check_output( ['nvidia-smi', '--query-gpu=driver_version', '--format=csv,noheader'] ).decode().strip() except Exception: info['nvidia_driver'] = 'unknown' return info

这个脚本的输出可以保存成JSON文件,放在实验目录里,和模型checkpoint放在一起。这样任何时候你都能查到当时的环境信息。

4. 配置归档:让实验可追溯的工程化做法

4.1 配置管理的三层结构

配置管理不是简单地把超参数写在一个文件里就完事了。一个可追溯的配置管理系统应该有三层结构:

第一层:代码默认配置。写在代码里的默认值,作为兜底。这些值应该是经过验证的、合理的默认值,保证代码在没有任何外部配置的情况下也能跑起来。

第二层:实验配置文件。每个实验一个配置文件,覆盖默认配置中的特定参数。这个文件应该用版本控制管理,和代码一起提交。

第三层:运行时覆盖。通过命令行参数或环境变量在运行时覆盖配置。这一层主要用于调试和临时实验,不应该出现在正式实验中。

用代码表示大概是这样的:

import argparse import yaml from dataclasses import dataclass, field, asdict @dataclass class Config: # 第一层:代码默认配置 seed: int = 42 batch_size: int = 32 learning_rate: float = 1e-4 epochs: int = 100 model_name: str = 'resnet50' dataset: str = 'cifar10' optimizer: str = 'adamw' weight_decay: float = 0.01 warmup_steps: int = 500 output_dir: str = './outputs' @classmethod def from_yaml(cls, yaml_path): """第二层:从实验配置文件加载""" with open(yaml_path, 'r') as f: config_dict = yaml.safe_load(f) return cls(**config_dict) def override_from_args(self, args): """第三层:从命令行参数覆盖""" for key, value in vars(args).items(): if value is not None and hasattr(self, key): setattr(self, key, value) return self

这种三层结构的好处是,你既有一个合理的默认配置,又能针对每个实验做精确控制,还能在调试时临时改参数。而且因为配置是dataclass,可以很方便地序列化成JSON或YAML,保存到实验目录。

4.2 实验目录的标准化结构

每次实验都应该有一个独立的目录,目录结构标准化。我用了几年下来,觉得下面这个结构最实用:

experiments/ └── 2024-01-15_resnet50_cifar10_lr1e-4/ ├── config.yaml # 实验配置(完整快照) ├── env.json # 环境信息 ├── requirements.txt # Python依赖 ├── environment.yml # Conda环境 ├── train.log # 训练日志 ├── metrics.json # 评估指标 ├── checkpoints/ # 模型权重 │ ├── best.pt │ └── last.pt ├── tensorboard/ # TensorBoard日志 └── git_info.txt # Git提交哈希和分支

目录名用日期加关键配置的格式,一眼就能看出这个实验是什么时候跑的、用了什么模型和数据、关键超参数是多少。这样即使你有几十个实验目录,也能快速定位。

config.yaml保存的是完整的配置快照,包括所有默认值和覆盖值。这样你不需要去猜哪些参数用了默认值,哪些被覆盖了。

git_info.txt记录的是实验时的Git提交哈希和分支名。这个非常重要,因为代码可能在实验后有过修改,没有这个信息你根本不知道当时用的是哪个版本的代码。

4.3 用Git钩子自动记录代码版本

手动记录Git信息容易忘,可以用Git钩子自动化。在.git/hooks/目录下创建一个post-commit钩子:

#!/bin/bash # .git/hooks/post-commit git rev-parse HEAD > .git_info git branch --show-current >> .git_info git status --short >> .git_info

这样每次提交代码后,.git_info文件会自动更新。然后在训练脚本启动时,把这个文件复制到实验目录:

import shutil import os def archive_git_info(output_dir): """把Git信息归档到实验目录""" git_info_path = '.git_info' if os.path.exists(git_info_path): shutil.copy(git_info_path, os.path.join(output_dir, 'git_info.txt')) else: # 如果没有.git_info文件,尝试直接调用git命令 try: import subprocess commit = subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode().strip() branch = subprocess.check_output(['git', 'branch', '--show-current']).decode().strip() with open(os.path.join(output_dir, 'git_info.txt'), 'w') as f: f.write(f'commit: {commit}\nbranch: {branch}\n') except Exception: pass

如果代码有未提交的修改,git status --short会记录下来。这样你就能知道实验时代码是否干净,有没有未提交的改动。

4.4 配置快照的自动保存

训练脚本启动时,应该自动把当前配置保存到实验目录。这个操作要放在训练开始之前,确保即使训练中途崩溃,配置也已经保存了:

import json import yaml from datetime import datetime def save_config_snapshot(config, output_dir): """保存配置快照到实验目录""" os.makedirs(output_dir, exist_ok=True) # 保存为YAML(人类可读) with open(os.path.join(output_dir, 'config.yaml'), 'w') as f: yaml.dump(asdict(config), f, default_flow_style=False) # 保存为JSON(机器可读) with open(os.path.join(output_dir, 'config.json'), 'w') as f: json.dump(asdict(config), f, indent=2) # 记录时间戳 with open(os.path.join(output_dir, 'start_time.txt'), 'w') as f: f.write(datetime.now().isoformat())

同时保存YAML和JSON两个格式,YAML方便人看,JSON方便程序读取。时间戳记录实验开始时间,方便后续分析。

5. 完整复现流程的实战演练

5.1 从零开始搭建一个可复现的实验

假设我们要做一个CIFAR-10分类实验,用ResNet-50,学习率1e-4,batch size 32。下面是从零开始的完整流程。

第一步,创建conda环境并安装依赖:

conda create -n cifar_exp python=3.10 -y conda activate cifar_exp conda install pytorch=2.1.0 torchvision=0.16.0 pytorch-cuda=12.1 -c pytorch -c nvidia -y pip install pyyaml tensorboard wandb

第二步,导出环境配置:

conda env export --no-builds > environment.yml pip freeze > requirements.txt

第三步,写训练脚本,包含随机种子固定、配置管理、环境信息收集:

import os import json import yaml import random import numpy as np import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms, models from dataclasses import dataclass, asdict from datetime import datetime @dataclass class Config: seed: int = 42 batch_size: int = 32 learning_rate: float = 1e-4 epochs: int = 100 num_workers: int = 4 output_dir: str = './experiments' def set_seed(seed, deterministic=True): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) os.environ['PYTHONHASHSEED'] = str(seed) if deterministic: torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False os.environ['CUBLAS_WORKSPACE_CONFIG'] = ':4096:8' try: torch.use_deterministic_algorithms(True) except AttributeError: pass def worker_init_fn(worker_id): worker_seed = torch.initial_seed() % 2**32 np.random.seed(worker_seed) random.seed(worker_seed) def collect_env_info(): info = { 'python': __import__('platform').python_version(), 'pytorch': torch.__version__, 'cuda': torch.version.cuda, 'cudnn': torch.backends.cudnn.version(), 'gpu_count': torch.cuda.device_count(), 'gpu_names': [torch.cuda.get_device_name(i) for i in range(torch.cuda.device_count())], } return info def main(): config = Config() # 创建实验目录 timestamp = datetime.now().strftime('%Y-%m-%d_%H-%M-%S') exp_dir = os.path.join(config.output_dir, f'{timestamp}_resnet50_cifar10') os.makedirs(exp_dir, exist_ok=True) # 保存配置快照 with open(os.path.join(exp_dir, 'config.yaml'), 'w') as f: yaml.dump(asdict(config), f) # 保存环境信息 with open(os.path.join(exp_dir, 'env.json'), 'w') as f: json.dump(collect_env_info(), f, indent=2) # 固定随机种子 set_seed(config.seed) # 数据加载 transform = transforms.Compose([ transforms.Resize(224), transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), ]) train_dataset = datasets.CIFAR10( root='./data', train=True, download=True, transform=transform ) train_loader = DataLoader( train_dataset, batch_size=config.batch_size, shuffle=True, num_workers=config.num_workers, worker_init_fn=worker_init_fn, generator=torch.Generator().manual_seed(config.seed), pin_memory=True, ) # 模型 model = models.resnet50(weights=models.ResNet50_Weights.DEFAULT) model.fc = nn.Linear(model.fc.in_features, 10) model = model.cuda() # 优化器 optimizer = torch.optim.AdamW( model.parameters(), lr=config.learning_rate, weight_decay=0.01 ) criterion = nn.CrossEntropyLoss() # 训练循环 for epoch in range(config.epochs): model.train() total_loss = 0 for batch_idx, (data, target) in enumerate(train_loader): data, target = data.cuda(), target.cuda() optimizer.zero_grad() output = model(data) loss = criterion(output, target) loss.backward() optimizer.step() total_loss += loss.item() avg_loss = total_loss / len(train_loader) print(f'Epoch {epoch+1}/{config.epochs}, Loss: {avg_loss:.4f}') # 保存checkpoint torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'loss': avg_loss, 'config': asdict(config), }, os.path.join(exp_dir, 'checkpoints', f'epoch_{epoch+1}.pt')) if __name__ == '__main__': main()

这个脚本包含了完整的可复现要素:随机种子固定、配置快照、环境信息收集、DataLoader worker种子、checkpoint保存。你可以直接拿去改改就能用。

5.2 复现别人实验时的检查清单

当你拿到别人的代码想复现时,按下面这个清单逐项检查:

检查项检查方法常见问题
Python版本python --version版本差异导致语法或行为不同
PyTorch版本torch.__version__API变化、默认行为变化
CUDA版本torch.version.cuda数值精度差异
cuDNN版本torch.backends.cudnn.version()卷积算法差异
随机种子检查代码中set_seed调用遗漏某些随机源
DataLoader配置检查num_workers和worker_init_fn数据顺序不一致
依赖版本对比requirements.txt间接依赖版本漂移
硬件配置GPU型号和数量多卡和单卡结果可能不同
混合精度检查是否用了AMPAMP开关影响结果
数据版本检查数据集版本和预处理数据本身可能不同

这个清单看起来简单,但每一项都可能成为复现失败的元凶。我自己的经验是,复现别人实验时,先跑通流程,再逐步对齐配置,最后再调参。不要一上来就改代码,那样只会引入更多变量。

5.3 复现失败时的排查链路

当你按照清单检查完还是复现不出来时,需要系统性地排查。我的一般排查顺序是:

第一步,确认数据一致性。把两次运行的数据加载出来,逐batch对比。如果数据不一样,后面都不用查了。数据不一致的原因可能是:数据集版本不同、预处理参数不同、随机种子不同、DataLoader配置不同。

第二步,确认模型初始化一致性。在训练开始前,把模型的所有参数打印出来(或者计算一个哈希值),对比两次运行是否一致。如果不一致,检查随机种子设置和模型创建顺序。

第三步,确认前向传播一致性。用相同的输入,跑一次前向传播,对比输出。如果输出不一致,说明模型结构或某些层的配置不同。

第四步,确认损失和梯度一致性。跑一次反向传播,对比损失值和梯度。如果不一致,说明损失函数或优化器配置不同。

第五步,确认训练动态一致性。跑几个step,对比loss曲线。如果曲线形状不同,说明学习率、优化器、数据顺序等有差异。

这个排查链路是从底层到上层,从静态到动态。每一步都确认一致后再进行下一步,这样能快速定位问题所在。

6. 那些只有踩过坑才知道的细节

6.1 随机种子的"假固定"陷阱

有一种情况特别坑:你明明设置了随机种子,但结果还是不一样。这种情况通常是因为随机种子的设置时机不对。

比如,你在导入某些库的时候,这些库在导入时就已经消耗了随机数。如果你在导入之后才设置种子,那么这些库内部的随机状态已经确定了,不受你的种子控制。

正确的做法是在所有导入完成之后、任何随机操作之前设置种子。但有些库的导入本身就会触发随机操作,这时候就需要在导入之前设置种子,或者重新设置。

另一个陷阱是,某些操作会重置随机状态。比如torch.cuda.manual_seed_all会重置所有GPU的随机状态,如果你在设置种子之后又调用了这个函数,之前的设置就失效了。

还有一种情况是,你在训练过程中动态创建了新的随机源。比如在某个epoch突然加了一个数据增强,这个增强用的随机种子没有固定,就会导致后续结果不一致。

6.2 依赖锁定的"过度锁定"问题

依赖锁定要精确,但也不能过度。我见过有人把pip freeze的所有包都写进requirements.txt,包括那些间接依赖。这会导致两个问题:

第一,安装时可能冲突。间接依赖的版本是当时环境下的版本,但如果你换了平台或者Python版本,这些间接依赖可能需要不同的版本,强制安装会导致冲突。

第二,维护困难。每次更新一个直接依赖,都要重新生成整个requirements.txt,而且很难看出哪些是直接依赖,哪些是间接依赖。

我的做法是,requirements.txt只写直接依赖,用pip install时指定版本号。间接依赖的版本通过pip freeze记录在一个单独的文件里,作为参考,但不用于安装。

# requirements.txt - 直接依赖,用于安装 torch==2.1.0 torchvision==0.16.0 numpy==1.26.2 pyyaml==6.0.1 tensorboard==2.15.1 # requirements_full.txt - 完整依赖,用于参考 # 通过 pip freeze > requirements_full.txt 生成

这样既保证了直接依赖的精确性,又保留了完整的环境信息作为参考。

6.3 配置归档的"最小必要"原则

配置归档不是越多越好。把所有能想到的配置都记下来,会导致配置文件巨大,而且大部分配置可能根本不影响结果。

我的原则是记录"最小必要"配置:所有影响实验结果的配置都必须记录,不影响结果的配置可以不记。但问题是,你怎么知道哪些配置影响结果?

我的做法是,先记录所有配置,然后在实验过程中逐步识别哪些是关键的。比如,如果你发现改某个配置对结果没影响,就可以把它标记为"非关键",在后续实验中可以不记录。但第一次实验时,宁可多记,不要少记。

另外,配置的默认值也要记录。很多人只记录被覆盖的配置,不记录默认值。但默认值可能随着代码版本变化,如果不记录,复现时可能用到不同的默认值。

6.4 多GPU实验的额外注意事项

多GPU实验的复现比单GPU更复杂,因为多了几个变量:

GPU数量:用2卡和用4卡训练,即使总batch size相同,结果也可能不同。因为BatchNorm的统计量是在每张卡上单独计算的,卡数不同,统计量不同。

通信开销:多卡训练时的梯度同步顺序可能影响结果。虽然理论上AllReduce是确定的,但实际实现中可能有非确定性。

随机种子:每张卡上的随机种子需要单独设置。torch.cuda.manual_seed_all可以覆盖所有卡,但如果你在训练过程中动态创建了新的CUDA上下文,可能需要重新设置。

数据分配:多卡训练时,数据如何分配到各张卡上也会影响结果。DistributedSampler的shuffle行为需要固定。

我的建议是,多GPU实验的记录里必须包含GPU数量、每张卡的型号、通信后端(NCCL版本)、数据分配策略。这些信息在单GPU实验里不需要,但在多GPU实验里是必须的。

6.5 长期实验的中间检查点策略

对于需要跑几天甚至几周的实验,中间检查点策略很重要。不仅是为了防止训练中断,也是为了复现。

我的做法是,每隔一定步数保存一个完整的检查点,包括模型权重、优化器状态、学习率调度器状态、随机数生成器状态。这样即使训练中断,也能从检查点恢复,而且恢复后的结果和没中断一样。

def save_checkpoint(model, optimizer, scheduler, epoch, step, path): checkpoint = { 'model': model.state_dict(), 'optimizer': optimizer.state_dict(), 'scheduler': scheduler.state_dict() if scheduler else None, 'epoch': epoch, 'step': step, 'rng_state': torch.get_rng_state(), 'cuda_rng_state': torch.cuda.get_rng_state_all(), 'numpy_rng_state': np.random.get_state(), 'python_rng_state': random.getstate(), } torch.save(checkpoint, path) def load_checkpoint(model, optimizer, scheduler, path): checkpoint = torch.load(path) model.load_state_dict(checkpoint['model']) optimizer.load_state_dict(checkpoint['optimizer']) if scheduler and checkpoint['scheduler']: scheduler.load_state_dict(checkpoint['scheduler']) torch.set_rng_state(checkpoint['rng_state']) torch.cuda.set_rng_state_all(checkpoint['cuda_rng_state']) np.random.set_state(checkpoint['numpy_rng_state']) random.setstate(checkpoint['python_rng_state']) return checkpoint['epoch'], checkpoint['step']

保存随机数生成器状态是关键。很多人保存检查点时只保存模型和优化器,不保存随机状态,结果恢复训练后结果和没中断不一样。因为dropout、数据增强等操作的随机状态变了。

7. 把可复现性变成习惯

说了这么多技术细节,最后想聊聊习惯的问题。可复现性不是一次性的工作,而是需要融入日常实验流程的习惯。

我现在每次开一个新实验,第一件事就是建conda环境、写配置dataclass、加set_seed函数、建实验目录。这套流程已经成了肌肉记忆,花不了几分钟,但省去了后面无数麻烦。

另一个习惯是,每次实验结束后,花五分钟整理实验记录。把关键结果、异常情况、临时改动都记下来。这些记录在复现时价值巨大,因为很多信息是代码和配置里看不出来的。

还有一个习惯是,定期回顾旧实验。每隔一段时间,挑一个旧实验尝试复现。如果能复现,说明流程没问题;如果不能,说明有遗漏,及时补上。这个习惯帮我发现了好几个隐蔽的复现问题。

可复现性说到底是一种工程素养。它不会让你的模型更准,不会让你的训练更快,但它能让你的工作可信、可积累、可传承。在深度学习这个快速迭代的领域,可复现性是区分专业和业余的重要标志。

我在实际使用中发现,最难复现的往往不是技术问题,而是那些"当时觉得不重要所以没记"的信息。比如某个临时改的参数、某个特殊的数据处理步骤、某个环境变量的设置。这些信息在实验当时看起来无关紧要,但在复现时可能是关键。所以我的建议是,宁可多记,不要少记。记录的成本很低,复现失败的成本很高。

返回列表