3. 训练数据:把文档排成样本
分词器解决“文字变整数”。下一步要解决“怎样把大量文档排成模型一次能处理的固定长度样本”。
这件事看起来只是切一切、拼一拼,实际上会直接影响训练效率和模型质量。nanochat 的预训练数据加载器有一个明确的设计:每行都以 BOS 开始,用最佳适配打包,不填充。
数据从哪里来
预训练数据存放在 Hugging Face 的 karpathy/climbmix-400b-shuffle 数据集中。每个分片是一个 Parquet 文件,包含一列文本。nanochat 的相关常量在:
nanochat/dataset.py第 22–27 行↗- 数据地址:数据集的
resolve/main目录。 - 最大分片编号:
6542。 - 训练集:除最后一个分片外的所有文件。
- 验证集:固定使用最后一个分片
shard_06542.parquet。
下载脚本的 -n 参数表示“下载前多少个训练分片”,验证分片总是会附带下载:
python -m nanochat.dataset -n 8最终会得到 9 个文件:8 个训练分片和 1 个验证分片。正式训练 GPT-2 级别模型时,runs/speedrun.sh 会下载 170 个分片;数据越多,模型见到的内容越丰富,但下载和训练时间也越长。
为什么按分片而不是一次性读入
完整数据集有数百 GB,不可能全部放进内存。Parquet 文件内部又按行组(row group)组织,每次只读取一个行组,可以边读边训练。
parquets_iter_batched() 的逻辑很简单:
nanochat/dataset.py第 67–81 行↗- 列出本地已有的 Parquet 文件。
- 训练集去掉最后一个文件,验证集只留最后一个文件。
- 逐个打开 Parquet 文件。
- 按
start和step跳过或跨步行组,供多卡训练分片使用。 - 每次产出一个行组里的文本列表。
在多卡训练中,start=rank、step=world_size,每张卡读取不同的行组,避免各卡训练完全相同的数据。
一个样本长什么样
假设模型的最大序列长度是 T,一个训练样本实际上需要 T + 1 个词元:
row_capacity = T + 1多出来的一个词元不是浪费,而是为了让目标和输入错开。
例如 T = 4,一行有 5 个词元:
row: [BOS, A, B, C, D]
inputs: [BOS, A, B, C]
targets: [A, B, C, D]2
3
模型在位置 0 看到 BOS,要预测 A;在位置 1 看到 A,要预测 B;以此类推。inputs 和 targets 都是 (B, T) 的形状。
最佳适配打包
如果把每篇文档直接截断成固定长度,会丢掉文档开头或结尾,模型可能在不完整的上下文里学习。nanochat 采用一种更细致的打包方式。
nanochat/dataloader.py第 74–161 行↗对每一行,算法做三件事:
- 找能完整放下的最大文档。 在缓冲区中寻找长度不超过剩余空间、但尽可能大的文档,把它完整放进去。
- 重复,直到放不下任何完整文档。 这样能尽量保留完整文档,减少无意义的截断。
- 截断一篇文档填满剩余空间。 如果所有文档都太长,就取一篇文档的前半段填满当前行。
这个策略有几个结果:
- 每行都从 BOS 开始,模型能识别新文档的起点。
- 每行都被 100% 填满,没有填充词元浪费计算。
- 一部分词元会被截断。源码注释估计,在
T=2048时大约有 35% 的词元被截掉。
35% 看起来很大,但换来的是更完整的文档上下文。模型不会频繁看到“半句话开头 + 另一篇文档”的混乱衔接。
为什么每行都要有 BOS
BOS 是文档边界。模型在生成时看到 BOS,就知道“新的内容开始了”。训练时每行以 BOS 开始,也让每个词元都能通过注意力回看文档开头,而不是接在前一篇文档的结尾。
如果没有 BOS,两篇完全无关的文档会被拼成一个连续文本,模型会学到不存在的因果关系。
数据怎样送到 GPU
数据加载器没有每处理一行就单独拷贝到 GPU,而是预先分配缓冲区:
row_buffer:在 CPU 上拼出每行词元。cpu_buffer:同时存放inputs和targets的连续内存。gpu_buffer:一次把整批数据拷贝到 GPU。
这样做减少了频繁的小块内存拷贝,也让 GPU 在计算当前批次时,CPU 可以准备下一批数据。这个“预取”思想在训练脚本里反复出现。
无限循环和多轮训练
数据加载器是一个无限迭代器。读完全部训练分片后,它会回到第一片,epoch 加一,继续训练。训练脚本不需要自己处理“数据用完了怎么办”,只需要在指定步数停止。
如果你从检查点继续训练,元数据里会保存 pq_idx、rg_idx 和 epoch,让加载器从接近的位置继续,而不是完全从头开始。
SFT 的数据打包不一样
预训练数据可以截断,因为每篇文档彼此独立,少一点内容不会破坏对话格式。对话微调不能这样做。
SFT 的数据加载器使用另一种策略:
- 对话不截断,保持完整的
用户 → 助手结构。 - 放不下时,用 BOS 填充剩余位置。
- 填充位置和用户消息位置的损失设为
-1,不参与训练。 - 每个对话的词元带有损失掩码,只有助手部分需要学习。
这部分会在监督微调中用图和代码展开。
亲手看看分片
下载数据后,可以列出本地分片:
from nanochat.dataset import list_parquet_files
paths = list_parquet_files()
print("分片数量:", len(paths))
print("第一个:", paths[0])
print("最后一个:", paths[-1])2
3
4
5
6
读取一小段文本:
import pyarrow.parquet as pq
from nanochat.dataset import list_parquet_files
path = list_parquet_files()[0]
table = pq.ParquetFile(path).read_row_group(0)
texts = table.column("text").to_pylist()
print(texts[0][:500])2
3
4
5
6
7
不需要一次读完整个文件。先看一个行组,确认数据能正常读取即可。
本章要记住的三件事
- 预训练数据按 Parquet 分片和行组读取,最后一个分片固定作为验证集。
- 每条样本预留
T + 1个词元,输入和目标错开一位。 - 预训练使用 BOS 对齐的最佳适配打包,尽量填满每一行;SFT 使用填充和损失掩码,保护完整对话。
下一章进入模型本身:GPT 模型。