卷 I · 一张表CH 03深度 3/20

只靠数数,能续写多远

在神经网络之前,先用最笨的办法造一台续写机:数。数一数每一段上下文后面,各个字符出现过几次,出现得多的就给高概率。手机输入法用这个办法用了很多年。这一章拿它去续写莎士比亚,你会看到一条先降后升的曲线——多看几个字会更准,再多看几个字反而崩了。崩掉的原因,正是神经网络存在的理由。

最好 2.55 比特/字符63.6% 没见过组合爆炸
◉ 这一章在 GPT-2 的哪一段

▷ 先猜一下

拿 Karpathy 整理的「TinyShakespeare」——莎士比亚剧本,共 1115394 个字符——前 90% 当训练集(约一百万个字符),后 10% 当测试集。

现在只看前面 9 个字符来猜下一个字符。问:测试集里出现的那些「9 个字符的上下文」,有多大比例在训练集里出现过至少一次?

A 99% 左右。一百万个字符,什么组合没见过 B 80% 左右。大部分见过,少数生僻的没见过 C 50% 左右。一半一半 D 36% 左右。大部分都没见过

答案在本章末尾。

最笨的续写机

规则只有一句:看前面 n−1 个字符,查一查训练集里这 n−1 个字符后面跟过什么,按次数给概率。这叫 n-gram 模型(「gram」就是「一小段」)。n=2 就是只看前 1 个字符,n=5 就是看前 4 个。

比如上下文是 y lo(也就是「my lo……」的后四个字符)。在一百万个字符的莎士比亚里,这四个字符一共出现了 468 次,后面跟的字符是这么分布的:

5-gram 计数模型:训练集里「y lo」后面跟过的字符(共 468 次) y ␣ l o 下一个 token 概率 意外 'r' 77.25% 0.37 比特 'v' 12.59% 2.99 比特 'o' 4.91% 4.35 比特 'n' 1.50% 6.06 比特 's' 1.50% 6.06 比特 'y' 0.86% 6.87 比特 不是算出来的,是数出来的

77.2% 是 r(my lord),12.6% 是 v(my love),4.9% 是 o(my look)……这就是一张候选表,和第 1 章 GPT-2 吐出来的那张是同一种东西,只是候选从 50257 个 token 换成了 65 种字符。只不过这张表不是「算」出来的,是「数」出来的。

有一个小麻烦:训练集里没出现过的组合,次数是 0,概率就是 0——可测试集偏偏会出现它,那一下的意外就是无穷大。所以每个格子都先垫上一点点:这里给每个组合预先加 0.01 次(叫加法平滑)。一段上下文如果在训练集里一次都没出现过,那它后面 65 种字符就全是 0.01 次,表退化成完全平均——等于瞎猜。

怎么给一台续写机打分

拿测试集来考:逐个字符,看模型给「真实的下一个字符」打了多少概率,算它的意外 −log₂ p,然后取平均。单位是比特/字符,越低越好。(为什么用这个数而不是「猜对率」,下一章专门讲。)

先定两个参照:65 种字符完全瞎猜,每个字符的意外是 log₂ 65 ≈ 6.02 比特;而第 1 章那台 GPT-2,在同一份测试集上是 1.81 比特/字符。(GPT-2 的训练语料里很可能有莎士比亚,这个数对它有些偏袒;但它看的是前面上千个 token,这个差距不全是偏袒。)

多看几个字,更准了

n看前几个字符训练集测试集上下文没见过
1不看,只按字符频率4.774.830.0%
21 个3.533.590.0%
32 个2.752.980.2%
43 个2.152.591.4%
54 个1.782.554.2%

前五行是一个完全符合直觉的故事:看得越多,猜得越准。从不看上下文的 4.83 降到看 4 个字符的 2.55 比特/字符。如果这个趋势能一直延续下去,我们就不需要神经网络了——只要数据够多、看得够远。

再多看几个字,崩了

字符级 n-gram:训练集(灰)越来越好,测试集(红)先降后升 0 1 2 3 4 5 6 1 2 3 4 5 6 7 8 9 10 n(看前 n−1 个字符) 比特/字符 瞎猜 6.02 GPT-2 1.81
n看前几个字符训练集测试集上下文没见过
65 个1.522.8910.1%
76 个1.293.4120.8%
87 个1.113.9934.8%
98 个0.964.5549.7%
109 个0.865.0063.6%

