11 课 · 反向传播
LESSON 11 · 卷十一 往回走的信

几十万个方向,怎么一次全算出来

上一课把话说死了:「梯度告诉每个参数往哪挪」。 可我们那个小模型有 798,720 个参数 —— 那就是 798,720 个方向。 一个一个试?先把这笔账算清楚:按「一次前向 1 毫秒」, 量一轮梯度要 1,597,440 毫秒(26.6 分钟), 跑完第 9 课那 8,000 步是 147.9。 这一课讲的,就是把这件事变成 24的那个办法。

STAGE 01
拆成图
计算图:节点 + 边
STAGE 02
一层层乘
链式法则
STAGE 03
从 1 往回走
反向传播
STAGE 04
量一遍对账
梯度校验

先把那张账坐实。第 10 课那个山谷只有一维 —— 一个参数,一个人就能试出来。真实模型不是。 我们第 8 课数出来的模型是 798,720 个参数(798720.000000 —— 这个数在lib/block.ts 里能一行行加出来)。 要知道每个参数该往哪挪,最笨的办法是:把它轻轻推一下,看损失变多少。

一个参数推两次(加一点、减一点),就是 1,597,440 次前向。 一次前向按 1 毫秒算,量完一轮 1,597,440 毫秒; 第 9 课那份记录跑了 8,000 步 —— 乘起来 147.9。 而反向传播要的是 3 次前向的代价, 同样 8,000 步只要 24,快 532,480 倍。

所以问题不是「有没有更聪明的试法」,而是:能不能一次就把所有参数的梯度全算出来?能。办法分三步:把函数拆成一张计算图、 用链式法则把导数一层层乘起来、 再从损失那边往回走一遍。下面这张图就是这个过程的全部。

招牌动画一张能手算的计算图 —— 前向算值,反向算梯度
现在:前向,正在算数
换一组参数
第一层的偏置 b(拖过 2,z1 就变号)b = 3
x1?x2?w1?w2?b?z1?a1?v?c?z2?t?L?← 输入 / 参数损失 L →
还没算到前向算完了值反向拿到了梯度正在算这一步
前向 · 算出「x1
x1 = 2.000000
输入(第一个特征)
前向走到哪
还没开始
12 个节点
反向走到哪
还没开始
12 个节点
损失 L
z1 = b − 2 = 1.000000
现在的闸门
开着
梯度能流到第一层
前向那 12 步,每一步都是「把上一层的数代进公式」; 反向那 12 步,每一步都是「把上游的梯度乘以这条边的局部导数」。
重点看边上那些 ×数字:w1 → z1 那条边上是 ×2.000000, 因为 ∂z1/∂w1 = x1 = 2.000000;w1 拿到的梯度就是「∂L/∂z1 乘上它」。 也就是说 —— 反向传播不用重新算一遍函数,它用的全是前向时已经存下来的中间结果。 这正是它便宜的原因。
把 b 一路拖到 2 以下:z1 = b − 2 变负,ReLU 的局部导数从 1 变成 0, 沿着它乘过去,第一层的 w1、w2、b 拿到的梯度全变成 0 —— 参数一步都不动。这就是「ReLU 死区」。
点「自动走」看完整一轮:前 12 步把每个节点的值算出来(蓝色), 后 12 步从 L 往回把梯度传下去(琥珀色小牌子)。 边上标的 ×数字 是那条边的局部导数 —— 反向时就乘它。
图 11-1 · 前向 12 个节点、反向 12 个节点; L = 9.000000,∂L/∂w1 = -24.000000
前向存下来的每个中间结果,
都是反向要乘的那个数。

三句话讲完这一课

  • 计算图:把 L = (v·ReLU(w1x1 + w2x2 + b) + c − t)² 拆成 12 个节点,每条边记一个「局部导数」。
  • 链式法则:L 对某个参数的变化率 = 一路上所有局部导数相乘。 演示里 ∂L/∂w1 = (-6.000000) × (2.000000) × (1.000000) × (2.000000) = -24.000000
  • 反向传播:从 ∂L/∂L = 1 往回走,一次把 798,720 个参数的梯度全算出来 —— 代价约 3 次前向, 而不是 1,597,440 次。
换个熟悉的地方想 · 会计算总账

