> 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-02/day-009.md).

# Day 009：数据稀疏、平滑与未知事件概率

{% hint style="info" %}
**训练语料里没出现过“你 → 机器学习”，是否就应该断言它永远不会发生？如果不应该，怎样给未见事件分配非零概率，同时仍让整个概率分布之和等于 1？**
{% endhint %}

## 学习目标

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

1. 区分 OOV、未见 N-gram 与未见上下文；
2. 解释数据稀疏为什么会让最大似然估计产生零概率；
3. 推导加一平滑与加 k 平滑，并检查结果是否归一化；
4. 区分平滑、折扣、回退和插值；
5. 用开发集选择平滑超参数，而不污染测试集；
6. 从“不同前驱的数量”理解 Kneser–Ney 平滑的核心直觉；
7. 用带必要注释的 Python 实现一个加 k 平滑的 bigram 模型；
8. 识别“只消灭零概率”和“得到高质量概率估计”之间的差别。

{% hint style="info" %}
本节沿用 Day 008 的已分词语料和 bigram 计数。Day 008 只做未平滑最大似然估计；今天修复零概率。如何在测试集上用交叉熵与困惑度比较这些方案，留到 Day 010。
{% endhint %}

## 1. 零概率不是“不可能”，而是“训练集没有证据”

继续使用下面四句话：

```python
corpus = [
    ["我", "喜欢", "自然语言"],
    ["我", "喜欢", "机器学习"],
    ["我", "学习", "自然语言"],
    ["你", "喜欢", "自然语言"],
]
```

Day 008 的未平滑 bigram 最大似然估计是：

$$
P\_{\text{MLE}}(w\mid h)=\frac{C(h,w)}{C(h)}
$$

训练集中，`你` 后面只出现过一次 `喜欢`，因此：

$$
P\_{\text{MLE}}(\text{喜欢}\mid\text{你})=1
$$

而 `你 → 机器学习` 没出现过：

$$
P\_{\text{MLE}}(\text{机器学习}\mid\text{你})=0
$$

这不等于“人类语言禁止这种续写”。它只说明：在这份很小的训练集中，该 bigram 的计数是 0。

只要测试句中有一个条件概率为 0，整句概率就会变成 0：

$$
P(w\_1,\ldots,w\_T)=\prod\_i P(w\_i\mid h\_i)=0
$$

取对数后，

$$
\log 0=-\infty
$$

这会让后续的负对数似然、交叉熵和困惑度失去有限数值。平滑首先要修复的就是这个问题。

## 2. 三种“没见过”必须分清

### 2.1 OOV：Token 不在词表里

如果固定词表中没有 `深度学习`，那么它是 OOV（out of vocabulary）。模型甚至没有这个输出类别，不能直接给它分配概率。

常见处理方式是：

* 在训练前把低频词统一映射为 `<UNK>`，让模型真正见过并学习 `<UNK>`；
* 使用子词或字节级 Tokenizer，把新词拆成词表内 Token；
* 推理时严格复用训练时的 Tokenizer 和词表。

只在测试时临时添加 `<UNK>`，却没有在训练中出现过 `<UNK>`，并不能自动得到可靠的未知词概率。

### 2.2 未见 N-gram：Token 都认识，但组合没见过

`你`、`机器学习` 都在词表里，但 `你 → 机器学习` 没在训练集中出现。这是**未见 bigram**，也是本节平滑的主要对象。

### 2.3 未见上下文：分母对应的历史从未出现

如果把 `机器学习` 作为一个历史去预测下一个 Token，而训练中它从未充当过历史，就会有：

$$
C(h)=0
$$

未平滑公式此时出现 0/0。工程实现必须显式决定：回退到低阶分布、使用插值，还是采用某个兜底分布，不能让除零错误替模型做决定。

{% hint style="warning" %}
平滑未见 N-gram，不等于解决 OOV。前者在固定词表内重新分配概率质量；后者首先要让输入能够映射到词表中的表示。
{% endhint %}

## 3. 为什么 N-gram 天生稀疏

假设可预测词表大小为 $$|V|$$。理论上：

* unigram 最多有 $$|V|$$ 种；
* bigram 最多有 $$|V|^2$$ 种；
* trigram 最多有 $$|V|^3$$ 种。

当 |V|=50000 时，可能的 bigram 已有：

$$
50{,}000^2=2.5\times10^9
$$

真实语料不可能覆盖所有组合，而且长尾表达、新实体和领域变化还会不断制造新组合。因此，增加训练数据只能缓解稀疏，不能彻底消除它。

更高阶的 N-gram 能记录更具体的局部上下文，却也把计数切得更碎：

