# 文字怎么变成向量：子词分词与词嵌入表

从整词词表的 [UNK] 困境到 BPE 的合并规则，再到一张 50257×768 的查表：进入注意力层之前，文字到底经历了什么

> 序列建模基础 · Transformer 架构 · 子词分词 · 词嵌入 · 约 5 分钟 · 10 月 26 日

## 本篇要点

1. 进入注意力层之前，文字要经过两道转换：先被切成 token 并映射为整数编号，再按编号从词嵌入表里取出向量。
2. 整词词表的硬伤是预算有限：词表外的文本被压成一个 [UNK]，既丢失区别，也让模型永远写不出这些字符串；退到字节级虽然永不缺编号，却让序列长度成倍增加，注意力计算量随之大幅上升。
3. 子词分词取中间路线：常见的词整块保留，罕见的词拆成常用碎片。BPE 从字节出发，反复合并最常相邻的两个符号，词表大小 = 基础符号数 + 合并次数（GPT-2：256 + 50000 + 1 = 50257）。
4. 字节级 BPE 消除了 [UNK]，但同一个意思在不同语言、代码里的 token 数差别很大，上下文长度和费用都按 token 计。
5. 词嵌入表是一张“词表大小 × 模型宽度”的参数矩阵，token id 当行号取出一行，不做计算；GPT-2 基础版约 3863 万个数，占模型参数三成左右。
6. 这张表和其它权重一样由训练调整，同一 id 永远查出同一向量；随上下文变化是注意力层的功劳，不是查表的结果。

---

你随手打一句“Hello world”交给 GPT-2，它先把文字变成 `[15496, 995]`。如果在前面多加一个空格，同样两个词变成 `[18435, 995]`——空格被算进了 token，所以句首的“Hello”和句中的“ Hello”是两个不同编号 [1]。

这里有两件事同时发生：文字变成了整数，而且整数并不是按“词”切的。后面所有层处理的都是浮点向量，没有任何一个部件认识“词”。所以进入第一层注意力之前，有两道转换必须先完成：文字先变成一串整数，整数再变成一串向量。这一篇只讲这两道。

## 词表是有限的抽屉，而语言不是

最自然的想法是按词切，一个词一个编号。问题出在预算。词表里每多一个词，就要多一行 768 个数（下面会讲），所以词表大小是训练前就得定死的几万格抽屉。而语言里可能出现的东西——专名、拼写变体、技术词、新造词——远超这个数量 [3]。

装不下的怎么办？词表外的文本统一映射成一个特殊符号 `[UNK]`（unknown）[2]。这等于把“pytorch”、某个新人名和一个错字全部变成同一个符号：模型从此无法区分它们，生成时也永远写不出词表之外的字符串。还有一个不显眼但实在的损失——`dog` 和 `dogs` 拿到两个毫不相干的编号，模型得分别学一遍，学不到“加个 s”这件事。

## 另一个极端：一切退回字节

那就往反方向走到底：一个字节一个 token。UTF-8 能把任何文字表示成 0–255 的字节序列，基础词表只要 256 行，而且永远不会遇到表外的东西 [3]。

代价是序列变长。同一句话的两种切法：`This is some text` 在 UTF-8 下是 17 个字节，而 GPT-2 的分词器只用 4 个 token [3]。序列长度不是小事——注意力要让每个位置和所有位置算关系，长度翻四倍，计算量大约翻十六倍。全退回字节换来了“覆盖一切”，付出的是每个字都要多算几倍。

## 子词：常见的整块，罕见的拆块

两个极端之间有个位置：常见词整块给一个编号，罕见词拆成几个常用的碎块。这就是**子词分词**，其中最常用的一种叫 BPE（byte pair encoding，字节对编码）。

规则朴素得像一道统计题。拿一小份语料：`hug`×10、`pug`×5、`pun`×12、`bun`×4、`hugs`×5。先把所有词拆成字符，数哪两个相邻字符最常挨在一起。这里 `u`+`g` 出现在 hug、pug、hugs 中，次数最多，于是把 `ug` 合并成一个新符号加进词表；接着 `u`+`n` 最多，合并成 `un`。如此反复，直到词表长到目标大小 [2]。

