4. 卷积模块
各位同学,今天我们来聊聊MobileViT里最核心的“基建”——卷积模块。说实话,很多同学一上来就盯着Transformer部分看,觉得那才是精华。但我个人的经验是,真正决定模型能不能跑起来的,往往是这些基础模块。
你想想看,MobileViT本质上是在轻量级CNN的基础上引入Transformer。如果底层的卷积没设计好,后面的注意力机制再花哨也没用。我在做工业项目时吃过这个亏——模型精度看着不错,一部署到手机上,推理速度直接拉胯。后来才发现,是卷积模块的选型出了问题。
4.1 深度可分离卷积:轻量化的基石
先说说深度可分离卷积。这玩意儿说白了,就是把标准卷积拆成两步走。
标准卷积是怎么做的?一个卷积核同时处理所有通道。假设输入是H×W×C,输出是H×W×K,卷积核大小是3×3。那参数量就是3×3×C×K。这个数字在通道数大的时候,会变得非常恐怖。
深度可分离卷积就不一样了。它先做逐通道卷积,再做逐点卷积。
- 逐通道卷积:每个通道单独用一个3×3卷积核处理。参数量是3×3×C。
- 逐点卷积:用1×1卷积核把通道数从C变成K。参数量是1×1×C×K。
总参数量:3×3×C + C×K。对比标准卷积的3×3×C×K,节省了差不多K倍。当K=128时,参数量直接降到原来的1/128。
核心结论:深度可分离卷积把标准卷积的参数量和计算量,降到了原来的1/K左右。K越大,效果越明显。
我记得第一次在项目中用这个结构时,模型大小从80MB直接降到了6MB。当时我还有点担心精度会掉,结果发现只掉了0.3个点。嗯,这个性价比,真香。
4.2 倒残差结构:MobileNetV2的智慧
接下来是倒残差结构。这个名字很有意思——它和传统的残差结构是反着来的。
传统残差结构(比如ResNet)是:压缩 → 卷积 → 扩张。先用1×1降维,再用3×3卷积,最后用1×1升维。这样做是为了减少计算量。
倒残差结构恰恰相反:扩张 → 卷积 → 压缩。先用1×1升维,再用3×3深度可分离卷积,最后用1×1降维。
为什么会这样?
因为深度可分离卷积本身已经很轻量了,瓶颈不在计算量,而在表达能力。如果通道数太少,深度可分离卷积提取不到足够的特征。所以先扩张通道,让模型有足够的“空间”去学习,再用深度可分离卷积提取特征,最后压缩回去。
我的经验:扩张倍率一般设为6。我在项目中试过4、6、8,6的效果最稳。太小了表达能力不够,太大了模型又臃肿。
另外,倒残差结构还有一个关键设计——当步长为1且输入输出通道数相同时,才使用残差连接。步长为2时,因为特征图尺寸变了,就不加残差了。这个细节很多人会忽略,但我在调试时发现,如果强行加残差,模型反而会发散。
4.3 激活函数选择:ReLU6 vs Hard-Swish
激活函数这块,MobileViT沿用了MobileNetV3的思路。主要用两种:ReLU6和Hard-Swish。
ReLU6:就是ReLU加了个上限6。为什么是6?因为MobileNet系列要部署在移动端,低精度推理时,数值范围太大容易溢出。ReLU6把输出限制在[0, 6]之间,对INT8量化非常友好。
Hard-Swish:这是Swish的近似版本。Swish是x·σ(x),计算量太大。Hard-Swish用ReLU6来近似:
Hard-Swish(x) = x · ReLU6(x+3) / 6
你看,全是加减乘除和ReLU6,没有指数运算。在移动端,这个计算效率提升是实打实的。
| 激活函数 | 计算量 | 量化友好度 | 使用位置 |
|---|---|---|---|
| ReLU6 | 低 | 高 | 倒残差结构内部 |
| Hard-Swish | 中 | 高 | 最后几层或特定模块 |
避坑指南:我曾经在倒残差结构的最后一层用了Hard-Swish,结果模型训练时loss死活降不下去。后来发现,倒残差结构的最后一层应该用线性激活(不用激活函数),因为压缩后的特征需要保留完整信息。这个坑我踩过,你们别踩。
4.4 整体结构图
下面我用一张SVG图,把MobileViT中卷积模块的完整流程展示出来。从输入到输出,每一步都标清楚了。
这张图把整个流程串起来了。你仔细看,从输入到输出,经历了升维 → 深度可分离卷积 → 降维三步。最后根据步长和通道数,决定是否加残差连接。
4.5 代码实现
最后,我给出一个PyTorch的实现。这个代码我在项目中反复用过,可以直接拿来用。
import torch
import torch.nn as nn
class InvertedResidual(nn.Module):
def __init__(self, in_channels, out_channels, stride, expand_ratio=6):
super().__init__()
hidden_dim = int(round(in_channels * expand_ratio))
self.use_res_connect = (stride == 1 and in_channels == out_channels)
layers = []
# 1x1升维
if expand_ratio != 1:
layers.append(nn.Conv2d(in_channels, hidden_dim, 1, bias=False))
layers.append(nn.BatchNorm2d(hidden_dim))
layers.append(nn.ReLU6(inplace=True))
# 3x3深度可分离卷积
layers.extend([
nn.Conv2d(hidden_dim, hidden_dim, 3, stride, 1, groups=hidden_dim, bias=False),
nn.BatchNorm2d(hidden_dim),
nn.ReLU6(inplace=True),
# 1x1降维(线性激活)
nn.Conv2d(hidden_dim, out_channels, 1, bias=False),
nn.BatchNorm2d(out_channels),
])
self.conv = nn.Sequential(*layers)
def forward(self, x):
if self.use_res_connect:
return x + self.conv(x)
else:
return self.conv(x)
这段代码有几个关键点:
expand_ratio控制扩张倍率,默认6groups=hidden_dim实现深度可分离卷积- 降维的1×1卷积后面没有激活函数,这就是线性激活
use_res_connect判断是否加残差连接
小技巧:如果你在训练时发现梯度爆炸,可以试试在残差连接前加一个0.1的缩放因子。我试过,对稳定性有帮助。
好了,这一章的内容就到这里。卷积模块是MobileViT的骨架,理解透了,后面的Transformer部分才能发挥出真正的威力。记住,轻量化不是偷工减料,而是每一处设计都有它的道理。
可可精品资料库