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

# Day 003：词表、未知词和 OOV

{% hint style="info" %}
**模型的词表不可能收录所有文本，那么没有收录的内容如何变成 Token ID，模型又会损失什么信息？**
{% endhint %}

## 学习目标

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

1. 区分 token、type、vocabulary、OOV 和 unknown token；
2. 从训练语料构建一个最小词表，并解释最低词频阈值的作用；
3. 分别计算 OOV token rate 和 OOV type rate；
4. 解释为什么“单词不在词表”不一定产生 `[UNK]`；
5. 比较词级、字符级、子词级和字节级方法处理未知内容的方式；
6. 解释词表大小对序列长度、参数量和模型能力的影响；
7. 说明给预训练 Tokenizer 添加新 Token 后为什么必须调整模型。

## 1. 从 token、type 和 vocabulary 开始

假设语料只有一句话：

```
我 喜欢 NLP 我 喜欢 LLM
```

如果已经按空格切分，那么语料中共有 6 个 **token instances**：

```
[我, 喜欢, NLP, 我, 喜欢, LLM]
```

其中不同的 token 字符串只有 4 种：

```
{我, 喜欢, NLP, LLM}
```

这里需要区分：

* **token**：文本中某个位置出现的一次实例；
* **type**：去重后的一种不同形式；
* **vocabulary（词表）**：Tokenizer 允许使用的有限 Token 集合，以及 Token 到整数 ID 的映射。

在上面的语料中：

```
token 数 N = 6
type 数 |V_corpus| = 4
```

但“语料中出现过的所有 type”不一定就是最终模型词表。实际构建词表时可能删除低频项、限制最大容量，并加入特殊 Token。

### 1.1 词表不是普通单词列表

在 NLP 模型中，词表通常是一个映射：

```python
vocab = {
    "[PAD]": 0,
    "[UNK]": 1,
    "我": 2,
    "喜欢": 3,
    "NLP": 4,
}
```

它同时规定两件事：

1. 哪些字符串可以直接成为 Token；
2. 每个 Token 对应 Embedding 矩阵的哪一行。

因此，词表是 Tokenizer 与模型之间的接口契约。Token ID 的含义只在当前词表中成立。

## 2. 一个词表是怎样构建出来的

一个简化的词级词表构建流程如下：

```
训练语料
  ↓ 与训练时一致的规范化
切分成候选 Token
  ↓
统计频次
  ↓ 最低频次 min_freq 或容量上限 max_size
保留一部分 type
  ↓ 加入 [PAD]、[UNK] 等保留项
分配 Token ID
```

### 2.1 为什么删除低频词

训练语料中可能包含大量只出现一次的内容：

* 拼写错误；
* 人名、网址和随机字符串；
* 极少见专业术语；
* 数据采集噪声。

如果把每个低频词都放入词级词表：

* Embedding 矩阵会变大；
* 低频 Token 的向量只有很少训练样本，难以学好；
* 新语料仍会出现从未见过的词，问题并未消失。

因此传统词级系统常设置最低频次或只保留最高频的前 `K` 个 type。

### 2.2 用 Python 构建最小词表

```python
from collections import Counter

train_sentences = [
    ["我", "喜欢", "自然语言处理"],
    ["我", "喜欢", "机器学习"],
    ["机器学习", "很", "有趣"],
]

counter = Counter(
    token
    for sentence in train_sentences
    for token in sentence
)

reserved_tokens = ["<PAD>", "<UNK>"]
min_freq = 2

kept_tokens = sorted(
    (
        token
        for token, count in counter.items()
        if count >= min_freq
    ),
    key=lambda token: (-counter[token], token),
)

vocab = {
    token: token_id
    for token_id, token in enumerate(reserved_tokens + kept_tokens)
}

print(counter)
print(vocab)
```

输出：

```
Counter({'我': 2, '喜欢': 2, '机器学习': 2,
         '自然语言处理': 1, '很': 1, '有趣': 1})
{'<PAD>': 0, '<UNK>': 1, '喜欢': 2, '我': 3, '机器学习': 4}
```

这里有三个细节：

1. `自然语言处理` 等等词语在训练语料出现过，但因为频次只有 1，仍没有进入最终词表；
2. `<PAD>` 和 `<UNK>` 即使不来自普通文本，也占用词表位置；
3. 相同频次下继续按字符串排序，是为了让 ID 分配可复现。

{% hint style="warning" %}
OOV 的判断对象是“最终词表”，不是“训练语料中是否曾经出现”。一个词见过一次但被频率阈值删除，推理时仍然是 OOV。
{% endhint %}

## 3. 什么是 OOV

OOV 是 **out of vocabulary** 的缩写，表示某个输入单位不属于当前词表。

设词表为：

$$V = {t\_1, t\_2, \ldots, t\_{|V|}}$$

对于一个候选输入单位 (w)：

$$w \notin V \Rightarrow w \text{ is OOV}$$

但“候选输入单位”由 Tokenizer 的粒度决定：

* 词级系统询问整个单词是否在词表中；
* 字符级系统询问字符是否在词表中；
* 子词系统会继续尝试把完整单词拆成更小片段；
* 字节级系统可以把文本退回到 UTF-8 字节。

所以一句“这个词是 OOV”必须同时说明使用了哪个 Tokenizer。

