☰
GoogLeNet深度拆解:Inception模块、1x1卷积与PyTorch实现
2026/10/15 10:45:22 网站建设 项目流程

这个系列写到这里,前面几篇把LeNet、AlexNet、VGG从结构到PyTorch实现都过了一遍。如果只看这些,很容易产生一种错觉:图像分类网络就是不断叠加卷积层,最后挂几个全连接层收尾。但2014年出现的GoogLeNet恰恰打破了这条单一路线——它没有在层数上玩命叠加,而是在每个模块内部做多路并行,把参数量压到了AlexNet的十分之一左右,同时在当年的ImageNet图像分类竞赛里拿到了顶尖成绩。这篇文章会把GoogLeNet的结构彻底拆开讲透,再带大家用PyTorch从零搭出来、跑通前向传播。

这种“拆结构 + 写代码 + 顺带讲设计动机”的路子,适合两类读者:一类是正在做经典网络复现的入门者,想搞懂Inception到底在做什么;另一类是已经会搭VGG那种堆叠式网络、但不太理解“为什么有人把网络设计成并行分支”的人。读完你至少能回答三个问题:1x1卷积凭什么被称为Inception的灵魂?辅助分类器到底有什么用?为什么GoogLeNet参数少那么多,分类却不输给那些又深又重的网络?

1. GoogLeNet想解决什么问题:卷积核大小进入“多选题”时代

1.1 加深和加宽之外,还有一条更工程化的路

AlexNet 8层,VGG堆到16、19层,效果确实提升,但代价也很直观:参数暴涨。VGG的大卷积核和巨型全连接层让模型动不动就上亿参数,训练慢,而且非常容易过拟合。更深的网络还有一个老问题——梯度从最后一层传回第一层,要经过几十个非线性激活函数,信号早就衰减得不成样子。光靠“深”这一个维度,天花板很快就能摸到。

GoogLeNet的出发点其实很朴素:能不能在保持甚至提高精度的前提下,把参数和计算量大幅压下来?原作者团队在论文里给出了一条非常工程化的思路:与其让每一层都是一个巨大的、不管什么尺度都硬吃全部特征的卷积,不如把稀疏思想引进来——网络内部不一定每个位置都需要同样大的感受野,有的地方看3x3就够,有的地方需要5x5甚至更大范围的信息。

但问题来了:当年的硬件和矩阵运算天然喜欢密集结构,真正的稀疏计算反而跑不快。于是作者做了一个非常聪明的折中:用几个小尺度的卷积核并行处理,再把结果拼起来。这就是Inception模块的雏形。它没有发明新卷积核,而是把“选哪种卷积核”从单选题变成了多选题,让网络自己学习不同分支的融合比例。

1.2 从稀疏假设到多尺度并行

这里说的稀疏思想,可以这样理解:图像里不同对象、不同大小目标的特征尺度差异很大。一只猫的脸和远处的一辆车,需要的感受野完全不同。如果网络只有3x3卷积,大目标的信息看不全;只有7x7卷积,又会带来大量冗余计算。真实场景的信息分布并不均匀,理想的网络应该在某些位置更细腻、某些位置更宏观。

但直接做“自适应选择”在当年不现实,所以Inception的做法是:把1x1、3x3、5x5三条卷积路径和一条池化路径并行放在同一个模块里,让它们各自提取不同尺度的特征,最后在通道维度拼接。下一层网络能同时看到多尺度特征,然后根据数据自己学权重。与其纠结哪个卷积核好,不如全都要,让训练去取舍。

看起来计算量应该变贵了,但配合1x1卷积做瓶颈降维之后,反而比单纯堆大卷积核更省。这个数学账我放在第2节专门算,因为它直接决定了Inception能不能实用化。

1.3 和VGG的直观对比:少即是多

同样的竞赛里,VGG证明了深度有用,但它把网络当成乐高积木,一层层往上累。GoogLeNet则把每一层看成一个“特征工厂”,内部并行生产不同尺度的特征再合并。单看层数,GoogLeNet算到22层,比VGG更深;单看参数,它只有大约700万,而VGG上亿、AlexNet也有6000万。这说明“深”和“大”不是一回事,把计算资源分配在关键路径上,比无脑加卷积层更高效。

