0. 先建立一个直觉

想象你在调一台老式收音机的旋钮找台。你不知道最优位置在哪,但你知道:

你在做的事情,本质上就是"神经网络训练"的全部:

  1. 试一次(前向传播):用当前的旋钮位置(参数)听一下效果
  2. 算一下清楚/糊的程度(损失函数)
  3. 判断往哪个方向转能变得更好、转多少(反向传播算出的梯度)
  4. 真的转一下(梯度下降更新参数)
  5. 重复 1-4,直到调到最清楚为止

下面把这个直觉一步步变成数学。


1. 计算图:把一个表达式拆成"零件"

神经网络说到底就是一个很长的数学表达式。直接对着这个大表达式求导会很痛苦,但如果把它拆成一个个最简单的"零件"(加法、乘法、max\max……),每个零件的导数都简单到可以口算,再把这些零件的导数按规则拼起来——这就是计算图的全部意义。

来看一个最小的例子(改编自 Stanford CS231n 的反向传播讲义):

f(x,y,z)=(x+y)zf(x, y, z) = (x + y) \cdot z

拆成两个零件:

q=x+y,f=qzq = x + y, \qquad f = q \cdot z

画成图:

真实网络的计算图长什么样

上面的例子只有 3 个输入。一个真正的神经网络(比如一个单隐藏层的分类器,还带上 L2 正则化)计算图是这样的(记号沿用 Bishop PRML 与《动手学深度学习》的写法):

z=W(1)x,h=ϕ(z),o=W(2)h,L=(o,y),s=λ2(W(1)F2+W(2)F2),J=L+s\mathbf{z} = \mathbf{W}^{(1)}\mathbf{x}, \quad \mathbf{h} = \phi(\mathbf{z}), \quad \mathbf{o} = \mathbf{W}^{(2)}\mathbf{h}, \quad L = \ell(\mathbf{o}, y), \quad s = \frac{\lambda}{2}\left(\lVert \mathbf{W}^{(1)}\rVert_F^2 + \lVert \mathbf{W}^{(2)}\rVert_F^2\right), \quad J = L + s

零件换成了"矩阵乘法""激活函数""正则项",但拆解的思路和 (x+y)z(x+y)\cdot z 一模一样——图更大了,规则没变。


2. 前向传播:把数据真的算一遍

前向传播 = 从图的最左边(输入)开始,顺着箭头方向,一路把每个节点的值算出来,直到最右边(输出)。

代入具体数字 x=2, y=5, z=4x=-2,\ y=5,\ z=-4:

节点计算
qqx+y=2+5x+y = -2+533
ffqz=3×(4)q \cdot z = 3 \times (-4)12-12

看起来很简单,但有一件事必须记住:前向传播时算出的每个中间值(比如这里的 q=3q=3,神经网络里的 z,h,o\mathbf{z}, \mathbf{h}, \mathbf{o})都要存下来,因为反向传播马上要用到它。这也是为什么"训练"比单纯"预测"占用更多显存——预测时算完 o\mathbf{o} 就可以扔掉中间结果,训练时必须把它们全部缓存到反向传播结束。


3. 反向传播:链式法则,原路返回

一点历史:反向传播的数学本质(多层复合函数的链式求导)最早可以追溯到 Paul Werbos 1974 年的博士论文,但真正让它成为神经网络训练标准做法、并证明它能让隐藏层学出有意义特征的,是 Rumelhart、Hinton 和 Williams 1986 年发表在 Nature 上的论文 Learning representations by back-propagating errors——这篇论文的核心结论之一,就是用反向传播成功训练出能解决"异或问题"的隐藏层网络(见本文第 10 节)。

现在问题反过来了:如果 xxyyzz 各自变化一点点,ff 会变化多少?也就是求 fx\dfrac{\partial f}{\partial x}fy\dfrac{\partial f}{\partial y}fz\dfrac{\partial f}{\partial z}

核心规则只有一条:每个节点只需要知道自己的"局部导数",然后乘以从下游(输出方向)传回来的梯度。

ff 出发,反向沿着图走一遍:

