简介:本资源是一份面向Python初学者与高校计算机专业学生的CNN手写数字识别实践项目,适用于期末大作业、课程设计及深度学习入门实训。项目基于TensorFlow/Keras实现完整卷积神经网络训练与推理流程,含GUI交互界面,支持手绘数字实时识别,代码逐行注释清晰,配套README.md说明部署步骤与运行逻辑,新手可快速上手。压缩包共23个文件,包含3个核心Python脚本(gui.py、recognition.py、CNN-Model.py)、10张示例数字图像(png)、1个权重文件(weights.txt)、1个图标(ico)及开发环境配置文件(iml、gitignore等),整体仅3.53MB,轻量易部署。目前已有394人学习下载,资源结构合理、功能闭环,涵盖数据预处理、模型构建、训练可视化、GUI集成与结果展示全流程,是理解CNN原理与工程落地的高价值教学案例。
1. 这不是“跑通MNIST就完事”的玩具代码,而是一份能让你真正看懂CNN每一层在干什么的Python手写数字识别实验
如果你打开过 dozens 个“CNN手写数字识别”项目,大概率见过这样的流程:import tensorflow→model.fit()→print(accuracy)→ 完结。但当你被要求在大作业里解释“为什么第一层卷积核设为32、ReLU为什么接在卷积后、池化窗口大小如何影响感受野”,或者需要手动计算某一层输出尺寸、画出特征图变化过程、替换掉Softmax改用LogSoftmax做多分类校准——那些黑箱式调包代码立刻失效。本实验源码专为这类真实教学与工程复现场景设计:所有网络结构用纯PyTorch(非Keras封装)逐层构建,每行前向传播都带数学注释(如# [N,1,28,28] → conv2d(1→32,k=5,s=1,p=0) → [N,32,24,24]),关键超参(步长、填充、核大小)全部显式声明而非隐含在高层API中,训练循环里嵌入梯度范数监控和特征图可视化钩子。它面向的是需要讲清原理、调试中间层、应对课程答辩或课程设计验收的Python深度学习学习者——尤其适合刚学完《数字图像处理》又在啃《神经网络与深度学习》的本科生,以及需要快速验证CNN基础模块行为的转岗工程师。
2. 从零构建LeNet-5结构:为什么用5×5卷积核、为什么池化选2×2、为什么全连接层输入要展平
2.1 LeNet-5是手写数字识别的“最小可行CNN”,不是历史遗迹而是教学锚点
LeNet-5诞生于1998年,但它定义的“卷积→激活→池化→展平→全连接”范式至今仍是CNN入门必经路径。其结构精简(仅2个卷积块+2个全连接层)、参数量小(约6万个)、对MNIST数据集收敛快(通常20轮内达99%+),特别适合作为理解卷积操作物理意义的载体。例如:
- 第一卷积层使用5×5核而非3×3,是因为MNIST单通道图像(28×28)分辨率低,小核易丢失全局结构信息;
- 池化层固定用2×2最大池化(stride=2),既保证下采样率可控(每层减半),又避免因步长≠窗口导致的特征图错位;
- 全连接层前必须
torch.flatten(x, 1),因为nn.Linear只接受二维输入(batch_size × features),而卷积输出是四维张量(N×C×H×W)。
提示:不要直接复制网上“
model = nn.Sequential(...)”的链式写法。本实验采用nn.Module子类化,强制你为每个层命名(如self.conv1)、显式调用forward(),这是后续插入梯度钩子、提取中间特征的前提。
2.1.1 手动计算各层输出尺寸:拒绝“自动推导”,用公式验证你的理解
CNN中尺寸变化遵循确定公式:
$$ \text{Output Size} = \left\lfloor \frac{H + 2P - K}{S} \right\rfloor + 1 $$
其中$H$为输入高/宽,$P$为padding,$K$为kernel size,$S$为stride。以MNIST输入(28×28)为例:
| 层 | 输入尺寸 | 卷积参数(K,P,S) | 输出尺寸计算 | 实际输出 |
|---|---|---|---|---|
| Conv1 | 28×28 | (5,0,1) | ⌊(28+0−5)/1⌋+1 = 24 | 24×24 |
| Pool1 | 24×24 | MaxPool2d(2,2) | ⌊(24+0−2)/2⌋+1 = 12 | 12×12 |
| Conv2 | 12×12 | (5,0,1) | ⌊(12+0−5)/1⌋+1 = 8 | 8×8 |
| Pool2 | 8×8 | MaxPool2d(2,2) | ⌊(8+0−2)/2⌋+1 = 4 | 4×4 |
| Linear输入 | 4×4×16 | — | flatten后为 batch_size × 256 | 256 |
该表必须手算一遍——当你的代码报错size mismatch for linear1.weight时,90%源于此处计算偏差。
2.1.2 PyTorch实现LeNet-5:带尺寸注释的逐层定义
import torch import torch.nn as nn import torch.nn.functional as F class LeNet5(nn.Module): def __init__(self, num_classes=10): super().__init__() # Layer 1: Conv2d(1→6, k=5, s=1, p=0) → ReLU → MaxPool2d(2,2) self.conv1 = nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5, stride=1, padding=0) # 输入[1,28,28] → 输出[6,24,24] → 池化后[6,12,12] # Layer 2: Conv2d(6→16, k=5, s=1, p=0) → ReLU → MaxPool2d(2,2) self.conv2 = nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5, stride=1, padding=0) # 输入[6,12,12] → 输出[16,8,8] → 池化后[16,4,4] # Layer 3: 全连接层,输入展平后为16*4*4=256 self.fc1 = nn.Linear(in_features=16*4*4, out_features=120) self.fc2 = nn.Linear(in_features=120, out_features=84) self.fc3 = nn.Linear(in_features=84, out_features=num_classes) def forward(self, x): # [N,1,28,28] x = F.relu(self.conv1(x)) # → [N,6,24,24] x = F.max_pool2d(x, kernel_size=2, stride=2) # → [N,6,12,12] x = F.relu(self.conv2(x)) # → [N,16,8,8] x = F.max_pool2d(x, kernel_size=2, stride=2) # → [N,16,4,4] x = torch.flatten(x, 1) # → [N, 16*4*4] = [N,256] x = F.relu(self.fc1(x)) # → [N,120] x = F.relu(self.fc2(x)) # → [N,84] x = self.fc3(x) # → [N,10] (未加Softmax,交由CrossEntropyLoss处理) return x参数说明:
in_channels=1:MNIST为单通道灰度图,勿设为3;kernel_size=5:对应LeNet-5原始设计,若改为3需同步调整padding保尺寸(如p=1);torch.flatten(x, 1):dim=1表示从第1维(channel)开始展平,保留batch维(dim=0),这是PyTorch标准做法;F.relu而非nn.ReLU():函数式调用更轻量,且无需在__init__中注册模块。
3. 训练循环的硬核细节:学习率衰减策略、梯度裁剪、准确率分阶段验证
3.1 不只是optimizer.step():一个可调试的训练主循环
教科书式训练循环常省略关键控制点。本实验提供带完整监控的版本,每轮输出不仅含loss,还包含:
- 当前学习率(验证衰减是否生效);
- 训练集mini-batch准确率(非整体准确率,防过拟合预警);
- 验证集准确率(独立于训练集,决定早停);
- 梯度L2范数(>10时触发裁剪,防梯度爆炸)。
def train_one_epoch(model, train_loader, optimizer, criterion, device, epoch): model.train() running_loss = 0.0 correct_train = 0 total_train = 0 grad_norms = [] 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() # 梯度裁剪:防止RNN/LSTM常见问题,CNN虽少用但作为规范加入 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) grad_norm = torch.norm(torch.stack([p.grad.norm() for p in model.parameters() if p.grad is not None])) grad_norms.append(grad_norm.item()) optimizer.step() running_loss += loss.item() # 计算当前batch准确率 pred = output.argmax(dim=1, keepdim=True) correct_train += pred.eq(target.view_as(pred)).sum().item() total_train += target.size(0) avg_loss = running_loss / len(train_loader) train_acc = 100. * correct_train / total_train avg_grad_norm = sum(grad_norms) / len(grad_norms) print(f'Epoch {epoch}: Train Loss={avg_loss:.4f} | Train Acc={train_acc:.2f}% | Grad Norm={avg_grad_norm:.4f} | LR={optimizer.param_groups[0]["lr"]:.6f}') return avg_loss, train_acc # 验证函数(无梯度计算) def validate(model, val_loader, criterion, device): model.eval() val_loss = 0 correct = 0 with torch.no_grad(): for data, target in val_loader: data, target = data.to(device), target.to(device) output = model(data) val_loss += criterion(output, target).item() pred = output.argmax(dim=1, keepdim=True) correct += pred.eq(target.view_as(pred)).sum().item() val_loss /= len(val_loader) val_acc = 100. * correct / len(val_loader.dataset) print(f' Val Loss={val_loss:.4f} | Val Acc={val_acc:.2f}%') return val_loss, val_acc关键设计解析:
clip_grad_norm_阈值设为1.0:CNN梯度通常较稳定,但加入此句可避免极端情况(如初始权重过大)导致NaN;pred.eq(target.view_as(pred)):view_as确保target形状匹配pred([N,1]),避免广播错误;optimizer.param_groups[0]["lr"]:直接读取当前学习率,比get_lr()更可靠(后者在某些调度器中可能滞后)。
3.1.1 学习率调度:StepLR vs ReduceLROnPlateau的实战选择
- StepLR:每
step_size轮将学习率乘以gamma,适合训练初期快速下降。例如StepLR(optimizer, step_size=10, gamma=0.5)在第10、20轮后学习率减半。 - ReduceLROnPlateau:当验证损失连续
patience轮未改善时衰减,更适合精细调优。本实验采用后者,因其能自适应收敛状态:
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', factor=0.5, patience=3, verbose=True, min_lr=1e-6 ) # 在validate()后调用: val_loss, _ = validate(model, val_loader, criterion, device) scheduler.step(val_loss) # 传入验证损失,scheduler自动判断是否衰减注意:
ReduceLROnPlateau必须传入标量指标(如val_loss),不能传accuracy(mode应为'max')。此处用loss更符合其设计初衷。
3.2 数据加载的隐藏陷阱:MNIST的标准化为何用0.1307/0.3081?
MNIST官方统计显示:
- 训练集均值 = 0.1307,标准差 = 0.3081
- 测试集均值 = 0.1325,标准差 = 0.3105
使用训练集统计量对训练集和测试集同时标准化,是标准做法。若误用测试集统计量,会导致分布偏移:
from torchvision import datasets, transforms transform_train = transforms.Compose([ transforms.ToTensor(), # [0,255]→[0,1] transforms.Normalize((0.1307,), (0.3081,)) # 单通道,元组需逗号 ]) train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform_train) test_dataset = datasets.MNIST('./data', train=False, transform=transform_train) # 注意:同样用0.1307/0.3081!为什么不是[0,1]归一化就够了?
- CNN权重初始化(如He初始化)假设输入服从均值为0、方差为1的分布;
Normalize将数据拉回标准正态,加速收敛并提升泛化性;- 若只做
ToTensor(),模型需额外学习偏置项补偿均值偏移,增加优化难度。
4. 可视化与诊断:用Grad-CAM定位判别区域、用TSNE降维观察特征聚类
4.1 Grad-CAM热力图:让CNN“说出”它关注数字的哪一部分
Grad-CAM(Gradient-weighted Class Activation Mapping)通过反向传播的梯度加权卷积特征图,生成类敏感热力图。本实验提供轻量级实现,无需额外库:
def grad_cam(model, img_tensor, target_class, conv_layer): """ model: 训练好的LeNet5 img_tensor: [1,1,28,28] 张量 target_class: int, 如预测类别索引 conv_layer: 要可视化的卷积层,如model.conv2 """ model.eval() img_tensor.requires_grad_(True) # 前向传播获取特征图和logits x = F.relu(model.conv1(img_tensor)) x = F.max_pool2d(x, 2) x = F.relu(model.conv2(x)) # 目标特征图 [1,16,8,8] feature_map = x.clone() # 保存特征图用于加权 x = F.max_pool2d(x, 2) x = torch.flatten(x, 1) x = F.relu(model.fc1(x)) x = F.relu(model.fc2(x)) logits = model.fc3(x) # [1,10] # 获取目标类别的得分并反向传播 score = logits[0, target_class] score.backward() # 计算梯度均值作为权重 gradients = conv_layer.weight.grad # [16,6,5,5],但我们需要通道梯度 # 更准确的做法:取最后一层卷积输出的梯度(即feature_map的grad) pooled_gradients = torch.mean(feature_map.grad, dim=[0, 2, 3]) # [16] # 加权组合特征图 for i in range(16): feature_map[0, i, :, :] *= pooled_gradients[i] # 全局平均池化得到热力图 cam = torch.mean(feature_map, dim=1).squeeze() # [8,8] cam = torch.relu(cam) # ReLU确保非负 cam = F.interpolate(cam.unsqueeze(0).unsqueeze(0), size=(28,28), mode='bilinear')[0,0] return cam.detach().numpy() # 使用示例 img, label = test_dataset[0] img_tensor = img.unsqueeze(0) # [1,1,28,28] output = model(img_tensor) pred_class = output.argmax().item() cam_heatmap = grad_cam(model, img_tensor, pred_class, model.conv2)结果解读:
- 热力图高亮区域即CNN认为对分类最重要的像素区域(如数字“8”的上下环、数字“1”的竖直笔画);
- 若热力图分散或集中在边缘,提示模型可能过拟合噪声或未学到结构特征;
- 此技术直接回应“CNN到底学到了什么”的核心疑问,是大作业答辩高分关键。
4.1.2 特征空间可视化:用TSNE验证卷积层是否完成有效分离
全连接层前的256维向量是CNN提取的高级特征。用TSNE将其降至2D可视化,可直观检验类间分离度:
from sklearn.manifold import TSNE import matplotlib.pyplot as plt def visualize_features(model, data_loader, device, n_samples=1000): model.eval() features = [] labels = [] with torch.no_grad(): for data, target in data_loader: if len(features) >= n_samples: break data, target = data.to(device), target.to(device) # 提取fc1前的特征:即pool2后的flatten结果 x = F.relu(model.conv1(data)) x = F.max_pool2d(x, 2) x = F.relu(model.conv2(x)) x = F.max_pool2d(x, 2) x = torch.flatten(x, 1) # [N,256] features.append(x.cpu()) labels.append(target.cpu()) features = torch.cat(features)[:n_samples].numpy() labels = torch.cat(labels)[:n_samples].numpy() # TSNE降维 tsne = TSNE(n_components=2, random_state=42, perplexity=30) features_2d = tsne.fit_transform(features) plt.figure(figsize=(10,8)) scatter = plt.scatter(features_2d[:,0], features_2d[:,1], c=labels, cmap='tab10', s=1) plt.colorbar(scatter) plt.title('TSNE of CNN Features (256-dim → 2D)') plt.show() # 调用 visualize_features(model, test_loader, device)典型现象分析:
- 理想情况:10个数字形成10个清晰簇团,簇内紧密、簇间分离;
- 过拟合迹象:某几类(如4/9、3/8)严重重叠;
- 特征无效:所有点混成一团,无结构。
5. 大作业交付清单与避坑指南:从源码结构到答辩话术
5.1 源码文件组织:让老师3秒内确认你做了“真工作”
一个合格的大作业源码包必须包含以下文件,且命名体现专业性:
lenet5_mnist/ ├── main.py # 主训练脚本(含argparse参数) ├── model.py # LeNet5类定义(含详细注释) ├── utils.py # Grad-CAM、TSNE可视化等工具函数 ├── requirements.txt # 明确列出torch==1.13.1 torchvision==0.14.1 ├── README.md # 包含:运行命令、各文件作用、关键参数说明(如conv1 kernel_size=5) └── checkpoints/ # 自动保存best_model.pth禁止行为:
- 将所有代码塞进一个
train.py; requirements.txt写torch>=1.0(版本不兼容会导致复现失败);README.md只写“运行python train.py即可”。
5.1.1 参数配置表:答辩时被问“为什么这样设”有据可依
| 参数名 | 当前值 | 设定依据 | 可调范围 | 效果影响 |
|---|---|---|---|---|
batch_size | 64 | GPU显存(GTX1060 6GB)与收敛稳定性平衡 | 32~128 | 过小→梯度噪声大;过大→内存溢出 |
learning_rate | 0.01 | LeNet-5经典初值,配合StepLR衰减 | 0.001~0.1 | 过大→loss震荡;过小→收敛慢 |
kernel_size | 5 | MNIST低分辨率下保留结构信息 | 3,5,7 | 3需padding=1保尺寸,5最平衡 |
num_epochs | 30 | 验证集acc在25轮后饱和 | 20~50 | 少于20→欠拟合;多于40→过拟合风险增 |
提示:答辩时若被问“为何不用ResNet”,可答:“本实验目标是解构CNN基础组件,ResNet的残差连接会掩盖卷积/池化本身的贡献,不符合教学目的。”
5.2 三个必演示的“高光时刻”:让答辩老师主动提问
- 展示Grad-CAM热力图对比:同一张“7”图,分别用conv1和conv2生成热力图——conv1关注边缘(笔画粗细),conv2聚焦语义(数字闭合区域),证明层次化特征提取;
- TSNE动态演化:训练第1/10/25轮的特征散点图,展示簇团从混沌到分离的过程;
- 错误样本分析:找出1个被误判为“5”的“3”,用Grad-CAM显示模型关注了“3”的上半圆(像“5”),说明判别依据合理但数据扰动导致偏差。
这些演示不依赖复杂工具,全部基于本实验源码,却能瞬间建立“你真的懂CNN在做什么”的专业印象。
5.3 最后检查清单:避免因低级错误丢分
- [ ]
model.py中forward()函数末尾没有F.softmax()——CrossEntropyLoss内部已包含,重复添加会降低数值稳定性; - [ ]
main.py中torch.manual_seed(42)放在if __name__ == '__main__':内,确保可复现; - [ ]
requirements.txt包含matplotlib(可视化必需)和scikit-learn(TSNE必需); - [ ]
README.md明确写出:“本代码使用PyTorch原生API实现,未调用torchvision.models中的预训练模型,所有层手动构建”。
当你的代码能回答“这个ReLU为什么放这里”“这个池化步长怎么影响后续层”“这个准确率数字背后是什么数学”,它就不再是“大作业”,而是你深度学习能力的实体证明。
本文还有配套的精品资源,点击获取