后面实践部分我会反复提到,正是这种低参数量让GoogLeNet特别适合在中小数据集上做迁移学习和特征提取,这也是它至今仍被当作骨干网络使用的重要原因。

2. Inception模块拆解:四条并行分支的职责与计算账

2.1 四个分支各自在干什么

输入同一个特征图,Inception模块的四个分支独立处理,最后在通道维度拼接。先看每个分支的职责:

  • 分支一:直接走1x1卷积,负责逐位置跨通道的特征组合,看的是极局部信息,计算量最小。
  • 分支二:先1x1降维,再3x3卷积,这是Inception里最常见的“瓶颈”结构,用低维特征做中等感受野提取,成本低、效果好。
  • 分支三:先1x1降维,再5x5卷积,负责更宏观的特征,比如物体整体轮廓、大纹理块。5x5的感受野是3x3的约2.78倍,代价也高,所以降维比例通常更狠。
  • 分支四:3x3最大池化(stride=1、padding=1),再接1x1卷积。池化本身不增加参数,能提取平移不变特征,同时把原特征图里的强响应区域凸显出来。

四个分支输出的特征图高宽必须完全一致,才能执行拼接操作。好在所有卷积和池化都设置stride=1并补好padding,空间维度不会变化。拼接发生在通道维,所以四个分支的输出通道数加起来,就是模块的总输出通道数。

可以说,一个Inception模块就是一个“多尺度特征汇总层”:输入一张特征图,输出一张特征更丰富、通道数更多的特征图。网络把多个这样的模块串联起来,就构成了GoogLeNet的主体。

2.2 1x1卷积:整个网络里最被低估的组件

先给结论:1x1卷积在Inception里承担三个角色——跨通道信息融合、无损降维、计算量瘦身。

第一个角色好理解,1x1卷积本质上是在每个空间位置上对所有输入通道做一次线性组合,相当于一个逐像素的全连接层,能自由决定要把哪些通道的信息混合在一起。第二个角色也直观,把192通道压到16通道,不是简单丢掉176个通道,而是经过可学习的线性组合把关键信息压缩进更少的通道里。

最关键是第三个角色:计算量瘦身。假设输入是28x28x192,5x5分支要输出32个通道。直接做5x5卷积,一次乘加要覆盖全部192个输入通道,计算量大约是:

  • 28 × 28 × 32 × 5 × 5 × 192 ≈ 1.2亿次乘加

如果先接1x1卷积把通道从192降到16,再走5x5卷积,两步计算量分别是:

  • 1x1降维:28 × 28 × 192 × 16 ≈ 240万次乘加
  • 5x5卷积:28 × 28 × 32 × 5 × 5 × 16 ≈ 1000万次乘加
  • 合计约1240万次乘加,只有直接方案的十分之一左右

降维比例越大,节省越明显。这就是为什么5x5分支的降维总是最狠的——浅层Inception里常见192降到16、512降到24这类极端比例,而3x3分支一般只降到输入通道的一半左右。

2.3 九个Inception的通道配置与递增逻辑

GoogLeNet一共用了9个Inception模块,每个模块的通道配置在论文里都有明确表格。下面这个表是我按PyTorch实现整理出的完整参数,每一行的数字就是各分支的实际输出通道数。

模块1x1分支3x3分支(降维/卷积)5x5分支(降维/卷积)池化分支输出总计
3a6496 / 12816 / 3232256
3b128128 / 19232 / 9664480
4a19296 / 20816 / 4864512
4b160112 / 22424 / 6464512
4c128128 / 25624 / 6464512
4d112144 / 28832 / 6464528
4e256160 / 32032 / 128128832
5a256160 / 32032 / 128128832
5b384192 / 38448 / 1281281024

观察几个规律:通道数从3a的256一路涨到5b的1024,翻了四倍,但特征图尺寸从28x28逐步降到7x7,整体计算量被控制住了。网络越往后,1x1分支的输出越来越多,说明深层更依赖跨通道聚合;3x3分支始终是主力;5x5分支在浅层只给很小的通道数,到了深层5b才加大,因为特征图变小之后,5x5卷积的计算代价变得可以接受。这些数字不是随手拍的,背后是计算预算、感受野、精度之间反复权衡的结果。

