一、先聊聊复现性这件事

你有没有遇到过这种情况:昨天在实验室里跑了一个晚上,终于得到一个漂亮的准确率,今天想再复现一下,结果同样的代码、同样的数据集,出来的结果却差了一截?或者,你在调参的时候,固定了所有超参数,却发现每次运行数值都不一样,搞得你分不清是模型问题还是运气问题。

这不是你的错,这是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 注意事项

这里有几个容易踩的坑,提醒一下:

  1. 每次运行前都重置种子:不要只在脚本开头设一次,如果你在一个notebook里多次运行同一个训练,第二次运行前要重新设置所有种子,否则结果会变。
  2. PyTorch版本、GPU型号、驱动版本都会影响复现:换了一台机器,即使代码一样,结果也可能不同。复现性是在同一环境下保证的,跨环境无法完全一致。
  3. 多GPU训练时:除了设置torch.cuda.manual_seed_all,还要注意分布式初始化可能带来的额外随机性,比如不同进程的seed分配。
  4. 不要在训练中途调用那些会消耗随机数的操作:比如你为了调试,在训练循环里调用random.random(),这会影响后续的随机状态。最好把随机数消耗集中在一处,或者都用独立的generator。
  5. 确定性与benchmark=False配套使用:如果你开了benchmark=True,即使deterministic=True也可能无效,因为基准选择过程本身就不确定。所以两个要一起设置。

七、文章总结

“复现性危机”听起来很吓人,但解决起来其实并不复杂。核心思想就是:把每一个可能的随机源都收编到“可控的种子”底下。基础的randomnumpytorch.manual_seed只是第一层;CuDNN的算法选择是第二层;DataLoader的shuffle和worker是第三层。三层都治理好了,你的训练结果才能真正固定下来。

当然,复现性不是免费的午餐,它换取了确定性,代价可能是速度和代码复杂度。所以在实际工作中,你要根据场景去权衡。做科研时,宁可慢一点,也要结果可靠;跑大规模生产模型时,也许就可以牺牲一点确定性,换来更快的迭代。

最后,记住一个简单的检查清单:设置所有基础seed,关闭cudnn benchmark并开启deterministic,给DataLoader单独配generator,多进程时加上worker_init_fn。做到这几点,你的PyTorch实验就不会再“随机”翻车了。