§1.3.22

前向/反向传播的矩阵化推导?

手写练习
  • 用 numpy 手搓两层 MLP + 反向传播

核心概念

前向传播(Forward Propagation)是指在神经网络中,输入数据从输入层开始,逐层经过线性变换和非线性激活函数,直到输出层计算出最终预测值的过程。这个过程的本质是一个复杂的复合函数求值。

反向传播(Backward Propagation)是训练神经网络的核心算法。它首先计算出模型预测值与真实标签之间的损失(Loss),然后利用链式法则(Chain Rule)从输出层开始,逐层向后计算损失函数关于每一层参数(权重和偏置)的梯度。这些梯度随后被优化器(如 SGD, Adam)用来更新模型参数,以减小损失。

原理与推导

为了清晰地进行矩阵化推导,我们以一个两层的多层感知机(MLP)为例,用于解决一个 CC 分类的任务。

模型定义:

  • 输入数据:XRN×DinX \in \mathbb{R}^{N \times D_{in}},其中 NN 是批量大小 (batch size),DinD_{in} 是输入特征维度。
  • 第一层(隐藏层):
    • 权重和偏置:W[1]RDin×HW^{[1]} \in \mathbb{R}^{D_{in} \times H}b[1]R1×Hb^{[1]} \in \mathbb{R}^{1 \times H},其中 HH 是隐藏层神经元数量。
    • 线性输出:Z[1]=XW[1]+b[1]Z^{[1]} = XW^{[1]} + b^{[1]}
    • 激活输出:A[1]=g(Z[1])A^{[1]} = g(Z^{[1]}),其中 gg 是激活函数(如 ReLU)。
  • 第二层(输出层):
    • 权重和偏置:W[2]RH×CW^{[2]} \in \mathbb{R}^{H \times C}b[2]R1×Cb^{[2]} \in \mathbb{R}^{1 \times C},其中 CC 是类别数量。
    • 线性输出:Z[2]=A[1]W[2]+b[2]Z^{[2]} = A^{[1]}W^{[2]} + b^{[2]}
    • 激活输出(预测概率):Y^=A[2]=softmax(Z[2])\hat{Y} = A^{[2]} = \text{softmax}(Z^{[2]})
  • 损失函数:使用交叉熵损失 (Cross-Entropy Loss)。
    • L=1Ni=1Nj=1CYijlog(Y^ij)L = -\frac{1}{N} \sum_{i=1}^{N} \sum_{j=1}^{C} Y_{ij} \log(\hat{Y}_{ij}),其中 YRN×CY \in \mathbb{R}^{N \times C} 是独热编码的真实标签。

1. 前向传播 (Forward Propagation)

这是一个直接的计算过程,按照模型定义顺序即可。

  1. Z[1]=XW[1]+b[1]Z^{[1]} = XW^{[1]} + b^{[1]}
  2. A[1]=g(Z[1])A^{[1]} = g(Z^{[1]})
  3. Z[2]=A[1]W[2]+b[2]Z^{[2]} = A^{[1]}W^{[2]} + b^{[2]}
  4. Y^=softmax(Z[2])\hat{Y} = \text{softmax}(Z^{[2]})

2. 反向传播 (Backward Propagation)

我们的目标是计算损失 LL 对所有参数 (W[1],b[1],W[2],b[2]W^{[1]}, b^{[1]}, W^{[2]}, b^{[2]}) 的梯度。我们使用链式法则,从后向前逐层计算。

符号约定: 我们将 LV\frac{\partial L}{\partial V} 记为 dVdV,表示损失 LL 对变量 VV 的梯度。

Step 1: 计算 LZ[2]\frac{\partial L}{\partial Z^{[2]}} (记为 dZ[2]dZ^{[2]}) 这是反向传播的起点。对于 Softmax 层和交叉熵损失的组合,有一个非常简洁和优美的结果:

dZ[2]=LZ[2]=1N(Y^Y)dZ^{[2]} = \frac{\partial L}{\partial Z^{[2]}} = \frac{1}{N}(\hat{Y} - Y)

这个结果极大地简化了计算。它直观地表示:预测概率 (Y^\hat{Y}) 与真实标签 (YY) 的差异,就是需要反向传播的误差信号。

