前言


MobileNet 系列 是 Andrew G. Howard(Google Inc.) 等人于 2017 年(其实是 2016 年先于 Xception 已经提出,但是直到 2017 年才挂到 arXiv 上)在 MobileNets: Efficient Convolutional Neural Networks for Mobile Vision Applications 中提出的一种网络结构,这种网络结构的特点是模型小,计算速度快,适合部署到移动端或者嵌入式系统中。




一、Motivation


卷积神经网络(CNN)已经普遍应用在计算机视觉领域,并且已经取得了不错的效果。近几年来CNN在ImageNet竞赛的表现可以看到,为了追求分类准确度,模型深度越来越深,模型复杂度也越来越高,如深度残差网络(ResNet)其层数已经多达152层。然而,在某些真实的应用场景如移动或者嵌入式设备,如此大而复杂的模型是难以被应用的。


首先是模型过于庞大,面临着内存不足的问题,其次这些场景要求低延迟,或者说响应速度要快,想象一下自动驾驶汽车的行人检测系统如果速度很慢会发生什么可怕的事情。所以,研究小而高效的CNN模型在这些场景至关重要,至少目前是这样,尽管未来硬件也会越来越快。


目前的研究总结来看分为两个方向:一是对训练好的复杂模型进行压缩得到小模型;二是直接设计小模型并进行训练。不管如何,其目标在保持模型性能(accuracy)的前提下降低模型大小(parameters size),同时提升模型速度(speed, low latency)。


本文的主角MobileNet系列的网络属于后者,是Google最近提出的一种小巧而高效的CNN模型,其在accuracy和latency之间做了折中。下面对MobileNet做详细的介绍。


二、MobileNetV1


1.深度可分离卷积


MobileNetV1之所以轻量,与深度可分离卷积的关系密不可分。
在这里插入图片描述
如上图所示,模型推理中卷积操作占用了大部分的时间,因此MobileNetV1使用了深度可分离卷积对卷积操作做了进一步的优化,具体解释如下:


常规卷积操作


对于5x5x3的输入,如果想要得到3x3x4的feature map,那么卷积核的shape为3x3x3x4;如果padding=1,那么输出的feature map为5x5x4,如下图:
在这里插入图片描述
卷积层共4个Filter,每个Filter包含了3个Kernel,每个Kernel的大小为3×3。因此卷积层的参数数量可以用如下公式来计算(即:卷积核W x 卷积核H x 输入通道数 x 输出通道数):


N_std = 4 × 3 × 3 × 3 = 108


计算量:
(即:卷积核W x 卷积核H x (图片W-卷积核W+1) x (图片H-卷积核H+1) x 输入通道数 x 输出通道数):
C_std =3_3_(5-2)_(5-2)_3_4=972


深度可分离卷积 深度可分离卷积主要分为两个过程,分别为逐通道卷积(Depthwise Convolution)和逐点卷积(Pointwise Convolution)。


逐通道卷积(Depthwise Convolution)


Depthwise Convolution的一个卷积核负责一个通道,一个通道只被一个卷积核卷积,这个过程产生的feature map通道数和输入的通道数完全一样。


一张5×5像素、三通道彩色输入图片(shape为5×5×3),Depthwise Convolution首先经过第一次卷积运算,DW完全是在二维平面内进行。卷积核的数量与上一层的通道数相同(通道和卷积核一一对应)。所以一个三通道的图像经过运算后生成了3个Feature map(如果有same padding则尺寸与输入层相同为5×5),如下图所示。(卷积核的shape即为:卷积核W x 卷积核H x 输入通道数)


在这里插入图片描述
其中一个Filter只包含一个大小为3×3的Kernel,卷积部分的参数个数计算如下(即为:卷积核Wx卷积核Hx输入通道数):


N_depthwise = 3 × 3 × 3 = 27


计算量为(即:卷积核W x 卷积核H x (图片W-卷积核W+1) x (图片H-卷积核H+1) x 输入通道数)


C_depthwise=3x3x(5-2)x(5-2)x3=243


