跳到主要内容

内容阅读

AI 底层工程阿新聊ai

反向传播:计算图、链式法则,以及梯度如何一层层传回去

本文迁移自 mindcarver/91ai · 原始位置 docs/machine learning/stage2 deep learning/02 backpropagation.md · 由 @阿新聊ai 整理。 <! 调研来源: 1. Colah's Blog "Calculus on Computationa...

本文迁移自 mindcarver/91ai · 原始位置 docs/machine-learning/stage2-deep-learning/02-backpropagation.md · 由 @阿新聊ai 整理。

反向传播:计算图、链式法则,以及梯度如何一层层传回去

TL;DR: 反向传播(Backpropagation)不是一个新算法,它就是链式法则在计算图上的高效应用。前向传播把数据从输入推向输出,计算出预测值和损失。反向传播把梯度从输出拉回输入,告诉你每个参数该怎么调。核心规则只有一条:下游梯度 = 上游梯度 × 局部梯度。梯度消失和爆炸是深层网络最头疼的问题——Sigmoid 的导数最大才 0.25,多层连乘后梯度几乎为零。ReLU、BatchNorm、残差连接是三大解法。

为什么这很重要

假设你在训练一个 10 层的神经网络。每一层有几百个参数。总共有几千个参数需要更新。每次更新,你都需要知道:每个参数对最终损失的贡献有多大?

这就是梯度的作用。梯度告诉你:"如果把某个参数稍微增大一点,损失会增加还是减少?增加/减少多少?"

最直觉的计算方法是对每个参数分别计算梯度。如果有 N 个参数,你需要做 N 次前向传播。对于一个有 100 万参数的网络(很小的网络),这意味着 100 万次前向传播来计算一次梯度更新。这完全不可行。

反向传播算法的精妙之处在于:只需要一次前向传播和一次反向传播,就能计算出所有参数的梯度。 不管你有 100 个参数还是 1 亿个参数,计算量都是两次遍历网络(一次向前,一次向后)。

这个效率上的突破是深度学习能够在实践中训练大规模网络的基础。没有反向传播,深度学习就不可能存在。

核心概念

链式法则:反向传播的数学基础

链式法则(Chain Rule)是微积分中处理复合函数求导的基本规则。

如果 $y = f(g(x))$,那么:

$$ \frac{dy}{dx} = \frac{dy}{dg} \cdot \frac{dg}{dx} $$

直觉:如果 $g$ 变化 1 单位会导致 $y$ 变化 $\frac{dy}{dg}$ 单位,而 $x$ 变化 1 单位会导致 $g$ 变化 $\frac{dg}{dx}$ 单位,那么 $x$ 变化 1 单位最终导致 $y$ 变化 $\frac{dy}{dg} \times \frac{dg}{dx}$ 单位。

日常类比:假设你在开车。

  • 踩油门的力度($x$)决定了速度($g$):$\frac{dg}{dx} = 10$ km/h 每单位力度
  • 速度($g$)决定了油耗($y$):$\frac{dy}{dg} = 0.1$ 升每 km/h
  • 那么踩油门的力度($x$)和油耗($y$)的关系是:$\frac{dy}{dx} = 0.1 \times 10 = 1$ 升每单位力度

链式法则告诉你"力度 → 速度 → 油耗"这条链上,首尾(力度和油耗)之间的关系。

计算图:反向传播的数据结构

计算图(Computational Graph)是把计算过程表示为有向无环图(DAG)的方式。

  • 节点:表示一个操作(加法、乘法、函数应用)
  • :表示数据的流动方向

一个简单的例子:$f(x, y, z) = (x + y) \cdot z$

计算图:

    x ──→ [+] ──→ [×] ──→ f
          ↑         ↑
    y ──→┘    z ──→┘

前向传播(从左到右):
  a = x + y    (加法节点)
  f = a · z    (乘法节点)

前向传播:从输入节点开始,按照拓扑序依次计算每个节点的值,直到输出节点。

反向传播:从输出节点开始,沿着反方向传递梯度,直到输入节点。

反向传播的核心规则

反向传播只有一条核心规则:

$$ \text{下游梯度} = \text{上游梯度} \times \text{局部梯度} $$

  • 上游梯度:从后面(更靠近输出的层)传来的梯度,表示"输出对这个节点的输出的变化有多敏感"
  • 局部梯度:这个节点本身对其输入的导数,表示"这个节点的输出对输入的变化有多敏感"
  • 下游梯度:传给前面层的梯度,表示"输出对这个节点的输入的变化有多敏感"
