WARNING
🧪 Beta公测版本提示:教程主体已完成,正在优化细节,欢迎大家提Issue反馈问题或建议。
s06 反向传播与链式法则 — demo.py 代码详解
Download demo.py Download plot_demo.py
运行方式
cd docs/nn-decision/dl/backprop/code
python demo.py # mini autograd 主线(不画图)
python plot_demo.py # MSE / 演示1 计算图 / Fan-out代码逐段详解
第1步:导入库 — 每个库是做什么的
import os
import math
from typing import Set, List, Tupleos:操作系统接口库。此处用于创建images/输出目录,确保图片保存路径存在。math:数学函数库。提供math.exp()(指数函数)、math.tanh()(双曲正切)等底层数学运算,用于实现 Sigmoid、Tanh 等激活函数。typing.Set, List, Tuple:类型注解。Set用于记录计算图中已访问的节点,List用于拓扑排序结果列表,Tuple用于节点的前驱元组。
为什么不用 NumPy? 因为这个 demo 的目标是展示自动微分的底层原理——每个数值都是标量
Value,而非 NumPy 数组。下一节 s07 才扩展到矩阵/向量级别的反向传播,届时才需要 NumPy。
第2步:Value 类 — 自动微分的核心节点
整个 demo 的基石是 Value 类。每个 Value 对象就是计算图中的一个节点,它存储四样东西:
| 属性 | 含义 | 用途 |
|---|---|---|
data | 该节点的数值(标量) | 前向传播的结果值 |
grad | 累积的梯度 | 反向传播时累加 |
_backward | 局部反向传播函数(闭包) | 定义该操作对输入的梯度如何分配 |
_prev | 前驱节点集合 | 用于拓扑排序,确定反向传播顺序 |
class Value:
def __init__(self, data: float, _children: Tuple = (), _op: str = ""):
self.data = data # 存储数值
self.grad = 0.0 # 梯度初始化为 0
self._backward = lambda: None # 默认无操作(叶子节点)
self._prev = set(_children) # 前驱节点集合
self._op = _op # 操作名称(如 "+", "*", "ReLU")设计要点:
grad初始为 0,反传时通过+=累加而非=赋值——这是因为一个变量可能被多条路径使用(fan-out),梯度需要求和。_backward是一个闭包(closure),它捕获了当前操作的局部上下文(如两个输入的 data 值),从而在反向时无需重新计算。_children/_prev装的是这扇门的输入(谁喂进来),这个Value自己是输出(return给调用者)。叶子(手写的Value(2.0))没有输入,_backward为空。
第3步:基本算术运算 — 局部梯度规则
每个运算 __add__、__mul__ 都做两件事:前向算出一个新节点 out,以及给 out 挂上局部 _backward。先把名字和数据流钉死,再看公式。
注释里的 、 不是激活,也不是偏置
正文神经元公式里:
写 u = p + q 时,Python 调用的是 p.__add__(q),对应关系是:
| 角色 | 数学 | 代码 | 从哪来 | 给到谁 |
|---|---|---|---|---|
| 左输入 | self | 已经存在的 Value:叶子(权重 / 输入 / 偏置),或上一扇门 return 出来的 out | 本门 | |
| 右输入 | other | 同上;若是普通 float,会先包成 Value | 本门 | |
| 输出 | out | 本门用 .data 当场算出来 | return out 交给调用者,成为下一扇门的输入,直到变成损失 |
门不认识「这是权重还是激活」。谁出现在 + / * 两边,谁就是这扇门的输入。
前向:两个输入的数值流进门,门新建 out,把 _prev = {self, other} 记下来(反向时好找到「梯度该还给谁」),把 _backward 挂在 out 上,把 out 交出去。
反向:更靠近 out.grad。这扇门的 _backward 被调用时,只负责把这份「下游的不满」分回 self.grad 和 other.grad。它既不创造梯度,也不直接改权重——只是把账分给两个输入。
嵌进神经元
weight = Value(2.0) # 叶子:权重 w
x = Value(3.0) # 叶子:输入 x
bias = Value(1.0) # 叶子:偏置 b(注意:这是偏置,不是门的 q 的专用名)
wx = weight * x # 乘法门:self=weight, other=x, out=wx 交给下一行
z = wx + bias # 加法门:self=wx, other=bias, out=z 交给激活或损失| 代码 | 这扇门 | self( | other( | out 给到谁 |
|---|---|---|---|---|
wx = weight * x | 乘法 | 叶子 | 叶子 | 下一行加法门的左输入 |
z = wx + bias | 加法 | 上一扇乘法门的输出 | 叶子 | 激活,或直接进损失 |
所以:输入不是凭空出现的——来自叶子或上一扇门;输出也不是写进某个全局变量——return 给写这行表达式的人,由他接到下一扇门或接到
加法门 __add__
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 # 把 ∂L/∂u 原样还给左输入 p
other.grad += 1.0 * out.grad # 把 ∂L/∂u 原样还给右输入 q
out._backward = _backward
return outself/other:这扇加法门的两个加数、 (上一例里就是 wx和bias)。out:和,前向交给调用者;反向时 out.grad已经是,从下游来。 - 局部导数
、 ,所以加法门是梯度分发器:下游来多少,两边各原样累加多少。
乘法门 __mul__
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 # 还给 p 时乘上 q 的前向值
other.grad += self.data * out.grad # 还给 q 时乘上 p 的前向值
out._backward = _backward
return outself/other:两个乘数、 (上一例里就是 weight和x)。out:积,同样 return给调用者。- 局部导数
、 ,所以梯度交换:还给左边时乘的是右边的前向值。上一例 里, 收到的是 , 收到的是 。
闭包里用的是前向时的 other.data / self.data,不是反向时的值。前向存中间值、反向再用,这是自动微分的标准做法。
幂运算 __pow__
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这里底数 self(从上一节点来),指数 other(不当成图上的节点,所以 _children 只有 self)。输出 return 给调用者。反向只把梯度还给底数:
例如 .grad 可写。
除法 __truediv__:巧妙的复用法
def __truediv__(self, other):
return self * other ** -1没有单独写除法的 _backward,而是拆成已有的门:
- 先幂运算:输入
other(),输出 ,交给乘法门。 - 再乘法:左输入
self(),右输入 ,输出 ,交给调用者。
两条已有 _backward 串起来,链式法则自动得到:
第4步:激活函数 — 非线性变换的反向传播
激活函数是一元门:只有一个输入 self(通常是上一扇加法门送来的 out(激活值,交给下一层或损失)。没有 other。反向时 out.grad 仍从更靠近 self。
ReLU:梯度门控
def relu(self):
out = Value(max(0.0, self.data), (self,), 'ReLU')
def _backward():
self.grad += (out.data > 0) * out.grad
out._backward = _backward
return out数学定义与导数:
为什么此处用它? ReLU 是隐藏层最常用的激活函数。它的导数非0即1,没有饱和区(不像 Sigmoid/Tanh 在两端导数趋近0),因此能有效缓解梯度消失问题。
代码细节:(out.data > 0) 在 Python 中产生布尔值 True/False,但在算术运算中 True=1、False=0。因此整个表达式相当于"如果前向值 > 0,梯度通过;否则截断为0"。这是一个优雅的梯度门控(gating)实现。
Sigmoid:数值稳定技巧
def sigmoid(self):
x = self.data
if x >= 0:
s = 1.0 / (1.0 + math.exp(-x)) # 标准公式
else:
exp_x = math.exp(x)
s = exp_x / (1.0 + exp_x) # 数值稳定版本
out = Value(s, (self,), 'Sigmoid')
def _backward():
self.grad += out.data * (1 - out.data) * out.grad
out._backward = _backward
return out数学定义与导数:
Sigmoid 导数的优美性质是可以用前向输出直接计算导数,无需知道原始输入 out.data 即可。
数值稳定技巧:当
Tanh:零中心输出
def tanh(self):
t = math.tanh(self.data)
out = Value(t, (self,), 'Tanh')
def _backward():
self.grad += (1 - out.data ** 2) * out.grad
out._backward = _backward
return out数学定义与导数:
与 Sigmoid 类似,Tanh 的导数也可以用前向输出直接计算。Tanh 的输出范围是
Exp:导数等于自身
def exp(self):
out = Value(math.exp(self.data), (self,), 'exp')
def _backward():
self.grad += out.data * out.grad
out._backward = _backward
return out数学公式:
第5步:backward() — 拓扑排序 + 逆序执行
这是整个自动微分引擎的核心。backward() 方法的职责是:从当前节点(通常是损失 _backward()。
def backward(self):
# ---- 步骤 1: 拓扑排序(DFS 实现) ----
topo = []
visited = set()
def build_topo(v: Value):
if v not in visited:
visited.add(v)
for child in v._prev: # 递归访问所有前驱
build_topo(child)
topo.append(v) # 后序遍历:子节点在前
build_topo(self)
# ---- 步骤 2: 初始化梯度 ----
self.grad = 1.0 # ∂L/∂L = 1
# ---- 步骤 3: 按拓扑逆序调用 backward ----
for node in reversed(topo):
node._backward()为什么需要拓扑排序?
反向传播要求按计算图的逆序执行:先计算离输出近的节点的梯度,再计算离输入近的。拓扑排序确保了:当调用节点 _backward() 时,它的所有后继(路径上更靠近输出的节点)的梯度已经计算完毕。
具体来说,build_topo 使用 DFS 后序遍历:
- 从根节点(损失
)出发,沿 _prev(前驱关系)反向遍历 - 后序遍历确保:子节点(离输入近的)先被
topo.append(),父节点后 - 最终
reversed(topo)得到的顺序就是:从出发,逐层向输入传播
初始梯度为什么是 1.0?
我们求的是「损失 self.grad = 1.0 理解成:从这个标量(训练时就是 MSE)出发,开始给每个参数算「你要负多大的责」。演示 1–3 里这个标量可以是任意表达式;演示 4 起它才是真正的损失。
第6步:演示1 — 基本表达式的反向传播
这段还没有神经网络。四个数字、三扇第 3 步里的门,用来把「前向算出一个标量 → backward() 给每个叶子填上梯度」走通。看懂它,后面训练循环里的 total_loss.backward() 就是同一件事,只是图更大。
表达式(字母只是变量名,不是激活 / 偏置):
代入
a = Value(2.0) # 叶子:没有输入,_backward 为空
b = Value(3.0)
c = Value(4.0)
d = Value(5.0)
e = a * b # 第 1 扇:乘法,self=a, other=b, return 的 out 叫 e
f = e + c # 第 2 扇:加法,self=e, other=c, return 的 out 叫 f
L = f * d # 第 3 扇:乘法,self=f, other=d, return 的 out 叫 L
L.backward() # 从 L 往回走,给上面所有节点填 .grad前向:从左往右算数
每一行都是「两个已有节点喂进一扇门,门 return 一个新节点」:
| 代码 | 哪扇门 | self(左) | other(右) | out.data | 这个 out 接下来给谁 |
|---|---|---|---|---|---|
e = a * b | 乘法 | 下一行加法的左输入 | |||
f = e + c | 加法 | 下一行乘法的左输入 | |||
L = f * d | 乘法 | backward() 的起点 |
验算:.grad 仍是
反向:从最右边往左,一次只看一扇门
L.backward() 先做一件事:L.grad = 1。因为我们求的是「_backward:先最右的乘法,再中间的加法,最后最左的乘法。必须这样——左边那扇门要用到右边已经算好的 out.grad。

第 ① 扇(最右):
self = f = 10,other = d = 5,out = L,out.grad = 1(刚写上的)- 乘法规则:还给左边时乘右边的前向值
人话:
第 ② 扇(中间):
self = e = 6,other = c = 4,out = f,out.grad已经是上一步写下的- 加法规则:下游来多少,两边各原样累加多少
人话:
第 ③ 扇(最左):
self = a = 2,other = b = 3,out = e,out.grad已经是上一步写下的
人话:
这些 .grad 是什么意思?
| 节点 | .grad | 一句话 |
|---|---|---|
| 起点,手写的 | ||
| 加法两边平分 | ||
| 再乘上 | ||
| 再乘上 |
用展开式核对(这不是算法,只是验算)
把括号打开:
和 backward() 填进去的 .grad 一致。反向传播没有用到这组展开式——它只是一扇门一扇门地用第 3 步那两条局部规则。图一大,展开式写不出来,门规则照样能走。
第7步:演示2 — 激活函数的反向传播验证
这段把第 4 步的一元门接到 backward() 上核对导数。这里没有 MSE:直接对激活的输出调用 .backward(),等于把激活值本身当成 out.grad = 1,算出来的 x.grad 就是
共用同一个叶子 x.zero_grad()——同一个 x 会先后接到不同的门上,若不清零,后面的梯度会叠在前面的上面。
ReLU( )
x = Value(1.5)
a_relu = x.relu() # 一元门:self=x, out=a_relu=1.5
a_relu.backward() # 把 a_relu 当 L,所以 a_relu.grad=1- 前向:
- 反向:正区局部导数是
,所以
人话:在正区 ReLU 是恒等,输入加
负输入对照:
Sigmoid(同一点 )
先 x.zero_grad(),再:
a_sig = x.sigmoid()
a_sig.backward()反向仍是一元门:self=x,out=a_sig,out.grad=1,于是 out.data 来算导数,不必再记一份
Tanh(同一点)
同样清零后:
x.grad 填成 out.grad=1」,和第 6 步三扇二元门是同一套机制,只是没有 other。
第8步:演示3 — Fan-out 梯度累积
第 6 步里每个叶子只进一扇门。现在让同一个 grad += 而不是 grad = 的理由。
x = Value(2.0)
u = x * 2 # 路径1:乘法,self=x, other=2, out=u=4
v = x + 3 # 路径2:加法,self=x, other=3, out=v=5
L = u * v # 汇合:乘法,self=u, other=v, out=L=20
L.backward()
前向
| 代码 | 门 | self | other | out.data |
|---|---|---|---|---|
u = x * 2 | 乘法 | |||
v = x + 3 | 加法 | |||
L = u * v | 乘法 |
反向:仍从最右往左,但 会被写两次
先 L.grad = 1。
第 ① 扇
第 ② 扇
这是路径 1 的账:
第 ③ 扇
这是路径 2 的账:
两次 += 之后 =,后执行的那条路径会把前一条覆盖掉,答案就错了。多元链式法则写的就是这件事:
人话:
第9步:演示4 — 小神经网络完整训练
前面三步都在玩具表达式上。这一步把同一套门装进一个很小的 MLP,走通「前向 → MSE → backward → 改权重」。精度不是重点(四个点、一百轮,最后会塌成常数预测,下面会解释)。
网络是怎么用门拼出来的
model = MLP(2, [4, 1], ["relu", "linear"])含义:输入 2 维 → 隐藏层 4 个 ReLU 神经元 → 输出层 1 个线性神经元。可训练参数
一个 Neuron 的前向就是第 3 步那些门:
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b) # 一串乘法 + 加法
out = act.relu() # 或线性层:直接 return actLayer 是并排多个 Neuron;MLP 把上一层的输出列表喂给下一层。输出层取 x[0],因为这是标量回归。
四个训练点
目标函数
| 样本 | ||
|---|---|---|
| 1 | ||
| 2 | ||
| 3 | ||
| 4 |
四个标签的均值是
每一轮在干什么
lr=0.01,共
- 前向:
y_preds = [model(x) for x in xs],四个。 - MSE(demo 写法,省略
和 ): python每个样本是一扇减法再接幂运算losses = [(yp - Value(y_true)) ** 2 for yp, y_true in zip(y_preds, ys)] total_loss = sum(losses[1:], losses[0]),四个标量加起来得到 。 backward()的起点就是这个。 - 清零:上一轮写在权重上的
.grad必须抹掉,否则会和本轮加在一起(和第 7 步zero_grad、第 8 步+=是同一件事)。 - 反向:
total_loss.backward()从 MSE 出发,沿计算图把、 填进全部 17 个参数。输入 和标签 不当成旋钮。 - 更新:
p.data -= lr * p.grad,梯度上坡,减号走下坡。
random.seed(42) 后,打印出来的损失是:
| epoch | 损失 | 四个预测 |
|---|---|---|
| 0 | 大约都在 | |
| 20 | 四个都是 | |
| 40 | 四个都是 | |
| 99 | 四个都是 |
四个预测变成同一个数
正好等于最终损失。也就是说网络学成了常数函数
第10步:演示5 — 梯度下降求函数最小值
没有数据集,目标函数就是
x = Value(5.0)
for step in range(20):
loss = x * x + Value(3.0) * x # 用门拼出 f(x)
x.grad = 0.0 # 每步清零,理由同第 7、9 步
loss.backward() # x.grad 变成 2x+3
x.data -= 0.1 * x.grad # 沿下坡走一步这个表达式里的门
x * x 是第 8 步那种 fan-out:左右输入是同一个节点
合起来就是 3*x 那一扇乘法贡献的
第一拍(step 0)
人话:现在站在抛物线右侧,梯度为正,减号让
之后每 5 步(学习率
| 步骤 | 梯度 | ||
|---|---|---|---|
| 0 | |||
| 5 | |||
| 10 | |||
| 15 | |||
| 19 | |||
| 结束 | — |
梯度越来越小,因为越走越平。20 步到
辅助组件:计算图可视化
print_computation_graph(L) 在演示 1 末尾把图画成表。第 6 步那个
| 深度 | 操作 | 数据值 | 梯度 | 含义 |
|---|---|---|---|---|
| 0 | input | 四个叶子 | ||
| 1 | * | |||
| 2 | + | |||
| 3 | * |
深度 = 离叶子的最长路径。梯度列应和第 6 步手算一致。id=... 只是节点身份,用来看谁连着谁,不必记数字。
关键概念速查表
| 概念 | 数学公式 | 代码实现 |
|---|---|---|
| 链式法则 | self.grad += local_deriv * out.grad | |
| 加法门 | self,other,out | self.grad += 1.0 * out.grad(还给两个输入) |
| 乘法门 | self.grad += other.data * out.grad | |
| 幂运算 | self.grad += (other * self.data ** (other-1)) * out.grad | |
| ReLU | self.grad += (out.data > 0) * out.grad | |
| Sigmoid | self.grad += out.data * (1 - out.data) * out.grad | |
| Tanh | self.grad += (1 - out.data**2) * out.grad | |
| 拓扑排序(DFS) | 后序遍历计算图 | build_topo(v) 递归 + reversed(topo) 逆序 |
| 梯度累积(Fan-out) | self.grad += ...(用 += 而非 =) | |
| 梯度清零 | — | zero_grad() / p.grad = 0.0 |
| MSE | (yp - Value(y_true)) ** 2 再求和 | |
| 只有权重能改;梯度上坡,更新下坡 | p.data -= lr * p.grad | |
| 梯度下降 | p.data -= lr * p.grad |
链式法则那一行的
是神经元激活;加法/乘法门的 才是那一扇门的两个输入(代码里的 self/other)。两套字母不要混。
源码位置
clone 后打开(相对仓库根目录):
docs/nn-decision/dl/backprop/code/demo.py(mini autograd 主线)docs/nn-decision/dl/backprop/code/plot_demo.py(MSE、演示1 计算图、Fan-out 示意)