0. 先建立一个直觉
想象你在调一台老式收音机的旋钮找台。你不知道最优位置在哪,但你知道:
- 往右转一点,声音更清楚了 → 说明"往右"是对的方向,继续往右
- 往右转一点,声音更糊了 → 说明转反了,该往左
你在做的事情,本质上就是"神经网络训练"的全部:
- 试一次(前向传播):用当前的旋钮位置(参数)听一下效果
- 算一下清楚/糊的程度(损失函数)
- 判断往哪个方向转能变得更好、转多少(反向传播算出的梯度)
- 真的转一下(梯度下降更新参数)
- 重复 1-4,直到调到最清楚为止
下面把这个直觉一步步变成数学。
1. 计算图:把一个表达式拆成"零件"
神经网络说到底就是一个很长的数学表达式。直接对着这个大表达式求导会很痛苦,但如果把它拆成一个个最简单的"零件"(加法、乘法、……),每个零件的导数都简单到可以口算,再把这些零件的导数按规则拼起来——这就是计算图的全部意义。
来看一个最小的例子(改编自 Stanford CS231n 的反向传播讲义):
拆成两个零件:
画成图:
- 节点 = 一个最简单的运算(加法、乘法……)
- 边 = 数据往前流动的方向
- 这张图和 这一行公式完全等价,只是把"计算的先后顺序"显式画了出来
真实网络的计算图长什么样
上面的例子只有 3 个输入。一个真正的神经网络(比如一个单隐藏层的分类器,还带上 L2 正则化)计算图是这样的(记号沿用 Bishop PRML 与《动手学深度学习》的写法):
零件换成了"矩阵乘法""激活函数""正则项",但拆解的思路和 一模一样——图更大了,规则没变。
2. 前向传播:把数据真的算一遍
前向传播 = 从图的最左边(输入)开始,顺着箭头方向,一路把每个节点的值算出来,直到最右边(输出)。
代入具体数字 :
| 节点 | 计算 | 值 |
|---|---|---|
看起来很简单,但有一件事必须记住:前向传播时算出的每个中间值(比如这里的 ,神经网络里的 )都要存下来,因为反向传播马上要用到它。这也是为什么"训练"比单纯"预测"占用更多显存——预测时算完 就可以扔掉中间结果,训练时必须把它们全部缓存到反向传播结束。
3. 反向传播:链式法则,原路返回
一点历史:反向传播的数学本质(多层复合函数的链式求导)最早可以追溯到 Paul Werbos 1974 年的博士论文,但真正让它成为神经网络训练标准做法、并证明它能让隐藏层学出有意义特征的,是 Rumelhart、Hinton 和 Williams 1986 年发表在 Nature 上的论文 Learning representations by back-propagating errors——这篇论文的核心结论之一,就是用反向传播成功训练出能解决"异或问题"的隐藏层网络(见本文第 10 节)。
现在问题反过来了:如果 、、 各自变化一点点, 会变化多少?也就是求 、、。
核心规则只有一条:每个节点只需要知道自己的"局部导数",然后乘以从下游(输出方向)传回来的梯度。
从 出发,反向沿着图走一遍:
第一步,起点的梯度永远是 1(f 相对自己的变化率):
第二步,乘法节点 的局部导数:
第三步,加法节点 的局部导数(加法的导数永远是 1,它只是把上游传来的梯度原样"分发"给两个输入):
第四步,链式法则把两段乘起来:
汇总成表(forward 是第 2 节算出来的值,grad 是这一节反向传回来的梯度):
| 变量 | forward 值 | 反向梯度 |
|---|---|---|
反向传播 = 从输出往输入,在每个节点做一次"局部导数 × 上游梯度"的乘法,一直乘到头。
推广到矩阵形式
把这条规则套用到第 1 节那个更真实的网络计算图上,《动手学深度学习》给出的形式是:
和标量例子相比,矩阵乘法换掉了普通乘法,逐元素积 换掉了"激活函数"这个零件的局部导数——结构完全一样:都是"上游梯度 × 当前节点局部导数",只是从标量换成了向量/矩阵。神经网络里成千上万个参数的梯度,都是靠这一条规则在一张更大的图上算出来的,没有任何额外的魔法。
想验证 这个结果对不对?可以用数值法抽查:把 从 改成 ,重新算一遍 , 应该约等于 。
4. 换个角度看:给定目标值,反过来调参数
除了"最小化损失函数",还有一种更直观的理解反向传播的方式——已知当前输出离目标差多远,反过来去调参数。这是清华大学出版社《智能之门》教程(见文末链接)里的例子,数字上更简单,适合先建立手感:
初始 ,想让 从当前值调整到目标值 。
前向传播:
反向传播(链式法则):
注意 同时影响 和 两条路径,梯度要把两条路径的贡献加起来——这是链式法则在"多路径"情况下的标准规则,后面神经网络里一个隐藏单元同时影响多个输出神经元时,也是同样处理。
用梯度反推需要调整多少:当前 ,目标 ,只调 需要
只调 需要 。如果 同时调整,通常按各自梯度大小分摊误差,而不是简单各调一半——这也是第 6 节梯度下降要解决的更一般的问题:当有很多参数时,该怎么系统地分配"往哪调、调多少"。
5. 从表达式到神经网络:换个"零件"而已
神经网络的计算图,只是把前面例子里的加法/乘法节点,换成了"带权重的线性组合"和"激活函数"这两种零件,再首尾相连、层层堆叠。
以最小的一个隐藏神经元网络为例:
6. 完整手推一遍:数字版神经网络训练
取具体数值:输入 ,目标 ,初始参数 。
前向传播
| 节点 | 计算 | 值 |
|---|---|---|
反向传播
从 出发,依次算每个节点的局部导数,再链式相乘。用到的求导规则只有三条:
- 线性层 的局部导数:
- ReLU 的局部导数: 时为 , 时为 (本例中 ,取 )
| 计算 | 值 |
|---|---|
梯度是从输出往输入、一层一层往回传的——这也是"反向传播"这个名字的由来。每一步都在复用上一步刚算出来的结果( 被后面两行直接复用),这正是计算图方法比"对着最终公式硬展开求导"高效得多的原因:公共的中间结果只算一次。这个"复用中间结果"的效率优势,在参数数量达到百万、千万级的真实网络里是决定性的——如果不用反向传播而是对每个参数独立地重新展开求导,计算量会随参数数量线性甚至更快地增长。
7. 梯度下降:算出梯度之后怎么用
梯度 不是一个数字这么简单——梯度是一个向量:它既给出了"往哪个方向调整 增长最快"(方向),也给出了"变化有多剧烈"(大小/模长)。要让 变小,就要往梯度的反方向走一小步:
(学习率)控制每步走多远。这个更新公式本身可以严格证明收敛性:Robbins 和 Monro 在 1951 年的经典论文 A Stochastic Approximation Method 中证明了在学习率满足一定衰减条件时,这类迭代格式会收敛到驻点——这是随机梯度下降(SGD)理论基础的起点。

