下一个词最可能是什么:手写一个 Bigram 语言模型

下一个词最可能是什么:手写一个 Bigram 语言模型

你每天都在用语言模型

你在 IDE 里敲下 res 三个字母,补全列表已经弹出 response_body、response_time 两个候选。你按下 Tab,整行补完。这个动作背后没有魔法,编辑器只做了一件事:估算在当前上下文之后,下一个词最可能是什么。把这件事做成一个可以训练、可以查询的模型,就是语言模型的全部工作。

本文不从公式出发。我们从一份只有两句话、六个词的语料库开始,亲手把大模型的统计学祖先造一遍:数次数、做除法、填一张表。走完这一程,你会清楚三件事:语言模型算的到底是哪个概率;为什么数次数这种朴素操作是正经的统计估计而不是经验口诀;以及这张表天生带什么缺陷,逼出了后续所有的平滑技巧乃至神经网络。

阅读门槛只有会写循环和会查表。文中会出现公式,但每个公式都先落在代码或直觉上,再出现符号。

把预测写成一张表

把预测下一个词写成程序需求:输入已经出现的词,输出下一个词的概率分布。最诚实的实现是一张哈希表,键是完整前文,值是各后继词的计数。麻烦在于键的空间随前文长度指数膨胀,真实语料填不满它的零头,绝大多数查询永远 miss。一张几乎查不到的表,等于没有表。

统计语言模型的出路是主动丢信息:不看完整前文,只看最近一个词。我们把用来做依据的前一个词叫前驱词,把要预测的那个词叫后继词。只靠前驱词预测后继词的模型就是 Bigram,键缩短成一个词,表的大小降到词汇量级别,一次遍历语料就能填完。代价是模型变健忘,决定后继词的只有紧邻的前驱词,更远的上下文一律无视。本文全程使用 Bigram,因为它小到可以手算,又完整保留了这套方法的全部优点与全部缺陷。

截断历史之后,句子概率可以拆成连乘:整句概率等于逐词条件概率的乘积,每个条件概率只询问前一个词。下一节我们就把这套连乘套到一个六词语料上,亲手算一遍。

手算一遍:六词语料上的 Bigram

理论说完,直接上手。假设语料库只有两句话:

datawhale agent learns
datawhale agent works

现在要计算新句子 datawhale agent learns 出现的概率。按 Bigram 假设,它拆成三个因子相乘:

P(datawhale agent learns) ≈ P(datawhale) × P(agent | datawhale) × P(learns | agent)

这里有个细节:句首词 datawhale 前面没有前驱。严格做法是引入一个虚拟句首符,计算它在句首符之后的条件概率;入门阶段我们简化处理,句首直接用词频占比。这个简化到实践一节会被代码对照,先记下。

第一步,算 P(datawhale)。语料共六个词,datawhale 出现两次,得 2/6,约 0.333。

第二步,算 P(agent | datawhale)。前驱是 datawhale 的情形共两次,这两次的后继都是 agent,得 2/2,等于 1。

第三步,算 P(learns | agent)。前驱是 agent 的情形共两次,一次后继是 learns,一次是 works,得 1/2,等于 0.5。

第四步,连乘。0.333 乘 1 乘 0.5,约 0.167。

于是这个句子在迷你模型下的概率约为 0.167。但停下来想一想:为什么频率可以直接当概率?第二步里 datawhale 后面百分之百跟着 agent,我们就认定这个条件概率等于 1,这是经验口诀还是有数学支撑?回答这个问题的是极大似然估计,下一节的主角。

凭什么数次数就是极大似然估计

先离开语言模型,看一枚硬币。抛十次,七次正面,同事问你正面概率多少,你答 0.7。这个答案几乎不需要思考,但值得追问:为什么是 0.7 而不是 0.5 或 0.9?

