简介:这份资源是面向计算机相关专业学生与项目实战学习者的BP神经网络手写字体识别完整源码,可直接用于课程设计、期末大作业或算法入门练习。项目基于Python实现,涵盖神经网络前向传播、反向传播、权重与偏置更新等核心环节,并配套手写数字数据集,便于读者理解从数据加载到模型训练与识别的完整流程。压缩包共10个文件,约11.15MB,包含3个py源码文件、2个npz权重与偏置参数文件、2个idx1-ubyte标签文件、2个idx3-ubyte图像文件以及1个md说明文档,结构清晰,方便按模块阅读与调试。该资源为个人大三学期期末大作业,经导师指导并认可通过,评审分98分,目前已有220人学习。读者可从中获得可运行的完整项目代码、数据集组织方式、参数保存与加载思路以及神经网络实现细节,适合作为课程设计参考或进一步改进的起点。
1. 手写数字识别为什么成了期末大作业的“硬通货”
如果你正在翻 Python 期末大作业的选题,大概率绕不开一个组合:BP 神经网络 + 手写字体识别。这个题目之所以年年被选,不是因为它简单,而是因为它刚好卡在一个甜点区——既不像调包跑个model.fit()那样毫无技术含量,也不至于像从零推导反向传播那样让人直接放弃。它逼着你亲手搭一个三层网络,把前向传播、损失计算、反向传播、权重更新这条链路完整走一遍,同时还能跑出一个肉眼可见的准确率数字。
更现实的一点是,这个方向对算力几乎没有要求。MNIST 数据集里的手写数字是 28×28 的灰度图,展开成 784 维向量,配一个几十个神经元的隐藏层,用纯 NumPy 在普通笔记本上跑几轮就能到 90% 以上的准确率。你不需要显卡,不需要装 PyTorch 或 TensorFlow 那一整套环境,甚至不需要联网下载预训练权重。源码结构清晰、依赖少、可解释性强,答辩的时候你能把每一行代码为什么这么写讲清楚,这才是拿高分的关键。
这篇文章面向的是要交作业、要复现、要讲得出原理的人。我会从数据准备讲到网络结构设计,再到训练循环和调参,最后落到怎么验证和怎么在答辩里说清楚。中间会给出可以直接抄的代码块,也会标出我踩过的坑。你跟着走一遍,至少能拿到一个能跑、能讲、能改的完整方案。
2. 从 MNIST 到 784 维向量:数据加载与预处理
2.1 为什么选 MNIST 而不是自己拍照片
手写字体识别这个任务,公开数据集里最稳的就是 MNIST。它包含 60000 张训练图和 10000 张测试图,每张都是 28×28 的灰度图,标签是 0 到 9 的数字。选它的理由很直接:数据干净、格式统一、社区验证充分,你跑出来的准确率有参照系。如果自己拿手机拍手写数字,光照、角度、笔画粗细全都不一致,预处理的工作量会直接吃掉你写网络的时间,最后准确率还上不去,答辩时很难解释。
MNIST 的原始文件是 IDX 格式,不是常见的图片格式。很多同学第一次拿到train-images-idx3-ubyte.gz这种文件会懵,不知道怎么读。常见做法是用python-mnist或者直接手写解析函数。我一般会手写解析,因为这样你能完全掌控数据流,也方便在答辩时说明白每一步。
import numpy as np import struct def load_mnist_images(filename): with open(filename, 'rb') as f: magic, num, rows, cols = struct.unpack('>IIII', f.read(16)) # magic number 固定为 2051,用于校验文件格式 assert magic == 2051, f"Invalid magic number: {magic}" data = np.frombuffer(f.read(), dtype=np.uint8) # 将一维字节流重塑为 (样本数, 28, 28) data = data.reshape(num, rows, cols) return data def load_mnist_labels(filename): with open(filename, 'rb') as f: magic, num = struct.unpack('>II', f.read(8)) assert magic == 2049, f"Invalid magic number: {magic}" data = np.frombuffer(f.read(), dtype=np.uint8) return data这段代码的关键在struct.unpack('>IIII', ...)。>表示大端字节序,MNIST 文件就是按大端存储的,如果你用默认的小端去读,magic number 会对不上,直接报错。四个I分别读出魔数、图片数量、行数、列数。标签文件只需要读两个I。解析完之后,图像数据是uint8类型,值在 0 到 255 之间。
2.2 归一化和 One-Hot 编码的实操细节
原始像素值范围是 0 到 255,直接喂给网络会导致梯度爆炸或者收敛极慢。标准做法是除以 255,把值压到 0 到 1 之间。这一步看起来简单,但有个坑:如果你用整数除法//,结果全是 0 和 1,网络根本学不到东西。必须用浮点除法。
# 加载数据 train_images = load_mnist_images('train-images-idx3-ubyte') train_labels = load_mnist_labels('train-labels-idx1-ubyte') test_images = load_mnist_images('t10k-images-idx3-ubyte') test_labels = load_mnist_labels('t10k-labels-idx1-ubyte') # 归一化:必须转 float 再除,否则整数除法会截断 train_images = train_images.astype(np.float32) / 255.0 test_images = test_images.astype(np.float32) / 255.0 # 展平:把 28x28 拉成 784 维向量 train_X = train_images.reshape(-1, 784) test_X = test_images.reshape(-1, 784) # One-Hot 编码:把标签 3 变成 [0,0,0,1,0,0,0,0,0,0] def one_hot(labels, num_classes=10): return np.eye(num_classes)[labels] train_Y = one_hot(train_labels) test_Y = one_hot(test_labels)np.eye(10)[labels]这个写法比循环快得多,也简洁。labels是一个长度为 N 的数组,np.eye(10)生成 10×10 的单位矩阵,用labels去索引就得到 N×10 的 One-Hot 矩阵。注意train_images.reshape(-1, 784)里的-1是让 NumPy 自动推断样本数,这样你换数据集也不用改代码。
提示:归一化之后建议检查一下
train_X.max()和train_X.min(),确认范围在 0 到 1 之间。如果 max 是 255,说明你忘了转 float。
3. 三层 BP 网络的结构设计与前向传播
3.1 输入层、隐藏层、输出层各放多少个神经元
BP 神经网络的“BP”指的是反向传播,但网络本身是普通的前馈结构。对于 MNIST,输入层固定 784 个节点,因为每张图展平后就是 784 维。输出层固定 10 个节点,对应 0 到 9 十个类别。真正需要你决定的是隐藏层。
隐藏层神经元数量没有理论上的最优解,但有几个经验规则可以参考。太少会导致欠拟合,网络学不动;太多会过拟合,训练集准确率很高但测试集上不去,而且计算量变大。常见做法是取输入维度和输出维度之间的一个值,比如 128、256 或者 512。我一般先用 128 跑一轮,看训练损失下降是否顺畅,再决定要不要加。
| 层 | 神经元数量 | 激活函数 | 说明 |
|---|---|---|---|
| 输入层 | 784 | 无 | 28×28 展平 |
| 隐藏层 | 128 | ReLU | 可调,常用 64/128/256 |
| 输出层 | 10 | Softmax | 输出各类别概率 |
激活函数的选择上,隐藏层用 ReLU 比 Sigmoid 收敛快,而且能缓解梯度消失。输出层必须用 Softmax,因为你要的是十个类别的概率分布,Softmax 能把原始得分转成和为 1 的概率。
3.2 前向传播的矩阵运算怎么写才不绕
前向传播的本质就是两次矩阵乘法加激活。假设输入是X,形状(batch_size, 784),第一层权重W1形状(784, 128),偏置b1形状(128,),那么隐藏层输出就是ReLU(X @ W1 + b1)。第二层权重W2形状(128, 10),偏置b2形状(10,),输出层就是Softmax(hidden @ W2 + b2)。
def relu(x): return np.maximum(0, x) def softmax(x): # 减去每行最大值,防止 exp 溢出 x_shifted = x - np.max(x, axis=1, keepdims=True) exp_x = np.exp(x_shifted) return exp_x / np.sum(exp_x, axis=1, keepdims=True) def forward(X, W1, b1, W2, b2): # 第一层:线性变换 + ReLU Z1 = X @ W1 + b1 A1 = relu(Z1) # 第二层:线性变换 + Softmax Z2 = A1 @ W2 + b2 A2 = softmax(Z2) # 缓存中间结果,反向传播要用 cache = (X, W1, b1, Z1, A1, W2, b2, Z2, A2) return A2, cachesoftmax里减最大值这一步是血泪经验。如果不减,当Z2里有比较大的值时,np.exp会溢出成inf,然后整个损失变成nan,训练直接崩掉。减掉每行最大值不改变 Softmax 的输出结果,但数值上安全得多。keepdims=True是为了保持形状,让广播除法正确执行。
forward函数返回cache是为了反向传播时不用重新计算中间值。这是典型的空间换时间,MNIST 数据量不大,内存完全扛得住。
3.3 权重初始化:别再用全零了
权重初始化是很多同学翻车的地方。如果你把W1和W2全初始化为 0,那么所有神经元的输出完全一样,反向传播时梯度也一样,网络永远学不到东西。这叫对称性问题。
常见做法是用小随机数初始化。对于 ReLU 激活,推荐 He 初始化,标准差是sqrt(2 / 输入维度)。对于 Softmax 输出层,用 Xavier 初始化也可以。我一般直接用np.random.randn乘以一个缩放因子。
def init_params(input_size=784, hidden_size=128, output_size=10): # He 初始化:适合 ReLU W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size) b1 = np.zeros((1, hidden_size)) # Xavier 初始化:适合 Softmax W2 = np.random.randn(hidden_size, output_size) * np.sqrt(1.0 / hidden_size) b2 = np.zeros((1, output_size)) return W1, b1, W2, b2偏置b初始化为 0 是安全的,因为权重已经随机了,对称性被打破。W1的缩放因子sqrt(2/784)大约是 0.05,这样初始输出不会太大也不会太小,ReLU 不会一开始就大面积死掉。
注意:如果你发现训练几轮后损失完全不降,先检查权重初始化。全零初始化是最常见的低级错误。
4. 反向传播与训练循环:把梯度算对是唯一的门槛
4.1 交叉熵损失和它的梯度
多分类任务用交叉熵损失。假设网络输出A2是(batch_size, 10)的概率矩阵,真实标签Y是 One-Hot 矩阵,那么损失就是-sum(Y * log(A2)) / batch_size。这里要加一个极小值1e-8防止log(0)。
反向传播的起点是输出层的梯度。对于 Softmax + 交叉熵这个组合,有一个很漂亮的结论:损失对Z2的梯度就是A2 - Y,再除以batch_size。这个结论省去了手动推导 Softmax 导数的麻烦,直接用就行。
def compute_loss_and_grads(X, Y, params, cache): W1, b1, W2, b2 = params _, _, _, _, A1, _, _, _, A2 = cache m = X.shape[0] # batch size # 交叉熵损失 loss = -np.sum(Y * np.log(A2 + 1e-8)) / m # 输出层梯度:Softmax + 交叉熵的联合导数 dZ2 = (A2 - Y) / m dW2 = A1.T @ dZ2 db2 = np.sum(dZ2, axis=0, keepdims=True) # 隐藏层梯度 dA1 = dZ2 @ W2.T dZ1 = dA1 * (A1 > 0) # ReLU 导数:大于 0 为 1,否则为 0 dW1 = X.T @ dZ1 db1 = np.sum(dZ1, axis=0, keepdims=True) grads = (dW1, db1, dW2, db2) return loss, gradsdZ1 = dA1 * (A1 > 0)这一行是 ReLU 的导数。A1 > 0生成一个布尔矩阵,乘上去就把负值位置的梯度置零了。注意这里用的是A1而不是Z1,因为 ReLU 在 0 处的导数通常取 0,用A1 > 0和Z1 > 0效果一样。
dW2 = A1.T @ dZ2里的转置是因为矩阵乘法的维度要对上。A1是(m, hidden),dZ2是(m, 10),A1.T是(hidden, m),乘出来就是(hidden, 10),和W2形状一致。db2用sum沿 batch 维度求和,因为偏置对每个样本都加了一次,梯度要累加。
4.2 小批量梯度下降和参数更新
全批量梯度下降每次用全部 60000 张图算梯度,速度慢而且容易陷入局部极小。小批量是标准做法,每批 64 或 128 张图,跑完一轮叫一个 epoch。MNIST 60000 张图,batch size 取 128 的话,一个 epoch 有 469 个 batch。
def train(X, Y, X_test, Y_test, epochs=20, batch_size=128, lr=0.1): W1, b1, W2, b2 = init_params() params = (W1, b1, W2, b2) m = X.shape[0] for epoch in range(epochs): # 每个 epoch 打乱数据 indices = np.random.permutation(m) X_shuffled = X[indices] Y_shuffled = Y[indices] epoch_loss = 0 for i in range(0, m, batch_size): X_batch = X_shuffled[i:i+batch_size] Y_batch = Y_shuffled[i:i+batch_size] # 前向 A2, cache = forward(X_batch, *params) # 反向 loss, grads = compute_loss_and_grads(X_batch, Y_batch, params, cache) epoch_loss += loss # 参数更新 dW1, db1, dW2, db2 = grads W1 -= lr * dW1 b1 -= lr * db1 W2 -= lr * dW2 b2 -= lr * db2 params = (W1, b1, W2, b2) # 每个 epoch 打印训练损失和测试准确率 if (epoch + 1) % 5 == 0: train_acc = accuracy(X, Y, params) test_acc = accuracy(X_test, Y_test, params) print(f"Epoch {epoch+1}, Loss: {epoch_loss:.4f}, " f"Train Acc: {train_acc:.4f}, Test Acc: {test_acc:.4f}") return params def accuracy(X, Y, params): A2, _ = forward(X, *params) preds = np.argmax(A2, axis=1) labels = np.argmax(Y, axis=1) return np.mean(preds == labels)np.random.permutation(m)每个 epoch 重新打乱数据,防止网络学到样本顺序。学习率lr=0.1是一个比较激进的起点,如果损失震荡就降到 0.01。epoch_loss累加的是每个 batch 的平均损失,不是总和,所以数值看起来比较稳定。
accuracy函数里np.argmax(A2, axis=1)取每行最大概率的索引作为预测类别,np.argmax(Y, axis=1)把 One-Hot 还原成标签。两者比较取均值就是准确率。
4.3 学习率、batch size、隐藏层大小的调参顺序
调参不要一上来就网格搜索,那样跑一天也未必有好结果。我一般按这个顺序来:
先固定隐藏层 128、batch size 128,只调学习率。从 0.1 开始,如果损失下降但震荡,降到 0.05 或 0.01;如果损失几乎不降,说明学习率太小,往上加。学习率对了之后,损失应该在前几个 epoch 快速下降,然后逐渐平缓。
然后调隐藏层大小。128 跑通之后,试 64 和 256。64 如果欠拟合,训练准确率明显低于测试准确率或者两者都低,就加神经元。256 如果过拟合,训练准确率 99% 但测试准确率卡在 95% 上不去,就减回去或者加正则化。
最后调 batch size。batch size 越小,梯度噪声越大,有时反而能跳出局部极小,但训练速度慢。128 是一个比较平衡的值,64 和 256 也可以试。注意 batch size 变了之后,学习率最好也相应调整,一般 batch size 翻倍,学习率也翻倍。
| 参数 | 推荐范围 | 调整方向 |
|---|---|---|
| 学习率 | 0.01 ~ 0.5 | 损失震荡则降,不降则升 |
| 隐藏层 | 64 ~ 512 | 欠拟合加,过拟合减 |
| batch size | 32 ~ 256 | 影响训练速度和稳定性 |
| epoch | 10 ~ 50 | 看测试准确率是否还在涨 |
提示:训练过程中如果测试准确率连续几个 epoch 不涨甚至下降,说明过拟合了,可以提前停掉,不用跑满。
5. 避坑与排查:那些让准确率卡在 10% 的常见问题
5.1 损失一直是 2.3 左右不降
现象:训练开始后,损失稳定在 2.3 附近,准确率 10% 左右,相当于随机猜。
原因:最常见的是权重初始化用了全零,或者学习率太小导致梯度更新几乎为零。另一个可能是标签没有做 One-Hot 编码,Y还是(N,)的形状,和A2的(N, 10)广播后算出来的梯度是错的。
解决:检查init_params里有没有用np.random.randn。检查train_Y的形状是不是(60000, 10)。如果形状不对,用np.eye(10)[train_labels]重新生成。
5.2 损失变成 nan
现象:训练几个 batch 后损失突然变成nan,后续全部失效。
原因:Softmax 里的exp溢出。如果Z2的值超过 700 左右,np.exp就会返回inf,然后inf / inf就是nan。这通常是因为学习率太大,权重更新过猛,导致Z2数值爆炸。
解决:在softmax里减最大值,这一步必须做。另外把学习率降到 0.01 或 0.05,观察是否还出现nan。如果还有,检查输入数据有没有归一化,没归一化的 0 到 255 像素值会让第一层输出非常大。
5.3 训练准确率高但测试准确率低
现象:训练集准确率 99%,测试集只有 92% 左右,差距明显。
原因:过拟合。隐藏层神经元太多,或者训练 epoch 太多,网络把训练集的噪声也学进去了。
解决:减少隐藏层神经元数量,比如从 256 降到 128。或者减少 epoch,在测试准确率不再上升时就停。也可以加 L2 正则化,在损失里加上0.001 * (sum(W1**2) + sum(W2**2)),梯度里对应加上0.002 * W1和0.002 * W2。
5.4 准确率卡在 90% 上不去
现象:训练和测试准确率都在 90% 左右徘徊,怎么调都上不去。
原因:隐藏层太小,网络容量不够。或者学习率在后期太大,无法精细收敛。
解决:把隐藏层从 128 加到 256 或 512。学习率加一个衰减策略,比如每 10 个 epoch 乘以 0.5。另外检查 ReLU 有没有大量神经元死亡,如果A1里很多行全是 0,说明学习率太大导致 ReLU 输入长期为负,可以换 Leaky ReLU 试试。
5.5 预测结果全是一个数字
现象:模型对任何输入都预测同一个类别,准确率等于该类别在测试集中的占比。
原因:输出层偏置b2初始化不当,或者学习率太大导致W2被推到一个极端值。也可能是数据标签和图像没有对齐,比如图像和标签在打乱时用了不同的索引。
解决:检查train_X和train_Y是否用同一个indices打乱。检查b2是不是全零初始化。把学习率降到 0.01 重新跑。
6. 从 92% 到 97%:几个我常用的提分技巧
6.1 加一个隐藏层,但别加太多
单隐藏层 128 个神经元,跑 20 个 epoch,测试准确率大概在 92% 到 94% 之间。想再往上走,最直接的办法是再加一层隐藏层,变成 784 → 256 → 128 → 10 的结构。多一层非线性变换,网络能拟合更复杂的边界。
def init_params_deep(input_size=784, h1=256, h2=128, output_size=10): W1 = np.random.randn(input_size, h1) * np.sqrt(2.0 / input_size) b1 = np.zeros((1, h1)) W2 = np.random.randn(h1, h2) * np.sqrt(2.0 / h1) b2 = np.zeros((1, h2)) W3 = np.random.randn(h2, output_size) * np.sqrt(1.0 / h2) b3 = np.zeros((1, output_size)) return W1, b1, W2, b2, W3, b3两层隐藏层的反向传播就是多了一次链式法则,把dZ2继续往前传就行。注意第二层隐藏层后面也要接 ReLU,输出层还是 Softmax。层数不是越多越好,MNIST 这个任务两层隐藏层已经足够,再加下去收益很小,反而容易过拟合。
6.2 学习率衰减和早停
固定学习率在后期会让损失在最小值附近震荡,降不下去。加一个简单的衰减策略:每 10 个 epoch 把学习率乘以 0.5。这样前期大步走,后期小步微调。
if (epoch + 1) % 10 == 0: lr *= 0.5 print(f"Learning rate decayed to {lr}")早停是另一个实用技巧。每个 epoch 结束后算一下测试准确率,如果连续 5 个 epoch 没有提升,就停掉训练,返回当前最好的参数。这样既省时间,又避免过拟合。
6.3 用混淆矩阵看看到底哪些数字容易混
准确率是一个总体指标,但你可能想知道模型在哪些数字上容易出错。混淆矩阵能告诉你答案。跑完测试集后,把预测标签和真实标签做一个 10×10 的计数矩阵,对角线是正确分类,非对角线是错误分类。
def confusion_matrix(X, Y, params): A2, _ = forward(X, *params) preds = np.argmax(A2, axis=1) labels = np.argmax(Y, axis=1) cm = np.zeros((10, 10), dtype=int) for t, p in zip(labels, preds): cm[t, p] += 1 return cm跑出来你会发现,4 和 9、3 和 8、5 和 6 这几组最容易混。这不是你的网络有问题,而是这些数字在手写时本身就容易写得像。知道这一点之后,答辩时如果老师问“为什么准确率不是 100%”,你就能指着混淆矩阵说清楚。
6.4 答辩时怎么讲清楚你的网络
老师不会只看准确率数字,他更想听你讲明白网络是怎么工作的。我一般会准备三张图:第一张是网络结构图,标清楚 784、128、10 三层和激活函数;第二张是训练损失曲线,展示损失随 epoch 下降的趋势;第三张是混淆矩阵,说明模型在哪些类别上表现好、哪些容易混。
代码层面,重点讲清楚前向传播的矩阵维度变化、反向传播里dZ2 = A2 - Y这个结论怎么来的、学习率对收敛的影响。如果你能现场改一个参数,比如把隐藏层从 128 改成 256,然后解释为什么准确率会变,基本就稳了。
注意:答辩时不要只念代码,要讲“为什么这么写”。老师更看重你对原理的理解,而不是你敲了多少行。
最后说一个我自己的习惯:每次跑完实验,把学习率、隐藏层大小、batch size、最终测试准确率记在一个表格里。跑上五六组之后,你就能看出哪个参数影响最大,下次调参就不用瞎试了。这个习惯帮我省了很多重复劳动,也希望帮到你。
本文还有配套的精品资源,点击获取