{% hint style="info" %}
OOV 不能脱离 Tokenizer 而存在，因为每个 Tokenizer 的词表一般不同。
{% endhint %}

### 3.1 closed vocabulary 与 open vocabulary

**Closed vocabulary** 任务假设所有可能输入都来自一个预先固定的有限集合。例如只识别固定命令的语音系统，可能提前知道全部命令词。

**Open vocabulary** 场景不能提前列出所有输入。真实语言中的新名字、新术语、拼写变化、网址和混合语言，使开放词汇更常见。

需要注意，“开放词汇”通常不意味着模型拥有无限大的词表，而是模型能够用有限的基本单位组合表示未见过的文本。

## 4. `<UNK>` 如何处理未知词

传统词级做法是为未知内容保留一个伪 Token：

```
<UNK>  或  [UNK]
```

编码时，所有不在词表中的候选词都映射到同一个 ID：

```python
def encode(tokens: list[str], vocab: dict[str, int]) -> list[int]:
    unk_id = vocab["<UNK>"]
    return [vocab.get(token, unk_id) for token in tokens]


test_tokens = ["我", "喜欢", "深度学习", "深度学习"]
ids = encode(test_tokens, vocab)

print(ids)
```

输出：

```
[3, 2, 1, 1]
```

`深度学习` 两次都变成 ID 1。

### 4.1 `<UNK>` 解决了什么

它解决的是工程问题：

* 输入不会因为查不到 ID 而报错；
* 模型能学习“这里出现了某个未知词”的通用向量；
* 张量仍然由合法整数 ID 构成。

### 4.2 `<UNK>` 丢失了什么

假设下面三个词都不在词表中：

```
深度学习 → <UNK>
强化学习 → <UNK>
inwt2333 → <UNK>
```

进入 Embedding 层之前，它们已经变成同一个 ID。模型无法从这个位置的初始 Token 表示中知道原词是哪一个。

上下文仍可能帮助模型推测这个未知位置的作用，但已经无法恢复被折叠掉的拼写细节。这是“未知词问题”的核心信息损失。

## 5. OOV rate 应该怎样计算

“OOV 率”至少有两种常见口径，报告结果时必须写清分母。

### 5.1 OOV token rate

它关注文本中有多少次出现无法直接查到：

$$\text{OOV token rate} = \frac{\text{OOV token 实例数}}{\text{全部 token 实例数}}$$

测试序列：

```
我 / 喜欢 / 深度学习 / 深度学习
```

共有 4 个 token 实例，其中 `深度学习` 出现两次且均为 OOV：

$$\frac{2}{4}=50%$$

### 5.2 OOV type rate

它关注不同词形中有多少种无法直接查到：

$$\text{OOV type rate} = \frac{\text{不同 OOV type 数}}{\text{全部不同 type 数}}$$

测试序列有 3 种 type：

```
{我, 喜欢, 深度学习}
```

其中 1 种是 OOV：

$$\frac{1}{3}\approx 33.3%$$

### 5.3 代码实现

```python
def oov_statistics(
    tokens: list[str],
    vocab: dict[str, int],
) -> dict[str, float]:
    oov_tokens = [token for token in tokens if token not in vocab]
    all_types = set(tokens)
    oov_types = {token for token in all_types if token not in vocab}

    return {
        "token_rate": len(oov_tokens) / len(tokens),
        "type_rate": len(oov_types) / len(all_types),
    }


print(oov_statistics(test_tokens, vocab))
```

输出：

```
{'token_rate': 0.5, 'type_rate': 0.3333333333333333}
```

如果是子词 Tokenizer，还要说明统计的是“原始词不在完整 Token 词表中的比例”，还是“最终输出中 `[UNK]` 的比例”。两者可能完全不同。

## 6. 为什么 OOV 很难靠扩大词表彻底解决

自然语言具有明显的长尾现象：少量词非常高频，大量词很少出现。随着语料增加，新 type 仍会不断出现。

新增内容包括：

* 新人物、公司和产品名称；
* 数字、日期、网址和用户名；
* 拼写错误与口语变体；
* 新术语和网络用语；
* 训练语料覆盖不足的语言与文字；
* 新领域中的专业词汇。

词汇增长常用 Heaps' law 做近似描述：

$$|V(N)| = K N^{\beta}, \quad 0 < \beta < 1$$

其中 N 是已观察 token 数，|V(N)| 是不同 type 数。它表达的重点不是某组固定参数，而是：语料增长时，词表增长会变慢，却通常不会停止。

因此，把词级词表从 50,000 扩到 100,000 只能覆盖更多已见长尾词，无法保证未来不再出现 OOV。

## 7. 四种粒度如何面对未知内容

| 表示粒度     | 未见过的完整单词       | 真正产生 UNK 的条件          | 主要代价         |
| -------- | -------------- | --------------------- | ------------ |
| 词级       | 通常直接变成 `<UNK>` | 整词不在词表                | 信息损失大、词表大    |
| 字符级      | 可由字符逐个组成       | 某字符不在字符表              | 序列较长，词义需模型组合 |
| 子词级      | 尝试拆成已知子词       | 无法用现有子词完整覆盖           | 罕见文本会被切得很碎   |
| 字节级/字节回退 | 可退回 UTF-8 字节   | 基础 256 字节覆盖完整时通常无 OOV | 序列更长，人类可读性差  |

