Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

反向传播与自动微分

一个参数的梯度,我们可以手算。网络有几千、几百万个参数时,还要逐个展开整条公式吗?

不必。计算机已经知道前向过程用了哪些加法、乘法和激活函数。只要为每一种小运算写好局部求导规则,就能沿着计算过程把影响一层层传回去。这就是反向传播的核心。

1. 先把一个小网络算到底

取输入 x=2x=2,目标 y=4y=4。网络只有一个隐藏单元:

a=wx+bh=ReLU⁡(a)y^=vhL=(y^−y)2 \begin{aligned} a&=wx+b\\ h&=\operatorname{ReLU}(a)\\ \hat y&=vh\\ L&=(\hat y-y)^2 \end{aligned}

w,bw,b 控制隐藏单元,vv 是输出权重。初始值取 w=0.5,b=0.5,v=2w=0.5,b=0.5,v=2。

从上往下计算,得到 a=1.5,h=1.5,y^=3,L=1a=1.5,h=1.5,\hat y=3,L=1。把各步作为节点、依赖关系作为箭头,就得到一张计算图。

图 1:前向计算保存中间结果;反向计算从损失出发,沿依赖关系回传。图中小于零的导数表示相应中间量增加一点,会使当前损失下降。

2. 从最后一步往回算

先只看损失怎样依赖预测:

∂L∂y^=2(y^−y)=−2 \frac{\partial L}{\partial\hat y} =2(\hat y-y)=-2

预测比目标小,往上调会降低损失,因此导数为负。

接下来 y^=vh\hat y=vh。对 vv 而言,局部变化率是 hh;对 hh 而言,是 vv。乘上前面传来的 −2:

∂L∂v=(−2)×1.5=−3∂L∂h=(−2)×2=−4 \begin{aligned} \frac{\partial L}{\partial v} &=(-2)\times1.5=-3\\ \frac{\partial L}{\partial h} &=(-2)\times2=-4 \end{aligned}

当前 a=1.5>0a=1.5>0,ReLU 在这里的导数为 1,所以 ∂L/∂a=−4\partial L/\partial a=-4。

最后 a=wx+ba=wx+b,得到:

∂L∂w=(−4)×x=−8∂L∂b=−4 \begin{aligned} \frac{\partial L}{\partial w} &=(-4)\times x=-8\\ \frac{\partial L}{\partial b} &=-4 \end{aligned}

整个过程反复做同一件事:从后面传来的梯度,乘上当前运算的局部导数。前向做一次乘法,反向就知道该怎样把影响分配给它的两个输入。

对于 ReLU 的负半轴,局部导数为零,这条路径上的梯度也会变成零。零点处没有通常意义的导数,代码约定取零;不要把程序约定当成数学上存在唯一导数。

3. 一个值走了两条路,梯度要相加

考虑 L=x2+xL=x^2+x。同一个 xx 既参与平方,又直接相加,改变它会同时影响两条路径:

dLdx=2x+1 \frac{dL}{dx}=2x+1

在 x=3x=3 时结果是 7。如果反向过程中后一次计算把前一次梯度覆盖了,就会漏掉贡献。因此,代码里通常需要的是 +=,不是简单的赋值。

乘法也可能重复使用同一个节点,例如 x*x。两个输入位置都要贡献梯度,即使它们引用的是同一个对象。遍历图时避免重复访问一个节点,与反向时累加所有依赖路径,是两件不同的事。

一般地,一个变量 zz 影响多个后续变量 uju_j 时:

∂L∂z=∑j∂L∂uj∂uj∂z \frac{\partial L}{\partial z} =\sum_j \frac{\partial L}{\partial u_j} \frac{\partial u_j}{\partial z}

这正是多变量链式法则在计算图上的样子。

4. 写一个小小的自动微分器

我们为每个数保存四样东西:前向数值、目前累计的梯度、父节点,以及怎样把梯度传回父节点的函数。

加法的局部导数都是 1。乘法 c=abc=ab 的局部导数分别为 bb 和 aa。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,更新得到 w=0.58,b=0.54,v=2.03w=0.58,b=0.54,v=2.03。新隐藏值为 1.7,预测为 3.451,平方误差降到 0.301401。

