论文 PP-PicoDet: A Better Real-Time Object Detector on Mobile Devices
paddleDetection代码:picodet
PP-PicoDet 是百度提出的移动端友好和高精度Anchor-Free 目标检测算法,实测性能非常优越。关于模型的配置与训练的解读可以参考https://www.guyuehome.com/36666。

本文将详解 picodet 的 backbone——Enhanced ShuffleNet (ESNet)。
 
注:paddleDetection 已更新至2.4 版本,picodet增强版 将 backbone 从 ESnet 换成了 LCNet。本文仍解读初始的backbone ESnet。LCNet 将在新的文章中解读。

ESNet

ESNet,即 Enhanced ShuffleNet 是在 ShuffleleNetV2 的基础上改进的一个移动端友好的轻量级网络。下图详细描述了ESNet的ES模块。


图中 (a), (b)描述了 ESnet 的两个基本模块,对应代码中的 InvertedResidualDS 类和 InvertedResidual类,整个 ESnet 主要就是由这两个模块堆叠而成的。从图中我们可以看到,这两个基本模块由更基础的深度可分离卷积(pw conv,dw conv)、Ghost blockSE blockchannel shuffle 模块组成。下面将对各基础模块的原理进行解读。

1. 深度可分离卷积

参考:卷积神经网络中的Separable Convolution
Depthwise Separable Convolution 是谷歌2017 年在 Xception 中提出的, 用于减少网络参数,提升计算效率。它的核心思想是将一个完整的卷积运算分解为两步进行,分别为 Depthwise Convolution 与 Pointwise Convolution。

常规的卷积操作

假设输入为一个 64 \times 64\times 3 的图片,经过 4 个的3 \times 3 \times 3 卷积核,输出 4 个 Feature Map,整个计算过程可以用下图来概括:

此时,卷积层的参数量为 3 \times 3 \times 3 \times 4 = 108.

Depthwise 卷积

Depthwise 卷积就是逐个通道分开卷积。常规卷积卷积核需要与输入的每个通道都进行卷积操作,而 Depthwise 卷积的一个卷积核只与一个通道做卷积。如下图所示:

此时,卷积层的参数量为 3 \times 3 \times 3 = 27.
 
Depthwise 卷积没有有效利用输入层不同通道在相同空间位置上的特征信息。因此需要增加一步操作来融合不同通道上相同位置的特征信息,即 Pointwise 卷积。

Pointwise 卷积

Poinwise 卷积与常规卷积运算相同,只不过卷积核尺寸为1 \times 1 \times MM为输入层的通道数。

此例中输入通道数为 3,输出通道为 4 ,因此 Pointwise 卷积层参数量为1 \times 1 \times 3 \times 4 = 12.
 
可以看到深度可分离卷积的参数量只有常规卷积的参数量的 1/3.

pytorch 实现深度可分离卷积

可以使用 torch.nn.Conv2d() 中的卷积组参数 groups,来实现深度可分离卷积。groups 参数是用于控制输入和输出的连接的,表示要分的组数(in_channels 和 out_channels 都必须能被 groups 参数整除)。例如:

  • 当 groups =1 (默认值)时,就是同普通的卷积。
  • 当 groups=n 时,相当于把原来的卷积分成 n 组,每组 in_channels/n 的输入与 out_channels/n 个 kernel_size x kernel_size x in_channels/n的卷积核卷积,生成 out_channels/n 的输出 ,然后将各组输出连接起来,形成完整的 out_channels 的输出。

 
当 groups = in_channels 时,每个输入通道都只跟 out_channels/in_channels 个卷积核卷积。 out_channels = in_channels 时就是 Depthwise 卷积。
 
完整的深度可分离卷积代码如下

import torch.nn as nn

class depthwise_separable_conv(nn.Module):
    def __init__(self, in_channels, out_channels):
        super(depthwise_separable_conv, self).__init__()
        self.depthwise_conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, padding=1, groups=in_channels)
        self.pointwise_conv = nn.Conv2d(in_channels, out_channels, kernel_size=1, padding=0)

    def forward(self, input):
        out = self.depthwise_conv(input)
        out = self.pointwise_conv(out)
        return out