### 7.1 词级方法

```
vocab: [我, 喜欢, 机器学习]
input: 我 喜欢 深度学习
output: 我 喜欢 <UNK>
```

优点是序列短、Token 容易解释；缺点是词表庞大，而且所有未知词被折叠。

### 7.2 字符级方法

```
深度学习 → 深 / 度 / 学 / 习
```

即使完整词语没见过，只要单个字符在字符表中就能表示。中文常用字符数量有限，因此字符级覆盖比词级好，但序列变长，模型需要自己组合词义。

字符级仍不保证绝对无 OOV：罕见汉字、emoji 或未覆盖文字仍可能不在字符表中。

### 7.3 子词级方法

```
unhappiness → un / happi / ness
```

BPE、WordPiece 和 Unigram 都使用有限子词词表，把未见过的完整词拆成已知片段。Sennrich 等人的 BPE 工作正是为了让神经机器翻译更好地处理稀有词与未知词。

关键结论是：

> 完整单词不在词表，不等于最终一定产生 `[UNK]`。只要它能被已知子词完整覆盖，Tokenizer 仍能给出合法 ID 序列。

但子词模型仍可能产生 `[UNK]`，例如输入字符不在基础字符集合中，或具体实现的限制阻止了继续拆分。

### 7.4 字节级方法与 byte fallback

任何 Unicode 文本都能先编码为 UTF-8 字节，每个字节只有 256 种可能值。如果 Tokenizer 保留全部字节作为基础单位，就能覆盖任意合法 UTF-8 输入。

优点：

* 不需要为每种文字预先收录字符；
* 对拼写变化、罕见字符和混合语言更稳健；
* 可以消除传统意义上的未知字符。

代价：

* 一个字符可能需要多个字节 Token；
* 序列更长；
* Token 序列对人类不易读；
* 模型需要从更细粒度单位组合出语言结构。

ByT5 直接使用字节序列，并系统分析了参数量、训练计算量和推理速度之间的权衡。

## 8. 实验：`bert-base-chinese` 仍会产生 `[UNK]`

安装并加载 Tokenizer：

```bash
pip install transformers
```

```python
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained(
    "google-bert/bert-base-chinese"
)

print("vocab_size:", tokenizer.vocab_size)
print("unk_token:", tokenizer.unk_token)
print("unk_token_id:", tokenizer.unk_token_id)
print("\n")
texts = [
    "自然语言处理",
    "自然语言处理XYZ",
    "龘",
    "🙂",
    "ChatGPT学习",
]

for text in texts:
    ids = tokenizer(
        text,
        add_special_tokens=False,
    )["input_ids"]
    tokens = tokenizer.convert_ids_to_tokens(ids)
    print(repr(text),"\n", tokens, "\n", ids, "\n")
```

本次实测输出：

```
vocab_size: 21128
unk_token: [UNK]
unk_token_id: 100

'自然语言处理' 
['自', '然', '语', '言', '处', '理'] 
[5632, 4197, 6427, 6241, 1905, 4415]

'自然语言处理XYZ' 
['自', '然', '语', '言', '处', '理', '[UNK]'] 
[5632, 4197, 6427, 6241, 1905,4415, 100]

'龘' 
['[UNK]'] 
[100]

'🙂' 
['[UNK]'] 
[100]

'ChatGPT学习' 
['[UNK]', '学', '习'] 
[100, 2110, 739]
```

### 8.1 结论

1. `自然语言处理` 不是一个完整 Token，但每个汉字都有词表项，所以没有 `[UNK]`；
2. `XYZ` 和 `ChatGPT` 无法按该 Tokenizer 的规则完整表示，相关片段变成 `[UNK]`；
3. `龘` 是合法 Unicode 汉字，但合法 Unicode 不等于存在于 BERT 词表；
4. emoji 同样可能被折叠成 `[UNK]`；
5. 词表覆盖问题取决于具体模型，不能从“这是中文 BERT”推断它能覆盖全部中文字符。

还要注意：`tokenizer.vocab_size` 通常表示基础模型词表大小；`len(tokenizer)` 还可能包含后来添加的 Token。没有添加 Token 时，两者通常相同。

## 9. 词表大小的核心权衡

词表并非越大越好，也并非越小越好。

### 9.1 大词表

优点：

* 高频词或常见短语更可能由一个 Token 表示；
* 相同文本的 Token 序列可能更短；
* 模型不必总从细碎片段重新组合常见词义。

代价：

* Embedding 与输出层参数增加；
* 长尾 Token 获得的训练次数少；
* 训练数据不足时，很多词表行学得不充分；
* 多语言词表的容量如何分配会影响不同语言。

### 9.2 小词表

优点：

* Embedding 参数较少；
* 每个基本单位通常获得更多训练机会；
* 组合能力更强，较容易覆盖新词。

代价：

* 文本会切成更多 Token；
* 输入序列更长；
* Transformer 的注意力计算对序列长度敏感；
* 罕见语言或专业文本可能被过度切碎。

### 9.3 Embedding 参数量

词表大小为 |V|，隐藏维度为 d 时，输入 Embedding 参数量为：

$$|V| \times d$$

用 `bert-base-chinese` 的词表大小 21,128 和隐藏维度 768 估算：

