import numpy as np
rng = np.random.default_rng(seed=42)
print('NumPy version:', np.__version__)Chapter 3.1 从线性分类器到 MLP:为什么需要隐藏层
前面的章节已经把神经网络看成一个可学习的函数。给定输入 \(x\),模型经过一系列计算得到输出 \(\hat{y}\);给定真实标签 \(y\),损失函数衡量预测和标签之间的差距。训练模型的过程,本质上是在调整参数,使损失逐步下降。
本章从最经典的多层感知机(Multi-Layer Perceptron, MLP)开始,完整走一遍神经网络训练的基本流程。重点不是直接调用 PyTorch 的 nn.Linear、nn.ReLU 和 nn.CrossEntropyLoss,而是先用 NumPy 写出这些模块的前向传播和反向传播。
这样做的目标很明确:
先看清每一层的数值计算和梯度流向,再回到 PyTorch 理解框架自动完成的部分。
本节从一个具体任务开始:使用 MNIST 手写数字图片做分类。我们先把图像分类问题写成线性分类器,再分析线性模型的局限,最后引出 MLP 中的隐藏层和激活函数。
3.1.1 MNIST 分类问题
MNIST 是一个手写数字分类数据集。每张图片是一张灰度图,大小为 \(28 \times 28\),对应一个数字标签:
\[ y \in \{0, 1, 2, \dots, 9\} \]
这是一个 10 分类问题。模型看到一张手写数字图片后,需要判断它属于哪一个类别。