2. channel shuffle 通道洗牌

参考:
channel shuffle 通道洗牌
https://zhuanlan.zhihu.com/p/51566209

 
假设一个深度可分离卷积由一个 3 \times 3 的 Depthwise 卷积核一个 1 \times 1 的 Pointwise 卷积组成。输入尺寸为c_1 \times h \times w,输出尺寸为 c_2 \times h \times w, 则 FLOPS 为:


3 \times 3 \times c_1 \times h \times w + c_2 \times c_1 \times h \times w

一般情况下 c_2 是远大于 3 \times 3 的,所以深度可分离卷积的计算性能主要取决于 Pointwise 卷积。
 
模型中大量的对于整个 Feature Maps 的 Pointwise 卷积成了限制模型计算性能的瓶颈。
 
分组 Pointwise 卷积可以有效提高计算效率,但是不利于通道组之间的信息流通,会削弱模型的表征能力。为了克服分组卷积带来的副作用,ShuffleNet v1中提出了 channel shuffle 操作。具体流程如下:
1.将输入Feature Maps 展开成 g \times n \times h \times w 的四维矩阵(即,将c_1 通道的输入划分为 g 组,每组n个通道)。
2.沿着 g \times n \times h \times w 矩阵的g轴和n轴进行转置。
3.将转置后的矩阵平铺,得到洗牌之后的 Feature Maps。
4.进行组内 Pointwize 卷积。

来自小松鼠的图解:

pytorch 代码实现

import torch

def channel_shuffle(x, groups):
    batchsize, num_channels, height, width = x.data.size()
    channels_per_group = num_channels // groups

    # 通道分组. b, num_channels, h, w ->  b, groups, channels_per_group, h, w
    x = x.view(batchsize, groups, channels_per_group, height, width)

    # channel shuffle
    x = torch.transpose(x, 1, 2).contiguous()  # x.shape=(batchsize, channels_per_group, groups, height, width)
    x = x.view(batchsize, -1, height, width)
    return x

关于代码中 contiguous() 的解释可以参考 PyTorch中的contiguous
 
当然 pytorch 已经实现了channel_shuffle 模块,在实际应用中只需要:

import torch

channel_shuffle = torch.nn.ChannelShuffle(groups=2)
input = torch.randn(1,4,2,2)
output = channel_shuffle(input)

make_divisible(A,B):找到比A大的,能整除B的最小整数。

3. SE 模块

参考 https://www.jianshu.com/p/5af9f4811fbd
Squeeze-and-Excitation Networks, 2017, CVPR
SE 模块显式地建模特征通道之间的相互依赖关系。就是通过学习的方式获取每个 channel 的重要程度,然后依照这个重要程度来对各个通道上的特征进行加权,从而突出重要特征,抑制不重要的特征。简单说就是训练一组权重,对各个 channel 的特征图加权。
本质上,SE 模块是在 channel 维度上做 attention 或者 gating 操作,这种注意力机制让模型可以更加关注重要的 channel 的特征。SE模块可以轻松的移植到其他网络架构,能够以轻微的计算性能损失带来极大的准确率提升。

常规卷积操作会对输入各个通道做卷积,然后对个通道的卷积结果进行求和。这种操作将卷积学习到的空间特征和通道特征混合在一起了。而 SE 模块就是为了抽离这种混杂,让模型直接学习通道特征。

Ftr 是常规的卷积操作。U 后面是SE模块,包含 squeeze 和 excitation 两步。

Squeeze 操作

由于卷积只是在局部空间内进行操作(没有全局感受野),很难获得做够的信息来提取 channel 之间的关系特征。 为了提取 channel 之间的关系,首先要将每个 channel 上的空间特征编码(压缩)为一个全局特征(可以理解为对每个 channel 的特征信息的进行融合)。可以采用全局平局池化来实现:


z_{c}=\mathbf{F}_{s q}\left(\mathbf{u}_{c}\right)=\frac{1}{H \times W} \sum_{i=1}^{H} \sum_{j=1}^{W} u_{c}(i, j)

输出维度为 1 x 1 x C。

Excitation 操作

Sequeeze 操作获得了各个 channel 的全局特征,接下来要提取各个 channel 之间的关系。我们选择使用带有 sigmoid 激活的简单门控机制实现:


\mathbf{s}=\mathbf{F}_{e x}(\mathbf{z}, \mathbf{W})=\sigma(g(\mathbf{z}, \mathbf{W}))=\sigma\left(\mathbf{W}_{2} \delta\left(\mathbf{W}_{1} \mathbf{z}\right)\right)

公式中包含两个全连接层,第一个全连接层\mathbf{W}_{1} \mathbf{z}起到降维的作用,经过激活函数\delta(通常为 ReLU)后,再用一个全连接层 \mathbf{W}_{2} 恢复到原来的维度。最后经过 sigmoid 激活函数\sigma,得到输出\mathbf{s},其维度为 1 x 1 x C。
整个过程其实简单明了,本质的核心就是去学习各个通道数之间的权重关系,如上图的颜色所示,不同颜色就代表了不同的权重。

最后一步就十分简单了,就是拿各通道的权重s_{c}与各个通道的特征\mathbf{u}_{c}相乘:


\tilde{\mathbf{x}}_{c}=s_{c} \mathbf{u}_{c}

pytorch 实现

对1x1xC的输入进行1x1的卷积,其效果等同于全连接层,所以SE block 实现如下:

import torch.nn as nn

class SELayer(nn.Module):
    def __init__(self, channels, ratio=8): 
        super(SELayer, self).__init__()
        self.global_avgpool = nn.AdaptiveAvgPool2d(1)

        self.conv1 = nn.Conv2d(
            in_channels=channels,
            out_channels=channels // ratio,
            kernel_size=1,
            stride=1)
        self.relu = nn.ReLU()

        self.conv2 = Conv2d(
            in_channels=channels // ratio,
            out_channels=channels,
            kernel_size=1,
            stride=1)
        self.sigmoid = nn.Sigmoid()

    def forward(self, x):
        outputs = self.global_avgpool(x)
        outputs = self.conv1(outputs)
        outputs = self.relu(outputs)
        outputs = self.conv2(outputs)
        outputs = self.sigmoid(outputs)
        return x * outputs

4. Ghost 模块

原文链接:GhostNet: More Features from Cheap Operations;cvpr;2020
参考:GhostNet论文解析:Ghost Module
Ghost 模块可以用更少的参数生成更多的特征图。对比下图中 Resnet-50 网络第一个 Resdual group 输出的特征图可视化结果,可以发现很多特征图高度相似,一些特征图就像是另一些特征图的 ghost(可以理解为重影、影像)。如下图中三个颜色块分别框出的特征对。 我们常规思维会认为这些相似特征是冗余信息,而GhostNet作者却认为这些 ghost 特征不属于冗余信息,而是有助于增强模型的特征提取能力的。只不过这些 ghost 特征图只需要通过对本征特征图(intrinsic feature maps)做简单的线性操作即可获得,而没有必要通过卷积操作生成。这样可以大大缩减计算量。

常规卷积如下图所示,如果输入 X \in \mathbb{R}^{c \times h \times w},输出 Y \in \mathbb{R}^{h^{\prime} \times w^{\prime} \times n},卷积核f \in \mathbb{R}^{c \times k \times k \times n},则卷积操作的 FLOPs 约为 n \cdot h^{\prime} \cdot w^{\prime} \cdot c \cdot k \cdot k

ghost 模块分为三步:
1.基础卷积 Conv 得到本征特征图 Y^{\prime} \in \mathbb{R}^{h^{\prime} \times w^{\prime} \times m}
2.将 Y^{\prime} 每一个通道的特征图 y_{i}^{\prime} 用线性运算产生 Ghost 特征图y_{i j}:


