资讯详情

PyTorch实战:CNN实现MNIST手写数字识别,从原理到调参避坑

📅 2026/10/19 17:13:00 | 华诺云谱 👁 阅读
PyTorch实战:CNN实现MNIST手写数字识别,从原理到调参避坑
我第一次跑通 PyTorch CNN MNIST 手写数字识别的时候距离我完全理解卷积神经网络还有很长一段路但这不妨碍我盯着终端里那个测试准确率 98.6% 愣了半天。MNIST 这个手写数字数据集几乎成了深度学习入门阶段人人都会碰到的第一道题给一张 28×28 的灰度图片让模型判断数字是 0 到 9 中的哪一个。用 PyTorch 搭一个 CNN 在这个数据集上完成训练和测试也成了无数人摸索深度学习的第一站。这个项目看起来只是跑通一个示例但它串起来的东西比想象中完整环境安装、数据加载、网络搭建、训练循环、模型评估、调参优化一个都不少。接下来我会把每一步拆开讲包括很多文档里不会写的坑。无论你是刚装好环境准备入门还是已经跑过几个例子但想弄清楚原理这篇文章应该都有点用。1. 动手前先弄明白MNIST 和 CNN 为什么是绝配1.1 MNIST 是什么为什么拿它练手MNIST 由 6 万张训练图片和 1 万张测试图片组成每张图片都是 28×28 像素的灰度图也就是只有一层通道内容是不同人手写的 0 到 9。图片本身已经把数字区域裁得很规整没有复杂背景数字也基本居中所以它天然适合作为第一个分类任务。为什么几乎所有教程都拿它开刀原因很实在数据量适中6 万张图片足够训练一个像样的模型又不会大到下载要等半天任务定义非常清晰就是一个多分类问题准确率可以非常直观地反映模型好坏图片尺寸很小在普通 CPU 上也跑得动。早期阶段能在一台没有独显的机器上几分钟内看到训练效果这个体验极其重要。还有一个容易被忽略的点MNIST 上的结果好量化。你很快就能建立什么样的准确率算正常的直觉。第一次跑测试准确率大概在 98% 以上说明流程没问题如果低于 97%大概率不是模型不行而是数据或训练逻辑出了错。这种直觉在后面做任何视觉任务都会用到。1.2 全连接网络也能训练为什么非要用 CNN很多人会问MNIST 图片是 28×28拉直成 784 个数塞进全连接网络也能得到 97% 左右的准确率为什么非要用 CNN关键在于空间结构被忽略了。全连接层的输入是展开的一维向量784 个像素之间的相对位置关系比如左边有一条竖线中间有一个圈在图变成向量的瞬间被打散了。你可以靠全连接硬记像素模式但它没办法在不同位置之间做抽象特征的共享。而 CNN 的思路完全不同用一个小窗口卷积核在图片上滑动处处共享同一套权重。这样既保留了像素的邻域关系又大幅减少了参数数量。举个直观的数字784 输入到 128 个神经元的全连接层光这一层就有 10 万个左右的参数而一个 3×3 的卷积层输入 1 个通道输出 32 个通道参数只有 288 个。参数少了数据量没变模型的归纳偏置反而更合理效果自然更好。所以这个项目选择 CNN不只是跟风而是因为它确实更匹配图像数据的特点。2. 环境准备PyTorch、CUDA 和数据集下载的坑2.1 PyTorch 安装先搞清楚 CUDA 版本再动手现在的 PyTorch 安装已经比以前清爽很多但在命令行敲pip install torch torchvision之前我建议你先想清楚一个问题你到底要不要用 GPU。如果你是为了学习跑 MNIST完全可以在 CPU 上完成。MNIST 单张图片只有 28×28一个简单的 CNN 在普通 CPU 上训练一轮大概几十秒到一两分钟十轮就能收得很好。用 GPU 当然更快但会引入 CUDA 版本匹配问题显卡驱动支持的 CUDA 版本、PyTorch 编译时使用的 CUDA 版本、torchvision 的版本必须兼容否则装完一跑就报找不到 NVIDIA 驱动之类的错。这个问题在 Windows 上尤其常见pytorch 官网的安装命令会自动匹配默认版本但你本地的驱动不一定跟得上。我的建议是如果是新机器先执行python -c import torch; print(torch.__version__)确认安装结果再跑torch.cuda.is_available()检查 GPU 是否可用。如果是纯 CPU 环境也不用焦虑后面的代码在两种环境下都能跑只是设备不同。代码里用device torch.device(cuda if torch.cuda.is_available() else cpu)一行就够了。还有一个很常见的坑同时用 conda 和 pip 装 PyTorch结果包被分散到不同环境里互相覆盖最后 import 报错。建议建一个独立虚拟环境在同一个环境里统一使用一种安装方式别混着来。2.2 MNIST 数据集的下载与缓存机制torchvision 的datasets.MNIST会自动下载数据集但要先检查本地缓存。默认情况下它把文件放在你指定的root目录下结构大概是./data/MNIST/raw/里面是压缩后的原始文件。设计上很贴心下载过一次下次离线也能用。但这个自动背后有个坑如果网络不稳定下载到一半断了文件夹里会留下一个不完整的文件。torchvision 有时候不会自动重新下载你的代码会一直卡在那或者加载时报不是 GZIP 文件之类的错误。处理方式很直接把./data/MNIST/raw整个目录删掉重新运行让它再下一次。另外别忽略downloadTrue。如果你只在代码里写datasets.MNIST(root./data, trainTrue, transformtransform)而忘记 download 参数本地又没有缓存运行时就会报文件找不到新手特别容易在这里卡住。还要注意如果用的是公司内网或者网络受限的环境数据集可能下载失败这时可以找一台能联网的机器下载好把整个数据目录拷过来放回同样的路径torchvision 会自动识别不一定要走在线下载这一条路。3. 把 CNN 拆开看卷积、池化、全连接各自干了什么3.1 卷积层用一个放大镜扫完整张图卷积层做的事情可以概括成一句话用一个固定大小的放大镜一步一步滑过整张图片在每一块局部区域里做一次加权求和。这个放大镜就是卷积核通常大小是 3×3 或 5×5。每次滑过的区域会和卷积核里的权重做点积得到一个输出值多个位置组合起来就得到一张新的特征图。这里的核心是权重共享。同一个卷积核会作用于整张图片的所有位置所以识别到某个局部特征这件事不管它出现在图片左上角还是右下角都能被检测到。这给了模型很强的泛化能力也是 CNN 相对全连接网络最本质的优势。padding 和 stride 是两个直观的概念。padding1 是在图片四周补一圈 0保证卷积输出尺寸不变适合搭建比较深的网络stride 控制滑动步长stride2 会让输出缩小一半相当于卷积自带降采样。我的示例网络里用 padding1 的 3×3 卷积做完之后尺寸不变让特征提取和降采样各司其职逻辑更清晰。3.2 池化层和激活函数压缩信息、引入非线性池化层最常见的是 MaxPool2d作用是在一个小窗口比如 2×2内取最大值把特征图缩小一半。它最直接的好处是减少后续层的计算量同时保留最强的响应信号。想象你整理一份会议纪要把几百页材料浓缩成几十页重点摘要池化就是这个思路。激活函数是另一个必须有的东西。如果网络里全是线性运算那么无论堆多少层这个网络的表达能力都等价于一层线性变换根本学不了复杂函数。ReLU 的做法很简单负数输出直接变成 0正数保留。它计算快、梯度传播不容易消失所以是现在 CNN 里的主力激活函数。不要小看这两步。它们让网络有了逐层抽象的能力第一层卷积学会边缘、角点这类低级特征经过激活和池化后第二层能组合出圆、弧线这类更高一点的形状再往上才到数字结构这种语义。3.3 全连接层把特征变成答案卷积层和池化层输出的是一堆特征图本质上是这张图里有哪些地方像某种特征的空间分布。但分类任务要输出的是一个 10 维的答案表示属于每个数字的概率。全连接层就是干这个的把展平后的特征向量一步步映射到 10 个类别分数。展平这一步要心里有数。28×28 的图片经过两次padding1 卷积 2×2 池化后尺寸先保持 28×28池化变 14×14再卷积保持 14×14再池化变 7×7。第二层卷积输出 64 个通道所以展平后就是 64×7×73136 个数字。这个数字一旦算错代码里就会报维度不匹配后面我会专门讲怎么排查。最后一个细节是输出层不需要手动接 softmax。PyTorch 的nn.CrossEntropyLoss已经把 softmax 和负对数似然合并在一起了训练时直接吃原始分数算得又快又稳。4. 直接开跑一份能跑到 99% 的完整代码拆解4.1 数据加载和预处理我先给完整代码再逐块解释。下面这份脚本可以直接保存运行import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms device torch.device(cuda if torch.cuda.is_available() else cpu) print(使用的设备:, device) 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) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size256, shuffleFalse)几个值得说清楚的点。transforms.ToTensor()会把 PIL 图片转成形状为(1, 28, 28)的张量并且把像素值从 0 到 255 缩放到 0 到 1。Normalize((0.1307,), (0.3081,))这两个数是 MNIST 自己的均值和标准差标准化之后数据大致分布在 -1 到 1 之间训练更平稳。这里只需要一个通道的均值、标准差所以写成元组里只有一个数。如果你换了数据集这两个数要重新算不要硬搬。trainTrue和trainFalse分别拿到 6 万张训练样本和 1 万张测试样本。DataLoader 的shuffleTrue让每个 epoch 训练样本顺序不一样避免模型学到顺序相关的偏置测试集不需要打乱所以shuffleFalse。batch_size64是性价比很高的选择梯度更新够频繁训练也稳定。先不用纠结为什么是 64第 5 节我会对比不同取值的影响。4.2 网络结构定义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.dropout nn.Dropout(0.25) def forward(self, x): x self.pool(torch.relu(self.conv1(x))) x self.pool(torch.relu(self.conv2(x))) x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) x self.dropout(x) return self.fc2(x)这个网络的结构可以概括为卷积、激活、池化两次然后展平、全连接、Dropout、输出。conv1的输入通道是 1因为 MNIST 是灰度图输出通道 32表示第一层要学习 32 种基础特征。conv2从 32 个通道变成 64 个通道捕获更抽象的组合特征。fc1把 3136 维特征压缩到 128 维fc2输出 10 个数对应 0 到 9。Dropout 在训练时随机把 25% 的神经元输出置 0防止全连接层过拟合。forward里每一步都要配合尺寸变化来看。x.view(x.size(0), -1)是展平操作-1表示让 PyTorch 自动推导这一维的大小。如果你改动网络中间层时把 7×7 算错了这里就会报维度错误。4.3 训练循环和评估逻辑def run_epoch(model, loader, optimizerNone, criterionNone, trainTrue): if train: model.train() else: model.eval() total_loss 0.0 correct 0 total 0 with torch.set_grad_enabled(train): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) if train: optimizer.zero_grad() loss.backward() optimizer.step() 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这个函数把训练和评估合并到了一起唯一区别是train参数。训练时先model.train()打开 Dropout评估时用model.eval()把 Dropout 关掉否则测试结果每次都不一样。torch.set_grad_enabled(train)保证评估阶段不构建计算图省内存也快很多。optimizer.zero_grad()很多人第一次会漏掉。PyTorch 的梯度默认是累加的不清零的话第二次 backward 会把两次梯度加在一起loss 会乱七八糟。outputs.argmax(dim1)取每个样本预测概率最大的类别索引再和标签做比较来算准确率。然后是主流程model CNN().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) epochs 10 for epoch in range(epochs): train_loss, train_acc run_epoch( model, train_loader, optimizer, criterion, trainTrue ) test_loss, test_acc run_epoch( model, test_loader, criterioncriterion, trainFalse ) print(fEpoch {epoch 1}/{epochs} | ftrain_loss {train_loss:.4f} | ftrain_acc {train_acc * 100:.2f}% | ftest_acc {test_acc * 100:.2f}%)这里用nn.CrossEntropyLoss它内部已经包含 softmax所以模型最后一层不需要手动加激活函数。Adam 优化器配lr0.001是默认组合后面会讲怎么调。4.4 跑起来之后你会看到什么正常的话第一个 epoch 训练准确率就能到 95% 左右测试准确率大概在 97%~98%。之后每个 epoch 缓慢上升5 个 epoch 左右 train_acc 普遍能到 99% 以上test_acc 在 98.5%~99.2% 之间浮动。10 个 epoch 结束时常见结果就是 test_acc 在 99% 附近。这里有个心态问题你不需要看到 99.5% 才觉得跑对了。不同机器、不同随机种子、不同 PyTorch 版本结果会有零点几个百分点的浮动。判断标准应该是准确率每轮平稳上升loss 不断下降最终测试集稳定在 98% 以上。如果跑到第 3 个 epoch loss 还在原地打转、准确率在 10% 附近那一定是你哪一步写错了不是模型问题。5. 调参实战从 98% 到 99% 的优化路径5.1 基线结果和判断标准先把基线摆清楚两层卷积加一层全连接Adam、学习率 0.001、batch size 64、不用数据增强测试准确率约 99%。这就是大多数人的起点。如果你的结果明显低于这个水平不要急着换网络先确认三件事数据是否做了 Normalize、训练时是否调用了model.train()、评估时是否用了model.eval()。这三处错了模型再先进也白搭。反过来如果你已经稳定在 98.8% 以上再想往上走靠的不是堆网络而是观察训练过程中哪个环节收益最大。5.2 学习率、Batch Size 和优化器学习率是最容易出效果的参数。Adam 用 0.001 是常规起点但不同任务最优值不同。把学习率调成 0.01loss 可能很快发散准确率在低位抖动调成 0.0001训练会稳但慢10 个 epoch 可能还没充分收敛。比较稳妥的做法是先用 0.001 跑一轮观察 train_loss 是稳步下降还是震荡再决定往上还是往下调。Batch size 影响的是梯度的噪声程度。batch 越大每个 step 看到的样本越多梯度方向越稳定但参数更新次数变少batch 太小更新频繁但梯度噪声大训练可能不稳定。MNIST 上 batch size 取 32 或 64 都很好取 256 也不是不行只是收敛曲线会平缓一些。对于这个入门项目稳定性和速度的平衡点就在 64 附近。优化器的差异在 MNIST 上不算大。Adam 收敛快、对超参数不敏感适合快速验证想法带动量的 SGD 最终准确率往往更稳、泛化更好但要额外调学习率和动量上手成本高。我的建议是项目初期固定用 Adam跑通整个流程等你想深入理解优化器的作用时再换 SGD 对比 loss 曲线体会收敛速度和最终精度之间的权衡。5.3 更进一步的改进BatchNorm 和简单增强想让准确率稳定到 99% 以上性价比最高的改动是加 BatchNorm。加在卷积层和 ReLU 之间能加速收敛而且对初始学习率不那么敏感。class CNN_BN(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.bn1 nn.BatchNorm2d(32) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.bn2 nn.BatchNorm2d(64) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) self.dropout nn.Dropout(0.25) def forward(self, x): x self.pool(torch.relu(self.bn1(self.conv1(x)))) x self.pool(torch.relu(self.bn2(self.conv2(x)))) x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) x self.dropout(x) return self.fc2(x)加完之后你可能发现准确率提升只有 0.1% 或 0.2%但这不重要。BatchNorm 的真正价值在于让模型更皮实你不会因为学习率选得略高或参数初始化不佳而前功尽弃。这对以后做更复杂的数据集是长期受用的小习惯。数据增强方面MNIST 的使用空间有限。随机旋转 10 度、随机平移几个像素是可行的但要小心旋转过头数字 6 和 9 会互相混淆9 翻转一下就像 6反而把模型带偏。我的建议是在清晰理解数据增强的作用之前先不加。MNIST 本身就是个很干净的数据集简单模型已经够了把增强留到需要防过拟合时再用。5.4 过拟合的判断和应对MNIST 上模型容量远超任务难度过拟合确实可能出现但通常表现得很轻。判断方法是看训练准确率和测试准确率的差距如果训练集已经 99.9%测试集却只有 97%说明模型在死记训练样本泛化不够如果两者都接近 99%说明处在比较健康的状态。应对过拟合的常见武器是 Dropout、数据增强和早停。示例网络里已经在全连接层前用了 Dropout(0.25)。早停的意思是每个 epoch 后看验证集这里用测试集近似的 loss如果连续几个 epoch 不再下降就停止训练。PyTorch 没有内置早停 API但实现起来很简单在循环里比较上一个 epoch 的测试 loss没有改善就计数超过阈值就 break。对我来说这个项目里最重要的不是准去卡到 99.5%而是养成先看指标再改结构一次只改一个变量的习惯。两个参数一起调最后你根本不知道到底是哪个起了作用。6. 复盘避坑新手最容易翻车的几个地方6.1 高频错误汇总把实战里最常见的几个坑列成表格方便对照排查症状常见原因解决方式loss 初始就很大而且几乎不下降忘了 Normalize直接喂原始 PIL 图片检查 transform 是否包含 ToTensor 和 Normalize训练时报 shape 不匹配展平维度算错或网络输出和标签维度不一致核对 conv/pool 后的尺寸用x.view(x.size(0), -1)测试结果每轮波动很大评估时忘了model.eval()Dropout 还在随机丢神经元评估前调用model.eval()并放进torch.no_grad()loss 中途变成 NaN学习率过大或数据里有异常值调低学习率检查数据的取值范围训练时 loss 一直不降准确率停在 10%标签类型错误或优化器梯度没清零确认 labels 是 LongTensor每个 step 前调用zero_grad()加载数据时报文件损坏下载中断缓存文件不完整删掉./data/MNIST/raw重新运行这里需要多说一点loss 停在 10% 附近几乎是标签和预测不对齐的标志。MNIST 有 10 个类随机猜的正确率就是 10%模型如果真的在学一定会超过这个值。所以一旦看到准确率长时间贴着 10%第一反应应该是查数据流而不是换模型。另一个容易踩的坑是环境混乱。我见过不少人在好几个 Python 环境里装了不同版本的 torch跑同一份代码结果还不一样最后发现 import 的是另一个环境里的包。建议每次动手前用pip list | grep torch确认当前环境里的版本再开始跑。6.2 跑通之后下一步可以做什么我自己跑通这个项目之后做的第一件事不是急着换更大的网络而是把训练过程中的 loss 曲线画了出来。看着曲线一步步下降比盯着终端里的数字更能建立直觉。你可以用 matplotlib 简单记录每个 epoch 的 train_loss 和 test_acc也可以上 TensorBoard效果类似。接着可以试试几件低成本的事把网络结构打印出来确认自己理解的维度没有偏差把第一层卷积核的权重可视化看看模型学到了什么样的边缘模式或者在测试集里故意挑几张模糊的、倾斜的数字观察模型在边界样本上的表现。这些练习的意义在于你会真正理解模型学到了什么以及模型在什么场景下会失效这比多刷一个百分点的准确率更值钱。我个人还有一个习惯跑通一个项目之后会立刻把数据加载、训练循环这种公共部分抽成独立的函数或模块下次换数据集直接复用。MNIST 这个项目虽然简单但如果每一段代码都是复制粘贴、没有自己的组织后面做稍大一点的任务就会手忙脚乱。先把这个小循环走顺你会发现自己对 PyTorch 的掌控感明显上了一个台阶。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