3. 整体结构:Stem、辅助分类器与全局平均池化的设计选择

3.1 Stem模块:三次下采样把224变成28

GoogLeNet前半部分其实就是主干下采样,不急着进Inception。流程是:7x7卷积极核步长2把224变成112,最大池化步长2变成56,1x1卷积后接3x3卷积保持56,再最大池化步长2变成28。到这里,网络已经有了足够的感受野基础,才进入第一个Inception模块。

原论文在第一个最大池化之后用了局部响应归一化(LRN),目的是模仿神经生物学里的侧抑制,让大响应通道抑制周围通道。但后来的实践证明,BatchNorm的效果和稳定性明显更好,所以现代复现基本都用BN替代LRN。我这篇的实现也采用BN版本,这也是大多数开源复现的标准做法。

3.2 辅助分类器:让梯度从中间“抄近路”回流

GoogLeNet算到22层,在那个年代梯度消失问题非常现实。为了把梯度提前送回浅层,作者在4a和4d两个Inception之后各接了一个辅助分类器。它的结构很轻量:先用5x5平均池化(步长3)把14x14的特征图压成4x4,再1x1卷积升到128通道,展平后接一个1024维全连接,最后输出类别数。

训练时,两个辅助损失会按0.3的权重加入总损失。这样反向传播时,梯度除了从最末端一路传回来,还能从网络中部的辅助分类器直接“抄近路”流回浅层Inception,缓解前面几层学不动的问题。原作者还发现,辅助分类器除了对抗梯度消失,还天然有正则化效果,因为它强行让中间层提前承担分类任务,相当于给网络加了额外的监督约束。

这里有一个实操细节:aux1接在4a之后,aux2接在4d之后。从零训练时,你可能观察到aux2的曲线一直和主损失同步,而aux1的曲线偶尔会“捣乱”,因为4a处的特征抽象程度太低。遇到这种情况,可以考虑只保留aux2,或者把aux损失的权重降到0.1、0.2再观察。

3.3 全局平均池化:告别巨型全连接层

VGG最后是两个4096维的全连接层,光这两层就占了网络参数的一大半,而且一旦输入尺寸改变,全连接层的输入维数就套不上去了。GoogLeNet的解决方式是:在5b模块之后,对7x7特征图做全局平均池化,把每个通道压成一个数值,得到1024维向量,再接一个线性层输出1000类。

这个设计带来的直接好处有三个:第一,全网络几乎没有大全连接层,参数量大幅下降,过拟合风险也低了;第二,GAP强制每个通道编码一个全局响应,本质上是让高层特征更语义化;第三,配合自适应平均池化,模型对输入尺寸不那么敏感,推理时甚至不用固定224,做多尺度测试很方便。

4. 用PyTorch从零搭建GoogLeNet

4.1 BasicConv2d与Inception模块的代码

先定义一个带BN和ReLU的基础卷积块,后面所有卷积都复用它:

import torch import torch.nn as nn import torch.nn.functional as F class BasicConv2d(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0): super().__init__() self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, bias=False) self.bn = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) def forward(self, x): return self.relu(self.bn(self.conv(x)))

conv层设bias=False是因为后面接了BN,BN会做归一化平移,卷积的偏置是冗余的。接下来是Inception模块,四个分支严格按论文的通道配置写:

class Inception(nn.Module): def __init__(self, in_channels, ch1x1, ch3x3_red, ch3x3, ch5x5_red, ch5x5, pool_proj): super().__init__() # 分支一:1x1 self.branch1 = BasicConv2d(in_channels, ch1x1, kernel_size=1) # 分支二:1x1降维 + 3x3 self.branch2 = nn.Sequential( BasicConv2d(in_channels, ch3x3_red, kernel_size=1), BasicConv2d(ch3x3_red, ch3x3, kernel_size=3, padding=1) ) # 分支三:1x1降维 + 5x5 self.branch3 = nn.Sequential( BasicConv2d(in_channels, ch5x5_red, kernel_size=1), BasicConv2d(ch5x5_red, ch5x5, kernel_size=5, padding=2) ) # 分支四:3x3池化 + 1x1 self.branch4 = nn.Sequential( nn.MaxPool2d(kernel_size=3, stride=1, padding=1), BasicConv2d(in_channels, pool_proj, kernel_size=1) ) def forward(self, x): b1 = self.branch1(x) b2 = self.branch2(x) b3 = self.branch3(x) b4 = self.branch4(x) return torch.cat([b1, b2, b3, b4], dim=1)

