一、先聊聊复现性这件事
你有没有遇到过这种情况:昨天在实验室里跑了一个晚上,终于得到一个漂亮的准确率,今天想再复现一下,结果同样的代码、同样的数据集,出来的结果却差了一截?或者,你在调参的时候,固定了所有超参数,却发现每次运行数值都不一样,搞得你分不清是模型问题还是运气问题。
这不是你的错,这是PyTorch里的“随机性”在捣乱。神经网络训练本身就充满了随机性,比如参数初始化、数据打乱顺序、甚至GPU上的某些运算。这些随机性在有的时候无关紧要,但在做科研、写论文、或者需要严格对比实验的时候,就成了让人头疼的大问题。这就是大家常说的“复现性危机”。
想要解决它,第一步自然是“设置seed”。但很多人设了seed之后,发现事情并没有变得完美。原因很简单:PyTorch的随机源不止一个,光靠torch.manual_seed只能管住一部分,还有藏在cudnn和DataLoader里的“隐藏随机性”在偷偷作祟。今天我们就来把这些问题一个个揪出来,看看怎么才能真正让实验固定下来。
二、手动设置seed到底设置了什么
2.1 基础seed设置
在PyTorch里,最常见的“仪式”是这样一段代码:
# 技术栈:Python + PyTorch
import random
import numpy as np
import torch
def set_seed(seed=42):
"""统一设置所有基础的随机种子"""
random.seed(seed) # Python内置random库
np.random.seed(seed) # NumPy随机库
torch.manual_seed(seed) # PyTorch CPU随机源
torch.cuda.manual_seed(seed) # 当前GPU的随机源
torch.cuda.manual_seed_all(seed) # 所有GPU的随机源(多卡时用)
这段代码看起来已经很全面了,它把Python、NumPy、PyTorch CPU和GPU的种子都设了一遍。但你会发现,如果你在跑模型之前只做了这一步,结果还是不太稳定。尤其是当你的模型里有卷积层,或者你的数据加载用了多个进程的时候,随机性依然存在。
2.2 为什么还不够
问题出在两个地方:
- 第一个是GPU上的CuDNN库。CuDNN是NVIDIA提供的深度学习加速库,PyTorch用它在GPU上跑卷积、池化等操作。CuDNN为了追求性能,会自动选择“最快”的算法,但这个选择过程不是确定性的。换句话说,哪怕输入完全一样,CuDNN在不同时间可能选出不同的算法,导致结果有细微差别。
- 第二个是DataLoader的随机打乱。DataLoader在加载数据时会shuffle数据集,这个shuffle用到的随机数生成器如果没被固定,那你每次遍历数据的顺序就都不一样。而且当DataLoader启用多进程(num_workers > 0)时,每个worker会有自己的随机状态,处理起来更复杂。
下面我们来分别解决它们。
三、cudnn的隐藏随机性
3.1 什么是cudnn.benchmark
PyTorch里有两个和CuDNN相关的开关,一个叫torch.backends.cudnn.benchmark,一个叫torch.backends.cudnn.deterministic。
benchmark开关默认是False。当它为True时,PyTorch会在第一次运行卷积时做一次“基准测试”,测试多个算法,然后选一个最快的。这个“最快”的评估本身可能引入随机性,而且不同的GPU、不同的驱动版本,选出的算法也可能不一样。所以,如果要做实验复现,通常建议把benchmark关掉,或者至少理解它带来的影响。
deterministic开关就是用来强制CuDNN使用确定性算法的。当它是True时,CuDNN会放弃那些非确定性的算法,只保留每次计算结果一致的算法。当然,这可能会牺牲一些速度。
3.2 如何锁定cudnn
锁定方法很简单,在训练脚本开头加上两行配置:
# 技术栈:Python + PyTorch
import torch
# 关闭自动选择最快卷积算法
torch.backends.cudnn.benchmark = False
# 强制使用确定性算法
torch.backends.cudnn.deterministic = True
这两行要放在模型训练之前,最好在导入PyTorch后立刻设置。这样,CuDNN就会按照一个固定的算法路径来执行,每次跑出来的卷积结果就一致了。
但要注意,deterministic = True不一定能在所有操作上生效。某些PyTorch算子(比如某些池化、采样操作)可能在确定性模式下会报错,或者不支持。这时你会看到类似“RuntimeError: determinant is not implemented”之类的提示。遇到这种情况,你需要检查网络结构里有没有不支持的层,必要时换一种实现方式。
3.3 技术优缺点
开启确定性模式的好处非常明显:结果稳定,能复现。代价是性能损失。根据模型的不同,速度可能下降5%到30%不等。如果只是跑一遍实验验证效果,这损失无所谓;但如果你要训练一个大型模型几周,为了复现而牺牲速度可能不太划算。所以,我个人的习惯是:在做实验对比、写论文时,开启确定性;在跑正式的生产模型或大规模训练时,关闭它,让性能优先。
四、dataloader的隐藏随机性
4.1 shuffle的随机源
DataLoader是PyTorch用来加载数据的工具。你可以在DataLoader里设置shuffle=True让每个epoch自动打乱数据。这个打乱过程依赖一个随机数生成器,默认情况下它使用的是全局的torch随机源。如果你只设置了torch.manual_seed,那么第一次shuffle的顺序其实是固定的,但要注意,如果你在训练过程中手动改变过torch的随机状态,或者使用多个数据加载器,可能会出现干扰。
一个更稳妥的办法是给DataLoader专门分配一个generator,并给这个generator单独设置种子。这样,即使你的主随机源被其他操作占用,数据加载的随机性也是可控的。
示例:
# 技术栈:Python + PyTorch
import torch
from torch.utils.data import DataLoader, TensorDataset
# 随便构造一个数据集
data = torch.randn(100, 3)
labels = torch.randint(0, 2, (100,))
dataset = TensorDataset(data, labels)
# 为DataLoader单独创建一个generator,并固定种子
g = torch.Generator()
g.manual_seed(2024)
# 将generator传给DataLoader,shuffle=True时它就会被使用
loader = DataLoader(dataset, batch_size=16, shuffle=True, generator=g)
# 反复遍历两次,顺序完全一致
for epoch in range(2):
order = []
for x, y in loader:
order.append(x[0].item()) # 记录每个batch的第一个样本编号
print(f"Epoch {epoch}: {order[:5]}") # 只看前几个
运行一下你会发现,两次遍历的顺序一模一样。这就是把generator单独隔离出来的好处。
4.2 多进程worker的随机性
当你设置num_workers > 0时,DataLoader会启动多个子进程来并行加载数据。每个子进程在启动时都会继承主进程的随机状态,但它们之间的初始状态可能相同,也可能不同。而且,如果worker在加载数据时用了NumPy或者Python的random,这些随机源并没有被你的torch.manual_seed直接控制。所以,光有上面的generator还不够。
最简单的做法是设置num_workers=0。这会让数据加载在主进程里完成,随机性完全由generator控制,复现最方便。缺点就是数据加载速度慢,可能会拖慢训练。
如果一定要用多进程,就需要配合worker_init_fn。它会在每个worker启动时被调用,你可以在这里给每个worker设置不同的种子,以避免所有worker产生相同的数据增强路径。更关键的是,为了复现,最好让每个worker的种子也固定下来。
示例:
# 技术栈:Python + PyTorch
import random
import numpy as np
import torch
from torch.utils.data import DataLoader, TensorDataset
# 数据集
data = torch.randn(100, 3)
labels = torch.randint(0, 2, (100,))
dataset = TensorDataset(data, labels)
def seed_worker(worker_id):
"""每个worker启动时会调用这个函数,固定该worker的随机源"""
# 这里使用一个固定的基础种子,再结合worker_id区分
worker_seed = torch.initial_seed() % 2**32
random.seed(worker_seed + worker_id)
np.random.seed(worker_seed + worker_id)
# 主进程里的generator
g = torch.Generator()
g.manual_seed(42)
# 注意:generator仍然要设置,并且传给DataLoader
loader = DataLoader(
dataset,
batch_size=16,
shuffle=True,
num_workers=4,
worker_init_fn=seed_worker, # 指定每个worker的初始化函数
generator=g,
)
这样设置之后,DataLoader的随机性就完全可控了。不过要注意,如果你在训练过程中使用了CUDA的torch.cuda.manual_seed,最好也在worker_init_fn里再设置一次,防止GPU上的随机性被worker影响。
五、完整可复现配置示例
把前面所有内容整合起来,一个完整的、可复现的训练配置大概是这样的。假设我们训练一个简单的卷积模型,代码如下:
# 技术栈:Python + PyTorch
# 完整可复现训练配置示例
import random
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
# ---------- 第1步:设置所有随机源 ----------
def set_seed(seed):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed) # 多卡时确保所有GPU种子一致
# ---------- 第2步:锁定CuDNN ----------
torch.backends.cudnn.benchmark = False # 关闭自动算法选择
torch.backends.cudnn.deterministic = True # 强制确定性算法
# ---------- 第3步:定义worker初始化函数 ----------
def seed_worker(worker_id):
"""固定每个DataLoader worker的随机状态"""
worker_seed = torch.initial_seed() % 2**32
random.seed(worker_seed + worker_id)
np.random.seed(worker_seed + worker_id)
# ---------- 第4步:数据集与DataLoader ----------
# 构造假的图像数据(假设是10通道,32x32)
data = torch.randn(64, 10, 32, 32)
labels = torch.randint(0, 5, (64,))
dataset = TensorDataset(data, labels)
# 为DataLoader单独创建generator
g = torch.Generator()
g.manual_seed(0)
train_loader = DataLoader(
dataset,
batch_size=16,
shuffle=True,
num_workers=2, # 演示多进程
worker_init_fn=seed_worker,
generator=g,
)
# ---------- 第5步:定义模型 ----------
class SimpleCNN(nn.Module):
def __init__(self):
super().__init__()
self.conv = nn.Conv2d(10, 20, kernel_size=3, padding=1)
self.fc = nn.Linear(20 * 32 * 32, 5)
def forward(self, x):
x = torch.relu(self.conv(x))
x = x.view(x.size(0), -1)
return self.fc(x)
# ---------- 第6步:训练一轮,验证可复现性 ----------
set_seed(42) # 设置主种子
model = SimpleCNN()
optimizer = optim.SGD(model.parameters(), lr=0.01)
loss_fn = nn.CrossEntropyLoss()
model.train()
for batch_idx, (inputs, targets) in enumerate(train_loader):
optimizer.zero_grad()
outputs = model(inputs)
loss = loss_fn(outputs, targets)
loss.backward()
optimizer.step()
# 打印第一个batch的loss,方便对比
if batch_idx == 0:
print(f"First batch loss: {loss.item():.6f}")
你连续跑两次这个脚本,第一次batch的loss值会完全一致。这就是“完全可复现”的意思。
六、应用场景与注意事项
6.1 应用场景
这种可复现配置主要用在几个场景里:
- 学术研究:论文里要报告结果,别人要看你的实验能不能复现。如果你连自己都复现不了,那结论就站不住脚。
- 模型调参对比:对比不同超参数的效果时,如果随机性太大,你很难判断准确率的变化到底是因为超参数,还是因为随机种子导致的波动。固定了随机源,对比才有意义。
- 自动化测试:在CI/CD流水线里跑模型测试,如果每次结果都一样,测试才稳定。
6.2 技术优缺点
优点很明显:确定性,可信,方便对比。
缺点也不容忽视:
- 性能损失:关闭
benchmark并开启deterministic,会让CuDNN放弃某些优化,训练速度可能会变慢。 - 兼容性问题:某些操作在确定性模式下不支持,可能报错。你需要检查网络层,或者改用其他实现。
- 多worker的配置复杂度:DataLoader加上
worker_init_fn和自定义generator后,代码变多了,初学者容易漏掉某一步,导致仍然无法复现。 - 不能完全覆盖所有随机源:PyTorch的某些扩展库(比如第三方数据增强库)可能还有自己的随机源,需要自己去对应处理。
6.3 注意事项
这里有几个容易踩的坑,提醒一下:
- 每次运行前都重置种子:不要只在脚本开头设一次,如果你在一个notebook里多次运行同一个训练,第二次运行前要重新设置所有种子,否则结果会变。
- PyTorch版本、GPU型号、驱动版本都会影响复现:换了一台机器,即使代码一样,结果也可能不同。复现性是在同一环境下保证的,跨环境无法完全一致。
- 多GPU训练时:除了设置
torch.cuda.manual_seed_all,还要注意分布式初始化可能带来的额外随机性,比如不同进程的seed分配。 - 不要在训练中途调用那些会消耗随机数的操作:比如你为了调试,在训练循环里调用
random.random(),这会影响后续的随机状态。最好把随机数消耗集中在一处,或者都用独立的generator。 - 确定性与
benchmark=False配套使用:如果你开了benchmark=True,即使deterministic=True也可能无效,因为基准选择过程本身就不确定。所以两个要一起设置。
七、文章总结
“复现性危机”听起来很吓人,但解决起来其实并不复杂。核心思想就是:把每一个可能的随机源都收编到“可控的种子”底下。基础的random、numpy、torch.manual_seed只是第一层;CuDNN的算法选择是第二层;DataLoader的shuffle和worker是第三层。三层都治理好了,你的训练结果才能真正固定下来。
当然,复现性不是免费的午餐,它换取了确定性,代价可能是速度和代码复杂度。所以在实际工作中,你要根据场景去权衡。做科研时,宁可慢一点,也要结果可靠;跑大规模生产模型时,也许就可以牺牲一点确定性,换来更快的迭代。
最后,记住一个简单的检查清单:设置所有基础seed,关闭cudnn benchmark并开启deterministic,给DataLoader单独配generator,多进程时加上worker_init_fn。做到这几点,你的PyTorch实验就不会再“随机”翻车了。
评论
围绕“复现性危机下的PyTorch随机源治理:手动设置seed后仍需锁定cudnn与dataloader的隐藏随机性”参与讨论