第一步,起点的梯度永远是 1(f 相对自己的变化率):

ff=1\frac{\partial f}{\partial f} = 1

第二步,乘法节点 f=qzf = q \cdot z 的局部导数:

fq=z=4,fz=q=3\frac{\partial f}{\partial q} = z = -4, \qquad \frac{\partial f}{\partial z} = q = 3

第三步,加法节点 q=x+yq = x+y 的局部导数(加法的导数永远是 1,它只是把上游传来的梯度原样"分发"给两个输入):

qx=1,qy=1\frac{\partial q}{\partial x} = 1, \qquad \frac{\partial q}{\partial y} = 1

第四步,链式法则把两段乘起来:

fx=fqqx=4×1=4,fy=fqqy=4×1=4\frac{\partial f}{\partial x} = \frac{\partial f}{\partial q}\cdot\frac{\partial q}{\partial x} = -4 \times 1 = -4, \qquad \frac{\partial f}{\partial y} = \frac{\partial f}{\partial q}\cdot\frac{\partial q}{\partial y} = -4 \times 1 = -4

汇总成表(forward 是第 2 节算出来的值,grad 是这一节反向传回来的梯度):

变量forward 值反向梯度 f/()\partial f/\partial(\cdot)
ff12-1211
qq334-4
zz4-433
xx2-24-4
yy554-4

反向传播 = 从输出往输入,在每个节点做一次"局部导数 × 上游梯度"的乘法,一直乘到头。

推广到矩阵形式

把这条规则套用到第 1 节那个更真实的网络计算图上,《动手学深度学习》给出的形式是:

Jo=LoJh=(W(2)) ⁣JoJz=Jhϕ(z)\frac{\partial J}{\partial \mathbf{o}} = \frac{\partial L}{\partial \mathbf{o}} \qquad\longrightarrow\qquad \frac{\partial J}{\partial \mathbf{h}} = \left(\mathbf{W}^{(2)}\right)^{\!\top}\frac{\partial J}{\partial \mathbf{o}} \qquad\longrightarrow\qquad \frac{\partial J}{\partial \mathbf{z}} = \frac{\partial J}{\partial \mathbf{h}} \odot \phi'(\mathbf{z})

和标量例子相比,矩阵乘法换掉了普通乘法,逐元素积 \odot 换掉了"激活函数"这个零件的局部导数——结构完全一样:都是"上游梯度 × 当前节点局部导数",只是从标量换成了向量/矩阵。神经网络里成千上万个参数的梯度,都是靠这一条规则在一张更大的图上算出来的,没有任何额外的魔法。

想验证 (x+y)z(x+y)\cdot z 这个结果对不对?可以用数值法抽查:把 xx2-2 改成 2+0.001-2+0.001,重新算一遍 ff,Δf/Δx\Delta f/\Delta x 应该约等于 4-4


4. 换个角度看:给定目标值,反过来调参数

除了"最小化损失函数",还有一种更直观的理解反向传播的方式——已知当前输出离目标差多远,反过来去调参数。这是清华大学出版社《智能之门》教程(见文末链接)里的例子,数字上更简单,适合先建立手感:

x=2w+3b,y=2b+1,z=xyx = 2w+3b, \qquad y = 2b+1, \qquad z = x\cdot y

初始 w=3, b=4w=3,\ b=4,想让 zz 从当前值调整到目标值 ztarget=150z_{\text{target}}=150

前向传播:

x=2×3+3×4=18,y=2×4+1=9,z=18×9=162x = 2\times3+3\times4=18, \qquad y=2\times4+1=9, \qquad z=18\times9=162

反向传播(链式法则):

zw=zxxw=y×2=9×2=18\frac{\partial z}{\partial w}=\frac{\partial z}{\partial x}\cdot\frac{\partial x}{\partial w}=y\times2=9\times2=18 zb=zxxb+zyyb=y×3+x×2=27+36=63\frac{\partial z}{\partial b}=\frac{\partial z}{\partial x}\cdot\frac{\partial x}{\partial b}+\frac{\partial z}{\partial y}\cdot\frac{\partial y}{\partial b}=y\times3+x\times2=27+36=63

