Python手写数字识别源码拆解:CNN实战MNIST达到95%准确率

发布时间:2026/10/9 21:22:12
Python手写数字识别源码拆解:CNN实战MNIST达到95%准确率
简介这份资源是面向高校学生与深度学习入门者的卷积神经网络手写数字识别项目源码可直接作为课程大作业或毕业设计参考帮助解决从模型搭建到训练评估的完整实现问题。压缩包共15个文件约555KB以8个Python脚本为核心涵盖网络结构定义、数据集加载、参数保存与优化器实现等模块另含3张训练过程与结果图片、1个pkl模型参数文件、1个yaml配置文件及gitignore等辅助文件结构清晰便于按模块阅读。目前已有618人学习下载说明其在同类作业中具备一定参考价值。读者可据此获得一套经过严格调试、评审95分以上的完整方案理解卷积层、池化层与全连接层的组织方式掌握损失曲线绘制、模型参数持久化与训练流程编排思路并可直接运行验证效果适合需要快速完成手写数字识别任务或对照学习CNN工程实现的人群。1. 拆开这份 95 分手写数字识别源码它到底能帮你省下多少调试时间如果你正在为课程大作业发愁或者想找一个能跑通、结构清晰、还带一定精度的卷积神经网络实战项目这份基于 Python 的手写数字识别源码包值得你花时间拆一遍。它解决的核心问题很具体用 MNIST 数据集训练一个 CNN 模型把测试集准确率稳定推到 95% 以上同时把数据加载、模型定义、训练循环、评估输出这几个环节都写成可读、可改、可复现的脚本。适合谁刚学完 CNN 理论但没完整跑过项目的新手以及需要快速交一份大作业、又不想从零搭框架的从业者。我见过太多人卡在“模型定义会写但数据维度对不上”或者“训练半天准确率卡在 80% 上不去”这种玄学问题上这份源码的价值就在于它把常见坑都填过了。下面我按实际拆包顺序把这份资源从结构到参数再到排错一层层讲清楚。2. 源码结构与运行链路从 MNIST 到 95% 准确率的完整路径2.1 先看目录每个文件在训练流程里干什么拿到一个压缩包我一般不会急着跑python train.py而是先看目录结构。这份源码的典型布局是一个主训练脚本、一个模型定义文件、一个数据加载工具外加一个推理测试脚本。常见做法是把model.py单独拆出来里面只放网络结构train.py负责超参配置、数据迭代和训练循环predict.py用来加载权重做单张图片推理。这种拆法的好处是你改网络结构时不用翻训练逻辑调参时也不会误伤模型定义。文件作用关键内容model.py定义 CNN 网络卷积层、池化层、全连接层、Dropouttrain.py训练主入口超参、优化器、损失函数、训练循环dataset.py数据加载与预处理MNIST 下载、ToTensor、Normalizepredict.py推理与可视化加载权重、单图预测、结果打印requirements.txt依赖清单torch、torchvision、numpy、matplotlib如果你拿到的包只有一两个脚本那大概率是把所有逻辑塞在一起了。这种写法对新手友好但改起来容易翻车。我建议先确认有没有requirements.txt没有的话手动装torch和torchvision就行版本不用追新torch 1.10以上都能跑。2.2 数据加载MNIST 的归一化参数为什么是 0.1307 和 0.3081MNIST 数据集的像素值是 0 到 255直接送进网络会让梯度爆炸或者收敛极慢。这份源码里通常会做两步ToTensor()把像素压到 0 到 1再用Normalize((0.1307,), (0.3081,))做标准化。这两个数字不是随便写的它们是 MNIST 训练集的全局均值和标准差。用它们做归一化能让输入分布更接近标准正态训练更稳。# dataset.py 典型写法 import torch from torchvision import datasets, transforms # 训练集变换转张量 标准化 train_transform transforms.Compose([ transforms.ToTensor(), # 像素从 0-255 压到 0-1 transforms.Normalize((0.1307,), (0.3081,)) # MNIST 全局均值与标准差 ]) # 测试集必须用同样的变换否则分布不一致 test_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtrain_transform) test_dataset datasets.MNIST(root./data, trainFalse, downloadTrue, transformtest_transform) train_loader torch.utils.data.DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader torch.utils.data.DataLoader(test_dataset, batch_size1000, shuffleFalse)逻辑说明batch_size64是训练时的常见选择太小会导致梯度震荡太大显存吃紧且泛化可能变差。测试集用batch_size1000是为了一次性算完准确率减少循环次数。shuffleTrue只在训练集开测试集必须关掉否则你没法复现每次评估的结果。参数怎么改如果你显存只有 4G把训练 batch 降到 32如果训练 loss 下降太慢可以试着把学习率从 0.01 调到 0.001但别低于 1e-4否则收敛慢到你想砸键盘。2.3 网络定义两层卷积够不够撑到 95%很多人以为要堆很多层才能上 95%其实 MNIST 很简单两层卷积加两层全连接就足够了。这份源码的典型结构是第一层卷积 32 个 3x3 核接 ReLU 和 2x2 最大池化第二层卷积 64 个 3x3 核同样接 ReLU 和池化然后展平接一个 128 维全连接最后输出 10 类。Dropout 一般加在全连接层之间概率 0.5 或 0.25。# model.py 典型写法 import torch.nn as nn import torch.nn.functional as F class Net(nn.Module): def __init__(self): super(Net, self).__init__() self.conv1 nn.Conv2d(1, 32, 3, 1) # 输入1通道输出32通道3x3核 self.conv2 nn.Conv2d(32, 64, 3, 1) # 输入32输出64 self.dropout1 nn.Dropout2d(0.25) # 卷积后随机丢25%特征图 self.dropout2 nn.Dropout2d(0.5) # 全连接前丢50% self.fc1 nn.Linear(9216, 128) # 64*12*129216 self.fc2 nn.Linear(128, 10) # 10类输出 def forward(self, x): x self.conv1(x) x F.relu(x) x self.conv2(x) x F.relu(x) x F.max_pool2d(x, 2) # 28x28 - 14x14 - 7x7? 实际两次卷积后是12x12 x self.dropout1(x) x torch.flatten(x, 1) # 展平 x self.fc1(x) x F.relu(x) x self.dropout2(x) x self.fc2(x) return F.log_softmax(x, dim1)逻辑说明Conv2d(1, 32, 3, 1)里的参数依次是输入通道、输出通道、卷积核大小、步长。MNIST 是灰度图所以输入通道是 1。9216这个数字来自两次卷积和一次池化后的特征图尺寸28x28 经过 3x3 卷积不填充会变成 26x26再卷积变 24x24池化后 12x1264 通道乘 12x12 就是 9216。如果你改了卷积核大小或加了 padding这个数字必须跟着改否则全连接层会报维度不匹配。这是新手最容易翻车的地方之一。2.4 训练循环优化器和学习率怎么配才不白跑训练部分的核心就三件事选优化器、设学习率、控制 epoch。这份源码一般用 SGD 加动量或者 Adam。SGD 配lr0.01, momentum0.9是经典组合收敛稳但慢Adam 配lr0.001收敛快但有时会过拟合。我一般先用 Adam 跑 5 个 epoch 看趋势如果测试准确率能到 97% 以上再换 SGD 微调。# train.py 训练循环核心片段 import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model Net().to(device) optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9) criterion nn.CrossEntropyLoss() for epoch in range(1, 11): model.train() for batch_idx, (data, target) in enumerate(train_loader): data, target data.to(device), target.to(device) optimizer.zero_grad() # 清空上一轮梯度 output model(data) # 前向传播 loss criterion(output, target) loss.backward() # 反向传播 optimizer.step() # 更新参数 if batch_idx % 100 0: print(fEpoch {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}] Loss: {loss.item():.4f}) # 每个 epoch 结束后评估测试集 model.eval() 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(dim1) correct pred.eq(target).sum().item() acc 100. * correct / len(test_loader.dataset) print(fEpoch {epoch} Test Accuracy: {acc:.2f}%)逻辑说明optimizer.zero_grad()必须放在前向传播之前否则梯度会累加训练直接跑偏。model.train()和model.eval()的切换影响 Dropout 和 BatchNorm 的行为评估时忘了切eval()会导致准确率偏低。torch.no_grad()在评估时关掉梯度计算省显存也提速。如果你跑完 10 个 epoch 准确率还在 90% 左右晃先检查数据归一化有没有漏再检查学习率是不是太大导致震荡。3. 参数调优与精度验证把 95% 推到 99% 的实操手段3.1 学习率调度什么时候该降降多少固定学习率跑到底准确率往往卡在 98% 左右上不去。常见做法是加一个学习率调度器比如每 3 个 epoch 把学习率乘 0.5或者用StepLR。这样前期大步走后期小步微调测试准确率通常能再涨 0.5 到 1 个百分点。from torch.optim.lr_scheduler import StepLR optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9) scheduler StepLR(optimizer, step_size3, gamma0.5) # 每3轮学习率减半 for epoch in range(1, 11): # ... 训练代码 ... scheduler.step() # 每个 epoch 结束后更新学习率 print(fEpoch {epoch} LR: {scheduler.get_last_lr()[0]:.6f})参数说明step_size3表示每 3 个 epoch 调一次gamma0.5表示乘 0.5。如果你发现 loss 在某个 epoch 后突然跳高可能是学习率降得太猛把gamma改成 0.7 试试。调度器不是必须的但想冲 99% 以上它基本是标配。3.2 数据增强MNIST 能做的增强其实很有限MNIST 是居中的手写数字旋转和缩放容易把数字转出边界反而掉精度。常见做法是只加轻微的随机仿射变换比如 ±10 度旋转、±10% 平移。别加水平翻转6 和 9 会混。这份源码如果没带增强你可以自己补上但要注意增强只加在训练集测试集保持原样。train_transform transforms.Compose([ transforms.RandomAffine(degrees10, translate(0.1, 0.1)), # 轻微旋转和平移 transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])逻辑说明RandomAffine的degrees10表示随机旋转 ±10 度translate(0.1, 0.1)表示水平和垂直各平移 ±10%。增强后训练集分布更广模型泛化更好但增强太强会让训练准确率下降。如果你加了增强后测试准确率反而掉了先把增强关掉确认基线能到 98% 再加回来。3.3 模型保存与加载别让训练成果白费训练完不保存权重等于白跑。常见做法是每个 epoch 结束后判断测试准确率是否创新高是就保存一份best_model.pt。加载时用torch.load配合map_location避免设备不匹配报错。best_acc 0.0 for epoch in range(1, 11): # ... 训练和评估 ... if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pt) print(fModel saved with accuracy: {best_acc:.2f}%) # 加载时 model Net() model.load_state_dict(torch.load(best_model.pt, map_locationcpu)) model.eval()参数说明state_dict()只存参数不存网络结构所以加载前必须先实例化Net()。map_locationcpu让你在没 GPU 的机器上也能加载。如果你改了网络结构旧权重会加载失败这时候要么重新训练要么用strictFalse跳过不匹配的层但后者容易埋雷不推荐。4. 避坑与排查跑不通、精度低、显存爆的常见问题4.1 现象报错 “RuntimeError: size mismatch”原因全连接层的输入维度算错了。卷积和池化后特征图尺寸变了但fc1的输入还是旧数字。解决在forward里加一行print(x.shape)看展平前实际是多少然后把fc1的输入改成对应值。或者用nn.AdaptiveAvgPool2d自适应池化省去手算。4.2 现象训练 loss 不降准确率一直在 10% 左右原因数据标签没对齐或者归一化参数用错。常见的是把测试集变换写成了训练集变换或者Normalize的均值方差填了 ImageNet 的。解决检查target是不是 0 到 9 的整数检查两个变换是否一致。如果用的是自定义数据集确认图片路径和标签一一对应。4.3 现象显存爆了报 “CUDA out of memory”原因batch size 太大或者没加torch.no_grad()导致评估时还在建计算图。解决把训练 batch 降到 32 或 16评估时强制加with torch.no_grad():。如果还爆把模型搬到 CPU 跑MNIST 在 CPU 上也就多花几分钟。4.4 现象测试准确率比训练准确率低很多原因过拟合或者评估时忘了切model.eval()。解决先确认eval()和no_grad()都加了。如果还差很多加 Dropout 或者减小模型容量。MNIST 上过拟合不常见但如果你把全连接层加到 1024 维过拟合就来了。4.5 现象预测单张图片时结果乱跳原因推理时的预处理和训练时不一致。比如训练用了归一化推理时只做了ToTensor()。解决把训练时的transform原封不动搬到推理脚本里别凭感觉简化。5. 从源码到自己的项目改数据集、换网络、加可视化的进阶手法这份源码跑通之后真正的价值在于你能把它当成模板套到自己的任务上。我一般会做三件事换数据集、改网络、加可视化。换数据集时先把新数据的通道数、尺寸、类别数确认清楚然后改Conv2d的输入通道和fc2的输出类别。比如换成 Fashion-MNIST通道还是 1类别还是 10基本不用动结构直接换datasets.FashionMNIST就行。换成彩色图片输入通道改成 3第一层卷积的in_channels跟着改。改网络时别一上来就堆 ResNet。MNIST 这种简单任务加一层卷积或者把全连接维度从 128 提到 256准确率可能涨 0.2%但训练时间翻倍。我习惯先用小网络确认流程通再逐步加容量。加可视化的话用matplotlib把前 16 张测试图和预测标签画出来一眼就能看出哪些数字被认错了。这个习惯帮我省了很多瞎调参的时间。import matplotlib.pyplot as plt # 可视化一批预测结果 model.eval() data, target next(iter(test_loader)) data, target data.to(device), target.to(device) output model(data) pred output.argmax(dim1) fig, axes plt.subplots(4, 4, figsize(8, 8)) for i, ax in enumerate(axes.flat): ax.imshow(data[i].cpu().squeeze(), cmapgray) ax.set_title(fPred: {pred[i].item()} / True: {target[i].item()}) ax.axis(off) plt.tight_layout() plt.show()逻辑说明data[i].cpu().squeeze()把张量搬回 CPU 并去掉通道维度才能被imshow正确显示。标题里同时打预测和真实标签一眼就能定位错分样本。如果你发现某类数字总是被认错比如 4 和 9 混可以针对性加这类样本的增强或者检查数据里是不是有标注错误。从那以后我每次拿到一个新的图像分类源码都强制先跑一遍基线确认准确率能到论文或简介里说的水平再动手改。如果基线都跑不到那问题多半在数据或参数不在模型。希望这份拆解能帮你少走点弯路把这份源码真正用起来。本文还有配套的精品资源点击获取