4、N-gram语言模型:N-gram原理、概率计算、平滑技术、基于N-gram的纠错、KenLM工具使用

好,咱们进入第四章节。N-gram语言模型,这玩意儿在搜索引擎的纠错和同义词处理里,可以说是地基一样的存在。我当年刚接触搜索时,觉得这东西不就是统计词频嘛,有啥难的?结果真上手做项目,才发现坑多着呢。今天咱们就把它掰开揉碎了讲清楚。

4.1 N-gram原理:说白了就是猜下一个词

N-gram的核心思想其实特别朴素——一个词出现的概率,只跟它前面的N-1个词有关。你想想看,我们说话写文章,不都是这样吗?比如我说「我今天吃了」,你大概率会猜到后面是「饭」、「苹果」、「面条」这类词,而不是「火箭」、「量子力学」对吧?

这就是N-gram在模拟人类的语言习惯。具体来说:

  • Unigram (1-gram):只看单个词的概率。比如「我」出现的概率是0.01,「吃」是0.005。完全不考虑上下文,说白了就是词频统计。
  • Bigram (2-gram):看前一个词。P(吃 | 我) 表示「我」后面出现「吃」的概率。
  • Trigram (3-gram):看前两个词。P(饭 | 我, 吃) 表示「我吃」后面出现「饭」的概率。
  • 4-gram、5-gram:以此类推,N越大,对上下文的建模越精细,但数据稀疏问题也越严重。

核心公式:P(w₁, w₂, ..., wₘ) ≈ ∏ P(wᵢ | wᵢ₋₁, ..., wᵢ₋ₙ₊₁)

说白了就是:整句话的概率 ≈ 每个词在给定前N-1个词条件下的概率乘积。

我在项目中遇到过一个问题:用Unigram做纠错,结果「我吃饭」和「饭吃我」的概率居然一样高!因为Unigram根本不考虑词序。所以后来我学乖了,至少用Bigram起步。

4.2 概率计算:最大似然估计

概率怎么算?最直接的方法就是数数。拿Bigram举例:

P(wᵢ | wᵢ₋₁) = Count(wᵢ₋₁, wᵢ) / Count(wᵢ₋₁)

比如语料库中「我」出现了1000次,「我吃」出现了50次,那么P(吃 | 我) = 50/1000 = 0.05。

听起来很简单对吧?但这里有个大坑——数据稀疏问题。你想想看,汉语词汇量少说几万个,两两组合就是几亿种可能。你的语料库再大,也覆盖不了所有组合。比如「我吃量子力学」这个组合,语料里可能一次都没出现过,那概率就是0。但你能说这句话绝对不可能出现吗?

注意:概率为0会导致整句话概率为0,这在实践中是完全不可接受的。所以我们需要平滑技术。

4.3 平滑技术:给没出现的情况留条活路

平滑技术,说白了就是从已出现的N-gram里「借」一点概率,分给没出现的N-gram。我刚开始做的时候,觉得这步可有可无,直到线上出现用户搜「苹果手机」被纠错成「苹果手鸡」的惨案……嗯,从那以后我再也不敢忽略平滑了。

常见的平滑方法有这几种:

方法 原理 特点
加一平滑 (Laplace Smoothing) 每个N-gram计数加1 简单粗暴,但会过度惩罚高频词
Good-Turing平滑 根据出现r次的N-gram数量,重新估计概率 理论优美,但实现复杂
Kneser-Ney平滑 考虑词作为「延续」的多样性 目前效果最好的方法之一,KenLM默认使用
插值平滑 混合不同阶N-gram的概率 简单有效,适合快速原型

我的建议:如果你刚入门,先用加一平滑理解原理。但生产环境,直接上Kneser-Ney。我在一个搜索纠错项目里对比过,Kneser-Ney比加一平滑的准确率高了将近8个百分点。

4.4 基于N-gram的纠错:实战思路

有了语言模型,纠错就变成了一个搜索问题。具体流程是这样的:

  1. 生成候选词:对用户输入的每个词,通过编辑距离、拼音相似度、同义词表等方式,生成一批候选词。
  2. 计算语言模型概率:用N-gram模型计算每个候选词在上下文中的概率。
  3. 选择最优:选概率最高的那个作为纠错结果。

举个例子,用户输入「我想吃平果」:

  • 「平果」的候选词:苹果、平果、苹果、苹果……
  • 计算P(苹果 | 我想吃) 和 P(平果 | 我想吃)
  • 显然「苹果」的概率远高于「平果」,所以纠错为「我想吃苹果」
# 伪代码示例
def correct_sentence(sentence, lm, candidates_generator):
    words = sentence.split()
    for i, word in enumerate(words):
        candidates = candidates_generator.get_candidates(word)
        best_score = -inf
        best_candidate = word
        for cand in candidates:
            # 计算在上下文中的概率
            context = words[max(0, i-2):i]  # 取前两个词作为上下文
            score = lm.score(cand, context)
            if score > best_score:
                best_score = score
                best_candidate = cand
        words[i] = best_candidate
    return ' '.join(words)

关键点:纠错不是只看单个词,一定要结合上下文。我曾经踩过一个坑:用户搜「新发地」,结果因为「发」的拼音和「法」相近,被纠错成「新法地」。但结合上下文「北京新发地市场」,就知道不该纠错。

4.5 KenLM工具使用:工业级N-gram

自己实现N-gram?别傻了,有现成的轮子。KenLM是目前最流行的N-gram工具包,速度快、内存小、支持多种平滑算法。我所有线上项目都用它。

安装

pip install https://github.com/kpu/kenlm/archive/master.zip

训练模型

# 准备语料,一行一句话
echo "我 今天 吃 了 苹果" > corpus.txt
echo "他 昨天 去 了 学校" >> corpus.txt

# 训练3-gram模型,使用Kneser-Ney平滑
lmplz -o 3 --text corpus.txt --arpa model.arpa

# 二值化(加速加载)
build_binary model.arpa model.binary

Python调用

import kenlm

model = kenlm.Model('model.binary')

# 计算句子概率
sentence = "我 今天 吃 了 苹果"
score = model.score(sentence)
print(f"句子概率: {score}")

# 获取每个词的概率
for word, prob in model.full_scores(sentence):
    print(f"{word}: {prob}")

# 计算困惑度(perplexity)
perplexity = model.perplexity(sentence)
print(f"困惑度: {perplexity}")

使用技巧

  • 训练语料一定要做分词,中文用jieba或pkuseg都行
  • N一般取3-5,太大模型会很大,而且数据稀疏严重
  • 二值化后的模型加载速度能快10倍以上,生产环境一定要用binary格式

避坑指南:我曾经在训练语料里混入了大量标点符号和数字,结果模型把「2024年」和「2025年」当成完全不同的东西。后来我统一做了预处理:数字归一化为,标点去掉。效果立竿见影。

好了,N-gram的内容就到这里。下一章咱们会讲更高级的神经语言模型,但相信我,N-gram这套思路到现在依然不过时。很多大厂的核心纠错系统,底层还是用KenLM做兜底。打好这个基础,后面学深度学习语言模型会轻松很多。