通俗讲:端侧神经网络GhostNet(2019)
GhostNet是华为诺亚方舟实验室提出的一个新型神经网络结构。目的类似Google提出的MobileNet,都是为了硬件、移动端设计的轻小网络,但是效果想摆MobileNet更好。
GhostNet基于Ghost模块,这个特点是不改变卷积的输出特征图的尺寸和通道大小,但是可以让整个计算量和参数数量大幅度降低。简单的说,GhostNet的主要贡献就是减低计算量、提高运行速度的同时,精准度降低的更少了,而且这种改变,适用于任意的卷积网络,因为它不改变输出特征图的尺寸。下面来具体看一看GhostNet到底是怎么实现的吧。
GhostNet发现了一个这样的问题:想象一张要进行分类的图片,这个图片经过卷积层,假设产生了16个通道的特征图,如果我们把16个通道的图画出来,变成16个黑白图片,我们可以保证,每一个特征图都体现了原来图片不同的特征吗?如图19.9所示:
图19.9是处理MNIST手写数据集中第一层卷积层产生的32个特征图的图像,你可以找到多少组重复的、极为类似的特征图呢?不少吧。GhostNet就是觉得,既然有这么多的特征图都是相似的,那么生成相似的特征图的那部分计算量不就是多余的可以节省的嘛。
注意:这个相似的特征图,叫做Ghost,鬼影。
GhostNet的整体结构是仿照MobileNet-v3的结构,只是用Ghost Module作为基本组件,这里也不多将MobileNet-v3的结构了,下面要讲的就是我们能从GhostNet中到底学到什么?学到怎么用GhostNet的基本思想来降低我们模型的计算量。下面的内容就会围绕着GhostNet的Ghost-Blockneck展开。
注意:这个Ghost-Blockneck是基于Ghost Module组件,还有SE Module和Depthwise卷积。
19.14.1 Ghost Module
Ghost Module就是GhostNet的主要贡献了。来看代码:
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)
new_channels = init_channels*(ratio-1)
self.primary_conv = nn.Sequential(
nn.Conv2d(inp, init_channels, kernel_size, stride, padding=kernel_size//2, bias=False),
nn.BatchNorm2d(init_channels),
nn.ReLU(inplace=True) if relu else nn.Sequential(),
)
self.cheap_operation = nn.Sequential(
nn.Conv2d(init_channels, new_channels,dw_size,stride=1,
padding=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,:,:]
一个非常标准的PyTorch模型类的定义。代码中主要有以下内容需要注意:
- 看一下参数,inp就是输入的通道数,oup就是输出的通道数。这里的ratio参数是一个重点,体现了特征图中,有多少的特征图不是Ghost的比例。比方说,生成16个特征图,然后ratio=2的话,就说明有8个特征图不是鬼影,如果ratio=4的话,那就是只有4个特征图不是鬼影。
- init_channels就是不是鬼影的特征图的通道数量,new_channels就是鬼影特征图的通道数量。两者相加应该是等于oup的,但是因为math.ceil(oup / ratio) 是向上取整,所以可能出现两者相加大于oup的情况,所以在forward函数的return中,仅仅返回前oup个通道,来保证输出特征图和预想的通道数是一致的。
- 整个过程也很简单,先用卷积生成init_channels通道数量的特征图,认为这些是有效的、不重复的,然后再用这些init_channels通道特征图再通过卷积生成new_channels个鬼影通道特征图,是鬼影的和不是鬼影的在通道维度上拼接起来,就完事了。但是在生成鬼影特征图的过程中,卷积中出现了一个陌生的参数groups,这个就是分组卷积的知识,下一个小节会讲解。
- 这个Conv层+BN层+ReLU激活函数基本是标准配置了。
19.14.2 Group Convolution分组卷积
之前在MobileNet的深度可分离卷积中已经讲了什么是Depthwise,其实这就是分组卷积的一种形式。如果分的组数等于输入特征图的通道数,那么就是Depthwise了,如果分的组没有那么多,就是一般的分组卷积。具体区分如图19.10所示:
在这里介绍分组卷积只是因为在代码中使用到了这个概念。在代码中可以看到生成鬼影特征图的时候,使用的是分组卷积(也是Depthwise):
self.cheap_operation = nn.Sequential(
nn.Conv2d(init_channels, new_channels,dw_size,stride=1,
padding=dw_size//2, groups=init_channels, bias=False),
nn.BatchNorm2d(new_channels),
nn.ReLU(inplace=True) if relu else nn.Sequential(),
)
有一个参数groups就是要分的组数,如果groups的数值等于输入通道数,那么就是Depthwise的方法。
注意:现在用分组卷积的话,一般就是用Depthwise的方法,所以要是看到Depthwise,实现的时候要想到用分组卷积groups这个参数。
19.14.3 SE Module
SE Module是SENet网络提出的Module,这个网络在2017年的ImageNet的图像分类任务中拿到了冠军。SENet是啥就不说了,2017年的冠军现在在AI领域中现在已经有点过时了,但是SENet的核心SE Module保留了下来。
SE是Squeeze-and-Excitation,挤压与刺激(这个SE的翻译有点奇怪)。废话不多说,直接看代码来理解吧:
class SELayer(nn.Module):
def __init__(self, channel, reduction=4):
super(SELayer, self).__init__()
self.avg_pool = nn.AdaptiveAvgPool2d(1)
self.fc = nn.Sequential(
nn.Linear(channel, channel // reduction), # //是求商,%是求余数。8//2=4,8%2=0
nn.ReLU(inplace=True),
nn.Linear(channel // reduction, channel),
)
def forward(self, x):
b, c, _, _ = x.size()
y = self.avg_pool(x).view(b, c)
y = self.fc(y).view(b, c, 1, 1)
y = torch.clamp(y, 0, 1)
return x * y
这个自适应池化层,其实就是可以随便设置池化之后的尺寸,然后这个都可以适应。比方说,输入的维度是[16,64,7,7],这个batch中有16个特征图,然后每一个图片有64个通道,特征图的尺寸是7*7的,然后假设设置的参数是(5,7),那么可以得到[16,64,5,7]这样的池化结果。具体过程就不讲解了,在这里只用知道怎么用就行了。
这里的参数是1,那么就会产生[16,64,1]这样的结果。说白了,就是一个全局平均池化层嘛。
然后继续上面的例子,把这个[16,64,1]变成[16,64]之后输入到全连接层,经过两层全连接层后还是[16,64]的尺寸,然后x*y就是SE Module的最终返回值。
其实很好理解,相当于SE Module对通道进行了一个权重的评估。有的通道可能重要,有的通道可能不重要,所以经过这个过程让特征图的每一个通道,得到了1个权重值。如何体现权重值的?就是让那个通道的每一个值,都乘上这个权重值就行了,简单粗暴,但是是有效果的。
- 注意:SE Module使用全连接层,如果每一层都是用SE Module的话,可能增加10%左右的计算量。