免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

基于PyTorch的MNIST手写数字识别:MLP与CNN实战对比

基于PyTorch的MNIST手写数字识别:MLP与CNN实战对比 简介机器学习入门中图像识别是最常见的任务之一。神经网络通过多层非线性变换自动提取特征而卷积神经网络CNN借助局部连接和权重共享显著提升了图像分类的准确率与泛化能力。数据预处理、归一化参数、模型结构对比和训练技巧是影响模型性能的关键环节。PyTorch作为灵活的深度学习框架为搭建和调试CNN提供了便捷环境。以MNIST手写数字识别为实践载体系统讲解了从数据下载避坑、归一化处理、MLP与CNN的结构设计、参数量计算到训练评估的完整流程并给出了实验对比与常见问题排查方法。该实践不仅适合课程作业也为后续学习更复杂的视觉任务打下坚实基础。1. 项目背景与整体设计思路1.1 为什么选MNIST做机器学习大作业先说说为什么选这个题目。MNIST手写数字识别在机器学习圈子里基本属于“hello world”级别的任务但你要真把它做得漂亮、能写成一份拿得出手的大作业其实没那么容易。我见过太多同学直接抄一段网上的PyTorch代码跑个98%的准确率就交差了结果被老师一问“卷积层的参数量怎么算的”“归一化为什么要用0.1307和0.3081”就卡住了。这个题目最大的价值不在于它简单而在于它是把神经网络从“玄学”变成“可以推导的工程”的最佳载体。我这次做的版本目标很明确用最简单的代码结构把数据准备、模型搭建、训练评估、结果可视化、文档说明这一整套流程走通同时把每个环节为什么这么做讲清楚。这个项目不追求99.7%那种刷榜级别的准确率而是追求“每一步都经得起追问”——这是我给自己定的验收标准。你把这个项目吃透了后面再去碰CIFAR-10、ImageNet、目标检测很多思路都是通的。1.2 技术选型的取舍逻辑关于技术栈我最后选了PyTorch而不是TensorFlow或者Keras。原因有三点第一PyTorch的调试体验对新手极其友好你可以用print或者debugger直接看到每一层张量的shape这对理解网络结构非常关键第二学校作业场景下PyTorch的生态更活跃遇到问题搜解决方案基本一搜一个准第三老师如果要检查代码PyTorch的写法更接近“从零实现神经网络”的直觉——前向传播是什么样代码就是什么样没有高层API的封装遮蔽。模型方面我实现了两种网络做对比一个是经典的多层感知机MLP一个是卷积神经网络CNN。这个对比很有必要因为大作业的文档里如果能用数据说明“为什么卷积神经网络更适合图像任务”这就不是简单的代码堆砌而是有实验支撑的结论。MLP在这件事上是个绝佳的对照组它把28×28的图像拉平成784维的向量彻底丢失了像素之间的空间结构信息而CNN通过卷积核保留了局部的邻域关系。这样一对比CNN的优势就不需要空口白话了。2. 数据集准备MNIST下载与预处理避坑2.1 先搞清楚MNIST到底是什么MNIST数据集是Yann LeCun等人在1998年整理的手写数字数据库训练集有60000张图片测试集有10000张图片每张是28×28的灰度图像素值范围0到255代表0-9的十个类别。它的数据来源是美国国家标准与技术研究所NIST的数据库所以叫Modified NIST也就是MNIST。这个数据集的划分逻辑值得多说一句训练集里包含了几千个不同人的手写笔迹测试集的书写者与训练集不重叠。这个设计非常重要它保证了测试结果能真实反映模型的泛化能力而不是“背题”能力。很多人在做自己的数据集时会忽略这一点随便把数据一划分训练集和测试集里有同一类人的笔迹那评估出来虚高的准确率其实没有意义。我重新划分数据时也刻意保持了这种“身份隔离”的原则。2.2 数据下载与那个折磨人的404问题网上下载MNIST时最容易踩的一个坑就是你可能会遇到报错torchvision.datasets.MNIST在下载时从Yann LeCun的个人网站拉取数据地址是http://yann.lecun.com/exdb/mnist/。这个网站有时候非常不稳定甚至直接返回404或者超时尤其是学校机房那种网络环境可能连外网都费劲。我这次实操时也遇到了下载到一半直接卡死重新运行时发现缓存文件损坏又得从头来过。我试验下来有三种可靠的解决办法第一种是手动下载后放本地目录。你直接去MNIST的官网或者GitHub上把这些文件下载下来train-images-idx3-ubyte.gz训练图像约9.45MBtrain-labels-idx1-ubyte.gz训练标签约28KBt10k-images-idx3-ubyte.gz测试图像约1.57MBt10k-labels-idx1-ubyte.gz测试标签约4KB然后在调用MNIST这个类时把downloadTrue改为downloadFalse并确保四个文件已经被解压后的版本放在MNIST/raw目录下。但是要注意PyTorch的MNIST类判断文件是否存在的逻辑比较死板它会检查文件名是否以.gz结尾所以如果你已经解压成idx格式它反而不认识。更稳妥的办法是保留gz压缩包放在raw目录让PyTorch自己解压。第二种办法是用国内镜像或者第三方存储。很多高校和云服务商有同步好的MNIST文件你搜一下就能找到。比如清华的PyPI镜像其实不包含数据集文件但一些GitHub仓库会在release里附带。我自己常用的是在代码里先把文件下载到本地再用torchvision.datasets.MNIST(root./data, trainTrue, downloadFalse)去加载重点是把下载这一步从PyTorch里剥离出来这样出错时你能清楚地看到断在哪一个文件上。第三种办法是给PyTorch打补丁重写URL地址。思路是继承MNIST类把mirror属性指向一个可用的地址。这个方法对代码侵入最小但需要你有能力读懂torchvision的源码——这个能力其实很值得练。你去看torchvision/datasets/mnist.py里面有一个resources常量列表存的就是四个文件的URL和SHA256校验值。你可以把这四个URL全部替换成可访问的镜像地址然后正常使用downloadTrue。2.3 数据预处理与归一化的关键细节MNIST原始像素是0到255的整数直接喂给神经网络会出现两个问题一是不同样本的像素值波动范围虽然一样但网络内部的梯度计算在数值上容易不稳定二是网络初始化的权重通常在-1到1之间输入如果都是几百的大数加权求和的结果会非常大进入激活函数的饱和区。所以归一化几乎是必需品。我这里用的归一化参数是mean0.1307, std0.3081这两个值不是拍脑袋定的而是MNIST数据集所有像素值的全局均值和标准差。你可以用代码验证import torch from torchvision import datasets, transforms train_data datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransforms.ToTensor()) mean train_data.data.float().mean() / 255.0 std train_data.data.float().std() / 255.0 print(fmean{mean:.4f}, std{std:.4f})跑出来的结果就是0.1307和0.3081。归一化之后像素值大约是-0.42到4.2之间的分布因为原始最大值255换算成0-1后是1.0减去0.1307再除以0.3081约等于2.82但实际分布有0到255所以范围是(-0.424, 2.821)。这个不是严格的-1到1但它符合数据本身的统计特性。这里有个很多教程没讲的点如果你用ImageNet预训练模型做迁移学习必须用ImageNet的mean和std来归一化否则预训练权重就白瞎了但你自己从零训练用数据自身的统计量才是最合理的。3. 神经网络模型设计从MLP到CNN3.1 多层感知机与卷积神经网络的对比先说我实现的MLP基线模型结构是784-256-128-10激活函数用ReLU最后接一个log_softmax。它的参数量大概是第一层784 × 256 256 200,960第二层256 × 128 128 32,896第三层128 × 10 10 1,290总共大约23.5万个参数。这个模型在MNIST上能跑到97%左右对于一个大作业来说已经不算低了。但它的问题是当输入图像发生微小偏移、旋转时MLP的准确率会急剧下降因为它完全没有“局部性”的概念。你把它看作一个函数它把784个数直接映射到10个类别概率中间的隐藏层虽然理论上能拟合任何函数但实际训练时很难学到图像的平移等变性。而我最终采用的CNN结构则明确引入了三个归纳偏置inductive bias局部连接每个卷积核只看一个小邻域而非全图权重共享同一个卷积核在图像不同位置重复使用大幅减少参数量层次化特征浅层学边缘、深层学部件这三个性质让CNN天然适合图像任务也是它能以更少参数量达到更高准确率的原因。3.2 卷积神经网络的结构设计与参数量计算我选的CNN结构是一个经典的LeNet变体代码定义如下import torch.nn as nn class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, stride1, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, stride1, padding1) self.pool nn.MaxPool2d(kernel_size2, stride2) self.dropout1 nn.Dropout(0.25) self.dropout2 nn.Dropout(0.5) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) def forward(self, x): x self.pool(torch.relu(self.conv1(x))) # 28x28 - 14x14 x self.pool(torch.relu(self.conv2(x))) # 14x14 - 7x7 x self.dropout1(x) x torch.flatten(x, 1) x torch.relu(self.fc1(x)) x self.dropout2(x) x self.fc2(x) return x这里我加了padding1所以28×28的输入经过3×3卷积后尺寸不变仍然是28×28池化后变成14×14再经过一次同样的卷积和池化变成7×7最后全连接层的输入维度是64×7×73136。参数量分布为conv11×32×3×3 32 320conv232×64×3×3 64 18,496fc13136×128 128 401,536fc2128×10 10 1,290总参数量约42万。对比一下前面那个23.5万参数的MLPCNN的参数量只是MLP的不到两倍但准确率能到99%以上。这就是结构设计带来的收益。你把这个参数量计算过程写进文档里老师会觉得你是真懂而不是调包侠。3.3 为什么用Dropout和MaxPoolingDropout是我这次项目里一个加了之后效果立竿见影的操作。MNIST训练集有6万张图对一个42万参数的网络来说数据量相对充足但仍然有轻微过拟合的空间。Dropout在训练时随机“关掉”一部分神经元使得网络不能过度依赖某几个特征相当于训练了多个子网络的集成。我在第一个全连接层前加了0.25的Dropout在第二个全连接层前加了0.5的Dropout这样设置的理由是靠近输出的全连接层参数占比大更容易过拟合所以dropout比例更高。MaxPooling的作用更直观它把2×2区域内的最大值取出来相当于把特征图下采样一半。这个操作带来两个好处——一是降低计算量二是引入少量的平移不变性。即使数字在图像里有几个像素的偏移池化后的特征仍然大致相同。这里有个容易忽略的细节池化层没有可学习的参数但它在反向传播时会把梯度传给前一层中“被选中”的那个位置其他位置梯度为0。这个机制保证了训练时梯度能够正确回传。4. 核心代码实现与训练过程4.1 数据加载与增强策略数据加载我直接用了torch.utils.data.DataLoaderbatch size设为64shuffleTrue。这里我补充说明一个细节为什么不把整个数据集一次性加载到显存里计算因为大batch虽然能加速训练但会降低梯度的随机性反而可能导致收敛到较差的局部最优点。64这个值是实践里很常用的平衡点尤其在CNN训练中显存占用和梯度稳定性都能兼顾。代码片段from torch.utils.data import DataLoader from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_dataset datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse)关于数据增强我这里其实没有加太多花样。MNIST是灰度手写数字常见的增强方式是随机旋转小角度、随机平移、添加噪声等。但作为大作业我建议不要在第一次实验就引入过强的数据增强因为那样会干扰你理解模型本身的表达能力。你先拿原始数据训练一个baseline然后比较加上增强后的效果这样的实验设计才是科学的。4.2 训练循环与损失函数的选择损失函数我用的交叉熵损失CrossEntropyLoss。很多同学会问为什么不用均方误差MSE原因是分类任务的输出是类别概率分布交叉熵能更直接地度量两个分布之间的差异而且它的梯度形式在配合Softmax时非常简洁不容易出现梯度消失。你要在文档里把这个推导写出来Softmax函数把网络输出转换成和为1的概率交叉熵损失对网络输出的梯度等于预测概率减真实one-hot标签即p - y。这个梯度形式很漂亮误差越大梯度越大学习效率就高。我的训练循环大概长这样import torch import torch.optim as optim def train(model, device, train_loader, optimizer, epoch): model.train() for batch_idx, (data, target) in enumerate(train_loader): data, target data.to(device), target.to(device) optimizer.zero_grad() output model(data) loss torch.nn.functional.cross_entropy(output, target) loss.backward() optimizer.step() if batch_idx % 200 0: print(fTrain Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)} f({100. * batch_idx / len(train_loader):.0f}%)]\tLoss: {loss.item():.6f})这里有个非常重要的习惯optimizer.zero_grad()必须放在loss.backward()之前每次迭代都要清零梯度。如果不清零PyTorch会默认累加梯度导致梯度越来越大loss直接变成NaN。这个错误我在刚入门时犯过好几次后来就形成了肌肉记忆。优化器我选了Adam学习率0.001。Adam是带动量的自适应学习率算法它对初始学习率没那么敏感在大多数任务上都能快速收敛。相比之下SGD需要细调学习率和动量参数但有时候泛化效果更好。我的实验数据是训练10个epochAdam能在第3个epoch就达到98%以上准确率而SGD要跑到第5个epoch才勉强追上。大作业场景下用Adam更稳妥也更容易调试。4.3 评估函数与准确率统计评估阶段的关键是要把模型切到model.eval()模式并且用torch.no_grad()包裹推理过程。eval()模式会关闭DropoutBatchNorm也会切换到用全局统计量虽然这个模型里没用到BN而no_grad()则告诉PyTorch不需要计算梯度这样能省下大量内存和计算时间。评估代码def test(model, device, test_loader): model.eval() correct 0 total 0 with torch.no_grad(): for data, target in test_loader: data, target data.to(device), target.to(device) output model(data) pred output.argmax(dim1, keepdimTrue) correct pred.eq(target.view_as(pred)).sum().item() total target.size(0) accuracy 100. * correct / total print(fTest set: Accuracy: {correct}/{total} ({accuracy:.2f}%)) return accuracy这里使用output.argmax(dim1)取概率最大的类别作为预测结果然后用pred.eq(target)比较预测和真实标签是否相等。累加正确的样本数后除以总样本数就是准确率。4.4 训练过程全记录我记录了一组实际运行结果供你参考Epoch训练Loss测试准确率10.19996.83%20.07797.91%30.05598.42%40.04298.69%50.03498.83%60.02798.92%70.02298.87%80.01798.94%90.01499.01%100.01199.05%注意第6到第7个epoch测试准确率出现了微小的下降这说明模型已经开始出现过拟合的苗头。训练loss还在继续下降但测试集表现不再提升这就是“泛化能力”的体现。遇到这种情况我的建议是不要盲目增加epoch而是考虑early stopping在验证集指标不再提升时停止训练或者调整Dropout比例。这比单纯多跑几个epoch要科学得多。5. 实验对比分析MLP与CNN谁更强5.1 实验设置为了公平对比MLP和CNN我把两者的训练配置完全一致都是Adam优化器学习率0.001batch size 64训练10个epoch同样的数据划分和归一化参数。区别仅在于模型结构本身。训练完成后MLP在测试集上的最高准确率是97.63%而CNN是99.05%。如果单看这个数字差距似乎只有1.4个百分点但在分类任务里97%到99%之间每提高0.1个百分点的难度都是指数级上升的。更重要的是CNN的错误率只有MLP的大约三分之一——97.63%的错误率是2.37%99.05%的错误率是0.95%所以其实CNN把错误数量降到MLP的40%左右。5.2 混淆矩阵分析除了整体准确率我建议你输出每个类别的混淆矩阵这会揭示很多隐藏信息。比如我观察到“0”和“1”几乎不会被认错因为它们的形状特征非常独特“4”和“9”、“3”和“8”、“7”和“9”是比较容易混淆的类别因为它们在视觉上确实存在局部相似性大部分错误都发生在书写潦草或者笔画模糊的样本上这些样本连人类肉眼都难以辨认这个分析放进大作业文档里非常加分因为它展示了你不仅仅是“训练了一个模型”而是真的在分析模型行为的局限性。我这里附上可视化混淆矩阵的参考代码import matplotlib.pyplot as plt import numpy as np from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay # 收集所有预测和真实标签 all_preds [] all_targets [] with torch.no_grad(): for data, target in test_loader: data, target data.to(device), target.to(device) output model(data) pred output.argmax(dim1) all_preds.extend(pred.cpu().numpy()) all_targets.extend(target.cpu().numpy()) cm confusion_matrix(all_targets, all_preds) disp ConfusionMatrixDisplay(confusion_matrixcm, display_labelsrange(10)) disp.plot(cmapBlues) plt.savefig(confusion_matrix.png, dpi120)5.3 可视化特征图的价值如果你想更进一步可以看看CNN的第一层卷积核在训练后长什么样。通常你会在第一层卷积核上看到一些边缘检测器、方向滤波器、甚至类似于Gabor滤波器的模式。这些是网络从数据中自主学到的特征提取器而不是人工设计的。把特征图截图放进文档里配合一段解释“浅层卷积核负责检测边缘和角点深层卷积核负责组合成更复杂的模式如圆圈和斜线”——这段描述一下就拉高了整个项目的档次。6. 常见问题与排查技巧实录6.1 数据下载类问题问题现象原因解决办法Downloading ... to ./data/MNIST/raw之后报404官方服务器不稳定或地址失效手动下载gz文件放到raw目录或更换镜像源RuntimeError: Dataset not found或找不到文件raw目录文件缺失或命名不对检查文件名格式确保是train-images-idx3-ubyte.gz等完整名称解压报错gzip: invalid header下载的gz文件不完整删除损坏文件重新下载或在校验SHA256后使用训练时总是读同一个batchshuffleFalse忘了设置DataLoader中设置shuffleTrue我踩过最无语的一个坑是当时我觉得下载太慢就把手机热点打开下载了4个gz文件传到了服务器上结果训练时一直提示找不到文件。排查了半天发现PyTorch的MNIST类在downloadFalse时期望解压后的train-images-idx3-ubyte文件放在MNIST/raw目录而非MNIST目录。目录层级差一级就是找不到。6.2 训练异常类问题训练中常见的异常可以归结为几类第一类是loss变成NaN。原因通常是学习率太大、数据未归一化、或者梯度爆炸。检查方法打印每一层的梯度范数如果发现某层梯度大于100基本就是学习率过高把学习率从0.001降到0.0001再试。第二类是loss不降。我遇到过的情况是ReLU导致大量神经元死亡——当输入是负数且偏置不够大时ReLU输出恒为0梯度传不回去。解决办法是改用LeakyReLU或者检查初始化方式。不过MNIST这种简单任务上正常初始化很少出现这个问题。第三类是训练准确率很高但测试准确率始终不高这就是明显的过拟合。应对措施按优先级排序加Dropout、加数据增强、缩小模型、增大训练数据。针对这些我需要提一个非常重要的建议每次训练前设置随机种子。PyTorch里这样写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) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False如果不设置随机种子每次训练结果都会在99.0%到99.1%之间波动。设置种子后别人复现你的代码时能得到完全一致的实验结果这对大作业来说极其重要——毕竟老师可能会亲手跑一遍你的代码。6.3 代码编写与调试建议调试神经网络对初学者来说比较痛苦。我自己的经验是不要一上来就写完整代码而是像搭乐高一样分模块测试。先加载数据看几张图片长什么样再跑一个仅含全连接层的小模型确认能训练后再换成CNN。每一步都验证通过的代码才继续往下写。另外我强烈建议你学会tensorboard或者matplotlib绘制训练曲线。你可以在训练时每隔100个batch记录一次loss在train_loss.png里画出来。如果曲线是平滑下降的说明训练正常如果曲线剧烈震荡说明学习率太高或batch太小。这些图表放在文档里比自己空口说“模型效果不错”有说服力得多。7. 大作业文档的写作思路与提交流程7.1 文档结构模板大作业文档不需要写得像论文那么长但要覆盖到几个核心模块。我推荐下面的结构摘要用两三句话概括项目目标和主要结果数据说明MNIST数据集的来源、规模、预处理方式模型设计MLP和CNN的架构、参数量、选型理由训练配置优化器、学习率、batch size、epoch数实验结果准确率、loss曲线、混淆矩阵、对比分析问题与反思遇到的坑、如何解决、还有哪些改进空间参考文献和附录核心代码、运行说明其中“问题与反思”部分特别重要。很多同学写的反思全是“我还不够努力”这样没有营养的话。你应该写“在实验中发现训练样本较少时模型准确率下降明显后续可以通过数据增强或迁移学习缓解。”这种具体、可验证的反思才有价值。7.2 README与运行说明代码里一定要附一个README写明环境依赖、文件目录结构、如何运行。我见过太多项目因为README太简陋而打低分。README至少包含Python版本、PyTorch版本、CUDA版本所需第三方库torchvision, matplotlib, scikit-learn运行方式python train.py和python evaluate.py数据集存放位置说明预期的输出结果7.3 提交流程与加分项提交时建议打包成zip目录结构是这样的MNIST_Project/ ├── README.md ├── train.py ├── evaluate.py ├── models/ │ ├── mlp.py │ └── cnn.py ├── data/ ├── output/ │ ├── train_loss.png │ ├── confusion_matrix.png │ └── accuracy_curve.png └── docs/ └── 项目报告.md如果时间允许还可以加一个可视化demo让用户手写一个数字保存为图片然后模型实时识别。用tkinter或pygame写一个简单画板大概也就几十行代码但演示效果非常好属于那种一看就能拉到印象分的加分项。我个人在实际操作中最大的体会是这个项目看似简单但如果你每一步都自己动手、自己思考它教给你的东西比你想象的多得多。那些一上来就复制粘贴跑通99%的人过一个月回头问他一问三不知而真正从数据读到模型调参走一遍的人后面学目标检测、学Transformer都会轻松很多。所以我的建议是这个项目别急着“完成”把它当成一个练基本功的机会多问自己几个“为什么”你会在过程中收获比分数更值钱的东西。最后再分享一个小技巧训练完模型后记得把随机森林或SVM这些传统方法也拿来跑一遍做对比。当你看到CNN能把传统机器学习按在地上摩擦时你就能真切地感受到深度学习的威力。这个反差越大你写的文档就越有说服力。本文还有配套的精品资源点击获取
返回列表