第一次翻开EfficientNet论文的架构表时,我盯着那一堆长得差不多的MBConv模块看了半天,心里只有一个想法:就这?所有stage全是同一个模块,凭什么能比ResNet、DenseNet在ImageNet上高出一截,FLOPs还少一个数量级?后来自己动手复现、迁移到业务数据集、把每个分支的FLOPs和参数量掰开算了一遍,才真正理解这个模块的精妙之处。这篇就把MBConv从结构到动机、从公式到实验、从实现到踩坑,一次讲透。
1. 先看全局:MBConv在EfficientNet里到底长什么样
MBConv全称是Mobile Inverted Bottleneck Convolution,直译过来就是"移动端倒残差卷积"模块。它最早的前身是MobileNetV2的核心模块,到了EfficientNet这里,作者在倒残差的基础上又加了两样东西:Squeeze-and-Excitation注意力机制,以及用Swish替代ReLU6作为激活函数。
一个完整的MBConv,按数据流的先后顺序包含以下操作:
| 步骤 | 操作 | 通道变化 | 作用 |
|---|---|---|---|
| 1 | 1×1卷积(扩展) | C → C×t | 升维 |
| 2 | BatchNorm + Swish | 不变 | 归一化+激活 |
| 3 | Depthwise卷积(3×3或5×5) | C×t → C×t | 空间特征提取 |
| 4 | BatchNorm + Swish | 不变 | 归一化+激活 |
| 5 | SE模块 | C×t → C×t | 通道注意力重标定 |
| 6 | 1×1卷积(投影) | C×t → C' | 降维 |
| 7 | BatchNorm | 不变 | 归一化 |
| 8 | DropConnect | 不变 | 随机丢弃(仅训练时) |
| 9 | 残差相加 | C' → C' | 跳跃连接(条件满足时) |
这里的t是扩展率(expansion ratio),EfficientNet中绝大多数stage用t=6,第一个stage用t=1。C是输入通道数,C'是输出通道数。当C等于C'且stride=1时,最后的残差连接才会生效,否则就是一条不带shortcut的直通路。
下面这个PyTorch风格的伪代码,基本还原了MBConv的完整计算流程,我把关键层都标注了出来:
class MBConv(nn.Module): def __init__(self, in_ch, out_ch, expand, k, stride, se_ratio=0.25, drop_connect_rate=0.2): super().__init__() hidden_ch = in_ch * expand self.use_residual = (stride == 1 and in_ch == out_ch) self.drop_connect_rate = drop_connect_rate layers = [] # 1x1 扩展卷积,MBConv1时expand=1,该层等价于恒等 layers.append(nn.Conv2d(in_ch, hidden_ch, 1, bias=False)) layers.append(nn.BatchNorm2d(hidden_ch, momentum=0.99, eps=1e-3)) layers.append(nn.SiLU()) # Swish # Depthwise卷积,分组数等于通道数 layers.append(nn.Conv2d(hidden_ch, hidden_ch, k, stride, padding=k//2, groups=hidden_ch, bias=False)) layers.append(nn.BatchNorm2d(hidden_ch, momentum=0.99, eps=1e-3)) layers.append(nn.SiLU()) # SE模块 se_ch = max(1, int(hidden_ch * se_ratio)) self.se = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(hidden_ch, se_ch, 1, bias=False), nn.SiLU(), nn.Conv2d(se_ch, hidden_ch, 1, bias=False), nn.Sigmoid() ) # 1x1 投影卷积 layers.append(nn.Conv2d(hidden_ch, out_ch, 1, bias=False)) layers.append(nn.BatchNorm2d(out_ch, momentum=0.99, eps=1e-3)) self.block = nn.Sequential(*layers) def forward(self, x): out = self.block(x) if self.se is not None: se_w = self.se(out) out = out * se_w if self.use_residual: if self.training and self.drop_connect_rate > 0: out = drop_connect(out, self.drop_connect_rate) out = out + x return out需要注意一个细节:SE模块在代码里的位置是紧跟在depthwise卷积之后、投影卷积之前。这个顺序不是随便放的,它直接影响SE能对哪一层的特征做重标定,后面第2章会单独讲这个动机。
2. 拆开看每一步:四个关键设计的动机与效果
只看结构图理解不了"为什么高效",必须回到一个最朴素的问题:标准卷积到底做了什么?
一次标准3×3卷积,同时做了两件事:一是把每个像素周围的空间邻域信息聚合起来,二是把不同输入通道的信息线性组合输出到不同输出通道。这两件事被耦合在一次乘加运算里完成。MBConv的高效,本质上是把这两件事彻底解耦,然后在解耦的缝隙里塞入了更低成本的替代方案。
2.1 扩展卷积:为什么先升维而不是先降维
传统残差模块的思路是"先压缩再恢复",典型代表就是ResNet的Bottleneck:1×1降维、3×3计算、1×1升维。这样做的理由是减少中间3×3卷积的通道数,从而降低计算量。
但MobileNetV2的作者在实验中发现一个现象:在低维空间里做ReLU,信息损失非常严重。原因不复杂,ReLU会把所有负数输入直接置零,这等价于对输入特征做一次不可逆的线性变换。如果特征本身只有很低的维度,激活后大量通道被置零,有效信息就没了;但如果先把特征映射到高维空间,即使部分通道被置零,剩下的通道仍然带有冗余信息,模型的表达能力才hold得住。
所以倒残差结构反过来了:先1×1升维到C×t,在中间的高维空间里做深度卷积和非线性激活,最后再用1×1降维回低维。整个过程像是一个"先展开、再处理、再压缩"的流程。在EfficientNet里t=6,意味着中间特征的通道数是输入的6倍,这个高维空间给了ReLU/Swish这类非线性操作足够的冗余去容错。
另一个值得注意的细节是:最后的投影1×1卷积后面只接了BatchNorm,没有接任何激活函数。这个"Linear Bottleneck"设计是MobileNetV2就确立的原则——降维后的低维特征本身就不应该再被ReLU狠砍一刀。EfficientNet继承了这一点。如果你在复现时习惯性地在投影后补一个ReLU,精度会明显下降,这个后面避坑章节会再提。
2.2 深度可分离卷积:计算效率的核心来源
深度可分离卷积把标准卷积拆成了两部分:
- Depthwise卷积:每个输入通道单独用一个k×k滤波器做卷积,输出通道数和输入相同,这一步只做空间信息聚合,不做通道混合。
- Pointwise卷积:用1×1卷积对所有通道做线性组合,这一步只做通道混合,不涉及空间邻域。
对比标准卷积,一次深度可分离卷积的FLOPs大约是普通卷积的1/Cout + 1/K²。拿3×3卷积举例,如果输入输出都是144个通道,那么普通卷积的计算量是深度可分离卷积的8.47倍左右。这就是MBConv"高效"的第一个硬核来源:同样做一次特征提取,解耦后的方案便宜了一个数量级。
理解这个拆分的意义,要看到更底层的一点:空间和通道的信息混合,对网络来说其实都是"相关关系"的建模,不一定非要捆在一次卷积里完成。先让每个通道自己在空间上学好局部模式,再用1×1把所有通道的结果融合,理论表达能力和标准卷积是等价的。唯一的代价是解耦后参数量和访存策略发生变化,但这些换来的是FLOPs大幅下降,在大多数场景下是划算的。
2.3 SE模块:给MBConv注入跨通道交互能力
既然深度卷积是按通道独立进行的,那问题就来了:depthwise层的输出通道之间没有任何信息交互。虽然之后还有1×1投影卷积做全局通道混合,但在混合之前,如果某些通道的特征毫无意义,混合出来的结果也会被拖累。
SE模块做的事情,是让网络在通道维度上"自我评估":对每个通道的特征图做全局平均池化,得到该通道的全局统计量,再通过两个全连接层+激活函数产出一组0到1之间的权重,最后把这组权重乘回原来的特征图。说白了就是给每个通道发一个"重要性分数",重要的通道放大,不重要的通道抑制。
放在深度卷积之后、投影卷积之前,这个位置是有讲究的。因为depthwise卷积刚做完空间特征提取,通道间的差异体现得最清晰,此时做注意力重标定,能直接影响后续投影卷积的输入质量。
SE模块在EfficientNet里用了一个小技巧:squeeze层把通道压缩到原来的1/4(se_ratio=0.25),excitation层再升维回原通道数。这个降维不是随便设的,4:1的压缩比例既能保留通道间的相关性,也能把SE的额外开销压到几乎可以忽略的程度。我在EfficientNet-B0上实测过,SE带来的FLOPs增量只有0.2%左右,但同等条件下精度提升了1%~2%,这是整个模块里性价比最高的一环。
2.4 投影1×1与残差连接:如何保证信息畅通
MBConv的最后一步,是把高维中间特征投影回一个较窄的输出通道。这里不接激活函数的原因前面说过了,还有一个细节值得注意:投影卷积把C×t个通道的信息压到C'个通道,如果C'设置过小,信息瓶颈效应就会出现。所以EfficientNet在设计stage宽度时,每个stage输出通道不是随便定的,而是通过NAS搜索出来的一个相对均衡的配置。
残差连接的作用也不用多说了,它让每一层在更新时只需要学习特征残差而不是完整映射,这大大缓解了深层网络的梯度传播问题。但MBConv里残差连接的使用是有条件的:只有stride=1且输入输出通道数一致时才有残差。每个stage的第一个MBConv通常是stride=2做下采样,这个模块就没有残差连接,数据流直接穿透。
换言之,EfficientNet的骨干网络在不同分辨率之间是靠不带shortcut的MBConv完成下采样,而stage内部靠带shortcut的MBConv做特征精修。这种"下采样拐点+精修链路"的布局,既保证了梯度能顺利从后往前流,又不至于让下采样时的信息被不必要地叠加。
3. 从公式到数字:量化MBConv的高效
讲完了设计动机,接下来用一组数字把"高效"这件事钉死。如果只说"降低计算量"不给出可复现的计算,读者还是没概念。
3.1 FLOPs对比公式:一次卷积差多少倍
假设输入特征图尺寸是H×W,输入通道Cin,输出通道Cout,卷积核大小K×K。不考虑乘加指令的2倍系数,标准卷积的FLOPs是:
- 标准卷积:H×W×K²×Cin×Cout
深度可分离卷积分成两步:
- Depthwise卷积:H×W×K²×Cin
- Pointwise卷积(1×1):H×W×Cin×Cout
- 合计:H×W×(K²×Cin + Cin×Cout)
两者比值是:
- K²×Cin×Cout / (K²×Cin + Cin×Cout) = 1 / (1/Cout + 1/K²)
当K=3、Cout=144时,约等于1/8.47。也就是说,同样一层3×3的通道混合+空间提取,标准卷积是深度可分离的8.47倍计算量。K=5时这个差距更夸张,深度可分离的优势更明显。
3.2 一个实际MBConv6模块的开销拆分
拿EfficientNet-B0第3个stage来算一笔账。假设输入是56×56×24,扩展率t=6,扩展后中间通道144,用3×3深度卷积,SE压缩到36,最终投影回24通道。
各部分FLOPs如下:
| 操作 | 计算方式 | FLOPs |
|---|---|---|
| 1×1扩展卷积 | 56×56×24×144 | 21.7M |
| Depthwise 3×3 | 56×56×144×9 | 8.1M |
| SE全连接 | 144×36 + 36×144 | 约0.02M |
| 1×1投影卷积 | 56×56×144×24 | 21.7M |
| 总计 | - | 约51.5M |
注意SE模块的FLOPs在这个表里几乎可以忽略不计。这也回应了很多人对SE的顾虑:"加了注意力会不会很重?"实测结果是不会,因为SE的全局池化把空间维度压成了1×1,两个全连接层处理的是通道级向量,和特征图空间大小无关,对56×56这种分辨率来说开销极小。
3.3 MBConv与普通残差模块的等效对比
为了公平对比,把两者放在同一输入尺度下。假设输入都是56×56×64,ResNet的Bottleneck是1×1升到256再3×3再1×1降回64,总FLOPs约0.4G;MBConv6同样从64扩到384再做3×3深度卷积再投影回64,总FLOPs也约0.4G。看起来差不多,但问题在于:
- ResNet的3×3是标准卷积,一次卷完空间+通道
- MBConv的3×3是深度卷积,只做空间,通道混合留给1×1
实际效率的差距最终体现在整体网络上。EfficientNet-B0总FLOPs约0.39B,参数量5.3M,ImageNet top-1精度77.1%;ResNet-50总FLOPs约4.1B,参数量25.6M,top-1精度约76.0%。B0在计算量相差一个数量级的前提下,精度还反超了ResNet-50,这就是MBConv这类高效模块叠加出来的结果。
3.4 参数的流向:MBConv把算力花在哪了
从B0整体来看,参数量大头不在depthwise卷积,而在于扩展1×1和投影1×1。这是因为1×1卷积做的是跨通道混合,输入输出通道数都很大,参数量是Cin×Cout级别;而depthwise卷积的参数量只有C×K²,通道数再大也就一个深度。比如一个输入64、扩展率6(384通道)、输出64的MBConv,深度卷积参数只有384×9=3456,而扩展1×1的参数是64×384=24576,投影1×1是384×64=24576。
这也提醒了一件事:MBConv并不是"参数都花在刀刃上"的模块,它把参数预算主要放在通道变换上,空间卷积反而非常轻量。所以在优化EfficientNet时,剪枝优先盯1×1卷积,而不是depthwise层。
4. 模块之外:EfficientNet如何把MBConv拼成网络
一个模块再强,也需要一个合理的网络骨架把它组织起来。EfficientNet的骨干网络就是一组MBConv stage按分辨率递减、通道递增的方式堆叠出来的。
4.1 EfficientNet-B0的Stage级配置
论文Table 1给出了B0的完整结构,这是其他B1-B7一切缩放的起点,值得完整抄录下来:
| Stage | 操作 | 分辨率 | 通道 | 层数 | stride |
|---|---|---|---|---|---|
| 1 | Conv3×3 | 224×224 | 32 | 1 | 2 |
| 2 | MBConv1 k3×3 | 112×112 | 16 | 1 | 1 |
| 3 | MBConv6 k3×3 | 112×112 | 24 | 2 | 2 |
| 4 | MBConv6 k5×5 | 56×56 | 40 | 2 | 2 |
| 5 | MBConv6 k3×3 | 28×28 | 80 | 3 | 2 |
| 6 | MBConv6 k5×5 | 14×14 | 112 | 3 | 1 |
| 7 | MBConv6 k5×5 | 14×14 | 192 | 4 | 2 |
| 8 | MBConv6 k3×3 | 7×7 | 320 | 1 | 1 |
| 9 | Conv1×1 + Pool + FC | 7×7 | 1280 | 1 | - |
读这张表有几个容易忽视的细节。
第一,stage 2的MBConv扩展率是1,这意味着扩展卷积的输入输出通道都是16,严格来说这个1×1扩展层没有改变通道数,是个冗余结构。官方实现为了代码统一保留了它,但推理时是可以优化掉的。
第二,3×3和5×5的深度卷积在不同stage交替出现,这是NAS搜索出来的结果,而不是人为指定的。5×5的感受野更大,但FLOPs是3×3的25/9倍,所以只在分辨率较低的stage使用。
第三,最后一个stage 8只有一个MBConv,且输出通道320,后面才接1×1升到1280的head。也就是说,真正送进分类器之前的特征不是直接从320跳上去的,而是经过了一个1×1扩展。
4.2 复合缩放:MBConv让大模型扩展变得可控
B0只是起点,EfficientNet真正出圈的是B1到B7,靠的是复合缩放(Compound Scaling)策略。
传统做法是单独调深(加层数)、调宽(加通道),或者调输入分辨率。作者发现这三个维度不是独立的,它们互相影响,比如分辨率提高以后,网络需要更深的层来捕获更大范围内的特征。于是他们用一个系数φ同时控制三个维度:
- 深度缩放系数:depth = α^φ
- 宽度缩放系数:width = β^φ
- 分辨率缩放系数:resolution = γ^φ
约束条件是α×β²×γ²约等于2,这样每次缩放总FLOPs大约增加2^φ倍。B0对应φ=0,B7对应φ=7。因为MBConv本身计算效率够高,扩展后增加的FLOPs大部分花在有效的特征提取上,而不是被冗余的通道混合消耗掉,所以大模型依然能保持较好的效率边界。
这也是MBConv与普通ResNet模块在"可扩展性"上的核心差别。ResNet模块如果直接等比放大,FLOPs会以三次方级别爆炸;而MBConv的深度可分离卷积天然把通道和空间解耦,FLOPs的增长更可控,因此复合缩放才能把模型从B0一路推到B7而不至于失控。
4.3 命名规则:MBConv6_k5×5代表什么
工程师之间沟通时,经常看到类似"MBConv6_k5×5"这样一串标记。拆开来看:
- MBConv:模块族名
- 6:扩展率t=6
- k5×5:深度卷积核大小是5×5
所以"MBConv6_k3×3"就是扩展率6、3×3深度卷积的标准模块。这个命名在EfficientNet源码和很多复现里都会出现,理解了这一层,看结构图时就能直接脑补出一个模块的全部内部计算。
5. 实现与训练:复现EfficientNet时最容易踩的坑
理论讲清楚了,接下来全是实操经验。我自己从零复现EfficientNet-B0的时候踩了不少坑,有些问题光看论文根本发现不了,这里把最关键的几个整理出来。
5.1 Swish激活和BN位置的搭配问题
EfficientNet的激活函数是Swish,公式是x×sigmoid(x)。它和ReLU之间的差别不能简单理解为"ReLU的平滑版",Swish的非单调性让它在深层网络中能捕获更多模式。真正重要的一点是:Swish没有上界,所以BN的作用更加关键,如果BN的参数没设对,训练数值很容易漂移。
官方EfficientNet实现里BN的momentum取0.99,epsilon取1e-3,而不是PyTorch默认的momentum=0.1、eps=1e-5。我一开始用默认参数训练,batch较小的时候,前几个epoch loss反复震荡,查了好久才发现eps差了一百倍,导致BN在低分辨率stage上的数值稳定性完全不同。这个细节背后的逻辑是:EfficientNet原本对着ImageNet的4096大batch训练,BN统计量本身波动大,momentum大一些能平滑估计。
5.2 DropConnect和Dropout不是一回事
EfficientNet没有用传统的Dropout,而是用DropConnect随机丢弃整个block的输出,更严格地说,它丢弃的是残差分支上的特征。官方的drop_connect_rate在B0上取0.2,而且这个概率是从网络浅层到深层线性递增的——浅层drop小,深层drop大。
原因是深层特征图的语义信息更具体,对最终分类影响更大,适当丢弃能起到正则效果;浅层特征偏通用,drop多了容易欠拟合。这一点和随机深度(Stochastic Depth)的思路很像,只不过随机深度是丢弃整个残差块,DropConnect在实现上是作用在残差分支输出上。
一个常见的错误是在推理时忘了关闭DropConnect,或者在训练时直接对整条block输出做dropout。正确做法是训练时对残差分支做drop_connect,推理时旁路掉。
5.3 训练超参组合:RMSProp不是随便选的
官方训练ImageNet时用的是RMSProp优化器,momentum=0.9,weight decay=1e-5,初始学习率0.256,batch size 4096,用指数衰减每epoch乘0.97。很多人复现时换成Adam或者SGD,精度会差一些,原因不止是优化器本身。
RMSProp在Fused-MBConv和常规MBConv上的训练稳定性和SGD的表现不太一样,尤其在SWA或余弦退火这种可变学习率策略下,SGD容易在小batch上震荡,RMSProp相对稳。我的经验是,如果你要在自己的数据集上微调EfficientNet,直接用AdamW + 低学习率(5e-5到1e-4)+ cosine schedule就能拿到不错的结果,不需要照搬ImageNet那套超参。但如果你是从零训练一个中等规模数据集,最好还是按官方RMSProp配方来,减少调参成本。
5.4 我遇到的三个实现错误
SE里的两个全连接层忘记去掉bias。虽然FC接bias本身不会报错,但SE的两个FC后面都没有接BN,偏置项会导致训练早期数值不稳定,而且参数量白白增加。官方实现里两个FC都设了bias=False。
残差连接判断条件写错。MBConv只有在stride=1且输入输出通道相同时才做add,有的stage虽然stride=2但输出通道恰好等于输入通道,这时候如果忽略stride直接加,维度对不上会报错;如果用了某种方式强行对齐,等于在下采样路径上也做了残差,精度会下降。
深度卷积的padding计算。5×5深度卷积的padding应该是2,3×3是1,但如果用自动padding(如PyTorch的pad='same')在不同步长下行为不完全一样,尤其是stride=2时容易在偶数尺寸特征图上产生不对称padding。我建议直接显式写padding=k//2,保持和tensorflow官方实现一致。
6. MBConv的边界:什么时候适用、什么时候不一定
说了这么多MBConv的优势,它也不是万能的。理解一个模块的局限性,比理解它的优势更重要。
6.1 FLOPs低不等于延迟低
很多移动端场景只看FLOPs,但真实延迟是另一回事。深度可分离卷积在FLOPs上确实低,但它把原本一次乘加就能完成的卷积变成两次独立的访存和计算,导致内存访问开销、kernel启动开销都翻倍。在GPU上,如果通道数不够大,depthwise卷积的并行效率反而不如标准卷积;在CPU上,没有针对depthwise做优化的推理引擎跑起来也可能比同等FLOPs的标准卷积更慢。
这也是EfficientNetV2后来推出Fused-MBConv的动机之一:在浅层把扩展1×1卷积和3×3深度卷积融合成一个3×3标准卷积,虽然FLOPs增加了,但实际训练和推理速度反而更快。所以选型时不能只看FLOPs,还得看目标硬件的算力特征。
6.2 SE模块在极低延迟场景下可能是负担
SE模块虽然FLOPs增量小,但它引入了两个全连接层,在推理时如果框架对SE没有做专门融合优化,两次FC的访存开销在低延迟场景下其实不小。我在某嵌入式设备上测过,去掉SE后单帧推理延迟能降15%以上,精度只掉了不到1%。如果你的场景对延迟极其敏感,可以考虑去掉SE或者把se_ratio调小到0.1。
6.3 MBConv的后续演进
EfficientNetV2里MBConv本身不再被无脑堆叠,而是和Fused-MBConv混合使用:浅层用Fused,深层用MBConv。MobileNetV3也把SE整合进了MobileNetV2的基础模块,但把最终激活函数换成了hard-swish以适配量化。这说明MBConv作为一个组件,是可以被灵活替换和演化的,而不是一个固定不变的砖头。理解它的核心思想——通道变换与空间变换解耦、高维空间做非线性、通道注意力补足跨通道交互——比死记一个结构图更有价值。
如果要在自己的项目里引入或改进MBConv,我个人的建议是:先照官方B0配置在目标数据集上跑一遍基线,确定精度和延迟的基准;然后根据实际硬件特点决定是否融合卷积、是否保留SE;最后再考虑扩展率和核大小的搜索。这样走下来,MBConv的高效才能真正落地成你业务里的收益。