第一次看到神经网络把歪歪扭扭的手写数字认出来的时候,我盯着控制台里打印的准确率愣了几秒。那种感觉很微妙——你并没有给它写任何“数字长什么样”的规则,它自己就从数据里学会了。那个让我入坑的项目,就是基于 PyTorch 的 MNIST 手写数字识别。这个项目几乎是每个学深度学习的人都会跑一遍的经典入门案例:数据干净、任务直观、训练速度快,而且麻雀虽小五脏俱全——数据集加载、模型搭建、损失计算、反向传播、评估可视化,完整走了一遍深度学习的主流程。
这篇文章我会同时给出全连接神经网络(FCN)和卷积神经网络(CNN)两种实现,从网络结构设计讲到训练调参,再附上实测对比和踩坑记录。内容面向刚接触 PyTorch 的开发者,也适合想搞清楚“为什么 CNN 比全连接网络更适合图像任务”这类问题的读者。你可以直接照着代码运行,也可以先看思路对比,再动手改参数玩。
1. 项目拆解与总体思路
1.1 MNIST 是什么,为什么每个学深度学习的人都跑过它
MNIST 是一个手写数字灰度图像数据集,包含 0 到 9 共十个类别,每张图是 28×28 像素的灰度图。训练集有 6 万张,测试集有 1 万张,这些数字来自美国人口普查局的员工和高中生手写的样本,已经经过了尺寸归一化和居中对齐处理。
之所以说它是深度学习的“Hello World”,原因很实际:数据规模小,6 万张训练图对 CPU 都很友好,普通人没显卡也能在几分钟内跑完一个完整的训练流程;图像内容简单,黑白数字、背景干净、类别少,模型不需要特别复杂的设计就能达到很高的准确率;任务具备代表性,图像分类是深度学习最基础的应用方向,MNIST 虽然简单,但“输入图像 → 模型前向计算 → 对比标签 → 反向传播更新权重”的流程,和真实项目完全一致。
很多人觉得 MNIST 太简单没意思,这个观点我部分认同,但不完全认同。简单任务恰恰是理解机制的好载体。当你用全连接网络跑到 98% 准确率,再用 CNN 跑到 99% 以上的时候,你能直观感受到网络结构对模型表达能力的影响,这种体感比读十篇原理文章都深刻。
1.2 全连接网络和卷积网络,到底差在哪
全连接网络,也叫多层感知机(MLP),处理图像的方式很直接:把 28×28 的图像拉平成 784 维的向量,然后每一层的每个神经元都和上一层的所有神经元相连。这种结构的问题在于,它完全忽略了图像的二维空间结构——左上角的像素和右下角的像素在计算时被一视同仁地对待。对于 MNIST 这种数字居中的简单图像,拼空间结构也能勉强学出个样子,但参数效率非常低。
卷积神经网络则保留了图像的二维结构。卷积核在图像上滑动,每次只观察一个局部区域,提取到的特征跟位置是相关的。一个卷积核负责检测一种局部模式,比如横线、竖线、圆弧;多个卷积核堆叠,就能从低级特征组合出高级语义。再加上池化操作降采样,模型对小幅位移和形变有更强的容忍度。
生活类比一下:全连接网络像是一个脸盲的人,要记住一张人脸,必须记住每个像素的位置和颜色;而 CNN 像是一个正常人,他先注意到眼睛、鼻子、嘴巴这些局部特征,再进行组合判断。显然后者更高效,也更鲁棒。
1.3 为什么同时实现两种网络
我的建议是,两个都写一遍。只跑 CNN 的话,你可能觉得效果好是理所当然的;只跑全连接网络的话,你又会低估 CNN 带来的提升幅度。放在一起对比,你会理解几个关键概念:参数共享如何减少参数量、局部连接如何保留空间信息、网络深度如何影响表达能力。
还有一个实际原因:全连接网络实现简单,适合先跑通整体流程,确认数据加载、训练循环、评估逻辑都没问题。然后再替换成 CNN 模型,你会发现训练代码完全不用改,只需要改网络定义和输入维度处理。这种“换模型不动流程”的体验,能帮你建立对 PyTorch 模块化设计的直观认识。
2. 环境准备与数据预处理
2.1 环境搭建与依赖安装
这个项目不需要复杂的环境,Python 3.8 以上就可以,PyTorch 安装建议直接去官网选择对应系统的命令。CPU 版本完全够跑 MNIST,我最初就是在 MacBook Air 上跑的,一个 epoch 大概十几秒,5 个 epoch 也就一分钟左右。
除了 PyTorch 本体,还需要 torchvision,数据集下载和图像变换都靠它。可视化部分建议装 matplotlib。安装命令大致如下:
pip install torch torchvision matplotlib装完以后可以快速验证一下环境是否可用:
import torch import torchvision print(torch.__version__) print(torchvision.__version__)能输出版本号就说明环境没问题。如果你用的是 GPU 版本,可以加一句torch.cuda.is_available()检查 CUDA 是否可用,返回 True 就能享受显卡加速。
2.2 数据集加载与归一化的细节
PyTorch 加载 MNIST 的方式非常标准,用 torchvision 提供的接口:
from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST( root='./data', train=True, transform=transform, download=True ) test_dataset = datasets.MNIST( root='./data', train=False, transform=transform, download=True ) train_loader = torch.utils.data.DataLoader( train_dataset, batch_size=128, shuffle=True ) test_loader = torch.utils.data.DataLoader( test_dataset, batch_size=128, shuffle=False )这里有两个细节值得展开。第一个是ToTensor()的作用:它会把 PIL Image 或 numpy 数组转成形状为(C, H, W)的张量,也就是(1, 28, 28),同时把像素值从 0 到 255 缩放到 0 到 1,相当于自动做了一次归一化。第二个是Normalize((0.1307,), (0.3081,)):这两个值是 MNIST 数据集的全局均值和标准差,用它们做标准化,可以让输入数据的分布接近标准正态分布,有利于梯度下降收敛。
标准化公式是(x - mean) / std,也就是说原始像素经过 ToTensor 变成 0~1 之后,再套用这个公式,大部分数值会落在 -1 到 1 之间。为什么不直接用 0~1 的原始值?因为如果输入范围比较大且分布不均匀,不同维度的尺度差异会影响梯度更新的效果,收敛速度和稳定性都会变差。
DataLoader的参数也顺手说一下。batch_size=128表示每次迭代喂给模型 128 张图,不是一张一张喂,也不是一次性全喂。分批训练是内存和计算效率的折中。shuffle=True对训练集非常重要,它能让每个 epoch 打乱数据顺序,避免模型学到数据排列的“捷径”;测试集则不需要打乱,shuffle=False保持顺序即可。
下载数据的时候有个常见问题:第一次运行会自动从网上下载 MNIST 到./data目录。国内网络可能偶尔下载失败,解决办法是手动下载四个.gz 文件放到./data/MNIST/raw/目录下,再运行代码就能跳过自动下载。
3. 全连接神经网络的实现
3.1 网络结构设计思路
先看最基础的版本——全连接网络。MNIST 图像是 28×28,拉平后是 784 维。输出层是 10 个节点,对应 0 到 9 十个类别的得分。中间的隐藏层结构和层数没有标准答案,我常用的是三层结构:
import torch.nn as nn class FCN(nn.Module): def __init__(self): super(FCN, self).__init__() self.fc1 = nn.Linear(784, 512) self.fc2 = nn.Linear(512, 256) self.fc3 = nn.Linear(256, 10) self.relu = nn.ReLU() self.dropout = nn.Dropout(0.2) def forward(self, x): x = x.view(x.size(0), -1) 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为什么第一层用 512 个神经元,不用更多?这是一个经验权衡。MNIST 输入本来就小,784 维映射到 512 维已经保留了足够的信息量。盲目加宽度会增加参数量和过拟合风险,收益却很有限。如果你用 1024 甚至 2048 个神经元,准确率不会有明显提升,训练时间倒是会变长。
中间层的激活函数用 ReLU 而不是 sigmoid,原因是 ReLU 的计算简单,且能缓解梯度消失问题。sigmoid 函数的导数在饱和区域趋近于 0,深层网络反向传播时梯度连乘多次之后会变得非常小,导致靠近输入层的权重几乎无法更新。ReLU 在正区间导数为 1,梯度能比较顺畅地往后传。
最后一个线性层输出了 10 个原始得分(logits),没有接 softmax。这不是疏忽,而是 PyTorch 的CrossEntropyLoss内部已经包含了 softmax 计算,如果你在网络里先手动加了 softmax,再把结果传给这个损失函数,相当于连续做两次 softmax,反而会得到错误的梯度。
view(x.size(0), -1)是输入处理的关键操作:它把形状为(batch_size, 1, 28, 28)的四维张量重塑为(batch_size, 784)的二维矩阵。-1表示自动推断这一维的长度,因为 1×28×28 等于 784。
3.2 训练循环代码逐段解释
训练一个模型在 PyTorch 里有固定的套路:前向传播、计算损失、梯度清零、反向传播、更新参数。这套流程对所有网络都一样,我把它封装成一个函数:
import torch import torch.nn.functional as F from torch.optim import Adam device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = FCN().to(device) optimizer = Adam(model.parameters(), lr=0.001) criterion = nn.CrossEntropyLoss() def train_one_epoch(model, loader, optimizer, criterion): model.train() total_loss = 0 correct = 0 total = 0 for images, labels in loader: images = images.to(device) labels = labels.to(device) outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) avg_loss = total_loss / len(loader) acc = correct / total return avg_loss, acc几个细节新手容易踩坑。第一,model.train()必须要调用,它会启用 Dropout 和 BatchNorm 的训练模式。同理,评估时要调用model.eval(),否则 Dropout 会继续随机丢弃神经元,导致评估结果不稳定。第二,optimizer.zero_grad()要在backward()之前调用,因为 PyTorch 的梯度默认是累加的,不清零的话,每个 batch 的梯度会叠加到上一次的梯度上。第三,torch.max(outputs, 1)返回两个值,第一个是最大值,第二个是最大值对应的索引,也就是预测类别。
这里用 Adam 优化器而不是传统的 SGD,是因为 Adam 自带自适应学习率,对初始学习率的敏感度低很多。SGD 需要手动调整学习率和动量,对新手不那么友好。学习率设成 0.001 是 Adam 比较常用的经验值,太大容易震荡,太小收敛慢。
完整的训练循环可以写 5 个 epoch,每个 epoch 结束之后在测试集上评估:
def evaluate(model, loader, criterion): model.eval() correct = 0 total = 0 total_loss = 0 with torch.no_grad(): for images, labels in loader: images = images.to(device) labels = labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) acc = correct / total avg_loss = total_loss / len(loader) return avg_loss, acc for epoch in range(1, 6): train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion) test_loss, test_acc = evaluate(model, test_loader, criterion) print(f'Epoch {epoch}: Train Loss {train_loss:.4f}, Train Acc {train_acc:.4f}, ' f'Test Loss {test_loss:.4f}, Test Acc {test_acc:.4f}')torch.no_grad()这个上下文管理器也很重要,它告诉 PyTorch 不需要在评估时构建计算图,既省内存又加快速度。这个模式下没有梯度跟踪,反向传播相关的东西全都不执行。
3.3 全连接网络实测结果
我在本地 CPU 环境跑这个结构,5 个 epoch 之后测试集准确率大约在 97.8% 到 98.2% 之间。这个结果其实相当不错了,但注意一点:训练集的准确率通常会比测试集高一点,如果你看到训练集 99.5%、测试集 98%,说明模型存在一定程度的过拟合。全连接网络因为参数多,在 MNIST 这种小数据集上很容易记住训练集中的噪声,这也是我在中间层加 Dropout 的原因。
Dropout 的原理是在训练时随机“关掉”一部分神经元,让模型不能过度依赖某个特定神经元的输出,迫使网络学习冗余的特征表达。测试时不使用 Dropout,所有神经元都参与计算。0.2 的失活比例在 MNIST 这种简单任务上足够了,比例太大会让模型欠拟合。
4. 卷积神经网络的实现
4.1 CNN 结构拆解与维度计算
CNN 的结构比全连接网络复杂一些,但核心思想很清晰:先用卷积和池化逐层提取特征,最后用全连接层做分类。我用的结构是一个经典的轻量级 CNN:
class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.pool = nn.MaxPool2d(2) self.dropout = nn.Dropout(0.25) self.fc1 = nn.Linear(64 * 7 * 7, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) x = self.pool(F.relu(self.conv2(x))) x = x.view(x.size(0), -1) x = self.dropout(x) x = F.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return x我们来手算一下每一层输出张量的尺寸变化。输入是(1, 28, 28),第一层卷积用了padding=1和kernel_size=3,根据公式输出尺寸 = (输入尺寸 + 2×padding - kernel_size) / stride + 1,得到(28 + 2×1 - 3) / 1 + 1 = 28,所以卷积后图像尺寸还是 28×28,但通道数从 1 变成 32。接着过一个 2×2 的 MaxPool,尺寸减半变成 14×14,输出张量形状是(32, 14, 14)。
第二层卷积输入通道是 32,输出通道是 64,同样加 padding 保持尺寸不变,得到(64, 14, 14)。再过一个池化,变成(64, 7, 7)。最后展平,得到64×7×7 = 3136维向量,送入全连接层。
这里用padding=1配合kernel_size=3是个常用技巧,它让卷积操作不改变特征图的宽高,这样你只需要关心通道数变化,计算维度不容易出错。如果不加 padding,28×28 经过 3×3 卷积后变成 26×26,后一层的输入尺寸就需要重新计算,麻烦不说,还可能导致边界信息丢失过快。
4.2 卷积、池化、全连接各司其职
卷积层的核心是局部连接和参数共享。局部连接的意思是每个输出神经元只连接输入特征图上的一个小窗口,对应一个 3×3 的区域;参数共享的意思是同一个卷积核在所有位置滑动时权重都一样。这两个机制大大减少了参数量,也保留了图像的空间结构。
拿第一个卷积层来算一笔账:输入 1 个通道,输出 32 个通道,卷积核是 3×3。每个输出通道对应一个 3×3×1 的卷积核,加上偏置,参数量是(3×3×1 + 1) × 32 = 320。如果不用卷积而用全连接层把 784 维映射到同样规模的特征,参数量会是天文数字。
池化层做的事情是降采样。MaxPool 取窗口内的最大值作为输出,相当于在每个局部区域只保留最强烈的响应信号。它的好处有三个:降低特征图尺寸,减少后续计算量;增大感受野,让高层能看到更广的区域;提供一定的平移不变性,数字稍微偏了几个像素也能被识别出来。
最后再接全连接层,把卷积层提取到的高层特征组合起来做分类决策。前几层卷积负责学边缘、纹理、笔画这类局部模式,后层学到的是“这个图里有数字几的部件组合”,全连接层则负责给这些特征打分。这种从局部到整体的特征提取方式,正好符合人眼看物体的分层认知规律。
4.3 训练效果与关键观察
同样的训练循环,把模型替换成 CNN,跑相同的 5 个 epoch,测试集准确率能到 99.2% 以上,通常第一轮结束后就能超过 98%。和全连接网络对比,差距非常明显。
还有一个反直觉的现象:CNN 的参数总量反而可能比全连接网络少。我用的是 3136 维全连接展平,CNN 总参数大约在几十万量级;而前面那个三层的全连接网络,光第一个线性层就有784×512 = 401408个参数,再加上后续层,总参数超过 60 万。这说明 CNN 不是靠堆参数取胜的,而是靠合理的结构设计——把“空间局部性”这个先验知识直接编码进了网络结构里。
CNN 训练时我建议 batch size 可以稍微调大一点,比如 128 或 256,因为卷积层计算相对密集,更大的 batch 能更充分地利用并行计算能力,在 CPU 上整体速度反而更快。
5. FCN 和 CNN 实战对比分析
5.1 两组实验结果对照
我把两个模型在相同条件下跑了两遍,记录了一些关键指标,整理成表格,方便你直观对比:
| 对比项 | 全连接网络(FCN) | 卷积神经网络(CNN) |
|---|---|---|
| 输入处理 | 展平为 784 维向量 | 保持 1×28×28 二维结构 |
| 参数量 | 约 66 万 | 约 20 万 |
| 5 轮训练后准确率 | 约 98.0% | 约 99.3% |
| 单轮训练耗时(CPU) | 约 12 秒 | 约 18 秒 |
| 对空间位置敏感度 | 高,数字偏移影响大 | 低,对位移有一定容忍度 |
| 过拟合倾向 | 较高,需要 Dropout | 较低,结构自带正则化效果 |
注意两组实验用的训练超参数完全一致,都是 Adam、学习率 0.001、batch size 128、5 个 epoch。准确率的差异完全来自网络结构本身。如果你把全连接网络加深加宽,也能逼近 CNN 的准确率,但参数量会成倍增长,训练时间也会大幅上升。
我在实际对比中还有一个感受:CNN 的训练损失下降曲线比 FCN 平滑得多。FCN 的损失在早期会出现明显的波动,而 CNN 几乎是一条稳定下降的曲线。原因是 CNN 的梯度传播路径更短,且共享权重减轻了不同 batch 带来的梯度差异。
5.2 模型选型的实际建议
跑完这个对比,你可能会问:那全连接网络还有存在的意义吗?有,但要分场景。如果输入本身是向量数据,比如表格数据、特征向量,全连接网络依然是主流选择。如果任务是图像分类、目标检测这类空间结构明显的任务,CNN 结构是默认起点。
在 MNIST 这个项目里,我建议把两种模型都跑一遍,不是为了刷准确率,而是为了建立对“结构决定性能”的直觉。很多初学者容易陷入调参焦虑,整天调整学习率和 batch size,却忽略了一个事实:模型结构才是决定性能上限的第一要素。把相同的调参精力放在优化结构上,收益往往大得多。
还有一个点值得注意:t-SNE 降维可视化会让你更直观地理解两个模型的区别。用 sklearn 的 TSNE 把 FCN 倒数第二层和 CNN 展平后的特征都降到二维,画出来你会发现 CNN 的特征分布中同类数字聚得更紧,类间边界更清晰。特征质量高,分类自然更准。
6. 常见问题与排查技巧实录
6.1 准确率卡在 90% 以下怎么办
这种情况在 MNIST 上不太常见,但如果出现,优先检查这几项:数据归一化是否做了,没做归一化的话梯度更新容易不稳定;shuffle是否设成了 True,如果不打乱顺序,模型可能学到 batch 之间的虚假关联;网络结构是否太浅,全连接网络如果只用一个线性层,表达能力非常有限,准确率会很差;学习率是否合适,Adam 的默认学习率通常是 0.001,如果你手动调成了 0.01,很可能会出现损失不降反升的情况。
挨个排查之后,如果还在 90% 以下,可以直接在测试集上用模型跑一次前向传播,打印几个样本的真实标签和预测标签,人工看一下错在哪里。很多时候问题出在数据本身,比如手写数字倾斜角度过大,或者笔画太细,这种样本连人眼看起来都模糊,模型认错也情有可原。
6.2 Loss 为 NaN 的排查思路
NaN 是训练中比较容易遇到的异常。常见原因有三个:学习率过大导致梯度爆炸,权重更新步长太大,数值溢出;数据里有 NaN 值,但 MNIST 这种情况极少;损失函数和输出层配对错误,比如多分类任务用了 BCE Loss 而没有配合 sigmoid。
排查手段也很简单:先把学习率降低到 0.0001 试试,如果问题解决说明是学习率问题;打印第一个 batch 的输入和标签,确认数据本身没有异常;在loss.backward()之前加一行print(loss.item()),看损失是否从第一步就异常。如果第一步就 NaN,问题大概率在网络权重初始化和数据预处理上。
6.3 过拟合的迹象与对策
过拟合的表现是训练准确率远高于测试准确率。MNIST 这个任务上,FCN 比 CNN 更容易过拟合。对策主要有三个方向:加 Dropout,这是最简单有效的;加数据增强,虽然 MNIST 的数据增强空间不大,但可以尝试随机旋转 10 度以内、随机平移两个像素、随机缩放,模拟真实手写的多样性;早停,监控验证集准确率,连续几个 epoch 不上升就停止训练。
亲手训练这两个模型会有一个很直观的感受:FCN 在第 3 个 epoch 左右就能让训练集准确率冲到 99%,但测试集准确率可能还在 97.5% 附近徘徊;CNN 的训练准确率和测试准确率差距很小,一般不到一个百分点。这个差距本身就是模型结构对泛化能力影响的直接证据。
6.4 模型保存、加载与预测可视化
训练结束之后,别让模型只活在内存里。保存和加载模型的推荐方式是保存 state_dict,而不是整个模型对象:
torch.save(model.state_dict(), 'cnn_mnist.pth') # 加载 model = CNN().to(device) model.load_state_dict(torch.load('cnn_mnist.pth')) model.eval()为什么不建议直接torch.save(model)?因为保存整个模型对象会把模型结构定义也序列化进去,后续如果你改了网络类定义,加载就会报错。只保存权重参数更灵活,加载时先按当前代码创建模型实例,再填入权重即可。
加载模型做推理时有一个小细节:别忘了先调用model.eval()。我有一个朋友曾经忘了这一步,推理结果忽高忽低,排查了半天最后发现是 Dropout 还在生效,内心是崩溃的。
最后可以加一个可视化推理结果的函数,随机取 10 张测试图,用模型预测并用 matplotlib 画出来:
import matplotlib.pyplot as plt model.eval() images, labels = next(iter(test_loader)) images, labels = images[:10].to(device), labels[:10].to(device) with torch.no_grad(): outputs = model(images) _, preds = torch.max(outputs, 1) fig, axes = plt.subplots(2, 5, figsize=(12, 6)) for idx, ax in enumerate(axes.flatten()): img = images[idx].cpu().squeeze() ax.imshow(img, cmap='gray') ax.set_title(f'Label: {labels[idx].item()} | Pred: {preds[idx].item()}') ax.axis('off') plt.show()把预测错误的样本挑出来看是一个有价值的习惯。识别错误的样本往往能暴露出数据集中真实存在的模糊情况,也能帮你判断模型哪一类数字容易混淆。从我的经验来看,最容易混淆的是 4 和 9、3 和 5、7 和 9 这几组,它们的轮廓结构相似,卷积提取的特征差异本来就小,偶尔认错很正常。如果有需要,甚至可以统计出一个混淆矩阵,看看模型到底在哪里最纠结。
我个人在实际操作中的体会是:MNIST 项目虽然小,但值得认真对待。它逼你弄懂数据加载的来龙去脉、损失函数的内部逻辑、模型结构与参数量之间的关系,这些基本功在后续任何项目里都会反复用到。先别急着上花哨的网络结构和数据增强技巧,老老实实跑通全流程,理解每一个模块为什么存在,比刷到一个漂亮的准确率更有价值。