深度学习-计算机视觉CNN

本篇主要分析CV领域的主要代表性CNN网络结构,这些网络结构一般是在图像识别的任务上开发设计的,并迁移应用到其他的计算机视觉任务上。

对于计算机视觉而言,从简单到复杂一般包括:

  • 图像识别
  • 目标检测
  • 图像分割
  • 图像翻译/描述

所谓图像识别,就是识别一张图像的类别,比如说猫狗大战,图像识别是最基础的计算机视觉任务。对于图像识别任务中,待识别的照片类似于大头贴的形式,即目标基本覆盖整张图像的大部分区域。对于机器学习任务,网络的搭建以及模型的训练过程,实际上就是学习输入数据到标签之间的映射的过程,假设输入的数据为X,对应的标签为Y,网络模型为f(.),训练的过程就是为了找到合适的参数使得满足关系式$Y=f(x)$。

图像识别原理及技术

原理

人类识别图像的过程可以描述为:当看到一张图片时,我们的大脑会迅速感应到是否见过此图片或与其相似的图片。其实在“看到”与“感应到”的中间经历了一个迅速识别过程,这个识别的过程和搜索有些类似。在这个过程中,我们的大脑会根据存储记忆中已经分好的类别进行识别,查看是否有与该图像具有相同或类似特征的存储记忆,从而识别出是否见过该图像。

机器的图像识别实际和人对图像的识别方法类似:通过分类并提取重要特征而排除多余的信息来识别图像。机器所提取出的这些特征有时会非常明显,有时又是很普通,这在很大的程度上影响了机器识别的速率。总之,在计算机的视觉识别中,图像的内容通常是用图像特征进行描述。

技术过程

图像识别技术的过程分以下几步:信息的获取、预处理、特征抽取和选择、分类器设计和分类决策。其中这几个过程具体化到深度学习中的属于可以为

  • 信息的获取-数据集的准备及制作
  • 预处理:图像预处理及增强,统一图像尺寸,去除噪声,图像数据归一化等处理
  • 特征抽取:卷积网络进行图像的卷积计算
  • 分类器设计:选择何种分类损失计算函数,对于神经网络一般使用的是softmax
  • 分类决策:选择何种标准作为类别输出,一般为top1

之所以选用图像识别作为其他CV的基础任务,是因为在最大规模的CV数据集ImageNet是图片识别数据,并且图片识别任务最简单,可以很好的向其他任务迁移。

CNN网络框架

在图中非常清晰的说明了对于图像分类任务的pipeline,实际上对于相关从业者或者工程实践而言,整个模型的设计主要集中在输入与输出之间,即图中的绿色括号范围。

目前而言,对于图像分类任务,公认的是使用卷积神经网络进行模型设计。这些年以来,逐步发展起来了AlexnetVggnetGoogleNet/InceptionResnet。模型的深度不断增加,正确率已在不断提升

AlexNet-GPU加速

2012年的Imagenet冠军,这个网络结构的提出具有非常重要的意义,首先验证了cnn在复杂模型下的有效性,此外,使用gpu实现训练使得训练时间大幅度缩减,为以后不断提出的cnngpu计算都是极大的推动。

网络结构如下

图中显示的是两块gpu显卡的训练过程,将输入图片经过第一步的卷积之后得到的feature map分割成两部分进行两块gpu训练,对于目前来讲,已经可以实现单块显卡进行整个模型的训练。输入数据为227x227x3,整个结构中使用了11x11,5x5,3x3的卷积核,最大池化,全连接后面添加了droupout策略抑制过拟合,并且使用的是relu激活函数(之前使用的激活函数为sigmoidtanh),优化方法为SGD+momentum(带动量的随机梯度下降)。

一篇特别好的博客链接 https://www.learnopencv.com/number-of-parameters-and-tensor-sizes-in-convolutional-neural-network/

Vgg-小卷积更有效

vgg是相比于Alexnet的改进,使用的是多个的3x3的卷积核代替了原来的11x11,5x5卷积核,增加了网络的深度的同时,减小了模型的参数量。