要回答它,先分清两个方向。概率是参数定了、去问数据会出现什么。似然把问题倒过来:数据已经摆在眼前,反过来问哪组参数最说得通。硬币问题里参数是正面概率 p,观测到七正三反的似然正比于 p 的七次方乘以 1 减 p 的三次方,差一个与 p 无关的常数,不影响峰值位置,先忽略。

不信可以跑一遍。把 p 从 0 扫到 1,逐点算似然,看峰值落在哪:

heads, tails = 7, 3          # 观测:7 次正面,3 次反面

best_p, best_like = 0.0, 0.0
for i in range(1001):        # 把 p 从 0 到 1 扫一遍
    p = i / 1000
    like = p ** heads * (1 - p) ** tails
    if like > best_like:     # 留下似然最大的 p
        best_p, best_like = p, like

print(best_p)                # 输出 0.7,正好等于频率 7/10

峰值稳稳落在 0.7,和频率七除以十一致。

为什么峰值恰好是频率,可以算出来而不必靠扫。对似然取对数,得七乘 ln p 加三乘 ln(1-p),对 p 求导并置零,解出 p 等于七除以十。把七和三换成任意计数 k 与 n 减 k,同一步导出 p 等于 k 除以 n。峰值落在频率上,是求导的必然,不是巧合。不想看推导的读者记住结论即可:数次数做除法,正是极大似然估计在这种情形下的闭式解。

回到语料。把硬币换成词:每当一个前驱词出现,就相当于抛一次硬币,决定后继是谁。整段语料的似然可以按前驱词分组,写成各组子似然的乘积。乘积的最大化又能逐因子单独进行,于是每枚硬币各自求解,互不牵扯。语料里 agent 出现两次,一次接 learns,于是这枚硬币的极大似然解是 0.5,正是手动计算第三步的数;datawhale 后面那枚硬币两次都落在 agent 上,解是 1。整个 Bigram 模型,不过是为每个前驱词各抛一枚硬币、各做一次极大似然估计,再把结果填进一张查询表——N-gram 时代的训练,说白了就是数次数加填表。

顺便留一道题:用同一张表算一句 datawhale agent sleeps。你会得到整句概率为零,因为表里查不到 agent 接 sleeps 的记录。这个零是极大似然的性格使然,与实现无关:它把全部概率质量押在观测出现过的事件上,观测没出现的自然分不到,连”可能性很小”都不肯给。记住这个零:下一节你亲手把表建出来、再跑一句没见过的词,它会以程序异常的形式回来找你。

三十行代码建出那张表

手算四步之所以能算,是因为背后有一张表。这一节把它真正建出来,看清 Bigram 模型在内存里长什么样。

先想数据结构。把表画成二维表格,行是前驱词,列是后继词,格子填共现次数,那么真实词汇量下绝大多数格子是零:词表几万个词,格子就是几亿个,语料能填亮的只是极少数。用二维数组存,等于为一片空白付内存。嵌套字典是稀疏表的标准答案:外层字典的键是前驱词,值又是一个字典,内层字典的键是后继词,值才是计数。只有真正出现过的词对才占一个格子。

代码里还有一个小决定:分母不单独存。条件概率的分母是前驱词的总次数,恰好等于内层字典所有值之和,用 sum 现场算即可,少一份状态就少一处出错。唯一需要额外字典的是句首:第一个词没有前驱,我们沿用手算一节的简化约定,用词频除以总词数,所以再备一个 unigram 字典。

corpus = [
    ["datawhale", "agent", "learns"],
    ["datawhale", "agent", "works"],
]

bigram = {}    # 外层键:前驱词;内层键:后继词;值:共现次数
unigram = {}   # 词 -> 出现次数,只为句首概率服务

for sentence in corpus:
    for prev, nxt in zip(sentence, sentence[1:]):   # 错位配对产出相邻词对
        bigram.setdefault(prev, {}).setdefault(nxt, 0)
        bigram[prev][nxt] += 1
    for word in sentence:
        unigram[word] = unigram.get(word, 0) + 1

