CNN手写数字识别源码实战:MNIST高精度模型构建与PyTorch实现
简介这是一份基于卷积神经网络完成手写数字识别的Python项目源码源于个人大作业评审得分95分以上经过严格调试可稳定运行适合高校学生在课程设计、毕业设计或深度学习入门阶段参考借鉴。项目按工程化方式拆分多个模块涵盖数据集加载与预处理、网络层与模型结构定义、优化器与训练方法配置、参数保存载入以及损失曲线/识别结果绘制等完整流程并附带已训练好的pkl模型参数文件下载解压后即可快速复现识别效果。压缩包共15个文件核心为8个Python脚本同时包含3张过程/结果图片、YAML配置文件、模型参数文件和gitignore等辅助文件整包仅555KB体量轻巧、目录规整便于按模块阅读和二次扩展。目前已有618人学习浏览对需要完成同类任务或理解CNN图像识别原理的读者来说是一份兼具完整性和可操作性的参考资源。1. 从“95分以上大作业”说起这份CNN手写数字识别源码能帮你解决什么期末大作业里python的基于卷积神经网络手写数字识别项目源码95分以上大作业.zip 这个标题你一定不陌生。它的核心就三件事用 Python 搭一个卷积神经网络CNN在 mnist 手写数字识别数据集上训练最后交出一份拿得出手的分类模型和实验报告。网上这类包很多但“压缩包里有什么”和“跑起来能拿到什么”是两回事。这篇笔记按我实际交付课设的顺序拆先讲网络结构为什么这样设计再给你一套能直接复现的最小代码末了把最容易让人翻车的几个坑提前摆上桌。适合刚学完 Python 语法、想在一周内交付一个像样课设的同学。2. MNIST手写数字识别为什么是CNN的“黄金入门题”建模逻辑与网络结构2.1 从全连接到卷积为什么手写数字识别不能只用MLP硬扛把 28×28 灰度图拉成 784 维向量接一个三层全连接网络理论上确实能训练。但真正训过的人都有体会MLP 对这个任务有点“笨”。因为拉平操作把像素的空间位置关系硬生生切断了一个数字“2”的横画和竖画之间该有的连续性在 MLP 眼里只是 784 个独立特征点。同一个数字在图上偏移三五个像素MLP 的输出就可能完全变样它只能靠海量样本硬背位置而不是真正理解字形。卷积神经网络CNN的做法完全不同。它用 3×3 的卷积核在图上滑动每次只看局部 9 个像素然后整张图共享同一组权重。这相当于把“横线检测器”“竖线检测器”“弧线检测器”在全图各个位置复用天然具备平移容忍度。手写数字识别的核心特征恰恰是边缘方向和笔画形状这些正是 3×3 卷积最容易捕捉的低层模式。LeNet-5 早在 1998 年就用这套思路把银行支票数字识别准确率推到 99% 以上这是深度学习卷积神经网络在工业界最经典的一次落地。光有卷积还不够还要配池化。手写数字存在大量轻微形变同一个“7”可能写得歪一点或者瘦一点。MaxPooling 在 2×2 窗口里取最大值保留的是“这个位置有没有出现对应特征”而不是特征出现的精确坐标。这种空间容忍度决定了测试集准确率能不能稳定过 98%也是比 MLP 强一个档次的核心原因。2.2 一套能打到95分以上的CNN结构卷积、池化与Dropout怎么排布我常用的基础结构是两层卷积加两层池化再接两个全连接层中间夹一个 Dropout。这个结构在 MNIST 上测试集准确率能到 98.5% 到 99%大作业拿 95 分以上足够用了。网络排布如下层输出尺寸关键参数输入1×28×28灰度图单通道Conv1 ReLU32×28×283×3 卷积padding1MaxPool132×14×142×2 池化Conv2 ReLU64×14×143×3 卷积padding1MaxPool264×7×72×2 池化Flatten313664×7×7 展平FC1 ReLU1283136→128Dropout128丢弃率 0.5FC210128→10输出 logits为什么第一层用 32 个卷积核、第二层用 64 个手写数字的笔画简单32 个核足够把边缘方向、端点、拐角这些基础模式提取出来第二层在 32 个特征图上再做组合升到 64 个通道是为了表达“横线竖线组成直角”“弧线围成圆圈”这类高阶结构。两个卷积核都保持 3×3、padding1目的是让特征图尺寸经过卷积后不缩水只靠池化来减半维度更好推算。最后一定要说清楚FC2 输出的是 logits不是概率。因为后面用 CrossEntropyLoss它内部已经做了 log_softmax NLLLoss模型前向不需要再手动加 Softmax。很多同学在这里画蛇添足在输出层加了 Softmax 后再算 loss准确率虽然没大变但报告里解释置信度时会说不清。2.3 MNIST数据预处理ToTensor之外的归一化和batch_size选择数据集加载之前必须先处理好数值范围。MNIST 原始像素是 0 到 255 的整数直接用会带来两个问题一是数值太大导致梯度更新不稳定二是不同图片整体亮度差异会被模型当作有效特征。标准做法是先 ToTensor 把 PIL 图像变成 [0, 1] 的浮点 Tensor再做一次标准化transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])MNIST 全量像素的均值约 0.1307、标准差约 0.3081这是 torchvision 官方统计好的常数。Normalize 之后数据分布接近标准正态梯度下降更平稳。常见误用是 ToTensor 之后再手动除以 255这其实是重复操作更危险的是只 ToTensor 不归一化那样训练也能收敛但 loss 曲线会波动得很厉害报告里那张图会丑。batch_size 我一般取 64。太小比如 4梯度噪声大每一步方向都偏太大比如 512单 batch 算得慢且小显存显卡容易爆。64 是 MNIST 任务里一个“不玄学但真的稳”的默认值配 Adam 默认学习率 0.001 就能在第一轮 epoch 内看到明显收敛。3. 把zip源码跑起来环境安装、解压检查与最小训练命令3.1 Python与PyTorch环境准备先建虚拟环境再装依赖拿到 zip 包的第一步不是急着解压跑训练而是确认 Python 环境。我见过太多次“所有同学的代码都跑不通只有我能跑”的情况差的就是环境隔离。解压后进入项目目录先建一个干净的虚拟环境cd mnist_cnn_project python -m venv venv # Windows 下激活 venv\Scripts\activate # macOS / Linux 下激活 source venv/bin/activate pip install torch torchvision numpy matplotlib为什么一定要用虚拟环境大作业机器上通常已经装着 TensorFlow、paddle、opencv 一堆东西torch 的依赖和它们经常互相踩踏。venv 把 torch 的 numpy 版本、cuda 绑定全部隔离在项目里装坏了就删掉 venv 重建等于给自己留了后悔药。torchvision 的版本要跟 torch 匹配。用pip install torch torchvision时会自动拉取配套组合如果你需要用 CUDA最好去 PyTorch 官网的“get started”页面复制对应命令。Windows 上如果 Python 版本太高比如 3.11 以上装某些旧版 torch 会报 No matching distribution遇到这种翻车就把虚拟环境重建到 Python 3.9 或 3.10。装 numpy 其实不用单独操心torch 会把它带进来但源码里如果显式import numpy还是补一句pip install numpy更保险。网速差就把 pip 源切到清华镜像pip install torch torchvision numpy matplotlib -i https://pypi.tuna.tsinghua.edu.cn/simple3.2 zip包里你真正要关心的三个文件模型、训练、README解压时注意一个老坑Windows 自带解压工具对 zip 内中文文件名经常乱码导致路径带一堆奇怪字符DataLoader 一旦读到就会崩。用 7-Zip 解压基本不会出这个问题。解压完先看目录结构这类大作业源码的约定结构一般是这样model.py或net.pyCNN 网络定义train.py或main.py训练与评估入口data_loader.py或dataset.pyMNIST 数据加载README.md运行说明requirements.txt依赖列表经常不全缺 torch 要自己补先打开 README重点看三样Python 版本要求、依赖安装命令、训练启动命令。README 里如果写了python train.py --epochs 10 --batch-size 128那大概率源码里用了 argparse照着敲就行。requirements.txt 存在就pip install -r requirements.txt但别全信它装完再手动确认 torch 在不在。还要检查项目里有没有data/目录。如果作者已经把 MNIST 的 raw 文件放进 zip训练脚本启动时就不会触发下载离线也能跑如果 data 目录是空的那就等着第 5 章里的下载坑。3.3 用1个epoch做冒烟测试最小训练命令与预期输出第一次跑千万别直接按 README 默认的 10 个 epoch 启动。先用最小命令冒烟测试python train.py --epochs 1 --batch-size 64如果源码没写 argparse就直接python train.py然后观察它在第几个 epoch 停下来自己 CtrlC。冒烟测试的目的只有一个确认 DataLoader 能取到数据、前向传播不报错、loss 在第一步就开始下降。预期结果是这样第一个 batch 的 loss 在 0.2 到 0.5 之间随机初始化后的正常范围1 个 epoch 跑完测试集准确率在 90% 上下。如果第一个 epoch 直接冲到 98%先别高兴怀疑数据泄漏——比如把训练集当测试集用了或者 DataLoader 的 shuffle 被错误地作用到了测试集。如果冒烟测试报错不要继续调参数直接跳到第 5 章按现象排查。4. 核心代码逐段拆解DataLoader、CNN模型与训练循环4.1 数据加载torchvision下载MNIST失败时的本地路径兜底# data_loader.py from torch.utils.data import DataLoader from torchvision import datasets, transforms def get_dataloaders(data_dir./data, batch_size64, num_workers0): transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), ]) train_set datasets.MNIST( rootdata_dir, trainTrue, downloadTrue, transformtransform, ) test_set datasets.MNIST( rootdata_dir, trainFalse, downloadTrue, transformtransform, ) train_loader DataLoader( train_set, batch_sizebatch_size, shuffleTrue, num_workersnum_workers ) test_loader DataLoader( test_set, batch_size256, shuffleFalse, num_workersnum_workers ) return train_loader, test_loaderdownloadTrue 表示如果./data/MNIST/raw/下没有原始数据torchvision 会自动从官网下载。这里有个常见翻车点校园网经常连不上 yann.lecun.com然后卡在“Downloading…”老半天。兜底方案是手动把四个 gz 文件——train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz——放到位再运行就不会触发下载。DataLoader 的 num_workers 参数在 Windows 下一开始务必设 0跑通后再试 2 或 4否则很容易出现多进程和主程序抢控制台的崩溃。4.2 模型定义Conv2d参数选择与全连接层的维度计算# model.py import torch.nn as nn import torch.nn.functional as F class CNNNet(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(64 * 7 * 7, 128) self.drop nn.Dropout(0.5) self.fc2 nn.Linear(128, 10) def forward(self, x): x self.pool(F.relu(self.conv1(x))) # 28x28 - 14x14 x self.pool(F.relu(self.conv2(x))) # 14x14 - 7x7 x x.view(x.size(0), -1) # 展平为 3136 维 x F.relu(self.fc1(x)) x self.drop(x) x self.fc2(x) return x两个关键参数值得细说。kernel_size3 配合 padding1 时卷积不改变特征图尺寸所以第一层输出还是 28×28第二层输出还是 14×14空间尺寸只由池化层减半。如果你把 kernel_size 改成 5、padding 改成 2感受野变大但参数量从 288 涨到 800训练时间明显拉长MNIST 这种简单任务上收益几乎为零。全连接层的输入维度 64×7×7 必须和前面推出来的一致改任何一层卷积或池化参数都要同步改这里否则会报维度不匹配。Dropout(0.5) 放在第一个全连接层后面是 MNIST 任务最常见的防过拟合位置。测试时 PyTorch 会自动关闭 Dropout不需要手动切换。需要注意的是 forward 里用的是 F.relu 而不是 nn.ReLU 层这纯粹是写法习惯效果没有区别但大作业报告里如果画网络结构图两种写法要跟图对上。4.3 训练与评估loss、准确率、模型保存的完整闭环# train.py import torch import torch.nn as nn from torch.optim import Adam from model import CNNNet from data_loader import get_dataloaders def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss 0.0 for images, labels in 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() * images.size(0) return total_loss / len(loader.dataset) def evaluate(model, loader, device): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return correct / total def main(): device cuda if torch.cuda.is_available() else cpu train_loader, test_loader get_dataloaders(batch_size64, num_workers0) model CNNNet().to(device) criterion nn.CrossEntropyLoss() optimizer Adam(model.parameters(), lr0.001) for epoch in range(1, 11): loss train_one_epoch(model, train_loader, criterion, optimizer, device) acc evaluate(model, test_loader, device) print(fepoch {epoch:2d} | loss {loss:.4f} | acc {acc * 100:.2f}%) torch.save(model.state_dict(), mnist_cnn.pth) if __name__ __main__: main()训练循环里有三个细节常被忽略。第一是model.train()和model.eval()必须成对出现train 模式启用 Dropouteval 模式关掉它如果训练完直接推理忘了 eval每次预测结果都会带随机性报告里没法交代。第二是 loss 累加时乘了images.size(0)再除以样本总数这是按样本数加权平均而不是简单对 batch loss 求平均这样得到的“epoch 平均 loss”更平滑。第三是 Adam 的默认学习率 0.001 对 MNIST 基本是万能解调大到 0.01 可能前几步就 NaN调小到 0.0001 收敛慢但更稳。保存模型时我用state_dict()而不是整个 model 对象。加载时需要先实例化 CNNNet 再 load_state_dict比直接 torch.save(model) 严谨得多因为类定义路径一变整包保存的模型在别的机器上就会找不到类。5. 常见问题与排查跑手写数字识别源码必踩的5个坑5.1 数据集下载卡在Downloading证书与离线包问题现象程序启动后停在Downloading http://yann.lecun.com/exdb/mnist/train-images-idx3-ubyte.gz进度条纹丝不动或者直接报ssl.SSLCertVerificationError。原因Python 的 requests 要用系统证书库校验证书老系统或校园网环境经常缺中间证书另一个常见场景是校园网屏蔽了外网地址请求发不出去但也没有超时错误。解决手动下载数据。把 train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz、t10k-images-idx3-ubyte.gz、t10k-labels-idx1-ubyte.gz 四个文件放到项目的./data/MNIST/raw/目录下。放好后重新运行torchvision 检测到 raw 文件已存在不会重复下载。5.2 loss直接NaN或一直不降学习率与预处理双排查现象第一个 batch 结束后 loss 就是 nan之后永远 nan另一种情况是 loss 从头到尾停在 0.7 左右像被粘住了一样。原因NaN 十有八九是学习率太大。Adam 配 0.01 甚至 0.1前几步梯度更新就会把权重推到数值爆炸区域。loss 不降的情况更隐蔽多半是预处理链路出了问题——比如手动转了 numpy 数组但忘记归一化输入值还在 0 到 255模型只能把亮度当成分类特征。解决把学习率拉回 0.001检查 transform 里 ToTensor 和 Normalize 两个都在再检查输入图像是不是已经被某一步操作变成了 0-255 整数。还有个冷门可能Windows 下 num_workers 大于 0 时会反复加载数据集导致 worker 崩溃先设成 0 试。5.3 预测输出维度对不上忘记加batch维度的典型翻车现象训练时一切正常写推理函数时一跑就报Expected 4D input, got 3D input或者不报错但预测结果全错。原因模型前向要求输入是 (N, C, H, W)单张 28×28 图片 Read 出来后是 (C, H, W) 即 (1, 28, 28)少了 batch 维。更隐蔽的是用 PIL 读彩色图读出来是 (H, W, 3)通道顺序和数值范围全乱套。解决推理前强制补两个维度img_tensor torch.from_numpy(img).float() # 28x28 img_tensor img_tensor.unsqueeze(0).unsqueeze(0) # - 1,1,28,28再用 PIL 读图时记得convert(L)转灰度resize((28, 28))到标准尺寸。5.4 中文路径导致DataLoader报错Windows下最隐蔽的坑现象解压到“桌面\新建文件夹\mnist项目”后训练刚开始 DataLoader 就崩报UnicodeDecodeError或者BrokenPipeError源码一行都不用改。原因Windows 的 Python 默认编码不是 UTF-8PyTorch 的 DataLoader 多进程会把中文路径传给 worker编码不一致就爆。解决把整个项目挪到纯英文路径比如D:\mnist_cnn运行前设置环境变量PYTHONUTF81再执行python train.py。macOS 和 Linux 很少遇到这个问题Windows 上碰到路径相关报错就按这个思路排查。5.5 GPU显存不够或RuntimeErrorout of memory的降载处理现象小显存显卡2G 到 4G跑测试或者 batch_size 设成 256报CUDA out of memory。原因这个 CNN 本身不大但很多人把测试集一个 batch 塞满 256 张图再叠加训练时的中间激活值显存就爆了。解决把 batch_size 降到 32 或 16num_workers 设 0。如果还爆干脆用 CPU 跑。MNIST 这套两层卷积在 CPU 上大约 30 到 50 秒一个 epoch10 个 epoch 也就几分钟交大作业完全等得起。另外在 main() 里写device cuda if torch.cuda.is_available() else cpu可以让代码在没 GPU 的机器上自动降级。6. 把这份大作业从85分做到95分三个最有效的验证与提分技巧6.1 用混淆矩阵找到模型“最容易认错”的数字训练完别急着写报告先跑一遍测试集把预测结果和真实标签喂给 sklearn 的 confusion_matrix画出混淆矩阵。这一步能直接回答答辩老师最常问的问题“你的模型哪里不行”MNIST 上最常见的错误对是 4 和 9、7 和 2、3 和 5因为它们在手写体里笔画拓扑非常接近。如果混淆矩阵显示 4→9 的错误特别多说明模型缺乏对“横画闭合”这一特征的捕捉能力报告里可以针对性说一句“可以通过数据增强模拟不同书写风格来缓解”。就这一句话分数观感就不一样。from sklearn.metrics import confusion_matrix y_true, y_pred [], [] model.eval() with torch.no_grad(): for images, labels in test_loader: outputs model(images.to(device)) y_pred.extend(outputs.argmax(dim1).cpu().tolist()) y_true.extend(labels.tolist()) print(confusion_matrix(y_true, y_pred))6.2 一组被验证过的lr与batch_size搭配以及保存/加载闭环调参这件事在 MNIST 上没那么玄。我用下来的可靠组合是batch_size64 配 lr0.001如果显存大非要用 256 的 batch把 lr 提到 0.002 到 0.003否则收敛速度慢。大作业报告里千万不要写“我试了 30 组参数”那不叫调参叫撞运气。写清楚“为什么选 64——梯度噪声和收敛速度的折中”就够了。训练结束后把模型保存成mnist_cnn.pth只是一半还要写一个能加载回来做推理的入口让老师看到完整闭环。这里最容易被忽略的就是自己画一个数字传到模型里画布是白底黑字还是黑底白字MNIST 训练数据是黑底白字背景为 0笔画为 255你拿画图软件画一个白底黑字的图直接喂进去模型大概率识别错。推理前要做反色把背景变成 0、笔画变成接近 255 的值再归一化到和训练一致的范围。这个坑我帮别人排查过很多次每次都栽在同一处。6.3 留一个能“自己画数字”的完整推理入口import torch from PIL import Image from model import CNNNet model CNNNet() model.load_state_dict(torch.load(mnist_cnn.pth, map_locationcpu)) model.eval() img Image.open(my_digit.png).convert(L).resize((28, 28)) img_tensor torch.from_numpy(np.array(img, dtypenp.float32)) img_tensor img_tensor.unsqueeze(0).unsqueeze(0) # 1,1,28,28 img_tensor (255 - img_tensor) / 255.0 # 反色并归一化到 [0,1] img_tensor (img_tensor - 0.1307) / 0.3081 # 与训练时的 Normalize 对齐 with torch.no_grad(): logits model(img_tensor) print(预测数字:, logits.argmax(dim1).item())这段代码把前文所有踩坑点都收口了维度补 batch、转灰度、resize、反色、归一化、eval 模式。我自己的习惯是每份课设代码最后都会留下这样一个 10 行的推理入口哪怕老师不看代码顺手画个数字就能看到效果。大作业验收时一份能交互的代码永远比一份只能跑训练的代码更有说服力。希望帮到你。本文还有配套的精品资源点击获取