PyTorch搭建Inception网络:多分支卷积与1x1降维详解

📅 发布时间:2026/10/19 9:46:29
PyTorch搭建Inception网络:多分支卷积与1x1降维详解
很多人刚开始接触卷积神经网络都是沿着“VGG加深”这条路走的。但如果你翻一下ImageNet历史上有分量的模型会发现真正让分类精度上一个台阶的反而是GoogLeNet——也就是Inception v1。它没有一味把网络挖深而是改成了“横向并联”的思路用一组组小模块堆出宽度和尺度上的变化。PyTorch搭建Inception网络模型这件事能帮你打通很多认知比如1x1卷积是怎么降维的不同分支怎么在通道维度上拼接辅助损失又是为了什么而存在。这篇文章就按我自己的实操顺序来写从Inception的核心设计讲起再到PyTorch代码一步步落地最后附上我调参时踩过的坑。代码是完整的跑通之后你可以直接拿它去训练CIFAR-10或者小规模自定义数据集。1. Inception网络的设计思路什么启发大家用这种并联结构1.1 为什么要用多分支并行而不是一味地加深2014年之前大部分网络的思路很直接卷积层堆叠、池化层穿插深度越多性能越好。但这样做有两个代价一是参数爆炸二是梯度越来越难传。VGG虽然效果好但模型动辄上百MB训练和部署都痛苦。Inception的出发点是“让网络自己选尺度”。一张图里的物体有的很大有的很小。固定用3x3卷积可能会漏掉大目标的结构固定用5x5又可能对小纹理不敏感。Inception模块的做法是在同一个层上并行跑1x1、3x3、5x5卷积和3x3最大池化最后把四个分支的输出在通道维度上拼起来。这样网络可以同时看到不同感受野下的特征相当于给每一层增加了“多尺度视野”。这个思想放到今天依然很实用。很多做轻量化网络、检测骨干网络的人仍然会借鉴这种多分支结构。当时GoogLeNet的参数量只有VGG的十分之一左右精度却更高核心就是这种并联结构用更少的参数表达了更丰富的特征。1.2 1x1卷积是Inception的灵魂很多人第一次看Inception结构图会觉得奇怪为什么3x3卷积前面要接一个1x1卷积这不是多此一举吗实际上1x1卷积在Inception里承担了两个关键任务。第一个任务是降维。假设上一层的输出是256个通道如果直接在后面接一个3x3卷积假设输出也是256通道那这个卷积层的参数量是3x3x256x256接近59万个。但如果先用一个1x1卷积把通道从256压到64再接3x3卷积参数就变成1x1x256x64加上3x3x64x256加起来只有约16万个少了整整四分之三。这个降维操作让网络可以在不显著增加计算量的前提下把模块做深做宽。第二个任务是通道间的信息融合。1x1卷积本质上是跨通道的线性组合它能把前一层各个通道的信息混合起来再交给后面的空间卷积去处理。我现在自己做网络设计时也习惯在瓶颈结构里加1x1卷积受益就是从这里学来的。有人可能会问既然降维这么好为什么不把所有层都降到很低因为通道数太少会丢信息。所以Inception在降维之后很快就会通过多个分支把通道数重新扩大保证模块的表示能力。2. 搭建前的准备工程目录与核心模块规划2.1 环境与依赖我用的环境是PyTorch 2.xCUDA 11.8Python 3.10。其实Inception这种老结构对版本要求不高PyTorch 1.8以上都能跑。需要导入的库也非常少import torch import torch.nn as nn import torch.nn.functional as F如果你打算自己写训练脚本再补一个torchvision用于加载数据集以及tqdm看看训练进度。不需要额外的第三方库。有一点我要提醒如果机器显存有限不要一上来就跑ImageNet原图尺寸先用CIFAR-10这种32x32的小图把模型调通。Inception模块本身不算重但整个GoogLeNet堆出来后还是有几十MB真要用224x224跑至少保证有4GB以上显存比较稳。2.2 模型结构拆解在动手写代码前先把整个网络的结构层次梳理清楚。GoogLeNet大致可以分成下面几段Stem开头的一组卷积和池化把3通道输入降到合适的空间尺寸同时把通道数扩大。Inception模块堆叠中间的主体部分一共九组Inception模块按顺序堆叠每组内包含不同分支配置。辅助分类器在网络中间引出两个分支计算额外的分类损失用于缓解梯度消失。最后的全局平均池化、Dropout和全连接输出。这里要注意Inception各版本的结构细节不完全一样。我们这次搭的是最经典的v1就是论文里那个表格式结构。写代码时最好把每个Inception模块的输入输出通道提前列出来否则很容易在拼接的时候被维度报错拦住。我自己习惯是先写一个小的配置字典把每个Inception模块的参数都放进去然后用for循环批量生成。但如果你刚接触PyTorch更推荐显式地逐个定义这样报错时更容易定位。3. 手写Inception模块从BasicConv2d到InceptionV1核心块3.1 BasicConv2d封装Inception内部很多卷积后面都要接BN和ReLU如果每个卷积都单独写一遍代码会显得特别啰嗦。我习惯先写一个最基本的卷积块class BasicConv2d(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, stride1, padding0): super(BasicConv2d, self).__init__() self.conv nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, biasFalse) self.bn nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.conv(x) x self.bn(x) x self.relu(x) return x注意这里我把卷积层的bias设成了False因为后面紧跟BatchNormBN里有可学习的偏置项卷积层的bias就没用了留着反而浪费参数。这个细节在PyTorch官方实现里也是这样写的我自己一开始没注意训练时发现收敛稍慢一点排查了一下才改过来。在Inception v1的时代BN还没有被引入原版网络中原版用的只是ReLU。但实际训练时你会发现加了BN之后收敛速度快很多精度也更容易上去。PyTorch官方仓库里的GoogLeNet实现也带了BN所以咱们按带BN的版本写。3.2 Inception模块的forward一个标准的Inception v1模块有四个分支我这里直接按论文结构来写class Inception(nn.Module): def __init__(self, in_channels, ch1x1, ch3x3red, ch3x3, ch5x5red, ch5x5, pool_proj): super(Inception, self).__init__() self.branch1 BasicConv2d(in_channels, ch1x1, kernel_size1) self.branch2 nn.Sequential( BasicConv2d(in_channels, ch3x3red, kernel_size1), BasicConv2d(ch3x3red, ch3x3, kernel_size3, padding1) ) self.branch3 nn.Sequential( BasicConv2d(in_channels, ch5x5red, kernel_size1), BasicConv2d(ch5x5red, ch5x5, kernel_size5, padding2) ) self.branch4 nn.Sequential( nn.MaxPool2d(kernel_size3, stride1, padding1), BasicConv2d(in_channels, pool_proj, kernel_size1) ) def forward(self, x): branch1 self.branch1(x) branch2 self.branch2(x) branch3 self.branch3(x) branch4 self.branch4(x) outputs [branch1, branch2, branch3, branch4] return torch.cat(outputs, 1)这段代码里有几个容易忽略的细节。第一个是第三个分支的5x5卷积padding设成了2这样才能保证输出特征图的宽高不变。第二个分支的3x3卷积padding是1。第一个分支的1x1卷积不需要padding。第二个细节是池化分支。这里用的是最大池化并且stride1、padding1目的是让池化不改变特征图尺寸最后和卷积分支输出保持一致。如果尺寸不一致在torch.cat时会直接崩溃。我在刚开始写的时候把池化stride记成2了结果所有分支拼接到一半就报错这个坑后面细说。第三个细节是concat维度。torch.cat(outputs, 1)代表在通道维度上拼接也就是维度索引1。PyTorch默认的输入格式是NCHW所以channel索引是1。很多新手会下意识写成0那是batch维度一拼就错。4. 组装GoogLeNet主体从stem到全连接4.1 stem部分正文开始前的前置卷积层GoogLeNet叫stem。它的作用是快速把分辨率降下来同时把通道数提上去。原版结构依次是7x7卷积stride2padding3输出64通道特征图大小减半。3x3最大池化stride2特征图再减半。3x3卷积padding1输出192通道。3x3最大池化stride2特征图再减半。对应代码就是self.conv1 BasicConv2d(3, 64, kernel_size7, stride2, padding3) self.maxpool1 nn.MaxPool2d(kernel_size3, stride2, padding1) self.conv2 BasicConv2d(64, 192, kernel_size3, padding1) self.maxpool2 nn.MaxPool2d(kernel_size3, stride2, padding1)这里需要留意第一个池化的padding1原论文里其实没有这个padding导致特征图从112x112变成55x55不是严格的一半。后面各种实现里为了对齐一般会让池化后的尺寸变成56x56。如果你用224x224的输入设置为padding1后第一个池化输出是56x56后面计算维度会整齐很多。自己实现时保持一致即可不需要过度纠结。如果把224x224的输入经过stem输出大约是28x28x192再进入Inception堆叠部分。4.2 Inception堆叠与维度变化表这次搭建的Inception堆叠按论文来一共有九个模块。为了避免有人抄错我把每个模块的输入输出通道整理成一张表这也是我自己写代码时反复对照的表模块序号输入通道branch1branch2(red/conv)branch3(red/conv)branch4池化投影输出通道Inception3a1926496/12816/3232256Inception3b256128128/19232/9664480Inception4a48019296/20816/4864512Inception4b512160112/22424/6464512Inception4c512128128/25624/6464512Inception4d512112144/28832/6464528Inception4e528256160/32032/128128832Inception5a832256160/32032/128128832Inception5b832384192/38448/1281281024注意表里的branch2(red/conv)表示先降维到前面的数值再卷积到后面的数值。branch3同理。branch4是池化分支最后1x1卷积输出的通道数。我在写代码时没有另建配置文件而是直接在GoogLeNet类的__init__里逐个实例化Inception模块。代码比较直观也不容易搞混self.inception3a Inception(192, 64, 96, 128, 16, 32, 32) self.inception3b Inception(256, 128, 128, 192, 32, 96, 64) self.inception4a Inception(480, 192, 96, 208, 16, 48, 64) self.inception4b Inception(512, 160, 112, 224, 24, 64, 64) self.inception4c Inception(512, 128, 128, 256, 24, 64, 64) self.inception4d Inception(512, 112, 144, 288, 32, 64, 64) self.inception4e Inception(528, 256, 160, 320, 32, 128, 128) self.inception5a Inception(832, 256, 160, 320, 32, 128, 128) self.inception5b Inception(832, 384, 192, 384, 48, 128, 128)看到这里你可能会想这些数值怎么记得住说实话不需要硬记写代码时查论文的结构表就行。但你需要理解这些数字是怎么设计的随着网络加深通道数整体是呈阶梯式上升的从192一路涨到1024同时特征图从28x28先降到14x14再降到7x7。空间分辨率下降通道数增加这是CNN通用的趋势。在所有Inception堆叠之间还有两个最大池化层一个放在Inception3b和4a之间另一个放在4e和5a之间。它们负责把特征图分辨率从28降到14再从14降到7。这里仍然是stride2的3x3池化和stem里的池化保持一致。4.3 辅助分类器与训练技巧GoogLeNet在Inception4a和4d之后分别接了一个辅助分类器作用是反向传播时给中间层额外提供梯度信号缓解较深网络的梯度消失。这个思想在2014年很有效后面的ResNet用跳连接解决了更深的问题但辅助损失在现在的不少多任务模型里仍然能看到影子。辅助分类器的结构是平均池化kernel_size5stride3让14x14变成4x4或者让7x7变成2x2。1x1卷积降维到128。全连接层降到1024。全连接层降到类别数。在训练阶段辅助损失乘上一个权重原论文是0.3加到总损失里在推理阶段直接舍弃辅助分类器。用PyTorch实现时我通常把辅助分类器定义成独立类class InceptionAux(nn.Module): def __init__(self, in_channels, num_classes): super(InceptionAux, self).__init__() self.pool nn.AvgPool2d(kernel_size5, stride3) self.conv BasicConv2d(in_channels, 128, kernel_size1) self.fc1 nn.Linear(2048, 1024) self.fc2 nn.Linear(1024, num_classes) def forward(self, x): x self.pool(x) x self.conv(x) x F.adaptive_avg_pool2d(x, (1, 1)) x x.view(x.size(0), -1) x F.relu(self.fc1(x)) x F.dropout(x, 0.7, trainingself.training) x self.fc2(x) return x这里fc1的输入维度是2048含义是经过5x5池化后如果是14x14输入会得到4x4特征图再经1x1卷积输出128通道4x4x1282048。所以在fc1之前必须接一个adaptive_avg_pool2d把空间维度压到1x1否则不同分辨率下全连接层输入维度就对不上。这也是一个很容易踩坑的地方我后面会细说。在GoogLeNet的forward里辅助分类器默认只在训练时启用。如果写成了推理时也计算辅助损失会让验证指标变得莫名其妙。更稳妥的写法是forward里接收一个参数来控制是否返回辅助结果def forward(self, x): x self.conv1(x) x self.maxpool1(x) # ... 中间省略 ... aux1 self.aux1(x) # ... 中间继续 ... aux2 self.aux2(x) # ... 中间继续 ... x self.avgpool(x) x x.view(x.size(0), -1) x self.dropout(x) x self.fc(x) if self.training: return x, aux1, aux2 return x5. 训练准备损失函数、优化器与输入预处理5.1 损失函数搭配GoogLeNet的输出有三个主分类输出和两个辅助输出。损失函数通常这样算criterion nn.CrossEntropyLoss() loss_main criterion(output, targets) loss_aux1 criterion(aux1, targets) loss_aux2 criterion(aux2, targets) loss loss_main 0.3 * loss_aux1 0.3 * loss_aux2辅助损失的权重可以调如果不喜欢辅助分支直接设成0也可以。我在实际训练小数据集时辅助损失对最终精度的影响大概在1个点左右主要是前期收敛更快。到了训练后期主损失基本主导。优化器我习惯用SGD而不是Adam。原因很简单SGD配动量在ImageNet类任务上表现更稳Adam虽然前期冲得快但后期往往不如SGD微调。我自己跑CIFAR-10实验时SGD设置动量0.9权重衰减5e-4初始学习率0.1用余弦退火调整效果一直不错。5.2 数据增强注意Inception原文里用了很多数据增强技巧包括随机裁剪、水平翻转、色彩抖动等。PyTorch的torchvision.transforms可以直接组合。如果训练CIFAR-10不要直接上224x224否则分辨率强行拉大模型不仅跑得慢精度还会掉。我建议用128x128或者64x64作为输入。我常用的训练预处理transform_train transforms.Compose([ transforms.Resize((128, 128)), transforms.RandomCrop(128, padding4), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)) ])验证集不需要随机增强只需要Resize、ToTensor和Normalize。这里有一个很多人会忽略的问题CIFAR-10的均值方差是固定值如果换数据集需要自己统计不能照搬。否则BN虽然能缓解一部分但输入分布偏了精度上限会受限。6. 常见坑与调试记录6.1 维度不匹配排查表Inception这种多分支拼接结构最常见的问题就是torch.cat报错。我把几种典型报错和排查方式整理成表方便你对照报错信息可能原因解决方案Sizes of tensors must match except in dimension 1某个分支的特征图宽高不一致检查该分支卷积padding是否设置正确池化stride是否意外改变了尺寸Expected input batch_size to match target batch_size全连接层输入维度不对特征图被view成错误行数检查自适应平均池化是否在view之前或者左侧辅助分支的维度计算有误mat1 and mat2 shapes cannot be multiplied全连接层输入feature数与实际展平数不匹配用打印shape的方式把每一层输出尺寸打出来核对CUDA out of memory输入尺寸太大或batch太大降低batch size缩小输入分辨率或减少辅助分支Found no tracked tensors for loss辅助分支只在训练启用了但测试时返回了不同数量的输出在验证循环里跳过辅助损失或统一输出格式还额外提醒一点不要把特征图的宽和高搞混。PyTorch里Tensor的维度顺序是NCHW打印shape时x.size(2)是高度x.size(3)是宽度。很多人在写padding时把H和W记反最终出现各种奇怪错误。定位维度问题最快的办法是在forward里临时加print比如print(x.shape)每经过一个关键模块打印一次跑一个batch就能看到是哪一层开始错的改完再删掉。这种土办法比看堆栈有效率得多。6.2 训练技巧实录我在训练中遇到过几个很典型的问题。第一个是辅助分类器在训练和验证行为不一致导致准确率异常。原因是我在forward里直接返回三个输出eval模式下没有过滤掉辅助分支于是loss统计里多了一个随机指标。后来改成前面那种self.training条件判断才恢复正常。第二个是Dropout作用于辅助分类器的位置。GoogLeNet原版在辅助分类器的全连接层和主干最后的全连接层前都加了Dropout主干常用的dropout比例是0.4辅助分类器我习惯用0.7。如果把Dropout放在全连接层之后效果会打折扣。训练时dropout参与计算验证时要通过model.eval()自动关闭这个PyTorch已经处理好了前提是你没有手写一些奇怪的dropout逻辑。第三个是模型规模对学习率的影响。因为GoogLeNet比VGG小很多同样的学习率下收敛节奏会不一样。我在CIFAR-10上训练时如果用SGD lr0.1大概在120个epoch能到92%左右。如果你发现loss下降很慢先检查lr是不是太小而不是急着加层。第四个是BatchNorm的momentum默认值。PyTorch里BN的momentum默认是0.1对ImageNet这类大数据集没问题但对于小数据集0.1更新太快可能导致统计量不稳定。我自己在小数据集上会调成0.05不过这个影响不如学习率大可以先不管。另外还有一个偏工程向的建议保存模型时不要把整个model对象直接pickle因为PyTorch版本升级后容易load出问题。我习惯只存state_dicttorch.save(model.state_dict(), inception_cifar10.pth)加载时先根据配置创建模型再load_state_dict。如果报key不匹配多半是模型结构定义和保存时的结构不一致优先检查那里。还有一个在训练中容易被忽略的点BN层在训练和推理时的行为完全不同。如果你在训练完后直接拿model做推理忘了调用model.eval()那么BN还会继续用mini-batch统计量而不是全局统计量得到的预测结果会异常。这个错误看起来像“模型没训练好”实际上只是eval模式没开。6.3 Inception结构在今天的借鉴意义花了不少篇幅搭完这个模型最后还是想说说它对你后续学习的帮助。Inception模块里的多分支拼接思想后来演变成了很多结构比如InceptionResNet、Xception里的深度可分离卷积还有NAS搜索出来的各种Cell结构。可以说理解Inception就是理解“如何在同一层用不同粒度提取信息”。另外GoogLeNet首次系统性地使用了1x1卷积做瓶颈层这个技巧在MobileNet、EfficientNet里都能看到。如果你以后要做模型压缩或者轻量化改造一定会碰到同样的问题如何在减少计算量的同时保持足够通道数。Inception给出的答案——先用1x1压通道再分层卷积最后恢复通道——至今依然好用。如果你想把模型改小一点跑更多数据集可以先砍掉一些模块比如只保留Inception3a到4e再接全局池化和全连接参数量会大幅降低。如果追求更高精度可以参考Inception v2/v3里的因子分解思路把5x5拆成两个3x3或者把一个3x3拆成1x3加3x1这些都是基于同一个Inception模块做的变形。我自己在动手搭Inception之前其实一直对“通道数是凭什么定出来的”这件事很困惑。真正一行行代码写下来再把每一层输出shape打印出来对照论文结构表之后才明白这些数字都是经过大量实验调出来的。PyTorch的好处就是你随时可以在forward中间打印tensor的shape让结构里的每个设计都变得可以被观察、被验证。建议你拿到代码后不要急着跑训练先做一件事用torch.randn(1, 3, 224, 224)生成一个随机输入前向传播一次把每一层输出shape打印出来对照我上面给的维度变化表。这个过程比看十遍论文都管用。等你亲眼看到特征图从224一路缩到7通道数从64一路涨到1024你对Inception以及整个CNN设计语言的理解就立起来了。