> 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-007.md).

# Day 007：比较 WordPiece、BPE、Unigram 与字节级 Tokenizer

{% hint style="info" %}
**面对同一份训练语料和同一组测试文本，WordPiece、BPE、Unigram 与 Byte-level BPE 会怎样切分？差异究竟来自算法、基础单位，还是实验配置？**
{% endhint %}

## 学习目标

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

1. 用“训练目标 + 编码规则 + 基础单位”比较四类 Tokenizer；
2. 解释为什么 Byte-level BPE 不是与 BPE 完全互斥的第四种合并算法；
3. 在同一份语料上训练可复现的 WordPiece、字符 BPE、Unigram 与 Byte-level BPE；
4. 用 Token 数、unknown 覆盖字符数、offset 和词表大小解释真实输出；
5. 识别“Token 更少”可能只是整段变成 `[UNK]` 或记住训练句；
6. 区分算法差异与 normalizer、pre-tokenizer、词表容量、语料等混杂变量；
7. 设计一个面向真实 checkpoint、语言与任务的 Tokenizer 评测。

{% hint style="info" %}
本节是第 1 周实验。它会复用 [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) 的粒度权衡，以及 [Day 005](/ai-learning/di-yi-bu-fen-nlp-ji-chu/week-01/day-005.md) 的 BPE merge。实验产物可以作为 Mini LLM Lab 的第一个可复现组件。
{% endhint %}

## 1. 先规定比较对象：算法名不等于完整 Tokenizer

完整 Tokenizer 是一条流水线：

```
原始文本
  ↓ Normalizer
规范化文本
  ↓ Pre-tokenizer
粗粒度片段
  ↓ Model：WordPiece / BPE / Unigram ...
子词与 Token ID
  ↓ Post-processor
特殊 Token 与模型输入模板
  ↓ Decoder
尽可能恢复文本
```

如果直接比较四个公开模型，结果会同时混入：

* 训练语料和语言比例；
* Unicode 规范化与大小写规则；
* 预切分边界；
* 词表大小；
* 特殊 Token；
* 子词算法；
* byte fallback；
* checkpoint 的最大长度和模型架构。

所以“GPT-2 比 BERT 多切了几个 Token”不能直接证明 BPE 或 WordPiece 谁更好。

本节采用两层结论：

1. **受控玩具实验**：尽量固定语料、规范化和测试集，观察机制差异；
2. **工程评测原则**：真实项目必须比较具体 checkpoint 的完整流水线与下游指标。

{% hint style="warning" %}
这不是模型质量排行榜。没有训练语言模型，也没有测下游任务；实验只能回答“在这些配置下，Tokenizer 怎样表示这些字符串”。
{% endhint %}

## 2. 四套系统的机制

### 2.1 字符 BPE：从小单位反复合并高频相邻 pair

Day 005 已经完整推导 BPE：

```
基础字符
→ 统计相邻 pair
→ 合并最高频 pair
→ 更新邻接与频次
→ 重复
```

训练产物包含 Token vocabulary 与有序 merge rules。编码新文本时复用 merge rank，不重新统计，也不是任意最长匹配。

本节称它为“字符 BPE”，因为它从训练语料中出现的 Unicode 字符起步，没有完整字节兜底。未见字符仍可能得到 `[UNK]`。

### 2.2 WordPiece：常见实现按最长匹配编码

WordPiece 与 BPE 都学习子词词表，但编码阶段常见规则不同。给定一个预切分后的“词”，WordPiece 通常从左到右执行 longest-match-first：

```
tokenizers
→ t / ##o / ##k / ...
```

`##` 常表示该片段不是词首，而不是原文中真的出现了两个井号。

如果某个预切分片段无法被词表完整覆盖，常见 WordPiece 实现会让**整个片段**变成 `[UNK]`，而不是只替换失败的最后一个字符。这会在本节输出中直接出现。

WordPiece 的公开训练说明通常把候选 pair 分数写为类似：

$$
\operatorname{score}(a,b)
\=\frac{C(a,b)}{C(a)C(b)}
$$

它倾向于选择相对彼此更专属的组合，而不只看 pair 的绝对频次。但原始 WordPiece 训练细节没有像 BPE merges 那样形成唯一公开标准，不同实现可能不同。上式适合理解 Hugging Face 教程和本节库实现的动机，不能当作所有名为 WordPiece 的系统都必须逐字遵循的规范。

