> For the complete documentation index, see [llms.txt](https://inwt233.gitbook.io/ai-learning/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://inwt233.gitbook.io/ai-learning/di-yi-bu-fen-nlp-ji-chu/week-01/day-005.md).

# Day 005：BPE 的合并过程

{% hint style="info" %}
**BPE 如何从字符或字节基础单位开始，依据相邻符号频率学出子词；训练得到的 merge rules 又如何切分新文本？**
{% endhint %}

## 学习目标

完成本节后，你应该能够：

1. 区分压缩 BPE 与 NLP 子词 BPE，以及训练与编码阶段；
2. 根据词频加权统计相邻符号对；
3. 手工追踪至少 6 轮“统计 → 选择 → 合并 → 重算”；
4. 解释 tie-breaking、非重叠替换与 merge rank；
5. 区分 vocabulary 与有序 merge rules；
6. 用标准 Python 实现并验证一个最小 BPE；
7. 分析 BPE 的停止条件、复杂度与分布偏差。

{% hint style="info" %}
本节默认已经掌握 [Day 002](/ai-learning/di-yi-bu-fen-nlp-ji-chu/week-01/day-002.md) 的规范化与预切分、[Day 003](/ai-learning/di-yi-bu-fen-nlp-ji-chu/week-01/day-003.md) 的词表与 OOV，以及 [Day 004](/ai-learning/di-yi-bu-fen-nlp-ji-chu/week-01/day-004.md) 的字符/字节粒度、byte-level BPE 与 byte fallback。下面只保留理解 BPE 合并所必需的前置条件。
{% endhint %}

## 1. BPE 要解决的具体问题

Day 3 看到，整词词表容易遇到长尾和 OOV；Day 4 看到，字符或字节覆盖更稳定，却会拉长序列。BPE（Byte Pair Encoding，字节对编码）尝试在两端之间自动学习常见片段：

```
初始：l / o / w / e / s / t
学习后：low / est
```

训练语料中反复一起出现的相邻单位可以合并为新符号。高频模式逐渐变成较长 Token，低频或未见词仍可由较小单位组合。

这带来一个核心折中：

```
小型基础字母表
  + 有限次高频相邻合并
  = 可控词表 + 开放词汇表示 + 较短序列
```

但 BPE 并不是“把所有常见词直接加入词表”，也不是拿最终词表做任意最长匹配。它学习的是一串**有顺序的合并规则**。

## 2. 压缩 BPE 与 NLP 子词 BPE

### 2.1 原始压缩算法

经典 BPE 是一种数据压缩方法。它反复寻找数据中最常见的相邻字节对，用一个新符号替换，再保存替换表以便还原。

示意：

```
A A A B A A A B

最常见 pair: A A
合并后：AA A B AA A B
```

这里的目标是压缩数据，基本单位还是 byte。

### 2.2 NLP 中的改造

Sennrich 等人在神经机器翻译中推广的子词 BPE 保留了“反复合并高频相邻对”的思想，但目标变成学习子词词表：

```
语料
  ↓ 规范化与预切分
带频次的词/片段序列
  ↓ 拆成字符等基础符号
反复学习 merge rules
  ↓
子词词表 + 有序合并规则
```

因此，名称里有 `Byte` 不代表现代 NLP BPE 必然从 UTF-8 字节开始。具体实现的初始单位可以是：

* Unicode 字符/码点；
* UTF-8 字节经过可逆映射后的符号；
* 预先定义的其他基础字母表。

{% hint style="warning" %}
判断一个 Tokenizer 是否为 byte-level BPE，不能只看 “BPE” 三个字母，必须检查它的 normalizer、pre-tokenizer、initial alphabet、model 和 decoder。
{% endhint %}

## 3. BPE 训练前的三个固定条件

BPE 只在给定符号序列中学习合并，不负责决定所有文本预处理。训练开始前必须固定：

1. **Normalizer**：决定大小写、Unicode 规范化等统计对象；训练与推理必须一致。
2. **Pre-tokenizer**：决定哪些粗粒度边界不能跨越；BPE 通常只在每个 split 内合并。
3. **Initial alphabet**：决定最小可表示单位和兜底能力；有限字符表仍可能 OOV，完整 256 字节集合则可覆盖任意 UTF-8 输入。

完整预处理链路见 [Day 002](/ai-learning/di-yi-bu-fen-nlp-ji-chu/week-01/day-002.md)，字符、byte-level 与 fallback 的系统区别见 [Day 004](/ai-learning/di-yi-bu-fen-nlp-ji-chu/week-01/day-004.md)。本节玩具实验采用“每个词独立处理、Unicode 字符起步、追加 `</w>`”这一固定配置。

## 4. 玩具语料与加权 pair 频次

本节使用经典的小型词频表：

```
low      × 5
lower    × 2
newest   × 6
widest   × 3
```

这里不是把每个词只写一次。`newest` 出现 6 次，它内部每个相邻 pair 都应贡献 6 次。

我们采用字符作为初始单位，并在词尾加入 `</w>`：

```
l o w </w>          × 5
l o w e r </w>      × 2
n e w e s t </w>    × 6
w i d e s t </w>    × 3
```

设符号序列为 `s`，它在语料中的频次为 `f(s)`；`N_(a,b)(s)` 是 `(a,b)` 在该序列中相邻出现的次数。加权 pair 频次为：

$$
C(a,b)=\sum\_s f(s)N\_{(a,b)}(s)
$$

例如：

$$
C(e,s)=6+3=9
$$

因为 `newest` 与 `widest` 中各有一次 `e s`，词频分别为 6 和 3。

而：

$$
C(l,o)=5+2=7
$$

因为 `low` 和 `lower` 都以 `l o` 开始。

第一轮的部分统计是：

| pair        | 来源                      | 加权频次 |
| ----------- | ----------------------- | ---: |
| `(e, s)`    | `newest` 6 + `widest` 3 |    9 |
| `(s, t)`    | `newest` 6 + `widest` 3 |    9 |
| `(t, </w>)` | `newest` 6 + `widest` 3 |    9 |
| `(w, e)`    | `lower` 2 + `newest` 6  |    8 |
| `(l, o)`    | `low` 5 + `lower` 2     |    7 |
| `(o, w)`    | `low` 5 + `lower` 2     |    7 |
| `(n, e)`    | `newest` 6              |    6 |
| `(e, w)`    | `newest` 6              |    6 |

## 5. 并列频次必须规定 tie-breaking

第一轮有三个 pair 的频次都是 9：

```
(e, s)
(s, t)
(t, </w>)
```

仅说“选择频次最高的 pair”还不足以复现实验。不同实现可能采用：

* 首次扫描到的 pair；
* 字典序最小或最大；
* 堆或哈希表内部顺序；
* 其他显式优先级。

本节规定：

> 按词频表的固定插入顺序、每个序列从左到右扫描；频次相同时，选择最早首次出现的 pair。

于是第一轮选择 `(e, s)`。这个规则不代表所有库的实现，只是让教学实验完全确定。生产中训练器版本、并列处理和保存下来的 merge 文件都可能影响最终结果。

## 6. 手工追踪 6 轮 BPE

### 第 1 轮：`(e, s) → es`，频次 9

```
l o w </w>          × 5
l o w e r </w>      × 2
n e w es t </w>     × 6
w i d es t </w>     × 3
```

注意：新符号 `es` 立即改变了邻接关系。原来的 `(s,t)` 消失，变成 `(es,t)`。

### 第 2 轮：`(es, t) → est`，频次 9

```
l o w </w>          × 5
l o w e r </w>      × 2
n e w est </w>      × 6
w i d est </w>      × 3
```

### 第 3 轮：`(est, </w>) → est</w>`，频次 9

```
l o w </w>          × 5
l o w e r </w>      × 2
n e w est</w>       × 6
w i d est</w>       × 3
```

`est</w>` 表示只在词尾出现的 `est`。它不同于可能出现在词中间的 `est`。

### 第 4 轮：`(l, o) → lo`，频次 7

```
lo w </w>           × 5
lo w e r </w>       × 2
n e w est</w>       × 6
w i d est</w>       × 3
```

这里最容易算错。第一轮中 `(w,e)` 的频次曾是 8，但完成前三轮后，它不再是 8：

* `lower` 中仍有 `w e`，贡献 2；
* `newest` 中原来的 `w e s` 已变成 `w est</w>`，不再包含 `(w,e)`。

所以每轮必须基于**当前语料状态**重新统计，不能沿用第一轮频次表。

### 第 5 轮：`(lo, w) → low`，频次 7

```
low </w>            × 5
low e r </w>        × 2
n e w est</w>       × 6
w i d est</w>       × 3
```

### 第 6 轮：`(n, e) → ne`，频次 6

```
low </w>            × 5
low e r </w>        × 2
ne w est</w>        × 6
w i d est</w>       × 3
```

最终前 6 条规则按 rank 排列为：

```
1. e   s       → es
2. es  t       → est
3. est </w>    → est</w>
4. l   o       → lo
5. lo  w       → low
6. n   e       → ne
```

## 7. 为什么每轮都必须重新统计

合并不仅删除旧 pair，还会创建新 pair。

若局部序列为：

```
x / e / s / t / y
```

执行 `(e,s) → es` 后：

```
x / es / t / y
```

发生了三件事：

1. `(x,e)` 与 `(e,s)` 消失；
2. `(s,t)` 消失；
3. `(x,es)` 与 `(es,t)` 出现。

因此，正确训练循环是：

```
统计当前所有相邻 pair
  ↓
按频次和 tie-break 选 best pair
  ↓
在整个语料中合并它
  ↓
重新统计新的相邻 pair
  ↓
重复
```

高效实现不一定每轮从头扫描全部语料，可以维护增量计数、倒排位置和优先队列；但逻辑结果仍必须等价于更新后的统计。

## 8. 重叠 pair 怎样合并

考虑：

```
a / a / a
```

pair `(a,a)` 在位置 `(0,1)` 和 `(1,2)` 看起来出现两次，但两处共享中间的 `a`，不能同时被两个新符号消费。

本节采用从左到右、非重叠替换：

```
a / a / a
↓ 合并第 0、1 个
 aa / a
```

而不是：

```
aa / aa  # 错误：中间的 a 被使用了两次
```

pair **统计**可以按每个相邻窗口计数，而一次实际替换必须保证每个输入符号最多被消费一次。实现时用索引跳过已经合并的两个位置最清晰。

## 9. 训练与编码是两个不同阶段

### 9.1 训练阶段

输入是训练语料及配置，输出至少包含：

```
vocabulary
merge rules（有序）
normalizer / pre-tokenizer 等配置
```

训练循环会根据全语料频次决定下一条规则。

### 9.2 编码阶段

编码新文本时不会重新统计它的 pair，也不会新增规则。它只复用训练好的流水线：

```
新文本
  ↓ 同一 Normalizer
  ↓ 同一 Pre-tokenizer
  ↓ 拆成同一基础单位
  ↓ 按训练所得 merge rank 合并
  ↓ 词表查 ID
```

以未出现在训练词频表中的 `lowest` 为例：

```
l o w e s t </w>
  ↓ (e, s)
l o w es t </w>
  ↓ (es, t)
l o w est </w>
  ↓ (est, </w>)
l o w est</w>
  ↓ (l, o)
lo w est</w>
  ↓ (lo, w)
low est</w>
```

结果为：

```
["low", "est</w>"]
```

这正是开放词汇表示：训练中没见过完整词 `lowest`，但学过的片段仍可组合它。

另一个未见词 `newer`：

```
n e w e r </w>
  ↓ 前三条规则均不匹配
  ↓ (n, e)
ne w e r </w>
```

结果为：

```
["ne", "w", "e", "r", "</w>"]
```

### 9.3 为什么编码不等于任意 greedy longest-match

Day 4 的 `longest_match()` 只看最终词表，并在每个位置选择最长片段。BPE 编码则受 merge rank 约束：一个较长字符串即使存在于某份词表，也不代表当前输入可以无条件选择它。

BPE 的可达合并结构来自训练规则：

```
字符 → 较短片段 → 更长片段
```

Hugging Face 的 BPE 模型也分别接收 `vocab` 和 `merges`；GPT-2 的实现则把 merge 文件顺序转成 rank，每次优先执行优先级最高、也就是数值 rank 最小的可用 pair。

所以保存和迁移 BPE 时，只拿 `vocab.json` 而丢掉 `merges.txt` 通常是不完整的。

## 10. Vocabulary 与 merge rules 分别记录什么

### 10.1 Vocabulary

Vocabulary 是 Token 字符串到 ID 的映射，例如：

```
"l"        → 17
"lo"       → 302
"low"      → 918
"est</w>"  → 1204
```

它回答：

> 最终产生这个 Token 后，应该查哪一行 Embedding？

### 10.2 Merge rules

Merge rules 是有序 pair 列表，例如：

```
(e, s)
(es, t)
(est, </w>)
...
```

它回答：

> 从基础符号开始，哪些相邻单位可以合并，优先级是什么？

若初始字母表大小为 `A`，没有重复和特殊处理，每执行一次有效合并最多新增一个普通词表项。粗略地：

$$
|V| \approx A + M + S
$$

其中 `M` 是有效 merge 数，`S` 是特殊 Token 等额外项。真实库还受 `vocab_size`、`min_frequency`、初始字母表裁剪和实现策略影响，不能机械地把公式当作精确结果。

## 11. `</w>` 与边界信息

为什么不直接把每个词拆成字符，而要加 `</w>`？因为边界会影响学到的单位。

```
est + </w> → est</w>
```

这个 Token 明确表示词尾 `est`，不会自动等同于词中间的 `est`。边界标记可以防止某些不合理的跨词合并，也让词首、词中、词尾变体拥有不同表示。

不同系统使用不同约定：

* 早期 subword-nmt 示例常用词尾标记；
* SentencePiece 常用 `▁` 编码空格/词首；
* GPT-2 风格 Tokenizer 常见 `Ġ`，它来自前导空格的 byte-level 显示映射；
* 某些 BPE 配置使用 `continuing_subword_prefix` 或 `end_of_word_suffix`。

这些符号的语义由具体 Tokenizer 定义，不能互换解释。

{% hint style="warning" %}
本节为了手算清晰，把每个词独立处理并加入 `</w>`。这不是 GPT-2 Tokenizer 的复刻。GPT-2 的预切分、字节映射和空格表示都不同。
{% endhint %}

## 12. 本节实现属于字符 BPE

本节的数据流是：

```
词 → Unicode 字符 + </w> → BPE merges
```

它便于手算，但有限字符表可能无法覆盖未见字符。GPT-2 风格 byte-level BPE 则从完整 UTF-8 字节集合的可逆代理符号起步，再执行同一种“按 merge rank 合并”的核心机制。二者的 BPE 逻辑相通，基础单位、预切分与 decoder 不同。

纯字节模型不学习 BPE 合并，byte fallback 只是无法覆盖时的兜底路径；三者的完整比较见 [Day 004](/ai-learning/di-yi-bu-fen-nlp-ji-chu/week-01/day-004.md)。因此，本节代码不是 GPT-2 Tokenizer 的复刻。

## 13. 最小 Python 实现

下面的代码只使用 Python 标准库。它保留完整重算过程，不是生产级高性能 Trainer。

```python
from collections import defaultdict

END_OF_WORD = "</w>"

def initialize_corpus(
    word_freqs: dict[str, int],
) -> dict[tuple[str, ...], int]:
    """把词频表转成字符序列，并加入词尾标记。"""
    return {
        tuple(word) + (END_OF_WORD,): frequency
        for word, frequency in word_freqs.items()
    }


def count_pairs(
    corpus: dict[tuple[str, ...], int],
) -> tuple[dict[tuple[str, str], int],dict[tuple[str, str], int]]:
    """统计加权频次，并记录 pair 首次出现顺序。"""
    pair_counts = defaultdict(int)
    first_seen = {}
    order = 0

    for symbols, frequency in corpus.items():
        for left, right in zip(symbols, symbols[1:]):
            pair = (left, right)
            pair_counts[pair] += frequency
            if pair not in first_seen:
                first_seen[pair] = order
                order += 1

    return dict(pair_counts), first_seen

def merge_pair(symbols: tuple[str, ...], pair: tuple[str, str]) -> tuple[str, ...]:
    """从左到右合并指定 pair 的非重叠出现。"""
    merged = []
    i = 0

    while i < len(symbols):
        if (i + 1 < len(symbols) and symbols[i] == pair[0] and symbols[i + 1] == pair[1]):
            merged.append(pair[0] + pair[1])
            i += 2
        else:
            merged.append(symbols[i])
            i += 1
    return tuple(merged)

def train_bpe(
    word_freqs: dict[str, int],
    num_merges: int,
) -> tuple[
    list[tuple[str, str]],
    list[tuple[tuple[str, str], int]],
    dict[tuple[str, ...], int],
]:
    """训练一个教学用字符 BPE。"""
    corpus = initialize_corpus(word_freqs)
    merges = []
    history = []

    for _ in range(num_merges):
        pair_counts, first_seen = count_pairs(corpus)
        if not pair_counts:
            break

        best_pair = min(pair_counts, key=lambda pair: (-pair_counts[pair], first_seen[pair]))
        best_count = pair_counts[best_pair]
        corpus = {
            merge_pair(symbols, best_pair): frequency
            for symbols, frequency in corpus.items()
        }
        merges.append(best_pair)
        history.append((best_pair, best_count))

    return merges, history, corpus


def encode_word(
    word: str,
    merges: list[tuple[str, str]],
) -> list[str]:
    """按训练得到的 merge 顺序编码一个词。"""
    symbols = tuple(word) + (END_OF_WORD,)

    for pair in merges:
        symbols = merge_pair(symbols, pair)

    return list(symbols)
```

测试训练过程：

```python
word_freqs = {
    "low": 5,
    "lower": 2,
    "newest": 6,
    "widest": 3,
}

merges, history, final_corpus = train_bpe(word_freqs, num_merges = 6)

expected_merges = [
    ("e", "s"),
    ("es", "t"),
    ("est", END_OF_WORD),
    ("l", "o"),
    ("lo", "w"),
    ("n", "e"),
]
assert merges == expected_merges
assert merge_pair(
    ("a", "a", "a"),
    ("a", "a"),
) == ("aa", "a")
assert encode_word(
    "newer",
    merges,
) == ["ne", "w", "e", "r", END_OF_WORD]
assert encode_word(
    "lowest",
    merges,
) == ["low", "est</w>"]
assert sum(final_corpus.values()) == 16

for step, (pair, count) in enumerate(
    history,
    start=1,
):
    print(
        f"{step}. {pair} -> "
        f"{pair[0] + pair[1]} "
        f"(count={count})"
    )

print("newer:", encode_word("newer", merges))
print("lowest:", encode_word("lowest", merges))
```

本次实测输出：

```
1. ('e', 's') -> es (count=9)
2. ('es', 't') -> est (count=9)
3. ('est', '</w>') -> est</w> (count=9)
4. ('l', 'o') -> lo (count=7)
5. ('lo', 'w') -> low (count=7)
6. ('n', 'e') -> ne (count=6)
newer: ['ne', 'w', 'e', 'r', '</w>']
lowest: ['low', 'est</w>']
```

前三轮频次都为 9，是因为 `newest × 6` 与 `widest × 3` 共享的词尾链依次从 `e s` 变成 `es t`，再变成 `est </w>`，总权重始终为 9。第四、五轮的 7 来自 `low × 5` 与 `lower × 2` 共享的词首链；第六轮的 6 只来自 `newest × 6`。`newer` 表明未见词会保留未合并的基础字符，`lowest` 则表明训练中没见过的整词可以由已学到的 `low` 和 `est</w>` 组合。

### 13.1 代码省略了什么

这个实现没有包含：

* 从原始文档计算词频的 normalizer 和 pre-tokenizer；
* Token 到 ID 的完整 vocabulary；
* `min_frequency` 停止条件；
* 特殊 Token 与 post-processor；
* byte-level 映射和 decoder；
* 增量 pair 计数、缓存与并行训练；
* offset mapping；
* BPE dropout。

它验证的是核心算法，不应直接当作生产 Tokenizer。

## 14. 停止条件与分布权衡

训练通常在达到 `num_merges`、目标 `vocab_size`，或最高 pair 频次低于 `min_frequency` 时停止。更多 merge 往往让训练分布上的序列更短，但会增加词表项；Embedding 参数关系 `|V|d` 已在 [Day 003](/ai-learning/di-yi-bu-fen-nlp-ji-chu/week-01/day-003.md) 推导，粒度与序列成本的联合权衡见 [Day 004](/ai-learning/di-yi-bu-fen-nlp-ji-chu/week-01/day-004.md)。

BPE 优化的是训练语料中的相邻频率，不是语言学边界。阈值过低会吸收偶然共现和噪声，产生训练不足的低频 Token；阈值过高则会让领域术语、低资源语言继续被切碎。因此应分语言与领域测 fertility、每字符/字节 Token 数、UNK/fallback、截断率、下游质量、吞吐和延迟，而不是只报告词表大小。

## 15. 计算复杂度与工程实现

设当前语料展开后共有 `N` 个符号，进行 `M` 轮合并。最朴素实现每轮：

1. 扫描语料统计 pair，约 `O(N)`；
2. 扫描语料替换 pair，约 `O(N)`。

粗略上界可写成：

$$
O(MN)
$$

实际 `N` 会随合并逐渐下降，但大型语料仍不能靠本节的全量 Python 重算训练。高效实现会维护：

* pair 到出现位置的索引；
* pair 频次与优先队列；
* 合并后受影响邻域的增量更新；
* 并行词频统计；
* 编码缓存。

编码时，朴素地遍历所有 merge rules 也不是最高效方法。GPT-2 风格实现会查看当前可用 pair 的 rank，只执行优先级最高者，并缓存常见片段的编码结果。

## 16. BPE 的优势、代价与失败方式

| 方面    | 优势               | 代价/失败方式                     |
| ----- | ---------------- | --------------------------- |
| 开放词汇  | 未见词可由基础单位和子词组合   | 基础字母表不完整时仍可能 OOV            |
| 序列长度  | 高频字符串可成为单个 Token | 低资源语言和生僻领域可能被过度切碎           |
| 词表规模  | 比整词词表更可控         | merge 过多仍会增大 Embedding 与输出层 |
| 训练规则  | 简单、确定、易保存        | 并列处理和预处理差异会改变结果             |
| 语言学解释 | 常得到可读词根或词缀       | 优化频次，不保证符合词素边界              |
| 鲁棒性   | 比整词 OOV 更稳       | 拼写变化可能显著改变后续切分              |

### 16.1 频率高不等于语义完整

BPE 只看相邻统计，不理解词义。它可能学到：

* 有语言学意义的词根；
* 高频后缀；
* 常见空格加词片段；
* 偶然高频的格式、代码或网页模板；
* 跨越人类直觉边界的字符串。

### 16.2 更长 Token 不一定总更好

长 Token 可以缩短序列，却可能：

* 只在少数上下文出现，Embedding 学得不充分；
* 降低相似词之间的片段共享；
* 让新领域变体仍被切碎；
* 增大词表和输出 softmax 成本。

## 17. 常见误区

### 误区 1：BPE 名字里有 Byte，所以一定逐字节训练

错误。NLP BPE 可以从字符开始，也可以从字节映射符号开始。要看具体 Tokenizer 配置。

### 误区 2：第一轮 pair 频次算完后可以一直排序使用

错误。每次合并都会删除并创建邻接关系，必须更新频次。

### 误区 3：相同最高频 pair 随便选都不影响结果

错误。先合并哪一个会改变后续邻接和 merge rank，最终模型可能不同。可复现实验必须固定 tie-breaking 或保存训练产物。

### 误区 4：BPE 编码就是在词表里做最长匹配

错误。标准 BPE 受有序 merge rules 约束；词表负责 Token 到 ID，merge rules 负责切分如何形成。

### 误区 5：训练时没见过完整词，编码一定得到 `<UNK>`

错误。只要基础单位能够覆盖，新词可以由已知小单位和子词组合。

### 误区 6：有 BPE 就绝不会产生 `<UNK>`

错误。字符 BPE 若初始字母表不含新字符且没有 byte fallback，仍可能 unknown。完整 256 字节覆盖才提供稳定兜底。

### 误区 7：每个 pair 的两次重叠出现都能同时替换

错误。实际合并必须非重叠，每个输入符号只能被消费一次。

### 误区 8：子词越符合词素，BPE 就训练得越正确

错误。BPE 的目标是频繁相邻合并，不是语言学形态分析；词素重合只是可能结果。

### 误区 9：可以只把新 merge 文件装到旧模型

错误。Token ID、词表行和 Embedding 是契约。改变词表或 ID 后必须同步调整模型权重并训练，不能只替换 Tokenizer 文件。

## 18. 面试准备与答案

### 18.1 30 秒回答

> NLP 中的 BPE 从字符或字节等基础单位开始，按语料词频加权统计相邻符号对，每轮选择最高频 pair，非重叠地合并后重新统计，直到满足 merge、词表或频次停止条件。训练产物既有 vocabulary，也有按优先级排列的 merge rules；编码新文本时不再学习，只按这些规则合并。它在词表大小、序列长度和覆盖率之间折中，但切分依赖语料，不保证符合词素；字符 BPE 也不等于 byte-level BPE。

### 18.2 递进面试题

#### 问题 1：为什么 pair 统计要乘词频，并在每轮后重算？

<details>

<summary>查看答案</summary>

词频为 6 表示该序列在真实语料中出现 6 次，其中每个相邻位置也贡献 6 次。合并会删除旧邻接并创建新邻接，例如 `w/e/s` 合并 `(e,s)` 后变成 `w/es`，所以旧的 `(w,e)` 与 `(e,s)` 消失，新 `(w,es)` 出现；沿用第一轮统计会选择错误规则。

</details>

#### 问题 2：同频 pair 为什么需要 tie-breaking？

<details>

<summary>查看答案</summary>

先合并哪个 pair 会改变后续邻接和 merge rank，因此可能产生不同模型。训练器必须规定稳定规则，或至少保存最终有序 merges。本节采用“固定语料顺序、从左到右扫描，同频选首次出现最早者”。

</details>

#### 问题 3：vocabulary 与 merge rules 分别负责什么？

<details>

<summary>查看答案</summary>

Vocabulary 把最终 Token 字符串映射到 ID；merge rules 规定从基础符号出发，哪些相邻单位可以按什么优先级合并。只有词表项 `abc` 并不足以保证输入 `abc` 会产生该 Token，它还必须在初始单位和 merge 路径上可达。

</details>

#### 问题 4：为什么 BPE 编码不是任意 greedy longest-match？

<details>

<summary>查看答案</summary>

Longest-match 只看最终词表；BPE 编码受训练所得 merge rank 约束，每次执行当前可用且优先级最高的 pair。训练时没建立的合并路径不能因为最终词表里恰好有一个长字符串就被任意采用。

</details>

#### 问题 5：字符 BPE、byte-level BPE 与 byte fallback 的关系是什么？

<details>

<summary>查看答案</summary>

字符 BPE 从字符/码点起步，基础表有限时仍可能 OOV；byte-level BPE 从完整字节集合起步，并对字节代理符号学习全部 merges；byte fallback 的主路径仍是普通子词，只在无法覆盖时退回字节。名称中的 BPE 不自动意味着逐字节训练。

</details>

### 18.3 手算与手写题

给定 `hug × 10`、`pug × 5`、`hugs × 3`，加入 `</w>`，按“固定语料顺序、从左到右扫描，同频选首次出现”求前三次合并，并说明 `a/a/a` 合并 `(a,a)` 的结果。

<details>

<summary>查看答案</summary>

第一轮主要计数为 `(h,u)=13`、`(u,g)=18`、`(g,</w>)=15`、`(p,u)=5`、`(g,s)=3`、`(s,</w>)=3`，所以先合并 `(u,g)→ug`，频次 18。重算后 `(ug,</w>)=15`，第二轮合并为 `ug</w>`；随后 `(h,ug</w>)=10`，第三轮合并为 `hug</w>`。`a/a/a` 中两个候选窗口共享中间符号，实际替换必须非重叠，因此从左到右得到 `aa/a`，不是 `aa/aa`。

对应的核心实现应包含加权 `count_pairs()` 和索引跳步的 `merge_pair()`；本节第 13 节代码就是参考答案。

</details>

### 18.4 项目答辩题：中文医疗 BPE

一个 50k 词表的中文医疗问答 Tokenizer 在通用中文上每字符 0.85 Token，在医疗术语上每字符 1.9 Token，部分罕见字符变成 `<UNK>`，增大词表后训练吞吐下降。如何改进？

<details>

<summary>查看答题要点</summary>

先检查 normalizer、pre-tokenizer、initial alphabet 与 `<UNK>` 的实际来源，区分字符覆盖不足和领域 merges 不足。罕见字符问题可补基础字符、启用 byte fallback，或改用完整 byte-level 基础集合；医疗术语切碎则需从清洗、去重且合理配比的领域语料增加候选 merges。新 Token 必须同步新增 Embedding/输出行，可由旧切分向量聚合初始化，并继续预训练或领域适配。实验要按通用/医疗/罕见字符切片报告 fertility、UNK/fallback、截断、问答质量、原通用能力、延迟、吞吐和显存，并预先定义回滚标准。不能只替换 merge 或 vocab 文件。

</details>

## 19. 权威资料与延伸阅读

1. [Neural Machine Translation of Rare Words with Subword Units](https://aclanthology.org/P16-1162/)\
   将 BPE 用于开放词汇神经机器翻译的经典论文，解释通过子词表示稀有词和未登录词的动机。
2. [subword-nmt](https://github.com/rsennrich/subword-nmt)\
   上述工作配套的参考实现，可查看学习 BPE、应用 codes、词表约束和 glossary 等具体行为。
3. [Hugging Face NLP Course：Byte-Pair Encoding tokenization](https://huggingface.co/learn/llm-course/chapter6/5)\
   用 `hug`、`pug` 等小语料逐步展示词频加权统计、合并学习和新词编码。
4. [Hugging Face Tokenizers：Models](https://huggingface.co/docs/tokenizers/main/api/models)\
   BPE 模型的 `vocab`、`merges`、`unk_token`、词边界配置与 `byte_fallback` 等官方接口。
5. [Hugging Face Tokenizers：Components](https://huggingface.co/docs/tokenizers/main/components)\
   Normalizer、PreTokenizer、Model、PostProcessor 和 Decoder 的职责，以及 ByteLevel 预切分器的定义。
6. [OpenAI GPT-2 encoder.py](https://github.com/openai/gpt-2/blob/master/src/encoder.py)\
   可查看字节到 Unicode 的可逆映射、BPE rank、正则预切分、合并循环与缓存。
7. [SentencePiece](https://aclanthology.org/D18-2012/)\
   直接从原始句子训练语言无关子词模型，并把空格纳入表示；有助于比较传统按词 BPE 与不同边界设计。

## 一页回顾

```
BPE 训练：
原始语料
→ 规范化
→ 预切分
→ 基础字符/字节序列 + 词频
→ 统计加权相邻 pair
→ 选择最高频 pair（同频需固定规则）
→ 全语料非重叠合并
→ 重新统计
→ 达到 merge / vocab / min_frequency 条件后停止

关键公式：
C(a,b) = Σ_s f(s) N_(a,b)(s)

训练产物：
Vocabulary：Token → ID
Merge rules：pair 的合并顺序/rank
两者不能混为一谈

编码新文本：
复用同一 normalizer、pre-tokenizer 和初始单位
→ 按训练好的 merge rank 合并
→ 查 Token ID
不重新学习，不是任意最长匹配

本节前 6 次合并：
(e,s) → es
(es,t) → est
(est,</w>) → est</w>
(l,o) → lo
(lo,w) → low
(n,e) → ne

不要混淆：
字符 BPE ≠ byte-level BPE
byte-level BPE ≠ 纯字节模型
byte-level BPE ≠ byte fallback

工程权衡：
更多 merge → 词表通常更大、序列通常更短
但 Embedding/输出层更大，低频项更多
必须分语言和领域测 fertility、覆盖、质量和成本

下一步：
Day 6 学习特殊 Token、Attention Mask 与模型输入
看 Token ID 如何进一步组成可批处理的模型张量
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://inwt233.gitbook.io/ai-learning/di-yi-bu-fen-nlp-ji-chu/week-01/day-005.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
