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中卷积模块的完整流程展示出来。从输入到输出,每一步都标清楚了。

MobileViT卷积模块结构图 输入特征图 (H×W×C) 1×1 卷积 (升维, 扩张倍率6) 3×3 深度可分离卷积 + BN + ReLU6 1×1 卷积 (降维, 线性激活) 步长=1且C_in=C_out? 是 否 输出特征图 (H×W×C) 残差连接 (仅当步长=1且C_in=C_out)

这张图把整个流程串起来了。你仔细看,从输入到输出,经历了升维 → 深度可分离卷积 → 降维三步。最后根据步长和通道数,决定是否加残差连接。

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控制扩张倍率,默认6
  • groups=hidden_dim实现深度可分离卷积
  • 降维的1×1卷积后面没有激活函数,这就是线性激活
  • use_res_connect判断是否加残差连接

小技巧:如果你在训练时发现梯度爆炸,可以试试在残差连接前加一个0.1的缩放因子。我试过,对稳定性有帮助。

好了,这一章的内容就到这里。卷积模块是MobileViT的骨架,理解透了,后面的Transformer部分才能发挥出真正的威力。记住,轻量化不是偷工减料,而是每一处设计都有它的道理。

可可精品资料库