跳到主要内容

算子 — 一个能自己求导的最小零件

这一章讲三件事: 为什么框架要把每个基础运算包成一个「带两面」的零件; 这两面各自算什么;以及框架替你记的那本账长什么样、什么时候会算错。

它在全书链条里的位置: 第 01 章解决了「数据怎么装」,这一章解决「怎么知道每个数该往哪边改」。 这两件事合起来,下一章的训练循环才转得动。

需要的基础: 第 01 章的张量与形状。

1. 先看现象:手推导数,推到第三层就推不动了

一个 5 层的小网络就已经是这样一个东西:

y = f₅( f₄( f₃( f₂( f₁(x) ) ) ) )

每一层里还各自带着一堆待定的数。 要训练它,就得回答一个问题: 「第 1 层里那个数改一点点,最后的结果会变多少?」

直接推是推不出来的——它埋在四层函数里面。 高等数学里对付这种情况的工具叫链式法则: 一层套一层的函数,整体的导数等于每一层导数连乘起来。书里把这条法则原样搬了过来1

框架把这条法则落成了一个工程约定: 每个基础函数同时实现两套计算—— 沿前向定义的那一套叫前向函数,倒着传的那一套叫反向函数,二者一一对应2带前向、反向两套计算的基础零件,书里叫算子。

一个算子的两面
────────────────────────────────────────────────
前向: x, y ──→ [ Add ] ──→ z "算出结果"
反向: δx, δy ←── [ Add ] ←── δz "结果改一点,输入各该改多少"
────────────────────────────────────────────────

图说:同一个方框,两个方向。上面那条是你写的;下面那条框架要么帮你生成,
要么(像书里第 1.3 节那样)由你亲手写出来。

只要每个基础函数都备齐这两面,复杂模型就能靠组合得到, 梯度也能由链式法则自动往回传3——这句话是整本书的地基。

全章主走查:g = exp(a × b + c × d),取 a = 2、b = 3、c = 2、d = 2
────────────────────────────────────────────────────────────────
§3 加法零件 前向 z = x + y 反向 δx = δy = δz(原样分给两边)
§4 乘法零件 前向 z = x × y 反向 δx = δz × y、δy = δz × x(乘到另一侧)
§5 指数零件 前向 z = exp(x) 反向 δx = δz × exp(x)
§5 组装前向 a×b = 6、c×d = 4、6+4 = 10、exp(10) = 22026.465794806718
§6 组装反向 从 δg = 1 出发倒着走,拿回 ∂g/∂a、∂g/∂b、∂g/∂c、∂g/∂d
────────────────────────────────────────────────────────────────

前向那个 22026.465794806718 是书里打印出来的实际输出;
§6 那四个偏导书里没有打印(它把这一步留成了动手练习),是我们照上面三条反向公式算的。

2. 一个零件长什么样:两个必须填的空

结论先行:书里把「算子」定义成一个只有两个待填方法的基类。

它把两件事钉死了4:

  • 怎么调用。 实例可以像函数那样直接用:写 model(...) 等价于写 model.forward(...)。 这条约定和框架内置的层保持一致,所以后面所有模型都是这么调的。

  • 必须实现什么。 forwardbackward 都留成待重写的钩子; 子类按各自的用法补上参数和计算逻辑。

这个设计有个直接的好处:任何一个零件都能被单独测试。 给它一个输入跑前向、再给一个上游的数跑反向,两边都能和手算对照。 书里第 1.3 节剩下的部分就是在做这件事,一次做三个零件。

先约定一个记号,后面全章都用: 设最终要盯着的那个数是 L,那么 δx 就表示「x 改一点点,L 会跟着变多少」。 反向函数拿到的是输出端的 δz,要还给上游的是输入端的 δx、δy。

3. 加法零件:把上游的数原样分给两边

这一节走主走查的第一个零件。

前向平凡: z = x + y5

反向也很短: 由链式法则可得 δx = δy = δz—— 加法算子把梯度原样向两个输入分发5

为什么是原样分发
────────────────────────────────
z = x + y
z 对 x 的偏导 = 1,z 对 y 的偏导 = 1
δx = δz × 1 = δz
δy = δz × 1 = δz
────────────────────────────────
直觉:x 加 1,z 就跟着加 1;所以「z 变多少 L 变多少」原封不动传给 x。

