简介:这份资源是面向高校学生与深度学习入门者的卷积神经网络手写数字识别完整项目源码,可直接用于课程大作业、毕业设计或自学练手,帮助解决从模型搭建到训练评估的全流程实现问题。压缩包共15个文件,约555KB,以8个Python脚本为核心,涵盖网络结构定义、数据集加载、参数配置、优化器与训练分析等模块,另含3张训练过程与效果图、1个保存的模型参数文件及yaml配置等辅助内容,结构清晰、便于按模块阅读。目前已有618人学习下载,说明其在同类作业项目中具有一定参考价值。项目经过严格调试,评审分达95分以上,读者可据此理解卷积层、池化层与全连接层的组织方式,掌握损失曲线绘制、参数保存与加载、识别效果可视化等关键环节,并借鉴其代码分层与配置管理思路,快速完成自己的手写数字识别实践。
1. 手写数字识别为什么成了 CNN 入门的第一块试金石
如果你正在找一份能直接跑通、还能拿去交大作业的 Python 卷积神经网络项目,手写数字识别几乎是绕不开的选题。它数据集小、标签干净、单卡 CPU 都能训,但麻雀虽小五脏俱全:数据加载、卷积堆叠、批归一化、Dropout、学习率调度、模型保存与推理,一条链路全都能走一遍。很多人第一次真正理解「卷积核在学什么」「为什么验证集准确率会先升后降」,就是在这个任务上撞出来的。这份源码类项目的价值不在于模型多深,而在于它把一条完整的训练-评估-推理流水线摊开给你看,改几行就能换成自己的数据。适合两类人:一是刚学完反向传播、想找个能跑通的小项目练手的新手;二是需要一份结构清晰、指标能上 95 分的大作业底稿的开发者。下面我按实际复现顺序,把选型、代码、参数和踩坑一次讲透。
2. 从 MNIST 到可训练张量:数据管线怎么搭才不翻车
2.1 为什么选 MNIST 而不是自己拍照片
MNIST 是 70000 张 28×28 灰度图,60000 训练 + 10000 测试,类别 0-9 均衡。它的像素已经居中、归一化过,省掉了最耗时的数据清洗。常见做法是先用它把网络跑通,再迁移到自采数据。自采数据最大的坑是背景干扰和笔画粗细不一致,直接套 MNIST 训练的模型准确率会掉到 70% 以下。所以这份源码的定位是「教学基线」,不是「生产模型」。如果你要识别真实场景的票据数字或电表读数,得在 MNIST 预训练权重上做微调,而不是从零训。
2.2 用 torchvision 搭 Dataset 与 DataLoader
import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 训练集做轻微增强,测试集只做标准化 train_tf = transforms.Compose([ transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)), # 模拟手写倾斜 transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) # MNIST 全局均值方差 ]) test_tf = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_set = datasets.MNIST(root='./data', train=True, download=True, transform=train_tf) test_set = datasets.MNIST(root='./data', train=False, download=True, transform=test_tf) train_loader = DataLoader(train_set, batch_size=128, shuffle=True, num_workers=2) test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=2)逻辑说明:RandomAffine只做小角度旋转和平移,模拟手写时的自然抖动,幅度过大会让数字变形到不可识别。Normalize用的 0.1307 和 0.3081 是 MNIST 训练集的全局均值和标准差,这两个数不要随手改成 0.5,否则收敛会变慢。batch_size设 128 是显存和梯度稳定性的折中,CPU 训练可以降到 64。num_workers在 Windows 上建议设 0,否则容易卡在启动阶段。
2.3 数据划分的隐藏坑:验证集从哪来
很多人直接把测试集当验证集调参,调完再报测试准确率,这在方法论上是错的。正确做法是从 60000 训练集里切 5000 张做验证集,测试集只在最后跑一次。源码里如果没做这个切分,你自己补上:
from torch.utils.data import random_split train_sub, val_sub = random_split(train_set, [55000, 5000]) val_loader = DataLoader(val_sub, batch_size=256, shuffle=False)这样早停和调参都看验证集,最终指标才有说服力。大作业答辩时老师常问这一句,别在这翻车。
3. 网络结构怎么定:三层卷积够不够上 95 分
3.1 一个能稳定到 99% 的轻量 CNN
import torch.nn as nn import torch.nn.functional as F class DigitCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.conv1 = nn.Conv2d(1, 32, 3, padding=1) self.bn1 = nn.BatchNorm2d(32) self.conv2 = nn.Conv2d(32, 64, 3, padding=1) self.bn2 = nn.BatchNorm2d(64) self.conv3 = nn.Conv2d(64, 128, 3, padding=1) self.bn3 = nn.BatchNorm2d(128) self.pool = nn.MaxPool2d(2, 2) self.dropout = nn.Dropout(0.5) self.fc1 = nn.Linear(128 * 3 * 3, 256) self.fc2 = nn.Linear(256, num_classes) 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 = self.pool(F.relu(self.bn3(self.conv3(x)))) # 7->3 x = x.view(x.size(0), -1) x = self.dropout(F.relu(self.fc1(x))) return self.fc2(x)逻辑说明:三次卷积通道数 32→64→128,每次后接 BatchNorm 和 ReLU,再池化把空间尺寸从 28 降到 3。padding=1保证 3×3 卷积不缩小特征图,尺寸只由池化决定。最后展平成 128×3×3=1152 维接全连接。这个结构参数量约 40 万,CPU 上跑 10 个 epoch 大概几分钟。
参数说明:Dropout(0.5)放在全连接前,防止过拟合;卷积层后一般不加 Dropout,加了反而拖慢收敛。BatchNorm的 momentum 默认 0.1 就够,别乱调。如果显存紧张,把第三层卷积砍掉,准确率大概掉 0.3 个百分点,仍能过 95 分。
3.2 训练循环与学习率调度
import torch.optim as optim from torch.optim.lr_scheduler import StepLR device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = DigitCNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = StepLR(optimizer, step_size=5, gamma=0.5) for epoch in range(15): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() loss = criterion(model(imgs), labels) loss.backward() optimizer.step() scheduler.step() # 验证 model.eval() correct = total = 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) pred = model(imgs).argmax(1) correct += (pred == labels).sum().item() total += labels.size(0) print(f'epoch {epoch+1}, val_acc={correct/total:.4f}')逻辑说明:Adam 配weight_decay=1e-4做 L2 正则,StepLR每 5 个 epoch 把学习率乘 0.5,让后期收敛更稳。验证阶段必须model.eval()并包torch.no_grad(),否则 BatchNorm 会更新统计量、显存也会爆。
参数说明:学习率 1e-3 是 Adam 的常用起点,如果 loss 震荡就降到 5e-4。step_size和gamma是玄学重灾区,15 个 epoch 内设 5 和 0.5 比较稳。batch 太小(如 16)会让 BatchNorm 统计不准,验证准确率抖动明显。
3.3 模型保存与推理接口
torch.save(model.state_dict(), 'digit_cnn.pth') # 推理 model.load_state_dict(torch.load('digit_cnn.pth', map_location=device)) model.eval() with torch.no_grad(): logits = model(single_img.unsqueeze(0).to(device)) prob = torch.softmax(logits, dim=1) pred = prob.argmax(1).item()保存state_dict而不是整个模型,换设备加载时用map_location避免报错。推理前记得unsqueeze(0)补 batch 维度,输入必须是归一化后的张量,否则预测会乱跳。
4. 训练不收敛、准确率卡在 90% 的排查清单
4.1 现象:loss 一直不降,准确率在 10% 附近
原因通常是标签和输出维度对不上,或者数据没归一化。先打印一个 batch 的labels和model(imgs).shape,确认是 10 类。再检查Normalize是否漏写,未归一化的像素值在 0-255,梯度会炸。
4.2 现象:训练准确率 99%,验证只有 92%
这是典型过拟合。解决顺序:先加 Dropout 和数据增强,再把模型参数量降下来,最后才考虑加数据。别一上来就堆正则,先看训练验证曲线差距有多大。差距超过 5 个百分点,优先砍全连接层宽度。
4.3 现象:验证准确率忽高忽低,波动超过 2%
多半是 BatchNorm 在作怪。检查验证时是否model.eval(),以及 batch_size 是否太小。把验证 batch 调到 256 以上,波动会明显收敛。另外shuffle=True只给训练集,验证集不要 shuffle,否则指标不可复现。
4.4 现象:CPU 训练慢到无法忍受
num_workers设 0 在 Windows 上反而更快,因为多进程启动开销大。另外把batch_size提到 256,减少 Python 循环次数。如果还慢,把第三层卷积去掉,参数量减半,准确率损失很小。
4.5 现象:加载模型后预测全是同一个数字
九成是保存和加载的结构不一致,或者推理时忘了eval()。还有一种情况是输入图像没有做和训练时一样的归一化,像素分布偏移导致输出坍缩。打印prob看看是不是某个类概率接近 1,就能定位。
5. 把 95 分变成 99 分:三个我反复验证过的技巧
第一个技巧是测试时增强(TTA)。对同一张测试图做几次小幅度平移或旋转,把 softmax 概率平均后再取 argmax。MNIST 上这一招通常能涨 0.2 到 0.4 个百分点,代码不到十行:
def tta_predict(model, img, n=5): model.eval() probs = [] with torch.no_grad(): for _ in range(n): aug = transforms.RandomAffine(degrees=8, translate=(0.08, 0.08))(img) probs.append(torch.softmax(model(aug.unsqueeze(0).to(device)), 1)) return torch.stack(probs).mean(0).argmax(1).item()注意增强幅度要和训练时一致,太大反而掉点。第二个技巧是余弦退火学习率,把StepLR换成CosineAnnealingLR,后期学习率平滑降到接近 0,验证准确率曲线会更干净。第三个技巧是标签平滑,把CrossEntropyLoss(label_smoothing=0.1)打开,能压一压过拟合,对最终指标有微弱正向作用。
我自己的习惯是:任何一次改动只动一个变量,跑完记录验证准确率,别一次改三处,否则出了问题根本不知道是谁的锅。这份源码类项目最大的价值不是那个 95 分的数字,而是你顺着它把整条链路走一遍之后,再遇到新的图像分类任务,知道第一步该看数据、第二步该定基线、第三步该往哪儿调。希望帮到你。
本文还有配套的精品资源,点击获取