```
阶数更高
  → 上下文更具体
  → 每种上下文出现得更少
  → 未见组合更多
  → 更依赖平滑、回退或插值
```

## 4. 平滑的本质：重新分配概率质量

一个条件概率分布必须满足：

$$
\sum\_{w\in V}P(w\mid h)=1
$$

所以，不能在保留所有已见事件原概率不变的同时，凭空给未见事件增加概率。给未见事件的概率质量，必须从其他事件那里“匀”出来。

这带来一个重要术语：**折扣（discounting）**。折扣是降低已见事件的计数或概率，把释放出的质量留给未见事件。平滑是更宽泛的目标；折扣是实现平滑的一类机制。

## 5. 加一平滑：最容易理解的基线

加一平滑（add-one，也叫 Laplace smoothing）假装每个候选 Token 在每个上下文后都额外出现过一次：

$$
P\_{\text{add-1}}(w\mid h)
\=\frac{C(h,w)+1}{C(h)+|V|}
$$

这里的词表必须与模型真正能够预测的 Token 集合一致。本例把 `</s>` 视为可预测事件，但 `<s>` 只作为上下文，不放入预测词表：

```
V = {我, 你, 喜欢, 学习, 自然语言, 机器学习, </s>}
|V| = 7
```

对于上下文 `你`，有 $$C(\text{你})=1$$。于是：

$$
P\_{\text{add-1}}(\text{机器学习}\mid\text{你})
\=\frac{0+1}{1+7}=0.125
$$

已见事件也被压低：

$$
P\_{\text{add-1}}(\text{喜欢}\mid\text{你})
\=\frac{1+1}{1+7}=0.25
$$

剩余 6 个候选各得 0.125，总和为：

$$
0.25+6\times0.125=1
$$

加一平滑确实消灭了零概率，但在大词表中，它常常从已见事件拿走过多概率质量。它适合作为教学基线和代码正确性检查，通常不是高质量 N-gram 语言模型的首选。

## 6. 加 k 平滑：控制“匀出去多少”

把固定的 1 改成较小的正数 k，得到加 k 平滑：

$$
P\_{\text{add-}k}(w\mid h)
\=\frac{C(h,w)+k}{C(h)+k|V|},\qquad k>0
$$

当 k=0.1 时：

$$
P(\text{机器学习}\mid\text{你})
\=\frac{0.1}{1+0.1\times7}
\approx0.058824
$$

$$
P(\text{喜欢}\mid\text{你})
\=\frac{1.1}{1+0.1\times7}
\approx0.647059
$$

相比加一平滑，小 k 保留了更多训练证据，同时仍给未见事件非零概率。

### 6.1 归一化为什么仍成立

对所有候选 Token 求和：

$$
\begin{aligned}
\sum\_{w\in V}P\_{\text{add-}k}(w\mid h)
&=\frac{\sum\_w C(h,w)+\sum\_w k}{C(h)+k|V|}\\
&=\frac{C(h)+k|V|}{C(h)+k|V|}\\
&=1
\end{aligned}
$$

这也是实现时最值得写进测试的性质。

### 6.2 未见上下文会怎样

若 C(h)=0，加 k 公式变为：

$$
P(w\mid h)=\frac{k}{k|V|}=\frac{1}{|V|}
$$

也就是均匀分布。这是一个合法的兜底方案，但它完全忽略了 Token 的总体频率：常见词和罕见词概率相同。因此，“公式不会报错”不等于“估计足够好”。

## 7. Python 实现：可检查的加 k bigram 模型

下面的实现保留两个关键约束：

* `<s>` 只作为历史，不参与 next-token 归一化；
* `</s>` 是需要预测的结束事件，必须进入词表。

```python
from collections import Counter


class AddKBigramLM:
    def __init__(self, k=0.1, bos="<s>", eos="</s>"):
        if k <= 0:
            raise ValueError("k 必须大于 0")
        self.k = k
        self.bos = bos
        self.eos = eos
        self.vocab = set()
        self.context_counts = Counter()
        self.bigram_counts = Counter()

    def fit(self, corpus):
        # 清空旧状态，避免同一个对象重复训练时把计数意外累加。
        self.vocab = {self.eos}
        self.context_counts.clear()
        self.bigram_counts.clear()

        for sentence in corpus:
            self.vocab.update(sentence)
            sequence = [self.bos, *sentence, self.eos]

            for context, token in zip(sequence, sequence[1:]):
                self.context_counts[context] += 1
                self.bigram_counts[(context, token)] += 1

        return self

    def probability(self, token, context):
        if token not in self.vocab:
            raise ValueError(f"{token!r} 不在固定词表中；请先执行 OOV 映射")

        vocab_size = len(self.vocab)
        numerator = self.bigram_counts[(context, token)] + self.k
        denominator = self.context_counts[context] + self.k * vocab_size
        return numerator / denominator

    def distribution(self, context):
        # 排序不是概率计算所必需的，只是让示例输出稳定、便于检查。
        return {
            token: self.probability(token, context)
            for token in sorted(self.vocab)
        }


corpus = [
    ["我", "喜欢", "自然语言"],
    ["我", "喜欢", "机器学习"],
    ["我", "学习", "自然语言"],
    ["你", "喜欢", "自然语言"],
]

model = AddKBigramLM(k=0.1).fit(corpus)
dist = model.distribution("你")

print(f"P(机器学习 | 你) = {dist['机器学习']:.6f}")
print(f"P(喜欢 | 你)     = {dist['喜欢']:.6f}")
print(f"概率和            = {sum(dist.values()):.6f}")
```

