PyTorch手写数字识别实验:从LeNet-5原理到Grad-CAM可视化
简介本资源是一份面向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,k5,s1,p0) → [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最大池化stride2既保证下采样率可控每层减半又避免因步长≠窗口导致的特征图错位全连接层前必须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)⌊(280−5)/1⌋1 2424×24Pool124×24MaxPool2d(2,2)⌊(240−2)/2⌋1 1212×12Conv212×12(5,0,1)⌊(120−5)/1⌋1 88×8Pool28×8MaxPool2d(2,2)⌊(80−2)/2⌋1 44×4Linear输入4×4×16—flatten后为 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_classes10): super().__init__() # Layer 1: Conv2d(1→6, k5, s1, p0) → ReLU → MaxPool2d(2,2) self.conv1 nn.Conv2d(in_channels1, out_channels6, kernel_size5, stride1, padding0) # 输入[1,28,28] → 输出[6,24,24] → 池化后[6,12,12] # Layer 2: Conv2d(6→16, k5, s1, p0) → ReLU → MaxPool2d(2,2) self.conv2 nn.Conv2d(in_channels6, out_channels16, kernel_size5, stride1, padding0) # 输入[6,12,12] → 输出[16,8,8] → 池化后[16,4,4] # Layer 3: 全连接层输入展平后为16*4*4256 self.fc1 nn.Linear(in_features16*4*4, out_features120) self.fc2 nn.Linear(in_features120, out_features84) self.fc3 nn.Linear(in_features84, out_featuresnum_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_size2, stride2) # → [N,6,12,12] x F.relu(self.conv2(x)) # → [N,16,8,8] x F.max_pool2d(x, kernel_size2, stride2) # → [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_channels1MNIST为单通道灰度图勿设为3kernel_size5对应LeNet-5原始设计若改为3需同步调整padding保尺寸如p1torch.flatten(x, 1)dim1表示从第1维channel开始展平保留batch维dim0这是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_norm1.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(dim1, keepdimTrue) 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(fEpoch {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(dim1, keepdimTrue) 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.0CNN梯度通常较稳定但加入此句可避免极端情况如初始权重过大导致NaNpred.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_size10, gamma0.5)在第10、20轮后学习率减半。ReduceLROnPlateau当验证损失连续patience轮未改善时衰减更适合精细调优。本实验采用后者因其能自适应收敛状态scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience3, verboseTrue, min_lr1e-6 ) # 在validate()后调用 val_loss, _ validate(model, val_loader, criterion, device) scheduler.step(val_loss) # 传入验证损失scheduler自动判断是否衰减注意ReduceLROnPlateau必须传入标量指标如val_loss不能传accuracymode应为max。此处用loss更符合其设计初衷。3.2 数据加载的隐藏陷阱MNIST的标准化为何用0.1307/0.3081MNIST官方统计显示训练集均值 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, trainTrue, downloadTrue, transformtransform_train) test_dataset datasets.MNIST(./data, trainFalse, transformtransform_train) # 注意同样用0.1307/0.3081为什么不是[0,1]归一化就够了CNN权重初始化如He初始化假设输入服从均值为0、方差为1的分布Normalize将数据拉回标准正态加速收敛并提升泛化性若只做ToTensor()模型需额外学习偏置项补偿均值偏移增加优化难度。4. 可视化与诊断用Grad-CAM定位判别区域、用TSNE降维观察特征聚类4.1 Grad-CAM热力图让CNN“说出”它关注数字的哪一部分Grad-CAMGradient-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, dim1).squeeze() # [8,8] cam torch.relu(cam) # ReLU确保非负 cam F.interpolate(cam.unsqueeze(0).unsqueeze(0), size(28,28), modebilinear)[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_samples1000): 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_components2, random_state42, perplexity30) features_2d tsne.fit_transform(features) plt.figure(figsize(10,8)) scatter plt.scatter(features_2d[:,0], features_2d[:,1], clabels, cmaptab10, s1) 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 # 明确列出torch1.13.1 torchvision0.14.1 ├── README.md # 包含运行命令、各文件作用、关键参数说明如conv1 kernel_size5 └── checkpoints/ # 自动保存best_model.pth禁止行为将所有代码塞进一个train.pyrequirements.txt写torch1.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需padding1保尺寸5最平衡num_epochs30验证集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-learnTSNE必需[ ]README.md明确写出“本代码使用PyTorch原生API实现未调用torchvision.models中的预训练模型所有层手动构建”。当你的代码能回答“这个ReLU为什么放这里”“这个池化步长怎么影响后续层”“这个准确率数字背后是什么数学”它就不再是“大作业”而是你深度学习能力的实体证明。本文还有配套的精品资源点击获取