- 太小:每步挪得像蜗牛,要很久才能到最优点
- 太大:一步迈过头,在最优点附近来回震荡甚至发散
用第 6 节算出的梯度,取 ,更新一次参数:
| 参数 | 更新 | 新值 |
|---|---|---|
用新参数重新做一次前向传播:——从 降到了 ,网络真的在朝着"更好"的方向走了一步。
进阶可视化:为什么梯度下降会"震荡"
上面的等高线图是理想化的圆形山谷。真实的损失曲面几乎总是不同方向曲率不一样(有的方向平缓、有的方向陡峭),下图是一个标准的病态曲率例子 ( 方向的曲率是 方向的 10 倍),用固定学习率做梯度下降,真实计算出的路径会在陡峭方向来回震荡、在平缓方向缓慢推进,呈明显的"之"字形:

这正是实践中很少直接用"朴素梯度下降"训练大网络的原因——各个参数方向的曲率差异巨大时,单一的全局学习率很难兼顾"陡峭方向不震荡"和"平缓方向不太慢"。这也是动量法(Momentum)、以及 Kingma 和 Ba 在 2014 年提出的 Adam 优化器(Adam: A Method for Stochastic Optimization)被广泛使用的原因——它们对每个参数方向自适应地调整步长,而不是用一个学习率打天下。这些方法在"怎么用梯度更新参数"这一步做了改进,但梯度本身仍然是用反向传播算出来的——本文的核心内容不受影响。
8. 串起来:一次完整的训练循环
对应第 6-7 节例子的完整 numpy 实现(不依赖任何深度学习框架):
import numpy as np
def relu(z):
return np.maximum(0, z)
def relu_grad(z):
return (z > 0).astype(float)
x, t = 1.0, 1.0
w1, b1, w2, b2 = 0.5, 0.0, -0.3, 0.1
lr = 0.1
for step in range(50):
# ---- 前向传播 ----
z1 = w1 * x + b1
a1 = relu(z1)
y_hat = w2 * a1 + b2
loss = (y_hat - t) ** 2
# ---- 反向传播 ----
d_yhat = 2 * (y_hat - t)
d_w2 = d_yhat * a1
d_b2 = d_yhat
d_a1 = d_yhat * w2
d_z1 = d_a1 * relu_grad(z1)
d_w1 = d_z1 * x
d_b1 = d_z1
# ---- 梯度下降 ----
w1 -= lr * d_w1
b1 -= lr * d_b1
w2 -= lr * d_w2
b2 -= lr * d_b2
if step % 10 == 0:
print(f"step {step:2d} loss={loss:.4f} y_hat={y_hat:.4f}")跑起来会看到 loss 一路下降、y_hat 一路逼近 t=1.0。这段代码里的每一行,都能在第 6 节的表格里找到对应的手算过程——框架帮你做的事情,就是把这套手算自动化到几百万个参数的规模而已。
9. PyTorch 是怎么自动完成这一切的:动态计算图与 autograd
前面所有的前向传播、反向传播都是我们手写的。工业界实际训练模型时用的 PyTorch、TensorFlow 等框架,做的事情本质上就是把"计算图 + 链式法则"这套机制自动化。这一节用 PyTorch 官方的 torch.autograd 把前面手推的两个例子重新做一遍,顺便看看框架内部到底在维护一张什么样的图。
9.1 动态图(define-by-run):图是"运行出来的"
PyTorch 官方文档(Autograd mechanics)把这套机制描述为:每次执行前向传播的 Python 代码,PyTorch 都会一边真正执行运算、一边实时记录每一步用到的操作,动态搭出一张计算图;等你调用 .backward() 沿着这张图反向算完梯度,图就被释放掉了——用官方原文的说法,"the graph is recreated from scratch at every iteration"(每一次迭代,图都是从零重新搭建的)。
这跟早期 TensorFlow 1.x 那种"先把整张图定义好、编译完再喂数据跑"(define-and-run / 静态图)的方式不同。动态图的好处是前向传播里可以写普通的 Python if/for,每次运行路径可以不一样,图会自动跟着适配——这也是 PyTorch 常被叫做"所见即所得"的原因。
几个关键概念(均来自 PyTorch 官方文档):
| 概念 | 含义 |
|---|---|
requires_grad | 张量的一个开关,决定要不要追踪它参与的运算、记录进反向图 |
grad_fn | 张量身上的一个属性,记录"我是被哪个操作算出来的",是计算图的入口 |
| 叶子节点(leaf tensor) | 没有 grad_fn 的张量(比如直接创建的参数),只有叶子节点的梯度会被累积到 .grad |
.backward() | 从当前张量出发,沿 grad_fn 链一路反向走到所有叶子节点,自动应用链式法则 |
retain_graph | 默认 backward() 跑完图就释放;传 retain_graph=True 可以保留,供同一张图多次反向 |
图的生命周期,画成图就是:
9.2 用 autograd 重新做一遍手推的例子
先验证第 1 节 这个例子:
import torch
x = torch.tensor(-2.0, requires_grad=True)
y = torch.tensor(5.0, requires_grad=True)
z = torch.tensor(-4.0, requires_grad=True)
q = x + y
f = q * z
f.backward()
print(x.grad, y.grad, z.grad) # -4.0 -4.0 3.0真实运行输出(和第 3 节手推的表格完全一致):
forward: q=3.0, f=-12.0
grad: df/dx=-4.0, df/dy=-4.0, df/dz=3.0
hand-derived: df/dx=-4, df/dy=-4, df/dz=3再看一眼动态图里真实记录了什么——grad_fn 就是每个节点对应的"局部导数零件":
grad_fn of f: <MulBackward0 object at 0x...>
grad_fn of q: <AddBackward0 object at 0x...>
f.grad_fn.next_functions: ((<AddBackward0 ...>, 0), (<AccumulateGrad ...>, 0))对照着第 1 节画的计算图看,MulBackward0 就是乘法节点的"反向零件"、AddBackward0 就是加法节点的"反向零件"、AccumulateGrad 表示走到了叶子节点(要把梯度累积进 .grad)——和我们手画的图节点一一对应:
再验证第 6 节那个 1 隐藏神经元网络:
x2 = torch.tensor(1.0)
t2 = torch.tensor(1.0)
w1 = torch.tensor(0.5, requires_grad=True)
b1 = torch.tensor(0.0, requires_grad=True)
w2 = torch.tensor(-0.3, requires_grad=True)
b2 = torch.tensor(0.1, requires_grad=True)
z1 = w1 * x2 + b1
a1 = torch.relu(z1)
y_hat = w2 * a1 + b2
loss = (y_hat - t2) ** 2
loss.backward()真实运行输出:
forward: z1=0.5, a1=0.5, y_hat=-0.0500, loss=1.1025
grad: dL/dw1=0.6300 dL/db1=0.6300 dL/dw2=-1.0500 dL/db2=-2.1000
hand-derived: dL/dw1=0.63 dL/db1=0.63 dL/dw2=-1.05 dL/db2=-2.1和第 6 节表格里一格一格手算出来的数字分毫不差——这也是本文反复强调的那句话:框架没有用任何"魔法",它只是把手推的那套链式法则自动化了。
9.3 自定义一个"零件":torch.autograd.Function
如果你想自己定义一个计算图节点的前向/反向规则(比如实现一个框架里没有的新激活函数),PyTorch 提供 torch.autograd.Function 这个基类。官方文档给出的用法是:继承它,实现两个静态方法 forward 和 backward,前向传播里用 ctx.save_for_backward(...) 把需要的中间结果存起来,反向传播里用 ctx.saved_tensors 取出来用——这和本文第 2 节反复强调的"前向传播的中间值要缓存,反向传播要用"完全是同一件事,只是这里是显式地手写出来。
用它重新实现一遍我们例子里用到的 ReLU:
from torch.autograd import Function
class MyReLU(Function):
@staticmethod
def forward(ctx, input):
ctx.save_for_backward(input) # 缓存前向的中间值
return input.clamp(min=0)
@staticmethod
def backward(ctx, grad_output):
(input,) = ctx.saved_tensors # 取出缓存,算局部导数
grad_input = grad_output.clone()
grad_input[input < 0] = 0
return grad_input
a1 = MyReLU.apply(z1) # 注意:是 .apply(),不是直接调用 forward真实运行输出:
MyReLU forward: relu(0.5)=0.5 MyReLU backward grad: 1.0
MyReLU forward: relu(-0.5)=0.0 MyReLU backward grad: 0.0和第 6 节里手写的 relu_grad(z) = (z > 0) 规则完全一致——forward 对应第 2 节"把数据算一遍",backward 对应第 3 节"局部导数",ctx.save_for_backward 对应"前向的中间值要存下来"。torch.autograd.Function 本质上就是让你亲手填写一个计算图节点的两个格子。
完整可运行代码见
drafts/scripts/torch_autograd_demo.py。
10. 实际应用例子:用反向传播解决一个"单层网络解决不了"的问题
前面所有例子都只有 1 个隐藏神经元,只是为了方便手推。这一节用一个真实、可运行、有实际意义的问题,展示反向传播到底解决了什么单靠手工调参解决不了的事。
背景:异或(XOR)问题
1969 年,Minsky 和 Papert 在《感知机》(Perceptrons)一书中证明:单层感知机无法学会 XOR——因为 XOR 的两类样本在输入平面上不能用一条直线分开(想象一下:、 是一类,、 是另一类,四个点摆成正方形的对角,任何一条直线都做不到把它们正确分成两边)。这个结论一度让神经网络研究陷入低谷。
Rumelhart、Hinton 和 Williams 1986 年那篇奠定反向传播地位的论文,正是用带隐藏层的网络、配合反向传播训练,展示了隐藏层可以学出"有用的新特征"从而解决 XOR——这是反向传播相对早期简单方法(感知机收敛算法)最关键的优势之一。
真实训练一遍(2-2-1 网络,数字全部来自实际运行)
网络结构:2 个输入 → 2 个隐藏神经元(ReLU)→ 1 个输出神经元(Sigmoid),损失用均方误差,用本文第 8 节同款的"前向 → 反向 → 梯度下降"循环训练 4000 步、学习率 。完整代码见 drafts/scripts/xor_demo.py,以下是真实运行输出:
step 0 loss=0.26420
step 500 loss=0.00642
step 1000 loss=0.00211
step 1500 loss=0.00119
step 2000 loss=0.00082
...
step 3999 loss=0.00035
最终预测:
x=[0,0] target=0 y_hat=0.0295
x=[0,1] target=1 y_hat=0.9862
x=[1,0] target=1 y_hat=0.9862
x=[1,1] target=0 y_hat=0.0115
右图是训练收敛后网络在整个输入平面上的预测值——可以看到边界不再是一条直线,而是两条平行线夹出的一条对角带,这正是隐藏层学出来的"新特征"发挥的作用:两个隐藏神经元各自学会了一条直线判别边界,输出层再把两条直线的判断结果组合起来,才能围出 XOR 需要的非线性决策区域。
从 4 个样本到真实规模:LeNet-5
XOR 只有 4 个样本、2 个隐藏神经元,是"能在纸上手推"的极限。同一套前向传播 → 反向传播 → 梯度下降的原理,LeCun、Bottou、Bengio 和 Haffner 在 1998 年的论文 Gradient-Based Learning Applied to Document Recognition 中把它扩展到了卷积层堆叠的 LeNet-5,用于识别手写数字。这个系统后来在美国的银行支票自动读取系统中被实际部署——是反向传播从"教科书算法"走向"工业系统"的早期标志性案例之一。层数更多、每层参数量更大,但训练时具体每一步做的事情,和本文第 8 节 numpy 代码里的三段(前向、反向、更新)完全一样,只是循环的规模从 4 个参数变成了几万甚至上亿个参数。
11. 几个常见疑惑
反向传播和梯度下降是一回事吗? 不是。反向传播是"算梯度"的方法(基于链式法则),梯度下降是"拿到梯度之后怎么更新参数"的方法。反向传播算出的梯度,可以配合最朴素的梯度下降,也可以配合动量法、Adam(Kingma & Ba, 2014)等更高级的更新规则——反向传播本身不关心你之后怎么用这个梯度。
为什么要叫"链式法则"? 因为整个过程就是把 拆成一串局部导数相乘:,像链条一样一节一节扣起来。
ReLU 在 处导数不存在,代码里怎么处理?
数学上确实是不可导点,工程上直接约定该点导数取 或 (本文代码里 (z > 0) 在 时取 )。因为随机初始化后参数恰好落在 这一个点的概率几乎为零,这个约定在实践中不会造成问题。
为什么不能像最小二乘那样直接解方程,一次性求出最优参数? 线性回归的损失函数关于参数是二次的,存在闭式解(见 1.1 多项式曲线拟合 里的正规方程)。但神经网络里的 、多层复合等操作让损失函数关于参数是高度非凸的,没有闭式解,只能用梯度下降这类迭代法一步步逼近——第 10 节的 XOR 决策边界(两条直线组合)正是这种非线性带来的表达能力,但代价就是失去了闭式解。
真实训练时,每次只用一个样本算梯度吗? 本文的手推例子只用了 1 个样本方便计算,第 10 节的 XOR 例子每一步用了全部 4 个样本(称为 batch/全批量梯度下降)。真实的大数据集通常每步只随机抽一小批样本(mini-batch),这就是 Robbins-Monro (1951) 理论框架下的随机梯度下降(SGD)——用一小部分数据估计的"带噪声"梯度,同样能在理论上保证收敛,并且计算上快得多。
12. 参考文献与延伸阅读
原始论文
- Werbos, P. (1974). Beyond Regression: New Tools for Prediction and Analysis in the Behavioral Sciences (Ph.D. thesis, Harvard University). —— 已知最早给出神经网络反向传播算法雏形的工作。
- Rumelhart, D. E., Hinton, G. E., & Williams, R. J. (1986). Learning representations by back-propagating errors. Nature, 323, 533–536. —— 让反向传播成为神经网络训练标准方法的奠基论文,论文中用 XOR 作为核心示例。
- LeCun, Y., Bottou, L., Bengio, Y., & Haffner, P. (1998). Gradient-Based Learning Applied to Document Recognition. Proceedings of the IEEE, 86(11), 2278–2324. —— LeNet-5,反向传播 + 梯度下降在真实文档/手写数字识别系统中的经典应用。
- Robbins, H., & Monro, S. (1951). A Stochastic Approximation Method. The Annals of Mathematical Statistics, 22(3), 400–407. —— 随机梯度下降的理论基础。
- Kingma, D. P., & Ba, J. (2014). Adam: A Method for Stochastic Optimization. ICLR 2015 (arXiv:1412.6980). —— 目前最常用的梯度下降改进算法之一。
教程与讲义
- Bishop, Pattern Recognition and Machine Learning, Chapter 5(本仓库
Ch05 - Neural Networks) - 《动手学深度学习》第 4.7 节 前向传播、反向传播和计算图 —— 本文第 1、3 节的矩阵形式即参考自此
- 微软 AI-Edu 《智能之门》反向传播与梯度下降、线性反向传播、梯度下降 —— 本文第 4 节的数字例子改编自此
- CS231n 课程笔记 Backpropagation, Intuitions(本文第 1-3 节的 例子即改编自此)
- Andrej Karpathy, micrograd —— 100 行代码实现的最小反向传播引擎,把本文讲的东西又完整撸了一遍代码,非常适合跟着调试器单步看一遍
- PyTorch 官方文档 Autograd mechanics、torch.autograd、A Gentle Introduction to torch.autograd —— 本文第 9 节的动态图机制与
Function用法均来自这三份官方文档
