📘 第11章 自然语言处理和机器学习
📄 一句话概括

文本生成的全部内容,就是「看前几个字,猜下一个字」。猜完之后把它接到后面,再猜下一个。 这一节用二十句话训一个 n 元模型,量它的困惑度,再让它写句子。 顺便看清楚:为什么 n 不能一直加大,为什么生成必须靠采样。

📝 本节和前面几节的关系

上一节是「拆」,这一节是「造」。 拆的时候我们相信文法;造的时候连文法都不要了,只数频率。 它和 6.4 的隐马尔可夫模型是一路的:都是「给定前面的,预测后面的」, 区别在马尔可夫链只记前一个状态,n 元模型记前 n−1 个字。 这一节也是全书离今天最近的一节。 现在那些几十亿参数的大模型,做的还是这件事,只是把「数频率」换成了一层层网络。

一、二十句话也能训出一个模型

语料是手工写的二十个短句,十六句训练、四句留作测试, 一共 51 个不同的字(含句首标记和句号)。

训练集(十六句) 测试集(四句)
今天天气很好。 他去上班。
明天天气也很好。 她去上班。
我喜欢吃苹果。 今天我去学校。
小猫喜欢吃鱼。 明天他去上班。
这个语料小得可怜,只够演示原理。
真实的语言模型,语料是几十亿到几万亿个词。
但小语料有个好处:每一处失败都能追到具体是哪句没覆盖到。
大语料里你只能看到困惑度差一点,看不到原因。

二、一元模型:只会数频率

最省事的办法是完全不看前文,只按每个字出现的次数抽。这就是一元模型。

字 次数 字 次数
。 16 气 5
天 8 好 5
欢 6 很 4
喜 6 吃 4
Demo 让一元模型写了四句,一句都不通:「香。天天就甜果茶欢白就今玩天茶好」、
「欢色很色。我出太吃天小欢。狗。喜」。
字全是这二十句话里的字,一个生字都没有。
可句子不通,因为抽每个字的时候,它根本不知道前面写了什么。
「。 」出现在第一位不是偶然——它是最高频的字(16 次),随手一抽就抽到。
这也是 n 元模型要解决的第一件事。

三、n 元模型:记住见过的搭配,也困在里面

二元模型至少看一眼前一个字。给定「喜欢」,它能给的答案其实很少:

前文 下一个字 训练集里的次数 平滑后概率
喜欢 吃 4 0.4678
喜欢 喝 2 0.2339
天气 不 2 0.2649
天气 很 1 0.1325
天气 好 1 0.1325
天气 也 1 0.1325
「喜欢」后面,训练集里只出现过「吃」和「喝」。
所以模型只能在这两个里挑,跑不出第三种可能。
这就是 n 元模型的全部本事:记住见过的搭配,没见过的一个都编不出来。
它不会类比,不会推理,也不会犯「有创造性的错」。
注意「天气」那一行:四个候选的概率加起来正好是 1,
而「不」拿了四分之一——因为「天气不好」在训练集里出现两次。
语料里多写一句,概率就往那边偏一点。

四、困惑度:n 越大不一定越好

要比较模型好坏,得有个数。困惑度可以读成「每个字上还有几个候选在犹豫」,越小越好。

模型 训练集 测试集不平滑 测试集强平滑 测试集加一平滑
1 元 32.86 无穷大 158.32 54.01
2 元 2.49 无穷大 32.64 41.06
3 元 2.36 无穷大 24.23 41.46
4 元 2.24 无穷大 28.07 42.64
训练集那一列随 n 一路下降,这是应该的——记得越多,训练集上当然越准。
可测试集不跟着降。
强平滑那一列最好是 3 元(24.23),加一平滑那一列最好是 2 元(41.06)。
再往上反而变差。
为什么不平滑那一列全是无穷大?因为只要有一个位置的概率是 0,整句概率就是 0,
取对数就是负无穷,困惑度算不出来。
模型 没见过的位置 占全部位置的比例
1 元 6 个 25.00%
2 元 14 个 58.33%
3 元 15 个 62.50%
4 元 16 个 66.67%
n 越大,没见过的组合越多——四元模型有三分之二的位置是新的。
平滑只能拿越来越多的假概率去填,填得越多,越偏离真实分布。
这就是「维度灾难」在语言上的样子。

五、贪心和采样:生成的两条路

模型给出概率之后,怎么选下一个字?有两条路。 第一条是贪心:每一步都挑概率最高的那一个。Demo 让它写四条,四条一模一样: 「我喜欢吃苹果很甜。」 这句话本身是通的——它是训练集里两句的拼接(「我喜欢吃苹果」加「苹果很甜」)。 可它每次都走同一条路,因为最高概率那条路只有一条。 第二条是按概率抽。同一个模型,四条全不一样,而且大多不通: 「明不很狗里色苹好香狗我就热香奶家」。 贪心稳定但呆板,采样有变化但容易跑偏。 今天那些大模型都有一个「温度」参数,调的就是这两者之间的位置: 温度低接近贪心,温度高更随机。 这五节连起来是一句话: 语言模型只会算「下一个字是什么的概率」,别的都不会。 可就是这么一件事,做到了极大规模之后,看起来就像会说话了。

