三个词的词表,十二个参数:把一次真实的梯度下降全程手算一遍

用能装进脑子的最小真实模型(3 词词表、12 个参数的 bigram 语言模型),把生产级训练的一整步走通:前向算损失、反向传播分账(从 p−y 起点到每个参数)、数值梯度检验、SGD 与 AdamW 各更新一步。每个数字都先用脚本验算过,结尾附 40 行可跑的验证脚本和自检清单。

上一篇说”学习 = 在参数空间沿可计算的坡度下山”。这句话听懂容易,踏实很难——坡度到底怎么算出来的?这篇把它兑换成你能亲手按计算器的东西:一个 3 词词表、12 个参数的语言模型,走完生产级训练的完整一步——前向、反向、梯度检验、参数更新。没有一步是玩具逻辑:这条链路和 GPT-2 训练时每步发生的事完全同构,只是把 1.24 亿个参数缩到了 12 个。

为什么这个最小模型是”真实的”

先回应一个合理的怀疑:3 个词的模型,凭什么说是真实生产行为?

因为它就是把 GPT 掐头去尾后剩下的首尾两层

  • 嵌入矩阵 EE(token id → 向量):GPT-2 里真实存在,叫 wte
  • 输出投影 WW(向量 → 词表 logits):GPT-2 里真实存在,叫 lm_head
  • 中间被省略的只有 Transformer 块(注意力 + MLP)。省略它们不改变梯度下降的任何逻辑——链式法则只是多穿几层,每层的算账方式和本文的 WW 一模一样(那些块内部的矩阵运算之前拆过:QKV 投影)。

这个结构叫 bigram 语言模型(只看前一个词猜下一个词),Karpathy 的 makemore 系列就是从它起步的。训练目标、损失函数、反向传播、优化器——全部用生产同款。

主线一句话:生产里一次参数更新 = 前向算损失 → 反向从 pyp-y 起点逐层分账 → 优化器落笔改数。三段各自手算一遍,梯度下降就没有黑盒了。

0. 出场角色:一句语料,十二个参数

  • 词表(3 个词):=0,=1,=2
  • 训练数据(1 条):输入 ,目标 ——即希望模型学会 p()p(\text{鱼} \mid \text{吃}) 变大
  • 参数(12 个,初始值是随手设的小数字,模拟随机初始化):

E=[0.10.20.30.10.20.4](3×2, 每行一个词的向量)W=[0.50.30.10.20.40.5](2×3)E = \begin{bmatrix} 0.1 & 0.2 \\ 0.3 & -0.1 \\ -0.2 & 0.4 \end{bmatrix} \quad (3\times 2,\ \text{每行一个词的向量}) \qquad W = \begin{bmatrix} 0.5 & -0.3 & 0.1 \\ 0.2 & 0.4 & -0.5 \end{bmatrix} \quad (2\times 3)

GPT-2 124M 与此的差别只是规模:词表 3 → 50257,向量维度 2 → 768,参数 12 → 1.24 亿。顺带一个真实生产细节:GPT-2/nanoGPT 里 EEWW 其实共享同一份权重(weight tying),这里为了让两条梯度路径都看清楚,拆开算。

下文所有数字都先用文末脚本验算过(四舍五入到 4 位小数)。

1. 前向传播:五步算出”模型错得多离谱”

步骤公式手算结果
① 查行x=E[1]x = E[1]的向量)[0.3,0.1][0.3, -0.1]
② 打分z=xWz = xW(和每个词的”匹配度”)[0.13,0.13,0.08][0.13, -0.13, 0.08]
③ 变概率pj=ezj/kezkp_j = e^{z_j} / \sum_k e^{z_k}ez=[1.1388,0.8781,1.0833]e^z = [1.1388, 0.8781, 1.0833],和 Z=3.1002Z=3.1002p=[0.3673,0.2832,0.3494]p = [0.3673, 0.2832, 0.3494]
④ 取出正确答案的概率p=p2p_{\text{鱼}} = p_20.34940.3494
⑤ 损失L=lnp2L = -\ln p_21.0515\mathbf{1.0515}

②的展开(以第一个分量为例):z0=0.3×0.5+(0.1)×0.2=0.13z_0 = 0.3 \times 0.5 + (-0.1) \times 0.2 = 0.13

⑤为什么是 ln-\ln:概率越接近 1 损失越接近 0,概率越小损失爆炸——这条动机链在交叉熵为什么用 log 里推过。现在模型给正确答案的概率只有 0.3494(比瞎猜的 1/3 好一点点),损失 1.0515,该挨改了。

2. 反向传播:从最漂亮的梯度开始分账