对于一个5x5的卷积对应的参数量为$25c^{2}$的参数量,其中$c$为输出入和输出的通道数;如果使用2个3x3步长为1的卷积核,参数量为$2 \times (9c^{2})=18c^{2}$,从而降低了参数量。

  • 一个7x7卷积的感受野$\Leftrightarrow$3个3x3卷积步长为1
    • 参数量为 $49c^{2}$ $\rightarrow$ $3 \times 9c^{2}=27c^2$

Vgg 网络包含多种不同层数的结构,常用的有vgg16和vgg19

从图中可以看出,在vgg进行分块设计,每一个块内,使用的都是相同的卷积核及卷积数量,这种块结构block/modules之后经常使用。模型在ImageNet数据集上的top-5正确率为92.3%

GoogleNet/Inception-模型变宽一些

一般认为featuremap的通道数代表着模型的宽度。

googlenet证明了网络的深度比宽度更加有效,使用更深的网络结构,更多的卷积,可以得到更好的效果。

由于vgg的结构设计,其在内存和时间上的计算要求较高,并且由于卷积层的通道数过多,vgg并不是足够高效。加入对于一个conv_3-512对应的计算量为3x3x512,计算量很大,此外在vgg的结构设计中多个conv_3-512相连构成block,会带来大量计算。

googlenet基于的理念是:在深度网络中大部分的激活值是没有必要的,或者由于相关性是冗余的,因此最高效的神经网络架构应该是激活值之间是稀疏连接的,这就意味着在vgg中的多个512的输出特征图之间是没有必要进行完全连接的。

基于此,**googlenet提出了inception module,使用了一种密集结构来近似一个稀疏连接,在整个模型中使用了多个inception module进行连接从此构建了整个网络。只有很少一部分神经元真正有效,设计了1x1卷积,在保持尺寸不变的同时对通道数进行改变,增加了特征的融合可能性,此外大幅度减少了参数量,可以使模型设计的更加深。**

此外,**googlenet对原来的最后的全连接进行开了改进设计,使用了全局均值池化(在整个2D特征图上计算均值)代替了全连接层**,此举大幅度减少了模型的总参数量。相比vgg准确率有所提升,但是速度相比更快,top-5准确率为93.3%

ResNet-更深网络的可能性

随着模型的层数加深,模型的准确率应该是同步增加的,但是随着网络层数的增加,根据梯度反向传播的优化策略,在逐渐远离输出层时,会出现梯度逐渐变小甚至消失的情况,导致浅层处的网络无法进行参数更新导致学习失败,这就是梯度消失的问题;另一个问题就是,随着网络加深,意味着参数空间更大,优化问题更困难,因此如果简单的单纯增加网络深度有可能会出现更高的训练误差。

深度残差网络(deep residual network)正是针对深层次网络出现的梯度消失问题进行的网络结构改进设计。

对于传统的神经网络而言,图中左侧所示,输入x经过两个网络层之后得到H(x),激活函数为relu,在反向求倒时,在距离输入近的网络层要涉及多个网络层的导数进行交叉相乘,容易引起梯度消失。resnet将网络结构进行了修改,既然距离输入更近,距离输出更远的层会容易出现梯度消失,何不将距离输出更远的层短接到距离输出更近的层上,如图中右侧所示,经过网络传输之后得到的输出变为$H(x)=F(x)+x$,如此以来$F(x)$被设计为拟合输入$x$与目标输出$H(x)$之间的残差$H(x)-x$,残差网络的名称也由此而来。设想如果某一层的输出已经可以很好的拟合期望的结果,那么在此之后再增加一层也不会是的网络变得更差,因为该层输出将直接被短接到两层之后,相当于把学习了一个恒等变换,而跳过的两层之间只需要拟合上层的输出和目标之间的残差即可,学习残差相比于直接拟合新的特征会更加容易。因此可以适应更深层次的网络的同时避免了梯度消失的情形。