Step 2: 计算 LW[2]\frac{\partial L}{\partial W^{[2]}} (dW[2]dW^{[2]}) 和 Lb[2]\frac{\partial L}{\partial b^{[2]}} (db[2]db^{[2]}) 利用链式法则:LW[2]=LZ[2]Z[2]W[2]\frac{\partial L}{\partial W^{[2]}} = \frac{\partial L}{\partial Z^{[2]}} \frac{\partial Z^{[2]}}{\partial W^{[2]}}

  • 已知 Z[2]=A[1]W[2]+b[2]Z^{[2]} = A^{[1]}W^{[2]} + b^{[2]}
  • W[2]W^{[2]} 求偏导:Z[2]W[2]=(A[1])T\frac{\partial Z^{[2]}}{\partial W^{[2]}} = (A^{[1]})^T
  • 因此,梯度为:
dW[2]=LW[2]=(A[1])TdZ[2]dW^{[2]} = \frac{\partial L}{\partial W^{[2]}} = (A^{[1]})^T dZ^{[2]}
  • 维度检查:(A[1])T(A^{[1]})^T(H×N)(H \times N)dZ[2]dZ^{[2]}(N×C)(N \times C),相乘得到 (H×C)(H \times C),与 W[2]W^{[2]} 维度一致。

  • b[2]b^{[2]} 求偏导:Z[2]b[2]=1\frac{\partial Z^{[2]}}{\partial b^{[2]}} = 1

  • 因此,梯度为:

db[2]=Lb[2]=i=1N(dZ[2])i=np.sum(dZ[2],axis=0,keepdims=True)db^{[2]} = \frac{\partial L}{\partial b^{[2]}} = \sum_{i=1}^{N} (dZ^{[2]})_i = \text{np.sum}(dZ^{[2]}, \text{axis}=0, \text{keepdims}=True)
  • 这是将批次中所有样本的梯度相加,因为偏置 b[2]b^{[2]} 对批次中的每个样本都有贡献。

Step 3: 计算 LA[1]\frac{\partial L}{\partial A^{[1]}} (dA[1]dA^{[1]}) 这个梯度将误差从第二层传播到第一层。

  • 链式法则:LA[1]=LZ[2]Z[2]A[1]\frac{\partial L}{\partial A^{[1]}} = \frac{\partial L}{\partial Z^{[2]}} \frac{\partial Z^{[2]}}{\partial A^{[1]}}
  • 已知 Z[2]=A[1]W[2]+b[2]Z^{[2]} = A^{[1]}W^{[2]} + b^{[2]}
  • A[1]A^{[1]} 求偏导:Z[2]A[1]=(W[2])T\frac{\partial Z^{[2]}}{\partial A^{[1]}} = (W^{[2]})^T
  • 因此,梯度为:
dA[1]=LA[1]=dZ[2](W[2])TdA^{[1]} = \frac{\partial L}{\partial A^{[1]}} = dZ^{[2]} (W^{[2]})^T
  • 维度检查:dZ[2]dZ^{[2]}(N×C)(N \times C)(W[2])T(W^{[2]})^T(C×H)(C \times H),相乘得到 (N×H)(N \times H),与 A[1]A^{[1]} 维度一致。

Step 4: 计算 LZ[1]\frac{\partial L}{\partial Z^{[1]}} (dZ[1]dZ^{[1]}) 误差信号穿过第一层的激活函数 gg

  • 链式法则:LZ[1]=LA[1]A[1]Z[1]\frac{\partial L}{\partial Z^{[1]}} = \frac{\partial L}{\partial A^{[1]}} \frac{\partial A^{[1]}}{\partial Z^{[1]}}
  • 已知 A[1]=g(Z[1])A^{[1]} = g(Z^{[1]}),所以 A[1]Z[1]=g(Z[1])\frac{\partial A^{[1]}}{\partial Z^{[1]}} = g'(Z^{[1]})。这里的导数是逐元素计算的。
  • 因此,梯度为:
dZ[1]=dA[1]g(Z[1])dZ^{[1]} = dA^{[1]} \odot g'(Z^{[1]})
  • 其中 \odot 表示哈达玛积(element-wise product)。