### 2.3 Unigram：从较大候选词表中删除，而不是从小表向上合并

Unigram Language Model 为每个候选 piece 分配概率。一个分词方案 $$z=(z\_1,\ldots,z\_m)$$ 的概率可写成：

$$
P(z)=\prod\_{i=1}^{m}p(z\_i)
$$

字符串 x 可能有多个合法分词，训练考虑这些分词的概率质量：

$$
P(x)=\sum\_{z\in\mathcal{S}(x)}P(z)
$$

确定性编码通常用动态规划/Viterbi 选择概率最大的路径：

$$
z^\*=\arg\max\_{z\in\mathcal{S}(x)}P(z)
$$

训练过程从较大的候选集合开始，通过 EM 估计概率，再反复删除对似然影响较小的 piece，直到接近目标词表大小。它与 BPE 的方向相反：

```
BPE：小词表 → 逐步增加合并项
Unigram：大候选表 → 逐步剪除低价值项
```

因为存在多条分词路径，Unigram 还自然支持按概率采样分词；本节使用确定性最佳路径。

### 2.4 Byte-level BPE：BPE 算法 + 字节基础字母表

Byte-level BPE 仍然执行 BPE 合并，只是初始单位来自完整的 256 个字节值：

```
Unicode 文本
→ UTF-8 字节
→ 可显示的字节代理符号
→ BPE merges
→ Token ID
```

所以更准确的关系是：

```
Byte-level BPE 是 BPE 的一种基础单位与流水线配置
而不是完全独立于 BPE 的第四种合并算法
```

若完整 256 字节都保留，任意合法 UTF-8 输入都可以退到底层字节，因此通常不需要 `[UNK]`。代价是中文、emoji 和罕见字符可能被拆成多个 Token，代理符号也不适合按肉眼直接解释。

## 3. 放到同一张表中

| 系统             | 训练方向/目标           | 常见确定性编码           | 本节基础单位    | 主要边界                        |
| -------------- | ----------------- | ----------------- | --------- | --------------------------- |
| WordPiece      | 学习子词词表；训练打分依实现    | 逐词最长匹配，非词首常带 `##` | 训练语料字符    | 一个词无法完整覆盖时可能整词 `[UNK]`      |
| 字符 BPE         | 逐轮合并高频相邻 pair     | 按 merge rank 合并   | 训练语料字符    | 未见字符可能 `[UNK]`              |
| Unigram        | 概率模型 + EM，从大候选集剪枝 | 选择最高概率分词路径        | 训练语料候选片段  | 无 byte fallback 时仍有 unknown |
| Byte-level BPE | BPE merges        | 按 merge rank 合并   | 完整 256 字节 | 覆盖稳，但序列与显示更复杂               |

这张表只描述本节配置。SentencePiece 同时支持 BPE 与 Unigram，也可以配置 byte fallback；“使用 SentencePiece”本身不能推出算法一定是 Unigram。

## 4. 实验设计与控制变量

### 4.1 训练语料

实验使用 44 条中英混合短句，由 9 种句子按不同频次重复：

```
自然语言处理很有趣 × 8
自然语言模型处理文本 × 6
机器学习处理数据 × 5
深度学习模型 × 4
tokenization builds subword units × 6
tokenizer tokenizes unseen words × 5
learning learned learner × 4
lowest lower low × 3
AI学习Tokenizer × 3
```

重复不是为了模拟真实语料，而是让频次差异在小实验中可见。

### 4.2 固定与无法完全固定的变量

固定项：

* 同一训练语料和顺序；
* 同一测试集；
* NFC normalizer；
* 同样的 `[UNK]`、`[PAD]`、`[CLS]`、`[SEP]`；
* BPE 与 WordPiece 的 `min_frequency=2`；
* `tokenizers==0.22.2`。

必要差异：

* WordPiece、字符 BPE、Unigram 使用 `Whitespace` pre-tokenizer；
* Byte-level BPE 必须使用 ByteLevel pre-tokenizer 与完整初始字母表；
* 前三者目标词表为 80；Byte-level BPE 为 300，因为 256 个基础字节加特殊 Token 已经超过 80；
* Unigram Trainer 最终只保留 59 项，因为小语料无法提供 80 个都有意义且可保留的候选项。