resnet主要使用的是3x3卷积,与vgg类似,在vgg的基础上,使用短路连接插入进入形成残差网络,目前可以训练152深度的网络,并且相比传统网络结构正确率更好。

Densenet(2017)-参数再少一点

论文链接

参考博客:CVPR 2017最佳论文作者解读:DenseNet 的“what”、“why”和“how”|CVPR 2017

强推tensorflow implemention : https://github.com/taki0112/Densenet-Tensorflow, https://github.com/xiaofengShi/Densenet-Tensorflow

Torch implementation: https://github.com/liuzhuang13/DenseNet/tree/master/models

PyTorch implementation: https://github.com/gpleiss/efficient_densenet_pytorch

MxNet implementation: https://github.com/taineleau/efficient_densenet_mxnet

Keras implemention: https://github.com/flyyufelix/DenseNet-Keras

非常棒的网络结构,论文中拿来和resnetinception进行对比,设计了全新的结构,简单却有效。前面所述resnet通过残差的设计实现了网络更深的可能性,解决了网络更深的时候的梯度消失问题;inception探究了不同的inception module使网络变得更宽。

在深度学习网络中,随着网络深度的加深,梯度消失问题会愈加明显,目前很多论文都针对这个问题提出了解决方案,比如ResNetHighway NetworksStochastic depthFractalNets等,尽管这些算法的网络结构有差别,但是核心都在于:create short paths from early layers to later layers对于Densenet延续这个思路,那就是在保证网络中层与层之间最大程度的信息传输的前提下,进行了极限连接,网络的每一层的输入都是前面所有层输出的并集,该层学习的特征图也会直接传给其他后面的所有层作为输入。

图中显示的是包含5个dense block,假设第$l$层的变换函数为$H_{l}$(对应一组或两组的BN,Relu,conv的操作),输出为$x_{l}$,对于第$l$层接受所有之前靠近输入端的特征图$x_{0},x_{1}…,x_{l-1}$作为输入,由此可以得到第$l$特征图的输出为
$$
x_{l}=H_{l}([x_{0},x_{1}…,x_{l-1}])
$$
其中$[x_{0},x_{1}…,x_{l-1}]$表示从层$0,1,…,l-1$之间的连接。

densenetresnetcnn的对比如下所示

网络提出的启发

  • Stochastic depth中训练过程中随机drop一些层,可以显著提高resnet的泛化性能,由此可以得出结论神经网络之间其实不一定是一个层级递进的结构,在整个模型结构中,某一层可以不仅仅依赖于近紧邻的上一层的特征,而是可以依赖更前面层学习到的特征。

  • 训练过程中,随机droup很多层也不会破坏算法的收敛性,这说明了深层的resnet有明显的冗余,残差只提取了层间的很少特征,既然每层学习的特征很少,能否降低它的计算量来减少冗余呢?

DenseNet的设计正是基于此,

  • 让每一层都与前面的层进行直接可连接,实现特征的重复利用
  • 网络的每一层设计的很窄,只学习非常少的特征,以实现降低冗余。

第一点是第二点的前提,没有密集连接的话,不可能吧网络设计的很窄,不然网络无法收敛,造成欠拟合。

优点

  • 参数减少,计算开销降低但是精度没变差
  • 抗过拟合,DenseNet可以利用千层复杂度低的特征,更容易得到光滑的且具有更好泛华性能的决策函数

有可能的问题

  • 密集连接带来冗余?:网库每层计算量的减少和特征的重复利用,DenseNet每层只学习很少的特征,使得参数量和计算量显著减少
  • 耗费显存?:网络结构存在反复的拼接,将之前的输出和当前层拼接在一起传递给下一层,每次拼接都将会使用新的内存保存拼接够的特征,由此,一个n层的网络会消耗n(n+1)/2层网络的内存