书里给了一次实测:取 x = 1、y = 4,把上游的数设成 1 去调反向, 打印出来 x 的梯度是 1、y 的梯度也是 16

4. 乘法零件:梯度要乘到「另一侧」的输入上

前向同样平凡: z = x × y

反向是这一章第一个反直觉的地方: δx = δz × yδy = δz × x—— 梯度要与「另一侧」的输入相乘7

为什么是另一侧
────────────────────────────────
z = x × y
z 对 x 的偏导 = y ← 注意是 y,不是 x
z 对 y 的偏导 = x
────────────────────────────────
直觉:x 加 1,z 就加 y。所以 y 越大,动 x 的后果越严重。

这条「乘到另一侧」在后面会反复出现。 线性层的反向、注意力里的打分、第 11 章那三道门里的逐元素乘,底子上都是它。

顺带记一件工程细节: 上面两个零件的前向里都有一句 「把输入存到自己身上」。这不是多余的——反向要用到前向时的输入值, 不存下来,反向就没法算。框架替你记的那本账,记的正是这类中间值。

5. 指数零件,以及三个零件拼起来

指数运算的反向最简单:dz/dx = exp(x)δx = δz × exp(x)8

主走查的前向部分。 给定 a = 2、b = 3、c = 2、d = 2, 依次实例化乘法、加法、指数三个零件,按 g = exp(a×b + c×d) 的顺序组合调用9:

① 乘法零件跑第一次 a × b = 2 × 3 = 6
② 乘法零件跑第二次 c × d = 2 × 2 = 4
③ 加法零件 6 + 4 = 10
④ 指数零件 exp(10)
─────────────────────────────────────
打印结果 g = 22026.465794806718

这个数是书里实际打印出来的9。它就是自然常数的 10 次方。

注意这四步的形状:整件事没有任何「深度学习」的成分—— 只是三个初中就学过的运算按顺序组合了一下。 而后面二十章里的所有模型,结构上和它是同一类东西,只是零件更多、更复杂。

6. 倒着走一遍:四个偏导各是多少

这一节把主走查走完。 书里到这里停下,把反向留成了动手练习10, 所以下面这四个数是我们照第 3 到第 5 节那三条反向公式算出来的,不是书里打印的

记号:z₁ = a×b = 6,z₂ = c×d = 4,s = z₁ + z₂ = 10,g = exp(s)

① 从头开始 δg = 1 (盯着 g 自己看,所以设成 1)
② 过指数零件 δs = δg × exp(s) = 1 × 22026.4658 = 22026.4658
③ 过加法零件(原样分发) δz₁ = 22026.4658 δz₂ = 22026.4658
④ 过第一个乘法(乘另一侧) δa = δz₁ × b = 22026.4658 × 3 = 66079.397
δb = δz₁ × a = 22026.4658 × 2 = 44052.932
⑤ 过第二个乘法(乘另一侧) δc = δz₂ × d = 22026.4658 × 2 = 44052.932
δd = δz₂ × c = 22026.4658 × 2 = 44052.932
─────────────────────────────────────────────────────────────────
四个偏导:∂g/∂a = 66079.397 ∂g/∂b = ∂g/∂c = ∂g/∂d = 44052.932

这四个数里藏着一个可以自己核的结论:∂g/∂a 恰好是另外三个的 1.5 倍。 因为 a 的搭档 b 是 3,而另外三个的搭档都是 2。 「乘到另一侧」这条规则在结果上留下了指纹。

主走查到此走完。 从三个初中运算出发,前向拿到一个数, 反向拿回四个数——而这四个数正是「每个输入该往哪边改、改多少」。

7. 框架自己记账:边算边记,调一次就回放

结论先行:上面那套手写反向,主流框架已经自动化了;这套自动化叫自动求导。

书里的说法是:开发者只需写前向计算,框架便能依据它自动构造对应的反向计算11。 做法是把数值计算拆成若干原子操作,构造一张计算图 (每个节点保存前向结果和局部导数,沿图倒着遍历就能按链式法则拿到梯度)12

这里有个词要区分清楚,不然读源码会迷路: 框架用的是「边算边记」的方式——前向执行过程中记录用到了哪些算子和哪些张量, 在每个需要梯度的张量上挂好对应的反向操作,等到你要梯度时再按记录回放13