因此，这个实验不是严格的“只改变算法”消融。配置表必须与结果一起保存。

### 4.3 测试文本

```
自然语言处理很有趣   # 训练中高频出现
自然语言处理XYZ      # 已见中文 + 未见大写组合
tokenizers           # 未作为完整训练词出现，但片段相近
龘🙂                  # 训练中完全未见的汉字与 emoji
```

## 5. 完整可复现实验

安装依赖：

```bash
pip install tokenizers==0.22.2
```

运行：

```python
import tokenizers
from tokenizers import (
    Tokenizer,
    decoders,
    models,
    normalizers,
    pre_tokenizers,
    trainers,
)

SPECIAL = ["[UNK]", "[PAD]", "[CLS]", "[SEP]"]

# 通过重复句子制造可观察的频次差异；这不是生产训练语料。
CORPUS = (
    ["自然语言处理很有趣"] * 8
    + ["自然语言模型处理文本"] * 6
    + ["机器学习处理数据"] * 5
    + ["深度学习模型"] * 4
    + ["tokenization builds subword units"] * 6
    + ["tokenizer tokenizes unseen words"] * 5
    + ["learning learned learner"] * 4
    + ["lowest lower low"] * 3
    + ["AI学习Tokenizer"] * 3
)


def common_pipeline(model):
    """为三个非字节模型固定相同的规范化与预切分组件。"""
    tokenizer = Tokenizer(model)
    tokenizer.normalizer = normalizers.NFC()
    tokenizer.pre_tokenizer = pre_tokenizers.Whitespace()
    return tokenizer


wordpiece = common_pipeline(
    models.WordPiece(unk_token="[UNK]")
)
# 目标词表和最低频次相同，便于与字符 BPE 做教学对照。
wordpiece.train_from_iterator(
    CORPUS,
    trainers.WordPieceTrainer(
        vocab_size=80,
        min_frequency=2,
        special_tokens=SPECIAL,
        show_progress=False,
    ),
)

char_bpe = common_pipeline(
    models.BPE(unk_token="[UNK]")
)
char_bpe.train_from_iterator(
    CORPUS,
    trainers.BpeTrainer(
        vocab_size=80,
        min_frequency=2,
        special_tokens=SPECIAL,
        show_progress=False,
    ),
)

unigram = common_pipeline(models.Unigram())
# Unigram 从较大候选集合剪枝；目标 80 不保证实际一定保留 80 项。
unigram.train_from_iterator(
    CORPUS,
    trainers.UnigramTrainer(
        vocab_size=80,
        special_tokens=SPECIAL,
        unk_token="[UNK]",
        show_progress=False,
    ),
)

byte_bpe = Tokenizer(models.BPE(unk_token="[UNK]"))
byte_bpe.normalizer = normalizers.NFC()
# ByteLevel 必须保留完整 256 字节字母表，因此词表预算高于前三者。
byte_bpe.pre_tokenizer = pre_tokenizers.ByteLevel(
    add_prefix_space=False
)
byte_bpe.decoder = decoders.ByteLevel()
byte_bpe.train_from_iterator(
    CORPUS,
    trainers.BpeTrainer(
        vocab_size=300,
        min_frequency=2,
        special_tokens=SPECIAL,
        initial_alphabet=pre_tokenizers.ByteLevel.alphabet(),
        show_progress=False,
    ),
)

systems = [
    ("WordPiece", wordpiece),
    ("BPE-char", char_bpe),
    ("Unigram", unigram),
    ("ByteLevel-BPE", byte_bpe),
]

tests = [
    "自然语言处理很有趣",
    "自然语言处理XYZ",
    "tokenizers",
    "龘🙂",
]

print("tokenizers:", tokenizers.__version__)

for name, tokenizer in systems:
    unk_id = tokenizer.token_to_id("[UNK]")
    print(f"\n{name}: vocab={tokenizer.get_vocab_size()}")

    for text in tests:
        # 不添加特殊 Token，让结果只反映正文切分与 unknown 行为。
        encoding = tokenizer.encode(
            text,
            add_special_tokens=False,
        )
        unknown_count = sum(
            # Unigram 可能显示原始表面片段，所以必须按 ID 判断 unknown。
            token_id == unk_id
            for token_id in encoding.ids
        )
        print(
            f"{text!r} -> {encoding.tokens} "
            f"| n={len(encoding.ids)}, unk={unknown_count}"
        )
```

