1. 从一行标题拆出来的完整项目思路
1.1 这个项目到底在做什么
先把标题拆开看:PyTorch 实现 MNIST 手写数字识别,网络结构是 2 个卷积层加 1 个全连接层。这句话信息量其实不小,它同时锁定了四件事:框架用 PyTorch,数据集用 MNIST,任务类型是 10 分类,模型结构是"两层卷积 + 一层全连接"的极简卷积网络。
MNIST 这个数据集在圈子里算是"Hello World"级别的存在,60000 张训练图、10000 张测试图,每张都是 28×28 的灰度图,内容是 0 到 9 的手写数字。任务目标很直接:给一张图,让模型判断它是哪个数字。别看它简单,它几乎是所有人入门深度学习的必经之路,因为数据干净、任务明确、训练快,一台普通笔记本的 CPU 都能跑完,有张入门级显卡更是几分钟就出结果。
那为什么标题特意强调"2 卷积 1 全连接"?因为这是卷积神经网络(CNN)最精简的可用形态。很多人第一次接触 CNN 时会被各种层堆叠搞晕,而两层卷积负责提取特征、一层全连接负责分类,刚好把 CNN 的核心逻辑完整地展示出来,又不会因为层数太多而难以调试。它解决的问题是:用最小的结构代价,让你把"卷积提取特征、池化降维、全连接分类"这条链路彻底跑通一遍。
适合谁来参考?三类人最合适。第一类是刚学完 PyTorch 基础语法、想找个完整项目练手的初学者;第二类是学过理论但没亲手搭过 CNN、想补上实操环节的学生;第三类是需要一个快速验证环境是否配置正确的开发者——MNIST 跑通了,说明 PyTorch、CUDA、数据加载这条链路都没问题。我当年配新机器,第一件事就是拿这个项目测一遍,比任何环境检测脚本都直观。
1.2 为什么选这个结构而不是更复杂的
有人会问,现在都流行 ResNet、Transformer 了,为什么还要折腾两层卷积?我的看法是:入门阶段,结构越简单,你越能看清每一层在干什么。如果一上来就堆十几层,出了问题你根本不知道是哪一层导致的,调参也变成了玄学。
两层卷积的设计有它的道理。第一层卷积负责捕捉最基础的局部特征,比如边缘、笔画方向;第二层卷积在这些基础特征之上组合出更复杂的模式,比如数字的拐角、闭环。两层之后,特征已经足够表达手写数字这种简单图形了。再加层,收益递减,反而增加过拟合风险和训练时间。全连接层放在最后,作用是把卷积提取出来的特征图展平,映射到 10 个类别上做分类。
这个"够用就好"的思路,其实是很务实的工程哲学。我在实际项目里见过太多人一上来就追求复杂模型,结果数据量根本撑不起来,模型还没简单结构跑得好。MNIST 用两层卷积能轻松做到 99% 以上的测试准确率,这个成绩对入门项目来说完全够看。
2. 核心细节解析与实操要点
2.1 数据加载与预处理的关键细节
数据这块,PyTorch 提供了torchvision.datasets.MNIST,一行代码就能下载。但有几个细节新手特别容易忽略。
第一个是归一化参数。MNIST 官方给的均值和标准差是 0.1307 和 0.3081,这两个数字不是随便写的,是统计整个训练集算出来的。归一化的目的是把像素值从 0-255 缩放到接近 0 均值、1 方差的分布,让梯度下降更稳定。如果你不做归一化,或者随便填个 0.5,模型也能跑,但收敛会慢一些,准确率可能差零点几个百分点。别小看这点差距,入门阶段养成规范习惯很重要。
第二个是ToTensor 的顺序。transforms.ToTensor()会把 PIL 图像或 numpy 数组转成张量,同时自动把像素值从 0-255 缩放到 0-1。所以归一化要放在 ToTensor 之后,顺序反了会出错。这个坑我踩过,当时报错信息不直观,排查了半天才发现是 transform 顺序问题。
第三个是batch size 的选择。常见取值是 64 或 128。batch 太小,梯度噪声大,训练震荡;batch 太大,显存吃紧,而且泛化性能可能下降。MNIST 数据量小,64 是个稳妥的选择。我实测下来,64 和 128 最终准确率差别不大,但 64 的收敛曲线更平滑,适合观察。
transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])提示:归一化参数必须和数据集匹配。MNIST 是单通道灰度图,所以均值和标准差都只有一个值。如果你换成彩色数据集,这里要写三个值。
2.2 网络结构设计的取舍逻辑
标题说的"2 卷积 1 全连接",具体怎么搭?我的方案是这样的:第一层卷积输入 1 通道、输出 32 通道,卷积核 3×3,padding 设为 1;接 ReLU 激活,再跟一个 2×2 的最大池化。第二层卷积输入 32 通道、输出 64 通道,同样 3×3 卷积核、padding 1,接 ReLU 和 2×2 池化。最后展平,接一个全连接层输出 10 类。
为什么第一层输出 32 通道、第二层 64 通道?这是卷积网络的常见递增模式。浅层特征简单,通道数少一点够用;深层特征复杂,需要更多通道来容纳不同的模式组合。32 到 64 这个翻倍关系,是很多经典网络(比如 VGG)的惯用手法,属于经验值,实测效果好。
padding 设为 1 是为了保持特征图尺寸。3×3 卷积核不加 padding 会让尺寸缩小 2,两次卷积加两次池化下来,28×28 会缩得比较厉害。加 padding 后,卷积不改变尺寸,只有池化每次减半,最终特征图是 7×7,展平后是 64×7×7=3136 维,接全连接刚好。
激活函数用 ReLU 而不是 Sigmoid,原因是 ReLU 计算简单、梯度不会饱和,深层网络里能有效缓解梯度消失。MNIST 虽然浅,但养成用 ReLU 的习惯没坏处。
class Net(nn.Module): def __init__(self): super(Net, self).__init__() self.conv1 = nn.Conv2d(1, 32, 3, padding=1) self.conv2 = nn.Conv2d(32, 64, 3, padding=1) self.pool = nn.MaxPool2d(2, 2) self.fc = nn.Linear(64 * 7 * 7, 10) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) x = self.pool(F.relu(self.conv2(x))) x = x.view(-1, 64 * 7 * 7) x = self.fc(x) return x注意:
x.view(-1, 64*7*7)里的 -1 是让 PyTorch 自动推断 batch 维度。如果你改了网络结构导致特征图尺寸变化,这里的 7×7 必须同步改,否则会报维度不匹配的错。这是新手最常见的报错之一。
2.3 损失函数与优化器的搭配
分类任务用交叉熵损失nn.CrossEntropyLoss(),这个函数内部已经包含了 Softmax,所以网络最后一层不要再加 Softmax,否则等于做了两次,会影响训练。这一点很多人第一次写会搞错。
优化器选 Adam,学习率 0.001。为什么不用 SGD?SGD 需要仔细调学习率和动量,收敛慢,对新手不友好。Adam 自适应调整每个参数的学习率,开箱即用,在 MNIST 这种小任务上表现稳定。等你有经验了,可以试试 SGD 加动量,有时候泛化性能会略好,但那是后话。
学习率 0.001 是 Adam 的经典默认值,实测在 MNIST 上收敛快且稳。如果你发现 loss 下降很慢,可以试试 0.01;如果 loss 震荡厉害,降到 0.0001。不过大多数情况下,0.001 不用动。
3. 完整实操流程与训练环节实现
3.1 环境准备与依赖安装
先把环境搭起来。PyTorch 的安装命令要根据你的硬件选,有显卡的装 CUDA 版本,没显卡的装 CPU 版本。我建议直接用官方推荐的 pip 命令,别自己瞎折腾版本组合。
pip install torch torchvision如果你有 NVIDIA 显卡,去 PyTorch 官网选对应 CUDA 版本的命令。装完之后验证一下:
import torch print(torch.__version__) print(torch.cuda.is_available())第二行输出 True 说明显卡可用。如果 False,要么没显卡,要么驱动没装好。CPU 也能跑这个项目,就是慢一点,大概几分钟一轮。
3.2 训练循环的逐行拆解
训练循环是整个项目的核心,我把它拆成五步:取数据、前向传播、算损失、反向传播、更新参数。
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = Net().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) for epoch in range(5): model.train() running_loss = 0.0 for i, (inputs, labels) in enumerate(train_loader): inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() print(f"Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}")几个关键点。optimizer.zero_grad()必须放在反向传播之前,作用是清空上一轮的梯度。PyTorch 默认会累加梯度,不清零的话梯度会越积越大,训练直接崩掉。这个坑我见过太多人踩,报错信息还不明显,就是 loss 不下降。
model.train()和后面的model.eval()是切换训练和评估模式。训练模式会启用 Dropout 和 BatchNorm 的训练行为,评估模式则固定它们。这个项目虽然没用 Dropout 和 BatchNorm,但养成习惯没坏处,以后加层了不会忘。
inputs.to(device)是把数据搬到显卡上。模型在显卡上,数据也必须搬到显卡上,否则会报设备不匹配的错。这个错误新手经常遇到,记住"模型和数据要在同一个设备上"就行。
训练轮数设 5 轮。MNIST 很简单,5 轮足够收敛到 99% 左右。你可以设 10 轮,但收益很小,还容易过拟合。我一般看 loss 曲线,如果连续两轮 loss 下降不到 0.01,就可以停了。
3.3 测试评估与准确率计算
训练完要评估模型在测试集上的表现。评估阶段不需要计算梯度,所以用torch.no_grad()包起来,能省显存、加速。
model.eval() correct = 0 total = 0 with torch.no_grad(): for inputs, labels in test_loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f"Accuracy: {100 * correct / total:.2f}%")torch.max(outputs, 1)返回每行最大值和对应的索引,索引就是预测的类别。predicted == labels得到一个布尔张量,.sum().item()统计正确个数。这套写法是 PyTorch 分类评估的标准模板,可以直接抄。
实测下来,这个结构在测试集上能到 99% 左右。如果只有 98% 出头,检查一下归一化、学习率、训练轮数这几个点。如果低于 95%,大概率是代码有 bug,比如标签和输出对不上、梯度没清零之类的。
3.4 单张图片推理与可视化
训练完模型,总想拿张图试试效果。从测试集里取一张,跑一遍推理,把预测结果和真实标签打出来。
model.eval() dataiter = iter(test_loader) images, labels = next(dataiter) with torch.no_grad(): outputs = model(images.to(device)) _, predicted = torch.max(outputs, 1) print(f"真实标签: {labels[0].item()}") print(f"预测结果: {predicted[0].item()}")如果想看图片,可以用 matplotlib 画出来。注意images[0]是归一化后的张量,画图前要反归一化,或者直接看也能看出大概形状。可视化这块不是必须的,但能帮你直观感受模型在识别什么。
4. 常见问题与排查技巧实录
4.1 训练不收敛的排查顺序
训练不收敛是最常见的问题,表现是 loss 一直不降或者震荡。我总结了一个排查顺序,按这个走基本能定位。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| loss 完全不降 | 梯度没清零 | 检查optimizer.zero_grad()位置 |
| loss 震荡剧烈 | 学习率太大 | 降到 0.0001 试试 |
| loss 下降极慢 | 学习率太小或没归一化 | 检查 transform 和 lr |
| loss 变 NaN | 学习率过大或数据有问题 | 检查数据范围,降 lr |
| 准确率卡在 10% | 标签和输出维度对不上 | 检查全连接输出是否为 10 |
梯度没清零这个坑,我单独拎出来说。它的表现是 loss 一开始降,然后突然飙升或者变 NaN。原因是梯度累加导致参数更新步长越来越大。解决方法就是确保zero_grad()在backward()之前调用。这个错误太常见了,几乎每个新手都会踩一次。
4.2 维度不匹配报错的定位技巧
PyTorch 的维度报错信息有时候不太直观,但有个技巧:从报错信息里找两个数字,一个是期望的,一个是实际的。比如Expected input ... to have 3136 channels, but got 64 channels,说明全连接层的输入维度写错了。
这个项目里,全连接输入是 64×7×7=3136。如果你改了卷积层通道数或者池化层,这个数字要重算。计算方法:输入 28×28,两次 2×2 池化后变成 7×7,通道数是第二层卷积的输出通道数。所以全连接输入 = 第二层输出通道 × 7 × 7。
我建议在forward里加一句print(x.shape),跑一次看看展平前的实际形状,比手算靠谱。调试完再删掉。
4.3 显存不足与设备不匹配
显存不足一般出现在 batch size 设太大或者显卡太老的情况。MNIST 模型很小,正常不会爆显存,除非你 batch 设了几千。解决办法就是调小 batch size,64 不行就 32。
设备不匹配的报错是Expected all tensors to be on the same device。原因是模型在显卡上,数据还在 CPU 上。检查两处:模型有没有.to(device),数据有没有.to(device)。两处都加上就对了。
还有一个隐蔽的坑:如果你在评估时忘了model.eval(),虽然这个项目没影响,但以后加了 BatchNorm 层,评估结果会不准。养成训练前train()、评估前eval()的习惯。
4.4 提升准确率的小技巧
想从 99% 再往上挤一点,可以试这几个方法。第一,加数据增强,比如随机旋转 10 度、随机平移几个像素,能让模型见到更多变化,泛化更好。第二,训练轮数加到 10 轮,配合学习率衰减,后期微调。第三,把卷积通道数翻倍,32 变 64、64 变 128,但训练时间会增加。
不过说实话,MNIST 到 99% 以上之后,每提升 0.1% 都很费劲,性价比不高。这个项目的价值在于跑通流程、理解原理,而不是刷榜。我个人的建议是,先把基础版本跑稳,再尝试改进,别一上来就追求极致准确率。
提示:数据增强只在训练集上做,测试集不要做。测试集要保持原始分布,否则评估结果不可信。
5. 结构扩展与后续进阶方向
5.1 从两层卷积到更深网络的平滑过渡
这个项目跑通之后,下一步可以尝试加深网络。但别一下子跳到 ResNet,中间有个过渡:试试三层卷积加 BatchNorm。BatchNorm 能加速收敛、稳定训练,是现代网络的标配。加的时候注意,卷积层后面接 BatchNorm,再接 ReLU,顺序别搞反。
再往后可以试试 Dropout,在全连接层前加一个nn.Dropout(0.5),能缓解过拟合。MNIST 上过拟合不明显,但换成更复杂的数据集就有用了。这些组件的加入,能让你逐步理解现代 CNN 的构造逻辑。
5.2 换数据集验证代码通用性
代码写完之后,可以换个数据集验证通用性。比如 Fashion-MNIST,同样是 28×28 灰度图、10 分类,但内容是衣物,比数字难一些。把数据加载部分改一下,其他代码基本不用动,就能跑。如果准确率明显下降,说明模型容量不够,可以试试加深或加宽。
再进一步可以试 CIFAR-10,32×32 彩色图、10 分类。这时候输入通道要从 1 改成 3,网络也要相应调整。这个过程能帮你理解"网络结构要匹配数据特点"这个原则。
5.3 模型保存与加载的规范做法
训练完的模型要保存,方便下次直接用。PyTorch 推荐保存state_dict而不是整个模型,因为state_dict更轻量、更灵活。
torch.save(model.state_dict(), "mnist_cnn.pth")加载的时候,先实例化同样的网络结构,再加载参数:
model = Net() model.load_state_dict(torch.load("mnist_cnn.pth")) model.eval()注意加载后要调eval(),否则推理结果可能不对。这个细节很多人忽略,尤其是模型里有 Dropout 或 BatchNorm 的时候。
我在实际使用中发现,保存模型时顺便把训练轮数、准确率、超参数记在一个文本文件里,过段时间回头看能快速回忆起来当时怎么调的。这个习惯看起来小,但项目多了之后特别有用,不然你根本记不清哪个.pth文件对应哪次实验。
最后分享一个小技巧:如果你要反复跑这个项目做实验,把超参数(batch size、学习率、轮数)提到文件开头定义成变量,改的时候只改一处,比在代码里到处找要高效得多。这个习惯在调参阶段能省不少时间。