ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Inception-ResNet从零复现:v1/v2结构对比与Pytorch实现全解析

Inception-ResNet从零复现:v1/v2结构对比与Pytorch实现全解析 去年年底我重新啃了一遍Inception-ResNet相关论文顺手把v1和v2两个版本的网络结构用Pytorch从零搭了一遍。这篇文章就是我的学习笔记核心内容包括Inception与ResNet的结合思路、两个版本的差异对比、网络各模块的拆解以及一份注释比较完整的Pytorch实现代码。如果你已经在用CNN做分类、检测或特征提取想搞清楚Inception-ResNet这类经典网络的内部构造或者打算自己复现一个能改、能跑、能迁移的网络骨架这篇文章可以帮你省不少查资料的时间。我尽量不照着论文念而是把我理解到的设计逻辑和实操中踩过的坑都写出来。有些细节比如具体滤波器数目我按常见开源实现做了整理和论文原始训练配置可能有一点点出入但不影响整体理解和复现。1. 为什么要把Inception和ResNet放到一起开始看代码之前先聊清楚一件事Inception-ResNet到底解决了什么问题。我自己刚开始接触时以为它就是Inception模块加一条捷径这么简单。真去复现才发现关键不在有没有捷径而在于残差结构到底改变了Inception模块训练的什么。1.1 Inception和ResNet各自擅长什么Inception家族的核心思想是在同一层里用不同尺寸的卷积核并行提取特征然后把结果在通道维上拼接起来。1x1捕捉点级别的特征3x3捕捉局部区域5x5或连续两个3x3捕捉更大的感受野。这样网络在同一层就能看到不同粗细粒度的信息表达能力更强。但代价是结构比较复杂分支多超参数多训练的时候对初始化、学习率都更敏感。ResNet的核心贡献则是解决了深度网络的梯度传播问题。通过让某一层的输出等于输入 残差F(x)梯度可以沿着捷径直接回传网络加深后依然能稳定优化。这个结构有一个隐藏的好处就是网络在训练初期可以先学到接近恒等映射的解再由残差分支逐渐细化。一个负责表达更强一个负责训练更稳直觉上确实很配。Inception-ResNet就是把这俩粘在一起保留Inception多分支并行提取特征的骨架又在模块外部套上残差连接让深层Inception网络的训练难度显著下降。注意Inception-ResNet里的残差连接和ResNet的捷径恒等映射在概念上一致但实现细节有差异稍后重点讲。1.2 融合的关键残差缩放因子在我最早试的版本里就是把Inception模块的输出直接加到输入上结果训练一开始loss抖动很厉害收敛也慢。后来才发现忽略了一个关键细节Inception模块的输出经过多个分支的卷积之后特征数值的方差往往比输入分支大不少直接相加很容易破坏网络前期的稳定性。所以论文和主流实现都会在残差分支相加之前乘一个缩放系数scale通常取0.1~0.3。这个逻辑其实有点像学习率的作用把每次残差更新的步幅先压小让网络在训练初期更接近恒等映射等训练稳定后再逐步让残差分支发挥作用。实践下来scale取0.1是比较稳的默认值效果不错。我在实现里把scale作为模块的一个参数这样后面如果做实验调大调小都很方便。2. Inception-ResNet的整体架构Inception-ResNet的宏观结构并不复杂按顺序可以分成这么几个部分Stem入口 → 若干Inception-ResNet-A → Reduction-A → 若干Inception-ResNet-B → Reduction-B → 若干Inception-ResNet-C → 全局池化 → 分类层或任意下游任务头。这个堆模块-降采样-再堆模块的模式和很多分类网络类似但有三个地方值得单独拆开看Stem、模块组、Reduction。2.1 Stem模块入口的下采样雕刻Stem可以理解成网络的预处理区任务是把299x299的原始输入经过几步卷积和池化快速压缩到35x35左右的空间分辨率同时把通道数扩到192或256。为什么要单独做Stem而不是直接堆Inception模块因为输入分辨率太高直接做多分支卷积计算量太浪费。先用串行的标准卷积把尺寸降下来后续Inception模块才能在比较小的特征图上发挥更好的性价比。Stem的基本套路是卷积-卷积-卷积-池化-卷积-卷积-池化这样交替前几层用简单的3x3卷积中间插入1x1卷积做通道调整最后用MaxPool把空间尺寸减半。v1和v2的Stem复杂度不同v2在细节上会多出一些卷积和通道扩展目的是在入口阶段保留更多信息毕竟v2后期要支撑更宽的网络。2.2 三种Inception-ResNet模块这是整个网络的核心。三种模块A、B、C对应特征图在35x35、17x17、8x8三个分辨率阶段使用的模块。它们的共同点是内部有多个不同尺度的卷积分支分支结果拼接后通过1x1卷积投影回输入通道数乘上缩放系数后与原输入相加。Inception-ResNet-A使用的分支比较简单包括1x1、3x3、以及连续两个3x3适合较大的特征图。B模块把大卷积核拆成非对称的1x7和7x1或者1x3、3x1等组合这种做法在Inception-v3里就有目的很明确在不减少感受野的前提下减少参数量。C模块用于最小的特征图卷积核进一步变小一般用1x3和3x1的组合因为此时空间信息已经很紧凑再追求大感受野意义不大。三种模块的骨架一致区别就在于分支内部卷几层、卷积核大小怎么组合、通道数怎么设置。所以写代码的时候最合理的做法是把它们设计成三个独立类方便各自调整而不是硬写成一个万能类。2.3 两种Reduction模块Reduction模块负责降分辨率。Reduction-A把输入从35x35降到17x17Reduction-B把17x17降到8x8。它们的设计思路和Inception模块有点相似也是多分支但目标是产生不同感受野下的下采样结果最后拼到一起。Reduction-A通常包含三条路一条MaxPool直接采样一条用stride2的3x3卷积一条用1x1降通道后接3x3、再接stride2的3x3覆盖更大感受野。Reduction-B的分支更多因为输入已经比较深需要更精细的下采样策略。写Reduction的时候最容易踩的坑是所有分支的输出尺寸没对齐。MaxPool和stride2卷积如果padding、stride取错分支之间的空间尺寸就会不一致torch.cat直接报错。这个问题我几乎每次都遇到下面单独列一节说。3. v1和v2到底差在哪儿很多人看论文时最关心的问题就是v1和v2到底哪里不一样我一开始也以为两者差不多后来发现差异主要体现在定位、宽度和训练策略上。3.1 计算量定位为什么有两个版本Inception-ResNet-v1的设计目标是控制在和Inception-v3相近的计算成本下利用残差连接获得更好的效果。Inception-ResNet-v2则追求更高的精度计算量向Inception-v4看齐网络各阶段都更宽更深。简单说如果你原来的实验条件能跑起Inception-v3那就拿v1来试试如果你的GPU资源充足直接上v2它通常能榨出更高的精度。当然这个成本和精度的取舍不是绝对的。在今天的算力条件下很多人直接用v2做特征提取或者用v2迁移到下游任务。v1更多被用在资源受限或者需要快速迭代的场景里。3.2 每个阶段的具体差异以我整理的配置为例两者的差异可以从这几张表感受一下位置Inception-ResNet-v1Inception-ResNet-v2Stem输出通道192256Inception-ResNet-A数量/输出通道5个 / 1925个 / 256Reduction-A后通道384896Inception-ResNet-B数量/输出通道10个 / 38410个 / 896Reduction-B后通道9601792Inception-ResNet-C数量/输出通道5个 / 9605个 / 1792从这里能看出v2几乎在每个阶段都比v1宽。模块数量没有变化都是A类5个、B类10个、C类5个这个数量关系在论文里是固定的。所以v2的计算量增长主要来自通道数翻倍而不是模块堆得更多。3.3 实际使用时怎么选其实没有绝对答案但我可以分享一些参考标准。如果只是做学习验证用v1就好参数少训练快代码也更轻。如果是做正式项目且有充足显存优先v2尤其是ImageNet规模的数据集v2的精度上限更高。做迁移学习时如果预训练模型是用ImageNet训练过的那v2的backbone特征往往更丰富。个人建议第一次复现这个网络先把v1跑通forward和反向都能过再考虑换成v2。上来就调v2的话尺寸不对或者显存爆了排查难度会大很多。4. Pytorch代码实现下面我给出按我之前说的结构整理的Pytorch实现。代码分几部分基础卷积块、Stem、三个Inception-ResNet模块、两个Reduction模块、整体网络。注释里会写清楚每层的作用和形状变化。4.1 基础Conv-BN-ReLU层几乎所有层级复用同一个基础卷积块。有一点要说明论文原始结构里很多分支内部没有紧跟BN而是只在最后投影时统一加。我在这里每个卷积都接BN因为实际训练中更稳收敛也快。如果你想完全复刻论文结构去掉分支内部的BN即可但效果未必更好。import torch import torch.nn as nn class BasicConv2d(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, stride1, padding0, activationTrue): super().__init__() self.conv nn.Conv2d( in_channels, out_channels, kernel_sizekernel_size, stridestride, paddingpadding, biasFalse, ) self.bn nn.BatchNorm2d(out_channels) self.activation activation self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.conv(x) x self.bn(x) if self.activation: x self.relu(x) return x这里设置activation参数是因为残差投影层的1x1卷积后面通常不接ReLU而是等相加之后再激活。所以在网络里看到activationFalse时它的作用就是纯线性投影把分支拼接后的通道数拉回输入通道数。4.2 Stem实现v1和v2的Stem我分别写了。v1比较朴素v2多加了几层让入口特征更丰富。两者的输出空间尺寸保持一致都是35x35只是通道数不同。class StemV1(nn.Module): def __init__(self, in_channels3): super().__init__() self.conv1 BasicConv2d(in_channels, 32, 3, stride2, padding0) self.conv2 BasicConv2d(32, 32, 3, stride1, padding0) self.conv3 BasicConv2d(32, 64, 3, stride1, padding1) self.pool1 nn.MaxPool2d(3, stride2, padding0) self.conv4 BasicConv2d(64, 80, 1, stride1, padding0) self.conv5 BasicConv2d(80, 192, 3, stride2, padding0) self.pool2 nn.MaxPool2d(3, stride2, padding0) def forward(self, x): x self.conv1(x) # [N, 32, 149, 149] x self.conv2(x) # [N, 32, 147, 147] x self.conv3(x) # [N, 64, 147, 147] x self.pool1(x) # [N, 64, 73, 73] x self.conv4(x) # [N, 80, 73, 73] x self.conv5(x) # [N, 192, 71, 71] x self.pool2(x) # [N, 192, 35, 35] return x class StemV2(nn.Module): def __init__(self, in_channels3): super().__init__() self.conv1 BasicConv2d(in_channels, 32, 3, stride2, padding0) self.conv2 BasicConv2d(32, 32, 3, stride1, padding0) self.conv3 BasicConv2d(32, 64, 3, stride1, padding1) self.pool1 nn.MaxPool2d(3, stride2, padding0) self.conv4 BasicConv2d(64, 80, 1, stride1, padding0) self.conv5 BasicConv2d(80, 192, 3, stride2, padding0) self.conv6 BasicConv2d(192, 256, 3, stride1, padding1) self.pool2 nn.MaxPool2d(3, stride2, padding0) def forward(self, x): x self.conv1(x) # [N, 32, 149, 149] x self.conv2(x) # [N, 32, 147, 147] x self.conv3(x) # [N, 64, 147, 147] x self.pool1(x) # [N, 64, 73, 73] x self.conv4(x) # [N, 80, 73, 73] x self.conv5(x) # [N, 192, 71, 71] x self.conv6(x) # [N, 256, 71, 71] x self.pool2(x) # [N, 256, 35, 35] return xv2的Stem这里做了一个调整在第二次下采样前加了一个3x3卷积把通道扩到256。这样做的好处是让网络入口阶段就保留更多信息但代价是显存和计算量变大。4.3 Inception-ResNet-A/B/C实现三个模块写起来很相似我直接按分支来组织。A模块使用1x1、3x3、连续两个3x3三种粒度的分支。B模块把3x3拆成1x77x1再用两个这样的非对称卷积组合成更深的路径。C模块更小用1x33x1。下面是A和B的代码示例C与B思路一致只是卷积核尺寸变化。class InceptionResNetA(nn.Module): def __init__(self, in_channels, hidden_channels32, out_channels192, scale0.1): super().__init__() self.branch1 BasicConv2d(in_channels, hidden_channels, 1, padding0) self.branch2 nn.Sequential( BasicConv2d(in_channels, hidden_channels, 1, padding0), BasicConv2d(hidden_channels, hidden_channels, 3, padding1), ) self.branch3 nn.Sequential( BasicConv2d(in_channels, hidden_channels, 1, padding0), BasicConv2d(hidden_channels, hidden_channels, 3, padding1), BasicConv2d(hidden_channels, hidden_channels, 3, padding1), ) self.proj BasicConv2d(hidden_channels * 3, out_channels, 1, padding0, activationFalse) self.scale scale self.relu nn.ReLU(inplaceTrue) def forward(self, x): b1 self.branch1(x) b2 self.branch2(x) b3 self.branch3(x) out torch.cat([b1, b2, b3], dim1) out self.proj(out) * self.scale return self.relu(x out) class InceptionResNetB(nn.Module): def __init__(self, in_channels, hidden_channels128, out_channels384, scale0.1): super().__init__() self.branch1 BasicConv2d(in_channels, hidden_channels, 1, padding0) self.branch2 nn.Sequential( BasicConv2d(in_channels, hidden_channels, 1, padding0), BasicConv2d(hidden_channels, hidden_channels, [1, 7], padding[0, 3]), BasicConv2d(hidden_channels, hidden_channels, [7, 1], padding[3, 0]), ) self.branch3 nn.Sequential( BasicConv2d(in_channels, hidden_channels, 1, padding0), BasicConv2d(hidden_channels, hidden_channels, [1, 7], padding[0, 3]), BasicConv2d(hidden_channels, hidden_channels, [7, 1], padding[3, 0]), BasicConv2d(hidden_channels, hidden_channels, [1, 7], padding[0, 3]), BasicConv2d(hidden_channels, hidden_channels, [7, 1], padding[3, 0]), ) self.proj BasicConv2d(hidden_channels * 3, out_channels, 1, padding0, activationFalse) self.scale scale self.relu nn.ReLU(inplaceTrue) def forward(self, x): b1 self.branch1(x) b2 self.branch2(x) b3 self.branch3(x) out torch.cat([b1, b2, b3], dim1) out self.proj(out) * self.scale return self.relu(x out)C模块只需要把B里的1x7/7x1换成1x3/3x1其余结构一模一样。这里有一个容易忽略的问题BasicConv2d的kernel_size是int时nn.Conv2d可以直接接受但如果是[1,7]这样的list也能解析成非对称卷积核。我在代码里直接传listPytorch是支持的。但要注意padding也必须是list并且分别对应两个方向否则卷积输出尺寸会偏。4.4 Reduction-A/B实现Reduction模块负责改变特征图尺寸。写之前必须算清楚每个分支输出后的长宽是否一致以及concat时通道数是否按照预期。class ReductionA(nn.Module): def __init__(self, in_channels, out_channels_pool, out_channels_branch2, out_channels_branch3_1, out_channels_branch3_2, out_channels_branch3_3): super().__init__() self.branch1 nn.MaxPool2d(3, stride2, padding0) self.branch2 BasicConv2d(in_channels, out_channels_branch2, 3, stride2, padding0) self.branch3 nn.Sequential( BasicConv2d(in_channels, out_channels_branch3_1, 1, padding0), BasicConv2d(out_channels_branch3_1, out_channels_branch3_2, 3, padding1), BasicConv2d(out_channels_branch3_2, out_channels_branch3_3, 3, stride2, padding0), ) def forward(self, x): b1 self.branch1(x) b2 self.branch2(x) b3 self.branch3(x) return torch.cat([b1, b2, b3], dim1)ReductionB类似但多一个分支具体通道数按之前的配置表来。注意MaxPool的输出通道和输入保持一致所以concat后的通道数就是输入通道 两个卷积分支输出通道之和。4.5 组装完整网络最后把模块串起来。我用一个基类InceptionResNet通过variant参数区分v1和v2。这样只用一份代码就可以实例化两个版本。class InceptionResNet(nn.Module): def __init__(self, variantv1, num_classes1000, dropout_rate0.8): super().__init__() assert variant in [v1, v2] if variant v1: stem_channels 192 stem StemV1() a_in, a_hidden, a_out 192, 32, 192 reda_pool 192 reda_b2 96 reda_b3 [64, 96, 96] b_in, b_hidden, b_out 384, 128, 384 redb_pool 384 redb_b2 192 redb_b3 [256, 256, 384] c_in, c_hidden, c_out 960, 256, 960 else: stem_channels 256 stem StemV2() a_in, a_hidden, a_out 256, 32, 256 reda_pool 256 reda_b2 256 reda_b3 [256, 384, 384] b_in, b_hidden, b_out 896, 256, 896 redb_pool 896 redb_b2 384 redb_b3 [384, 448, 512] c_in, c_hidden, c_out 1792, 256, 1792 self.stem stem # Inception-ResNet-A self.block_a nn.Sequential(*[ InceptionResNetA(a_in, a_hidden, a_out) for _ in range(5) ]) # Reduction-A self.reduction_a ReductionA( a_out, reda_pool, reda_b2, reda_b3[0], reda_b3[1], reda_b3[2] ) # Inception-ResNet-B self.block_b nn.Sequential(*[ InceptionResNetB(b_in, b_hidden, b_out) for _ in range(10) ]) # Reduction-B self.reduction_b ReductionA( b_out, redb_pool, redb_b2, redb_b3[0], redb_b3[1], redb_b3[2] ) # Inception-ResNet-C self.block_c nn.Sequential(*[ InceptionResNetC(c_in, c_hidden, c_out) for _ in range(5) ]) self.avg_pool nn.AdaptiveAvgPool2d((1, 1)) self.dropout nn.Dropout(dropout_rate) self.fc nn.Linear(c_out, num_classes) for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) def forward(self, x): x self.stem(x) x self.block_a(x) x self.reduction_a(x) x self.block_b(x) x self.reduction_b(x) x self.block_c(x) x self.avg_pool(x) x x.view(x.size(0), -1) x self.dropout(x) x self.fc(x) return x这里有一个小细节ReductionB我用的是ReductionA类因为ReductionA类本身只是三个分支concat这一通用结构。如果ReductionB多一个分支你需要新增一个ReductionB类把第四个分支加进去。我上面的代码为了简洁把ReductionB按ReductionA的结构写了如果按论文原版这里应该还有一个分支通道数拼接结果不同。实际复现时请对照你自己的配置表决定是否添加。初始化部分kaiming_normal_是Conv2d常用初始化BN的gamma初始化为1、beta初始化为0。这能让网络训练初期更快稳定下来。5. 实际训练与踩坑记录代码能跑只是第一步真正训练的时候还有一堆问题。我把复现时遇到比较高频的问题整理成了一张速查表顺便给点排查建议。问题现象原因处理方式forward时报尺寸不匹配某分支padding或stride设置错把每个分支的tensor shape逐个print出来人工检查concat时通道维度对不上Reduction输出通道没算对对照配置表重算分支输出或统一用变量传递训练loss不下降学习率过大/过小或scale设置太激进先用较小输入和单batch验证过拟合再调整scale和lr显存爆掉v2通道数太多或batch设太大降低batch、用gradient accumulation或换输入尺寸如224测试效果一般没做数据增强、没加载预训练权重分类任务建议加载ImageNet预训练或用合适的augmentation5.1 尺寸计算与排查技巧最让我花时间的其实是各层的输出尺寸。对于一个输入299x299的tensorStem部分几次卷积和池化之后尺寸变成35x35如果有一步padding少算了后面所有特征图都错位。Quick Fix的办法是写一个小脚本把每个模块的输入输出shape都打印出来一跑就能定位到哪一层算错了。一个实用公式输出尺寸 (输入尺寸 2 * padding - kernel_size) / stride 1。所有分支都按这个公式算基本不会出错。5.2 输入尺寸和预处理Inception-ResNet原始训练用的是299x299的输入这点和ResNet的224x224不一样。如果你直接拿224x224的数据来训练Stem部分第一次池化后尺寸可能不是35x35而是更小的形状后面的Reduction模块会报错。最简单的办法是用interpolate把输入缩放或者干脆按299x299进行train/val预处理。5.3 残差缩放因子的调整实验里我发现当scale从0.1改成0.3时loss曲线前期会明显更陡但震荡也更大。如果数据量少还是老实保持0.1。如果你要做更细的调参可以把scale做成可学习的参数或者跟随训练epoch数做warmup不过这会增加不少复杂度普通场景不推荐。6. 写在最后对我个人来说Inception-ResNet最大的学习价值不只是又多了一个能用的分类网络而是它展示了两个完全不同的架构思想如何优雅地融合。Inception强调在同一层用多尺度感受野扩展表达能力ResNet则通过残差连接让深层网络更容易优化。两者一结合训练稳定性和表达上限都得到了提升这个设计思路放到今天的很多网络里仍然能看到影子。如果你打算把这份代码用到自己的项目里建议还是自己把forward的每个shape过一遍不要直接无脑跑。尤其Reduction部分如果不理解每个分支为什么会输出那个尺寸后面改输入大小或调结构时会很痛苦。文章里的代码是我按教学目的整理的完整可运行版本和论文原始配置可能有一点点差异但它非常适合作为你继续魔改的底子。最后再分享一个小技巧复现任何论文网络时先写一个最小可运行版本把网络搭好之后立刻用一张随机输入验证forward和反向。尺寸错了、维度错了都能在几秒钟内暴露出来不用等整个训练流程走完才发现问题。我就是靠这个习惯省下了大量debug时间。
返回列表