## 6. 本次实测输出

```
tokenizers: 0.22.2

WordPiece: vocab=80
'自然语言处理很有趣' -> ['自', '##然', '##语', '##言', '##处', '##理', '##很', '##有', '##趣'] | n=9, unk=0
'自然语言处理XYZ' -> ['[UNK]'] | n=1, unk=1
'tokenizers' -> ['t', '##o', '##k', '##e', '##n', '##i', '##z', '##e', '##r', '##s'] | n=10, unk=0
'龘🙂' -> ['[UNK]', '[UNK]'] | n=2, unk=2

BPE-char: vocab=80
'自然语言处理很有趣' -> ['自然语言处理很有趣'] | n=1, unk=0
'自然语言处理XYZ' -> ['自然语言', '处理', '[UNK]', '[UNK]', '[UNK]'] | n=5, unk=3
'tokenizers' -> ['tokeniz', 'er', 's'] | n=3, unk=0
'龘🙂' -> ['[UNK]', '[UNK]'] | n=2, unk=2

Unigram: vocab=59
'自然语言处理很有趣' -> ['自然语言', '处理', '很', '有', '趣'] | n=5, unk=0
'自然语言处理XYZ' -> ['自然语言', '处理', 'XYZ'] | n=3, unk=1
'tokenizers' -> ['tokenize', 'r', 's'] | n=3, unk=0
'龘🙂' -> ['龘', '🙂'] | n=2, unk=2

ByteLevel-BPE: vocab=300
'自然语言处理很有趣' -> ['èĩªçĦ¶è¯Ńè¨Ģ', 'å¤ĦçĲĨ', 'å', '¾', 'Ī', 'æľ', 'ī', 'è', '¶', '£'] | n=10, unk=0
'自然语言处理XYZ' -> ['èĩªçĦ¶è¯Ńè¨Ģ', 'å¤ĦçĲĨ', 'X', 'Y', 'Z'] | n=5, unk=0
'tokenizers' -> ['tokeniz', 'er', 's'] | n=3, unk=0
'龘🙂' -> ['é', '¾', 'ĺ', 'ð', 'Ł', 'Ļ', 'Ĥ'] | n=7, unk=0
```

## 7. 逐个解释最重要的结果

### 7.1 WordPiece 的 `n=1` 不是高效，而是信息丢失

```
自然语言处理XYZ → [UNK]
```

这一整段在 `Whitespace` 预切分后是一个连续片段。WordPiece 最长匹配无法完整覆盖尾部 `XYZ`，于是整个片段折叠为一个 `[UNK]`。

如果只看 Token 数，它从 9 个字符变成 1 个 Token，好像“压缩率最好”；实际上中文和 `XYZ` 的区别全部丢失。

所以评测必须同时报告：

* Token 数；
* `[UNK]` ID 数；
* `[UNK]` 覆盖了多少原文字符；
* offset 对应的失败区间。

### 7.2 字符 BPE 的 1 个 Token 是玩具语料记忆

```
自然语言处理很有趣 → ['自然语言处理很有趣']
```

这句话在 44 条语料中重复 8 次，而且目标词表足以连续合并整个字符串。BPE 因此把训练句记成一个长 Token。

这不证明 BPE 对中文总能做到“一句一个 Token”。它反而揭示：

* 小语料的重复模板会占用词表；
* 训练集 Token 数可以被过度优化；
* 应在独立保留集上测量表示效率；
* 长 Token 的 Embedding 是否训练充分还取决于上下文多样性。

### 7.3 Unigram 的显示 Token 可能掩盖 unknown ID

输出中出现：

```
['自然语言', '处理', 'XYZ']
['龘', '🙂']
```

看起来 `XYZ`、`龘` 和 emoji 被“保留”了，但代码按 ID 统计得到 `unk=1` 和 `unk=2`。本次实现为了保留 offset/表面片段，在 `encoding.tokens` 中显示原字符串；这些位置的 `encoding.ids` 实际都等于 `[UNK]` 的 ID 0。

因此：

```
显示 token 字符串 ≠ 一定拥有独立词表 ID
```

诊断 OOV 时应同时看 Tokens、IDs、unknown ID 和 offsets，不能只搜索字符串 `[UNK]`。

### 7.4 Byte-level BPE 的代理符号不是乱码

