资讯动态

PyTorch入门实战:详解CNN核心组件与MNIST图像分类

发布时间:2026/10/18 5:56:14 来源:尧图企业网站定制
PyTorch入门系列写到这里终于轮到卷积神经网络CNN了。这是深度学习中曝光率最高、应用面最广的网络结构也是计算机视觉方向绕不开的基础课。很多刚入门的朋友学完张量操作和全连接网络之后会觉得CNN的组件特别多——卷积、池化、Flatten、Dropout、BatchNorm每个词单看都认识放进模型里就不知道谁在前谁在后。这篇就专门解决这个问题我会从基本原理讲起把每个组件的设计动机、参数含义和实际踩坑点都拆开说明最后用完整代码搭一个能在MNIST上跑到99%准确率的小网络。适合已经掌握PyTorch基本操作、正准备进入视觉领域的读者。1. 为什么CNN是处理图像任务的主流选择1.1 全连接网络处理图像的天然缺陷先用最朴素的想法来推演给你一张28×28的灰度图如果把它拉平成一个784维的向量再接到一个全连接网络上能完成分类任务吗能但代价非常大。假设第一层隐藏层有256个神经元仅仅是输入层到第一层的权重就有784×256约20万个参数。这还只是处理28×28这样的小图。现实中的图片很少这么小一张1080p的彩色照片拉平后有1920×1080×3约622万个数值哪怕隐藏层只放128个神经元第一层权重就有接近8亿个参数。这么庞大的参数量训练时要么严重过拟合要么对显存和训练时长提出非常苛刻的要求。更大的问题在于结构本身。全连接层对每个位置的像素都一视同仁它会去学习“位置1的像素和位置2的像素之间的相关性”。但图像里的信息是高度局部化的——某个物体往往只占图像的一小片区域边缘、纹理、形状这些特征本质上都是“邻近像素之间的相互作用”。全连接网络把这种空间结构完全打散相当于把中文文章拆成字库顺序交给读者能读但费劲。这就是为什么CNN会出场它天然保留了空间结构。1.2 CNN的三个关键设计思路局部连接、参数共享、平移不变性CNN和全连接最本质的区别在于三个设计原则这三个原则正好对应图像数据的两个特性。第一个是局部连接。图像上某个像素的特点只和它周围的像素相关离得越远相关性越弱。所以卷积层的每个神经元不必像全连接层那样“看全整张图”只需要关注一个局部窗口就行。这个窗口大小就是卷积核尺寸通常取3×3或5×5。每个神经元只对窗口内的像素做加权求和窗口外的信息不参与计算参数数量因此大幅缩减。第二个是参数共享。卷积核在一个位置提取到某个特征比如水平边缘这个特征在图像的另一个位置同样适用。所以同一个卷积核会滑过图像的所有位置用同一组权重完成全部计算。形象地说全连接层是每个位置配一个专属员工卷积层是请一个质检员用同一套标准跑遍全图。第三个是平移不变性。因为同一个卷积核在所有位置扫描同一类特征不管出现在图像的左上角还是右下角都会被同一个卷积核捕捉到。这让模型对物体的位置不敏感不能直接说“模型因此具有平移不变性”准确的表述是卷积操作天然具备“特征响应的位置无关性”——当物体在图像中移动时卷积得到的特征图也会相应平移但特征本身的响应强度不变。这对实际任务非常关键因为我们不会要求训练数据里物体必须待在画面正中央。1.3 CNN的完整骨架从卷积到分类一个典型的CNN分类模型整体结构是一条流水线输入图片 → 卷积层 → 激活函数 → 池化层 → 重复几次 → Flatten → 全连接层 → 输出类别概率。卷积层负责提取特征池化层负责压缩空间尺寸、增大感受野Flatten把三维特征图拉平成向量最后全连接层把特征映射到类别编号上。还有一个前置步骤——归一化。输入图片通常是[0,255]范围的整数像素值进入网络前要变成[0,1]或更合理的浮点范围。这个步骤直接影响训练稳定性和收敛速度后面实操环节会具体展示。2. 核心组件逐个拆解与参数计算2.1 卷积层输出尺寸是怎么算出来的卷积层的核心参数就四个in_channels、out_channels、kernel_size、stride/padding。第一次接触的人最关心的问题是给定输入尺寸输出特征图尺寸怎么计算。标准公式很简单假设输入尺寸为H卷积核大小为K步长为S边缘填充为P输出尺寸W的计算方式是W (H - K 2P) / S 1举个例子输入28×28卷积核3×3步长1填充1即在四周各补1圈0那么输出尺寸是(28 - 3 2×1) / 1 1 28。也就是说padding1、kernel3、stride1这个组合不会改变特征图尺寸这在搭建深层网络时特别常用因为神经网络设计讲究“先把空间尺寸稳住再增加通道数”。out_channels代表这一层用了多少个卷积核每个卷积核产生一张新的特征图。16个卷积核就能得到16张特征图也就意味着这一层学到了16种不同的特征模板。这个数值是超参数经典经验是逐层翻倍比如16→32→64因为经过池化后空间尺寸减半通道数翻倍可以保留信息量。padding的另一个作用是保护边缘信息。如果不做填充每经过一次3×3卷积图像尺寸就会缩水2像素多层之后边缘信息损失严重。而且图像角落的像素参与计算的次数远少于中心像素填充一圈0可以在一定程度上缓解这个问题。2.2 池化层为什么需要压缩空间尺寸池化的本质是下采样把特征图“缩小”。最常见的是最大池化取每个窗口里的最大值窗口通常2×2、步长2所以输出空间尺寸直接减半。也有平均池化取窗口均值。最大池化实践效果更好一些因为图像特征通常是“有没有某种模式”最大值能保留最强烈的激活响应同时过滤掉那些不重要的弱响应。池化的作用不只是减少计算量。它还能扩大感受野——下层卷积核能“看到”的范围越大更好地捕捉全局信息。同时它提供轻微的平移鲁棒性特征在池化窗口内移动几个像素最大值往往不变输出的下游特征基本一致。关键点池化层没有可学习参数它就是个固定的下采样操作所以搭建模型时不会给池化层注册梯度。2.3 Flatten、全连接层与Dropout经过几个卷积池化模块后特征图是一个“通道×高×宽”的三维张量比如32×7×7。全连接层要求二维输入batch_size, 特征数所以必须先把三维张量拉平成向量这个操作叫Flatten在PyTorch中就是x.view(x.size(0), -1)。注意这里-1会自适应计算比如32×7×7一个样本就是32×7×71568个特征8个样本就成了8×1568的矩阵。全连接层的作用是把卷积层提取到的抽象特征组合起来给出最终分类判断。一般会接一两层全连接中间加激活函数和Dropout。Dropout是我非常建议加的组件。它的逻辑很直接训练时以概率p随机丢弃一部分神经元的输出置为0测试时恢复全部输出。这样可以迫使网络不依赖某个局部神经元迫使信息冗余分布在更广的范围内抑制过拟合。经验上p取0.25到0.5之间即可注意Dropout只有在model.train()模式下才生效验证和测试时一定要切换到model.eval()否则随机丢弃会让预测结果不稳定。2.4 激活函数要不要每个卷积层都放卷积操作本质是线性运算——加权求和。多个线性运算叠在一起还是线性网络再怎么加深表达能力也有限。激活函数的作用就是引入非线性让网络能拟合任意复杂函数。CNN里最常用的激活函数是ReLU公式是max(0, x)。它的好处是计算极快、能有效缓解梯度消失问题。负半轴直接置0也顺便制造了稀疏性。ReLU有个缺点需要注意如果某一层输出长期为负对应的梯度就永远是0神经元可能“死掉”一个网络死掉的ReLU神经元多了有效容量就会下降。稍微降低学习率、配合BatchNorm能显著减少这种情况。想要更稳妥也可以用LeakyReLU负半轴改为0.01x给了一点梯度回传通道。关于激活函数和卷积层的排列推荐顺序是卷积 → BatchNorm → ReLU → 池化。BatchNorm放在卷积层和激活函数之间能先把数据分布拉回均值为0、方差为1的状态再交给ReLU去非线性化实践效果更稳定。2.5 BatchNorm到底在做什么BatchNorm的动机源于一个现象网络训练时每层输入分布会随着前面层参数更新而不断变化这会让每层都在追赶移动的靶子。梯度下降变得更不稳定收敛变慢。BatchNorm的做法是在当前batch内对每个通道的数值求出均值和方差做标准化再用两个可学习参数进行缩放和偏移。它带来的直接收益是可以使用更大的学习率、收敛更快、对初始化不再那么敏感、还带了轻微的正则化效果。用了BatchNorm之后网络对学习率的容忍度会显著提高这点在实操中很有用。推理时BatchNorm不会用当前batch的统计量而是用训练期间累计的全局均值和方差所以训练/评估模式的切换对结果影响很大。3. 完整实操在PyTorch中从零搭建并训练一个CNN3.1 环境准备与数据加载动手之前先明确一点下面所有代码都是完整可跑的。环境方面只要PyTorch版本在1.10以上即可没有GPU也能用CPU跑只是训练时间稍长MNIST这种小数据集CPU跑十个epoch也就一两分钟。数据加载使用torchvision自带的MNIST手写数字数据集。这里有两个细节容易踩坑第一MNIST的原始像素是0到255的整数必须转成浮点张量并归一化第二归一化使用的均值和标准差不是随便拍的MNIST全量数据上统计出来的标准值是0.1307和0.3081。用这两个值做标准化数据分布才接近标准正态。import torch import torch.nn as nn import torch.nn.functional as F import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms 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_size128, shuffleFalse)shuffleTrue对训练集来说必须开否则每个epoch内样本顺序固定模型会学到样本顺序相关的错误模式。测试集不需要打乱。3.2 搭建第一个CNN模型现在定义模型。为了演示CNN的典型结构我设计一个两层卷积的小网络卷积→BatchNorm→ReLU→池化再来一轮然后Flatten接全连接层。这里就把前面讲解的组件全部串起来。class SimpleCNN(nn.Module): def __init__(self): super(SimpleCNN, self).__init__() self.conv1 nn.Conv2d(in_channels1, out_channels16, kernel_size3, padding1) self.bn1 nn.BatchNorm2d(16) self.conv2 nn.Conv2d(in_channels16, out_channels32, kernel_size3, padding1) self.bn2 nn.BatchNorm2d(32) self.pool nn.MaxPool2d(kernel_size2, stride2) self.dropout nn.Dropout(0.25) self.fc1 nn.Linear(32 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) def forward(self, x): x self.pool(F.relu(self.bn1(self.conv1(x)))) x self.pool(F.relu(self.bn2(self.conv2(x)))) x x.view(x.size(0), -1) x self.dropout(F.relu(self.fc1(x))) x self.fc2(x) return x这里最关键的尺寸计算我完整推一遍。输入是1×28×28。第一个卷积层kernel3、padding1、stride1特征图尺寸维持28×28得到16×28×28。BatchNorm和ReLU不改变形状。最大池化kernel2、stride2空间尺寸28变为14。第二个卷积层同样维持尺寸输出32×14×14。池化后变为32×7×7。所以全连接层的输入维度是32×7×71568。在view(x.size(0), -1)中x.size(0)是batch size-1自动推导为剩余的1568。batch size为64时view之后得到64×1568的矩阵。3.3 训练循环的标准写法训练部分我用全流程代码注意其中包含了几个我在实际使用中总结的几个关键细节。device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleCNN().to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) for epoch in range(10): model.train() # 设置训练模式 running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) epoch_loss running_loss / len(train_dataset) print(fEpoch {epoch1}/{10}, Loss: {epoch_loss:.4f})几个必知细节第一model.train()和model.eval()的切换是强制性的。Dropout和BatchNorm在两种模式下的行为完全不同前面讲过原因。很多人训练后直接在测试集上评估忘了切回model.eval()导致结果莫名其妙地变差。第二optimizer.zero_grad()必须在每次反向传播前执行。PyTorch的梯度默认是累积的不手动清零会把多个batch的梯度叠加在一起相当于用大了好几倍的batch size更新参数。如果不清楚这一点训练曲线会剧烈震荡。第三交叉熵损失nn.CrossEntropyLoss()这个函数本身就内置了Softmax。也就是说网络输出层不需要额外接Softmax模型输出10个原始数值损失函数内部会完成softmax和负对数似然的计算。3.4 测试与准确率评估测试阶段有几个明显的不同不需要计算梯度、不需要更新参数所以代码要包在torch.no_grad()里。逻辑上也应该用准确率而不是loss来衡量因为用户关心的是“预测对不对”而不是预测的置信程度。def evaluate(model, test_loader): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() accuracy correct / total return accuracy accuracy evaluate(model, test_loader) print(fTest Accuracy: {accuracy * 100:.2f}%)这里torch.max(outputs, 1)返回两个值一个是最大值一个是最大值对应的索引。索引就是预测的类别编号比如数字3就输出索引3。这两个返回值平时要用下划线接收第一个_, predicted torch.max(outputs, 1)。这样一个15行左右的模型结构MNIST测试集准确率轻松超过98.5%训练时间在CPU上也就几分钟。如果还想进一步提高可以尝试下面的思路把模型加深多加一层卷积、加宽增加out_channels、使用数据增强、增加训练轮数、加入学习率衰减。不过作为入门教程98%以上的准确率已经说明这个网络结构work得不错。4. 常见问题与排查技巧实录4.1 训练loss不降怎么办这是新手遇到最多的情况模型训练了几个epochloss卡在一个很高的值不往下走或者下降极其缓慢。我按照出现频率排出排查思路第一检查数据归一化。如果输入像素停留在[0,255]而没归一化到[0,1]附近梯度数值会偏大导致优化过程非常不稳定。这是最简单也最容易被忽略的问题。观察输入数据范围时可以打印images.min()和images.max()确认。第二降低学习率。lr1e-3在Adam优化器下对MNIST是合适的但如果自建数据集或者网络更深学习率可能需要降到1e-4。一个简单的判断标准如果loss初期出现明显上升或者来回剧烈震荡大概率是学习率太大先降10倍试试。第三确认Loss函数和标签匹配。多分类任务标签必须是整数型0到9CrossEntropyLoss不接受one-hot编码。如果数据加载时不小心把标签转成float类型训练会直接报错或出现不可理喻的loss。可以用labels.dtype确认是torch.int64。我见过一个很隐蔽的问题某个自定义数据集在拼DataLoader时标签全部设置成了0模型训练期间准确率一直维持在10%左右——数字分类问题随机猜也有10%的准确率。遇到这种情况可以先打印几个batch的标签确认标签分布是均匀的。4.2 训练集表现好、测试集表现差过拟合如果训练集准确率到了99%测试集只有85%这是典型的过拟合——模型把训练样本“背”下来了但没学到可泛化的规律。在网络很小、参数不够时这种现象反而不容易出现因为模型没有能力记住样本细节。所以过拟合往往出现在网络较大、训练轮数较多的时候。针对这个问题的实操建议增加Dropout的比例从0.25提高到0.5观察验证集准确率是否上升。给全连接层之前多加一个nn.Dropout。减少训练轮数配合Early Stopping当验证集准确率连续几个epoch不再上升就停止训练。使用数据增强。MNIST虽然简单也可以做小幅度的随机旋转和偏移。torchvision.transforms里有现成的RandomAffine和RandomRotation。注意数据增强只对训练集用验证集和测试集只用标准化。4.3 显存不足显存爆了怎么办训练时如果遇到CUDA out of memory最简单的应急办法是减小batch size。但要注意nn.BatchNorm2d对batch size很敏感batch size太小比如2或4会导致统计均值方差不准影响训练效果。如果减batch size后效果下降可以考虑减少卷积层的out_channels比如从32减到16这样模型参数量直接变小显存占用也相应减少。还有一种办法是检查代码里有没有累积大量计算图的嫌疑。比如在循环里不小心把多个loss加在一起再backward或者没有使用torch.no_grad()包裹评估过程都会导致显存持续增长。4.4 训练中途出现NaN lossNaNNot a Number出现的原因通常是数值溢出。最常见的情况是学习率过大梯度更新步长太大权重数值在几次迭代内膨胀到无穷大。解决办法很直接把学习率降到1e-4甚至1e-5同时检查数据里是否混入了异常值比如像素值变成了NaN。另一个容易忽视的原因是网络某处的输出出现了0除以0。如果自己实现了自定义的标准化步骤检查分母是否可能为0。用现成的BatchNorm2d基本不会遇到这个问题因为它内部做了数值稳定处理。如果问题出现在训练很久之后可以考虑梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)限制梯度的最大范数防止梯度爆炸。这在训练图像模型时是常用的稳定手段。4.5 如何判断模型真的学到了有用的特征一个特别实用的技巧是可视化特征图。把某张输入图片过一个卷积层把输出的16张特征图用matplotlib画出来你会看到边缘、纹理、颜色块等不同的激活模式。这在排查“卷积层到底学到了什么”时非常直观。MNIST这种简单数据集特征图通常能清晰地看到数字的轮廓。更大的收获在于如果某个卷积核的特征图全部是空白的整体都是0这个神经元很可能已经“死掉”了。这时该思考是不是学习率过大或ReLU分布出了问题。这也能反向验证网络设计的合理性。最后说几句我在实际训练中的体会是入门阶段学会用CNN完成一次图像分类远不如真正动手把每个组件、每个参数试一遍更有效。建议你搭建好模型后主动把padding改成0把Dropout删掉把BatchNorm去掉观察训练曲线和最终准确率的变化。这种对比实验带来的理解远比读十篇教程更扎实。另外一个小技巧调试时可以先拿一个很小的子集比如200张图单独跑一两个epoch确认模型能过拟合这批数据再放大到全量数据集上去。这样能快速区分“模型搭建有问题”和“训练策略需要调整”两类情况省下的时间非常可观。

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

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

免费获取报价 →
↑