卷 II · 长出来CH 06深度 6/20

十三万个旋钮,一次算出往哪拧

上一章说训练会把相似的 token「推」到一起。这一章看那只推手:一台 13 万个参数的小网络,从一堆随机数开始,在莎士比亚上练 40000 步,最后打败了第 3 章最好的计数模型。整个过程里最关键的一步,是算出 13 万个参数各自「该往哪边拧、拧多少」——而这一步便宜得不合常理。这个「不合常理」,就是深度学习能存在的原因。

133721 个参数2.3 次前向的钱反向传播
◉ 这一章在 GPT-2 的哪一段

▷ 先猜一下

一台有 133721 个参数的网络。训练的每一步,都要知道:每一个参数如果稍微调大一点,损失会变大还是变小、变多少。

问:算出全部 133721 个「该往哪拧」,大约要花几次「前向计算」(把一批数据从头算到尾一次)的时间?

A 133721 次左右。一个参数试一次 B 267442 次左右。每个参数往大往小各试一次 C 几百次。抽一部分参数试,估计其余的 D 两三次。不管有多少个参数

答案在本章末尾。

先造一台能训的小网络

用 2003 年 Bengio 那篇论文的结构,在第 3 章的莎士比亚上做字符级续写:

  1. 看前 8 个字符。每个字符查一张 65 × 24 的嵌入表(上一章的那种表),得到 24 个数;8 个字符拼起来是 192 个数。
  2. 这 192 个数乘上一个 192 × 512 的矩阵、加一串偏置,再过一个 tanh(一个把数压到 −1 和 1 之间的弯曲函数),得到 512 个数。这一步叫隐藏层。
  3. 512 个数再乘一个 512 × 65 的矩阵,得到 65 个分数,过 softmax(把任意分数变成加起来为 1 的概率),就是一张候选表。

所有矩阵和表里的数加起来:1560 + 98304 + 512 + 33280 + 65 = 133721 个参数。一开始它们全是随机数,这台网络第一步在测试集上的成绩是 5.71 比特/字符——和 65 种字符瞎猜(6.02)差不多。

损失是一片山地,参数是你站的位置

把 133721 个参数想成 133721 个旋钮。每一种旋钮组合,都对应一个损失值(第 4 章那个「平均意外」)。于是「训练」就变成了一个地形问题:你站在一片 133721 维的山地上,要往低处走。

往哪走?看脚下的坡。梯度就是这片山地在你脚下的坡度——对每个旋钮,它告诉你「把它往大拧一点点,损失会变化多少」。梯度是正的就往小拧,是负的就往大拧,坡越陡拧得越多:

新的参数 = 旧的参数 − 学习率 × 梯度

每一步只用随机抽的 256 个例子来算这个坡(所以叫随机梯度下降,SGD),走一小步,再抽一批,再算,再走。实际用的是它的一个改良版 Adam:会记住最近几步的方向(像带了惯性),并且给每个旋钮单独调步长。核心仍是那一行。

难的是:怎么算出 13 万个方向

最笨的办法是一个个去试:把第 1 个旋钮调大一点、跑一遍,调小一点、再跑一遍,看损失变了多少;然后第 2 个、第 3 个……一共要跑 267442 次。每一步训练都要这么做,40000 步——没有人等得起。

聪明的办法叫反向传播(backpropagation)。它利用一件事:这台网络是一串简单运算的复合(查表 → 乘矩阵 → tanh → 乘矩阵 → softmax → 取对数),而复合函数的导数可以用链式法则一环一环地乘出来。关键在于方向:从输出往回算。最后一步「损失对 65 个分数」的导数最简单(就是「概率 − 正确答案」);拿它乘上最后那个矩阵,得到「损失对 512 个隐藏值」的导数;再乘 tanh 的斜率、再乘前一个矩阵……一路倒推回嵌入表。每往回走一层,就顺手把这一层所有参数的梯度算出来了。

整个反向过程的计算量,和前向差不多是同一个量级。本机实测:

做法花费(以「一次前向」为单位)
反向传播:一次前向 + 一次反向,得到全部 133721 个梯度2.31
有限差分:每个参数往两边各试一次267442
两者之比115745 倍

