简介:这份资源面向计算机相关专业的毕业设计学生及Python深度学习入门者,提供一套基于Numpy从零实现的手写数字识别系统完整源码与使用教程,帮助读者理解神经网络底层原理并完成可运行的毕设项目。压缩包共28个文件,约14.18MB,以9个py源码文件为核心,涵盖激活函数、全连接层、卷积层、池化层、网络结构接口定义及参数保存加载等模块;另含10个npz训练参数文件、4张png结果图、2组idx格式MNIST数据集及说明文档,目录按data、parameters、figure分层组织。项目同时提供BP神经网络与卷积神经网络两套测试入口,训练日志与各轮次正确率参数均有留存,最高正确率约96.98%,便于对照实验效果。目前已有1152人学习下载,适合需要完整代码框架、可复现训练流程与排错参考的读者。
1. 从一份毕业设计压缩包说起:手写数字识别到底难在哪
很多人拿到「基于Python实现的手写数字识别系统源码+使用教程(毕业设计).zip」这类资源,第一反应是解压、装依赖、跑python main.py,然后看到准确率 98% 就以为万事大吉。但真正动手做过 MNIST 手写数字识别的人都知道,这个任务的门槛不在模型有多深,而在工程细节有多碎。MNIST 本身只有 7 万张 28×28 的灰度图,用 CNN 跑几轮就能到 99% 以上,可一旦你要把它做成一个能演示、能答辩、能换自己手写图片测试的完整系统,问题就来了:环境怎么配、数据怎么读、模型怎么存、界面怎么接、自己写的数字为什么识别不出来。
这篇笔记面向三类人:正在做计算机毕业设计、需要一套能跑通且能讲清楚的手写数字识别系统的同学;刚学完 CNN 基础、想找一个完整项目练手的 Python 入门者;以及需要快速搭一个图像分类 demo 的工程师。我会按「环境搭建 → 数据与模型 → 训练与评估 → 系统集成 → 避坑 → 进阶」的顺序,把一份典型的手写数字识别源码拆开讲透,让你不只是跑通,而是知道每一步为什么这么做、参数怎么调、翻车了去哪找原因。
2. 环境搭建与依赖安装:把 numpy、CNN 框架和编辑器一次配好
2.1 为什么推荐 Anaconda + PyTorch 而不是裸 pip
手写数字识别系统的依赖链其实不短:numpy 做数组运算、matplotlib 做可视化、PyTorch 或 TensorFlow 做 CNN 训练、Pillow 做图片读取、有时还要 Flask 或 Tkinter 做界面。裸 pip 装最大的问题是 numpy 版本不匹配——很多毕业设计源码写的时候用的是 numpy 1.x,你本地默认装了 2.x,一跑就报np.float已移除或者numpy三维数组相乘维度对不上。Anaconda 的好处是能把整个环境隔离在一个 conda env 里,numpy、scipy、matplotlib 的版本由 conda 统一解析,冲突概率低很多。
我一般会这么做:先建一个专用环境,Python 版本选 3.9 或 3.10,这两个版本对 PyTorch 和 TensorFlow 的兼容性最稳。不要用 3.12,部分老源码里的np.int之类写法会直接报错。
# 创建名为 mnist 的虚拟环境,指定 Python 3.10 conda create -n mnist python=3.10 -y # 激活环境 conda activate mnist # 安装核心依赖,numpy 锁在 1.26 以下避免 API 移除问题 pip install "numpy<2.0" matplotlib pillow # 安装 PyTorch(CPU 版即可,手写数字识别不需要 GPU) pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu这段命令的逻辑是:conda 负责环境隔离和 Python 版本,pip 负责装深度学习框架。numpy<2.0这个约束很关键,因为 numpy 2.0 移除了np.float、np.int等别名,而大量毕业设计源码还在用这些写法。PyTorch 用 CPU 版就够,MNIST 训练一轮在普通笔记本上也就十几秒,没必要折腾 CUDA。
参数说明:-n mnist是环境名,可以改成你喜欢的;python=3.10是版本约束;--index-url指定 PyTorch 官方 CPU 轮子源,避免默认源下载慢或版本不对。如果你用的是 TensorFlow,把最后一行换成pip install tensorflow-cpu即可,但要注意 TensorFlow 2.15 之后对 numpy 版本也有要求,建议同样锁 numpy。
提示:装完以后跑一句
python -c "import numpy, torch; print(numpy.__version__, torch.__version__)",确认两个库都能正常导入再往下走。这一步能挡掉后面 80% 的「ImportError」。
2.2 VSCode Python 环境配置与解释器选择
编辑器我推荐 VSCode,轻量且对 Python 支持好。装好 VSCode 后需要做三件事:装 Python 扩展、选对解释器、配好调试配置。很多人代码跑不起来不是代码问题,而是 VSCode 默认用了系统 Python 而不是 conda 环境里的 Python。
操作步骤:按Ctrl+Shift+P打开命令面板,输入Python: Select Interpreter,选择路径里带envs/mnist的那个解释器。然后在项目根目录建一个.vscode/launch.json,内容如下:
{ "version": "0.2.0", "configurations": [ { "name": "Python: 当前文件", "type": "python", "request": "launch", "program": "${file}", "console": "integratedTerminal", "justMyCode": true } ] }这个配置的作用是让 F5 调试时用集成终端运行当前文件,justMyCode设为 true 可以避免调试时跳进库源码。如果你要调试训练脚本,把program改成${workspaceFolder}/train.py这种固定入口更稳。
常见的一个坑是:VSCode 终端里conda activate不生效。这是因为默认终端是 PowerShell 且没初始化 conda。解决办法是在设置里把默认终端改成 Command Prompt,或者执行conda init powershell后重启 VSCode。环境配好之后,后面所有代码都在这个环境里跑,不要再混用系统 Python。
3. 数据加载与 CNN 模型:从 MNIST 到可保存的权重文件
3.1 MNIST 数据集的读取、归一化与 DataLoader 参数
MNIST 手写数字识别的数据加载看起来简单,但有几个参数直接决定训练能不能收敛。原始图片是 28×28 的灰度图,像素值 0 到 255。直接喂给网络不是不行,但收敛慢且容易梯度爆炸。标准做法是做归一化,把像素值映射到 0 到 1 或者标准化到均值 0.1307、标准差 0.3081——这两个数是 MNIST 训练集的全局统计量,用它们做标准化能让输入分布更接近标准正态,训练更稳。
import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader # 定义预处理:转张量 + 标准化 transform = transforms.Compose([ transforms.ToTensor(), # 把 PIL 图片转成 [0,1] 的张量,形状 (1,28,28) transforms.Normalize((0.1307,), (0.3081,)) # MNIST 全局均值和标准差 ]) # 下载并加载训练集和测试集 train_set = datasets.MNIST(root='./data', train=True, download=True, transform=transform) test_set = datasets.MNIST(root='./data', train=False, download=True, transform=transform) # DataLoader:batch_size 和 shuffle 是关键参数 train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=0) test_loader = DataLoader(test_set, batch_size=1000, shuffle=False, num_workers=0)逻辑说明:ToTensor()会自动把像素除以 255,所以 Normalize 里的均值 0.1307 是针对已经除以 255 后的数据。batch_size=64是手写数字识别的常用值,太小训练慢,太大显存吃紧且泛化略差。shuffle=True只在训练集开,测试集必须关掉,否则评估结果不可复现。num_workers在 Windows 上建议设 0,设大了容易报多进程相关的错。
参数怎么改:如果你数据量小、想快速看效果,batch_size 可以调到 128;如果发现 loss 震荡厉害,降到 32。标准化那一步如果你用的是自己手写的数字图片做测试,也要用同样的均值和标准差处理,否则训练和推理的输入分布不一致,识别率会断崖式下跌。
3.2 一个够用的 CNN 结构:两层卷积加全连接
手写数字识别不需要 ResNet 那种深度,两层卷积加两层全连接就能到 99%。结构设计的关键是卷积核数量、池化方式和 Dropout 的位置。下面是一个我常用的结构,参数量小、训练快、容易解释,答辩时也好讲。
import torch.nn as nn import torch.nn.functional as F class Net(nn.Module): def __init__(self): super(Net, self).__init__() # 第一层卷积:1 通道输入,32 个 3x3 卷积核 self.conv1 = nn.Conv2d(1, 32, 3, padding=1) # 第二层卷积:32 通道输入,64 个 3x3 卷积核 self.conv2 = nn.Conv2d(32, 64, 3, padding=1) # 池化层:2x2 最大池化 self.pool = nn.MaxPool2d(2, 2) # Dropout:随机丢弃 25% 神经元,防过拟合 self.dropout1 = nn.Dropout2d(0.25) self.dropout2 = nn.Dropout(0.5) # 全连接:经过两次池化后是 7x7,64 通道 self.fc1 = nn.Linear(64 * 7 * 7, 128) self.fc2 = nn.Linear(128, 10) # 10 个数字类别 def forward(self, x): x = self.pool(F.relu(self.conv1(x))) # -> (32,14,14) x = self.pool(F.relu(self.conv2(x))) # -> (64,7,7) x = self.dropout1(x) x = torch.flatten(x, 1) # 展平成 (batch, 64*7*7) x = F.relu(self.fc1(x)) x = self.dropout2(x) x = self.fc2(x) return F.log_softmax(x, dim=1) # 配合 NLLLoss 使用逻辑说明:padding=1保证卷积后尺寸不变,这样两次池化后正好从 28 降到 7。Dropout2d用在卷积层后,按通道丢弃;普通Dropout用在全连接层后。最后用log_softmax是因为训练损失用NLLLoss,如果你习惯用CrossEntropyLoss,就把最后一行去掉,因为 CrossEntropyLoss 内部已经包含 softmax。
参数说明:卷积核数量 32 和 64 是经验值,翻倍到 64 和 128 准确率提升很小但训练时间翻倍。fc1的 128 是隐藏层维度,调到 256 也可以,但要注意过拟合。Dropout 的 0.25 和 0.5 是经典配置,如果发现训练集准确率远高于测试集,可以把 0.5 提到 0.6。
3.3 训练循环、模型保存与加载的完整写法
训练循环里最容易被忽略的是model.train()和model.eval()的切换,以及保存模型时到底存什么。只存state_dict是最推荐的做法,体积小、加载灵活。
import torch.optim as optim device = torch.device("cpu") # 有 GPU 可改 "cuda" model = Net().to(device) optimizer = optim.Adam(model.parameters(), lr=0.001) def train(epoch): model.train() # 开启训练模式,Dropout 生效 for batch_idx, (data, target) in enumerate(train_loader): data, target = data.to(device), target.to(device) optimizer.zero_grad() # 清空上一轮梯度 output = model(data) loss = F.nll_loss(output, target) loss.backward() # 反向传播 optimizer.step() # 更新参数 if batch_idx % 100 == 0: print(f'训练轮次 {epoch} 批次 {batch_idx} 损失 {loss.item():.4f}') def test(): model.eval() # 关闭 Dropout,固定 BatchNorm correct = 0 with torch.no_grad(): # 推理不需要梯度,省内存 for data, target in test_loader: data, target = data.to(device), target.to(device) output = model(data) pred = output.argmax(dim=1) correct += pred.eq(target).sum().item() acc = correct / len(test_loader.dataset) print(f'测试集准确率 {acc:.4f}') return acc for epoch in range(1, 6): train(epoch) test() # 只保存参数字典,不保存整个模型对象 torch.save(model.state_dict(), 'mnist_cnn.pt')逻辑说明:optimizer.zero_grad()必须放在反向传播前,否则梯度会累加。torch.no_grad()在测试时能显著降低内存占用。保存state_dict而不是整个模型,是因为整个模型对象依赖类定义,换环境加载容易出问题。
加载时这样写:
model = Net() model.load_state_dict(torch.load('mnist_cnn.pt', map_location='cpu')) model.eval()参数说明:学习率 0.001 配 Adam 是稳妥选择,如果 loss 不下降可以试 0.0005。训练 5 轮通常能到 99% 左右,轮次太多会过拟合。map_location='cpu'保证在没 GPU 的机器上也能加载。
4. 系统集成与推理:把模型接上界面和真实手写图片
4.1 用 Flask 搭一个最小可用的识别接口
毕业设计通常要求有界面演示,最省事的是 Flask 起一个网页,上传图片返回识别结果。核心是把上传的图片转成模型能吃的张量,注意预处理必须和训练时一致。
from flask import Flask, request, jsonify from PIL import Image import torch import io app = Flask(__name__) model = Net() model.load_state_dict(torch.load('mnist_cnn.pt', map_location='cpu')) model.eval() @app.route('/predict', methods=['POST']) def predict(): file = request.files['image'] img = Image.open(io.BytesIO(file.read())).convert('L') # 转灰度 img = img.resize((28, 28)) # 缩放到 28x28 tensor = transforms.ToTensor()(img) # 转张量 tensor = transforms.Normalize((0.1307,), (0.3081,))(tensor) tensor = tensor.unsqueeze(0) # 加 batch 维度 with torch.no_grad(): output = model(tensor) pred = output.argmax(dim=1).item() return jsonify({'digit': pred}) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)逻辑说明:convert('L')把彩色图转灰度,resize统一尺寸,unsqueeze(0)补上 batch 维度因为模型期望输入是(N,1,28,28)。预处理顺序必须和训练时完全一致,否则识别率会崩。
参数说明:host='0.0.0.0'让局域网内其他设备也能访问,答辩时可以用手机拍照上传演示。端口 5000 如果被占用改成 5001。
4.2 自己手写的数字为什么识别不出来
这是最高频的翻车点。MNIST 的图片是白字黑底、居中、笔画粗细固定,而你自己用画图工具写的是黑字白底、可能偏左偏上、笔画粗细随意。直接丢进去识别率极低。解决办法是做反色和居中裁剪。
import numpy as np from PIL import Image, ImageOps def preprocess_handwritten(path): img = Image.open(path).convert('L') img = ImageOps.invert(img) # 黑字白底 -> 白字黑底 img = img.resize((28, 28)) arr = np.array(img) # 按像素重心居中:先找非零区域 ys, xs = np.nonzero(arr) if len(xs) > 0: cx, cy = int(xs.mean()), int(ys.mean()) arr = np.roll(arr, 14 - cx, axis=1) arr = np.roll(arr, 14 - cy, axis=0) img = Image.fromarray(arr) tensor = transforms.ToTensor()(img) tensor = transforms.Normalize((0.1307,), (0.3081,))(tensor) return tensor.unsqueeze(0)逻辑说明:ImageOps.invert做反色,np.nonzero找笔画像素,np.roll把重心平移到图像中心。这一步做完,自己手写的数字识别率能从 30% 提到 80% 以上。
参数说明:14 - cx里的 14 是 28 的一半,目标是让重心落在中心。如果你的图片笔画特别粗,可以在 resize 前做一次二值化,把灰度值大于 128 的置 255,其余置 0。
5. 避坑与排查:手写数字识别系统最常见的 5 个翻车现场
5.1 现象:训练 loss 一直是 2.3 不下降
原因:log_softmax和CrossEntropyLoss混用了,等于做了两次 softmax,梯度被压平。或者学习率设得太大,直接跳过最优解。
解决:检查损失函数和模型最后一层是否匹配。用NLLLoss就保留log_softmax,用CrossEntropyLoss就去掉。学习率从 0.001 开始试,不降就减半。
5.2 现象:测试准确率 99% 但自己上传图片全错
原因:训练数据是白字黑底且居中,你的输入是黑字白底且偏移,分布不一致。
解决:按 4.2 节做反色和重心居中。另外确认上传图片确实是灰度单通道,彩色图要先转L模式。
5.3 现象:加载模型时报Missing key(s) in state_dict
原因:保存时用了torch.save(model)存整个对象,加载时类定义变了;或者保存的是 DataParallel 包装后的模型,key 前面多了module.。
解决:统一用state_dict保存和加载。如果 key 有module.前缀,加载时用model.load_state_dict({k.replace('module.',''):v for k,v in sd.items()})去掉。
5.4 现象:numpy 报module 'numpy' has no attribute 'float'
原因:numpy 2.0 移除了np.float、np.int等别名,老源码里还在用。
解决:降级到pip install "numpy<2.0",或者全局搜索把np.float改成float、np.int改成int。这是毕业设计源码在新环境跑不起来的第一大原因。
5.5 现象:DataLoader 在 Windows 上报多进程错误
原因:num_workers大于 0 时 Windows 的 spawn 机制和某些库不兼容。
解决:把num_workers设为 0,或者把训练代码放在if __name__ == '__main__':保护块里。前者最省事,MNIST 数据量小,单进程加载完全够快。
6. 进阶技巧:用混淆矩阵和置信度把系统讲出深度
答辩时如果只报一个准确率,老师很容易问「哪些数字容易混」。这时候混淆矩阵就是你的后悔药。MNIST 里最容易混的是 4 和 9、3 和 5、7 和 1,把这些可视化出来,再配合置信度输出,整个系统的完成度会高一个档次。
from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt model.eval() all_preds, all_targets = [], [] with torch.no_grad(): for data, target in test_loader: output = model(data) pred = output.argmax(dim=1) all_preds.extend(pred.numpy()) all_targets.extend(target.numpy()) cm = confusion_matrix(all_targets, all_preds) plt.figure(figsize=(8, 6)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues') plt.xlabel('预测标签') plt.ylabel('真实标签') plt.title('MNIST 混淆矩阵') plt.savefig('confusion_matrix.png', dpi=150)逻辑说明:confusion_matrix的行是真实标签、列是预测标签,对角线是正确分类。annot=True把数字标在格子里,fmt='d'保证显示整数。跑完这张图,你能直接指出模型在哪些类别上弱。
置信度输出也很简单,把log_softmax的输出取指数就是概率:
with torch.no_grad(): output = model(tensor) prob = torch.exp(output) # log_softmax -> 概率 conf, pred = prob.max(dim=1) print(f'预测 {pred.item()},置信度 {conf.item():.2%}')参数说明:置信度低于 60% 的样本可以单独挑出来人工检查,这在实际系统里就是「不确定就转人工」的雏形。如果某个数字的置信度普遍偏低,说明训练集里这类样本太少,可以针对性做数据增强,比如轻微旋转和缩放。
我自己做这类系统最大的教训是:不要一上来就堆模型深度,先把数据预处理和评估做扎实。一个两层 CNN 加正确的归一化,比一个五层网络加错误的输入处理要强得多。每次改完预处理,先跑一遍测试集看准确率有没有掉,再去看单张图片的预测,这个习惯帮我省了无数返工时间。希望帮到你。
本文还有配套的精品资源,点击获取