from typing import override
import dnnlpy.models.mlp as mlp
import numpy as np
rng = np.random.default_rng(42)
print('NumPy version:', np.__version__)NumPy version: 2.4.6
jshn9515
2026-06-07
2026-06-07
前面几节中,我们已经分别实现了 MLP 需要的几个基本组件:
Linear 层负责做可学习的线性变换;CrossEntropyLoss 负责把 logits 转成损失,并给出反向传播的起点。现在我们终于可以把这些组件拼起来,得到一个完整的多层感知机。
不过,我们还有一个问题没有解决:
一个 MLP 的前向传播和反向传播到底是怎样连接起来的?
我们会用 NumPy 实现一个两层 MLP:
\[ X \rightarrow \operatorname{Linear}_1 \rightarrow \operatorname{ReLU} \rightarrow \operatorname{Linear}_2 \rightarrow \operatorname{CrossEntropyLoss} \rightarrow L \]
也就是:
\[ \begin{aligned} H &= XW_1 + b_1 \\ A &= \operatorname{ReLU}(H) \\ Z &= AW_2 + b_2 \\ L &= \operatorname{CrossEntropyLoss}(Z, y) \end{aligned} \]
其中,\(X\) 是输入,\(y\) 是标签,\(H\) 是隐藏层输出,\(A\) 是经过 ReLU 后的隐藏层表示,\(Z\) 是最终 logits,\(L\) 是分类损失。
NumPy version: 2.4.6
在前面的推导中,每一层都只关心自己的局部计算。
例如,线性层只知道:
\[ Y = XW + b \]
ReLU 只知道:
\[ A = \operatorname{ReLU}(H) \]
Softmax cross entropy 只知道:
\[ L = \operatorname{CrossEntropy}(\operatorname{Softmax}(Z), y) \]
但是一个神经网络不是孤立的一层,而是很多层串起来的复合函数:
\[ f(X) = \operatorname{Linear}_2(\operatorname{ReLU}(\operatorname{Linear}_1(X))) \]
这也正是深度学习模型的基本结构:
每一层只负责一个简单变换,整个网络通过层与层的组合得到更复杂的函数。
因此,实现一个 MLP 的关键不是让某一层变复杂,而是要让这些层能够顺利地:
假设我们要处理 MNIST 分类问题。每张图片大小为 \(28 \times 28\),展平后输入维度为:
\[ D_{\text{in}} = 28 \times 28 = 784 \]
如果隐藏层维度为 \(D_{\text{hidden}}\),类别数为 \(C=10\),那么一个 batch 的输入输出形状为:
\[ X \in \mathbb{R}^{B \times 784} \]
第一层线性变换:
\[ H = XW_1 + b_1 \]
其中:
\[ W_1 \in \mathbb{R}^{784 \times D_{\text{hidden}}}, \quad b_1 \in \mathbb{R}^{D_{\text{hidden}}} \]
所以:
\[ H \in \mathbb{R}^{B \times D_{\text{hidden}}} \]
接着经过 ReLU:
\[ A = \operatorname{ReLU}(H) \]
ReLU 不改变张量形状,因此:
\[ A \in \mathbb{R}^{B \times D_{\text{hidden}}} \]
第二层线性变换输出 logits:
\[ Z = AW_2 + b_2 \]
其中:
\[ W_2 \in \mathbb{R}^{D_{\text{hidden}} \times 10}, \quad b_2 \in \mathbb{R}^{10} \]
所以:
\[ Z \in \mathbb{R}^{B \times 10} \]
最后,softmax cross entropy 根据 logits 和真实标签 \(y\) 计算损失:
\[ L = \operatorname{CrossEntropyLoss}(Z, y) \]
注意,这里的 \(Z\) 是 logits,不是 softmax 之后的概率。我们在上一节已经看到,把 softmax 和 cross entropy 合在一起实现会更稳定,也更方便反向传播。
前向传播是从输入走到损失:
\[ X \rightarrow H \rightarrow A \rightarrow Z \rightarrow L \]
反向传播则沿着相反方向,把梯度一层一层传回来:
\[ L \rightarrow Z \rightarrow A \rightarrow H \rightarrow X \]
上一节我们已经推导过 softmax cross entropy 的反向传播:
\[ \frac{\partial L}{\partial Z} = \frac{1}{B}(\hat{Y} - Y) \]
记作:
\[ G_Z = \frac{\partial L}{\partial Z} \]
那么第二个线性层:
\[ Z = AW_2 + b_2 \]
对应的梯度为:
\[ \begin{aligned} \frac{\partial L}{\partial W_2} &= A^\top G_Z \\ \frac{\partial L}{\partial b_2} &= \sum_{i=1}^{B} (G_Z)_i \\ \frac{\partial L}{\partial A} &= G_Z W_2^\top \end{aligned} \]
接着经过 ReLU。因为:
\[ A = \operatorname{ReLU}(H) \]
所以:
\[ \frac{\partial L}{\partial H} = \frac{\partial L}{\partial A} \odot \mathbb{1}(H > 0) \]
记作:
\[ G_H = G_A \odot \mathbb{1}(H > 0) \]
最后回到第一个线性层:
\[ H = XW_1 + b_1 \]
对应的梯度为:
\[ \begin{aligned} \frac{\partial L}{\partial W_1} &= X^\top G_H \\ \frac{\partial L}{\partial b_1} &= \sum_{i=1}^{B} (G_H)_i \\ \frac{\partial L}{\partial X} &= G_H W_1^\top \end{aligned} \]
如果我们只是训练模型,通常不需要继续使用 \(\frac{\partial L}{\partial X}\),因为输入图片不是需要学习的参数。但从反向传播的角度看,线性层仍然会把梯度传回给前一层。
有了这些模块之后,MLP 本身就非常简单。
前向传播时,我们按照层的顺序依次调用 forward:
\[ X \rightarrow \operatorname{Linear}_1 \rightarrow \operatorname{ReLU} \rightarrow \operatorname{Linear}_2 \]
反向传播时,我们按照相反顺序依次调用 backward:
\[ \operatorname{Linear}_2 \rightarrow \operatorname{ReLU} \rightarrow \operatorname{Linear}_1 \]
class MLP(mlp.Module):
def __init__(self, input_dim: int, hidden_dim: int, num_classes: int):
self.fc1 = mlp.Linear(input_dim, hidden_dim)
self.relu = mlp.ReLU()
self.fc2 = mlp.Linear(hidden_dim, num_classes)
@override
def forward(self, x: np.ndarray) -> np.ndarray:
h = self.fc1(x)
a = self.relu(h)
logits = self.fc2(a)
return logits
@override
def backward(self, grad: np.ndarray) -> np.ndarray:
grad = self.fc2.backward(grad)
grad = self.relu.backward(grad)
grad = self.fc1.backward(grad)
return grad这里我们省略了 parameters() 方法,具体实现在 mlp.Module 中已经写好了。
为了检查模型是否能够跑通,我们先构造一个很小的随机 batch。
假设输入维度为 4,隐藏层维度为 8,类别数为 3:
先做前向传播:
Logits shape: (5, 3)
Loss: 1.0857084920130033
接着,从 loss 开始反向传播:
此时模型中的每个参数都应该有对应的梯度:
Parameter shape: (4, 8) | Gradient shape: (4, 8)
Parameter shape: (8,) | Gradient shape: (8,)
Parameter shape: (8, 3) | Gradient shape: (8, 3)
Parameter shape: (3,) | Gradient shape: (3,)
如果一切正确,参数和梯度的形状应该一一对应。这说明:
反向传播只是算出了梯度,还没有真正改变参数。最简单的更新方式是 SGD:
\[ \theta \leftarrow \theta - \eta \frac{\partial L}{\partial \theta} \]
其中,\(\eta\) 是学习率。
现在我们可以做一次完整的训练步骤:
logits = model(x)
optimizer = mlp.SGD(model.parameters(), lr=0.1)
loss_before = loss_fn(logits, y)
dlogits = loss_fn.backward()
model.backward(dlogits)
optimizer.step()
logits = model(x)
loss_after = loss_fn(logits, y)
print('Loss before update:', loss_before)
print('Loss after update:', loss_after)Loss before update: 1.0857084920130033
Loss after update: 1.0454196741226824
由于这里只更新了一步,而且数据是随机生成的,所以 loss 不一定会大幅下降。但这个例子说明了一个完整训练步骤的基本流程:
forward -> loss -> backward -> update
也就是:
这就是一个神经网络训练循环中最核心的部分。
前向传播中,数据依次经过每一层:
\[ X \rightarrow H \rightarrow A \rightarrow Z \rightarrow L \]
也就是说,\(Z\) 依赖 \(A\),\(A\) 依赖 \(H\),\(H\) 又依赖 \(X\)。
反向传播计算的是损失对每个中间变量的梯度。根据链式法则,如果想算 \(L\) 对 \(H\) 的梯度,需要先知道 \(L\) 对 \(A\) 的梯度;如果想算 \(L\) 对 \(X\) 的梯度,需要先知道 \(L\) 对 \(H\) 的梯度。
因此梯度必须从最后一层开始,一层一层往前传:
\[ \frac{\partial L}{\partial Z} \rightarrow \frac{\partial L}{\partial A} \rightarrow \frac{\partial L}{\partial H} \rightarrow \frac{\partial L}{\partial X} \]
这就是为什么每个模块的 backward 都接收一个上游梯度 grad,然后返回一个下游梯度 dx。
从这个角度看,反向传播其实一套局部规则:
每一层只需要知道自己前向传播时做了什么,以及上游传回来的梯度是什么,就可以计算自己的参数梯度,并把梯度继续传给前一层。
这一节我们把前面几节讨论过的模块连接起来,用 NumPy 实现了一个完整的两层 MLP。
这个模型的前向传播为:
\[ X \rightarrow \operatorname{Linear}_1 \rightarrow \operatorname{ReLU} \rightarrow \operatorname{Linear}_2 \rightarrow Z \]
损失函数为:
\[ L = \operatorname{CrossEntropyLoss}(Z, y) \]
反向传播则从 softmax cross entropy 开始,按照相反顺序依次穿过:
\[ \operatorname{Linear}_2 \rightarrow \operatorname{ReLU} \rightarrow \operatorname{Linear}_1 \]
最后,我们用最简单的 SGD 完成了参数更新:
\[ \theta \leftarrow \theta - \eta \frac{\partial L}{\partial \theta} \]
至此,我们已经有了一个可以完整执行 forward、backward 和 update 的 MLP。下一节会把它放到真实的 MNIST 数据集上,完成第一个从零实现的图像分类训练实验。