带 Sliding Window 的 Tokenized Dataset
一次预训练,本质上就是从 token ids 到梯度的函数。这节课构建的,就是把 ids 稳定送进去的那条传送带。
类型: Build 语言: Python 前置要求: 第 04 阶段课程、第 07 阶段 transformer 课程、本阶段第 30 课 预计时间: ~90 分钟
学习目标
- 只调用一次 tokenizer,把原始语料转换成 token id 流。
- 用可配置 overlap stride,把 id 流切成定长窗口。
- 构建一个 PyTorch Dataset,为 next-token prediction 返回 input/target tensor。
- 再用带每轮固定随机种子的 DataLoader 包住它。
- 理解 stride、冗余和有效数据集大小之间的权衡。
cap-sliding-window框架
预训练每次读一批 token ids,更新一次模型。每个 batch 的形状早被训练契约钉死。对 causal LM 来说,batch 包含 (B, T) 的 input ids 和 (B, T) 的 target ids,其中 target 只是 input 左移一位。数据管线的职责,就是从可能数 GB 的原始文本中,稳定、可复现地按需产出这种形状。
这节课就做整条管线。上一课的 tokenizer 把文本变成一条很长的平坦 id 列表。sliding window 再把这条列表切成训练样本。自定义 Dataset 把它们暴露成 tensor。DataLoader 负责 batch 和带已知随机种子的 shuffle。
形状契约
causal LM 吃的是 (B, T) 形状的 id,B 是 batch size,T 是 context length。位置 t 的 target 就是位置 t+1 的 input,所以每个训练样本底层其实要覆盖 T+1 个原始 id。window stride 决定了相邻样本之间重叠多少。
flowchart LR
A[原始语料文本] --> B[tokenizer.encode]
B --> C[扁平 id 列表]
C --> D[滑动窗口切分器]
D --> E[(id_window_0)]
D --> F[(id_window_1)]
D --> G[(id_window_n)]
E --> H[PyTorch Dataset]
F --> H
G --> H
H --> I[带固定种子 shuffle 的 DataLoader]
I --> J[B x T+1 id 的 batch]
J --> K[拆分为 input 和 target]slicer 不会越过语料末尾。如果最后一段不足以填满 T+1,本课直接丢掉。你当然也可以用 <|pad|> 填尾巴,但那会额外引入 loss mask 复杂度,所以本课不做。
为什么要 Sliding Window
预训练语料就是一条超长 id 流。若模型只看完全不重叠的窗口,那么它每次都会在同一批边界上训练。调整 stride 的意义,就是把这些边界挪动起来,让模型见到更多样的“预测下一个 token”局面。
- stride =
T:完全不重叠 - stride =
T // 2:50% 重叠,数据集规模约翻倍 - stride =
1:最大重叠,数据集规模约放大T倍
代价是每个 epoch 计算更多;好处是边界更多样。多数真实预训练会取 stride = context length,因为语料本来就远大于模型一轮能吃完的数据,边界多样性的收益相对没那么重要。
Dataset 类
PyTorch Dataset 只要求两个方法:__len__ 返回样本数,__getitem__ 返回单个样本。我们的 Dataset 存的是编码后的 id 流和 stride。索引时现算窗口起点,因此无论 stride 产出多少个样本,内存里都只保留一份 id 流。
sequenceDiagram
participant Trainer as 训练器
participant DataLoader
participant Dataset
participant Tokenizer
Trainer->>DataLoader: iter(dataloader)
DataLoader->>Dataset: __len__
DataLoader->>Dataset: __getitem__(i)
Dataset->>Dataset: window = ids[start:start+T+1]
Dataset->>DataLoader: (input_ids, target_ids)
DataLoader->>Trainer: batch (B,T) input, (B,T) target
Note over Tokenizer,Dataset: tokenizer.encode 在构建时只跑一次左移一位的逻辑发生在 __getitem__ 内:input = window[:-1],target = window[1:]。两者都是 PyTorch long tensor。训练循环直接把 target 当 ground truth。
确定性 Shuffle
shuffle=True 的 DataLoader 依赖 PyTorch 的随机生成器。只要你显式传一个 torch.Generator,并按 epoch 固定 seed,就能保证重启后看到同样的数据顺序。这对“只改一个超参数,比两条 loss curve”很关键。不然两次训练数据顺序都不一样,loss 分叉根本不能说明问题。
本课的 seed 契约很简单:epoch_seed = base_seed + epoch_index。构造 DataLoader 时给 base seed,trainer 在每个 epoch 开头把 epoch_index 加上去。同样的 base seed,会在每次重跑里给出完全同样的顺序。
Batch Sampler
PyTorch 默认 sampler 会在不放回的前提下均匀随机抽索引。这正是预训练想要的。对小数据集微调来说,也一样适用。DataLoader 通过反复调用 __getitem__ 取出 B 个样本,再沿 batch 维堆叠。由于每个样本长度天生一致,这里完全不需要 padding 逻辑。
本课把 num_workers 固定为 0,好让思路干净。上生产时你可以开 worker 并行 __getitem__。对我们这套“纯内存切片”来说,worker 收益不大,但同一个 Dataset API 本来就支持它。
怎么数样本数
若 id 流长度为 N,context length 为 T,stride 为 S,样本数就是:
max(0, 1 + (N - (T + 1)) // S)
这节课把这个公式做成 Dataset 的静态方法,让 trainer 无需遍历整个数据集就能提前算出每个 epoch 的总 step 数。
这节课不做什么
它不做磁盘流式读取。语料会被完整编码进内存,作为一整块 tensor 持有。对几百万 id 规模的数据来说,这不过几十 MB,完全适合本课。以后若要做 disk streaming,本质上只是换存储层,而 Dataset 契约不需要变。
它也不专门处理多文档边界。本课把语料视为一条连续 id 流。若原始语料本来由多篇文档组成,那就在构建语料时插入 <|endoftext|>。模型会自己学到如何跨边界预测。
怎么读代码
main.py 里有两个类和一个 helper。SlidingWindowDataset 是 PyTorch Dataset;make_dataloader 返回带固定随机种子的 DataLoader;_encode_corpus_to_ids 负责一次性 tokenizer 调用。底部 demo 会现场搭一个小 tokenizer,编码一份内置语料,再构建 dataset 和 dataloader,打印一个 batch,并断言形状契约。code/tests/test_dataset.py 会钉住窗口数公式、左移一位性质、确定性 shuffle 和 stride 权衡。
跑一遍 demo。然后把 context length 从 16 改成 32,看每个 epoch 的样本数怎么掉下去。那就是你的 steps-per-epoch 预算。