六、动手 Demo:让它一个字一个字往下猜

这个 Demo 从零搭一个 n 元语言模型:建词表、数条件频次、加平滑、算困惑度、生成。 全部用字典和循环,没有用任何现成库。 这也是全课最后一段「纯手工」的代码。

def build(seqs, n):                # n 元表:键是(前 n-1 个字, 当前字)
    C = {}
    for s in seqs:
        t = BOS * (n - 1) + s      # 句首补齐,这样第一句也有前文
        for i in range(len(t)):
            ctx = t[max(0, i - n + 1):i]
            if len(ctx) < n - 1:
                continue
            C[(ctx, t[i])] = C.get((ctx, t[i]), 0) + 1
    return C

def perplex(C, seqs, n, alpha):    # 加 alpha 平滑的困惑度
    for s in seqs:
        t = BOS * (n - 1) + s
        for i in range(len(t)):
            ctx = t[max(0, i - n + 1):i]
            den = ctx_total(C, ctx)
            c = C.get((ctx, t[i]), 0)
            if c == 0 and alpha == 0.0:
                return float(u"inf"), 0      # 概率 0,整句就废了
            p = (c + alpha) / (den + alpha * NV)
            tot += math.log(p)
    return math.exp(-tot / cnt)

实跑输出:

--------------------------------------------------------------------
让它一个字一个字往下猜
--------------------------------------------------------------------
==============================================================

[1] 语料只有二十句话

  前十六句训练,后四句留作测试:

    训练  今天天气很好。
    训练  明天天气也很好。
    训练  昨天天气不太好。
    训练  我喜欢吃苹果。
    训练  他喜欢吃香蕉。
    训练  我喜欢喝牛奶。
    训练  她喜欢喝茶。
    训练  小猫喜欢吃鱼。
    训练  小狗喜欢吃骨头。
    训练  天气好就出去玩。
    训练  天气不好就待在家里。
    训练  苹果很甜。
    训练  香蕉也很甜。
    训练  牛奶是白色的。
    训练  茶是热的。
    训练  我去学校。
    测试  他去上班。
    测试  她去上班。
    测试  今天我去学校。
    测试  明天他去上班。

  一共 51 个不同的字(含句首标记 ^ 和句号)。

  **这个语料小得可怜,只够演示原理。**
  **真实的语言模型,语料是几十亿到几万亿个词。**

[2] 一元模型:只看每个字出现多少次

  「下一个字是谁」完全不看前文,只按频率:

    。(16)  天(8)  欢(6)  喜(6)  气(5)  好(5)  很(4)  吃(4)  我(3)  香(2)  蕉(2)  茶(2)

  生成四条(从句首开始,按概率抽,最多 16 个字):

    香。天天就甜果茶欢白就今玩天茶好
    欢色很色。我出太吃天小欢。狗。喜
    。。就气果茶好天。蕉吃很我喝我香
    气。头苹气我喜很茶气不欢我苹茶的

  **字都是这些话里的字,可句子不通。**
  **因为抽每个字的时候,它根本不知道前面写了什么。**

[3] 二元、三元模型:至少看一眼前文

  给定前两个字,模型能给出的下一个字其实很少:

    前文    下一个字    训练集里的次数  平滑后概率
    喜欢    吃          4               0.4678
    喜欢    喝          2               0.2339
    天气    不          2               0.2649
    天气    很          1               0.1325
    天气    好          1               0.1325
    天气    也          1               0.1325

  **「喜欢」后面,训练集里只出现过「吃」和「喝」。**
  **所以模型只能在这两个里挑,跑不出第三种可能。**
  **这就是 n 元模型的全部本事:记住见过的搭配,没见过的一个都编不出来。**

  同样两个模型,各生成三条:

    2 元模型:
      出欢喝喜欢吃狗家狗学校。鱼就待热
      天气不天天去。。校奶喜欢喝牛我去
      昨学喜欢吃苹蕉。的在$

    3 元模型:
      出不是明茶。去白喝校是苹鱼在是学
      天很骨今明学气。校头他茶出喜。里
      昨学昨也果太出好的白$