注意 bb 同时影响 xxyy 两条路径,梯度要把两条路径的贡献加起来——这是链式法则在"多路径"情况下的标准规则,后面神经网络里一个隐藏单元同时影响多个输出神经元时,也是同样处理。

用梯度反推需要调整多少:当前 z=162z=162,目标 150150,只调 ww 需要

Δw=16215018=0.667    w2.333\Delta w = \frac{162-150}{18} = 0.667 \;\Rightarrow\; w \approx 2.333

只调 bb 需要 Δb=12/630.190\Delta b = 12/63 \approx 0.190。如果 w,bw,b 同时调整,通常按各自梯度大小分摊误差,而不是简单各调一半——这也是第 6 节梯度下降要解决的更一般的问题:当有很多参数时,该怎么系统地分配"往哪调、调多少"


5. 从表达式到神经网络:换个"零件"而已

神经网络的计算图,只是把前面例子里的加法/乘法节点,换成了"带权重的线性组合"和"激活函数"这两种零件,再首尾相连、层层堆叠。

以最小的一个隐藏神经元网络为例:

z1=w1x+b1ReLUa1=max(0,z1)y^=w2a1+b2L=(y^t)2z_1 = w_1 x + b_1 \quad\xrightarrow{\text{ReLU}}\quad a_1 = \max(0, z_1) \quad\longrightarrow\quad \hat{y} = w_2 a_1 + b_2 \quad\longrightarrow\quad L = (\hat{y}-t)^2

6. 完整手推一遍:数字版神经网络训练

取具体数值:输入 x=1x=1,目标 t=1t=1,初始参数 w1=0.5, b1=0, w2=0.3, b2=0.1w_1=0.5,\ b_1=0,\ w_2=-0.3,\ b_2=0.1

前向传播

节点计算
z1z_1w1x+b1=0.5×1+0w_1 x + b_1 = 0.5\times1+00.50.5
a1a_1max(0,z1)\max(0, z_1)0.50.5
y^\hat{y}w2a1+b2=0.3×0.5+0.1w_2 a_1 + b_2 = -0.3\times0.5+0.10.05-0.05
LL(y^t)2=(0.051)2(\hat{y}-t)^2 = (-0.05-1)^21.10251.1025

反向传播

LL 出发,依次算每个节点的局部导数,再链式相乘。用到的求导规则只有三条:

计算
Ly^=2(y^t)=2×(1.05)\dfrac{\partial L}{\partial \hat y} = 2(\hat y - t) = 2\times(-1.05)2.1-2.1
Lw2=Ly^a1=2.1×0.5\dfrac{\partial L}{\partial w_2} = \dfrac{\partial L}{\partial \hat y}\cdot a_1 = -2.1\times0.51.05-1.05
Lb2=Ly^1\dfrac{\partial L}{\partial b_2} = \dfrac{\partial L}{\partial \hat y}\cdot 12.1-2.1
La1=Ly^w2=2.1×(0.3)\dfrac{\partial L}{\partial a_1} = \dfrac{\partial L}{\partial \hat y}\cdot w_2 = -2.1\times(-0.3)0.630.63
Lz1=La1ReLU(z1)=0.63×1\dfrac{\partial L}{\partial z_1} = \dfrac{\partial L}{\partial a_1}\cdot \text{ReLU}'(z_1) = 0.63\times10.630.63
Lw1=Lz1x=0.63×1\dfrac{\partial L}{\partial w_1} = \dfrac{\partial L}{\partial z_1}\cdot x = 0.63\times10.630.63
Lb1=Lz11\dfrac{\partial L}{\partial b_1} = \dfrac{\partial L}{\partial z_1}\cdot 10.630.63

梯度是从输出往输入、一层一层往回传的——这也是"反向传播"这个名字的由来。每一步都在复用上一步刚算出来的结果(L/y^\partial L/\partial \hat y 被后面两行直接复用),这正是计算图方法比"对着最终公式硬展开求导"高效得多的原因:公共的中间结果只算一次。这个"复用中间结果"的效率优势,在参数数量达到百万、千万级的真实网络里是决定性的——如果不用反向传播而是对每个参数独立地重新展开求导,计算量会随参数数量线性甚至更快地增长。