total_words = sum(unigram.values())

def sentence_prob(sentence):
    p = unigram[sentence[0]] / total_words   # 句首简化:用词频
    for prev, nxt in zip(sentence, sentence[1:]):
        p *= bigram[prev][nxt] / sum(bigram[prev].values())
    return p

print(sentence_prob(["datawhale", "agent", "learns"]))  # 约 0.167,与手算一致

两个分母,别搞混

跑通这段代码之前,先钉死两个容易混为一谈的量。一个是总词数,即语料中词的总个数,本例为 6,它是 unigram 字典所有值之和,也就是 total_words,充当句首概率的分母。另一个是词汇量,即去重后词表的大小,本例为 4,它是 unigram 字典的键数,下一节记作 V。前者衡量语料长度,后者衡量模型要覆盖的词表规模。下一节加一平滑往分母上加的是词汇量而非总词数,因为加一相当于给词表中每一个可能的后继都预置一次计数,后继的候选个数正是词汇量。

跑通,然后撞墙

zip(sentence, sentence[1:]) 是全段最密的一行:把句子和它自己错开一位配对,一次遍历产出所有相邻词对,等价于手算时逐个数词对。建表循环跑完,bigram 的内容是 datawhale 指向 agent 计 2,agent 指向 learns 和 works 各计 1,与手算数出的完全相同。

跑最后一行,输出约 0.167,正是手算的 1/6。

但请再跑一句 sentence_prob([“datawhale”, “agent”, “sleeps”])。程序不会给你一个很小的概率,它会直接抛 KeyError。先别怀疑代码,模型其实在诚实地说:我从没见过 agent 后面接 sleeps。上一节末尾记住的那个零,就是以这种形式回来的。一个会因查不到词就崩溃的语言模型没法用,下一节我们来治它。

查不到的格子

语料再大,也总有没见过的词对,而频率估计的态度是:没见过,概率就是零。零概率的可怕之处在于连乘:句子概率是各步相乘,只要一个格子是零,整句归零,模型等于宣称这个句子不可能存在。一个词对的无知,绑架了整句话的判断。

加一平滑,给所有格子撒盐

兜底的最老办法是加一平滑,也叫拉普拉斯平滑:在建表之前,先假设每个词对都已经出现过一次。实现上不用真去填表,只需查询时分子加一、分母加词汇量 V:

P_smooth(后继 | 前驱) = (共现次数 + 1) / (前驱总次数 + V)

分子加一保证任何词对至少有一次计数,分母加 V 保证同一前驱下所有后继概率加和仍是一。落到代码,用 get 兜底取零再加一,三行把异常换成一个小而非零的概率:

V = len(unigram)   # 词汇量

def prob_smooth(prev, nxt):
    counts = bigram.get(prev, {})
    return (counts.get(nxt, 0) + 1) / (sum(counts.values()) + V)

代价立刻可见:它给每个格子都硬塞一次虚构计数,包括计数上千的热门词对。词汇量 V 越大,分母被抬得越高,等于把大量概率质量撒向永远不该出现的组合。于是有了加 k 平滑:把那个一换成可调的小数 k,让虚构计数的重量变成超参数,由验证集定。本文不展开,知道有这个旋钮即可。

回退:查不到才降级

工程上更常用的是回退。加一平滑是给所有格子撒盐,回退是查不到才降级:查询词对时,若 bigram 表里有计数就照常信它;只有计数为零时,才退而求其次问 unigram,用这个词本身的词频概率顶替那个零。

def prob_backoff(prev, nxt):
    counts = bigram.get(prev, {})  # prev 未出现时 counts 为空,自动走回退,不会除零
    if counts.get(nxt, 0) > 0:
        return counts[nxt] / sum(counts.values())  # 见过,信 bigram
    nxt = nxt if nxt in unigram else "<unk>"       # 未登录词归入未知桶
    return unigram[nxt] / total_words              # 退到 unigram

