3. N-gram与语言模型:统计语言模型、N-gram原理、平滑技术

聊到NLP,绕不开的一个基础概念就是语言模型。说白了,语言模型就是用来判断「一句话在自然语言中出现的概率」有多大。你想想看,机器翻译、语音识别、拼写纠错,哪个场景不需要知道哪句话更「像人话」?

我记得刚入行那会儿,带我的老大哥跟我说:「别急着上深度学习,先把统计语言模型吃透。」当时我不太服气,后来踩了几个坑才明白——这些基础思想,到现在还在用。

3.1 统计语言模型:从概率角度看语言

统计语言模型的核心思想很简单:一个句子出现的概率,等于每个词在给定历史条件下出现的概率的乘积

假设一个句子由词序列 \( w_1, w_2, ..., w_n \) 组成,它的概率可以写成:

P(w1, w2, ..., wn) = P(w1) × P(w2|w1) × P(w3|w1,w2) × ... × P(wn|w1,...,wn-1)

这个公式叫「链式法则」。理论上没问题,但实际中你算到第5个词的时候,条件概率 \( P(w_5|w_1,w_2,w_3,w_4) \) 就已经很难估计了——因为「w1 w2 w3 w4」这个历史组合在语料里可能压根没出现过。

核心矛盾:历史越长,条件越精确,但数据越稀疏。这就是统计语言模型要解决的根本问题。

3.2 N-gram原理:马尔可夫假设来救场

怎么解决上面那个矛盾?马尔可夫假设给出了一个漂亮的近似:当前词只与前面 N-1 个词有关

这就是N-gram模型的核心思想。说白了,就是「偷个懒」——不看那么远的历史,只看最近的一小段。

常见的N-gram有:

  • Unigram (1-gram):只看当前词本身,不考虑上下文。P(我|历史) ≈ P(我)
  • Bigram (2-gram):只看前一个词。P(我|历史) ≈ P(我|前一个词)
  • Trigram (3-gram):看前两个词。P(我|历史) ≈ P(我|前两个词)

举个例子,计算「我今天去公司」这句话的概率(用Bigram):

P(我|<s>) × P(今天|我) × P(去|今天) × P(公司|去) × P(</s>|公司)

其中 <s> 和 </s> 是句子开始和结束的特殊标记。

我在项目中遇到过一个问题:用Bigram做中文输入法候选词排序,效果比Unigram好不少,但到了Trigram就开始出现数据稀疏问题了。嗯,这里要注意——N越大,模型越「聪明」,但需要的训练数据也呈指数级增长

我的经验:实际工程中,Bigram和Trigram用得最多。Unigram太「傻」,4-gram以上数据量不够的话基本白给。

3.3 平滑技术:给「零概率」一个台阶下

N-gram模型有个致命问题:如果某个N-gram组合在训练语料里没出现过,它的概率就是0

你想想看,一个句子只要包含一个没见过的N-gram,整个句子的概率就变成0了。这合理吗?当然不合理。「我今天去公司」这句话,如果「去公司」这个Bigram没在训练数据里出现过,难道这句话就不可能存在吗?

平滑技术就是来解决这个问题的。它的核心思想是:从已出现的N-gram中「匀」一点概率给未出现的N-gram

3.3.1 Laplace平滑(加一平滑)

Laplace平滑是最简单的方法。说白了,就是给每个N-gram的计数都加1。

公式长这样:

P_Laplace(wi | wi-1) = (count(wi-1, wi) + 1) / (count(wi-1) + V)

其中 V 是词表大小。

举个例子:假设语料里「去公司」出现了5次,「去」出现了100次,词表大小是10000。

不加平滑:P(公司|去) = 5/100 = 0.05
加一平滑:P(公司|去) = (5+1)/(100+10000) ≈ 0.00059

你看,概率从0.05降到了0.00059。这就是「匀」出去的概率——匀给了那些没出现过的「去XX」组合。

我曾经踩过的坑:Laplace平滑虽然简单,但在大词表场景下效果很差。因为V太大了,匀出去的概率太多,导致高频N-gram的概率被严重「稀释」。我试过在100万词表上用Laplace平滑做语言模型,结果模型几乎分不清「我吃饭」和「我吃桌子」哪个更合理。

3.3.2 Kneser-Ney平滑

Kneser-Ney平滑是目前公认效果最好的平滑方法之一。它的核心思想很巧妙:不仅要看一个词出现的频率,还要看这个词「有多灵活」

什么叫「灵活」?举个例子:

  • 「Francisco」这个词,几乎只出现在「San Francisco」里。它虽然出现频率不低,但「灵活性」很差。
  • 「吃」这个词,可以出现在「吃饭」「吃面」「吃水果」等各种组合里。它很「灵活」。

Kneser-Ney平滑的核心公式:

P_KN(wi | wi-1) = max(count(wi-1, wi) - d, 0) / count(wi-1) + λ(wi-1) × P_continuation(wi)

其中:

  • d 是一个折扣常数(通常取0.75左右)
  • P_continuation(wi) 衡量的是词 wi 的「灵活性」——它出现在多少种不同的历史后面
  • λ(wi-1) 是归一化因子,保证概率和为1

说白了,Kneser-Ney做了两件事:

  1. 打折:从已出现的N-gram计数中减去一个常数d,把省下来的概率「匀」出去
  2. 重新分配:匀出去的概率不是平均分配,而是根据词的「灵活性」来分配

我在做语音识别语言模型时,对比过几种平滑方法的效果:

平滑方法 Perplexity(困惑度) 适用场景
Laplace(加一平滑) 高(效果差) 小词表、教学演示
Good-Turing 中等 中等规模语料
Kneser-Ney 低(效果好) 大规模语料、生产环境
Modified Kneser-Ney 最低(最好) 工业级应用

我的建议:如果你在做生产环境的语言模型,直接上Modified Kneser-Ney。别在Laplace上浪费时间——除非你只是想快速验证一个想法。

3.4 工程落地:N-gram模型的实现要点

最后聊点工程上的实操经验。实现一个N-gram模型,有几个关键点要注意:

1. 存储结构

别用Python的dict存所有N-gram——内存会炸。我建议用Trie树或者反向索引。对于大规模模型,用KenLM这种C++实现的库,效率高得多。

2. 剪枝策略

N-gram模型可以很大。一个3-gram模型,词表10万,理论上就有10^15个可能的3-gram。实际中我们只保留出现次数超过阈值的N-gram。我一般设阈值为2或3。

3. 回退(Backoff)

当某个3-gram没出现过时,回退到2-gram;2-gram也没出现过,回退到1-gram。Kneser-Ney平滑天然支持这种回退机制。

# 伪代码:N-gram概率查询
def get_prob(ngram, order=3):
    if order == 3 and trigram_exists(ngram):
        return trigram_prob(ngram)
    elif order >= 2 and bigram_exists(ngram[-2:]):
        return backoff_weight * bigram_prob(ngram[-2:])
    else:
        return unigram_prob(ngram[-1])

嗯,差不多就这些。N-gram模型虽然看起来「古老」,但它的思想——马尔可夫假设、平滑技术、回退机制——至今仍在各种现代模型里发光发热。你想想看,Transformer里的位置编码,本质上不也是在处理「上下文依赖」这个问题吗?

下一章我们聊聊词向量,那又是另一个有意思的话题了。