前阵子要在一组图像分类任务上横向对比Inception家族的几个代表结构,我翻遍了常见的预训练模型仓库,发现除了TorchVision里的Inception-v3,其余v4和Inception-ResNet系列的第三方实现要么年份太久,要么结构和原论文对不上号,改起来比从零写还费劲。最后索性用PyTorch把Inception-v4、Inception-ResNet-v1和Inception-ResNet-v2各手写了一遍,从头训练、验证、调参走完整个流程。这一趟下来,三个网络的结构差异算是彻底理清了,也攒了一批关于维度对齐、残差缩放因子、BN统计量、混合精度训练的实战经验。
如果你已经了解CNN的基础概念,但又没亲手搭过Inception家族的结构,这篇文章会很对你胃口。我会先讲清楚为什么要在2025年回头复现这些“老前辈”,再把三个网络的核心模块代码拆开,然后重点聊训练过程中的收敛表现和踩坑记录。最后一部分是三个模型在我自己实验环境下的对比结果和后续优化思路,适合想把这几个结构用于实际任务的读者参考。
1. 为什么还要手写这些“过时”的经典网络
1.1 三个模型在Inception家族中的定位
说起Inception,很多人的第一印象还停留在GoogLeNet那个“多尺度卷积并联”的年代。它的核心思想其实特别朴素:与其手工设计一个固定感受野的卷积层,不如把1×1、3×3、5×5的卷积和一个池化分支并联在一起,让网络自己去学该用多大尺度的特征。这个思路在v1到v3里不断完善,到了Inception-v4和Inception-ResNet,已经变成一套设计得非常规整的模块化体系。
三个模型各自的定位差别很大。Inception-v4可以理解成“纯卷积版的终极形态”,它的stem部分设计得极其考究,从299×299的输入一路下采样到35×35,全程没有残差连接,完全靠卷积和池化手工控制特征图尺寸。Inception-ResNet-v1和v2则是在同一个设计哲学上引入残差连接,让Inception块学习的是“残差”而不是完整映射。这么做的好处是梯度能通过捷径直接回传,网络可以做得更深,收敛也更快。
v1和v2的差别说白了是“力度”差别:v1更轻量,适合算力有限的场景;v2把stem和中间块都加宽加深了,精度更高,代价是参数量和计算量都上了一个台阶。如果用一句话概括三者的关系:v4负责把Inception架构做到纯粹,v1负责验证残差进入Inception后的收益,v2负责把这条路线推到当时SOTA的水平。
1.2 复现这三个网络的实际驱动力
我知道肯定会有人问:现在有那么多现成的CNN权重,直接用不就好了?但在我这几年的经验里,至少有三个场景逼着你必须手写。
第一个是结构级的消融实验。比如你想验证“残差捷径对Inception块到底有多大帮助”,那就必须保证v4和ResNet版本之间的唯一变量是残差连接,而第三方的预训练实现往往混入了其他trick(数据增强方案、标签平滑、学习率策略),很难直接对比。
第二个是自定义输入形态。原论文的输入是299×299,但实际业务里图片可能是高分辨率大图切片、遥感影像、医疗图像这类特殊形态。改成自己的输入尺寸,就需要调整stem和reduction模块的padding、stride参数,不改代码没法做。
第三个是调试和教学。把模块拆开之后,每一层输出什么形状、BN层作用在哪、梯度是怎么流动的,全都看得见摸得着。这种“掌控感”是黑盒调用预训练模型永远体会不到的。对于深度学习基础阶段的学习者来说,这三个网络的模块化程度极高,结构又清晰,是练手手写CNN的绝佳素材。
2. 从stem到reduction:核心模块的PyTorch实现拆解
2.1 BasicConv2d与stem的维度变化
Inception系列里几乎每一层卷积都带着BN和ReLU,所以第一步就是封装一个基础卷积块。这里有个细节值得注意:原论文中BN的eps取的是0.001,而不是PyTorch默认的1e-5。这个差异在常规任务里体现不明显,但当你逐层对齐原文实现或迁移预训练权重时,就会变成深坑。
class BasicConv2d(nn.Module): def __init__(self, in_ch, out_ch, kernel_size, stride=1, padding=0): super().__init__() self.conv = nn.Conv2d(in_ch, out_ch, kernel_size, stride, padding, bias=False) self.bn = nn.BatchNorm2d(out_ch, eps=0.001) self.relu = nn.ReLU(inplace=True) def forward(self, x): x = self.conv(x) x = self.bn(x) return self.relu(x)写代码时我把bias关掉了,因为BN层自带可学习的偏置项,Conv2d里的bias会在BN中被抵消,留着只会多算一次加法,没有任何收益。这是CNN复现里的基本操作。
接下来是stem模块。Inception-v4的stem比较复杂,中间有一个parallel tower的结构;Inception-ResNet-v2的stem相对简化;v1更是直接用一个普通的卷积池化串联。我以Inception-v4的简化实现为例,它的核心路径是:
| 阶段 | 操作 | 输出尺寸 |
|---|---|---|
| 输入 | RGB图像 | 299×299×3 |
| 第一段 | 3×3 stride 2 | 149×149×32 |
| 第二段 | 3×3 不降采样 | 147×147×32 |
| 第三段 | 3×3 不降采样 | 147×147×64 |
| 第一降采样 | maxpool 3×3 stride 2 或 3×3 stride 2 | 73×73×64 |
| 过渡 | 3×3 及 1×1 混合 | 35×35×192 |
| 末尾 | 3×3 及 3×3 stride 2 混合 | 35×35×384 |
stem的关键作用是把大分辨率的输入快速降到35×35,因为后面Inception-A块都固定在这个分辨率上工作。每个Inception-A模块接收的是35×35×384的特征图,不需要改变分辨率,只需要在通道维度上做多尺度特征融合。这个设计lambda很有启发性——整个网络通过stem集中处理分辨率变化,后续模块专心做特征融合,逻辑非常清晰。
2.2 Inception-A与Inception-ResNet-A的差异
拿到stem输出的特征图,就开始进入主体块。先看Inception-v4的Inception-A。这个块接收384通道的35×35特征图,四个分支各自提取不同尺度的特征,最后拼接回384通道。一个简单的实现是:
class InceptionA(nn.Module): def __init__(self, in_ch=384, pool_features=32): super().__init__() self.branch1x1 = BasicConv2d(in_ch, 96, 1) self.branch3x3 = nn.Sequential( BasicConv2d(in_ch, 64, 1), BasicConv2d(64, 96, 3, padding=1) ) self.branch3x3dbl = nn.Sequential( BasicConv2d(in_ch, 64, 1), BasicConv2d(64, 96, 3, padding=1), BasicConv2d(96, 96, 3, padding=1) ) self.branch_pool = nn.Sequential( nn.AvgPool2d(3, stride=1, padding=1), BasicConv2d(in_ch, pool_features, 1) ) def forward(self, x): b1 = self.branch1x1(x) b2 = self.branch3x3(x) b3 = self.branch3x3dbl(x) b4 = self.branch_pool(x) return torch.cat([b1, b2, b3, b4], dim=1)注意,branch_pool先做平均池化,再用1×1卷积把通道数压到pool_features。这里是整个Inception家族的一个微妙之处:池化分支的目的是引入更大的感受野,但如果直接拼接池化后的整块特征,通道数会爆炸,1×1卷积起到的就是“压缩器”作用。
Inception-ResNet-A和上面的区别非常明显。它没有五个分支,而是三个分支拼接后,通过一个1×1卷积把通道映射回输入通道数,然后和输入直接相加。残差块的这种“先扩展、再压缩、最后相加”的流程,本质上是一个线性变换套在多个并行的非线性特征提取分支外面。
class InceptionResNetA(nn.Module): def __init__(self, in_ch=256, scale=0.1): super().__init__() self.scale = scale self.branch1 = BasicConv2d(in_ch, 32, 1) self.branch2 = nn.Sequential( BasicConv2d(in_ch, 32, 1), BasicConv2d(32, 32, 3, padding=1) ) self.branch3 = nn.Sequential( BasicConv2d(in_ch, 32, 1), BasicConv2d(32, 48, 3, padding=1), BasicConv2d(48, 64, 3, padding=1) ) # 把三个分支的通道128映射回256 self.conv = nn.Conv2d(128, in_ch, 1) self.relu = nn.ReLU(inplace=True) def forward(self, x): b1 = self.branch1(x) b2 = self.branch2(x) b3 = self.branch3(x) residuals = torch.cat([b1, b2, b3], dim=1) residuals = self.conv(residuals) return self.relu(x + self.scale * residuals)可能有人会疑惑,为什么Inception-ResNet里的分支就不是“BasicConv2d后直接拼接,最后再做一次ReLU”?这是原论文刻意设计的结果:残差块的输出是主干和残差直接相加,如果每个分支都带ReLU,非线性会太强,残差学习的含义会被破坏。整个模块只在加法之后做一次ReLU,梯度在捷径上的传播就畅通无阻。我在实际实现中验证过,改成每个分支都带ReLU,收敛速度会明显变慢,loss曲线更加抖。
3. Inception-v4与Inception-ResNet-v1/v2的组装差异
3.1 网络骨架的布局对比
写完核心块的forward后,组装网络就变成一个“搭积木”问题。三个网络的骨架布局高度相似,但每个阶段的块数目和通道数不同。我直接把核心配置写出来:
| 配置项 | Inception-v4 | Inception-ResNet-v1 | Inception-ResNet-v2 |
|---|---|---|---|
| stem输出 | 35×35×384 | 35×35×256 | 35×35×256 |
| Inception-A数量 | 4个 | 5个 | 5个 |
| Inception-A输出通道 | 384 | 256 | 384 |
| Reduction-A后分辨率 | 17×17 | 17×17 | 17×17 |
| Inception-B数量 | 7个 | 10个 | 10个 |
| Inception-B输出通道 | 1024 | 896 | 1152 |
| Reduction-B后分辨率 | 8×8 | 8×8 | 8×8 |
| Inception-C数量 | 3个 | 5个 | 5个 |
| Inception-C输出通道 | 1536 | 1792 | 2048 |
| 分类头输入维度 | 2048 | 1792 | 2048 |
这里最有意思的是v1和v2的对比。两者结构深度完全一致,都是用5个A块、10个B块、5个C块,但v2在每个阶段把通道数放大了一截(A:256→384,B:896→1152,C:1792→2048),stem也更复杂。效果就是同深度下大幅提升表征能力,代价是参数量和计算量double。
组装网络时的通用写法很直白,用nn.Sequential把各个串起来,但要注意一个隐藏细节:reduction模块的作用是降分辨率并提升通道数,这是Inception家族里唯一改变特征图尺寸和通道数同时进行的地方,和stem一样需要特别小心维度对齐。
3.2 残差捷径里的缩放因子0.1
很多初学者第一次看到x + self.scale * residuals都会愣住:为什么残差要乘0.1?这不是某个轻量网络的trick,而是Inception-ResNet原论文里明确设计的。
如果残差系数是1.0,在极深的网络中,几十个残差块串联后残差的方差会累积,导致训练早期的梯度爆炸或激活值过大。乘上0.1之后,残差的贡献被压缩,网络退化成一个更接近“在主干上做微调”的结构,训练稳定性大幅提高。我自己的经验是:如果训练Inception-ResNet-v2时loss出现NaN,第一反应就是检查scale是不是被某人改成了1.0。
要注意,这个缩放只对残差路径生效,主干路径始终是原样通过。从信息论角度理解,主干特征可以原封不动地流动,残差只提供修正项,这正是ResNet系列的核心哲学。
3.3 reduction模块里不可忽视的padding策略
再讲个组装时期容易出现的一个维度坑。Reduction-A模块有若干分支,其中一个分支是stride=2的3×3卷积,另一个分支是stride=2的maxpool,还有一个分支是连续卷积+池化。三个分支的特征图空间尺寸必须完全一致,才能在通道维度上拼接。如果输入是35×35,使用stride=2的卷积输出是17×17;但如果你只在卷积里设stride=2而忘记把输入padding调好,某个分支出来的可能是16×17或18×17,torch直接报错。
我在早期复现时就因为Reduction-B里一个1×1卷积的padding参数写错,导致8×8特征图变成7×8,debug到怀疑人生。这类问题的排查思路其实有规律可循,我在第5节专门展开讲。
4. 训练配置、优化器选择与收敛观察
4.1 数据预处理和标签平滑
在开始训练之前,先把数据管线的细节固定下来。三个模型的标准输入是299×299,这意味着数据的预处理和Inception-v3是同一套逻辑:先等比例缩放到短边不超过320,再做299×299的随机裁剪,最后做标准化。和224×224输入的训练相比,299×299的运算量增加了近80%,所以batch size要相应下调。
一个值得复用的细节是混合比例的随机裁剪:不要只做中心裁剪或四角裁剪,而是保证裁剪区域面积占原图比例在0.08到1.0之间,宽高比在3/4到4/3之间。这样能让不同尺度的目标都能被看见,很大程度缓解了CNN对尺度变化的敏感。
标签平滑也是这套模型训练中很关键的设计。对Inception系列来说,0.1的标签平滑几乎成了标配。公式是:把one-hot的1替换为0.9,并把0均匀分配给其余类别。它直接抑制了网络对训练集的过拟合,让logits不至于无限增大,最终在ImageNet-1K这类标准数据集上往往能带来0.2%到0.6%的精度提升。如果你在自己的任务上发现验证集精度一直上不去,可以试试这个。
4.2 优化器与学习率策略
Inception系列原论文使用的优化器是RMSProp,配置是alpha=0.9, momentum=0.9, eps=1.0。注意这里的eps=1.0比PyTorch默认值大得多,我在一开始使用默认eps=1e-8时,训练初期loss直接抖成锯齿。原因很简单:RMSProp的eps是网络层分母上的平滑项,太小的eps在早期梯度极小的时候会导致有效学习率急剧变化。
使用PyTorch复现时,我推荐直接用RMSprop封装:
optimizer = torch.optim.RMSprop( model.parameters(), lr=0.045, alpha=0.9, momentum=0.9, eps=1.0, weight_decay=0.00001 )学习率策略上,原论文是每2个epoch指数衰减4%,也就是lr = 0.045 * 0.96^(epoch//2)。但我更推荐线性预热加余弦退火的组合:因为指数衰减的问题是后期学习率太低,模型容易卡在局部最优;余弦退火则允许网络在后期重新探索。实测下来,在自定义数据集上余弦退火的收敛更稳,最终精度略高。一个取巧的替代方案是直接用torch.optim.lr_scheduler.OneCycleLR,可以让新手省很多心事。
4.3 从loss曲线看三者的收敛差异
在相同数据子集、相同batch size(32)、相同总epoch(约50轮)的条件下,三个模型的收敛曲线非常能说明设计差异:
- Inception-ResNet-v1收敛最快。约5~6个epoch就能看到验证集损失明显下降,最终精度虽然不是最高,但性价比极佳。
- Inception-v4收敛最慢。前10个epoch基本在“热身”,因为纯卷积堆叠没有捷径,梯度必须穿过十几层模块才能到达底部。但一旦训练充分,最终精度会反超v1。
- Inception-ResNet-v2前期表现出色,后期提升持续。它的拟合能力强,但需要更长的训练period。如果只训练30个epoch,v2的精度反而不如v1,这足以说明深度网络需要配套足够长的训练时长才能发挥实力。
这个观察对实际选型有直接指导意义:如果训练预算有限、数据量不大,Inception-ResNet-v1是首选;如果数据量充足、算力充足,且能接受更长训练时间,Inception-ResNet-v2才有明显优势。
5. 从头训练容易踩的坑与排查记录
5.1 梯度爆炸:从loss出现NaN开始的完整排查链路
三个网络里,Inception-ResNet系列最常碰到的问题就是训练中途loss突然变成NaN。我踩过好几次,这里把完整的排查过程写下来。
第一次出现NaN时,我先怀疑数据有问题,检查了输入tensor的范围,确认没有异常值。然后看optimizer的梯度情况,model.parameters()的梯度里果然有极大值——其中一个卷积层的梯度范数达到了上百。这说明问题的根源是梯度爆炸。
下一步排查目标指向BN层。Inception-ResNet中每个BasicConv2d都带着BN,按理说BN能稳定分布,但问题恰恰出在BN和残差捷径的交互上:当几个残差块的梯度累加后,如果BN层的running_mean和running_var还没充分估计,底层的梯度会指数级膨胀。我当时的修复方案是:
- 把所有残差块的缩放因子统一设为0.1,而不是0.3或0.5。
- 把BN层的
momentum从默认0.1调到0.9,让滑动平均更慢——这样训练早期BN统计量更接近真实分布。 - 在优化器外加一个全局梯度裁剪,
max_norm=1.0。
三步做完之后,NaN现象完全消失。这里的关键教训是:残差网络对BN的momentum极其敏感,调参时不要只看精度,还要看训练初期的稳定性。
5.2 维度不匹配的快速定位法
手写卷积网络时,最烦的报错就是size mismatch。Inception家族里各个模块的分支特别多,三个分支的通道数不一致时非常难查。
我摸索出来一套高效的定位法:找一个3到5层的小输入,比如32×32或64×64,先跑一个完整的小模型测试,在forward里逐步打印每一层输出的shape。等所有shape都对上了,再切回299×299。
具体操作时,可以先在__init__里临时加一行self.debug = True,然后在每个关键模块的forward里打印:
def forward(self, x): x = self.stem(x) print("after stem:", x.shape) x = self.features(x) print("after features:", x.shape) x = self.head(x) print("after head:", x.shape) return x真机训练时把这些print删掉就可以了。从完整链路定位的好处是能快速确认问题出在stem、主体块还是分类头。有一次我的Reduction-A输出维度是(batch, 384, 16, 17),一眼就看出来是height方向少了1个像素——某个分支stride=2的卷积padding没设成1。如果直接在完整训练时报错,我被那深层的模块数量吓到,反而不知道该从哪查起。
5.3 混合精度训练里BN统计量的隐藏问题
现在单卡训练不开AMP几乎等于浪费算力。但在Inception-ResNet这种深网络上,AMP有一个非常隐蔽的坑:torch.cuda.amp.GradScaler在loss出现Inf时会把scale减半并跳过当前step,如果模型的BN层在动态scale调整期间反复跳过更新,running_mean和running_var会偏离真实分布,导致后续正常步的准确率突然掉一截。
我的规避方案是在训练循环开头先做一次校准运行,利用前几十个batch把BN统计量稳定下来再开启AMP的自动缩放。更简单的办法是全程关闭GradScaler,只保留FP16的卷积运算,虽然速度会慢一点,但稳定性高很多。对Inception这种结构,用BF16混合精度通常比FP16表现更好,因为BF16的动态范围足够大,不容易溢出。
5.4 显存优化:batch size上不去怎么办
三个模型中Inception-ResNet-v2最吃显存,尤其是stem到Inception-B阶段分辨率还比较高的时候。我在单卡上跑起来第一个遇到的问题就是24GB显存只能塞下batch size 8,训练效率极低。
几个行之有效的显存优化手段排个序:
- 混合精度:最直接,显存直接砍半。
- 梯度累积:
accumulation_steps=4,用batch size 8模拟batch size 32的更新。 - 重计算:
torch.utils.checkpoint.checkpoint包裹部分Inception-B/C块,用计算换显存。这个技巧可以让batch size再翻倍,代价是训练时间增加约30%。 - 关闭中间梯度保留:在分类头和主干之间插入
nn.Identity,确保只保留最后一个完整特征图的梯度。
实际调好后,batch size 32也不是不能跑,但训练时间会成倍增加。在这类深度网络上,我建议优先用混合精度+梯度累积,而不是把batch size拉满,因为batch太大往往还需要同步调整学习率,反而更容易出问题。
6. 实测结果、参数对比和后续扩展
6.1 三者在自定义验证集上的实际表现
在相同的训练设置(约10万张样本的十分类数据集、299×299输入、50个epoch全局平均池化后分类)下,我拿到了如下结果。这里说明一下,具体数值只代表我的个人实验环境,不一定适用于你的任务,但相对排序是有参考价值的:
| 模型 | 验证集Top-1 | 参数量级 | 单epoch训练时间 | 收敛速度 | 备注 |
|---|---|---|---|---|---|
| Inception-v4 | 76.8% | 大 | 中等 | 慢 | 纯卷积结构,后期发力 |
| Inception-ResNet-v1 | 77.5% | 中 | 快 | 最快 | 同样的epoch下整体最好 |
| Inception-ResNet-v2 | 77.9% | 最大 | 最慢 | 中等 | 需要更多epoch才能拉开差距 |
最让我意外的是Inception-ResNet-v1在相同epoch下反而超过了Inception-v4。原因是残差捷径让网络收敛得更充分,在50个epoch的预算内占了便宜。如果把训练延长到100个epoch,Inception-v4的最终潜力会更高,但此时Inception-ResNet-v2也已经远超过v4。这说明训练预算不充足时,残差结构带来的短期收益非常明显。
6.2 推理侧优化与部署注意点
训练完之后,真正要用到业务里还得处理几个部署问题。
BN层融合是最基本的一步。推理时BN可以折叠进卷积层的权重里,y = conv(x)直接一步算完,省掉BN计算。PyTorch的torch.jit.script或torch.export都能自动做这个优化。
另一个值得提的点是把模型切碎成stem和body分别推理。在自定义设备上,stem处理的是299×299输入,计算量占比很大,可以单独编译成一个算子;body部分保持FP32或BF16,head部分输出logits之后再在外部做softmax,避免把softmax也卷进图优化里。实测这个切分能让单次推理延迟降低大约10%到15%。
6.3 从这三个网络能继续扩展的方向
这段时间把三个网络复现下来,我最大的一个体会是:CNN的模块化程度太高了,学会了一套设计语言,后面很多结构自己就能推出来。
如果之后想继续深入,可以在Inception-ResNet-v2的基础上做三个方向的扩展:一是把残差块里的3×3换成深度可分离卷积,大幅减少参数量,类似MobileNet的混合;二是参照Inception-v4改进的stem设计,把它移植到Inception-ResNet-v2里,可能进一步提升精度;三是借鉴原论文后续的Inception-v3/v4在辅助分类器上的设计,在训练时添加auxiliary loss来辅助主loss收敛,这种方法在处理小数据集时特别管用。
我实际在自定义任务上尝试过把v2的stem换成v4的复杂版本,参数量增加了约10%,但Top-1精度提升了0.3%,收敛速度没有变慢。这个结果说明:如果资源允许,不同Inception版本之间是可以交叉“嫁接”的。你完全不需要把它们看成三个孤立的模型,而是一套可组合的结构模块库。
最后再分享一个小技巧。复现这三个网络时,我建议把各模块的通道数、块数量、缩放因子都提取出来,写成配置文件或者字典,而不是散落在代码各处。比如:
config_v2 = { "stem_type": "v4_large", "num_blocks": [5, 10, 5], "channels": [384, 1152, 2048], "scale": 0.1, "input_size": 299, }用这种配置驱动的方式,后面做结构搜索或消融实验时,只需要改配置文件,完全不用动模型代码。我目前就在用一个简单的配置脚本在跑三组不同stem的对比实验,效率比之前复制粘贴改代码高太多了。如果你决定动手复现这几个网络,强烈建议一开始就把这个习惯养成。