实现细节

  1. 每层开始的瓶颈层(1x1 卷积)对于减少参数量和计算量非常有用。
  2. VGGResNet 那样每做一次下采样(down-sampling)之后都把层宽度(growth rate) 增加一倍,可以提高 DenseNet 的计算效率(FLOPS efficiency)。
  3. 与其他网络一样,DenseNet 的深度和宽度应该均衡的变化,当然 DenseNet 每层的宽度要远小于其他模型。
  4. 每一层设计得较窄会降低 DenseNetGPU 上的运算效率,但可能会提高在 CPU 上的运算效率

代码详解

参考链接

  1. https://github.com/taki0112/Densenet-Tensorflow

  2. https://github.com/xiaofengShi/Densenet-Tensorflow

  • densenet architecture

在每个densenet block内部进行不同feature 的连接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def Dense_net(self, input_x):
# input_X=[224,224,3],self.filters=12
# 输入图片首先进行conv7-2
# 输出为:[112,112,24]
x = conv_layer(input_x, filter=2 * self.filters, kernel=[7,7], stride=2, layer_name='conv0')
# maxpool3-2 输出[56,56,24]
x = Max_Pooling(x, pool_size=[3,3], stride=2)
# block+transition
x = self.dense_block(input_x=x, nb_layers=6, layer_name='dense_1')
x = self.transition_layer(x, scope='trans_1')

x = self.dense_block(input_x=x, nb_layers=12, layer_name='dense_2')
x = self.transition_layer(x, scope='trans_2')

x = self.dense_block(input_x=x, nb_layers=48, layer_name='dense_3')
x = self.transition_layer(x, scope='trans_3')

x = self.dense_block(input_x=x, nb_layers=32, layer_name='dense_final')

x = Batch_Normalization(x, training=self.training, scope='linear_batch')
x = Relu(x)
x = Global_Average_Pooling(x)
x = Linear(x)

return x
  • Dense block

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    # 模型核心代码
    # 每个densenet block内部存在多个bottleneck层,并且多个在block内部
    # 前面的bottleneck输出传递到后面的bottleneck中
    def dense_block(self, input_x, nb_layers, layer_name):
    with tf.name_scope(layer_name):
    layers_concat = list()
    layers_concat.append(input_x)
    # 进入bottlebneck 层
    # conv1-4n --> conv3-n
    x = self.bottleneck_layer(input_x, scope=layer_name + '_bottleN_' + str(0))

    layers_concat.append(x)
    # layers_concat 包含
    # 原始输入x
    # bottleneck层计算之后的特征
    # nb_layers 对应着denseblock内的包含多少个bottleneck
    # 每增加一个bottleneck整个densenet block输出的层数增加
    # 同时layers_concat保存当前bottleneck的输出并传递到下次计算中
    for i in range(nb_layers - 1):
    x = Concatenation(layers_concat)
    x = self.bottleneck_layer(x, scope=layer_name + '_bottleN_' + str(i + 1))
    layers_concat.append(x)

    x = Concatenation(layers_concat)

    return x
  • Bottleneck layer

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    # 瓶颈层
    def bottleneck_layer(self, x, scope):
    with tf.name_scope(scope):
    x = Batch_Normalization(x, training=self.training, scope=scope+'_batch1')
    x = Relu(x)
    x = conv_layer(x, filter=4 * self.filters, kernel=[1,1], layer_name=scope+'_conv1')
    x = Drop_out(x, rate=dropout_rate, training=self.training)

    x = Batch_Normalization(x, training=self.training, scope=scope+'_batch2')
    x = Relu(x)
    x = conv_layer(x, filter=self.filters, kernel=[3,3], layer_name=scope+'_conv2')
    x = Drop_out(x, rate=dropout_rate, training=self.training)

    return x
  • transition layer

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # 两个densenet block之间的传递层,使用bn+relu+conv1+dropuout+avg_pool
    def transition_layer(self, x, scope):
    with tf.name_scope(scope):
    x = Batch_Normalization(x, training=self.training, scope=scope+'_batch1')
    x = Relu(x)
    x = conv_layer(x, filter=self.filters, kernel=[1,1], layer_name=scope+'_conv1')
    x = Drop_out(x, rate=dropout_rate, training=self.training)
    x = Average_pooling(x, pool_size=[2,2], stride=2)

    return x

