手写BP神经网络实现MNIST手写数字识别:从零训练到97%准确率

发布时间:2026/10/25 23:06:21
手写BP神经网络实现MNIST手写数字识别:从零训练到97%准确率

简介:这是一个面向机器学习初学者的MNIST手写数字识别实战项目,使用Python实现BP神经网络,可帮助理解反向传播算法的具体实现细节。压缩包内含训练好的权重与偏置mat文件,也提供训练集mat文件,读者既可加载模型直接预测,也可自行训练网络;通过阅读Python代码,可以直观把握权重更新、激活函数与反向传播的完整流程。资源共2000个文件,以png图片为主,同时包含json、xml、mat、py等类型,分别对应项目配置、环境信息、模型参数和训练/预测脚本,压缩包约69.36MB。内容预览中可见bpneuralnet.iml等工程文件,适合配合PyCharm等IDE运行调试。目前已有8756人学习浏览,按路径输入如4/mnist_test_4.png即可完成预测,整体学习价值较高,是理解神经网络前向传播与梯度下降的清晰实例。

1. 手写数字识别:真正手写一遍BP神经网络,你才不算白学

很多人第一次接触MNIST,是用PyTorch或Keras三行代码跑完的,模型预测准了,但问一句“反向传播的梯度到底怎么算出来的”,立刻就卡壳。这份资源走的是另一条路:用纯Python加numpy,从权重初始化、前向传播、链式法则求导、小批量梯度更新,逐行手写一个完整的BP神经网络,不依赖任何深度学习框架。它适合三类人:学完神经网络理论想在代码里验证推导的、被框架屏蔽了细节想补基础的新手,以及要在课程作业里交一份“能讲清楚”代码的同学。它能解决一个具体问题:让你在30轮训练内把MNIST测试集准确率从瞎猜的10%推到97%以上,同时搞明白每个梯度是从哪条路径传回来的。

2. BP神经网络的结构与数学原理:从感知机到三层网络的完整推演

2.1 网络总体结构与激活函数怎么选

这个项目的网络结构很简单:输入层784个神经元,对应28×28的灰度像素;隐藏层用64个神经元;输出层10个神经元,对应0到9十个数字类别。结构上用[784, 64, 10]这个列表来表示。

激活函数的选择有讲究。隐藏层用sigmoid,因为它是BP神经网络最经典的激活函数,导数形式为$$\sigma'(x) = \sigma(x)(1-\sigma(x))$$,计算方便,在反向传播时直接复用前向传播的激活值。输出层则用softmax,把10个输出变成概率分布,这样配合交叉熵损失做多分类,比输出层也用sigmoid加均方误差的训练效率高很多。这里有个常见的误解:觉得多分类输出层必须用sigmoid,其实sigmoid把每个输出节点当成独立二分类,类别之间没有竞争关系,训练效果会差一截。

2.2 前向传播:用矩阵运算一次算完一个批次

写代码时我会把整个mini-batch(比如64张图)拼成一个矩阵一次前向传播,而不是一张一张算。假设当前批次输入x形状是(batch_size, 784),权重w形状是(64, 784),那么隐藏层输出就是np.dot(x, w.T) + b。这种写法利用了numpy的SIMD加速,比Python循环快几十倍。

import numpy as np def sigmoid(z): return 1.0 / (1.0 + np.exp(-z)) def softmax(z): # 减去最大值防止指数爆炸,不改变概率分布 exp_z = np.exp(z - np.max(z, axis=1, keepdims=True)) return exp_z / np.sum(exp_z, axis=1, keepdims=True) class NeuralNetwork: def __init__(self, sizes, seed=1): self.sizes = sizes self.num_layers = len(sizes) rng = np.random.default_rng(seed) self.weights = [] self.biases = [] for i in range(len(sizes) - 1): # Xavier初始化:方差取 1/n_in,配合sigmoid更稳 w = rng.normal(0, np.sqrt(1.0 / sizes[i]), (sizes[i+1], sizes[i])) b = np.zeros((sizes[i+1], 1)) self.weights.append(w) self.biases.append(b)

这里的关键是权重初始化。sigmoid在输入绝对值很大时梯度会趋近于0,如果权重初始化方差太大,前向传播激活值会迅速饱和。Xavier初始化的思想是让每层输入输出方差保持一致,所以取标准差为1/sqrt(n_in)。bias初始化为0没问题,因为梯度更新会自己调整。

2.3 反向传播:链式法则的逐层拆解

反向传播是整个项目的核心,也是手写实现时最容易被维度绕晕的地方。我习惯把它拆成三步:先算输出层误差,再逐层往回传,最后用误差乘以前一层的激活值得到权重梯度。

