第 14 课:数据加载 —— 文本如何变成训练样本

2026-09-15 干徒
RustLLM数据

第 14 课:数据加载 —— 文本如何变成训练样本

代码位置:src/data.rs 训练入口:src/train.rs(第 13 课)

1. 本课要搞懂的问题

  1. 语言模型的训练数据长什么样?一篇文章怎么变成一条条"题目"?
  2. 什么是自监督学习?为什么训练 GPT 不需要人工标注?
  3. 训练样本里的 x 和 y 是怎么配对的?为什么说 y 是 x 平移一位?
  4. batch_sizeblock_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_sizeblock_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_batchmax_start = tokens.len() - block_size - 1 还额外要求至少留 1 个 token 当 y 的尾巴(因为 y 比 x 多取一位)。如果语料比 block_size 还短,就切不出任何完整窗口,直接 panic 提示——这是第 1 课就强调的"出错早、出错明显"的防御性编程习惯。

9. 动手练习

  1. 手动算一遍:语料有 700 个 token,block_size = 32max_start 是多少?一共能切出多少个不同的起点?(答案:668)
  2. batch_size 从 8 改成 1 再训练,观察 loss 曲线的抖动幅度(batch 越小噪声越大)。
  3. block_size 改成 64(注意 GPTConfig 的 block_size 也要一起改)再训练,对比效果和训练速度。
  4. 思考:为什么 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 里"采样"出通顺、不呆板的文本
Rust 大语言模型 学习指南共 22 章
1从零用 Rust 实现大语言模型 —— 学习计划2第 1 课:张量 Tensor —— 一切的基础3第 2 课:自动微分 Autograd —— 让模型学会「自我修正」4第 3 课:张量运算扩展 —— 广播、归约、softmax、批量矩阵乘法5第 4 课:模块化重构 —— 项目结构分层与 Rc<RefCell> 架构6第 5 课:线性层与激活函数 —— 神经网络的「积木」7第 6 课:损失函数与优化器 —— 让模型知道「错在哪、怎么改」8第 7 课:第一个 MLP —— 教会神经网络算 XOR9第 8 课:BPE 分词器 —— 让模型「读懂」文字10第 9 课:注意力机制 —— 让 token 互相「看」11第 10 课:多头注意力 —— 让模型「多角度」看世界12第 11 课:位置编码与归一化 —— 让序列带上「位置感」13第 12 课:完整 GPT 模型 —— 把积木拼成能预测下一个词的模型14第 13 课:训练循环 —— 让模型真正开始学习15第 14 课:数据加载 —— 文本如何变成训练样本本篇16第 15 课:推理与采样 —— 让模型「创造性」地生成17第 16 课:训练小 GPT —— 看 loss 从 1.46 一路降到 0.1618第 17 课:AdamW 优化器 —— 给梯度下降装上「惯性」和「自适应步长」19第 18 课:KV Cache —— 让逐 token 生成不再重复计算20第 19 课:RoPE 旋转位置编码 —— 把「相对位置」揉进注意力21第 20 课:学习率调度与收尾 —— warmup、cosine decay 与全项目总结22第 21 课:GPU 加速训练与推理