分支三的5x5卷积padding=2,分支二的3x3卷积padding=1,池化分支的MaxPool是3x3且stride=1、padding=1。只要这四个分支的空间尺寸保持一致,concatenate就不会出问题。

4.2 辅助分类器与主干网络拼装

辅助分类器按论文结构实现,注意平均池化的核大小和步长对应14x14输入,输出正好是4x4:

class InceptionAux(nn.Module): def __init__(self, in_channels, num_classes): super().__init__() self.pool = nn.AvgPool2d(kernel_size=5, stride=3) self.conv = BasicConv2d(in_channels, 128, kernel_size=1) self.fc1 = nn.Linear(4 * 4 * 128, 1024) self.fc2 = nn.Linear(1024, num_classes) self.dropout = nn.Dropout(p=0.7) def forward(self, x): x = self.pool(x) # 14 -> 4 x = self.conv(x) # 升到128通道 x = x.view(x.size(0), -1) # 展平 x = F.relu(self.fc1(x), inplace=True) x = self.dropout(x) x = self.fc2(x) return x

接下来是主干网络。这里把Stem、9个Inception、两个池化下采样、辅助分类器、全局平均池化和最终线性层全部串起来:

class GoogLeNet(nn.Module): def __init__(self, num_classes=1000, aux_logits=True): super().__init__() self.aux_logits = aux_logits # Stem self.conv1 = BasicConv2d(3, 64, kernel_size=7, stride=2, padding=3) self.maxpool1 = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) self.conv2 = BasicConv2d(64, 64, kernel_size=1) self.conv3 = BasicConv2d(64, 192, kernel_size=3, padding=1) self.maxpool2 = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # Inception 3x self.inception3a = Inception(192, 64, 96, 128, 16, 32, 32) self.inception3b = Inception(256, 128, 128, 192, 32, 96, 64) self.maxpool3 = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # Inception 4x 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.maxpool4 = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # Inception 5x self.inception5a = Inception(832, 256, 160, 320, 32, 128, 128) self.inception5b = Inception(832, 384, 192, 384, 48, 128, 128) if aux_logits: self.aux1 = InceptionAux(512, num_classes) self.aux2 = InceptionAux(528, num_classes) # 分类头 self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.dropout = nn.Dropout(0.4) self.fc = nn.Linear(1024, num_classes) def forward(self, x): # Stem x = self.conv1(x) x = self.maxpool1(x) x = self.conv2(x) x = self.conv3(x) x = self.maxpool2(x) # Inception 3x x = self.inception3a(x) x = self.inception3b(x) x = self.maxpool3(x) # Inception 4x x = self.inception4a(x) if self.training and self.aux_logits: aux1 = self.aux1(x) x = self.inception4b(x) x = self.inception4c(x) x = self.inception4d(x) if self.training and self.aux_logits: aux2 = self.aux2(x) x = self.inception4e(x) x = self.maxpool4(x) # Inception 5x x = self.inception5a(x) x = self.inception5b(x) # 分类头 x = self.avgpool(x) x = x.view(x.size(0), -1) x = self.dropout(x) x = self.fc(x) if self.training and self.aux_logits: return x, aux1, aux2 return x

4.3 前向传播逻辑:训练三个输出,推理一个输出

上面forward里的写法有一个很容易踩的坑,也会贯穿训练和验证过程:模型在训练模式下会返回三个输出(主logits、aux1、aux2),在eval模式下只返回主logits。如果你在验证时忘记调用model.eval(),程序会试图把三个输出解包成两个变量,直接抛异常。

在实际代码中,训练循环可以这样写:

import torch.optim as optim model = GoogLeNet(num_classes=1000, aux_logits=True) optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=4e-5) criterion = nn.CrossEntropyLoss() def train_step(images, labels): model.train() optimizer.zero_grad() outputs = model(images) # 训练模式返回 (main, aux1, aux2) loss = criterion(outputs[0], labels) loss += 0.3 * criterion(outputs[1], labels) loss += 0.3 * criterion(outputs[2], labels) loss.backward() optimizer.step() return loss.item()

推理时:

def evaluate(images): model.eval() with torch.no_grad(): logits = model(images) # eval模式只返回主输出 return logits

4.4 用随机输入验证网络尺寸与参数量

搭好之后,马上用随机输入验证一下前向传播和参数量,这个习惯能省掉很多低级错误:

model = GoogLeNet(num_classes=1000, aux_logits=True) x = torch.randn(1, 3, 224, 224) model.train() out_main, out_aux1, out_aux2 = model(x) print(out_main.shape, out_aux1.shape, out_aux2.shape) # torch.Size([1, 1000]) torch.Size([1, 1000]) torch.Size([1, 1000]) model.eval() out = model(x) print(out.shape) # torch.Size([1, 1000]) total_params = sum(p.numel() for p in model.parameters()) trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad) print(f"Total params: {total_params / 1e6:.2f}M") print(f"Trainable params: {trainable_params / 1e6:.2f}M")

我自己复现的这个版本,参数量大约在7M上下,和论文里报告的约700万基本一致。相比VGG的上亿参数,这个量级在当年是很惊人的。

5. GoogLeNet在实践中的那些坑与经验

5.1 训练配置:优化器、学习率与BN配合

GoogLeNet带BN之后,训练要比原版友好很多,但仍有几个配置值得注意。优化器推荐SGD加momentum,momentum=0.9,weight_decay用4e-5到1e-4之间都可以,原论文的4e-5在中小数据集上表现就很好。学习率方面,因为是预训练模型直接微调的话,lr=0.001到0.01起步比较稳;如果是从零训练,有BN的情况下可以直接开0.05到0.1,配合warmup和cosine调度,训练速度会快不少。

数据增强建议随机缩放裁剪加水平翻转,这已经是图像分类的标准配置。GoogLeNet虽然参数量小,但Inception四条分支并行,中间激活特别多,训练时显存占用并不低。batch size如果遇到OOM,不建议强行调小到极端值,可以优先试试混合精度训练,能省下一大截显存。

5.2 辅助分类器到底要不要开

这是很多人纠结的点。我的建议是:从零训练时先开,尤其是网络真正深起来之后,aux对浅层梯度的帮助是实实在在的。但要留意观察aux1和aux2的曲线。

在我自己跑过的实验里,aux2(4d之后)通常和主损失走势非常一致,辅助作用明显;aux1(4a之后)有时会在训练初期造成一点扰动,因为4a处的特征还比较底层,强行让它学分类容易让浅层偏向某些纹理特征。如果发现aux1明显拖慢主损失收敛,可以直接把aux1去掉,只保留aux2,甚至把两个aux都关掉,只训练主分类器。这个开关在GoogLeNet初始化时就有,构造时传aux_logits=True或False就行。

5.3 推理部署与迁移学习的注意点

部署时最容易犯的错误是忘记切换eval模式。由于GoogLeNet的训练模式会额外计算两个辅助分类器,如果不切换eval,推理不仅慢,还会遇到输出数量和预期不匹配的问题。写了这么久这个网络,我自己至少被这个坑绊倒过两次,所以每次写验证代码前都会先确认model.eval()。

迁移学习时,GoogLeNet是很好的特征提取器。一般做法是加载预训练权重,冻结前面大部分参数,只训练最后一层线性层或者后面几个Inception模块。因为前层提取的是通用边缘、纹理特征,后面几层更偏向具体类别特征。如果数据集和ImageNet差异较大,可以把冻结层数调低,让更多层参与微调,配合较小的学习率,效果通常比从头训练好很多。

另外,GoogLeNet因为用了自适应平均池化,推理时的输入不用严格限制在224,可以在保持宽高比的前提下放大分辨率做多尺度测试。实测下来,把测试分辨率从224提升到288或320,分类准确率通常还有小幅提升,这在用GoogLeNet做比赛或项目时是个便宜又有效的技巧。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询