回退的直觉是:我虽然没见过 agent 接 sleeps,但可以退一步问 sleeps 本身常不常出现。若它常见,退回去的概率不会太小;若它也罕见,概率自然小。模型由此把”没见过这个组合”和”没见过这个词”区分开,比一律加一精细。

代码里那行未知桶映射,是为了堵住另一类崩溃。回退治好了未见词对,却治不了完全未登录词:若后继词在整个语料里一次都没出现,unigram 里同样没有它,函数会在回退那一行再抛一次 KeyError。于是查询前先把不在词表的词统一映射到未知桶,未登录词也能拿到一个非零概率。要让这行真正跑通,训练时需把表外词归入未知桶并累计计数,或至少为它预设一个非零频次。本文语料太小,建表后手动补一行 unigram 的未知键等于 1 即可演示。补进未知键,total_words 就从 6 变成 7,V 从 4 变成 5。分母一变,前面所有概率都要重算。sentence_prob 也不再输出 0.167。把补桶当成一次重新训练就好,别想在旧数字上打补丁。

需要声明一点。严格回退在退到 unigram 时还要乘一个折扣系数,把 bigram 层没分配完的概率质量转交回退层。有了它,同一前驱下所有后继概率加和才仍为一。本文省略系数,取教学版本,代价是加和不再等于一。见过词对的那部分 bigram 频率本身已加和到一,未见后继退回来的正概率是额外多出来的一块。两者相加,总和大于一。所以这个教学版函数只适合比较候选词的相对大小,不适合当作严格概率参与连乘或归一化场合。

两种补丁,一种立场

把加一和回退放在一起,它们在做同一件事:承认稀疏表有不可信的部分,再用别的证据源去补。加一的办法是虚构计数,回退则去借一个低阶模型。补丁打到最后,人们索性不再手工设计证据源,而让神经网络自己从上下文里学,这就走到了本文最后一节。

从数次数到神经网络

回看全文,我们只做了三件事。第一,把预测下一个词落实成一张表:外层键是前驱词,内层键是后继词,值是共现次数,用嵌套字典对抗稀疏。第二,用极大似然估计决定格子里填什么:数次数做除法,是让观测语料出现可能性最大的闭式解。第三,承认表有不可信的部分,用加一平滑和回退打补丁,让查不到的格子从崩溃变成一个小概率。

三件事的共同瓶颈也清楚:它们都站在 Bigram 假设之上,只看前一个词。表填得再满、补丁打得再巧,也补不回被主动丢掉的上下文。于是 N-gram 路线在工程上逐渐退场,把位置让给神经网络语言模型,尽管在拼写纠错与低资源场景它仍有一席之地。

但退场的是工具,不是立场。把语言当作可估计的概率分布,这一立场由 Shannon 用 Markov 链近似英语的工作确立,N-gram 是它最朴素的工程形态。它留给后世的真正遗产,是把这个立场变成一套实操方法,能数次数、能查表,也允许失败。预测下一个词,就是估计这个分布,再从中挑出最可能的那一个。神经网络换掉了估计分布的手段。查表变成一个从上下文学习出来的函数,键从上一个词扩展成整段前文,值从数出来的频率变成网络输出的分布。手段全换,核心问题一字未改。顺带一提,神经网络训练用的交叉熵损失,本质上就是负对数似然。N-gram 的对数似然有闭式最大值,数次数即得;神经网络的对数似然没有闭式解,只能靠梯度下降一步步逼近。极大似然从未退场,只是换了求解器。

回到引言里那一下 Tab。编辑器弹出的候选列表,正是这条思路的现代后代:一张无法手算、由梯度下降填出来的巨表。从六个词的语料到千亿参数,变的是填表的手段,不变的是那句提问——下一个词,最可能是什么。读懂了手里这张小表,你就读懂了所有语言模型正在做的那一件事。