```
龘🙂 → ['é', '¾', 'ĺ', 'ð', 'Ł', 'Ļ', 'Ĥ']
```

`龘` 的 UTF-8 占 3 字节，`🙂` 占 4 字节，本例未学到相关合并，所以共得到 7 个 byte Token。显示出的 `é`、`¾`、`Ł` 等是可逆字节代理符号，不是原文真的被改写成这些字符。

解释或恢复文本时必须使用同一 Tokenizer 的 decoder，不能直接用：

```python
"".join(encoding.tokens)
```

完整 256 字节初始字母表使四个测试都没有 unknown，但代价是罕见多字节字符的序列更长。

### 7.5 `tokenizers` 展示了可复用片段

虽然完整字符串 `tokenizers` 没有作为训练句中的独立词出现，训练语料含有 `tokenizer`、`tokenizes` 与 `tokenization`。结果：

```
BPE-char     → tokeniz / er / s
Unigram      → tokenize / r / s
ByteLevel-BPE→ tokeniz / er / s
WordPiece    → t / ##o / ##k / ...
```

不同算法根据训练目标保留了不同片段。WordPiece 在本次 80 项小词表中把许多容量留给基础字符与 continuation 变体，因此切得更碎；这不是 WordPiece 在大词表上必然逐字母切分。

## 8. 用 offset 统计 unknown 覆盖，而不是只数 `[UNK]`

下面把四个测试文本共 30 个 Python 码点作为分母。对每个 unknown ID，使用 `[start,end)` offset 统计它覆盖的原文位置；用集合去重，避免同一字符重复计算。

```python
for name, tokenizer in systems:
    unk_id = tokenizer.token_to_id("[UNK]")
    total_tokens = 0
    unknown_chars = 0

    for text in tests:
        # offsets 使用左闭右开区间 [start, end)，对应原始字符串位置。
        encoding = tokenizer.encode(
            text,
            add_special_tokens=False,
        )
        total_tokens += len(encoding.ids)
        covered_positions = set()

        for token_id, (start, end) in zip(
            encoding.ids,
            encoding.offsets,
        ):
            if token_id == unk_id:
                # 用集合合并重叠区间，避免同一字符被重复计数。
                covered_positions.update(range(start, end))

        unknown_chars += len(covered_positions)

    print(
        f"{name:13} "
        f"vocab={tokenizer.get_vocab_size():3d} "
        f"tokens={total_tokens:2d} "
        f"unknown_chars={unknown_chars:2d}/30"
    )
```

本次实测输出：

```
WordPiece     vocab= 80 tokens=22 unknown_chars=11/30
BPE-char      vocab= 80 tokens=11 unknown_chars= 5/30
Unigram       vocab= 59 tokens=13 unknown_chars= 5/30
ByteLevel-BPE vocab=300 tokens=25 unknown_chars= 0/30
```

解释时必须把两个指标放在一起：

* WordPiece 只有 22 个 Token，却有 11/30 个字符落入 unknown；
* 字符 BPE 的 11 个 Token 很短，但包含对高频训练整句的记忆；
* Byte-level BPE 有 25 个 Token，覆盖却最稳定；
* 不同词表大小和基础字母表使总 Token 数不能当作公平的单一排名。

## 9. 这个实验仍然不能得出什么

### 9.1 不能证明某算法让模型质量更高

没有训练语言模型，也没有下游标签。Tokenizer 的序列更短，不代表分类、生成或检索效果更好。

### 9.2 不能证明 Byte-level BPE 永远更慢

本例中字节 Token 较多，但真实速度还受模型宽度、注意力实现、batching、缓存、硬件和编译影响。需要实际测吞吐、延迟与显存。

### 9.3 不能把差异全部归因于算法

Byte-level 系统使用不同 pre-tokenizer 和更大词表；Unigram 实际词表不足 80；小语料重复程度很高。这些都是混杂变量。

### 9.4 不能从玩具中文推断多语言公平性

真实多语言评测必须按语言、文字系统、领域和群体切片。总体平均 Token 数可能掩盖某种语言被过度切碎。

## 10. 真实项目怎样评估 Tokenizer

### 10.1 使用预训练 checkpoint 时

通常不重新选择算法，而是使用配套 Tokenizer。评测目标是发现它是否适合自己的数据：

