news 2026/10/16 23:00:39

从朴素贝叶斯到N-gram:构建语言模型的概率方法论

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从朴素贝叶斯到N-gram:构建语言模型的概率方法论

作为 NLP 系列的第 5 篇,我想聊一个承上启下的关键转折:从朴素贝叶斯到 N-gram 语言模型。前面我们用朴素贝叶斯做文本分类,靠的是“词袋 + 条件独立”这两个假设;但一旦你想让模型理解词序、生成自然句子,或者给一句话打一个“像不像人话”的分数,朴素贝叶斯就明显不够用了。这篇文章我会把这几步的推导逻辑、实操细节、平滑处理和评估方法完整讲一遍,无论你是在学 NLP 基础,还是准备动手做一个基于统计的文本生成、拼写纠错小项目,都可以直接照着用。

1. 为什么从朴素贝叶斯这一站转向语言模型

1.1 朴素贝叶斯到底帮你搞定了什么

朴素贝叶斯解决的是分类问题,它的核心公式是这样的:

P(y | x1, x2, ..., xn) ∝ P(y) × P(x1 | y) × P(x2 | y) × ... × P(xn | y)

这里的 xi 是文本里的特征,通常是词。这个公式建立在两个假设上:一是特征之间相互独立,二是不考虑顺序。换句话说,朴素贝叶斯把一段文本当成一个“装了词的袋子”,所有词语只负责投票,谁先谁后完全不重要。

这个假设在垃圾邮件过滤这类任务里非常管用。邮件里出现“中奖”“点击链接”这些词,不管它们出现在开头还是结尾,都不影响这是一封垃圾邮件的结论。我在实际项目里用朴素贝叶斯做过短文本分类,只要做好分词、去掉停用词、用 TF-IDF 稍微调一下特征权重,几千条样本就能得到一个准确率不错的小分类器。训练快、内存小、可解释性强,这是它的优势。

但缺陷也很明显:它无法区分“猫喜欢鱼”和“鱼喜欢猫”这两句话。词袋模型下这两句的特征完全一样,分类结果也会完全一样。然而作为人类我们很清楚,前者是猫爱吃鱼,后者是鱼爱吃猫,语义差别巨大。如果你在做的是排序、纠错、生成这一类任务,模型连基本的语序都不敏感,结果基本没法用。

1.2 从“文本属于哪类”到“文本有多自然”

朴素贝叶斯的输出是一个类别概率分布:这段文本属于“正面”还是“负面”,属于“垃圾邮件”还是“正常邮件”。而语言模型要回答的问题完全不同:它要给任意一段词序列分配一个概率,这个概率代表“这句话在自然语言里有多自然”。

举例来说,模型应该给“我今天上午九点开会”一个很高的概率,给“开会我上午今天九点”一个很低的概率。即使两句话用词一模一样,词序不同,概率就应该不同。这正是朴素贝叶斯的知识盲区。

这里顺便提一个重要的理解角度:朴素贝叶斯其实可以被看作一个“有类别条件的 unigram 语言模型”。分类的时候,它对不同类别分别统计单词概率,再结合先验概率做判别;而 unigram 语言模型可以理解为只有一个类别、没有先验约束的独立词概率乘积。所以从朴素贝叶斯走向语言模型,本质上就是给模型补上“上下文”和“顺序”这两块拼图,让它从“鉴别器”逐步变成“生成器”和“理解器”。

2. 语言模型要回答的基本问题:一个句子“像不像话”

2.1 给一句话的概率下定义

语言模型的目标很简单:对任意词序列 w1, w2, ..., wm,计算出概率 P(w1, w2, ..., wm)。

这个概率必须满足两个基本性质。第一是非负性,任何词序列的概率都不小于 0;第二是归一性,所有可能词序列的概率加总等于 1。这保证了模型是一个合法的概率分布,而不是随便给出一个拍脑袋的分数。

有人会问:为什么要费劲算这个概率?它又不能直接给出分类标签。但实际应用里这个概率太有用了。语音识别系统说出一句“我今天上午九点开会”,语言模型给它打分 0.8;又说出一句“我今天上午九点开烩”,语言模型给它打分 0.0001,识别器就会选择前者。机器翻译里候选译文排序、输入法里的候选词重排、拼写纠错里的候选项取舍,核心都是在做同一件事:多个候选结果,谁的概率更高,谁就排在前面。

