1. 从零手搓一个CNN:为什么我不建议你直接调库
刚入门深度学习那会儿,我和很多人一样,觉得搭一个卷积神经网络是件特别“神圣”的事。打开各种教程,满屏的nn.Conv2d、nn.MaxPool2d、nn.Linear,照着敲一遍,跑通了MNIST,准确率刷到99%,然后就觉得自己会了。直到后来真正去做项目,遇到自定义层、梯度异常、维度对不上这些破事,才发现当初那种“搭积木式”的写法,根本没让我理解CNN到底在干什么。
这篇内容,我想把“PyTorch搭建CNN跑MNIST”这件事,从头到尾、从里到外讲透。不是那种“复制粘贴就能跑”的流水账,而是把每一步背后的为什么说清楚。MNIST这个数据集虽然老,老到很多人觉得它“没资格”用来练手,但恰恰因为它足够简单、足够干净,才最适合用来理解CNN的每一个组件到底在做什么。你把它吃透了,换成CIFAR-10、换成自定义的图片分类任务,思路是完全一样的。
这篇文章适合谁看?如果你已经会一点Python,知道张量是什么,甚至跑过全连接网络,但对卷积、池化、通道这些概念还停留在“知道名字但说不清原理”的阶段,那这篇就是写给你的。我会用大量生活化的类比、可直接复现的代码、以及我在实际调试中踩过的坑,帮你把这块硬骨头啃下来。全程基于PyTorch,不依赖任何高级封装框架,所有代码你都可以直接复制到自己的环境里跑。
2. 整体设计思路:为什么是CNN,而不是全连接
2.1 全连接网络处理图像的致命缺陷
在讲CNN之前,得先说说为什么不用全连接网络(MLP)来处理图像。MNIST的图片是28x28的灰度图,一共784个像素。如果你用一个全连接网络,输入层就是784个神经元,第一层隐藏层假设有512个神经元,那么光是这一层的参数量就是784×512,再加上偏置,接近40万个参数。这还只是第一层。
问题不只是参数量大。全连接网络把图像拉平成一维向量,这意味着它完全丢失了像素之间的空间关系。一个像素和它上下左右邻居的关系,在拉平之后就不存在了。对于图像来说,相邻像素的相关性极强,边缘、纹理、形状这些特征都是局部出现的。全连接网络要重新学习这种空间关系,效率极低,而且容易过拟合。
更直观地说,如果你把一张猫的图片整体向右平移几个像素,全连接网络的输出可能会发生巨大变化,因为它每个神经元都固定地连接特定位置的像素。但CNN不一样,卷积核在图像上滑动,无论猫在左上角还是右下角,它都能检测到“猫耳朵”这个特征。这种平移不变性是CNN的核心优势之一。
2.2 卷积到底在“卷”什么
很多人第一次听到“卷积”这个词是懵的。我用一个最通俗的类比来解释:想象你拿一个手电筒在黑暗的房间里扫视一面墙,手电筒的光斑就是卷积核,墙就是输入图像。你每移动一次手电筒,就照亮一小块区域,然后你根据这块区域的样子做一个判断——比如“这里有没有一条竖线”。你把整个墙扫一遍,就得到了一张“哪里有竖线”的地图,这就是特征图。
数学上,卷积操作就是把卷积核(一个小矩阵,比如3x3)覆盖在输入图像的某个区域上,对应位置相乘再求和,得到一个数值。然后卷积核滑动到下一个位置,重复这个过程。滑动的步长叫stride,边缘补零叫padding。
这里有个关键点:卷积核的权重是共享的。也就是说,同一个卷积核在整张图上滑动时,用的都是同一组权重。这就是为什么CNN参数量少——一个3x3的卷积核只有9个权重加1个偏置,共10个参数,却能扫描整张28x28的图。而全连接网络处理同样大小的图,参数量是它的几万倍。
2.3 池化层的降维哲学
池化层的作用,说白了就是“抓大放小”。最常用的是最大池化(Max Pooling),比如2x2的窗口,步长为2,就是把图像分成2x2的小块,每块取最大值。这样做有两个好处:一是减少参数量和计算量,二是提供一定程度的平移不变性——只要特征在2x2范围内移动,池化后的输出不变。
你可以把池化想象成看一张高清照片和它的缩略图。缩略图虽然细节少了,但主要物体还在,而且你更容易一眼看出照片里有什么。池化就是在做类似的事,它保留了最显著的特征,丢弃了不那么重要的细节。
2.4 整体网络架构的设计考量
对于MNIST这个任务,我设计的CNN结构是这样的:两个卷积层,每个后面跟一个最大池化层,然后是两个全连接层,最后输出10个类别。具体来说:
- 第一层:卷积(输入1通道,输出32通道,卷积核3x3,padding=1),ReLU激活,最大池化(2x2)
- 第二层:卷积(输入32通道,输出64通道,卷积核3x3,padding=1),ReLU激活,最大池化(2x2)
- 然后展平,接全连接层(64×7×7 → 128),ReLU激活,再接输出层(128 → 10)
为什么选32和64这两个通道数?这是经验值。通道数太少,网络容量不够,学不到复杂特征;通道数太多,参数量暴涨,容易过拟合,而且MNIST这么简单的任务用不着。32和64是在实践中被反复验证过的、对于小图像分类比较均衡的选择。
为什么用两个卷积层而不是一个或三个?一个卷积层只能提取低级特征(边缘、角点),两个卷积层可以组合出稍高级的特征(纹理、简单形状),对于MNIST的手写数字来说足够了。三个卷积层在MNIST上提升不明显,反而增加过拟合风险。当然,这不是绝对的,你可以根据实际情况调整。
padding=1是为了保持卷积后图像尺寸不变。3x3的卷积核,如果不加padding,输出尺寸会缩小2。加一圈0之后,输入输出尺寸一致,这样设计网络时更方便计算。
3. 核心细节拆解:每一步都在做什么
3.1 数据加载与预处理的门道
MNIST数据集在PyTorch里可以通过torchvision.datasets.MNIST直接下载。但这里有几个细节值得注意。
首先是归一化。原始像素值是0到255的整数,直接输入网络会导致梯度爆炸或收敛缓慢。我们通常把它缩放到0到1之间,再减去均值、除以标准差。MNIST的全局均值大约是0.1307,标准差是0.3081。这两个数字是统计出来的,直接用就行。归一化之后,数据分布更接近标准正态分布,梯度下降更稳定。
transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])ToTensor()会把PIL图像转成PyTorch张量,并且自动把像素值从0-255缩放到0-1。Normalize则执行(x - mean) / std的操作。
其次是批处理。我们不会一次把60000张图全部塞进网络,而是分成小批次(batch)。常见的batch size是64、128、256。batch size太小,梯度更新噪声大,训练不稳定;batch size太大,内存吃不消,而且可能陷入局部最优。对于MNIST,128是一个比较稳妥的选择。
还有一个容易被忽略的点:数据加载器的shuffle。训练集一定要打乱顺序,否则网络会学到“前1000张都是数字1”这种无意义的顺序信息。测试集不需要打乱,因为只是评估,顺序无所谓。
3.2 卷积层的参数初始化
PyTorch默认使用Kaiming初始化(也叫He初始化)来初始化卷积层权重,这是专门为ReLU激活函数设计的。它的核心思想是:让每一层的输出方差保持一致,避免梯度消失或爆炸。
具体来说,Kaiming初始化从均值为0、标准差为sqrt(2 / fan_in)的正态分布中采样权重,其中fan_in是输入通道数乘以卷积核面积。对于我们的第一层卷积,输入通道1,卷积核3x3,所以fan_in = 1×3×3 = 9,标准差就是sqrt(2/9) ≈ 0.471。
你不需要手动去算这些,PyTorch会自动处理。但理解这个机制有助于你在遇到梯度问题时知道从哪里排查。如果你发现训练初期loss就变成NaN,很可能是初始化出了问题,或者学习率太大。
3.3 激活函数的选择:为什么是ReLU
ReLU(Rectified Linear Unit)的定义极其简单:f(x) = max(0, x)。大于0的数原样输出,小于等于0的变成0。
为什么不用Sigmoid或Tanh?因为Sigmoid在输入很大或很小时,梯度趋近于0,导致梯度消失。网络层数一多,反向传播时梯度层层相乘,很快就变成0了,前面的层根本学不动。ReLU在正区间的梯度恒为1,梯度可以顺畅地传回去。
ReLU还有个好处是计算极快,就是一个max操作,比Sigmoid的指数运算快得多。当然ReLU也有缺点,比如“神经元死亡”问题——如果一个神经元的输出永远是负的,那它的梯度永远是0,就再也学不回来了。不过对于MNIST这种简单任务,这个问题不严重。如果你想更稳妥,可以用LeakyReLU,它在负区间给一个很小的斜率(比如0.01),避免神经元完全死亡。
3.4 全连接层的维度计算
从卷积层到全连接层的过渡,是新手最容易出错的地方。我们的第二层卷积输出是64通道,经过两次2x2池化后,图像尺寸从28x28变成14x14,再变成7x7。所以展平后的维度是64 × 7 × 7 = 3136。
这个计算过程必须清晰:输入28x28,第一次池化后14x14,第二次池化后7x7。通道数不变,因为池化不改变通道数。所以最终特征图是64个7x7的矩阵,展平就是3136维向量。
如果你改了网络结构,比如加了一层卷积或者改了池化方式,这个维度就要重新算。我见过太多人在这里卡住,报错信息通常是“mat1 and mat2 shapes cannot be multiplied”,意思就是全连接层的输入维度和实际展平后的维度对不上。
3.5 损失函数与优化器的搭配
分类任务用交叉熵损失(CrossEntropyLoss)。在PyTorch里,nn.CrossEntropyLoss已经把Softmax和负对数似然损失合在一起了,所以网络的最后一层不要加Softmax,直接输出原始logits就行。这一点很多人会搞错,加了Softmax反而会导致数值不稳定。
优化器选Adam还是SGD?Adam自适应调整学习率,收敛快,对超参数不敏感,适合快速实验。SGD加动量虽然收敛慢一些,但在某些任务上泛化性能更好。对于MNIST,Adam就足够了,学习率设0.001是标配。如果你想更精细地调,可以用学习率调度器,比如每10个epoch把学习率乘以0.1。
4. 完整实操:从零到99%准确率的每一步
4.1 环境准备与依赖安装
我假设你已经装了Python 3.8以上版本。需要的库不多:
pip install torch torchvision如果你有NVIDIA显卡并且装了CUDA,PyTorch会自动使用GPU加速。没有GPU也没关系,MNIST这个规模的数据集,CPU训练也就几分钟的事。
验证安装是否成功:
import torch print(torch.__version__) print(torch.cuda.is_available())如果输出True,说明GPU可用。接下来所有代码我都会写成兼容CPU和GPU的形式。
4.2 数据集的下载与加载
import torch import torch.nn as nn import torch.optim as optim from torchvision import datasets, transforms from torch.utils.data import DataLoader # 定义预处理 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=128, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False)第一次运行会自动下载数据到./data目录。训练集60000张,测试集10000张。shuffle=True只在训练集上设置,测试集保持顺序。
4.3 网络结构的完整定义
class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() # 第一层卷积:1通道输入,32通道输出,3x3卷积核,padding=1 self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) # 第二层卷积:32通道输入,64通道输出,3x3卷积核,padding=1 self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) # 最大池化:2x2窗口,步长2 self.pool = nn.MaxPool2d(2, 2) # 全连接层:64*7*7 -> 128 self.fc1 = nn.Linear(64 * 7 * 7, 128) # 输出层:128 -> 10 self.fc2 = nn.Linear(128, 10) # Dropout层,防止过拟合 self.dropout = nn.Dropout(0.25) def forward(self, x): # 第一层卷积 + ReLU + 池化 x = self.pool(torch.relu(self.conv1(x))) # 第二层卷积 + ReLU + 池化 x = self.pool(torch.relu(self.conv2(x))) # 展平 x = x.view(-1, 64 * 7 * 7) # 全连接 + ReLU + Dropout x = self.dropout(torch.relu(self.fc1(x))) # 输出层 x = self.fc2(x) return x这里我加了一个Dropout层,丢弃率0.25。Dropout在训练时随机“关掉”一部分神经元,强迫网络不依赖某些特定的神经元,从而增强泛化能力。测试时Dropout自动关闭。对于MNIST,不加Dropout也能跑到99%,但加了之后测试集准确率更稳定,不容易波动。
x.view(-1, 64 * 7 * 7)中的-1表示自动推断batch size。这样写的好处是你不用关心当前batch有多少张图,PyTorch会帮你算。
4.4 训练循环的编写与技巧
def train(model, device, train_loader, optimizer, epoch): model.train() for batch_idx, (data, target) in enumerate(train_loader): data, target = data.to(device), target.to(device) optimizer.zero_grad() output = model(data) loss = nn.CrossEntropyLoss()(output, target) loss.backward() optimizer.step() if batch_idx % 100 == 0: print(f'Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)} ' f'({100. * batch_idx / len(train_loader):.0f}%)]\tLoss: {loss.item():.6f}')几个关键点:
model.train()把模型设为训练模式,这会影响Dropout和BatchNorm的行为。optimizer.zero_grad()清空上一轮的梯度,否则梯度会累加。loss.backward()计算梯度,optimizer.step()更新参数。
打印频率设为每100个batch一次,这样你能看到训练进度,又不至于刷屏。
4.5 测试与评估
def test(model, device, test_loader): model.eval() test_loss = 0 correct = 0 with torch.no_grad(): for data, target in test_loader: data, target = data.to(device), target.to(device) output = model(data) test_loss += nn.CrossEntropyLoss()(output, target).item() pred = output.argmax(dim=1, keepdim=True) correct += pred.eq(target.view_as(pred)).sum().item() test_loss /= len(test_loader) accuracy = 100. * correct / len(test_loader.dataset) print(f'\nTest set: Average loss: {test_loss:.4f}, ' f'Accuracy: {correct}/{len(test_loader.dataset)} ({accuracy:.2f}%)\n') return accuracymodel.eval()切换到评估模式,关闭Dropout。torch.no_grad()禁用梯度计算,节省内存和计算时间。output.argmax(dim=1)取每个样本最大logit对应的类别作为预测结果。
4.6 主训练流程与结果
def main(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = CNN().to(device) optimizer = optim.Adam(model.parameters(), lr=0.001) for epoch in range(1, 11): train(model, device, train_loader, optimizer, epoch) test(model, device, test_loader) if __name__ == '__main__': main()跑10个epoch,每个epoch训练集完整过一遍。通常在第3到第5个epoch,测试准确率就能达到98.5%以上,第10个epoch能到99%左右。如果你想要更高的准确率,可以加数据增强(比如随机旋转、平移),或者用更深的网络,但99%对于MNIST来说已经是很不错的水平了。
5. 常见问题与排查技巧实录
5.1 维度不匹配报错
这是最常见的问题,报错信息通常是RuntimeError: mat1 and mat2 shapes cannot be multiplied。原因几乎总是全连接层的输入维度算错了。
排查方法:在forward函数里,展平之前打印一下x.shape。比如:
x = self.pool(torch.relu(self.conv2(x))) print(x.shape) # 应该是 torch.Size([batch_size, 64, 7, 7]) x = x.view(-1, 64 * 7 * 7)如果打印出来不是[*, 64, 7, 7],说明前面的卷积或池化参数有问题。常见错误包括:池化窗口设成了3x3但步长没改,或者卷积的padding设错了。
5.2 Loss不下降或变成NaN
Loss变成NaN通常是因为学习率太大,导致梯度爆炸。解决方法:把学习率调小,比如从0.01降到0.001甚至0.0001。另外检查一下数据有没有归一化,如果输入是0-255的原始像素,梯度会非常大。
Loss不下降可能是网络结构有问题,比如所有层都是线性的,没有激活函数。或者学习率太小,更新步子太慢。还有一种可能是数据加载器出了问题,比如标签和图像不对应。
5.3 过拟合的识别与处理
过拟合的典型表现是:训练集准确率很高(比如99.9%),但测试集准确率明显低(比如97%),而且两者差距越来越大。
处理方法有几种:加Dropout层(我上面已经加了)、加L2正则化(在优化器里设置weight_decay)、减少网络参数量、增加训练数据(可以通过数据增强)。对于MNIST,过拟合通常不严重,因为数据量足够大,任务也简单。
5.4 GPU内存不足
如果你在GPU上训练,遇到CUDA out of memory,可以尝试减小batch size。MNIST的模型很小,通常不会遇到这个问题,但如果你改了网络结构,加了很多通道,就可能爆显存。另外,记得在测试时用torch.no_grad(),否则会保存不必要的计算图,占用额外内存。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 维度不匹配报错 | 全连接层输入维度算错 | 打印展平前的shape,重新计算 |
| Loss变成NaN | 学习率太大或数据未归一化 | 降低学习率,检查归一化 |
| Loss不下降 | 学习率太小或无激活函数 | 调大学习率,检查网络结构 |
| 测试准确率远低于训练 | 过拟合 | 加Dropout,加正则化,数据增强 |
| GPU内存不足 | batch size太大 | 减小batch size,测试时用no_grad |
| 训练速度极慢 | 没用GPU或数据加载瓶颈 | 检查cuda是否可用,增加num_workers |
6. 进阶优化:让准确率再上一层楼
6.1 数据增强的威力
MNIST的手写数字有自然的变化——不同人写的“7”有的带横杠有的不带,有的“1”是竖线有的是斜线。数据增强就是人工制造这些变化,让网络见过更多样的样本。
常用的增强方式包括:随机旋转(±10度)、随机平移(±2像素)、随机缩放。在PyTorch里可以用transforms.RandomAffine实现:
transform_train = transforms.Compose([ transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])注意,数据增强只用在训练集上,测试集保持原始变换。加了增强之后,训练准确率可能会下降一点,但测试准确率通常会提升0.2%到0.5%。
6.2 学习率调度策略
固定学习率在训练后期可能太大,导致在最优解附近震荡。学习率调度器可以让学习率随着训练进行逐渐减小。最常用的是StepLR:
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)每5个epoch把学习率乘以0.1。这样前期学得快,后期精细调整。还有CosineAnnealingLR,让学习率按余弦曲线下降,效果也不错。
6.3 Batch Normalization的加入
BatchNorm层可以加速训练、提高稳定性。它把每一层的输入重新归一化到均值为0、方差为1,然后再用一个可学习的缩放和平移参数调整。在卷积层后面、激活函数前面加BatchNorm:
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(32)然后在forward里:
x = self.pool(torch.relu(self.bn1(self.conv1(x))))加了BatchNorm之后,学习率可以设大一点(比如0.01),收敛更快。不过对于MNIST这种简单任务,BatchNorm的提升不明显,更多是在深层网络中发挥作用。
6.4 模型集成与投票
如果你想榨干最后一点准确率,可以训练多个CNN,然后对它们的预测结果进行投票。比如训练5个结构相同但初始化不同的网络,测试时取5个网络输出的平均logits,再取argmax。这种方法通常能提升0.1%到0.3%的准确率,但代价是训练时间和推理时间成倍增加。对于MNIST,99.5%已经接近人类标注的准确率上限了,再往上提升意义不大。
7. 我踩过的坑与实操心得
第一个坑是忘记加padding。刚开始学的时候,我照着教程写卷积层,没注意padding参数,结果28x28的图经过两次3x3卷积和两次池化后,尺寸变成了4x4,全连接层的输入维度完全对不上。后来养成了习惯:每次改网络结构,先在纸上画一遍尺寸变化,或者写个简单的脚本打印每层输出。
第二个坑是测试时忘记切换eval模式。有一次我发现测试准确率波动特别大,同一个模型跑两次结果差1%。排查了半天才发现,测试时没有调用model.eval(),Dropout还在随机丢弃神经元,导致每次预测结果都不一样。这个坑很隐蔽,因为训练时一切正常,只有测试时才会暴露。
第三个坑是数据加载的num_workers设置。在Windows上,num_workers大于0有时会导致程序卡死或报错。如果你在Windows上跑,建议先设num_workers=0,确认能跑通再尝试调大。在Linux上就没这个问题,可以放心设成4或8来加速数据加载。
第四个坑是学习率设得太大。我一开始用SGD,学习率设了0.1,结果loss直接飞到NaN。后来改成Adam加0.001,一切正常。Adam对学习率确实不敏感,但也不是随便设都行,0.001到0.0001之间是比较安全的范围。
还有一个心得是保存最佳模型。不要只看最后一个epoch的结果,训练过程中测试准确率最高的那个模型才是最好的。可以加一段逻辑,每次测试准确率超过历史最佳就保存模型参数:
best_acc = 0 if accuracy > best_acc: best_acc = accuracy torch.save(model.state_dict(), 'best_model.pth')这样即使后面过拟合了,你手里还有一个最佳版本的模型。
最后说一个关于随机种子的事。深度学习实验有随机性,每次跑的结果可能略有不同。为了可复现,可以在代码开头固定随机种子:
torch.manual_seed(42)但要注意,固定种子并不意味着结果完全确定,因为GPU上的某些操作本身就有非确定性。不过对于MNIST这种任务,固定种子后结果基本一致。
这个CNN跑MNIST的框架,你稍微改改就能用到其他图像分类任务上。把输入通道从1改成3(RGB),把输出类别从10改成你的类别数,调整一下全连接层的维度,就是一个通用的图像分类模板。真正重要的是理解每一步在做什么,而不是记住代码怎么写。