MNIST手写数字识别实战:从数据预处理到CNN调参全记录 📅 发布时间:2026/8/26 5:43:48 👁 浏览次数: 简介机器学习入门常从图像分类任务开始而MNIST手写数字识别正是最经典的基准数据集。其核心原理在于将28×28像素的灰度图像映射到10个数字类别通过神经网络自动提取特征并优化参数。全连接网络与卷积神经网络是两大类主流模型前者结构简单适合理解反向传播后者借助卷积核共享权重有效降低参数量在图像任务上表现更优。这类技术广泛应用于光学字符识别、银行票据处理、邮政分拣等真实场景是深度学习工程实践的基石。本文基于PyTorch框架完整梳理了MNIST大作业的流程包括数据集下载避坑、预处理与DataLoader设计、MLP和CNN模型实现、训练调参、结果可视化以及常见错误排查为初学者提供一套可复现的实战方案。 老实说机器学习大作业选MNIST手写数字识别是我当初做得最快的一个决定也是收获最多的一次。MNIST数据集足够经典网上资料多到看不完又足够小60000张28×28的灰度图片普通笔记本都能轻松跑起来。我当时选这个题目就是看中它能把一整条机器学习项目流程完整走一遍读数据集、做预处理、设计神经网络、训练模型、评估效果最后把文档写出来。而且它属于被研究得最透的入门级问题哪怕中途卡住也大概率能找到现成经验。这篇内容很适合正在做机器学习大作业的学生也适合刚学PyTorch但不知道从哪下手的人。做这个项目之前建议你先想清楚一件事大作业不是“跑通代码”就结束老师评分的重点往往在文档说明、实验结果分析和代码规范性上。所以下面我会按项目设计、数据获取、模型实现、调参评估、常见问题这条线展开把我实际踩过的坑和解决办法都写出来。1. 项目整体设计与思路拆解1.1 为什么选MNIST做机器学习大作业MNIST全称是Modified National Institute of Standards and Technology database由Yann LeCun等人整理。它包含70000张手写数字图片其中60000张用于训练、10000张用于测试每张图片都是28×28像素的灰度图内容对应0到9一共10个数字类别。这个规模放在今天来说非常轻量不需要昂贵的GPU也不需要在数据处理上耗费太多时间非常适合课程大作业。从评分角度看MNIST能覆盖的知识点非常密集。你可以用它解释数据预处理、神经网络结构、反向传播、损失函数、过拟合与正则化等概念。哪怕老师要求比较严格也能很容易往深度扩展比如对比全连接网络和卷积网络或者分析不同初始化方法对收敛速度的影响。这些内容写进文档比单纯贴一段训练日志要有说服力得多。另外MNIST还有一个隐藏的好处它是一个“烂大街”的数据集几乎所有的深度学习框架和教程都会内置或提供下载入口。正因为资料多遇到问题时搜索成本很低不用像做某些领域数据集那样卡在一个冷门bug上好几天。个人建议是大作业题目如果没有限定具体数据集优先选MNIST做主线再搭配一个对比模型或一个额外的小数据集会显得更有工作量。1.2 大作业整体架构与技术栈选择做机器学习大作业最忌讳的是“一上来就写模型”。我更建议先搭一个清晰的整体架构把流程拆成五层数据层负责下载/加载MNIST数据集完成归一化、批处理、可选的数据增强模型层定义神经网络结构比如全连接网络MLP或卷积神经网络CNN训练层实现训练循环包含损失计算、反向传播、参数更新、学习率调整评估层在测试集上计算准确率输出分类报告和混淆矩阵可视化层绘制损失曲线、准确率曲线展示预测结果和错误样本。这样分层的好处是每部分可以独立调试。如果训练结果不对你可以先检查数据层有没有问题再看模型层输出shape是否正确而不是在一大段代码里翻来翻去找不到原因。技术栈方面我推荐Python 3.9PyTorch 2.xtorchvision再搭配matplotlib和numpy。为什么选PyTorch而不是TensorFlow两个原因其一PyTorch是动态计算图调试时可以随时打印中间张量的shape和值对新手非常友好其二大作业文档里需要展示模型结构和训练流程PyTorch的代码写出来更接近Python直觉老师看起来也舒服。如果你们课程用的是TensorFlow那也可以但下面我给的代码需要做一点API层面的转换。1.3 时间规划与交付物组织很多同学做这种大作业容易在“下载数据”或“调参”上耗掉大量时间最后文档草草了事。我的建议是提前规划好时间参考以下划分数据准备1小时内完成包括下载手动数据集、确认加载成功、写好预处理。模型设计2小时完成至少实现MLP和CNN两个模型并跑通一次训练循环。训练调优2到3小时完成重点观察损失曲线调整学习率和Dropout。可视化与结果分析2小时完成保存损失曲线、混淆矩阵、预测样例。文档撰写留出完整半天把实验背景、原理、结果和心得写透。交付物方面参考标题里的“数据集源代码文档说明”建议做成这样的目录结构MNIST_Project/ ├── data/ │ └── MNIST/ ├── src/ │ ├── model.py │ ├── train.py │ ├── evaluate.py │ └── utils.py ├── docs/ │ ├── 实验报告.md │ └── 运行说明.md ├── requirements.txt └── README.md源代码里不要写死绝对路径尽量都基于项目根目录的相对路径这样压缩包发给老师也能直接跑。requirements.txt里固定好关键库的版本避免环境不一致导致报错。2. 数据集获取与预处理不踩坑的完整流程2.1 从MNIST原始文件讲起目录结构与数据格式MNIST官方提供的原始数据是4个.gz压缩文件放到data/MNIST/raw目录下完整文件名是train-images-idx3-ubyte.gz train-labels-idx1-ubyte.gz t10k-images-idx3-ubyte.gz t10k-labels-idx1-ubyte.gz文件名的含义很直白train是训练集t10k是测试集10000张images存图片像素labels存标签。idx1和idx3是文件格式标识代表不同的维度结构。虽然用torchvision.datasets.MNIST加载时不需要自己解析这些二进制格式但我还是建议花几分钟弄懂原理。idx3格式的前4个字节是魔数接下来4个字节是样本数量再接下来是行数和列数最后才是像素数据。idx1格式类似只是后面只有样本数量和标签。以后如果你要处理不是MNIST的自定义数据集这种二进制解析能力会派上用场。教学演示时也可以写一个小函数读取原始字节加深对“数据在硬盘上到底长什么样”的理解。2.2 torchvision下载MNIST报404手动下载与镜像源解决这两年使用torchvision.datasets.MNIST时一个高频问题就是下载时报404或超时。原因很简单PyTorch官方代码中默认的下载地址是http://yann.lecun.com/exdb/mnist/这个服务器现在访问不太稳定经常出现HTTP Error 404。很多同学看到报错第一反应是自己网络问题实际上换个下载源就能解决。我的处理办法有三种按推荐程度排列方案一手动下载到本地。用浏览器或命令行下载4个.gz文件分别重命名为上面列出的文件名放到data/MNIST/raw目录下。然后加载时设置downloadFalsefrom torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST( root./data, trainTrue, transformtransform, downloadFalse ) test_dataset datasets.MNIST( root./data, trainFalse, transformtransform, downloadFalse )注意文件名必须严格一致大小写、连字符都不能错。如果文件名不对程序会认为数据集不存在即使你明明下载了。方案二使用镜像下载地址。如果是Linux服务器或命令行环境可以直接用wget从镜像站拉取常见可用镜像之一是https://ossci-datasets.s3.amazonaws.com/mnist/。4个文件的下载命令大致是wget https://ossci-datasets.s3.amazonaws.com/mnist/train-images-idx3-ubyte.gz -P data/MNIST/raw/ wget https://ossci-datasets.s3.amazonaws.com/mnist/train-labels-idx1-ubyte.gz -P data/MNIST/raw/ wget https://ossci-datasets.s3.amazonaws.com/mnist/t10k-images-idx3-ubyte.gz -P data/MNIST/raw/ wget https://ossci-datasets.s3.amazonaws.com/mnist/t10k-labels-idx1-ubyte.gz -P data/MNIST/raw/方案三从同学或老师那里拷贝现成的MNIST文件夹。很多高校机房或实验室都会缓存常用数据集拷贝到项目目录后同样把download设为False。拷贝后一定要检查raw目录下的4个文件是否完整不能只拷一半。下载完成后最好检查一下文件大小避免下载中断导致解压失败。4个文件的完整大小大致是train-images约9.9MBtrain-labels约28KBt10k-images约1.6MBt10k-labels约4.5KB。如果相差太大删除重下比强行解压更省时间。提示如果运行时出现RuntimeError: The archive is corrupted几乎可以断定是.gz文件没有下完整或者文件被第三方工具损坏。不要想着在代码里绕过直接重新下载即可。2.3 数据预处理与DataLoader加载器设计数据预处理这一步常见做法是ToTensor()加Normalize()。ToTensor()会把PIL图像或numpy数组转成[0,1]范围的浮点张量Normalize()则用均值和标准差做标准化让数据分布接近标准正态分布有助于模型更快收敛。MNIST官方常用的均值和标准差是transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])这里的0.1307和0.3081是训练集全体像素计算出来的统计量很多教程会直接当固定值使用。自己做实验时完全可以先跑一遍统计代码验证但没必要每次重复算。数据加载器DataLoader的设计也有讲究。我一般设置batch_size128shuffleTruenum_workers根据机器性能来。Windows上num_workers设成2或4可能触发多进程报错最简单是设成0让数据加载在主进程完成速度慢一点但稳定。如果机器内存不大建议不要把batch_size开到256以上否则一次迭代加载的数据太多反而拖慢训练。数据增强这块容易走极端。MNIST是标准灰度数字空间变换过于丰富反而可能破坏数字结构。我个人经验是大作业主线模型不用加数据增强或者最多加一个微小的随机旋转角度不超过10度。加了增强后训练时间会变长准确率不一定提升写文档时还得多解释为什么这样设计。如果是为了展示“数据增强能缓解过拟合”可以单独设计对比实验而不是默认加上。加载完成后建议先打印一批数据看看images, labels next(iter(train_loader)) print(images.shape) # torch.Size([128, 1, 28, 28]) print(labels.shape) # torch.Size([128])如果shape不对大多是因为transform写错或DataLoader参数配错。我见过不少同学在这里卡住其实只需要确认两个数字图片张量有没有1这个通道维度标签是不是一维整数。3. 神经网络模型设计与实现从MLP到CNN3.1 不同神经网络怎么选全连接、BP与CNN的区别MNIST大作业中“基于神经网络”是一个很宽泛的要求。很多同学会直接上卷积神经网络CNN这没错但为了体现对机器学习原理的理解我强烈建议同时实现一个全连接网络MLP做对比。机器学习期末复习时经常提到的BP反向传播和“前馈神经网络”本质上都和MLP高度相关前馈指信息从输入到输出单向流动反向传播则指训练时梯度的逆向传播方式。MLP的输入是一个28×28784维的向量。第一层全连接如果设256个神经元参数量就是784×256256200960个光这一层就超过20万参数。换成512个神经元更是直接翻倍。全连接层的问题在于它把二维图片强行拉成一维丢掉了像素之间的空间位置关系而且参数多、容易过拟合。CNN则天然适合图像。它通过卷积核在图片上滑动提取局部特征再用池化层降低分辨率保留主要信息。以我常用的结构为例输入(1,28,28) → Conv2d(1,32,3,padding1) → ReLU → MaxPool2d(2) → Conv2d(32,64,3,padding1) → ReLU → MaxPool2d(2) → Flatten到(64*7*7) → 全连接层(128) → Dropout → 输出层(10)这里卷积层的参数远小于全连接层第一层卷积核大小是3×3输入通道1输出通道32参数量是3×3×1×3232320个非常轻量。因为卷积核在整张图上共享权重所以CNN参数量小却能把准确率做到99%以上。MLP通常只能到97%到98%这就是“不同神经网络”在图像任务上的直观差距。3.2 两个可直接落地的PyTorch模型实现先写MLP模型结构是784→256→128→10中间用ReLU激活加Dropout防止过拟合import torch import torch.nn as nn class MLP(nn.Module): def __init__(self): super().__init__() self.fc1 nn.Linear(28 * 28, 256) self.fc2 nn.Linear(256, 128) self.fc3 nn.Linear(128, 10) self.relu nn.ReLU() self.dropout nn.Dropout(0.2) def forward(self, x): x x.view(x.size(0), -1) # (batch, 28, 28) - (batch, 784) x self.relu(self.fc1(x)) x self.dropout(x) x self.relu(self.fc2(x)) x self.dropout(x) x self.fc3(x) return x这里x.view(x.size(0), -1)非常关键它把每个样本从1×28×28展平成784维向量。很多维度不匹配的报错就是漏了这一步。再写CNN模型class CNN(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) self.relu nn.ReLU() self.dropout nn.Dropout(0.25) def forward(self, x): x self.pool(self.relu(self.conv1(x))) # 28 - 14 x self.pool(self.relu(self.conv2(x))) # 14 - 7 x x.view(x.size(0), -1) # - (batch, 64*7*7) x self.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) return x为什么全连接层输入是64*7*7因为输入是28×28经过一次padding1的3×3卷积后仍是28×28池化变成14×14第二次卷积后仍为14×14池化变成7×7。输出通道是64所以展平后是64×7×7。写完模型后可以用以下代码统计参数量写文档时也会用到def count_parameters(model): return sum(p.numel() for p in model.parameters() if p.requires_grad) mlp MLP() cnn CNN() print(count_parameters(mlp)) # 约 23万 print(count_parameters(cnn)) # 约 20万左右3.3 损失函数、优化器与标准训练循环多分类问题最常用的损失函数是nn.CrossEntropyLoss()。它内部已经包含了Softmax操作所以模型的最后一层直接输出原始logits即可不需要手动加Softmax。这一点初学者很容易搞混如果在最后一层显式加了Softmax再传进CrossEntropyLoss训练可能会变慢甚至不收敛因为损失函数又把输出当作logits处理相当于对已经归一化的概率再做了一次Softmax。优化器我用的是Adam学习率设为0.001。相比SGDAdam对学习率的敏感度低在大作业这种规模的数据集上表现更稳。如果课程要求使用SGD可以尝试0.01的学习率加momentum0.9效果也还行。标准训练循环可以直接封装成函数import torch.optim as optim from tqdm import tqdm def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss 0 correct 0 total 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() total_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) avg_loss total_loss / total acc correct / total return avg_loss, acc验证时要用model.eval()和torch.no_grad()否则会多算梯度白白消耗内存还可能因为BatchNorm或Dropout行为不同导致结果偏差def evaluate(model, test_loader, criterion, device): model.eval() total_loss 0 correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total这段代码还有个隐藏知识点model.train()和model.eval()并不是只影响计算结果Dropout层在训练时随机丢弃神经元在验证时保持全量BatchNorm在训练时用当前batch的统计量在验证时用累计运行均值。如果忽略了这两行结果会忽高忽低。4. 训练与评估从过拟合到高精度的调参实录4.1 一组典型训练结果与指标解读我随手用上面的CNN结构跑了一次10个epoch的MNIST训练batch_size128Adam学习率0.001设备是普通CPU。训练过程大致是第一个epoch结束时测试集准确率已经能到98%左右后续几个epoch逐渐爬升到99%以上。MLP则明显慢一些同样条件下大概到97%到98%之间。如果你画一条训练损失曲线会发现CNN的loss下降比较平滑MLP在中后期可能出现轻微震荡。这不是模型坏了而是Dropout在训练时随机丢弃神经元造成的正常现象。真正需要警惕的是“训练集准确率很高、测试集准确率明显偏低”一旦出现这种差距代表模型过拟合了。测试集上MNIST最常见混淆组合包括4和9、7和9、3和5、2和7。这是因为部分手写数字确实在视觉特征上接近。这也是大作业报告里很好的分析素材与其只贴一个“准确率99%”不如展示混淆矩阵指出哪些数字容易混淆分析原因。4.2 调参要点学习率、Dropout、早停与随机种子学习率是最常见的“翻车点”。Adam默认0.001在MNIST上是比较稳妥的但如果模型输出的是NaN大概率是学习率过大。如果学习率太小比如1e-5loss会下降得非常缓慢10个epoch看不到明显效果。可以先固定0.001跑几轮观察损失曲线再决定要不要调低。Dropout比例方面MLP我一般设0.2到0.5CNN的全连接层前设0.25左右。设太大会欠拟合设太小起不到正则化作用。可以把Dropout理解成“训练时故意制造一点噪声让模型不能死记硬背训练样本”但对MNIST这种简单的任务过拟合压力并不大所以比例不要堆太高。早停是个实用的策略。在训练过程中每完成一个epoch就在验证集上算一次loss如果连续3个epoch验证loss都没有下降就停止训练并恢复最佳模型。对于大作业来说早停可以避免无意义地烧时间也能证明你了解“防止过拟合”的工程手段。最后记得在代码开头固定随机种子def set_seed(seed42): torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) import numpy as np np.random.seed(seed) import random random.seed(seed)不固定随机种子每次跑出来的结果不同写文档时如果你说“复现了实验”老师可能真去跑一遍结果和你报告对不上体验会很差。4.3 把结果可视化并写进报告训练结束后我习惯把两类图保存下来曲线图和预测样例图。曲线图包括训练损失、验证损失、训练准确率、验证准确率直接用matplotlib绘制import matplotlib.pyplot as plt plt.figure(figsize(8, 4)) plt.plot(train_losses, labeltrain loss) plt.plot(val_losses, labelval loss) plt.xlabel(epoch) plt.ylabel(loss) plt.legend() plt.title(Loss Curve) plt.savefig(docs/loss_curve.png, dpi150)预测样例图更直观。从中随机挑8到10张测试图片把图片、真实标签、预测标签排列在一起如果预测错了就把错误标签标红。这样的图放进报告比任何文字都更有说服力。混淆矩阵可以用sklearn.metrics.confusion_matrix计算然后用seaborn.heatmap画出来。大作业报告里有这三张图实验部分基本就丰满了。如果还想更专业可以再画一下各类别的precision、recall、F1-score这些信息用classification_report几行就能输出。5. 常见问题与排查技巧实录5.1 数据加载与预处理阶段的常见坑围绕数据加载我遇到的典型问题可以整理成下面的速查表现象可能原因解决办法HTTP Error 404或URLError官网下载源不可达手动下载或用镜像源见2.2节RuntimeError: The archive is corruptedgz文件未下载完整删除文件重新下载检查文件大小FileNotFoundError文件放错目录或文件名不一致放在data/MNIST/raw严格命名4个文件Dataset not founddownloadFalse但raw目录为空把download设为True或补全文件中文路径导致读取异常项目路径含中文把项目放到纯英文路径下养成好习惯DataLoader报多进程错误Windows下num_workers设置过大设num_workers0或移到Linux环境预处理阶段还有个很容易被忽略的问题如果你用matplotlib显示图片记得张量要先转回numpy并且通道维度在最后。比如img images[0].squeeze().numpy() # shape (28, 28) plt.imshow(img, cmapgray)如果直接用plt.imshow(images[0])可能会因为shape是(1,28,28)而显示异常或报错。5.2 模型训练阶段的高频报错模型训练阶段的报错90%都集中在“shape不匹配”和“设备不一致”上。先看shape问题。mat1 and mat2 shapes cannot be multiplied是经典提示意思是你在全连接层输入了错误维度的向量。比如MLP里忘了view(-1, 784)或者CNN展平后尺寸不是64*7*7。排查办法很简单在forward里临时加几行print(x.shape)跑到哪里断了就是哪里的问题。再看设备问题。如果写了images.to(device)但忘了model.to(device)训练时只会告诉你“Expected all tensors to be on the same device”。很多同学的GPU训练失败都源于此。我习惯在训练函数开头统一检查device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device)另外loss.item()这个操作要求loss是标量如果模型输出多标签或形状不对loss.item()可能报错。确认一下你的label是不是一维的LongTensorCrossEntropyLoss不需要one-hot编码如果是one-hot反而会出问题。我还见过一种隐蔽的错误准确率长期停在10%左右。这通常不是模型崩溃而是训练流程没跑起来比如忘记调用optimizer.step()或者写了optimizer.zero_grad()在backward()之后不过影响不大。遇到这种“看似没报错但结果异常”的情况建议从训练循环里每个关键步骤检查前向、loss、梯度归零、反向传播、参数更新。5.3 大作业文档与源代码交付建议标题里明确写了“数据集源代码文档说明”所以交付时三样东西必须齐全。很多同学提交的时候只压缩了代码忘了数据集或者文档写得很敷衍这很可惜。文档我建议按下面这个结构走摘要一句话说明做了什么、达到什么效果环境准备Python版本、依赖库、是否需要GPU数据集介绍MNIST规模、图片尺寸、类别数、预处理方式模型设计MLP和CNN的层结构、参数量、为什么选这个结构训练流程损失函数、优化器、学习率、batch_size、epoch数实验结果准确率、损失曲线、混淆矩阵、错误样例分析问题与心得写两三个实际遇到的问题和解决过程老师很爱看这部分附录代码结构说明和运行命令。源代码不建议写成一个大文件最好按模块拆分。至少要有model.py、train.py、evaluate.py、utils.py这四类。每个文件开头写三行docstring说明用途函数名和变量名尽量有语义。README里写清运行顺序和依赖安装命令还有数据集的获取方式避免老师拿到手后不知道怎么跑。另外数据集文件较大如果提交平台有大小限制可以把data目录排除但在README里写清楚下载链接或提供下载脚本。更稳妥的做法是单独写一个download_data.py老师运行后自动下载到本地。这样既满足“数据集”要求又不会让压缩包爆炸。提示实践中我建议你至少把训练好的模型权重文件.pth或.ckpt也保存一份文档里写明“模型权重在哪个路径测试时如何加载”。这能让老师快速复现你的效果也能避免他为了看结果还要重新训练。最后分享一个我自己的习惯即便时间再紧我也会把遇到的每个报错和解决办法记录下来。哪怕只是在文档里列一个“问题和解决”表格最后整理报告时会省力不少。MNIST这个项目虽然简单但它是你理解神经网络训练流程的起点。做完这次之后再做YOLO训练自己的数据集、MMRotate处理旋转目标这类进阶项目时你会觉得很多流程都是相通的——数据加载、模型构建、训练评估、问题排查本质都是同一套方法论。基础打牢后面才能走得更快。本文还有配套的精品资源点击获取