1. 为什么随机种子这件事值得单独拎出来讲
做过深度学习实验的人大概都有过这种经历:同一份代码,同一份数据,跑两遍结果对不上,Loss曲线像两条性格迥异的蛇,一条平稳下降,一条上蹿下跳。你开始怀疑人生,怀疑显卡,怀疑框架版本,最后发现——什么都没改,只是没设随机种子。
这不是段子,这是每天都在实验室和工程团队里上演的真实场景。PyTorch(下称Torch)作为当前主流的深度学习框架,它的随机性来源远比很多人想象的要多。Python内置的random模块、NumPy的随机数生成器、Torch自身的CPU和GPU随机数生成器,甚至cuDNN底层的卷积算法选择,每一个环节都可能成为结果不可复现的“罪魁祸首”。
我见过不少同学在复现论文时,把代码从头到尾读了三遍,超参数对齐了,数据增强策略也照搬了,结果指标就是差那么一两个点。最后排查下来,问题出在DataLoader的worker_init_fn没有设置,多进程加载数据时每个worker的随机状态不一致。这种坑,不亲自踩一次是很难记住的。
所以这篇内容,我想把Torch实验复现中跟随机种子相关的所有关键点,从原理到实操,从单卡到多卡,从常见坑到排查思路,完整地梳理一遍。不管你是刚入门的研究生,还是带团队做工程落地的开发者,这些内容都应该能帮你省下不少“玄学调参”的时间。
提示:本文讨论的是实验可复现性中的随机性控制问题,不涉及任何特定框架版本的独家特性,所有方法在主流Torch版本中均适用。
2. Torch实验里到底有多少个随机源
很多人以为设一个torch.manual_seed(42)就万事大吉了,实际上这只是冰山一角。要真正做到可复现,你得先搞清楚整个实验流程中哪些环节会引入随机性。
2.1 Python层面的随机源
Python标准库的random模块是最容易被忽略的一个。它在你导入Torch之前就可能已经被调用了,比如某些数据预处理库、文件打乱操作、甚至你自己写的辅助函数里用到了random.shuffle。如果你只设了Torch的种子而没管Python的,那数据划分的随机性就没被控制住。
import random import numpy as np import torch def set_all_seeds(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed)这三行看起来简单,但顺序有讲究。我习惯把random放在最前面,因为有些库在import阶段就会调用Python的随机模块。如果你先设了Torch的种子,再import某个会调用random的库,那Python的随机状态就被改掉了。
2.2 NumPy的随机数生成器
NumPy在Torch实验中的存在感极强。数据加载、图像增强、特征预处理,几乎处处都有它的影子。np.random.seed(seed)设置的是全局随机状态,但如果你用的是np.random.RandomState或者np.random.default_rng创建的独立生成器,那全局种子对它们无效。
# 全局种子,影响np.random.*系列函数 np.random.seed(42) # 独立生成器,不受全局种子影响 rng = np.random.default_rng(42) # 需要单独指定种子实测下来,很多开源代码库在数据增强部分用的是独立生成器,这时候你光设全局种子是不够的。排查的时候可以用一个简单的方法:固定输入,跑两次增强,看输出是否一致。
2.3 Torch的CPU与GPU随机状态
torch.manual_seed(seed)同时设置CPU和当前GPU的随机种子。但如果你有多张GPU,或者用了torch.cuda.manual_seed_all(seed),情况会复杂一些。
torch.manual_seed(seed) # CPU和当前GPU torch.cuda.manual_seed(seed) # 当前GPU torch.cuda.manual_seed_all(seed) # 所有GPU在单卡环境下,torch.manual_seed就够了。但多卡训练时,每个进程的随机状态需要单独控制。如果你用DistributedDataParallel,每个进程的种子应该不同,否则所有进程会生成相同的随机数,数据打乱就失去意义了。
2.4 cuDNN的算法选择随机性
这是最隐蔽的一个随机源。cuDNN在实现卷积操作时,会自动选择最快的算法。这个选择过程本身带有随机性,而且不同算法在数值精度上有微小差异,累积起来就可能导致结果不可复现。
torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = Falsedeterministic=True强制cuDNN使用确定性算法,benchmark=False关闭算法自动搜索。代价是训练速度可能下降,因为放弃了最快的算法选择。但在复现实验时,这个代价是值得的。
2.5 DataLoader的多进程随机性
DataLoader在num_workers>0时,每个worker进程有独立的随机状态。如果你在Dataset的__getitem__里用了随机增强,每个worker的随机序列是不同的,而且worker的启动顺序和数量会影响最终的随机结果。
def worker_init_fn(worker_id): worker_seed = torch.initial_seed() % 2**32 np.random.seed(worker_seed) random.seed(worker_seed) loader = DataLoader( dataset, batch_size=32, num_workers=4, worker_init_fn=worker_init_fn )torch.initial_seed()返回的是当前worker的基础种子,每个worker拿到的值不同,这样既保证了worker之间的随机性差异,又保证了整体实验的可复现性。
2.6 其他容易被忽略的随机源
还有一些零散的随机源,比如:
- Dropout层:训练时随机丢弃神经元,受Torch全局种子控制。
- 权重初始化:
torch.nn.init系列函数,受Torch全局种子控制。 - 数据打乱:
torch.randperm、random.shuffle,分别受Torch和Python种子控制。 - 第三方库:某些数据增强库(如Albumentations)有自己的随机状态,需要单独设置。
把这些随机源都理清楚之后,你才能说自己的实验环境是“可控”的。下面这张表可以帮你快速对照检查:
| 随机源 | 控制方法 | 常见遗漏点 |
|---|---|---|
| Python random | random.seed(seed) | import顺序问题 |
| NumPy全局 | np.random.seed(seed) | 独立生成器不受控 |
| Torch CPU/GPU | torch.manual_seed(seed) | 多卡需用manual_seed_all |
| cuDNN算法 | cudnn.deterministic=True | 速度下降,需权衡 |
| DataLoader worker | worker_init_fn | 多进程下易遗漏 |
| 第三方增强库 | 库自带API | 文档不明确,需实测 |
3. 一套可直接抄作业的种子设置方案
理论讲完了,接下来给一套我实际在用的种子设置方案。这套方案覆盖了单卡和多卡场景,你可以直接复制到自己的项目里。
3.1 基础版:单卡实验的完整设置
import os import random import numpy as np import torch def set_seed(seed=42): """设置所有随机种子,确保实验可复现""" # Python随机 random.seed(seed) # NumPy随机 np.random.seed(seed) # Torch随机 torch.manual_seed(seed) torch.cuda.manual_seed(seed) torch.cuda.manual_seed_all(seed) # cuDNN确定性 torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False # 环境变量,控制哈希随机性 os.environ['PYTHONHASHSEED'] = str(seed) # 在实验开始处调用 set_seed(42)PYTHONHASHSEED这个环境变量控制的是Python的哈希随机化。如果你在代码里用了set或者dict的遍历顺序,而这个顺序又影响了实验结果(比如特征拼接的顺序),那这个变量就很重要。不过它必须在Python启动前设置才有效,在代码里设置只对后续新建的哈希表生效。
3.2 进阶版:多卡分布式训练的种子处理
多卡训练时,每个进程需要不同的种子来保证数据打乱的差异性,但整体实验又要是可复现的。我的做法是用基础种子加上进程rank:
import torch.distributed as dist def set_seed_ddp(base_seed=42): """分布式训练种子设置""" rank = dist.get_rank() seed = base_seed + rank random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False return seed这样每个进程的种子是base_seed + rank,既保证了进程间的差异性,又保证了整体实验的可复现性。只要base_seed固定,所有进程的随机状态就都确定了。
3.3 DataLoader的worker种子配置
def seed_worker(worker_id): """为每个DataLoader worker设置种子""" worker_seed = torch.initial_seed() % 2**32 np.random.seed(worker_seed) random.seed(worker_seed) g = torch.Generator() g.manual_seed(42) loader = DataLoader( dataset, batch_size=32, num_workers=4, worker_init_fn=seed_worker, generator=g, shuffle=True )这里有两个关键点:worker_init_fn负责设置每个worker的Python和NumPy种子,generator负责控制DataLoader本身的随机打乱。两者配合,才能保证多进程加载数据时的完全可复现。
3.4 验证种子是否生效的实用方法
设完种子之后,怎么确认真的生效了?我常用的方法是跑两次前向传播,对比输出:
# 第一次 set_seed(42) model = MyModel() x = torch.randn(4, 3, 32, 32) out1 = model(x) # 第二次 set_seed(42) model = MyModel() x = torch.randn(4, 3, 32, 32) out2 = model(x) # 对比 print(torch.allclose(out1, out2, atol=1e-6))如果输出是True,说明种子设置基本生效。如果训练过程中还有随机操作(如Dropout),需要把模型设为eval()模式再对比。
注意:即使所有种子都设对了,不同硬件、不同驱动版本、不同Torch版本之间仍可能存在微小数值差异。完全跨平台的比特级复现是非常困难的,工程上通常追求的是“同一环境下可复现”。
4. 那些年我踩过的种子相关的坑
种子设置这件事,理论看着简单,实操中坑却不少。下面这几个是我自己或身边同事真实遇到过的,每一个都值得单独拿出来说。
4.1 坑一:设了种子但结果还是变了
有一次复现一个图像分类实验,所有种子都设了,但每次跑出来的准确率还是有0.5%左右的波动。排查了很久,最后发现是数据增强库的问题。那个库内部用了自己的随机数生成器,而且没有暴露种子设置接口。
解决办法有两个:一是换用支持种子设置的增强库,二是把增强操作改成确定性的(比如固定裁剪位置而不是随机裁剪)。我选择了后者,虽然牺牲了一些增强的多样性,但换来了完全可复现的结果。
这个坑给我的教训是:不要假设任何第三方库会自动遵循你设置的全局种子。每个库都可能有自己的随机状态管理方式,用之前最好翻一下文档,或者做个简单的复现测试。
4.2 坑二:多卡训练时结果对不上
单卡跑得好好的实验,换成多卡之后结果就变了。排查下来发现是两个问题叠加:一是没有用manual_seed_all,导致非当前GPU的种子没设;二是DataLoader的worker数量变了,随机序列跟着变了。
多卡训练的种子设置需要额外注意几点:
- 用
torch.cuda.manual_seed_all(seed)确保所有GPU的种子都设了。 - DataLoader的
num_workers在单卡和多卡下保持一致,否则随机序列会不同。 - 如果用
DistributedSampler,它的shuffle行为也受种子控制,需要确保每个epoch的种子设置正确。
# DistributedSampler的正确用法 sampler = DistributedSampler( dataset, num_replicas=world_size, rank=rank, shuffle=True, seed=42 # 固定种子 )4.3 坑三:cuDNN的benchmark模式带来的不确定性
torch.backends.cudnn.benchmark = True是个很诱人的设置,它能让cuDNN自动搜索最快的卷积算法,通常能带来10%到20%的速度提升。但这个设置会让每次运行的算法选择可能不同,从而导致结果不可复现。
我的建议是:在实验调试和论文复现阶段,把benchmark设为False;在最终产品部署和性能压测阶段,可以设为True。这样既保证了实验的可复现性,又能在生产环境榨取性能。
如果你确实需要benchmark带来的速度提升,又想要可复现性,可以尝试先跑一次让cuDNN完成算法搜索,然后固定住选择的算法。不过Torch没有直接提供这个接口,需要一些hack手段,这里就不展开了。
4.4 坑四:环境变量设置的时机问题
PYTHONHASHSEED这个环境变量必须在Python解释器启动前设置。如果你在代码里用os.environ['PYTHONHASHSEED'] = '42',它只对之后新建的哈希表生效,对已经存在的字典和集合没有影响。
正确的做法是在启动脚本里设置:
PYTHONHASHSEED=42 python train.py或者在Dockerfile、conda环境配置里提前设好。这个坑很隐蔽,因为大多数时候哈希随机化不影响实验结果,只有在涉及集合遍历顺序的代码里才会暴露出来。
4.5 坑五:断点续训时的种子恢复
断点续训是个容易被忽略的场景。如果你保存了模型权重和优化器状态,但没有保存随机数生成器的状态,那续训之后的随机序列就跟原来不一样了。
# 保存时 checkpoint = { 'model': model.state_dict(), 'optimizer': optimizer.state_dict(), 'epoch': epoch, 'torch_rng': torch.get_rng_state(), 'cuda_rng': torch.cuda.get_rng_state_all(), 'numpy_rng': np.random.get_state(), 'python_rng': random.getstate(), } # 恢复时 torch.set_rng_state(checkpoint['torch_rng']) torch.cuda.set_rng_state_all(checkpoint['cuda_rng']) np.random.set_state(checkpoint['numpy_rng']) random.setstate(checkpoint['python_rng'])这样续训之后的随机序列才能跟原来完全一致。虽然麻烦,但对于需要长时间训练的实验来说,这个步骤能省去很多“为什么续训后结果变了”的困惑。
5. 可复现性与训练速度的取舍策略
设了这么多确定性选项,训练速度肯定会受影响。cudnn.deterministic=True会禁用一些非确定性算法,benchmark=False会放弃算法自动搜索,这些都会让训练变慢。那怎么在可复现性和速度之间找到平衡?
5.1 不同阶段的差异化配置
我的做法是根据实验阶段来调整:
| 阶段 | cudnn.deterministic | cudnn.benchmark | 种子设置 | 目的 |
|---|---|---|---|---|
| 调试期 | True | False | 完整设置 | 快速定位问题 |
| 论文复现 | True | False | 完整设置 | 结果可验证 |
| 超参搜索 | False | True | 基础设置 | 追求速度 |
| 生产训练 | False | True | 基础设置 | 性能优先 |
调试期和复现期追求的是“每次跑结果一样”,所以确定性优先。超参搜索和生产训练追求的是“尽快跑完”,所以速度优先。这个策略在实际项目中很实用,既不会因为追求可复现性而拖慢整体进度,也不会因为追求速度而让实验结果无法解释。
5.2 确定性设置对速度的实际影响
我做过一个简单的测试,在ResNet-50加ImageNet规模的数据上:
- 完全确定性模式:每epoch约120秒
- 非确定性模式:每epoch约105秒
差距大概在12%左右。这个差距在调试期完全可以接受,但在大规模训练中累积起来就很可观了。所以差异化配置是必要的。
5.3 部分确定性的折中方案
如果你想要大部分可复现,又不想牺牲太多速度,可以只对关键部分开启确定性:
# 只对卷积层开启确定性 torch.backends.cudnn.deterministic = True # 但允许benchmark搜索(注意:这仍可能引入不确定性) torch.backends.cudnn.benchmark = True这种配置下,cuDNN会使用确定性算法,但仍会搜索最快的那个。不同运行之间,如果搜索到的算法不同,结果仍可能不一致。所以这个折中方案只适合对复现性要求不那么严格的场景。
提示:如果你的实验涉及对比不同方法的性能,建议所有方法都用相同的确定性配置,否则比较结果可能受到随机性的干扰。
6. 排查随机性问题的完整思路
即使按照上面的方案设置了种子,有时候还是会遇到结果不可复现的情况。这时候需要一套系统的排查思路,而不是盲目地改代码。
6.1 从最简单的场景开始隔离
第一步永远是隔离变量。用一个最小的可复现脚本,只包含模型定义和前向传播,不涉及数据加载和训练循环:
import torch import torch.nn as nn def test_reproducibility(): torch.manual_seed(42) model = nn.Sequential( nn.Linear(10, 20), nn.ReLU(), nn.Linear(20, 1) ) x = torch.randn(4, 10) out1 = model(x) torch.manual_seed(42) model2 = nn.Sequential( nn.Linear(10, 20), nn.ReLU(), nn.Linear(20, 1) ) out2 = model2(x) return torch.allclose(out1, out2) print(test_reproducibility())如果这个最小脚本都不可复现,那问题出在Torch本身或环境配置上。如果可复现,再逐步加入数据加载、训练循环、多卡等模块,每次加入一个模块就测试一次,直到找到引入不确定性的那个环节。
6.2 逐层排查的检查清单
按照数据流的方向,我通常按这个顺序排查:
- 数据加载:固定一个batch,跑两次,看数据是否一致。如果不一致,检查Dataset的
__getitem__里是否有随机操作,DataLoader的worker种子是否设了。 - 模型初始化:固定种子后创建两次模型,对比权重是否一致。如果不一致,检查是否有自定义初始化逻辑绕过了Torch的种子控制。
- 前向传播:
model.eval()模式下跑两次,对比输出。如果不一致,检查是否有Dropout、BatchNorm等受随机性影响的层没有正确切换到eval模式。 - 损失计算:对比两次的loss值。如果不一致,检查损失函数里是否有随机采样操作。
- 反向传播:对比两次的梯度。如果不一致,检查是否有自定义的梯度操作引入了随机性。
- 优化器更新:对比两次更新后的权重。如果不一致,检查优化器是否有随机状态(如Adam的动量)没有正确初始化。
这个清单看起来繁琐,但实际排查时往往在前两步就能定位问题。我遇到的大部分不可复现问题,都出在数据加载环节。
6.3 用工具辅助定位
除了手动排查,还可以用一些工具来辅助。比如torch.autograd.set_detect_anomaly(True)可以帮助定位梯度计算中的异常,虽然它主要用于排查NaN问题,但对随机性排查也有帮助。
另外,可以在关键位置插入断言,自动检测随机性:
def assert_reproducible(func, *args, **kwargs): """运行两次func,断言结果一致""" result1 = func(*args, **kwargs) result2 = func(*args, **kwargs) assert torch.allclose(result1, result2), "结果不可复现!" return result1把这个装饰器加到关键函数上,一旦出现不可复现的情况就会立即报错,比事后对比结果要高效得多。
6.4 记录完整的实验环境信息
有时候问题不在代码,而在环境。不同版本的Torch、CUDA、cuDNN之间,随机数的实现可能有细微差异。所以记录实验环境信息很重要:
import torch import platform def log_env_info(): info = { 'torch_version': torch.__version__, 'cuda_version': torch.version.cuda, 'cudnn_version': torch.backends.cudnn.version(), 'python_version': platform.python_version(), 'os': platform.platform(), } return info把这些信息跟实验结果一起保存,将来复现时就能快速排除环境差异的影响。
7. 关于种子设置的一些个人体会
写了这么多,最后分享几点我在实际项目中的体会,不一定对,但都是踩坑踩出来的。
第一,种子设置要趁早。最好在项目模板里就把set_seed函数写好,每个实验脚本开头都调用。等到实验跑了一半才想起来设种子,前面的结果就都白跑了。
第二,不要迷信“完全可复现”。跨硬件、跨版本、跨平台的比特级复现是非常困难的,工程上追求的是“同一环境下可复现”和“统计意义上一致”。如果你的实验结果在不同环境下有微小波动,但整体趋势一致,这通常是可接受的。
第三,种子值本身不重要,重要的是固定。42也好,3407也好,选一个顺眼的数字固定下来就行。不要花时间纠结“哪个种子能跑出最好的结果”,那是在过拟合测试集。
第四,把种子设置写进实验记录。每次实验跑了什么种子,用了什么确定性配置,都应该记录下来。将来复现或者对比时,这些信息比超参数还重要。
第五,多卡训练的种子问题最容易被忽略。如果你从单卡切换到多卡,一定要重新检查种子设置。我见过太多“单卡能复现,多卡不能”的案例了。
第六,断点续训时记得保存随机状态。这个习惯能帮你省去很多“为什么续训后结果变了”的困惑。虽然多写几行代码,但值得。
第七,定期做复现测试。不要等到论文投稿前才发现结果不可复现。在项目进行中,每隔一段时间就跑一次完整的复现测试,确保种子设置没有因为代码改动而失效。
这些体会说起来都是常识,但真正做起来,每一条都需要刻意练习。希望这篇内容能帮你少走一些弯路,把更多时间花在真正有价值的实验设计上,而不是跟随机性做斗争。