第2章补充材料:文本数据处理扩展
本文是主章节「文本数据处理」的补充材料,汇总自两个中文翻译项目(MLNLP-World、Datawhale)中的扩展内容,对 BPE 分词、嵌入层本质、滑动窗口采样等主题做更深入的探讨。
一、BPE 为什么不需要 <|unk|>?
在简单的词级分词器中,遇到词表外的词(OOV)需要用特殊标记 <|unk|> 替代,这不可避免地丢失信息。BPE(字节对编码)通过一种更巧妙的方式解决这个问题。 [Datawhale]
子词分解机制
BPE 的核心优势在于:任何未见过的词都可以被分解为更小的子词单元,直至单个字节。例如,假设训练数据中从未出现过 someunknownPlace,BPE 分词器会把它拆成一系列子词 token:
"someunknownPlace" → ["some", "unknown", "Place"]
或者更细粒度地拆成字母级 token。这意味着:
- 词表外词 = 0:理论上不存在无法编码的文本
- 不需要
<|unk|>标记 - BPE 可以处理任何语言的任何字符序列,因为它是字节级操作
[Datawhale] GPT-2/GPT-3 的 BPE 分词器总词表大小为 50,257,其中
<|endoftext|>被分配了最大的 token ID(50256)。
特殊 token 的角色
虽然 BPE 不需要 <|unk|>,但 GPT 模型仍然使用 <|endoftext|> 作为特殊 token,它同时承担三个角色 [Datawhale]:
| 角色 | 说明 |
|---|---|
| 文档分隔符 | 在多个不相关文本源之间插入,帮助 LLM 理解文本边界 |
类似 [EOS] | 标记一段文本的结束 |
| 填充(Padding) | 在批量训练时填充较短文本(配合 attention mask 使用) |
其他常见的特殊 token(如 [BOS]、[PAD])在 GPT 的分词器中并不使用,保持了简洁性。
二、词嵌入的本质:从离散到连续
什么是嵌入?
深度神经网络无法直接处理原始文本——文本属于分类数据(categorical data),与神经网络中使用的数学运算不兼容。我们需要一种方法将单词表示为连续值向量。这个将数据转换为向量格式的过程,就叫做嵌入(embedding)。 [Datawhale]
嵌入的本质是:将离散对象(单词、图像、文档)映射到连续向量空间中的点。
Word2Vec 到 LLM:嵌入的演变
Word2Vec 是最早也是最流行的词嵌入方法之一。它的核心思想是:
出现在相似上下文中的词往往具有相似的含义。
如果将 Word2Vec 训练出的词向量投影到二维平面,可以看到语义相近的词聚集在一起——比如不同种类的鸟聚集在一片区域,而国家和城市聚集在另一片区域。 [Datawhale]
但 LLM 采用了一种不同的策略:
- Word2Vec:预训练后固定不变,与下游任务无关
- LLM 的嵌入层:作为模型的一部分,在训练过程中与模型一起更新
后者的优势在于,嵌入被优化以适应当前任务和数据,而不是通用的、可能不适配的预训练向量。 [Datawhale]
嵌入维度:性能与效率的权衡
嵌入维度从 1 维到数千维不等。维度越高,能捕捉到的词间细微关系越多,但计算效率越低。 [Datawhale]
| 模型 | 嵌入维度 |
|---|---|
| GPT-2 Small(117M) | 768 |
| GPT-3 Small(125M) | 768 |
| GPT-3 Large(175B) | 12,288 |
三、嵌入层 = 矩阵乘法:数学等价性
这是一个经常被忽略但非常重要的直觉:嵌入层在数学上等价于对独热编码(one-hot)向量做全连接层的矩阵乘法。 [MLNLP]
具体解释
假设词表大小为 $V$,嵌入维度为 $d$:
方法 1:嵌入查找
embedding = nn.Embedding(V, d) # 形状: (V, d)
output = embedding(token_id) # 直接查表,取第 token_id 行
方法 2:独热编码 + 矩阵乘法
one_hot = F.one_hot(token_id, num_classes=V) # 形状: (V,)
weight_matrix = embedding.weight # 形状: (V, d)
output = one_hot.float() @ weight_matrix # 矩阵乘法
两种方法的结果完全相同,因为独热向量中只有第 token_id 位是 1,矩阵乘法的效果就是取出权重矩阵的第 token_id 行。
为什么用嵌入层?
虽然数学等价,但实现上有巨大差异:
| 维度 | 嵌入层(查表) | 独热 + 矩阵乘法 |
|---|---|---|
| 内存 | $O(V \times d)$ | $O(V \times d)$ + 独热向量 $O(V)$ |
| 计算 | $O(d)$ 直接索引 | $O(V \times d)$ 密集乘法 |
| 效率 | 极高 | 当 $V$ 很大时极低 |
对于 GPT-2 的词表大小 $V = 50257$,用独热编码意味着每次查表都要做 50257 维的向量乘法——这完全是浪费。嵌入层通过直接索引绕过了这个问题。 [MLNLP]
四、滑动窗口采样:用数字理解直觉
理解滑动窗口采样的关键在于 输入和目标的错位关系。MLNLP 项目提供了一个绝妙的方法:用纯数字序列代替文本来直观展示采样过程。 [MLNLP]
数字版示例
假设我们的”语料”是 0 1 2 3 ... 1000,设置 max_length=4:
stride=1(逐个滑动):
输入: [0, 1, 2, 3] 目标: [1, 2, 3, 4]
输入: [1, 2, 3, 4] 目标: [2, 3, 4, 5]
输入: [2, 3, 4, 5] 目标: [3, 4, 5, 6]
...
输入: [996, 997, 998, 999] 目标: [997, 998, 999, 1000]
每个样本之间只移动 1 步,重叠最大,产生的样本数最多(997 个)。
stride=4(无重叠):
输入: [0, 1, 2, 3] 目标: [1, 2, 3, 4]
输入: [4, 5, 6, 7] 目标: [5, 6, 7, 8]
...
样本之间完全不重叠,产生约 249 个样本。
Batch 化后的样子
当 batch_size=2, stride=4 时,最后一批数据:
Inputs:
tensor([[992, 993, 994, 995],
[996, 997, 998, 999]])
Targets:
tensor([[ 993, 994, 995, 996],
[ 997, 998, 999, 1000]])
注意目标(Targets)永远是输入(Inputs)右移一位的结果。这就是”下一 token 预测”训练范式的具体体现。 [MLNLP]
Shuffle 的作用
训练时通常设置 shuffle=True,让每个 epoch 中样本的出现顺序随机化,避免模型”记住”文本顺序而非学习真正的语言规律。 [MLNLP]
torch.manual_seed(123)
dataloader = create_dataloader_v1(raw_text, batch_size=2, max_length=4, stride=4, shuffle=True)
# 随机化后的最后一批:
# Inputs: [[880, 881, 882, 883], [112, 113, 114, 115]]
# Targets: [[881, 882, 883, 884], [113, 114, 115, 116]]
五、位置编码的两种范式
绝对位置编码 vs 相对位置编码
[Datawhale] 位置编码主要分为两种类型:
绝对位置编码:与序列中的特定位置相关联。每个位置(第 1 个、第 2 个…)有唯一的位置向量,直接加到 token embedding 上。GPT 系列使用这种方式,且位置编码在训练过程中可学习(而非像原始 Transformer 那样用正弦/余弦函数固定生成)。
相对位置编码:关注 token 之间的相对距离而非绝对位置。模型学习的是”彼此之间有多远”而不是”在哪个确切位置”。优势是能更好地泛化到训练时未见过的不同长度序列。
为什么 GPT 选择绝对编码?
GPT 选择绝对位置编码(且可学习),主要是工程简洁性:实现简单,且在 GPT 的仅解码器架构中效果足够好。位置向量与 token 向量维度相同,通过逐元素相加融合:
$$\text{input_embed} = E_{\text{token}} + E_{\text{pos}}$$
实际代码中,位置编码的输入是 torch.arange(context_length),即 [0, 1, 2, ..., T-1],其中 context_length 是模型支持的最大上下文长度。如果输入文本超出此长度,需要截断。 [Datawhale]
六、参考资源
- MLNLP-World/LLMs-from-scratch-CN — 第2章额外材料目录,包含 BPE 基准测试、嵌入 vs 矩阵乘法、DataLoader 直觉三个 bonus notebook [MLNLP]
- datawhalechina/llms-from-scratch-cn — 第2章翻译笔记,涵盖 2.1~2.8 共八个小节的 Jupyter notebook [Datawhale]