
简介这是基于 Python 实现的手写数字识别系统完整项目属于经导师指导并认可的高分毕业设计评审分为 98适合计算机专业学生用于课程设计、毕业设计或深度学习入门实战。项目以 MNIST 数据集为对象同时提供卷积网络与 BP 网络两条可运行实现路径代码覆盖数据加载、卷积、池化、激活函数、训练、模型保存与结果可视化等关键环节能够帮助读者理解图像分类流程和调参思路。压缩包共 28 个文件14.18MB包含 py 源码、npz 训练参数、idx 格式的 MNIST 原始数据、png 效果图及 md 使用说明其中 10 组参数记录了 10 次训练的准确率变化最高达到 96.98%可以直接加载验证或继续迭代。已有 70 人浏览学习适合正在完成大作业或毕业设计、需要可运行项目作参考的学习者是一份难度适中、结构完整且带完整数据的高分毕设方案。1. 手写数字识别毕业设计源码先看清这套资源到底给了什么打开这套资源一眼看到的是 module.py、CNNmain.py、bp.py、parameters 这些文件和目录形式很像一个压缩包工程但沿 README.md 走一遍你会发现它是一条完整的 Python 手写数字识别实践链MNIST 数据加载、BP 神经网络和 CNN 两套模型、训练入口、参数保存和结果绘图全部能跑。最让我认可的一点是它不靠 Keras 几个 API 就出效果核心的 bp.py、conv.py、pool.py 都写在明处适合做毕业设计和大作业的学生也适合刚学完 Python、想亲手复现一次手写数字识别全流程的练习者。老师能给出高分说明文档、代码和数据都是完整的不需要你再到处找样本拼代码。2. 两个模型两条路线BP 与 CNN 的结构差在哪里2.1 从文件看项目骨架每个模块到底管什么看源码不要先翻 CNNmain.py 和 BPmain.py我习惯先列一个“文件职责表”知道谁在给谁打工。data/mnist 是原始数据load_mnist.py 负责把 MNIST 的二进制格式读成 numpy 数组bp.py 实现 BP 网络的层结构和反向传播conv.py、pool.py 是 CNN 的卷积和池化基础操作activate.py 统一提供激活函数module.py 更像工具箱放公用的辅助函数和组件saveandread.py 管训练参数的保存与读取figure 目录和 parameters 目录分别放训练结果图和每次训练留下的权重文件。文件职责一句话理解module.py公共组件/工具封装把重复代码收拢供两个网络共用activate.py激活函数sigmoid、tanh、softmax 等conv.py卷积层局部窗口乘加提取笔画特征pool.py池化层2x2 窗口取最大值降维bp.pyBP 网络实现全连接前向与反向CNNmain.pyCNN 训练入口组装模型、跑训练、出图BPmain.pyBP 训练入口训练 BP 并保存参数load_mnist.pyMNIST 数据加载二进制转 numpy 数组saveandread.py参数持久化npz 格式读写parameters十次训练权重文件名即准确率记录figure可视化结果训练曲线、预测样例图这个分工模式对一个毕业设计来说非常值钱。答辩时老师问“你这张图是哪段代码生成的”你直接说“saveandread.py 保存完参数后用 matplotlib 把训练历史画到 figure/Figure_1.png”说明你理解整个链条而不是只会跑别人的 main 函数。我第一次做项目时把所有逻辑塞进一个 py 文件后面改参数改到想哭从那以后就学乖了每个模块只做一件事。2.2 前向计算到底差在哪全连接与卷积的像素视角手写数字识别本质上是在干一件事把 28x28 像素映射到 09 十个类别。BP 网络和 CNN 的分歧在第一步先把图像展平成一维 784 向量还是把图像当成二维结构处理。BP 采用前一条路。输入 x 是 [batch, 784] 的矩阵第一个全连接层权重 W1 的维度是 [784, hidden]输出层权重 W2 是 [hidden, 10]# bp.py 里常见的前向逻辑示意可对照源码看 import numpy as np def sigmoid(x): return 1 / (1 np.exp(-x)) def softmax(x): e np.exp(x - np.max(x, axis1, keepdimsTrue)) return e / e.sum(axis1, keepdimsTrue) class BPNet: def __init__(self, hidden_size128, lr0.01): # 小随机初始化避免神经元一开始就饱和 self.W1 np.random.randn(784, hidden_size) * 0.01 self.b1 np.zeros(hidden_size) self.W2 np.random.randn(hidden_size, 10) * 0.01 self.b2 np.zeros(10) self.lr lr def forward(self, x): # x: [batch, 784]已经除以 255 归一化 self.h sigmoid(x self.W1 self.b1) # 隐藏层输出 self.out softmax(self.h self.W2 self.b2) # 10 类概率 return self.out这段代码有三个容易看懵的参数维度x W1 合法是因为 np.dot 把每个样本按行向量处理784 个特征和 W1 第一维对齐有人说“为什么有的代码 W1 是 [hidden, 784]”其实两种写法都常见区别只在于你习惯用 x W1 还是 W1 x.T反过来用又没转置一定会报维度不匹配的 ValueError。隐藏层节点数 hidden_size 是第一个可调参数128 在 MNIST 上效果不错但要注意BP 的参数量大约等于 784128 12810十万级参数去拟合六万张图理论上很容易过拟合。CNN 走的完全是另一条路。conv.py 先用卷积核在二维图像上滑动每次只观察一个小局部比如 3x3 或 5x5 的窗口这样相邻像素的空间关系被显式保留下来。“数字 7 的横和竖在一个窗口里”这种特征卷积网络天然能学到。pool.py 随后做 2x2 最大池化把每个小区域里的最强响应留下降低分辨率的同时保留显著特征。# conv.py pool.py 的配合逻辑示意非项目完整代码 def conv2d(image, kernel, stride1): # image: [1, 28, 28]kernel: [5, 5] # 输出特征图尺寸近似 (24, 24)取决于是否做 padding h_out (image.shape[1] - kernel.shape[0]) // stride 1 w_out (image.shape[2] - kernel.shape[1]) // stride 1 out np.zeros((h_out, w_out)) # 滑动窗口做乘加代码略 return out def max_pool2d(feature, pool_size2, stride2): # 把相邻 2x2 区域取最大值 h feature.shape[0] // pool_size w feature.shape[1] // pool_size return feature[:h*2, :w*2].reshape(h, 2, w, 2).max(axis(1, 3))理解这段的关键是池化层没有可学习参数卷积层的每个卷积核在整张图上共享同一组权重所以 CNN 的参数量远小于 BP。共享权重和局部连接这两点让它对笔画的平移、粗细变化更宽容这就是为什么项目里 CNN 能跑到 96.98%而 BP 想稳定上 94% 要仔细调参。从结构上看CNN 和 BP 不是谁取代谁而是特征提取方式的区别。2.3 反向传播的差异和激活函数选择前向只是前半段。BP 的反向传播把输出层误差按链式求导一层层传回更新 W2 和 W1CNN 的误差还要经过池化层上采样和卷积核翻转才能回到前面的层。这里最容易掉进黑匣子的是激活函数sigmoid 在输入很大或很小时导数趋近 0梯度一层层乘下来会越来越小这就是深度网络里说的梯度消失。activate.py 如果只提供 sigmoid我第一次训练卡在 66% 一点不意外换成 tanh 或 ReLU 往往立刻改善。我的建议是读源码时先不要纠结整个反向传播的每一步推导把前向算一遍、确认各层输出形状陪你的始终是 batch、hidden、10 这三个维度再回看反向中的误差项。等你能解释“为什么第 8 次参数比第 9 次准确率还高”时这个项目才算真正吃透了一半。3. 数据加载与预处理load_mnist.py 该怎么看、怎么改3.1 原始 MNIST 数据的字节结构data/mnist 目录存放的不是普通图片而是 IDX 二进制格式。很多人在这一步翻车直接把文件后缀改成 png、用图像库打开得到一堆乱码或直接打开失败。MNIST 文件有固定的头结构图片文件前 16 字节依次是 magic number、样本数、行数、列数标签文件前 8 字节是 magic number 和样本数。import struct import numpy as np def load_mnist(path, kindtrain): images_path f{path}/{kind}-images.idx3-ubyte labels_path f{path}/{kind}-labels.idx1-ubyte with open(labels_path, rb) as f: magic, n struct.unpack(II, f.read(8)) # 大端读取两个 uint32 labels np.frombuffer(f.read(), dtypenp.uint8) with open(images_path, rb) as f: magic, n, rows, cols struct.unpack(IIII, f.read(16)) images np.frombuffer(f.read(), dtypenp.uint8) images images.reshape(n, rows * cols) # 每个样本展平成 784 维 return images, labels # 验证读取结果 images, labels load_mnist(data/mnist, train) print(训练集形状:, images.shape, labels.shape) print(像素范围:, images.min(), images.max()) print(标签分布:, np.bincount(labels))注意 struct.unpack 里的II表示大端字节序。MNIST 是早期格式写成小端会得到错位的 magic number后面所有数据都会跟着错位。我一般跑完先打印形状[60000, 784] 和 [60000] 是对的如果出现 [65536, 8] 之类的形状多半是头字段解析错误。images 的 reshape 用的是 n * rows * cols即 n * 784顺序是图像逐行展开不能随意转置。3.2 训练前必须做的两步归一化和 one-hotload_mnist 读回来的像素是 0255 的 uint8直接送进网络会有两个问题一是数值范围太大权重更新被个别亮像素主导二是中间层加权和很容易落在 sigmoid 的饱和区。常见做法是转成 float32 并除以 255让输入范围落在 01。标签也需要一次转换。模型输出层有 10 个节点分别对应数字 09因此标签要从标量变成 one-hot 向量。比如数字 5 的 one-hot 是 [0,0,0,0,0,1,0,0,0,0]而不是整数 5。# 预处理示意 import numpy as np images, labels load_mnist(data/mnist, train) X images.astype(np.float32) / 255.0 Y np.eye(10)[labels] # one-hot 编码Y[0] 是 0 对应的向量 # 验证把第一张图还原成 28x28并打印它对应的标签 sample X[0].reshape(28, 28) print(第一个样本真实标签:, labels[0]) print(one-hot:, Y[0])np.eye(10)[labels] 是最简洁的 one-hot 写法下标取到 09生成 [60000, 10] 矩阵。有些人不理解为什么不用 sklearn 的 OneHotEncoder那个更适合稀疏类别编码在这里直接用 np.eye 更直观。预处理后可以用 matplotlib 看一眼第一张图确认图像和标签是对齐的如果显示出来的数字跟 label 对不上多半是文件指针或 reshape 顺序出了问题。3.3 训练集和验证集的划分原则MNIST 原本就有官方划分train 六万张test 一万张。这套资源里 data/mnist 保留了这种标准划分所以在 load_mnist 时通过 kind 参数切换就够。不需要自己手动再切除非你想做 cross-validation。如果手动切分请记住一定先打乱再切而且要用固定随机种子# 推荐的数据切分方式 np.random.seed(42) indices np.random.permutation(len(X)) train_idx indices[:50000] val_idx indices[50000:] X_train, Y_train X[train_idx], Y[train_idx] X_val, Y_val X[val_idx], Y[val_idx] print(训练集:, X_train.shape, 验证集:, X_val.shape)固定随机种子有两个作用一是让实验可复现二是避免每次跑结果不一样、答辩时被问倒。我习惯把切分放在数据加载脚本里而不是每个训练入口各切各的这样 BPmain.py 和 CNNmain.py 能用同一份数据比较结果才有意义。4. 把训练跑起来从第一次 66% 到第十次 96.98% 的参数演进4.1 训练入口怎么选BPmain.py 先起步CNNmain.py 出效果项目里两个训练入口分工明确。BPmain.py 适合先跑一方面模型简单前向反向都是矩阵乘法CPU 上几十秒就能看到一轮训练结果另一方面它更容易暴露你对维度、梯度、归一化的理解短板。CNNmain.py 结构复杂一些启动参数包括卷积核数量、池化窗口和全连接维度训练速度也慢适合作为最终效果模型。# BPmain.py 的训练骨架示意需结合项目源码调整 from load_mnist import load_mnist from bp import BPNet from saveandread import save_params images, labels load_mnist(data/mnist, train) X images.astype(np.float32) / 255.0 Y np.eye(10)[labels] net BPNet(hidden_size128, lr0.01) # 假设 BPNet 提供 fit 方法返回历史 loss net.fit(X[:50000], Y[:50000], X[50000:], labels[50000:]) save_params(net.export_params(), 第1次训练参数-正确率66.28%.npz)参数说明hidden_size 为隐藏层节点数lr 为学习率X[:50000] 是训练集X[50000:] 是验证集。文件名里的正确率不是测试集最终结果而是这次训练在验证集上的表现。如果训练时 loss 一直不降优先检查 lr 要不要降到 0.001其次检查输入是否做了归一化。CNNmain.py 相对复杂它的启动流程一般是这样# CNNmain.py 的常见配置示意 # 输入: [batch, 1, 28, 28] # 卷积层: 1 - 6 个 5x5 卷积核 # 池化层: 2x2 最大池化 # 全连接: 把 pool 输出展平映射到 10 类CNN 调参建议先固定结构再调学习率。卷积核数量从 6 试到 16准确率会有提升但训练时间也在增加。如果第八次到第十次都在 96% 上面波动就不要再盲目增加卷积核而是考虑数据增强或换优化器。4.2 参数保存与断点续训saveandread.py 的 npz 读写parameters 目录下有一排带准确率的 .npz 文件这是 saveandread.py 干的事。npz 是把多个数组一次性压缩保存的格式比 mat 文件轻比 pkl 更通用在 Python 生态里回读也简单。import numpy as np def save_params(params_dict, path): np.savez(path, **params_dict) def load_params(path): data np.load(path) return {key: data[key] for key in data.files} # 保存前先把网络权重收集成字典 params {W1: net.W1, b1: net.b1, W2: net.W2, b2: net.b2} save_params(params, parameters/第10次训练参数-正确率96.98%.npz) # 回读时不要假设固定键名 params_loaded load_params(parameters/第10次训练参数-正确率96.98%.npz) print(npz 里的字段:, list(params_loaded.keys()))踩坑提醒别人分享的 npz 文件字段可能叫 w1、weight1 或 model_weights直接把 net.W1 赋值给它会报维度错误。我每次拿到新的模型文件第一件事就是打印 keys()确认字段和网络定义对应再加载。这个习惯能省下不少 debug 时间。4.3 十次训练的正确率演进参数文件里的调参日志十份参数文件里的准确率本质是一个完整的调参记录。把它们按顺序排开参数文件正确率第1次训练66.28%第2次训练82.46%第3次训练93.61%第4次训练95.20%第5次训练93.86%第6次训练95.75%第7次训练96.30%第8次训练96.58%第9次训练96.42%第10次训练96.98%从第一次到第二次是最大跳变说明最初 66% 大概率是忘了归一化或者学习率太小。第5次掉到 93.86%第8次又比第7次高说明训练过程不是线性的中途出现过拟合或 learning rate 波动。这些波动在答辩时反而是加分项因为它证明你是在迭代调参而不是一把梭地跑了个最终结果。我常用的做法是每次训练结束把 网络结构 参数文件 准确率 训练曲线截图 四样东西记在同一个 markdown 文档里。第8次为什么比第9次高回去查当时的 lr 和 epoch 记录就能定位。这段“实验日记”本身就是毕设论文里最好用的素材。4.4 结果可视化figure 目录里的图怎么生成figure 里的图是训练完顺手存下来的。用 matplotlib 把 loss 曲线、验证准确率或错分样例画出来放到论文里比纯文字描述有力得多。import matplotlib.pyplot as plt # 假设 net 训练时把每轮 loss 存进了 history plt.plot(history) plt.title(Training Loss Over Epochs) plt.xlabel(Epoch) plt.ylabel(Loss) plt.savefig(figure/Figure_1.png, dpi150)保存图片时注意两点一是 dpi 设到 150 以上否则放到论文里会糊二是中文标签在 matplotlib 默认字体下容易变成方块我一般先用英文画图等确定投递或答辩再替换中文字体。还有一点figure 目录和 parameters 目录最好每次训练都归档一份文件名带上准确率这样几十个文件里不会找错版本。5. 避坑与排查手写数字识别最容易踩的五个坑5.1 训练阶段最容易翻车的三件事现象一训练了很多轮准确率一直卡在 60%70%。原因大多数情况下是输入没归一化。784 维像素值直接在 0255 范围内参与加权和梯度数值会被个别大像素主导另一常见原因是权重初始化太宽所有神经元过早饱和。解决强制在训练入口做X images.astype(np.float32) / 255.0权重初始化用np.random.randn(784, hidden) * 0.01或者换 Xavier。改完这两处直接从第1次参数跳到第2次后的正确率是这类项目最典型的起飞路径。这时有两个快速验证手段可以让调试不要太玄学一是用前几十个 batch 的 loss 做 sanity check如果 loss 第一轮就在正常范围说明前向没问题二是把训练集切到只剩几百张强制过拟合如果 loss 能压下去代码逻辑就没问题。现象二loss 跑到一半变成 NaN。原因交叉熵对预测概率取对数时如果 softmax 输出出现 0会出现 log(0)反向传播梯度也可能因学习率太大而爆炸。解决在交叉熵里给np.log(p)加一个1e-8的极小值把激活函数从 sigmoid 换成 tanh 或 ReLU学习率从 0.1 降到 0.001。这是老生常谈但百分之八十的 NaN 都能靠这三条解决。NaN 还有一个隐蔽来源是学习率太高导致梯度步长过大参数直接跳到数值溢出建议先把 lr 按 10 倍往下调如果 0.001 还在 NaN就要看输入数据里有没有 inf 或意外的大值。现象三验证集准确率在某个 epoch 后掉了训练集还在继续涨。原因模型开始过拟合把训练集里数字边缘的噪声也记住了。MNIST 虽然比较干净但 BP 网络参数量大epoch 一多照样过拟合。解决训练时每轮结束都记录验证集准确率连续两轮没提高就提前停止或者把 lr 调小、在隐藏层加一个简单的 L2 正则。parameters 里保留十份参数的好处就是你可以随时回退到第 8 次那个 96.58% 的版本不用重新训练——这就是后悔药。5.2 数据和文件环节常见的两个坑现象四加载别人的 npz 文件时直接 KeyError明明代码里写了 W1。原因npz 只是把字典存下来字段名叫什么完全取决于训练方的实现。有人用 W1、W2有人用 conv1_weight、fc2_weight还有人把参数打包成两层 dict 再存。解决先执行data np.load(path); print(data.files)看清楚里面有哪些键再写加载代码。我一般会把 load_params 写成{key: data[key] for key in data.files}不过度绑定任何字段名同时加一段注释标注模型层名。另外要留意 npz 的 dtype训练时是 float32加载回来也是 float32如果中间转成 float64显存占用和计算速度都会变化。还有保存时如果直接把变量名写错比如 b2 写成 bias2也会出现 KeyError所以保存函数和加载函数最好结对写在一个文件里。现象五自己用手机拍的数字丢进去预测全乱猜。原因MNIST 训练集是白字黑底、居中的 28x28 图片随手拍的照片是灰底、大小不一、位置偏移像素分布完全不在同一个空间里。解决预测前做预处理把图片转成 MNIST 风格。import cv2 import numpy as np def preprocess_custom_image(img_path): # 读取灰度图 img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) # 缩放到 28x28 img cv2.resize(img, (28, 28), interpolationcv2.INTER_AREA) # 白底黑字转成黑底白字和 MNIST 对齐 img 255 - img # 归一化到 0~1 img img.astype(np.float32) / 255.0 return img.reshape(1, 784)这里 INTER_AREA 比默认线性插值更适合缩小能减少摩尔纹和锯齿。反色那一步很关键手写白纸上写的字是黑字白底MNIST 是白字黑底忘记反色时模型会看到完全相反的数字。预处理做完用训练集统计的均值和标准差做归一化比单纯除 255 更稳但 MNIST 场景下除 255 已经够用。6. 验证与进阶换一张自己的手写数字用训练好的参数做预测训练完十次参数下一步就该做验证了。MNIST 测试集跑准确率只能说明“在这个数据集上没翻车”真正拿一张自己的手写数字去走一遍才能验证模型有没有实用价值。我会建一个 predict.py组合 load_params、load_mnist 的预处理逻辑和 top-k 输出三个部分import numpy as np from saveandread import load_params # 加载第10次训练参数 params load_params(parameters/第10次训练参数-正确率96.98%.npz) net.set_params(params) # 假设 preprocess_custom_image 已按上面方法写好 prob net.forward(preprocess_custom_image(my_digit.png)) top_k np.argsort(prob[0])[::-1][:3] print(Top3 预测:, top_k) print(对应概率:, prob[0][top_k])Top3 输出比只看 argmax 有说服力得多。手写数字里“4 和 9”、“7 和 1”本来就容易混淆如果 Top1 是 7、Top2 是 1说明模型其实已经抓到部分特征只是信心不足如果 Top1 概率连 0.4 都不到多半是预处理不一致。想进一步压榨准确率可以把第6、8、10 三次参数都加载进来对同一个输入做三次预测再取平均概率这种简单集成的稳定性比单份参数好问题是调试时你不再能一眼看出哪个模型在主导需要保留每份参数的单独输出。这套资源里已经生成的 parameters 和 figure是最值得利用的起步点。我用它只花了很少时间就复现从 66% 到 96.98% 的完整曲线接下来所有改动都以“能否超过第 10 次 96.98%”作为标准。从那以后我每次跑完训练都会强制自己走一遍“保存参数、画图、记实验日志”三个动作否则下次再来连当前模型是什么结构都得重新猜。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取