反向传播
2026-07-11 21:53:00 创建
2026-07-15 13:41:10 修改
为了方便打字, 本篇文章使用英文标点, 并遵循英文的空格添加法则.
反向传播
复合函数的求导遵循链式法则, 如果仅仅是这样一个数学结论, 反向传播还远远不能称为一个算法. 它实际上利用了计算图, 通过前向遍历计算图, 程序可以从输入得到输出; 反向遍历计算图, 程序可以得到一个输出对一个输入的导数 (见下).
梯度下降
我认为首先需要在这里提及梯度下降, 以及参数更新的方式.
对于梯度下降, 一般地, 设函数为 \(f(\theta)\), 现在需要从某个初始点 \(\theta_0\) 开始, 寻找函数的最小值点 \(\theta_{\min}\).
设当前参数点为 \(\theta_t\), 简单梯度下降 (GD) 的参数更新公式为
$$\theta_{t+1} = \theta_t - \alpha_t \nabla f(\theta_t)$$
其中 \(\alpha_t\) 为步长.
神经网络中的梯度下降
在引入计算图之前, 我们先来看一个简单的只有一层隐藏层的神经网络
$$y = f(x;\theta) = w_2^T\sigma(W_1x + b_1) + b_2,$$
其中 \(w_2, b_2\) 分别是向量和实数, \(W_1, b_1\) 分别是矩阵和向量, 激活函数 \(\sigma\) 是 pointwise 的. \(\theta\) 表示所有这些参数. 现在我们需要找出最优的参数 \(\theta = (W_1,b_1, w_2, b_2)\). 由于 \(\mathbb{R}^n\to \mathbb{R}\) 的多元函数的梯度, 就是该函数对各个变量的偏导数组成的向量, 因此我们可以分别对 \(\theta\) 里的每个参数分别求梯度 (也就是偏导数), 从而得到 \(g(x,y;\theta) = \ell(f(x;\theta), y)\) 的梯度. 这时候, 链式法则就排上了用场.
下面列出所有这些梯度:
$$\begin{align*} \nabla_{b_2} g &= \ell'(f(x;\theta), y)\\ \nabla_{w_2} g &= \sigma(W_1x + b_1)\cdot \ell'(f(x;\theta), y)\\ \nabla_{b_1} g &= \mathrm{diag}\{\sigma'(W_1x + b_1)\}\cdot w_2\cdot \ell'(f(x;\theta), y)\\ \nabla_{W_1} g &= \frac{\partial W_1x}{\partial W_1}\cdot \mathrm{diag}\{\sigma'(W_1x + b_1)\}\cdot w_2\cdot \ell'(f(x;\theta), y) \end{align*}$$
其中 \(\sigma'\) 也是 pointwise 的. 因此梯度下降就变为了
$$(W_1,b_1,w_2,b_2)_{t+1} = (W_1,b_1,w_2,b_2)_{t} - \alpha_t (\nabla_{W_1} g,\nabla_{b_1} g,\nabla_{w_2} g,\nabla_{b_2} g)$$
因此问题变得简单, 只要我们能求出函数对每个参数的偏导数, 我们就可以对所有这些参数进行梯度下降.
计算图
个人感觉, 计算图就是一种流程图, 并不算复杂. 在上面的神经网络的例子中, 计算图可以表示如下
对于每个节点, 它都可以对前面的节点求导数. 正向走完这个流程图, 就得到了每个节点的值 (特别地, 得到了 \(f(x;\theta)\) 和损失函数的值); 反过来, 从后往前求导, 每一个导数算且仅算一遍, 结合正向过程中计算的节点的值, 就可以得到函数对每个参数的梯度, 从而可以实施梯度下降.
反向传播的 numpy 实现
下面试图用 numpy 自己实现反向传播, 并手写一个小模型来检验之.
关于 Jacobian 的吐槽
在我的脑子里, 我记得两件事情. 第一, 如果一个映射 \(f:\mathbb{R}^n\to\mathbb{R}^m\), 那么梯度 (雅可比矩阵) 的形状是 \(m\times n\) 的, 即
$$\begin{bmatrix} \nabla_{x_1}f_1&\cdots&\nabla_{x_n}f_1\\ \vdots&&\vdots\\ \nabla_{x_1}f_m&\cdots&\nabla_{x_n}f_m \end{bmatrix}_{m\times n}$$
(第二,) 但是矩阵代数又告诉我, \(a^Tx\) 对 \(x\) 的导数是列向量 \(a\). 如果在雅可比矩阵中令 \(m=1\), 就成了行向量! 不过好像网上搜到的矩阵代数, 都说 \(Ax\) 对 \(x\) 求导是 \(A^T\), 那就这样了. 链式法则时, 内层在左, 外层在右.
妥协与矩阵乘法的梯度推导
在思考如何矩阵乘法的反向传播如何写时, 我就在考虑这样一个情形:
$$B_{k\times m}A_{m\times n}x_{n\times 1}$$
现在考虑这个东西对 \(A\) 的导数.
$$\frac{\partial}{\partial(Ax)}BAx = B^T, \frac{\partial}{\partial A}Ax = \begin{bmatrix}E_{11}x&\cdots&E_{1n}x\\\vdots&&\vdots\\E_{m1}x&\cdots&E_{mn}x\end{bmatrix}$$
这里我用了一种比较蹩脚的方式表示 \(Ax\) 对 \(A\) 的导数, 它是一个 \(m\times n\times m\) 的张量. 那么按照链式法则, 我们要计算这个张量去乘 \(m\times k\) 矩阵 \(B^T\). 实际上在 numpy 中是可以算的, 乘出来形状是 \(m\times n\times k\).
这在数学公式里还能表示, 但如果写到代码里, 麻烦就大了.
def __matmul__(self, other):
# 这里假定 self.data 是一个矩阵, 而 other.data 是一个向量
out = MyTensor(data=self.data @ other.data, parent=(self, other))
def backward(self):
self.grad +=
在重写 @ 时, 我不可能只假设 other 是一个向量, 那样的话无法处理矩阵和矩阵的乘法. 但如果是矩阵乘矩阵, 导数该怎么算? 我又该如何写这个导数和子节点 out 回传回来的梯度 (这个梯度的形状还未知) 的乘法?
于是我问了一下 AI, 发现 PyTorch 根本不允许对一个非标量执行 .backward() 方法. 好了, 那前面考虑的一大堆复杂情况就消失了, out 回传的梯度 (\(\nabla_{out}f\)) 形状, 一定是和 out 形状相同的 (回扣 \(a^Tx\) 对列向量 \(x\) 求导仍然是列向量 \(a\)). 而且这样有一个好处, 既然我们确定一个节点 (回传) 的梯度的形状就是这个节点的张量的形状, 那可以不需要管导数乘导数具体是怎么算的了, 只要最后搞出来一个梯度, 形状对, 数值对, 就够了.
这样一来, 我们可以很容易地推导矩阵乘法中的梯度值. 设 \(AB\) 的梯度是 \(\nabla_{AB}\), 现在考虑 \(A_{ij}\) 的梯度 (应当是一个标量).
$$\begin{align*} \nabla_{A_{ij}}&=\sum_{k,l}(\nabla_{AB})_{kl}\frac{\partial}{\partial A_{ij}}(AB)_{kl}\\ &=\sum_{l}(\nabla_{AB})_{il}\frac{\partial}{\partial A_{ij}}(AB)_{il}\\ &=\sum_{l}(\nabla_{AB})_{il}B_{jl} \end{align*}$$
因此 \(\nabla_{A}=\nabla_{AB}\cdot B^T\), 同理可以推导得 \(\nabla_{B}=A^T\cdot \nabla_{AB}\)
核心思路
import numpy as np
# numpy 有广播机制
class MyTensor:
def __init__(self, data = None, parent = ()):
self.data = np.array(data)
self._prev = set(parent) # 这是为了避免a+a的情况
self.grad = 1.0
self._backward = lambda: None
...
def __matmul__(self, other):
out = MyTensor(data=self.data @ other.data, parent=(self, other))
def _backward(self):
self.grad += out.grad @ other.data.T
other.grad += self.data.T @ out.grad
out._backward = _backward
return out
def backward(self):
topo = []
visited = set()
def build(v):
if v not in visited:
visited.add(v)
for parent in v._prev:
build(parent)
topo.append(v) # topo在for parent之后, 这使得子节点(即靠前的节点)在父节点(即靠后的节点)之前
build(self)
self.grad = 1.0 # 我们要求被调用backward方法的Tensor必须是一个标量
for v in reversed(topo): # 第一个v就是最终的标量
v._backward()
对于每一个 out 节点, 当它调用 ._backward() 时, 它会将梯度累加 (+=) 到它之前的运算数节点上. 由于靠前的节点在 reversed(topo) 中总是在靠后的节点之后, 而靠后的节点总在更靠后的节点之后. 因此按照逆拓扑序进行反向传播, 一个节点总是将上游梯度接收完毕后 (即该节点的梯度不再变化) 才向下游传递. 这就确保了所有节点的梯度都是准确的.

遇到的问题
梯度的梯度
如果让梯度仍然是 MyTensor 类的, 那如何处理反向传播时梯度之间的运算? 因此我选择让梯度全部是 np 数组, 梯度之间的运算全部是 np 数组的运算.
requires_grad
在写 softmax 函数时, 经 AI 提醒, 我发现要减去最大值. 但是一个张量减去一个最大值, 这个最大值本身是不需要梯度的. 因此需要对 MyTensor 类添加实例属性 requires_grad: bool. 通过对 .data 操作, 得到最大值, 再根据造出来一个不需要梯度的 0 维张量. 后续计算通过广播进行.
如果运算数都不需要梯度, 那么结果也不需要梯度; 只要运算数中有一个需要梯度, 结果就需要梯度. 因此在每个函数中都需要判断操作数中是否有需要梯度的. 但如果在每个函数中都这样写会显得重复, 因此引入抽象基类 Function, 见下.
类的解耦
在朴素地写 MyTensor 类时, 我发现我要在这个类里定义一大堆魔法函数和方法, 类会变得非常长, 因此问了 AI, 选择把类的原始定义和那些方法解耦:
"""primary.py"""
import numpy as np
class MyTensor:
def __init__(self, data = None, parent = (), dtype = float, requires_grad = True):
self.data = np.array(data, dtype=dtype)
self._backward = lambda: None
self.requires_grad = requires_grad
self._prev = set(parent) if requires_grad else () # 这是为了避免a+a的情况
self.grad = np.zeros_like(self.data) if requires_grad else None
...
def backward(self):
...
而在另一个文件里实现各种函数, 方法. 再追加到 MyTensor 类中:
"""funcs.py"""
import numpy as np
from .primary import MyTensor
class Function:
"""
Function是一个抽象类, 其子类必须实现forward和backward方法\\
forward和backward方法只操作raw_data\\
forward(ctx, *args): 接收raw_data, 返回计算结果, 把运算数或中间结果写入ctx\\
backward(ctx, *grad_output): 从ctx读取运算数和中间结果, 对应该函数的forward的写入形式, 返回要累加的梯度
"""
is_differentiable = True
@staticmethod
def forward(ctx, *args):
raise NotImplementedError
@staticmethod
def backward(ctx, *grad_output):
raise NotImplementedError
def unbroadcast(grad: np.ndarray, shape):
while grad.ndim > len(shape):
grad = grad.sum(axis=0)
for i, dimlen in enumerate(shape):
if dimlen == 1 and grad.shape[i] > 1:
grad = grad.sum(axis=i, keepdims=True)
return grad
@classmethod
def apply(cls, *args, **kwargs):
"""在被调用时, 输入为MyTensor, 在中间转换为raw_data, 输出仍为MyTensor"""
tensors = [arg if isinstance(arg, MyTensor) else MyTensor(arg, requires_grad=False) for arg in args]
ctx = [] # 存放中间变量, 方便backward计算
requires_grad = cls.is_differentiable and any(t.requires_grad for t in tensors)
raw_data = [t.data for t in tensors]
out_raw = cls.forward(ctx, *raw_data, **kwargs)
out = MyTensor(out_raw, requires_grad=requires_grad)
if requires_grad:
out._prev = set(tensors) # 只有需要梯度时才往前连接节点 (因为这说明前面的节点也需要梯度)
def _backward():
grads = cls.backward(ctx, out.grad)
if not isinstance(grads, tuple):
grads = (grads,)
for t, g in zip(tensors, grads):
if t.requires_grad == True and g is not None: # 往回传梯度时看前面的节点是否需要梯度
t.grad += cls.unbroadcast(g, t.data.shape)
out._backward = _backward
return out
class Add(Function):
@staticmethod
def forward(...):
...
@staticmethod
def backward(...):
...
MyTensor.__add__ = lambda a, b: Add.apply(a, b)
unbroadcast
numpy 在进行加法时会自动广播, 但是我们需要在反向传播时, 把梯度 "unbroadcast" 回广播前的维度, 无非是一些累加. 见上述代码, 同样, 只需要写在抽象基类里.
是否可微
有些操作, 比如 a > b, 返回的是一个布尔值, 不可微, 或者说梯度永远是 0, 不参与反向传播. 因此做法是在抽象基类里定义 is_differentiable = True, 写不可微的算子时重写该属性为 False. 在 apply 中判断 requires_grad = cls.is_differentiable and any(t.requires_grad for t in tensors), 即可.
context
记录前向传播时的中间变量, 方便反向传播时梯度计算. 由于类被解耦, 而梯度永远是 np 数组, 我们只要让 backward 返回的是一个正确的梯度即可, 怎么算不重要.
魔法函数
有一些不那么直观的东西, 也需要给出前向传播和反向传播算梯度的公式. 比如切片操作, 需要实现 __getitem__; 再如负号, 需要实现 __neg__.
实践
手写一个单隐藏层的神经网络, 实现训练函数, 在 MNIST 数据集上训练. 全部计算通过 numpy 实现. 这里的 MNIST 数据集使用处理好的 csv 文件, 从 https://github.com/phoebetronic/mnist/tree/main 下载. 完整代码及训练过程见 kaggle.