两种办法算出来的是不是同一个东西?随机挑 25 个参数,拿「一个个试」的结果和反向传播比——最大的相对误差是 1.7 × 10⁻⁷,也就是小数点后第七位才开始不同(那一点差别来自「试」的那一下本身就是近似)。它们算的是同一个梯度,一个花了 2.31 份钱,一个花了 267442 份。

这件事在 GPT-2 本身上也成立:1.24 亿个参数,「前向 + 反向」只比单独前向贵 2.7 倍左右(本章末尾那段代码就是量这个的)。不管参数有多少个,拿到全部梯度的代价都只是常数倍的前向。这个事实——数学上叫「反向模式自动微分的廉价梯度原理」——是整个深度学习的地基:没有它,训练一个 1.24 亿参数的模型,每一步都要做 2.5 亿次前向。

看它学会

40000 步,每步 256 个例子,本机用 numpy 跑了 81 秒。测试集上的成绩:

13 万参数的小网络:测试集比特/字符随训练步数 1.5 2 3 4 5 6 1 10 100 1000 1 万 4 万 训练步数(对数) 比特/字符 第 3 章最好的 5-gram 2.55 GPT-2 1.81
训练步数测试集(比特/字符)
15.71
104.59
1003.58
10002.95
100002.52
200002.44
400002.40

最终 2.40 比特/字符,赢了第 3 章最好的 5-gram(2.55)。注意它看的是前 8 个字符——计数模型看 8 个字符时已经崩到了 4.55,因为一半的上下文没见过。这台网络没有崩,因为它不需要「见过」:它把 8 个字符各自变成坐标,相似的上下文落在相近的地方,自然得到相近的预测。

同一个上下文,看看它和第 3 章那张计数表的区别:

第 6 章那台 13 万参数的小网络:看前 8 个字符「e, my lo」 e , ␣ m y ␣ l o 下一个 token 概率 意外 'r' 91.57% 0.13 比特 'v' 4.27% 4.55 比特 'o' 1.52% 6.04 比特 's' 0.75% 7.06 比特 'n' 0.64% 7.28 比特 'y' 0.30% 8.36 比特 对照第 3 章的计数模型:r 77.2%

计数模型只看最后 4 个字符 y lo,给 r 77.2%;这台网络看了 8 个字符 e, my lo,给 r 91.6%——「逗号 + my lo」在莎士比亚里几乎一定是「my lord」(呼语)。它从来没被告知「逗号后面的 my lo 是在称呼人」,这个规律是被梯度一点点「拧」进那 13 万个旋钮里的。

不过还有一个数值得留意:同样这台网络,在训练集上是 1.93 比特,测试集 2.40。差出来的那一截,是它记住的、但不能推广的东西。这是下一章的话题。

这一招不只在 AI 里

「从输出倒推回去,一次算出所有输入的敏感度」在 AI 之外有一个更老的名字:伴随方法(adjoint method)。

  • 天气预报:欧洲中期天气预报中心(ECMWF)的「四维变分同化」要找出:几千万个格点的初始温度、气压、风速,各自微调多少,能让模型的预报最贴合过去几小时的观测?它的做法就是写一个「伴随模型」,把预报误差从未来倒推回初始时刻——和反向传播是同一件事。
  • 银行:一个衍生品组合的价格,对几千个市场变量(利率曲线上的每一个点、每一个汇率)的敏感度,叫「希腊字母」。2008 年之后监管要求天天算,一个个去扰动算不过来,于是大银行普遍改用 AAD(伴随算法微分)——一次定价加一次倒推,拿到全部敏感度。
  • 工程设计:飞机机翼的形状由成千上万个参数描述,空气动力学软件用伴随方法一次算出「每个参数往哪调能减阻」。

三个领域,同一个结构:一个输出(误差、价格、阻力),极多的输入,要知道每个输入的影响——那就从输出倒着走一遍。

⌨ 自己跑一遍

那台 13 万参数的小网络(连手写的反向传播和梯度检验)在本书仓库的 scripts/probe-next/probe.py 里,搜 mlp_run。这里给一个更短的版本:直接在 GPT-2 上量「前向」和「前向 + 反向」各要多久——PyTorch 的 loss.backward() 就是反向传播。

import time, torch
from transformers import AutoModelForCausalLM

model = AutoModelForCausalLM.from_pretrained("gpt2")
x = torch.randint(0, 50257, (1, 512))
n = sum(p.numel() for p in model.parameters())

