简介:这份资源是面向高校学生与深度学习入门者的卷积神经网络手写数字识别项目源码,可直接作为课程大作业或毕业设计参考,帮助解决从模型搭建到训练评估的完整实现问题。压缩包共15个文件,约555KB,以8个Python脚本为核心,涵盖网络结构、数据集加载、优化器、参数保存与绘图分析等模块,另含3张png训练效果图、yaml参数配置、pkl模型权重及gitignore等辅助文件,结构清晰便于按模块阅读。目前已有618人学习下载,说明其参考价值得到一定认可。项目经过严格调试,评审分达95分以上,可正常运行,读者能借此掌握卷积层、池化层与全连接层的组织方式,理解损失曲线与准确率可视化流程,并参考参数配置与权重保存思路完成自己的实验复现与二次开发。
1. 手写数字识别为什么成了 CNN 入门的“试金石”
如果你正在找一份能直接跑通、结构清晰、还能撑起大作业评分的手写数字识别项目,那基于卷积神经网络的 MNIST 方案大概率是你绕不开的起点。它看起来简单——不就是识别 0 到 9 十个数字吗?但真正动手写一遍就会发现,从数据加载、网络搭建、训练调参到模型保存与推理,每一步都有细节能把人卡住。我见过太多人直接复制一份网上的代码,跑出来准确率只有 90% 出头,离 95 分以上的要求差一口气,却不知道问题出在哪。这个项目的核心价值不在于“能跑”,而在于“跑得稳、说得清、改得动”。它适合两类人:一是刚学完 Python 和深度学习基础、需要一份完整项目练手的学生;二是想快速验证 CNN 效果、需要一个干净基线代码的开发者。接下来我会按实际落地顺序,把数据预处理、网络设计、训练策略、避坑点和提分技巧一层层拆开,让你不仅能复现,还能在答辩或报告里讲出每一处设计的理由。
2. 从 MNIST 到可训练张量:数据加载与预处理的四个关键动作
2.1 为什么不能直接拿原始图片喂给网络
MNIST 数据集里的手写数字是 28×28 的灰度图,像素值在 0 到 255 之间。很多人第一次写代码时,直接把图片数组 reshape 成 (784,) 就丢进全连接层,结果训练慢、准确率低。卷积神经网络需要的是具有空间维度的输入,也就是 (通道数, 高度, 宽度) 或 (高度, 宽度, 通道数) 的格式。PyTorch 默认使用 (N, C, H, W),所以我们需要把单张图变成 (1, 28, 28)。另外,原始像素值范围太大,会导致梯度更新不稳定,通常要归一化到 0 到 1 之间,或者按均值 0.1307、标准差 0.3081 做标准化——这两个数字是 MNIST 训练集的全局统计量,直接用就行,不用自己算。
还有一个容易被忽略的点:数据增强。MNIST 本身比较干净,但如果你想让模型对轻微旋转、平移更鲁棒,可以加一点随机仿射变换。不过对于大作业来说,不加增强也能轻松上 99%,加了反而可能因为过度变换让数字变形,导致准确率下降。我的建议是:先跑通不加增强的基线,再根据需求决定是否加。
2.2 用 DataLoader 构建可迭代的数据管道
下面这段代码展示了从下载数据集到构建训练和测试加载器的完整过程。注意参数设置和注释里的细节。
import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader # 定义预处理:转为张量并标准化 transform = transforms.Compose([ transforms.ToTensor(), # 将PIL图像转为Tensor,同时归一化到[0,1] transforms.Normalize((0.1307,), (0.3081,)) # MNIST全局均值和标准差 ]) # 下载并加载训练集和测试集 train_dataset = datasets.MNIST( root='./data', # 数据存放路径 train=True, # 训练集 download=True, # 如果本地没有则自动下载 transform=transform ) test_dataset = datasets.MNIST( root='./data', train=False, download=True, transform=transform ) # 构建DataLoader train_loader = DataLoader( dataset=train_dataset, batch_size=64, # 每批64张,可根据显存调整 shuffle=True, # 训练时打乱顺序,防止模型记住样本顺序 num_workers=2 # 读取数据的子进程数,Windows下建议设为0 ) test_loader = DataLoader( dataset=test_dataset, batch_size=1000, # 测试时可以加大批次,加快评估速度 shuffle=False, # 测试不需要打乱 num_workers=2 )逻辑说明:transforms.Compose把多个预处理步骤串起来,ToTensor负责转换和缩放,Normalize负责减均值除标准差。DataLoader的batch_size直接影响训练稳定性和速度,64 是一个比较稳妥的起点。shuffle=True只在训练集开启,测试集必须关闭,否则评估结果会失去意义。num_workers在 Linux 或 macOS 上可以设 2 或 4,Windows 上如果报错就改成 0。
参数说明:root指定数据存放目录,第一次运行会自动下载。download=True在数据已存在时不会重复下载。标准化参数(0.1307,), (0.3081,)是 MNIST 的固定值,不要随意改动,否则相当于人为偏移了数据分布。
2.3 验证数据加载是否正确的三个检查点
写完加载代码后,别急着定义网络。先做三个检查:第一,打印一个批次的形状,应该是torch.Size([64, 1, 28, 28]);第二,检查像素值范围,标准化后应该在 -0.5 到 2.5 左右,而不是 0 到 255;第三,随机可视化几张图,确认标签和图像对应正确。这三个检查能帮你排除掉一半的低级错误,比如通道维度放错、归一化参数写反、标签错位等。
3. 卷积神经网络设计:从两层基线到 99% 准确率的模块拆解
3.1 一个能打 95 分以上的最小网络结构
很多人以为要堆很多层才能上 99%,其实 MNIST 非常简单,两个卷积层加两个全连接层就足够了。下面是我常用的基线结构,参数量小、训练快、准确率稳定在 99% 以上。
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,步长2 self.pool = nn.MaxPool2d(2, 2) # 全连接层:经过两次池化后,特征图大小为7x7,通道64 self.fc1 = nn.Linear(64 * 7 * 7, 128) self.fc2 = nn.Linear(128, 10) # 10个类别 # Dropout层,防止过拟合 self.dropout = nn.Dropout(0.25) def forward(self, x): # 第一层:卷积 -> ReLU -> 池化 x = self.pool(F.relu(self.conv1(x))) # 输出: (N, 32, 14, 14) # 第二层:卷积 -> ReLU -> 池化 x = self.pool(F.relu(self.conv2(x))) # 输出: (N, 64, 7, 7) # 展平 x = x.view(-1, 64 * 7 * 7) # 全连接 -> ReLU -> Dropout x = F.relu(self.fc1(x)) x = self.dropout(x) # 输出层 x = self.fc2(x) return x逻辑说明:padding=1保证卷积后特征图尺寸不变,这样两次池化后刚好从 28 降到 14 再到 7。view(-1, 64*7*7)把多维特征图拉成一维向量,-1表示自动推断批次大小。Dropout(0.25)在训练时随机丢弃 25% 的神经元,测试时自动关闭,能有效缓解过拟合。
参数说明:卷积核数量 32 和 64 是经验值,再大对小数据集收益有限。全连接层 128 个神经元也是折中选择,太大容易过拟合,太小欠拟合。Dropout 概率 0.25 比常见的 0.5 更温和,因为 MNIST 本身过拟合风险不高。
3.2 卷积核、池化窗口和通道数怎么选
卷积核大小通常选 3×3,因为两个 3×3 卷积的感受野等于一个 5×5,但参数更少、非线性更强。池化窗口选 2×2 是标准做法,步长等于窗口大小,不重叠。通道数从 32 起步,第二层翻倍到 64,这是经典的 VGG 式设计思路。如果你把通道数改成 16 和 32,准确率可能掉 0.2% 左右,但训练速度会快不少;改成 64 和 128,准确率提升微乎其微,反而容易过拟合。所以我的建议是:大作业用 32/64 足够,想炫技可以加 BatchNorm 层,但别盲目堆通道。
3.3 损失函数与优化器的搭配逻辑
分类任务用交叉熵损失nn.CrossEntropyLoss(),它内部已经包含了 Softmax 操作,所以网络最后一层不要加 Softmax。优化器选 Adam,学习率设 0.001,这是最省心的组合。SGD 加动量也能达到类似效果,但需要手动调学习率衰减,对新手不友好。下面给出训练循环的核心代码。
import torch.optim as optim device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = Net().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) def train(model, device, train_loader, optimizer, epoch): 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(f'Epoch {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)}] Loss: {loss.item():.4f}') for epoch in range(1, 6): train(model, device, train_loader, optimizer, epoch)逻辑说明:optimizer.zero_grad()必须在反向传播前调用,否则梯度会累加。loss.backward()计算所有可学习参数的梯度,optimizer.step()执行更新。训练 5 个 epoch 通常就能达到 99% 左右的测试准确率。
参数说明:学习率 0.001 是 Adam 的经典值,太大容易震荡,太小收敛慢。批次大小 64 和训练轮数 5 是平衡速度和精度的选择。如果你发现损失下降很慢,可以检查数据是否标准化、标签是否对应正确。
4. 训练过程避坑:准确率卡在 90% 的五个真实原因
4.1 现象:训练损失正常下降,但测试准确率始终低于 92%
原因:最常见的是数据预处理不一致。训练集用了标准化,测试集却忘了加同样的 transform,导致分布偏移。另一个可能是shuffle在测试集被误开,评估结果随机波动。解决:检查测试集的 transform 是否和训练集完全一致,确认shuffle=False。
4.2 现象:模型输出全是同一个类别
原因:学习率设得太大,比如 0.1 或 0.01,导致参数更新过猛,直接跳过最优解。或者损失函数用错,比如手动加了 Softmax 又用 CrossEntropyLoss,相当于做了两次 Softmax。解决:把学习率降到 0.001,检查网络最后一层是否有多余的激活函数。
4.3 现象:训练集准确率很高,测试集准确率低 3 个点以上
原因:过拟合。MNIST 虽然简单,但如果全连接层神经元过多、训练轮数过长,模型会记住训练样本的噪声。解决:加 Dropout 层,或者减少全连接层神经元数量,也可以提前停止训练。我一般会在测试准确率连续两个 epoch 不提升时停止。
4.4 现象:GPU 显存溢出或训练速度极慢
原因:batch_size设得太大,或者num_workers在 Windows 上引发多进程问题。解决:把batch_size降到 32 或 64,Windows 下把num_workers设为 0。另外,确认数据已经下载到本地,避免每个 epoch 都重新下载。
4.5 现象:模型保存后重新加载,预测结果全乱
原因:保存时只存了state_dict,加载时网络结构定义不一致,或者忘了调用model.eval()。解决:保存和加载使用同一份网络定义代码,加载后先model.eval()再推理。下面给出正确的保存与加载方式。
# 保存模型参数 torch.save(model.state_dict(), 'mnist_cnn.pth') # 加载模型参数 model = Net().to(device) # 必须用相同的网络结构 model.load_state_dict(torch.load('mnist_cnn.pth', map_location=device)) model.eval() # 切换到评估模式,关闭Dropout逻辑说明:state_dict只保存参数张量,不保存网络结构,所以加载前必须手动实例化同样的网络。map_location确保在 CPU 上也能加载 GPU 训练的模型。model.eval()会关闭 Dropout 和 BatchNorm 的训练行为,保证推理结果稳定。
5. 把准确率推到 99% 以上:三个低成本高回报的调优技巧
5.1 学习率调度:让模型在后期“慢下来”
Adam 默认学习率 0.001 在前期收敛很快,但后期容易在最优解附近震荡。加一个简单的学习率衰减,比如每两个 epoch 乘以 0.5,就能让测试准确率再提升 0.1% 到 0.2%。代码只需要一行:
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=2, gamma=0.5) # 在每个epoch训练结束后调用 scheduler.step()逻辑说明:step_size=2表示每 2 个 epoch 调整一次,gamma=0.5表示学习率变为原来的 0.5 倍。这样后期更新步长更小,更容易逼近最优解。
5.2 模型集成:把多个模型的预测结果平均
如果你愿意多花一点训练时间,可以训练 3 个结构相同但初始化不同的模型,推理时把三个模型的输出概率取平均。这个方法在 MNIST 上能稳定提升 0.05% 到 0.1%,而且代码改动很小。具体做法是保存三个模型的state_dict,推理时分别加载并累加 Softmax 输出,最后取 argmax。
5.3 用混淆矩阵定位“顽固错误”
即使准确率达到 99.2%,仍然有 0.8% 的样本被分错。打印混淆矩阵能帮你看到哪些数字最容易混淆,比如 4 和 9、3 和 5。针对这些类别,可以单独分析错误样本的像素分布,判断是标注问题还是模型特征提取不足。这一步在写大作业报告时非常加分,因为它体现了你不仅会跑代码,还会分析结果。
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: data, target = data.to(device), target.to(device) output = model(data) preds = output.argmax(dim=1) all_preds.extend(preds.cpu().numpy()) all_targets.extend(target.cpu().numpy()) cm = confusion_matrix(all_targets, all_preds) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues') plt.xlabel('Predicted') plt.ylabel('True') plt.show()逻辑说明:torch.no_grad()关闭梯度计算,节省显存并加速推理。argmax(dim=1)取每个样本最大概率对应的类别。混淆矩阵的横轴是预测标签,纵轴是真实标签,对角线上的数字越大越好。非对角线上的高值就是需要重点分析的混淆对。
参数说明:annot=True在格子里显示数值,fmt='d'保证显示整数。如果环境里没有 seaborn,可以用 matplotlib 的matshow替代。
5.4 我踩过的一个“玄学”坑
有一次我训练完模型,测试准确率 99.3%,但用单张图片推理时总是预测错。排查了半天才发现,测试集加载时用了batch_size=1000,而单张推理时没有做同样的标准化,导致输入分布不一致。后来我养成了一个习惯:把预处理封装成一个函数,训练、测试、单张推理都调用同一个函数。这个习惯帮我省下了至少十次重复排查的时间。希望帮到你。
本文还有配套的精品资源,点击获取