7. 梯度下降:算出梯度之后怎么用

梯度 Lw\dfrac{\partial L}{\partial w} 不是一个数字这么简单——梯度是一个向量:它既给出了"往哪个方向调整 LL 增长最快"(方向),也给出了"变化有多剧烈"(大小/模长)。要让 LL 变小,就要往梯度的反方向走一小步:

wwηLww \leftarrow w - \eta\,\frac{\partial L}{\partial w}

η\eta(学习率)控制每步走多远。这个更新公式本身可以严格证明收敛性:Robbins 和 Monro 在 1951 年的经典论文 A Stochastic Approximation Method 中证明了在学习率满足一定衰减条件时,这类迭代格式会收敛到驻点——这是随机梯度下降(SGD)理论基础的起点。

梯度下降在损失函数等高线上的路径

用第 6 节算出的梯度,取 η=0.1\eta=0.1,更新一次参数:

参数更新新值
w1w_10.50.1×0.630.5 - 0.1\times0.630.4370.437
b1b_100.1×0.630 - 0.1\times0.630.063-0.063
w2w_20.30.1×(1.05)-0.3 - 0.1\times(-1.05)0.195-0.195
b2b_20.10.1×(2.1)0.1 - 0.1\times(-2.1)0.310.31

用新参数重新做一次前向传播:z1=0.374, a1=0.374, y^0.237, L0.582z_1=0.374,\ a_1=0.374,\ \hat y \approx 0.237,\ L\approx 0.582——1.10251.1025 降到了 0.5820.582,网络真的在朝着"更好"的方向走了一步。

进阶可视化:为什么梯度下降会"震荡"

上面的等高线图是理想化的圆形山谷。真实的损失曲面几乎总是不同方向曲率不一样(有的方向平缓、有的方向陡峭),下图是一个标准的病态曲率例子 L(a,b)=a2+10b2L(a,b)=a^2+10b^2(bb 方向的曲率是 aa 方向的 10 倍),用固定学习率做梯度下降,真实计算出的路径会在陡峭方向来回震荡、在平缓方向缓慢推进,呈明显的"之"字形:

梯度下降在病态曲率损失曲面上的震荡轨迹(真实计算生成)

这正是实践中很少直接用"朴素梯度下降"训练大网络的原因——各个参数方向的曲率差异巨大时,单一的全局学习率很难兼顾"陡峭方向不震荡"和"平缓方向不太慢"。这也是动量法(Momentum)、以及 Kingma 和 Ba 在 2014 年提出的 Adam 优化器(Adam: A Method for Stochastic Optimization)被广泛使用的原因——它们对每个参数方向自适应地调整步长,而不是用一个学习率打天下。这些方法在"怎么用梯度更新参数"这一步做了改进,但梯度本身仍然是用反向传播算出来的——本文的核心内容不受影响。


8. 串起来:一次完整的训练循环

前向传播算出预测和损失    反向传播算出每个参数的梯度    梯度下降用梯度更新参数    重复\underbrace{\text{前向传播}}_{\text{算出预测和损失}} \;\to\; \underbrace{\text{反向传播}}_{\text{算出每个参数的梯度}} \;\to\; \underbrace{\text{梯度下降}}_{\text{用梯度更新参数}} \;\to\; \text{重复}

对应第 6-7 节例子的完整 numpy 实现(不依赖任何深度学习框架):

python
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 节 f=(x+y)zf=(x+y)\cdot z 这个例子:

python
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 节手推的表格完全一致):

text
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 就是每个节点对应的"局部导数零件":

text
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 隐藏神经元网络:

python
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()

真实运行输出:

text
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 这个基类。官方文档给出的用法是:继承它,实现两个静态方法 forwardbackward,前向传播里用 ctx.save_for_backward(...) 把需要的中间结果存起来,反向传播里用 ctx.saved_tensors 取出来用——这和本文第 2 节反复强调的"前向传播的中间值要缓存,反向传播要用"完全是同一件事,只是这里是显式地手写出来。

