目标检测入坑指南4:GoogLeNet神经网络

前面介绍的三个神经网络都是“串联”的,仅仅是卷积层的不断堆叠,结构比较简单。接下来两篇博客要介绍的GoogLeNet和ResNet中开始出现“并联”结构,这也是正式进入目标检测算法前最后要介绍的两个神经网络啦!


在这里插入图片描述

一、引言

目标检测整体的框架是由backbone、neck和head组成的,所以在学习具体的目标检测算法之前,有必要了解一下常见的卷积神经网络结构,这有利于后面学习目标检测算法的backbone部分。此前提到的AlexNet、VGGNet都是通过增大网络的层数来获得更好的训练效果,但是盲目增加网络层数会造成计算资源的浪费,增加网络复杂度不仅要考虑“深度”,也可以考虑“宽度”,GoogLeNet的做法给后面一系列网络结构带来了启发,因此有必要了解一下。

上一篇博客提到的VGGNet在2014年的ImageNet图像分类竞赛中获得了亚军,而冠军就是本文要介绍的GoogLeNet。GoogLeNet专注于加深网络结构,一共有22层,没有全连接层,同时引入了新的基本结构——Inception模块,以增加网络的宽度,这也是核心改进所在。GoogLeNet最初的想法很简单,想要更好的预测效果,就要从网络深度和网路宽度两个角度出发增加网络的复杂度。但这个思路有两个较为明显的问题:首先,更复杂的网络意味着更多的参数,即使是ILSVRC这种有1000类标签的数据集也很容易过拟合;其次,更复杂的网络会消耗更多的计算资源,而且卷积核个数设计不合理会导致卷积核中参数没有被完全利用(多种权重都趋近0),造成大量计算资源的浪费。GoogLeNet通过引入Inception模块来解决上述问题。

二、网络结构

1. Concatenation

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]) 

2. Inception v1

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的特点:

  1. 卷积层共有的一个功能,可以实现通道方向的降维和增维,至于是降还是增,取决于卷积层的通道数(卷积核个数);
  2. 由于1×1卷积只有一个参数,相当于对原始特征图做了一个scale,并且这个scale还是通过训练学习到的,对识别精度就会有提升;
  3. 增加了网络的深度和宽度
  4. 同时使用了1×1,3×3,5×5的卷积,增加了网络对尺度的适应性

3. 1×1卷积

Inception模块里用到了1×1卷积,可能很多人在第一次看到这个东东时和我有着同样的疑惑,和平常用的3×3、5×5卷积相比,这是个什么鬼?其实,1×1卷积的出现解决了很多问题,作用也很大,所以在这里单独分析一波。

1×1卷积首先是出现在Network in Network(NIN)这篇论文当中,一般来讲,其主要作用有以下四个:

  1. 进行通道数的降维和升维;
  2. 增加网络的非线性;
  3. 实现跨通道的交互和信息整合;
  4. 实现与全连接层等价的效果。

对于作用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测试阶段用卷积层替换全连接层的例子应该可以理解。

4. 整体设计

GoogLeNet的结构非常完整,原论文用了整整一面展示网络结构,下面是我把pdf横过来后的截图。

在这里插入图片描述

其实看下面这个表格也可以,同样来自原论文,推荐大家去原论文Going deeper with convolutions
里看4K大图:

在这里插入图片描述

上表中#3×3reduce、#5×5reduce表示在3×3、5×5的卷积操作之前使用了1×1卷积操作。

除了前文讨论,网络还有以下特性:

  1. 采用模块化结构,组合拼接Inception结构,便于调整;
  2. 采用平均池化和全连接层的组合,实验证明这样做可以将准确率提高0.6%;
  3. 为了避免出现梯度消失的问题,网络额外增加了两个辅助softmax函数,目的是增强反向传播的速度。在训练时,它们产生的损失会被加权到网络的总损失中;在测试时,它们不参与分类工作。

三、实例演示

首先把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等有时间了再单独记录吧~

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码
< <上一篇
下一篇>>