ARTICLE DETAIL

资讯详情

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

手写数字识别实战:PyTorch CNN实现与期末作业交付全指南

手写数字识别实战:PyTorch CNN实现与期末作业交付全指南 简介这份资源是面向计算机、电子信息工程、数学等专业大学生的机器学习期末大作业方案以MNIST手写数字识别为核心任务覆盖深度学习入门中最典型的图像分类场景适合用于课程设计、期末大作业以及入门级毕业设计参考。包内共14个文件包含12个Python源码文件和2张PNG图片压缩包整体仅20KB却涵盖模型搭建、训练、测试与界面交互等完整环节。代码实现包括图像预处理、LeNet-5网络的前向与反向传播、模型训练与评估、图形界面可视化等模块并配有效果截图便于核对运行结果与理解从数据到部署的流程。资源已通过运行测试采用参数化编程方便修改网络结构与超参数注释清晰便于二次开发。目前已有580人学习下载适合需要快速搭建手写数字识别项目、复现实验或参考工程结构的大学生开发者。1. 手写数字识别是机器学习期末大作业里最“划算”的一个题目手写数字识别这个题目表面上是机器学习入门课的标配实际上它是少数能同时覆盖“数据加载、模型设计、训练调参、结果分析”完整链条的作业题。大多数提交版本都能跑到99%以上的准确率但同样多的版本只写了“我用CNN训练了5个epoch”运行结果只有一张别人看不出含义的损失曲线代码也没有注释文档说明就更别提了。这篇笔记把源代码、文档说明、运行结果这三件事拆开讲适合正在做课程作业、或者想把手写数字识别做成一份完整可交付项目的同学。看完你可以照着搭出一个能复现、能解释、老师挑不出毛病的作业包。2. 期末大作业的选型思路为什么PyTorch CNN是稳妥起跑线2.1 三种常见实现路线各自适合什么情况手写数字识别的主流实现路线有三条基于sklearn的逻辑回归、基于sklearn的SVM、基于PyTorch的卷积神经网络。期末大作业选哪条取决于你课程讲到了什么程度以及你想在文档说明里写多深。逻辑回归是最简单的路线核心代码不超过三十行训练在CPU上十几秒就跑完。优点是代码短、容易解释缺点是上限很低MNIST上大约能到92%左右这个数字用经典机器学习算法很难再往上提。如果你的课程以传统机器学习为主作业要求重点在“特征提取分类器”而不在深度学习这条路完全够用。常见做法是先把图片像素归一化到0到1之间再拉平成784维向量直接塞进Softmax回归不需要做复杂特征工程。SVM路线同理用RBF核在MNIST的原始像素上也能跑出98%左右的准确率但训练时间明显变长如果不用PCA降维10000张验证图片的预测可能要等上几分钟。它的价值在于解释性支持向量、核函数、决策边界这些概念正好呼应课程理论考点。PyTorch CNN是当前最稳妥的选择。原因很直接手写数字识别几乎就是为CNN量身定做的入门场景MNIST的28×28灰度图用两层卷积就能达到99%以上训练在普通笔记本CPU上五轮epoch也只要几分钟GPU可有可无。而且PyTorch代码的可读性比TensorFlow 1.x时代的做法友好得多期末作业源码评审阶段一个继承nn.Module的模型类比一串tf.Session更让老师容易读。更关键的是CNN路线能支撑你写出一份内容充实的文档说明卷积核提取了什么特征、池化为什么能降维、Dropout为什么能抑制过拟合这些全是课本考点。如果你学的是《机器学习》周志华那本教材课程可能根本没讲深度学习。这种时候千万别硬上CNN用sklearn的逻辑回归或者SVM把重点放在“特征工程交叉验证模型对比”上更贴合课程大纲。判断标准很简单翻一下课程PPT如果神经网络只占最后一两讲那期末作业做传统机器学习路线就够了。2.2 期末大作业交付物应该长什么样源代码、文档说明、运行结果三件套标题里写了“源代码文档说明运行结果”这三件东西的分工要清楚。源代码解决“怎么做的”文档说明解决“为什么这么做”运行结果解决“做出来效果如何”。很多作业翻车不是因为模型跑不出准确率而是三件套各自为政对不上号。我一般会把作业包组织成这样的目录结构mnist_homework/ ├── README.md ├── requirements.txt ├── src/ │ ├── dataset.py │ ├── model.py │ ├── train.py │ └── evaluate.py ├── docs/ │ └── 实验报告.md ├── output/ │ ├── loss_curve.png │ ├── confusion_matrix.png │ ├── misclassified_samples.png │ └── model_state_dict.pth源代码部分要能做到“一键跑通”。requirements.txt里写清依赖README.md里写清从安装到出结果的三条命令。文档说明不要求长篇大论但要能回答三个问题你用了什么模型、为什么用这个模型、结果比基线好多少。运行结果不能只贴一张loss曲线要包含准确率、混淆矩阵、错误样本可视化这三个层次的证据。2.3 环境准备最小依赖清单与版本陷阱PyTorch的安装是第一个坑。很多同学的翻车现场是pip install torch装上了CPU版结果代码里有.cuda()调用直接报错。期末作业没必要用GPUCPU版PyTorch完全够跑MNIST关键是代码里不要写死CUDA用torch.device(cuda if torch.cuda.is_available() else cpu)这样的写法自适应环境。另一个常见问题是Python版本。PyTorch 2.x要求Python 3.8以上有些学校机房还在用Python 3.6装不上新版本。这种情况下可以用Anaconda先建一个干净环境再把依赖写进requirements.txt作业交上去的时候附上环境导出文件老师复现时少踩一半的坑。3. 手写数字识别的数据准备MNIST加载、划分与数据增强的边界3.1 MNIST数据集为什么成为机器学习作业的事实标准MNIST手写数字识别数据集由60000张训练图片和10000张测试图片组成每张是28×28的灰度图标注为0到9的标签。它的历史地位在于尺寸小、类别均衡、标注质量高是一个“正好能跑动深度学习实验”的规模。在GPU普及之前研究人员用它在CPU上就能验证一个新想法是否有效这也让它成了机器学习入门的第一块试验田。作业里要理解一个关键点MNIST自带的那10000张测试图片严格意义上不应该是你反复调参的依据。如果调一个超参数就去看一次测试集准确率测试集的信息就渗入了你的决策过程最后报出来的99.2%是有水分的。正确的做法是从60000张训练集里再切出一部分做验证集拿验证集调参全部调完再用测试集评估一次。代码里的对应写法是用torch.utils.data.random_split把训练集切分。3.2 用torchvision加载MNIST一行命令背后的参数细节torchvision提供了直接下载MNIST的接口第一件事是明确downloadTrue的行为第一次运行会从网络下载数据集到root目录之后再次运行不会重复下载。作业交到不同机器上时数据集路径要保持稳定否则老师复现时会在下载这一步卡住。from torch.utils.data import DataLoader from torchvision import datasets, transforms # 训练集ToTensor 会把 PIL 图片转成 [0,1] 区间的 Tensor不再额外做归一化 train_dataset datasets.MNIST( root./data, trainTrue, downloadTrue, transformtransforms.Compose([ transforms.ToTensor(), ]), ) # 从 60000 张训练集里切出 5000 张做验证集剩余 55000 张用于训练 train_subset, val_subset torch.utils.data.random_split( train_dataset, [55000, 5000], generatortorch.Generator().manual_seed(42) ) train_loader DataLoader(train_subset, batch_size64, shuffleTrue, num_workers0) val_loader DataLoader(val_subset, batch_size64, shuffleFalse, num_workers0) # 测试集shuffleFalse 保证评估顺序固定方便后期画混淆矩阵 test_dataset datasets.MNIST( root./data, trainFalse, downloadTrue, transformtransforms.Compose([ transforms.ToTensor(), ]), ) test_loader DataLoader(test_dataset, batch_size64, shuffleFalse, num_workers0)transforms.ToTensor()这个步骤经常被误解。它做的事情有两件把PIL Image或者numpy数组转成PyTorch张量同时把像素值从[0,255]缩放到[0,1]。这意味着你不需要再额外做x / 255.0的归一化做了反而是错的。另一个参数是num_workers在Windows系统上如果设成大于0的值代码可能在数据加载阶段报BrokenPipeError期末作业环境不统一建议直接设成0。3.3 数据增强要不要做MNIST的特殊性手写数字识别的作业里数据增强是个很容易用力过猛的点。对MNIST做随机旋转、随机裁剪、添加噪声都能提升模型对小规模扰动的鲁棒性但期末作业的验证集也是从MNIST里切出来的分布与训练集一致增强带来的提升往往只有零点几个百分点却会显著增加训练时间和代码复杂度。按照我的经验MNIST场景下做两个增强就够了RandomAffine里的微小旋转以及RandomCrop配合padding。如果你想在文档说明里写“我做了数据增强来提升泛化性”那么增强幅度要克制比如旋转控制在±10度以内并且要在报告里对比增强前后的验证集准确率否则老师一问“增强到底涨了多少点”你答不上来反而减分。这里有一个更省事的做法把增强只作用在训练集上验证集和测试集保持原始数据。这样做的原因是评估结果必须反映模型在“真实场景”上的表现如果你对验证集也做了随机旋转验证集准确率会带有额外的随机性不利于多次实验对比。4. 手写数字识别核心源码模型定义、训练循环与评估照着抄就能跑4.1 模型结构两层卷积为什么是性能与可解释性的平衡点手写数字识别的CNN模型没有必要上ResNet或者VGG那种深层结构。28×28的输入图本身就很小第一层卷积输出32个特征图第二层输出64个特征图再接一个全连接层参数量大约在几百万量级CPU上训练完全可行。结构再深比如加到四层卷积准确率提升不超过0.2%但训练时间翻倍而且文档说明里解释每一层的作用会变得很困难。import torch.nn as nn class DigitCNN(nn.Module): 手写数字识别专用CNN2层卷积 2层全连接参数量约 1.2M def __init__(self, num_classes10, dropout_rate0.5): super().__init__() self.features nn.Sequential( # 输入: (batch, 1, 28, 28) nn.Conv2d(1, 32, kernel_size3, padding1), # 输出: 3228x28 nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 输出: 3214x14 nn.Conv2d(32, 64, kernel_size3, padding1), # 输出: 6414x14 nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 输出: 647x7 ) self.classifier nn.Sequential( nn.Flatten(), # 64*7*73136 nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplaceTrue), nn.Dropout(pdropout_rate), nn.Linear(128, num_classes), ) def forward(self, x): return self.classifier(self.features(x))代码里的padding1是一个容易忽略的细节。它保证卷积前后特征图尺寸不变让MaxPool2d正好把28×28变成14×14再变成7×7最后全连接层的输入维度才是确定的64 * 7 * 7 3136。如果你去掉padding第二层卷积之后特征图变成6×6全连接层的输入维度就要改成64 * 6 * 6很多报错都是从这里来的。4.2 训练循环交叉熵损失、Adam优化器与学习率的搭配逻辑训练部分的核心是“损失函数 优化器 学习率”三件事。分类问题用nn.CrossEntropyLoss()这个函数在PyTorch里同时做了softmax和交叉熵计算所以模型的最后一层不要额外接nn.Softmax()否则损失会算错。优化器选Adam是比较稳的默认选择它对学习率的敏感度远低于SGDMNIST场景下lr0.001基本不需要调。import torch import torch.nn as nn from torch.utils.tensorboard import SummaryWriter def train_model( model, train_loader, val_loader, epochs5, lr0.001, devicecpu, seed42, ): # 固定随机种子保证作业可复现 torch.manual_seed(seed) model.to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lrlr) train_losses [] val_accuracies [] for epoch in range(epochs): # ---- 训练阶段 ---- model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) epoch_loss running_loss / len(train_loader.dataset) train_losses.append(epoch_loss) # ---- 验证阶段 ---- val_acc evaluate(model, val_loader, device) val_accuracies.append(val_acc) print(fEpoch {epoch 1}/{epochs} | Loss: {epoch_loss:.4f} | Val Acc: {val_acc:.2f}%) return train_losses, val_accuracies def evaluate(model, loader, devicecpu): 在给定数据加载器上计算分类准确率不修改模型参数 model.eval() correct 0 total 0 with torch.no_grad(): # 推理阶段关闭梯度计算省内存且提速 for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, dim1) correct (predicted labels).sum().item() total labels.size(0) return 100.0 * correct / totaloptimizer.zero_grad()放在每次迭代开头是PyTorch的固定习惯因为梯度默认是累加的。如果你漏掉这一行模型的梯度会在每个batch之间叠加loss曲线会变成剧烈震荡甚至直接发散。torch.no_grad()用于评估阶段它告诉PyTorch不需要计算梯度这个上下文管理器能把推理内存占用降下来同时避免BatchNorm层在推理时更新统计量。4.3 评估与推理混淆矩阵、错误样本与模型保存准确率只能说明模型“大体上”是好的这学期最难的2和7、4和9到底错在哪需要混淆矩阵来回答。期末作业的运行结果部分至少要包含三张图训练损失曲线、混淆矩阵热力图、错误样本展示。保存模型用torch.save(model.state_dict(), path)只存权重不存整个模型对象这样可以避免PyTorch版本不同导致的加载失败。import matplotlib.pyplot as plt import numpy as np from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay def evaluate_with_details(model, loader, devicecpu): 返回预测标签序列与真实标签序列用于后续绘图分析 model.eval() all_preds [] all_labels [] with torch.no_grad(): for images, labels in loader: images images.to(device) outputs model(images) _, predicted torch.max(outputs, dim1) all_preds.extend(predicted.cpu().numpy()) all_labels.extend(labels.numpy()) return np.array(all_preds), np.array(all_labels) preds, labels evaluate_with_details(model, test_loader, device) cm confusion_matrix(labels, preds) fig, ax plt.subplots(figsize(8, 8)) disp ConfusionMatrixDisplay(confusion_matrixcm) disp.plot(axax, cmapBlues, colorbarFalse) plt.title(Confusion Matrix on MNIST Test Set) plt.savefig(confusion_matrix.png, dpi150, bbox_inchestight)混淆矩阵的读法要写进文档说明主对角线越亮越好非对角线上的亮点就是系统性的混淆点。MNIST上最常见的混淆是4和9、3和8因为这两种数字在笔画结构上的确相似这种解释比写“准确率99.2%”更有说服力。4.4 命令行入口把三件套串起来让源代码能被一键复现期末作业的代码不能再是一堆散乱的.py文件必须有一个命令行入口让评审老师打开终端就能跑。用Python标准库argparse就能做到不用引入click或者typer避免增加依赖。# train.py —— 一键训练 评估 出图 import argparse def main(): parser argparse.ArgumentParser(descriptionMNIST 手写数字识别训练脚本) parser.add_argument(--epochs, typeint, default5) parser.add_argument(--batch_size, typeint, default64) parser.add_argument(--lr, typefloat, default0.001) parser.add_argument(--dropout, typefloat, default0.5) args parser.parse_args() model DigitCNN(dropout_rateargs.dropout) train_losses, val_accuracies train_model( model, train_loader, val_loader, epochsargs.epochs, lrargs.lr, ) # 保存三件套模型权重、损失曲线图、测试准确率 torch.save(model.state_dict(), model_state_dict.pth) plot_loss_curve(train_losses, val_accuracies) test_acc evaluate(model, test_loader) print(fTest Accuracy: {test_acc:.2f}%) if __name__ __main__: main()参数用命令行暴露出来既方便你自己调参时不用改源码也方便老师复现时用--epochs 10 --lr 0.0005这种命令去验证不同设置下的表现。评价一个期末作业源码写得好不好就看评审老师能不能不看报告就独立跑完整个流程。能做到这一步源代码这项已经给你加印象分了。5. 手写数字识别作业避坑五个让期末大作业翻车的典型问题5.1 现象CUDA out of memory代码在自己电脑上跑得好好的到老师机器上报错原因代码里写了model.cuda()或者images.cuda()这种硬编码CUDA的写法。老师的机器不一定有NVIDIA显卡即使有显存也可能只有2GB而你把batch_size设成了256。期末作业复现环境五花八门这种写法是翻车重灾区。解决所有设备相关代码统一改成device torch.device(cuda if torch.cuda.is_available() else cpu)模型和tensor都用.to(device)迁移。同时把batch_size默认值设为64这个值在2GB显存和纯CPU环境下都能跑。如果确实想在作业里展示GPU性能在文档说明里写清楚“本实验在GPU上训练CPU环境可通过减小batch_size复现”。5.2 现象训练损失在下降验证准确率却始终卡在98%附近换模型也没用原因98%左右的准确率其实是“只用原始像素特征、未做精细调参”的合理水平。MNIST很多类别的数字模板相似度不高简单模型能轻松到97%以上但4和9、3和8这种易混淆类别需要模型学到更精细的笔画特征如果训练集增强不足或者模型容量不够准确率就会在这个平台期停滞。解决先把batch_size降一半看准确率是否变化排除优化器的问题。接着给训练集加一个幅度在±10度以内的RandomRotation增强让模型看到更多形变样本。如果还不行把第一层卷积的通道数从32提到64参数量上去之后记得加Dropout(0.5)防止过拟合。这一套组合拳下来MNIST到99%以上是可复现的结果。5.3 现象自己跑出来的准确率是99.2%换台机器复现只有98.6%怀疑是代码有bug原因代码里没有固定随机种子。PyTorch的卷积核初始化、数据加载器的shuffle顺序、甚至CPU上的某些算子都带随机性不固定种子两次训练的结果天然会有波动。解决在训练脚本开头固定三重种子torch.manual_seed(42)、np.random.seed(42)以及数据加载器里的generatortorch.Generator().manual_seed(42)。注意random_split也需要传入generator才能固定划分结果。固定种子之后同一份代码在同一环境下跑出来的准确率波动应该控制在0.05个百分点以内。5.4 现象预测结果和真实标签对不上画出来的错误样本图里图片是“7”但标签写“3”原因数据加载器的shuffle顺序没有固定导致训练结束后记录的预测顺序与测试集的标签顺序错位。很多人写评估逻辑时习惯用next(iter(test_loader))取一个batch看看效果而test_loader的shuffle如果是True每次迭代出来的图片顺序都不一样。解决测试集的DataLoader一定要设shuffleFalse这是评估代码里的铁律。保存预测结果时用all_preds.extend(predicted.cpu().numpy())累积所有batch的预测而不是只记录最后一个batch。画错误样本图时用predicted ! labels作为筛选条件然后按索引去取原始图片避免单独维护一份“预测标签”列表。5.5 现象代码交上去老师运行时报ModuleNotFoundError: No module named torchvision原因本地环境装了torchvision但是requirements.txt里漏写了或者写的是torchvision0.10.0这种和当前PyTorch版本不匹配的旧版本号。期末作业的依赖声明讲究的是“能跑”而不是“版本最新”。解决requirements.txt里写torch和torchvision的主版本范围即可不要锁死patch版本。建议格式是torch1.13.0和torchvision0.14.0另外加上matplotlib、numpy、scikit-learn这几个常用的。如果你用的是Anaconda还可以在README里附上一句conda create -n mnist python3.9的建环境命令这能帮你过滤掉大量环境问题。6. 运行结果的进阶呈现混淆矩阵、错误样本与参数对照实验期末作业的运行结果部分决定了老师对你的印象是在“交差”还是“在做研究”。三个呈现技巧能显著拉高完成度。第一混淆矩阵一定要用颜色映射展示并且把对角线数字标出来让易混淆的类别一目了然。第二单独打印一张6×6的错误样本图每个样本上面标真实标签和预测标签这比任何文字都更有说服力。第三做一个学习率和batch_size的对照实验表格里列出不同参数组合下的验证准确率证明你理解这些超参数的作用。# 错误样本可视化展示前30个预测错误的图片 misclassified_indices np.where(preds ! labels)[0][:30] fig, axes plt.subplots(5, 6, figsize(12, 10)) for i, ax in enumerate(axes.flat): idx misclassified_indices[i] ax.imshow(test_dataset[idx][0].squeeze(), cmapgray) ax.set_title(fTrue: {labels[idx]}, Pred: {preds[idx]}, fontsize10) ax.axis(off) plt.tight_layout() plt.savefig(misclassified_samples.png, dpi150, bbox_inchestight)参数对照实验找一个做就行比如固定其他参数分别跑lr0.001、0.0005、0.0001把验证集准确率和训练时间记到一张表里。这一切做完之后你的文档说明里就有了一手数据而不是复述别人的实验结论。我自己做这类作业养成的习惯是调参时把每次实验的命令和结果追加到一个experiments.md里最后写报告时直接复用不会出现“忘了当时learning rate是多少”的尴尬。希望这篇笔记帮到你也祝你这份作业一次过。本文还有配套的精品资源点击获取
返回列表