用它重新实现一遍我们例子里用到的 ReLU:

python
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

真实运行输出:

text
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)问题

XOR(0,0)=0,XOR(0,1)=1,XOR(1,0)=1,XOR(1,1)=0\text{XOR}(0,0)=0,\quad \text{XOR}(0,1)=1,\quad \text{XOR}(1,0)=1,\quad \text{XOR}(1,1)=0

1969 年,Minsky 和 Papert 在《感知机》(Perceptrons)一书中证明:单层感知机无法学会 XOR——因为 XOR 的两类样本在输入平面上不能用一条直线分开(想象一下:(0,0)(0,0)(1,1)(1,1) 是一类,(0,1)(0,1)(1,0)(1,0) 是另一类,四个点摆成正方形的对角,任何一条直线都做不到把它们正确分成两边)。这个结论一度让神经网络研究陷入低谷。

Rumelhart、Hinton 和 Williams 1986 年那篇奠定反向传播地位的论文,正是用带隐藏层的网络、配合反向传播训练,展示了隐藏层可以学出"有用的新特征"从而解决 XOR——这是反向传播相对早期简单方法(感知机收敛算法)最关键的优势之一。

真实训练一遍(2-2-1 网络,数字全部来自实际运行)

网络结构:2 个输入 → 2 个隐藏神经元(ReLU)→ 1 个输出神经元(Sigmoid),损失用均方误差,用本文第 8 节同款的"前向 → 反向 → 梯度下降"循环训练 4000 步、学习率 η=0.5\eta=0.5。完整代码见 drafts/scripts/xor_demo.py,以下是真实运行输出:

text
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 训练的损失曲线与决策边界(真实计算生成)

右图是训练收敛后网络在整个输入平面上的预测值——可以看到边界不再是一条直线,而是两条平行线夹出的一条对角带,这正是隐藏层学出来的"新特征"发挥的作用:两个隐藏神经元各自学会了一条直线判别边界,输出层再把两条直线的判断结果组合起来,才能围出 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)等更高级的更新规则——反向传播本身不关心你之后怎么用这个梯度。

为什么要叫"链式法则"? 因为整个过程就是把 d输出d输入\dfrac{d\text{输出}}{d\text{输入}} 拆成一串局部导数相乘:dLdx=dLdududvdvdx\dfrac{dL}{dx} = \dfrac{dL}{du}\cdot\dfrac{du}{dv}\cdot\dfrac{dv}{dx},像链条一样一节一节扣起来。

ReLU 在 z=0z=0 处导数不存在,代码里怎么处理? 数学上确实是不可导点,工程上直接约定该点导数取 0011(本文代码里 (z > 0)z=0z=0 时取 00)。因为随机初始化后参数恰好落在 z=0z=0 这一个点的概率几乎为零,这个约定在实践中不会造成问题。

为什么不能像最小二乘那样直接解方程,一次性求出最优参数? 线性回归的损失函数关于参数是二次的,存在闭式解(见 1.1 多项式曲线拟合 里的正规方程)。但神经网络里的 max(0,)\max(0,\cdot)、多层复合等操作让损失函数关于参数是高度非凸的,没有闭式解,只能用梯度下降这类迭代法一步步逼近——第 10 节的 XOR 决策边界(两条直线组合)正是这种非线性带来的表达能力,但代价就是失去了闭式解。

真实训练时,每次只用一个样本算梯度吗? 本文的手推例子只用了 1 个样本方便计算,第 10 节的 XOR 例子每一步用了全部 4 个样本(称为 batch/全批量梯度下降)。真实的大数据集通常每步只随机抽一小批样本(mini-batch),这就是 Robbins-Monro (1951) 理论框架下的随机梯度下降(SGD)——用一小部分数据估计的"带噪声"梯度,同样能在理论上保证收敛,并且计算上快得多。


12. 参考文献与延伸阅读

原始论文

教程与讲义