2907 字
15 分钟
从零手搓一个 MNIST 分类器

写完那些玩具模型之后,老板又给我扔了个新任务:用纯 numpy 在 MNIST 上训练一个分类器。

MNIST 是机器学习界的”Hello World”,但用纯 numpy 手写一个能跑到 90% 准确率的模型,还是挺折磨人的。

累是真的累,但跑通之后看到测试准确率 90.79%,还是挺有成就感的。至少能证明我不是只会调包的工具人。

什么是 MNIST#

MNIST 是一个手写数字数据集,包含 0-9 十个数字的手写图像。

数据规模:

  • 训练集:60,000 张图像
  • 测试集:10,000 张图像
  • 图像大小:28×28 灰度图

任务目标:
给定一张手写数字图像,预测它是 0-9 中的哪一个数字。

这是一个典型的多分类问题,也是深度学习入门的经典数据集。

NOTE

MNIST 虽然简单,但它包含了分类任务的所有核心要素:多分类、图像数据、训练/测试集划分。很多经典模型(LeNet、AlexNet)都是在 MNIST 上验证的。

模型架构#

我们要实现的是一个最简单的两层全连接神经网络(MLP):

Input (28×28) → Flatten (784) → FC1 (128) → ReLU → FC2 (10) → Softmax

网络结构:

  • 输入层:784 个神经元(28×28 展平)
  • 隐藏层:128 个神经元 + ReLU 激活
  • 输出层:10 个神经元(10 个类别)+ Softmax

损失函数:Cross-Entropy

这个网络没有卷积层,只有全连接层。虽然不是最优的,但足够演示反向传播的完整流程。

加载 MNIST 数据#

MNIST 数据集的格式是 IDX(一种简单的二进制格式)。Kaggle 上可以下载到这个数据集的原始文件。

数据集链接:https://www.kaggle.com/datasets/hojjatk/mnist-dataset

下载后会得到四个文件:

  • train-images-idx3-ubyte (或 .gz)
  • train-labels-idx1-ubyte (或 .gz)
  • t10k-images-idx3-ubyte (或 .gz)
  • t10k-labels-idx1-ubyte (或 .gz)

读取 IDX 格式#

IDX 格式很简单:前几个字节是头部信息,后面是数据。

图像文件格式:

  • 前 16 字节:头部(magic number、图像数量、高度、宽度)
  • 之后:uint8 像素值(0-255)

标签文件格式:

  • 前 8 字节:头部(magic number、标签数量)
  • 之后:uint8 标签(0-9)

实现加载函数#

import numpy as np
import os
import gzip
def _open_maybe_gz(path):
"""
尝试打开文件,支持 .gz 压缩格式
"""
if os.path.exists(path):
return open(path, "rb")
if os.path.exists(path + ".gz"):
return gzip.open(path + ".gz", "rb")
raise FileNotFoundError(f"Cannot find {path} or {path+'.gz'}")
def load_mnist_from_local(data_dir):
"""
从本地 IDX 文件加载 MNIST
参数:
data_dir: MNIST 数据集所在目录
返回:
X_train, y_train, X_test, y_test
"""
train_images_path = os.path.join(data_dir, "train-images-idx3-ubyte")
train_labels_path = os.path.join(data_dir, "train-labels-idx1-ubyte")
test_images_path = os.path.join(data_dir, "t10k-images-idx3-ubyte")
test_labels_path = os.path.join(data_dir, "t10k-labels-idx1-ubyte")
# 读取训练集图像
with _open_maybe_gz(train_images_path) as f:
data = np.frombuffer(f.read(), dtype=np.uint8, offset=16)
X_train = data.reshape(-1, 28, 28, 1) / 255.0 # 归一化到 [0, 1]
# 读取测试集图像
with _open_maybe_gz(test_images_path) as f:
data = np.frombuffer(f.read(), dtype=np.uint8, offset=16)
X_test = data.reshape(-1, 28, 28, 1) / 255.0
# 读取训练集标签
with _open_maybe_gz(train_labels_path) as f:
labels_train = np.frombuffer(f.read(), dtype=np.uint8, offset=8)
# 读取测试集标签
with _open_maybe_gz(test_labels_path) as f:
labels_test = np.frombuffer(f.read(), dtype=np.uint8, offset=8)
# 转换为 one-hot 编码
y_train = np.zeros((labels_train.size, 10))
y_train[np.arange(labels_train.size), labels_train] = 1
y_test = np.zeros((labels_test.size, 10))
y_test[np.arange(labels_test.size), labels_test] = 1
return X_train, y_train, X_test, y_test
TIP

