随机种子与 DataLoader:shuffle 和 sampler 的秘密

这一讲,我们聊聊一个让很多人头疼的问题——随机种子是怎么影响数据加载顺序的。

说实话,我之前带过好几个项目组,大家刚开始都会忽略这个细节。直到有一次,一个同学发现每次训练出来的模型指标波动特别大,排查了两天才发现——原来是 DataLoader 的 shuffle 没有固定种子。嗯,这里面的坑,我今天一次给你讲清楚。

DataLoader 的 shuffle 是怎么工作的?

先看最基础的情况。你用 PyTorch 的 DataLoader 加载数据,设置 shuffle=True。这时,框架会在每个 epoch 开始时,对数据集的索引进行一次随机排列。

这个随机排列的底层逻辑,靠的就是 Python 内置的随机数生成器,或者 NumPy 的随机状态。如果你不设置全局种子,那每次运行的结果都不一样。

核心结论:shuffle=True 的行为,直接受全局随机种子影响。

如果你的训练脚本没有在开头设置 torch.manual_seed(42),那每次启动训练,数据的喂入顺序都不同。

看个最直接的例子:

import torch
from torch.utils.data import DataLoader, TensorDataset

# 创建一个简单数据集
data = TensorDataset(torch.arange(20))
loader = DataLoader(data, batch_size=5, shuffle=True)

# 第一次运行
print("第一次遍历结果:")
for batch in loader:
    print(batch[0].tolist())

print("\n第二次遍历结果(同一进程内):")
for batch in loader:
    print(batch[0].tolist())

同一个进程内,每次遍历 DataLoader,shuffle 都会重新生成新的随机顺序。这很关键——同一个 epoch 内部,每个 batch 的顺序是确定的吗? 不,恰恰相反,每次遍历都会重新洗牌。

Sampler:真正控制数据顺序的幕后角色

DataLoader 的 shuffle 参数,本质上是在帮你创建一个 RandomSampler。

我建议你记住这张对应表:

DataLoader 参数 实际使用的 Sampler 种子控制方式
shuffle=False SequentialSampler 不需要种子,顺序固定
shuffle=True RandomSampler 依赖全局随机种子,或显式传入 generator
自定义 sampler 传入 你给的 sampler 由 sampler 自身控制

想自己控制种子?我给你两个办法:

方法一:设置全局种子

在脚本最开头调用:

torch.manual_seed(42)
# 如果用了 NumPy 操作
import numpy as np
np.random.seed(42)
# 如果用了 Python 随机
import random
random.seed(42)

方法二:显式传入 generator

这是更推荐的做法——精细控制,不污染全局状态:

g = torch.Generator()
g.manual_seed(42)

loader = DataLoader(dataset, batch_size=32, shuffle=True, generator=g)

一个实用技巧:

如果你希望每个 epoch 的洗牌方式不同,但又要保证实验可复现,可以为每个 epoch 创建一个新的 generator:

for epoch in range(num_epochs):
    g = torch.Generator()
    g.manual_seed(42 + epoch)
    loader = DataLoader(dataset, batch_size=32, 
                        shuffle=True, generator=g)

这样做,每个 epoch 的数据顺序都是确定的,但不同 epoch 之间又不同。

多 Worker 情况下的种子传播

这里有个大坑——我当年在分布式训练时踩过。

当你设置 num_workers=4 时,DataLoader 会启动多个子进程来预取数据。每个子进程都会复制主进程的随机种子。如果你只设了主进程的种子,多个 worker 就可能生成相同的数据顺序。

PyTorch 的设计是:每个 worker 启动时,会调用 worker_init_fn 来重新设置种子,确保各 worker 的随机序列独立。默认实现是这样:

def _worker_init_fn(worker_id):
    torch.manual_seed(torch.initial_seed() + worker_id)
    np.random.seed(torch.initial_seed() + worker_id)
    random.seed(torch.initial_seed() + worker_id)

但是,如果你覆盖了 worker_init_fn,就一定要小心:

def my_worker_init_fn(worker_id):
    # 错误的做法:所有 worker 都用同一个种子
    # torch.manual_seed(42)  
    
    # 正确的做法:确保每个 worker 的种子不同
    torch.manual_seed(42 + worker_id)

loader = DataLoader(dataset, batch_size=32, 
                    num_workers=4,
                    worker_init_fn=my_worker_init_fn)

我曾经踩过的坑:

有一次我写了一个自定义的数据增强,里面用了 Python 的 random 模块。我没设置 worker_init_fn,结果所有 worker 的数据增强都做了完全相同的变换。模型训练了 50 个 epoch,验证集上的表现始终上不去。排查了两天,才发现是 worker 之间种子冲突,每个 epoch 看到的数据都被增强了同一批图像。

SubsetRandomSampler 的种子问题

我们在做数据集划分时,经常用 SubsetRandomSampler。它也需要关注种子:

from torch.utils.data import SubsetRandomSampler

# 划分训练集和验证集
indices = list(range(len(dataset)))
train_indices = indices[:800]
val_indices = indices[800:]

# 如果这里不设种子,每次划分结果不同
np.random.seed(42)
np.random.shuffle(indices)

train_sampler = SubsetRandomSampler(train_indices)
val_sampler = SubsetRandomSampler(val_indices)

train_loader = DataLoader(dataset, sampler=train_sampler, batch_size=32)
val_loader = DataLoader(dataset, sampler=val_sampler, batch_size=32)

看到了吗?SubsetRandomSampler 在初始化时,会进行一次内部洗牌。这也依赖全局随机状态。

你想保持严格的可复现性?我建议你这样做:

# 创建独立的 generator
g = torch.Generator()
g.manual_seed(42)

sampler = SubsetRandomSampler(indices, generator=g)
loader = DataLoader(dataset, sampler=sampler, batch_size=32)

知识结构一览

我把这一章的核心逻辑画了出来。你可以对照着看,理解 DataLoader 内部的数据流和种子影响点:

DataLoader 数据流与种子影响点 原始 Dataset Sampler 决定索引顺序 SequentialSampler (shuffle=False) RandomSampler (shuffle=True,受种子控制) 自定义 Sampler (种子由实现决定) DataLoader 多 Worker 并行加载 种子作用点 • torch.manual_seed() • np.random.seed() • Generator.manual_seed() 容易踩的坑 • worker 间种子冲突 • 不同进程种子重复 • 数据增强不一致

一个完整的实验对比

最后,我用一个实际例子帮你直观理解:同一个数据集,三种不同的种子设置,数据加载顺序到底差多少。

import torch
from torch.utils.data import DataLoader, TensorDataset

dataset = TensorDataset(torch.arange(10))

# 实验1:不设置种子
print("=== 实验1:无种子 ===")
loader1 = DataLoader(dataset, batch_size=3, shuffle=True)
for i, batch in enumerate(loader1):
    print(f"Batch {i}: {batch[0].tolist()}")

# 实验2:设置全局种子
print("\n=== 实验2:全局种子 42 ===")
torch.manual_seed(42)
loader2 = DataLoader(dataset, batch_size=3, shuffle=True)
for i, batch in enumerate(loader2):
    print(f"Batch {i}: {batch[0].tolist()}")

# 实验3:使用 generator 控制
print("\n=== 实验3:generator 种子 42 ===")
g = torch.Generator()
g.manual_seed(42)
loader3 = DataLoader(dataset, batch_size=3, shuffle=True, generator=g)
for i, batch in enumerate(loader3):
    print(f"Batch {i}: {batch[0].tolist()}")

实验结论:

实验2和实验3每次运行的结果完全相同。实验1每次运行都不同。

如果你在不同机器或不同进程间做对比实验,务必固定种子,否则数据加载顺序的差异会引入额外噪声,让你误判模型改进的效果。

好了,关于 DataLoader 的种子控制,核心要点就这些。记住 Sampler 是真正的主角,种子是控制它的钥匙。下回你的实验结果波动太大,先查查 DataLoader 的种子设了没。