应用

图像分割,图像翻译,目标检测任务,本人使用densenet实现过不定长字符的识别任务,效果相当不错,相比于传统的图像encode+RNN_decode的方式训练快速很多并且正确率一点不差。

SqueezeNet(2016) -移动平台运行

SqueezeNet的设计初衷不是为了提高CNN的精度,而是为了简化模型复杂度的同时能够达到可用的网络识别精度,在正确率和参数量之间进行平衡,方便能够在移动端运行。

论文连接

设计原则

在模型设计时,主要从模型压缩,卷积核尺寸,下采样方面着手,并且设计了全新的模块Firemodel,这是squeezenet最显著的贡献。

模型压缩

主要采用的技术有

SVD

使用SVD奇异值分解,减少网络参数。

NetworkPruning

网络剪枝(network pruning):在weight中设置一个阈值,低于这个阈值就设为0,从而将weight变成系数矩阵,可以采用比较高效的稀疏存储方式,进而降低模型大小

DeepCompression

DeepCompression,其包括网络剪枝,权重共享以及Huffman编码技术。简单说一下权重共享,其实就是对一个weight进行聚类,比如采用k-means分为256类,那么对这个weight只需要存储256个值就可以了,然后可以采用8 bit存储类别索引,其中用到了codebook来实现。关于Deep Compression详细技术可以参考文献[deep compression]

Quantization

量化(quantization): 对参数降低位数,比如从float32变成int8,这样是有道理,因为训练时采用高位浮点是为了梯度计算,而真正做inference时也许并不需要这么高位的浮点,TensorFlow中是提供了量化工具的,采用更低位的存储不仅降低模型大小,还可以结合特定硬件做inference加速。

CNNKernel

替换$3 \times 3$卷积核为$1 \times 1$卷积核

延迟下采样

延迟下采样(downsample),前面的layers可以有更大的特征图,有利于提升模型准确度。目前下采样一般采用strides>1的卷积层或者pool layer

FireModule

设计Fire Module减少$3 \times 3$卷积的feature map数量,卷积核的参数为(number of input channels) * (number of filters) * 3 * 3

示意图如下所示,也是squeezenet的核心组件,设计思想是将原来的一层conv变成两层

  • squeeze层+relu

    squeeze中使用$1 \times 1$的卷积核,卷积核的数量为S11,使用$1\times 1$的卷积核可以看做这一步是将当前输入的featuremap在卷积核数量的维度上进行压缩,提取当前featuremap的显著特征;

  • expand层+relu

    expand中存在$1 \times 1$和$3 \times 3$的卷积核,数量分别为E11E33,将二者在维度的方向上进行拼接,并且保证S11<E11+E33,这一步可以理解在featuremap上提取不同的感受野情况下的特征,并且如此设计减少了原来输入到下一层的feature map的数量

  • 具体的示意图如下所示

  • 举个🌰

    假设输入为H,W,C,

    • 如果使用的是普通的卷积方式为conv3-16+conv3-128,

      • 参数量为:

      $$1633C+1283316=144*(128+C)=18432+144C$$

      • 计算量为:

      $$HW16*(33C)+HW128*(3316)=HW(18432+144C)$$

    • 如果使用的是FireModule的卷积方式,conv1-16+conv1-64+conv3-64

      • 参数量为:

      $$1611C+641116+6433*16=10240+16C$$

      • 计算量为(expand中的contact计算为加法运算,可以忽略,只考虑乘法):

      $$
      \begin{align}
      HW16*(11C)+HW64*(1116)&+HW64*(3316) \
      &=HW(10240+16C)
      \end{align}
      $$

    可以看到相比于普通卷积FireModule具有更少的参数和计算量

模型结构