```python
vocab_size = 21_128
hidden_size = 768

parameters = vocab_size * hidden_size
memory_mib = parameters * 4 / 1024**2

print(parameters)
print(memory_mib)
```

输出：

```
16226304
61.8984375
```

仅输入 Embedding 就约有 1,623 万个参数；按 float32 粗略计算约 61.9 MiB。语言模型的输出投影也与词表大小相关，有些架构会把输入 Embedding 与输出权重绑定，因此实际新增参数取决于模型设计。

### 9.4 序列长度也会带来成本

小词表使序列从长度 n 增加到更长的 n'。标准全注意力中，注意力矩阵规模随序列长度近似按平方增长：

$$O(n^2)$$

因此设计词表是在两类成本间取平衡：

```
更大词表 → 更大 Embedding / 输出层，序列可能更短
更小词表 → 更小 Embedding / 输出层，序列可能更长
```

## 10. 特殊 Token 也属于词表

常见特殊 Token 包括：

| Token             | 常见作用                 |
| ----------------- | -------------------- |
| `[PAD]` / `<pad>` | 将不同长度序列补齐成批次         |
| `[UNK]` / `<unk>` | 表示无法被词表和切分规则覆盖的内容    |
| `[CLS]`           | BERT 中放在序列开头，常用于句级任务 |
| `[SEP]`           | 分隔或结束 BERT 输入序列      |
| `[MASK]`          | BERT 掩码语言模型的预测位置     |
| `<bos>` / `<s>`   | 序列开始                 |
| `<eos>` / `</s>`  | 序列结束                 |

它们不是普通文本单词，但仍拥有 Token ID 和对应模型参数。SentencePiece 也会为 unknown、BOS、EOS 和 padding 等 meta symbols 保留 ID。

不同模型的特殊 Token 名称、ID 和用法不同，不能把 BERT 的 `[CLS]`、`[SEP]` 直接套到任意 LLM。

## 11. 给预训练模型增加 Token 时发生了什么

先纠正一个很容易产生的误解：

> 预训练模型不会在推理时观察到一个新词频繁出现，就自动把它加入词表。

如果直接把一批包含“量子纠错码”的新语料输入原模型，而不修改 Tokenizer，那么原 Tokenizer 仍会按旧规则处理它。例如 `bert-base-chinese` 会继续把它切成：

```
量 / 子 / 纠 / 错 / 码
```

模型可以在这些旧 Token 上继续训练，也可能已经能通过它们和上下文理解该术语。**出现新语料**与**扩充词表**是两个彼此独立的操作。

### 11.1 三种看起来相似、实际不同的情况

#### 情况 A：只继续训练模型，不改变 Tokenizer

数据流仍然是：

```
量子纠错码
  ↓ 旧 Tokenizer
量 / 子 / 纠 / 错 / 码
  ↓ 旧 Token ID 与旧 Embedding
继续预训练或微调
```

这里没有新增 Token。模型通过已有的五个 Token 及其上下文学习这个概念。这通常是最简单、风险最低的方案。

#### 情况 B：开发者显式追加 Token

开发者事先决定把某个字符串作为整体 Token，再调用 `add_tokens()`。这个 API 不会读取语料、统计词频或自动选择候选词，它只检查指定字符串是否已经存在，并把不存在的项追加到 ID 空间末尾。

对于中文 BERT，还要注意 Added Token 的规范化方式。下面是本次实际运行的版本：

```python
from transformers import AddedToken, AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained(
    "google-bert/bert-base-chinese"
)

term = "量子纠错码"
print("before:", tokenizer.tokenize(term))

added_token = AddedToken(term, normalized=False)
num_added = tokenizer.add_tokens([added_token])

print("num_added:", num_added)
print("after:", tokenizer.tokenize(term))
print("new id:", tokenizer.convert_tokens_to_ids(term))
print("vocab_size:", tokenizer.vocab_size)
print("len(tokenizer):", len(tokenizer))
```

本次实测输出：

```
before: ['量', '子', '纠', '错', '码']
num_added: 1
after: ['量子纠错码']
new id: 21128
vocab_size: 21128
len(tokenizer): 21129
```

这里可以观察到：

1. `add_tokens()` 前，该术语由五个旧 Token 表示；
2. `add_tokens()` 后，完整字符串匹配到一个新 Token；
3. 新 Token 的 ID 是 21,128，位于原基础词表之后；
4. `tokenizer.vocab_size` 仍报告基础模型词表大小，而 `len(tokenizer)` 包含追加项；
5. `normalized=False` 表示 Added Token 按原始字符串匹配。是否需要它取决于具体 Tokenizer 的规范化流程，不能机械套用。

{% hint style="warning" %}
Added Token 与通过 BPE、WordPiece 等算法训练出来的基础词表项并不完全等价。它通常在正常子词切分前按额外规则匹配，因此边界、大小写、前导空格和规范化配置都可能改变结果。
{% endhint %}

#### 情况 C：用新语料重新训练 Tokenizer

另一种做法是让 BPE、WordPiece、Unigram 或 WordLevel Trainer 在领域语料上重新学习词表。这时训练器会根据算法、频率和词表容量选择单位。