反向传播回答一个问题:这 12 个参数,每个对 1.0515 的损失负多少责任? 它不是逐个试探(那要算 12 次前向),而是用链式法则从输出端往回一次分账

2.1 起点:L/z=py\partial L/\partial z = p - y(两行推完)

softmax + 交叉熵合在一起求导,结果干净得出奇。把 LL 展开:

L=lneztZ=zt+lnZ(t=2, Z=kezk)L = -\ln \frac{e^{z_t}}{Z} = -z_t + \ln Z \qquad (t=2,\ Z = \textstyle\sum_k e^{z_k})

对任意 zjz_j 求导:第一项当 j=tj=t 时贡献 1-1 否则 00;第二项 lnZzj=ezjZ=pj\frac{\partial \ln Z}{\partial z_j} = \frac{e^{z_j}}{Z} = p_j。所以:

Lzj=pjyj(y=[0,0,1] 是 one-hot 答案)\frac{\partial L}{\partial z_j} = p_j - y_j \qquad (y = [0,0,1]\ \text{是 one-hot 答案})

代入数字:

Lz=[0.3673, 0.2832, 0.6506]\frac{\partial L}{\partial z} = [0.3673,\ 0.2832,\ \mathbf{-0.6506}]

读一下这三个数,它们就是”训练信号”本身:模型给的分数高了(正梯度→要压低),给的分数低了(负梯度→要抬高),而且力度恰好等于”预测概率与正确答案的差距”。猜得越错,改得越狠;猜对了(pyp \to y),梯度自动归零。整个大模型训练的起点就是这个减法。

2.2 分账到每个参数:一个外积、一个矩阵乘

链式法则往回穿一层,得到两组梯度(推导就是标量链式法则逐项写开,不跳步的前置链条见泰勒展开与梯度下降):

WW 的账单——外积 LW=x(py)\dfrac{\partial L}{\partial W} = x^\top (p-y),即 WijW_{ij} 的梯度 = xi×(pjyj)x_i \times (p_j - y_j)

LW=[0.11020.08500.19520.03670.02830.0651]\frac{\partial L}{\partial W} = \begin{bmatrix} 0.1102 & 0.0850 & -0.1952 \\ -0.0367 & -0.0283 & 0.0651 \end{bmatrix}

验证一个:L/W02=x0×(p21)=0.3×(0.6506)=0.1952\partial L/\partial W_{02} = x_0 \times (p_2 - 1) = 0.3 \times (-0.6506) = -0.1952 ✓。注意结构:输入 xx 决定梯度的”行”,输出误差 pyp-y 决定”列”——这就是”知识写进权重”的最小发生现场:这次更新只会显著改动与”吃”的向量、“鱼”的误差相关的那些参数。

xx(即 EE那一行)的账单——Lx=W(py)\dfrac{\partial L}{\partial x} = W(p-y)

Lx=[0.0336, 0.5121]\frac{\partial L}{\partial x} = [0.0336,\ 0.5121]

的嵌入行梯度为 0——它们这次没被用到,一个参数不改。稀疏更新不是优化技巧,是链式法则的自然结果:没参与前向的参数分不到账。

2.3 生产核对动作:数值梯度检验

上面的解析梯度对不对?生产里有个标准核对手法(PyTorch 的 gradcheck 干的就是这事):有限差分——把某个参数拨动一丁点 ε\varepsilon,看损失实际变了多少:

L(θ+ε)L(θε)2εLθ\frac{L(\theta + \varepsilon) - L(\theta - \varepsilon)}{2\varepsilon} \approx \frac{\partial L}{\partial \theta}

我用 ε=105\varepsilon = 10^{-5} 亲手核了两个(脚本在文末):

参数数值梯度解析梯度
W02W_{02}0.195173-0.1951730.195173-0.195173
E11E_{11}0.5120510.5120510.5120510.512051

小数点后六位完全一致。反向传播不是近似,是精确的导数——它和”逐个参数拨动试探”算出来的是同一个东西,只是把 12 次(生产里是 1.24 亿次)前向压缩成了一次反向。这就是它统治深度学习的原因。

3. 落笔改数:SGD 一步,然后看真实生产的 AdamW

3.1 SGD:沿负梯度走一步

最朴素的更新规则 θθηg\theta \leftarrow \theta - \eta \cdot g,取教学用的大学习率 η=0.5\eta = 0.5

W02:0.10.5×(0.1952)=0.1976E[1]:[0.3,0.1][0.2832,0.3560]W_{02}: 0.1 - 0.5 \times (-0.1952) = 0.1976 \qquad E[1]: [0.3, -0.1] \to [0.2832, -0.3560]

12 个参数全部更新后重新前向:

p=[0.3373, 0.2517, 0.4109]L=1.05150.8894p = [0.3373,\ 0.2517,\ \mathbf{0.4109}] \qquad L = 1.0515 \to \mathbf{0.8894}