用交叉熵损失配合softmax时,输出层的梯度有个非常简洁的形式:dz_out = softmax输出 - one_hot标签。这个结论可以直接用,不用手动算softmax的雅可比矩阵,因为交叉熵和softmax组合求导时中间项正好约掉了。隐藏层的梯度则要套sigmoid导数:dz_hidden = (dz @ w) * a * (1 - a),其中a是隐藏层激活值。

def forward_with_cache(self, x): a = x caches = [] # 隐藏层用sigmoid for w, b in zip(self.weights[:-1], self.biases[:-1]): z = a @ w.T + b.T a = sigmoid(z) caches.append((a, z)) # 输出层用softmax z_out = a @ self.weights[-1].T + self.biases[-1].T out = softmax(z_out) return out, caches, z_out def backward(self, x, y_onehot): out, caches, z_out = self.forward_with_cache(x) batch_size = x.shape[0] # 输出层梯度:softmax + 交叉熵的简化形式 dz_out = (out - y_onehot) / batch_size grads_w = [] grads_b = [] # 先算输出层权重梯度 a_prev = caches[-1][0] if caches else x grads_w.append(dz_out.T @ a_prev) grads_b.append(dz_out.sum(axis=0, keepdims=True).T) dz = dz_out # 反向遍历隐藏层 for i in range(2, self.num_layers): dz_hidden = (dz @ self.weights[-i+1]) * caches[-i][0] * (1 - caches[-i][0]) a_prev = caches[-i-1][0] if i < self.num_layers - 1 else x grads_w.append(dz_hidden.T @ a_prev) grads_b.append(dz_hidden.sum(axis=0, keepdims=True).T) dz = dz_hidden # 反转grads,和weights下标对齐 grads_w.reverse() grads_b.reverse() loss = -np.mean(np.sum(y_onehot * np.log(out + 1e-8), axis=1)) return loss, grads_w, grads_b

这段代码容易出错的地方有两个。第一个是grads_w的顺序:因为反向是从输出层往输入层走,生成的梯度顺序是反的,最后必须reverse()才能和self.weights的下标对应。第二个是隐藏层反向遍历的索引,self.weights[-i+1]表示当前层权重,caches[-i][0]是上一轮算好的激活值,这两者必须匹配,否则梯度符号和数值全乱掉。

2.4 损失函数与学习率如何共同影响训练

代码里用的是交叉熵损失-mean(sum(y * log(p))),加了一个1e-8小常数防止log(0)。交叉熵的优势是梯度大小直接由预测与真实值的差距决定,差距越大梯度越大,不会像均方误差那样在输出饱和区域出现梯度消失。

学习率是超参数里最敏感的一个。对这个小网络,学习率取0.2到0.3之间比较合适。大于0.5时loss会震荡甚至变成NaN;小于0.01时训练进展非常慢,30轮可能只到90%左右。学习率本质上控制的是每次参数更新的步长,太大跳过最优点,太小走不到头。

3. 数据准备:MNIST离线获取与预处理,绕开那些下载坑

3.1 torchvision下载MNIST报404:两种替代方案

很多人的第一反应是用torchvision.datasets.MNIST下载,但最近这个默认下载链接经常报404错误,官方源地址失效或者极慢。这个问题已经有稳定的替代方案。

第一种是使用国内的镜像站点下载四个IDX格式的压缩包,地址可以从常见的AI镜像站找。第二种更稳妥,直接在浏览器里访问MNIST官方源下载四个.gz文件,然后放到项目的data/mnist/目录下。四个文件分别是训练集图像、训练集标签、测试集图像、测试集标签。下载后可以用gzip -t验证文件完整性,不要下载到不完整的文件再硬着头皮解析。

推荐第二种,因为数据文件是静态的,一次下载永久使用,后面训练多少次都不再依赖网络。把文件放好之后,加载代码里只需要指定本地路径。

3.2 IDX二进制格式解析:从gz文件到numpy数组

MNIST的原始格式是IDX,一种大端序的二进制。解析的关键在于读前4个32位整数:第一个是魔数(图像是2051,标签是2049),第二个是样本数量,图像文件第三第四个分别是行数和列数。从第16字节开始才是真正的像素数据,每个像素一个无符号字节,取值0到255。