数据归一化很重要。原始像素值是 0-255,除以 255 归一化到 [0, 1],能让训练更稳定。

实现基础组件#

复用之前写的组件:ReLU、Flatten、Softmax、Cross-Entropy。

def relu(x):
return np.maximum(0, x)
def relu_grad(x):
return (x > 0).astype(float)
def flatten(x):
batch_size = x.shape[0]
return x.reshape(batch_size, -1)
def softmax(x):
x_max = np.max(x, axis=1, keepdims=True)
exp_x = np.exp(x - x_max)
return exp_x / np.sum(exp_x, axis=1, keepdims=True)
def cross_entropy(y_pred, y_true):
eps = 1e-15
y_pred = np.clip(y_pred, eps, 1 - eps)
ce = -np.sum(y_true * np.log(y_pred), axis=1)
return np.mean(ce)
def softmax_cross_entropy_grad(y_pred, y_true):
return (y_pred - y_true) / y_true.shape[0]

这些函数在之前的博客里都详细解释过了,这里直接拿来用。

实现两层神经网络#

把前向传播、反向传播和参数更新封装成一个类。

class MinimalClassifier:
def __init__(self, input_dim, hidden_dim, output_dim, lr=0.01):
"""
初始化两层神经网络
参数:
input_dim: 输入维度(784)
hidden_dim: 隐藏层维度(128)
output_dim: 输出维度(10)
lr: 学习率
"""
self.lr = lr
# 第一层:input_dim → hidden_dim
self.W1 = np.random.randn(input_dim, hidden_dim) * 0.01
self.b1 = np.zeros((1, hidden_dim))
# 第二层:hidden_dim → output_dim
self.W2 = np.random.randn(hidden_dim, output_dim) * 0.01
self.b2 = np.zeros((1, output_dim))
def forward(self, X):
"""
前向传播
参数:
X: 输入图像,形状 (batch_size, 28, 28, 1)
返回:
y_pred: 预测概率,形状 (batch_size, 10)
"""
self.X = flatten(X) # (batch_size, 784)
self.z1 = self.X @ self.W1 + self.b1 # (batch_size, 128)
self.a1 = relu(self.z1) # (batch_size, 128)
self.z2 = self.a1 @ self.W2 + self.b2 # (batch_size, 10)
self.y_pred = softmax(self.z2) # (batch_size, 10)
return self.y_pred
def backward(self, y_true):
"""
反向传播
参数:
y_true: 真实标签(one-hot),形状 (batch_size, 10)
"""
# 输出层梯度
dL_dz2 = softmax_cross_entropy_grad(self.y_pred, y_true)
# 第二层参数梯度
self.dW2 = self.a1.T @ dL_dz2
self.db2 = np.sum(dL_dz2, axis=0, keepdims=True)
# 隐藏层梯度
dL_da1 = dL_dz2 @ self.W2.T
dL_dz1 = dL_da1 * relu_grad(self.z1)
# 第一层参数梯度
self.dW1 = self.X.T @ dL_dz1
self.db1 = np.sum(dL_dz1, axis=0, keepdims=True)
def step(self):
"""
参数更新
"""
self.W2 -= self.lr * self.dW2
self.b2 -= self.lr * self.db2
self.W1 -= self.lr * self.dW1
self.b1 -= self.lr * self.db1

这个类封装了完整的训练流程:

  1. forward:前向传播,计算预测值
  2. backward:反向传播,计算梯度
  3. step:参数更新
IMPORTANT

反向传播的关键是理解梯度的链式传播。从输出层往回传,每一层的梯度都依赖于后一层的梯度。

训练循环#

完整的训练流程:

import tqdm
np.random.seed(0)
# 加载数据
data_dir = "./mnist" # 修改为你的数据集路径
X_train, y_train, X_test, y_test = load_mnist_from_local(data_dir)
# 初始化模型
model = MinimalClassifier(input_dim=784, hidden_dim=128, output_dim=10, lr=0.01)
# 训练参数
epochs = 5
batch_size = 64
loss_list = []
# 训练循环
for epoch in range(epochs):
pbar = tqdm.tqdm(range(0, len(X_train), batch_size),
desc=f"Epoch {epoch+1}/{epochs}")
for i in pbar:
# 获取当前 batch
X_batch = X_train[i:i+batch_size]
y_batch = y_train[i:i+batch_size]
# 前向传播
y_pred = model.forward(X_batch)
loss = cross_entropy(y_pred, y_batch)
loss_list.append(loss)
# 反向传播
model.backward(y_batch)
# 参数更新
model.step()
# 更新进度条
pbar.set_postfix({"loss": f"{loss:.4f}"})
# 测试
y_pred_test = model.forward(X_test)
pred_classes = np.argmax(y_pred_test, axis=1)
true_classes = np.argmax(y_test, axis=1)
acc = (pred_classes == true_classes).mean()
print(f"\nTest accuracy: {acc:.4f}")

关键参数#

batch_size = 64:每次用 64 个样本更新参数。
epochs = 5:整个训练集遍历 5 次。
lr = 0.01:学习率。

batch_size 不能太大也不能太小。太大会占用太多内存,太小会导致梯度噪声太大。64 是个比较常见的选择。

训练结果#

训练过程输出:

Epoch 1/5: 100%| 938/938 [00:01<00:00, 627.07it/s, loss=0.9948]
Epoch 2/5: 100%| 938/938 [00:01<00:00, 797.06it/s, loss=0.3770]
Epoch 3/5: 100%| 938/938 [00:01<00:00, 770.49it/s, loss=0.2485]
Epoch 4/5: 100%| 938/938 [00:01<00:00, 628.35it/s, loss=0.1981]
Epoch 5/5: 100%| 938/938 [00:01<00:00, 497.55it/s, loss=0.1713]
Test accuracy: 0.9079

让我们看看这些数字:

第 1 个 epoch:loss 从高位开始下降,结束时 loss = 0.9948。
第 2 个 epoch:loss 降到 0.3770。
第 3 个 epoch:loss 降到 0.2485。
第 4 个 epoch:loss 降到 0.1981。
第 5 个 epoch:loss 降到 0.1713。

测试准确率:90.79%

这个准确率对于一个纯 numpy 实现的两层神经网络来说,已经相当不错了。

NOTE

每个 epoch 有 938 个 batch(60000 / 64 ≈ 938)。每个 batch 更新一次参数,所以总共更新了 5 × 938 = 4690 次。

训练速度#

每个 epoch 大概需要 1-2 秒。五个 epoch 总共不到 10 秒。

这个速度对于纯 numpy 实现来说已经很快了。如果用 PyTorch + GPU,速度会快几十倍。

可视化训练过程#

把每一步的 loss 画出来:

import matplotlib.pyplot as plt
plt.figure(figsize=(6, 4))
plt.plot(loss_list)
plt.xlabel("Iteration")
plt.ylabel("Loss (CE)")
plt.yscale("log")
plt.title("MNIST Training Loss (MLP, Kaggle idx)")
plt.grid(True)
plt.tight_layout()
plt.show()

曲线会从高位快速下降,然后趋于平稳。用对数坐标能更清楚地看到后期的变化。

为什么只有 90.79%#

这个准确率虽然不错,但离最优还有距离。经典的 CNN 模型(LeNet)能跑到 99% 以上。

我们的模型有哪些局限性:

  1. 只有全连接层:没有利用图像的空间结构。
  2. 隐藏层太小:只有 128 个神经元。
  3. 没有正则化:没有 Dropout、没有 L2 正则化。
  4. 训练轮数少:只训练了 5 个 epoch。
  5. 学习率固定:没有学习率衰减或自适应优化器(Adam)。

如何提升准确率:

  • 加卷积层:用 CNN 提取空间特征,准确率能提升到 98%+。
  • 增加隐藏层:256、512 个神经元会更好。
  • 加 Dropout:防止过拟合。
  • 训练更久:10-20 个 epoch。
  • 用更好的优化器:Adam、RMSprop。