一家连锁店想知道「全国 798,720 家门店, 每家的房租涨 1 块钱,总成本会涨多少」。笨办法是挨家挨户去试一遍。

反向传播干的事是:先顺着算一遍总成本(前向), 再把「总成本对每一层汇总项的变化率」从总部往下发 —— 华东区拿到的变化率 × 华东区对某家店的变化率 = 那家店的变化率。

每一级只做一次乘法,798,720 家店的变化率就全有了。

这就是为什么它叫「反向传播」:账是从总账(损失)往明细(每个参数)方向推的。

第 1 站

把函数拆成一张图

页首那张图里的每个圆圈,都是一个大函数里的一次小运算。 乘法、加法、ReLU、平方 —— 每一个单独看都简单得不能再简单, 它们的导数更是小学水平。麻烦只在于它们套在一起

计算图要做的事就一件:把「套在一起」摊开成「谁喂给谁」。 这样一来,每一步的导数只跟它自己的输入有关,不用去管外面套了多少层。 下面是我们这张图的全部 12 个节点 —— 每个数都能按计算器核对:

#节点它怎么来的前向的值∂L/∂自己1x1输入(第一个特征)2.000000-6.0000002x2输入(第二个特征)3.00000012.0000003w1第一层的权重(x1 的系数)0.500000-24.0000004w2第一层的权重(x2 的系数)-1.000000-36.0000005b第一层的偏置3.000000-12.0000006z1w1·x1 + w2·x2 + b = 0.5×2 + (-1)×3 + 3 = 11.000000-12.0000007a1ReLU(z1),z1 = 1 → a1 = 11.000000-12.0000008v第二层的权重2.000000-6.0000009c第二层的偏置0.000000-6.00000010z2v·a1 + c = 2×1 + 0 = 22.000000-6.00000011t正确答案(标签)5.0000006.00000012L(z2 − t)² = (2 − 5)² = 99.0000001.000000
这张表就是页首动画的两个阶段:最后一列是「反向走完之后每个节点拿到的梯度」。 注意 t 那一行 —— 标签也会拿到梯度(∂L/∂t = 6.000000), 但我们不会去更新标签:它是数据,不是参数。

有了图,一件重要的事就成立了:前向时顺手把中间结果存下来, 反向时不用重算。图上每条边标的 ×数字,全都是前向时已经算出来的东西 (比如 w1 → z1 那条边上标的是 x1 = 2.000000)。 这就是为什么训练比推理吃显存:推理可以走一个丢一个, 训练必须把这一整张图(术语叫激活值)留着,等反向用。

面试常问 · 训练为什么比推理吃显存
  • 激活值要留着:反向传播要用前向的中间结果,所以前向时算出的每一层输出 都不能丢。序列越长、层数越多,这份「账本」越大。
  • 梯度自己也要存一份:每个参数一张 .grad, 和参数本身一样大。798,720 个参数就是两份账。
  • 优化器还要再存:Adam 这类优化器为每个参数额外存两个状态(第 12 课细算)。 这是「全量微调要几十 G 显存」的原因,也是第 16 课 LoRA 要解决的问题。
第 2 站

链式法则:把导数一层层乘起来

图上有了边,导数就有了着落。链式法则只有一句话: L 对某个参数的变化率,等于从 L 走到它的路上,每一个中转站贡献的「局部变化率」全部相乘

手算一遍 ∂L/∂w1(演示里那条路)

从 L 出发到 w1,路上会经过 z2 → a1 → z1 三个站。 每个站贡献一个因子:

L/∂z2 = ∂L/∂z2 = 2(z2 − t) = 2×(2 − 5) = -6
z2/∂a1 = ∂z2/∂a1 = v = 2
a1/∂z1 = ∂a1/∂z1 = ReLU′(z1) = 1(z1 = 1 > 0)
z1/∂w1 = ∂z1/∂w1 = x1 = 2

∂L/∂w1 = (-6.000000) × (2.000000) × (1.000000) × (2.000000) = -24.000000

对照页首动画:小球在 w1 旁边那个琥珀色小牌子上写的正是 -24.000000。四个数字,一次乘法链,就是「w1 该往哪拧」的全部答案。