书里用一个最小例子把这本账摊开了。 两个数 a = 2.0、b = 5.0, 其中只有 a 标记为需要梯度,然后算 c = a * b,再从 c 出发倒着走14:

a = 2.0(要梯度) b = 5.0(不要梯度) c = a × b = 10.0

倒着走完之后打印三个梯度:
a 的梯度 → 5.0 ← 就是「另一侧」的那个输入 b
b 的梯度 → None ← 一开始就声明不要,框架连账都没记
c 的梯度 → None ← 它是中间结果,不是起点,默认不保留

这三行输出各自对应一条规则,值得逐条看:

  • a 拿到 5.0,正是第 4 节那条「乘到另一侧」;

  • b 拿到 None,因为它被声明为不参与梯度计算,框架不给它建反向那一路14;

  • c 拿到 None,因为它是中间结果而不是起点; 书里说明:按默认设置中间结果不保留梯度,调试时想看可以在前向之后补一句 c.retain_grad()15

「起点」这个词在框架里有个正式名字:叶子。 它指的是那些不由别的运算算出来、而是你自己造出来的张量——模型里的参数全是叶子。 只有叶子才默认保留梯度,因为只有它们需要被更新。

8. 梯度是累加的,所以训练循环里必须先清零

这是全章最实用的一条,也是新手最容易漏的一行代码。

书里写得很直接:每次倒着走都会把新算出的梯度加到已有的梯度上,而不是覆盖16

书里的实测(x = [1.0, 2.0, 3.0],盯着 (x²) 的和看)
──────────────────────────────────────────────
第一次倒着走 梯度 = [2., 4., 6.]
不清零,再走一次 梯度 = [4., 8., 12.] ← 累加上去了
调一次清零,再走一次 梯度 = [2., 4., 6.] ← 回到正常
──────────────────────────────────────────────

这个设计不是 bug,是为了让「梯度累计」这类技巧能实现16—— 显卡内存装不下一大批数据时,可以拆成几小份分别倒着走、让梯度自己加起来, 效果接近一次喂一大批。

但常规训练必须先清零。 忘了清零,梯度会越加越大,参数更新的方向就会出错17下一章那个最小训练循环里,那一行 opt.zero_grad() 就是干这件事的。

9. 不需要梯度的时候,把账本关掉

结论先行:只做前向的时候,记账是纯浪费。

书里给了两个工具18:

  • 一个「圈一段代码」的写法,被它圈住的那段不建反向图,省下这部分内存与计算;
  • 一个「切断追踪」的方法,从某个张量得到一份不再被追踪的副本。

为什么要专门讲这件事? 因为模型上线之后跑的全是前向。 记的那本账在这时候一点用都没有,却照样占显卡内存—— 一个 70 亿参数的模型,这笔账能占掉好几十个 GB 的显卡内存。

10. 作者的判断与证据

书里给了证据的:

  • 三个零件的反向公式——每一条都从链式法则推出来,加法那一条还配了一次实测6;
  • 前向的组合结果 22026.465794806718——书里的实际打印输出9;
  • 梯度累加——书里连打三次,数值 [2,4,6] → [4,8,12] → [2,4,6] 肉眼可核16

属于作者交代、但推导指回另一本书的:

  • 自动求导的原理细节——书里只给了「拆成原子操作、构造计算图」这一句概括, 并明写「原理细节可参考《神经网络与深度学习》第 4.5 节」12这是这本书的一贯做法:它负责讲怎么用,不负责讲怎么证。

判断(我们的,不是书里的):这一章手写三个零件的教学价值,大于它的工程价值。 现实里没人会去手写加法的反向。但亲手推一遍「乘到另一侧」, 后面读线性层、读注意力、读那三道门时,会省下大量重新理解的时间—— 那些地方的反向公式全是这条规则的变体。 如果错,会错在: 如果读者只打算调用现成的框架、从不看反向那一侧, 这一章确实可以跳。判据是:去看一眼第 06 章那四个反向公式, 判断自己是不是能一眼认出它们都是同一条规则。

11. 边界与局限

这一章没覆盖的(书里明显没有):

没讲什么为什么值得知道
自动求导的两种方向(从前往后累积还是从后往前)书里只实现了从后往前那一种,没说另一种存在
二阶导数有些方法要用到,书里全程只用一阶
用重算换内存的那类手法训练大模型时省内存的常用招,全书没提
自定义算子怎么接进框架的自动求导书里的手写零件是自成一套的,和框架的求导机制并不打通