两条曲线分道扬镳:训练集上越来越准(0.86 比特,几乎在背书),测试集上越来越差——看前 9 个字符时是 5.00 比特,比只看 1 个字符的 3.59 还差。

原因就在最后一列。看前 9 个字符时,测试集里 63.6% 的上下文在训练集里一次都没出现过——遇到它们,模型只能瞎猜(6.02 比特)。而剩下那 36.4% 见过的上下文,大多也只见过一两次,「后面跟什么」的统计全是巧合。

算一笔账就知道这不是数据不够多的问题:65 种字符,9 个一组,可能的组合有 65⁹ ≈ 2.07 × 10¹⁶ 种。训练集只有约一百万个字符,就算把全世界写过的英文都拿来,也填不满这张表的一个零头。上下文的组合数随长度指数增长,数据只能线性增长。这叫组合爆炸,n-gram 模型撞上的就是这堵墙。

让 5-gram(它在测试集上最好)自己往下写一段,能亲眼看见它撞墙的那一刻:

ROMEO:
I am and be sound musty Caius Marcius to dearers,
But if I have will let us:
But I have both fancy:⟦;x3;CCSZE⟧
⟦OLXwfUbe$tlrmMM,c&&A:J$⟧
⟦3,K ra3DJK-qzRS',JEo⟧: what mischarge
Their me for a nursi⟦iKBB?AbtpSdm'duljS;mInyMNwi;.3tn3oydJW.⟧
⟦ydVvProADGCZ⟧

前几行像模像样——每四个字符都是莎士比亚用过的组合。直到它碰巧写出了 cy:;:这四个字符在训练集里从没出现过,表一下变成完全平均,于是它开始吐乱码;乱码又制造出更多没见过的上下文,越陷越深,直到偶然撞回一个见过的组合(o: w),又短暂地恢复正常。它没有「这串乱码看起来像什么」的概念——没见过就是没见过,相似也没用。

真正缺的是「举一反三」

问题的核心不是数据少,而是每一段上下文都是孤立的一格。在计数模型里,my lord 和 my lady 毫无关系;thou art 见过一万次,对 thou wert 一点帮助都没有。它没法说「这段上下文我没见过,但它很像那段我见过的,所以后面大概也差不多」。

2003 年,Yoshua Bengio 等人在一篇叫《A Neural Probabilistic Language Model》的论文里正面回答了这个问题,开头就点名了这堵墙——他们管它叫维度灾难。他们的办法是:

  1. 给每个词一个向量(一串数),让意思相近的词向量也相近;
  2. 把上下文的那几个向量交给一个神经网络去算下一个词的概率。

这样一来,没见过的上下文只要和见过的那些「长得像」——向量离得近——就能分到相近的预测。泛化不再靠「见过」,而靠「像」。这两步正是下一卷的前两章:第 5 章讲向量,第 6 章讲网络怎么被训出来。GPT-2 的 1.81 比特,就是这条路走了十六年的结果。

⌨ 自己跑一遍

纯 Python,不用装任何库,二十行。会从 GitHub 下载 1 MB 的 TinyShakespeare:

import math, collections, urllib.request

url = "https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt"
text = urllib.request.urlopen(url).read().decode()
cut = int(len(text) * 0.9)
train, test = text[:cut], text[cut:]          # 前 90% 拿来数,后 10% 拿来考
V, a = len(set(text)), 0.01                   # 65 种字符;a 是「没见过也给一点点概率」

for n in [1, 2, 3, 5, 7, 10]:                 # n-gram:看前 n-1 个字符猜下一个
    k = n - 1
    grams = collections.Counter(train[i - k:i + 1] for i in range(k, len(train)))
    ctxs = collections.Counter(train[i - k:i] for i in range(k, len(train)))
    bits = unseen = 0
    for i in range(k, len(test)):
        c = test[i - k:i]
        unseen += ctxs[c] == 0
        bits -= math.log2((grams[test[i - k:i + 1]] + a) / (ctxs[c] + a * V))
    m = len(test) - k
    print(f"n={n:2d}  测试集 {bits / m:.3f} 比特/字符   上下文没见过 {unseen / m:.1%}")

