最近通过 3Blue1Brown 的课程理解了神经网络的基本结构。为了不让这些概念停留在动画和公式层面,我用 NumPy 从零实现了一个简单的手写数字分类网络。
这篇文章记录整个过程:如何加载 MNIST 数据、进行前向传播、计算损失、通过反向传播求梯度,以及使用梯度下降训练模型。
本文使用的完整代码保存在
neural_network_numpy.py中。为了保持推导直观,本文采用 3Blue1Brown 风格的 Sigmoid 激活函数和二次损失。
一、网络结构
MNIST 中每张图片大小为 28 × 28,展开后就是 784 个输入特征。数字类别有 10 个,分别对应 0~9。
本文使用的网络结构是:
text
784 → 30 → 10
也就是:
- 输入层:784 个神经元;
- 一个隐藏层:30 个神经元;
- 输出层:10 个神经元。
输入层到隐藏层、隐藏层到输出层分别有两组权重和偏置:
python
W1.shape == (30, 784)
b1.shape == (30,)
W2.shape == (10, 30)
b2.shape == (10,)
总参数量并不大:
text
784 × 30 + 30 × 10 + 偏置 ≈ 23,860
这也是为什么用普通 CPU 和 NumPy 就能很快完成训练。
二、加载 MNIST 数据
数据文件 mnist.pkl 中包含训练集、验证集和测试集:
python
(train_images, train_labels), \
(validation_images, validation_labels), \
(test_images, test_labels) = load_mnist()
数据形状如下:
text
训练图片:(50000, 784)
验证图片:(10000, 784)
测试图片:(10000, 784)
单张图片的输入是:
python
x = train_images[0]
它的形状是:
text
(784,)
标签是一个整数,例如:
python
y = 5
为了计算损失,需要把标签转换成 one-hot 向量:
python
y = [0, 0, 0, 0, 0, 1, 0, 0, 0, 0]
三、前向传播
前向传播就是让输入从网络前面逐层流向输出层。
1. 输入层到隐藏层
隐藏层的线性计算为:
python
z1 = W1 @ x + b1
这里的 @ 是矩阵乘法。形状变化为:
text
(30, 784) @ (784,) = (30,)
W1 的每一行对应一个隐藏神经元,所以这一步实际上是 30 个点积同时进行。
对于其中一个神经元,计算形式是:
text
z = w1*x1 + w2*x2 + ... + w784*x784 + b
然后使用 Sigmoid 激活函数:
python
a1 = sigmoid(z1)
Sigmoid 函数为:
text
sigmoid(z) = 1 / (1 + e^(-z))
它会把任意数值压缩到 0~1 之间:
text
z 很小 → 接近 0
z = 0 → 0.5
z 很大 → 接近 1
2. 隐藏层到输出层
第二层的线性计算为:
python
z2 = W2 @ a1 + b2
形状变化为:
text
(10, 30) @ (30,) = (10,)
最后对输出层也使用 Sigmoid:
python
prediction = sigmoid(z2)
因此,完整的前向传播可以写成:
python
z1 = W1 @ x + b1
a1 = sigmoid(z1)
z2 = W2 @ a1 + b2
prediction = sigmoid(z2)
这里的 prediction 是 10 个 0~1 之间的激活值。由于我们没有使用 Softmax,它们的总和不一定等于 1。
预测数字可以通过最大值所在的位置得到:
python
predicted_label = np.argmax(prediction)
四、二次损失函数
为了和 3Blue1Brown 的推导保持一致,本文使用二次损失:
text
C = 1/2 × Σ(y - ŷ)²
其中:
y是 one-hot 形式的真实标签;ŷ是网络输出;Σ表示对 10 个输出求和。
代码如下:
python
def quadratic_loss(prediction, true_label):
target = one_hot(true_label)
return 0.5 * np.sum((prediction - target) ** 2)
1/2 的作用主要是方便求导,不会改变损失函数的最优点。
直观上:
- 真实类别的输出越接近 1,损失越小;
- 其他类别的输出越接近 0,损失越小;
- 预测结果和 one-hot 标签差距越大,损失越大。
五、反向传播
前向传播是从输入到输出,反向传播则是从损失开始,利用链式法则把误差传回网络前面的层。
1. 输出层梯度
输出层激活值为:
python
prediction = sigmoid(z2)
二次损失对输出激活值的导数是:
python
da2 = prediction - target
Sigmoid 的导数可以用它自己的输出表示:
text
sigmoid'(z) = sigmoid(z) × (1 - sigmoid(z))
因此:
python
dz2 = da2 * sigmoid_derivative(prediction)
代码中可以写成:
python
def sigmoid_derivative_from_activation(activation):
return activation * (1.0 - activation)
2. 第二层参数的梯度
第二层公式是:
python
z2 = W2 @ a1 + b2
所以:
python
dW2 = np.outer(dz2, a1)
db2 = dz2
这里 np.outer(dz2, a1) 是两个向量的外积。
text
dz2.shape = (10,)
a1.shape = (30,)
np.outer(dz2, a1).shape = (10, 30)
它的每个元素为:
python
dW2[i, j] = dz2[i] * a1[j]
刚好对应 W2 中每一条连接的梯度。
3. 把误差传回隐藏层
误差通过 W2 传回隐藏层:
python
da1 = W2.T @ dz2
然后再乘以隐藏层 Sigmoid 的导数:
python
dz1 = da1 * sigmoid_derivative_from_activation(a1)
4. 第一层参数的梯度
第一层公式是:
python
z1 = W1 @ x + b1
因此:
python
dW1 = np.outer(dz1, x)
db1 = dz1
最终,反向传播得到四组梯度:
text
dW1.shape = (30, 784)
db1.shape = (30,)
dW2.shape = (10, 30)
db2.shape = (10,)
六、梯度下降
有了梯度之后,就可以更新参数:
python
参数_new = 参数_old - 学习率 × 梯度
代码如下:
python
def gradient_descent_step(parameters, gradients, learning_rate):
parameters["W1"] -= learning_rate * gradients["dW1"]
parameters["b1"] -= learning_rate * gradients["db1"]
parameters["W2"] -= learning_rate * gradients["dW2"]
parameters["b2"] -= learning_rate * gradients["db2"]
梯度表示损失增加最快的方向,因此减去梯度,就可以朝损失下降的方向移动。
学习率决定每次移动的步长:
- 太小:学习速度慢;
- 太大:可能在最低点附近来回震荡,甚至发散;
- 合适:损失稳定下降。
七、训练循环
一次参数更新还不算真正训练。训练需要反复执行:
text
前向传播
→ 计算损失
→ 反向传播
→ 更新参数
当前代码使用单样本随机梯度下降:
python
for epoch in range(1, epochs + 1):
indices = rng.permutation(sample_count)
for index in indices:
x = train_images[index]
label = int(train_labels[index])
prediction, cache = forward(x, parameters)
gradients = backward(x, label, prediction, cache, parameters)
gradient_descent_step(parameters, gradients, learning_rate)
epoch 表示完整遍历一次训练集。每轮开始前打乱样本顺序,可以减少模型对固定数据顺序的依赖。
八、训练结果
使用以下配置:
text
网络:784 → 30 → 10
激活函数:Sigmoid
损失函数:二次损失
训练样本:50,000 张
训练轮数:10
学习率:0.1
训练过程中,损失和准确率大致如下:
text
第 1 轮:平均损失 0.0621,准确率 92.72%
第 5 轮:平均损失 0.0347,准确率 96.11%
第 10 轮:平均损失 0.0276,准确率 97.02%
最终评估结果:
text
验证集(10,000 张):
损失 0.0336
准确率 96.23%
测试集(10,000 张):
损失 0.0357
准确率 95.90%
训练集准确率高于验证集和测试集是正常现象。验证集和测试集之间的差距较小,说明当前模型具有不错的泛化能力。
九、训练过程中遇到的一个问题
一开始输出曾经写成:
text
训练集(1000 张)最终准确率:...
但代码实际评估的是:
python
validation_images
validation_labels
max_samples=10000
因此这个结果实际上是验证集,而不是训练集。正确的打印方式应该明确区分:
text
训练集:用于更新参数
验证集:用于训练过程中调整模型和超参数
测试集:用于最后一次客观评估
数据集名称和样本数量必须与代码保持一致,否则很容易误读实验结果。
十、总结
通过这个项目,我真正走完了一个神经网络的基本训练闭环:
text
读取数据
→ 初始化参数
→ 前向传播
→ 计算损失
→ 反向传播
→ 梯度下降
→ 重复多个 epoch
→ 在验证集和测试集上评估
现在可以把神经网络理解成一组可学习的函数:
- 前向传播负责根据当前参数做预测;
- 损失函数负责衡量预测有多差;
- 反向传播负责计算每个参数应该如何调整;
- 梯度下降负责真正更新参数。
这还不是神经网络的全部内容。下一步可以继续学习:
- 数值梯度检查,验证反向传播是否正确;
- Mini-batch 和矩阵化训练;
- Softmax + 交叉熵;
- Xavier、He 等参数初始化;
- Adam 等优化器;
- 卷积神经网络 CNN;
- 使用 PyTorch 重写同一个模型。
但从"理解神经网络如何学习"的角度看,自己用 NumPy 完成这个 MNIST 分类器,已经走完了非常关键的一步。