注意顺序:先在旧参数上完成整张图的梯度计算,再一起更新参数,再重新做一次前向计算。若在反向传播尚未结束时改动某个权重,其他路径就可能用到与前向不一致的数值。

这个实现只支持标量、几种运算和一阶反向求导,不具备成熟框架的张量、高阶梯度、设备调度和内存优化能力。它足够小,便于把每一步看清楚。

5. 它和数值差分、符号求导有什么区别

第二章用过数值差分:稍微扰动一个参数,重新计算损失,用差商近似梯度。它很适合检查小模型,但参数很多时,需要重复大量前向计算;步长太大有近似误差,太小又会受浮点精度影响。

符号求导会对表达式作代数变换,得到新的表达式,例如把 x2x^2 变成 2x2x。复杂程序的分支、循环和大量共享计算,不一定适合完整展开。

自动微分则记录实际执行的基本运算,并用链式法则组合它们的导数。它没有使用差分近似,但仍受到浮点计算和不可导点约定的影响,不能简单说成“无限精确”。

当许多参数共同决定一个标量损失时,反向模式可以在一次反向遍历中得到它们的梯度。典型代价与前向计算同一量级,但要保存或重算中间结果。后来讨论大模型显存时,这个保存成本会再次出现。

6. 框架会求导,仍然需要你决定什么

自动微分不会替你选择有意义的损失,也不会发现标签错位。只要计算图合法,一个错误任务同样可以被认真求导。

还要区分三个动作:

动作做了什么
前向计算根据当前参数得到预测和损失
反向传播计算损失对参数的梯度
优化器更新根据梯度与优化规则修改参数

backward() 并不等于“训练完成”,也不自动更新权重。

本章实现每次反向前会重置整张图的梯度。PyTorch 默认会把新梯度累加到参数现有梯度上,因此普通训练循环通常先调用 optimizer.zero_grad()。累加有时是刻意需要的,但忘记清零与有计划的梯度累积不是一回事。

许多框架根据本次前向执行动态建立计算图。输入变了、分支变了,下次图也可能不同。不要在反向前把需要求导的中间张量转换成普通 Python 数值,再重新装成张量;那会切断原有依赖关系。

7. 自测与小实验

  1. 本例为什么先算损失对预测的导数,而不是先猜 ww 的梯度?
  2. 若隐藏单元输入 a<0a<0,通过这个 ReLU 的梯度是什么?
  3. 为什么 x*x+x 的实现必须累加梯度?
  4. 调用反向传播以后,参数一定已经改变了吗?
  5. 数值差分与自动微分的主要计算方式有什么不同?
  6. 本例更新后 aa 和 y^\hat y 分别是多少?
  7. 为什么需要重新做前向,而不能直接拿旧损失判断更新效果?
展开参考答案
  1. 从末端开始,每一步只需已知的下游梯度与局部导数,避免重复展开整条依赖。
  2. 为零,当前这条路径不会把梯度传回 ReLU 之前。
  3. 同一个输入通过多条路径影响结果,每条路径的贡献都要保留。
  4. 不一定。反向传播只计算梯度,更新由优化器或手写更新代码完成。
  5. 差分用扰动后的函数值近似变化率;自动微分组合基本运算的解析导数。
  6. a=0.58×2+0.54=1.7a=0.58\times2+0.54=1.7,y^=2.03×1.7=3.451\hat y=2.03\times1.7=3.451。
  7. 参数已经改变,旧损失对应旧预测,无法描述新参数的表现。

选做:分别扰动 w,b,vw,b,v,用中央差分核对自动微分结果。再把 w,bw,b 改到使 a<0a<0 的位置,检查梯度。避开 ReLU 恰好为零的点,以免把不可导点约定和程序错误混在一起。

下一章训练一个可靠的神经网络会把前向、反向和更新放进完整循环。PyTorch 的自动微分教程展示了张量框架中的对应操作;本章小实现用于理解机制,不替代框架。