1. 随机种子到底在解决什么问题
做深度学习的人大概都遇到过这种场景:同一份代码、同一份数据、同一个模型结构,今天跑出来准确率 92.3%,明天再跑一次变成 91.8%,换个同事的机器跑又变成 92.7%。你想复现论文里的结果,试了七八次都对不上号,最后只能归结为"深度学习就是玄学"。其实这不是玄学,是随机性在作怪。
神经网络训练过程中充满了随机操作:权重初始化是随机的、数据加载打乱顺序是随机的、Dropout 丢弃神经元是随机的、数据增强的裁剪翻转也是随机的。这些随机操作叠加起来,就导致每次训练都是一条不同的路径,最终落到不同的局部最优点上。而Pytorch 设置随机种子 Seed,本质上就是给这些随机操作指定一套确定的"剧本",让它们每次都按同样的顺序、同样的数值执行,从而保证训练结果唯一可复现。
这个技能看着简单,但真要做好,远不是写一行torch.manual_seed(42)就能完事的。我在实际项目里踩过的坑包括:设了种子结果还是不稳定、多卡训练种子失效、换了 CUDA 版本结果又变了、DataLoader 里的 worker 各自为政。这些问题不解决,复现就是一句空话。这篇文章我就把这几年关于随机种子设置的经验整理一下,从原理到代码,从单卡到多卡,从常见坑到排查方法,尽量讲透。
不管你是在做论文复现、比赛刷分、还是工程落地需要结果可追溯,随机种子都是绕不开的一环。小白可以照着抄代码,有经验的可以看看多卡和 worker 那部分有没有你遗漏的细节。
2. 随机性从哪来:先把源头理清楚
2.1 训练流程中的五类随机源
很多人以为设置随机种子就是torch.manual_seed()一句话的事,结果发现结果还是飘。原因在于 Pytorch 训练流程里的随机源不止一个,它们分属不同的库和不同的层级,需要分别对付。
第一类是Python 原生随机。random模块负责很多数据预处理、文件读取顺序、部分数据增强库的内部逻辑。如果你用了random.shuffle()或者某些第三方增强库,它们的随机性来自 Python 的random,只设 torch 的种子压不住。
第二类是NumPy 随机。数据预处理阶段大量使用 NumPy,比如np.random.rand()、np.random.choice(),尤其是自己写 Dataset 的时候经常用到。NumPy 有自己独立的随机状态,和 torch 互不干扰。
第三类是Pytorch CPU 随机。torch.manual_seed()管的就是这个,负责 CPU 上的张量初始化、CPU 版 Dropout 等。
第四类是Pytorch GPU 随机。CUDA 上的随机操作有独立的种子,需要通过torch.cuda.manual_seed()或者torch.cuda.manual_seed_all()来设置。只设 CPU 种子,GPU 上的权重初始化照样随机。
第五类是cuDNN 算法选择的不确定性。这是最隐蔽的一类。cuDNN 为了追求速度,会在多个卷积实现算法里动态选择,不同的算法累积误差不同,导致即使种子固定,结果也会有微小差异。需要通过torch.backends.cudnn.deterministic和benchmark两个开关来控制。
理解这五类随机源,是做好种子设置的前提。只设其中一个,其他地方漏了,复现就会失败,而且失败得很隐蔽——你可能跑十次有八次一样,两次不一样,这种最难查。
2.2 为什么种子能保证可复现
从原理上讲,计算机里的"随机"都是伪随机。所谓伪随机,就是用一个确定的算法(比如梅森旋转算法)从一个初始值(也就是种子)开始,生成一串看起来随机的数列。种子相同,生成的数列就完全相同;种子不同,数列就不同。
举个生活化的例子:伪随机数生成器就像一本超级厚的字典,种子就是页码。你从第 42 页开始往下读,每次读到的字都是一样的。别人只要也翻到第 42 页,就能读出完全相同的序列。这就是种子相同、结果相同的原理。
Pytorch 里每个涉及随机的地方都有自己的伪随机数生成器实例和状态。设置种子,就是把所有这些生成器的起始状态都拨到同一个确定的位置。当所有随机源都被拨到确定位置,整个训练流程就变成了一条确定的计算路径,结果自然唯一。
但要注意两个前提:一是硬件环境要一致(同样的 GPU、同样的 CUDA 和 cuDNN 版本),因为不同硬件上浮点运算的精度和顺序可能不同;二是所有随机源都要覆盖到,漏一个就会导致不确定。这也是为什么很多人设了种子还是复现不了——不是原理不成立,是覆盖不全。
2.3 设置种子的代价
天下没有免费的午餐。完全确定性的训练意味着要放弃一部分性能。开启 cuDNN 确定性模式后,cuDNN 不能再动态选择最快的算法,只能选择确定的算法,这可能导致训练速度下降 10% 到 30%,具体取决于模型结构和 GPU 型号。
另外开启确定性后,某些操作如果找不到确定性实现,Pytorch 会直接报错而不是静默使用非确定性算法。这是好事也是麻烦事——好事是它逼你把问题暴露出来,麻烦是有些算子确实没有确定性版本,你得改代码绕过。
我的建议是分场景:调试阶段、论文复现阶段、需要结果可追溯的生产环境,一定要开确定性。日常探索性实验、追求极限速度的刷榜,可以不开,但至少要把种子固定住,让结果波动范围可控。下面几节我就按这个思路往下讲。
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) # Pytorch CPU 随机 torch.manual_seed(seed) # Pytorch GPU 随机(多卡时用 manual_seed_all) 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 哈希函数的随机化种子。Python 3 之后字典、集合的迭代顺序受哈希随机化影响,某些数据加载逻辑会因此产生顺序差异。要让它生效,必须在 Python 进程启动之前设置,所以在程序里设置往往已经晚了,更稳妥的做法是在运行命令里带上,比如PYTHONHASHSEED=42 python train.py。
torch.cuda.manual_seed()和manual_seed_all()的区别也值得一提。前者只设置当前设备的种子,后者设置所有可见设备的种子。单卡时两者等价,多卡时一定要用manual_seed_all(),否则其余卡上的随机状态不受控。
3.2 固定在代码开头还是每个实验开头
种子的设置位置也有讲究。我习惯把它封装成一个函数,在每次实验开始前调用一次。不要在 import 阶段设置,因为 import 顺序可能变化;也不要设了之后又调用别的随机操作,否则种子状态会被推进。
有一个常见的误区:把set_seed放在 DataLoader 创建之后。这样 DataLoader 的 shuffle 序列已经定下来了,种子再设就不影响它。正确的顺序是先设种子,再创建模型、优化器、DataLoader,保证所有随机初始化都发生在种子设定之后。
还要注意多进程的影响。如果你用num_workers > 0的 DataLoader,worker 进程是 fork 出来的子进程,子进程会继承父进程的随机状态,但如果不额外处理,每个 worker 的随机序列可能相同,导致每个 epoch 打乱顺序一致。这一点在 3.3 节详细说。
3.3 DataLoader 的 worker 种子问题
DataLoader 是随机性重灾区。当你设置shuffle=True时,每个 epoch 打乱样本的顺序;当你设置num_workers > 0时,数据加载会分到多个子进程。这两者结合会产生几个隐蔽问题。
第一个问题是 worker 之间的种子冲突。默认情况下,所有 worker 继承相同的初始随机状态,如果 Dataset 的__getitem__里有随机增强,多个 worker 可能产生相同的增强结果。官方推荐的做法是在worker_init_fn里给每个 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, shuffle=True, num_workers=4, worker_init_fn=worker_init_fn, generator=torch.Generator().manual_seed(42) )torch.initial_seed()在 worker 里返回的是基种子加上 worker_id 派生的值,所以每个 worker 拿到的种子不同且可复现。注意generator参数也要指定,它控制的是主进程里 shuffle 用的随机源。
第二个问题是 epoch 之间的打乱。Pytorch 的 DataLoader 在不同 epoch 会使用不同的 shuffle 顺序,这个顺序由内部的随机状态推进决定。只要种子固定、epoch 数确定,每个 epoch 的顺序就是确定的。但如果你发现同一 epoch 在不同运行里顺序不同,多半是 worker 种子没处理好。
第三个问题是持久化 worker。设置persistent_workers=True可以避免每个 epoch 重建 worker,节省启动开销,但会让随机状态的跨 epoch 行为发生变化,需要重新验证复现性。
4. 进阶配置:多卡与混合精度下的坑
4.1 多卡训练种子的同步
单卡搞定了,多卡是另一个世界。DDP(分布式数据并行)下,每个进程有自己独立的随机状态。如果你只在主进程设种子,其余进程的种子可能不一致,导致权重初始化不同,训练一开始就分叉。
正确的做法是在每个进程里都调用set_seed,并且给每个进程设置不同的种子或者相同的种子取决于你的目标。通常为了可复现,我们希望所有进程的初始权重一致(由 rank 0 广播),但数据采样的随机性需要按 rank 区分。
def set_seed_ddp(seed, rank): # 所有进程 Python/NumPy 用各自种子,避免完全一致导致采样冲突 process_seed = seed + rank random.seed(process_seed) np.random.seed(process_seed) torch.manual_seed(seed) # torch 用统一种子保证模型初始化一致 torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False这里的权衡是:模型参数初始化要一致,所以 torch 的种子用统一的seed;而数据采样、随机增强要区分 rank,所以 Python 和 NumPy 用seed + rank。这只是一个常见方案,具体怎么分配要看你的数据采样逻辑。如果用 DistributedSampler,它本身就带 shuffle 和种子控制,可以通过sampler.set_epoch(epoch)来保证每个 epoch 的采样可复现。
4.2 DistributedSampler 的种子控制
DistributedSampler 是 DDP 训练里的标配。它负责把数据切分到各个进程,同时支持 shuffle。要让它可复现,有两个点:
- 创建时指定
seed参数; - 每个 epoch 开始调用
set_epoch(epoch)。
sampler = DistributedSampler( dataset, num_replicas=world_size, rank=rank, shuffle=True, seed=42 ) for epoch in range(epochs): sampler.set_epoch(epoch) # 关键,不调用每 epoch 打乱顺序会相同 for batch in loader: ...set_epoch的作用是把 epoch 编号混入 shuffle 种子,让每个 epoch 的打乱顺序不同。忘了调用它,所有 epoch 的数据顺序会完全一样,既不科学也影响收敛。这个坑我在早期项目里踩过,模型收敛明显变慢,排查半天才发现是没调set_epoch。
4.3 混合精度与确定性
用 AMP(自动混合精度)训练时,GradScaler 的行为也涉及随机性吗?严格说 GradScaler 本身不引入随机,它只是动态调整缩放因子。但混合精度会让部分算子走不同的实现路径,可能踩到非确定性算子。
另外,某些版本的 Pytorch 在混合精度下开cudnn.deterministic会报错,提示某算子没有确定性实现。遇到这种情况,可以试试设置torch.use_deterministic_algorithms(True),然后用torch.utils.deterministic.fill_uninitialized_memory等辅助开关。但要提前做好心理准备:开了这个总开关,很可能有一堆算子直接抛异常,需要逐个改。
我的经验是,如果混合精度和完全确定性冲突且难以两全,可以退一步:固定所有可固定的随机源,接受 cuDNN 层面的微小不确定性,通过多次运行取平均或设置容差来应对。论文复现场景下,准确率差异在 0.1% 以内通常可以接受。
5. 完整代码模板与运行验证
5.1 一份可直接抄的训练脚本骨架
把前面的内容整合成一个可复用的模板。这份代码我在这几个月的项目里一直在用,单卡场景直接可用:
import os import random import numpy as np import torch import torch.nn as nn from torch.utils.data import DataLoader def set_seed(seed=42, deterministic=True): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.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 # 可选:强制确定性算法,遇到不支持的算子会报错 # torch.use_deterministic_algorithms(True) def worker_init_fn(worker_id): worker_seed = torch.initial_seed() % 2**32 np.random.seed(worker_seed) random.seed(worker_seed) def build_dataloader(dataset, batch_size=32, num_workers=4, seed=42): generator = torch.Generator() generator.manual_seed(seed) return DataLoader( dataset, batch_size=batch_size, shuffle=True, num_workers=num_workers, worker_init_fn=worker_init_fn, generator=generator, pin_memory=True ) class SimpleNet(nn.Module): def __init__(self, in_dim=784, hidden=256, out_dim=10): super().__init__() self.net = nn.Sequential( nn.Linear(in_dim, hidden), nn.ReLU(), nn.Dropout(0.5), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, out_dim) ) def forward(self, x): return self.net(x) def train_one_run(seed): set_seed(seed) # 这里替换成你自己的数据集 # dataset = YourDataset(...) # loader = build_dataloader(dataset, seed=seed) model = SimpleNet() model = model.cuda() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() # 训练循环省略,重点是随机源已在创建模型前固定 print(f"seed={seed}, init weight sum={sum(p.sum().item() for p in model.parameters()):.4f}") return model if __name__ == "__main__": w1 = train_one_run(42) w2 = train_one_run(42) print("两次初始化是否一致:", all(torch.equal(a, b) for a, b in zip(w1.parameters(), w2.parameters())))跑一下这个脚本,你会看到两次初始化完全一致,权重和也相同。这是验证种子生效最直接的方法。
5.2 用权重和诊断复现性
怎么快速判断种子到底生效了没有?我最常用的诊断手段是打印模型参数的校验和,也就是所有权重张量的和或者哈希值。如果两次运行的初始校验和一致,说明初始化随机源控制住了;如果一致但训练后结果不同,问题多半出在数据加载或 cuDNN 上。
进一步,可以在每个 epoch 结束时打印一个 batch 的 loss,同时记录一个固定 batch 的预测结果。如果这些数值在两次运行里逐位相同,复现就成功了;如果只在小数点后好几位才分叉,那是浮点累积误差,属于正常范围,可以放宽比较标准。
下面这个对比表总结了不同诊断层次的判断方法:
| 诊断对象 | 对比方式 | 正常运行预期 | 排查指向 |
|---|---|---|---|
| 初始化权重 | 参数和/哈希 | 完全相同 | 不一致查 torch 种子 |
| 第一个 batch 数据 | 张量 equality | 完全相同 | 不一致查 DataLoader/worker |
| 训练 loss 曲线 | 逐 epoch 对比 | 前期相同后期微差 | 差异大查 cuDNN |
| 最终精度 | 绝对值比较 | 差异 < 0.1% | 差异大查全流程 |
| 梯度值 | 逐层对比 | 相同或极接近 | 不一致查反向算子 |
5.3 容差比较的实用技巧
要求两个浮点数逐位相同在深度学习里往往不现实,尤其是长期训练后。我的做法是写一个小工具函数做容差比较:
def tensors_close(a, b, atol=1e-6, rtol=1e-5): return torch.allclose(a, b, atol=atol, rtol=rtol) def compare_models(m1, m2, atol=1e-6): for (n1, p1), (n2, p2) in zip(m1.named_parameters(), m2.named_parameters()): if not tensors_close(p1, p2, atol=atol): print(f"参数 {n1} 不一致, 最大差异: {(p1-p2).abs().max().item()}") return False return Trueatol设为 1e-6、rtol设为 1e-5 是比较稳妥的默认值。如果发现某些层差异特别大,可以针对性放宽。记住一个经验:差异如果随时间指数增长,说明有非确定性源;如果保持恒定水平,多半只是浮点顺序导致的误差。
6. 常见问题排查与避坑清单
6.1 设了种子结果还是不稳定
这是最高频的问题,没有之一。按下面的顺序逐条排查,基本能定位到原因。
- 检查是否所有随机源都设了:Python、NumPy、torch CPU、torch CUDA、环境变量 PYTHONHASHSEED。
- 检查设置顺序对不对:种子必须在任何随机操作之前设置,包括模型创建、DataLoader 创建。
- 检查 cuDNN 开关:
deterministic=True、benchmark=False。 - 检查 DataLoader 的 worker:是否设置了
worker_init_fn和generator。 - 检查是否用了多进程以外的随机库:某些增强库(如 albumentations 的部分版本)有自己的随机状态。
- 检查多卡:是否每个进程都设了种子。
- 检查数据加载顺序:如果用了
IterableDataset且数据源顺序不确定,种子也救不了。
有一类隐蔽情况是:设了benchmark=True。这个开关会让 cuDNN 在第一次运行时自动搜索最快算法并缓存,第二次运行可能选到不同算法。所以追求复现时benchmark必须关掉。
6.2 换机器就复现不了
Pytorch 在不同硬件、不同 CUDA/cuDNN 版本上,浮点运算的实现细节不同,比如某些归约操作在不同 GPU 上用不同的并行拆分方式,导致累加顺序不同,浮点误差就不同。这类差异即使种子完全一致也无法消除。
应对方法有三条。一是记录完整环境信息,包括 Pytorch 版本、CUDA 版本、cuDNN 版本、GPU 型号,复现时尽量对齐。二是用 Docker 或 Conda 锁定环境。三是结果比较用容差而非严格相等,同时把随机种子作为实验记录的一部分。
我在论文复现的时候养成一个习惯:在 README 里专门开一块"环境与复现"说明,写清楚版本信息、种子值、确定性开关,别人照着配就能得到接近的结果。这比事后被问"你怎么复现的"要省事得多。
6.3 常见问题速查表
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 初始化权重每次不同 | 只设了 CPU 种子 | 补上 cuda.manual_seed_all |
| 第一个 batch 数据不同 | DataLoader 没设 worker 种子 | 加 worker_init_fn 和 generator |
| 前期一致后期分叉 | cuDNN 非确定性算法 | 开 deterministic、关 benchmark |
| 多卡结果不可复现 | 部分进程没设种子 | 每个进程都调用 set_seed |
| 每个 epoch 顺序相同 | DistributedSampler 没 set_epoch | 每 epoch 调用 set_epoch |
| 开了确定性报错 | 算子在当前版本无确定性实现 | 替换算子或关闭总开关 |
| 换机器结果不同 | 硬件/版本差异 | 对齐环境或改用容差比较 |
| 数据增强结果不一致 | 增强库自带随机源 | 单独设置该库的随机种子 |
6.4 几个我踩过的坑
第一个坑是DataLoader 的 generator 忘了传。早期我只设了全局种子,没给 DataLoader 传 generator,结果 shuffle 顺序在某些版本下不受全局种子控制,导致每个 epoch 的数据顺序飘忽。补上 generator 之后才稳定。
第二个坑是随机种子设在了模型定义之后。有一次我把set_seed写在了model = ResNet()后面,结果每次初始化权重都不同。这个错误特别隐蔽,因为代码能跑,只有对比两次初始化才发现问题。后来我把set_seed放在了main函数第一行,杜绝这类问题。
第三个坑是多进程数据加载的 fork 行为。Linux 上默认用 fork 创建 worker,子进程继承父进程状态;Windows 上用 spawn,行为不同。如果你的代码跨平台,要注意 spawn 模式下 worker 会重新 import 模块,种子设置可能重复执行。稳妥做法是用worker_init_fn显式控制。
第四个坑是环境变量 PYTHONHASHSEED 没生效。在 Python 代码里用os.environ设置这个变量,只对后续启动的子进程有效,对当前进程已经初始化的哈希不起作用。要真正生效,得在命令行前置设置,比如PYTHONHASHSEED=42 python train.py。
6.5 关于种子的几个误解
误解一:种子设一次就够了。实际上每个涉及随机的库、每个进程、每个 worker 都要管,尤其是分布式和多 worker 场景。
误解二:种子相同结果必然完全相同。在深度学习里,由于浮点运算和硬件差异,做到逐位相同很难,通常只能做到统计意义上接近。把"可复现"理解为"差异可控"更现实。
误解三:开了确定性训练就慢了所以不值得。对于大部分中小模型,性能损失可以接受;只有在大模型、大 batch 场景下代价才明显。分场景决策比一刀切更好。
误解四:种子值越大越好或者要选特殊的数。种子值本质上只是一个起点,42、0、1234 都行,关键是要记录在实验配置里。我习惯用 42,纯粹是图个方便。
7. 工程化落地的一些实践体会
7.1 把种子纳入实验管理
种子不该是散落在代码里的魔法数字,而应该是实验配置的一部分。我现在的做法是用一个配置字典或者配置文件统一管理:
config = { "seed": 42, "deterministic": True, "num_workers": 4, "batch_size": 32, "lr": 1e-3, "epochs": 100 }每次实验保存一份完整的 config,包括种子值。这样回溯的时候能精确知道当时用的什么设置。配合 WandB、TensorBoard 或者简单的 JSON 日志,管理起来很省心。
做多次实验取平均时,我会用一组固定的种子列表,比如[42, 43, 44, 45, 46],分别跑然后统计均值和方差。这样报告的指标比单次跑更可信,也避免了"这个结果是不是运气好"的质疑。
7.2 什么时候该放弃严格复现
有些场景下追求完全复现是得不偿失的。比如:
- 大规模分布式训练,节点数多、通信复杂,完全确定性代价过高;
- 使用了第三方 CUDA 算子或自定义扩展,内部随机性不可控;
- 数据本身有在线生成、流式读取等特性,天然不可复现;
- 探索性研究阶段,重点在快速试错而非精确复现。
这些情况下我会退而求其次:固定能固定的随机源,记录完整实验条件,用统计方法评估结果稳定性。与其纠结于逐位相同,不如把精力放在结果的可信度评估上。
7.3 小项目里最简单的做法
如果你只是跑个小实验、做个课程作业、验证个想法,不需要搞那么复杂。最低限度记住三条:在训练脚本开头调用set_seed;DataLoader 传worker_init_fn和generator;把种子值写进日志。这三条做到,日常复现需求基本能满足。cuDNN 的确定性开关开不开,看你对结果稳定性的要求,开了更稳但慢一点,不开也能用。
我个人在实际操作中的体会是,随机种子这个事,说简单也简单,说复杂也复杂。简单在于核心就那几行代码,复杂在于覆盖要全、场景要分、排查要有章法。把这篇文章里的流程走一遍,把三个常见坑(generator 漏传、种子设晚了、多进程没管)避开,你在 Pytorch 复现这条路上就算入门了。后面遇到新的随机源,按同样的思路——找到它、固定它、验证它——基本都能解决。