经典卷积模型系列写到第四篇,轮到GoogLeNet了。我第一次看Inception V1源码时,第一反应是:这玩意儿怎么这么多分支?第二反应是:为什么一个各路教科书都要拿出来讲半天的模型,代码看起来反而比VGG要绕?后来把结构捋顺了才明白,GoogLeNet(Inception V1)做的事情其实特别朴素——在同一层里并行放几种不同尺寸的卷积核,让网络自己去挑对当前特征更合适的尺度。这篇博文我不打算复述论文公式,而是直接站在代码的角度,把为什么这样设计、每个Inception模块的通道怎么算、辅助分类器到底在帮什么忙、以及自己实际训练时踩过的坑一次讲清楚。
1. 从VGG的“笨功夫”看GoogLeNet的设计动机
好多初学者会有个困惑:VGG靠着堆3x3卷积就能拿到很好的效果,GoogLeNet凭什么还能更进一步?这得先看看VGG在2014年那个时代暴露出来的两个问题。
1.1 VGG留下的两个问题:参数爆炸与层级僵化
VGG的核心思路是把大卷积核拆成小卷积核,用连续多个3x3去模拟更大感受野,这种做法的好处是思路简单、结构规整,到现在依然好用。但代价也很明显:每一层都输出厚厚一堆特征图,通道数动不动512、1024,再加上后面三个全连接层,整个模型的参数量直接飙到上亿级别。当年GPU显存普遍只有2G到4G,训练一个VGG不仅慢,还特别吃显存,很多实验室只能靠数据并行硬扛。
另一个问题更隐蔽:VGG的结构是串行的,每一层只接受上一层的一种固定尺度特征。可现实中的图像内容尺度差异极大,同一张图里可能既有大范围的天空纹理,又有细小的鸟喙轮廓。网络如果只能用一个固定的卷积核尺寸去观察,就相当于要求它用同一个焦距去看所有物体,自然会有浪费和盲区。
1.2 Inception的破题思路:并行多尺度代替串行堆叠
GoogLeNet的应对方式很有启发性:既然不知道当前特征适合多大感受野,那我就把1x1、3x3、5x5、还有池化全都放在同一层里并行计算,最后把结果在通道维度上拼起来,让下一层自己去融合不同尺度的信息。这个结构就是后来大家熟知的Inception模块。
这样做的好处肉眼可见:一方面网络在同一层就能捕获多尺度特征,适应性更强;另一方面,它给后续的“稀疏连接”思想留了空间——不是每一层都要全连接式地计算所有卷积核,而是通过模块化的分支组合,把计算量控制在一个合理范围。整个GoogLeNet主干有22层,但参数量只有VGG的几十分之一,这个“少参数、深层次”的组合在2014年的ImageNet竞赛里拿到了非常亮眼的结果,也一下子让“网络到底该怎么设计”这个问题重新热了起来。
2. Inception V1的结构拆解:四个分支各自在干什么
Inception V1的模块结构其实不复杂,但初次看代码时容易被参数名绕晕。这里先把每个模块的通道配置列成一张表,后面讲代码时随时对照着看。
2.1 分支参数速查表:先建立整体印象
| Inception层 | 输入通道 | 1×1输出 | 3×3降维 | 3×3输出 | 5×5降维 | 5×5输出 | 池化投影 | 输出通道 |
|---|---|---|---|---|---|---|---|---|
| 3a | 192 | 64 | 96 | 128 | 16 | 32 | 32 | 256 |
| 3b | 256 | 128 | 128 | 192 | 32 | 96 | 64 | 480 |
| 4a | 480 | 192 | 96 | 208 | 16 | 48 | 64 | 512 |
| 4b | 512 | 160 | 112 | 224 | 24 | 64 | 64 | 512 |
| 4c | 512 | 128 | 128 | 256 | 24 | 64 | 64 | 512 |
| 4d | 512 | 112 | 144 | 288 | 32 | 64 | 64 | 528 |
| 4e | 528 | 256 | 160 | 320 | 32 | 128 | 128 | 832 |
| 5a | 832 | 256 | 160 | 320 | 32 | 128 | 128 | 832 |
| 5b | 832 | 384 | 192 | 384 | 48 | 128 | 128 | 1024 |
这张表里有个容易看漏的细节:每一个Inception层前面的“输入通道”,实际上来自上一个模块的输出通道,中间隔着池化下采样时也不例外。比如4e的输入528来自4d的输出,而不是4d的输入512,动手推一遍才不会在代码里把维度接错。
以第一层3a为例,输入特征图尺寸是28x28,通道数192。四个分支分别算完再拼接,得到的输出通道数是64+128+32+32=256,特征图宽高保持不变。分支二里的96和分支三里的16、32,就是常说的“降维通道”,它们存在的意义非常关键,后面单独讲。
2.2 1x1卷积为什么是灵魂组件
很多初学者第一次看Inception结构时会问:1x1卷积不是什么都没做吗?其实1x1卷积在通道维度上做了一次全连接式的线性组合,作用有三个方面。
第一是降维。以3a分支三为例,先用16个1x1卷积把192个通道压到16个,再做5x5卷积。如果不用1x1直接做5x5卷积,计算量会高出一个数量级。这个“先压缩再计算”的思路在当时是非常前沿的工程优化。
第二是跨通道信息融合。四个分支在最后拼接时,通道之间是彼此独立的,而1x1卷积可以在每个空间位置上把不同通道的信息混合起来,让后面的大卷积核拿到的不再是“各自为政”的特征,而是经过一次汇总的特征。
第三是引入额外非线性。每层1x1卷积后面都跟了ReLU激活,这意味着即便主干网络的深度不变,每一层内部的非线性表达能力也在增强。可以说,1x1卷积是Inception模块能够既宽又深还不爆炸的关键支点。
2.3 辅助分类器:不只是为了“中间监督”
GoogLeNet还有两个辅助分类器,分别接在4a和4d后面。原论文给出的理由很直白:防止网络太深导致梯度消失,让中间层也能直接拿到分类任务的梯度。
只看这个解释容易把辅助分类器理解得很工具化,实际上它还有一层“正则化”的作用。相当于是强迫网络在中间层就学会有区分度的特征,而不是把所有分类压力都压到最后几层。训练时两个辅助分类器的loss按0.3的权重加进总loss,可以看作是给主干网络提供了两次“阶段性考试”,但推理阶段它们会被整个扔掉,完全不影响部署结构。
我在实际实验里的体会是,辅助分类器对收敛速度的改善比较明显,尤其在没有BatchNorm的年代,它是让22层网络真正训练起来的重要保证。现代复现如果加了BN,辅助分类器的重要性会下降一些,但保留它依然能让训练更稳。
3. 完整代码实现与逐段解析
扣题到代码层面。下面这份代码是我按原论文结构写的PyTorch版本,为了贴近原始设计,默认不带BatchNorm。完整代码可以直接跑通,每个模块我都加了解释。
3.1 基础卷积块:一个ReLU也要单独封装
import torch import torch.nn as nn 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=True ) self.relu = nn.ReLU(inplace=True) def forward(self, x): return self.relu(self.conv(x))把“卷积+ReLU”封装成一个类,看起来是多此一举,但Inception模块里要用到大量相同组合,不封装的话每个分支都要重复写一遍,代码会又臭又长。这里有一个新手容易忽视的细节:inplace=True可以让ReLU在原来的张量上直接修改,省一点显存。因为后面没有需要保留原始x的操作,所以这样用是安全的。
3.2 Inception模块:四路并行的核心实现
class Inception(nn.Module): def __init__(self, in_channels, ch1x1, ch3x3red, ch3x3, ch5x5red, ch5x5, pool_proj): super().__init__() # 分支1:直接1x1卷积 self.branch1 = BasicConv2d(in_channels, ch1x1, kernel_size=1) # 分支2:1x1降维 + 3x3卷积 self.branch2 = nn.Sequential( BasicConv2d(in_channels, ch3x3red, kernel_size=1), BasicConv2d(ch3x3red, ch3x3, kernel_size=3, padding=1) ) # 分支3:1x1降维 + 5x5卷积 self.branch3 = nn.Sequential( BasicConv2d(in_channels, ch5x5red, kernel_size=1), BasicConv2d(ch5x5red, ch5x5, kernel_size=5, padding=2) ) # 分支4: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)这个模块类接收7个参数,正好对应表格里的前7列。forward里面没有写任何复杂的计算逻辑,四个分支各算各的,最后在通道维度上拼接,非常直白。
注意分支4的池化是stride=1, padding=1,这么做是为了让池化不改变特征图尺寸,因为最后要跟其他分支的特征图对齐拼接。如果这里写成常见的stride=2,尺寸就全乱了。这个细节在抄代码时很容易被忽略。
3.3 辅助分类器InceptionAux:小网络藏在主干里
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(2048, 1024) self.fc2 = nn.Linear(1024, num_classes) self.relu = nn.ReLU(inplace=True) self.dropout = nn.Dropout(0.7) def forward(self, x): x = self.pool(x) x = self.conv(x) x = torch.flatten(x, 1) x = self.fc1(x) x = self.relu(x) x = self.dropout(x) x = self.fc2(x) return x辅助分类器的输入是14x14的特征图。平均池化kernel_size=5, stride=3会把14x14压成4x4,再经过1x1卷积变成128个通道,展平之后就是4 x 4 x 128 = 2048个数字,正好对应fc1的输入维度。
这里有个很重要的前提:2048这个数字是跟着输入分辨率走的。如果输入图片不是224x224,4a、4d层的特征图尺寸就不再是14x14,池化后的4x4也会变,fc1的维度就要重新计算。这也是为什么我建议第一遍复现时老老实实用224x224。
3.4 GoogLeNet主干:从stem到全连接输出
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, 192, kernel_size=3, padding=1) self.maxpool2 = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # Inception组 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) 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) self.inception5a = Inception(832, 256, 160, 320, 32, 128, 128) self.inception5b = Inception(832, 384, 192, 384, 48, 128, 128) # 分类头 self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.dropout = nn.Dropout(0.4) self.fc = nn.Linear(1024, num_classes) if self.aux_logits: self.aux1 = InceptionAux(512, num_classes) self.aux2 = InceptionAux(528, num_classes) self._initialize_weights() def _initialize_weights(self): for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') if m.bias is not None: nn.init.zeros_(m.bias) elif isinstance(m, nn.Linear): nn.init.normal_(m.weight, 0, 0.01) nn.init.zeros_(m.bias)主干网络的连接顺序可以按特征图尺寸变化来记忆。224x224的输入经过stem后变成28x28,经过3a、3b后下采样到14x14,经过4a到4e后再下采样到7x7,最后5a、5b把特征抽象到1024个通道,全局平均池化后接全连接分类。
原论文在最后用的是固定7x7的平均池化,因为输入固定224x224时5b输出正好是7x7。现代复现为了鲁棒性,经常改成AdaptiveAvgPool2d((1, 1)),这样即使输入尺寸不是224x224,最后一层也能正常工作。我在这里选择自适应池化版本,对学习更友好。
3.5 前向传播:训练和推理的不同路径
def forward(self, x): # stem x = self.conv1(x) x = self.maxpool1(x) x = self.conv2(x) x = self.maxpool2(x) # inception 3 x = self.inception3a(x) x = self.inception3b(x) x = self.maxpool3(x) # inception 4 x = self.inception4a(x) aux1 = self.aux1(x) if self.training and self.aux_logits else None x = self.inception4b(x) x = self.inception4c(x) x = self.inception4d(x) aux2 = self.aux2(x) if self.training and self.aux_logits else None x = self.inception4e(x) x = self.maxpool4(x) # inception 5 x = self.inception5a(x) x = self.inception5b(x) # 分类头 x = self.avgpool(x) x = torch.flatten(x, 1) x = self.dropout(x) x = self.fc(x) if self.training and self.aux_logits: return x, aux1, aux2 return x这段forward是整份代码里最容易出问题的地方。self.training是PyTorch的模型内置状态,调用model.train()时为True,调用model.eval()时为False。所以同一个forward在训练时返回三个输出,在推理时只返回主干输出。
新手经常在这里踩坑:写完测试代码,忘了调model.eval(),直接把模型丢进with torch.no_grad()里跑,结果发现返回值是个三元素的tuple,完全没法算准确率。并不是代码写错了,而是没有让模型进入推理模式。这个习惯一定要养成。
4. 训练细节与损失函数设计
代码能跑通只是第一步,真正让GoogLeNet出效果还需要理解它的训练机制。
4.1 三个输出如何组合成最终loss
训练时GoogLeNet会输出三个分类结果:主干输出、aux1输出、aux2输出。它们对应同一个ground truth,所以可以用同一个交叉熵来计算损失,然后按权重合并:
criterion = nn.CrossEntropyLoss() output, aux1, aux2 = model(images) loss1 = criterion(output, targets) loss2 = criterion(aux1, targets) loss3 = criterion(aux2, targets) loss = loss1 + 0.3 * loss2 + 0.3 * loss30.3这个权重是论文里明确写出来的,也是后来大多数复现的默认值。它在整体loss里的作用相当于给两个辅助分支“打折”,让它们帮助训练但不至于喧宾夺主。如果数据集比较大,我会建议先按0.3跑一轮,等验证集loss不再下降时,再尝试把辅助权重调到0.1或0.5对比一下。这个超参对最终精度有一定影响,但远没有学习率和数据增强敏感,不必太纠结。
推理阶段由于辅助分支已经不存在,损失函数自然回到单输出,测试代码里不需要再做任何额外处理。
4.2 优化器、学习率与初始化选择
GoogLeNet不是用ImageNet默认初始化直接训练的,而是用了自己的初始化策略,这也是我在代码里单独写_initialize_weights的原因。卷积层用适合ReLU的kaiming初始化,全连接层用标准差0.01的高斯初始化,偏置置零。这个操作放到今天看依然合理,尤其是不带BN的网络,良好的初始化直接决定了前期收敛速度。
优化器方面,原论文用的是SGD,初始学习率0.01,momentum 0.9,权重衰减0.0001。以ImageNet这样的大规模数据为标准配置,我用下来觉得这个参数组合很稳。如果换成了Adam或AdamW,由于它们自带自适应学习率,初始化策略相对没那么敏感,但GoogLeNet这种深层网络我个人还是更推荐SGD,泛化能力一般更好。
补充一个实操建议:如果只是在小数据集上快速验证代码正确性,不需要严格按0.01起步,可以先用0.001跑100个iter,看loss有没有明显下降趋势,确认维度、数据流都没问题,再切换到正式学习率训练。
4.3 一个计算量对比:1x1降维到底省了多少
为了更直观地感受1x1卷积降维的价值,我算一个最简单的对比。假设输入是28x28x192,这一层想提取32个5x5维度的特征。
如果直接用一个5x5卷积完成:
计算量 = 28 x 28 x 32 x 192 x 5 x 5 ≈ 120.4M 次乘加如果按照Inception分支三的方式,先用16个1x1卷积降维,再用32个5x5卷积:
第一步 1x1:28 x 28 x 16 x 192 ≈ 2.4M 第二步 5x5:28 x 28 x 32 x 16 x 5 x 5 ≈ 10.0M 合计 ≈ 12.4M 次乘加两种方案差了接近10倍的计算量,而输出尺寸完全相同。所以说Inception不是简单地把网络做“宽”,而是在每个分支内部都做了严格的预算控制,这也是它22层深度却依然能在当年硬件上训练起来的重要原因。
5. 我踩过的坑与调参心得
任何经典模型的复现都不是一次跑通就结束的,这里把我和周围同学实际遇到过的问题整理成速查表,后面再挑几个重点详细说。
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 训练时维度不匹配,报错在Inception拼接处 | 某个分支的卷积padding或stride写错 | 核对四个分支输出尺寸后再拼接 |
| 推理时收到tuple,无法直接算准确率 | 忘了调用model.eval() | 推理前加model.eval() |
| 修改输入尺寸后Aux fc报错 | 池化后4x4x128不是2048 | 改用AdaptiveAvgPool或重新计算fc输入维度 |
| 没有BN的网络训练loss不稳 | 学习率太高或初始化不当 | 降低学习率,检查_kinitialize_weights是否被调用 |
| 训练速度特别慢 | 辅助分类器在训练时也参与计算 | 若不需要辅助监督,设置aux_logits=False |
5.1 最容易崩的地方:通道数对不上
GoogLeNet的Inception模块输出通道是四个分支通道数之和,这个和表格里下一个模块的输入通道必须严格一致。很多初学者改着改着就把某个分支的ch3x3或pool_proj参数改了,导致拼接后通道总数对不上下一个模块的输入。
排查思路很简单:在forward里每个模块后面打印一下x.shape,或者临时写一段只跑前几层的测试代码,逐层检查。如果发现某个模块输出和下一个模块的输入通道不一致,第一件事就是回去核对Inception层的每个分支参数,尤其是分支4的pool_proj,它是最容易被漏掉的一项。
5.2 Aux分支的2048是怎么来的
代码里fc1 = nn.Linear(2048, 1024)中的2048,常有人问是怎么来的。它等于1x1卷积输出通道128,乘上平均池化后特征图的宽高4x4,也就是128 x 4 x 4 = 2048。这里的前提是输入图片224x224时,4a和4d层的特征图尺寸正好14x14。
如果以后想把GoogLeNet用在非224x224的输入上,最稳的做法是把Aux里的池化层改成自适应池化:
self.pool = nn.AdaptiveAvgPool2d((4, 4))这样无论输入尺寸怎么变,展平之后都是128 x 4 x 4 = 2048,fc1不用跟着改。代价是和原论文的严格结构有些出入,但工程上更省心。
5.3 关于BatchNorm的历史遗留问题
原论文的GoogLeNet没有使用BatchNorm,因为BN大规模引入是之后的事。所以严格复刻V1时,BasicConv2d里没有BN层。但如果你拿这份代码去跑自己的数据集,会发现收敛速度有点慢,这很正常。
想在保留Inception结构的前提下加速收敛,最简单的改动就是给BasicConv2d加BN:
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)))加了BN之后有两个连锁反应:一是卷积层不再需要bias,因为BN自带偏置项,所以bias=False;二是_initialize_weights里要额外处理BN层的weight和bias,一般直接用PyTorch默认初始化即可。
5.4 从V1到后续版本:短短几次改进意味着什么
做代码解析时顺便把后续版本一起了解,有助于建立坐标系。Inception V2/V3的核心变化是把大卷积核分解成更小的卷积核,比如把5x5拆成两个3x3,再把3x3拆成1x3加3x1的组合,同时接入BN,让同样的感受野用更少参数和计算量实现。到了Inception V4,又融合了残差连接的思路,训练稳定性进一步提升。
回头看V1,它是整个Inception家族里结构最“原生态”的版本,没有花哨的卷积分解,也没有残差跳连,但四个分支并行多尺度的核心思想已经完整成型。这也是为什么我建议对经典模型做源码级学习时,先从V1入手,它最直观,也最能体现设计动机。
最后分享一点代码之外的经验。GoogLeNet整套设计放在今天并不复杂,但它最值得琢磨的地方在于,设计者没有一味地把网络加深加宽,而是从“信息尺度”的角度重新思考了卷积层怎么组织。我自己跑这个模型时最明显的感受是它对输入分辨率比较敏感,调代码时建议第一遍就固定用224x224,然后把每个模块的输出维度打印出来跟论文对照一遍,确认无误后再去做分辨率或结构上的改动。不然卡在维度坑里,很容易把耐心磨光。希望这篇能帮正在啃经典结构的你少走几步弯路。