bobofamilyNIU CHUN
04/36

自动求导引擎:规则归节点,顺序归拓扑排序

2026-08-3112 分钟—阅读

前三篇解决的是「看懂反向传播」,这一篇解决的是「把它写成能自动跑的代码」。

没有新数学。链式法则、δ、多路径求和都已经推过,难点全在工程。

数值求导退到了什么位置

不从符号求导入手,先看最笨的办法 —— 数值扰动:

f'(x) ≈ [ f(x+h) − f(x) ] / h

f(x) = 3x² − 4x + 5 在 x = 3 处,取 h = 0.001 得 14.003,解析解 6x − 4 = 14。对得上。

但把 h 从 0.1 一路减到 1e-14,结果先越来越准,然后突然崩掉。因为 h 太小时 f(x+h) 和 f(x) 在浮点里几乎相等,相减把有效数字吃光了。

所以数值求导不能用来训练,有两个理由:

  • 慢:每个参数都要单独跑一次前向。13,002 个参数就是 13,002 次
  • 不稳:h 小到一定程度精度反而崩

但它没有退场,只是换了岗位 —— 现在用来做 gradient check:反向传播写完之后,拿数值求导验一遍梯度算对没有。

Value:让每个数记住自己怎么来的

核心思路:d = a*b + c 执行完之后,d 身上应该带着「我由 a*b 的结果和 c 相加得来」这个信息。所有数连起来就形成一张计算图。

四个字段:

字段存什么
data数值本身
_prev我由哪些 Value 算出来(set)
_op用什么运算得到的(仅供画图,不参与计算)
grad反向传播填进来的梯度,初值 0

这张图是手写的,Python 没帮任何忙。 关键在于 _prev 存的是对象引用,不是数值拷贝 —— 一层层引用下去就是一棵树。_prev 为空的就是叶子(输入和参数)。

运算符重载是另一半:因为实现了 __add__ / __mul__,才能写出 d = a*b + c 这种和普通数学一模一样的式子,背地里却在建图。PyTorch 的张量也是靠重载这些方法实现的。

下面这个动画把一个 tanh 神经元的全过程拆成了 16 步:建图 → 拓扑排序 → 十次 _backward。

交互动画 · 建图 → 拓扑排序 → 反向 · 可单步新窗口打开 ↗

手填一遍:两条规则就够用

在自动化之前,先手工填一遍。只需要两条规则:

  • 加法节点:梯度原样传给两个孩子(局部导数是 1)
  • 乘法节点:梯度乘上「另一个因子」再传

十个节点填完:

节点grad
o1.0(起点)
n0.5
四个加法节点、b全是 0.5
x1−1.5
w11.0
x20.5
w20.0

两个现象值得记:

w2.grad = 0,因为 x2 = 0。 第二篇里那条「三项里任意一个接近 0,这个权重就不用动」,这是它的数值形态 —— 前一层那个神经元没有信号,连到它的权重怎么调都是乘 0。

四个加法节点的 grad 全是 0.5,一路没有衰减。 加法的局部导数是 1,乘 1 不缩水。这也解释了为什么偏置的梯度总等于本层的 δ。

手填阶段有个坑:为了引用中间节点,n = x1*w1 + x2*w2 + b 不得不拆成五行、给每个中间结果起名字。中途一旦新建 s1 = w1 * x1 就会错乱 —— 那是个全新对象,不是图上那个。手工方式在真实网络上完全不可用。

局部规则与全局调度分离

关键洞察:上面那两条规则和整张图无关,只和「我是什么运算」有关。乘法节点永远按乘法规则传,不管它在图的哪个位置。

所以把规则下放到节点自己:

谁负责内容
每个节点怎么传_backward局部规则,创建节点时就定好
按什么顺序传backward()全局调度,遍历整张图

这是所有自动求导框架的骨架。

闭包:把规则存进节点自己

def __mul__(self, other):
out = Value(self.data * other.data, (self, other), '*')
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward # 存函数,不是调用
return out

三个要点:

  • 顺序不能反:先创建 out,再定义 _backward,因为函数体要引用 out.grad
  • 定义时不求值:out.grad 此刻还是 0,要等反向传播走到这里才读。闭包捕获的是变量本身,不是快照
  • 每个节点绑定自己那三个对象:图上一万个乘法节点就有一万个 _backward,各记各的 self / other / out,互不干扰

闭包把参数提前绑好,所以调用时零参数 —— 这才能让 backward() 里的循环写得那么干净。

拓扑排序:append 的位置决定正确性

