简介:这是一套基于Python与MNIST数据集实现的CNN手写数字识别项目,面向高校毕业设计、课程设计与开发者项目实践。项目基于TensorFlow搭建卷积神经网络模型,通过优化器训练并保存模型变量,识别阶段复用已保存权重;同时借助OpenCV完成灰度化、二值化、降噪与裁剪等图像预处理,最终封装为ImgProcess库,只需传入图片路径即可调用API完成数字识别,整体流程清晰、易扩展。压缩包共68个文件,约105.3MB,以jpg样本图片、py源码、md项目文档为主,另含模型检查点(meta、data、checkpoint、index)与txt说明等,结构完整,便于按需查阅和二次开发。目前已有129人浏览/学习该项目。资源包含严格测试过的源码、项目文档和使用教程,无论是用于课题参考还是功能延申,都能帮助快速跑通并理解从数据预处理、模型构建到训练识别的全链路实现。
1. 第一个手写数字识别项目:为什么我劝你先别急着自己发明轮子
对于一个想用 Python 入门深度学习的开发者,MNIST 手写数字识别几乎是最好的第一站:它只有 7 万张 28×28 的灰度小图,一张普通笔记本显卡就能在几十分钟内跑完一个完整的 CNN 卷积神经网络训练周期。很多人把这个项目当毕业设计或课程设计来做,但真正有价值的不是那份源码本身,而是你亲手把数据加载、模型搭建、训练评估这条链路跑通后获得的排错能力。这篇文章按我自己的落地顺序走:先讲数据预处理,再搭模型,然后训练调参,最后把模型做成一个能画数字的小 Demo,每个步骤都给出可以直接抄走的代码和参数。
2. 训练之前的功课:环境、数据加载与预处理
2.1 选 PyTorch 还是 TensorFlow:课程设计里我一般这么定
先说框架。这个题目只限定 Python + CNN,没限定框架,但实际做毕业设计和课程设计时,选错框架会让你多花一倍时间。我的默认选择是 PyTorch,原因是动态图机制让报错信息更直观,你在 forward 里 print 一个张量的 shape 就能立刻定位问题;而 TensorFlow 的 Keras 接口虽然写起来更短,但一旦涉及自定义训练循环和调试,黑匣子感会很强。
另外一个现实因素是 torchvision 提供了 MNIST 数据集的一键下载,配合 DataLoader 几乎不用手写数据读取逻辑,这对新手非常友好。如果你拿到的项目源码是 TensorFlow 1.x 写的,那又是另一套故事——老代码里tf.placeholder、Session.run那套写法现在跑起来满是坑,我一般会建议直接按 Pytorch 重写模型,而不是去兼容旧版本。这不是说 TensorFlow 不好,而是从「拿到源码 → 成功跑通 → 改成自己的项目」这条链路看,PyTorch 的试错成本最低。
环境方面,深度学习课程设计多数不需要 GPU,CPU 跑 MNIST 也就是一次训练几分钟的事。我通常建议装 Python 3.9+、PyTorch 2.x,再加 torchvision 和 matplotlib。安装命令就一条,但要注意 PyTorch 的默认源在国外,国内网络环境下手动配置国内镜像源能省去很多超时重试的时间。
pip install torch torchvision matplotlib pillow -i https://pypi.tuna.tsinghua.edu.cn/simple这段命令里-i指定了清华 PyPI 镜像,属于国内从业者最常见的做法,核心目的是把下载源换成国内可达的镜像,正常安装 PyTorch CPU 版约 200MB 左右,网速正常时几分钟能完成。如果你使用的是 Anaconda,也可以用conda install pytorch cpuonly -c pytorch,但 conda 依赖解析较慢,我一般两条都不排斥,哪个环境先跑通用哪个。
2.2 torchvision 加载 MNIST:先解决下载问题
torchvision 里加载 MNIST 的代码极短,但很多人的第一个坑就出在这里:.download=True时程序卡在下载环节,甚至报 404 错误——torchvision 下载 MNIST 会 404 是最近一年高频出现的热搜词,原因是 MNIST 在 Yann LeCun 官网的原始文件地址变动频繁,老版本 torchvision 的硬编码链接会失效。
所以我的习惯是:第一次先把数据文件手动准备好,之后再用download=False加载。你需要准备的其实是四个.gz压缩包:训练集图像、训练集标签、测试集图像、测试集标签,放到data/MNIST/raw目录下。torchvision 会自动识别这个目录结构,不必自己写解压代码。
from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST( root='./data', train=True, transform=transform, download=False ) test_dataset = datasets.MNIST( root='./data', train=False, transform=transform, download=False )这里root是数据根目录,train=True表示加载 6 万张训练图,train=False加载 1 万张测试图。transform里的ToTensor()会把 PIL 图像或者 numpy 数组从 H×W×C 变成 C×H×W 的张量,并将像素值从 0~255 缩放到 0~1;Normalize((0.1307,), (0.3081,))是 MNIST 数据集的全局均值和标准差,归一化后数据分布接近标准正态分布,能明显加速收敛。这两个变换的顺序不能反,先转张量再归一化,否则维度不匹配会直接报错。
如果手动下载不方便,还有一个几乎零成本的临时办法:换用datasets.MNIST的新参数download=True配合新版本 torchvision,新版本已经将下载源切到了内部镜像。但我依然建议你把下载好的文件备份一份,因为后续每次重新创建环境都要重新下,这个数据集的经典程度决定了它值得被你本地留存。
2.3 数据增强要不要做:MNIST 的特殊性
CV 项目里数据增强几乎是标配,但 MNIST 是个罕见的反例。像随机裁剪、水平翻转这类增强用在 CIFAR-10 上效果不错,用在 MNIST 上反而有害——原因很直接:把数字 6 水平翻转后看起来像 9,把 7 翻转后完全不伦不类,标签却没变,等于强行给模型喂错误样本。
MNIST 本身已经是 28×28 的小图,而人的手写数字除了位置偏移和粗细变化外,没有太多可增强的空间。我见过有人对 MNIST 做随机旋转 15 度,结果测试集精度从 99% 掉到 97%。如果确实想提升泛化能力,更推荐做的是随机仿射变换里的轻微平移,把图像平移 1~2 个像素,这也符合手写数字的真实分布——大多数人写字并不会严格居中。但课程设计阶段,我建议不做任何数据增强,先把基准模型跑明白,再谈优化。这也是为什么这一节的结论能帮你避开一个常见的「越努力越翻车」操作。
数据加载的最后一个细节是DataLoader的shuffle参数。训练集必须设置shuffle=True,否则模型每个 epoch 看到的样本顺序完全一致,会学到样本间的排列顺序而不是数字本身;测试集不需要 shuffle,因为评估时不需要随机性。batch_size我一般取 64,这个值对 MNIST 来说在训练速度和收敛稳定性之间比较平衡;取 128 也可以,但梯度会更平滑,收敛稍微慢一点;取 32 会让梯度噪声偏大,新手调参时容易误判。
3. 搭建 CNN 模型:从 LeNet-5 到能拿出手的改进版
3.1 为什么是 CNN:计算一下全连接层的参数量
在动手敲代码前,我先算一笔账,这笔账算完你就能理解这个项目为什么几乎必然用 CNN 而不是全连接网络。MNIST 每张图是 28×28 灰度图,摊平就是 784 个像素。如果第一层就直接用全连接,输入维度是 784,假设隐藏层是 256 个神经元,那这一层的权重参数就是 784×256+256,约 20 万个。再加一层 256 就是个 6 万参数的层,两层全连接下来参数轻松破 50 万。在 7 万张训练图上,这个模型能训,但参数量远大于数据量,过拟合几乎是必然的。
CNN 解决这个问题的核心是局部连接和权值共享。一个 5×5 的卷积核只有 25 个权重,但它在整张图上滑动时把这 25 个权重用在了所有位置上。MNIST 属于 LeNet-5 诞生的年代就是这个原因——手写数字的特征是局部的,数字的弧线、拐角、端点都只跟邻近像素有关系,不需要看整张图才能判断。这跟人脸识别不一样,人脸需要把握五官之间的全局关系,所以后来有了更深更宽的网络。
池化层的作用则是下采样。2×2 最大池化把特征图边长缩小一半,同时保留每个小区域内最显著的响应。它的好处有两个:一是参数和计算量直接降到四分之一,二是让特征对轻微的位置偏移更不敏感——手写数字笔画本来就有抖动,池化恰好把这些抖动吸收掉。
3.2 LeNet-5 的结构拆解:每一层尺寸变化都得心里有数
LeNet-5 是最经典的 MNIST CNN 结构,1998 年由 Yann LeCun 提出,到现在依然是这个项目最稳妥的起点。它的原始版本用的是 tanh 激活,我们现在一般换成 ReLU,效果更好。一个现代化的 LeNet-5 长这样:输入是 1×28×28,经过第一个 5×5 卷积得到 6 个通道,此时输出尺寸为 6×24×24;接着 ReLU 和 2×2 最大池化,变成 6×12×12;再经过第二个 5×5 卷积得到 16×8×8;再池化变成 16×4×4;最后把特征图展平成一维,接三层全连接,输出维度是 10,对应 0~9 十个类别。
每一层尺寸变化的规律很简单:卷积后尺寸依赖 padding 和 kernel size,池化后边长减半。MNIST 输入特别小,所以很多人在这个项目里不给卷积加 padding,让尺寸自然缩小;如果加了 padding=2,第一层卷积后还是 28×28,第二层卷积后是 14×14,参数量不变但特征图更大,理论上信息保留更多,实际训练效果差别不大。课程设计里我建议不加 padding,让结构更紧凑,也更容易在答辩时讲清楚尺寸变化计算。
第二个卷积层输出 16 个通道、4×4 的特征图,展平后是 16×4×4=256 个值,这正好和第一个全连接层的输入维度对得上。这个 256 是怎么来的,是答辩时老师最常问的问题,建议你在源码注释里写清楚,免得现场推导卡壳。
3.3 代码实现:一个能跑通并能到 99% 以上的 CNN
下面这个模型是 LeNet-5 的 PyTorch 实现,我加了一点现代改进:激活函数用 ReLU,最后一个全连接层前加了 Dropout。这段代码可以直接复制到项目里当成核心模型文件。
import torch.nn as nn class CNNModel(nn.Module): def __init__(self): super(CNNModel, self).__init__() # 卷积部分:提取局部特征 self.conv1 = nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5) self.relu1 = nn.ReLU() self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2) self.conv2 = nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5) self.relu2 = nn.ReLU() self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2) # 全连接部分:把特征映射到10个类别 self.fc1 = nn.Linear(in_features=16 * 4 * 4, out_features=120) self.relu3 = nn.ReLU() self.dropout = nn.Dropout(p=0.5) self.fc2 = nn.Linear(in_features=120, out_features=84) self.relu4 = nn.ReLU() self.fc3 = nn.Linear(in_features=84, out_features=10) def forward(self, x): x = self.pool1(self.relu1(self.conv1(x))) x = self.pool2(self.relu2(self.conv2(x))) x = x.view(x.size(0), -1) # 展平,变成 (batch_size, 256) x = self.relu3(self.fc1(x)) x = self.dropout(x) x = self.relu4(self.fc2(x)) x = self.fc3(x) return xview(x.size(0), -1)是把每个样本的特征图展平成向量,-1表示自动推导该维度大小,这里自动算出 256。Dropout(p=0.5)只在训练时生效,模型状态切到 eval() 后 Dropout 层会自动关闭,所以评估时不用担心它干扰结果。前向传播的维度流转是:1×28×28 → 6×24×24 → 6×12×12 → 16×8×8 → 16×4×4 → 256 → 120 → 84 → 10。
这个模型的参数量大约 4.4 万个,和全连接网络动辄几十万相比少了一个数量级,但 MNIST 测试精度可以到 99% 左右。其原因就是 CNN 的归纳偏置跟手写数字这种局部特征密集的任务高度契合。如果你嫌这个结构太老,想体现一点工作量,常见的改进是把第一个卷积层输出通道从 6 改成 32、第二个从 16 改成 64,配合 padding 保持尺寸,参数量会增加但精度提升有限,反而更容易过拟合。
3.4 激活函数和 Dropout 的小建议
ReLU 是这个项目里毫无争议的首选。Sigmoid 在深层网络里容易梯度消失,MNIST 的网络虽然只有几层,但用 Sigmoid 的收敛速度就是肉眼可见地比 ReLU 慢。你在答辩时可以提一句:ReLU 对负值直接截断为零,梯度在正区间恒为 1,缓解了深层网络反向传播时的梯度衰减。
Dropout 我一般只加在第一层全连接后面,并且概率设为 0.5。卷积层的特征图本身已经通过池化做了降维,再在卷积层加 Dropout 对 MNIST 这种小模型帮助不大。加在最后的全连接层则是性价比最高的位置,因为这个位置的参数量占整个模型的比重最大,最容易过拟合。另外有个容易被忽略的细节:模型定义里的 Dropout 在训练和评估时行为不同,这由model.train()和model.eval()控制,如果忘了切状态,训练完的模型在测试时精度会偏低,这是新手最容易踩的隐藏坑。
4. 训练主循环:损失、优化器与指标怎么看
4.1 训练代码:epoch、batch、loss 的完整流程
模型定义完,下一步就是训练。训练这个环节看起来就十几行代码,但里边的细节决定了你能不能稳定收敛。我给出最朴素也最可靠的一版训练循环,没有提前停止,没有学习率调度,先把链路跑通。
import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = CNNModel().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) epochs = 10 for epoch in range(epochs): model.train() total_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() avg_loss = total_loss / len(train_loader) print(f"Epoch {epoch+1}/{epochs}, Loss: {avg_loss:.4f}")这里的几个关键操作我逐个说明。optimizer.zero_grad()必须放在每次反向传播之前,因为 PyTorch 的梯度默认会累积,不清零的话上一批数据的梯度会加到本批上,loss 曲线会异常震荡。criterion用的是CrossEntropyLoss,它把 Softmax 和交叉熵合在了一步,所以你模型最后一层输出的原始 logits 直接传给 loss 即可,不需要自己在模型里加 Softmax。loss.backward()计算梯度,optimizer.step()更新参数,顺序不可调换。
images.to(device)把数据搬到 GPU 或 CPU;MNIST 训练集有 6 万张图,batch_size=64 时一个 epoch 大约 938 个 batch,10 个 epoch 就是 9380 次参数更新。CPU 跑这 10 个 epoch 大约需要 5~10 分钟,GPU 上是 1 分钟内的事。如果你用 CPU 发现每轮要几分钟,是正常现象。
4.2 优化器和学习率:Adam 与 SGD 的取舍
Adam 是这个项目的默认优化器,学习率 0.001 几乎不需要调。Adam 的优点是自适应学习率,对初始学习率不敏感,哪怕你设成 0.01 它也能慢慢收敛,只是后期可能会在最优解附近震荡。SGD 需要手动调学习率、动量,收敛更慢,但最终精度往往比 Adam 略高一点——在 MNIST 上这个差距约 0.1%,完全感知不到。
如果你想让训练过程更像「标准流程」,可以在答辩时展示一份参数对照表,说明你调过的几组参数。我自己平时会记录一张小表,既方便自己复现,也让项目文档更扎实:
| 参数 | 推荐值 | 备注 |
|---|---|---|
| epochs | 10 | 从第 8 轮起 loss 几乎不降 |
| batch_size | 64 | 32 偏慢,128 略影响收敛 |
| optimizer | Adam | 默认 betas 即可 |
| lr | 0.001 | 调大至 0.01 会发散 |
| dropout | 0.5 | 只加在第一个全连接层后 |
Learning rate 是这个项目最典型的「玄学」参数,我见过不少人把 lr 设成 0.1,然后眼睁睁看着 loss 跳到 NaN。一个实用的检查方法是:第一个 epoch 结束后,如果 loss 比随机初始化时的 2.3 还高,先怀疑 lr 过大。交叉熵损失在 10 分类下随机猜测时约等于 ln(10)=2.302,loss 明显高于这个数说明模型根本没在学,大概率是学习率或者数据归一化出了问题。
4.3 测试集和验证集:别让精度数字骗了你
MNIST 官方划分是 6 万训练 + 1 万测试。但你做课程设计时最好从 6 万里再切出 5000 张当验证集,剩下的 55000 张当训练集。原因很简单:如果你一直拿测试集调参,测试集的精度就失去了可信度,因为你已经间接地把测试集的信息用在了模型选择上。验证集则是用来观察训练过程的,每个 epoch 结束后计算一下验证集精度,看模型有没有过拟合。
验证集划分用random_split就够,注意先设置随机种子,保证结果可复现:
from torch.utils.data import random_split total = len(train_dataset) train_subset, val_subset = random_split( train_dataset, [total - 5000, 5000], generator=torch.Generator().manual_seed(42) ) train_loader = DataLoader(train_subset, batch_size=64, shuffle=True) val_loader = DataLoader(val_subset, batch_size=64, shuffle=False)这里的原理是:验证集和训练集必须来自同一分布,随机切分能避免按顺序切分时可能引入的类别分布偏差。manual_seed(42)是固定随机数种子,跑出来的结果完全可复现,这在写实验报告的时候是刚需。评估时记得model.eval()并且整个循环包在with torch.no_grad():里,关闭梯度计算以省内存并避免影响模型状态。这个细节卡了很多新手:忘了model.eval()时 Dropout 还在随机丢弃,同一批数据测两次结果不同,就会怀疑模型是不是坏了。
5. 常见问题与避坑:从 404 到玄学收敛的 5 条记录
5.1 现象:torchvision 下载 MNIST 报 404 或卡住
我见过最频繁的报错就是torchvision从官方源下载 MNIST 时返回 404,或者下载到一半超时。原因已经提过:原始数据集托管在外部服务器,链接结构变化后老版本 torchvision 的固定地址失效,加上国内访问该服务器时网络路径不稳定,几乎没办法根治,只能本地绕行。
解决:先手动把四个.gz文件下到本地,放进data/MNIST/raw目录,然后在代码里用download=False。如果不想手动找文件,还有一个更省事的方式:用新版本 torchvision(0.19 以上)的download=True,它内部已经换用了可用的镜像源。判断自己 torchvision 版本用python -c "import torchvision; print(torchvision.__version__)",低于 0.17 的强烈建议直接升级而不是去折腾旧版下载逻辑。
5.2 现象:loss 在 0.6 左右不再下降,精度停在 97%
这个现象我遇到太多次了,尤其在一些改动过的模型上。原因有三类:一是学习率偏大导致在局部最优附近来回震荡;二是模型结构里少了 ReLU 或者错用了 Pooling;三是数据归一化忘了做,像素值还在 0~255 的范围,梯度数值太大把权重推到了异常区间。
解决:先确认transform里有Normalize,没有就加上。再把 lr 调小一半观察 3 个 epoch,如果 loss 开始继续下降就说明是学习率问题。最后检查模型结构里每个卷积后面是否都有激活函数,连续两个卷积之间没有激活会让这部分退化成线性变换,模型表达能力大幅下降。
5.3 现象:训练精度 99.5%,但用自己写的数字图一张都识别不对
这是典型的过拟合到数据集偏置,或者推理预处理不一致。MNIST 的图像是黑底白字、数字居中、笔画粗且规整,你拿手机拍的白纸黑字照片直接喂给模型,分布完全不同,识别率自然暴跌。
解决:把你的测试图片也处理成 28×28 灰度图,做和训练一样的归一化,并且尽量让数字居中。如果用的是自己画的图片,可以用 PIL 预处理:先转灰度convert('L'),缩放到 28×28,再用ToTensor和Normalize。这里最容易忽略的是灰度反转,MNIST 数字是白色 255、背景黑色 0,而扫描图片往往是白底黑字,你要用255 - img把前景色反转,否则模型看到的特征正好反过来,精度直接崩到 10% 等于随机猜。
5.4 现象:单张图片预测时RuntimeError: Expected 4D input
模型在训练时接收的输入是四维张量(batch, channel, height, width),但很多人做单张图推理时直接喂了一个三维张量(1, 28, 28)进去,于是报维度错误。原因就是没有补 batch 维度。
解决:在图片张量上调用img.unsqueeze(0)增加一维,变成(1, 1, 28, 28)。另外还要注意图片张量必须是浮点型并且已经归一化,很多新手从 PIL 直接转来的是 uint8,需要先.float()再除以 255。推理用的图要和训练用同一种预处理链,这是这条坑的根子。
5.5 现象:数据路径包含中文名,程序报找不到文件
Windows 环境下 PyTorch 的 DataLoader 读取含中文的路径偶尔会报编码错误,尤其当root='./数据'或当前用户名为中文时。原因是 Python 在部分 Windows 编码环境下对非 ASCII 路径处理不稳定。
解决:把项目和数据目录全部改成英文字符,data、models、outputs这种命名在课程设计里足够清晰,还能避免答辩换电脑时路径兼容性问题。如果你一定需要用中文路径,可以在代码开头设置import os; os.environ['PYTHONUTF8'] = '1',但这是补救手段,不如直接改名省心。
6. 把模型用起来:性能验证与一个 Tkinter 手写板 Demo
模型训练完,最后一步要做两件事:一是用测试集做一次干净的最终验证,二是把模型打包成一个可以交互的东西。对毕业设计来说,能演示比能跑通重要得多,一个画板 Demo 能在答辩时直接抓住注意力。
Tkinter 是 Python 自带 GUI 库,不需要额外安装,配合 PIL 就能做画板。核心思路是:用 Canvas 捕获鼠标拖动的坐标,把笔画画上去的同时把轨迹记录下来,鼠标松开后把 Canvas 坐标系的图像转成 28×28 灰度图,再走一遍训练时的预处理,最后model.predict输出概率最高的数字。
import torch from PIL import Image, ImageDraw def canvas_to_tensor(canvas, bbox): # canvas: Tkinter Canvas 对象 img = Image.new('L', (400, 400), 'black') draw = ImageDraw.Draw(img) # 从 bbox 中取出落在矩形内的事件坐标,在 img 上画白色轨迹 for x1, y1, x2, y2 in bbox: draw.line([(x1, y1), (x2, y2)], fill='white', width=16) img = img.resize((28, 28), Image.LANCZOS) img_tensor = torch.tensor( list(img.getdata()), dtype=torch.float32 ).view(1, 28, 28) / 255.0 img_tensor = (img_tensor - 0.1307) / 0.3081 return img_tensor.unsqueeze(0)这段代码把 400×400 画板缩放到 28×28,除以 255 归一化到 0~1,再做标准化,和训练时的Normalize完全一致。注意这里不再做灰度反转,因为画板背景设为黑色、画笔为白色,方向和 MNIST 训练集一致。缩放用LANCZOS重采样可以保留边缘细节,实测比默认的BILINEAR识别效果好一截,这也是手写数字识别项目里一个容易被低估的细节。
我自己的一个习惯是:保留每一次训练后的模型文件和训练曲线图,文件名带时间戳,这样答辩时能准确说出「这个模型是在哪次实验之后定下来的」。遇到课程设计需要上交的源码包里,项目文档我一般会附三样东西:环境要求、代码结构说明、训练和推理的运行命令。这也是我做完几个类似项目后总结出的最省事的交付方式。希望这个项目能让你把 MNIST 和 CNN 这条路彻底走通,真正把它变成自己的东西——希望帮到你。
本文还有配套的精品资源,点击获取