做NLP这几年,我越来越觉得,大部分看似复杂的模型问题,最后都能归到“概率怎么拆、假设怎么做”这两件事上。今天要聊的Chain Rule和Markov Assumption,恰恰是概率语言模型最底层的两块基石。如果你正在系统学NLP,或者打算自己实现一个文本生成、拼写纠错、语音识别候选重排的模块,这两个概念几乎是绕不开的。
先说人话版:Chain Rule告诉我们“一句完整的话出现的概率”可以被拆成一串条件概率相乘;Markov Assumption则告诉大家,计算条件概率时别贪心,只依赖最近的一两个词就够了,别把整段历史都背上。这两招组合在一起,构成了整个统计语言模型的起点——从最早的n-gram模型,到今天所有自回归大模型的最底层形式,都跑不出这个框架。
文章后面会给出完整的推导、Python实现、迷惑点排查和避坑经验,尽量让没接触过概率语言模型的同学也能照着做一遍。
1. 概率语言模型到底在算什么
1.1 一句话定义:给句子打分
概率语言模型的目标,用一句话说就是:输入一个句子,输出它出现的概率,也就是计算 P(w₁, w₂, ..., wₙ)。这里的 w 是句子里的词,下标表示顺序。
这个“打分”能力用途极广。拼写纠错时,候选词更正的句子要算概率,选最大的那个;输入法候选排序要考虑哪个句子更像人话;语音识别出一堆候选结果,也要靠语言模型挑出语义上最可能的那个;文本生成更是直接把概率当成采样依据。
你可能会问,为什么非要“概率”?因为概率天然给了我们一个统一的比较尺度:一句话概率大,说明语料里常见、语法语义上更合理;概率小,说明别扭。这个思路最朴素,也最好用。
1.2 直接统计整句话为什么行不通
最直觉的做法是:拿一个巨大的语料库,数一数某个句子出现了多少次,除以总句子数,就是概率。听起来很直接,但实际上完全不可行。
原因在于语料库不可能覆盖所有句子。自然语言的组合空间极大,大部分合法句子在语料库里出现次数是0。就算今天互联网语料再多,你随便写一句“昨晚那只橘猫在窗台上对着月亮发呆了半小时”,很可能全网都找不到完全一致的重复句子。Zipf分布告诉我们,绝大多数句子只出现一次,甚至零次。
所以直接数整句话这条路,本质上被数据稀疏性堵死了。我们需要的是“通过局部统计来逼近整体概率”的做法——这正是Chain Rule和Markov Assumption要解决的问题。
1.3 适合谁来学
如果你是刚入门NLP的学生,这部分内容是课程里最早的“硬骨头”,理解了它,后面看HMM、语言模型评估、机器翻译的噪声信道模型都会顺畅很多。如果你已经上手深度学习,正在用预训练模型做任务,回头补这块知识也不算浪费时间——很多线上模型的bad case,最后追查到底,都是底层条件概率处理不当。
2. 链式法则:把联合概率拆成一道递归式
2.1 先从两个事件看起
概率论里有一个恒等式:
P(A ∩ B) = P(A) × P(B | A)
意思是,A和B同时发生的概率,等于“A发生的概率”乘以“在A发生的条件下B发生的概率”。这不是什么近似,这是条件概率定义的直接推论,对任意事件都成立。
如果再加一个事件C,也能继续拆:
P(A ∩ B ∩ C) = P(A) × P(B | A) × P(C | A, B)
注意最后的因子变成了“在A和B都发生的情况下C发生的概率”。多一个事件,条件里就多一项历史。整个Chain Rule的核心就是这种递归拆分方式:
P(w₁, w₂, ..., wₙ) = P(w₁) × P(w₂ | w₁) × P(w₃ | w₁, w₂) × ... × P(wₙ | w₁, ..., wₙ₋₁)
2.2 从整句拆到局部
把这条规则套到句子上,意思就是:一个句子出现的概率,等于第一个词的概率,乘以在第一个词出现后第二个词的概率,再乘以在前两个词出现后第三个词的概率……一直乘到句尾。
这个拆法有什么好处?最关键的是,它把一个“几乎没法直接统计的整句事件”,变成了很多个“局部片段的条件概率”。比如“猫 喜欢 鱼”这个句子:
P(猫, 喜欢, 鱼) = P(猫) × P(喜欢 | 猫) × P(鱼 | 猫, 喜欢)
前两个因子在语料里很容易统计:“猫”出现的次数很多,“喜欢”出现在“猫”后面的情况也比较常见。第三个因子“猫 喜欢 之后出现 鱼”虽然稍微特殊一点,但比统计完整句子“猫喜欢鱼”出现多少次要容易得多——因为前者只需要看“猫 喜欢”后面接什么词。
这就是拆分的力量:把大问题切成小碎片,用小碎片的统计量拼出整体。
2.3 链式法则拆完了,问题并没解决
很遗憾,链式法则是恒等式,它只负责“拆”,不负责“算”。拆到最后,你还是要估计形如 P(wᵢ | w₁, ..., wᵢ₋₁) 这种带完整历史的条件概率。
这里的困难非常具体。假设词表大小是 V,要估计 P(wᵢ | w₁, ..., wᵢ₋₁),理论上需要为每一种历史组合都准备一组概率分布。一个长度为 m 的句子,其可能的历史组合数量高达 V 的 m 次方。哪怕 V 只有一万,m 只有六,V^m 就是 10 的 24 次方——这已经远远超过世界上所有语料的词数总和。
所以光有Chain Rule还不够,它给了我们一根“拆解线索”,但真正让计算可行的,是下面的马尔可夫假设。
3. 马尔可夫假设:用最近的活动窗口换掉全量历史
3.1 核心思想就一句:别管太远的事
马尔可夫假设的核心表述非常朴素:当前状态只依赖于最近的历史,而不是全部历史。放到语言模型上就是:
P(wᵢ | w₁, w₂, ..., wᵢ₋₁) ≈ P(wᵢ | wᵢ₋k, ..., wᵢ₋₁)
其中 k 是窗口大小。k=1 时,当前词只依赖前一个词;k=2 时,依赖前两个词。
这个假设看起来像偷懒,但语言学上是有直觉的。在大多数句子里,一个词怎么接下去,主要受最近一两个词的影响。比如“我 昨天 在 路边 看到 一只——”后面大概率接动物名词,因为最近的信息是“看到一只”;至于句首的“我昨天在路边”,对选词的影响已经弱很多了。
用一个生活化类比:你在群里聊天,判断对方下一句要说什么,往往不需要翻完整段聊天记录,只需要看最近几条消息就够了。马尔可夫假设就是这么个意思。
3.2 一阶、二阶和三阶到底差多少
我们常说的unigram、bigram、trigram,其实对应着不同强度的马尔可夫假设。
| 模型 | 依赖历史长度 | 条件概率形式 | 参数规模量级 |
|---|---|---|---|
| unigram | 0(不依赖历史) | P(wᵢ) | V |
| bigram | 1 | P(wᵢ | wᵢ₋₁) | V² |
| trigram | 2 | P(wᵢ | wᵢ₋₂, wᵢ₋₁) | V³ |
这里的 n-gram 中的 n 是“上下文窗口 + 1”,bigram 窗口是1,trigram 窗口是2。原理上,窗口越大,模型刻画上下文的能力越强,生成的文本也会更连贯。但代价是参数数量指数级增长,训练数据不够时,大量条件概率根本统计不到,出现严重的零概率问题。
这就是语言模型里最经典的一个权衡:大窗口更准,但更稀疏;小窗口更稳,但更笨。
3.3 马尔可夫假设不是万能药
必须要说清楚,马尔可夫假设是一种简化,不是真理。自然语言里存在大量长距离依赖,最典型的就是“如果……那么……”“虽然……但是……”这类结构。句子前半段的“如果”决定了后半句要用“那么”,中间可能隔了二十个词。如果只用bigram,模型根本抓不住这种关系。
所以真实工程里,要么提高阶数到trigram甚至更高,要么干脆放弃“硬截断窗口”的思路,用神经网络里的注意力机制去动态选择历史——Transformer就是干这个的。但理解马尔可夫假设仍然重要,因为它是“局部性先验”的最经典表达,也是理解注意力机制为什么能奏效的对照组。
4. 两者合体:一个bigram模型从0到1的完整推算
4.1 先造一个小语料,动手算一遍
理论讲完了,还是要落到数字上。假设我们有四句训练语料:
- 我 喜欢 喝 咖啡
- 我 喜欢 喝 茶
- 他 喜欢 吃 苹果
- 她 喜欢 看 书
现在要计算句子“我 喜欢 喝 咖啡”的概率。
按照链式法则,完整形式是:
P(我) × P(喜欢 | 我) × P(喝 | 我, 喜欢) × P(咖啡 | 我, 喜欢, 喝)
这里的条件历史很长,语料太小,统计不了。应用一阶马尔可夫假设,bigram模型把条件截断到前一个词:
P(我 |) × P(喜欢 | 我) × P(喝 | 喜欢) × P(咖啡 | 喝) × P(| 咖啡)
注意我在句首和句尾都加了特殊符号和。为什么要加?因为句子的第一个词前面其实有一个虚拟的“开头状态”,模型的第一个概率得有个来源,否则没法归一。
4.2 最大似然估计:比例就是概率
训练阶段,所有概率都是用最大似然估计(MLE)算出来的。公式极其简单:
P(wᵢ | wᵢ₋₁) = count(wᵢ₋₁, wᵢ) / count(wᵢ₋₁)
分母是“前一个词出现的总次数”,分子是“前一个词后面跟着当前词的出现次数”。本质上就是一个频率比,符合直觉:前一个词一共出现了多少次,其中多少次后面跟的是当前词,比例自然就是条件概率。
上面四句语料里,统计“喜欢”后面的词,得到:喝出现2次,吃出现1次,看出现1次。“喜欢”总共出现4次。所以:
P(喝 | 喜欢) = 2/4 = 0.5
同理:
P(咖啡 | 喝) = 1/2 = 0.5
因为“喝”后面只跟着两种词:“咖啡”和“茶”,各出现一次。这个例子也能看出,bigram的意义在于它捕捉了“局部搭配”:看到“喜欢”,语言模型知道后面最可能是“喝”,概率0.5;看到“喝”,模型知道后面只有咖啡或茶二选一。
4.3 为什么必须两个假设一块用
单独用链式法则,拆出来的条件概率还是没法算;单独用马尔可夫假设,没有说明怎么从整句概率出发做拆解。只有两者配合,才可以写成:
P(w₁, ..., wₙ) ≈ ∏ᵢ P(wᵢ | wᵢ₋k, ..., wᵢ₋₁)
这个式子就是所有n-gram语言模型的统一表达式。它同时完成了两件事:Chain Rule负责把联合概率拆成可解释的乘积,Markov Assumption负责把乘积里每个条件概率的历史压缩到固定窗口。一个负责“拆”,一个负责“截”,缺一不可。
5. 实操:用Python搭一个可评估的n-gram语言模型
5.1 最小可用代码:统计与概率估计
直接上完整可运行的Python代码,我用最朴素的字典结构,不依赖重型库,方便你一行行看懂。
from collections import defaultdict, Counter def build_bigram(corpus): # corpus 是字符串列表,每行已经按空格分词 bigram_counts = defaultdict(Counter) first_counts = Counter() for line in corpus: tokens = ['<s>'] + line.strip().split() + ['</s>'] first_counts[tokens[0]] += 1 for i in range(len(tokens) - 1): bigram_counts[tokens[i]][tokens[i + 1]] += 1 return first_counts, bigram_counts corpus = [ '我 喜欢 喝 咖啡', '我 喜欢 喝 茶', '他 喜欢 吃 苹果', '她 喜欢 看 书' ] first_counts, bigram_counts = build_bigram(corpus) def bigram_prob(prev, cur): total = sum(bigram_counts[prev].values()) if total == 0: return 0.0 return bigram_counts[prev].get(cur, 0) / total # 测试 print(bigram_prob('我', '喜欢')) # 我后面只跟喜欢,概率1.0 print(bigram_prob('喜欢', '喝')) # 2/4 = 0.5 print(bigram_prob('喝', '咖啡')) # 1/2 = 0.55.2 句子打分:log域计算避免下溢
句子概率是多个小于1的数相乘,句子稍长,乘积就会小到超出浮点数能表示的精度范围,直接乘会得到0。工程上通通转成对数相加。
import math def sentence_log_prob(sentence): tokens = ['<s>'] + sentence.strip().split() + ['</s>'] logp = 0.0 for i in range(len(tokens) - 1): p = bigram_prob(tokens[i], tokens[i + 1]) if p <= 0: return float('-inf') # 概率为0,直接判死 logp += math.log(p) return logp print(sentence_log_prob('我 喜欢 喝 咖啡')) print(sentence_log_prob('他 喜欢 喝 咖啡'))第二句“他 喜欢 喝 咖啡”有没有问题?逐项拆开:P(他|)有,P(喜欢|他)有,P(喝|喜欢)有,P(咖啡|喝)有,全都不为0,所以bigram会给它一个有限的对数概率。尽管这个句子根本没在训练语料里出现过,但bigram靠局部片段就能给出来概率,这正是马尔可夫假设的价值。
但如果算“他 喜欢 喝 书”,到 P(书|喝) 这一步,因为训练语料里“喝”后面只有咖啡和茶,概率就是0,整个句子被判为不可能事件。
5.3 用困惑度横向对比不同阶数的模型
有了打分函数,就要有评估指标。语言模型最常用的指标是困惑度,定义是:
perplexity = exp(- (1/N) × ∑ log P(wᵢ | history))
N是测试语料里的总词数。困惑度越小,说明模型给真实文本打的概率越高,也就是预测能力越强。
下面实现一个完整的困惑度计算函数,同时支持一元、二元和三元模型的简单对比(这里我把n=1/2/3分别映射为不同历史长度)。
def build_ngram(corpus, n): counts = defaultdict(Counter) for line in corpus: tokens = ['<s>'] * (n - 1) + line.strip().split() + ['</s>'] for i in range(len(tokens) - (n - 1)): history = tuple(tokens[i:i + n - 1]) cur = tokens[i + n - 1] counts[history][cur] += 1 return counts def ngram_prob(counts, history, cur): total = sum(counts[history].values()) if total == 0: return 0.0 return counts[history].get(cur, 0) / total def perplexity(corpus, n): counts = build_ngram(corpus, n) total_logp = 0.0 total_tokens = 0 for line in corpus: tokens = ['<s>'] * (n - 1) + line.strip().split() + ['</s>'] for i in range(len(tokens) - (n - 1)): history = tuple(tokens[i:i + n - 1]) cur = tokens[i + n - 1] p = ngram_prob(counts, history, cur) if p <= 0: return float('inf') total_logp += math.log(p) total_tokens += 1 return math.exp(-total_logp / total_tokens) print(f"unigram perplexity: {perplexity(corpus, 1):.2f}") print(f"bigram perplexity: {perplexity(corpus, 2):.2f}") print(f"trigram perplexity: {perplexity(corpus, 3):.2f}")在这么小的玩具语料上,结果可能反直觉:有时候higher-order的困惑度反而更大,甚至直接变成inf。原因很简单,语料太小,高阶n-gram统计到的组合太稀疏,大量没见过的历史组合导致测试时概率归零。真实场景中,语料越大,高阶模型越占优势。这也说明,n-gram的阶数选择不是越大越好,而是要综合考虑语料量与上下文需求。
6. 一进真实数据就炸的坑:零概率与平滑方法
6.1 零概率是最大的假象
上面代码里有个非常大的隐患:只要某个n-gram组合在训练集里从未出现,测试时概率就是0,句子整体概率直接变成0,对数概率变成负无穷。这种零概率会扼杀所有可能性——哪怕整句话非常通顺,仅仅因为一个局部搭配没在语料里出现过,模型就残忍地给它判死刑。
真实语料远比你想象的稀疏。即使几十亿词的语料,still会有大量合理但罕见的搭配没被收录。比如“喝 空气”你大概率没见过,但小说里完全可能写出这种句子。零概率不是“这张牌不存在”,而是“我们没统计到这张牌”。
解决这个问题的统称叫平滑(Smoothing),核心思想是:给那些没见过的组合也留一点概率,把见过的高频组合的概率稍微往下让一让。
6.2 几种平滑方案怎么选
最简单的方案是加一平滑,也叫拉普拉斯平滑:
P(wᵢ | wᵢ₋₁) = (count(wᵢ₋₁, wᵢ) + 1) / (count(wᵢ₋₁) + V)
V是词表大小。分子加1,分母加V,目的是保证整个概率分布加起来仍然等于1。这个方法简单,但坏处也很明显:会把过多概率分配给没见过的组合,尤其是在词表很大时,每个高频词的条件概率被稀释得厉害。
更好的方案是插值平滑:把高阶和低阶模型线性混合,比如:
P_interp(wᵢ | wᵢ₋₂, wᵢ₋₁) = λ₃ × P_ml(wᵢ | wᵢ₋₂, wᵢ₋₁) + λ₂ × P_ml(wᵢ | wᵢ₋₁) + λ₁ × P_ml(wᵢ)
高阶层没见过的,低阶层也许见过;低阶层也没见过的,unigram兜底。这在实践里非常有效。
更高级的还有回退(backoff)和Kneser-Ney平滑。Kneser-Ney的核心思想很有意思:估算一个词的“一元概率”时,看的不是它出现多少次,而是它能搭配多少种不同上下文。比如“好”这个字很常见,但真正重要的是它出现在多少种不同历史后面。这个思路比朴素的词频更贴近语言实际,也是业界效果最好的传统平滑方法之一。对初学者,我建议先把加一平滑和插值平滑吃透,再进阶Kneser-Ney。
6.3 其他高频坑:分词、OOV、语料分布
除了零概率,还有几个坑我实际踩过,值得提前说。
第一是分词不一致。训练语料用了一个分词器,测试语料换了另一个分词器,结果同一个词被切开或拼上,所有n-gram组合全变了。看起来模型性能骤降,实则是数据不对齐。
第二是未登录词(OOV)。测试时遇到一个训练词表里没有的词,直接导致概率为0。工程上通常会把低频词统一替换成特殊符号 ,然后给 留一个概率空间。这样虽然模型说不出这个生词的具体概率,但至少不会被一个陌生词击穿整个句子。
第三是语料分布偏移。你用新闻语料训练模型,然后拿去给聊天对话排序,效果一定差。n-gram模型本质上是把训练集的统计规律原样复述出来,没有泛化到完全不同风格的文本上。这个限制在深度学习时代依然存在,只是被大规模预训练缓解了一部分。
7. 从统计语言模型到深度学习:这套底层逻辑还活着
7.1 自回归模型本质上就是链式法则
很多人一上来就学Transformer,会以为大规模语言模型和传统统计语言模型是两码事。但如果打开任何一个自回归语言模型,比如最典型的GPT系列,它的训练目标本质上还是:
P(x₁, x₂, ..., x_T) = ∏ P(x_t | x₁, ..., x_{t-1})
这就是链式法则,一字不差。区别只在于,n-gram模型用马尔可夫假设把历史截断到最近k个词,而神经网络模型用隐藏状态(或者注意力矩阵)把“历史”压缩成了一个固定维度的向量,理论上能记住更长的依赖。
马尔可夫假设之所以在深度时代依然有价值,是因为“局部性”这个先验在语言中真实存在。即便Transformer已经能捕捉长距离依赖,实际应用中仍然会给它位置编码,让它知道词序;仍然会用滑动窗口式注意力,本质上仍然是“看近不看远”的归纳偏置。
7.2 teacher forcing 的数学含义
训练基于RNN或Transformer的语言模型时,有一个几乎人人都在用但少有人细想的操作叫teacher forcing。它的意思是:训练时,预测第t个词的时候,输入的是真实的前一个词,而不是模型自己生成的词。
为什么这样做?因为训练目标函数是最大化如下对数似然:
L = ∑ log P(x_t | x₁, ..., x_{t-1})
这个函数里的条件历史,用的是真实前文 x₁, ..., x_{t-1}。这不就是最大似然估计在链式法则下的直接应用吗?模型每一步都在学着给“真实前文条件下出现真实当前词”赋予更高概率。生成时模型换成自回归采样,自己的预测输出被当作下一时刻的输入,原理上跟n-gram一个路数:用已知的前文去推下一个词。
想通这一点,你会对“模型训练和生成之间的exposure bias问题”理解得更扎实:训练时一直喂真实历史,生成时历史里开始混入模型自己的错误,分布不再匹配,这正是很多文本生成模型越跑越偏的一个原因。
7.3 关于实践的一个个人体会
我刚学NLP那阵,一直盯着各种花哨模型,觉得n-gram和马尔可夫假设太老、太简单。真到了上手做项目,用n-gram搭一个轻量候选排序模块,发现效果稳得超出预期。反而是后来用复杂模型时,经常因为忽略概率基础,在数据处理和训练目标上埋雷。
我还会偶尔回来看一遍Chain Rule和马尔可夫假设。每次都有点新体会。最近一个是:如果你能把链式法则和马尔可夫假设用最简单的话给同事讲明白,那你对语言模型就已经建立起了整体直觉。很多线上模型的问题,最后定位到根因都很朴素——编码不对齐、条件概率没归一化、某个零概率没兜底。这些都不是模型架构层面的问题,而是对上篇文章里这两块基石的掌握程度问题。