整个模型实际上就是使用了FireModule进行堆叠形成,网络结构如图所示。左侧是标准模式,最开始是一个卷积层,之后就是一堆FireModule的堆叠,中间穿插着stride=2maxpool进行下采样并采用了延迟策略,尽量使前面拥有较大的feature map。中间和右侧结构是借鉴了resnet引入了不同短路机制的squeezenet

下面说一下SqueezeNet的一些具体的实现细节:

(1)在FireModule模块中,expand层采用了混合卷积核1x13x3,其stride均为1,对于1x1卷积核,其输出feature map与原始一样大小,但是由于它要和3x3得到的feature mapconcat,所以3x3卷积进行了padding=1的操作,实现的话就设置padding=”same”;

(2)Fire模块中所有卷积层的激活函数采用ReLU

(3)Fire9层后采用了dropout,其中keep_prob=0.5

(4)SqueezeNet没有全连接层,而是采用了全局的avgpool层,即pool size与输入feature map大小一致;

(5)训练采用线性递减的学习速率,初始学习速率为0.04。

  • 模型效果

    在论文中和alexnet进行了对比,squeezenet在性能相同的情况下,模型的的参数量大为减少。为移动端部署提供了支持,本人在看到这个效果时,不禁对deep compression的压缩方法产生了浓厚的兴趣,不自觉的爆了粗口:我靠,真的假的,接下来要试试自己的模型能否进行这种极限压缩。

  • 代码实现tensorflow

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    class SqueezeNet(object):
    def __init__(self, inputs, nb_classes=1000, is_training=True):
    # conv1
    net = tf.layers.conv2d(inputs, 96, [7, 7], strides=[2, 2],
    padding="SAME", activation=tf.nn.relu,
    name="conv1")
    # maxpool1
    net = tf.layers.max_pooling2d(net, [3, 3], strides=[2, 2],
    name="maxpool1")
    # fire2
    net = self._fire(net, 16, 64, "fire2")
    # fire3
    net = self._fire(net, 16, 64, "fire3")
    # fire4
    net = self._fire(net, 32, 128, "fire4")
    # maxpool4
    net = tf.layers.max_pooling2d(net, [3, 3], strides=[2, 2],
    name="maxpool4")
    # fire5
    net = self._fire(net, 32, 128, "fire5")
    # fire6
    net = self._fire(net, 48, 192, "fire6")
    # fire7
    net = self._fire(net, 48, 192, "fire7")
    # fire8
    net = self._fire(net, 64, 256, "fire8")
    # maxpool8
    net = tf.layers.max_pooling2d(net, [3, 3], strides=[2, 2],
    name="maxpool8")
    # fire9
    net = self._fire(net, 64, 256, "fire9")
    # dropout
    net = tf.layers.dropout(net, 0.5, training=is_training)
    # conv10
    net = tf.layers.conv2d(net, 1000, [1, 1], strides=[1, 1],
    padding="SAME", activation=tf.nn.relu,
    name="conv10")
    # avgpool10
    net = tf.layers.average_pooling2d(net, [13, 13], strides=[1, 1],
    name="avgpool10")
    # squeeze the axis
    net = tf.squeeze(net, axis=[1, 2])

    self.logits = net
    self.prediction = tf.nn.softmax(net)


    def _fire(self, inputs, squeeze_depth, expand_depth, scope):
    with tf.variable_scope(scope):
    squeeze = tf.layers.conv2d(inputs, squeeze_depth, [1, 1],
    strides=[1, 1], padding="SAME",
    activation=tf.nn.relu, name="squeeze")
    # squeeze
    expand_1x1 = tf.layers.conv2d(squeeze, expand_depth, [1, 1],
    strides=[1, 1], padding="SAME",
    activation=tf.nn.relu, name="expand_1x1")
    expand_3x3 = tf.layers.conv2d(squeeze, expand_depth, [3, 3],
    strides=[1, 1], padding="SAME",
    activation=tf.nn.relu, name="expand_3x3")
    return tf.concat([expand_1x1, expand_3x3], axis=3)
赏杯咖啡!