PyTorch手写数字识别实验:从LeNet-5原理到Grad-CAM可视化
2026/9/14 5:15:30 网站建设 项目流程

简介:本资源是一份面向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 tensorflowmodel.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)输出尺寸计算实际输出
Conv128×28(5,0,1)⌊(28+0−5)/1⌋+1 = 2424×24
Pool124×24MaxPool2d(2,2)⌊(24+0−2)/2⌋+1 = 1212×12
Conv212×12(5,0,1)⌊(12+0−5)/1⌋+1 = 88×8
Pool28×8MaxPool2d(2,2)⌊(8+0−2)/2⌋+1 = 44×4
Linear输入4×4×16flatten后为 batch_size × 256256

该表必须手算一遍——当你的代码报错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.txttorch>=1.0(版本不兼容会导致复现失败);
  • README.md只写“运行python train.py即可”。
5.1.1 参数配置表:答辩时被问“为什么这样设”有据可依
参数名当前值设定依据可调范围效果影响
batch_size64GPU显存(GTX1060 6GB)与收敛稳定性平衡32~128过小→梯度噪声大;过大→内存溢出
learning_rate0.01LeNet-5经典初值,配合StepLR衰减0.001~0.1过大→loss震荡;过小→收敛慢
kernel_size5MNIST低分辨率下保留结构信息3,5,73需padding=1保尺寸,5最平衡
num_epochs30验证集acc在25轮后饱和20~50少于20→欠拟合;多于40→过拟合风险增

提示:答辩时若被问“为何不用ResNet”,可答:“本实验目标是解构CNN基础组件,ResNet的残差连接会掩盖卷积/池化本身的贡献,不符合教学目的。”

5.2 三个必演示的“高光时刻”:让答辩老师主动提问

  1. 展示Grad-CAM热力图对比:同一张“7”图,分别用conv1和conv2生成热力图——conv1关注边缘(笔画粗细),conv2聚焦语义(数字闭合区域),证明层次化特征提取;
  2. TSNE动态演化:训练第1/10/25轮的特征散点图,展示簇团从混沌到分离的过程;
  3. 错误样本分析:找出1个被误判为“5”的“3”,用Grad-CAM显示模型关注了“3”的上半圆(像“5”),说明判别依据合理但数据扰动导致偏差。

这些演示不依赖复杂工具,全部基于本实验源码,却能瞬间建立“你真的懂CNN在做什么”的专业印象。

5.3 最后检查清单:避免因低级错误丢分

  • [ ]model.pyforward()函数末尾没有F.softmax()——CrossEntropyLoss内部已包含,重复添加会降低数值稳定性;
  • [ ]main.pytorch.manual_seed(42)放在if __name__ == '__main__':内,确保可复现;
  • [ ]requirements.txt包含matplotlib(可视化必需)和scikit-learn(TSNE必需);
  • [ ]README.md明确写出:“本代码使用PyTorch原生API实现,未调用torchvision.models中的预训练模型,所有层手动构建”。

当你的代码能回答“这个ReLU为什么放这里”“这个池化步长怎么影响后续层”“这个准确率数字背后是什么数学”,它就不再是“大作业”,而是你深度学习能力的实体证明。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询