对于计算机来说,一张 \(28 \times 28\) 的灰度图可以看成一个矩阵:
\[ X_{\text{image}} \in \mathbb{R}^{28 \times 28} \]
矩阵中的每个元素表示一个像素的灰度值。
最基本的 MLP 由全连接层组成。全连接层通常接收一维特征向量,而不是直接接收二维图像矩阵。这一点和传统机器学习中的支持向量机(SVM)分类器类似。因此,在送入最简单的全连接模型之前,通常先把二维图像展平成一维向量:
\[ x \in \mathbb{R}^{28 \times 28} \rightarrow x \in \mathbb{R}^{784} \]
如果一个 batch 中有 \(B\) 张图片,那么输入就可以写成:
\[ X \in \mathbb{R}^{B \times 784} \]
其中每一行是一张图片展开后的向量。
这一步会丢掉图像原本的二维空间结构。例如,一个像素的上、下、左、右邻居是谁,展平之后不会被模型直接感知。后面讨论 CNN 和 ViT 时,我们会重新处理图像结构;在 MLP 中,先把图像当作普通向量。
batch_size = 4
image_height = 28
image_width = 28
images = rng.random((batch_size, image_height, image_width))
x = images.reshape(batch_size, -1)
print('x.shape:', x.shape)输出形状是 (4, 784)。每张图片已经从一个 \(28 \times 28\) 矩阵变成 784 维向量。
3.1.2 最简单的分类器:线性模型
有了输入向量之后,最直接的想法是使用一个线性模型,把 784 维输入直接映射到 10 个类别分数:
\[ Z = XW + b \]
其中:
\[ \begin{aligned} X &\in \mathbb{R}^{B \times 784} \\ W &\in \mathbb{R}^{784 \times 10} \\ b &\in \mathbb{R}^{10} \\ Z &\in \mathbb{R}^{B \times 10} \end{aligned} \]
这里的 \(Z\) 通常称为 logits,表示模型对每个类别的未归一化分数。对于每张图片,模型输出 10 个数,分别对应 10 个类别的打分。分数越高,模型越倾向于把图片判为该类别。
比如,对于一张图片,模型输出:
\[ z = [z_0, z_1, z_2, \dots, z_9] \]
如果 \(z_7\) 最大,可以把模型的预测记为数字 7:
\[ \hat{y} = \arg\max_j z_j \]
需要注意,logits 不是概率,只是未归一化的类别分数。后面会用 softmax 把 logits 转成概率,再用 cross entropy 衡量预测和真实标签之间的差距。
先用 NumPy 写一下这个线性分类器的前向传播:
input_dim = 784
num_classes = 10
W = rng.random((input_dim, num_classes))
b = np.zeros(num_classes)
logits = x @ W + b
print('logits.shape:', logits.shape)输出形状是 (4, 10)。对于 batch 中的 4 张图片,模型分别输出 10 个类别分数。
3.1.3 线性分类器学到了什么?
线性分类器的形式非常简单:
\[ Z = XW + b \]
其中,\(X\) 是输入图片的特征矩阵,\(W\) 是权重矩阵,\(b\) 是偏置向量。在上图中,输入层对应 \(X\),输出层对应 \(Z\),中间的连线对应权重矩阵 \(W\)。每个输出节点还带有一个偏置项 \(b\),图中没有单独画出。
如果只看某一个类别 \(j\),它的 logit 是:
\[ z_j = x^\top w_j + b_j \]
其中 \(w_j\) 是矩阵 \(W\) 的第 \(j\) 列。每个类别都有自己的权重向量 \(w_j\)。模型把输入图片 \(x\) 与该权重向量做内积,再加上偏置 \(b_j\),得到类别 \(j\) 的分数。从直觉上看,\(w_j\) 可以理解成类别 \(j\) 的模板;如果输入图片和这个模板更匹配,内积就更大,该类别的分数也更高。反复训练模型,就是调整不同数字对应的 \(W\) 和 \(b\),使它们学到更适合 MNIST 分类的模板。
这种模型的表达能力有限。它只能对输入做一次线性变换。对于 MNIST 这种相对简单的数据集,线性分类器也能学到一些有用模式;但如果图像中的形状变化更复杂,例如数字出现平移、旋转或粗细变化,单纯线性模型就很难稳定处理这些因素。
线性分类器只能学习如下形式的决策边界:
\[ x^\top w + b = 0 \]
这对应二维空间中的直线、三维空间中的平面,或高维空间中的超平面。它适合处理线性可分的数据,但不足以表达更复杂的非线性关系。传统机器学习中的线性 SVM 也有类似限制:决策边界仍然是线性的。
一个自然的问题是:如果一个线性层不够,能否直接堆叠多个线性层?
3.1.4 只堆线性层有用吗?
假设我们把两个线性层连起来:
\[ \begin{aligned} H &= XW_1 + b_1 \\ Z &= HW_2 + b_2 \end{aligned} \]
把第一行代入第二行,可以得到:
\[ Z = (XW_1 + b_1)W_2 + b_2 \]
展开以后:
\[ Z = X(W_1W_2) + b_1W_2 + b_2 \]
令:
\[ \begin{aligned} W' &= W_1W_2 \\ b' &= b_1W_2 + b_2 \end{aligned} \]
那么整个模型就变成:
\[ Z = XW' + b' \]
这仍然是一个线性模型。只要中间没有任何非线性操作,多个线性层叠在一起,最终仍然等价于一个线性层。层数变多了,但表达能力没有本质提升。
因此,神经网络不能只靠线性层堆叠。层与层之间需要加入非线性函数,使模型不再退化为一个大的线性变换。这个非线性函数就是激活函数(activation function)。
3.1.5 加入隐藏层和激活函数
激活函数这个名字来自生物学。生物神经元会接收多个输入信号,信号加权求和后,如果刺激足够强,神经元就会被激活,并向下一个神经元传递信号。在人工神经网络中,激活函数对应这个“被激活”的过程。它是一个非线性函数,用来对中间表示施加非线性变换。只要满足非线性、可微或几乎处处可微等基本条件,函数通常就可以作为激活函数使用。
现在我们在两个线性层之间加入一个激活函数 \(\phi\):
\[ \begin{aligned} H &= XW_1 + b_1 \\ A &= \phi(H) \\ Z &= AW_2 + b_2 \end{aligned} \]
这里的 \(H\) 是隐藏层的 pre-activation,也就是进入激活函数之前的值;\(A\) 是经过激活函数之后的隐藏表示。\(\phi\) 是一个非线性函数,例如 sin、cos 等函数也满足非线性要求,虽然实际训练中更常用 ReLU 及其变体。
加入 \(\phi\) 后,模型不再等价于单个线性层。非线性变换改变了中间表示,使模型可以组合出更复杂的函数。
对于 MNIST,我们可以写成:
\[ \begin{aligned} X &\in \mathbb{R}^{B \times 784} \\ W_1 &\in \mathbb{R}^{784 \times H} \\ b_1 &\in \mathbb{R}^{H} \\ A &\in \mathbb{R}^{B \times H} \\ W_2 &\in \mathbb{R}^{H \times 10} \\ b_2 &\in \mathbb{R}^{10} \\ Z &\in \mathbb{R}^{B \times 10} \end{aligned} \]
其中,\(H\) 是隐藏层维度。例如令 \(H=256\),表示每张图片先被映射成 256 维隐藏表示,再由该表示预测 10 个类别。这个结构让模型先从输入中提取可用于分类的隐藏特征,再基于这些特征输出类别分数。\(H\) 是一个超参数,可以根据数据集复杂度和预期表达能力调整。
用 NumPy 写出来就是:
hidden_dim = 256
W1 = rng.random((input_dim, hidden_dim))
b1 = np.zeros(hidden_dim)
W2 = rng.random((hidden_dim, num_classes))
b2 = np.zeros(num_classes)
h = x @ W1 + b1
a = np.maximum(0, h) # activation function: ReLU
logits = a @ W2 + b2
print('h.shape:', h.shape)
print('a.shape:', a.shape)
print('logits.shape:', logits.shape)这里我们使用的激活函数是 ReLU:
\[ \operatorname{ReLU}(x) = \max(0, x) \]
ReLU 会把负数变成 0,正数保持不变。这个操作很简单,但它提供了关键的非线性,使两层线性变换的组合无法再压缩成一个线性变换。ReLU 也是现代神经网络中最常用的激活函数之一。
3.1.6 MLP 的基本结构
现在得到的模型就是一个最简单的 MLP:
\[ X \rightarrow \operatorname{Linear}_1 \rightarrow H_1 \rightarrow \operatorname{ReLU} \rightarrow H_2 \rightarrow \operatorname{Linear}_2 \rightarrow Z \]
其中,\(\operatorname{Linear}_1\) 表示第一个线性层,\(\operatorname{ReLU}\) 是激活函数,\(\operatorname{Linear}_2\) 表示第二个线性层。\(H_1\) 和 \(H_2\) 分别对应隐藏层的 pre-activation 和 post-activation 表示,\(Z\) 是输出 logits。
它也可以写成一个函数:
\[ f(X) = \operatorname{ReLU}(XW_1 + b_1)W_2 + b_2 \]
这里有两点需要注意。
第一,MLP 中的每一层通常都是对最后一个维度做线性变换。对于 MNIST,我们把每张图片展平成 784 维向量,所以第一层把 784 维映射到隐藏维度 \(H\)。
第二,现在的输出 \(Z\) 是 logits,还不是概率。后续需要用 softmax 把 logits 转成概率,并用 cross entropy 计算分类损失。它们的前向传播和反向传播会在后面单独展开。
因此,MLP 的分类流程可以概括为:
\[ \text{image} \rightarrow \text{flatten} \rightarrow \text{hidden representation} \rightarrow \text{logits} \rightarrow \text{loss} \]
本章后面会把这个流程中的每个部分拆开:
- 激活函数前向传播和反向传播怎么写?
- Softmax 和 cross entropy 如何把 logits 变成分类损失?
- 线性层的参数梯度怎么推导?
- 多个模块连起来之后,梯度如何一层一层传回去?
- 如何用 NumPy 在 MNIST 上完整训练这个模型?
3.1.7 本章小结
本节从 MNIST 分类问题出发,介绍了从线性分类器到 MLP 的基本思路。
MNIST 的每张图片可以从 \(28 \times 28\) 的矩阵展平成 784 维向量。最简单的分类器可以用一个线性变换把输入直接映射到 10 个类别 logits:
\[ Z = XW + b \]
线性分类器的表达能力有限。如果只是堆叠多个线性层,而中间不加入非线性操作,整个模型仍然等价于一个线性层。因此,MLP 会在线性层之间加入激活函数:
\[ Z = \phi(XW_1 + b_1)W_2 + b_2 \]
激活函数让模型能够表示更复杂的非线性关系,是神经网络表达能力的重要来源。
下一节会专门讨论常见激活函数。除了前向传播形式,还会推导它们在反向传播中如何把梯度传回上游。