Depthwise Convolution完成后的Feature map数量与输入层的通道数相同,无法扩展Feature map。而且这种运算对输入层的每个通道独立进行卷积运算,没有有效的利用不同通道在相同空间位置上的feature信息。简而言之,虽然减少了计算量,但是失去了通道维度上的信息交互。因此需要Pointwise Convolution来将这些Feature map进行组合生成新的Feature map。


逐点卷积(Pointwise Convolution)


Pointwise Convolution的运算与常规卷积运算非常相似,它的卷积核的尺寸为 1×1×M,M为上一层的通道数。所以这里的卷积运算会将上一步的map在深度方向上进行加权组合,生成新的Feature map。有几个卷积核就有几个输出Feature map。
在这里插入图片描述
由于采用的是1×1卷积的方式,此步中卷积涉及到的参数个数可以计算为(1 x 1 x 输入通道数 x 输出通道数):


N_pointwise = 1 × 1 × 3 × 4 = 12


计算量(1 x 1 特征层W x 特征层H x 输入通道数 x 输出通道数):


C_pointwise = 1 × 1 × 3 × 3 × 3 × 4 = 108


经过Pointwise Convolution之后,同样输出了4张Feature map,与常规卷积的输出维度相同。


参数对比


回顾一下,常规卷积的参数个数为:


N_std = 4 × 3 × 3 × 3 = 108


Separable Convolution的参数由两部分相加得到:


N_depthwise = 3 × 3 × 3 = 27


N_pointwise = 1 × 1 × 3 × 4 = 12


N_separable = N_depthwise + N_pointwise = 39


相同的输入,同样是得到4张Feature map,Separable Convolution的参数个数是常规卷积的约1/3。因此,在参数量相同的前提下,采用Separable Convolution的神经网络层数可以做的更深。


计算量对比


回顾一下,常规卷积的计算量为:


C_std =3_3_(5-2)_(5-2)_3_4=972


Separable Convolution的参数由两部分相加得到:


C_depthwise=3x3x(5-2)x(5-2)x3=243


C_pointwise = 1 × 1 × 3 × 3 × 3 × 4 = 108


C_separable = C_depthwise + C_pointwise = 351


相同的输入,同样是得到4张Feature map,Separable Convolution的计算量是常规卷积的约1/3。因此,在计算量相同的情况下,Depthwise Separable Convolution可以将神经网络层数可以做的更深。


2.MBconv


前面讲述了depthwise separable convolution,这是MobileNet的基本组件,但是在真正应用中会加入batchnorm,并使用ReLU激活函数,所以depthwise separable convolution的基本结构如下图右面所示:
在这里插入图片描述


整体网络就是通过不断堆叠MBconv组件组成的,另外值得提一句的是深度可分卷积并不是 MobileNet 首次提出的,仅仅是利用这一变换来达到减少参数量和计算量的目的。


3.Model Architecture


MobileNet的网络结构如表1所示。首先是一个3x3的标准卷积,然后后面就是堆积depthwise separable convolution,并且可以看到其中的部分depthwise convolution会通过strides=2进行down sampling。经过 卷积提取特征后再采用average pooling将feature变成1x1,根据预测类别大小加上全连接层,最后是一个softmax层。


如果单独计算depthwise convolution和pointwise convolution,整个网络有28层(这里Avg Pool和Softmax不计算在内)。我们还可以分析整个网络的参数和计算量分布,如下面第二张图所示。
在这里插入图片描述


可以看到整个计算量基本集中在1x1卷积上,如果你熟悉卷积底层实现的话,你应该知道卷积一般通过一种im2col方式实现,其需要内存重组,但是当卷积核为1x1时,其实就不需要这种操作了,底层可以有更快的实现。对于参数也主要集中在1x1卷积,除此之外还有就是全连接层占了一部分参数。


在这里插入图片描述


MobileNet到底效果如何,这里与GoogleNet和VGG16做了对比,如Table8所示。相比VGG16,MobileNet的准确度稍微下降,但是优于GoogleNet。然而,从计算量和参数量上MobileNet具有绝对的优势。
在这里插入图片描述




小结


本文简单介绍了Google提出的移动端模型MobileNet,其核心是采用了可分解的depthwise separable convolution,其不仅可以降低模型计算复杂度,而且可以大大降低模型大小。另外,值得一提的是,文中将激活函数从Relu替换成Relu6。在真实的移动端应用场景,像MobileNet这样类似的网络将是持续研究的重点。