但如果从头训练一个新 Tokenizer，旧 Token 的 ID 和切分方式可能大面积变化。旧模型的 Embedding 第 100 行原本对应 Token A，新 Tokenizer 的 ID 100 却可能对应 Token B，两者不能直接拼在一起。

因此，给预训练模型做词表扩展时，工程上通常要满足：

* 保留全部旧 Token 及旧 ID；
* 只把筛选出的新 Token 追加到末尾；
* 为新增行初始化参数；
* 再进行继续预训练或微调。

### 11.2 模型利用什么阈值决定是否增加 Token

答案是：**预训练模型本身没有这个阈值。**

需要把两类接口分开：

| 操作                            | 谁选择新 Token | 是否自动读取语料 | 阈值的含义                                                        |
| ----------------------------- | ---------- | -------- | ------------------------------------------------------------ |
| `tokenizer.add_tokens([...])` | 开发者直接给出字符串 | 否        | 没有词频阈值，只检查是否已存在                                              |
| 训练 WordLevel Tokenizer        | Trainer    | 是        | `min_frequency` 常指候选词自身最低频次                                  |
| 训练 BPE/WordPiece              | Trainer    | 是        | 受 `vocab_size`、合并候选频率和算法规则影响；BPE 的 `min_frequency` 常约束可合并符号对 |
| 训练 Unigram                    | Trainer    | 是        | 从较大候选集合中按概率模型和目标词表大小逐步删减                                     |

所以，即使两个训练器都提供名为 `min_frequency` 的参数，它们统计的对象也未必相同。不能把它统一理解成“某个完整单词出现超过 100 次就成为 Token”。

#### 没有通用的最佳阈值

领域词表扩展一般同时考虑下面几个信号：

1. **出现频次**：该字符串在目标语料中出现多少次；
2. **旧切分长度**：原 Tokenizer 平均把它切成几个 Token；
3. **覆盖质量**：它是否包含 `[UNK]`，或者被切成大量单字符、字节；
4. **上下文多样性**：它是否出现在不同句式和语义关系中，而非同一句话重复复制；
5. **序列长度收益**：加入后能节省多少 Token 位置；
6. **下游收益**：是否改善准确率、召回率、困惑度、速度或成本；
7. **回归风险**：新 Token 是否意外抢占其他字符串的切分，影响旧任务。

可以使用一个很粗略的候选排序启发式：

$$
\operatorname{gain}(t)
\=c(t)\left(k\_{\text{old}}(t)-1\right)
$$

其中：

* $c(t)$ 是候选字符串 $t$ 在目标语料中的出现次数；
* $k\_{\text{old}}(t)$ 是旧 Tokenizer 表示该字符串所需的 Token 数；
* 加入后假设它变成 1 个 Token。

例如“量子纠错码”出现 1,000 次，旧表示每次需要 5 个 Token，那么理论上最多可以少用：

$$
1000\times(5-1)=4000
$$

个 Token 位置。

这只是衡量**压缩收益**的工程启发式，不是通用标准，更不能证明模型质量一定提高。实际还要处理前导空格、词形变化、大小写和重叠候选，并在保留集上评估。

### 11.3 `resize_token_embeddings()` 到底改了什么

设原词表大小为 $|V|$，隐藏维度为 $d$，输入 Embedding 矩阵为：

$$
E\in\mathbb{R}^{|V|\times d}
$$

追加 $m$ 个 Token 后，需要把它扩成：

$$
E'\in\mathbb{R}^{(|V|+m)\times d}
$$

旧的前 $|V|$ 行通常保留，末尾新增 $m$ 行。Hugging Face 模型用下面的调用同步尺寸：

```python
model.resize_token_embeddings(len(tokenizer))
```

对于生成式语言模型，还要关心输出层。给定隐藏状态 $h\_t$，模型通过输出矩阵计算整个词表上的 logits：

$$
z\_t=W\_{\text{out}}h\_t+b
$$

增加 Token 后，模型不仅要能把新 ID 当作输入，还要能把它预测为输出。因此输出投影通常也需要增加对应行。有些模型会进行 **weight tying**，让输入 Embedding 与输出权重共享参数；有些模型则分别维护。`resize_token_embeddings()` 的具体联动取决于模型实现。

新增行需要初始化。可能的初始化方式包括：

* 按模型原始初始化分布随机初始化；
* 根据旧 Embedding 的统计分布初始化；
* 用该词原有子词向量的平均值初始化；
* 用外部词向量或专门方法映射到原 Embedding 空间。

当前 Transformers API 还暴露了 `mean_resizing` 选项，但具体默认值和模型行为可能随版本变化，实际项目应检查所用版本的官方文档与模型代码。

无论采用哪种方式，初始化都只是提供一个起点。字符串“量子纠错码”本身不会神奇地把定义写进向量。

### 11.4 新 Token 怎样从训练数据中学到含义

模型不是通过“查字典释义”学习概念，而是通过训练目标和上下文中的统计规律更新参数。

以因果语言模型为例，在位置 $t$ 的目标 Token 为 $y$ 时：

$$
p=\operatorname{softmax}(z),\qquad
\mathcal{L}=-\log p\_y
$$

对第 $j$ 个 logit 的梯度是：

$$
\frac{\partial\mathcal{L}}{\partial z\_j}
\=p\_j-\mathbb{1}\[j=y]
$$