2.2 链式法则:概率拆解的开始

一个句子的联合概率怎么算?数学上有个精确的分解方法叫链式法则:

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

这个公式本身是严格的,没有做任何近似。它告诉我们:算整个句子概率,可以拆成“逐词预测”的乘积。第一个词的概率,乘以第二个词在第一个词出现后的概率,再乘以第三个词在前两个词出现后的概率,以此类推。

麻烦在于,条件里的历史越来越长。以“我今天上午九点开会”为例,算到“开会”时,条件是“我今天上午九点”。在真实语料里,“我今天上午九点开会”这个完整序列可能一次都没出现过,但你没法说它的概率就是 0,否则所有没见过的长句子都会被打成零分,模型就完全失效了。

这正是朴素贝叶斯当初选择“条件独立”的原因——它不去管条件里那长长一串历史,直接把词看成各自独立事件。可独立假设又丢了顺序信息。那么问题来了:有没有一个折中方案,既保留一定程度的历史信息,又不至于让条件复杂度爆炸?答案就是 N-gram。

3. N-gram 的具体建模:马尔可夫假设与频率统计

3.1 马尔可夫假设:只看最近 n-1 个词

N-gram 模型的核心是马尔可夫假设:下一个词的出现只与最近的 n-1 个词有关,更早的历史不需要考虑。

按照这个假设,链式法则中的条件概率被大幅简化:

  • unigram(1-gram):P(wi | w1, ..., wi-1) ≈ P(wi),完全不看上下文;
  • bigram(2-gram):P(wi | w1, ..., wi-1) ≈ P(wi | wi-1),只看前一个词;
  • trigram(3-gram):P(wi | w1, ..., wi-1) ≈ P(wi | wi-2, wi-1),只看前两个词。

用表格总结一下直观差别:

阶数依赖范围对顺序的感知参数规模(词汇量 V)典型场景
unigram无完全没有V词袋分类、基线模型
bigram前一个词只有相邻局部关系V²输入法、简单文本生成
trigram前两个词捕捉双词搭配和局部短语V³分词后处理、拼写纠错
更高阶(4-gram 以上)前 n-1 个词更长的上下文V^n大语料才能支撑,否则稀疏严重

这里要注意一个工程上的权衡:n 越大,模型对历史的建模越细致,但需要的语料量和参数空间也急剧膨胀。词汇量如果是 5 万,bigram 潜在参数就有 25 亿,trigram 更是 125 万亿的规模。现实中绝大多数组合不会出现,所以 n 通常取 2 到 5,再往上就很不划算。

3.2 用频率估计概率:最大似然估计

N-gram 条件概率怎么算?最直观的方法是最大似然估计,也就是数次数:

P(wi | wi-1) = count(wi-1, wi) / count(wi-1)

这里的 count(wi-1, wi) 是语料中“wi-1 后面跟着 wi”这个二元组合的出现次数,count(wi-1) 是 wi-1 单独出现的次数。更好理解的方式是:在历史 wi-1 出现的所有情况下,有多少比例后面跟的是 wi。

举一个非常小的语料例子,方便你手算验证:

<s> 猫 喜欢 鱼 </s> <s> 狗 喜欢 肉 </s> <s> 猫 喜欢 猫粮 </s>

这里<s>表示句子开头,</s>表示句子结尾。统计一下相关次数:

  • count(猫) = 2
  • count(喜欢) = 3
  • count(鱼) = 1
  • count(猫, 喜欢) = 2
  • count(喜欢, 鱼) = 1
  • count(喜欢, 肉) = 1
  • count(喜欢, 猫粮) = 1

于是可以算出:

P(喜欢 | 猫) = count(猫, 喜欢) / count(猫) = 2 / 2 = 1

P(鱼 | 喜欢) = count(喜欢, 鱼) / count(喜欢) = 1 / 3

在训练语料里,只要“猫”出现过,后面必然是“喜欢”,所以概率是 1;而“喜欢”后面跟什么并不确定,三种词各占三分之一。