y_{i j}=\Phi_{i, j}\left(y_{i}^{\prime}\right), \quad \forall i=1, \ldots, m, \quad j=1, \ldots, s

\Phi_{i, j} 表示生成第 j 个 ghost 特征图的线性运算。也就是说y_{i}^{\prime}可以有不止一个 ghost 特征图。
3.将本征特征图和 Ghost 特征图拼接得到最终结果。当然也可以认为(原文就是这样认为的)拼接的本征特征图是第 2 步\Phi_{i, j}的单位映射(恒等映射),即 s 个线性操作包含了 1 个单位映射和(s-1)个线性操作,那么就不需要第 3 步了。此时n=m·s

关于线性操作 \Phi_{i, j}:论文中使用的线性操作并不是常见的旋转、平移、仿射变换、小波变换等,而是 Depthwise 卷积。论文中表示这样做是因为 Depthwise 卷积的计算效率高。
在输入输出尺寸与常规卷积相同的情况下,第 1 步 Conv 操作的 FLOPs 为 \frac{n}{s} \cdot h^{\prime} \cdot w^{\prime} \cdot c \cdot k \cdot k,第 2 步 假设Depthwise 卷积的kernel\_size=d,则 FLOPS 为 (s-1) \cdot \frac{n}{s} \cdot h^{\prime} \cdot w^{\prime} \cdot d \cdot d。通常 s<<{c} , 用Ghost模块升级普通卷积的理论加速比为


\begin{aligned} r_{s} &=\frac{n \cdot h^{\prime} \cdot w^{\prime} \cdot c \cdot k \cdot k}{\frac{n}{s} \cdot h^{\prime} \cdot w^{\prime} \cdot c \cdot k \cdot k+(s-1) \cdot \frac{n}{s} \cdot h^{\prime} \cdot w^{\prime} \cdot d \cdot d} \ &=\frac{c \cdot k \cdot k}{\frac{1}{s} \cdot c \cdot k \cdot k+\frac{s-1}{s} \cdot d \cdot d} \approx \frac{s \cdot c}{s+c-1} \approx s \end{aligned}

Ghost 模块的 pytorch 实现

class GhostModule(nn.Module):
    def __init__(self, inp, oup, kernel_size=1, ratio=2, dw_size=3, stride=1, relu=True):
        super(GhostModule, self).__init__()
        self.oup = oup
        init_channels = math.ceil(oup / ratio)  # 经过基础卷积得到的 feature maps  # ceil 向上取整
        new_channels = init_channels*(ratio-1)  # 经过线性变换得到的 feature maps

        #  基础卷积部分。kernel_size=1时 ,实际上是 Pointwise  卷积
        self.primary_conv = nn.Sequential(
            nn.Conv2d(inp, init_channels, kernel_size, stride, kernel_size//2, bias=False),
            nn.BatchNorm2d(init_channels),
            nn.ReLU(inplace=True) if relu else nn.Sequential(),
        )

         # 线性变换部分。实际上是Depthwise 卷积,从参数 groups=init_channels 可以看出
        self.cheap_operation = nn.Sequential(
            nn.Conv2d(init_channels, new_channels, dw_size, 1, dw_size//2, groups=init_channels, bias=False),
            nn.BatchNorm2d(new_channels),
            nn.ReLU(inplace=True) if relu else nn.Sequential(),
        )

    def forward(self, x):
        x1 = self.primary_conv(x)
        x2 = self.cheap_operation(x1)
        out = torch.cat([x1,x2], dim=1)
        return out[:,:self.oup,:,:]

从代码可见,Ghost Module和深度可分离卷积十分相似,不同之处在于Ghost 模块先进行 PointwiseConv,后进行 DepthwiseConv,另外增加了 DepthwiseConv 的数量(包括一个恒定映射)。
从另一个角度理解 ghost 模块:相当于对 Feature Maps 做了数据增强。