Step 5: 计算 LW[1]\frac{\partial L}{\partial W^{[1]}} (dW[1]dW^{[1]}) 和 Lb[1]\frac{\partial L}{\partial b^{[1]}} (db[1]db^{[1]}) 这是反向传播的最后一步,计算输入层参数的梯度。

  • 链式法则:LW[1]=LZ[1]Z[1]W[1]\frac{\partial L}{\partial W^{[1]}} = \frac{\partial L}{\partial Z^{[1]}} \frac{\partial Z^{[1]}}{\partial W^{[1]}}
  • 已知 Z[1]=XW[1]+b[1]Z^{[1]} = XW^{[1]} + b^{[1]}
  • W[1]W^{[1]} 求偏导:Z[1]W[1]=XT\frac{\partial Z^{[1]}}{\partial W^{[1]}} = X^T
  • 因此,梯度为:
dW[1]=XTdZ[1]dW^{[1]} = X^T dZ^{[1]}
  • 维度检查:XTX^T(Din×N)(D_{in} \times N)dZ[1]dZ^{[1]}(N×H)(N \times H),相乘得到 (Din×H)(D_{in} \times H),与 W[1]W^{[1]} 维度一致。

  • b[1]b^{[1]} 求偏导,同理于 b[2]b^{[2]}

db[1]=np.sum(dZ[1],axis=0,keepdims=True)db^{[1]} = \text{np.sum}(dZ^{[1]}, \text{axis}=0, \text{keepdims}=True)

算法复杂度: 对于一次前向+反向传播,主要计算量在于矩阵乘法。

  • 时间复杂度:O(NDinH+NHC)O(N \cdot D_{in} \cdot H + N \cdot H \cdot C)
  • 空间复杂度:O(NH+NC)O(N \cdot H + N \cdot C),主要用于存储前向传播过程中的激活值 (A[1],Z[1]A^{[1]}, Z^{[1]} 等),以便在反向传播时使用。

代码实现

下面是一个使用 NumPy 从零开始实现的两层 MLP,并包含完整的反向传播和训练过程。

