资讯动态

PyTorch实现CNN基本架构:卷积池化全连接层详解

发布时间:2026/10/16 14:01:35 来源:尧图企业网站定制
1. 为什么先想清楚“基本架构”再动手比直接抄代码更重要很多刚开始接触PyTorch的人拿到一个CNN模型的第一反应就是把官方示例里的网络结构复制下来把数据集换掉然后跑起来看准确率。这种做法不能说完全错误但有一个很实际的问题——一旦网络表现不好你根本不知道问题出在结构设计上还是出在数据处理上还是出在训练参数上。因为你没有先建立“这个架构每一层到底在做什么”的认知。CNN的基本架构说白了就是三种层组合出来的流水线卷积层负责提特征池化层负责压缩特征全连接层负责把特征变成最终的分类结果。这个流水线听起来简单但每个环节里都有大量值得掰扯的细节。比如卷积核大小选3×3还是5×5步长设成1还是2padding怎么补特征图从卷积层出来之后尺寸是怎么变化的全连接层需要输入多少个神经元这些全部都要算得清清楚楚。否则维度报错的时候你只能对着报错信息干瞪眼。这篇文章我不会堆一个特别复杂的模型出来而是从最基础的架构讲起怎么用PyTorch定义自己的CNN网络每一层为什么这样放训练的时候哪些代码是必不可少的以及我实际跑过程中遇到的一些问题和解决思路。适合刚入门PyTorch、想系统理解CNN网络结构或者已经能跑通简单模型但总觉得自己“只是在抄代码”的人。我自己的经验是把基本架构彻底弄明白之后再去网上看那些复杂的模型结构比如ResNet、DenseNet之类的会轻松很多。因为它们本质上也是在“基础架构”之上加了一些残差连接、密集连接这类更高级的特征复用手段而已。2. CNN架构的三大支柱卷积层、池化层和全连接层在打开PyTorch代码之前先把CNN的基本组成搞清楚。这不是枯燥的理论而是你后面调整网络结构时的决策依据。2.1 卷积层它在提取照片里的什么信息卷积层的核心作用是用一个小的“滑动窗口”在输入数据上滑动每个位置做一次乘法累加运算得到一个新的数值。这个滑动窗口就是卷积核kernel。为什么它能提特征因为它只关注一个局部区域里的信息比如一张图片里某个位置的边缘、纹理、颜色变化模式都是被这个小窗口捕获的。比如输入是一张28×28的灰度图用一个3×3的卷积核、步长1、不做padding输出就是26×26的“特征图”。特征图上的每一个点都代表了原图对应局部区域对当前卷积核的响应程度。一个卷积核只能捕捉一种特征所以我们通常会叠加多个卷积核得到多个特征图。PyTorch里控制这个的是nn.Conv2d(in_channels, out_channels, kernel_size)其中的out_channels就是卷积核个数也是输出特征图的通道数。在实际搭建架构的时候我会先确定一个原则越前面的卷积层捕捉的越是低级的边缘、颜色特征越后面的卷积层越能把前面的局部特征组合成更高级的语义特征。这就是为什么CNN网络往往会堆叠多层卷积而不是只用一层。2.2 池化层不是必需但几乎必用池化层很多人理解成“降采样”这个说法没问题但它更本质的作用是让特征具有一定的平移不变性同时减少后续的计算量。最常见的最大池化Max Pooling就是在一个小窗口内取出最大值。以2×2、步长2的最大池化为例特征图尺寸会直接缩水一半。好处很明显参数减少了运算速度加快了而且即便物体在图像里的位置稍微偏了一点只要最大值还在窗口内池化结果就不会变这对分类任务来说非常有用。PyTorch里对应的是nn.MaxPool2d(kernel_size2, stride2)。我用的时候基本上只调整窗口大小和步长很少碰别的参数。2.3 全连接层把二维特征变成一维分类向量CNN结构走到最后都会接入几个全连接层。全连接层做的事很简单把前面得到的特征图“展平”成一维向量然后做传统的神经网络线性变换加激活。这里的神经元数量通常是从几万、几千逐渐减少最后收敛到分类类别数。有一个非常关键的细节是全连接层的输入维度必须和前面特征图的尺寸严格匹配。如果你前面经过了卷积、池化后得到的是8×8×64的特征图展平后的维度是8×8×64 4096那你的第一个全连接层的in_features就必须是4096写错了就会报维度错误。我就遇到过这种情况改了前面卷积层的padding导致特征图尺寸变了但没有同步去改全连接层的输入维度结果训练直接报错。后来我养成了一个习惯每设计完卷积层的参数先用纸笔算一遍特征图尺寸的演变过程再去看代码。这个习惯帮我省了很多调试时间。3. 用PyTorch定义CNN网络骨架完整代码拆解本章是全文的核心我会把一个最经典的CNN架构——通常被称为基础卷积网络——的PyTorch实现逐行拆开讲清楚。3.1 nn.Module子类的标准写法PyTorch定义网络的推荐方式是把网络结构写成一个继承自nn.Module的类。类的__init__方法里定义所有的层类的forward方法里定义数据从输入到输出的流程。import torch import torch.nn as nn class MyCNN(nn.Module): def __init__(self, num_classes10): super(MyCNN, self).__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), # 第1层卷积 nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 第1次池化 nn.Conv2d(32, 64, kernel_size3, padding1), # 第2层卷积 nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # 第2次池化 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(inplaceTrue), nn.Linear(128, num_classes) ) def forward(self, x): x self.features(x) x self.classifier(x) return x这段代码用nn.Sequential把一系列操作按顺序组织起来可读性很高。features部分负责特征提取classifier部分负责分类。这种“先提取特征、再分类”的结构是我目前觉得最适合讲基础架构的写法。3.2 特征图尺寸变化的关键计算不过上面的代码里有一个数字是必须自己算清楚的64 * 7 * 7。我来演示一下它是怎么得来的。假设输入是28×28的单通道灰度图。第一次卷积nn.Conv2d(1, 32, kernel_size3, padding1)。因为有padding1所以输出尺寸还是28×28通道数变成32。数据形状是[batch, 32, 28, 28]。第一次池化nn.MaxPool2d(kernel_size2, stride2)。28×28变成14×14通道数不变。现在形状是[batch, 32, 14, 14]。第二次卷积输入通道32输出通道64同样是3×3、padding1尺寸不变。形状是[batch, 64, 14, 14]。第二次池化14×14变成7×7通道数还是64。形状是[batch, 64, 7, 7]。所以展平之后是64×7×7 3136个值第一个全连接层的in_features就该填3136。这是CNN基础架构里最容易出错的一步。你可以自己在代码里加一行打印x.shape在forward里临时确认一下每层输出形状这是个很实用的调试手段。3.3 为什么forward里不使用inplaceTrue这段代码里我用了nn.ReLU(inplaceTrue)inplaceTrue的意思是直接在输入张量上修改而不是新分配一块内存。这能省一点内存但有一个需要注意的地方如果forward里同一块张量被多个操作引用inplace可能会带来问题。在实际的现代化架构里很多人已经不加inplaceTrue了这个参数在前向传播里对模型输出并没有影响加不加结果一样。它属于典型的“可加可不加加了可能省内存但出了问题不好排查”的类型。对于基础教学我的建议是你了解inplaceTrue的存在就够了初学阶段不推荐在ReLU里写它因为省下的内存对大多数入门项目来说微不足道但清掉这个参数可以少踩很多坑。3.4 网络输出后接上softmax吗很多人在定义网络的时候喜欢在最后一个全连接层后面再写一个softmax操作。其实没必要甚至在某些情况下会有反效果。因为在PyTorch中如果你的损失函数使用的是nn.CrossEntropyLoss这个损失函数内部已经包含了log_softmax计算。也就是说你网络输出的原始logits——未经过激活的线性输出——会被它自动转换成概率并计算交叉熵损失。如果你在forward里手动加了nn.Softmax损失函数再算一次log_softmax数值上就会出问题训练也会不稳定。所以我个人建议模型结构的最后输出保持纯线性输出不要额外加softmax。如果需要预测概率推理时再调用torch.softmax即可。4. 数据加载与训练循环让搭建好的网络真正“动”起来模型架构搭好只是第一步。接下来要把数据送进去让它通过前向传播算出损失再用反向传播更新权重。这个阶段涉及PyTorch里另外几个核心组件。4.1 用Dataset与DataLoader管理数据PyTorch标准做法是把数据封装到Dataset类里再用DataLoader按批次取出来。Dataset类要做的事只有两件告诉框架数据集有多长以及根据索引返回一条样本和它对应的标签。from torch.utils.data import Dataset, 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_size64, shuffleFalse)这里用了MNIST手写数字数据集。batch_size64表示每次取64张照片进入网络shuffleTrue表示每个epoch把数据顺序打乱避免模型记住样本的顺序这是训练中很基本但很重要的操作。对测试集就不需要shuffle了因为预测结果不需要关心顺序。4.2 损失函数与优化器的选择逻辑代码里我选nn.CrossEntropyLoss()作为损失函数选torch.optim.Adam作为优化器。这两个选择背后是有逻辑的。交叉熵损失适合多分类问题。它的原理可以这样理解我们希望模型对正确类别的预测概率接近1对其他类别的预测概率接近0。交叉熵就是衡量“模型预测的概率分布”和“真实标签的概率分布”之间的差异。分布差异越大损失值越大。优化器方面对于入门级CNNAdam通常是首选。它自带自适应学习率相比纯SGD它对初始学习率的敏感度低一些。初学者选lr0.001基本不会太离谱。当然如果你想要更高的最终精度传统的带momentum的SGD经过精细调参有时候会比Adam做得更好这属于进阶话题了。device torch.device(cuda if torch.cuda.is_available() else cpu) model MyCNN(num_classes10).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.001)4.3 一个完整的训练循环里每个步骤在干什么训练循环的代码看起来就那么几行但要清楚每一步在干什么。def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss 0.0 correct 0 total 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() total_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) avg_loss total_loss / total accuracy correct / total return avg_loss, accuracy拆解一下model.train()把模型设置为训练模式。这会影响到某些层的行为比如BatchNorm层和Dropout层它们只有在训练模式下才会发挥正常作用这个细节很重要。optimizer.zero_grad()清空上一次迭代留下的梯度。如果不做这一步本次反向传播计算的梯度会累加到上一次的梯度上相当于用了更大的batch进行更新这是新手经常漏掉但绝对致命的错误。outputs model(images)把一批图像送入网络进行前向传播得到每个类别的logits。loss criterion(outputs, labels)计算这批图像的交叉熵损失。loss.backward()反向传播算出每个参数的梯度。optimizer.step()根据当前梯度和优化器设定的规则更新模型参数。这些步骤顺序千万不能乱。有一次我在一个项目里把optimizer.zero_grad()放在loss.backward()之后结果每个batch的梯度都在累加损失值完全下不去。最后一行一行排查才发现是顺序写错了。4.4 验证阶段的细节eval模式与no_grad训练过程中我一般每个epoch结束后都会在测试集上做一次验证用来实时观察模型是否过拟合。def evaluate(model, val_loader, criterion, device): model.eval() total_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) avg_loss total_loss / total accuracy correct / total return avg_loss, accuracymodel.eval()把模型切到评估模式告诉模型你不需要更新参数了。torch.no_grad()则更是彻底它直接关掉了梯度计算大幅降低内存占用和计算量。这两行配合起来是验证代码的标准姿势。5. 完整流程演示从创建模型到保存checkpoint把所有代码拼起来跑一个完整的训练流程并加入模型保存部分。5.1 训练多个epoch并打印信息num_epochs 5 for epoch in range(1, num_epochs 1): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc evaluate(model, test_loader, criterion, device) print(fEpoch {epoch:2d} | Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f})上面这段代码会输出每个epoch的训练损失、训练准确率、验证损失和验证准确率。我观察的重点有两个第一训练准确率是否在稳步提升第二训练准确率和验证准确率之间的差距是否在扩大。如果两者差距越拉越大就是典型的过拟合信号意味着模型把训练集“背下来了”但泛化能力不行。5.2 保存模型checkpoint的标准做法训练结束后我习惯把整个训练状态保存下来而不是只保存模型参数。torch.save({ epoch: num_epochs, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), train_acc: train_acc, val_acc: val_acc, }, ./cnn_checkpoint.pth)这样保存的好处是后续如果要接着训练可以直接加载这些状态恢复优化器的历史动量信息。只保存model.state_dict()也能用但如果训练中断需要继续你没有优化器的状态就只能从头开始调整学习率效率低很多。5.3 推理阶段的完整流程模型训练好了怎么对一张新照片做预测呢流程比训练简单很多model.eval() with torch.no_grad(): sample test_dataset[0][0].unsqueeze(0).to(device) output model(sample) pred output.argmax(dim1).item() print(Predicted class:, pred)这里的unsqueeze(0)把原本形状是[1, 28, 28]的单张图片变成[1, 1, 28, 28]模拟出一个batch大小为1的数据。网络对输出计算argmax取出概率最大的那个类别索引就是最终的预测结果。6. 实际踩坑记录几个“看起来对但实际有问题”的代码细节我把自己在搭建CNN过程中真正踩过的坑挑了几个最典型的分享给你这些坑在官方文档里通常不会专门提出来。6.1 model.train() 和 model.eval() 被忽视导致的精准度波动很多初学者写验证函数时忘了model.eval()直接在当前模型状态里跑验证集。如果你的网络里没有任何对状态敏感的层比如没有Dropout和BatchNorm可能感觉不到问题。但一旦结构里加了BatchNorm问题就很大训练模式下BatchNorm会使用当前batch的均值和方差来归一化数据这意味着验证时每次得到的归一化结果都在变化验证准确率会忽高忽低。所以我现在的习惯是在每一段代码块的开始先写上model.train()或model.eval()让模式切换这回事根本不给忘掉的机会。6.2 GPU显存不足batch_size并不是越大越好训练过程中最常见的报错之一就是CUDA out of memory。很多人第一反应是调小模型但最简单的调整方式是直接调小batch_size。比如原来用64改成32显存占用基本减半。另一个技巧是torch.no_grad()。在验证阶段特别是测试集比较大的情况下如果忘了写with torch.no_grad():模型照样会为每个中间激活保存用于反向传播的额外数据占用大量显存。加上之后这部分压力立刻消失。6.3 更改输入尺寸后忘记更新全连接层模型结构写完之后如果有一天你想把输入图片从28×28换成32×32前面卷积池化层都没问题但全连接层的in_features就会报错因为特征图尺寸变了。这类问题没有悬念就是重新计算一下特征图尺寸更新nn.Linear的第一个参数。为了防止这个问题反复出现我现在的做法是在模型类的__init__上方加一段注释记录每个阶段的特征图像尺寸计算过程。看起来有点笨但半年后回来看自己的代码这个注释能帮你省很多时间去重新推算。6.4 过拟合在CNN里怎么快速缓解当你发现训练准确率很高、验证准确率一直上不去的时候有几个立竿见影的手段增加数据增强比如随机旋转、随机裁剪、水平翻转让模型看到更多样的数据。在全连接层之前插入Dropout层一般概率设成0.5。减小模型容量比如把卷积通道数从64降到32减少模型可以“死记硬背”的空间。以我个人的项目经验来看最简单的过拟合缓解手段其实是数据增强。它不需要改模型结构不需要调一堆超参数只是让训练数据“看起来更丰富”就能非常有效地提升模型的泛化能力。7. 补充一些进阶调整思路让基础架构往实用项目延伸当你把基础CNN架构跑通准确率也能稳在合理水平之后可以根据自己的任务方向做一些进阶调整。这里说几个我实际用过的方向不展开具体实现但给你一个思考框架。7.1 针对小数据集的架构微调如果你的数据集很小比如只有几千张图片那么把网络设计得过大反而会带来严重的过拟合问题。这个时候通常的做法是减少卷积层的通道数减少全连接层的神经元数量或者把全连接层的数量从2层降为1层。架构越简单需要拟合的参数越少在小数据集上反而能得到更稳定的结果。7.2 针对大尺寸图像的架构微调如果输入图片是224×224或者更大的尺寸基础架构的池化层数量就需要相应增加或者直接使用现有的成熟结构作为特征提取部分。学习基础架构的意义在于你能看懂成熟结构里每一层在做什么理解它们对图片尺寸做了什么操作最后改起来就不会心虚。7.3 针对二进制分类任务如果任务从多分类变成了二分类比如判断一张图片是猫还是狗那么只需要把最后的num_classes改成2损失函数依然用CrossEntropyLoss其他部分几乎不用动。如果只有一个输出节点那就需要把损失函数换成BCEWithLogitsLoss同时输出层保持一个节点。7.4 关于预训练模型与基础架构的关系现在很多业务场景直接加载在大型数据集上预训练好的模型往往比从零训练一个基础CNN效果更好。但这并不意味着不需要学基础架构。恰恰相反理解基础架构之后你才能看懂预训练模型内部那些skip connection、attention module都是干嘛的才能决定到底要冻结哪些层、微调哪些层。我当时花了不少时间把基础架构手写了一遍之后再去读ResNet源码瞬间就觉得没那么难了——因为它就是“基础CNN架构 残差连接”这个组合。基础架构是所有复杂网络的地基这个地基打牢了后面看什么都快。8. 我的几个使用心得与建议在自己跑项目这么多轮之后我愈发觉得搭建CNN基本架构最忌讳的是一开始就追求复杂度。很多刚入门的同学喜欢把一个网络写得特别花哨各种模块堆得很高结果训练速度慢、调试困难、效果也不好。我现在的做法是先用最精简的架构把数据流程跑通准确率只要有个70%左右就算合格然后再逐步增加复杂度每加一个部件就观察一次效果变化。这样你才能知道每个部件对最终结果贡献了多少而不是一堆组件堆在一起出问题根本不知道是谁的锅。另外我强烈建议在本地准备一个小规模的数据集比如MNIST或CIFAR-10的某一小部分专门用来做架构实验。这种数据集训练一个epoch只要几秒架构调整后的反馈链路很短。你可以在一天之内试几十种不同的配置组合这种高效率的试错是直接在大数据集上使用满血网络训练完全没法比的。还有一件事就是刚开始不要把自己限制在“必须先看懂所有数学原理再动手”的框框里。卷积的数学推导当然重要但对入门来说先跑通代码再回头补原理学习效率会高很多。我在边跑代码边理解原理的过程中才真正明白了什么叫“卷积核滑动的局部感受野”什么叫“池化的平移不变性”。最后一个小建议不管你是为了课程作业、项目实践还是自己研究学CNN都建议把你自己的网络完整地写一遍不要直接用我上面给的代码。哪怕只是把类名改了、把通道数改成别的值你也会自然地经历“手算特征图尺寸—定义网络—跑训练—查错误”这个过程。自己踩过一遍这些细节比看十篇文章都管用。等你能从零写出一个结构清晰、训练稳定的CNN基本架构后面无论是做图像分类、目标检测还是更复杂的模型改造路就好走很多了。

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

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

免费获取报价 →
↑