核心实验换一个参数,看它的乘链长什么样
看谁的乘链
1L / ∂z2∂L/∂z2 = 2(z2 − t) = 2×(2 − 5) = -6-6.000000
2z2 / ∂a1∂z2/∂a1 = v = 22.000000
3a1 / ∂z1∂a1/∂z1 = ReLU′(z1) = 1(z1 = 1 > 0)1.000000
4z1 / ∂w1∂z1/∂w1 = x1 = 22.000000
乘起来:(-6.000000) × (2.000000) × (1.000000) × (2.000000) = -24.000000 ∂L/∂w1 = -24.000000
为什么是而不是加?因为每一步都在问同一个问题: 「上游那一站变了 1 个单位,我这一站跟着变多少?」 变化是一层层放大或缩小着传下来的 —— 第 1 步放大 -6.000000 倍、 第 2 步放大 2.000000 倍……一路乘下来, 就是「L 对 w1 的变化率」。
这条链上的每个因子,就是图上对应那条边标的 ×数字。 注意第 3 行那个 1.000000:它是 ReLU 的局部导数。 把 b 拖到 2 以下(页首那张图),这一格会变成 0 —— 0 乘任何数都是 0,于是第一层的参数一个梯度都拿不到。
链式法则本身是微积分里就有的东西,反向传播的新意只在于:把整张图上的这些乘法,按「从 L 往回走」的顺序一次全做完,而且中间结果全部复用
点按钮换参数:每个参数的乘链长得都不一样, 但都由同一批边上的局部导数拼出来 —— 共用的那些因子只算一次。
图 11-2 · c 的链最短(一步:∂z2/∂c = 1),w1 的链最长(四步)
看 ChainLab 里 c 的那条链:它只有一步,因子是 1为什么 ∂L/∂c 恰好等于 ∂L/∂z2?(想一句话)
第 3 站

反向传播:从 1 往回走一遍

链式法则谁都会背,可它单独用起来是场灾难:每个参数都要把整条路重走一遍。 反向传播的两处「省」,把它从灾难变成一行代码。

两处省
一、起点固定。从 ∂L/∂L = 1 出发 —— 损失对自己求导永远是 1。
二、顺序固定。从 L 往回走,走到某个节点时, 它的所有上游都已经结完账了, 于是它只需要做一件事:

  ∂L/∂自己 += Σ上游 ∂L/∂上游 × ∂上游/∂自己

每个节点只做一次这种加法 —— 798,720 个参数、 几百万个节点,代价都只跟「图有多大」成正比, 跟「参数怎么两两组合」毫无关系。

现在把页首那张图只走反向那半段再看一遍。这次盯着 b 拖: 把第一层的偏置 b 从 3 拖到 −3,z1 从 1.000000 变成 -5.000000, ReLU 的门就关了。

