自动求导引擎:规则归节点,顺序归拓扑排序
前三篇解决的是「看懂反向传播」,这一篇解决的是「把它写成能自动跑的代码」。
没有新数学。链式法则、δ、多路径求和都已经推过,难点全在工程。
数值求导退到了什么位置
不从符号求导入手,先看最笨的办法 —— 数值扰动:
f'(x) ≈ [ f(x+h) − f(x) ] / hf(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 |
|---|---|
o | 1.0(起点) |
n | 0.5 |
四个加法节点、b | 全是 0.5 |
x1 | −1.5 |
w1 | 1.0 |
x2 | 0.5 |
w2 | 0.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 + ab.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):
| 算子 | 局部导数 |
|---|---|
exp | eˣ 本身,即 out.data |
__pow__ | n·xⁿ⁻¹ |
组合算子(只转发,不写 _backward):
def __truediv__(self, other): return self * other**-1def __neg__(self): return self * -1def __sub__(self, other): return self + (-other)def __rmul__(self, other): return self * otherdef __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 - 1den = e + 1o = num / deno.backward()叶子梯度与打包版完全一致(−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 三个类搭在它上面,是下一篇的内容。