ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写数字识别期末作业:从可复现基线到99%准确率的完整实践

手写数字识别期末作业:从可复现基线到99%准确率的完整实践 简介面向机器学习课程期末大作业与毕业设计场景手写数字识别项目以MNIST数据集为基线完整覆盖LeNet-5网络的前向推理、反向传播、模型训练、测试评估与图形界面交互。压缩包共14个文件主体为12个Python脚本分别承担网络结构定义、参数训练、图像预处理、UI界面构建等任务另有2张运行效果图用于核对输出整包仅20KB目录紧凑、便于逐文件研读和二次开发。已有580人学习下载。代码采用参数化编程关键超参数可快速修改注释明细并附带可直接运行的测试结果从数据预处理到模型部署形成闭环适合计算机、电子信息工程、数学等专业学生快速理解深度学习项目全流程也可作为课程设计、期末大作业的完整参考方案。通过阅读脚本可掌握模型训练与调用方法在此基础上继续调整网络层数或优化器配置也较为方便。1. 手写数字识别期末作业模型能跑只是开始能讲清楚才是得分点机器学习期末大作业里手写数字识别是出现频率最高的选题之一MNIST 数据集、PyTorch 实现、一份能跑的源代码加文档说明和运行结果截图这套组合在很多课程里直接对应一份完整的大作业交付。问题是大多数队伍第一天就能训练出 97% 的准确率却在答辩前夜发现文档里写不清“为什么这样设计网络”“参数为什么这么设”“结果怎么复现”。这篇文章把从零搭一个可复现、可解释、能应对追问的手写数字识别作业的完整路径拆开适合准备交作业、想拿高分的同学照着做。2. 先立住基线用 PyTorch 跑通 MNIST 的最小可复现代码2.1 环境与数据准备下载 MNIST 的两种姿势做这个作业之前先把环境统一。Python 3.8 以上的版本PyTorch 配上 torchvision期末机器上没有 GPU 也完全够用因为 MNIST 是 28x28 的灰度图单张图片只有 784 个像素普通 CPU 就能在十几分钟内跑完一个像样的训练循环。环境最怕的是版本混乱torchvision 和数据读取方式耦合在一起版本太旧可能连数据集都下载不下来所以我的建议是直接建一个干净的 conda 环境装最新稳定版别在这个环节上省时间。数据读取用 torchvision.datasets.MNIST它会自动下载并缓存到本地 root 目录trainTrue 表示加载训练集downloadTrue 表示缺失时自动下载。第一次跑的时候网络慢是正常的也可以手动把四个 gz 压缩包放进 data 目录。下面是数据加载的最小代码import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 期末作业先不做增强只把图片转成 Tensor 喂给模型 transform transforms.ToTensor() train_dataset datasets.MNIST( root./data, trainTrue, transformtransform, downloadTrue, ) val_dataset datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue, ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse)root 参数决定数据集下载到哪个目录我一般会固定成一个项目内的 data 文件夹避免散落在用户目录里。batch_size 先取 64这是 CPU 训练时速度与显存占用的平衡点取 32 会更稳但更慢取 128 每个 epoch 的梯度更新次数变少期末作业用 64 不用纠结。shuffleTrue 只在训练集上开验证集保持顺序否则每次评测的样本顺序变化会影响你对准确率波动的判断。2.2 网络结构为什么选 CNN从全连接到卷积的改动量最小手写数字识别可以用全连接网络直接做28x28 拉平成一个 784 维向量接两层隐藏层准确率也能到 94% 左右。但期末答辩被问到“为什么用 CNN”时如果只回答“准确率高”很容易被追问到哑口无言。真正的原因是 28x28 的图像里数字的笔画是局部相关的全连接把每个像素单独当作特征丢失了“相邻像素组合成笔画”这种空间信息卷积核天然只在局部滑动一个 kernel 就能捕捉一条斜线或一个圆环这正是数字识别的关键结构。卷积网络的改动量其实比想象中小核心就是把特征提取部分替换成“卷积 池化”的组合最后的分类头仍然是全连接。这里给出一个没有 BatchNorm、没有 Dropout 的最小 CNN先把基线跑通后面再逐步加东西import torch.nn as nn class ConvNet(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplaceTrue), nn.Linear(128, 10), ) def forward(self, x): return self.classifier(self.features(x))第一个 Conv2d 的 in_channels1 对应灰度图的单通道out_channels32 是第一层卷积核数量期末这个量级足够。padding1 让卷积输出保持 28x28两次 MaxPool2d(2) 尺寸减半28 - 14 - 7所以最后特征图是 64 通道 7x7全连接输入维度是 64773136。这套结构的参数量大约在 40 万级别期末作业里属于“小但有效”的配置。2.3 训练循环与保存 checkpoint跑 10 个 epoch 能拿到什么结果模型定义好之后训练循环是固定套路前向传播算 loss反向传播算梯度优化器更新参数。这里有一个容易踩的坑是 model.train() 和 model.eval() 的切换如果后面加了 BatchNorm 或 Dropout 之后忘了切换验证集的准确率会被严重干扰。下面这段代码把训练、验证、保存三个动作都做了每次 epoch 结束打印一次损失和准确率这是我习惯的节奏省得每步打印把终端刷屏。import torch.optim as optim model ConvNet() criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) for epoch in range(10): model.train() total_loss 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() print(fEpoch {epoch1}/10, fLoss: {total_loss / len(train_dataset):.4f}, fVal Acc: {correct / total:.4f}) torch.save(model.state_dict(), mnist_cnn.pth)loss.backward() 之后一定要先 optimizer.zero_grad()否则梯度会在多次迭代里累加这个参数比学习率还容易被忽略。验证时用 torch.no_grad() 关闭梯度计算省内存也省时间。torch.max(outputs, 1) 返回每行最大值和对应索引predicted 就是模型预测的数字类别。最后保存的是 state_dict 而不是整个模型这是期末答辩最容易搞混的地方——state_dict 只保存参数加载时必须先用相同结构实例化模型再用 load_state_dict 恢复直接 torch.save(model) 虽然也能加载但对后续改代码非常不友好。这段代码跑完 10 个 epoch验证集准确率在 97% 到 98% 之间是正常水平。3. 把准确率从 97% 提到 99%数据增强、优化器与学习率调度3.1 数据增强写进文档里的“玄学”其实是可解释的MNIST 上从 97% 提到 99%靠硬调网络结构性价比很低最常见做法是加数据增强。很多同学对增强的理解停留在“让模型看到更多数据”但答辩时只说这一层会被认为没想清楚。增强的实质是构造训练分布与测试分布的差异真实手写数字存在轻微的旋转、位移和粗细不均训练集里原样的小写数字反而过于“干净”。RandomAffine 对图像做随机旋转和平移等于告诉模型“数字稍微歪一点、偏一点都会被识别”这跟人类识别手写体的方式是吻合的。增强只加在训练集上验证集必须保持原始分布否则评测结果就不是真实泛化能力。下面这个 Transform 是我期末作业里的标准搭配注意 Normalize 的均值和标准差不是随手填的MNIST 官方统计值是 0.1307 和 0.3081填 0.5 会让像素分布整体偏移影响收敛速度。from torchvision import transforms train_transform transforms.Compose([ # 旋转 ±10 度平移 ±10% 像素让模型适应轻微的笔画偏移 transforms.RandomAffine(degrees10, translate(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), ]) val_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)), ])RandomAffine 的两个参数degrees10 表示随机旋转范围是 -10 到 10 度translate(0.1, 0.1) 表示水平和垂直方向最多平移图像宽高的 10%。28 像素的 10% 是 2.8 像素这个幅度对 MNIST 足够太大会把 1 和 7 旋转到互相混淆。Normalize 用元组包一个值是方便扩展到 RGB 图像时对齐接口灰度图只用一位。训练时再在 DataLoader 里把 train_dataset 的 transform 换成 train_transformval_dataset 保持 val_transform别把两个写反了。3.2 优化器与学习率调度AdamW OneCycle 的期末友好组合优化器选 Adam 还是 AdamW期末作业差别不大但 AdamW 把权重衰减从梯度里拆出来单独处理文档里解释起来更容易站住脚。真正拉开差距的是学习率调度。很多教程只告诉你设置 lr1e-3不告诉你学习率在整个训练过程中应该怎么变化。OneCycleLR 的思路是先用一个较快的上升段让模型快速走出初始区域再用一个缓慢的下降段逼近局部最优。这个策略在 MNIST 上能在同样 epoch 数下把准确率再推高零点几个点。from torch.optim.lr_scheduler import OneCycleLR import torch.optim as optim optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) epochs 10 scheduler OneCycleLR( optimizer, max_lr3e-3, steps_per_epochlen(train_loader), epochsepochs, ) for epoch in range(epochs): model.train() for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() # 每个 batch 更新一次学习率必须在 optimizer.step() 之后 scheduler.step()max_lr3e-3 是学习率的上限OneCycle 会先从 max_lr 的十分之一升温到 max_lr再衰减到比初始学习率还低。steps_per_epochlen(train_loader) 告诉它一个 epoch 有多少个 batch算错了会导致学习率曲线整体错位。weight_decay1e-4 是 L2 正则的一种实现方式防止权重过大导致过拟合期末作业里设 1e-4 到 1e-5 都合理别设到 1e-2。被问到“学习率为什么这么设”时回答“先用默认 1e-3 做基线再加 OneCycle 从 1e-3 升温到 3e-3 再衰减”是拿得出手的调参逻辑。3.3 从 99% 到 99.5%BatchNorm、Dropout 与残差连接的边界加了增强和调度之后准确率通常会到 99% 上下。还想往上走就要动网络结构。BatchNorm2d 的作用是让每一层输入的分布稳定下来用均值和方差对特征做归一化这样后面的层不用去适应前面层越来越大的方差。Dropout 则随机把一部分神经元的输出置零迫使网络不依赖某一个节点。这两个机制在 MNIST 上的收益是能稳定超过 99%。残差连接在这个任务上反而是边际收益极低因为 28x28 的输入经过两层卷积已经足够抽象加一层 shortcut 只是把参数量提上去答辩时被问“为什么用残差”反而解释不清。import torch.nn as nn class ConvNetBN(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), # 先 BN 再激活训练更稳定 nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Dropout(0.3), # 只在全连接前 drop卷积层保留空间信息 nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplaceTrue), nn.Linear(128, 10), )这里 BN 放在 Conv 和 ReLU 之间顺序是 Conv - BN - ReLU这是被验证得比较多的做法BN 在激活之前归一化避免激活函数输出被拉到饱和区。Dropout(0.3) 而不是 0.5因为 MNIST 本身不复杂drop 掉一半信息反而让欠拟合风险变大。换了模型之后训练循环里的 model 实例要换成 ConvNetBN()其余代码不动。还要提醒一句加了 BN 之后model.train() 和 model.eval() 的区别不只是 DropoutBN 在训练时用当前 batch 的统计量评估时用训练阶段累计的滑动平均忘了切换会导致验证集结果不稳定。如果你还想往 99.5% 以上冲剩下的主要空间在测试集增强 TTA 和多模型融合这两种做法期末作业里不太划算一个让推理代码翻倍一个让文档很难讲清楚我一般不建议在期末阶段碰。把基线、增强、调度写到 99% 左右比堆模型拿一个数字更稳。4. 手写数字识别避坑指南答辩前最容易翻车的 5 个细节这一章把我在复现手写数字识别作业时真正遇到过的坑按“现象 - 原因 - 解决”三段式列出来。这些细节在机器学习期末作业里最容易被当成八股略过却恰恰是决定你代码能不能复现、文档能不能经得起追问的关键。4.1 训练集准确率 100%、验证集 96%过拟合与早停的判断现象训练集每个 epoch 结束准确率都是 100%验证集却始终在 95%-96% 徘徊loss 曲线在后期训练集还在降、验证集开始反弹。原因模型容量大于任务需求参数把训练集里的噪声也记下来了。解决第一个手段是加 Dropout把 0.3 提到 0.5 看验证集是否回升第二个手段是加早停自己定义一个 patience比如连续 3 个 epoch 验证集准确率没有提升就停止训练并保存最优模型。写文档的时候把“早停的 patience3”写进实验设置答辩时这就是一个明确的防过拟合策略比空说“用了正则化”更具体。4.2 CPU 上跑得太慢batch size 和 num_workers 的调参现象一个 epoch 要跑五六分钟10 个 epoch 等得人发麻你以为是自己代码写差了实际是默认配置没吃满机器。原因DataLoader 默认 num_workers0数据在主进程里一个一个预处理等于每次喂数据都要等 CPU 串行干活同时 batch_size32 会让每个 epoch 的迭代次数翻倍。解决Windows 上 num_workers 设 2 到 4Linux 上可以设到 4 到 8batch_size 提到 64 或 128再把打印频率改成每个 epoch 一次。改完大概率能快两三倍。注意 num_workers 在 Windows 上如果设太高会反复启动子进程反而更慢这个靠机器实际表现定。4.3 预测结果全是同一个数字归一化漏写与标签错位现象loss 在第一个 batch 之后就变成 nan或者模型预测全部输出同一个类别验证集准确率停在 10% 左右。原因最常见的两个一个是 Transform 里只写了 ToTensor() 没按 MNIST 的统计值做归一化像素分布偏移导致梯度方向异常另一个是数据加载时把 labels 拿错比如 enumerate 的时候取成了 index。解决在 DataLoader 外面打印一下第一个 batch 的 data.min()、data.max() 和 labels 的前十个值确认图像是 0-1 范围、标签是 0-9 的整数。再顺手打印一下 outputs 的 shape 是不是 (batch_size, 10)三秒定位问题。4.4 运行结果截图和代码对不上随机种子与复现现象今天跑出来 98.7%明天重跑变成 98.2%截图放到文档里老师真按步骤重跑了一遍发现数字对不上轻则觉得你复制了别人的结果重则直接质疑真实性。原因PyTorch 里随机初始化、DataLoader 的 shuffle、数据增强都依赖随机数生成器不固定种子每次结果不一样。解决训练脚本最前面固定三个种子既保证结果可复现也为文档里“运行结果”截图的真实性背书。import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)set_seed 必须在模型实例化和 DataLoader 创建之前调用如果先建了模型再设种子模型参数已经随机初始化完了等于白设。文档里要把 seed 值写出来并注明“复现时使用与实验相同的 seed 和同一份数据集”。MNIST 是固定数据集没有随机划分这个坑主要出在 shuffle 和增强上固定 seed 之后这两个环节就确定了。4.5 答辩演示时模型加载报错checkpoint 路径与设备迁移现象实训机器上训练好的模型拿到自己电脑上 load_state_dict 直接报 size mismatch或者报 no attribute。原因把保存整个 model 的 checkpoint 和保存 state_dict 的 checkpoint 混用了两个完全不同的恢复方式另一个常见问题是训练在 GPU 上加载时没指定 map_locationPyTorch 默认 CUDA 优先机器没显卡就崩。解决统一用 state_dict 保存和加载加载时显式指定 map_location。model ConvNetBN() # 必须与训练时同构否则 state_dict 对不上 checkpoint torch.load(mnist_cnn.pth, map_locationcpu) model.load_state_dict(checkpoint) model.eval()torch.load 的 map_location 参数把参数张量先映射到 CPU再从 CPU 拷贝到当前设备传字符串 cpu 之后即使原来用的是 GPU 也能顺利加载。加载完必须调一次 model.eval()否则 Dropout 和 BN 还在训练模式同样一份参数推理出来的准确率会明显偏低。还有一个不起眼但很容易踩的坑保存文件路径如果含中文Windows 上某些环境会编码报错项目路径尽量全英文。5. 让作业从“能跑”变成“能答辩”验证集的三种用法与可视化技巧5.1 混淆矩阵与错误样本可视化回答“模型哪里没学好”期末答辩常被问“模型哪里最弱”一张混淆矩阵比一百句“效果不错”都有用。MNIST 上 9 和 4、3 和 8 是最常见的混淆对因为它们的形状在收笔处容易交叉。把混淆矩阵和错误样本的九宫格放进文档结论写成“模型对 4 和 9 区分最弱主要原因是它们的上半部分笔画结构相似”这份结论就坐实了你做过错误分析而不是只贴结果。from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in val_loader: outputs model(images) _, predicted torch.max(outputs, 1) all_preds.extend(predicted.numpy()) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) plt.figure(figsize(8, 6)) plt.imshow(cm, cmapBlues) plt.colorbar() plt.xlabel(Predicted) plt.ylabel(True) plt.xticks(range(10)) plt.yticks(range(10)) plt.savefig(confusion_matrix.png, dpi150)confusion_matrix 的输入是两个一维数组顺序不能反第一个是真值、第二个是预测值。存图用 dpi150文档里插进来不会糊。如果你的环境没装 scikit-learn自己用一个 10x10 的零矩阵循环累加也就十行代码但对期末来说 sklearn 这个包基本是标配。5.2 用简化 Grad-CAM 证明模型在看“数字”而不是“背景”文档里如果能放一张热力图证明模型关注的区域集中在笔画而不是背景答辩的含金量会明显上一个台阶。完整 Grad-CAM 实现牵扯 hook 和梯度流期末阶段我用一个简化版取最后一个卷积层的特征图对预测类别计算特征图的梯度然后对通道做平均得到权重最后和特征图加权求和得到一张 28x28 的热力图。activations {} def forward_hook(module, input, output): activations[feat] output # model 是第 3.3 节训练完成的 ConvNetBN最后一个卷积层在 features[4] target_layer model.features[4] target_layer.register_forward_hook(forward_hook) image images[0].unsqueeze(0).requires_grad_(True) out model(image) _, pred torch.max(out, 1) model.zero_grad() feat activations[feat] grad torch.autograd.grad(out[0, pred], feat)[0] weights grad.mean(dim(2, 3), keepdimTrue) cam (weights * feat).sum(dim1, keepdimTrue).clamp(min0) cam torch.nn.functional.interpolate( cam, size(28, 28), modebilinear, align_cornersFalse )target_layer 选最后一个卷积层这一层特征图的语义最丰富也最容易解释。torch.autograd.grad 直接算特征图对预测类别的梯度结果里正值代表“这些像素让模型更倾向于预测这个数字”clamp(min0) 把负贡献抹掉。把 cam 叠加到原图上你会看到数字 7 的热量集中在中间斜杠和顶部横杠数字 0 的热量分布在一个环上这就是“模型在看数字本身”的可视化证据。注意每次生成热力图前 image 都要重新 requires_grad_否则会报 tensors does not require grad。5.3 写进文档的运行结果一张表、三张图、一句结论运行结果按这个结构写最稳一张表对比不同配置的验证集准确率三张图分别是训练 loss 曲线、混淆矩阵、Grad-CAM 热力图最后用一句话下结论。表里至少有三行无增强 Adam、增强 Adam、增强 AdamW OneCycle分别对应基线、改进、最终版本。三张图下面各配两三句说明重点解释“为什么这个图长这样”而不是只写“如图所示”。我自己的教训是第一次做这个作业时把 99% 的时间花在调模型结构上最后文档里的运行结果只剩一张截图和三行字答辩被问“这个准确率怎么复现的”时甚至想不起当初用的学习率。后来我每次都先固定种子、再按“基线到改进”的顺序记录实验文档自然就完整了。这个习惯帮我省掉了无数次重跑排查也推荐你先从固定种子和一张配置表开始希望帮到你。本文还有配套的精品资源点击获取
返回列表