核心实验回到那张图 · 只走反向 —— 把 b 拖过 2,看第一层的梯度全部归零
现在:反向,正在往回传梯度
换一组参数
第一层的偏置 b(拖过 2,z1 就变号)b = 3
x12.000000x23.000000w10.500000w2-1.000000b3.000000z11.000000a11.000000v2.000000c0.000000z22.000000t5.000000L9.000000← 输入 / 参数损失 L →
还没算到前向算完了值反向拿到了梯度正在算这一步
反向 · 起点
∂L/∂L = 1 —— 损失对自己求导永远是 1。梯度就是从这里开始往回传的。
前向走到哪
算完了
12 个节点
反向走到哪
还没开始
12 个节点
损失 L
9.000000
z1 = b − 2 = 1.000000
现在的闸门
开着
梯度能流到第一层
前向那 12 步,每一步都是「把上一层的数代进公式」; 反向那 12 步,每一步都是「把上游的梯度乘以这条边的局部导数」。
重点看边上那些 ×数字:w1 → z1 那条边上是 ×2.000000, 因为 ∂z1/∂w1 = x1 = 2.000000;w1 拿到的梯度就是「∂L/∂z1 乘上它」。 也就是说 —— 反向传播不用重新算一遍函数,它用的全是前向时已经存下来的中间结果。 这正是它便宜的原因。
把 b 一路拖到 2 以下:z1 = b − 2 变负,ReLU 的局部导数从 1 变成 0, 沿着它乘过去,第一层的 w1、w2、b 拿到的梯度全变成 0 —— 参数一步都不动。这就是「ReLU 死区」。
前向已经全部算完(值都在卡片上),你只需要点「往回传一步 ←」。 拖 b 的时候注意 a1 → z1 那条边上的 ×数字: 它会在 1 和 0 之间跳 —— 那是 ReLU 的局部导数。
图 11-3 · z1 = b − 2:b > 2 时闸门开着,b ≤ 2 时 ReLU 把梯度全挡回去
参数活着的路(b = 3,z1 = 1死区(b = -3,z1 = -5权重 w1-24.0000000.000000权重 w2-36.0000000.000000偏置 b-12.0000000.000000权重 v-6.0000000.000000偏置 c-6.000000-10.000000
死区那一列里,第一层的三个参数(w1、w2、b)梯度全是 0.000000 —— 链式法则里乘了一个 0,后面全成了 0。第二层的 v 也吃了个 0 (因为 ∂z2/∂v = a1 = 0),但 c 照样拿到 -10.000000 —— 它不经过 ReLU。
面试常问 · 梯度消失与 ReLU 死区
  • 死区怎么来的:ReLU 的导数是 0/1 开关。一旦某个神经元的加权和长期为负, 它的局部导数永远是 0,梯度再也传不进来 —— 这个神经元就「死」了。
  • 梯度消失(另一件事):深层网络里,每层都乘一个小于 1 的局部导数, 乘上几十层就趋近于 0。这是 Sigmoid 的老毛病,也是 ReLU 当年上位的理由; 残差连接(第 8 课)则是给梯度开了一条「直通车」。
  • 怎么察觉:盯每一层的梯度范数。如果第一层的梯度长期是 0 或极小, 说明前面这几层根本没在学 —— 这是调模型时最值得先看的两个数之一 (另一个是 loss 曲线,第 12 课)。
第 4 站

凭什么相信它算对了

上面全是推导。推导可以错得很隐蔽 —— 一个符号写反,训练照样跑,只是怎么都学不好。 所以每一次手写反向传播,工程上都要做一件事:用最笨的办法抽查一遍

核心实验梯度校验 —— 反向传播算的,和「推一把」量出来的对一对
ε(扰动多小的一撮)最差相对误差 2.11e-12
参数反向传播说扰动法量出来相对误差-24.0000-24.00001.10e-13-36.0000-36.00006.30e-13-12.0000-12.00002.11e-12-6.0000-6.00006.30e-13-6.0000-6.00006.30e-13
挑中的这个参数:第一层权重 w1,ε = 1e-4
第 1 步:往前向里喂 w1 = 0.50010000 → 损失 L = 8.99760016
第 2 步:往前向里喂 w1 = 0.49990000 → 损失 L = 9.00240016
第 3 步:两次相减 = -0.00480000,再除以 2ε = 2.00e-4 -24.000000
反向传播给的是 -24.000000 —— 两条路只差最后几位。
「扰动法」就是不会求导时的土办法:把某个参数轻轻推一下, 看损失变多少 —— 变化率就是梯度。它一次只能量一个参数, 每个参数要花 2 次前向;我们这个小模型有 79.9 万个参数, 量一轮就是 159.7 万次前向。
ε 并不是越小越好。还是看 第一层权重 w1:ε = 1e-4 时相对误差只有 1.10e-13,可把 ε 缩到 1e-6,两次前向的损失只差 -4.80e-5 这个量级 —— 浮点数的舍入误差开始盖过信号, 相对误差涨到 4.53e-11。 实战里做梯度校验,ε 一般取 1e-3 ~ 1e-5,看的只是「量级对不对」, 不是「小数点后第几位一样」。
注意它只是抽查工具,不是训练手段:训练每一步都要全部参数的梯度, 绝不可能用扰动法 —— 那就是第 1 站算过的「148 天」。 反向传播才是那个「一次全算完」的答案。
👆 点表格里的任意一行换参数;点上面的按钮换 ε,看相对误差怎么变。 这张表是同一件事实的两种量法:左边是链式法则推导出来的,右边是老老实实推一把量出来的。 它们一致,说明反向传播没算错 —— 这是训练前必做的一道体检。
两条路算出来的数在小数点后七八位才分开 —— 这就叫「对上了」。 表里最右边那列是相对误差,看的是量级(10⁻¹⁰ 级别 = 完全一致)。
图 11-4 · 反向传播给 ∂L/∂w1 = -24.000000; 扰动法给 -24.000000 —— 两条独立的路,同一个数

这件事之所以成立,是因为我们选了一张小到能手算的图。 真实模型里可没有「推一把」这个选项 —— 那就是第 5 站要算的账。 梯度校验只用来给实现做体检:跑几个样本,量量每个参数的量级对不对, 对上了就关掉它去训练。

既然扰动法「最笨也最可靠」,为什么不干脆用它来训练?(算一笔账再回答)
第 5 站

一次就够:这笔账到底差多少

把这笔账摊成一张表。前提说清楚:一次前向按 1 毫秒算 (对小模型是合理量级),反向的代价按「前向的 3 倍」估 (业界常用的粗略说法,实际取 2~3 倍)。

做法一步要几次前向8,000 步总共老老实实扰动1,597,440147.9反向传播324
动手实验把参数量推上去 —— 扰动法那根条怎么从「天」跳进「年」
参数量本课小模型
直接跳档 
老老实实扰动147.9 天
反向传播24 秒
扰动法:798,720 个参数 × 2 次前向 = 1,597,440 次前向/步 × 8,000 步 = 147.9 天
反向传播:3 次前向的代价/步(1 次前向 + 1 次反向)× 8,000 步 = 24 秒 ← 一步把全部参数的梯度都算出来,跟参数量无关
同样 8,000 步:扰动法 147.9 天,反向传播 24 秒 —— 快 532,480 倍。
老老实实扰动:8,000 步的账
147.9 天
每步 1,597,440 次前向
(每个参数 2 次:加 ε、减 ε)
反向传播:同一件事的账
24 秒
每步 3 次前向
参数从 8 万到 700 亿,这一格都不动
快了多少倍
532,480×
本课小模型那一档:532,480×
= 2 × 参数量 ÷ 3
👆 拖最上面那根「参数量」滑块,或者直接按下面的档位跳。两根条都画在对数刻度上 (从 1 秒到 10¹³ 秒 ≈ 31.7 万年,一格一个数量级),所以条的长度比的是数量级,不是倍数。琥珀色那根是扰动法:参数每翻一个数量级, 它的读数就从「天」跳进「年」;蓝色那根是反向传播,不管参数多少都停在秒级 —— 两根条之间那段空隙,就是反向传播省下来的。绿色虚线标的是本课小模型那一档。
先拖最上面那根「参数量」滑块,再盯右边两行读数。默认停在本课小模型那一档, 也就是上面那张表里的 147.9 24;往上拖到 1 亿、7B, 扰动法那格就一路变成「年」。
图 11-5 · 同样 8,000 步:扰动法 2 × 798,720 次前向、147.9 天;反向传播 3 次前向、24 秒 —— 差 532,480
一步的耗时:扰动法 1,597,440 毫秒(26.6 分钟),反向传播 3 毫秒。 倍数:532,480 倍。参数再多一个数量级, 「扰动法」那一格就从「天」变成「年」—— 所以这件事上没有第二种选择

反过来说,这也解释了一个工程细节:反向传播既然靠前向存下的中间结果, 那么显存就是这条路的过路费。用不用得起大 batch、 能不能跑长序列,全看这份账本有多大(第 12、15 课会反复回到这里)。

最后一件小事:为什么要写 zero_grad()

框架把反向传播包装成了 loss.backward() 一行。 第一次看训练循环的人都会卡在那行 optimizer.zero_grad() 上: 为什么要把辛苦算出来的梯度清掉

核心实验忘了 zero_grad 会发生什么 —— 拖滑块看梯度被叠了几层
连着调用几次 backward(中间不清零)n = 3
1 次之后∂L/∂w1 = -24.000000
2 次之后∂L/∂w1 = -48.000000
3 次之后∂L/∂w1 = -72.000000
4 次之后∂L/∂w1 = -96.000000
5 次之后∂L/∂w1 = -120.000000
6 次之后∂L/∂w1 = -144.000000
忘了 zero_grad
-72.000000
= -24.000000 × 3(这些步的梯度加在一起了)
写了 zero_grad
-24.000000
每一步都是干净的 -24.000000
差多少倍
3×
相当于学习率被悄悄放大了 3
虚线是正确的梯度 -24.000000。切到 n = 6:不清零的话 ∂L/∂w1 会变成 -144.000000 —— 那一步迈出去,等于把学习率放大了 6 倍, 参数直接被踹飞(第 10 课那只「步子太大就飞出去」的小球,在这里又见了一面)。
所以训练循环里那行 optimizer.zero_grad() 不是可有可无的仪式:PyTorch 的 .grad 默认累加, 不清零就会把好几十步的梯度叠在一起。 忘了写它的典型症状是:loss 前几步就炸成 NaN。
但累加本身不是 bug —— 它是故意留着的:想让小显存跑大 batch 时, 就故意把几个小批的梯度先累起来、再走一步(叫「梯度累加」,第 16 课会用到)。 我们这个小模型有 798,720 个参数, 每个参数都有一份自己的 .grad,所以清零是「把每个参数的账本都划掉」。
👆 拖滑块看「不划账」的后果。真实训练里,一个 batch 走完是前向 → backward → 更新参数 → zero_grad 四步一轮, zero_grad 排在最后(或者下一轮开头),位置错了就会踩这个坑。
虚线是正确值 -24.000000。不清零的话,连着调几次 backward, 它会变成 -24.000000 × n —— 参数那一步就被踹飞了。
图 11-6 · 正确值 -24.000000;累加 6 次是 -144.000000,刚好 6 倍
第 6 站 · 收官

把这一课钉在墙上

本课核心 · TAKEAWAY

反向传播不是新数学,是链式法则的工程版: 把复合函数拆成计算图,每条边记一个局部导数; 前向算值的时候顺手把中间结果存下来;然后从 ∂L/∂L = 1 出发往回走一遍, 每个节点把「上游的梯度 × 边上的局部导数」加到自己账上。 一次前向 + 一次反向,798,720 个参数的梯度全出来了 —— 换成最笨的「逐个扰动」要 1,597,440 次前向、147.9 天,反向传播只要 24 秒。 两个必须记住的工程细节:ReLU 把关了门,梯度就一个都传不进来(死区), 以及 .grad 是累加的,每步都要 zero_grad()

这一课你亲手做完了

  • 走过一张计算图12 个节点,前向 9.000000 一路算到 L,反向从 1 走到每个参数 —— 全长只用到加减乘除。
  • 手算过一条乘链:∂L/∂w1 = (-6.000000) × (2.000000) × (1.000000) × (2.000000) = -24.000000
  • 拖出过 ReLU 死区:b 从 3 拖到 -3,z1 从 1.000000 -5.000000,第一层的 w1、w2、b 梯度全变成 0.000000。
  • 对过一次账:反向传播与扰动法在 5 个参数上全部对上, 相对误差都在 1e-10 以内(ε = 1e-4)。
  • 算过成本:扰动法一步 1,597,440 毫秒、8,000147.9 天; 反向传播一步 3 毫秒、8,000 24 秒 —— 差 532,480 倍。

学习小测验

已完成 0 / 60.0%答对 0
还没提交过 —— 每题先选一个选项,再点「提交」,答完就能看到诊断。
Q1反向传播到底在做什么?
Q2模型有 798,720 个参数。老老实实「逐个扰动」要算多少次前向?反向传播呢?
Q3反向传播凭什么这么便宜?
Q4把 b 拖到 −3,z1 = -5 < 0,ReLU 把闸门关死。这时候第一层的 w1 拿到多少梯度?
Q5那怎么知道反向传播没算错?
Q6为什么训练循环里每一步都要写 optimizer.zero_grad()?
NEXT · 第 12 课

训练流程与优化器:一步到底怎么走

现在你手里有了全套零件:前向(第 5~7 课)、损失(第 10 课)、 梯度(这一课)。可把它们串成一个真正的训练循环,还有一堆坑要填 ——数据怎么喂(一个 batch 装几道题)、步子怎么迈(SGD / Momentum / Adam 到底差在哪)、学习率怎么随步数变(预热 + 衰减)、梯度太大怎么办(裁剪)、 以及那段你早晚要写的 for step in range(...) 里每一行在干嘛。 下一课把这台机器装起来,然后我们就可以真的开训了。

从零手册 —— 下一课:训练流程与优化器:一步到底怎么走