但这些都需要更多代码。今天的目标是用最简单的结构跑通 MNIST,90.79% 已经达标了。

TIP

对于学习来说,90% 的准确率足够证明你理解了反向传播。追求 99% 需要大量的超参数调优和工程技巧,那是另一个层面的工作。

预测示例#

我们可以随机挑几张测试集的图像,看看模型预测得对不对。

import random
# 随机挑 5 张图像
indices = random.sample(range(len(X_test)), 5)
plt.figure(figsize=(12, 3))
for i, idx in enumerate(indices):
# 预测
img = X_test[idx:idx+1]
pred_prob = model.forward(img)
pred_class = np.argmax(pred_prob)
true_class = np.argmax(y_test[idx])
# 可视化
plt.subplot(1, 5, i+1)
plt.imshow(img.squeeze(), cmap="gray")
plt.title(f"Pred: {pred_class}\nTrue: {true_class}")
plt.axis("off")
plt.tight_layout()
plt.show()

大部分图像都能预测正确。偶尔会有一些模糊的图像预测错误,比如”1”和”7”、“3”和”8”这种容易混淆的。

完整代码#

把所有代码整合在一起:

import numpy as np
import tqdm
import matplotlib.pyplot as plt
import os
import gzip
# ========== 基础组件 ==========
def relu(x):
return np.maximum(0, x)
def relu_grad(x):
return (x > 0).astype(float)
def flatten(x):
batch_size = x.shape[0]
return x.reshape(batch_size, -1)
def softmax(x):
x_max = np.max(x, axis=1, keepdims=True)
exp_x = np.exp(x - x_max)
return exp_x / np.sum(exp_x, axis=1, keepdims=True)
def cross_entropy(y_pred, y_true):
eps = 1e-15
y_pred = np.clip(y_pred, eps, 1 - eps)
ce = -np.sum(y_true * np.log(y_pred), axis=1)
return np.mean(ce)
def softmax_cross_entropy_grad(y_pred, y_true):
return (y_pred - y_true) / y_true.shape[0]
# ========== 两层神经网络 ==========
class MinimalClassifier:
def __init__(self, input_dim, hidden_dim, output_dim, lr=0.01):
self.lr = lr
self.W1 = np.random.randn(input_dim, hidden_dim) * 0.01
self.b1 = np.zeros((1, hidden_dim))
self.W2 = np.random.randn(hidden_dim, output_dim) * 0.01
self.b2 = np.zeros((1, output_dim))
def forward(self, X):
self.X = flatten(X)
self.z1 = self.X @ self.W1 + self.b1
self.a1 = relu(self.z1)
self.z2 = self.a1 @ self.W2 + self.b2
self.y_pred = softmax(self.z2)
return self.y_pred
def backward(self, y_true):
dL_dz2 = softmax_cross_entropy_grad(self.y_pred, y_true)
self.dW2 = self.a1.T @ dL_dz2
self.db2 = np.sum(dL_dz2, axis=0, keepdims=True)
dL_da1 = dL_dz2 @ self.W2.T
dL_dz1 = dL_da1 * relu_grad(self.z1)
self.dW1 = self.X.T @ dL_dz1
self.db1 = np.sum(dL_dz1, axis=0, keepdims=True)
def step(self):
self.W2 -= self.lr * self.dW2
self.b2 -= self.lr * self.db2
self.W1 -= self.lr * self.dW1
self.b1 -= self.lr * self.db1
# ========== 加载 MNIST ==========
def _open_maybe_gz(path):
if os.path.exists(path):
return open(path, "rb")
if os.path.exists(path + ".gz"):
return gzip.open(path + ".gz", "rb")
raise FileNotFoundError(f"Cannot find {path} or {path+'.gz'}")
def load_mnist_from_local(data_dir):
train_images_path = os.path.join(data_dir, "train-images-idx3-ubyte")
train_labels_path = os.path.join(data_dir, "train-labels-idx1-ubyte")
test_images_path = os.path.join(data_dir, "t10k-images-idx3-ubyte")
test_labels_path = os.path.join(data_dir, "t10k-labels-idx1-ubyte")
with _open_maybe_gz(train_images_path) as f:
data = np.frombuffer(f.read(), dtype=np.uint8, offset=16)
X_train = data.reshape(-1, 28, 28, 1) / 255.0
with _open_maybe_gz(test_images_path) as f:
data = np.frombuffer(f.read(), dtype=np.uint8, offset=16)
X_test = data.reshape(-1, 28, 28, 1) / 255.0
with _open_maybe_gz(train_labels_path) as f:
labels_train = np.frombuffer(f.read(), dtype=np.uint8, offset=8)
with _open_maybe_gz(test_labels_path) as f:
labels_test = np.frombuffer(f.read(), dtype=np.uint8, offset=8)
y_train = np.zeros((labels_train.size, 10))
y_train[np.arange(labels_train.size), labels_train] = 1
y_test = np.zeros((labels_test.size, 10))
y_test[np.arange(labels_test.size), labels_test] = 1
return X_train, y_train, X_test, y_test
# ========== 训练 ==========
if __name__ == "__main__":
np.random.seed(0)
data_dir = "./mnist"
X_train, y_train, X_test, y_test = load_mnist_from_local(data_dir)
model = MinimalClassifier(input_dim=784, hidden_dim=128, output_dim=10, lr=0.01)
epochs = 5
batch_size = 64
loss_list = []
for epoch in range(epochs):
pbar = tqdm.tqdm(range(0, len(X_train), batch_size),
desc=f"Epoch {epoch+1}/{epochs}")
for i in pbar:
X_batch = X_train[i:i+batch_size]
y_batch = y_train[i:i+batch_size]
y_pred = model.forward(X_batch)
loss = cross_entropy(y_pred, y_batch)
loss_list.append(loss)
model.backward(y_batch)
model.step()
pbar.set_postfix({"loss": f"{loss:.4f}"})
y_pred_test = model.forward(X_test)
pred_classes = np.argmax(y_pred_test, axis=1)
true_classes = np.argmax(y_test, axis=1)
acc = (pred_classes == true_classes).mean()
print(f"\nTest accuracy: {acc:.4f}")
plt.figure(figsize=(6, 4))
plt.plot(loss_list)
plt.xlabel("Iteration")
plt.ylabel("Loss (CE)")
plt.yscale("log")
plt.title("MNIST Training Loss (MLP, Kaggle idx)")
plt.grid(True)
plt.tight_layout()
plt.show()

