MobileNet v1 and MobileNet v2

tags: MobileNet

前言

MobileNet v2 是在v1的Depthwise Separable的基础上引入了残差结构。并发现了ReLU的在通道数较少的Feature Map上有非常严重信息损失问题,由此引入了Linear Bottlenecks和Inverted Residual。

首先在这篇文章中我们会详细介绍两个版本的MobileNet,然后我们会介绍如何使用Keras实现这两个算法。

1. MobileNet v1

1.1 回顾:传统卷积的参数量和计算量

它的一层网络的计算代价约为:

v1中介绍的Depthwise Separable Convolution就是解决了传统卷积的参数数量和计算代价过于高昂的问题。Depthwise Separable Convolution分成Depthwise Convolution和Pointwise Convolution。

1.2 Depthwise卷积

其中Depthwise卷积是指不跨通道的卷积,也就是说Feature Map的每个通道有一个独立的卷积核,并且这个卷积核作用且仅作用在这个通道之上,如图2所示。

在Keras中,我们可以使用DepthwiseConv2D实现Depthwise卷积操作,它有几个重要的参数:

  • kernel_size:卷积核的尺寸,一般设为3。

  • strides:卷积的步长

  • padding:是否加边

  • activation:激活函数

由于Depthwise卷积的每个通道Feature Map产生且仅产生一个与之对应的Feature Map,也就是说输出层的Feature Map的channel数量等于输入层的Feature map的数量。因此DepthwiseConv2D不需要控制输出层的Feature Map的数量,因此并没有filters这个参数。

1.3 Pointwise卷积

Depthwise卷积的操作虽然非常高效,但是它仅相当于对当前的Feature Map的一个通道施加了一个过滤器,并不会合并若干个特征从而生成新的特征,而且由于在Depthwise卷积中输出Feature Map的通道数等于输入Feature Map的通道数,因此它并没有升维或者降维的功能。

Pointwise的可视化如图3:

1.4 Depthwise Separable卷积

计算量为:

和普通卷积的比值为:

1.5 Mobile v1的Keras实现及实验结果分析

通过上面的分析,我们知道一个普通卷积的一组卷积操作可以拆分成了个Depthwise卷积核一个Pointwise卷积,由此而形成MobileNet v1的结构。在这个实验中我们首先会搭建一个普通卷积,然后再将其改造成v1,并在MNIST上给出实验结果,代码和实验结果见链接CPUGPU

首先我们搭建的传统卷积的结构如下面代码片段:

def Simple_NaiveConvNet(input_shape, k):
    inputs = Input(shape=input_shape)
    x = Conv2D(filters=32, kernel_size=(3,3), strides=(2,2), padding='same', activation='relu', name='n_conv_1')(inputs)
    x = Conv2D(filters=64, kernel_size=(3,3),padding='same', activation='relu', name='n_conv_2')(x)
    x = Conv2D(filters=128, kernel_size=(3,3),padding='same', activation='relu', name='n_conv_3')(x)
    x = Conv2D(filters=128, kernel_size=(3,3), strides=(2,2),padding='same', activation='relu', name='n_conv_4')(x)
    x = GlobalAveragePooling2D(name='n_gap')(x)
    x = BatchNormalization(name='n_bn_1')(x)
    x = Dense(128, activation='relu', name='n_fc1')(x)
    x = BatchNormalization(name='n_bc_2')(x)
    x = Dense(k, activation='softmax', name='n_output')(x)
    model = Model(inputs, x)
    return model
def Simple_MobileNetV1(input_shape, k):
    inputs = Input(shape=input_shape)
    x = Conv2D(filters=32, kernel_size=(3,3), strides=(2,2), padding='same', activation='relu', name='m_conv_1')(inputs)
    x = DepthwiseConv2D(kernel_size=(3,3),padding='same', activation='relu', name = 'm_dc_2')(x)
    x = Conv2D(filters=64, kernel_size=(1,1),padding='same', activation='relu', name = 'm_pc_2')(x)
    x = DepthwiseConv2D(kernel_size=(3,3), padding='same', activation='relu', name = 'm_dc_3')(x)
    x = Conv2D(filters=128, kernel_size=(1,1),padding='same', activation='relu', name = 'm_pc_3')(x)
    x = DepthwiseConv2D(kernel_size=(3,3), strides=(2,2),padding='same', activation='relu', name = 'm_dc_4')(x)
    x = Conv2D(filters=128, kernel_size=(1,1),padding='same', activation='relu', name = 'm_pc_4')(x)
    x = GlobalAveragePooling2D(name='m_gap')(x)
    x = BatchNormalization(name='m_bn_1')(x)
    x = Dense(128, activation='relu', name='m_fc1')(x)
    x = BatchNormalization(name='m_bc_2')(x)
    x = Dense(k, activation='softmax', name='m_output')(x)
    model = Model(inputs, x)
    return model

通过Summary()函数我们可以得到每个网络的每层的参数数量,见图4,左侧是普通卷积,右侧是MobileNet v1。