出门会撞见的名字:

  • requires_grad 是那个「要不要给它记账」的开关,写在张量上14;
  • grad_fn 是账本里那一格:它记着「这个张量是被哪个反向操作算出来的」13;
  • .grad 是梯度存放的地方,只有叶子张量默认有值15;
  • .backward() 是「按账本回放一遍」的那个动作;
  • torch.no_grad() / .detach() 就是第 9 节那两个关账本的工具,前者在文档里叫上下文管理器(圈住一段代码、让它在一种特殊模式下运行的写法)18

补充(不在书里,来自通用知识): 「链式法则」这个名字在中文教材里是固定译法, 英文写作 chain rule;而框架文档里通常不提这个词,直接说 autograd。 读英文文档时不要因为找不到 chain rule 就以为它们讲的是别的东西。

12. 可带走的

  1. 一个算子 = 一对函数:前向算结果,反向算「输入该往哪边改」;
  2. 加法把上游的数原样分给两个输入;
  3. 乘法把上游的数乘到「另一侧」的输入上——这条规则后面会反复出现;
  4. 前向必须把输入存下来,否则反向没得算;
  5. 主走查的前向是 22026.4658,反向拿回 66079.397 和三个 44052.932, 比值 1.5 正好对应搭档 3 与 2 之比;
  6. 框架边算边记账,调一次就回放;
  7. 只有叶子张量默认保留梯度,中间结果要看得先声明;
  8. 梯度是累加不是覆盖,所以每轮训练前必须清零;
  9. 累加这个设计是为了让「拆成几小份分别算」成为可能;
  10. 只做前向时把账本关掉,能省下可观的显卡内存。

13. 原文地图

主题原书章原文位置
链式法则与前向/反向函数第1章 实践基础text/02-ch01.txt:704(搜「前向计算」) · text/02-ch01.txt:725(搜「二者一一对应」)
算子的定义与基类第1章 实践基础text/02-ch01.txt:727(搜「带前向、反向双计算的基础单元」) · text/02-ch01.txt:752(搜「基类把」)
加法算子第1章 实践基础text/02-ch01.txt:777(搜「把梯度原样向两个输入分发」) · text/02-ch01.txt:801(搜「grad is」)
乘法算子第1章 实践基础text/02-ch01.txt:805(搜「即梯度」)
指数算子与组合第1章 实践基础text/02-ch01.txt:819(搜「指数运算」) · text/02-ch01.txt:843(搜「22026」)
自动求导与计算图第1章 实践基础text/02-ch01.txt:851(搜「自动微分」) · text/02-ch01.txt:855(搜「原子操作」)
最小例子的三个输出第1章 实践基础text/02-ch01.txt:872(搜「requires_grad=True代表参与梯度计算」) · text/02-ch01.txt:934(搜「c是中间结果」)
梯度累加与清零第1章 实践基础text/02-ch01.txt:962(搜「而不是覆盖」)
关掉梯度第1章 实践基础text/02-ch01.txt:999(搜「上下文管理器」)