本机输出:

n= 1  测试集 4.829 比特/字符   上下文没见过 0.0%
n= 2  测试集 3.589 比特/字符   上下文没见过 0.0%
n= 3  测试集 2.980 比特/字符   上下文没见过 0.2%
n= 5  测试集 2.554 比特/字符   上下文没见过 4.2%
n= 7  测试集 3.413 比特/字符   上下文没见过 20.8%
n=10  测试集 4.997 比特/字符   上下文没见过 63.6%

改一改 a(平滑量)试试:调大一点,长上下文那几行会好看一些,但谷底不会消失。更聪明的平滑方法(比如 Kneser–Ney)能把谷底往后推一两格,推不倒那堵墙。

python3 03.py

在线跑:Google Colab 或者任何能跑 Python 的地方,这段代码零依赖。

▸ 在现实里
  • 手机输入法在神经网络普及之前,用的就是词级的 n-gram:根据前一两个词推荐下一个词。它只看那么短,正是因为看长了就会撞上这一章这堵墙。
  • 2006 年,Google 公开了一份从约一万亿个英文词里数出来的 5-gram 统计,这大概是「靠数数」这条路能走到的最远处。即便如此,它也只敢数到 5。
  • 你在《打分》第 14 章见过同一堵墙:强化学习里的 Q 表——给每个「局面」开一格——在局面一多的时候装不下了,解法也一模一样:别给每个局面开一格,用一个函数去「猜」整张表,让相似的局面共享估计。
  • 垃圾邮件过滤、搜索框联想里还有大量计数模型在跑:它们便宜、可解释、在短上下文里足够好。只是它们和 GPT 之间,隔着这一章的那条曲线。
✗ 这个直觉是错的

「大模型不过是一张更大的统计表:数据够多,查表就够了。」

这一章就是这个说法的反例。表的大小随上下文长度指数增长——9 个字符就有 2.07 × 10¹⁶ 格;GPT-2 看的是 1024 个 token,每个 token 有 50257 种可能,那张表的格数是一个 4800 多位的数字。任何数据都填不满它。

一台模型只要在没见过的上下文上还能猜得准——GPT-2 在测试集上做到了 1.81 比特,而这一章最好的表是 2.55——它就不可能只是在查表。它必须把「像」的东西归到一起,这就是泛化。(不过这个直觉也有一点是对的:对于训练集里重复了千百遍的文字,模型确实会逐字背下来——那是第 7 章的事。)

◇ 揭晓

正确答案是 D:只有 36.4% 见过,63.6% 一次都没出现过。
看前 4 个字符时,没见过的只有 4.2%;每多看一个字符,这个比例就往上跳一截。

A 「一百万个字符,什么组合没见过」——这个直觉把「一百万」当成了一个很大的数。可是 9 个字符的组合有 2.07 × 10¹⁶ 种,比一百万大一百亿倍。真实文本当然不会均匀地用到每种组合,但即便只算「像英文的」那些组合,也远远多于一百万。 B 「大部分见过,少数生僻的没见过」——这是 4 到 5 个字符时的真实情况。错在以为这个比例会随长度慢慢变,其实它在 n=6 到 n=10 之间从 10.1% 一路冲到 63.6%。 C 「一半一半」——很接近,那是看 8 个字符时的情况(49.7%)。再多看一个字符,就跨过了一半。

这一章的一句话

靠数数的续写机,多看几个字会更准,再多看就崩:上下文的组合数随长度指数爆炸,任何数据都填不满那张表。出路不是更多的数据,而是让「相似的上下文给出相似的预测」——这就是神经网络被请进来的原因。

下一章先停下来,把这一章随手用的那把尺子——「比特/字符」——讲透。它不只是一个打分方式:GPT-2 读一篇它从没见过的 2022 年维基百科文章,平均每个字符只花 1.01 比特。把这个数乘回去,你会得到一个比 zlib 小三倍多的压缩文件——这不是比喻,是同一件事。