三、MobileNetV2


Andrew G. Howard 等于 2018 年在 MobileNet V1 的基础上又提出了改进版本 MobileNet V2。具体可以参考原始论文 MobileNetV2: Inverted Residuals and Linear Bottlenecks。


从标题我们就可以看出,V2 中主要用到了 Inverted Residuals 和 Linear Bottlnecks。


1. Inverted Residuals


上一篇我们看到 V1 的网络结构还是非常传统的直桶模型(没有分支),但是 ResNet 在模型中引入分支并取得了很好的效果,因此到了 V2 的时候,作者也想引入进来,这就有了我们要探讨的问题了。


首先我们看下 Residual block。下图可以看到,采用 1x1 的卷积核先将 256 维度降到 64 维,经过 3x3 的卷积这后,然后又通过 1x1 的卷积核恢复到 256 维。
在这里插入图片描述
那如果我们要把 residual block 运用到 MobileNet 中来的话,如果我们还是采用相同的策略显然是有问题的,因为 MobileNet 中由于逐通道卷积,本来 feature 的维度就不多,如果还要先压缩的话,会使模型太小了,所以作者提出了 Inverted Residuals,即先扩展(6倍)后压缩,这样就不会使模型被压缩的太厉害。 下图对比了原始残差和反转残差(截取自原论文):


在这里插入图片描述


2. Linear Bottlnecks


Linear Bottlnecks 听起来很高级,其实就是把上面的 Inverted Residuals block 中的 bottleneck 处的 ReLU 去掉。通过下面的图片对比就可以很容易看出,实际上就是去掉了最后一个 1x1 卷积后面的 ReLU。整体的网络模型就是由堆叠下图右图的Bottlenecks搭建成的
在这里插入图片描述


那为什么要去掉呢?而且为什么是去掉最后一个1X1卷积后面的 ReLU 呢?因为在训练 MobileNet V1 的时候发现最后 Depthwise 部分的 kernel 训练容易失去作用,最终再经过ReLU出现输出为0的情况。作者发现是因为ReLU 会对 channel 数较低的张量造成较大的信息损耗,因此执行降维的卷积层后面不会接类似于ReLU这样的非线性激活层。说人话就是:1X1卷积降维操作本来就会丢失一部分信息,而加上 ReLU 之后那是雪上加霜,所以去掉 ReLU 缓一缓。


3. Model Architecture


完整的MobileNetV2的网络结构参数如下:
t代表反转残差中第一个1X1卷积升为的倍数;c代表通道数;n代表堆叠bottleneck的次数;s代表DWconv的幅度(1或2),不同的步幅对应了不同的模块,详见上图(d)MobilenetV2。
在这里插入图片描述
在效果上,在 ImageNet 上相比 V1 参数量减少了,效果也更好了,详见下图:
在这里插入图片描述


小结


MobileNetV2最大的贡献就是改进了通道数较少的网络运用残差连接的方式:设计了反转残差(Inverted Residuals)的结构。


四、MobileNetV3


V3 保持了一年一更的节奏,Andrew G. Howard 等于 2019 年又提出了 MobileNet V3。文中提出了两个网络模型, MobileNetV3-Small 与 MobileNetV3-Large 分别对应对计算和存储要求低和高的版本。具体可以参考原始论文 Searching for MobileNetV3。


这回的标题(Searching for MobileNetV3)说的不是 V3 里面有什么,而是说的 V3 是怎么来的。Searching 说的是网络架构搜索(NAS),即 V3 是通过搜索和网络优化而来。


这里我们不详细讨论 NAS网络搜索计算,虽然这是论文的一大亮点。原因是这个技术不是一般人玩得起的…它相当于训练的不是模型参数,而是模型架构。说白了就是设计一个网络模型结构的集合,通过不同网络层的排列组合可以组合出许多许多的模型,再通过NAS搜索技术搜索出最佳的网络结构。这相当于大力出奇迹嘛,将调参工作交给NAS技术去做,实属一种降维打击


当然,随之而来的缺点也很明显,这需要大量的计算资源才能完成,恐怕只有想GoogLe,Baidu这种可以买显卡当买白开水的公司才有财力去搞这些研究。而且,由于搜索过程中最关注网络的性能,因此最优的的网络结构可能长得五花八门,换一种说法就是层级结构的排列比较混乱


