上一课你从零写出了完整训练循环:取一批数据 → 前向 → 算损失 → 反向 → 更新。但那时的"取一批数据"是手工切片的玩具数组。真实训练里,数据可能是几十万张图片、上百万条句子,长度还各不相同;它们要被高效、正确地源源不断喂进训练循环。本课就补上这块拼图:用 Dataset 描述"一条样本长什么样",用 DataLoader 描述"怎么把样本攒成一个 batch"。数据管线是训练里最容易被忽视、却最常成为瓶颈的一环——GPU 再快,数据喂不上也是空转。
读完这一课,你将能够
- 说清
Dataset协议(__len__与__getitem__)和DataLoader的四步流水线分别负责什么。 - 从零手写一个可 shuffle、按 batch 切片的迭代器,逐 batch 打印 shape。
- 写出处理变长序列的
collate_fn:右侧 pad 到同长,并生成 0/1 的注意力掩码(attention mask)。 - 判断 transform / 数据增强该加在训练集还是验证集,并解释为什么。
- 正确理解
num_workers与pin_memory,避免把它们当成"无脑调大就快"的旋钮。
Dataset:把"第 i 条样本"封装成一个对象
机制直觉。一个 Dataset 本质上只是一本"按编号取样本的字典"。它只需回答两个问题:一共有多少条(__len__)、第 i 条是什么(__getitem__(i))。注意 __getitem__ 返回的是单条样本(比如一个 (特征, 标签) 元组),不是一批。攒成 batch 是 DataLoader 的活,分工要清楚。
这套"实现两个方法就能被框架使用"的约定叫协议(protocol,协议)。你不需要继承什么神奇基类,只要对象有 __len__ 和 __getitem__,DataLoader 就能用它——这正是 Python 鸭子类型的体现(更正式地说,这属于结构化类型 structural typing,本课不展开),和你上一课见到的 nn.Module 用 __call__ 的思路一脉相承。
最小手写。下面这个 ToyTextDataset 装的是已经被转成整数 id 的句子(真实 NLP 里这一步叫 tokenize,即分词,把文本切成 token id;本课先用现成 id)。注意每条句子长度不同——这正是后面 collate_fn 要解决的麻烦。
class ToyTextDataset:
def __init__(self, sentences, labels):
self.sentences, self.labels = sentences, labels
def __len__(self): # 协议方法一:样本总数
return len(self.sentences)
def __getitem__(self, i): # 协议方法二:返回第 i 条(单条)
return self.sentences[i], self.labels[i]
sents = [[5,7,9], [2], [4,4,4,8,1], [3,6]] # 4 条变长句子(已转成 id)
labels = [1, 0, 1, 0]
ds = ToyTextDataset(sents, labels)
print("len(ds) =", len(ds)) # -> 4
print("ds[2] =", ds[2]) # -> ([4, 4, 4, 8, 1], 1) 单条样本
print("ds[1] =", ds[1]) # -> ([2], 0)
真实 API 当指针。PyTorch 里你继承 torch.utils.data.Dataset,实现同样的两个方法。基类几乎不做事,它的价值是给 DataLoader 一个统一的类型约定。
from torch.utils.data import Dataset
class TextDataset(Dataset):
def __init__(self, sentences, labels):
self.sentences, self.labels = sentences, labels
def __len__(self):
return len(self.sentences)
def __getitem__(self, idx):
return self.sentences[idx], self.labels[idx]
DataLoader:一条四步流水线
机制直觉。DataLoader 把零散样本变成成批张量,每一批走四步:
- 采样索引:决定这一批取哪几个编号。
shuffle=True时每个 epoch 先把[0, 1, …, N-1]打乱再切片。 - 取样本:对采到的每个索引调一次
dataset[i],得到一个样本列表。默认是主进程串行取(num_workers=0);开num_workers>0是对这一步的可选加速,交给多个子进程并行去取(与下面 pin_memory 的"可选"并列,详见"隐形瓶颈"节)。 - collate(拼接):把这个样本列表拼成一个 batch 张量。这是本课的重点,下一节细讲。
- (可选)pin_memory:把拼好的张量放进锁页内存,方便后续更快拷到 GPU(下一课细讲)。
关键参数速记:batch_size(每批几条)、shuffle(是否每 epoch 打乱,训练开、验证关)、num_workers(几个子进程并行取数据,默认 0 即串行)、collate_fn(怎么拼 batch)、pin_memory(是否锁页加速传 GPU)。
最小手写。我们自己实现这条流水线的前三步。注意 shuffle 只打乱索引,不动原始数据;用一个带种子的随机源保证可复现(呼应本模块"可复现"主线)。collate_fn 先留个简单版本,下一节升级。
import random
class DataLoader:
def __init__(self, dataset, batch_size, shuffle=False,
collate_fn=None, seed=0):
self.ds, self.bs = dataset, batch_size
self.shuffle = shuffle
self.collate_fn = collate_fn
self.rng = random.Random(seed) # 可复现的打乱
def __iter__(self):
idx = list(range(len(self.ds))) # 步骤1: 索引
if self.shuffle:
self.rng.shuffle(idx) # 每 epoch 打乱索引
for k in range(0, len(idx), self.bs): # 步骤2: 按 batch_size 切片
chunk = idx[k:k + self.bs]
batch = [self.ds[i] for i in chunk] # 逐个调 __getitem__
yield self.collate_fn(batch) if self.collate_fn else batch
# 没有 collate_fn 时,直接吐出"样本列表"——还不是张量
loader = DataLoader(ds, batch_size=2, shuffle=False)
for b, batch in enumerate(loader):
print(f"batch {b}: {batch}")
# batch 0: [([5, 7, 9], 1), ([2], 0)] <- 两条长度不一,没法直接堆成矩阵!
# batch 1: [([4, 4, 4, 8, 1], 1), ([3, 6], 0)]
看最后两行注释:一批里两条样本长度不同,numpy 没法把 [5,7,9] 和 [2] 直接堆成一个规整矩阵。这正是默认 collate 遇到变长会直接报错的原因——下一节解决它。
collate_fn:把变长序列 pad 成等长 + 生成 mask【重点】
机制直觉。神经网络吃的是规整的张量,一个 batch 必须是形如 (B, L) 的矩阵,每行等长。但自然语言里句子天生不等长。解决办法是填充(padding,填充):选一个特殊的 pad id(通常是 0),把这一批里较短的句子右侧补 0,补到和最长的那条一样长。
但这带来一个新问题:模型会把补出来的 0 当成真 token 去算注意力、算损失,污染结果。所以我们额外生成一个注意力掩码(attention mask,注意力掩码),形状和 batch 一样:真实 token 处标 1,padding 处标 0。模型靠它区分"哪些位置是真的、哪些是凑数的"。记住:pad 和 mask 是一对,永远成对出现。
一个常被忽视的细节:pad 的目标长度是这一批的最长句,不是全局最长句。所以不同 batch 的矩阵宽度可以不同(这叫 dynamic padding,比 pad 到全局最长省算力)。下面运行时你会看到不同 batch 的 X.shape 第二维确实不一样。
核心手写(完整可跑)。把 Dataset + DataLoader + collate_fn 三件套接起来,逐 batch 打印 shape 和 mask(其中 X[r, :len(s)] = s 用的就是模块 2 的切片赋值):
import numpy as np, random
class ToyTextDataset:
def __init__(self, sentences, labels):
self.sentences, self.labels = sentences, labels
def __len__(self): return len(self.sentences)
def __getitem__(self, i): return self.sentences[i], self.labels[i]
def collate_fn(batch, pad_id=0):
seqs = [b[0] for b in batch]
labels = [b[1] for b in batch]
maxlen = max(len(s) for s in seqs) # 只 pad 到"本批最长"
X = np.full((len(seqs), maxlen), pad_id, dtype=np.int64)
mask = np.zeros((len(seqs), maxlen), dtype=np.int64)
for r, s in enumerate(seqs):
X[r, :len(s)] = s # 真实 token 写进去
mask[r, :len(s)] = 1 # 对应位置 mask 标 1
return X, mask, np.array(labels, dtype=np.int64)
class DataLoader:
def __init__(self, ds, batch_size, shuffle=False, collate_fn=None, seed=0):
self.ds, self.bs, self.shuffle = ds, batch_size, shuffle
self.collate_fn, self.rng = collate_fn, random.Random(seed)
def __iter__(self):
idx = list(range(len(self.ds)))
if self.shuffle: self.rng.shuffle(idx)
for k in range(0, len(idx), self.bs):
batch = [self.ds[i] for i in idx[k:k+self.bs]]
yield self.collate_fn(batch)
ds = ToyTextDataset([[5,7,9],[2],[4,4,4,8,1],[3,6]], [1,0,1,0])
loader = DataLoader(ds, batch_size=2, shuffle=True, collate_fn=collate_fn, seed=42)
for b, (X, mask, y) in enumerate(loader):
print(f"batch {b}: X.shape={X.shape}, y={y.tolist()}")
print(" X =", X.tolist())
print(" mask=", mask.tolist())
运行后你会看到两批的宽度不同(一批 pad 到 5,一批 pad 到 3),每条短句右侧是 0,对应 mask 也是 0——pad 与 mask 严丝合缝地对齐。
真实 API 当指针。PyTorch 的 DataLoader 接受一个 collate_fn 参数,签名就是"收一个样本列表,返回拼好的 batch"。变长序列常用 pad_sequence 来填充:
import torch
from torch.utils.data import DataLoader
from torch.nn.utils.rnn import pad_sequence
def collate_fn(batch):
seqs = [torch.tensor(s) for s, _ in batch]
labels = torch.tensor([y for _, y in batch])
X = pad_sequence(seqs, batch_first=True, padding_value=0) # (B, L)
mask = (X != 0).long() # 0 处为 padding
return X, mask, labels
loader = DataLoader(ds, batch_size=2, shuffle=True,
num_workers=4, pin_memory=True,
collate_fn=collate_fn)
for X, mask, y in loader:
... # X.shape == (B, L),mask 与之同形
transform 与数据增强:只加在训练集
机制直觉。预处理可以分两类。一类是确定性的、保证输入格式正确的步骤(缩放到固定尺寸、按训练集均值方差归一化),训练集和验证集都要用,而且必须一致。另一类是随机数据增强(random augmentation,随机数据增强,如随机水平翻转、随机裁剪、加噪声),目的是人为制造多样性、防止模型死记硬背训练样本——它只加在训练集。
为什么验证集不能加随机增强?因为验证的使命是"在固定、干净的数据上量出模型真实水平"。如果每次验证时图片都随机翻转裁剪,你的验证指标会带上随机抖动,无法跨 epoch 比较,也就失去了判断"模型是否变好"的意义。验证集要的是可复现的、确定的预处理。
from torchvision import transforms
# 训练集:确定性预处理 + 随机增强
train_tf = transforms.Compose([
transforms.RandomResizedCrop(224), # 随机裁剪(增强)
transforms.RandomHorizontalFlip(), # 随机翻转(增强)
transforms.ToTensor(),
transforms.Normalize(MEAN, STD), # 确定性
])
# 验证集:只有确定性预处理,没有任何随机
val_tf = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224), # 固定中心裁剪,非随机
transforms.ToTensor(),
transforms.Normalize(MEAN, STD), # 与训练用同一套均值方差
])
数据管线是隐形瓶颈
机制直觉。训练每一步其实是两段接力:CPU 端准备下一批数据(读盘、解码、增强、collate),GPU 端计算当前批。如果数据准备比 GPU 计算还慢,GPU 就得干等——你买的算力在空转。这就是"隐形瓶颈":loss 在降、代码没报错,但 GPU 利用率只有 30%。
两个对症旋钮,但都要正确理解,别迷信:
num_workers:用几个子进程并行调__getitem__取数据。默认 0 表示在主进程里串行取(最容易卡 GPU,也是前面四步流水线里的默认行为)。调大能让数据准备和 GPU 计算重叠起来。但不是越大越好:每个 worker 吃内存、有进程启动开销,过多会争抢 CPU 反而变慢。常见做法是从 4 起试,盯着 GPU 利用率调。pin_memory:把 batch 放进"锁页内存"(pin_memory,锁页内存),让 CPU→GPU 的拷贝更快、且能异步进行。它只在用 GPU 时有意义,纯 CPU 训练开了反而浪费内存。它加速的是"传输",不是"取数据"——别指望它解决num_workers=0的慢。
常见误解澄清:num_workers 管"并行读数据",pin_memory 管"快速传 GPU",二者解决的是不同环节,常配合使用但不可互相替代。下一课讲 GPU 时会把这条传输链路接完整。
调一调,观察现象
微任务 1:对比 shuffle=False 与同 seed 的 shuffle=True。预期现象:shuffle=False 时索引始终是 [0,1,2,3,4] 顺序切片,batch 0 永远是前两条、跨多次运行完全固定;shuffle=True 时顺序被打乱,但只要用同一个 seed,每次运行得到的打乱顺序也完全一样(这就是"可复现")。下面四行打印,前两行(shuffle=False)相同、后两行(同 seed 的 shuffle=True)也相同——一个靠"不打乱"固定、一个靠"固定种子"固定。训练时若不 shuffle,同样顺序反复喂入,模型可能学到"数据排列顺序"这种虚假规律。
import random
class DS:
def __init__(s, x): s.x = x
def __len__(s): return len(s.x)
def __getitem__(s, i): return s.x[i]
def loader(ds, bs, shuffle, seed=0):
rng = random.Random(seed); idx = list(range(len(ds)))
if shuffle: rng.shuffle(idx)
for k in range(0, len(idx), bs):
yield [ds[i] for i in idx[k:k+bs]]
ds = DS(['a','b','c','d','e'])
print("shuffle=False, 第1次:", [b for b in loader(ds, 2, False)])
print("shuffle=False, 第2次:", [b for b in loader(ds, 2, False)]) # 与上一行相同
print("shuffle=True , 第1次:", [b for b in loader(ds, 2, True, seed=0)])
print("shuffle=True , 第2次:", [b for b in loader(ds, 2, True, seed=0)]) # 同 seed,与上一行相同
# 思考:把第3、4行的 seed 改成不同值,shuffle=True 的两次还会一样吗?
微任务 2:把 collate_fn 里的 maxlen = max(...) 改成固定值(比如写死 maxlen = 4)。预期现象:长度为 5 的句子会被静默截断,而短句仍补到 4。为什么:pad 长度若小于本批最长句,就装不下真实 token;这说明"pad 到本批最长"不是随便选的——它是恰好容纳所有句子的最小宽度。注意下面这格主动用了 min() 防越界,所以只会丢弃超出部分、不会报错;如果你把 X[r, :n] = s[:n] 改成直接写 X[r, :len(s)] = s(不写 min),numpy 才会因为 shape 不匹配而报错——这一步留给你自己试。
import numpy as np
def collate(batch, pad_id=0, fixed=None):
seqs = [b for b in batch]
maxlen = fixed if fixed is not None else max(len(s) for s in seqs)
X = np.full((len(seqs), maxlen), pad_id, dtype=np.int64)
for r, s in enumerate(seqs):
n = min(len(s), maxlen) # 防越界:超出部分被丢弃
X[r, :n] = s[:n]
return X
batch = [[5,7,9], [2], [4,4,4,8,1]]
print("自动 maxlen:\n", collate(batch)) # 宽度=5,无丢失
print("写死 fixed=4:\n", collate(batch, fixed=4)) # 长度5的句子被静默截断!
微任务 3:把 batch_size 从 2 改成 3,观察最后一批。预期现象:bs=2 时 5 条样本切成 [2,2,1]、bs=3 时切成 [3,2]——两种 batch_size 演示的是同一件事:样本数不整除 batch_size 时,最后总会剩一个不满的"尾批/零头批"。为什么:样本数不一定整除 batch_size,默认保留这个零头批;真实 DataLoader 有 drop_last=True 可丢弃它(某些场景需要每批严格等大)。
def loader(n, bs, drop_last=False):
idx = list(range(n)); out = []
for k in range(0, n, bs):
chunk = idx[k:k+bs]
if drop_last and len(chunk) < bs: break
out.append(chunk)
return out
print("bs=2:", loader(5, 2)) # [[0,1],[2,3],[4]] 尾批1条
print("bs=3:", loader(5, 3)) # [[0,1,2],[3,4]] 尾批2条
print("bs=3, drop_last:", loader(5, 3, True)) # [[0,1,2]] 零头批被丢弃
动手练习
- 左 padding 版 collate。有些场景(如 decoder 生成)需要把 padding 补在左侧而非右侧。改写本课的
collate_fn,让短句左侧补 0,并相应生成 mask(真实 token 在右、mask 右侧为 1)。逐 batch 打印验证 mask 和 X 的 0 位置一致。 - 带 transform 的 Dataset。给
ToyTextDataset加一个transform参数(一个函数),在__getitem__里对样本调用它。再写一个"训练用"transform(给序列随机丢一个 token,模拟增强)和一个"验证用"transform(什么都不做)。验证:同一条样本,训练 transform 多次调用结果可能不同,验证 transform 永远相同。 - 测量 num_workers 的意义(用 sleep 模拟)。在纯 Python 里无法真用多进程展示,但你可以用
time.sleep(0.01)模拟"取一条样本很慢",然后串行遍历整个 dataset 计时。思考题(写注释回答):如果有 4 个 worker 并行,理想情况下这段时间会变成原来的几分之几?为什么实际达不到这个理想值?
掌握自检
- 我能说出
Dataset必须实现的两个方法,以及__getitem__返回的是单条还是一批。 - 我能复述
DataLoader取一批数据的四步流水线,并指出shuffle/num_workers各发生在哪一步。 - 我能不看讲义写出处理变长序列的
collate_fn:pad 到本批最长 + 生成 0/1 mask,并解释 pad 和 mask 为何成对。 - 我能回答:为什么验证集不加随机数据增强、shuffle 为什么训练开验证关。
- 我能区分
num_workers和pin_memory各解决什么环节,并说出"它们不是越大/越开越好"的理由。
可以先放过的点
这几块现在不懂不影响你跑通训练,等遇到再回来:
- 多进程加载的内部机制(worker 怎么 fork、数据怎么跨进程传、
worker_init_fn里怎么设随机种子)——等你真的遇到"数据加载卡 GPU"或"多 worker 下随机性不复现"时再深入。现在只需记住num_workers的作用和"从 4 起试"。 pin_memory与异步传输、CUDA stream 的配合——这属于下一课 GPU 与混合精度的范畴,到那时再连起来看。IterableDataset(流式数据集)——当数据大到无法用索引随机访问(如读取无限日志流)时才需要,常规任务用本课的 map 式Dataset足够。Sampler/BatchSampler的定制(如按长度分桶减少 padding、类别均衡采样)——属于性能调优,等你的管线确实成为瓶颈再研究。