反向传播与自动微分
一个参数的梯度,我们可以手算。网络有几千、几百万个参数时,还要逐个展开整条公式吗?
不必。计算机已经知道前向过程用了哪些加法、乘法和激活函数。只要为每一种小运算写好局部求导规则,就能沿着计算过程把影响一层层传回去。这就是反向传播的核心。
1. 先把一个小网络算到底
取输入 ,目标 。网络只有一个隐藏单元:
控制隐藏单元, 是输出权重。初始值取 。
从上往下计算,得到 。把各步作为节点、依赖关系作为箭头,就得到一张计算图。
图 1:前向计算保存中间结果;反向计算从损失出发,沿依赖关系回传。图中小于零的导数表示相应中间量增加一点,会使当前损失下降。
2. 从最后一步往回算
先只看损失怎样依赖预测:
预测比目标小,往上调会降低损失,因此导数为负。
接下来 。对 而言,局部变化率是 ;对 而言,是 。乘上前面传来的 −2:
当前 ,ReLU 在这里的导数为 1,所以 。
最后 ,得到:
整个过程反复做同一件事:从后面传来的梯度,乘上当前运算的局部导数。前向做一次乘法,反向就知道该怎样把影响分配给它的两个输入。
对于 ReLU 的负半轴,局部导数为零,这条路径上的梯度也会变成零。零点处没有通常意义的导数,代码约定取零;不要把程序约定当成数学上存在唯一导数。
3. 一个值走了两条路,梯度要相加
考虑 。同一个 既参与平方,又直接相加,改变它会同时影响两条路径:
在 时结果是 7。如果反向过程中后一次计算把前一次梯度覆盖了,就会漏掉贡献。因此,代码里通常需要的是 +=,不是简单的赋值。
乘法也可能重复使用同一个节点,例如 x*x。两个输入位置都要贡献梯度,即使它们引用的是同一个对象。遍历图时避免重复访问一个节点,与反向时累加所有依赖路径,是两件不同的事。
一般地,一个变量 影响多个后续变量 时:
这正是多变量链式法则在计算图上的样子。
4. 写一个小小的自动微分器
我们为每个数保存四样东西:前向数值、目前累计的梯度、父节点,以及怎样把梯度传回父节点的函数。
加法的局部导数都是 1。乘法 的局部导数分别为 和 。ReLU 根据输入是否大于零,选择传回梯度或截断。
计算结束后,先按依赖顺序整理节点,再反过来访问。这样,一个节点的所有后续贡献都已汇总,才继续往更早的节点传递。这个依赖顺序叫作拓扑序。
完整实现只用 Python 标准库:
python3 src/examples/autodiff.py
"""第 13 章:加法、乘法和 ReLU 组成的标量反向自动微分。"""
class Value:
def __init__(self, data, parents=()):
self.data = float(data)
self.grad = 0.0
self.parents = parents
self._backward = lambda: None
@staticmethod
def as_value(value):
return value if isinstance(value, Value) else Value(value)
def __add__(self, other):
other = self.as_value(other)
output = Value(self.data+other.data, (self, other))
def backward():
self.grad += output.grad
other.grad += output.grad
output._backward = backward
return output
__radd__ = __add__
def __mul__(self, other):
other = self.as_value(other)
output = Value(self.data*other.data, (self, other))
def backward():
self.grad += other.data*output.grad
other.grad += self.data*output.grad
output._backward = backward
return output
__rmul__ = __mul__
def __neg__(self):
return self*-1
def __sub__(self, other):
return self + (-self.as_value(other))
def __rsub__(self, other):
return self.as_value(other) + (-self)
def relu(self):
output = Value(max(0, self.data), (self,))
def backward():
self.grad += (self.data > 0)*output.grad
output._backward = backward
return output
def backward(self):
order, visited = [], set()
def visit(node):
if node not in visited:
visited.add(node)
for parent in node.parents:
visit(parent)
order.append(node)
visit(self)
# 为了让本例重复调用可预测,每次重置这张图上全部梯度。
for node in order:
node.grad = 0.0
self.grad = 1.0
for node in reversed(order):
node._backward()
def network_loss(w, b, v):
hidden = (w*2+b).relu()
prediction = v*hidden
error = prediction-4
return error*error, prediction
def main():
w, b, v = Value(0.5), Value(0.5), Value(2)
loss, prediction = network_loss(w, b, v)
loss.backward()
print(f"预测={prediction.data:.3f},损失={loss.data:.3f}")
print(f"梯度:dw={w.grad:.3f},db={b.grad:.3f},dv={v.grad:.3f}")
for parameter in [w, b, v]:
parameter.data -= 0.01*parameter.grad
updated, prediction = network_loss(w, b, v)
print(f"更新后预测={prediction.data:.3f},损失={updated.data:.6f}")
x = Value(3)
shared = x*x+x
shared.backward()
print(f"x=3 时,x*x+x 对 x 的梯度={x.grad:.1f}")
if __name__ == "__main__":
main()
运行结果:
预测=3.000,损失=1.000
梯度:dw=-8.000,db=-4.000,dv=-3.000
更新后预测=3.451,损失=0.301401
x=3 时,x*x+x 对 x 的梯度=7.0
学习率为 0.01,更新得到 。新隐藏值为 1.7,预测为 3.451,平方误差降到 0.301401。
注意顺序:先在旧参数上完成整张图的梯度计算,再一起更新参数,再重新做一次前向计算。若在反向传播尚未结束时改动某个权重,其他路径就可能用到与前向不一致的数值。
这个实现只支持标量、几种运算和一阶反向求导,不具备成熟框架的张量、高阶梯度、设备调度和内存优化能力。它足够小,便于把每一步看清楚。
5. 它和数值差分、符号求导有什么区别
第二章用过数值差分:稍微扰动一个参数,重新计算损失,用差商近似梯度。它很适合检查小模型,但参数很多时,需要重复大量前向计算;步长太大有近似误差,太小又会受浮点精度影响。
符号求导会对表达式作代数变换,得到新的表达式,例如把 变成 。复杂程序的分支、循环和大量共享计算,不一定适合完整展开。
自动微分则记录实际执行的基本运算,并用链式法则组合它们的导数。它没有使用差分近似,但仍受到浮点计算和不可导点约定的影响,不能简单说成“无限精确”。
当许多参数共同决定一个标量损失时,反向模式可以在一次反向遍历中得到它们的梯度。典型代价与前向计算同一量级,但要保存或重算中间结果。后来讨论大模型显存时,这个保存成本会再次出现。
6. 框架会求导,仍然需要你决定什么
自动微分不会替你选择有意义的损失,也不会发现标签错位。只要计算图合法,一个错误任务同样可以被认真求导。
还要区分三个动作:
| 动作 | 做了什么 |
|---|---|
| 前向计算 | 根据当前参数得到预测和损失 |
| 反向传播 | 计算损失对参数的梯度 |
| 优化器更新 | 根据梯度与优化规则修改参数 |
backward() 并不等于“训练完成”,也不自动更新权重。
本章实现每次反向前会重置整张图的梯度。PyTorch 默认会把新梯度累加到参数现有梯度上,因此普通训练循环通常先调用 optimizer.zero_grad()。累加有时是刻意需要的,但忘记清零与有计划的梯度累积不是一回事。
许多框架根据本次前向执行动态建立计算图。输入变了、分支变了,下次图也可能不同。不要在反向前把需要求导的中间张量转换成普通 Python 数值,再重新装成张量;那会切断原有依赖关系。
7. 自测与小实验
- 本例为什么先算损失对预测的导数,而不是先猜 的梯度?
- 若隐藏单元输入 ,通过这个 ReLU 的梯度是什么?
- 为什么
x*x+x的实现必须累加梯度? - 调用反向传播以后,参数一定已经改变了吗?
- 数值差分与自动微分的主要计算方式有什么不同?
- 本例更新后 和 分别是多少?
- 为什么需要重新做前向,而不能直接拿旧损失判断更新效果?
展开参考答案
- 从末端开始,每一步只需已知的下游梯度与局部导数,避免重复展开整条依赖。
- 为零,当前这条路径不会把梯度传回 ReLU 之前。
- 同一个输入通过多条路径影响结果,每条路径的贡献都要保留。
- 不一定。反向传播只计算梯度,更新由优化器或手写更新代码完成。
- 差分用扰动后的函数值近似变化率;自动微分组合基本运算的解析导数。
- ,。
- 参数已经改变,旧损失对应旧预测,无法描述新参数的表现。
选做:分别扰动 ,用中央差分核对自动微分结果。再把 改到使 的位置,检查梯度。避开 ReLU 恰好为零的点,以免把不可导点约定和程序错误混在一起。
下一章训练一个可靠的神经网络会把前向、反向和更新放进完整循环。PyTorch 的自动微分教程展示了张量框架中的对应操作;本章小实现用于理解机制,不替代框架。