上一篇说”学习 = 在参数空间沿可计算的坡度下山”。这句话听懂容易,踏实很难——坡度到底怎么算出来的?这篇把它兑换成你能亲手按计算器的东西:一个 3 词词表、12 个参数的语言模型,走完生产级训练的完整一步——前向、反向、梯度检验、参数更新。没有一步是玩具逻辑:这条链路和 GPT-2 训练时每步发生的事完全同构,只是把 1.24 亿个参数缩到了 12 个。
为什么这个最小模型是”真实的”
先回应一个合理的怀疑:3 个词的模型,凭什么说是真实生产行为?
因为它就是把 GPT 掐头去尾后剩下的首尾两层:
- 嵌入矩阵 (token id → 向量):GPT-2 里真实存在,叫
wte; - 输出投影 (向量 → 词表 logits):GPT-2 里真实存在,叫
lm_head; - 中间被省略的只有 Transformer 块(注意力 + MLP)。省略它们不改变梯度下降的任何逻辑——链式法则只是多穿几层,每层的算账方式和本文的 一模一样(那些块内部的矩阵运算之前拆过:QKV 投影)。
这个结构叫 bigram 语言模型(只看前一个词猜下一个词),Karpathy 的 makemore 系列就是从它起步的。训练目标、损失函数、反向传播、优化器——全部用生产同款。
主线一句话:生产里一次参数更新 = 前向算损失 → 反向从 起点逐层分账 → 优化器落笔改数。三段各自手算一遍,梯度下降就没有黑盒了。
0. 出场角色:一句语料,十二个参数
- 词表(3 个词):
猫=0,吃=1,鱼=2 - 训练数据(1 条):输入
吃,目标鱼——即希望模型学会 变大 - 参数(12 个,初始值是随手设的小数字,模拟随机初始化):
GPT-2 124M 与此的差别只是规模:词表 3 → 50257,向量维度 2 → 768,参数 12 → 1.24 亿。顺带一个真实生产细节:GPT-2/nanoGPT 里 和 其实共享同一份权重(weight tying),这里为了让两条梯度路径都看清楚,拆开算。
下文所有数字都先用文末脚本验算过(四舍五入到 4 位小数)。
1. 前向传播:五步算出”模型错得多离谱”
| 步骤 | 公式 | 手算结果 |
|---|---|---|
| ① 查行 | (吃的向量) | |
| ② 打分 | (和每个词的”匹配度”) | |
| ③ 变概率 | ,和 , | |
| ④ 取出正确答案的概率 | ||
| ⑤ 损失 |
②的展开(以第一个分量为例):。
⑤为什么是 :概率越接近 1 损失越接近 0,概率越小损失爆炸——这条动机链在交叉熵为什么用 log 里推过。现在模型给正确答案的概率只有 0.3494(比瞎猜的 1/3 好一点点),损失 1.0515,该挨改了。
2. 反向传播:从最漂亮的梯度开始分账
反向传播回答一个问题:这 12 个参数,每个对 1.0515 的损失负多少责任? 它不是逐个试探(那要算 12 次前向),而是用链式法则从输出端往回一次分账。
2.1 起点:(两行推完)
softmax + 交叉熵合在一起求导,结果干净得出奇。把 展开:
对任意 求导:第一项当 时贡献 否则 ;第二项 。所以:
代入数字:
读一下这三个数,它们就是”训练信号”本身:模型给猫和吃的分数高了(正梯度→要压低),给鱼的分数低了(负梯度→要抬高),而且力度恰好等于”预测概率与正确答案的差距”。猜得越错,改得越狠;猜对了(),梯度自动归零。整个大模型训练的起点就是这个减法。
2.2 分账到每个参数:一个外积、一个矩阵乘
链式法则往回穿一层,得到两组梯度(推导就是标量链式法则逐项写开,不跳步的前置链条见泰勒展开与梯度下降):
给 的账单——外积 ,即 的梯度 = :
验证一个: ✓。注意结构:输入 决定梯度的”行”,输出误差 决定”列”——这就是”知识写进权重”的最小发生现场:这次更新只会显著改动与”吃”的向量、“鱼”的误差相关的那些参数。
给 (即 的吃那一行)的账单——:
猫和鱼的嵌入行梯度为 0——它们这次没被用到,一个参数不改。稀疏更新不是优化技巧,是链式法则的自然结果:没参与前向的参数分不到账。
2.3 生产核对动作:数值梯度检验
上面的解析梯度对不对?生产里有个标准核对手法(PyTorch 的 gradcheck 干的就是这事):有限差分——把某个参数拨动一丁点 ,看损失实际变了多少:
我用 亲手核了两个(脚本在文末):
| 参数 | 数值梯度 | 解析梯度 |
|---|---|---|
小数点后六位完全一致。反向传播不是近似,是精确的导数——它和”逐个参数拨动试探”算出来的是同一个东西,只是把 12 次(生产里是 1.24 亿次)前向压缩成了一次反向。这就是它统治深度学习的原因。
3. 落笔改数:SGD 一步,然后看真实生产的 AdamW
3.1 SGD:沿负梯度走一步
最朴素的更新规则 ,取教学用的大学习率 :
12 个参数全部更新后重新前向:
从 0.3494 涨到 0.4109,损失降了 0.16。继续迭代(同一条数据反复练):
| 步数 | ||
|---|---|---|
| 1 | 1.0515 | 0.3494 |
| 2 | 0.8894 | 0.4109 |
| 5 | 0.2890 | 0.7490 |
| 10 | 0.0450 | 0.9560 |
| 20 | 0.0125 | 0.9875 |
20 步后模型对”吃→鱼”接近笃定。这条下降曲线就是训练日志里 loss 曲线的最小标本(真实曲线怎么读见这篇)。也顺便看见了过拟合的胚胎:单条数据反复练,模型只会背这一条——为什么”背”与”会”不同、容量压力怎么逼出规则,回看上一篇第 1.4 节。
3.2 AdamW:生产真正在用的那支笔
真实训练几乎不用裸 SGD,用的是 AdamW(Loshchilov & Hutter, ICLR 2019)。它对每个参数维护两本流水账——梯度的滑动平均 (方向惯性)和梯度平方的滑动平均 (波动大小)——再用 做更新。对 (,)手算第一步,超参用 nanoGPT 同款(,weight decay ,这组值的出处和源码位置见 nanoGPT 训练配方地图):
| 量 | 公式 | 数值 |
|---|---|---|
| 一阶矩 | ||
| 二阶矩 | ||
| 偏差修正 | , | , |
| 自适应步长 | ||
| 权重衰减 | ||
| 更新后 |
两个值得带走的观察(都是从手算里直接读出来的):
- 第一步的 恰好约等于 ——梯度大小被归一化掉了,只留方向。同样 ,裸 SGD 这一步只挪 ,AdamW 挪了 ,大 5 倍。这就是”自适应”的含义:小梯度的参数也能迈出像样的步子(此后 累积历史,步长才开始反映信噪比)。
- weight decay 是单独一笔账(直接按参数现值收 0.1% 的”税”往 0 拉),不掺进 的自适应缩放——这正是 AdamW 论文标题里 “Decoupled” 的意思,也是它和老式 Adam+L2 的全部区别。
4. 从 12 个参数到 1.24 亿:生产还加了什么
同一条”前向→→分账→落笔”的链路,GPT-2 训练时每步原样执行,只是外面套了四件工程外衣(细节都在 nanoGPT 配方地图里):
| 外衣 | 一句话 | 对应本文哪一步 |
|---|---|---|
| batch 平均 | 不是一条”吃→鱼”,是几十万 token 的梯度取平均再更新 | 2.2 的账单先求和平均 |
| 梯度裁剪 | 梯度向量总长超过 1.0 就等比缩短,防单步爆炸 | 落笔前加一道安检 |
| lr 调度 | 学习率先 warmup 后余弦衰减,不是常数 | 3.2 的 变成时间函数 |
| 混合精度 | 前向反向用 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}')
一个我亲手踩过再测出来的陷阱:循环里必须先把 和 的梯度都算完(都基于旧 ),再一起更新。如果偷懒先更新 、再用新 去算 的梯度,第 2 步的损失就从 0.8894 变成 0.8792——第 2 位小数就开始偏,且逐步累积。PyTorch 先 loss.backward() 把所有梯度建好、再统一 optimizer.step(),隔离的正是这类顺序错误。
下一级实验(可选):把同一份前向用 PyTorch 写一遍,loss.backward() 后打印 W.grad,和本文手算对数——autograd 算的就是 2.2 节那两个公式,没有别的。再下一级就是 nanoGPT 字符级模型:同一条链路,参数从 12 个变成千万级。
6. 自检清单:能答出来才算踏实
- 为什么 ?(能默写两行推导吗?)
- 训练信号 里,负号意味着什么?
- 的梯度为什么恰好是 ?“行由输入定、列由误差定”说的是什么?
猫的嵌入行这次为什么一个数都不改?- 数值梯度检验在验什么?它和反向传播算出的数应该差多少?
- 反向传播比”逐参数拨动试探”省在哪?(12 次前向 vs 1 次反向)
- AdamW 第一步的更新量为什么几乎等于 ,和裸 SGD 差在哪?
- weight decay 的 “decoupled” 解耦的是什么和什么?
答不出的题号,回到对应小节把数字重新按一遍计算器——这篇的每个结论都能用四则运算和一个 键复现。
诚实的提醒
- 亲手测出的:本文全部数值(前向、梯度、gradcheck、SGD 20 步曲线、AdamW 一步)都来自我跑过的第 5 节脚本及其扩展版,无一例外。
- 核实过来源的:AdamW 的解耦设计(arXiv 1711.05101);nanoGPT 的超参与参数分组行为(引自本站 nanoGPT 配方地图,其中含源码链接)。
- 简化声明:本文模型无 bias、无 LayerNorm、无注意力块,batch 大小为 1;“GPT-2 与此同构”指梯度传播逻辑同构,不是说容量或行为相当。AdamW 手算只做了第一步(此时偏差修正恰好完全抵消滑动平均,之后各步数字会不同)。
- 最低成本验证:复制第 5 节脚本运行,对照文中表格——不需要 GPU,不需要装任何包。
参考来源
论文
- Decoupled Weight Decay Regularization(AdamW) — Loshchilov & Hutter, ICLR 2019(arXiv 1711.05101)
工程实践
- karpathy/nanoGPT — 本文超参与”生产外衣”部分的对照实现
- karpathy/makemore — 从 bigram 起步的字符级语言模型教学系列
本站回链
大模型为什么能学习,知识怎么存(本篇是它的实现层陪读) · 任务能力怎样写进 LLM 权重 · 交叉熵为什么用 log · 泰勒展开和大模型 · nanoGPT 训练配方地图 · nanoGPT 字符级模型 · 怎么读 loss 曲线 · QKV 投影的矩阵运算