1. 分语言/领域计算每字符、每词或每字节 Token 数；
2. 统计 `[UNK]`、byte fallback 与异常长片段；
3. 统计截断率和有效原文长度；
4. 核对 offset、decode round-trip 与特殊 Token；
5. 测下游质量、吞吐、延迟、显存和成本；
6. 保存最差案例，而不是只看平均值。

不能把另一个 Tokenizer 直接替换进旧模型，因为 ID 与 Embedding 行的契约会失效。

### 10.2 从头训练模型时

可以做更接近公平的消融：

```
同一训练/验证/测试语料
同一规范化与数据去重
同一语言采样比例
多个词表预算
多个 Tokenizer 随机种子/训练配置
同等参数或同等计算预算的语言模型训练
```

至少报告：

| 指标                            | 说明                       |
| ----------------------------- | ------------------------ |
| actual vocab size             | 真实生成的词表项数，不只写目标值         |
| tokens/character、fertility    | 表示效率，按语言与领域切片            |
| unknown/fallback coverage     | 多少原文位置丢失或退回字节            |
| truncation rate               | 固定上下文窗口内丢失多少样本           |
| round-trip/offset tests       | 解码和位置对齐是否可靠              |
| validation loss / task metric | Tokenizer 与模型联合后的质量      |
| throughput / latency / memory | 实际系统成本                   |
| worst slices                  | 罕见字符、代码、emoji、低资源语言和领域术语 |

### 10.3 Mini LLM Lab 应保存的产物

本周实验建议保存：

```
实验配置：库版本、语料哈希、normalizer、pre-tokenizer、vocab_size
Tokenizer 文件：vocab、merges 或 tokenizer.json
评测结果：按切片的 Token 数、unknown、offset、round-trip
失败案例：输入、Tokens、IDs、offsets、解释
结论：哪些结果是事实，哪些只是对原因的推断
```

这比只截一张 Token 列表更适合项目复现和面试答辩。

## 11. 常见误区

### 误区 1：WordPiece 就是把 BPE 的 Token 加上 `##`

错误。两者训练与编码规则不同；`##` 只是常见 WordPiece continuation 标记。

### 误区 2：SentencePiece 就等于 Unigram

错误。SentencePiece 是工具与原始句子处理框架，同时支持 Unigram 和 BPE 等模型。

### 误区 3：Byte-level Tokenizer 不使用 BPE

错误。Byte-level BPE 正是在字节基础单位上继续学习 BPE merges。

### 误区 4：Token 最少的系统一定最好

错误。整段 `[UNK]` 或记住训练模板都会让 Token 数很小，却可能丢信息或泛化很差。

### 误区 5：`encoding.tokens` 没显示 `[UNK]` 就没有 OOV

错误。本节 Unigram 会显示 unknown 的原始表面片段，但对应 ID 仍是 `[UNK]` ID。应检查 IDs 与 offsets。

### 误区 6：相同 `vocab_size` 就是公平比较

不完整。还要控制实际词表大小、基础字母表、特殊 Token、normalizer、pre-tokenizer、语料与训练停止行为。

### 误区 7：代理符号可以直接拼接恢复 Byte-level 文本

错误。它们表示可逆映射后的字节，需要配套 decoder。

### 误区 8：玩具语料上的整句 Token 表明学到了整句语义

错误。Tokenizer 只学到了高频字符串单元；语义要由后续模型和上下文训练获得。

### 误区 9：比较公开 checkpoint 就能隔离算法影响

错误。不同 checkpoint 的语料、词表、预处理、模型规模与训练目标都不同。

## 12. 练习与面试准备

### 12.1 30 秒回答

> BPE 从小单位开始反复合并高频相邻 pair，并按 merge rank 编码；WordPiece 常用最长匹配编码，片段无法完整覆盖时可能整词变 `[UNK]`；Unigram 为候选 piece 建概率模型，从较大候选集经 EM 和剪枝得到词表，再用动态规划选最大概率分词；Byte-level BPE 仍是 BPE，只是从完整 256 字节起步，覆盖稳定但序列可能更长。比较时不能只看 Token 数，还要控制语料、词表、normalizer 和 pre-tokenizer，并报告 unknown 覆盖、截断、下游质量和系统成本。

### 12.2 递进练习与面试题

#### 问题 1：BPE 与 Unigram 的训练方向为什么相反？

<details>

<summary>查看答案</summary>

