资讯动态

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

发布时间:2026/10/17 4:25:33 来源:尧图企业网站定制
第一次看到神经网络把歪歪扭扭的手写数字认出来的时候我盯着控制台里打印的准确率愣了几秒。那种感觉很微妙——你并没有给它写任何“数字长什么样”的规则它自己就从数据里学会了。那个让我入坑的项目就是基于 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, trainTrue, transformtransform, downloadTrue ) test_dataset datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue ) train_loader torch.utils.data.DataLoader( train_dataset, batch_size128, shuffleTrue ) test_loader torch.utils.data.DataLoader( test_dataset, batch_size128, shuffleFalse )这里有两个细节值得展开。第一个是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_size128表示每次迭代喂给模型 128 张图不是一张一张喂也不是一次性全喂。分批训练是内存和计算效率的折中。shuffleTrue对训练集非常重要它能让每个 epoch 打乱数据顺序避免模型学到数据排列的“捷径”测试集则不需要打乱shuffleFalse保持顺序即可。下载数据的时候有个常见问题第一次运行会自动从网上下载 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(), lr0.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(fEpoch {epoch}: Train Loss {train_loss:.4f}, Train Acc {train_acc:.4f}, fTest 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 的结构比全连接网络复杂一些但核心思想很清晰先用卷积和池化逐层提取特征最后用全连接层做分类。我用的结构是一个经典的轻量级 CNNclass CNN(nn.Module): def __init__(self): super(CNN, self).__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) 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)第一层卷积用了padding1和kernel_size3根据公式输出尺寸 (输入尺寸 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维向量送入全连接层。这里用padding1配合kernel_size3是个常用技巧它让卷积操作不改变特征图的宽高这样你只需要关心通道数变化计算维度不容易出错。如果不加 padding28×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, cmapgray) ax.set_title(fLabel: {labels[idx].item()} | Pred: {preds[idx].item()}) ax.axis(off) plt.show()把预测错误的样本挑出来看是一个有价值的习惯。识别错误的样本往往能暴露出数据集中真实存在的模糊情况也能帮你判断模型哪一类数字容易混淆。从我的经验来看最容易混淆的是 4 和 9、3 和 5、7 和 9 这几组它们的轮廓结构相似卷积提取的特征差异本来就小偶尔认错很正常。如果有需要甚至可以统计出一个混淆矩阵看看模型到底在哪里最纠结。我个人在实际操作中的体会是MNIST 项目虽然小但值得认真对待。它逼你弄懂数据加载的来龙去脉、损失函数的内部逻辑、模型结构与参数量之间的关系这些基本功在后续任何项目里都会反复用到。先别急着上花哨的网络结构和数据增强技巧老老实实跑通全流程理解每一个模块为什么存在比刷到一个漂亮的准确率更有价值。

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

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

免费获取报价 →
↑