Skip to content

第 2 章 写出反向传播引擎 ​

手算一次计算图不难,难的是每次改表达式都要重写求导程序。解决办法是让每次运算顺手留下两样东西:它依赖哪些输入,以及拿到输出梯度后怎样给输入加梯度。等最终结果出来,再统一倒着走一次图。

这一章的完整实现是 examples/part1/scalar_engine.py。它借鉴课程的 Value 设计,同时明确改变了重复反向传播的行为,不能把它当成 micrograd 原库的原样复制。课程实现

2.1 一个数还需要保存什么 ​

普通 float 只保存数值。Value 还保存 grad、父节点和一个反向函数:

python
class Value:
    def __init__(self, data, children=(), op=''):
        self.data = float(data)
        self.grad = 0.0
        self.parents = tuple(children)
        self.op = op
        self._backward = lambda: None

data 是前向值;grad 是最终标量目标对当前值的导数。parents 保存输入对象本身,不能只保存它们的数值,否则无法把梯度送回同一个对象。op 方便画图和调试,数学计算不依赖这个字符串。叶节点没有上游运算,反向函数暂时什么也不做。

Python 的 __add__ 让 a+b 能创建一个新的 Value。这次调用既算值,又建立图,还创建一个闭包:

python
def __add__(self, other):
    other = self.wrap(other)
    out = Value(self.data + other.data, (self, other), '+')
    def backward():
        self.grad += out.grad
        other.grad += out.grad
    out._backward = backward
    return out

闭包保留 self、other、out 的引用,稍后调用时还能访问它们。它不是现在就执行求导传播:此刻 out.grad 通常还没收到最终目标传来的信息。wrap 把普通数字转成 Value,使 a+2 与 a+b 使用同一路径。

乘法的局部规则不同:给左边的梯度乘右边前向值,反过来也一样。本书代码先把这两个值保存到局部变量 a,b,防止后来不小心修改 .data,让反向函数读到与前向不一致的值。课程简化实现直接读取对象数值,因此正常使用要求先反向,再改参数。

2.2 为什么每条贡献都必须累加 ​

令 a=Value(3),执行 b=a+a。数学上 b=2a,所以 db/da=2。加法闭包里 self 和 other 实际指向同一个对象。第一条 += 加 1,第二条再加 1,得到 2。若改成赋值 =,第二条会覆盖第一条,最终只剩 1。

图遍历的去重和梯度的累加,解决的是两件不同的事:一个节点的反向函数只执行一次;该节点从多个使用者收到的梯度必须全部加起来。课程用集合保存父节点并不会丢掉 a+a 的第二个导数贡献,因为两条加法贡献写在闭包中。我们用元组保留输入顺序,遍历时另外用集合防止重复访问。micrograd 引擎固定源码

在更明显的分叉图 q=(ab)(a+b) 中,如果在一条路径尚未回传完之前就执行 a 的上游计算,也会丢信息。因此还需要正确顺序。

2.3 拓扑排序保证先收齐,再往回传 ​

递归访问一个节点时,先访问它所有父节点,再把自己放进列表。列表由叶子走向结果,称为拓扑顺序。反过来遍历,就由结果走向叶子:

python
order, seen = [], set()
def visit(v):
    if v in seen:
        return
    seen.add(v)
    for parent in v.parents:
        visit(parent)
    order.append(v)
visit(loss)

for v in order:
    v.grad = 0.0
loss.grad = 1.0
for v in reversed(order):
    v._backward()

想象一个中间节点被后面的两个节点使用。前向时,它必须排在两个使用者之前;反向时,两个使用者就都排在它之前。轮到它时,两份梯度已经加齐。对有 Vg 个节点、Eg 条依赖边的图,这次遍历的时间量级是 O(Vg+Eg)。这里下标 g 区分图节点数与后面章节的词表大小。

这份教学实现递归构图,图太深会受 Python 递归深度限制;每个标量都分配 Python 对象,也会耗费大量内存。它适合看清算法,不适合用来训练实际大型网络。

有一个 API 差异必须记牢:课程和 micrograd 原库不会在 backward() 开头清空整个图,训练循环需主动清空参数梯度。本书实现会清空所有可达节点,所以同一图重复调用两次会得到同一个结果,而不会累积。这个选择使初学者实验更明确,但也意味着不能直接拿连续两次 backward() 来做跨批次梯度累加。后面换用 PyTorch 时,要重新遵守 PyTorch 的累加行为。