前向传播方向 →
输入 → [节点] → 输出
  a      f(a)      b = f(a)

← 反向传播方向
       上游梯度 ∂L/∂b
           ↓
输入 ← [节点] ← 输出
  a      f(a)      b

下游梯度 = 上游梯度 × 局部梯度
∂L/∂a = ∂L/∂b × ∂b/∂a = ∂L/∂b × f'(a)

工作原理(简化的心智模型)

反向传播像一个公司里的信息流动

把神经网络想象成一家公司。

前向传播:客户需求从销售部门传到生产部门。

  • 销售部门接到订单 → 传给设计部门 → 设计方案传给生产部门 → 产品出厂
  • 每个部门根据自己的输入做自己的工作,把结果传给下一个部门

反向传播:客户投诉从销售部门反向传到生产部门。

  • 客户投诉(损失值)→ 销售部门分析:"这个问题有多少是因为产品设计的?有多少是因为原材料选择的?"
  • 销售部门把"责任分配"传给设计部门 → 设计部门分析:"这个问题有多少是因为生产环节的?有多少是因为设计方案的?"
  • 每个部门只需要知道自己做了什么(局部梯度),就能算出自己应该承担多少"责任"(下游梯度)

关键洞察:每个部门不需要了解整个公司的运作方式。它只需要知道:

  1. 上游传来的"责任分配"(上游梯度)
  2. 自己做了什么操作(局部梯度)

然后就能算出自己应该传递给下游多少责任。这就是反向传播的美妙之处——每个节点只需要局部信息就能参与全局计算

反向传播为什么高效

如果你对每个参数单独求导(朴素的链式法则),很多计算会被重复。反向传播通过从输出到输入的反向遍历,让每个中间梯度只计算一次,然后被所有需要它的下游节点共享。

对于一个有 $N$ 个参数的网络:

  • 朴素方法:$O(N)$ 次前向传播(每个参数一次)
  • 反向传播:1 次前向传播 + 1 次反向传播

反向传播的复杂度大约是前向传播的 2-3 倍(因为需要存储中间值和计算反向梯度),但与参数数量 $N$ 无关。

工作原理(详细机制)

一个完整的反向传播例子

让我们一步步计算 $f(x, y, z) = (x + y) \cdot z$ 的梯度。

假设 $x = 2, y = -3, z = 4$。

第一步:前向传播

    x = 2 ──→ [+] ──→ a ──→ [×] ──→ f
              ↑         ↑
    y = -3 ─→┘    z = 4 ─→┘

a = x + y = 2 + (-3) = -1
f = a · z = (-1) · 4 = -4

第二步:反向传播

从输出 $f$ 开始,初始上游梯度 = $\frac{\partial f}{\partial f} = 1$。

反向传播:

    x ←── [+] ←── a ←── [×] ←── f (上游梯度 = 1)
              ↑         ↑
    y ←──────┘    z ←──┘

乘法节点 [×]:
  局部梯度:∂f/∂a = z = 4,  ∂f/∂z = a = -1
  传给 a 的梯度:1 × 4 = 4
  传给 z 的梯度:1 × (-1) = -1

加法节点 [+]:
  上游梯度(从乘法节点传来)= 4
  局部梯度:∂a/∂x = 1,  ∂a/∂y = 1
  传给 x 的梯度:4 × 1 = 4
  传给 y 的梯度:4 × 1 = 4

最终结果:

  • $\frac{\partial f}{\partial x} = 4$
  • $\frac{\partial f}{\partial y} = 4$
  • $\frac{\partial f}{\partial z} = -1$

验证:

  • $\frac{\partial f}{\partial x} = z = 4$ ✓
  • $\frac{\partial f}{\partial y} = z = 4$ ✓
  • $\frac{\partial f}{\partial z} = x + y = -1$ ✓

常用操作的反向传播规则

加法节点 $c = a + b$:

  • $\frac{\partial L}{\partial a} = \frac{\partial L}{\partial c} \cdot 1 = \frac{\partial L}{\partial c}$
  • $\frac{\partial L}{\partial b} = \frac{\partial L}{\partial c} \cdot 1 = \frac{\partial L}{\partial c}$
  • 规则:加法的梯度分配器——上游梯度原封不动地传给每个输入