3.3 训练与生成中的实操细节:句子边界标记一定要加

我在第一次自己写 N-gram 训练代码时,漏掉了句子边界标记,结果生成出来的文本总是在不该断的地方截断,候选句子概率算出来也怪怪的。这个坑值得单拎出来说:句子开头和结尾必须显式建模。

加入<s>和</s>的好处有三点。第一,能统计一个词出现在句首的概率,也就是 P(词 |<s>);第二,能统计一个词结束句子的概率,也就是 P(</s>| 词);第三,生成文本时,模型才会知道什么时候该“收尾”。

比如上面这个小语料里,P(<s>, 猫) = 2,P(<s>, 狗) = 1,所以 P(猫 |<s>) = 2/3,P(狗 |<s>) = 1/3。也就是说,新的句子有三分之二概率从“猫”开头,三分之一概率从“狗”开头。

做训练的时候,我建议提前把所有句子都转成带边界标记的 token 序列,统一拼接后再统计 n-gram 计数。这样实现起来最干净,后面做测试句子的概率评估时,也能按同样的格式计算,避免训练和评估不一致的诡异问题。

下面是训练 bigram 计数的一份简化 Python 代码,方便你直接跑通流程:

from collections import defaultdict corpus = [ "猫 喜欢 鱼", "狗 喜欢 肉", "猫 喜欢 猫粮", ] def tokenize(sentence): return ["<s>"] + sentence.split() + ["</s>"] bigram_count = defaultdict(int) unigram_count = defaultdict(int) for sentence in corpus: tokens = tokenize(sentence) for i in range(len(tokens) - 1): unigram_count[tokens[i]] += 1 bigram_count[(tokens[i], tokens[i+1])] += 1 # 最后一个 unigram 也要计入 unigram_count[tokens[-1]] += 1 def bigram_prob(w1, w2): return bigram_count[(w1, w2)] / unigram_count[w1] print(bigram_prob("<s>", "猫")) # 2/3 print(bigram_prob("喜欢", "鱼")) # 1/3 print(bigram_prob("猫", "喜欢")) # 1

这段代码虽然简单,却构成了一个最基础的 bigram 训练器。实际项目里你还需要加入分词工具、低频词替换、平滑处理等环节,但核心逻辑就是这个“数数”。

4. 数据稀疏:N-gram 工程里躲不开的坎

4.1 零概率是怎么毁掉一个模型的

N-gram 用频率估计概率,天然有一个致命问题:很多合理搭配在有限语料里压根不会出现。

比如你训练的语料里没有出现过“喜欢 看书”,但测试文本里正好有一句“我喜欢看书”。按照最大似然估计,P(看书 | 喜欢) = 0,这句话的整个概率直接归零。这显然不合理——没出现过的组合,不代表它就不可能存在,只是我们的语料样本太小没采到而已。

我把这个问题称为“语料抽样误差被误当成语言规律”。在真实 NLP 项目里,数据永远是稀缺的,尤其是长尾搭配极其丰富的自然语言。一个完整的中文语料库哪怕包含了上亿句子,也无法覆盖所有合法的词序组合。所以平滑处理不是可选项,而是必选项。

4.2 拉普拉斯平滑与加 δ 平滑:简单但有一个大问题

最直观的平滑方法是拉普拉斯平滑,也就是传说中的“加一平滑”。把每个 n-gram 的计数都加上 1:

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

这里的 V 是词表大小。依然用前面的语料,如果词表是“猫、狗、喜欢、鱼、肉、猫粮”六个词,那么:

P(鱼 | 喜欢) = (1 + 1) / (3 + 6) = 2/9

而未出现的组合 P(肉 | 猫) = (0 + 1) / (2 + 6) = 1/8。这就有问题了:一个从未见过的搭配,概率居然比真实出现过的“喜欢 鱼”还高?原因是“喜欢”的计数只有 3,分母里加上的 V = 6 把高频 n-gram 的概率稀释得太厉害。

实际工程中一般不用加一平滑,而是用加 δ 平滑,也就是给每个计数加一个很小的 δ,通常取 0.01 到 0.5 之间。δ 越小,对高频搭配的干扰越小。不过它只是缓解了问题,并没有从根本上解决“给未见组合一个合理估计”的难题。

