ARTICLE DETAIL

资讯详情

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

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

PyTorch实验可复现性指南:随机种子、依赖锁定与配置归档实战 跑深度学习实验的人大概都经历过这种崩溃时刻上周跑出来一个不错的结果这周想复现一下同样的代码、同样的数据结果就是差了几个点。更离谱的是换台机器跑连loss曲线都长得不一样。你开始怀疑人生怀疑模型怀疑数据最后发现——原来是忘了固定随机种子或者某个依赖库悄悄升了个小版本。这不是什么罕见的事。PyTorch实验的可复现性是每个做深度学习的人都绕不开的坎。不管你是发论文、做项目交付还是单纯想让自己三个月后还能看懂当时的实验随机种子、依赖锁定、配置归档这三件事都必须做到位。这篇内容就是围绕这三个核心环节把我在实际实验中踩过的坑、总结出来的操作流程完整地分享出来。适合所有用PyTorch做实验的人不管你是刚入门的新手还是已经跑过几十组实验的老手都能从中找到可以直接抄作业的方案。1. 为什么你的PyTorch实验总是复现不了1.1 随机性到底藏在哪些角落很多人以为固定一个torch.manual_seed(42)就万事大吉了实际上PyTorch实验里的随机源远比你想象的多。我刚开始做实验的时候也是这么想的直到有一次发现两次运行的dataloader顺序完全不同才意识到问题没那么简单。PyTorch的随机性至少来自以下几个地方Python内置的random模块数据预处理、文件读取顺序等都可能用到NumPy的随机数生成器很多数据增强库底层用的是NumPyPyTorch的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(seed42, deterministicTrue): 固定所有随机源确保实验可复现。 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在遇到不确定的操作时直接报错而不是静默地产生不确定结果。提示deterministicTrue会禁用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_size32, shuffleTrue, num_workers4, worker_init_fnworker_init_fn, generatortorch.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 --formatjson导出更结构化的信息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: - python3.10.13 - pytorch2.1.0 - torchvision0.16.0 - torchaudio2.1.0 - pytorch-cuda12.1 - numpy1.26.2 - pip: - transformers4.36.2 - datasets2.16.1 - wandb0.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-gpudriver_version, --formatcsv,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(fcommit: {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_okTrue) # 保存为YAML人类可读 with open(os.path.join(output_dir, config.yaml), w) as f: yaml.dump(asdict(config), f, default_flow_styleFalse) # 保存为JSON机器可读 with open(os.path.join(output_dir, config.json), w) as f: json.dump(asdict(config), f, indent2) # 记录时间戳 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-4batch size 32。下面是从零开始的完整流程。第一步创建conda环境并安装依赖conda create -n cifar_exp python3.10 -y conda activate cifar_exp conda install pytorch2.1.0 torchvision0.16.0 pytorch-cuda12.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, deterministicTrue): 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_okTrue) # 保存配置快照 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, indent2) # 固定随机种子 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, trainTrue, downloadTrue, transformtransform ) train_loader DataLoader( train_dataset, batch_sizeconfig.batch_size, shuffleTrue, num_workersconfig.num_workers, worker_init_fnworker_init_fn, generatortorch.Generator().manual_seed(config.seed), pin_memoryTrue, ) # 模型 model models.resnet50(weightsmodels.ResNet50_Weights.DEFAULT) model.fc nn.Linear(model.fc.in_features, 10) model model.cuda() # 优化器 optimizer torch.optim.AdamW( model.parameters(), lrconfig.learning_rate, weight_decay0.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(fEpoch {epoch1}/{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, fepoch_{epoch1}.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 - 直接依赖用于安装 torch2.1.0 torchvision0.16.0 numpy1.26.2 pyyaml6.0.1 tensorboard2.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函数、建实验目录。这套流程已经成了肌肉记忆花不了几分钟但省去了后面无数麻烦。另一个习惯是每次实验结束后花五分钟整理实验记录。把关键结果、异常情况、临时改动都记下来。这些记录在复现时价值巨大因为很多信息是代码和配置里看不出来的。还有一个习惯是定期回顾旧实验。每隔一段时间挑一个旧实验尝试复现。如果能复现说明流程没问题如果不能说明有遗漏及时补上。这个习惯帮我发现了好几个隐蔽的复现问题。可复现性说到底是一种工程素养。它不会让你的模型更准不会让你的训练更快但它能让你的工作可信、可积累、可传承。在深度学习这个快速迭代的领域可复现性是区分专业和业余的重要标志。我在实际使用中发现最难复现的往往不是技术问题而是那些当时觉得不重要所以没记的信息。比如某个临时改的参数、某个特殊的数据处理步骤、某个环境变量的设置。这些信息在实验当时看起来无关紧要但在复现时可能是关键。所以我的建议是宁可多记不要少记。记录的成本很低复现失败的成本很高。
返回列表