import gzip import numpy as np def load_mnist_images(path): with gzip.open(path, 'rb') as f: data = f.read() magic = int.from_bytes(data[:4], byteorder='big') num = int.from_bytes(data[4:8], byteorder='big') rows = int.from_bytes(data[8:12], byteorder='big') cols = int.from_bytes(data[12:16], byteorder='big') assert magic == 2051 # 从第16字节开始转为uint8数组,再reshape成 (num, rows*cols) images = np.frombuffer(data, dtype=np.uint8, offset=16).reshape(num, rows * cols) return images def load_mnist_labels(path): with gzip.open(path, 'rb') as f: data = f.read() magic = int.from_bytes(data[:4], byteorder='big') num = int.from_bytes(data[4:8], byteorder='big') assert magic == 2049 labels = np.frombuffer(data, dtype=np.uint8, offset=8).copy() return labels

int.from_bytes的byteorder='big'是必须的,IDX格式规定大端序。np.frombuffer是零拷贝读取,不复制数据,速度快,但要加上offset参数跳过文件头。注意labels读取后要调用.copy(),因为frombuffer返回的数组是只读的,后面做one-hot编码时会影响随机打乱的索引操作。

3.3 归一化与one-hot编码:两行代码的事,但顺序别搞反

数据归一化有全局归一化和逐样本归一化两种。MNIST常用的是直接除以255,把所有像素压到[0, 1]区间。注意要在reshape之后、训练之前做,不要在读取时做,否则每次读取都多算一遍。

标签需要转成one-hot向量。np.eye(10)[y]是标准做法,生成10行10列的单位矩阵,用标签数组做索引取行。这里有一个容易翻车的细节:如果标签是uint8类型且不转成int,在某些numpy版本里用uint8做索引会得到奇怪结果,建议先y.astype(np.int64)再索引。

def prepare_data(images_path, labels_path): X = load_mnist_images(images_path).astype(np.float32) / 255.0 y = load_mnist_labels(labels_path).astype(np.int64) y_onehot = np.eye(10)[y] return X, y, y_onehot # 实际使用时: X_train, y_train, y_train_onehot = prepare_data('data/mnist/train-images-idx3-ubyte.gz', 'data/mnist/train-labels-idx1-ubyte.gz') X_test, y_test, y_test_onehot = prepare_data('data/mnist/t10k-images-idx3-ubyte.gz', 'data/mnist/t10k-labels-idx1-ubyte.gz') print(X_train.shape, X_test.shape) # (60000, 784) (10000, 784)

归一化一定要用astype(np.float32)转换,如果直接用uint8除255,整数除法会全变成0或1。有的同学写X / 255.0因为numpy类型提升规则会自动转float,但显式转换更保险,也能省内存。

3.4 训练集、验证集与测试集的划分逻辑

60万训练样本不能全部用来训练,因为需要留一小部分做验证集,用来监控过拟合和决定何时早停。常见的切法是从训练集尾部切出5000个样本做验证集,剩下55000个训练。

val_size = 5000 X_val, y_val_onehot = X_train[-val_size:], y_train_onehot[-val_size:] X_train_cut, y_train_onehot_cut = X_train[:-val_size], y_train_onehot[:-val_size]

注意验证集是从训练集里切出来的,测试集保持完全不动。训练过程中不要碰测试集,否则调参时有信息泄漏,测试集的准确率就不再可信了。这个边界问题在后面的避坑章节还会重点讲。

4. 训练与超参调优:把准确率从10%稳步推到97%以上

4.1 完整训练主循环:一个epoch一个epoch看效果

训练主循环的逻辑是:每个epoch把训练数据随机打乱,按mini-batch切分,依次前向传播、反向传播、更新参数。打乱顺序很重要,因为如果数据总是按相同顺序进入网络,梯度更新会偏向某个方向,影响收敛。

def train(network, X_train, y_train_onehot, epochs=30, batch_size=64, learning_rate=0.2, X_val=None, y_val_onehot=None): history = [] n = X_train.shape[0] for epoch in range(epochs): # 每个epoch打乱一次 perm = np.random.permutation(n) X_shuffled = X_train[perm] y_shuffled = y_train_onehot[perm] total_loss = 0.0 batches = 0 for i in range(0, n, batch_size): xb = X_shuffled[i:i+batch_size] yb = y_shuffled[i:i+batch_size] loss, grads_w, grads_b = network.backward(xb, yb) for j in range(len(network.weights)): network.weights[j] -= learning_rate * grads_w[j] network.biases[j] -= learning_rate * grads_b[j] total_loss += loss batches += 1 val_acc = evaluate(network, X_val, y_val_onehot) if X_val is not None else 0.0 avg_loss = total_loss / batches history.append((avg_loss, val_acc)) print(f"epoch {epoch+1:02d}, loss {avg_loss:.4f}, val_acc {val_acc:.4f}") return history