于是词表大小有个很干净的公式：**词表 = 基础符号数 + 合并次数**。GPT-2 是 256 个字节 + 50000 次合并 + 1 个结束符 = 50257 [2]。现在的模型词表更大，OpenAI 的 cl100k_base 有 100,256 个常规 token，o200k_base 有 199,998 个 [4]。

合并完之后，常见词是一整块，罕见词自动被切碎。要留意：BPE 学的是频率，不是词根词缀，切出来的块常像巧合——`annoyingly` 可能被切成 `annoy`+`ing`+`ly` [2]；`antidisestablishmentarianism` 在 cl100k_base 里是六块：`ant` `idis` `establish` `ment` `arian` `ism` [5]。

![词表外的词被拆成几个子词片段，每个片段再映射成对应的 token ID](https://sebastianraschka.com/images/LLMs-from-scratch-images/ch02_compressed/11.webp)

这里最该记住的一点是：**`[UNK]` 消失了**。基础符号就是 UTF-8 的 256 个字节，任何合法文本都能一路退回单字节，所以你现编的词、别人的名字、新出的 emoji 都有编号可查。

但这个性质很容易被推过头。**能表示不等于表示得经济。** 主要用英文语料训出来的分词器，处理中文或代码时同一个意思往往要花更多 token：同样一段话，切出来的长度可以明显更长。上下文窗口按 token 计费、按 token 截断，所以“128k 上下文”对不同语言并不等长，API 账单也是按 token 算的。另外，分词器在模型训练之前就固定，训练过程中不再变动——它不在 Transformer 里，不是被梯度学出来的部分。

## 从 ID 到 768 个数：查表

现在每个位置有了一个整数。把它变成注意力层能吃的向量，靠的是**词嵌入表**（token embedding）。

直觉上就是一张巨大的表格：行数是词表大小，列数是模型宽度。GPT-2 最基础那档宽度是 768，于是表是 50257 行 × 768 列，约 3860 万个数；nanoGPT 为了对齐把词表补到 50304，得到 3863 万个数，在它报出的 1.24 亿参数里占三成左右 [6]。查表这个动作本身不做任何计算：token id 就是行号，取出那一行，就是该位置的向量。

```python
wte = nn.Embedding(config.vocab_size, config.n_embd)   # 50257 × 768
tok_emb = self.transformer.wte(idx)                     # (batch, 序列长度, 768)
```

两件事值得停下来想。

**第一，这些数也是参数。** 它们和 QKV 的权重一样，初始化时是一堆随机数（nanoGPT 用标准差 0.02 的正态分布 [6]），之后靠[交叉熵损失](https://pangzhengboyin.com/articles/cross-entropy-loss-next-token-training-cee2a592)给出的 $p-y$ 梯度一路调整。所谓“词向量的含义”是训练挪出来的，不是谁设计进去的。用法相近、上下文相近的词会被挪到一起——注意这个“相近”是训练的结果，不是分词保证的：`dog` 和 `dogs` 在空间里靠得近，是因为它们反复出现在相似的句子里、收到相似的梯度。

**第二，同一个 id 永远查出同一个向量。** “苹果”在“吃苹果”和“苹果发布会”里，此刻拿到的是同一行 768 个数。让同一个词在不同句子里表示不同意思，是后面注意力层的事——它把周围的信息混进来，输出才随上下文变化。这一步只负责把编号翻译成初始状态，而查出的向量还要加上[位置编码](https://pangzhengboyin.com/articles/multi-head-attention-positional-encoding-192a9cbf)，才对第一层注意力有完整意义。

## 一次进门，四步

把链路串起来：原始文本 → 按规则预切分（空格通常粘在后面的词上 [5]）→ BPE 合并出子词 → 得到 token id 序列（长度 $T$）→ 查表得到 $T \times 768$ 的矩阵 → 加位置编码 → 进第一层。

想自己验证，跑三行就够：

```python
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
ids = enc.encode("你写的那句话")
print(len(ids), [enc.decode([i]) for i in ids])
```

看着自己的一句话被切成碎片，你会对“模型看到的不是字”有具体印象，也会立刻明白为什么数 token 必须用模型自带的分词器，而不是数字数或数词。

## 思维导图

```mermaid
flowchart LR
  A["原始文本"] --> B["预切分：空格粘在后一个词上"]
  B --> C["BPE 合并：常用整块、罕见拆块"]
  C --> D["token id 序列，长度 T"]
  D --> E["词嵌入表 50257×768"]
  E --> F["加位置编码"]
  F --> G["第一层注意力"]

  H["整词词表"] --> H1["几万格预算装不下全部词语"]
  H1 --> H2["表外变 UNK：无法区分、写不出"]
  I["字节级"] --> I1["256 个基础符号，永无 UNK"]
  I1 --> I2["序列变长，注意力开销翻倍"]
  H2 --> C
  I2 --> C

  E --> E1["id 是行号，取出即向量，不做计算"]
  E --> E2["参数由训练调整，同 id 恒同一向量"]
  E2 --> G
```

## 术语表

- 子词分词（subword tokenization）：把文本切成比词小、比字符大的片段，常见词整块保留，罕见词拆成常用碎片，从而在小词表下覆盖近乎无限的文本。
- BPE（byte pair encoding）：从基础符号（字节）开始，反复把语料中最常相邻的两个符号合并成新符号，直到词表达标；合并规则一旦学定就固定下来。
- 词表（vocabulary）：模型能直接表示的全部符号编号，大小等于基础符号数加上合并次数，是训练前就定死的参数预算。
- [UNK]：词表外文本被统一映射成的未知符号，会抹掉不同字符串之间的区别，生成时也写不出这些内容。
- 字节级（byte-level）：用 UTF-8 的 256 个字节作为基础符号，任何合法文本都能退回单字节，因此不再需要未知符号。
- 词嵌入表（token embedding）：形状为“词表大小 × 模型宽度”的参数矩阵，token id 作为行号取出对应的一行向量；这些数由训练得到，不是手工设计。

## 来源

1. [Hugging Face: OpenAI GPT2 文档 — GPT-2 分词器把空格并入 token，`Hello world` 与 ` Hello world` 编号不同](https://huggingface.co/transformers/v3.2.0/model_doc/gpt2.html)
2. [Hugging Face: Tokenization algorithms — 词级/字符级/子词分词对比、BPE 合并过程、GPT-2 词表 50257 的构成](https://huggingface.co/docs/transformers/en/tokenizer_summary)
3. [Sebastian Raschka: Tokenization and byte pair encoding — 未知词问题、UTF-8 17 字节对比 GPT-2 4 个 token、词表大小取舍](https://sebastianraschka.com/faq/docs/tokenization-bpe.html)
4. [tiktoken 文档：encoding 索引 — cl100k_base 100,256 与 o200k_base 199,998 个常规 token](https://docs.rs/tiktoken/latest/tiktoken/encoding/index.html)
5. [OpenAI Cookbook: How to count tokens with tiktoken — 同一单词在不同编码下的切分，空格通常粘在词首](https://developers.openai.com/cookbook/examples/how_to_count_tokens_with_tiktoken)
6. [nanoGPT model.py — `nn.Embedding(vocab_size, n_embd)`、词表补齐到 50304、std=0.02 初始化、与 lm_head 共享权重](https://github.com/karpathy/nanoGPT/blob/master/model.py)

---

原文：https://pangzhengboyin.com/articles/subword-tokenization-and-token-embeddings-4f566d3d

> **庞征博引** · 想学的，慢慢都会
>
> 庞征博引是把想学的东西写成连载的 AI 学习工具：每篇 5–10 分钟，边读边问，接下来学什么跟着你走。这篇就是这样写出来的。
>
> 开始你自己的连载 → https://pangzhengboyin.com