BPE 从基础字母表开始，每轮新增一个合并片段；Unigram 先建立较大候选集合，估计 piece 概率，再删除对语料似然影响较小的候选。前者是逐步增加，后者是逐步剪枝。

</details>

#### 问题 2：为什么 WordPiece 的 1 个 `[UNK]` 可能比 BPE 的 3 个 `[UNK]` 丢失更多？

<details>

<summary>查看答案</summary>

unknown ID 数量不表示覆盖长度。WordPiece 可能把整个无法完整切分的预切分片段折叠成一个 `[UNK]`，而 BPE 只对三个未见字符各输出一个 unknown。应通过 offset 统计被 unknown 覆盖的原文字符数。

</details>

#### 问题 3：Byte-level BPE 为什么通常没有 OOV？

<details>

<summary>查看答案</summary>

任意合法 Unicode 文本都能编码为 UTF-8 字节，字节值只有 256 种。只要基础字母表完整保留这 256 个值，即使没有任何高层 merge，也能逐字节表示输入。

</details>

#### 问题 4：为什么不能只用平均 tokens/character 选 Tokenizer？

<details>

<summary>查看答案</summary>

平均值可能被训练模板、unknown 折叠和主流语言支配。还需按语言/领域切片报告 unknown、fallback、截断、最差样本、下游质量、吞吐、延迟、显存和 offset/round-trip 正确性。

</details>

#### 问题 5：如何证明差异来自算法而非语料？

<details>

<summary>查看答案</summary>

使用同一训练/保留语料、规范化、预切分、特殊 Token 与多个词表预算；记录实际词表大小和版本；在同等模型参数或计算预算下重复训练并做消融。若 byte-level 必须改变基础字母表，要明确它是设计变量而不是隐藏混杂项。

</details>

### 12.3 手写题：unknown 字符覆盖率

给定一个 `encoding.ids`、`encoding.offsets` 和 `unk_id`，实现 unknown 覆盖字符数。重叠 offset 只计一次。

<details>

<summary>查看参考答案</summary>

```python
def unknown_character_count(
    ids: list[int],
    offsets: list[tuple[int, int]],
    unk_id: int,
) -> int:
    """返回所有 unknown offset 覆盖的不同字符位置数。"""
    covered = set()

    for token_id, (start, end) in zip(ids, offsets):
        if token_id == unk_id:
            # set 自动去重重叠区间中的字符位置。
            covered.update(range(start, end))

    return len(covered)


assert unknown_character_count(
    ids=[7, 0, 0],
    offsets=[(0, 2), (2, 5), (4, 6)],
    unk_id=0,
) == 4
```

两个 unknown 区间 `[2,5)` 与 `[4,6)` 有一个重叠位置，覆盖并集是 `{2,3,4,5}`，所以结果为 4，不是区间长度之和 5。

</details>

### 12.4 项目答辩题：多语言 Tokenizer 选型

你要从头训练一个中英混合领域模型。候选 A 在总体测试集 tokens/character 更低，但中文医疗切片的 unknown 覆盖为 2.5%；候选 B 使用 byte fallback，总体序列长 18%，没有 unknown，训练吞吐低 12%。如何选择？

<details>

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

先确认统计口径、语料比例和 unknown 来源，并把中文医疗进一步按术语、罕见汉字、数字/单位和混合文本切片。A 的低 Token 数可能来自信息折叠，不能直接接受；可尝试补基础字符、调整领域语料/词表容量，或为 A 加 byte fallback。对 A、B 训练同等预算的小模型，比较领域验证 loss、问答/抽取指标、截断、上下文有效长度、吞吐、延迟和显存；同时检查通用中英文回归。最终决策要给出质量底线与成本预算，例如 unknown 必须为 0、领域指标不得下降、吞吐损失可接受上限 15%，而不是只凭单一平均值。

</details>

### 12.5 基础自测

<details>

<summary>1. Byte-level BPE 与纯字节模型有什么区别？</summary>

Byte-level BPE 从字节起步后继续合并高频字节序列，一个 Token 可包含多个字节；纯字节模型始终让每个字节成为一个模型 Token，不学习 BPE merges。

</details>

<details>

<summary>2. 为什么本节 Byte-level BPE 的目标词表不能设为 80？</summary>

