写完那些玩具模型之后,老板又给我扔了个新任务:用纯 numpy 在 MNIST 上训练一个分类器。
MNIST 是机器学习界的”Hello World”,但用纯 numpy 手写一个能跑到 90% 准确率的模型,还是挺折磨人的。
累是真的累,但跑通之后看到测试准确率 90.79%,还是挺有成就感的。至少能证明我不是只会调包的工具人。
什么是 MNIST
MNIST 是一个手写数字数据集,包含 0-9 十个数字的手写图像。
数据规模:
- 训练集:60,000 张图像
- 测试集:10,000 张图像
- 图像大小:28×28 灰度图
任务目标:
给定一张手写数字图像,预测它是 0-9 中的哪一个数字。
这是一个典型的多分类问题,也是深度学习入门的经典数据集。
NOTEMNIST 虽然简单,但它包含了分类任务的所有核心要素:多分类、图像数据、训练/测试集划分。很多经典模型(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 npimport osimport 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_testTIP数据归一化很重要。原始像素值是 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这个类封装了完整的训练流程:
forward:前向传播,计算预测值backward:反向传播,计算梯度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 = 5batch_size = 64loss_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% 以上。
我们的模型有哪些局限性:
- 只有全连接层:没有利用图像的空间结构。
- 隐藏层太小:只有 128 个神经元。
- 没有正则化:没有 Dropout、没有 L2 正则化。
- 训练轮数少:只训练了 5 个 epoch。
- 学习率固定:没有学习率衰减或自适应优化器(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 npimport tqdmimport matplotlib.pyplot as pltimport osimport 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 实现。
核心流程就是:
- 加载数据:从 IDX 文件读取图像和标签
- 前向传播:Flatten → FC1 → ReLU → FC2 → Softmax
- 计算损失:Cross-Entropy
- 反向传播:链式法则计算梯度
- 参数更新:梯度下降
虽然只是一个两层的全连接网络,但它包含了深度学习的所有核心要素。后面那些复杂的模型(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.