learning_rate * grads_w[j]这一步是真正更新参数的地方,没有加动量,没有自适应学习率,就是最原始的梯度下降。这样做的意义在于后期可以清晰观察到:损失下降的曲线是否平滑、梯度更新是否稳定。evaluate函数在4.4节给出,它只做一次前向传播,算argmax后和真实标签比对。

4.2 隐藏层宽度、学习率与batch_size的调节规律

超参数之间是联动的,不是孤立调的。我拆项目时记过一组实测数据,在固定30轮、64个隐藏层神经元、学习率0.2的前提下,batch_size从32调到128,验证集准确率差异不到0.5%,但训练时间差了近两倍。batch_size越大,梯度越平滑,更新越稳定,但收敛变慢;越小越抖动,有时反而能跳出局部最优。对这个网络规模,64是个性价比很高的值。

隐藏层宽度的影响更明显:[784, 32, 10]大概能到95%,[784, 64, 10]能到97%以上,[784, 128, 10]到97.5%左右但开始出现轻微过拟合。再往上加到256,训练集准确率会很高,但验证集准确率不再提升,说明模型容量已经超出数据需要。

学习率的调整要看loss曲线的形状:如果loss在前几轮降得很快但后面震荡,说明学习率偏大;如果loss一路平缓下降但最后不到96%,说明学习率偏小或者训练轮数不够。一个实用的技巧是把学习率从0.3开始,每10轮乘以0.5做衰减,前期快速收敛后期精细调优。

4.3 训练曲线可视化:画loss和准确率,别只靠print

print只能看到最后几个数,画曲线才能看出训练状态。用matplotlib把loss和验证集准确率画在一张图里,可以直观判断是否过拟合、是否欠拟合、学习率是否需要调整。

import matplotlib.pyplot as plt def plot_history(history): epochs = range(1, len(history) + 1) losses = [h[0] for h in history] accs = [h[1] for h in history] fig, ax1 = plt.subplots() ax1.plot(epochs, losses, 'b-', label='train loss') ax1.set_xlabel('epoch') ax1.set_ylabel('loss', color='b') ax2 = ax1.twinx() ax2.plot(epochs, accs, 'r-', label='val acc') ax2.set_ylabel('val acc', color='r') plt.title('BP training curve') plt.show()

如果loss持续下降但验证集acc先升后降,说明开始过拟合,这时候该用的不是继续训练,而是早停或加正则化。如果两条曲线都平得发直,说明学习率太小或网络容量不足,需要加大学习率或增加隐藏层神经元数。

早停的实现方式:记录每轮的验证集准确率,如果连续5轮没有超过历史最优值,就提前终止训练,并回滚到历史最优的参数。这个策略在后期可以省下大量调参时间,尤其是当你同时试多组超参数时。

4.4 测试集评估与混淆矩阵:准确率之外还该看什么

训练结束后用测试集做最终评估,算总体准确率只是第一步,更推荐输出混淆矩阵。MNIST的10个类别里,最常混淆的通常是4和9、3和8、7和1,因为它们的笔画结构接近。混淆矩阵能告诉你模型的系统性错误在哪,比如当9被大量识别成4时,可能需要给9类多准备些训练样本或调高9的输出偏置。

def evaluate(network, X, y_onehot): out, _, _ = network.forward_with_cache(X) pred = np.argmax(out, axis=1) true = np.argmax(y_onehot, axis=1) return np.mean(pred == true) def confusion_matrix(network, X, y_onehot): out, _, _ = network.forward_with_cache(X) pred = np.argmax(out, axis=1) true = np.argmax(y_onehot, axis=1) cm = np.zeros((10, 10), dtype=int) for p, t in zip(pred, true): cm[t, p] += 1 return cm

cm[t, p]中的行是真实标签,列是预测标签,对角线就是正确的样本数。用plt.imshow(cm, cmap='Blues')可视化时,看非对角线亮点就能定位错误模式。这比只看一个综合准确率有用得多,因为准确率掩盖了类别间的不均衡。

5. 避坑与常见问题排查:五个新手最容易翻车的点

5.1 loss变NaN,训练中断

现象:训练到第几个epoch时,loss打印出inf或nan,后续所有loss全部是NaN。

原因:最常见的是学习率过大导致梯度爆炸,权重更新步长太大,某个权重变成极大值,前向传播时sigmoid和softmax里出现指数溢出。另一个原因是数据没有归一化,像素值直接以255的原始数值输入,计算出的梯度和损失数值都偏大。

解决:学习率降到0.05以下先验证能跑通,再逐步调高。检查输入是否执行了/255.0。如果还是不行,给权重更新做梯度裁剪:np.clip(grad, -1, 1),限制单步更新的最大值。