当新 Token 是预测目标时，这个梯度会训练模型“在什么上下文后应该输出它”；当新 Token 出现在输入上下文时，反向传播会更新它的输入 Embedding，使其更适合参与后续预测。若输入与输出权重绑定，同一行还会同时接收两方面的训练信号。

更严格地说，softmax 的每个输出行通常都会获得梯度：目标行的梯度是 $p\_y-1$，非目标行是 $p\_j$。但要让新 Token 学到“应该在什么语境出现”，仍需要它在足够多样的样本中成为真实目标或输入；只靠它在所有其他位置受到很小的非目标梯度，学不到概念。

下面的极简实验只保留 Embedding 层，用来观察最基本的事实：新增行先被初始化，只有训练中实际查到的行才会收到输入 Embedding 梯度。

```python
import torch
from torch import nn

torch.manual_seed(7)

old = nn.Embedding(4, 3)
expanded = nn.Embedding(5, 3)

with torch.no_grad():
    expanded.weight[:4].copy_(old.weight)
    expanded.weight[4].copy_(old.weight.mean(dim=0))

before = expanded.weight.detach().clone()
new_id = torch.tensor([4, 4])
target = torch.tensor([
    [0.20, -0.10, 0.30],
    [0.20, -0.10, 0.30],
])

loss = ((expanded(new_id) - target) ** 2).mean()
loss.backward()

print("initial new row:", [round(x, 4) for x in before[4].tolist()])
print("loss:", round(loss.item(), 6))
print(
    "gradient norm by row:",
    [round(x, 6) for x in expanded.weight.grad.norm(dim=1).tolist()],
)

optimizer = torch.optim.SGD(expanded.parameters(), lr=0.5)
optimizer.step()
after = expanded.weight.detach()

print("old row 0 changed:", not torch.equal(before[0], after[0]))
print("new row changed:", not torch.equal(before[4], after[4]))
print("updated new row:", [round(x, 4) for x in after[4].tolist()])
```

本次实测输出：

```
initial new row: [-0.8247, 1.0067, 0.0807]
loss: 0.774353
gradient norm by row: [0.0, 0.0, 0.0, 0.0, 1.016106]
old row 0 changed: False
new row changed: True
updated new row: [-0.4832, 0.6378, 0.1538]
```

这个实验没有模拟完整语言模型，只说明 Embedding 查表与梯度更新。真实模型还会根据上下文更新 Transformer 层、输出层以及其他被解冻的参数。

### 11.5 少量语料能否让模型充分理解新概念

需要先区分两种“新”：

#### 新字符串，但概念并不新

假设模型原来把“量子纠错码”切成五个 Token，并且预训练时已经见过相关知识。此时模型可能早已能组合理解这个短语，只是表示得较长。

如果把新 Token 的初始向量设为旧切分向量的组合，例如：

$$
e\_{\text{new}}^{(0)}
\=\frac{1}{k}\sum\_{i=1}^{k}e\_{s\_i}
$$

那么新向量可以从旧子词表示附近开始训练。少量但有代表性的语料，可能足以让模型学会“用一个新 ID 近似替代原来的多个 ID”。这里主要学习的是**表示兼容和切分压缩**。

不过，简单平均会忽略顺序、位置和上下文化过程，只是初始化启发式，不等于原短语经过 Transformer 后的真实语义表示。

#### 新字符串，而且概念知识也是新的

如果模型从未接触过该概念，那么加 Token 只相当于给它一个空白参数槽。要学习概念，需要语料提供多种关系，例如：

```
量子纠错码用于保护量子信息免受噪声影响。
表面码是一类量子纠错码。
量子纠错码通过冗余编码检测或纠正错误。
提高码距通常会增加所需的物理量子比特数量。
```

模型需要从不同上下文中逐渐建立它与“量子信息”“噪声”“表面码”“码距”等概念的关系。把同一句话复制 1,000 次，虽然频次高，却只有一种上下文，容易记忆句式而不能充分形成可泛化的概念。

因此，决定学习质量的不只是样本数，还包括：

* 上下文是否多样；
* 定义、属性、因果、比较和用法是否都被覆盖；
* 训练目标是否真的让模型预测或利用该 Token；
* 是否同时训练输入 Embedding、输出层和必要的 Transformer 参数；
* 学习率、采样比例和训练步数是否合适；
* 是否用独立问题测试了泛化，而非只测试训练句复述。

{% hint style="info" %}
“新增 Token”主要解决表示效率和词表覆盖；“让模型掌握新知识”是继续预训练、微调或检索增强要解决的问题。两者可以一起做，但不能互相替代。
{% endhint %}

### 11.6 训练哪些参数

有三种常见选择：

1. **只训练新增 Embedding/输出行**：成本低，也较少破坏旧能力，但新 Token 只能适应现有表示空间，能力上限有限；
2. **新增行 + 参数高效微调**：允许部分中间表示适应新领域，成本和遗忘风险居中；
3. **继续预训练较多或全部参数**：适合较大规模领域或语言适配，但数据、算力和灾难性遗忘风险更高。

实现“只训练新增行”不能仅靠把整个 Embedding 参数设为可训练，因为普通优化器会把它视为一个完整矩阵。通常需要梯度掩码、参数拆分、专门训练逻辑，或在每步后恢复旧行，并验证 weight tying 下的输出权重是否同步处理。