接着我们在MNIST上跑一下实验,我们在CPU(Intel i7)和GPU(Nvidia 1080Ti)两个环境下运行一下,得到的收敛曲线如图5。在都训练10个epoch的情况下,我们发现MobileNet v1的结果要略差于传统卷积,这点完全可以理解,毕竟MobileNet v1的参数更少。

对比单个Epcoh的训练时间,我们发现了一个奇怪的现象,在CPU上,v1的训练时间约70秒,传统卷积训练时间为140秒,这和我们的直觉是相同的。但是在GPU环境下,传统卷积和v1的训练时间分别为40秒和50秒,v1在GPU上反而更慢了,这是什么原因呢?

问题在于cudnn对传统卷积的并行支持比较完善,而在cudnn7之前的版本并不支持depthwise卷积,现在虽然支持了,其并行性并没有做优化,依旧采用循环的形式遍历每个通道,因此在GPU环境下MobileNet v1反而要慢于传统卷积。所以说,是开源工具慢,并不是MobileNet v1的算法慢

2. MobileNet v2 详解

在MobileNet v2中,作者将v1中加入了残差网络,同时分析了v1的几个缺点并针对性的做了改进。v2的改进策略非常简单,但是在编写论文时,缺点分析的时候涉及了流行学习等内容,将优化过程弄得非常难懂。我们在这里简单总结一下v2中给出的问题分析,希望能对论文的阅读有所帮助,对v2的motivation感兴趣的同学推荐阅读论文。

根据对上面提到的信息损耗问题分析,我们可以有两种解决方案:

  1. 既然是ReLU导致的信息损耗,那么我们就将ReLU替换成线性激活函数;

  2. 如果比较多的通道数能减少信息损耗,那么我们就使用更多的通道。

2.1 Linear Bottlenecks

我们当然不能把ReLU全部换成线性激活函数,不然网络将会退化为单层神经网络,一个折中方案是在输出Feature Map的通道数较少的时候也就是bottleneck部分使用线性激活函数,其它时候使用ReLU。代码片段如下:

def _bottleneck(inputs, nb_filters, t):
    x = Conv2D(filters=nb_filters * t, kernel_size=(1,1), padding='same')(inputs)
    x = Activation(relu6)(x)
    x = DepthwiseConv2D(kernel_size=(3,3), padding='same')(x)
    x = Activation(relu6)(x)
    x = Conv2D(filters=nb_filters, kernel_size=(1,1), padding='same')(x)
    # do not use activation function
    if not K.get_variable_shape(inputs)[3] == nb_filters:
        inputs = Conv2D(filters=nb_filters, kernel_size=(1,1), padding='same')(inputs)
    outputs = add([x, inputs])
    return outputs

这里使用了MobileNet中介绍的ReLU6激活函数,它是对ReLU在6上的截断,数学形式为:

图7便是结合了残差网络和线性激活函数的MobileNet v2的一个block,最右侧是v1。

2.2 Inverted Residual

2.3 MobileNet v2

MobileNet v2的实现可以通过堆叠bottleneck的形式实现,如下面代码片段

def MobileNetV2_relu(input_shape, k):
    inputs = Input(shape = input_shape)
    x = Conv2D(filters=32, kernel_size=(3,3), padding='same')(inputs)
    x = _bottleneck_relu(x, 8, 6)
    x = MaxPooling2D((2,2))(x)
    x = _bottleneck_relu(x, 16, 6)
    x = _bottleneck_relu(x, 16, 6)
    x = MaxPooling2D((2,2))(x)
    x = _bottleneck_relu(x, 32, 6)
    x = GlobalAveragePooling2D()(x)
    x = Dense(128, activation='relu')(x)
    outputs = Dense(k, activation='softmax')(x)
    model = Model(inputs, outputs)
    return model

3. 总结

在这篇文章中,我们介绍了两个版本的MobileNet,它们和传统卷积的对比如图10。

如图(b)所示,MobileNet v1最主要的贡献是使用了Depthwise Separable Convolution,它又可以拆分成Depthwise卷积和Pointwise卷积。MobileNet v2主要是将残差网络和Depthwise Separable卷积进行了结合。通过分析单通道的流形特征对残差块进行了改进,包括对中间层的扩展(d)以及bottleneck层的线性激活(c)。Depthwise Separable Convolution的分离式设计直接将模型压缩了8倍左右,但是精度并没有损失非常严重,这一点还是非常震撼的。

Depthwise Separable卷积的设计非常精彩但遗憾的是目前cudnn对其的支持并不好,导致在使用GPU训练网络过程中我们无法从算法中获益,但是使用串行CPU并没有这个问题,这也就给了MobileNet很大的市场空间,尤其是在嵌入式平台。

最后,不得不承认v2的论文的一系列证明非常精彩,虽然没有这些证明我们也能明白v2的工作原理,但是这些证明过程还是非常值得仔细品鉴的,尤其是对于从事科研方向的工作人员。

最后更新于