
简介基于反向传播神经网络的手写数字识别Python实现源码是一套面向计算机相关专业学生期末大作业与课程设计的完整项目。项目曾获98分代码结构清晰涵盖网络定义、数据加载、训练测试与超参数调节等模块适合正在完成大作业或需要实战练习的初学者进阶参考。压缩包共61个文件大小15.3MB包含7个程序源码文件主程序、网络模型、数据预处理与缓存脚本、22个数据文件手写数字数据集及缓存、16张测试图片、2个模型文件以及效果演示视频、说明文档和依赖清单等便于直接运行、对照学习与结果验证。目前已有535人学习下载。通过本资源可掌握从数据加载、模型构建到训练评估的完整流程同时附带训练好的模型参数、演示录屏和测试图片可帮助读者快速复现手写数字识别效果并基于源码进一步调整网络层数与超参数是期末项目与课程设计的高分实用参考。1. 基于BP神经网络MNIST手写数字识别这套Python源码包能拿来直接做期末大作业如果你在找免费Python源码大全里能直接交的期末大作业基于BP神经网络的手写数字识别基本是出镜率最高的选题。我拿到这套源码时第一反应是目录比一般课程设计完整dataset、models、test_imgs、效果展示.mp4全齐了readme.md里写了运行顺序requirements.txt也锁好了依赖。它不是那种只丢一个训练脚本的半成品而是把数据加载、图片预处理带缓存、BP网络训练、超参数调优、自定义图片预测都拆成了独立模块。你既能直接改参数跑分也能把network.py当作BP神经网络结构图对应的实现来逐行读。适合两类人一类是只想跑通流程、拿到课程设计分数的同学另一类是真正想把反向传播搞清楚、再动手改代码的学习者。下文按数据管线、网络核心、调参和避坑顺序拆每个文件是干什么的、出问题该改哪一行都会说到。2. 数据管道mnist.npz加载与图片预处理的三个关键步骤2.1 dataset_load.pymnist.npz里的四类数组怎么取项目里dataset/mnist.npz是MNIST数据集的numpy压缩包dataset_load.py是整套代码的数据入口。很多同学做MNIST时第一反应是去torchvision下载但torchvision下载mnist会404的情况这几年很常见网络一波动就要重试半天。本地这份npz的好处就是完全离线只要numpy能装好数据就不会缺。用numpy加载时首先要看压缩包内部字段名import numpy as np data np.load(dataset/mnist.npz) print(data.files) # 常见字段名[x_train, y_train, x_test, y_test] # 也可能是 [train_images, train_labels, test_images, test_labels] x_train data[x_train] y_train data[y_train] x_test data[x_test] y_test data[y_test] print(x_train.shape, y_train.shape, x_test.shape, y_test.shape) # 常见输出(60000, 784) (60000,) (10000, 784) (10000,)先解释一下为什么是60000, 784。MNIST原始图片是28×28像素784就是28×28展开后的向量长度60000是训练集图片数10000是测试集图片数。y_train里每个元素是0到9的数字标签不是向量。如果你打印出data.files后看到的字段名和我写的不一样没关系源码里一般会先判断一下再取值。训练BP网络时标签通常要做one-hot编码。输出层有10个神经元每个神经元对应一个数字类别所以要把单个标签变成10维向量def one_hot(labels, num_classes10): out np.zeros((labels.shape[0], num_classes), dtypenp.float64) for i, label in enumerate(labels): out[i, int(label)] 1.0 return out y_train_onehot one_hot(y_train) y_test_onehot one_hot(y_test)这里用循环写是为了让新手一眼看懂每行在干什么。如果你追求简洁直接写np.eye(10)[y_train.astype(int)]也行效果一样。值得注意的坑是标签必须先转成int有些版本从npz里读出来的是uint8直接当索引用没问题但如果读出来是字符串格式out[i, label]就会报类型错误。训练过程建议全部用float64避免梯度更新时精度被截断。2.2 img_preprocess_and_cache.py任意照片如何变成28×28灰度特征test_imgs里放了1.jpg、2.jpg、5.jpg、10.jpg这些手写数字图img_preprocess_and_cache.py就是用来把手机拍的照片处理成MNIST能识别的输入。这步最容易翻车MNIST训练集是黑底白字背景像素接近0笔画像素接近255而实际拍摄的图片常常是白底黑字甚至还有灰色阴影。from PIL import Image import numpy as np def load_and_preprocess(path, target_size28, invertTrue): img Image.open(path).convert(L) img img.resize((target_size, target_size), Image.Resampling.LANCZOS) arr np.array(img, dtypenp.float64) if invert: arr 255.0 - arr arr arr / 255.0 return arr参数说明不必每个都死记convert(L)做灰度化把RGB三通道变成单通道resize把任意尺寸压缩到28×28Image.Resampling.LANCZOS在缩小图片时保留边缘信息更多比默认的NEAREST抗锯齿效果好invertTrue执行反色白底黑字转成黑底白字最后arr / 255.0把像素值压到0到1。整套流程做完图片的数值分布才和mnist.npz里的训练样本一致。后端img_preprocess_and_cache.py里的“cache”指图片处理结果缓存。不缓存的代价是每跑一次预测就要重新读图、resize、反色、归一化如果只是几张图还好反复调模型时就非常浪费时间。import os import pickle cache_path test_cache.pkl if os.path.exists(cache_path): with open(cache_path, rb) as f: test_arrays pickle.load(f) else: test_arrays [] for name in os.listdir(test_imgs): if name.endswith(.jpg): arr load_and_preprocess(os.path.join(test_imgs, name)) test_arrays.append((name, arr)) with open(cache_path, wb) as f: pickle.dump(test_arrays, f)这段代码的逻辑是缓存文件在就直接读不在就遍历test_imgs目录处理图片并写成pkl。之后就算你把网络参数换了一批也不用手动重新加载图片只要缓存没删每次跑预测都能直接拿到处理好的数组。删掉pkl再跑一次缓存会重新生成。2.3 归一化不是可选项是必修项BP神经网络对输入范围非常敏感。如果像素值保持0到255第一层加权和后数值会很大sigmoid的输入瞬间推到饱和区梯度直接消失训练速度肉眼可见变慢。源码里训练前一定有这两行x_train x_train.astype(np.float64) / 255.0 x_test x_test.astype(np.float64) / 255.0做完之后所有输入值都在[0,1]区间。这里要强调一个一致性训练集和预测时用的预处理必须完全一样。训练时除以255测试图片也除以255训练时反色测试图片也要反色。我看到过有人训练时用黑底白字预测时却丢了一张白底黑字图进去结果识别率直接崩到个位数这不算模型问题是数据分布和训练时不一致。顺带说一句如果你想知道某一张图预处理完长什么样可以用matplotlib画出来import matplotlib.pyplot as plt plt.imshow(x_train[0].reshape(28, 28), cmapgray) plt.title(first train sample) plt.show()cmapgray是必须的不然imshow默认用彩色伪彩色看不出灰阶效果。用这段代码还能顺手检查一下缓存图片的反色方向到底对不对远比直接跑预测更直观。3. 核心BP网络实现读懂network.py里的前向传播与反向传播3.1 784到10的映射三层网络结构先立规矩项目里有models/3_layers.pkl说明默认模型是三层BP网络输入层784个神经元隐层若干神经元输出层10个神经元。隐层节点数在train_example.py里可以调常见配置是128或200。你如果看过BP神经网络结构图对这个形状应该不陌生左边一列像素点中间一层隐层右边10个输出。初始化参数时有个细节权重不能直接np.random.randn否则前向传播的方差会被逐层放大loss容易变成nanclass Network3Layer: def __init__(self, hidden_size128, learning_rate0.1, epochs5): self.input_size 784 self.hidden_size hidden_size self.output_size 10 self.lr learning_rate self.epochs epochs self.W1 np.random.randn(self.input_size, self.hidden_size) * np.sqrt(1.0 / self.input_size) self.b1 np.zeros(self.hidden_size) self.W2 np.random.randn(self.hidden_size, self.output_size) * np.sqrt(1.0 / self.hidden_size) self.b2 np.zeros(self.output_size)W1维度是784×hidden_sizeW2维度是hidden_size×10两个偏置分别是hidden_size和10。np.sqrt(1.0 / n)的作用是控制初始化方差让隐层神经元收到的输入加权和保持在较小范围。Xavier初始化更严谨但MNIST这个场景下用sqrt(1/n)已经够用。3.2 前向传播sigmoid激活函数与输出层处理def forward(self, x): self.z1 x self.W1 self.b1 self.a1 1.0 / (1.0 np.exp(-self.z1)) self.z2 self.a1 self.W2 self.b2 self.a2 1.0 / (1.0 np.exp(-self.z2)) return self.a2这是最标准的BP网络前向传播先算隐层加权和z1再通过sigmoid激活成a1接着算输出层加权和z2再激活成a2。sigmoid函数的好处是输出范围永远是(0,1)适合当概率理解。但严格来说输出层做多分类应该用softmax因为MNIST的10个类别是互斥的softmax能让10个输出之和等于1。源码如果用sigmoid也能预测靠np.argmax(a2)取最大下标只是输出值的概率含义没那么规范。如果要改成softmax写法是def softmax(z): exp_z np.exp(z - np.max(z, axis1, keepdimsTrue)) return exp_z / np.sum(exp_z, axis1, keepdimsTrue) def forward(self, x): self.z1 x self.W1 self.b1 self.a1 1.0 / (1.0 np.exp(-self.z1)) self.z2 self.a1 self.W2 self.b2 self.a2 softmax(self.z2) return self.a2z - np.max(z)这一步是数值稳定处理防止指数运算溢出不影响最终概率分布。你如果只拿这份代码交作业不改成softmax问题也不大但如果报告里写了“输出层使用softmax”代码就要同步改。3.3 反向传播四个梯度公式是怎么来的反向传播是整个BP神经网络手写数字识别项目里最核心的部分。代码里的backward方法说白了就是四件事算输出层误差、算输出层权重梯度、算隐层误差、算隐层权重梯度。def backward(self, x, y_onehot): m x.shape[0] delta2 self.a2 - y_onehot dW2 (self.a1.T delta2) / m db2 np.sum(delta2, axis0) / m delta1 (delta2 self.W2.T) * self.a1 * (1 - self.a1) dW1 (x.T delta1) / m db1 np.sum(delta1, axis0) / m self.W1 - self.lr * dW1 self.b1 - self.lr * db1 self.W2 - self.lr * dW2 self.b2 - self.lr * db2这里最容易劝退新人的就是delta2 self.a2 - y_onehot。为什么输出层梯度可以直接用预测减标签因为当输出层用softmax、损失函数用交叉熵时求导结果正好等于a2 - y_onehot不需要再乘激活函数的导数。这是softmax加交叉熵的经典简化结果不是魔法。如果你没用softmax而是用sigmoid输出加均方误差那delta2就要改成delta2 (self.a2 - y_onehot) * self.a2 * (1 - self.a2)这一行就是sigmoid的导数部分。两种写法对应两套不同的损失函数混着用梯度方向就会出错。我在debug.py里看到这种混用是常见翻车点后面避坑章会展开讲。delta1里的self.a1 * (1 - self.a1)是隐层sigmoid激活函数的导数。如果隐层换成tanh这里要改成1 - self.a1 ** 2换成ReLU就要改成np.where(self.z1 0, 1, 0)。激活函数一换反向传播里的导数必须跟着换这也是把BP网络改成其他结构时最容易踩的坑。3.4 模型保存与加载pkl文件不是黑匣子models/3_layers.pkl是训练结束后保存的参数文件。保存和加载逻辑在train_example.py和main.py里都能看到影子一般写法如下import pickle params { W1: model.W1, b1: model.b1, W2: model.W2, b2: model.b2 } with open(models/3_layers.pkl, wb) as f: pickle.dump(params, f) with open(models/3_layers.pkl, rb) as f: loaded pickle.load(f) model.W1 loaded[W1] model.b1 loaded[b1] model.W2 loaded[W2] model.b2 loaded[b2]我建议只存参数dict不要直接dump整个类实例。原因是如果你后面改了网络结构比如隐层从128改成256旧模型dump出来的对象里可能还带着旧结构信息加载时容易报维度不匹配。存参数dict就干净得多加载后手动赋给新模型就行。还有一点pickle.load出来的loaded必须先看是不是dict有些代码存的是模型对象加载后直接当模型用结构一变就崩。4. 训练与调参从train_example到hyperparameters_adjust的完整实验4.1 main.py和train_example.py一个负责演示一个负责跑实验main.py是入口脚本通常用来加载预训练好的模型再做预测演示。train_example.py则负责完整训练流程加载数据、初始化网络、多次epoch训练、保存模型。两者配合起来看整个项目的运行顺序就清楚了。# train_example.py 的核心流程简写后如下 import numpy as np from network import Network3Layer from dataset_load import load_mnist x_train, y_train, x_test, y_test load_mnist() net Network3Layer(hidden_size200, learning_rate0.1, epochs10) for epoch in range(net.epochs): net.forward(x_train) net.backward(x_train, y_train_onehot) acc net.evaluate(x_test, y_test) print(fepoch {epoch}, test accuracy: {acc:.4f})这里load_mnist()封装的正是第2章讲的npz加载逻辑Network3Layer就是第3章的类。evaluate方法一般是先forward再argmax把输出层10个值里最大的下标当作识别结果再和真实标签比对算准确率。如果你只想体验一下运行效果直接跑main.py通常就能看到模型加载和图片预测结果。如果你要做课程设计报告就必须手动跑一遍train_example.py把训练日志里的准确率变化记录下来报告里才有数据支撑。4.2 hyperparameters_adjust.py学习率、隐层节点、epoch的扫描实验hyperparameters_adjust.py是这份源码里最值得读的一个文件。它把超参数调优做成了循环扫描不是让你手动改一处跑一次而是自动试验多组参数组合最后把结果打出来。# hyperparameters_adjust.py 的伪代码逻辑 from network import Network3Layer from dataset_load import load_mnist x_train, y_train_onehot, x_test, y_test load_mnist() hidden_sizes [64, 128, 256] learning_rates [0.01, 0.1, 0.5] epochs 10 for hidden in hidden_sizes: for lr in learning_rates: net Network3Layer(hidden_sizehidden, learning_ratelr, epochsepochs) net.fit(x_train, y_train_onehot) acc net.evaluate(x_test, y_test) print(fhidden{hidden}, lr{lr}, acc{acc:.4f})这种双重循环虽然笨但非常直观。你一眼就能看出哪组参数在测试集上准确率最高。实际跑的时候hidden_sizes[64,128,256]、learning_rates[0.01,0.1,0.5]是常见的扫描范围每组组合都完整训练10轮。如果你的机器性能一般可以把hidden_sizes改成[64,128]learning_rates改成[0.1,0.3]减少一半时间。重要提醒hyperparameters_adjust.py每次执行结束应该把结果保存下来。我不止一次遇到过跑了一晚上扫描第二天发现终端日志丢了所有结果全没了的惨剧。你可以在循环里把结果追加写进一行CSV或txtwith open(hyper_results.txt, a) as f: f.write(fhidden{hidden}, lr{lr}, acc{acc:.4f}\n)这样就算后面终端关了结果也不丢。4.3 怎么判断训练收敛准确率和损失曲线要看哪个很多同学调参时只盯着最终准确率但最终准确率无法告诉你模型是欠拟合还是过拟合。我一般会同时记录训练集和测试集准确率如果训练集准确率很高、测试集明显低那就是过拟合需要减小隐层节点数或加正则化如果两边都低那更可能是学习率太大导致不收敛或者特征预处理没对齐。debug.py在这个项目里扮演的角色就是打印中间状态。你可以通过它查看每一轮的平均loss、每张测试图片的预测结果。如果loss曲线反复震荡不下降优先检查学习率。学习率0.5通常偏高0.01在BP网络中收敛偏慢但更稳。如果loss在几个epoch后突然变nan先去看输入数据有没有归一化再看权重初始化有没有做缩放。4.4 用参数表快速定位问题把训练中常见的参数现象整理成一张表跑实验时对照着看比凭感觉调参数快得多。参数常见设置范围现象判断hidden_size64~256太小欠拟合太大训练慢且可能过拟合learning_rate0.01~0.3偏大抖动偏小收敛慢epochs5~20MNIST上通常10轮左右够用归一化必须除以255不归一化loss易震荡或卡住输入反色黑白必须统一反色反了准确率接近随机猜测这张表也是我后来改代码时最常查阅的清单。如果你把learning_rate改成1.0发现准确率不是上升而是掉到0.1附近这不是玄学是梯度更新步长太大直接越过最优点。5. 避坑清单BP网络手写识别最常见的四个翻车现场5.1 现象训练准确率卡在0.1左右几乎等于瞎猜原因输入像素没有反色或者训练集是黑底白字测试图片却是白底黑字。BP网络对输入分布极其敏感它学的是“深色背景、亮色笔画”的规律你突然给一张反色的图所有特征语义反转输出自然乱套。解决对每张测试图走统一的load_and_preprocess流程设置invertTrue。如果用matplotlib画出预处理后的图像要确保背景是黑色、数字是白色。这一点在整个项目里是血泪经验10次识别失败有8次出在这里。5.2 现象loss输出为nan训练几轮后准确率不升反降原因输入没有归一化像素值0到255直接进入网络加权和过大sigmoid进入饱和区梯度反向传播时数值不稳定或者学习率设置过大权重更新步长超过合理范围参数发散。解决训练前先确认x_train x_train.astype(np.float64) / 255.0做过一遍。再看学习率BP网络手写数字识别这个场景下learning_rate0.1已经是比较激进的设置出现nan后改为0.01重跑。如果改完还不收敛检查权重初始化里有没有用* np.sqrt(1.0 / input_size)直接np.random.randn容易引爆方差。5.3 现象加载mnist.npz时提示EOFError或字段名报错原因npz文件不完整或者下载时文件损坏也有可能是源码里写死了字段名和实际文件里的key不一致。部分同学之前用torchvision下载mnist会404换成本地npz后字段访问方式没同步改过来。解决先执行np.load(dataset/mnist.npz)并打印data.files确认字段是x_train还是train_images。如果是文件损坏重新获取完整npz覆盖到dataset目录。只要numpy和文件都在这个源头问题一般半小时内能排查完。5.4 现象加载models/3_layers.pkl时提示维度不匹配或UnpicklingError原因训练模型时的隐层节点数和加载模型后Network3Layer(hidden_size...)指定的隐层节点数不一致。假设模型训练时隐层是128加载时却定义成200W1的维度是784×128新的网络结构却要求784×200赋值时直接出错。解决先读取pkl里的参数形状import pickle with open(models/3_layers.pkl, rb) as f: p pickle.load(f) print(p[W1].shape, p[W2].shape)看到W1的shape后再把网络类的hidden_size设置成对应的数字。如果你只改了隐层节点数但没重训那就要重新跑train_example.py生成新模型别指望旧参数能复用。pkl文件路径也有讲究如果运行脚本时不在项目根目录相对路径models/3_layers.pkl会找不到文件建议用os.path.dirname(__file__)拼绝对路径。5.5 现象测试自己的图片时数字写对了但识别成别的数原因图片resize到28×28时比例变形或者数字没有居中。MNIST的手写数字基本都在图片中心位置笔画粗且饱满。你拿手机拍一张数字很小的白纸数字四周全是空白直接resize后笔画占比太低网络很难判断。解决先用PIL把图片裁剪到数字区域再往四周补边到方形最后resize到28×28。补边可以保持数字的宽高比避免瘦长的“1”被压扁成“7”。这个处理在课程设计报告里写出来也是很加分的细节。我当时调了一下午才明白不是模型不行是图片预处理没做好。6. 进阶用法用test_imgs批量验证把识别结果可视化出来当你把基础流程跑通、参数也调顺之后建议再往前走一步一次性处理test_imgs里所有图片并输出每张图的预测标签和置信度。这个动作看起来简单但能帮你快速发现预处理和模型之间的一致性。import os from PIL import Image import numpy as np def predict_image(model, path): img Image.open(path).convert(L) img img.resize((28, 28), Image.Resampling.LANCZOS) arr 255.0 - np.array(img, dtypenp.float64) arr arr.reshape(1, 784) / 255.0 probs model.forward(arr) # 形状为(1, 10) pred int(np.argmax(probs[0])) confidence float(np.max(probs[0])) return pred, confidence for name in sorted(os.listdir(test_imgs)): if name.endswith(.jpg): pred, conf predict_image(model, os.path.join(test_imgs, name)) print(f{name}: predicted{pred}, confidence{conf:.2f})probs就是softmax输出的一组概率argmax取最大下标作为预测标签confidence表示模型对这个结果的把握。如果confidence常年低于0.5说明这张图要么预处理方向错了要么图片里的数字笔画太细MNIST训练集里没有见过类似风格。在此基础上你还可以写一个小函数把原图、预处理后的灰度图、预测结果拼在一起展示import matplotlib.pyplot as plt fig, axes plt.subplots(1, 3, figsize(9, 3)) axes[0].imshow(Image.open(test_imgs/5.jpg)) axes[1].imshow(np.array(Image.open(test_imgs/5.jpg).convert(L)), cmapgray) axes[2].imshow(model.forward(preprocess(test_imgs/5.jpg)).reshape(1, -1), cmapgray) plt.show()第三张图其实应该展示的是隐藏层激活或者输出分布但直接画预测概率分布也很有用如果10个输出里最大项不够突出而是好几个数字都有相对高的概率基本可以断定预处理和训练数据不一致。每次跑这种批量验证之前我都强制自己先做一遍三连检查第一输入有没有反色第二有没有除以255归一化第三模型隐层参数和pkl文件名里的结构是否匹配。自从养成这个习惯我几乎没有再因为低级错误浪费过时间。这个习惯也传导到了我后来做的所有BP网络项目里换数据集、换图片格式、换网络结构第一步永远是检查数据管线和模型参数是否对上。希望这份拆解能帮你把期末大作业跑得顺也能让你在后续改代码时少走弯路。本文还有配套的精品资源点击获取