4.3 回退与插值:让高阶模型把问题交给低阶模型

比“硬加一个数”更聪明的方式,是让模型在信息不足时自动“降级”到低阶 n-gram。这有两条主流路线。

第一条是回退。如果高阶 n-gram 的计数为 0,就回退到低阶 n-gram。比如 trigram 的 P(猫粮 | 猫, 喜欢) 没有计数,就改查 bigram 的 P(猫粮 | 喜欢);如果还没有,就再退回 unigram 的 P(猫粮)。条件不足时逐级下放,保证总能给出一个非零概率。

第二条是线性插值。不是等到零概率才回退,而是直接把高阶和低阶概率按权重揉在一起:

P(wi | wi-2, wi-1) = λ1 × P(wi | wi-2, wi-1) + λ2 × P(wi | wi-1) + λ3 × P(wi)

三个权重加起来等于 1,可以通过在验证集上调参得到。工程上我觉得插值更顺手,因为它永远同时参考高阶和低阶信息,不会出现“突然切档”的割裂感。很多开源统计语言模型框架的默认配置也是插值。

下面是一个插值的简单示例:统计完三个阶别的模型后,对概率做加权汇总。

lambda_1 = 0.6 # trigram 权重 lambda_2 = 0.3 # bigram 权重 lambda_3 = 0.1 # unigram 权重 def interpolated_prob(w3, w2, w1): p3 = trigram_prob(w3, w2, w1) if (w3, w2, w1) in trigram_count else 0.0 p2 = bigram_prob(w2, w1) if (w2, w1) in bigram_count else 0.0 p1 = unigram_prob(w1) # 如果高阶为 0,权重会自然让低阶接管 return lambda_1 * p3 + lambda_2 * p2 + lambda_3 * p1

这里 λ1 给当前分词最高权重,但当高阶没有统计到该组合时,p3 是 0,权重会按比例落到低阶部分。更专业的做法是把三个 λ 在留出集上做交叉验证,而不是像我示例里这样手动拍。

4.4 未登录词:平滑解决不了的问题

比零概率更棘手的是未登录词。测试句子里出现了一个训练时完全没见过的词,比如一个生僻人名“张三疯猫”。不管怎么平滑,词表里都没有这个 token 的位置,概率就是算不出来。

常规做法是设置一个词表截断阈值:训练语料中频率低于某个次数(比如 2 次)的词,统一替换为<UNK>。这样模型学的不是具体生僻词,而是“出现了没见过的词应该怎么往后接”的规律。到了测试环节,所有没有出现在词表里的词也统一替换为<UNK>,问题就解决了。

我在做新闻语料分类时,一开始没有加<UNK>,结果测试集里出现一个新词,整个分类器直接报零概率。加了<UNK>之后,模型变得“皮实”很多,新词不再让它崩溃。

5. 评估模型:困惑度怎么用才不坑

5.1 什么是困惑度

训练好一个 N-gram 模型,怎么客观衡量它好不好?最常用的指标是困惑度。公式长这样:

PP(W) = exp(-1/N × Σlog P(wi | context))

其中 N 是测试文本中需要预测的词的总数。困惑度可以直观理解成“模型在预测下一个词时,平均面临多少个备选答案”。如果困惑度是 2,相当于平均每预测一个词,模型只在 2 个选项之间犹豫;如果困惑度是 100,说明模型几乎是在瞎猜,可能的词有上百个。

困惑度和句子的概率是反比关系:句子概率越大,困惑度越低。也就是模型对真实文本给出的概率越高,越“自信”,困惑度越小,模型越好。

5.2 一个可以手算的评估例子

沿用前面的语料,我用 bigram 和 unigram 分别算一下测试句子“猫 喜欢 鱼”的困惑度,让你直观看到上下文带来的提升。

假设测试句子是“猫 喜欢 鱼”。对于 bigram 模型,句子的概率是:

P(猫 |<s>) × P(喜欢 | 猫) × P(鱼 | 喜欢)

= 2/3 × 1 × 1/3

= 2/9