乘法节点 $c = a \cdot b$:

  • $\frac{\partial L}{\partial a} = \frac{\partial L}{\partial c} \cdot b$
  • $\frac{\partial L}{\partial b} = \frac{\partial L}{\partial c} \cdot a$
  • 规则:乘法的梯度交换器——每个输入的梯度是上游梯度乘以另一个输入的值

ReLU 节点 $c = \max(0, a)$:

  • $\frac{\partial L}{\partial a} = \frac{\partial L}{\partial c} \cdot \begin{cases} 1 & a > 0 \ 0 & a \leq 0 \end{cases}$
  • 规则:ReLU 的梯度开关——正值区域梯度直接通过,负值区域梯度被截断

Sigmoid 节点 $c = \sigma(a) = \frac{1}{1 + e^{-a}}$:

  • $\frac{\partial L}{\partial a} = \frac{\partial L}{\partial c} \cdot \sigma(a)(1 - \sigma(a))$
  • 注意:$\sigma(a)(1 - \sigma(a))$ 的最大值是 0.25(在 $a=0$ 时取到)

多层网络的反向传播

对于一个 $L$ 层网络:

$$ \mathbf{h}^{(l)} = \sigma(W^{(l)} \mathbf{h}^{(l-1)} + \mathbf{b}^{(l)}) $$

反向传播计算 $\frac{\partial L}{\partial W^{(l)}}$ 和 $\frac{\partial L}{\partial \mathbf{b}^{(l)}}$ 对每一层 $l$。

从第 $L$ 层开始,反向逐层计算