交互实验:反向传播与梯度累加 ​

训练实验室 · 反向传播

一个输入走两条路,梯度该怎么收?

a、b 都被使用两次。先算前向数值,再倒着传梯度,看看第二条路径如何加到已有结果上。

分叉图:u = a × b,v = a + b,q = u × v。

01

输入 · 从两条路径开始

同时进入乘法 u 与加法 v。修改后从零梯度重算。完整前向预览:q=-6。两条路都用到了 a;下方逐步演算从零梯度开始。
另一条输入,同样会收到来自两条路径的贡献。完整前向预览:q=-6。修改 b 会同时改变 u 和 v。

滑块与精确输入控制同一个值。输入越界会报错,不会自动截断。

02

过程 · 先前向,再反向

步骤 0 / 7 · 输入

先给 a、b 赋值。中间节点还没计算,所有梯度暂时为 0。

节点前向值累计梯度
a-20
b30
u待计算0
v待计算0
q待计算0

每个梯度都针对最终输出 q;反向尚未结束时,贡献可能还没收齐。

展开依赖图和本步链式法则

输入 a、b 分叉为乘法 u 与加法 v 汇入输出 q

  1. 输入节点 · a输入 a前向值:-2累计 ∂q/∂a:0输入节点
  2. 输入节点 · b输入 b前向值:3累计 ∂q/∂b:0输入节点
  3. 运算节点 · uu = a × b前向值:待计算累计 ∂q/∂u:0依赖 a、b
  4. 运算节点 · vv = a + b前向值:待计算累计 ∂q/∂v:0依赖 a、b
  5. 运算节点 · qq = u × v前向值:待计算累计 ∂q/∂q:0依赖 u、v

前向运算顺序:u → v → q;反向运算顺序:q → v → u。节点里的梯度始终指“最终输出 q 对此节点的导数”。反向结束前,它可能还没收齐所有贡献。

03

指标 · 两种方法,交叉检查

指标保留完整数字;如数字较长,可左右滑动,或聚焦数字后用方向键查看。

输出 q待计算前向完成后才有值
输入 a 的最终梯度待完成收齐两条路径的贡献
输入 b 的最终梯度待完成收齐两条路径的贡献

完成反向传播后,下面会显示输入梯度与中心差分的对照。

04

解释 · 多条路径,贡献相加

这是反向传播算法,还是预设动画?

页面实际建立依赖图、做拓扑排序、计算前向值,再按逆拓扑顺序把“收到的梯度 × 局部导数”累加给输入。改变 a、b 后会重新演算,不是播放固定数字。图很小,只包含标量加法与乘法,没有执行 PyTorch 或训练神经网络。

每次重新演算都会把整个图的梯度清零。这里的累加发生在一次反向传播的不同路径之间;不要与多个批次之间保留梯度混淆。micrograd 原库和 PyTorch 的参数梯度通常需要显式清零。

中心差分是独立检查,不参与反向求导。对这个图,固定另一个输入后,函数至多是二次多项式,所以中心差分在精确算术下没有截断误差;实际仍有浮点误差。一般非线性函数还会有截断误差。

不操作也能读懂:a=−2、b=3 时,u=−6、v=1、q=−6。a 收到 −6 和 3,合计 −3;b 收到 −6 和 −2,合计 −8。覆盖任何一条贡献都会算错。

依据:micrograd 固定版本源码中的累加与逆拓扑遍历;PyTorch:自动微分。核对于 2026-10-14。

2.4 只实现少量规则,组合出更多运算 ​

令输出节点为 y,收到的梯度为 g=∂L/∂y。每个运算只需给出下表中的局部规则:

运算输入得到的贡献条件或约定
y=xkkxk−1g本实现只支持常数指数;检查实数定义域
y=exp⁡xyg太大的 x 会溢出
y=log⁡xg/xx>0
y=tanh⁡x(1−y2)g输出接近 ±1 时梯度很小
y=max(0,x)g 若 x>0,否则 0在 0 不可导,代码取 0