Footnotes

  1. 出处:「第1章 实践基础」第 704 段(text/02-ch01.txt:704,搜「前向计算」)。原文把一个 L 层网络写成嵌套形式,指出从输入到损失的过程称为前向计算,要训练网络就要算损失对所有参数的偏导数,而这一步由链式法则给出。

  2. 出处:「第1章 实践基础」第 725 段(text/02-ch01.txt:725,搜「二者一一对应」)。原话:沿着前向方向定义的那一支称为前向函数,对应的那一支就是反向函数,二者一一对应。

  3. 出处:「第1章 实践基础」第 727 段(text/02-ch01.txt:727,搜「带前向、反向双计算的基础单元」)。原话:只要为每个基础函数同时实现前向和反向两套计算,复杂模型就能通过它们的组合得到,梯度也能由链式法则自动反向传播;深度学习框架把这种单元称为算子。

  4. 出处:「第1章 实践基础」第 752 段(text/02-ch01.txt:752,搜「基类把」)。原文说明基类把「怎么调用」和「必须实现什么」两件事钉死:实例可以像函数那样调用,而 forward 与 backward 都留作待重写的钩子。

  5. 出处:「第1章 实践基础」第 777 段(text/02-ch01.txt:777,搜「把梯度原样向两个输入分发」)。原文先给出前向 z = x + y,再由链式法则推出两个输入端的梯度都等于上游梯度。 2

  6. 出处:「第1章 实践基础」第 801 段(text/02-ch01.txt:801,搜「grad is」)。这是书里取 x = 1、y = 4、上游梯度为 1 时的实际打印输出,两个梯度都是 1。 2

  7. 出处:「第1章 实践基础」第 805 段(text/02-ch01.txt:805,搜「即梯度」)。原话:反向时由链式法则得到 δx = δz × y、δy = δz × x——即梯度需要与「另一侧」的输入相乘。

  8. 出处:「第1章 实践基础」第 819 段(text/02-ch01.txt:819,搜「指数运算」)。原文由 dz/dx = exp(x) 直接给出 δx = δz × exp(x)。

  9. 出处:「第1章 实践基础」第 843 段(text/02-ch01.txt:843,搜「22026」)。这是书里给定 a、b、c、d 的具体取值后按 g = exp(a×b + c×d) 组合调用三个算子的实际输出:g: 22026.465794806718。取值见第 834 段(text/02-ch01.txt:834,搜「a, b, c, d = 2, 3, 2, 2」)。 2 3

  10. 出处:「第1章 实践基础」第 847 段(text/02-ch01.txt:847,搜「执行上述算子的反向过程」)。这是书里的动手练习 1.4,原文要求读者自己执行反向并验证梯度是否正确——所以四个偏导的具体数值书里没有打印,本节那四个数是我们照书里给出的三条反向公式算的。

  11. 出处:「第1章 实践基础」第 851 段(text/02-ch01.txt:851,搜「自动微分」)。原话:主流深度学习框架几乎都内置了自动微分,开发者只需写前向计算,框架便能依据它自动构造对应的反向计算。

  12. 出处:「第1章 实践基础」第 855 段(text/02-ch01.txt:855,搜「原子操作」)。这是书里的一条笔记:自动微分的核心思想是把数值计算拆分为若干原子操作,构造一张计算图,每个节点保存前向结果与局部导数,沿图反向遍历即可按链式法则得到所需梯度;同一条笔记末尾写明「原理细节可参考《神经网络与深度学习》第 4.5 节」 2

  13. 出处:「第1章 实践基础」第 861 段(text/02-ch01.txt:861,搜「动态计算图」)。原话:PyTorch 在前向执行过程中边算边记录算子与张量,并在每个有梯度需求的张量上挂接对应的反向操作,调用 backward 时再按记录回放。 2

  14. 出处:「第1章 实践基础」第 872 段(text/02-ch01.txt:872,搜「requires_grad=True代表参与梯度计算」)。三个梯度的实际输出见第 884–886 段(text/02-ch01.txt:884,搜「grad is: 5.0」)。 2 3

  15. 出处:「第1章 实践基础」第 934 段(text/02-ch01.txt:934,搜「c是中间结果」)。原话:c 是中间结果而非叶子,按默认设置不保留梯度,所以 c.grad 为 None;若调试时希望查看,可在前向后追加 c.retain_grad()。 2

  16. 出处:「第1章 实践基础」第 962 段(text/02-ch01.txt:962,搜「而不是覆盖」)。原话:每次 backward 都会把新算出的梯度加到 .grad 上,而不是覆盖;这种设计便于实现「梯度累计」一类技巧,但常规训练时必须先把上一步的梯度清零。三次打印的实际输出见第 980–984 段(text/02-ch01.txt:980,搜「1st backward」)。 2 3

  17. 出处:「第1章 实践基础」第 1236 段(text/02-ch01.txt:1236,搜「如果忘记清零」)。这是书里放在最小训练循环旁边的一条笔记:忘记清零,框架会把上一步算出的梯度继续累加,参数更新方向会越走越偏。

  18. 出处:「第1章 实践基础」第 999 段(text/02-ch01.txt:999,搜「上下文管理器」)。原话:推断阶段只做前向,没有必要建立反向图;用 torch.no_grad() 上下文管理器包裹相关代码可以省去这部分内存与计算开销,而 tensor.detach() 给出一个不再追踪梯度的副本。 2