### 11.7 什么时候不应该增加 Token

以下场景通常先不要扩词表：

* 该词虽然不是完整 Token，但只需两个稳定子词就能表示；
* 语料很少，新增行得不到足够训练；
* 真正需求是让模型知道几条会变化的事实，此时 RAG 往往比改词表合适；
* 新 Token 会改变大量旧文本的切分，却没有明确收益；
* 无法保证训练、推理、保存和部署使用同一套 Tokenizer 与权重；
* 只是为了“一个词对应一个 Token 看起来更自然”。Token 并不需要与人类词语一一对应。

### 11.8 一套更稳妥的扩词表流程

```
目标领域语料
  ↓ 用旧 Tokenizer 统计
频次 + 切分长度 + UNK/字节回退 + 上下文多样性
  ↓ 生成候选并人工/规则审查
保留旧 ID，只追加少量高价值 Token
  ↓
初始化输入 Embedding 与输出权重
  ↓
继续预训练或微调
  ↓
同时评估：领域任务、切分长度、速度、旧任务回归
  ↓
一起保存并发布 Tokenizer + 模型权重
```

评估至少应包含：

* 新 Token 在训练集与验证集中的出现次数；
* 平均每字符 Token 数或 fertility；
* 领域验证损失或下游任务指标；
* 新术语在未见句式中的理解和生成；
* 旧领域保留集上的能力回归；
* 推理长度、吞吐量和延迟变化。

所以，添加 Token 是一次小型的模型接口迁移，而不是在词典里加一行。

### 11.9 面试时怎样简洁回答

> 预训练模型不会根据新语料自动扩词表。`add_tokens()` 是开发者显式追加，没有自动词频阈值；重新训练 Tokenizer 时才会使用 `vocab_size`、`min_frequency` 和算法目标。追加后要扩展输入 Embedding，生成模型还要处理输出层，并通过继续训练让新参数获得上下文语义。若模型原来已能用子词理解该概念，少量数据可能完成表示替换；若知识本身是新的，新增 Token 不能替代多样语料、训练或 RAG。

继续追问时，应该能解释：

1. 为什么旧 Token ID 必须保持不变；
2. 输入 Embedding 与输出投影分别何时收到梯度；
3. 为什么频次高但上下文单一仍可能学不好；
4. 如何用切分长度、领域指标和旧任务回归证明扩词表有效。

## 12. 常见误区

### 误区 1：训练语料出现过就一定在词表中

错误。低频 type 可能因 `min_freq` 或词表容量限制被删除。

### 误区 2：一个完整单词不在词表就一定输出 `[UNK]`

错误。子词 Tokenizer 可以把它拆成多个已知子词。

### 误区 3：使用子词后绝对没有 OOV

错误。字符覆盖不足、实现限制或规范化规则仍可能导致 unknown token。是否有字节回退也很关键。

### 误区 4：Unicode 能表示的字符，模型就能表示

错误。Unicode 码点空间与模型词表是两个不同系统。`龘` 和 emoji 都可能是合法 Unicode，却不在具体 Tokenizer 的覆盖范围中。

### 误区 5：词表越大，模型越强

错误。大词表可能缩短序列，却增加参数并让长尾 Token 学习不足。最优大小取决于语料、语言、模型规模和计算预算。

### 误区 6：`<UNK>` 能保留未知词的大致含义

不准确。不同未知字符串首先被折叠为同一个 ID。模型只能利用周围上下文，无法从 `<UNK>` 本身恢复原拼写。

### 误区 7：给 Tokenizer 添加 Token 后可以直接使用旧模型

错误。模型 Embedding 行数必须与 Tokenizer ID 空间匹配，而且新增向量还需要训练。

### 误区 8：OOV rate 是一个不需要解释的固定指标

错误。必须说明统计单位、分母、预切分方式，以及统计的是原始词 OOV 还是最终 unknown Token。

### 误区 9：模型会把新语料中的高频词自动加入词表

错误。普通推理和继续训练都沿用原 Tokenizer。`add_tokens()` 由开发者显式指定字符串；重新训练 Tokenizer 才会根据语料统计和算法参数选择单位。

### 误区 10：新增 Token 就等于给模型注入了这个概念

错误。扩词表只创建新 ID 和新参数行。概念是否学会取决于初始化、训练目标、上下文多样性、可训练参数和独立评估；对于会变化的事实，RAG 可能更加合适。

## 13. 自测题

### 问题 1

在 `我 喜欢 NLP 我 喜欢 LLM` 中，token 数和 type 数分别是多少？

### 问题 2

一个词在训练语料中出现过一次，为什么推理时仍可能变成 `<UNK>`？

### 问题 3

测试序列共 100 个词实例，其中 8 次出现的词不在词级词表中。OOV token rate 是多少？

### 问题 4

为什么“未登录词”与“Tokenizer 输出 `[UNK]`”不能直接画等号？

### 问题 5

为什么 byte-level Tokenizer 可以避免传统未知字符？代价是什么？

### 问题 6

把词表从 30,000 增加到 60,000，在隐藏维度不变时会对 Embedding 和序列长度产生什么影响？

### 问题 7

为什么 `tokenizer.add_tokens()` 后还需要 `model.resize_token_embeddings()`？

### 问题 8