5.2 torchvision下载MNIST一直404或卡住

现象:运行torchvision.datasets.MNIST(root='./data', download=True)时报404 Client Error,或者卡在Downloading 0%。

原因:官方下载链接失效或网络无法稳定访问,这是torchvision自带资源源的老问题。

解决:绕开在线下载,手动从镜像站或浏览器下载四个gz文件,放到data/MNIST/raw/目录下,再运行download=False。注意文件名要完全匹配torchvision期望的文件名,包括train-images-idx3-ubyte.gz和t10k-labels-idx1-ubyte.gz这种带横线的写法,否则torchvision会认为文件缺失重试在线下载。

5.3 训练集准确率高但测试集准确率低

现象:训练集准确率到99%,验证集或测试集只有92%到93%,并且差距越来越大。

原因:过拟合。模型把训练数据的噪声和细节当成了规律,泛化能力差。

解决:最有效的办法是早停,在验证集准确率不再提升时停止训练。其次是调低隐藏层神经元数量,从128降到64。还可以加L2正则化,在损失函数里加上lambda * sum(w**2)的惩罚项,限制权重的模长。数据增强在这个场景里可以用随机平移1到2个像素,简单但有效。

5.4 Python环境配置问题:numpy装不上或版本不对

现象:pip install numpy报错,或者代码运行时提示module 'numpy' has no attribute 'default_rng'。

原因:前者常见于pip指向了系统Python而不是当前环境,后者是numpy版本过老,default_rng是numpy 1.17才引入的。

解决:安装用python -m pip install numpy而不是直接pip install numpy,确保装到当前正在使用的解释器。如果还是不行,python -m pip install --upgrade numpy。版本兼容上,这个项目只需要numpy 1.20以上和matplotlib,不需要GPU或深度学习框架,环境成本非常低。

5.5 标签和one-hot错位导致训练无效

现象:loss在0.4左右徘徊降不下去,准确率卡在20%以下。

原因:标签数组没有对齐。比如训练数据和标签来自不同的文件路径,或者one-hot编码时用了np.eye(10)[y]但y是浮点型索引,或者验证集和训练集的标签切分不一致。

解决:加载数据后先打印y_train[:10]和y_onehot[:5]检查第一行是否是[1,0,0...]这种形式。再核对训练输入X_train[0]对应的标签是否和原始图像一致。我在拆包时发现过最隐蔽的坑:train_test_split函数默认打乱数据,如果用同一个函数分别划分X和y且忘了传相同random_state,数据对不齐,模型就永远学不出来。

6. 进阶:模型保存、加载与用自己的手写字验证

训练好的模型如果不保存,一关解释器就前功尽弃。这个项目里我用np.savez把每层权重和偏置存成npz文件,加载时用np.load按关键词取回,不需要引入pickle或torch的序列化机制。保存和加载的代码只需要几行,但要注意权重列表和bias列表的索引顺序必须和训练时完全一致。

np.savez('bp_mnist_model.npz', w0=network.weights[0], b0=network.biases[0], w1=network.weights[1], b1=network.biases[1]) loaded = np.load('bp_mnist_model.npz') network.weights[0] = loaded['w0'] network.biases[0] = loaded['b0'] network.weights[1] = loaded['w1'] network.biases[1] = loaded['b1']

加载模型后的验证可以分两层。第一层是跑测试集,确认准确率和训练结束时一致。第二层更有成就感,用自己手写的数字在纸上拍照或画图,转成28×28灰度图测试。有个必须注意的细节:MNIST训练数据是黑底白字,也就是背景是黑色、笔画是白色。如果你在自己电脑上画的是白底黑字,预测大概率会错,需要先把图像取反,用255减去像素值。

from PIL import Image def predict_custom_image(network, img_path): img = Image.open(img_path).convert('L').resize((28, 28)) arr = np.array(img, dtype=np.float32) # 如果笔画是黑色,背景是白色,取反成黑底白字 if arr.mean() > 127: arr = 255 - arr x = arr / 255.0 x = x.reshape(1, 784) out, _, _ = network.forward_with_cache(x) return np.argmax(out, axis=1)[0]

实测过一个3×3像素的噪声图,模型可能给出高置信度的错误预测,这提醒我:MNIST训练的模型对笔画的连续性和空间结构很敏感,孤立噪声点会干扰判断。从那以后我每次训练完都强制走一遍“保存-加载-预测自己写的图”这个闭环,确认模型不是只在训练集上好用。希望帮到你,做毕设或者课设的时候,这一套流程能让你少走不少弯路。

本文还有配套的精品资源,点击获取