资讯动态

从零开始用PyTorch实现CNN识别MNIST:完整实战指南

发布时间:2026/10/14 12:34:26 来源:尧图企业网站定制
1. 从零开始为什么用CNN识别MNIST值得认真做一遍很多人在入门深度学习时会跳过MNIST直接上手真实场景但我个人觉得MNIST仍然是学习CNN最合适的第一站。原因很简单数据集小、标注准确、尺寸统一一张图就28x28的灰度图训练一次只要几分钟跑在普通笔记本上也不会让你等得怀疑人生。更关键的是通过MNIST可以把卷积、池化、全连接、损失函数、训练循环这些概念完整串起来搞懂之后你再看其他图像分类任务基本上就是换数据集、调网络结构的问题了。我曾经带过一个项目组组里三位同学都是第一次接触深度学习框架我让他们先各自独立实现一个MNIST分类器不要急着上现成模型库。一周之后几个人对CNN的理解深度完全不一样照着教程抄代码的人遇到输入维度报错就发懵而从卷积层参数一步步推导过来的人能自己debug出问题在哪。差别就在于是否真正理解了数据从原始像素到最终分类分数的流动过程。这篇文章我会按照我自己的学习路径来写先说明CNN的核心设计思路再用PyTorch从头搭建一个可运行的卷积网络然后处理数据集加载、训练循环、批量训练细节最后给出我踩过的一些坑和现成的排查方法。写的所有代码都是可以直接复制跑的如果你有一张支持CUDA的显卡速度会更快即便只有CPU整个流程也可以在几分钟内跑完一轮。2. CNN核心思路为什么卷积层能比全连接层更高效2.1 全连接层的困境假设我们直接用一个普通的全连接网络来做MNIST分类输入一张28x28的灰度图片最朴素的做法是把784个像素值全部展开成一维向量然后接几个全连接层。理论上这个网络也能拟合训练集但问题在于参数量大而且没有利用图像的二维空间结构。图片里相邻像素之间往往有临接关系一个数字“7”的横线和斜线在局部范围内才有意义如果强行拉平这些空间信息就丢了一部分。更麻烦的是把784维输入映射到隐藏层比如隐藏层256个神经元这个权重矩阵就已经是784x256约20万个参数。再加几层参数量会上百万。MNIST训练集本身就6万张图勉强能撑住但如果是250x250的彩色图输入维度变成20多万全连接层根本没法直接处理训练速度和内存占用都会爆炸。2.2 卷积层到底在做什么卷积神经网络的设计思路很直接用一个小尺寸的卷积核在图像上滑动每次只看一个小局部窗口。比如3x3的卷积核它一次覆盖9个像素然后计算出该位置的一个新值。窗口从左到右、从上到下移动最后得到一张新的特征图。每个卷积核相当于一个特征检测器比如有的核专门检测横线有的检测竖线有的检测角点。直觉上可以这样理解图像上的特征是有局部性的一个数字“1”的竖直笔画只影响图像中一小块区域卷积核通过局部感受野捕捉这些模式而不是像全连接层那样把整张图的所有像素做加权组合。多层卷积堆叠后低层学习到边缘纹理高层把边缘组合成部件甚至完整的数字形状。这种层次化特征提取是CNN的核心优势。2.3 池化层的实际作用池化层我一开始也觉得很玄其实它就是降采样。常见的是2x2的最大池化即在2x2的小块里取最大值宽高各减半参数量不增加。池化的意义在于一来减少特征图尺寸让后续计算量下降二来提供一定的平移不变性稍微移动几个像素最大池化后的结果往往不会变化太大。对于MNIST这种明显居中的数字池化不会带来质变但它是CNN里非常常用的一环学习时必须理解。2.4 为什么CNN在MNIST上表现这么好MNIST的识别本质是一个10类分类问题数字内部的区分度较高即使是简单的两层卷积加全连接测试准确率也能轻松到99%以上。这个任务的难点早已不是“能不能识别”而是“如何高效优雅地搭建一个完整流程”。我见过不少初学者把网络结构抄得很花哨却忽略了数据的标准化、批处理、优化器选择这些细节最终精度反而不如一个简单但配置正确的模型。真正决定模型能到多少精度的往往不是网络深度而是整个训练流程的工程化程度。3. 环境准备与数据集加载动手前的必修课3.1 依赖安装和版本选择我用的环境是Python 3.10 PyTorch 2.x这个搭配在目前的主流生态里非常稳定。安装PyTorch的时候要注意版本选择直接打开PyTorch官网选择对应的操作系统和CUDA版本即可。如果你还没有安装CUDA版本的PyTorch先确认自己的显卡驱动是否支持CUDA不确定的话可以直接装CPU版本先把流程跑通后续再换GPU版本也不会影响代码结构。环境准备的几个核心依赖如下pip install torch torchvision matplotlibtorch是主框架torchvision里自带MNIST数据集和常用的数据变换工具matplotlib用于画训练曲线和查看样本图片。网上有些教程还会让你装numpy但PyTorch本身带了numpy相关的底层依赖你直接import numpy一般也没问题不过为了保险起见还是可以显式安装。3.2 数据集下载与transform配置torchvision.datasets.MNIST会自动下载数据到指定目录。我第一次使用时非常担心网络问题结果发现这个下载源一般都比较稳定。下载过程中最需要注意的是数据类型和transform。MNIST原本的数据范围是0到255的整数如果直接喂给网络数值幅度过大会让梯度更新变得很不稳定。标准做法是归一化到0到1更推荐的是标准化到均值为0.1307、标准差为0.3081。这两个数值是MNIST整个数据集的像素统计值直接拿来用就行。数据预处理配置如下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)ToTensor操作会把PIL图像或者numpy数组转换为Tensor并且把值从0到255缩放到0到1。Normalize再进一步做标准化让像素值分布接近零均值、单位方差。很多新手漏掉这一层直接跑出来的模型往往收敛慢甚至最终精度低一截。3.3 DataLoader批量训练的关键角色PyTorch的DataLoader负责把数据集分成一批一批同时在训练时进行打乱。shuffleTrue表示每个epoch重新洗牌避免模型记住每个批次的固定顺序带来更好的泛化。batch_size的选择直接影响训练速度和显存占用。MNIST这种小图设64或128都很常见。我用64因为CPU训练时更稳定。from torch.utils.data import DataLoader batch_size 64 train_loader DataLoader(datasettrain_dataset, batch_sizebatch_size, shuffleTrue) test_loader DataLoader(datasettest_dataset, batch_sizebatch_size, shuffleFalse)有一个细节测试集不需要shuffle因为验证准确率不依赖数据顺序。如果shuffle也不会错但会让每次跑验证集合的结果顺序不同不利于调试时做对比所以固定设置False。4. 搭建CNN模型网络结构设计与逐层详解4.1 一个经典的小型CNN结构网上有非常多的MNIST CNN结构我建议从下面这个结构入手因为它简单、好记而且性能足够好import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self): super(SimpleCNN, self).__init__() self.conv1 nn.Conv2d(in_channels1, out_channels32, kernel_size3, padding1) self.relu1 nn.ReLU() self.pool1 nn.MaxPool2d(kernel_size2, stride2) self.conv2 nn.Conv2d(in_channels32, out_channels64, kernel_size3, padding1) self.relu2 nn.ReLU() self.pool2 nn.MaxPool2d(kernel_size2, stride2) self.fc1 nn.Linear(in_features64*7*7, out_features128) self.relu3 nn.ReLU() self.fc2 nn.Linear(in_features128, out_features10) def forward(self, x): x self.pool1(self.relu1(self.conv1(x))) x self.pool2(self.relu2(self.conv2(x))) x x.view(x.size(0), -1) x self.relu3(self.fc1(x)) x self.fc2(x) return x先分析一下输入输出维度。输入是(batch_size, 1, 28, 28)经过conv1保持尺寸不变padding1得到(batch, 32, 28, 28)ReLU激活后MaxPool2d变为(batch, 32, 14, 14)。再经过conv2通道从32变成64尺寸保持14x14池化后变成(batch, 64, 7, 7)。到这里特征图已经很小了接下来必须把多维张量展开成一维才能送入全连接层。x.view(x.size(0), -1)这一行的意思是保留batch维度把后面所有维度展平成长向量。因为64773136每个样本变成了3136维的一维向量。fc1把它映射到128维最后fc2输出10维对应0到9十个类别的分数。4.2 为什么用padding1在卷积操作里padding是指在图像外围补一圈0。不补的话3x3卷积会把28x28的图变成26x26。如果后续结构里想精确计算每层尺寸会很麻烦而且多次卷积后特征图缩得特别快。补一圈0之后输出尺寸和输入尺寸保持一致这样在搭建更多层时你只需要关心通道数变化不用反复算尺寸这个习惯在搭建更深网络时非常省心。4.3 ReLU的必要性ReLU是现在用得最多的激活函数公式很简单输入大于0就原样输出小于等于0就输出0。它的好处是计算快并且能缓解梯度消失问题。在全连接层之间如果不加ReLU多个线性层堆叠还是线性变换无法拟合复杂函数。CNN的卷积层和全连接层之间都需要非线性激活这个顺序千万不能搞反。有些框架中Conv2d后面可以不显式写激活层但PyTorch里我建议显式定义让网络结构一目了然。4.4 感受一下模型参数量训练之前先统计一下模型参数有助于了解模型规模。在代码里这样看model SimpleCNN() total_params sum(p.numel() for p in model.parameters()) print(fTotal parameters: {total_params})这个模型大概有120万参数。相比全连接网络动辄几千万参数CNN已经非常轻量了。但120万参数对于MNIST来说依然有些冗余如果你想验证一下什么是“轻量”可以试试把卷积通道数减半比如用16和32精度几乎不会下降太多参数量却能减少四倍多。这也是一个很好的小实验。5. 训练过程详解从损失函数到参数更新的完整循环5.1 选择交叉熵损失函数分类任务最常用的损失函数是交叉熵。PyTorch里直接调用nn.CrossEntropyLoss()即可。这个函数内部其实做了两件事一是对模型输出的原始分数做softmax归一化成概率二是计算预测概率与真实标签之间的交叉熵。所以不需要自己在模型最后一层加softmax。交叉熵公式理解起来不复杂给定真实类别y和预测概率p损失是-log(p_y)。也就是说模型给正确类别的概率越低损失越大。这样优化器就会推动模型不断提高正确类别上的置信度。MNIST有10个类别每个样本属于一个类别这个分类设定非常适合交叉熵。5.2 优化器选择Adam还是SGD我推荐初学者用Adam优化器因为它自带自适应学习率在多数情况下不需要频繁调参收敛速度也快。学习率默认1e-3就能跑得不错。不过如果你是严谨派想充分体验训练过程SGD加动量也是很好的选择。SGD需要手动调节学习率常见起步值是0.01配合momentum0.9。很多老手会告诉你SGD最后的泛化能力往往比Adam稍好一点但差距在MNIST上其实很不明显。我实际测试过Adam和SGD分别跑10轮最终准确率都能到99%左右没必要在这里过度纠结。5.3 训练循环的标准写法下面的代码是每个epoch里遍历所有训练批次并更新参数的完整流程import torch import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleCNN().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) epochs 5 train_loss_history [] 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_loss_history.append(epoch_loss) print(fEpoch {epoch1}/{epochs}, Loss: {epoch_loss:.4f})每一步的逻辑我分别说明一下。optimizer.zero_grad()必须放在前向传播之前作用是清空上一轮留下的梯度。如果不做这一步PyTorch默认会累加梯度导致梯度值越来越大训练直接发散。这是个非常经典的坑。loss.backward()根据当前损失计算模型里所有参数的梯度计算图会自动释放。optimizer.step()用优化器更新参数。整个过程是标准的三步清旧梯度、算梯度、更新参数。5.4 训练过程中的model.train()和model.eval()这两行代码很容易被忽略但确实有实际作用。model.train()会启用BatchNorm的统计信息更新以及Dropout的随机失效机制model.eval()则关闭这些机制保证验证时的结果可复现。我们当前这个SimpleCNN里即没有BatchNorm也没有Dropout所以两者看起来没有差别但一旦模型复杂起来漏掉model.eval()会导致验证结果时好时坏让人误以为是模型随机性大。6. 测试与准确率评估模型到底学会了什么6.1 测试循环实现训练结束后我们需要在完全没有参与训练的测试集上评估模型。MNIST测试集有1万张图足够真实反映泛化能力。测试阶段要关闭梯度计算使用torch.no_grad()上下文管理器减少内存占用并加速运行。def evaluate(model, dataloader, device): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in dataloader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() accuracy 100.0 * correct / total return accuracy test_acc evaluate(model, test_loader, device) print(fTest Accuracy: {test_acc:.2f}%)torch.max(outputs, 1)返回每一行最大值和对应的索引。索引就是预测类别。如果某一行是[-1.2, 3.4, -0.5, ...]最大值是3.4且出现在第1列那么预测结果就是类别1。对比predicted和labels得到一个布尔张量sum()数出其中True的数量就是预测正确的样本个数。我跑这个网络5个epoch测试准确率通常到98.8%附近跑到10个epoch可以到99.4%左右。如果你发现自己的准确率在99%上下波动那说明流程没问题再往上提就需要靠数据增强或者调整网络结构了。6.2 查看分类错误的样本准确率只是冷冰冰的数字真正有用的做法是挑出模型预测失败的样本看看到底是哪些图片让模型犯难。很多时候这些错误样本本身对人也具有一定挑战性比如手写体过于潦草或者图像噪声较大。这里给一段查看错误样本核心逻辑的代码import matplotlib.pyplot as plt model.eval() errors [] with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) preds torch.max(outputs, 1).indices for i in range(len(labels)): if preds[i] ! labels[i]: errors.append((images[i].cpu(), labels[i].item(), preds[i].item())) if len(errors) 5: break if len(errors) 5: break fig, axes plt.subplots(1, 5, figsize(12, 3)) for i, (img, label, pred) in enumerate(errors): axes[i].imshow(img.squeeze(), cmapgray) axes[i].set_title(flabel:{label}, pred:{pred}) axes[i].axis(off) plt.show()通过这个可视化我遇到过好几次很有意思的情况“3”被识别成“8”“7”被识别成“1”“9”被识别成“4”。仔细看样本会发现有些图写得确实凌乱人眼都不一定能看清。这并不代表模型出了严重问题而是说明MNIST里确实存在模糊样本任何模型都不可能做到百分之百准确。6.3 绘制损失曲线用以判断收敛状态训练过程中打印的Loss数值很难直接看出趋势画个曲线更直观。把每个epoch的损失记录下来训练结束后画出来plt.plot(range(1, epochs1), train_loss_history, markero) plt.xlabel(Epoch) plt.ylabel(Training Loss) plt.title(Training Loss Curve) plt.show()正常曲线应该快速下降然后逐渐变平。如果你发现Loss在后期出现反弹说明学习率太高模型在最优点附近震荡如果Loss下降得非常慢可能是学习率太低或者数据没有标准化。MNIST这个任务一般不会出现太离谱的曲线一旦出现异常迅速从学习率和数据预处理两个方向排查即可。7. 常见问题与调试实录那些不跑一遍绝对遇不到的坑7.1 维度不匹配报错新手经常会遇到类似Expected input batch_size to match target size这样的报错原因通常是模型最后输出10维而标签是单个数看起来匹配不上实际上是对批维度理解有误。常见原因是输入时忘了把图片从(batch, 1, 28, 28)展平直接送入全连接层。解决方案就是检查forward函数里是否执行了view操作。我在第一次搭建时就把view放到了卷积层之前导致后续全连接层收到了四维张量报错非常迷惑。后来习惯在forward开头先打印x.shape调试起来快很多。7.2 训练Loss变成NaNNaN问题一般有三个来源学习率太大导致梯度爆炸、数据出现异常值、损失函数不匹配。MNIST标准化后数据范围固定不太会出现异常值。最常见的是学习率设置偏高特别是Adam虽然自适应但初始学习率超过1e-2也可能发散。建议从1e-3起步如果看到NaN先把学习率降到1e-4一般就能恢复正常训练。还有一种情况是模型在最后一层使用了softmax之后又接CrossEntropyLoss导致数值不稳定CrossEntropyLoss内部已经包含了softmax不用重复加。7.3 CPU训练太慢怎么办如果设备只有CPU也不要着急。MNIST图片小5轮训练最多也就几分钟。可以把batch_size调成128或者256减少批次数量训练速度会有明显提升。再一个技巧是torch.backends.mkl.enabled TrueIntel CPU上开启MKL后卷积运算更快但MKL通常默认就是开启的。真正想加速的话减少每层的卷积通道数或层数对MNIST影响不大速度却可以快不少。7.4 验证准确率一直不增长这种情况在使用SGD时比较常见学习率设成了0.0001太低了网络几乎学不到东西。处理方法是先用Adam1e-3试跑3轮看Train Loss有没有明显下降。如果Adam下正常说明代码没问题只是优化器参数需要调整。另外还要确认数据预处理是否正确比如标准化参数写反了像素值变成负的大数模型也会难以收敛。7.5 过拟合的几个信号MNIST本身数据集足够大用我们这个小网络很难过拟合。但如果你使用更大的模型比如把卷积通道换成128、256训练Loss可以降到很低而测试准确率反而停滞甚至下降这就是过拟合信号。解决办法是增加Dropout层、减少网络容量或者加入数据增强。MNIST上最常用的数据增强是随机平移几个像素这个操作简单有效。比如在transform里加入transforms.RandomAffine(degrees10, translate(0.1, 0.1))可以让模型对位置偏差更鲁棒。8. 从MNIST出发后面还能继续做什么8.1 调整网络结构做对比实验跑通一个CNN之后最有价值的动作是做对比实验。把卷积核从3x3换成5x5把池化换成stride2的卷积把ReLU换成LeakyReLU每改动一个变量就记录一次准确率变化。这个过程能让你直观理解每个组件对模型性能的影响。我当时做了三组对比有无padding、是否使用池化、全连接层中间维度是64还是128结果发现去掉池化会让模型参数变大但准确率几乎不变说明在MNIST上池化更多是节省计算量。8.2 迁移到其他数据集把代码迁移到Fashion-MNIST只需要替换数据集名称。Fashion-MNIST也是28x28灰度图但是衣物分类比数字识别更有挑战性。直接同样网络结构跑一遍准确率会在92%到94%之间通过对Fashion-MNIST的调优你会更清楚什么时候需要增加模型容量。CIFAR-10是32x32彩色图需要把第一层卷积的in_channels改成3也基本能跑但准确率不会太高因为网络结构确实偏浅了。8.3 记录实验笔记的习惯我建议从现在开始就用一个表格记录每次实验的配置和结果比如网络层数、卷积核大小、学习率、batch_size、最终准确率。别小看这个习惯等你后续实验做多了就会发现很多参数之间互相影响单靠脑子记根本分不清哪个改动带来了提升。表格记录方式可以很简单用Excel或者Markdown都行但务必包含“改动点”这一列方便回溯。我自己后来做复杂项目时最感谢的就是早期老老实实记笔记的习惯。像batch_size从64调到128之后准确率下降了0.2%如果不记录我根本不会注意到这其实是学习率没有相应调整的结果只会觉得模型结构有问题然后在错误的方向上浪费大量时间。最后再分享一点个人经验这篇文章里所有代码我都是按照自己从零复现时最合适的顺序写出来的。如果你现在正在学习不要一次性把代码全部看完就直接跑建议手动重打一遍核心模型类边打边在注释里写出每个张量经过每层之后的shape。这个过程对理解CNN的实际数据流非常有帮助。我第一次跑这个模型时因为对PyTorch的device机制不熟悉忘了把模型和数据都放到cuda上结果程序报错提示“input and parameter must be on the same device”当时还以为是电脑坏了后来才发现是cpu和gpu不一致的问题。这类小问题其实每个初学者都会遇到只要你记住一个原则模型在哪个设备上数据就必须在哪个设备上见一次就能彻底避开。MNIST只是一个起点但它足够让你把深度学习从纸上谈兵变成手上练兵。跑通之后再回头看那些复杂的网络结构你会发现它们不过是卷积、激活、池化、全连接这些基础模块的不同组合方式。基础打牢了后面迎接更复杂任务的底气就完全不一样了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