这就导致了两个缺点:
一:网络的可解释性更差,没办法说为啥这么排列性能好,这能说实验得出…
二:这种不规律的模型层级排列也不利于模型的部署。因此经过NAS搜索后的模型一般需要人为的进行进一步调整,让它长的规矩一些…


1. 对 V2 最后几层的修改


作者发现 V2 网络最后一部分结构可以优化,如Figure5所示,原始的结构用 1x1 的卷积来调整 feature 的维度,从而提高预测的精度,但是这一部分也会造成一定的延时,为了减少延时,作者把 average pooling 提前,这样的话,这样就提前把 feature 的 size 减下来了(pooling 之后 feature size 从 7x7 降到了 1x1)。这样一来延时减小了,但是试验证明精度却几乎没有降低。
在这里插入图片描述


2. h-swish


这个得先说说 swish(也是 google 自家人搞出来的),说是这个激活函数好用,替换 ReLU 可以提高精度,但是这个激活函数(主要是 σ ( x ) \sigma(x)σ(x) 部分)在移动端设备上显得太耗资源,所以作者又提出了一个新的 h-swish 激活函数来取代 swish,效果跟 swish 差不多,但是计算量却大大减少。
在这里插入图片描述


3. squeeze-and-excite(SE)


Squeeze-and-Excitation Networks(SENet)是由自动驾驶公司Momenta在2017年公布的一种全新的图像识别结构,它通过对特征通道间的相关性进行建模,把重要的特征进行强化来提升准确率。这个结构是2017 ILSVR竞赛的冠军,top5的错误率达到了2.251%,比2016年的第一名还要低25%,可谓提升巨大。SE也被一下研究人员成为基于通道的注意力机制。


与MobileNetV2相比,MobileNetV3 中增加了 SE 结构,并且将含有 SE 结构部分的 expand layer 的 channel 数减少(为原来的 1/4 以减少延迟,但是时间查看模型,貌似只是减少了1/2),试验发现这样不仅提高了模型精度,而且整体上延迟也并没有增加。
在这里插入图片描述


Model Architecture


Table1对应着MobileNetV3_Large版的网络结构参数;Table1对应着MobileNetV3_Small版;
在这里插入图片描述
值得注意的是:对比 V3 和 V2 还可以发现模型开始的 conv2d 部分的输出 size 减少为原来的一般了,试验发现延迟有所降低,精度没有下降。


至于效果,相比 V2 1.0 来说, V3-Small 和 V3-Large 在性能和精度上各有优势。但是在工程实际中,特别是在移动端上 V2 用的更为广泛,因为 V2 结构更简单,移植更方便,速度也更有优势。
在这里插入图片描述


小结


  • 利用NAS网络搜索结构优化了网络架构
  • 使用h-swish激活函数
  • 加入SE模块

五,代码实现


这里给出模型搭建的python代码(基于pytorch实现)。完整的代码是基于图像分类问题的(包括训练和推理脚本,自定义层等)详见我的GitHub:完整代码链接


from typing import Callable, List, Optional

import torch
from torch import nn, Tensor
from torch.nn import functional as F
from functools import partial

from custom_layers.CustomLayers import ConvBNActivation, SqueezeExcitation
from custom_layers.CustomMethod import make_divisible8

class InvertedResidualConfig:
def __init__(self, input_c: int, kernel: int, expanded_c: int, out_c: int, use_se: bool, activation: str, stride: int, width_multi: float):
self.input_c = self.adjust_channels(input_c, width_multi)
self.kernel = kernel
self.expanded_c = self.adjust_channels(expanded_c, width_multi)
self.out_c = self.adjust_channels(out_c, width_multi)
self.use_se = use_se
self.use_hs = activation == “HS” # whether using h-swish activation
self.stride = stride

@staticmethod
def adjust_channels(channels: int, width_multi: float):
return make_divisible8(channels * width_multi, 8)


class InvertedResidual(nn.Module):
def __init__(self, cnf: InvertedResidualConfig, norm_layer: Callable[..., nn.Module]):
super(InvertedResidual, self).__init__()