p()p(\text{鱼}) 从 0.3494 涨到 0.4109,损失降了 0.16。继续迭代(同一条数据反复练):

步数LLp()p(\text{鱼})
11.05150.3494
20.88940.4109
50.28900.7490
100.04500.9560
200.01250.9875

20 步后模型对”吃→鱼”接近笃定。这条下降曲线就是训练日志里 loss 曲线的最小标本(真实曲线怎么读见这篇)。也顺便看见了过拟合的胚胎:单条数据反复练,模型只会背这一条——为什么”背”与”会”不同、容量压力怎么逼出规则,回看上一篇第 1.4 节。

3.2 AdamW:生产真正在用的那支笔

真实训练几乎不用裸 SGD,用的是 AdamW(Loshchilov & Hutter, ICLR 2019)。它对每个参数维护两本流水账——梯度的滑动平均 mm(方向惯性)和梯度平方的滑动平均 vv(波动大小)——再用 m/vm/\sqrt{v} 做更新。对 W02W_{02}θ=0.1\theta = 0.1g=0.1952g = -0.1952)手算第一步,超参用 nanoGPT 同款(β1=0.9,β2=0.999,η=103\beta_1{=}0.9, \beta_2{=}0.999, \eta{=}10^{-3},weight decay λ=0.1\lambda{=}0.1,这组值的出处和源码位置见 nanoGPT 训练配方地图):

公式数值
一阶矩m=0.1×gm = 0.1 \times g0.019517-0.019517
二阶矩v=0.001×g2v = 0.001 \times g^20.000038090.00003809
偏差修正m^=m/(10.9)\hat m = m/(1-0.9)v^=v/(10.999)\hat v = v/(1-0.999)m^=0.195170\hat m = -0.195170v^=0.195170\sqrt{\hat v} = 0.195170
自适应步长ηm^/(v^+108)\eta \cdot \hat m / (\sqrt{\hat v} + 10^{-8})0.001000\mathbf{-0.001000}
权重衰减ηλθ=103×0.1×0.1\eta \lambda \theta = 10^{-3} \times 0.1 \times 0.10.00001000.0000100
更新后θ衰减步长\theta - \text{衰减} - \text{步长}0.10.10099000.1 \to 0.1009900

两个值得带走的观察(都是从手算里直接读出来的):

  1. 第一步的 m^/v^\hat m / \sqrt{\hat v} 恰好约等于 sign(g)\mathrm{sign}(g)——梯度大小被归一化掉了,只留方向。同样 η=103\eta{=}10^{-3},裸 SGD 这一步只挪 0.0001952-0.0001952,AdamW 挪了 0.001-0.001,大 5 倍。这就是”自适应”的含义:小梯度的参数也能迈出像样的步子(此后 m,vm, v 累积历史,步长才开始反映信噪比)。
  2. weight decay 是单独一笔账(直接按参数现值收 0.1% 的”税”往 0 拉),不掺进 m/vm/\sqrt{v} 的自适应缩放——这正是 AdamW 论文标题里 “Decoupled” 的意思,也是它和老式 Adam+L2 的全部区别。

4. 从 12 个参数到 1.24 亿:生产还加了什么

同一条”前向→pyp-y→分账→落笔”的链路,GPT-2 训练时每步原样执行,只是外面套了四件工程外衣(细节都在 nanoGPT 配方地图里):

外衣一句话对应本文哪一步
batch 平均不是一条”吃→鱼”,是几十万 token 的梯度取平均再更新2.2 的账单先求和平均
梯度裁剪梯度向量总长超过 1.0 就等比缩短,防单步爆炸落笔前加一道安检
lr 调度学习率先 warmup 后余弦衰减,不是常数3.2 的 η\eta 变成时间函数
混合精度前向反向用 bf16 省显存,账本仍用 fp32所有矩阵乘的精度选择

没有任何新逻辑,全是对同一条链路的保护和提速。你在本文手算的,就是那 1.24 亿个参数每一步各自经历的事。

5. 验证脚本:40 行,无依赖,几秒跑完

文中每个数字来自这份纯 Python 脚本(无 numpy/torch 依赖),复制即可跑:

import math

E = [[0.1, 0.2], [0.3, -0.1], [-0.2, 0.4]]   # 3x2: 猫/吃/鱼 的嵌入
W = [[0.5, -0.3, 0.1], [0.2, 0.4, -0.5]]     # 2x3: 输出投影
tok, tgt = 1, 2                               # 输入"吃" -> 目标"鱼"