输出：

```
P(机器学习 | 你) = 0.058824
P(喜欢 | 你)     = 0.647059
概率和            = 1.000000
```

### 7.1 这段代码没有偷偷解决 OOV

调用下面的代码会抛出异常：

```python
model.probability("深度学习", "你")
```

这是有意为之。平滑只在固定词表内工作。生产系统应在进入语言模型前，按训练时的规则把 OOV 映射成 `<UNK>` 或拆成已知子词。

### 7.2 建议至少写三类测试

```python
# 每个上下文后的条件分布都应归一化。
assert abs(sum(model.distribution("你").values()) - 1.0) < 1e-12

# 词表内的未见 bigram 应获得非零概率。
assert model.probability("机器学习", "你") > 0

# 同一上下文下，有计数的事件仍应比无计数事件更可能。
assert model.probability("喜欢", "你") > model.probability("机器学习", "你")
```

## 8. 插值：高阶证据与低阶证据同时参与

bigram 没见过，不代表目标 Token 本身没有出现过。插值（interpolation）把多个阶数的分布混合起来。最简单的 unigram–bigram 插值是：

$$
P\_{\text{interp}}(w\mid h)
\=\lambda P\_{\text{MLE}}(w\mid h)
+(1-\lambda)P\_{\text{MLE}}(w)
$$

其中 $$0\le\lambda\le1$$。如果两个子分布都归一化，那么它们的凸组合也归一化。

本例包含 16 个预测事件：四句话各有 3 个普通 Token 和 1 个 `</s>`。`机器学习` 出现 1 次，因此：

$$
P\_{\text{MLE}}(\text{机器学习})=\frac{1}{16}
$$

取 $$\lambda=0.75$$：

$$
\begin{aligned}
P(\text{机器学习}\mid\text{你})
&=0.75\times0+0.25\times\frac{1}{16}\\
&=0.015625
\end{aligned}
$$

即使高阶 bigram 计数为 0，低阶 unigram 仍提供了证据。

对已见事件 `喜欢`：

$$
P(\text{喜欢}\mid\text{你})
\=0.75\times1+0.25\times\frac{3}{16}
\=0.796875
$$

固定 $$\lambda$$ 便于教学，但更成熟的方法会让权重依赖上下文可靠程度：上下文计数越充分，越信任高阶分布；证据越少，越依赖低阶分布。权重应在开发集上选择。

## 9. 回退与插值不是同义词

### 9.1 插值：始终混合

无论高阶 N-gram 是否出现，插值都会同时使用高阶和低阶概率：

$$
P=P\_{\text{high}}\lambda+P\_{\text{low}}(1-\lambda)
$$

### 9.2 回退：高阶不可用时再退到低阶

回退（backoff）的基本决策是：

```
高阶事件有足够证据？
  ├─ 是：使用经过折扣的高阶概率
  └─ 否：乘上归一化权重，退到低阶分布
```

一个规范的回退概率模型必须同时处理：

1. 已见高阶事件折扣后释放多少质量；
2. 释放的质量如何分给未见事件；
3. 回退权重如何保证总和仍为 1。

因此，“未见就直接查低阶计数”只是直觉，还不是完整的概率模型。

{% hint style="warning" %}
工程中常见的 **stupid backoff** 适合大规模排序，但其原始形式给出的是分数，不是归一化概率。未经处理，不能把它的分数直接代入概率困惑度公式。
{% endhint %}

## 10. Kneser–Ney 的关键直觉：出现得多，还要看在哪里出现

普通 unigram 回退只看 Token 总频次。但一个 Token 总频次高，可能只是因为它反复跟在同一个前驱后面，不代表它适合出现在许多新上下文中。