N = 3,困惑度 = (1 / (2/9))^(1/3) ≈ 1.65,说明模型在三个预测位置上,平均有效备选词不到 2 个。

对于 unigram 模型,句子的概率是:

P(猫) × P(喜欢) × P(鱼) = 2/6 × 3/6 × 1/6 = 1/36

困惑度 = 36^(1/3) ≈ 3.30。bigram 明显更好,说明引入“前一个词”的信息后,预测的确定性提升了一倍。

用代码校验这个计算过程,是这样:

import math # 手工统计自上面的语料 p = (2/3) * (2/2) * (1/3) n = 3 ppl = math.exp(- (1/n) * math.log(p)) print(ppl) # 约 1.65

日常工作中,建议你在相同语料、相同分词规则的前提下,对比 unigram、bigram、trigram 的困惑度。通常 n 从 1 升到 2 会有一个明显的下降;从 2 升到 3 只要语料足够大,也会有下降。如果语料不够大,trigram 的困惑度反而可能恶化,这就是数据稀疏在惩罚高阶模型。

5.3 困惑度的两个实践陷阱

第一个陷阱:困惑度是相对指标,不是绝对指标。不同词表、不同分词方式下的困惑度不能直接比较。中文按字切分和按词切分计算出来的困惑度差别很大,别看到别人报告一个数字就觉得自己的模型该达到这个值。

第二个陷阱:困惑度低不等于下游任务效果好。我见过一个模型,在测试集上困惑度很漂亮,但生成的文本很短很突兀,因为它倾向于把所有概率都押在几个高频词上。做生成任务时,不要只盯着困惑度,还要看生成结果的多样性、人工评估得分等指标。困惑度适合做快速迭代筛选,不适合作为唯一验收标准。

6. 落到实际场景:选阶数、调语料,以及 N-gram 的后续

6.1 一个能直接照做的 N-gram 实战路径

如果你现在想动手做一个基于 N-gram 的小项目,我很建议从“拼写纠错”或者“输入法候选词排序”入手,因为它对候选范围有限,模型容易体现价值。基本路径是:

  1. 准备语料。不需要太多,几十万到几百万句都可以,但领域要贴近你的应用场景。做新闻纠错就别用太多口语聊天语料,概率分布会跑偏。
  2. 中文分词。用现成的分词工具把句子切成词序列。如果做字级别的 N-gram,可以跳过切词,直接按字符计数,这在处理人名、产品名时反而更稳。
  3. 建立词表并替换低频词。把出现次数小于阈值(我常用 2 或 3)的词替换为<UNK>。
  4. 统计 n-gram 计数,做插值平滑。一开始可以用 trigram + 插值,语料不足就降到 bigram。
  5. 对每个候选结果计算句子概率,选困惑度最低的那个作为最终输出。

文本生成也能做。用 N-gram 生成文本的思路是:给定一个前缀,从模型里找出所有可能的下一个词及其概率,按概率随机采样,然后把新词追加到前缀,继续采样。它生成的句子短期通顺,但长一点就会跑偏,因为没有全局主题和语义规划。作为玩具和教学 Demo 很有趣,想商用还是得上后面的神经网络方案。

6.2 中文项目里 N-gram 的一个特别选择:按字还是按词

中文做 N-gram 有个独特的纠结:按字切还是按词切。按词切分,语义单元更完整,统计出的 bigram 更“像话”,比如“喜欢/鱼”这种搭配,比“欢/鱼”有意义得多。代价是分词本身可能出错,词表大,数据稀疏更严重。

按字切分则相反,词表很小,训练数据几乎不会浪费,任何生僻人名都能拆成字来兜底。我在做敏感词过滤和纠错任务时,经常是“字级别 bigram + 词级别 trigram”同时上,两者分数各占一定权重,效果比单用一个稳定。你也可以在项目里尝试这种混合方案,很多输入法就是这么干的。

6.3 N-gram 的穷途与传承:它没有消失,只是换了个形态

说起 N-gram,很多初学者会误以为它是“过时的技术”,想直接跳到神经网络。但我要诚实地告诉你:N-gram 的统计思想并没有消失,它只是被吸收进了更复杂的模型里。

