第 14 课:数据加载 —— 文本如何变成训练样本
第 14 课:数据加载 —— 文本如何变成训练样本
代码位置:src/data.rs 训练入口:src/train.rs(第 13 课)
1. 本课要搞懂的问题
- 语言模型的训练数据长什么样?一篇文章怎么变成一条条"题目"?
- 什么是自监督学习?为什么训练 GPT 不需要人工标注?
- 训练样本里的 x 和 y 是怎么配对的?为什么说 y 是 x 平移一位?
batch_size和block_size到底是什么含义?代码里怎么体现?
2. 从文本到训练数据:三步走
训练 GPT 不需要人工标注,只需要大量纯文本。数据流水线是:
原始文本(CORPUS)
│ ① 分词 tokenize(第 8 课:CharTokenizer.encode)
▼
token id 序列 [t₀, t₁, t₂, t₃, ...]
│ ② 切窗口:每 block_size 个 token 一段
▼
输入窗口 x
│ ③ 配答案:x 右移一位得到 y
▼
训练样本 (x, y)
本项目的语料是内置在 src/data.rs 里的一个英文小故事:
pub const CORPUS: &str = "\
Once upon a time in a small village, there lived a curious little fox named Red. \
... every adventure begins with a single step.";
3. 第一步:文本 → token id
模型只认识数字,所以先用第 8 课的 CharTokenizer(字符级分词)把文本变成 id 序列:
let tokenizer = CharTokenizer::new(CORPUS); // 词表 = 语料中所有出现过的字符
let tokens = tokenizer.encode(text); // "Once" -> [O, n, c, e] 的 id 序列
在本项目中每个字符就是一个 token(一个 usize),例如故事开头的 id 序列就是:
'O' 'n' 'c' 'e' ' ' 'u' 'p' 'o' 'n' ' ' 'a' ' ' 't' 'i' 'm' 'e' ' ' ...
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 4 ...
4. 自监督:预测下一个 token
训练目标一句话:给一段前缀,让模型预测下一个 token 是什么。
输入:Once upon a ti
输出:m ← 下一个字符(来自原文)
输入:Once upon a tim
输出:e ← 下一个字符
输入:Once upon a time
输出:空格 ← 下一个字符
这为什么叫自监督?因为"标准答案"就藏在文本自己身上——预测"下一个 token",标签就是原文里紧跟其后的那个 token,不需要任何人去标注。模型只要在海量文本上反复做这件事,就能学会语法、语义和世界知识。这也是 ChatGPT 这类大模型训练的第一阶段。
5. 第二步 + 第三步:x/y 配对(平移一位)
DataLoader::sample_batch 的核心逻辑(src/data.rs):
pub fn sample_batch(&self, rng: &mut Rng) -> (Vec<usize>, Vec<usize>) {
let max_start = self.tokens.len() - self.block_size - 1;
let mut x = Vec::with_capacity(self.batch_size * self.block_size);
let mut y = Vec::with_capacity(self.batch_size * self.block_size);
for _ in 0..self.batch_size {
let start = rng.choice(max_start); // 随机选一个起点
for j in 0..self.block_size {
x.push(self.tokens[start + j]); // 窗口 [start, start+T)
y.push(self.tokens[start + j + 1]); // 窗口 [start+1, start+T+1)
}
}
(x, y)
}
关键就两行:
x.push(tokens[start + j]):x 的第 j 个 token 是窗口里的第 j 个y.push(tokens[start + j + 1]):y 的第 j 个 token 是 x 的第 j 个 token 的下一个
所以 y 恰好是 x 向右平移一位,一一对应:
位置: 0 1 2 3 4 5 ... T-1
x: [t₀ t₁ t₂ t₃ t₄ t₅ ... t_{T-1}]
y: [t₁ t₂ t₃ t₄ t₅ t₆ ... t_T ]
↑ ↑ ↑
x[0]的下一个 = y[0],x[1]的下一个 = y[1],……
6. 具体例子:窗口从语料中切出来
设 block_size = 16,随机起点 start = 0,故事开头 17 个字符为
Once upon a time(注意末尾还有个空格)。则这一个样本是:
x = tokens[0..16] = O n c e ' ' u p o n ' ' a ' ' t i m
y = tokens[1..17] = n c e ' ' u p o n ' ' a ' ' t i m e
| x(输入) | y(答案) | 含义 |
|---|---|---|
O |
n |
看到 "O",预测 "n" |
n |
c |
看到 "On",预测 "c" |
| ... | ... | ... |
m |
e |
看到 "Once upon a tim",预测 "e" |
模型在这个样本上要同时做 T=16 次"预测下一个 token",cross_entropy_loss 对它们取平均(第 13 课)。
为什么同一段文本要重复利用? 一个长度为 700+ 字符的语料,可以切成几百个互相重叠的窗口(起点 0、1、2、……都可以),一份语料就被"榨出"远超自身长度的训练样本。
7. batch_size 与 block_size:两个容易混淆的概念
| 参数 | 英文含义 | 含义 | 代码中的体现 |
|---|---|---|---|
block_size |
序列长度(上下文窗口) | 每个样本里有多少个 token,也就是模型一次"看"多长的历史 | x.push(tokens[start + j]) 循环 0..block_size |
batch_size |
批大小 | 一次训练同时处理多少个独立样本 | 外层 for _ in 0..self.batch_size |
它们合起来决定了一次前向的输入规模:
一次前向的输入张量:[B*T] 展平 → model.forward 里 reshape 成 [B, T, D]
其中 B = batch_size(样本数)、T = block_size(每个样本的 token 数)。在 train_gpt 里:
let (x, y) = loader.sample_batch(rng);
let b = batch_size; // B = 8
let t = block_size; // T = 32(GPTConfig::tiny 的默认值)
let logits = model.forward(&x, b, t, None); // [B*T, vocab_size]
用一张图理解 B 和 T:
batch_size = 2, block_size = 4 时的一次采样:
┌───── 第 1 个样本(起点 start=0)─────┐
x = [ t0 t1 t2 t3 ] y = [ t1 t2 t3 t4 ]
┌───── 第 2 个样本(起点 start=57)────┐
x = [ t57 t58 t59 t60 ] y = [ t58 t59 t60 t61 ]
└──────────────────────────────┘
展平后 x 是长度 B*T = 8 的一维数组
两个起点之间没有关系——每个样本是独立从语料里随机切的,这正是"随机梯度下降"里"随机"二字的来源。
8. 一个隐藏的断言:语料不能太短
assert!(tokens.len() > block_size, "语料太短,无法切出完整序列");
sample_batch 里 max_start = tokens.len() - block_size - 1 还额外要求至少留 1 个 token 当 y 的尾巴(因为 y 比 x 多取一位)。如果语料比 block_size 还短,就切不出任何完整窗口,直接 panic 提示——这是第 1 课就强调的"出错早、出错明显"的防御性编程习惯。
9. 动手练习
- 手动算一遍:语料有 700 个 token,
block_size = 32,max_start是多少?一共能切出多少个不同的起点?(答案:668) - 把
batch_size从 8 改成 1 再训练,观察 loss 曲线的抖动幅度(batch 越小噪声越大)。 - 把
block_size改成 64(注意GPTConfig的 block_size 也要一起改)再训练,对比效果和训练速度。 - 思考:为什么 y 要比 x 多取一个 token(
tokens[start + block_size])?如果只取tokens[start..start+block_size]当 y 会有什么问题?
10. 本课总结
- 数据流水线:文本 → token id → 切窗口 → 配答案
- 自监督:预测下一个 token,标签来自文本自身,无需人工标注
- 配对方式:y 是 x 右移一位,
y[j] = x[j] 的下一个 token block_size控制"看多长",batch_size控制"一次看几段",两者相乘是一次前向的 token 总数sample_batch随机选起点切重叠窗口,把一份语料复用出大量样本- 下一步(第 15 课):训练好模型后,如何从输出的 logits 里"采样"出通顺、不呆板的文本