减法可以写成加负数,除法可以写成乘以负一次幂,因此不用再发明一套独立的求导逻辑。__radd__、__rmul__ 处理 2+x、2*x;__rtruediv__ 处理 2/x。Python 的 sum 默认从整数 0 开始,如果没有反向加法,累加一组 Value 时会出错。

课程用恒等式

tanh⁡x=e2x−1e2x+1

拆开激活函数。令 u=e2x,则 dy/du=2/(u+1)2,du/dx=2u,所以 dy/dx=4u/(u+1)2。另一方面,1−y2=((u+1)2−(u−1)2)/(u+1)2=4u/(u+1)2。两条计算图大小不同,导数应相同。

这也说明,自动微分不要求每个常见函数都必须写成最小原子操作。可以把 tanh 当成一个节点并提供正确的反向函数,图会更小。实际实现用 math.tanh,而不直接使用上面的指数公式,以免大正数导致 exp(2*x) 溢出。公式适合推导,稳定函数适合运行。

2.5 一组能抓出真错误的测试 ​

只检查某个输出等于预期值不够,反向可能仍然错。本书测试分别覆盖单路径、共享节点、分叉路径、重复调用和运算组合:

bash
source qa/environment/env.sh
python examples/part1/scalar_engine.py
python examples/part1/language_lab.py

前一个脚本核对 x+x+x*x 在 x=3 处梯度为 8,重复反向仍为 8;核对分叉图的梯度为 (−3,−8);再比较原生 tanh 与分解形式。后一个脚本把同一个含乘法、指数、平方、对数的函数交给 PyTorch float64,两边前向值和梯度在 10−12 容差内一致。

还可以直接核对成熟框架的局部实现。本书查阅的 PyTorch 2.6 CPU tanh_backward_kernel 在普通实浮点分支计算传入梯度乘以一减输出平方,也就是这里的 g(1−y2)。周围的类型分派和向量化代码负责让同一规则高效覆盖不同张量类型;数学规则并没有因为换成 C++ 而改变。

数值检查、手工推导、另一个自动微分系统是三个不同的证据来源。它们都可能有使用错误,因此测试最好有明确的反例。脚本专门保留了 = 替代 += 的错误演示,确认预期的 2 会变成 1,而不是只在文字里提醒 “小心累加”。

2.6 官方练习:让 Value 支持分类损失 ​

官方 micrograd 练习第二部分给四个打分 z=(0,3,−2,1),让它们经过 softmax 得到概率,再对正确类别 3 的概率取负对数。softmax 的完整解释在第 6 章;这里先把它当成一个可组合表达式:

pi=ezi∑j=03ezj,L=−log⁡p3.

要让给定练习工作,需要乘法、常数幂、除法、反向加法、取负、指数和对数。在最终损失可达的图中,正确类别的指数项同时通过分子和分母影响结果,恰好考验共享图是否正确。其他三个概率输出虽被构建,却不在这个单目标损失的反向可达路径上。log 的反向贡献是 out.grad / x,exp 是 exp(x) * out.grad,两者都要累加。

运行 python examples/part1/micrograd_exercises.py,真实输出的损失约为 2.175515362617,梯度按类别顺序约为 (0.041772571, 0.839024507, 0.005653303, -0.886450381)。正确类别的梯度为负,梯度下降会提高它的打分;其他类别的梯度为正,更新会降低它们。脚本还用 PyTorch 交叉熵独立核对全部分量。详细日志在 qa/part1/micrograd_exercises.log,不要用四舍五入后的数字做严格等值断言。

补充题:给所有打分加同一个常数,概率会变吗?把 ezi+c=ecezi 代入分子分母,公共因子消掉,答案是不变。它是后面稳定 softmax 的依据,也解释了为何上述四个梯度之和为零。

来源与实现边界 ​

本章对应第一讲前半 notebook 的 Value、手工 _backward、拓扑排序、共享节点测试,以及后半 notebook 的运算扩展、tanh 分解和 PyTorch 对照。前半 notebook;官方练习。本引擎只求一阶导数,不支持张量广播、自动混合精度、高阶梯度、并行执行或生产级图生命周期管理。理解它以后换用成熟框架,是把同样的规则交给更完整的实现。

保留各书来源与许可 · 示例运行条件以正文为准