为什么图像领域使用CNN?
- 传统的FC每一层都是全部的输入。但是在图像作业中,只要知道局部的特征就足够进行一些判断了
- 一些局部特征会重复出现在不同图像的不同位置(平移不变性),虽然位置不同,但这种特征识别仍然是重复性的工作,最好使用相同的神经元来完成
CNN的卷积实现了以上两点
- 适当的下采样不影响图像所传达的含义,但像素少了,参数也就少了,网络更简单了,还减轻了过拟合
CNN的最大池化支持了这一点
1 卷积
1.1 卷积核
卷积核Filter/Kernel是一个矩阵,里边的参数是学习到的。卷积核实际上是一个小图像,通过对整张图像的卷积,来寻找整张图里和这张小图像一样的部分。卷积核在扫过整个图像的过程中,步长记为stride s。
但有时候卷积核移动一个步长之后超过了图像边缘,此时是没法计算的,只能另起一行或者结束。这时候可以对边缘进行填充padding,这也是卷积过程中的一个重要参数p。padding=valid时,不填充,p=0,该怎么样还是怎么样;padding=same时,就从图像边缘往外补0,使得卷积核可以扫略完,此时p=(k-1)/2,k为卷积核大小。
给定卷积核size=k x k x #channel,步长stride s,填充padding p,输入图像尺寸i x i,经过卷积之后,输出大小o x o为
$$
o= \lfloor \frac{i+2p-k}{s}+1 \rfloor
$$
上述公式比较通用,具体可以根据padding用以下公式快速计算:
- padding = valid:$o = \lceil (i-k+1)/s \rceil$
- padding = same:$o = \lceil i/s \rceil$
tensorflow中的卷积
最常用的2维卷积tf.nn.conv2d
,4个主要参数,输入,卷积核,步长,填充。
tf.nn.conv2d(
input, # 4D tensor (batch, h, w, #channels)
filter, # 4D tensor (h, w, #in_channels, #out_channels)
strides, # 4 length list (1,s,s,1)
padding, # string 'VALID' or 'SAME'
use_cudnn_on_gpu=True,
data_format='NHWC',
dilations=[1, 1, 1, 1],
name=None
)
在处理一些序列时,可能会用到1维卷积tf.nn.conv1d
:
tf.nn.conv1d(
value, # 3D tensor (batch, w, #channels)
filters, # 3D tensor (w, #in_channels, #out_channels)
stride, # integer
padding, # string 'VALID' or 'SAME'
use_cudnn_on_gpu=None,
data_format=None,
name=None
)
处理三维体素时,可能会用到3维卷积tf.nn.conv2d
。和1维2维类似,不再赘述。
1.2 多通道中的卷积
在多通道中,每个通道被1个卷积核卷完之后,将结果沿着通道方向加起来,得到通道只有1的feature map。
一般来说会使用多个卷积核。那么每个卷积核都能卷出一个feature map,在通道方向上拼接起来,于是将一个2维图像平面卷积成长方体。
一般都是image-conv-pool-conv-pool-…-fc-…-fc-softmax这样的架构,一张图像经过一系列conv-pool之后展成1个vector,喂入fc。卷积核一般随着层越深数量越多,feature map也越卷尺寸越小、通道越大。
1.3 1x1卷积核
很多网络中都出现了1x1的卷积核,作用就是在不改变尺寸的条件下改变通道数。1x1的卷积核实际上就是FC,卷积核数量就是改变之后的通道数。
1.4 全卷积
卷积核尺寸就是feature map尺寸,则卷积结果为1x1x卷积核数量维的向量,一般用来代替fc实现支持任意尺寸的输入。
1.5 分解卷积
用两个1维卷积实现一个2维卷积的效果。比如采用3x1和1x3的两次卷积相当于3x3的卷积的效果,但是参数却是6:9,节省资源,而且多了一层非线性。
1.6 空洞卷积
卷积核是有空洞的,这样使用较少的参数就可以获得较大的感受野,还可以去掉池化层避免信息丢失。空洞卷积又叫膨胀卷积/暗黑卷积(什么鬼名字)。tensorflow中的空洞卷积步长固定为1,而空洞则由参数rate控制:
tf.nn.atrous_conv2d(
value, # 4D tensor (batch, h, w, #channels)
filters, # 4D tensor (h, w, #in_channels, #out_channels)
rate, # + int32。rate-1为空洞的大小
padding, # string 'VALID' or 'SAME'
name=None
)
1.7 转置卷积
上采样常用的操作,虽然和反卷积不一样,但也常被叫做反卷积。GAN中经常用到。
tf.nn.conv2d_transpose(
value, # 4D tensor (batch, h, w, #channels)
filter, # 4D tensor (h, w, #in_channels, #out_channels)
output_shape, # 1D tensor
strides, # 4 length list (1,s,s,1)
padding='SAME',
data_format='NHWC',
name=None
)
1.8 CNN和FC的关系
一张图像image经过一个卷积核filter卷积一次之后得到了一张更小的feature map。feature map的每一个点就是神经元输出,每个神经元的输入就是得到该点的那次卷积所卷过的image的像素。这样看来,每个神经元的输入不是image的所有像素,而是filter大小数量的像素——所以,CNN是稀疏连接版的FC。此外,由于filter是同一个,所以feature map中神经元的参数是共用的,和fc相比极大减少了参数数量。
2 池化pooling
池化做的事情就是下采样。池化有max pooling(更常用)和average pooling,以步长进行扫略,计算池化size区域内的值,所以也有2个参数步长s和尺寸k。
池化会提供一些旋转不变性,这点很容易理解
但是是否需要池化要好好考虑。比如Alpha Go就不应该使用,下采样丢了很多信息在围棋中可不是好事。
# 平均池化
tf.nn.avg_pool(
value, # 4D tensor (batch, h, w, #channels)
ksize, # 4 length list (1,s,s,1)
strides, # 4 length list (1,s,s,1)
padding, # string 'VALID' or 'SAME'
data_format='NHWC',
name=None
)
# 最大池化tf.nn.avg_pool
# 参数和平均池化相同
随机池化SP的思想是,将区域内的元素池化为为区域内某个元素的值,元素值大的被选中的概率更大。
一般池化区域是不重叠的,即s=k,除了一般池化外,还有其他类型的池化。
空间金字塔池化SPP
空间金字塔池化SPP能够支持任意输入大小的图像。网络确定之后,受限于FC的输入维度,对图像尺寸是有要求的,不满足就要进行裁剪crop或者拉伸wrap,破坏了图像的信息。但使用金字塔结构来进行池化,就可以把feature map按照金字塔不同层i的尺度$(w/n_i,h/n_i)$固定提取出$\sum n_i$个特征,组成特征向量喂入fc。
全局池化GP
全局池化的池化区域和整个feature map的尺寸一样大,这样w*h*c的feature map就会被转化为1*1*c的向量输出。
在CNN的最后一般有fc,输入是将上一层的所有feature map展成vector拼接起来,所以fc的参数仍然很多,降低训练速度,容易过拟合。如果使用全局池化代替fc,比如全局平均池化GAP,池化后reshape直接将c维向量喂入softmax,就极大减少了参数。
从网络结构上考虑,全局平均池化实际上就是一种正则化方法,避免网络过拟合。
GAP拿掉了FC,任意尺寸的输入也被支持了,并且操作简单,应用越来越广泛。
3 CNN中的反向传播
又到麻烦的环节了。手推BP是必须的。先回顾下DNN的BP:
$$
\begin{aligned}
\delta ^L &= \frac{\partial J}{\partial Z^L}\\
\delta ^l &= \delta ^{l+1} W^{l+1}\sigma ^\prime (Z^l)\\
\frac{\partial J}{\partial W} &= \delta ^l A^{l-1}\\
\frac{\partial J}{\partial b} &= \delta ^l
\end{aligned}
$$
CNN除了维度不同外,还有一些特点:
- 池化层因为没有激活函数和参数,所以不用考虑激活函数和参数的求导,只需要考虑BP到上一层,这一点倒是很方便
- 池化层进行了下采样,它BP到上一层就和DNN很不同
- 卷积层的特性导致对该层的W,b以及BP到上一层的方法和DNN都不同
于是分开考虑:
3.1 池化层BP
不论是什么类型的池化,都需要先把误差上采样upsample之后才能继续反向传播。上采样的方法当然和池化方法有关,思路是很简单的,这里不再赘述。设池化层误差为$\delta ^l$,则BP为
$$
\delta ^{l-1} = upsample(\delta ^l)\sigma ^\prime (z^{l-1})
$$
3.2 卷积层BP
卷积层FP为
$$
a^l = \sigma (z^l)=\sigma(a^{l-1}*W^l+b^l)
$$
BP其实和DNN也是很像的:
$$
\delta ^{l-1} = \frac{\partial J}{\partial z^l}\frac{\partial z^l}{\partial z^{l-1}} =
\delta ^l * rot180(W^l)\sigma ^\prime (z^{l-1})\\
$$
这里rot180操作是将原卷积核各参数倒序排列构成新卷积核,原因可以拿个卷积核推导试试,就不赘述了。
3.3 卷积层求本层参数梯度
w的梯度直接算,非常简单:
$$
\frac{\partial J}{\partial W^l} = \frac{\partial J}{\partial z^l} \frac{\partial z^l}{\partial W^l} = a^{l-1} * \delta ^l
$$
b因为是1维向量,所以算法是求和。
4 可视化
把低层的filter可视化可以看到它们在识别一些低维特征,如某些方向的边缘、色彩等。
高层的filter可以这样可视化:将filter参数固定,设第k个filter的输出为矩阵$a_{ij}$,定义激活函数为$a^k=\sum \sum a^k_{ij}$将输入图片的像素值随机初始化,找到输入图片$x^*=arg \max_x a^k$。
FC也可以通过这种方式可视化。不过最后一层可视化可能会是噪声——机器对图像理解的编码和人类还是不同的,这也为对抗攻击提供了可能。加上正则化的话可能能够可视化出稍微可辨识些的结果。
5 网络赏析
本部分基于Tensorflow对前3个经典网络(LeNet, AlexNet, VGG-16)进行了复现,代码见这里。使用mnist和cifar-10数据集进行实验。Inception Net和ResNet搭建比较耗时,只学习了源码。
5.1 LeNet
现代CNN开山经典之作(不是最早)。最经典的CNN网络架构(conv–pooling–non-linear)^n-fc就是由此而起。
3个卷积层,2个平均池化层,1层fc,输出层高斯连接(RBF网络,但softmax更好,所以不做深入)。
在实验代码中,用fc代替rbf,然后算
tf.nn.softmax_cross_entropy_with_logits
作为loss,并且加上了dropout
结果受初始化影响比较大,用tf.truncated_normal_initializer
的话,有时候精度在5、60就上不去了,有时候可以到90+,并且不加dropout的话很容易陷入局部最优。GAN难训练有目共睹,于是我从DCGAN中把tf.contrib.layers.xavier_initializer
引入了这里,效果完美,准确率97+。(后来发现slim.conv2d默认的就是这个初始化器)
5.2 AlexNet
更宽更深的LeNet,并且采用了ReLU、Dropout、数据增强、多GPU、LRN。奠定了DL在CV中的地位。
局部响应归一化LRN是RBF径向基神经网络层,将生物中被激活的神经元抑制相邻神经元的机制(侧抑制)应用到人工神经网络中,将大的值变得更大,小的更小,不过后来被VGG指出并没有什么卵用……所以这里就不做深入。
5个卷积层,3个最大池化层,3个fc,softmax输出。在实际搭建过程中,为了适应MNIST的尺寸,自行调整了所有层的参数。
AlexNet参数多真不是盖的,为了跑MNIST参数已经改得比原网络少了很多了,单1080卡跑起来还是略吃力,而且最后test的时候内存会不够(这还只是MNIST啊,只能忍痛割测试集了)。和LeNet一样,
xavier_initializer
和relu更配哦~
5.3 VGG-Net
VGG层数更多(11-19层),更小的卷积核尺寸(全部使用3*3卷积核),并且经常出现好几层卷积核叠加的情况,这很好地体现了“深度”网络的设计,更深比更广的参数更少、表现更好。例如2层3*3卷积相当于1层5*5卷积,但参数却是18:25,训练起来更快。并且前者ReLU用了2次,后者才用了1次。这也带来更强的非线性,学习能力更强。
VGG还是一个泛用性很强的模型,识别、风格化等很多任务都选择用训练好的VGG来提取和编码图像或迁移学习。MNIST在VGG的架构上到后面是没办法继续卷积的,我采用了CIFAR-10数据集进行试验。除了把最后一层fc换成10维之外其他未作变动。
用
slim
的arg_scope
和repeat
搭建VGG这种局部高重复性的网略简直太爽。
至于训练那必然比跑MNIST的AlexNet慢多了,实验搭建了VGG-16跑CIFAR-10数据集。一开始用了和AlexNet一样的1e-4的学习率不收敛,毕竟网络深,调小了学习率(1e-5)之后终于有了收敛的迹象,吃完饭回来之后跑了70个epoch,达到了90左右的准确率。这里直接粗暴地用了理论最强优化器AdamOptimizer
,但是在很多CV领域,SGD+Momentum(tf.train.MomentumOptimizer
)的表现要优于自适应学习率的优化方法。AdamOptimizer在最终100个epoch的时候,准确率也只有93左右。
当然,slim有内置的vgg可以直接调用slim.nets.vgg
来构建vggnets。实际训练中可以先训练层数少的VGG,再逐步增加层数,参数复用,就能训练出比较深的VGG。
5.4 InceptionNet
Inception Module
VGG这种堆叠架构虽然可以通过不断卷积而提取到不同尺度的特征,但因为池化层的存在,还是多多少少有信息损失。那么如果在一次卷积过程中分别用不同尺寸的卷积核进行卷积并把各结果和直接池化的结果拼接起来并传入下一层,是不是就能减少池化的信息丢失呢?同时还能拥有多种尺寸的感受野。Inception模块就是基于这个天马行空的理念。
Inception V1
但是5*5的卷积核以及拼接得很深的feature map带来的计算量还是略大,于是有了Inception V1,它用了很多尺寸为1的卷积核来减少feature map的channel,并且用GAP替换含有大量参数的fc。Inception V1有22层,但参数只有500万,是AlexNet的1/12。
- V1的底层用的还是传统的CNN结构,再深才用的Inception Module
- 实际代码中在GAP之后还是用了一层1024维的ReLU FC用于泛化到其他数据集以及炼丹……而且即使去掉了FC,dropout还是有用的(设为70%的dropout概率。能带来0.6%的Top-1准确率提升)
- 网络比较深,容易遇到梯度消失问题,所以在中间过程用了两个额外分类器AC(都是softmax),以0.3加权的方式将低层的梯度加到从末端传回来的梯度上。当然在test过程中这两个AC就不需要了
在训练中,采用了以下数据增强手段:
- 8%~100% crop,宽高比4/3或3/4
- 变光照
- 在后期使用各种随机插值来resize(但他们表示没法明确分辨是否有用)
学习率每8步减少4%,优化器使用带0.9动量的ASGD(这个异步随机梯度下降就不做深入了解了,好像是和服务器并行计算有关)
Inception V2
Inception V2借鉴了VGG的深>广的思路,使用堆叠的2个3*3卷积代替5*5卷积。其最大的贡献是提出了如今广泛使用的BN。有了BN强大的正则化能力,因此可以去掉droput并减小L2正则,学习速率提升,学习衰减速率增加。去掉了Incetion模块间的池化层,常规卷积之后直接是Inception模块堆叠。
Inception V3
Inception V3使用了卷积分解,将二维卷积拆分成2个一维卷积,又极大减少了一波参数、增加了一次非线性。不过卷积分解不适合用在比较靠前的层里。另外也对Inception模块进行了改进,设计了3种Inception模块,并且在AC分支上的FC也加了BN。
Inception V4
Inception V4对V3进行了更深和更优化的设计,废话不多说,一图胜千言
Inception-ResNet
和Inception V4同时提出来的还有Inception-ResNet V1/V2,借鉴了ResNet的残差块结构。最终各Inception网络对比下来,Inception-ResNet V2准确率最高,Top-5 error只有4.9%
5.5 ResNet
ResNet可以将网络层数堆叠到相当多的程度。我们知道网络太深容易出现梯度消失,此时的问题是还没达到足够小的误差就已经因为梯度趋近于0导致无法再继续训练,而不是参数多引起过拟合。
ResNet的BottleNeck结构允许前面层的网络输出跳过中间几层,直接输入到后面的层里。直接传递到后边的部分是idendity,中间的几层是residual。论文里提出了2种类型的bottleneck:
这种skip connection的传递方式,保证了信息的完整性,同时也让网络只需要学习输入输出的残差,简化了学习。第一种常规的BottleNeck用于浅层网络的搭建,第二种BottleNeck使用了1*1卷积先降低维度,经过常规卷积之后又用1*1卷积恢复维度,减少了参数数量,用于深层网络中。
BottleNeck用公式表示为:
$$
z^l = h(x^l)+F(x^l),x^{l+1}=f(z^l)
$$
其中$h(x^l)=x^l$或$h(x^l)=Wx^l$,根据卷积后的尺寸是否一致而定。
在细节上,以浅层BottleNeck举例:
注意residual最后没有relu,不然residual的输出总是非负的,“残差”的效果实际上没了。
V2改进了BottleNeck的结构:
V2的设计遵循了这样的准则:add之后不改变分布。所以relu移到了residual内,保证identity一条线下来只有+residual。但是V1中提到过,redisual的最后不能是relu,因此V2的residual中使用的是bn-relu-conv的堆叠方式,在ResNet里表现比conv-bn-relu要好。(我感觉V2的结构才更贴近传统的conv-bn-relu,只是bn的输入是上一个residual的最后一个conv加上了identity。V1在conv之前加了identity,但没bn,不够充分)
slim.nets.resnet
为官方实现。大概说一下代码结构:
nets/resnet_utils.py
定义了Block
类- 块内的下采样使用1x1的最大池化实现
- 将stride=1 SAME卷积和下采样封装成了
conv2d_same
stack_block_dense
将一个Block对象转化为网络。这里使用了带孔的卷积,可以扩大感受野而不会使卷积结果尺寸变小。
resnet.py
定义了bottleneck
类,实现了残差块的深度瓶颈结构。- 定义一个Block序列,使用
resnet_v1/v2
转化为整个网络。不同层数的ResNet只需要修改该Block序列就可以。