def forward(E, W):
    x = E[tok]
    z = [x[0]*W[0][j] + x[1]*W[1][j] for j in range(3)]
    Z = sum(math.exp(v) for v in z)
    p = [math.exp(v)/Z for v in z]
    return x, p, -math.log(p[tgt])

x, p, L = forward(E, W)
print('p =', [round(v,4) for v in p], 'L =', round(L,4))

y = [0, 0, 1]
dz = [p[j] - y[j] for j in range(3)]                          # p - y
dW = [[x[i]*dz[j] for j in range(3)] for i in range(2)]       # 外积
dx = [sum(W[i][j]*dz[j] for j in range(3)) for i in range(2)] # W(p-y)

eps = 1e-5                                    # 数值梯度检验 W[0][2]
W[0][2] += eps; _,_,Lp = forward(E, W)
W[0][2] -= 2*eps; _,_,Lm = forward(E, W)
W[0][2] += eps
print('gradcheck:', round((Lp-Lm)/(2*eps),6), 'vs', round(dW[0][2],6))

lr = 0.5                                      # SGD 迭代 20 步
for step in range(1, 21):
    x, p, L = forward(E, W)
    dz = [p[j]-y[j] for j in range(3)]
    dW = [[x[i]*dz[j] for j in range(3)] for i in range(2)]
    dx = [sum(W[i][j]*dz[j] for j in range(3)) for i in range(2)]
    for i in range(2):                        # 两份梯度都算完(基于旧 W),再统一落笔
        for j in range(3):
            W[i][j] -= lr * dW[i][j]
    E[tok] = [E[tok][i] - lr*dx[i] for i in range(2)]
    if step in (1, 2, 5, 10, 20):
        print(f'step {step}: L={L:.4f} p(鱼)={p[2]:.4f}')

一个我亲手踩过再测出来的陷阱:循环里必须先把 WWEE 的梯度都算完(都基于旧 WW),再一起更新。如果偷懒先更新 WW、再用新 WW 去算 EE 的梯度,第 2 步的损失就从 0.8894 变成 0.8792——第 2 位小数就开始偏,且逐步累积。PyTorch 先 loss.backward() 把所有梯度建好、再统一 optimizer.step(),隔离的正是这类顺序错误。

下一级实验(可选):把同一份前向用 PyTorch 写一遍,loss.backward() 后打印 W.grad,和本文手算对数——autograd 算的就是 2.2 节那两个公式,没有别的。再下一级就是 nanoGPT 字符级模型:同一条链路,参数从 12 个变成千万级。

6. 自检清单:能答出来才算踏实

  1. 为什么 L/z=py\partial L / \partial z = p - y?(能默写两行推导吗?)
  2. 训练信号 [0.3673,0.2832,0.6506][0.3673, 0.2832, -0.6506] 里,负号意味着什么?
  3. W02W_{02} 的梯度为什么恰好是 x0×(p21)x_0 \times (p_2 - 1)?“行由输入定、列由误差定”说的是什么?
  4. 的嵌入行这次为什么一个数都不改?
  5. 数值梯度检验在验什么?它和反向传播算出的数应该差多少?
  6. 反向传播比”逐参数拨动试探”省在哪?(12 次前向 vs 1 次反向)
  7. AdamW 第一步的更新量为什么几乎等于 η×sign(g)\eta \times \mathrm{sign}(g),和裸 SGD 差在哪?
  8. weight decay 的 “decoupled” 解耦的是什么和什么?

答不出的题号,回到对应小节把数字重新按一遍计算器——这篇的每个结论都能用四则运算和一个 exe^x 键复现。

诚实的提醒

  • 亲手测出的:本文全部数值(前向、梯度、gradcheck、SGD 20 步曲线、AdamW 一步)都来自我跑过的第 5 节脚本及其扩展版,无一例外。
  • 核实过来源的:AdamW 的解耦设计(arXiv 1711.05101);nanoGPT 的超参与参数分组行为(引自本站 nanoGPT 配方地图,其中含源码链接)。
  • 简化声明:本文模型无 bias、无 LayerNorm、无注意力块,batch 大小为 1;“GPT-2 与此同构”指梯度传播逻辑同构,不是说容量或行为相当。AdamW 手算只做了第一步(此时偏差修正恰好完全抵消滑动平均,之后各步数字会不同)。
  • 最低成本验证:复制第 5 节脚本运行,对照文中表格——不需要 GPU,不需要装任何包。

参考来源

论文

工程实践

本站回链

大模型为什么能学习,知识怎么存(本篇是它的实现层陪读) · 任务能力怎样写进 LLM 权重 · 交叉熵为什么用 log · 泰勒展开和大模型 · nanoGPT 训练配方地图 · nanoGPT 字符级模型 · 怎么读 loss 曲线 · QKV 投影的矩阵运算