[4] 用困惑度打分:n 越大越好吗

  困惑度可以读成「每个字上还有几个候选在犹豫」,越小越好。

    模型    训练集     测试集不平滑   测试集强平滑   测试集加一平滑
    1 元    32.86      无穷大         158.32         54.01
    2 元    2.49       无穷大         32.64          41.06
    3 元    2.36       无穷大         24.23          41.46
    4 元    2.24       无穷大         28.07          42.64

  **训练集那一列随 n 一路下降,这是应该的——记得越多,训练集上越准。**
  **可测试集不跟着降:强平滑列最好是 3 元(24.23),加一平滑列最好是 2 元(41.06)。**
  **再往上反而变差——n 越大,没见过的组合越多,平滑只能拿越来越多的假概率去填。**

  不平滑那一列全是无穷大,原因很具体:

    模型    没见过的位置  占全部位置的比例
    1 元    6 个          25.00%
    2 元    14 个         58.33%
    3 元    15 个         62.50%
    4 元    16 个         66.67%

  **只要有一个位置的概率是 0,整句的概率就是 0,困惑度算不出来。**
  **加一平滑就是给每个没见过的组合留一点点概率。**
  **代价是从见过的组合那里匀走一部分,所以强平滑那一列的数明显更差。**
  **用一点偏差换不炸,这就是全部技巧的核心。**

[5] 贪心每次一样,采样才有变化

  每一步都挑概率最高的那个字(贪心),四条:

    我喜欢吃苹果很甜。
    我喜欢吃苹果很甜。
    我喜欢吃苹果很甜。
    我喜欢吃苹果很甜。

  同一个模型,改成按概率抽,四条:

    明不很狗里色苹好香狗我就热香奶家
    小。出奶出今天天校吃狗班玩不喝好
    天太他苹去上上出是牛吃猫骨热蕉猫
    我牛热我狗果在猫不茶我天好果$

  **四条一模一样——最高概率那条路只有一条,它每次都走同一条。**
  **采样每条路都走一点,看起来才有变化。**
  **今天那些大模型都有一个「温度」参数,做的就是这件事:温度调低就接近贪心,调高就更随机。**

  五段连起来:**一元模型只会数频率,句子不通**;**看上前一个字就通顺一些**;
  **可 n 一大就撞上没见过的组合**;
  **平滑拿一点偏差换不炸**;**而生成终究得靠采样。**

第 2 段那四句要读出声。字全是熟悉的字, 串起来却不是话——这就是「只数频率、不看上下文」到底缺了什么。 第 3 段那张表是 n 元模型的全部家底:六行就是「喜欢」和「天气」两个前缀的所有可能。 注意前三行加起来不到 1,剩下的概率被平滑分给了没出现过的字。 第 4 段那张困惑度表要横着读: 训练集越读越顺(32.86 到 2.24),测试集却在 3 元那里触底反弹。 这中间的差就是过拟合,量得清清楚楚。 第 5 段那八句是这一节的落点: 同样一个模型,贪心给出的是通顺但重复的句子,采样给出的是有变化但荒唐的句子。 「怎么选」和「模型多好」是两件独立的事。

30 秒实验

  1. 把第 1 段训练集加三句「我喜欢吃香蕉」,重跑第 5 段的贪心。 生成结果不变——加句子只改概率,不改第一名是谁。
  2. 把第 4 段的强平滑系数从 0.1 改成 1,重跑。 测试困惑度会整体抬高——平滑越强,模型越保守,越不敢给见过的组合高概率。
  3. 把第 5 段的采样温度调成 0.2(概率取 1/T 次方再归一),重跑。 四条会开始趋同——温度趋 0 就是贪心,这条曲线能自己走出来。

常见误区

  1. 以为「生成就是模型在思考」。它只是在算下一个字的概率分布, 然后抽一个。所谓「灵感」,就是抽样里的随机性。
  2. 以为「n 越大模型越好」。Demo 第 4 段:四元模型的测试困惑度比三元还差。 参数变多不等于泛化变好,数据不够时正好相反。
  3. 以为「困惑度低就一定写得好」。贪心生成的困惑度很低(每步都挑最高概率), 可它写出来的句子重复、呆板。困惑度量的是「像不像语料」,不是「好不好」。
  4. 以为「平滑是可有可无的补丁」。不加平滑,测试集困惑度直接是无穷大。 它在数学上是必需的,不是工程上的讲究。
自测三问(点开对答案) 1. 为什么一元模型写出来的句子不通? —— 因为**它抽每个字的时候完全不知道前面写了什么**。 **「天气」后面本该接「好」「不」这类字,一元模型只按全局频率抽,** **所以「。」这种最高频的字会随时冒出来。** 2. 困惑度到底在量什么? —— 量的是**「模型平均在每个位置上有多少个候选在犹豫」**。 **困惑度 32.86,相当于每次都在三十多个字里挑;降到 2.49,相当于只剩两个半候选。** **它等价于「每个字的平均负对数似然的指数」,所以越小越好。** 3. 为什么不能只靠贪心生成? —— 因为**最高概率那条路只有一条,贪心每次都走同一条**——**Demo 第 5 段四条输出一字不差。** **要多样性就必须引入随机,而随机一多又容易跑偏,温度参数调的就是这个平衡。**

第 11 章到这里结束。 下一章换个话题:这些分析出来的东西,最后存在哪里、怎么找回来。 数据库、检索、语义网,三节把「知识」落到具体的存储结构上。

相关笔记