def forward():
    with torch.no_grad():
        model(x, labels=x)

def forward_backward():
    model.zero_grad()
    model(x, labels=x).loss.backward()           # 反向传播:一次拿到全部参数的梯度

def best(f, r=5):
    f()                                          # 预热
    ts = []
    for _ in range(r):
        t = time.perf_counter(); f(); ts.append(time.perf_counter() - t)
    return min(ts)

tf, tb = best(forward), best(forward_backward)
print(f"参数 {n:,} 个")
print(f"一次前向           {tf * 1000:.0f} ms")
print(f"前向 + 反向传播     {tb * 1000:.0f} ms(= {tb / tf:.1f} 次前向)")
print(f"有限差分逐个去试   {2 * n:,} 次前向 ≈ {2 * n * tf / 86400:.0f} 天")

本机输出(计时类数字换台机器就变,比值大致稳定):

参数 124,439,808 个
一次前向           89 ms
前向 + 反向传播     252 ms(= 2.8 次前向)
有限差分逐个去试   248,879,616 次前向 ≈ 255 天

pip install torch transformers && python 06.py

在线跑:Google Colab,选 GPU 运行时数字会小很多,但比值仍在 2 到 3 之间。想从零手写一遍反向传播:Andrej Karpathy 的 micrograd 只有一百来行,是最好的起点。

▸ 在现实里
  • 训练大模型的总算力有一个常用的估算:每个 token 约 6 × 参数个数 次浮点运算。前向约 2 次乘加(每个参数一乘一加),反向约 4 次——正好对应这一章量到的「前向 + 反向 ≈ 3 次前向」。
  • 学习率是这一切里最敏感的那个旋钮。太小,走得慢;太大,会冲过谷底、在两侧来回振荡甚至发散——这和《过冲》里那个增益太大的控制器是同一回事。大模型训练里常见的「loss 突然飙升」,很多时候就是步子迈大了。
  • 「梯度检验」是写训练代码时最朴素也最可靠的单元测试:拿有限差分和你写的反向传播比一比,对得上到小数点后五六位,才说明链式法则没写错。
✗ 这个直觉是错的

「训练就是不断随机地改参数,效果好的留下、效果差的丢掉——和进化差不多。」

梯度下降不是随机试错。每一步它都精确地知道 13 万(或者 1.24 亿)个参数各自该往哪边动、动多少,然后所有参数同时朝那个方向走一步。在 13 万维的空间里随机乱试,几乎每一步都是白走——维数越高,随便一个方向是「下坡」的机会越渺茫。

第 1 章拿进化做过类比,类比的是结果:一个简单的目标被施加巨大的优化压力,会长出复杂的、没人设计的内部结构。但过程完全不同:进化手里没有梯度,只能靠变异和淘汰慢慢摸索;梯度下降每一步都拿着一张精确的坡度图。这也是为什么它能在几周里,把几十亿个参数调到位。

◇ 揭晓

正确答案是 D:反向传播一次算出全部 133721 个梯度,前向 + 反向只花 2.31 次前向的时间;在 1.24 亿参数的 GPT-2 上,这个比值约 2.7。
和「一个个试」的结果相比,最大相对误差 1.7 × 10⁻⁷。

A 「一个参数试一次」——这是有限差分的思路,而且只试一边的话精度还不够。它的代价随参数个数线性增长,在 GPT-2 上每一步训练要做上亿次前向。 B 「往大往小各试一次」——正是验证时用来对答案的那种办法(中心差分),所以它确实能算出正确的梯度,只是贵了十一万多倍。 C 「抽一部分估计其余」——历史上确实有人这么试过(比如各种「随机方向估计梯度」的办法),在参数少的问题里能用;在几亿个参数上,估计的噪声会大到让训练寸步难行。

这一章的一句话

训练就是沿着损失的坡往下走;难的是算出每个参数的坡度,而反向传播让这件事的代价只是常数倍的前向——不管有 13 万个还是 1.24 亿个参数。没有这份「几乎免费的梯度」,就没有深度学习。

下一章看训练集和测试集之间那条缝:同一台 GPT-2,压一篇新文章要 0.96 比特/字符,压 MIT 许可证只要 0.29——它把那段几百万个代码仓库里都有的文字逐字背了下来,只给第一行,它能往下默写 29 个词才第一次出错。