完整字节字母表已有 256 个基础值，再加 4 个特殊 Token 就至少需要 260 个位置。80 无法同时保留完整字节覆盖。

</details>

<details>

<summary>3. Unigram 的 `['XYZ']` 为什么仍可能是 unknown？</summary>

表面 Token 用于保留原文和 offset，但其 ID 可以是统一的 unknown ID。判断覆盖必须检查 `encoding.ids` 是否等于 `unk_id`。

</details>

<details>

<summary>4. 为什么高频整句成为一个 BPE Token 可能不是好事？</summary>

它占用词表容量、上下文多样性低，可能只压缩训练模板而不能泛化；对应 Embedding 也可能只在少数固定语境中更新。

</details>

<details>

<summary>5. 使用现成预训练模型时，能否选择本实验 Token 数最少的 Tokenizer 替换原 Tokenizer？</summary>

不能直接替换。新旧 ID 与 Embedding 行语义不一致，切分和特殊 Token 模板也会变化。若确需迁移，必须调整并训练模型参数，做完整质量与回归验证。

</details>

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

1. [Neural Machine Translation of Rare Words with Subword Units](https://aclanthology.org/P16-1162/)\
   将 BPE 用于开放词汇神经机器翻译的经典论文，以及序列长度和子词覆盖动机。
2. [BERT 原始论文](https://aclanthology.org/N19-1423/)\
   BERT 使用 WordPiece 与输入表示的原始来源。
3. [Japanese and Korean Voice Search](https://ieeexplore.ieee.org/document/6289079)\
   Schuster 与 Nakajima 提出 WordPiece 的早期论文来源。
4. [Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates](https://aclanthology.org/P18-1007/)\
   Unigram Language Model、多个分词候选与 subword regularization 的核心论文。
5. [SentencePiece](https://aclanthology.org/D18-2012/)\
   直接从原始句子训练语言无关子词模型，并支持 BPE 与 Unigram。
6. [Hugging Face Transformers：Tokenization algorithms](https://huggingface.co/docs/transformers/main/tokenizer_summary)\
   BPE、WordPiece、Unigram 与 Byte-level BPE 的官方教学比较。
7. [Hugging Face Tokenizers：Models](https://huggingface.co/docs/tokenizers/main/api/models)\
   本实验使用的 `BPE`、`WordPiece` 和 `Unigram` 模型接口与参数。
8. [Hugging Face Tokenizers：Trainers](https://huggingface.co/docs/tokenizers/main/api/trainers)\
   `BpeTrainer`、`WordPieceTrainer`、`UnigramTrainer` 的词表、频次和初始字母表配置。
9. [Hugging Face Tokenizers：Pre-tokenizers](https://huggingface.co/docs/tokenizers/main/api/pre-tokenizers)\
   `Whitespace` 与 `ByteLevel` 的预切分行为和 byte alphabet。
10. [OpenAI GPT-2 `encoder.py`](https://github.com/openai/gpt-2/blob/master/src/encoder.py)\
    Byte-to-Unicode 可逆映射、byte-level 预切分、BPE rank 与缓存的参考实现。

## 一页回顾

```
完整 Tokenizer：
Normalizer
→ Pre-tokenizer
→ Model
→ Post-processor
→ Decoder

BPE：
小基础表开始
→ 合并高频相邻 pair
→ 按 merge rank 编码

WordPiece：
学习子词词表
→ 常见编码为逐词最长匹配
→ 无法完整覆盖时可能整词 [UNK]

Unigram：
大候选表开始
→ 概率模型 + EM
→ 剪除低价值 piece
→ 动态规划选最大概率路径

Byte-level BPE：
完整 256 字节基础表
+ BPE merges
→ 覆盖稳定，序列可能更长
→ 代理符号必须由 decoder 还原

本实验最重要的反例：
Token 少不一定好
1 个 [UNK] 可能丢掉整段文本
1 个长 BPE Token 可能只是记住训练模板
Unigram 表面字符串可能仍映射到 unk_id

评测至少同时报告：
实际词表大小
Token 数/fertility
unknown 覆盖字符数
fallback 与截断
offset/round-trip
下游质量
吞吐、延迟和显存

第 1 周闭环：
Unicode/字节
→ 规范化
→ 词表与 OOV
→ 粒度
→ BPE merges
→ 特殊 Token 与 masks
→ Tokenizer 对比实验
```


---

# 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-007.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.
