我第一次跑通 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整个目录删掉,重新运行,让它再下一次。
另外别忽略download=True。如果你只在代码里写datasets.MNIST(root="./data", train=True, transform=transform)而忘记 download 参数,本地又没有缓存,运行时就会报文件找不到,新手特别容易在这里卡住。还要注意,如果用的是公司内网或者网络受限的环境,数据集可能下载失败,这时可以找一台能联网的机器下载好,把整个数据目录拷过来,放回同样的路径,torchvision 会自动识别,不一定要走"在线下载"这一条路。
3. 把 CNN 拆开看:卷积、池化、全连接各自干了什么
3.1 卷积层:用一个放大镜扫完整张图
卷积层做的事情可以概括成一句话:用一个固定大小的"放大镜",一步一步滑过整张图片,在每一块局部区域里做一次加权求和。这个放大镜就是卷积核,通常大小是 3×3 或 5×5。每次滑过的区域会和卷积核里的权重做点积,得到一个输出值;多个位置组合起来,就得到一张新的特征图。
这里的核心是权重共享。同一个卷积核会作用于整张图片的所有位置,所以"识别到某个局部特征"这件事,不管它出现在图片左上角还是右下角,都能被检测到。这给了模型很强的泛化能力,也是 CNN 相对全连接网络最本质的优势。
padding 和 stride 是两个直观的概念。padding=1 是在图片四周补一圈 0,保证卷积输出尺寸不变,适合搭建比较"深"的网络;stride 控制滑动步长,stride=2 会让输出缩小一半,相当于卷积自带降采样。我的示例网络里用 padding=1 的 3×3 卷积,做完之后尺寸不变,让特征提取和降采样各司其职,逻辑更清晰。
3.2 池化层和激活函数:压缩信息、引入非线性
池化层最常见的是 MaxPool2d,作用是在一个小窗口(比如 2×2)内取最大值,把特征图缩小一半。它最直接的好处是减少后续层的计算量,同时保留最强的响应信号。想象你整理一份会议纪要,把几百页材料浓缩成几十页重点摘要,池化就是这个思路。
激活函数是另一个必须有的东西。如果网络里全是线性运算,那么无论堆多少层,这个网络的表达能力都等价于一层线性变换,根本学不了复杂函数。ReLU 的做法很简单:负数输出直接变成 0,正数保留。它计算快、梯度传播不容易消失,所以是现在 CNN 里的主力激活函数。
不要小看这两步。它们让网络有了逐层抽象的能力:第一层卷积学会边缘、角点这类低级特征,经过激活和池化后,第二层能组合出圆、弧线这类更高一点的形状,再往上才到数字结构这种语义。
3.3 全连接层:把特征变成答案
卷积层和池化层输出的是一堆特征图,本质上是"这张图里有哪些地方像某种特征"的空间分布。但分类任务要输出的是一个 10 维的答案,表示"属于每个数字的概率"。全连接层就是干这个的:把展平后的特征向量,一步步映射到 10 个类别分数。
展平这一步要心里有数。28×28 的图片经过两次"padding=1 卷积 + 2×2 池化"后,尺寸先保持 28×28,池化变 14×14;再卷积保持 14×14,再池化变 7×7。第二层卷积输出 64 个通道,所以展平后就是 64×7×7=3136 个数字。这个数字一旦算错,代码里就会报维度不匹配,后面我会专门讲怎么排查。
最后一个细节是输出层不需要手动接 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", train=True, download=True, transform=transform) test_dataset = datasets.MNIST(root="./data", train=False, download=True, transform=transform) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False)几个值得说清楚的点。transforms.ToTensor()会把 PIL 图片转成形状为(1, 28, 28)的张量,并且把像素值从 0 到 255 缩放到 0 到 1。Normalize((0.1307,), (0.3081,))这两个数是 MNIST 自己的均值和标准差,标准化之后数据大致分布在 -1 到 1 之间,训练更平稳。这里只需要一个通道的均值、标准差,所以写成元组里只有一个数。如果你换了数据集,这两个数要重新算,不要硬搬。
train=True和train=False分别拿到 6 万张训练样本和 1 万张测试样本。DataLoader 的shuffle=True让每个 epoch 训练样本顺序不一样,避免模型学到顺序相关的偏置;测试集不需要打乱,所以shuffle=False。batch_size=64是性价比很高的选择:梯度更新够频繁,训练也稳定。先不用纠结为什么是 64,第 5 节我会对比不同取值的影响。
4.2 网络结构定义
class CNN(nn.Module): def __init__(self): super().__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, 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, optimizer=None, criterion=None, train=True): 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(dim=1) 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(dim=1)取每个样本预测概率最大的类别索引,再和标签做比较来算准确率。
然后是主流程:
model = CNN().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) epochs = 10 for epoch in range(epochs): train_loss, train_acc = run_epoch( model, train_loader, optimizer, criterion, train=True ) test_loss, test_acc = run_epoch( model, test_loader, criterion=criterion, train=False ) print(f"Epoch {epoch + 1}/{epochs} | " f"train_loss {train_loss:.4f} | " f"train_acc {train_acc * 100:.2f}% | " f"test_acc {test_acc * 100:.2f}%")这里用nn.CrossEntropyLoss,它内部已经包含 softmax,所以模型最后一层不需要手动加激活函数。Adam 优化器配lr=0.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.01,loss 可能很快发散,准确率在低位抖动;调成 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_size=3, padding=1) self.bn1 = nn.BatchNorm2d(32) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) 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 的掌控感明显上了一个台阶。