if cnf.stride not in [1, 2]:
raise ValueError(“illegal stride value.”)

self.use_res_connect = (cnf.stride == 1 and cnf.input_c == cnf.out_c)
layers: List[nn.Module] = []
activation_layer = nn.Hardswish if cnf.use_hs else nn.ReLU

# expand
if cnf.expanded_c != cnf.input_c:
layers.append(ConvBNActivation(cnf.input_c, cnf.expanded_c, kernel_size=1, norm_layer=norm_layer, activation_layer=activation_layer))
# depthwise
layers.append(ConvBNActivation(cnf.expanded_c, cnf.expanded_c, cnf.kernel, cnf.stride,
groups=cnf.expanded_c, norm_layer=norm_layer,activation_layer=activation_layer))
if cnf.use_se:
layers.append(SqueezeExcitation(cnf.expanded_c))
# project
layers.append(ConvBNActivation(cnf.expanded_c, cnf.out_c, kernel_size=1, norm_layer=norm_layer, activation_layer=nn.Identity))

self.block = nn.Sequential(*layers)
self.out_channels = cnf.out_c
self.is_strided = cnf.stride > 1

def forward(self, x: Tensor):
result = self.block(x)
if self.use_res_connect:
result += x

return result


class MobileNetV3(nn.Module):
def __init__(self,
inverted_residual_setting: List[InvertedResidualConfig],
last_channel: int,
num_classes: int = 1000,
block: Optional[Callable[..., nn.Module]] = None,
norm_layer: Optional[Callable[..., nn.Module]] = None):
super(MobileNetV3, self).__init__()

if not inverted_residual_setting:
raise ValueError(“The inverted_residual_setting should not be empty.”)
elif not (isinstance(inverted_residual_setting, List) and
all([isinstance(s, InvertedResidualConfig) for s in inverted_residual_setting])):
raise TypeError(“The inverted_residual_setting should be List[InvertedResidualConfig]”)

if block is None:
block = InvertedResidual

if norm_layer is None:
norm_layer = partial(nn.BatchNorm2d, eps=0.001, momentum=0.01)

layers: List[nn.Module] = []
# building first layer
firstconv_output_c = inverted_residual_setting[0].input_c
layers.append(ConvBNActivation(3, firstconv_output_c, kernel_size=3, stride=2, norm_layer=norm_layer, activation_layer=nn.Hardswish))

# building inverted residual blocks
for cnf in inverted_residual_setting:
layers.append(block(cnf, norm_layer))

# building last several layers
lastconv_input_c = inverted_residual_setting[-1].out_c
lastconv_output_c = 6 _ lastconv_input_c
layers.append(ConvBNActivation(lastconv_input_c, lastconv_output_c, kernel_size=1, norm_layer=norm_layer, activation_layer=nn.Hardswish))

self.features = nn.Sequential(_layers)
self.avgpool = nn.AdaptiveAvgPool2d(1)
self.classifier = nn.Sequential(nn.Linear(lastconv_output_c, last_channel),
nn.Hardswish(inplace=True),
nn.Dropout(p=0.2, inplace=True),
nn.Linear(last_channel, num_classes))

# initial weights
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode=“fan_out”)
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, (nn.BatchNorm2d, nn.GroupNorm)):
nn.init.ones_(m.weight)
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Linear):
nn.init.normal_(m.weight, 0, 0.01)
nn.init.zeros_(m.bias)

def forward(self, x: Tensor):
x = self.features(x)
x = self.avgpool(x)
x = torch.flatten(x, 1)
x = self.classifier(x)

return x


def mobilenet_v3_large(num_classes: int = 1000, reduced_tail: bool = False, init_weights=True):
“””
Constructs a large MobileNetV3 architecture from
“Searching for MobileNetV3” <https://arxiv.org/abs/1905.02244&gt;.
weights_link:
https://download.pytorch.org/models/mobilenet_v3_large-8738ca79.pth
Args:
num_classes (int): number of classes
reduced_tail (bool): If True, reduces the channel counts of all feature layers
between C4 and C5 by 2. It is used to reduce the channel redundancy in the
backbone for Detection and Segmentation.
“””