python
1import numpy as np
2
3# --- 激活函数及其导数 ---
4def relu(z):
5 return np.maximum(0, z)
6
7def relu_derivative(z):
8 return (z > 0).astype(float)
9
10def softmax(z):
11 # 为数值稳定性,减去最大值
12 exp_z = np.exp(z - np.max(z, axis=1, keepdims=True))
13 return exp_z / np.sum(exp_z, axis=1, keepdims=True)
14
15# --- 损失函数及其导数 ---
16def cross_entropy_loss(y_hat, y):
17 n_samples = y.shape[0]
18 # 防止 log(0)
19 log_likelihood = -np.log(y_hat[range(n_samples), y.argmax(axis=1)] + 1e-9)
20 loss = np.sum(log_likelihood) / n_samples
21 return loss
22
23class TwoLayerMLP:
24 """一个用 NumPy 实现的两层 MLP"""
25 def __init__(self, input_dim, hidden_dim, output_dim):
26 # 初始化权重和偏置
27 # 使用 Xavier/Glorot 初始化,这是一种很好的实践,有助于缓解梯度消失/爆炸
28 self.W1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(1. / input_dim)
29 self.b1 = np.zeros((1, hidden_dim))
30 self.W2 = np.random.randn(hidden_dim, output_dim) * np.sqrt(1. / hidden_dim)
31 self.b2 = np.zeros((1, output_dim))
32
33 # 缓存,用于反向传播
34 self.cache = {}
35
36 def forward(self, X):
37 """执行前向传播"""
38 # 第一层
39 # Z1 = X * W1 + b1
40 self.cache['X'] = X
41 Z1 = X @ self.W1 + self.b1
42 self.cache['Z1'] = Z1
43
44 # A1 = ReLU(Z1)
45 A1 = relu(Z1)
46 self.cache['A1'] = A1
47
48 # 第二层
49 # Z2 = A1 * W2 + b2
50 Z2 = A1 @ self.W2 + self.b2
51 self.cache['Z2'] = Z2
52
53 # y_hat = softmax(Z2)
54 y_hat = softmax(Z2)
55
56 return y_hat
57
58 def backward(self, y_hat, y):
59 """执行反向传播"""
60 n_samples = y.shape[0]
61
62 # 1. 计算 dZ2 (Softmax + CrossEntropy 的梯度)
63 # 这是反向传播的起点,公式为 (y_hat - y) / N
64 dZ2 = (y_hat - y) / n_samples
65
66 # 2. 计算 dW2 和 db2
67 # dW2 = A1.T * dZ2
68 A1 = self.cache['A1']
69 dW2 = A1.T @ dZ2
70 # db2 = sum(dZ2)
71 db2 = np.sum(dZ2, axis=0, keepdims=True)
72
73 # 3. 计算 dA1
74 # dA1 = dZ2 * W2.T
75 dA1 = dZ2 @ self.W2.T
76
77 # 4. 计算 dZ1
78 # dZ1 = dA1 * g'(Z1)
79 Z1 = self.cache['Z1']
80 dZ1 = dA1 * relu_derivative(Z1)
81
82 # 5. 计算 dW1 和 db1
83 # dW1 = X.T * dZ1
84 X = self.cache['X']
85 dW1 = X.T @ dZ1
86 # db1 = sum(dZ1)
87 db1 = np.sum(dZ1, axis=0, keepdims=True)
88
89 # 将梯度存储起来,以便更新
90 self.grads = {'dW1': dW1, 'db1': db1, 'dW2': dW2, 'db2': db2}
91
92 def update(self, learning_rate):
93 """使用梯度下降更新参数"""
94 self.W1 -= learning_rate * self.grads['dW1']
95 self.b1 -= learning_rate * self.grads['db1']
96 self.W2 -= learning_rate * self.grads['dW2']
97 self.b2 -= learning_rate * self.grads['db2']
98
99# --- 训练示例 ---
100if __name__ == '__main__':
101 # 1. 生成模拟数据
102 from sklearn.datasets import make_classification
103 from sklearn.model_selection import train_test_split
104 from sklearn.preprocessing import OneHotEncoder
105
106 X, y = make_classification(n_samples=500, n_features=20, n_informative=10, n_redundant=5,
107 n_classes=3, n_clusters_per_class=1, random_state=42)
108
109 # One-hot 编码标签
110 encoder = OneHotEncoder(sparse_output=False)
111 y_onehot = encoder.fit_transform(y.reshape(-1, 1))
112
113 X_train, X_test, y_train, y_test = train_test_split(X, y_onehot, test_size=0.2, random_state=42)
114
115 # 2. 定义模型和超参数
116 input_dim = X_train.shape[1]
117 hidden_dim = 64
118 output_dim = y_train.shape[1]
119 learning_rate = 0.1
120 epochs = 200
121
122 model = TwoLayerMLP(input_dim, hidden_dim, output_dim)
123
124 # 3. 训练循环
125 for epoch in range(epochs):
126 # 前向传播
127 y_hat = model.forward(X_train)
128
129 # 计算损失
130 loss = cross_entropy_loss(y_hat, y_train)
131
132 # 反向传播
133 model.backward(y_hat, y_train)
134
135 # 更新参数
136 model.update(learning_rate)
137
138 if (epoch + 1) % 20 == 0:
139 print(f'Epoch {epoch+1}/{epochs}, Loss: {loss:.4f}')
140
141 # 4. 评估模型
142 y_pred_test = model.forward(X_test)
143 test_predictions = np.argmax(y_pred_test, axis=1)
144 true_labels = np.argmax(y_test, axis=1)
145 accuracy = np.mean(test_predictions == true_labels)
146 print(f'\nTest Accuracy: {accuracy:.4f}')

