
简介基于Python实现的手写数字识别系统完整覆盖BP神经网络与卷积神经网络两大主流方案适合毕业设计、课程实践及机器学习入门者参考学习。项目自带MNIST数据集、9个Python源码文件、训练好的10组神经网络参数及效果图可直接运行复现也能对照源码理解全连接层、卷积层、池化层、激活函数等核心模块的Numpy实现。资源共28个文件主要包含py源码、npz参数文件、png可视化图像、idx数据文件和md说明文档压缩包大小14.18MB。随包附带使用教程细致说明了BPmain.py与CNNmain.py的运行方式和结果读者可从中掌握数据加载、网络训练、参数保存与读取的完整流程并借助不同轮次的准确率对比体会调参与优化过程。目前已有1151人学习下载适合需要快速搭建手写数字识别系统或完成课设汇报的同学使用。1. 从 28×28 像素到 97% 准确率为什么手写数字识别是 Python 入门 AI 的必修课手写数字识别Handwritten Digit Recognition是几乎所有 Python 学习者接触的第一个深度学习实战项目这个标题里的毕业设计.zip 本质上就是一套完整的 MNIST 训练与评估流程。MNIST 数据集由 60000 张训练图和 10000 张测试图组成每张图是一个 28×28 像素的灰度值矩阵标签是 0–9 中的一个数字。别小看这个“玩具级”任务——LeNet-5 就是在这里诞生的现代卷积神经网络的卷积、池化、全连接三大件都能在这个任务里完整走一遍。这个项目能解决的实际问题不只是“识别数字”而是让你掌握一套可迁移的能力如何组织图像数据、如何构建与训练神经网络、如何评估模型性能、如何把训练好的模型导出并集成到应用里。对准备找工作或做毕设的人来说这套流程比单纯的背原理重要得多。本篇文章会从环境搭建开始逐步用代码实现在 MNIST 上达到 97% 以上准确率的手写数字识别模型并做到能真正运行、能可视化。需要说明的是这篇教程面向的读者是接触过 Python 基础语法、了解 numpy 和基本机器学习概念但还没系统做过深度学习项目的人。如果以上条件都满足那你接下来要做的事情就很简单准备好 Python 环境然后跟着下面的步骤逐行实现。2. 手写数字识别的任务拆解与数据准备——从原始图像到可训练的张量2.1 手写数字识别到底在解决什么问题图像分类的数学本质每次从数学本质上理解手写数字识别说不清这个问题后面调参容易失去方向。手写数字识别的本质是图像多分类问题——给定一张 28×28 的灰度图输出一个概率分布表示这张图属于 0–9 中每个类别的可能性。数学上一个输入样本 x ∈ R^(28×28)经过模型 f 得到输出 y_hat f(x; θ)训练的目标是让 y_hat 尽可能接近真实标签 y 的 one-hot 编码。如果是单通道灰度图每个像素值范围是 0–25528×28784 个像素点直接展开就是一个 784 维的向量可以作为全连接网络的输入。但为什么不用简单的全连接网络而要用卷积神经网络CNN原因很直观一张图像的数字具有平移不变性和局部相关性。数字“7”无论在图像的左上角还是中央都应该是“7”全连接网络对像素位置的绝对依赖导致同样一个数字换了个位置可能需要完全重新学习。而卷积操作通过滑动窗口提取局部特征CNN 天然对局部结构敏感再配合池化层实现一定程度的平移不变性。这是手写数字识别最好用 CNN 而非 MLP 的根本原因。2.2 环境准备与依赖安装Python 版本怎么选、PyTorch 还是 Keras动手之前先把环境装好。做手写数字识别这一类的入门项目常见选型有 TensorFlow/Keras 和 PyTorch两者都是主流。毕业设计用哪个取决于你的偏好和后续的部署需求框架学习曲线部署方便程度适用场景PyTorch稍陡更灵活需要额外转 ONNX 或 TorchScript做科研、需要自定义层Keras/TensorFlow平缓代码简洁有 TensorFlow Serving/JS快速建模、Web/移动端部署建议如果你是从零开始做毕设且没有明确部署要求优先选 PyTorch。原因是它的调试体验更好——每行代码的输出都是 Python 张量打print就能看结果适合学习理解。下面的代码全部基于 PyTorch。Python 版本建议使用 3.8 及以上因为 PyTorch 和 torchvision 对新版本 Python 的支持更完善。安装核心依赖Mac/Linux/Windows 都适用pip install torch torchvision matplotlib numpy2.3 MNIST 数据集的加载与数据预处理归一化和张量转换数据是一切模型的基础。PyTorch 的torchvision.datasets已经内置了 MNIST不需要去手动下载数据文件但预处理必须自己做。核心是两点归一化和张量化。import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 定义预处理流程转 Tensor 归一化 transform transforms.Compose([ transforms.ToTensor(), # 把 PIL Image 从 [0,255] 转成 [0,1] 的 FloatTensor transforms.Normalize((0.1307,), (0.3081,)) # MNIST 全局均值与标准差 ]) train_dataset datasets.MNIST( root./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.MNIST( root./data, trainFalse, downloadTrue, transformtransform) batch_size 128 train_loader DataLoader(datasettrain_dataset, batch_sizebatch_size, shuffleTrue, num_workers2) test_loader DataLoader(datasettest_dataset, batch_sizebatch_size, shuffleFalse, num_workers2)代码里transforms.ToTensor()会把 numpy 数组或 PIL 图像转换成 PyTorch 张量同时把像素值除以 255 缩放到 0–1。transforms.Normalize((0.1307,), (0.3081,))是 MNIST 数据集预先计算好的均值和标准差标准化后数据分布近似均值为 0、方差为 1有助于模型收敛。如果跳过这一步模型训练的震荡会更明显。提示如果你是在国内网络环境运行上面的代码downloadTrue可能需要从官方源下载数据。如果下载失败可以手动去 MNIST 官网下载四个.gz文件放到./data/MNIST/raw目录下再运行一次代码即可。3. 构建手写数字识别模型——从全连接到 CNN 的核心演进3.1 为什么朴素的 MLP 不足以胜任手写数字识别先做一个直觉对比一个两层全连接网络784 - 128 - 10理论上已经具备足够的表达能力来拟合训练集但实际效果只在 90% 左右徘徊。问题出在 MLP 对空间结构的信息丢失——把 28×28 的像素展开成一维时“8”的上下两个圆的相对位置被变成了一堆互不相邻的特征值模型很难学到“上面有个圈、下面有个圈”这种高层语义。一个简单的缓解思路是数据增强例如对图像做微小平移让模型见过更多的位置变化。但这只是治标结构的缺陷不能靠数据完全弥补。3.2 CNN 的卷积与池化如何提取图像的层级特征卷积神经网络通过三个核心操作解决上述问题卷积用一个可学习的卷积核例如 3×3在图像上滑动逐位置做点积运算。卷积核的作用是提取局部特征边缘、角度、弧线多个不同的卷积核对应多种特征提取器。激活函数ReLU整流线性单元为网络引入非线性。没有激活函数的堆叠卷积仍然是线性变换深度就没有意义。池化用 2×2 的最大池化MaxPooling把图像压缩为原来的一半保留区域内最大值。池化的效果是减少参数数量、扩大感受野让模型对微小位移更鲁棒。典型的 LeNet-5 结构就是卷积 - 池化 - 卷积 - 池化 - 全连接 - 输出。3.3 用 PyTorch 实现一个 LeNet 风格的 CNN 模型直接写一个精简版 LeNet保留核心层结构但比原始版本更适配 MNIST 的分辨率import torch.nn as nn import torch.nn.functional as F class LeNet(nn.Module): def __init__(self): super(LeNet, self).__init__() # 卷积层1通道 - 6特征图卷积核5x5 self.conv1 nn.Conv2d(in_channels1, out_channels6, kernel_size5, padding2) # 卷积层6通道 - 16特征图卷积核5x5 self.conv2 nn.Conv2d(in_channels6, out_channels16, kernel_size5) # 全连接层16*5*5400 - 120 - 84 - 10 self.fc1 nn.Linear(400, 120) self.fc2 nn.Linear(120, 84) self.fc3 nn.Linear(84, 10) def forward(self, x): # 输入形状: (batch, 1, 28, 28) x F.max_pool2d(F.relu(self.conv1(x)), kernel_size2) # 28-14 x F.max_pool2d(F.relu(self.conv2(x)), kernel_size2) # 14-5 x x.view(x.size(0), -1) # 展平成16*5*5400 x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) x self.fc3(x) # 不经过激活交给损失函数处理 return xConv2d的参数含义in_channels是输入通道数灰度图像是 1out_channels是卷积核数量也是输出特征图的通道数kernel_size是卷积核尺寸padding2是为了让第一层卷积输出保持 28×28(282*2-5)/1128。经过第一轮池化变成 14×14第二轮没有 padding 的卷积后变成 10×10再池化到 5×5。最后展平行向量进入全连接。3.4 训练流程交叉熵损失与随机梯度下降的配合模型构建完毕接下来是训练环节。这一阶段的核心配置包含三个要素损失函数、优化器和迭代轮数。交叉熵损失是分类任务的标准选择它结合了 LogSoftmax 和 NLLLoss不需要在网络最后一层额外加 Softmax。import torch.optim as optim model LeNet() # 分类任务标准损失函数 criterion nn.CrossEntropyLoss() # Adam 优化器学习率 0.001 是实际效果较好的经验值 optimizer optim.Adam(model.parameters(), lr0.001) def train_one_epoch(epoch): model.train() running_loss 0.0 for images, labels in train_loader: # 梯度清零 optimizer.zero_grad() # 前向传播 outputs model(images) loss criterion(outputs, labels) # 反向传播 参数更新 loss.backward() optimizer.step() running_loss loss.item() avg_loss running_loss / len(train_loader) print(fEpoch [{epoch1}/5], Loss: {avg_loss:.4f}) for epoch in range(5): train_one_epoch(epoch)CrossEntropyLoss的输入是未经过 Softmax 的原始 logits模型最后的输出和整数标签。optimizer.zero_grad()必须每轮都执行否则 PyTorch 会默认累积梯度导致参数更新方向错误。Adam 优化器内部维护着自适应学习率相对 SGD 来说更省心但代价是在某些数据上最终精度可能略低于调好动量的 SGD。4. 模型评估与准确率调优——用测试集验证并突破 99% 的精度4.1 模型评估的核心指标选择Accuracy vs. Loss训练完成后评估模型性能不只看 Loss 降了多少更要在测试集上计算 Accuracy准确率。MNIST 任务类别均衡准确率能直观反映模型表现。完整评估代码如下def evaluate(model, data_loader): model.eval() # 切换到推理模式影响 dropout/batchnorm 行为 correct 0 total 0 with torch.no_grad(): # 不追踪梯度省内存加快速度 for images, labels in data_loader: outputs model(images) _, predicted torch.max(outputs.data, 1) # 取每行最大值的索引 total labels.size(0) correct (predicted labels).sum().item() accuracy 100.0 * correct / total return accuracy test_acc evaluate(model, test_loader) print(fTest Accuracy: {test_acc:.2f}%)torch.max(outputs.data, 1)的含义第一个返回值是最大值本身第二个返回值是最大值所在的索引0–9也就是预测类别。model.eval()很重要这会关闭 dropout 层的随机失活让模型用完整权重进行预测。跑完 5 个 epoch 后上述结构的测试准确率大概在 98.5% 左右Loss 在 0.05 附近。这个结果已经碾压了传统机器学习方法但距离 SOTA 的 99.7% 还有优化空间。4.2 三个关键调优手段Batch Normalization、Dropout 与优化器选择想让准确率继续上扬常用的三板斧是批归一化BN、Dropout 和学习率调度。在 LeNet 上只加 BN 就能涨到 99.2%再搭配 Dropout 和 ReduceLROnPlateau 冲击 99.4%。修改后的模型结构class ImprovedNet(nn.Module): def __init__(self): super(ImprovedNet, self).__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.bn1 nn.BatchNorm2d(32) # 对 32 通道做归一化 self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.bn2 nn.BatchNorm2d(64) self.pool nn.MaxPool2d(2, 2) # 2x2 最大值池化 self.fc1 nn.Linear(64 * 7 * 7, 128) self.dropout nn.Dropout(0.5) # 50% 概率失活抑制过拟合 self.fc2 nn.Linear(128, 10) def forward(self, x): x self.pool(F.relu(self.bn1(self.conv1(x)))) # 28 - 14 x self.pool(F.relu(self.bn2(self.conv2(x)))) # 14 - 7 x x.view(x.size(0), -1) # 展平 x F.relu(self.fc1(x)) x self.dropout(x) # 只在训练时生效 x self.fc2(x) return x参数说明BatchNorm2d在通道维度上做归一化缓解内部协变量偏移加速收敛Dropout(0.5)让全连接层一半的神经元随机失活防止网络对特定节点过度依赖。注意 Dropout 在model.eval()模式下会自动关闭。优化器可以尝试从 Adam 换回 SGD Momentumoptimizer optim.SGD(model.parameters(), lr0.01, momentum0.9)SGDMomentum 在某些小数据集上收敛精度优于 Adam。配合学习率调度器当验证集 Loss 连续不降时衰减学习率scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience2) # 每个 epoch 结束时传递验证 loss scheduler.step(val_loss)ReduceLROnPlateau的意思当指标连续 2 个 epoch 没有改善时学习率乘以 0.5耐心值为 2 轮防止学习率衰减太快导致后期收敛过慢。4.3 缺陷识别什么情况下准确率高但实际不可用一个容易被忽略的问题是数据分布偏移。MNIST 是标准化的数字真实场景中的手写数字可能是蓝底白字、有背景噪声、有倾斜旋转。准确率再高如果只适应 MNIST 的分布换一套手写测试图就瞬间失效。建议在推理验证时自己手写几个数字拍照用 OpenCV 做二值化和缩放再送入模型做预测测试这才是检验模型泛化能力的试金石。5. 模型保存、加载与 OpenCV 推理——把训练结果用起来5.1 模型持久化PyTorch 的两种保存方式对比训练阶段结束后最关键的收尾工作是把模型保存下来。PyTorch 提供两种方式state_dict和整模型保存。# 推荐方式只保存模型参数官方推荐做法 torch.save(model.state_dict(), mnist_cnn.pt) # 加载 model ImprovedNet() # 必须先实例化模型结构 model.load_state_dict(torch.load(mnist_cnn.pt, map_locationcpu)) model.eval()state_dict保存的是{层名: 张量}的字典优点是安全、体积小、跨环境兼容性更好。加载时注意必须先定义模型结构再填入权重。传入map_locationcpu是为了避免在无 GPU 机器上加载时出现显存相关的报错。5.2 用 OpenCV 处理手写图片并完成推理验证训练集和测试集都是 28×28 的灰度图所以要推理自己的图片必须做一套预处理 pipeline。核心步骤是读取图像 - 灰度化 - 二值化去噪 - 缩放与居中 - 转成张量。注意边界处理不能靠假设必须把像素归一化到与训练集相同范围。import cv2 import numpy as np import torch def preprocess_image(image_path): # 读取为灰度图 img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(f无法读取图片: {image_path}) # 反转颜色MNIST 里数字是白底黑字 - 黑底白字 _, thresh cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 找轮廓并裁剪出包围盒去除多余白边 contours, _ cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) x, y, w, h cv2.boundingRect(contours[0]) digit thresh[y:yh, x:xw] # 缩放到20x20MNIST官方预处理标准再居中到28x28 resized cv2.resize(digit, (20, 20), interpolationcv2.INTER_AREA) canvas np.zeros((28, 28), dtypenp.uint8) canvas[4:24, 4:24] resized # 转成float并归一化增加batch和channel维度 tensor torch.from_numpy(canvas).float().unsqueeze(0).unsqueeze(0) tensor tensor / 255.0 # 使用同样的均值和标准差做标准化 tensor (tensor - 0.1307) / 0.3081 return tensor # 推理示例 model.eval() input_tensor preprocess_image(my_digit.jpg) with torch.no_grad(): output model(input_tensor) pred torch.argmax(output, dim1).item() print(f识别结果: {pred})THRESH_BINARY_INV会把白底黑字的图像反转为黑底白字和 MNIST 训练数据的颜色约定保持一致。THRESH_OTSU自动计算二值化阈值比固定阈值更鲁棒。cv2.findContours拿到数字的外接矩形后做裁剪、缩放和居中是为了消除平移和缩放差异——这正是 CNN 对平移鲁棒但依然依赖尺度归一化的体现。5.3 识别正确但置信度低什么时候该引入置信度阈值有时模型会把结果“猜”对一个数字但概率很平均。在实际应用中不只看argmax的结果也要检查 Softmax 后的最大值是否达到阈值。softmax torch.nn.functional.softmax(output, dim1) confidence, pred torch.max(softmax, dim1) if confidence.item() 0.8: print(低置信度建议人工确认)这样处理的好处是防止把低质量输入盲目归类尤其在需要人工复核的场景下很有用。6. 超越 99% 准确率的关键提升技巧——给毕业设计加分的进阶优化当基础版本跑通、测试集准确率稳定在 99% 以上手写数字识别的课题其实还没有结束。项目从“能用”到“有亮点”还有几个值得落地的方向。数据增强Data Augmentation是提升泛化能力的最直接手段。MNIST 的任务相对简单不用做太强的增广但随机小幅度的旋转和平移能有效增强对偏移的容忍度train_transform transforms.Compose([ transforms.RandomRotation(degrees10), # 最多旋转 ±10 度 transforms.RandomAffine(degrees0, translate(0.1, 0.1)), # 最多平移 10% 像素 transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])RandomAffine的translate接受两个值分别表示水平和垂直平移的比例0.1 表示平移不超过原始宽高的 10%。注意训练时增广、测试时不增广这是标准流程。模型集成Ensemble是压箱底技巧。训练 3 个结构相同但随机种子不同的模型推理时对三个模型的 Softmax 输出取平均通常能再提升 0.1%–0.2% 的准确率。代价是推理时间变为原来的三倍在 MNIST 这种小图上可以接受。可视化分析Visualization是答辩或文档评审中的加分利器。用matplotlib画出模型在测试集上的混淆矩阵并挑出几个预测失败的样本分析是旋转过大、笔画断裂还是和另一个数字形似。这种分析体现的不是“我把模型跑通了”而是“我理解模型错在哪里”。在这个项目上一般做到 99.2% 的准确率就属于较好的完成状态。往上的提升依赖更深的网络结构、数据增强策略和训练技巧的综合配合。最后当你的模型对 MNIST 测试集拿到理想的指标后记得用章节 5.2 里的预处理流程去测几张自己手写的数字——模型只有在真实输入上同样稳定时这个毕业设计才算真正交付了一个可用的识别系统。本文还有配套的精品资源点击获取