第2章:文本数据处理

学习目标

  • 理解从”字符串”到”模型输入张量”中间发生了什么。
  • 自己动手实现一个最小的 BPE 分词器调用流程。
  • 掌握”滑动窗口 + teacher forcing”的样本构造方式。
  • 弄清 token embedding 和 positional embedding 为什么要相加。

2.1 为什么需要分词

神经网络只接受张量,不接受字符串。我们需要一个编码器(tokenizer)把文字变成整数序列、一个解码器把整数序列还原成文字。

候选方案有三类:

方案粒度优点缺点
字符级单字符词表小、零 OOV序列变长、语义稀薄
单词级完整单词语义紧凑词表巨大、OOV 严重
子词级(BPE/WordPiece)介于两者之间词表可控、几乎无 OOV训练略复杂

GPT 系列采用 BPE(Byte-Pair Encoding),本课程沿用该路线。

2.2 BPE 的核心思想

BPE 的训练流程概述:

  1. 初始化:把每个字符当作一个 token。
  2. 统计:在语料中找出频率最高的相邻 token 对
  3. 合并:把这一对合成一个新 token,加入词表。
  4. 重复 2–3 步,直到词表达到目标大小(如 50257)。

推理时按相同的合并规则反向把字符串切成 token id。

实操中我们直接用 OpenAI 开源的 tiktoken,它的 gpt2 编码器与 GPT-2 训练时使用的完全一致。

import tiktoken

tokenizer = tiktoken.get_encoding("gpt2")
ids = tokenizer.encode("Hello, large language model!")
text = tokenizer.decode(ids)
print(ids)   # [15496, 11, 1588, 3303, 2746, 0]
print(text)  # Hello, large language model!

注意:BPE 是字节级的,因此中文也能编码,只是一个汉字会被拆成多个 token,效率较低。这就是为什么后续中文模型(如 Qwen、ChatGLM)会重新训练自己的分词器。

2.3 把语料切成训练样本:滑动窗口

预训练任务是”下一 token 预测”,因此每个样本需要一对:

  • 输入 $x_{1:T}$
  • 目标 $x_{2:T+1}$(输入整体右移一位)

我们用”滑动窗口”在长文本上滑取这样的对,窗口长度即为 context_length(如 256、1024)。

import torch
from torch.utils.data import Dataset, DataLoader

class GPTDataset(Dataset):
    def __init__(self, text, tokenizer, max_len, stride):
        self.x_chunks = []
        self.y_chunks = []
        ids = tokenizer.encode(text)
        for i in range(0, len(ids) - max_len, stride):
            x = ids[i : i + max_len]
            y = ids[i + 1 : i + max_len + 1]   # 右移一位
            self.x_chunks.append(torch.tensor(x))
            self.y_chunks.append(torch.tensor(y))

    def __len__(self):
        return len(self.x_chunks)

    def __getitem__(self, idx):
        return self.x_chunks[idx], self.y_chunks[idx]


def make_loader(text, batch_size=4, max_len=256, stride=128, shuffle=True):
    tok = tiktoken.get_encoding("gpt2")
    ds = GPTDataset(text, tok, max_len, stride)
    return DataLoader(ds, batch_size=batch_size, shuffle=shuffle, drop_last=True)

stride 怎么选?

  • stride == max_len:样本之间不重叠,数据利用率最低但最不冗余;
  • stride < max_len:样本之间有重叠,等价于数据增强,但可能让验证集”看见”训练集。

2.4 Token Embedding

经过 tokenizer 后,每个 token 是一个整数 id(范围 [0, V-1])。我们需要一个查找表 nn.Embedding(V, d_model),把 id 映射为 $d_{model}$ 维向量。

vocab_size = 50257
d_model = 768

token_embed = torch.nn.Embedding(vocab_size, d_model)

batch_ids = torch.randint(0, vocab_size, (4, 256))   # (B, T)
x = token_embed(batch_ids)                            # (B, T, d_model)

这张查找表的参数会和模型其他部分一起被反向传播更新——也就是说,词向量是学出来的,不是预先固定的

2.5 为什么还要位置编码

注意力机制本身对顺序无感知:把”猫追狗”的 token 顺序打乱,自注意力的输出会被等比例打乱,但这只能告诉模型”打乱后的位置关系”,没有”原本第几位”这个信息。所以我们必须显式地把位置信息注入。

GPT-2 的做法:

  • 再开一张 nn.Embedding(context_length, d_model),每个位置 0..T-1 对应一个可学习向量;
  • 把它和 token embedding 相加

$$h^{(0)}t = E{token}[\text{id}t] + E{pos}[t]$$

context_length = 256
pos_embed = torch.nn.Embedding(context_length, d_model)

positions = torch.arange(context_length)              # (T,)
h0 = token_embed(batch_ids) + pos_embed(positions)   # (B, T, d_model)

为什么是相加而不是拼接? 拼接会让维度变化,下游所有 Linear 层都要改;相加则保持 $d_{model}$ 不变,并且只要训练数据足够,模型有能力把”哪部分是位置、哪部分是词义”分解出来。这是工程上一个非常划算的选择。

后续模型(如 LLaMA)改用 RoPE(旋转位置编码),把位置信息以旋转矩阵形式直接注入到 Q、K 上,效果更好,但本课程仍以经典的可学习位置嵌入为基线。

2.6 一个完整的”输入流水线”图

"Hello, world!"
      │ tokenizer.encode

[15496, 11, 995, 0]
      │ DataLoader 批处理

batch_ids: (B, T) int64
      │ token_embed

(B, T, d_model)  ────加────▶  位置嵌入 (T, d_model)


                          h0: (B, T, d_model)   ← 进入 Transformer Block

检查清单

  • 我能说清楚 BPE 训练的三步迭代。
  • 我能写出从原始字符串到 (B, T, d_model) 张量的完整代码。
  • 我理解为什么”输入”和”目标”要错位一格。

练习题

  1. 把任意一段中文文本用 tiktoken.get_encoding("gpt2") 编码后再解码,比较 token 数和字符数。思考为什么差距这么大。
  2. max_len=4, stride=2,对 [a,b,c,d,e,f,g,h] 列出所有 (x, y) 对。
  3. 把位置嵌入换成”用正弦/余弦函数生成”的固定版本(参考原始 Transformer 论文),写一个 sinusoidal_pos_embed(T, d_model) 函数。

📖 第2章补充材料 → — BPE深入解析、嵌入直觉、数据采样机制


← 上一章 · 返回目录 · 下一章 · 注意力机制 →