第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 的训练流程概述:
- 初始化:把每个字符当作一个 token。
- 统计:在语料中找出频率最高的相邻 token 对。
- 合并:把这一对合成一个新 token,加入词表。
- 重复 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)张量的完整代码。 - 我理解为什么”输入”和”目标”要错位一格。
练习题
- 把任意一段中文文本用
tiktoken.get_encoding("gpt2")编码后再解码,比较 token 数和字符数。思考为什么差距这么大。 - 设
max_len=4, stride=2,对[a,b,c,d,e,f,g,h]列出所有 (x, y) 对。 - 把位置嵌入换成”用正弦/余弦函数生成”的固定版本(参考原始 Transformer 论文),写一个
sinusoidal_pos_embed(T, d_model)函数。
📖 第2章补充材料 → — BPE深入解析、嵌入直觉、数据采样机制
← 上一章 · 返回目录 · 下一章 · 注意力机制 →