做深度学习实验的人,谁没吃过“复现”的亏?上周跑的模型明明收敛到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_workers>0时,多进程的数据加载顺序会受操作系统调度影响;多GPU的AllReduce求和顺序也会带来累积误差。
这五层里面,前三层可以通过加种子解决,第四层需要改cuDNN配置,第五层得靠工程手段去规避。后面第二、三节会分别讲。
1.2 两个最容易踩的“隐性随机”
除了上面五层,还有两个新手特别容易忽略的地方,我单独拎出来说。
第一个是DataLoader的worker线程。PyTorch的DataLoader一旦设置num_workers>0,PyTorch 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_only=True)逐行说明一下为什么这么写:
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_only=True)是PyTorch 1.8之后提供的全局确定性开关,它比cudnn.deterministic检查得更全面,会拦截例如torch.Tensor.index_put_这类非确定性算子。warn_only=True的意思是遇到不支持的算子只报警告,不中断运行,一般调试阶段先开着,确认干净之后再决定要不要改成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_size=64, shuffle=True, num_workers=4, worker_init_fn=worker_init_fn, generator=torch.Generator().manual_seed(42) )解释一下torch.Generator().manual_seed(42)的作用:DataLoader的shuffle依赖内部一个全局生成器,如果你不显式传入generator,它就用自己的默认生成器,而这个默认生成器的状态受进程启动时间影响。显式传入一个手动设种子的generator,之后DataLoader的索引分配和采样顺序就完全固定了。
再聊一个可能被忽略的点:如果你的数据预处理比较重,比如几十万张图要算各种统计量,用了独立的预处理脚本,脚本里用的随机源也要在脚本入口统一设置,否则就算训练代码全锁死,预处理那步还是有浮动。最好的做法是把预处理阶段的确定性也纳入洁癖范围,比如直接把set_seed抽成一个公共模块,训练和预处理都调用它。
还有个关于种子的“策略问题”:到底是用固定种子还是动态种子?我自己的习惯是,基础实验用固定种子,比如0、42、2024,方便和别人对齐;调参时用一个基准种子跑通逻辑,最后再换3到5个不同种子跑稳定性验证。固定种子能消除随机差异,但也会掩盖极端情况,所以论文里常说的“seed=0结果最好”没什么参考价值,真正可靠应该是多个种子下统计结果。这个意识比技巧更重要。
2.3 什么时候确定性会失效
实话说,种子不是万能的。即便你严格遵守上面的全部步骤,依然存在一些无法完全确定的场景,我列举几个我踩过的:
- 多GPU分布式训练:分布式DataParallel(DDP)的后端在不同设备间通信时,梯度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长这样:
torch>=1.9.0 numpy>=1.20 opencv-python>=4.5这种写法在哲学上没有问题——给出大致范围方便兼容,但对复现是灾难。因为>=意味着pip会解析到当前环境最新的满足版本,而PyTorch每个patch版本之间的浮点行为都可能变化,你的实验结果自然跟着飘。
真正可复现的依赖锁定要精细到“精确版本+哈希值”。第一步,用pip freeze导出现有环境的精确版本:
pip freeze > requirements-lock.txt这个文件长这样:
numpy==1.24.3 opencv-python==4.8.0.74 torch==2.1.2+cu121 torchvision==0.16.2+cu121注意torch==2.1.2+cu121这个本地版本号,它表明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', type=str, required=True) parser.add_argument('--override', nargs='*', default=[]) args = parser.parse_args() with open(args.config, 'r') as f: config = yaml.safe_load(f) # 支持类似 --override training.lr=0.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_ok=True) # 归档配置文件 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(f"commit: {git_commit}\n") f.write(f"diff:\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_name=config['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.benchmark=False;设置CUBLAS_WORKSPACE_CONFIG |
| GPU/CPU结果差异很大 | CPU与GPU算子实现不同;数据加载顺序受num_workers影响 | 尽量在同一设备类型上对比;固定num_workers=0做对照实验 |
| 复现别人项目指标对不上 | 依赖版本不同、数据预处理不同 | 检查requirements-lock文件;对照Dataset源码确认预处理顺序 |
| 换台机器结果就飘 | 硬件型号差异、cuDNN版本差异 | 使用Docker镜像;在同一规格GPU上验证 |
| 部分算子报“非确定性”警告 | 代码里使用了PyTorch标记的非确定性算子 | 查看具体算子文档;替换为确定性实现或关闭use_deterministic_algorithms |
5.2 一个从“飘”到“稳”的实战排查案例
我举一个具体经历。之前帮团队排查一个语义分割模型的复现问题,同事反馈“同一份代码,在三台机器上分别跑了三次,MIoU差了0.8个百分点”。
我们做了这些操作:
第一轮,先检查依赖锁定。发现同事的requirements.txt里写的是torch>=1.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安装时默认benchmark=False,但如果代码里某处写了cudnn.benchmark=True提升性能,就会引入算法选择的不确定性。把这些全部关掉之后,三台机器在同一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实验可复现这件事,本质上就是和“不确定性”对抗。我们把随机种子当第一道防线,把依赖锁定当第二道防线,把配置归档当第三道防线,三层防线都拉起来,你的实验结果才具备说服力,自己在迭代的时候也才不会被互相对不上的数字消耗精力。实操中你会慢慢发现,这套流程坚持下来,除了数字稳定,整个人的实验效率也会高一大截——再也不用为了“上次是怎么调出来的?”这种问题烦躁了。