$$ \frac{\partial L}{\partial \mathbf{h}^{(l-1)}} = \left(\frac{\partial L}{\partial \mathbf{h}^{(l)}} \odot \sigma'(\mathbf{z}^{(l)})\right) {W^{(l)}}^T $$

$$ \frac{\partial L}{\partial W^{(l)}} = {\mathbf{h}^{(l-1)}}^T \left(\frac{\partial L}{\partial \mathbf{h}^{(l)}} \odot \sigma'(\mathbf{z}^{(l)})\right) $$

$$ \frac{\partial L}{\partial \mathbf{b}^{(l)}} = \sum_{i} \left(\frac{\partial L}{\partial \mathbf{h}^{(l)}} \odot \sigma'(\mathbf{z}^{(l)})\right)_i $$

其中 $\odot$ 是逐元素乘法(Hadamard 积),$\mathbf{z}^{(l)} = W^{(l)} \mathbf{h}^{(l-1)} + \mathbf{b}^{(l)}$。

梯度消失和爆炸

当网络很深时,反向传播需要把梯度通过很多层传回去。如果每一层都把梯度乘以一个小于 1 的数,经过多层后梯度会衰减到接近 0——这就是梯度消失

数学分析

假设每层的激活函数是 Sigmoid,其导数最大值为 0.25。对于 10 层网络:

$$ \frac{\partial L}{\partial W^{(1)}} \propto \prod_{l=1}^{10} \sigma'(\mathbf{z}^{(l)}) \cdot {W^{(l)}}^T $$

如果每个 $\sigma'(z) \approx 0.25$,那么经过 10 层后梯度被乘以 $0.25^{10} \approx 10^{-6}$。第一层的梯度几乎为零,参数基本不更新。

梯度在反向传播中的变化:

第 10 层 → 梯度 × 0.25 → 第 9 层 → × 0.25 → 第 8 层 → ... → 第 1 层

原始梯度: 1.0
第 9 层:  0.25
第 8 层:  0.0625
第 7 层:  0.016
...
第 1 层:  0.000001  ← 几乎为零,参数不会更新

梯度爆炸是相反的问题:如果权重矩阵的值太大,每层的梯度被放大,经过多层后变成天文数字。

解决方案

  1. ReLU 激活函数:正区间的导数恒为 1(不是 0.25),梯度不会在传递过程中衰减。这是最简单有效的解决方案。

  2. 权重初始化:Xavier 初始化和 He 初始化确保初始时各层的激活值和梯度方差大致相同,避免梯度消失或爆炸。

  3. Batch Normalization:通过归一化每层的输出,使得梯度的分布更加稳定。

  4. 残差连接(ResNet):通过加法让梯度可以直接"跳过"某些层,不经过激活函数的衰减。

$$ \mathbf{h}^{(l+1)} = \sigma(W^{(l)} \mathbf{h}^{(l)} + \mathbf{b}^{(l)}) + \mathbf{h}^{(l)} $$

反向传播时:$\frac{\partial \mathbf{h}^{(l+1)}}{\partial \mathbf{h}^{(l)}} = \sigma'(\cdot) W^{(l)} + I$

即使 $\sigma'(\cdot) W^{(l)}$ 很小,还有 $+I$(单位矩阵)保底,梯度不会消失。

  1. 梯度裁剪(Gradient Clipping):限制梯度的最大值,防止梯度爆炸。
# 梯度裁剪
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

自动微分

现代深度学习框架(PyTorch、TensorFlow)使用**自动微分(Automatic Differentiation)**来实现反向传播。你不需要手动推导梯度——框架会自动构建计算图并计算梯度。

import torch

# PyTorch 的自动微分
x = torch.tensor([2.0], requires_grad=True)
y = torch.tensor([-3.0], requires_grad=True)
z = torch.tensor([4.0], requires_grad=True)

# 前向传播
a = x + y
f = a * z

# 反向传播(一行代码!)
f.backward()

print(f"∂f/∂x = {x.grad}")  # 输出: 4.0
print(f"∂f/∂y = {y.grad}")  # 输出: 4.0
print(f"∂f/∂z = {z.grad}")  # 输出: -1.0

PyTorch 使用动态计算图(Define-by-Run):每次前向传播都构建一个新的计算图。TensorFlow 2.x 默认也使用动态图(Eager Execution)。

代码示例

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
import warnings
warnings.filterwarnings('ignore')

np.random.seed(42)

# ============================================================
# 1. 手动实现反向传播(完整版)
# ============================================================
print("=" * 70)
print("1. 手动实现反向传播")
print("=" * 70)

class NeuralNetwork:
    """从零实现的前馈神经网络,带手动反向传播"""
    
    def __init__(self, layer_sizes, learning_rate=0.01):
        self.layer_sizes = layer_sizes
        self.lr = learning_rate
        self.n_layers = len(layer_sizes) - 1
        
        # He 初始化(适用于 ReLU)
        self.weights = []
        self.biases = []
        for i in range(self.n_layers):
            W = np.random.randn(layer_sizes[i], layer_sizes[i+1]) * np.sqrt(2.0 / layer_sizes[i])
            b = np.zeros((1, layer_sizes[i+1]))
            self.weights.append(W)
            self.biases.append(b)
    
    def relu(self, z):
        return np.maximum(0, z)
    
    def relu_derivative(self, z):
        return (z > 0).astype(float)
    
    def sigmoid(self, z):
        return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
    
    def forward(self, X):
        """前向传播:存储所有中间值"""
        self.z_list = []  # 线性变换结果
        self.a_list = [X]  # 激活值(第一层是输入)
        
        a = X
        for i in range(self.n_layers - 1):
            z = a @ self.weights[i] + self.biases[i]
            self.z_list.append(z)
            a = self.relu(z)
            self.a_list.append(a)
        
        # 输出层用 sigmoid
        z = a @ self.weights[-1] + self.biases[-1]
        self.z_list.append(z)
        a = self.sigmoid(z)
        self.a_list.append(a)
        
        return a
    
    def backward(self, y):
        """反向传播:计算所有梯度"""
        m = y.shape[0]
        
        # 存储梯度
        self.dW = [None] * self.n_layers
        self.db = [None] * self.n_layers
        
        # 输出层梯度(sigmoid + BCE loss 的简化形式)
        delta = self.a_list[-1] - y  # ∂L/∂z_L
        
        for i in range(self.n_layers - 1, -1, -1):
            # 参数梯度
            self.dW[i] = self.a_list[i].T @ delta / m
            self.db[i] = np.mean(delta, axis=0, keepdims=True)
            
            # 传播到前一层(如果不是第一层)
            if i > 0:
                delta = (delta @ self.weights[i].T) * self.relu_derivative(self.z_list[i-1])
    
    def update(self):
        """梯度下降更新"""
        for i in range(self.n_layers):
            self.weights[i] -= self.lr * self.dW[i]
            self.biases[i] -= self.lr * self.db[i]
    
    def train(self, X, y, epochs=1000, verbose=True):
        """训练循环"""
        losses = []
        for epoch in range(epochs):
            # 前向传播
            output = self.forward(X)
            
            # 计算 loss
            eps = 1e-8
            loss = -np.mean(y * np.log(output + eps) + (1 - y) * np.log(1 - output + eps))
            losses.append(loss)
            
            # 反向传播
            self.backward(y)
            
            # 更新参数
            self.update()
            
            if verbose and (epoch + 1) % 200 == 0:
                acc = accuracy_score(y.flatten(), (output > 0.5).flatten())
                print(f"  Epoch {epoch+1}: loss={loss:.4f}, accuracy={acc:.4f}")
        
        return losses

# ============================================================
# 2. 梯度消失演示
# ============================================================
print("\n" + "=" * 70)
print("2. 梯度消失/爆炸演示")
print("=" * 70)

# 用 Sigmoid 激活函数的深层网络,观察梯度变化
def check_gradient_magnitude(layer_sizes, activation='sigmoid', n_trials=100):
    """检查不同层的梯度量级"""
    np.random.seed(42)
    
    # 生成随机数据
    X = np.random.randn(50, layer_sizes[0])
    y = np.random.randint(0, 2, (50, 1)).astype(float)
    
    # 初始化网络
    weights = []
    biases = []
    for i in range(len(layer_sizes) - 1):
        W = np.random.randn(layer_sizes[i], layer_sizes[i+1]) * 0.5
        b = np.zeros((1, layer_sizes[i+1]))
        weights.append(W)
        biases.append(b)
    
    # 前向传播
    z_list = []
    a_list = [X]
    a = X
    for i in range(len(layer_sizes) - 1):
        z = a @ weights[i] + biases[i]
        z_list.append(z)
        if activation == 'sigmoid':
            a = 1 / (1 + np.exp(-np.clip(z, -500, 500)))
        elif activation == 'relu':
            a = np.maximum(0, z)
        a_list.append(a)
    
    # 反向传播
    delta = a_list[-1] - y  # 输出层梯度
    
    layer_gradient_norms = [np.linalg.norm(delta)]
    
    for i in range(len(weights) - 1, 0, -1):
        if activation == 'sigmoid':
            s = 1 / (1 + np.exp(-np.clip(z_list[i-1], -500, 500)))
            delta = (delta @ weights[i].T) * s * (1 - s)
        elif activation == 'relu':
            delta = (delta @ weights[i].T) * (z_list[i-1] > 0).astype(float)
        layer_gradient_norms.append(np.linalg.norm(delta))
    
    layer_gradient_norms.reverse()  # 从第 1 层到最后一层
    return layer_gradient_norms

# 对比 Sigmoid 和 ReLU 在 10 层网络中的梯度
layers = [20] + [64] * 10 + [1]  # 10 个隐藏层

sigmoid_grads = check_gradient_magnitude(layers, activation='sigmoid')
relu_grads = check_gradient_magnitude(layers, activation='relu')

print(f"10 层网络中各层的梯度范数:")
print(f"\n{'层':&#x3C;8} {'Sigmoid 梯度范数':&#x3C;25} {'ReLU 梯度范数':&#x3C;25}")
print("-" * 60)
for i in range(len(sigmoid_grads)):
    print(f"  层 {i+1:&#x3C;3} {sigmoid_grads[i]:&#x3C;25.6f} {relu_grads[i]:&#x3C;25.6f}")

sigmoid_ratio = sigmoid_grads[0] / sigmoid_grads[-1] if sigmoid_grads[-1] > 0 else float('inf')
relu_ratio = relu_grads[0] / relu_grads[-1] if relu_grads[-1] > 0 else float('inf')

print(f"\n第 1 层 vs 最后层梯度比:")
print(f"  Sigmoid: {sigmoid_ratio:.2f}x  ← 梯度严重衰减")
print(f"  ReLU:    {relu_ratio:.2f}x  ← 梯度保持得更好")

# ============================================================
# 3. 梯度消失可视化
# ============================================================
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Sigmoid 导数
z = np.linspace(-6, 6, 200)
sigmoid_deriv = (1 / (1 + np.exp(-z))) * (1 - (1 / (1 + np.exp(-z))))
relu_deriv = (z > 0).astype(float)

axes[0].plot(z, sigmoid_deriv, 'b-', linewidth=2, label='Sigmoid 导数')
axes[0].axhline(0.25, color='r', linestyle='--', alpha=0.5, label='最大值 = 0.25')
axes[0].set_title('Sigmoid 导数(最大值 0.25,导致梯度消失)')
axes[0].set_xlabel('z')
axes[0].set_ylabel('σ\'(z)')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# 各层梯度
layer_indices = range(1, len(sigmoid_grads) + 1)
axes[1].semilogy(layer_indices, sigmoid_grads, 'ro-', label='Sigmoid', linewidth=2)
axes[1].semilogy(layer_indices, relu_grads, 'bs-', label='ReLU', linewidth=2)
axes[1].set_title('各层梯度范数(10 层网络)')
axes[1].set_xlabel('层编号(1 = 最靠近输入)')
axes[1].set_ylabel('梯度范数(log scale)')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('gradient_vanishing.png', dpi=150)
plt.show()
print("\n梯度消失可视化已保存")

# ============================================================
# 4. 在真实数据上训练
# ============================================================
print("\n" + "=" * 70)
print("4. 在 Moons 数据集上训练")
print("=" * 70)

X_moons, y_moons = make_moons(n_samples=1000, noise=0.2, random_state=42)
X_tr, X_te, y_tr, y_te = train_test_split(X_moons, y_moons, test_size=0.2, random_state=42)

# 训练网络
nn = NeuralNetwork([2, 32, 16, 1], learning_rate=0.1)
y_tr_2d = y_tr.reshape(-1, 1)
losses = nn.train(X_tr, y_tr_2d, epochs=2000)

# 评估
y_pred = (nn.forward(X_te) > 0.5).astype(int).flatten()
print(f"\n测试集准确率: {accuracy_score(y_te, y_pred):.4f}")

# 可视化训练过程和决策边界
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Loss 曲线
axes[0].plot(losses)
axes[0].set_title('训练 Loss')
axes[0].set_xlabel('Epoch')
axes[0].set_ylabel('Loss')
axes[0].grid(True, alpha=0.3)

# 决策边界
h = 0.05
x_min, x_max = X_moons[:, 0].min() - 1, X_moons[:, 0].max() + 1
y_min, y_max = X_moons[:, 1].min() - 1, X_moons[:, 1].max() + 1
xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h))
Z = (nn.forward(np.c_[xx.ravel(), yy.ravel()]) > 0.5).reshape(xx.shape)
axes[1].contourf(xx, yy, Z, alpha=0.3, cmap='coolwarm')
axes[1].scatter(X_te[:, 0], X_te[:, 1], c=y_te, cmap='coolwarm', edgecolors='k', s=20)
axes[1].set_title(f'决策边界 (测试准确率: {accuracy_score(y_te, y_pred):.3f})')

plt.tight_layout()
plt.savefig('backprop_training.png', dpi=150)
plt.show()
print("训练过程可视化已保存")

# ============================================================
# 5. 计算图手动演示
# ============================================================
print("\n" + "=" * 70)
print("5. 计算图手动演示")
print("=" * 70)

print("""
问题:f(w, b) = σ(w · x + b),其中 x=3, w=2, b=1
求 ∂f/∂w 和 ∂f/∂b

计算图:

  w=2 ──→ [×] ──→ [+] ──→ [σ] ──→ f
            ↑       ↑
  x=3 ──→──┘  b=1 ─┘

第一步:前向传播
  a = w · x = 2 · 3 = 6
  z = a + b = 6 + 1 = 7
  f = σ(7) = 1/(1+e⁻⁷) ≈ 0.9991

第二步:反向传播
  初始梯度 = ∂f/∂f = 1

  Sigmoid 节点 [σ]:
    局部梯度 = σ(z)(1-σ(z)) = 0.9991 × 0.0009 ≈ 0.0009
    下游梯度 = 1 × 0.0009 = 0.0009  (传给 z)

  加法节点 [+]:
    ∂z/∂a = 1,  ∂z/∂b = 1
    下游梯度给 a = 0.0009 × 1 = 0.0009
    下游梯度给 b = 0.0009 × 1 = 0.0009

  乘法节点 [×]:
    ∂a/∂w = x = 3,  ∂a/∂x = w = 2
    下游梯度给 w = 0.0009 × 3 = 0.0027
    下游梯度给 x = 0.0009 × 2 = 0.0018

最终结果:
  ∂f/∂w = 0.0027
  ∂f/∂b = 0.0009
  
注意:由于 σ(7) ≈ 0.9991(接近饱和),梯度非常小。
这就是 Sigmoid 在极端输入下梯度消失的例子。
""")

# 用 PyTorch 验证(如果可用)
try:
    import torch
    w = torch.tensor([2.0], requires_grad=True)
    b = torch.tensor([1.0], requires_grad=True)
    x_val = 3.0
    f = torch.sigmoid(w * x_val + b)
    f.backward()
    print(f"PyTorch 验证:")
    print(f"  ∂f/∂w = {w.grad.item():.4f}")
    print(f"  ∂f/∂b = {b.grad.item():.4f}")
except ImportError:
    # 手动验证
    z = 2 * 3 + 1
    sig = 1 / (1 + np.exp(-z))
    dw = sig * (1 - sig) * 3
    db = sig * (1 - sig) * 1
    print(f"手动验证:")
    print(f"  ∂f/∂w = {dw:.4f}")
    print(f"  ∂f/∂b = {db:.4f}")

# ============================================================
# 6. 反向传播效率分析
# ============================================================
print("\n" + "=" * 70)
print("6. 反向传播效率分析")
print("=" * 70)

print("""
计算复杂度对比:

假设网络有 L 层,每层平均有 n 个神经元。

方法 1:数值梯度(有限差分)
  对每个参数 p,计算 (L(p+ε) - L(p-ε)) / (2ε)
  需要两次前向传播 × N 个参数 = O(N) 次前向传播
  对于有 100 万参数的网络:需要 200 万次前向传播

方法 2:反向传播
  1 次前向传播 + 1 次反向传播 ≈ 2-3 次前向传播的计算量
  不管有多少参数,都是固定次数

加速比:N / 3 ≈ 333,333 倍(对于 100 万参数的网络)

这就是为什么反向传播是深度学习的核心算法。
没有它,训练任何超过几千个参数的网络都是不可行的。
""")

# ============================================================
# 7. 关键概念总结
# ============================================================
print("=" * 70)
print("7. 反向传播关键概念总结")
print("=" * 70)

summary = """
╔════════════════════════════════════════════════════════════════════════╗
║                    反向传播关键概念                                    ║
╠════════════════════════════════════════════════════════════════════════╣
║                                                                      ║
║  核心规则:下游梯度 = 上游梯度 × 局部梯度                             ║
║                                                                      ║
║  前向传播:输入 → [线性变换] → [激活函数] → ... → 输出 → 损失        ║
║  反向传播:梯度 ← [×激活导数] ← [×权重转置] ← ... ← ∂L/∂输出       ║
║                                                                      ║
║  常见节点的局部梯度:                                                 ║
║  ├── 加法: 梯度原封不动传递 (×1)                                     ║
║  ├── 乘法: 梯度 × 另一个输入的值                                     ║
║  ├── ReLU: 正区间 ×1,负区间 ×0                                      ║
║  └── Sigmoid: ×σ(z)(1-σ(z)),最大值 0.25                            ║
║                                                                      ║
║  梯度消失的解法:                                                     ║
║  ├── ReLU(正区间导数为 1)                                           ║
║  ├── Batch Normalization(稳定每层输出分布)                          ║
║  ├── 残差连接(加法让梯度直接流过)                                   ║
║  └── 合适的权重初始化(Xavier/He)                                   ║
║                                                                      ║
║  梯度爆炸的解法:                                                     ║
║  ├── 梯度裁剪(限制梯度范数)                                        ║
║  └── 合适的权重初始化                                                 ║
║                                                                      ║
╚════════════════════════════════════════════════════════════════════════╝
"""
print(summary)

真实案例

案例 1:训练 ResNet 时的梯度流分析

在 ResNet 之前,训练 20 层以上的网络非常困难——更深的网络反而比浅层网络表现更差。这并不是过拟合(训练误差也更高),而是梯度消失导致深层参数几乎不更新。

何恺明在 ResNet 论文中做了一个关键实验:

普通网络(20 层 vs 56 层)

  • 20 层网络的训练误差比 56 层更低
  • 56 层网络不是过拟合——它在训练集上的误差就比 20 层高
  • 原因:梯度在传播到浅层时已经消失

残差网络(20 层 vs 56 层)

  • 56 层网络的训练误差比 20 层更低
  • 残差连接让梯度可以直接通过加法路径传到任何层
  • 每个残差块只需要学习"和恒等映射的差值"(residual),而不是完整的映射

案例 2:梯度裁剪在 Transformer 训练中的重要性

Transformer 模型(如 GPT、BERT)在训练初期经常遇到梯度爆炸问题。这是因为:

  1. 自注意力机制涉及 softmax 操作,在未充分训练时可能产生极端的注意力权重
  2. 多头注意力的梯度需要通过多个路径传播,某些路径可能放大梯度

标准的做法是使用梯度裁剪:

# 在每个训练步骤中
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()

这确保了梯度的 L2 范数不超过 1.0。如果梯度范数超过这个值,所有梯度会被等比缩小到 1.0。

案例 3:LSTM 如何解决 RNN 的梯度消失

循环神经网络(RNN)在处理长序列时有严重的梯度消失问题——经过很多时间步后,梯度几乎为零,网络无法学习长距离依赖。

LSTM(Long Short-Term Memory)通过引入门控机制来解决这个问题:

  • 遗忘门:控制上一时刻的记忆有多少被保留
  • 输入门:控制新的信息有多少被写入记忆
  • 输出门:控制记忆有多少被输出

关键设计:记忆单元的更新使用的是加法而不是乘法:

$$ c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t $$

反向传播时,加法操作让梯度可以直接流过($\frac{\partial c_t}{\partial c_{t-1}} = f_t$),如果遗忘门接近 1,梯度几乎不衰减。这允许网络学习跨越数百个时间步的依赖关系。

权衡取舍以及何时不该使用

反向传播的局限

  1. 内存消耗:反向传播需要存储前向传播的所有中间值(激活值)。对于非常大的模型(如 GPT-3),这可能需要几十 GB 的 GPU 内存。Gradient Checkpointing 技术可以通过重新计算部分中间值来降低内存,但以增加计算时间为代价。

  2. 只能计算梯度,不能保证找到全局最优。梯度告诉你"最陡的下坡方向",但可能把你带到局部最小值。不过实践中,高维空间的局部最小值通常不是大问题。

  3. 不适用于离散操作。反向传播要求计算图中的所有操作都是可微的。如果你的模型中有不可微的操作(如条件分支、离散采样),需要特殊的处理(如 REINFORCE、Gumbel-Softmax)。

何时不应该手动实现反向传播

几乎永远不应该。现代框架(PyTorch、TensorFlow、JAX)提供了高效的自动微分。手动实现反向传播只在以下情况有意义:

  • 学习目的(理解原理)
  • 自定义操作没有框架支持
  • 需要极致的性能优化

关键要点

  1. 反向传播就是链式法则在计算图上的高效应用。核心规则只有一条:下游梯度 = 上游梯度 × 局部梯度。从输出开始反向逐层应用这条规则。

  2. 反向传播的高效性在于避免重复计算。通过反向遍历计算图,每个中间梯度只计算一次。计算量是前向传播的 2-3 倍,与参数数量无关。

  3. 梯度消失是深层网络训练的最大障碍。Sigmoid 的导数最大值只有 0.25,经过多层连乘后梯度趋近于零。ReLU(导数为 1)、BatchNorm(稳定分布)和残差连接(加法路径)是三大解法。

  4. 现代框架通过自动微分隐藏了反向传播的细节。你只需要定义前向传播,框架会自动构建计算图并计算梯度。但理解原理对于调试和设计新架构至关重要。

  5. 梯度消失和爆炸是对称的问题。消失是梯度太小(参数不更新),爆炸是梯度太大(参数更新太激进)。两者都源于梯度在多层间传递时的连乘效应。

延伸阅读

  1. Rumelhart, D. E., Hinton, G. E., & Williams, R. J. (1986). "Learning representations by back-propagating errors." Nature, 323, 533-536. — 反向传播算法的经典论文

  2. Olah, C. (2015). "Calculus on Computational Graphs: Backpropagation." colah.github.io — 最清晰的反向传播教程

  3. He, K., Zhang, X., Ren, S., & Sun, J. (2016). "Deep Residual Learning for Image Recognition." CVPR. — ResNet,通过残差连接解决梯度消失

  4. Glorot, X., & Bengio, Y. (2010). "Understanding the difficulty of training deep feedforward neural networks." AISTATS. — Xavier 初始化

  5. Paske, A., et al. (2019). "PyTorch: An Imperative Style, High-Performance Deep Learning Library." NeurIPS. — PyTorch 的自动微分实现

  6. Baydin, A. G., Pearlmutter, B. A., Radul, A. A., & Siskind, J. M. (2018). "Automatic differentiation in machine learning: a survey." JMLR. — 自动微分的全面综述

评论

0
登录后可以参与评论和讨论。
💬

还没有评论

欢迎留下第一条评论,帮助这篇内容更快形成讨论。