从 3Blue1Brown 到 NumPy:手写一个神经网络训练 MNIST

最近通过 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
→ 在验证集和测试集上评估

现在可以把神经网络理解成一组可学习的函数:

  • 前向传播负责根据当前参数做预测;
  • 损失函数负责衡量预测有多差;
  • 反向传播负责计算每个参数应该如何调整;
  • 梯度下降负责真正更新参数。

这还不是神经网络的全部内容。下一步可以继续学习:

  1. 数值梯度检查,验证反向传播是否正确;
  2. Mini-batch 和矩阵化训练;
  3. Softmax + 交叉熵;
  4. Xavier、He 等参数初始化;
  5. Adam 等优化器;
  6. 卷积神经网络 CNN;
  7. 使用 PyTorch 重写同一个模型。

但从"理解神经网络如何学习"的角度看,自己用 NumPy 完成这个 MNIST 分类器,已经走完了非常关键的一步。

相关推荐
老牛日常随笔1 小时前
Google把Android测试搬进AI对话:ARTEMIS如何用自然语言操作你的手机
android·人工智能·智能手机
信可维1 小时前
AI 读 Word 表格总读错?先查合并单元格和表头这 3 处
开发语言·人工智能·c#·word·软件工程
Maxwin1 小时前
Manim 竖屏画布选 9×16 还是 4.5×8?从 font_size 和每单位像素数说起
python·ffmpeg
Csvn1 小时前
A/B 实验:85% 比 78% 好,居然不算赢?(R03)
人工智能·aigc·agent
随性而行3601 小时前
企业微信 API 如何实现业务消息自动通知?接口应用场景解析
java·人工智能·后端·微信·机器人·企业微信
昭昭日月明1 小时前
使用 Ragas 量化 RAG
python·langchain·ai编程
FPGA信号处理1 小时前
【雷达原理与系统】第四节课:雷达波形与脉冲压缩
人工智能·计算机视觉·目标跟踪
姜九笙5611 小时前
python学习---归来仍是新人
java·python·学习
林浩杨_1 小时前
《CVPR 2026 Highlight|PersonaVLM:让多模态Agent长期记住并理解用户》
人工智能