某术语在领域语料中出现 1,000 次，模型会自动把它加入词表吗？只看频次为什么不够？

### 问题 9

“新缩写，但模型已理解其展开形式”和“全新的科学概念”在扩词表训练上有什么区别？

<details>

<summary>查看答案</summary>

1. token 数为 6，type 数为 4。
2. 最终词表还受最低频次和最大容量约束，出现过的低频词可能被删除。
3. `8 / 100 = 8%`。
4. 子词 Tokenizer 可以把不作为完整词表项的词拆成多个已知片段；只有无法被当前规则完整覆盖时才产生 unknown Token。
5. 任意合法文本都能编码成 0–255 的 UTF-8 字节，保留全部 256 个基本字节即可覆盖输入；代价是序列可能更长、Token 更难解释，模型要从更细单位学习结构。
6. Embedding 参数量近似增加一倍；更多完整词可能成为 Token，从而缩短部分文本，但实际效果取决于新增词表项质量和语料。
7. 新 Token ID 超出了旧 Embedding 的行数。调整矩阵后才有对应向量，但新向量仍需后续训练。
8. 不会。预训练模型没有自动扩词表阈值；还要考虑旧切分长度、上下文多样性、序列压缩收益、下游指标与旧任务回归。
9. 前者可从旧子词表示附近初始化，少量数据可能足以学习表示替换；后者需要多样语料和继续训练来建立概念关系，单独增加 ID 不能注入知识。

</details>

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

1. [Speech and Language Processing, Chapter 2: Words and Tokens](https://web.stanford.edu/~jurafsky/slp3/)\
   token、type、词汇规模和词/子词切分的系统教材来源。
2. [Neural Machine Translation of Rare Words with Subword Units](https://aclanthology.org/P16-1162/)\
   使用 BPE 子词处理稀有词和未知词的经典论文。
3. [BERT: Pre-training of Deep Bidirectional Transformers](https://aclanthology.org/N19-1423/)\
   BERT、WordPiece、特殊 Token 与输入表示的原始论文。
4. [SentencePiece](https://aclanthology.org/D18-2012/)\
   语言无关子词训练、固定词表大小和特殊 meta symbol 的论文。
5. [Hugging Face Tokenizers: Components](https://huggingface.co/docs/tokenizers/main/en/components)\
   WordLevel、BPE、WordPiece 和 Unigram 的官方组件说明。
6. [Hugging Face Tokenizer API](https://huggingface.co/docs/tokenizers/main/api/tokenizer)\
   `get_vocab()`、`get_vocab_size()`、ID 转换和添加 Token 等接口。
7. [Hugging Face Transformers Tokenizer](https://huggingface.co/docs/transformers/main_classes/tokenizer)\
   添加 Token、特殊 Token 及同步调整 Embedding 的官方说明。
8. [ByT5: Towards a Token-Free Future with Pre-trained Byte-to-Byte Models](https://aclanthology.org/2022.tacl-1.17/)\
   字节级模型的覆盖能力、鲁棒性和计算代价。
9. [google-bert/bert-base-chinese](https://huggingface.co/google-bert/bert-base-chinese/tree/main)\
   真实实验使用的公开词表和 Tokenizer 配置。
10. [Hugging Face Transformers：Tokenizer API](https://huggingface.co/docs/transformers/main_classes/tokenizer)\
    `add_tokens()`、Added Token 行为，以及扩词表后同步调整 Embedding 的官方说明。
11. [Hugging Face Transformers：模型 API](https://huggingface.co/docs/transformers/main_classes/model)\
    `resize_token_embeddings()`、新增向量与 `mean_resizing` 参数的官方说明。
12. [Hugging Face Tokenizers：Trainers](https://huggingface.co/docs/tokenizers/main/api/trainers)\
    BPE、WordPiece、WordLevel 与 Unigram Trainer 的 `vocab_size`、`min_frequency` 等参数定义。
13. [An Empirical Comparison of Vocabulary Expansion and Initialization Approaches for Language Models](https://aclanthology.org/2024.conll-1.8/)\
    预训练模型扩词表时不同新增 Embedding 初始化方法的系统比较。

## 一页回顾

```
token：文本中一次具体出现
type：去重后的一种形式
vocabulary：有限 Token 集合及 Token → ID 映射
OOV：候选输入单位不在当前词表中
<UNK>：把无法表示的不同输入折叠到同一个保留 ID

完整词不在词表 ≠ 一定产生 [UNK]
子词可继续拆分；字节回退可覆盖任意合法 UTF-8 输入

OOV token rate = OOV 实例数 / 全部实例数
OOV type rate  = 不同 OOV 类型数 / 全部不同类型数

大词表：Embedding 更大，序列可能更短
小词表：Embedding 更小，序列可能更长

添加 Token：
不会由新语料自动触发
add_tokens：开发者显式选择，没有自动词频阈值
训练 Tokenizer：算法按 vocab_size、频率与目标函数选择单位

完整流程：
候选统计 → 追加新 ID → 调整输入/输出参数 → 初始化
→ 继续训练 → 同时评估新领域收益和旧能力回归

新增 Token ≠ 注入新知识
少量数据能否奏效，取决于旧模型是否已有相关概念、
初始化质量、上下文多样性、训练目标与可训练参数
```


---

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