简介:面向机器学习初学者与MATLAB使用者的手写数字识别实操项目,基于BP神经网络完成从图像输入到数字分类的完整流程,帮助理解反向传播、梯度下降与网络调参方法。这类技术常被用于邮政编码自动读取、银行签名验证和各类OCR系统,因此项目也适合作为相关应用开发的前置练习。压缩包共5027个文件,以5000张bmp格式手写数字图片为主体,配合5个m脚本实现网络构建、训练和识别,另有docx实验报告与txt说明文档,以及20个ini配置文件,整体大小仅6.93MB,下载和复现成本低。目前已有2170人浏览学习。资源内含可运行的MATLAB仿真代码,涵盖网络结构定义、训练参数设置(学习率、动量项)、模型评估等环节;实验报告从研究背景、问题描述、实验设计到结果分析逐步展开,并涉及准确率等量化指标与过拟合优化思路。此外,图像预处理与特征向量转换的处理方法也有体现,能帮助读者将所学迁移到其他OCR识别任务中,是一份兼顾代码、数据与文档的综合性学习资料。
1. BP神经网络做手写数字识别:不靠CNN也能到97%的纯numpy方案
一提手写数字识别,很多人默认就得上CNN,好像全连接网络已经拿不出手。但实际用BP神经网络在MNIST上也能跑到97%~98%,而且把前向传播、反向传播、梯度下降这几件事一步不落地手写出来之后,你才能真正读懂后面那些框架API到底替你做了什么。这个zip项目包的价值就在这儿:它不依赖TensorFlow、PyTorch的自动求导,给你一份从数据到训练到评估的完整可复现代码。适合正在学神经网络原理、准备课程设计或面试手撕算法的人;如果你只想做产品级OCR识别精度,那直接上CNN或Transformer,不用浪费时间读这篇。
我接过不少类似的项目,最常见的问题是"照着公式写了但训练一塌糊涂":损失函数不降、准确率卡在10%附近、训练集表现好测试集直接掉队。下面这套路线,我从数据预处理开始,把BP网络的核心实现和调参逻辑拆开讲,最后落到避坑和验证,争取让你拿到这个zip后直接能跑、能改、能给自己讲清楚。
2. 从MNIST到模型输入:数据预处理与标签编码
手写数字识别的第一步不是搭网络,是把图像变成网络能吃的矩阵。这个环节看起来简单,却是后面所有翻车的第一来源。图像格式不对、数值范围不对、标签编码不对,都会让网络训练变成随机猜。
2.1 先选MNIST:因为10类任务规模小、标签干净
我一般建议新手先固定用MNIST,而不是一上来就拍自己手写的照片。MNIST是7万张28×28灰度图,0到9共10类,训练集6万、测试集1万。它的标签是人工校验过的,没有脏数据,模型训练不收敛时你可以确定问题出在算法或超参数,而不是数据本身。
| 项目 | 数值 |
|---|---|
| 样本总数 | 70000 |
| 训练集 | 60000 |
| 测试集 | 10000 |
| 图像大小 | 28×28 灰度 |
| 类别数 | 10(0~9) |
这个规模对BP神经网络非常友好:784维输入、一层128神经元的隐藏层,CPU跑几十个epoch也就几分钟。如果把同样的网络放到CIFAR-10(32×32彩色图)上,全靠全连接层参数会膨胀几十倍,训练效果也远不如MNIST。所以选MNIST不只是图方便,是为了让"BP能不能做图像分类"这个问题在可控成本下得到验证。
2.2 把图像变成矩阵输入:reshape、float32、归一化
MNIST原始数据里每张图是一个28×28的二维数组,像素值范围0到255,0是黑底,255是白笔画。BP网络的输入层每个神经元对应一个像素,所以要先把它拉平成784维的向量。代码里我会直接拿框架API返回的numpy数组来演示。
import numpy as np from keras.datasets import mnist (x_train, y_train), (x_test, y_test) = mnist.load_data() # 28*28 = 784,把二维图像拉平成784维特征向量 # -1表示让numpy自动推断这个维度的数量,这里是60000 X_train = x_train.reshape(-1, 784) X_test = x_test.reshape(-1, 784) # 强制转成float32,否则后面除法会丢掉小数 X_train = X_train.astype('float32') X_test = X_test.astype('float32') # 归一化到[0, 1],这是sigmoid激活函数梯度最活跃的区域 X_train /= 255.0 X_test /= 255.0reshape里的-1是numpy的自动推断:总样本数除以784后剩下的数就是第一维。注意两个坑:一是原始数据从keras返回时是uint8,如果直接做X_train / 255,结果会全部变成0和1,因为整数除法丢小数;二是不要把所有像素减去均值再做,MNIST里0是黑底,保留这个对比度信息反而更好。归一化到0~1之后,那784个输入值大部分接近0,少数接近1,恰好落在sigmoid两端不饱和的区域。
2.3 标签做独热编码:输出层为什么是10个神经元
分类任务里,输出层的每个神经元对应一个类别。手写数字有10类,所以输出层是10个神经元,第0个神经元输出"是0"的概率,第1个输出"是1"的概率,以此类推。原始标签y_train是整数,不能直接拿来和10维输出对比,需要做成独热编码。
def one_hot(y, num_classes=10): m = y.shape[0] Y = np.zeros((m, num_classes)) Y[np.arange(m), y] = 1.0 return Y Y_train = one_hot(y_train) Y_test = one_hot(y_test)独热编码的意思就是把类别5变成[0,0,0,0,0,1,0,0,0,0],只在正确位置置1。np.arange(m)生成每个样本的行下标,y本身是列下标,两套下标组合起来正好把每个样本的对应位赋成1。做完这个,训练时网络输出的10维向量才能和标签算交叉熵;推理时只要用argmax把网络输出转回整数,就能直接和y_test对比准确率。如果跳过这一步直接拿整数标签训练,损失函数算出来是乱的,网络最后只会输出一个固定值。
3. 用Python手写BP神经网络:前向传播、反向传播与训练循环
这一章是整个zip包的核心。我把单隐藏层的BP网络拆成初始化、前向、反向、训练循环四个模块,每一段都对应到数学公式,方便你出问题时按维度排查。
3.1 网络结构定义:784-128-10,权重初始化为什么是0.01
网络骨架是输入层784、隐藏层128、输出层10。权重W1形状是128×784,偏置b1形状是128×1;W2是10×128,b2是10×1。我把X按列排,即X的形状是784×m,这样一次前向就能把整个mini-batch算完。
def init_params(n_input=784, n_hidden=128, n_output=10): # 固定随机种子,保证每次跑结果可复现 rng = np.random.default_rng(42) # 标准差取0.01,而不是1或更大的值 W1 = rng.standard_normal((n_hidden, n_input)) * 0.01 b1 = np.zeros((n_hidden, 1)) W2 = rng.standard_normal((n_output, n_hidden)) * 0.01 b2 = np.zeros((n_output, 1)) return W1, b1, W2, b2权重初始化的标准差设为0.01是个关键选择。如果初始权重太大,特征值在sigmoid里经过加权和之后会落在±10附近,sigmoid在那里的导数趋近0,反向传播时梯度乘上这个接近0的导数,参数几乎不动,训练直接卡死。太小也不行,权重接近0会让所有神经元输出相同,网络退化成线性模型。0.01是单隐藏层全连接网络里比较稳的起点,如果换ReLU激活函数,可以放宽到0.05甚至0.1。
偏置全部初始化为0没太大问题,因为有梯度之后它会自己调整。但注意不要用np.random.randn不乘0.01,很多人第一次写就是栽在这,loss曲线像条直线。
3.2 前向传播:sigmoid隐藏层 + softmax输出层
前向传播分两步:先算隐藏层的加权和并过sigmoid,再算输出层的加权和并过softmax。sigmoid给网络引入非线性,softmax把输出变成10个类别的概率分布。
def sigmoid(x): # 加一个平滑下界,防止exp溢出 return 1.0 / (1.0 + np.exp(-np.clip(x, -500, 500))) def softmax(x): # 减去每列最大值,数值上更稳 e = np.exp(x - np.max(x, axis=0, keepdims=True)) return e / np.sum(e, axis=0, keepdims=True) def forward(X, W1, b1, W2, b2): # X: (784, m),代表m个样本 Z1 = W1 @ X + b1 # (128, m) A1 = sigmoid(Z1) # 隐藏层激活 Z2 = W2 @ A1 + b2 # (10, m) A2 = softmax(Z2) # 输出概率 return A1, A2softmax里减去最大值那一步不是可选的。如果不减,exp(20)和exp(-20)一起算,大概率得到inf或nan。减去每列最大值后,指数最大值变成exp(0)=1,所有值都被压到[0,1]区间,分母也不会溢出。同样的道理放在sigmoid里就是np.clip。
W1 @ X是矩阵乘法,W1的每一行对应一个隐藏神经元,每行和X的所有784维做点积,得到该神经元对这个样本的加权和。b1加上去之后形状还是128×m,numpy广播规则会把b1的每一列复制到所有样本上,这里不显式复制没有问题,但一定要保证b1是128×1而不是(128,),否则广播会出错。
3.3 反向传播与梯度下降:更新公式落实到代码
反向传播是整个BP网络里大家最容易写错的地方,而核心其实就四个梯度公式:输出层误差、隐藏层误差、两个权重梯度。
def backward(X, Y, A1, A2, W2): m = X.shape[1] # 合并了softmax和交叉熵的梯度,结果干净得吓人 dZ2 = A2 - Y # (10, m) dW2 = dZ2 @ A1.T / m # (10, 128) db2 = np.sum(dZ2, axis=1, keepdims=True) / m dA1 = W2.T @ dZ2 # (128, m) dZ1 = dA1 * A1 * (1 - A1) # sigmoid导数 dW1 = dZ1 @ X.T / m # (128, 784) db1 = np.sum(dZ1, axis=1, keepdims=True) / m return dW1, db1, dW2, db2dZ2 = A2 - Y这行是反向传播里最优雅也最容易让人迷惑的地方。它的原理是:当输出层使用softmax、损失函数使用交叉熵时,两者的梯度在链式法则里互相抵消,最终归结为预测概率减真实标签。我见过很多人在这里老老实实分开算softmax导数和交叉熵导数,结果要么算错,要么在中间步骤产生除以0的nan。记住:softmax加交叉熵,梯度就是A2-Y,这是标准做法。
隐藏层误差dZ1里,A1 * (1 - A1)是sigmoid导数的简写。它的问题是当A1接近0或1时,这个值接近0,梯度经过这里会大幅衰减,所以两层以上全用sigmoid时浅层梯度基本消失。单隐藏层结构下这个影响还能接受。
最后所有梯度除以m,表示对mini-batch里每个样本的梯度取平均。这样做的效果是loss值和梯度的量级不随batch_size变化,调整batch_size时不需要跟着缩放学习率。
3.4 梯度下降更新与训练循环:epoch、batch_size、shuffle
有了梯度,更新参数就是老式梯度下降:param = param - lr * grad。训练循环里我加入mini-batch和shuffle,这是让BP网络真正收敛的两个工程细节。
def train(X, Y, n_hidden=128, epochs=30, lr=0.1, batch_size=64): n_input = X.shape[0] n_output = Y.shape[0] W1, b1, W2, b2 = init_params(n_input, n_hidden, n_output) rng = np.random.default_rng(1) m = X.shape[1] for epoch in range(epochs): # 每个epoch重新打乱样本顺序,避免网络学到样本排列规律 perm = rng.permutation(m) total_loss = 0 for i in range(0, m, batch_size): idx = perm[i:i + batch_size] X_batch = X[:, idx] Y_batch = Y[:, idx] A1, A2 = forward(X_batch, W1, b1, W2, b2) batch_m = X_batch.shape[1] loss = -np.sum(Y_batch * np.log(A2 + 1e-8)) / batch_m total_loss += loss dW1, db1, dW2, db2 = backward(X_batch, Y_batch, A1, A2, W2) W1 -= lr * dW1 b1 -= lr * db1 W2 -= lr * dW2 b2 -= lr * db2 if epoch % 5 == 0: print(f"epoch {epoch}, loss {total_loss / (m // batch_size):.4f}") return W1, b1, W2, b2mini-batch的核心意义在于梯度噪声。全量batch的梯度是精确的,但更新一次只能迈一小步;单样本SGD噪声太大,路径震荡;64~128的mini-batch能在稳定性和收敛速度之间取平衡。shuffle则保证每个batch的样本分布接近整体分布,如果不打乱,MNIST数据集默认按0到9排序,一个batch里全是"7",梯度方向就会被少数类带着跑。
loss计算里加1e-8是为了防止log(0)。当预测概率极度接近0时,log直接报nan,这个保护代价很小但很有用。训练若干epoch后loss一般能降到0.1以下,这对应平均每个样本交叉熵很小、置信度很高。
4. BP网络手写数字识别的6个常见坑:翻车现场与排查清单
下面这几条是我在这个zip项目里反复遇到、也帮别人排查过最多的问题。每条按"现象-原因-解决"写,遇到类似情况直接对照定位。
4.1 训练不收敛:NaN、loss不降的根源
现象:训练到第几个epoch时loss突然变成nan,或者从第一个epoch开始loss一直是2.3不变,准确率始终在10%上下。
原因:nan最常见于学习率过大。LR设为1.0或更大时,梯度更新步伐太大,权重在最优解两侧来回震荡,幅度越来越大,最终溢出float32范围。loss一直不动则多半是权重初始化过大导致sigmoid饱和,梯度接近0,参数实际上没有更新。
解决:先检查学习率,从0.1往下调试,当loss出现nan就除以10。如果小学习率下loss还是不动,把权重初始化标准差从0.01降到0.001跑一次。这两个参数是联动的,我建议先固定初始化、只调学习率,跑通后再反过来动初始化。
现象:训练30个epoch后loss正常降到0.1,但准确率只有70%左右。
原因:隐藏层神经元数量太少(比如16个)会欠拟合,网络容量不够学习完整的数字结构。这是最容易忽略的一点,因为训练流程本身没有任何报错。
解决:把隐藏层从16加到128,准确率基本会有明显提升。如果128后收益不大,不要继续翻倍到1024,过拟合风险反而上升。
4.2 准确率卡在10%:标签编码和输出层不匹配
现象:loss下降得很漂亮,从2.3降到0.2,但训练集准确率一直在10%徘徊,和随机猜的概率一样。
原因:标签编码和输出层对不上。最常见的情况是独热编码写成了从1开始,比如把数字0编码到第1列、数字9编码到第0列,或者预测时用argmax(A2) + 1取标签,导致所有预测整体偏移一位。代码上看没有错,逻辑上差了一位。
解决:打印独热编码的前几行,确认Y[i, y[i]]等于1,Y[i, y[i]-1]等于0。再抽几个样本手动对比:比如数据第一行是数字5,检查Y[0,5]是否为1,以及argmax(A2[0])是否为5。下标问题最坑在于它不报错,只会让准确率安静地停在随机水平。
4.3 忘了归一化或用了错误的数据类型
现象:训练集准确率有98%,测试集只有60%,而且数字"1"和"7"大面积混淆。
原因:这不完全是过拟合,可能是在训练和测试之间做了不同的预处理。常见做法是训练时X_train /= 255,但测试时忘了对X_test做同样的除法,测试输入变成0~255大数值,网络从没见过这种分布。
解决:把预处理写成统一函数,训练和测试都调用。另外检查X_train.dtype是不是float32,如果不是,除法后小数被截断成0或1,输入信息量直接报废。
4.4 训练集99%测试集70%:过拟合信号与早期停止
现象:训练集准确率接近满分,验证集或测试集却掉了20个百分点以上。
原因:网络容量太大或训练轮数太多。MNIST本身模式简单,128个神经元的单隐藏层有大概10万个参数,训练集上有6万个样本,拟合完训练集绰绰有余,但其中很多权重学的是训练集特有的噪声。
解决:从训练集里切出5000个样本做验证集,每个epoch结束都算验证准确率,验证集准确率连续5个epoch不再上升就停。这比固定epoch次数更实用,因为不同初始化下最优epoch可能在20到80之间浮动。另外可以加一点L2正则,比如在loss里加上0.0001倍的权重平方和,对抑制过拟合有帮助。
4.5 训练自己画的图片全错:预处理格式不一致
现象:网络在MNIST测试集上准确率97%,把你画的数字存成PNG后喂进去,预测结果完全不对。
原因:MNIST是黑底白字、28×28灰度,而你用画图板画出来的通常是白底黑字、尺寸几百像素,直接缩放成28×28后,背景是白色(255)笔画是黑色(0),和训练分布正好反了,网络当然全错。
解决:推理前统一做四件事:转灰度、缩放成28×28、白底黑字图执行255 - img反转像素值、最后除以255归一化。这四步少一步,推理准确率都会崩。我把这段预处理写进代码,部署时作为固定的前提条件。
4.6 混淆矩阵里"4"和"9"、"2"和"7"系统性地互换
现象:整体准确率正常,但测试集里数字4被识别成9、数字2被识别成7的比例明显高于其他对。
原因:这些数字在笔画形态上确实接近,单隐藏层全连接网络能学到的局部特征有限,它主要依赖全局像素分布,对轻微的角度差异不敏感。这是模型能力边界,不是bug。
解决:最有效的办法是做平移数据增强,把每张训练图随机平移1~2像素再训练,相当于白送几倍的训练数据,对这类混淆有明显的缓解效果。如果还不行,就得考虑卷积网络提取局部笔画特征了。
5. 把准确率从90%推到98%:调参与验证的一线组合
跑通之后,接下来的问题是"怎么知道该改哪里"以及"现在这个结果够不够好"。我一般按容量、学习率、早停、混淆矩阵这个顺序来调,而不是一次性改七八个参数结果不知道该归功于谁。
5.1 网络容量:隐藏层128还是256、要不要第二层
单隐藏层BP网络在MNIST上的容量上限大约在97%~98%之间。把隐藏层从128加到256,准确率可能只提升0.2%,但参数量翻倍、训练时间也几乎翻倍,性价比很低;加第二层隐藏层,如果激活函数仍是sigmoid,浅层梯度消失会让第二层几乎学不到东西。
| 隐藏层配置 | 参数量(约) | 验证集准确率量级 | 训练成本 |
|---|---|---|---|
| 64 | 5.1万 | 95%~96% | 低 |
| 128 | 10.2万 | 97%~98% | 中 |
| 256 | 20.4万 | 98%左右 | 高 |
| 128+64 | 10.9万 | 略低于单128 | 中偏高 |
我给的结论可能和直觉相反:单隐藏层128在MNIST上已经接近这个模型族的天花板。与其堆容量,不如花时间在预处理和数据增强上。
5.2 学习率、动量与批次大小:三个参数联动
这三个参数不是独立调的。学习率定了梯度下降的步长,batch大小定了梯度的噪声程度,两者需要配合。批次越大,梯度越准,学习率可以稍大一点;批次越小,梯度噪声越大,学习率要调小,否则会在最优点附近震荡。
| 参数 | 推荐范围 | 影响 |
|---|---|---|
| 学习率 | 0.01 ~ 0.3 | 太大发散,太小收敛慢 |
| batch_size | 32 ~ 128 | 影响梯度噪声和每次更新耗时 |
| 动量 | 0.9(可选) | 加速收敛,抑制震荡 |
如果不想引入动量公式,直接把学习率设0.1、batch_size设64,基本能跑出不错的效果。想再压榨一点,可以给权重更新加个动量项:维护一个速度变量v,每个epoch让v = 0.9 * v + lr * dW,然后W -= v。这个改动对收敛速度的提升比调隐藏层大小明显得多。
5.3 用验证集而不是训练集做决策
我在训练时从训练集里留出5000个样本当验证集,模型不在这批数据上训练,只用来观察每个epoch的准确率。保存验证集准确率最高时的权重,训练结束后恢复这组权重,就是早停。
def train_with_early_stopping(X_train, Y_train, X_val, Y_val, ...): best_acc = 0 best_params = None patience = 5 no_improve = 0 for epoch in range(epochs): # 训练一个epoch过程省略,复用之前的train_step逻辑 val_acc = evaluate(X_val, Y_val, W1, b1, W2, b2) if val_acc > best_acc: best_acc = val_acc best_params = (W1.copy(), b1.copy(), W2.copy(), b2.copy()) no_improve = 0 else: no_improve += 1 if no_improve >= patience: print(f"early stop at epoch {epoch}") break return best_params这里有个细节:保存参数时要用.copy(),否则后面权重更新会覆盖掉best_params指向的同一个数组。我在项目里见过有人忘了copy,最后早停恢复了跟最后一轮完全一样的权重,白写了早停逻辑。
验证集和测试集要严格分开。有人图省事直接用测试集当验证集调参,调几天后测试集准确率看似很高,但那是模型在测试集上过拟合的结果,换一批新数据立刻现原形。我一般从训练集尾部切5000张做验证,等所有参数定完,才用测试集跑最终评估。
5.4 用混淆矩阵找系统性错误
准确率只是一个数字,它不会告诉你哪里错了。混淆矩阵把每个真实类别被预测成什么类别展示出来,是定位系统性错误的直接工具。
def confusion_matrix(labels, preds, num_classes=10): cm = np.zeros((num_classes, num_classes), dtype=int) for t, p in zip(labels, preds): cm[t, p] += 1 return cm y_pred = np.argmax(predict(X_test, W1, b1, W2, b2), axis=0) cm = confusion_matrix(y_test, y_pred) # cm[i, j] 表示真实数字i被预测成数字j的次数跑完看cm的哪些非对角线元素最大,那两类就是最容易混淆的。比如cm[4, 9]很高,说明数字4经常被认成9。这时候不要再加隐藏层了,加数据增强或检查预处理流程更有效。混淆矩阵还能暴露下标偏移:如果cm的对角线整体偏向某一条斜线,比如cm[0,1]、cm[1,2]都偏高,基本又是标签编码错位,不是模型问题。
6. 从实验到可用模型:保存、推理与权重可视化
训练跑通、准确率也满意了,还差最后三件事才能让这个zip项目真正闭环:把权重存下来、能对单张图推理、能解释网络学到了什么。我一般会额外写三个小函数。
def save_model(path, W1, b1, W2, b2): np.savez(path, W1=W1, b1=b1, W2=W2, b2=b2) def load_model(path): data = np.load(path) return data['W1'], data['b1'], data['W2'], data['b2'] def predict_image(img, W1, b1, W2, b2): # img已是28x28灰度数组,先做和训练时一致的预处理 x = img.reshape(-1, 784).astype('float32') / 255.0 A1, A2 = forward(x.reshape(784, 1), W1, b1, W2, b2) return np.argmax(A2, axis=0)[0]保存用np.savez足够,不需要pickle,后者的兼容性问题更多。推理函数里最容易踩的就是忘了把shape做成(784, 1),numpy的(784,)向量和一个二维权重矩阵做矩阵乘法,维度经常不匹配,报错还是小事,广播规则有时会悄悄给你算出错误结果。
权重可视化是我每次拿到项目最后会做的事:把W1的第一行reshape成28×28矩阵,用matplotlib画热力图。你会发现一部分行能看出笔画形状模板,比如有的行在图像中部有强响应,有的行在右上角有强响应,这说明网络确实学到了空间位置的局部模式。如果所有权重图都是均匀的噪点,没有任何结构,那训练大概率没收敛,val_acc也不会高。这个检查比loss曲线更直观,也是我在面试候选人的时候最喜欢反问的问题。
回头看这个zip项目,我早期犯的最大错误就是太关注loss下降,测试集一跑97%就以为完工了,结果把自己画的数字图喂进去全线翻车。后来把预处理的统一封装、验证集早停、混淆矩阵检查做成固定流程,才真正从"能跑通"变成"可交付"。希望帮到你。
本文还有配套的精品资源,点击获取