神经网络语言模型本质上在做的还是链式法则和条件概率建模,只是把“固定窗口的离散计数”换成了“可学习的低维向量表示”。早期的神经网络语言模型,比如前馈网络语言模型,输入的就是前 n-1 个词,本质上还是 N-gram 的“滑动窗口”思路。后来的 RNN、LSTM 是想把窗口从固定 n 变成无限长,Transformer 则是用注意力机制在长距离上下文中动态提取历史信息。

所以我把从朴素贝叶斯到 N-gram 这一步,理解成一条认知升级的重要链条:先学会做概率假设,再学会用上下文信息。这个思维方式一旦建立,后面看词向量、注意力机制、预训练模型都会觉得顺理成章。

我自己的体会是,很多 NLP 难点在对概率建模的理解上,而不在模型本身多复杂。把 N-gram 的计数、平滑、评估这一套完整跑通,你对概率语言模型的感觉会完全不一样。第一次生成出一段看起来还算通顺的 N-gram 文本时,那种“原来概率真的能产生语言”的直观冲击,是直接读论文替代不了的。有兴趣的话,你现在就可以拿自己的聊天记录或者新闻语料跑一个 bigram 模型,看看下一词预测的结果,再想想怎么用平滑和插值把它的表现往上推。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 23:00:31

DeepSeek提示词实战指南:从复制模板到写出高质量Prompt

简介&#xff1a;《DeepSeek高级应用指南》是一份面向AI工具新手与职场效率提升者的提示词实战手册。内容围绕职场、自媒体、电商、程序员、副业、个人成长等常见场景&#xff0c;整理出50组可直接套用的高阶提示词&#xff0c;帮助读者摆脱“不会提问、回答泛泛”的困境&#…

作者头像 李华
网站建设 2026/10/16 23:00:11

成为AI工程师:从数学基础到模型部署的完整技能路线图

1. 先理清&#xff1a;人工智能工程师到底在忙什么这几年经常有人问我&#xff1a;想成为人工智能工程师&#xff0c;到底要先学什么&#xff1f;这个问题听起来简单&#xff0c;回答起来却非常费劲。AI工程师不是一个只写模型的岗位&#xff0c;它更像一个连接算法、数据、业务…

作者头像 李华
网站建设 2026/10/16 23:00:06

从requests到BeautifulSoup:豆瓣Top250电影爬虫实战

爬取豆瓣评分前50的电影信息&#xff0c;这个需求我太熟了。前阵子帮一位朋友做可视化课程作业&#xff0c;他要的就是这个——把豆瓣评分最高的前50部电影整理成表格&#xff0c;再画个评分分布图。我一听&#xff0c;这事看着简单&#xff0c;真做起来全是坑。豆瓣的反爬不是…

作者头像 李华
网站建设 2026/10/16 22:59:58

专有云DTS API调用实战:从凭证准备到任务编排

简介&#xff1a;阿里云专有云企业版 V3.16.0 数据传输服务DTS开发指南是一份面向企业开发者和系统集成工程师的官方产品文档&#xff0c;专注于DTS API接口的完整调用方法与集成流程。资源压缩包共1个PDF文件&#xff0c;大小3.57MB&#xff0c;便于检索和离线翻阅&#xff0c…

作者头像 李华
网站建设 2026/10/16 22:59:48

ASP.NET MVC 5项目源码包:含权限、数据库、JWT改造的完整站点

简介&#xff1a;本资源是面向高校计算机专业学生及.NET初学者的ASP.NET网站开发实战教程配套源码包&#xff0c;聚焦图书管理系统这一典型Web项目&#xff0c;系统覆盖页面生命周期、MVC架构、C#核心编程、ADO.NET与Entity Framework数据库操作、Web API构建及IIS部署等关键技…

作者头像 李华
网站建设 2026/10/16 22:56:14

MATLAB数字信号处理实战:从源码包到滤波器设计

简介&#xff1a;万永革《数字信号处理的MATLAB实现》高清扫描版&#xff0c;是一本以大量实例和可运行程序贯穿始终的数字信号处理实践教材&#xff0c;主要面向数理基础相对薄弱、重视动手能力的读者&#xff0c;也适合本科生、研究生与工程技术人员作为案头参考。压缩包内共…

作者头像 李华