十三万个旋钮,一次算出往哪拧
上一章说训练会把相似的 token「推」到一起。这一章看那只推手:一台 13 万个参数的小网络,从一堆随机数开始,在莎士比亚上练 40000 步,最后打败了第 3 章最好的计数模型。整个过程里最关键的一步,是算出 13 万个参数各自「该往哪边拧、拧多少」——而这一步便宜得不合常理。这个「不合常理」,就是深度学习能存在的原因。
一台有 133721 个参数的网络。训练的每一步,都要知道:每一个参数如果稍微调大一点,损失会变大还是变小、变多少。
问:算出全部 133721 个「该往哪拧」,大约要花几次「前向计算」(把一批数据从头算到尾一次)的时间?
答案在本章末尾。
先造一台能训的小网络
用 2003 年 Bengio 那篇论文的结构,在第 3 章的莎士比亚上做字符级续写:
- 看前 8 个字符。每个字符查一张 65 × 24 的嵌入表(上一章的那种表),得到 24 个数;8 个字符拼起来是 192 个数。
- 这 192 个数乘上一个 192 × 512 的矩阵、加一串偏置,再过一个 tanh(一个把数压到 −1 和 1 之间的弯曲函数),得到 512 个数。这一步叫隐藏层。
- 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 秒。测试集上的成绩:
| 训练步数 | 测试集(比特/字符) |
|---|---|
| 1 | 5.71 |
| 10 | 4.59 |
| 100 | 3.58 |
| 1000 | 2.95 |
| 10000 | 2.52 |
| 20000 | 2.44 |
| 40000 | 2.40 |
最终 2.40 比特/字符,赢了第 3 章最好的 5-gram(2.55)。注意它看的是前 8 个字符——计数模型看 8 个字符时已经崩到了 4.55,因为一半的上下文没见过。这台网络没有崩,因为它不需要「见过」:它把 8 个字符各自变成坐标,相似的上下文落在相近的地方,自然得到相近的预测。
同一个上下文,看看它和第 3 章那张计数表的区别:
计数模型只看最后 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⁻⁷。
《张成》第 21 章是这一章的线性代数版本:反向传播里每一步「乘上前一层矩阵的转置」是怎么来的。《过冲》讲增益太大的反馈回路为什么会振荡——学习率就是梯度下降这个回路的增益。《抹零》提醒你另一件事:梯度检验那 1.7 × 10⁻⁷ 的误差里,有一部分来自浮点数本身。
这一章的一句话
训练就是沿着损失的坡往下走;难的是算出每个参数的坡度,而反向传播让这件事的代价只是常数倍的前向——不管有 13 万个还是 1.24 亿个参数。没有这份「几乎免费的梯度」,就没有深度学习。
下一章看训练集和测试集之间那条缝:同一台 GPT-2,压一篇新文章要 0.96 比特/字符,压 MIT 许可证只要 0.29——它把那段几百万个代码仓库里都有的文字逐字背了下来,只给第一行,它能往下默写 29 个词才第一次出错。