工程实践

  • 使用场景: 前向/反向传播是所有基于梯度下降的深度学习模型(CNN, RNN, Transformer等)训练的底层核心。虽然现代框架(PyTorch, TensorFlow)会自动处理反向传播(自动微分),但理解其矩阵化原理对于模型设计、调试和性能优化至关重要。
  • 超参数选择:
    • 学习率 (Learning Rate): 最重要的超参数。太大会导致训练不稳定,太小会使收敛过慢。通常从 1e-3, 1e-2 开始尝试,并配合学习率衰减策略。
    • 隐藏层维度 (Hidden Dim): 决定了模型的容量。维度太小可能欠拟合,太大则容易过拟合且计算成本高。通常选择 32, 64, 128, 256 等 2 的幂次方,便于硬件优化。
    • 激活函数: 现代神经网络中,隐藏层几乎都使用 ReLU 或其变体(Leaky ReLU, PReLU),因为它们计算简单且能有效缓解梯度消失问题。
  • 性能 / 显存 / 吞吐 的权衡:
    • 显存: 反向传播需要存储前向传播过程中的激活值(如代码中的 self.cache)。模型越深、批量越大,所需显存越多。这是训练大模型时的主要瓶颈。技术如梯度检查点 (Gradient Checkpointing) 通过牺牲计算时间(重算前向传播)来节省显存。
    • 吞吐: 通常使用更大的批量 (Batch Size) 可以更好地利用 GPU 的并行计算能力,提高训练吞吐量。但过大的批量可能损害模型的泛化能力,并需要更多显存。
  • 常见坑和调试技巧:
    • 梯度检查 (Gradient Checking): 通过数值方法(有限差分)近似计算梯度,并与反向传播计算出的解析梯度进行比较。这是验证反向传播实现是否正确的黄金标准,但计算成本极高,通常只在调试时对小批量数据使用。
    • 维度不匹配: 这是手动实现时最常见的错误。务必在每一步矩阵运算后,在纸上或代码注释中检查结果的维度是否符合预期。
    • 初始化: 糟糕的权重初始化(如全零或过大的随机值)会导致神经元饱和或梯度消失/爆炸。使用 Xavier/Glorot 或 He 初始化是标准做法。

常见误区与边界情况

  • 误区1: 混淆 A.T @ dZdZ @ W.T

    • 初学者常搞不清矩阵乘法的顺序和是否需要转置。核心原则是匹配维度。例如,计算 dW[2]dW^{[2]} 时,目标是得到一个 (H×C)(H \times C) 的矩阵。已知 A[1]A^{[1]}(N×H)(N \times H)dZ[2]dZ^{[2]}(N×C)(N \times C),只有 (A[1])T@dZ[2](A^{[1]})^T @ dZ^{[2]}(H×N)@(N×C)(H \times N) @ (N \times C) 才能得到 (H×C)(H \times C)。时刻关注维度是避免这类错误的关键。
  • 误区2: 忘记更新偏置或错误地更新偏置

    • 偏置的梯度 dbdb 是对应线性输出梯度 dZdZ 在批次维度上的和。忘记求和或错误地将 dZdZ 直接赋给 dbdb 会导致维度错误和训练失败。
  • 数值稳定性:

    • 梯度消失/爆炸: 在深层网络中,梯度在反向传播时会连乘多个雅可比矩阵。如果这些矩阵的奇异值(或激活函数导数)持续小于1,梯度会指数级衰减(消失);反之则会指数级增长(爆炸)。解决方法包括:ReLU激活函数、残差连接(ResNet)、批归一化(Batch Normalization)、合理的权重初始化。
    • Softmax 溢出: softmax 函数中的 exp(z)z 较大时容易上溢出 (overflow)。工程上的标准做法是,在计算 exp 前,将 z 的每个元素都减去 z 在该样本中的最大值,即 softmax(z) = softmax(z - max(z))。这在数学上是等价的,但避免了数值溢出。
  • 常见面试追问:

    • : "为什么反向传播需要缓存前向传播的中间结果?"
      • : 因为计算某一层参数(如 W[1]W^{[1]})的梯度时,需要用到该层的前向输入(XX)和后一层的误差信号(dZ[1]dZ^{[1]})。而计算 dZ[1]dZ^{[1]} 又需要 dA[1]dA^{[1]}Z[1]Z^{[1]}(用于计算激活函数的导数 g(Z[1])g'(Z^{[1]}))。这些 (XX, Z[1]Z^{[1]}, A[1]A^{[1]} 等) 都是在前向传播时计算的,必须缓存下来供反向传播使用。
    • : "如果我把激活函数 ReLU 换成 Sigmoid,反向传播代码需要改哪里?会带来什么问题?"
      • : 需要修改 relu_derivativesigmoid_derivative。具体来说,dZ1 = dA1 * relu_derivative(Z1) 这一行需要改变。问题是,Sigmoid 函数在输入值较大或较小时,其导数趋近于0,这会导致梯度消失问题在深层网络中更为严重,使得模型难以训练。
    • : "如果一个 ReLU 神经元的输入总是负数,会发生什么?"
      • : 这个神经元会"死亡"。因为它的输出恒为0,导致流经它的梯度 dZdZ(在 dZ = dA * (z > 0) 这一步)也恒为0。这意味着它的权重将永远不会得到更新。选择合适的偏置初始化和学习率可以在一定程度上避免这个问题。
相关题目