前面介绍的三个神经网络都是“串联”的,仅仅是卷积层的不断堆叠,结构比较简单。接下来两篇博客要介绍的GoogLeNet和ResNet中开始出现“并联”结构,这也是正式进入目标检测算法前最后要介绍的两个神经网络啦!
目标检测整体的框架是由backbone、neck和head组成的,所以在学习具体的目标检测算法之前,有必要了解一下常见的卷积神经网络结构,这有利于后面学习目标检测算法的backbone部分。此前提到的AlexNet、VGGNet都是通过增大网络的层数来获得更好的训练效果,但是盲目增加网络层数会造成计算资源的浪费,增加网络复杂度不仅要考虑“深度”,也可以考虑“宽度”,GoogLeNet的做法给后面一系列网络结构带来了启发,因此有必要了解一下。
上一篇博客提到的VGGNet在2014年的ImageNet图像分类竞赛中获得了亚军,而冠军就是本文要介绍的GoogLeNet。GoogLeNet专注于加深网络结构,一共有22层,没有全连接层,同时引入了新的基本结构——Inception模块,以增加网络的宽度,这也是核心改进所在。GoogLeNet最初的想法很简单,想要更好的预测效果,就要从网络深度和网路宽度两个角度出发增加网络的复杂度。但这个思路有两个较为明显的问题:首先,更复杂的网络意味着更多的参数,即使是ILSVRC这种有1000类标签的数据集也很容易过拟合;其次,更复杂的网络会消耗更多的计算资源,而且卷积核个数设计不合理会导致卷积核中参数没有被完全利用(多种权重都趋近0),造成大量计算资源的浪费。GoogLeNet通过引入Inception模块来解决上述问题。
concatenation,也被简称为concat或者cat,其实就是一种特征融合方式,即整合特征图的信息。concat可以看成单独一个层,实现对输入数据的拼接。怎么拼接呢?下面来看一个例子:
import numpy as np
A = np.array([[1, 2], [3, 4]])
print("A.shape:", A.shape)
B = np.array([[5, 6]])
print("B.shape:", B.shape)
C = np.concatenate((A, B))
print("C:", C)
print("C.shape:", C.shape)
得到的输出结果如下:
A.shape: (2, 2)
B.shape: (1, 2)
C: [[1 2]
[3 4]
[5 6]]
C.shape: (3, 2)
可以看出,其实就是完成矩阵之间的拼接,维度增加了。PyTorch中也有相应的cat()方法,可以指定按某个维度进行拼接,比如对一个矩阵来说,按行拼就是“竖着拼”,按列拼就是“横着拼”。参数dim默认是0,而PyTorch中特征图的第二维才是channels(batch_size×channels×height×width),所谓的特征融合concat就是把相同高和宽的特征图按照通道叠加拼接在一起,所以写代码的时候要指定一下dim=1。如果要对两个特征图进行concat,一般需要通过上采样或者下采样,将他们的height和width调整成同样大小。下面是PyTorch里的cat()方法使用示例,其实很好理解:
import torch
print("===========按维度0拼接===========")
A = torch.ones(2, 3) # 2x3的张量(矩阵)
print("A:", A, "\nA.shape:", A.shape)
B = 2 * torch.ones(4, 3) # 4x3的张量(矩阵)
print("B:", B, "\nB.shape:", B.shape,)
C = torch.cat((A, B), 0) # 按维数0(行)拼接
print("C:", C, "\nC.shape:", C.shape)
print("===========按维度1拼接===========")
A = torch.ones(2, 3) # 2x3的张量(矩阵)
print("A:", A, "\nA.shape:", A.shape)
B = 2 * torch.ones(2, 4) # 2x4的张量(矩阵)
print("B:", B, "\nB.shape:", B.shape)
C = torch.cat((A, B), 1) # 按维度1(列)拼接
print("C:", C, "\nC.shape:", C.shape)
得到的输出结果如下:
===========按维度0拼接===========
A: tensor([[1., 1., 1.],
[1., 1., 1.]])
A.shape: torch.Size([2, 3])
B: tensor([[2., 2., 2.],
[2., 2., 2.],
[2., 2., 2.],
[2., 2., 2.]])
B.shape: torch.Size([4, 3])
C: tensor([[1., 1., 1.],
[1., 1., 1.],
[2., 2., 2.],
[2., 2., 2.],
[2., 2., 2.],
[2., 2., 2.]])
C.shape: torch.Size([6, 3])
===========按维度1拼接===========
A: tensor([[1., 1., 1.],
[1., 1., 1.]])
A.shape: torch.Size([2, 3])
B: tensor([[2., 2., 2., 2.],
[2., 2., 2., 2.]])
B.shape: torch.Size([2, 4])
C: tensor([[1., 1., 1., 2., 2., 2., 2.],
[1., 1., 1., 2., 2., 2., 2.]])
C.shape: torch.Size([2, 7])
GoogLeNet有很多版本,其区别主要体现在Inception的改进上,每个版本的Inception都在前一版的基础上有所完善,先来看看最初的Inception v1,这也是GoogLeNet的核心模块。
Inception的设计初衷是什么呢?首先,神经网络的权重矩阵是稀疏的,如果能将下面左边的稀疏矩阵与2×2矩阵的卷积转换成右边2个子矩阵与2×2矩阵做卷积的方式,就能大大降低计算量。
[ 5 2 0 0 0 0 1 2 0 0 0 0 0 0 3 7 4 0 0 0 6 4 0 0 0 0 0 0 5 0 0 0 0 0 0 0 ] ⨁ [ 3 4 2 2 ] ⇔ [ 5 2 1 2 ] ⨁ [ 3 4 2 2 ] [ 3 7 4 6 4 0 0 0 5 ] ⨁ [ 3 4 2 2 ] \begin{bmatrix} 5 &2 &0 &0 &0 &0 \\ 1 &2 &0 &0 &0 &0 \\ 0 &0 &3 &7 &4 &0 \\ 0 &0 &6 &4 &0 &0 \\ 0 &0 &0 &0 &5 &0 \\ 0 &0 &0 &0 &0 &0 \end{bmatrix}\bigoplus \begin{bmatrix} 3 &4 \\ 2 &2 \end{bmatrix}\Leftrightarrow \frac{\begin{bmatrix} 5 &2 \\ 1 &2 \end{bmatrix}\bigoplus \begin{bmatrix} 3 &4 \\ 2 &2 \end{bmatrix}}{\begin{bmatrix} 3 &7 &4 \\ 6 &4 &0 \\ 0 &0 &5 \end{bmatrix}\bigoplus \begin{bmatrix} 3 &4 \\ 2 &2 \end{bmatrix}} ⎣⎢⎢⎢⎢⎢⎢⎡510000220000003600007400004050000000⎦⎥⎥⎥⎥⎥⎥⎤⨁[3242]⇔⎣⎡360740405⎦⎤⨁[3242][5122]⨁[3242]
同样的道理,可以考虑将全连接变成稀疏连接,减少参数数量。但是这样做在实现时却不能很好地优化计算量,因为大部分硬件是针对密集矩阵的计算进行优化的,稀疏矩阵虽然数据量比较少,但是在计算上的耗时较长。不过,大量文献表明,可以通过将稀疏矩阵聚类为较为密集的子矩阵来提高计算性能,从而在保持稀疏性的同时保持较高的计算性能。GoogLeNet就是通过构造“基础神经元”来搭建一个稀疏的、具有高计算性能的网络结构。
于是就产生了下图所示的结构,在这个结构中,将256个均匀分布在3×3尺度的特征转换成多个不同尺度的聚类,这样可以使计算更有效,收敛更快。下面是最原始的结构,利用了上一节说到的concatenation方法,使用不同大小的卷积核得到不同尺寸的特征并将其融合。卷积核的大小使用1、3、5的目的是方便对齐:设定步长为1,对三个尺寸的卷积核分别填充0、1、2,对池化操作填充1,通过concatenation方法即可实现相同尺寸特征图的堆叠(尺寸相同,通道相加)。
然而,这种结构仍然不理想,计算量的问题没有得到很好的改善。对于5×5的卷积核来说,假设对100×100×128的输入使用256个5×5的卷积核进行操作,那么参数个数将多达(128×5×5+1)×256=819456。如果在使用5×5的卷积核进行卷积之前,使用32个1×1的卷积操作,步长为1填充为0,那么就会得到100×100×32的特征图,再与256个5×5的卷积核进行卷积的话,这两步走的参数加起来也只有(128×1×1+1)×32+(32×5×5+1)×256=209184,参数量大大减少。整个过程如下图所示,Inception v1就是以上述描述为基础得到的。
最后总结一下Inception v1的特点:
Inception模块里用到了1×1卷积,可能很多人在第一次看到这个东东时和我有着同样的疑惑,和平常用的3×3、5×5卷积相比,这是个什么鬼?其实,1×1卷积的出现解决了很多问题,作用也很大,所以在这里单独分析一波。
1×1卷积首先是出现在Network in Network(NIN)这篇论文当中,一般来讲,其主要作用有以下四个:
对于作用1,降维主要是为了减少参数,上一节的讨论就是最好的例子。GoogLeNet在利用1×1的卷积降维后,得到了更为紧凑的网络结构,虽然总共有22层,但是参数数量却只是8层的AlexNet的十二分之一(当然也有很大一部分原因是去掉了全连接层)。而升维主要是为了用最少的参数拓宽网络的通道数,比如256个3×3的卷积核参数量显然要比256个1×1的卷积核大得多。
对于作用2,也很好理解,因为卷积层后面一般都会接一个非线性的激活函数,所以使用1×1卷积可以在保持特征图尺度不变(即不损失分辨率,不改变height和width)的前提下增加非线性特性。
对于作用3,其实就是通道之间的变换,这个作用可能会抽象一些。使用1×1卷积核,实现降维和升维的操作其实就是通道间信息的线性组合变化。举个栗子:在一个3×3×64的卷积核后面添加一个1×1×28的卷积核,这样就得到了3×3×28卷积核,原来的64个通道就可以理解为跨通道线性组合变成了28个通道,这就是通道间的信息交互。
对于作用4,通过上一篇博客里VGGNet测试阶段用卷积层替换全连接层的例子应该可以理解。
GoogLeNet的结构非常完整,原论文用了整整一面展示网络结构,下面是我把pdf横过来后的截图。
其实看下面这个表格也可以,同样来自原论文,推荐大家去原论文Going deeper with convolutions
里看4K大图:
上表中#3×3reduce、#5×5reduce表示在3×3、5×5的卷积操作之前使用了1×1卷积操作。
除了前文讨论,网络还有以下特性:
首先把Inception模块实现出来。从前面的图可以看出,Inception模块有4条并行的线路,前3条线路分别使用1×1、3×3和5×5的卷积核提取不同空间尺寸下的信息,中间2个线路会对输入做1×1卷积运算,以减少输入通道数,降低模型复杂度。第4条线路则会使用3×3最大池化层,然后接1×1卷积核改变通道数。4条线路都使用了合适的填充,使得输入与输出的特征图高和宽一致。最后将每条线路的输出在通道上合并,并输入到接下来的层中。Inception v1模块实现代码如下:
import torch.nn as nn
import torch
def BasicConv2d(in_channels, out_channels, kernel_size):
return nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size, stride=1, padding=kernel_size // 2),
nn.BatchNorm2d(out_channels),
nn.ReLU(True)
)
class InceptionV1Module(nn.Module):
def __init__(self, in_channels, out_channels1, out_channels2reduce, out_channels2, out_channels3reduce,
out_channels3, out_channels4):
super.__init__()
# 线路1,单个1×1卷积层
self.branch1_conv = BasicConv2d(in_channels, out_channels1, kernel_size=1)
# 线路2,1×1卷积层后接3×3卷积层
self.branch2_conv1 = BasicConv2d(in_channels, out_channels2reduce, kernel_size=1)
self.branch2_conv2 = BasicConv2d(out_channels2reduce, out_channels2, kernel_size=3)
# 线路3,1×1卷积层后接5×5卷积层
self.branch3_conv1 = BasicConv2d(in_channels, out_channels3reduce, kernel_size=1)
self.branch3_conv2 = BasicConv2d(out_channels3reduce, out_channels3, kernel_size=5)
# 线路4,3×3最大池化层后接1×1卷积层
self.branch4_pool = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)
self.branch4_conv = BasicConv2d(in_channels, out_channels4, kernel_size=1)
def forward(self, x):
out1 = self.branch1_conv(x)
out2 = self.branch2_conv2(self.branch2_conv1(x))
out3 = self.branch3_conv2(self.branch3_conv1(x))
out4 = self.branch4_conv(self.branch4_pool(x))
out = torch.cat([out1, out2, out3, out4], dim=1)
return out
假设原始输入图像尺寸为224×224×3。GoogLeNet的第一个模块使用了一个64通道、卷积核尺寸为7×7的卷积层,stride=2,padding=3,输出尺寸为112×112×64,卷积后进行ReLU操作,经过3×3的最大池化操作(stride=2)后,得到的输出尺寸为56×56×64。
nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3),
nn.BatchNorm2d(64),
nn.ReLU(True),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
第二个模块使用两个卷积层,首先经过64通道的1×1卷积层,然后经过192通道的3×3卷积层,对应的是Inception模块中从左到右的第二条线路。
nn.Conv2d(64, 64, kernel_size=1),
nn.Conv2d(64, 192, kernel_size=3, padding=1),
nn.BatchNorm2d(192),
nn.ReLU(True),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
第三个模块Inception(3a):①先经过1×1×64的卷积操作,得到28×28×64的输出特征图并送入ReLU激活函数;②接着经过1×1×96的卷积操作,得到28×28×96的输出特征图并送入ReLU激活函数,然后经过3×3×128的卷积操作(padding=1),得到28×28×128的输出特征图;③再经过1×1×16的卷积操作,得到28×28×16的输出特征图并送入ReLU激活函数,然后经过5×5×32的卷积操作(padding=2),得到28×28×32的输出特征图;④最后经过3×3的最大池化操作(padding=1)后接1×1×32的卷积操作,得到28×28×32的输出特征图。把4个分支合并为64+128+32+32=256个通道,就得到了尺寸为28×28×256的输出特征图。Inception(3b)和Inception(3a)计算过程类似,最后输出480通道。
InceptionV1Module(192, 64, 96, 128, 16, 32, 32),
InceptionV1Module(256, 128, 128, 192, 32, 96, 64),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
第四个模块更复杂了一些,但是理解起来很简单,就是串联了5个Inception模块。
InceptionV1Module(480, 192, 96, 208, 16, 48, 64),
InceptionV1Module(512, 160, 112, 224, 24, 64, 64),
InceptionV1Module(512, 128, 128, 256, 24, 64, 64),
InceptionV1Module(512, 112, 144, 288, 32, 64, 64),
InceptionV1Module(528, 256, 160, 320, 32, 128, 128),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
第五个模块和上述模块类似,只是后面紧跟输出层,该模块需要使用平均池化层将每个通道的height和width都变成1。
InceptionV1Module(832, 256, 160, 320, 32, 128, 128),
InceptionV1Module(832, 384, 192, 384, 48, 128, 128),
nn.AvgPool2d(kernel_size=7, stride=1)
最后,将输出变成二维数组后,接一个输出个数为标签类别数的全连接层。
nn.Dropout(0.4),
nn.Linear(1024, num_classes)
完整的代码如下:
class GoogLeNet(nn.Module):
def __init__(self, num_classes):
super().__init__()
self.conv1 = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3),
nn.BatchNorm2d(64),
nn.ReLU(True),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
)
self.conv2 = nn.Sequential(
nn.Conv2d(64, 64, kernel_size=1),
nn.Conv2d(64, 192, kernel_size=3, padding=1),
nn.BatchNorm2d(192),
nn.ReLU(True),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
)
self.inception3 = nn.Sequential(
InceptionV1Module(192, 64, 96, 128, 16, 32, 32),
InceptionV1Module(256, 128, 128, 192, 32, 96, 64),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
)
self.inception4 = nn.Sequential(
InceptionV1Module(480, 192, 96, 208, 16, 48, 64),
InceptionV1Module(512, 160, 112, 224, 24, 64, 64),
InceptionV1Module(512, 128, 128, 256, 24, 64, 64),
InceptionV1Module(512, 112, 144, 288, 32, 64, 64),
InceptionV1Module(528, 256, 160, 320, 32, 128, 128),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
)
self.inception5 = nn.Sequential(
InceptionV1Module(832, 256, 160, 320, 32, 128, 128),
InceptionV1Module(832, 384, 192, 384, 48, 128, 128),
nn.AvgPool2d(kernel_size=7, stride=1)
)
self.fc = nn.Sequential(
nn.Dropout(0.4),
nn.Linear(1024, num_classes)
)
def forward(self, x):
x = self.conv1(x)
x = self.conv2(x)
x = self.inception3(x)
x = self.inception4(x)
x = self.inception5(x)
x = x.view(x.size(0), -1)
out = self.fc(x)
return out
可以把以下FlattenLayer加在全连接层容器最前面,来替换掉forward里的x.view,用下面的代码查看各层输出的尺寸:
class FlattenLayer(nn.Module):
def __init__(self):
super().__init__()
def forward(self, x):
return x.view(x.size(0), -1)
net = GoogLeNet(1000)
X = torch.rand(1, 3, 224, 224)
for block in net.children():
X = block(X)
print('output shape: ', X.shape)
得到的输出结果如下:
output shape: torch.Size([1, 64, 56, 56])
output shape: torch.Size([1, 192, 28, 28])
output shape: torch.Size([1, 480, 14, 14])
output shape: torch.Size([1, 832, 7, 7])
output shape: torch.Size([1, 1024, 1, 1])
output shape: torch.Size([1, 1000])
下面简要介绍一下Inception v1改进:Inception v2,更多的诸如Xception这样的变体就先不讨论了,后面有时间再专门整理个Inception系列家族。之所以介绍v2不介绍后续的v3是因为v3里很多东西前面的博客还没有涉及到,v2的核心思想在VGGNet里有提到一下。Inception v2和Inception v3是在同一篇论文Rethinking the Inception Architecture for Computer Vision中出现,提出Batch Normalization的论文Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift并不是Inception v2。两者的区别在于论文里提到了多种设计和改进技术,使用其中一部分结构和改进技术的是Inception v2,全部使用了的是Inception v3。
GoogLeNet团队在Inception v1的基础上,提出了卷积核分解和特征图尺寸缩减两种优化方法。由于较大的卷积核尺寸可以带来较大的感受野,但同时会带来更多的参数和计算量,所以GoogLeNet团队提出了用两个连续的3×3卷积核代替一个5×5卷积核,在保持感受野大小的同时减少参数个数。第一个3×3的卷积核通过卷积,得到一个3×3的特征图,然后通过一个3×3的卷积核产生一个1×1的特征图,输出尺寸与通过一个5×5的卷积核得到的输出尺存相同,且参数的个数有所减少。大量实验证明,这种替换不会造成表达缺失。这也是在上一篇博客中讨论过的。
在此基础上,GoogLeNet团队考虑将卷积核进一步分解。例如,将3×3的卷积核分解,如下图所示:先采用1×3的卷积核进行卷积,再通过3×1的卷积核进行二次卷积,最终的输出尺寸与使用一个3×3的卷积核进行卷积后的输出尺寸相同,且参数的数量又有所减少。所以,一个n×n的卷积核能够由1×n和n×1的卷积核的组合代替。GoogLeNet团队发现,在网络低层使用这种方法的效果并不好,而在中等大小的特征图上使用这种方法的效果比较好(建议在第12层到第20层使用)。
缩减特征图尺寸的两种方式,如下图上面的两个结构所示:一是先进行池化操作,再做Inception卷积;二是先做Inception卷积,再进行池化操作。然而,这两种方式都有弊端:采用先池化、再卷积的方式,很可能会丢失部分特征;采用先卷积、再池化的方式,计算量会很大。为了在保持特征的同时降低计算量,GoogLeNet团队让卷积操作与池化操作并行,即先分开计算、再合并。
不知不觉1w多字了orz,Batch Normalization等有时间了再单独记录吧~