Kneser–Ney 平滑的低阶分布关注**不同前驱的数量**。其续接概率可写成：

$$
P\_{\text{cont}}(w)
\=\frac{N\_{1+}(\cdot,w)}{N\_{1+}(\cdot,\cdot)}
$$

其中：

* $$N\_{1+}(\cdot,w)$$：能出现在 w 前面的不同 Token 数量；
* $$N\_{1+}(\cdot,\cdot)$$：训练集中不同 bigram 类型总数。

在示例语料中：

* `自然语言` 出现在 `喜欢` 和 `学习` 两种前驱后；
* `机器学习` 只出现在 `喜欢` 一种前驱后。

所以当模型必须退到低阶分布时，`自然语言` 比 `机器学习` 展现出更强的“适应新上下文”能力。

经典 Kneser–Ney 方法还结合绝对折扣：从非零高阶计数中减去折扣量，再把释放的概率质量交给续接分布。Modified Kneser–Ney 会针对不同计数范围使用不同折扣值。在经典 N-gram 实验中，它通常显著优于简单加一平滑。

{% hint style="info" %}
本节要求掌握 Kneser–Ney 的动机和续接概率，不要求手写完整工业实现。真正实现时还要处理多阶递归、折扣估计、边界符号和高效计数结构。
{% endhint %}

## 11. 超参数只能用开发集选择

典型数据划分是：

```
训练集：统计 N-gram 计数并拟合模型
开发集：选择 k、插值权重、折扣方案等超参数
测试集：只在最终方案确定后做一次无偏评估
```

如果试过 $$k=0.1,0.01,0.001$$ 后，根据测试集结果挑出最好的一项，那么测试集已经参与了训练决策，最终分数会偏乐观。

推荐流程：

1. 先固定 Tokenizer、词表、OOV 和句子边界规则；
2. 只用训练集建立计数；
3. 在开发集比较候选平滑方案；
4. 锁定方案后，在测试集报告一次结果；
5. 记录随机种子、数据版本和全部计分约定。

## 12. 常见误区与故障排查

### 误区一：把所有 0 都改成一个很小的数

这通常破坏归一化。概率增加后必须从别处扣除，且每个上下文的分布都要重新检查。

### 误区二：分母仍写成 (C(h))

加 k 后，分子总共增加了 k|V|，分母也必须相应增加：

$$
C(h)+k|V|
$$

### 误区三：把 `<s>` 也放进可预测词表

如果模型永远不应在句中生成 `<s>`，却把它纳入分母，就会给一个不合法输出分走概率质量。

### 误区四：漏掉 `</s>`

漏掉结束事件后，模型无法学习句子何时结束，句子概率也不完整。

### 误区五：认为加一平滑一定“更公平”

概率估计追求的是泛化质量，不是给每个事件同样照顾。大词表下，加一平滑常常过度惩罚已见事件。

### 误区六：在全量数据上建词表后再切分

这会让测试集信息提前进入词表和 OOV 决策。严格实验应先划分，再只根据训练集拟合相关规则。

### 误区七：只检查“没有 0”，不检查分布和效果

一个模型可以全部非零，却给出很差的概率。至少还要检查归一化、已见与未见事件的相对顺序，以及开发集指标。

## 13. 从概率到工程：一条完整的数据流

```
原始文本
  → 用训练阶段固定的 Tokenizer 分词
  → 按固定规则处理 OOV 与特殊 Token
  → 从训练集统计各阶 N-gram
  → 折扣高阶计数
  → 用插值或回退分配剩余概率质量
  → 检查每个上下文后的分布和为 1
  → 在开发集选择超参数
  → 在未见测试集计算交叉熵与困惑度
```

这条链路中任一约定变化，最后的概率与评估结果都可能改变。

## 练习与面试准备

### 30 秒口述题

请在 30 秒内回答：

> 为什么 N-gram 模型必须平滑？平滑、回退和插值分别解决什么问题？

合格答案至少应包含：训练计数稀疏、未见事件零概率、概率质量重新分配，以及高阶证据不足时借助低阶分布。

### 基础题 1：辨别三种未知

判断下列情况属于 OOV、未见 bigram 还是未见上下文：

1. `深度学习` 不在模型词表中；
2. `你` 和 `机器学习` 都在词表中，但组合计数为 0；
3. 作为历史的 `机器学习` 从未在训练句中出现过。

<details>

<summary>参考答案</summary>

1. OOV；
2. 未见 bigram；
3. 未见上下文，表现为 C(h)=0。

</details>

### 基础题 2：手算加 (k)

已知 |V|=10、C(h)=4、C(h,w)=0，取 k=0.2。计算 $$P(w\mid h)$$。如果另一个 Token 的计数是 3，它的概率是多少？