小结#

这就是一个完整的 MNIST 分类器,从数据加载到训练到测试,全部用纯 numpy 实现。

核心流程就是:

  1. 加载数据:从 IDX 文件读取图像和标签
  2. 前向传播:Flatten → FC1 → ReLU → FC2 → Softmax
  3. 计算损失:Cross-Entropy
  4. 反向传播:链式法则计算梯度
  5. 参数更新:梯度下降

虽然只是一个两层的全连接网络,但它包含了深度学习的所有核心要素。后面那些复杂的模型(ResNet、Transformer),本质上也是在做同样的事情,只是结构更复杂、技巧更多。

最终结果:

  • 训练时间:约 10 秒(5 个 epoch)
  • 测试准确率:90.79%
  • 代码行数:约 200 行

90.79% 对于一个纯 numpy 实现的两层网络来说,已经很不错了。如果用 CNN,准确率能提升到 98%+,但那需要更多代码。

[!quote] “The best way to learn is by doing.”
— Richard Feynman

上一篇:ML3-手写神经网络的四大基础组件 下一篇:ML5-给神经网络加上保存和加载功能


Works Cited

Goodfellow, Ian, et al. Deep Learning. MIT Press, 2016.

LeCun, Yann, et al. “Gradient-Based Learning Applied to Document Recognition.” Proceedings of the IEEE, vol. 86, no. 11, 1998, pp. 2278-2324, https://doi.org/10.1109/5.726791.

从零手搓一个 MNIST 分类器
https://blog.lishuyu.top/posts/ml4-从零手搓一个mnist分类器/
作者
猫猫魔女
发布于
2025-11-20
许可协议
CC BY-NC-SA 4.0