def backward(self):
topo, visited = [], set()
def build(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build(child)
topo.append(v) # 先孩子,后自己
build(self)
self.grad = 1.0 # 打开水龙头
for node in reversed(topo):
node._backward()

topo.append(v) 放在递归之后,这一点决定整个算法的正确性。先处理完所有孩子再把自己加进列表,就保证列表里孩子永远排在父亲前面;反过来遍历就是从输出往输入走。

self.grad = 1.0 是整条链上唯一一个不用算的数(∂o/∂o = 1),其余全是内部传递。

叶子的 _backward 是 lambda: None,所以循环可以无差别地调每个节点,不用判断是不是叶子。

为什么必须是 +=

故意写错一次就看清了:

a = Value(3.0)
b = a + a
b.backward()
# 用 = → a.grad = 1.0 ❌
# 用 += → a.grad = 2.0 ✅

原因是 a + a 时 self 和 other 是同一个对象,_backward 里那两行都在改同一个 a.grad。用 =,后写的覆盖先写的。

而 b = a + a = 2a,所以 ∂b/∂a = 2 才是对的。

这个加号就是第三篇那个 Σ 的代码形态。 那里讲的是「隐1 有两个下游,就有两项相加」;这里 a 被用了两次,就是两次 +=。巧妙之处在于:_backward 只管自己这一份贡献,累加由 += 自动完成,写代码的人不需要判断「这个节点是不是被多处使用」。

配套代价:grad 不会自动清空。跑第二次 backward() 会累加到第一次上。训练循环里必须每轮清零 —— PyTorch 里 optimizer.zero_grad() 存在的理由完全相同。

算子补齐:原子的与组合的

新增的原子算子(造了新节点,必须写 _backward):

算子局部导数
expeˣ 本身,即 out.data
__pow__n·xⁿ⁻¹

组合算子(只转发,不写 _backward):

def __truediv__(self, other): return self * other**-1
def __neg__(self): return self * -1
def __sub__(self, other): return self + (-other)
def __rmul__(self, other): return self * other
def __radd__(self, other): return self + other

判断标准很简单:代码里出现 Value(...) 就要写 _backward;只是 return self * ... 就不用。 计算图上根本没有「减法节点」,只有一个乘法和一个加法;除法通过 __pow__(-1) 自动获得正确梯度。

少写三个 _backward,就少三处可能写错的地方。

__rmul__ 什么时候被调,取决于 Python 执行 x * y 的固定流程:先试 type(x).__mul__(x, y),成功就结束;返回 NotImplemented 就试 type(y).__rmul__(y, x)。r 是 reflected —— 左边处理不了,问右边愿不愿意接手。所以 2 * n 能跑。

还需要让 __add__ / __mul__ 接受纯数字,开头加一行:

other = other if isinstance(other, Value) else Value(other)

拆开 tanh:节点粒度可以自选

tanh 前面是当作一个算子用的,它的导数 1 − t² 是手推的。但它也可以用基础算子拼出来:

e = (2*n).exp()
num = e - 1
den = e + 1
o = num / den
o.backward()
交互动画 · 拆开的 tanh · e 的两条路径 · 可单步新窗口打开 ↗

叶子梯度与打包版完全一致(−1.5 / 1.0 / 0.5 / 0),但图上节点从 5 个变成十来个。

值得看的是动画里 e 那个分叉:e 同时被 num 和 den 使用,反向时两个 _backward 先后往 e.grad 写一次 —— −0.1036 加 +0.1464 累加成 +0.0429。上一节那个 += 在这里现身了。

于是结论是:自动求导不规定你在什么粒度上定义算子。

打包成大算子拆成小算子
要做的自己推导数直接拼
速度快慢
出错风险推错就完了复用已验证的

这就是 PyTorch 里 F.softmax、F.layer_norm 这类融合算子存在的理由 —— 它们本可以用基础运算拼出来,打包是为了速度和数值稳定。

自动求导只保证数学正确,不保证数值稳定。 sigmoid 拆开写成 1 / (1 + (-x).exp()),x = -1000 时 exp(1000) 直接溢出。梯度是对的,但你已经拿不到数了。

顺带:eˣ 不能当激活函数

  • 输出无上界:x=10 → 22026,x=50 → 5×10²¹,多层叠加直接溢出成 inf/NaN
  • 导数也是 eˣ:梯度同步爆炸 —— 和第二篇讲的梯度消失相反的另一个极端

它在网络里到处都是,但永远被除法约束着:sigmoid 的分母、tanh 的分子分母、softmax 除以总和。这里实现 exp 是为了拆 tanh,不是为了拿它当激活函数。

完整代码

上面是拆着讲的,这里放一份能直接跑的完整版。

Value

import math
class Value:
def __init__(self, data, _children=(), _op='', label=''):
self.data = data
self._prev = set(_children)
self._op = _op
self.grad = 0.0
self.label = label
self._backward = lambda: None
def __repr__(self):
return f"Value(data={self.data})"
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), '+')
def _backward():
self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def tanh(self):
t = (math.exp(2*self.data) - 1) / (math.exp(2*self.data) + 1)
out = Value(t, (self,), 'tanh')
def _backward():
self.grad += (1 - t**2) * out.grad
out._backward = _backward
return out
def exp(self):
out = Value(math.exp(self.data), (self,), 'exp')
def _backward():
self.grad += out.data * out.grad
out._backward = _backward
return out
def __pow__(self, other):
assert isinstance(other, (int, float))
out = Value(self.data ** other, (self,), f'**{other}')
def _backward():
self.grad += other * (self.data ** (other-1)) * out.grad
out._backward = _backward
return out
# 以下都是组合算子:只转发,不造节点,所以不写 _backward
def __truediv__(self, other): return self * other**-1
def __neg__(self): return self * -1
def __sub__(self, other): return self + (-other)
def __rmul__(self, other): return self * other
def __radd__(self, other): return self + other
def backward(self):
topo = []
visited = set()
def build(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build(child)
topo.append(v)
build(self)
self.grad = 1.0
for node in reversed(topo):
node._backward()
print([v.label for v in topo])

末尾那行 print 是调试用的,把拓扑序打出来 —— 下面两个用例的区别就靠它看。

画图

draw_dot 需要 graphviz,只用来看图,和求导无关:

from graphviz import Digraph
def trace(root):
"""顺着 _prev 走遍整张图,收集所有节点和边"""
nodes, edges = set(), set()
def walk(v):
if v not in nodes:
nodes.add(v)
for child in v._prev:
edges.add((child, v))
walk(child)
walk(root)
return nodes, edges
def draw_dot(root):
dot = Digraph(format='svg', graph_attr={'rankdir': 'LR'})
nodes, edges = trace(root)
for n in nodes:
uid = str(id(n))
label = getattr(n, 'label', '')
dot.node(name=uid,
label=f"{{ {label} | data {n.data:.4f} | grad {n.grad:.4f} }}",
shape='record')
if n._op:
dot.node(name=uid + n._op, label=n._op)
dot.edge(uid + n._op, uid)
for a, b in edges:
dot.edge(str(id(a)), str(id(b)) + b._op)
return dot

两个用例

前半部分一样,只有最后那一步 tanh 不同。

x1 = Value(2.0, label='x1'); x2 = Value(0.0, label='x2')
w1 = Value(-3.0, label='w1'); w2 = Value(1.0, label='w2')
b = Value(6.8813735870195432, label='b')
x1w1 = x1*w1; x1w1.label = 'x1*w1'
x2w2 = x2*w2; x2w2.label = 'x2*w2'
s = x1w1 + x2w2; s.label = 'x1w1+x2w2'
n = s + b; n.label = 'n'
o = n.tanh(); o.label = 'o' # 打包版
o.backward()
draw_dot(o)

打印出来的拓扑序是 10 个节点(这是其中一次运行的结果):

['b', 'x1', 'w1', 'x1*w1', 'x2', 'w2', 'x2*w2', 'x1w1+x2w2', 'n', 'o']

把最后一行换成拆开版:

e = (n * 2).exp(); e.label = 'e' # 2*n 也行,走 __rmul__
num = e - 1; num.label = 'num'
den = e + 1; den.label = 'den'
o = num / den; o.label = 'o'
o.backward()

拓扑序变成 18 个节点:

['', '', 'b', 'w1', 'x1', 'x1*w1', 'w2', 'x2', 'x2*w2',
'x1w1+x2w2', 'n', '', 'e', 'den', '', '', 'num', 'o']

多出来的八个里有五个是空 label —— 那是没被显式命名的中间量:常数 2、1、-1 各被包成一个叶子 Value,加上 n*2 这个乘法节点和 den**-1 这个幂节点。

这两个列表的顺序你多半复现不出来。 _prev 是个 set,兄弟节点之间谁先谁后取决于对象的 id,换一次运行、换一台机器都可能不同。拓扑排序唯一保证的是「孩子排在父亲前面」 —— 也就只需要保证这一条,因为反过来遍历时,只要每个节点的下游都已经算完,梯度就是对的。 节点个数(10 与 18)倒是稳定的。

b 的值 6.8813735870195432 不是随便挑的:它让 n = 0.8813735870、o = tanh(n) = 0.7071067812(也就是 √2/2),于是 n.grad = 1 − o² = 0.5 正好是个整数,手算对照方便。

两个版本跑完,叶子梯度完全一致。

和前三篇的对应

前三篇这一篇
解决什么看懂反向传播写出能自动跑的代码
难点数学工程(闭包、递归遍历)
链式三项手推_backward 里的一行
多路径求和公式里的 Σ+=
δ手填的中间量Value.grad 字段

一句话概括这一篇:把「每个运算怎么传梯度」存进节点自己,把「按什么顺序传」交给拓扑排序。 两者分离,就得到一个能处理任意表达式的引擎 —— 只要基础算子的导数写对了,任何用它们拼出来的复合函数,梯度都免费获得。


到这里引擎写完了,但它还只会对表达式求导,不是神经网络。

把 Neuron / Layer / MLP 三个类搭在它上面,是下一篇的内容。