<details>

<summary>参考答案</summary>

分母为 $$4+0.2\times10=6$$。未见事件概率为 $$0.2/6=1/30\approx0.0333$$；计数为 3 的事件概率为 $$3.2/6\approx0.5333$$。

</details>

### 进阶题 3：证明归一化

从加 k 公式出发，证明对固定上下文 h，所有 $$w\in V$$ 的概率和为 1。证明中必须写出 $$\sum\_w C(h,w)=C(h)$$。

### 进阶题 4：代码修复

下面的实现有什么问题？

```python
def probability(token, context, k=0.1):
    return (bigram_counts[(context, token)] + k) / context_counts[context]
```

<details>

<summary>参考答案</summary>

分母没有加入 k|V|，因此所有候选概率之和大于 1；当上下文未见时还会除以 0。正确分母是 `context_counts[context] + k * len(vocab)`，并且应明确检查 OOV。

</details>

### 面试追问 5：为什么加一平滑常常不好

回答时不要只说“效果差”。请解释：当词表很大时，每个未见候选都加 1，累计伪计数为 |V|，会从已见事件夺走过多概率质量。

### 面试追问 6：回退与插值

请说明两者何时使用低阶信息，并回答：为什么回退不能只写一个 `if count == 0`？

<details>

<summary>参考答案</summary>

插值始终混合多个阶数；回退通常在高阶证据不足时才主要使用低阶分布。规范回退还要对已见事件折扣、计算回退权重并保持归一化，不能只有分支判断。

</details>

### 项目答辩题 7：如何证明你的平滑实现可信

至少准备以下证据：

* 任意已测上下文后的概率和接近 1；
* 词表内未见事件概率大于 0；
* OOV 行为明确且与训练规则一致；
* 开发集选参，测试集只用于最终报告；
* 与未平滑模型、均匀模型或其他平滑基线比较；
* 数据、Tokenizer、词表、边界符号和随机种子可复现。

### 自测清单

* [ ] 我能解释零计数为什么不代表语言事件绝对不可能。
* [ ] 我能区分 OOV、未见 N-gram 和未见上下文。
* [ ] 我能独立写出加 $k$ 的分子与分母。
* [ ] 我会检查条件概率分布是否归一化。
* [ ] 我能区分平滑、折扣、回退与插值。
* [ ] 我能用不同前驱数量解释 Kneser–Ney 的续接概率。
* [ ] 我知道超参数应在开发集而不是测试集上选择。
* [ ] 我的 Python 代码对边界符号和 OOV 有明确约定。

## 延伸阅读

1. [Speech and Language Processing：N-gram Language Models](https://web.stanford.edu/~jurafsky/slp3/ed3book.pdf)\
   系统介绍 N-gram、加一平滑、插值、回退与 Kneser–Ney，也是 Day 008–010 的主要理论来源。
2. [Chen & Goodman：An Empirical Study of Smoothing Techniques for Language Modeling](https://aclanthology.org/P96-1041/)\
   对多种平滑技术进行系统实验比较，说明为什么不能把“消灭零概率”等同于“得到最好的语言模型”。
3. [Kneser & Ney：Improved Backing-Off for M-gram Language Modeling](https://www-i6.informatik.rwth-aachen.de/publications/download/951/Kneser-ICASSP-1995.pdf)\
   Kneser–Ney 回退思想的经典原始论文，可重点阅读低阶分布为何不应只依赖普通 unigram 频率。

## 一页回顾

```
问题根源：
可能的 N-gram 组合随阶数指数增长
有限训练集必然留下大量零计数

三类未知：
OOV           → Token 不在词表，需要 <UNK>/子词/字节方案
未见 N-gram   → Token 已知但组合未见，需要平滑
未见上下文    → C(h)=0，需要回退、插值或明确兜底

最大似然：
P_MLE(w|h) = C(h,w) / C(h)
未见事件得到 0

加 k 平滑：
P(w|h) = [C(h,w)+k] / [C(h)+k|V|]
k > 0
优点：简单、非零、归一化
局限：尤其在大词表下，概率分配通常不够精细

插值：
高阶与低阶分布始终共同参与

回退：
高阶证据不足时退到低阶
必须配合折扣和归一化权重

Kneser–Ney 直觉：
低阶概率不只看“出现多少次”
还看“在多少种不同上下文中出现”

实验纪律：
训练集建计数
开发集选 k/权重/折扣
测试集只做最终评估

下一节：
用平均负对数概率得到交叉熵
再把交叉熵转换为困惑度
```


---

# 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-02/day-009.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.