width_multi = 1.0
BottleNeck_conf = partial(InvertedResidualConfig, width_multi=width_multi)
adjust_channels = partial(InvertedResidualConfig.adjust_channels, width_multi=width_multi)

reduce_divider = 2 if reduced_tail else 1

inverted_residual_setting = [
# input_c, kernel, expanded_c, out_c, use_se, activation, stride
BottleNeck_conf(16, 3, 16, 16, False, “RE”, 1),
BottleNeck_conf(16, 3, 64, 24, False, “RE”, 2), # C1
BottleNeck_conf(24, 3, 72, 24, False, “RE”, 1),
BottleNeck_conf(24, 5, 72, 40, True, “RE”, 2), # C2
BottleNeck_conf(40, 5, 120, 40, True, “RE”, 1),
BottleNeck_conf(40, 5, 120, 40, True, “RE”, 1),
BottleNeck_conf(40, 3, 240, 80, False, “HS”, 2), # C3
BottleNeck_conf(80, 3, 200, 80, False, “HS”, 1),
BottleNeck_conf(80, 3, 184, 80, False, “HS”, 1),
BottleNeck_conf(80, 3, 184, 80, False, “HS”, 1),
BottleNeck_conf(80, 3, 480, 112, True, “HS”, 1),
BottleNeck_conf(112, 3, 672, 112, True, “HS”, 1),
BottleNeck_conf(112, 5, 672, 160 // reduce_divider, True, “HS”, 2), # C4
BottleNeck_conf(160 // reduce_divider, 5, 960 // reduce_divider, 160 // reduce_divider, True, “HS”, 1),
BottleNeck_conf(160 // reduce_divider, 5, 960 // reduce_divider, 160 // reduce_divider, True, “HS”, 1),
]
last_channel = adjust_channels(1280 // reduce_divider) # C5

return MobileNetV3(inverted_residual_setting=inverted_residual_setting,
last_channel=last_channel,
num_classes=num_classes)


def mobilenet_v3_small(num_classes: int = 1000, reduced_tail: bool = False, init_weights=True):
“””
Constructs a large MobileNetV3 architecture from
“Searching for MobileNetV3” <https://arxiv.org/abs/1905.02244&gt;.
weights_link:
https://download.pytorch.org/models/mobilenet_v3_small-047dcff4.pth
Args:
num_classes (int): number of classes
reduced_tail (bool): If True, reduces the channel counts of all feature layers
between C4 and C5 by 2. It is used to reduce the channel redundancy in the
backbone for Detection and Segmentation.
“””

width_multi = 1.0
bneck_conf = partial(InvertedResidualConfig, width_multi=width_multi)
adjust_channels = partial(InvertedResidualConfig.adjust_channels, width_multi=width_multi)

reduce_divider = 2 if reduced_tail else 1

inverted_residual_setting = [
# input_c, kernel, expanded_c, out_c, use_se, activation, stride
bneck_conf(16, 3, 16, 16, True, “RE”, 2), # C1
bneck_conf(16, 3, 72, 24, False, “RE”, 2), # C2
bneck_conf(24, 3, 88, 24, False, “RE”, 1),
bneck_conf(24, 5, 96, 40, True, “HS”, 2), # C3
bneck_conf(40, 5, 240, 40, True, “HS”, 1),
bneck_conf(40, 5, 240, 40, True, “HS”, 1),
bneck_conf(40, 5, 120, 48, True, “HS”, 1),
bneck_conf(48, 5, 144, 48, True, “HS”, 1),
bneck_conf(48, 5, 288, 96 // reduce_divider, True, “HS”, 2), # C4
bneck_conf(96 // reduce_divider, 5, 576 // reduce_divider, 96 // reduce_divider, True, “HS”, 1),
bneck_conf(96 // reduce_divider, 5, 576 // reduce_divider, 96 // reduce_divider, True, “HS”, 1)
]
last_channel = adjust_channels(1024 // reduce_divider) # C5

return MobileNetV3(inverted_residual_setting=inverted_residual_setting,
last_channel=last_channel,
num_classes=num_classes)

六、 总结


MobileNet系列是卷积神经网络中轻量级的经典,相比于后期像Transfomer这种又大又笨难以训练的模型来说,对嵌入式非常友好。