深度视觉入门(下)

在掌握卷积、池化等基本元素,并成功复现了AlexNet并理解其思路后,我们可以利用这些基本元素来构建属于自己的浅层神经网络架构,以解决小型数据集上的分类问题了。自建架构听起来和“自建算法”很类似,仿佛是一种重新造轮子的费时费力的做法,为什么我们要学习自建架构呢?事实上,计算机视觉中还有数个更强大(层数更多更深的)的经典架构可以学习,难道它们不足以完成我们希望完成的任务吗?没错,与机器学习中可以直接拿来用的“强学习器”们不同,深度学习中的经典架构主要是处于“指导”地位的基本准则而已,大多数时候,他们并不能够直接解决我们眼前具体的问题。

对于经典机器学习算法而言,算法的学习能力大多由其根本的数学逻辑决定,不同算法之间的数学逻辑不可轻易切换或混用,无论我们如何调整参数,也不会轻易改变机器学习算法的基本逻辑,因此,通常来说,强大的算法在任意数据上都会显得更强大,比如XGBoost及其变体,简直就是机器学习界的“魔戒”,可以应对几乎所有数据。

然而,在深度学习中,每个架构都是为眼前的数据“量身定制”的,虽然存在一些底层的逻辑和思路,但一般来说,都没有严格的数学逻辑,也没有必须遵循的信息处理流程。虽然我们也会依赖经典架构来进行建模,但当输入数据发生变化之后,任何经典架构都可能需要调整、甚至变得完全不适用,因此我们必须具备改变经典架构的能力和勇气。根据输入数据的不同,我们可能会完全改变层的数目、层的参数、层的排列组合、串联并联方式等内容,在多次改动之后,我们可能完全看不出原始架构的痕迹了,也就谈不上利用经典架构来解决眼前的问题。

同时,除了考虑模型的效果之外,我们还得考虑模型的效率——深度学习模型需要大量数据进行训练,这让大家对于模型的效率更加敏感,在达成相似效果的前提下,参数更少的CNN会更受欢迎。虽然还有许多更加强大、更深的架构等待我们去学习,但对于小型数据集以及大部分个人所能获得的计算资源来说,20层以下的浅层CNN才是最经济实惠的架构。20层以下的经典架构本来也不多,在此基础上充分发挥自建网络的可能性,才是正确的道路。因此,即便你还没有学习最先进的卷积架构,你也需要具备提升模型表现的基本思路,直到你能够应对任何小型图像、小数据集的分类任务。

在自建架构时,我们需要时刻牢记模型评估的三角:效果、效率(运算速度)、可解释性。对于深度学习架构而言,可解释性几乎可以忽略不计,但是如果我们能够使用可解释的方式对数据进行处理,我们也不会刻意避开。

在这里插入图片描述

模型效果和运算速度对我们来说是最为关键的考虑因素。在讲解AlexNet时,我们已经说明,“小卷积核、多通道、更深层”的规则在实践中被证明是有效的,这个规则可以一定程度上保证我们架构的效果,但围绕这种架构的疑问还很多,比如,为什么这样的架构会有效?能够严谨证明吗?如果深度会更有效,我能够自由修改/增删层的结构吗?假设更深的架构有效,我们能否承担深层架构的训练成本呢?在自建卷积网络的时候,这些都是我们要考虑的因素。接下来,我们就从架构的执行效率以及学习能力两个方面进行更深入的说明。

一 架构对学习能力/鲁棒性的影响

1 深度

1.1 困难与实践

更深的网络会展现出更强大的学习能力,这是深度学习领域的一个普遍认知,因此在建立自己的神经网络时,一个常用的思路就是“加深现有模型的深度”来提升模型效果。

  • 深度
    卷积神经网络当中,带有权重的层的数量,也指全部层的数量。通常来说,深度越深,参数量越大,卷积网络的规模越大。
    需要注意的是,在一些英文文献中,将每层上特征图的数量称之为"depth",这个称呼其实是为了和特征图的高(Height)、宽(Width)相对应,但为避免混淆,在课程中我们将不使用"depth"或深度一词来描绘特征图的数量。我们使用“广”(Wide)来描绘特征图数量非常多的卷积层。

理想是美好的,但在追寻“深度”的路上,我们总是会遭遇理论和实践上的重重困难。第一个难以忽视的问题,就是输入图像的尺寸会限制我们可以选择的深度。即便你知道继续加深会获得更好的效果,你能够建立的网络远远没有你想象中的那么深。

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

可见,从加深深度的角度来说,我们能够做的就是“递减2或4”,或者“保持特征图不变、并将减小特征图的工作较给池化层去做”这两种选项。在输入的图像的结构都为(128,1,224,244),且每层输出的特征图数目相同,且进入FC层之前的特征图尺寸被缩减至相似大小(7x7,8x8)的前提下,逐层递减的架构(1)与保持特征图不变的“重复”架构(2)分别如下:

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

如果递减架构不如重复架构有效,那我在重复架构上不断加深网络是否有效呢?在AlexNet出圈2年之后,以卷积、池化层为基础继续奋斗在“加深深度”这一方向并取得成功的架构,就是VGGNet,他们的核心思想就是使用多个连续且保持特征图尺寸不变的卷积层来增加深度,以增加算法的学习能力(其架构与输入为224x224的重复架构,即上图中的图(2)很类似,不过图(2)中的架构并没有随着特征图的缩小而
放大特征图数目)。遗憾的是,VGGNet从未成为ILSVRC的冠军,但它代表了一族仅仅使用卷积和池化层的CNN,并且已经被证明在各种小型数据集的问题上有很好的表现。

VGGNet的论文中提出了6种重复架构,其中每重复几个卷积就会跟一个最大池化层,这种“n个卷积+池化”的结构在VGG中被称之为“块”(block)。在深度卷积网路中,我们常常会划分“块”,VGG是按照特征图尺寸的变化来进行划分(每池化一次,就分一次块),6种架构都有五个块。

在这里插入图片描述

其中架构A与AlexNet高度相似,架构B被称为VGG13(包含10个卷积层+3个线性层),架构D被称为VGG16(包含13个卷积层+3个线性层),架构E被称为VGG19(包含16个卷积层+3个线性层)。在这三个网络中,VGG13的效果远远不如19层和16层的网络,因此几乎不会被提及。在ILSVRC中获得高分的是VGG19,然而在实际应用中拔得头筹的却是VGG16,许多研究证明,VGG19的深度更深,但它在效果
上对比起VGG16却只是“slightly better”(很微小的进步)。在论文《Layers Modification of Convolutional Neural Network for Pneumonia Detection》(卷积神经网络在肺炎检测中的架构修改)中,笔者分别使用了VGG16、VGG19以及作者调整后的一个35层的卷积神经网络,得到的结果如下:

在这里插入图片描述

可以看到,从16层到19层,准确率上升了1.6%,但从19层到35层,准确率却只上升了0.6%。不难发现——16层明显比13层要高,但19层却和16层差异不太大,35层与19层之间的差异就更小,随着深度的加深,模型的学习能力大概率会增强,但深度与模型效果之间的关系不是线性的,可以增长的边际准确率是在递减的,准确率的变化会逐渐趋于平缓。但同时,参数量却是高速增加的,VGG19的参数量就达到了1.43亿个,对于任何个人计算机来说这个参数量都是一个巨大的考验。

受启发于VGG架构,在过去数年的研究中,人们通过实验发现了这样的结论:在不改变原始卷积层输入输出机制的前提下,增加卷积层的数目来增加深度,会很快让模型效果和性能都达到上限。深度并不能高效提升模型的效果,需要先降低模型的训练成本,才能够追求更深的神经网络。如果想要通过“加深”卷积神经网络来实现网络效果的飞跃,那必须是从16层加到160层,而不是从16层加到19层。事实上,在2014年后的ILSVRC上,赢得冠军的网络架构变得越来越复杂,深度也越来越深,在2017年ILSVRC闭幕之前,网络深度大约停留在了220层左右,这是得益于研究者们发现了更高效地提升深度的方法。

在这里插入图片描述

从现在的眼光来看,网络的实际深度一般与数据的复杂程度有关。对于ImageNet这样的数据集,200层的网络能够实现2.99%的错误率,而对于Fashion-MNIST这样的数据集,可能只需要几十层就能达到很高的测试准确率(目前为止Github上的Fashion-MNIST最高测试准确率大约在96%)。对于尺寸小于50x50的图像而言,若追求测试准确率在90%以上,则只需要小于20层左右就足够了。为了计算效率,不必强行加深网络,若的确追求更高的准确率,则可以考虑换成更高级的架构或者对数据进行预处理。

1.2 VGG16的复现

VGG架构对于神经网络研究和使用都有重要的意义,它不仅简单、有效,而且非常适合用来做各种实验和测试。在我们已经详细复现AlexNet与LeNet5的基础上,VGG架构的代码就显得异常简单。在这里,我为大家提供输入为224x224的VGGNet16的详细架构和复现后的代码,大家可以参考。VGG16的架构用语言来表示则有:
输入→(卷积x2+池化)x2 →(卷积x3+池化)x3 → FC层x3 →输出

其中每组卷积+池化算一个block。同时,架构图中没有显示出来的内容包括:
1、除了输出层外,所有的激活函数都是ReLU函数

2、最后三个全连接层中的前两个全连接层前有Dropout层,p=0.5
为了在卷积层中保持特征图尺寸不变,卷积层的padding、步长是灵魂,千万别写错了。复现后VGG16的参数量大约在1.38亿上下,根据输入数据尺寸的不同可能会有少许区别,只要架构能够跑通就算是复现成功。
在这里插入图片描述

class VGG16(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 64, 3, padding=1)
        self.conv2 = nn.Conv2d(64,64,3,padding=1)
        self.pool1 = nn.MaxPool2d(2)
        #block2
        self.conv3 = nn.Conv2d(64,128,3,padding=1)
        self.conv4 = nn.Conv2d(128,128,3,padding=1)
        self.pool2 = nn.MaxPool2d(2)
        #block3
        self.conv5 = nn.Conv2d(128,256,3,padding=1)
        self.conv6 = nn.Conv2d(256,256,3,padding=1)
        self.conv7 = nn.Conv2d(256,256,3,padding=1)
        self.pool3 = nn.MaxPool2d(2)
        #block4
        self.conv8 = nn.Conv2d(256,512,3,padding=1)
        self.conv9 = nn.Conv2d(512,512,3,padding=1)
        self.conv10 = nn.Conv2d(512,512,3,padding=1)
        self.pool4 = nn.MaxPool2d(2)
        #block5
        self.conv11 = nn.Conv2d(512,512,3,padding=1)
        self.conv12 = nn.Conv2d(512,512,3,padding=1)
        self.conv13 = nn.Conv2d(512,512,3,padding=1)
        self.pool5 = nn.MaxPool2d(2)
        #FC层
        self.linear1 = nn.Linear(512*7*7,4096)
        self.linear2 = nn.Linear(4096,4096)
        self.linear3 = nn.Linear(4096,10)
    def forward(self,x):
        x = F.relu(self.conv1(x))
        x = self.pool1(F.relu(self.conv2(x)))
        x = F.relu(self.conv3(x))
        x = self.pool2(F.relu(self.conv4(x)))
        x = F.relu(self.conv5(x))
        x = F.relu(self.conv6(x))
        x = self.pool3(F.relu(self.conv7(x)))
        x = F.relu(self.conv8(x))
        x = F.relu(self.conv9(x))
        x = self.pool4(F.relu(self.conv10(x)))
        x = F.relu(self.conv11(x))
        x = F.relu(self.conv12(x))
        x = self.pool5(F.relu(self.conv13(x)))
        x = x.view(-1, 512*7*7)
        x = F.relu(self.linear1(F.dropout(x,p=0.5)))
        x = F.relu(self.linear2(F.dropout(x,p=0.5)))
        output = F.softmax(self.linear3(x),dim=1)
        return output

vgg = VGG16()
summary(vgg, input_size=(10, 3, 224, 224), device='cpu')

在这里插入图片描述

1.3 原理与研究方向

深度究竟如何影响神经网络,以及要如何严格证明这种影响是现在深度学习理论发展的一个重要研究方向,可惜的是,现在这个领域才刚刚起步,只有数篇论文可以称得上是有显著成果。为什么越深的网络会效果越好呢?从千禧年到现在,学者们持续在研究“深度为何能提升模型效果”这个议题,但至今我们仍然不太了解深度是如何帮助卷积神经网络增强学习能力的。有数篇论文试图研究深度网络对比浅层网络的优越性(注意,不只限于卷积网络,而是通用于任意的深度网络),并且使用数学的方法进行了类比证明,我挑选了2个较为有趣的方向,以较为通俗的描述与大家分享。关于严格的理论证明,大家可以参考论文。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

3、更深的网络能带来更大的感受野,而更大的感受野能带来更好的模型效果

这几乎是卷积神经网络的学术界公认的事实,虽然人们还没有从理论层面对此进行证明,但从实践的结果来看,感受野、深度与模型效果之间的相关性是确实存在的。接下来,我们就来认识感受野。

2 感受野

2.1 认识感受野

除了深度之外,另一个常常被认为是与卷积神经网络的预测效果相关的因素是感受野的尺寸,比起现在还不知如何工作的“深度”,感受野提升模型表现的相关理论要丰富一些。感受野的概念在课程中有被简单定义过,我们定义它是“原图上被扫描到的区域”。以下图为例,左侧为原始图像,右侧为输出的特征图,则右侧特征图上位置为[0,0]的像素点所对应的感受野就是左图上的原始区域。

在这里插入图片描述

为了更深地认识感受野,我们需要呈现一个更清晰的定义:

感受野
在深度卷积神经网络中,每个神经元节点都对应着输入图像上的某个区域,且该神经元仅受这个区域中的图像内容的影响,那么这个区域称之为神经元的感受野。

还记得卷积网络中的神经元节点在哪里吗?在之前的课程中我们提到过,对卷积神经网络而言,每个特征图上的每个像素就是一个单独的神经元,一张特征图是由多个神经元排列而成的。由于卷积神经网络“稀疏交互”的特性,CNN中的神经元只受到原始图像上一部分数据的影响,而这部分数据其实就是神经元在生成过程中、使用卷积操作时扫描到的那部分原始数据,这部分数据所在的区域也就是感受野。由于大多数时候,图像和卷积核都是正方形,如果padding=0,步长为1,因我们可以只考虑行/列中的一个维度来看感受野的变化。

如下图,下图是卷积架构的俯视角度。当卷积核为3x3时,特征图上的每个神经元都对应原图上的3x3区域,这个3x3区域也就是第一个卷积层上每个神经元的感受野。

在这里插入图片描述

对于一个卷积层,感受野很好理解,但加上多层卷积和池化层呢?我们来看一下,依然是俯视图:

在这里插入图片描述

卷积核依然是3x3,对于第二个卷积层上生成的特征图来说,其每个神经元的信息都来自于上一层特征图的3x3共9个神经元(从俯视图来看只能看见3个列),而上一层特征图的9个神经元又覆盖了原始图像上的5x5共25个像素。对于第二层特征图上的神经元来说,他们的感受野就是原始图像上5x5的区域。对于没有重叠的池化层来说,池化层所覆盖的原始图像上的区域就是一个4x4的区域。

在这里插入图片描述

如果是一张巨大的图像,有4个卷积层,每个卷积层的步长都为1,kernel_size=3x3,则有:

在这里插入图片描述
对于卷积层4上的任意神经元(像素)而言,它的感受野就是原始图像上的9x9的区域,仿佛最下面一层的神经元就是光源,而图像上的感受野就是神经元的光能够照射到的地方。不难发现,随着深度的加深,神经元上的感受野会越来越大,这意味着这个神经元上所携带的原始数据的信息会越来越多。而由于卷积神经网络是稀疏交互的,为了让神经元在做出判断之前能够获取尽量多的信息,在被输入到FC层之前的感受野越大越好。理论研究证明,一个表现优异的模型在FC层前的感受野一定是非常大的,巨大的感受野是模型表现好的必要条件。和“深度”一样,通常我们认为,较大的感受野意味着较好的模型效果,但稍微增加一些感受野的尺寸,并不能对整个模型的预测效果带来巨大的改变。

在这里插入图片描述
对于像素级别的预测任务(也叫密集预测任务),如需要给原始图像中每个像素点进行分类的语义分割任务,语音处理领域的立体声和光流估计任务等,FC层之前的感受野的大小至关重要,对于其他任务,我们也会尽量保证分类前感受野的大小是足够大的。

2.2 感受野的性质
  • 深度越大,感受野越大,池化层放大感受野的效率更高

感受野的尺寸变化可以在一定程度上解释,为什么重复架构的预测效果比递减架构更好。不难发现,下面这张图就是每次将特征图尺寸缩小2个像素的递减架构,在经历4个卷积层后,图像的尺寸由22x22下降到了14x14,感受野尺寸为9x9。

在这里插入图片描述

如果使用VGG中的重复架构使用的参数,即每个卷积层都使用kernel_size=3,padding=1的架构,且每3个卷积层后跟一个(2,2)参数的最大池化层,在4个卷积层之后我们可以有下图:

在这里插入图片描述
图中灰色格子代表padding,注意:每个卷积层的padding参数影响的是上一层输出的特征图。可以看到,重复架构中,经过4层卷积层和1个池化层之后,图像的尺寸下降到了11x11,且第四个卷积层生成的特征图上的神经元的感受野达到了12x12的尺寸,比递减架构相同卷积层下的感受野要大。在两种架构中,只要卷积核的尺寸保持3x3,那每经过一个卷积层,感受野的尺寸实际上只会增加2,但池化层在将特征图尺寸减半的同时,却能够将感受野的宽和高都扩大一倍。池化层的存在令重复架构放大感受野的效率更高,这让重复架构更有优势。

  • 放大感受野,是否有极限?

如果说受到计算量和图像本身尺寸的限制,导致我们无法不断加深深度的话,那放大感受野是否也有限制呢?从直觉上来说,我们应该认为感受野的尺寸也是有上限的,因为一个神经元所包含的信息最多也就是整张图片上全部的信息,也就是感受野能够覆盖整张原始输入图像。但事实上,感受野的尺寸没有上限。

在这里插入图片描述

依然是延续VGG的架构及参数,依然是4个卷积层和1个池化层之后的感受野图像,这次我们不观察位于特征图中心的神经元,而观察位于特征图边缘的神经元。从常理来看,无论是边缘神经元还是中心神经元,同一个特征图上神经元的感受野尺寸应当是一致的(在现有参数下,都是12x12),并且感受野的范围应该是以神经元为中心向两边扩散的。对于中心神经元来说,神经元的整个感受野都在图像上,而对于边缘神经元来说(无论是左右的边缘还是上下的边缘),它的感受野却会超出原始图像一部分。这一部分的尺寸,可以由最下面的卷积层向上推测得出。

超出图像的部分在最终的特征图的像素来看是什么样的呢?就相当于是没有值,全为0,表现在图像上就是黑框。对于各个卷积层上的神经元们来说,他们的感受野会有如下差别:

在这里插入图片描述

对于AlexNet及之后诞生的卷积神经网络,在进入FC层之前,其感受野上的图像基本都如上图中的“深层神经元的感受野”所示。随着网络深度的逐渐加深,感受野中超出原始图像的部分会越来越多、黑边会越来越厚。事实上,在输入图像尺寸保持在224x224水平的时候,最强大的现代深层卷积网络们的感受野基本都在1000以上。

在这里插入图片描述

那这样做,有什么意义呢?当感受野大小超出图像大小时,增加黑框不就是增加噪音吗?为什么要让感受野越来越大,让图像信息越缩越小呢?这和感受野的第三个性质有关。




  • 关注中心,周围模糊

关于“中间清晰,周围模糊”的感性认识,其实可以由人眼来提供。看下面这张图,这是一张足球比赛中的TV stream截图,现在紧盯着草地上的足球,在不转动眼球前提下,你能告诉我本场比赛现在的比分是多少吗?或者,你能告诉我场边广告上的品牌名称吗?

在这里插入图片描述

我相信你不能,应该没有任何人可以在不转动眼前的情况下看清比分或者品牌名称,当你把视线转向比分的时候,你会发现比分这个信息很大而且显示得很清晰,甚至比球员们的身影还要清晰,但你之前就是“看不见比分”。事实上,你是能够看到这些内容的,你虽然紧盯着球,但在你的视线里,应该能够“看见”球场周围的情况、看见你的电脑或手机屏幕的边缘、看见你的书桌或你所在的任意场景,甚至能看见在你旁边1、2m左右的人、床、巴士车的扶手等等。但是你能够看清的只有你紧盯着的球,其他的东西都是模糊的。这就是人眼感受野的“中间清晰,周围模糊”。所有你能够看见的内容都在你双眼的“感受野”内,但是你能够看清的就只有你眼球正前方的那个非常小的范围。

卷积神经网络的感受野也是一样。对于特征图来说,每个神经元所捕捉到的感受野区域不同,但这些区域是会有重叠的,并且很好理解的是,越是位于中间的像素,被扫描的次数越多,感受野重叠也就会越多。

在这里插入图片描述

对整个特征图来说,重叠越多的部分,信息就越饱满,“看得就越清晰”,而重叠较少的部分,信息就比较稀疏,因此就会“模糊”。因此,位于图像中间的像素有更多可以影响最终特征图的“路径”,他们对最终特征图的影响更大,对卷积网络的分类造成的影响也会更大。论文《Understand the Effective Receptive Field in Deep CNN》中严格证明了,原始图像上的像素点对最终特征图的影响力呈现二元高斯分布,只有极少数位于非常中心的像素点能够对最终的特征图产生巨大的影响,随着像素点的位置逐
渐向图像四周扩散,这些像素点对于特征图的影响力会衰减得非常快。

在这里插入图片描述
在学术上我们定义了“有效感受野”(Effective Receptive Fields,ERF),它是一个比感受野小很多的区域,在这个有效感受野中所有的像素都对分类有较强的影响(具体定义可查看上面提到的论文)。这个区域是客观存在的,并且研究已经表明,有效感受野的大小受到网络结构的影响。但是现在我们还无法精确计算出有效感受野的大小,我们只能够观察到这个区域的存在。研究如何计算有效感受野的大小、了解有效感受野的性质是现代卷积网络研究的一个关键课题。

因为中间清晰两边模糊的性质,也因为有效感受野的存在,在卷积神经网络开始分类之前,我们必须尽量让图像的有效信息集中在感受野的中心,这些有效信息越集中,感受野就越能“看清”这些信息,越能提取出有效的特征。

总之,使用远远超出图像尺寸的感受野,而将图像信息“锁”在感受野中心,让本来应该“模糊”的部分全都被黑边所替代,就是最有效的做法。由于现在还无法精确地计算出有效感受野的大小,理论上来说感受野更大会更好。唯一的例外就是,你在图像中想要识别的对象在图像的边缘,而巨大的干扰项却在图像中间的时候。此时,你把感受野放得越大,干扰就会越大。

当然,与深度一致,感受野对于模型效果的影响也不是“线性”的。即是说,并不是感受野变大一个像素,模型的效果就能提升一点点,只是从整体来看,在架构相似的前提下,更大的感受野能带来更好的效果。

2.3 扩大感受野:膨胀卷积

有数个方法可以扩大感受野:
1、加深卷积神经网络的深度,理论上来说,每增加一个卷积层,感受野的宽和高就会按照卷积核的尺寸-1线性增加
2、使用池化层或其他快速消减特征图尺寸的技术
3、使用更加丰富的卷积操作,如膨胀卷积dilated convolution、残差连接等等在这一节,我们重点来讲讲膨胀卷积。膨胀卷积又叫做空洞卷积,它通过在感受野上使需要计算点“膨胀”的方式来“扩大”卷积核可以扫描的区域。所谓膨胀的概念如下图所示。

在这里插入图片描述

在感受野上,需要参与卷积计算的任意一个像素都是计算点。以计算点为中心向外“扩充”像素点的行为就叫做“膨胀”。注意,膨胀和填充非常相似,但膨胀只是扩大以计算点为中心的某个面积,并不会改变计算点相邻像素点的值,填充则是需要对计算点相邻像素点的值进行修改。以计算点为中心,膨胀率(dilation rate)为1时,计算点自身就是全部面积。膨胀率为2时,在计算点周边扩充一圈像素,当膨胀率为3时,在计算点周边填充2圈像素,以此类推。膨胀卷积就是在每个参与卷积计算的计算点上做“膨胀”操作,让计算点与计算点之间出现“空洞”,并跳过空洞进行计算的卷积方式。描述上来说有些难以理解,我们来看图:

在这里插入图片描述

膨胀率为1时,3x3的卷积核所对应的感受野大小也是3x3(绿色区域),全部9个被感受野所包括的像素点都会和卷积核上的值执行卷积操作,并得到特征图中的像素点。当膨胀率为2时,每个计算点所覆盖的面积都会向外拓展一圈,将原来的计算点向右向下“挤”,构成如图所示的感受野。此时,卷积核的尺寸为3x3,但感受野的尺寸为5x5,感受野中白色的格子都是计算点“膨胀”的结果,不参与计算,绿色的格子依然按照无膨胀时的规则与卷积核进行计算。

在这里插入图片描述

扫描的过程可以如下图所示:

在这里插入图片描述

当膨胀率为3时,计算点向外膨胀的像素值为2圈,感受野的大小则变成7x7,但执行计算的计算点数依然是9个。

在这里插入图片描述
不难发现,通过在计算点周围进行膨胀,计算点与计算点之间出现了“空洞”,这是膨胀卷积也被称为空洞卷积的原因。很明显,膨胀卷积会改变输出的特征图的尺寸,其计算公式如下:

在这里插入图片描述
其中dilation[0]表示横向的膨胀率,dilation[1]表示纵向的膨胀率。膨胀率与核尺寸一样,处于分子中被减掉的部分,因此膨胀率越大,生成的特征图就越小。当步长和膨胀率都小于核尺寸时,增加步长和增加膨胀率都可以降低特征图的尺寸,但如果我们希望更快地放大感受野,我们则会选择膨胀卷积,而不是选择增加步长。

通过膨胀卷积,我们可以在不增加卷积核参数的情况下放大感受野。不过这里有一个很大的问题:虽然看上去感受野的面积是被放大了,但是跳过其中的像素点不进行计算,真的算是放大了本应该用来捕获信息的感受野吗?这样的放大是有效的吗?从直觉来看,这样做应该会产生非常多的信息损失,要了解膨胀卷积真正的力量,还需要将多个卷积层连起来考虑。

我们先考虑没有膨胀的原始卷积层,并且假设原始图像是一张巨大的图像,其特征图的尺寸变化在3层卷积层之间可以忽略。

在这里插入图片描述

对于没有膨胀的原始卷积,在第三层卷积层的特征图上,任意两个相邻的神经元的感受野如上所示。其中绿色是绿色神经元的感受野,黄色是黄色神经元的感受野,橙色是两个神经元在各层上感受野的交叉部分。不难看出,对于普通卷积,相邻神经元的感受野大概率是重复的,两个神经元的感受野合起来尺寸有8x8的大小,而其中7x7的部分都是重复的。但如果加入膨胀卷积呢?

在这里插入图片描述

假设从第一个卷积层开始,我们就采用dilation=2的设置,在第三个卷积层生成的特征图上,相邻的两个神经元所涉及到的计算点如图所示:每次进行计算时,都是上一层不相邻的9个像素被扫描到。虽然在生成单个神经元时,上一层的特征图上留下了不少未计算的空隙,但相邻的神经元却很好地补上了这些没有被计算的部分,使得相邻两个神经元之间没有重复进行扫描。此时,两个相邻神经元的感受野合起来就有14x14的大小,比起没有膨胀卷积时的感受野面积大了约3倍左右。这个性质可以被很好地利用,当我们将膨胀率调大,并且让膨胀卷积层与普通卷积层串联使用时,单个像素的感受野可以被持续放大(这种情况下,两个像素共同的感受野自然更大了)。如下图所示:

在这里插入图片描述
在这个图中,我们第一层卷积层是普通卷积(膨胀率为1),因此这一层的每一个像素点都对应原始图像中3x3的面积。第二个卷积层我们使用膨胀率=2的卷积,即每个计算点之间相隔1个像素。第三个卷积层我们让膨胀率=6,即每个计算点之间相隔5个像素,可以看到,在第三个卷积层输出的特征图上,一个像素能够覆盖到原始图像的感受野尺寸变成了19x19。如果再结合池化层进行使用,那膨胀卷积放大感受野的性质会更加明显。

因为这个非常有用的性质,膨胀卷积在语义分割等任务中有非常优秀的表现,也属于卷积神经网络入门的基础内容。当然,膨胀卷积所涉及到的架构设计并不简单,它存在各种各样的问题:比如说,并不是在所有的卷积架构里,我们都能够完美地将膨胀卷积造成的空洞填补上,信息损失在所难免,所以一般认为膨胀卷积不太适合于小物体的分割,而更适合于大物体的分割。在语义分割的章节,我们将更详细地说明膨胀卷积的架构设计问题。在这里,膨胀卷积作为“扩大感受野”的一个例子,同时也是卷积神经网络入门的必备基础,希望给大家留下较为深刻的印象。

2.4 感受野尺寸的计算

在之前的课程中,我们已经明确了感受野的重要性,以及感受野大小如何影响神经网络。因此,理解哪些因素会影响和放大感受野、了解感受野的尺寸受哪些参数影响,也是至关重要的。还记得我们计算特征图尺寸的时候,我们是一层一层向下计算吗?感受野的尺寸也是一样,只不过感受野尺寸的计算公式要复杂一些:

在这里插入图片描述

import torch
from torch import nn
from torch.nn import functional as F
data = torch.ones(size=(10,1,32,32))
class LeNet5(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1,6,5)
        self.pool1 = nn.MaxPool2d(kernel_size=2,stride=2) #这里更换为maxpool,在原论
文中为average pool
        self.conv2 = nn.Conv2d(6,16,5)
        self.pool2 = nn.MaxPool2d(kernel_size=2,stride=2)
        self.fc1 = nn.Linear(16*5*5,120)
        self.fc2 = nn.Linear(120,84)
    
    def forward(self,x):
        x = F.tanh(self.conv1(x))
        x = self.pool1(x)
        x = F.tanh(self.conv2(x))
        x = self.pool2(x)
        x = x.view(-1,16*5*5)
        x = F.tanh(self.fc1(x))
        output = F.softmax(self.fc2(x),dim=1)
        output = F.softmax(x.view(-1,16*5*5),dim=1)        

在这里插入图片描述

注意,全连接层一般不计算感受野,因为全连接层的神经元就是前一个卷积层输出的特征图上的全部神经元拉平后的结果,因此全连接层的感受野就与最后一个卷积层上输出的特征图的感受野一致。

AlexNet网络的情况同理:

data = torch.ones(size=(10,3,227,227)) #假设图像的尺寸为227x227
class AlexNet(nn.Module):
    def __init__(self):
        super().__init__()
        
        #大卷积核、较大的步长、较多的通道
        self.conv1 = nn.Conv2d(3,96,kernel_size=11, stride=4) 
        self.pool1 = nn.MaxPool2d(kernel_size=3,stride=2)
        
        #卷积核、步长恢复正常大小,进一步扩大通道
        self.conv2 = nn.Conv2d(96,256,kernel_size=5, padding=2) 
        self.pool2 = nn.MaxPool2d(kernel_size=3,stride=2)
        
        #连续的卷积层,疯狂提取特征
        self.conv3 = nn.Conv2d(256,384,kernel_size=3,padding=1) 
        self.conv4 = nn.Conv2d(384,384,kernel_size=3,padding=1)
        self.conv5 = nn.Conv2d(384,256,kernel_size=3,padding=1)
        self.pool3 = nn.MaxPool2d(kernel_size=3,stride=2)
        #全连接层
        self.fc1 = nn.Linear(256*6*6,4096) #这里的上层输入是图像中的全部像素
        self.fc2 = nn.Linear(4096,4096)
        self.fc3 = nn.Linear(4096,1000) #输出ImageNet的一千个类别
    
    def forward(self,x):
        
        x = F.relu(self.conv1(x))
        x = self.pool1(x)
        
        x = F.relu(self.conv2(x))
        x = self.pool2(x)
        
        x = F.relu(self.conv3(x))
        x = F.relu(self.conv4(x))
        x = F.relu(self.conv5(x))
        x = self.pool3(x)
        
        x = x.view(-1,256*6*6) #需要将数据的特征部分“拉平”才能够进入FC层
        
        x = F.relu(F.dropout(self.fc1(x),0.5)) #dropout:随机让50%的权重为0
        x = F.relu(F.dropout(self.fc2(x),0.5)) 
        output = F.softmax(self.fc3(x),dim=1)        
  • 总共有5个卷积层和3个池化层,感受野的大小计算如下:

在这里插入图片描述

3 平移不变性

在计算机视觉、尤其是图像识别相关的任务中,不变性(Invariance,或Invariant)是至关重要、甚至影响建模和数据处理流程的一种性质。不变性是指,如果我们能够识别出一张图像中的一个对象,那即便这个对象以完全不同的姿态呈现在别的图像中,我们依然可以识别出这个对象。对算法而言,不变性意味着在训练集上被成功识别的对象,即便以不同的姿态出现在测试集中,也应该能够被成功识别。来看一个例子:

在这里插入图片描述

上面这张图是网红猫Narnia的照片,在见过这张图片之后,你应该能够轻易地判断出下面这四张图都是Narnia,虽然下面这些图像中,猫咪的姿势、观察角度、光线、颜色等信息都和最初我们看见的图像完全不一致(这会导致一张图像上的像素大小及其排列高度不一致),但人眼非常强大的“不变性”支持我们去判断出所有图像都是同一只猫咪。

在这里插入图片描述

再来看下面的图像,你认为下面的图像中是同一只猫咪吗?大部分的人会认为下面的图像中只是花纹相近、姿势相仿的猫咪,而不是同一只猫。但对卷积神经网络来说,下面的图像才更像是“同一只猫”。对于Narnia的照片来说,当训练集中只有一张猫脸正面照时,卷积网络很难识别出各种仰头、侧躺、侧脸的生物是什么。但下面这张图像中,虽然画风、背景、甚至猫咪的品种都发生了变化,但图中的物体位置、像素排列(轮廓、颜色)等信息有很高的相似度,因此更容易被判断成是“同一只猫”。

在这里插入图片描述

可以看到,人类和卷积网络对图像的“理解”方式不同,导致我们对同样的图像有不同的判断。在这种情况下,假设我们在相似背景中绘制一个颜色类似的花瓶,很可能也会被模型判断成是“猫”,假设我们将训练数据的图像水平翻转,模型反而无法判断这就是“猫”了。此时,模型就是处于过拟合状态之中,只认识“自己见过的东西”,泛化水平较低,鲁棒性较低。

  • 鲁棒性(robustness)
    英文robust描述的是一种强健且稳定的状态,鲁棒性衡量一个系统所具有的抵抗变化、使自身保持稳定的能力。
    在软件工程中,鲁棒性常常用来衡量程序是否足够稳定(不稳定的系统报错频率较高、并且可能会在意想不到的地方出现意想不到的错误)。在数据科学中,鲁棒性用于形容一个模型或算法在不同数据、不同环境下的表现是否稳定。特别地,一个过拟合的模型的鲁棒性一定是较低的,因为过拟合就意味着不能适用数据的变化。

计算机视觉的使命就是让算法完全实现人类视觉的功能,因此让卷积网络拥有和人眼一样的“不变性”就有巨大的意义。拥有“不变性”不止意味着图像能被算法正确识别,还意味着图像能够被算法正确“理解”。与深度、感受野等提升模型学习能力和拟合能力的因素不同,不变性被认为是切实可能提升模型鲁棒性的因素,因此,让CNN具有不变性也是提升测试集效果、减少过拟合、提升鲁棒性的关键步骤。

在视觉领域,有非常多从不同角度定义的不变性,其中基础的不变性有4种:平移不变性(Translation Invariance)、旋转/视野不变性(Rotation/ViewPoint Inviariance)、尺寸不变性(size Inviarance)与明度不变性(Illumination Invariance),其他常见的不变性包括镜面不变性(镜面翻转图像),颜色不变性等。理解这些不变性的最佳方式,就是下面这张著名的石像图。平移不变性是指,对图像上的任意一个对象,无论它出现在图像上的什么位置,我们都能够识别这是同一个对象。旋转不变性是指,对图像上的任意一个对象,无论我们如何旋转它、或更换查看对象的视野,我们都能识别这是同一个对象。其他不变性以此类推。

在这里插入图片描述

在训练深度CNN时,为了增强模型的效果,我们会尽量帮助模型去获取更多的“不变性”。令人欣慰的是,大部分深层卷积网络的架构自带一定的“平移不变性”,只要对象的轮廓一致,无论对象出现在图像的哪个位置,卷积网络都能够判断出来。这是怎么实现的呢?来看下面这张图:

在这里插入图片描述

原始图像是一张字母C的图像,绿色区域就是字母C的像素。在卷积层中,我们使用4x4的卷积核对原始图像进行扫描,并在卷积层之后跟上核尺寸为2x2的最大池化层。不难发现,当我们将字母C整个向下平移一个像素时,虽然卷积层在捕捉特征时将字母C的特征信息放置在了不同的神经元上,但在卷积层后的特征图上,相关信息就会被放置在不同的神经元上。

然而,对于大部分图像而言,有效信息较为集中的区域的像素值会更大,在卷积操作后得到的特征图上的值也会更大,这是最大池化层能够有效的基础。因此,无论有效信息位于特征图的什么位置,在经过(也许不止一个)最大池化层之后,有效信息都能够被顺利筛选出来,也因此,在卷积神经网络中,关键像素被平移后对模型整体准确率的影响相对较小。

不难发现,在这个过程中,卷积层的“无差别扫描”起到了重要作用,但真正让信息保持不变、不产生损失的其实是池化层。一些观点认为,池化层的存在是CNN自带一定的平移不变性的主要理由,但有些观点正相反,但无论如何,公认的是:更深层的卷积网络的平移不变性更强。下面这篇博文很好地完成了卷积网络在不同平移程度以及不同深度下的平移不变性的实验:https://divsoni2012.medium.com/translation-invariance-in-convolutional-neural-networks-61d9b6fa03df。

简单说说这篇博文的内容。在这篇博文中,作者持有两个重要观点:
1、CNN的平移不变性只能够应对“微小的平移”,当物体横向或纵向平移的像素过多时,CNN的平移不变性会衰减
2、卷积+池化层的叠加可以增强CNN的平移不变性(更深的网络拥有更强的平移不变性),同时增强模型的鲁棒性。

实验中,作者基于手写数字数据集MNIST创造了两个平移数据集,一个是微小平移的ShiftMin,另一个是大范围平移的Shift。同时,作者建立了三个神经网络,分别是带有2个隐藏层的DNN,1个卷积+1个池化+2个线性层的卷积网络1号,以及2个卷积+2个池化+2个线性层的卷积网络2号,使用同样的训练和测试流程,最终得到的测试结果如下:

在这里插入图片描述

从实验结果可以看出,深层神经网络在数据微小平移之后准确率下降到了86.1%,在巨大的平移之后准确率直接低于了50%,普通深层网络对数据平移几乎没有抗性,基本不具备“平移不变性”。卷积网络的情况就好得多,带有两个池化层的2号卷积网络明显在微小平移和巨大平移网络上都取得了更好的成绩,随着神经网络中卷积+池化组合越来越多,卷积网络的平移不变性会逐渐增强。但如同我们之前讨论的,卷积网络的深度、尤其是池化层的数量严格受到输入图像大小的控制,不可能无限增加池化层。并且,池化层只能解决平移不变性的问题,对其他不变性却无计可施(有不少资料都提到池化层具备旋转不变性,但目前来看更可能是误传,没有证据能够表明池化层能够提供旋转不变性),为了让卷积神经网络具备各类不变性,我们需要采取更强力的手段:数据增强(Data Augmentation)。

  • 数据增强
    数据增强数据科学体系中常用的一种增加数据量的技术,它通过添加略微修改的现有数据、或从现有数据中重新合成新数据来增加数据量。

对于图像数据而言,可用的数据增强技术数不胜数,例如:

在这里插入图片描述

以及镜面、镜面翻转:

在这里插入图片描述

再比如:

在这里插入图片描述

你发现了吗,这些变化都是通过改变原始图像的某些像素而实现的,因此这些操作大多可以通过opencv或者深度学习框架们来实现。每一种变化都对应着一种可能的“不变性”,对任意一种变化,我们都会从原始图像生成十数张新的图像放入训练集,这样做可以将训练数据集极速扩大,并且可以让模型快速见到各种“旋转”、“镜像”过的数据,从而让模型能够在测试中判断出旋转或镜像过的数据。在后续的章节中我们会详细讲解数据增强的具体操作(包括代码)。

如果做了数据增强,模型可能拥有了各种的不变性,那是否就高枕无忧了呢?并不是如此。为模型引入不变性的确很有用,但也会有一些尴尬的情况。例如,在卷积神经网络不断将特征图缩小的过程中,像素之间的“位置”信息是会逐渐损失掉的,当这些信息进入全连接层之后,网络就再也没有“相对位置”的概念了,因此在人脸识别中,平移不变性会让卷积神经网络将下面两张图像都判断成人脸,但左侧明显只是一些元素的堆积,并不是真正的人脸。大多数时候平移不变性会提升模型的效果,但对于密集任务(需要对每个像素进行预测的任务)而言,平移不变性可能导致灾难。

在这里插入图片描述

同样尴尬的还有旋转不变性,看下面这张图,左侧看起来是一只兔子,而右侧看起来是一只鸭子,但实际上他们只是同样图片旋转后的结果。在视频https://youtu.be/VO1bQo4PXV4中可以看到,在这张图像旋转360°的过程中,大约有一半的角度下会被算法判断为鸭子,另一半则判断为兔子,因此感觉你的标签,你可能不希望给这张图片做360°的旋转增强。

在这里插入图片描述

这种概况可能发生在任何你未曾预料到的数据上,如下图,算法并不能判断旋转后的澳大利亚与非洲大陆是不是一片大陆。

在这里插入图片描述

因此,在为模型添加“不变性”时,应当要清楚自己需要的是什么,这也要求我们在建立卷积网络之前需要对训练数据有详细的了解,这样才能够将数据处理得更加恰当。无论是AlexNet还是VGG都采用了数据增强来降低模型的过拟合程度。不变性也是现在的研究方向之一,池化层能够切实增加模型鲁棒性的理论基础还不清楚,因此,这个方向虽然冷门,但也是需要解决问题的方向。

在这一节中,我们介绍了深度、感受野以及不变性三个通过架构影响CNN效果的因子,通过将这三个因子的作用发挥到极限,我们可以稍微一观神经网络不可理解的“黑盒”内在,并且在构建自己的神经网络时增加更多考虑的维度。

最后,关于架构如何影响卷积神经网络的效果,学术界或网络上还有一些不同的声音:
1、另一个被认为是影响CNN效果的因子是最后一个卷积层后的特征图的数目,这个数目也被称作“最大感受野上的通道数”,有研究表明,通道数越大,CNN的效果会越好。从现代卷积的角度来看,最后一层的通道数至少都有512,已经是不少小数据集可以达到的极限通道数了。在大量卷积网络都使用512的前提下,要验证更大的通道数更有效有些困难。但这仍然是一个可以讨论的议题。
2、池化层是一个争议很大的元素。有的观点认为,池化层不能提供不变性,甚至不能对卷积神经网络的效果有影响。池化层缩小特征图的功能可以由步长等于2的卷积层来替代,等等。具体可以查看这些连接和论文:

《Striving for Simplicity: The All Convolutional Net》
https://zhuanlan.zhihu.com/p/94477174
https://blog.csdn.net/qq_34107425/article/details/107503099
对此,我的观点是,池化层不能提供完美的平移不变性,因此一定会存在信息损失和例外,但从放大感受野的角度而言,池化层应该对模型还是存在影响。对池化层而言,最关键的还是能够快速下采样(down-sampling),即快速减少特征图尺寸,减少模型所需的参数量。

二 架构对参数量/计算量的影响

在自建架构的时候,除了模型效果之外,我们还需要关注模型整体的计算效率。深度学习模型天生就需要大量数据进行训练,因此每次训练中的参数量和计算量就格外关键,因此在设计卷积网络时,我们希望相似预测效果下,参数量越少越好。为此我们必须理解卷积中的每层会如何影响模型整体的参数量和计算量。

模型参数是需要学习的参数,例如权重w和常数项b,任何不需要学习、人为输入的超参数都不在“参数量”的计算范围内。对于卷积神经网络中的任意元素(层或函数,有两种方式影响模型的参数量:
1、这个层自带参数,其参数量与该层的超参数的输入有关
2、这个层会影响feature map的尺寸,影响整体像素量和计算量,从而影响全连接层的输入全连接层、bn层通过第一种方式影响参数,而池化、padding、stride等操作则通过第二种方法影响参数,卷积层通过两种方式影响参数,dropout、激活函数等操作不影响参数量。接下来,我们仔细说明一下卷积的参数量问题。

1 卷积层

1.1 参数量计算

一个卷积网络的卷积层究竟包含多少参数量,就是由卷积核的尺寸kernel_size、输入的通道数in_channels,输出的通道数out_channels(卷积核的数量)共同决定的。其参数量如下:

在这里插入图片描述

来看两个简单的例子:

conv1 = nn.Conv2d(3,6,3) #(3 * 3 * 3)*6 + 6
conv2 = nn.Conv2d(6,4,3) #(3 * 3 * 6)*4 + 4
#检查一下结果
conv1.weight.numel()
conv1.bias.numel()
conv2.weight.numel()
conv2.bias.numel()

相对的,padding以及stride这些参数,不影响卷积层的所需要的参数量:

conv3 = nn.Conv2d(4,16,5,stride=2,padding=1)
# (5*5*4)*16 + 16
conv4 = nn.Conv2d(16,3,5,stride=3,padding=2)
# (5*5*16)*3 + 3
conv3.weight.numel()
conv3.bias.numel()
conv4.weight.numel()
conv4.bias.numel()

从卷积层的参数计算公式来看,较大的卷积核、较多的输入和输出都会对参数量影响较大,由于实际中使用的卷积核都很小,所以真正对卷积核参数有影响力的是输出和输入的特征图的数量。在较为复杂的架构中,卷积层的输出数量可能达到256、512、甚至更大的数字,巨大的数字足以让一个卷积层包含的参数达到百万级别。例如VGG16中,比较深的几个卷积层,他们的参数都在百万以上。

在这里插入图片描述

通常来说,如果我们希望减小卷积神经网络的参数量,那我们优先会考虑减少的就是输出的特征图数量。但随着网络加深,特征图是越来越小的,学习到更多深入的信息,特征图数量必然会增加(依照惯例,每经过一个池化层,就将特征图数量翻倍)。因此,如果希望消减卷积层的参数量,可以考虑不使用那么多卷积+池化的组合(不要那么深的深度),如果一定要保持深度,则在第一层时就使用较小的特征图数量,例如32。

1.2 大尺寸卷积核vs小尺寸卷积核

在深度卷积网络使用的众多场景中,我们都默认使用小卷积核,虽然我们列举了各种各样的理由,但在卷积网络的发展历史上,真正让大家都放弃大卷积核、转而使用小卷积核的是VGG的论文中提出的一个事实:大尺寸卷积核的效果可由多个小尺寸卷积核累积得到。具体如下:

在这里插入图片描述
在讲解感受野时我们曾经使用过卷积层的俯视图,假设我们有两层核尺寸为3x3的卷积层,对于第二个卷积层输出的特征图而言,一个神经元映射到原始图像上的感受野尺寸为5x5。同样的图像,假设我们使用一层5x5的卷积层,也可以得到5x5的感受野。同样的,2个3x3卷积层将10x10的特征图缩小为了6x6,一个5x5卷积层也将特征图缩小到了6x6。可以说,在“捕获的信息量”、“压缩尺寸”这两个层次上,两个3x3的卷积层和一个5x5的卷积层获得了一样的结果。同理,我们也可以用三层3x3卷积核的卷积层替代一层7x7的卷积核,更大的卷积核亦然。

对比一下,一个5x5卷积层在一次扫描中需要的参数是25个,2个3x3卷积层却只需要9 + 9 = 18个,因此两个3x3卷积层所需要的参数更少。当特征图数量巨大时,这一点点参数量的差异会被放大:假设输入的特征图数量为64,conv1和conv2输出的特征图也是64个,则有如下参数量:

在这里插入图片描述

两个3x3的卷积层总共需要7万+参数,而一个5x5的卷积层却需要10万+参数。对于VGG16这种重复架构的网络而言,如果将所有的3x3卷积核都替换成5x5卷积核,那整体参数量将增加3个亿。可见,3x3的两个卷积层不仅加深了深度,一定程度上让提取出的特征信息更“抽象”、更“复杂”,同时也让参数量大幅减少。这又给了我们一个坚定使用小卷积核的理由。

1.3 1x1卷积核

在众多的小卷积核中,小到极致的就是1x1尺寸的卷积核。

在这里插入图片描述

1x1的卷积核上只有一个权重,每次进行卷积操作时,该权重会与原始图像中每个像素相乘,并得到特征图上的新像素,因此1x1卷积也被叫做“逐点卷积”(Pointwise Convolution)。这种计算方式和矩阵*常数一致,同时,其本质也非常像我们在CV第一堂课时直接给像素直接乘上一个值来改变图像的某些属性的做法:

在这里插入图片描述

在这里插入图片描述

在实际中,1x1卷积的重要作用之一就是加深CNN的深度。1x1卷积不会改变特征图的尺寸,因此可以被用于加深CNN的深度,让卷积网络获得更好的特征表达。这个性质被论文《Network in Network》所使用,并在架构NiN中发挥了重要的作用。NiN是AlexNet诞生不久之后被提出的架构,虽然也是2014年的论文,但早于VGG之前诞生,其架构如下:

在这里插入图片描述

在NiN的架构中,存在着一种特殊的层:MLP layer。虽然在NiN的论文中,MLP layer是被看成是一个独立的单元来说明,但从其结构、操作和输出的特征图来看,MLP layer毫无疑问就是1x1的卷积层。NiN是以每个3x3卷积层后紧跟2个1x1卷积层组成一个block,并重复3个block达成9层卷积层架构的网络。之后我们会简单复现NiN的架构。

1x1卷积层不会改变特征图的尺寸,这个性质虽然有用,但和使用padding的卷积层差异不是特别大。从今天的眼光来看,1x1卷积核在加深深度方面最关键的作用还是用在卷积层之间,用于调整输出的通道数,协助大幅度降低计算量和参数量,从而协助加深网络深度,这一作用又被称为“跨通道信息交互”。

在VGG架构中,我们串联不缩小特征图大小的数个卷积层,每层输出128、256或512个特征图。当输出256个特征图的卷积层串联时,输入和输出的特征图数目很多,会使得整个卷积层的参数量变得很巨大。为此,我们可以如下图右侧所示的架构,在两个含有256个特征图的输出之间使用(1x1, 3x3,1x1)的三个卷积层来代替原始的3x3卷积层。在右侧架构中,虽然两个含有256特征图并没有直接交互,但他们之间的信息通过1x1卷积层进行了交换,这也是这个架构的作用被称为“跨通道信息交互”的原
因。

在这里插入图片描述

这种在核尺寸为1x1的2个卷积层之间包装其他卷积层的架构被称为瓶颈设计(bottleneck design),也可简称叫做瓶颈或bottleneck,它被广泛使用在各种深层网络当中,代表了CNN目前为止最高水平架构之一的残差网络ResNet的论文中也使用了瓶颈架构。从直觉上来说,通道数目缩小意味着提取的信息量会变少,但瓶颈设计基本只会出现在超过100层的深度网络中,实践经验证明这样的架构在深度网络中几乎不会造成信息损失,但带来的参数量的骤减却是肯定的,因此瓶颈设计在现实中应用非常广泛。

以上图的3x3卷积层的瓶颈设计为例,具体参数量如下所示:

在这里插入图片描述

可以看到,虽然最后都输出了256个相同尺寸的特征图,并且所有信息都经过了3x3的卷积核的扫描,但瓶颈架构所需要的参数量只有2.6万个,一个3x3卷积层所需要的参数却有59万个。对于百层以上的深层神经网络来说,这个参数差异足以让人放弃一些性能,也要坚持使用瓶颈设计。

1.4 减少参数量:分组卷积与深度分离卷积

除了1x1卷积之外,分组卷积(Grouped Convolution)也是一种高效的减少参数量的形式。要理解分组卷积,我们最好先理解下面这张图像(注意,在此图中,卷积图中的“格子”不代表具体像素数):

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

结合这张图与参数计算公式,很容易想到三种消减参数量的办法:消减输入特征图数量,消减输出特征图数量,消减每个连接上的核的尺寸,或者消减输入特征图与输出特征图之间的连接数量。分组卷积就是通过给输入特征图及输出特征图分组来消减连接数量的卷积方式。

在这里插入图片描述

我们来看分组卷积的具体操作:首先,分组卷积需要一个超参数groups,当groups=g时,则表示“将特征图分成g组”。我们可以让groups为任何正整数,但惯例来说,特征图数目一般都是偶数,因此分组数一般也是偶数。当确定g的数量之后,我们将输入的特征图和输出的特征图都分成g组。如上图,输入特征图数量为4,输出特征图数量为8,g=2,于是我们便将输入特征图和输出特征图分别分为2组。分组之后,一组输入特征图负责生成一组输出特征图。如图所示,上方两个输入特征图只负责上方4个输出特征图,在不考虑偏置的情况下,这个操作需要的参数为 3 * 3 * 2 * 4 = 72。同理,下方两个输入特征图只负责下方的4个输出特征图,因此需要的参数也为72个。分贝生成4个特征图后,再堆叠在一起,形成总共8个输出特征图。

在这里插入图片描述

在这里插入图片描述

不难发现,分组的存在不影响偏置,偏置只与输出的特征图数量有关。这个公式可以在pytorch中被轻松验证。

conv1 = nn.Conv2d(4,8,3) #(3 * 3 * 4)*8 + 8 = 296
conv1_ = nn.Conv2d(4,8,3,groups=2) # ((3 * 3 * 4)*8)/2 + 8 = 152
#检查一下结果
conv1.weight.numel()
conv1.bias.numel()
conv1_.weight.numel()
conv1_.bias.numel()
#如果输入了奇数group呢?
conv2 = nn.Conv2d(4,8,3,groups=3)

直接报错,无法运行。

在这里插入图片描述

可以看到,分组卷积可以有效减少参数量。虽然在之前的课程中没有提到过,但实际上AlexNet所使用的架构中包含groups=2的分组卷积,因此在AlexNet的论文中架构看起来是这样的:

在这里插入图片描述

在这里插入图片描述

我们还可以将深度卷积与1x1卷积核结合使用。对输入特征图,我们首先进行深度卷积,产出一组特征图,然后再这组特征图的基础上执行1x1卷积,对特征图进行线性变换。两种卷积打包在一起成为一个block,这个block就叫做“深度可分离卷积”(Depthwise separable convolution),也被称为“分离卷积”(separable convolution)。对于深度可分离卷积的一个block,若不考虑偏置,则整个block的参
数量为:

在这里插入图片描述

#与图上不同,在代码中我们令输出的特征图数量与输入的特征图数量不相等,用以区别。输出特征图数量
=8。
conv1 = nn.Conv2d(4,8,3, bias=False) #(3 * 3 * 4) * 8 = 288
conv_depthwise = nn.Conv2d(4,8,3,groups=4,bias=False) #1/4 * (3 * 3 * 4)*8 = 72
conv_pairwise = nn.Conv2d(8,8,1,bias=False) # 64
(conv_depthwise.weight.numel()+conv_pairwise.weight.numel())/(conv1.weight.numel
())
1/4 + 8/(4*3*3)

深度可分离卷积在2017年的论文《Xception: Deep Learning with Depthwise Separable Convolutions》中被提出,现在是谷歌的深度学习模型GoogLeNet进化版中非常关键的block。论文中提出,分组卷积核深度可分离卷积不仅可以帮助卷积层减少参数量,更可以削弱特征图与特征图之间的联系来控制过拟合。更多详细内容可以参考课程附件中的论文。之后我们还会用到深度可分离卷积的相关内容。

在卷积层上,我们还可以进行更多更丰富的操作来减少参数量并提升模型的效果,之后若有机会我们会就其他卷积相关操作继续展开来讨论。

2 全连接层

卷积层上减少参数的操作非常丰富,但从经典架构来看,真正对CNN参数量“贡献”巨大的是全连接层。数据在进入全连接层时,需要将所有像素拉平,而全连接层中的一个像素点就对应着一个参数,因此全连接层所携带大量参数。

为什么卷积网络里需要有全连接层呢?全连接层的作用主要有以下两个:
1、作为分类器,实现对数据的分类。在卷积网络中,卷积层和池化层的作用是提取特征,但提取出来的一张张特征图与我们希望要的对应类别的输出还相差很远,为了将信息转化为输出,我们需要在卷积和池化层的后面加上能够实现分类的结构,而全连接层是一切能够实现分类的结构中,较为简单、较为熟悉、同时成本也相对低的存在。本质上来说,卷积层提供了一系列有意义且稳定的特征值,构成了一个与输入图像相比维数更少的特征空间,而全连接层负责学习这个空间上的(可能是非线性的)函数关系,并输出预测结果。(其他可能的选择是,在卷积层后面放置一个SVM,或者放置其他机器学习算法作为分类器。)

2、作为整合信息的工具,将特征图中的信息进行整合。基于卷积层的输出来进行分类是一件困难的事情。由于卷积层输出的结果是特征图,因此我们有以下两种方式来进行分类:

在这里插入图片描述

由于我们在计算设置上的各种努力,卷积网络生成的特征图每张都由不同的卷积核扫描生成,因此每张都携带不同的信息,让不同特征图对应不同类别,很可能会损失掉一些本能够将样本判断正确的信息。而同时,又由于我们在参数设置上的各种努力(例如,使用奇数卷积核来保证图像不会失真太多),卷积层生成的特征图是自带位置信息的:任意像素映射到自己的特征图上的位置,与该像素的感受野映射到原图上的位置几乎是一致的,因此,若使用“特征图的不同区域”的信息来进行类别划分,可能会造成“只有某个区域的数据参与了某个标签的预测”的情况。在进行预测之前,将所有可能的信息充分混合、进行学习,对预测效果有重大的意义。全连接层能够确保所有信息得到恰当的“混合”,以保证预测的效果。

基于上面的两个理由,再加上约定俗成,我们一般都会在形似AlexNet或VGG的网络中包含全连接层。但通常来说,一旦有可以替换全连接层、并不影响模型效果的手段,大家就会尝试将全连接层替代掉,因为全连接层所带来的参数量对算力有很高的要求,并且,全连接层的存在让CNN整体变得更容易过拟合。2012年时,Hinton团队提出了dropout来控制全连接层的过拟合,后来又有了batch normalization方法,现在的CNN架构已经不太容易过拟合了。但是全连接层令人头疼的参数量问题依然没有解决。在讨论各种替换全连接层的可能性之前,我们先来看使用全连接层时性价比较高的参数组合:

在这里插入图片描述

更多层,还是更多神经元?

对于CNN中的全连接层来说,在一个层上增加足够多的神经元,会比增加层效果更好。一般来说,CNN中的全连接层最多只有3-4层(包括输出层),过于多的层会增加计算的负担,还会将模型带入过拟合的深渊。对于小型网络,3层全连接层已是极限了。需要注意的是,在卷积层和全连接层的连接中,通常全连接的输出神经元个数不会少于输入的通道数。对于全连接层之间的连接,只要不是输出层,也很少出现输出神经元少于输入神经元的情况。对全连接层而言,更大的参数代表了更高的复杂度、更强的学习能力、更大的过拟合可能,因此对于小型网络来说,除非你的数据量庞大或数据异常复杂,尽量不使用1024以上的参数。

2.1 从卷积到全连接层

决定全连接层参数数量的有两个因素:最后一个卷积层上的特征图所含的像素量,以及我们在全连接层之间设定的输出神经元个数。在自建网络时,全连接层的参数输入一直是无数卷积网络新手的盲点。在互联网资料齐全、代码基本靠复制粘贴、架构基本照着架构图写的今天,许多深度学习的学习者甚至意识不到这个问题的存在(也有一部分理由是,tensorflow不需要输入in_channels和in_features,因此许多深度学习学者可能没有考虑过这个问题)。以下图为例,假设输入数据的结构为(10,3,229,229),没有架构图,请问两个箭头处应该分别填写什么数字呢?

在这里插入图片描述

代码留给大家,大家可以自己先试试看,如果你能够顺利让数据通过Model、不报错的话,则说明你的参数设置正确了。

data = torch.ones(size=(10,3,229,229))
class Model(nn.Module):
    def __init__(self):
        super().__init__()
        
        #block1
        self.conv1 = nn.Conv2d(3,6,3)
        self.conv2 = nn.Conv2d(6,4,3)
        self.pool1 = nn.MaxPool2d(2)
        
        #block2
        self.conv3 = nn.Conv2d(4,16,5,stride=2,padding=1)
        self.conv4 = nn.Conv2d(16,3,5,stride=3,padding=2)
        self.pool2 = nn.MaxPool2d(2)
        
        #FC层
        self.linear1 = nn.Linear(   ,256)
        self.linear2 = nn.Linear(256,256)
        self.linear3 = nn.Linear(256,10)
        
    def forward(self,x):
        x = F.relu(self.conv1(x))
        x = self.pool1(F.relu(self.conv2(x)))
        x = F.relu(self.conv3(x))
        x = self.pool2(F.relu(self.conv4(x)))
        x = x.view(-1,   )
        
        x = F.relu(self.linear1(F.dropout(x,p=0.5)))
        x = F.relu(self.linear2(F.dropout(x,p=0.5)))
        output = F.softmax(self.linear3(x),dim=1)
        
        return output

首先你应当认识到,两个箭头处需要填写的数字是一样的,而这个数字就是最后一个卷积/池化层输出的特征图上所有的像素。通常在我们的架构中,这个输入会被写作如(64 * 7* 7)这样的形式,其中64就是最后一个卷积/池化层上输出的特征图的数量,而7*7就是我们的特征图尺寸。在一个架构中,输出的特征图数量是我们自己规定的,用上面的架构来看,就是3,因此唯一的问题就是特征图尺寸是多少。

在这里插入图片描述

在之前的课程中,我们使用torchinfo包中的summary来自动计算特征图尺寸,不难发现,要使用summary函数,前提是已经建好了能够顺利运行的model,但尴尬的是,当我们不知道架构中红色箭头处应该填写什么数字时,model是不可能跑通的。那怎么在模型架构不完整的情况下,找出最后一个池化层/卷积层上输出的特征图的尺寸呢?一种简单的方法是,将Model中所有的线性层都注释掉,只留下卷积层,然后将model输入summary进行计算,但有更简单的方法,使用另一种构筑神经网路架构的方式:nn.Sequential。

nn.Sequential是一种非常简单的构筑神经网络的方式,它可以将“以序列方式从前往后运行的层”打包起来,组合成类似于机器学习中的管道(Pipeline)的结构,以此避开建立类、继承类等稍微有些抽象的python代码。大多数深度学习课程和教材在最开始的时候就会介绍它,并且一直以它作为例子运行各类神经网络,我们来看具体的例子:

data = torch.ones(size=(10,3,229,229))

#不使用类,直接将需要串联的网络、函数等信息写在一个“序列”里
#重现上面的4个卷积层、2个池化层的架构
net = nn.Sequential(nn.Conv2d(3,6,3)
                   ,nn.ReLU(inplace=True)
                   ,nn.Conv2d(6,4,3)
                   ,nn.ReLU(inplace=True)
                   ,nn.MaxPool2d(2)
                   ,nn.Conv2d(4,16,5,stride=2,padding=1)
                   ,nn.ReLU(inplace=True)
                   ,nn.Conv2d(16,3,5,stride=3,padding=2)
                   ,nn.ReLU(inplace=True)
                   ,nn.MaxPool2d(2)
                   )
#nn.Sequential组成的序列不是类,因此不需要实例化,可以直接输入数据
net(data).shape

看见最终的特征图的结构了吗?399就是我们需要输入到红色箭头处的数字。同样的,我们也可以将nn.Sequential放入torch_receptive_field查看感受野的尺寸:

from torch_receptive_field import receptive_field
#net不是类所以不需要实例化
rfdict = receptive_field(net,(3,229,229))

使用nn.Sequential结构的好处多多,最明显的就是代码量的减少,至少在nn.Sequential中,我们可以不用再写一堆self.,而只需要按顺序列举数据会通过的类就可以了。由于nn.Sequential表示的是各个元素之间的串联计算过程,因此我们需要将架构写成输入数据的“运行管道”,让数据能够从上向下进行计算,因此我们实际上是将网络架构(各类层)和计算过程(激活函数、数据处理方式BN等)混写,这样
做代码量会减少很多。当然,混写既是优点,也是缺点——混写之后,卷积层架构将不再像左侧一样清晰明显,比较不容易看出分割的blocks,因此在深度学习的入门阶段,需要熟悉网络架构的时候,我们并没有让大家采用nn.Sequential的形式来构建网络。

在这里插入图片描述

在较为复杂的网络架构中,我们通常利用nn.Sequential来区分网络的不同部分:例如,在普通CNN中,卷积层、池化层负责的是特征提取,全连接层负责的是整合信息、进行预测,因此我们可以使用nn.Sequential来区别这两部分架构。

在这里插入图片描述

以VGG16为例,使用nn.Sequential的架构如下:

class VGG16(nn.Module):
    def __init__(self):
        super().__init__()
        self.features_ = 
nn.Sequential(nn.Conv2d(3,64,3,padding=1),nn.ReLU(inplace=True)
                                       
,nn.Conv2d(64,64,3,padding=1),nn.ReLU(inplace=True)
                                       ,nn.MaxPool2d(2)
                                       
                                       
,nn.Conv2d(64,128,3,padding=1),nn.ReLU(inplace=True)
                                       
,nn.Conv2d(128,128,3,padding=1),nn.ReLU(inplace=True)
                                       ,nn.MaxPool2d(2)
                                       
                                       
,nn.Conv2d(128,256,3,padding=1),nn.ReLU(inplace=True)
                                       
,nn.Conv2d(256,256,3,padding=1),nn.ReLU(inplace=True)
                                       
,nn.Conv2d(256,256,3,padding=1),nn.ReLU(inplace=True)
                                       ,nn.MaxPool2d(2)
                                       
                                       
,nn.Conv2d(256,512,3,padding=1),nn.ReLU(inplace=True)
                                       
,nn.Conv2d(512,512,3,padding=1),nn.ReLU(inplace=True)
                                       
,nn.Conv2d(512,512,3,padding=1),nn.ReLU(inplace=True)
                                       ,nn.MaxPool2d(2)
                                       
                                       
,nn.Conv2d(512,512,3,padding=1),nn.ReLU(inplace=True)
                                       
,nn.Conv2d(512,512,3,padding=1),nn.ReLU(inplace=True)
                                       
,nn.Conv2d(512,512,3,padding=1),nn.ReLU(inplace=True)
                                       ,nn.MaxPool2d(2)
                                     )
        self.clf_ = nn.Sequential(nn.Dropout(0.5)
                                 ,nn.Linear(512*7*7,4096),nn.ReLU(inplace=True)
                                 ,nn.Dropout(0.5)
                                 ,nn.Linear(4096,4096),nn.ReLU(inplace=True)
                                 ,nn.Linear(4096,1000),nn.Softmax(dim=1)
                                 )              
        
    def forward(self,x):
        x = self.features_(x)
        x = x.view(-1, 512*7*7)
        output = self.clf_(x)
        return output
vgg = VGG16()
summary(vgg, input_size=(10, 3, 224, 224),device="cpu")

可以看到,forward函数变得异常简单,整体代码量也缩小了。在构筑架构时,我们可以将代码稍作整理,让其更接近我们看到的网络架构,但是summary函数并不会对架构和层做这么多的区分,因此summary中的架构看上去就更长更深了。在实际构筑自己的神经网络时,我们常常会使用nn.Sequential来调试卷积层架构,并不断查看感受野的变化。

2.2 代替全连接层:1x1卷积核与全局平均池化(GAP)

虽然全连接层很有用,但它的参数量带来的计算成本的确是一个很大的问题。因此,研究者们曾经尝试找出各种方法,用来替代全连接层。其中流传比较广泛的方法之一,就是使用1x1卷积核来进行替代全连接层。虽然大部分持有此观点的材料的描述都模糊不清、甚至有胡言乱语之嫌,但是人们还是对1x1卷积核替代全连接层的效果深信不疑。那究竟可不可以呢?可以,但这么做的价值其实微乎其微,除了特殊的应用场景之外,既没有经典架构、也没有实际应用会这么做。我们来看看是怎么回事。
为什么人们说1x1卷积核可以替代全连接层呢?还记得之前绘制过的普通全连接层与卷积层在“链接”数量上的对比图吗?

在这里插入图片描述

对于普通卷积层而言,每个连接上的w就是完整的卷积核,一般至少带有9个参数。当使用1x1卷积核时,每个w中就只有一个参数,这就让1x1卷积层和普通全连接层更加相似,只不过普通全连接层的连接是在神经元与神经元之间,而1x1卷积层的连接是在特征图与特征图之间。因此从数学公式来看,全连接层和1x1的卷积层之间是可以互相转换的。对于卷积层来说,只要让特征图的尺寸为1x1,再让卷积核的尺寸也为1x1,就可以实现和普通全连接层一模一样的计算了。

在计算机视觉中,不包含全连接层,只有卷积层和池化层的卷积网络被叫做全卷积网络(fully convolutional network,FCN)。在无数减少全连接层的努力中,1x1卷积核可以在架构上完全替代掉全连接层,来看下面的例子

在这里插入图片描述

这是一个4分类的例子。在卷积和池化层之后,我们得到的特征图是(5x5)共16张,通过三个线性层(包括输出层)、或1个5x5卷积层+3个1x1卷积层,都可以将最终输出结果转化为我们需要的4个类别。

在这里插入图片描述

在1x1卷积层替代全连接层的例子中,输出的特征图的个数必须和全连接层上的神经元个数一致,这样才能使用输出的特征图“替代”掉全连接层,但在这样的要求下,不难发现,卷积层所需要的参数量是更大的。因此,使用1x1卷积层代替全连接层不能减少参数量。同时,没有证据能表明将全连接层更换成1x1卷积层之后能够提升模型的拟合效果,所谓“跨通道信息交互”等等的效果,在之前的课程中已经说明,和参数量以及是否替换全连接层都无关。因此,虽然1x1卷积核可以替换全连接层,但这么做的价值其实非常小。

如果要说1x1卷积核替换全连接层之后带来的最大的好处,那就是解放了输入层对图像尺寸的限制。在之前的学习中,我们已经知道卷积层和全连接层的连接处需要进行数据的“拉平”处理,并且需要人为手动输入全连接层的神经元数量,一旦无法正确计算卷积层输出的特征图尺寸,网络架构就会报错,无法运行。因为这个特点,输入卷积网络的图片的尺寸总是被严格规定的,一旦改变输入尺寸,网络架构就不能再使用了。而当整个架构中都只有卷积层的时候,无论如何调整输入图像的尺寸,网络都可以运行。比如上面的架构,输入尺寸是(3, 14, 14),最后输出的结果是4和(4, 1, 1)。现在我们将输入尺寸修改为(3,16,16),在普通CNN的架构中,数据就会因为无法通过全连接层而报错,但在FCN里就可以顺畅运行下去,最终输出(4, 2, 2)的结果。

在这里插入图片描述

无论多大的图像都能输出结果,这个性质在物体检测的实例中有一个有趣的应用。在物体检测中,我们需要判断一个物体位于图像的什么位置,因此需要使用小于图像尺寸的正方形区域对图像进行“滑窗”识别。在每一个窗口里,我们都需要执行一个单独的卷积网络,用以判断“物体是否在这个范围内”。

在这里插入图片描述

假设现在我们建立的网络是带有线性层的CNN网络,输入尺寸为14x14。那对于16x16尺寸的图像,就需要将下面四个14x14的区域分别输入CNN来进行判断,对每个区域输出“是”或“否”的结果。

在这里插入图片描述

但对于FCN而言,我们可以直接将这一张16x16的输入整个网络,最终会输出(2,2)大小的特征图。由于卷积网络层可以保留位置信息,所以这(2,2)的特征图中,每个像素的感受野都可以对应到原始图像中的相应区域,相当于使用一个网络一次性完成了对整个图像的四次扫描,并得到了四个相应的结果(2x2)。这种扫描方式比将图像切分成14x14的四块再运行4个CNN要高效得多,不过该应用仅限于物体检测中需要“滑窗”的场景。

总之,1x1卷积核的确可以替代全连接层,但效益不高。有些资料或文献会主张NiN网络使用1x1卷积替代了全连接层,但这一点不是非常严谨。在NiN的架构中,最后一个普通核尺寸的卷积核之后跟着的是MLP layers,并且这些MLP Layers最终将特征图数目缩小到了softmax公式要求的10个,因此说编号13、14的MLP Layers替代了普通CNN中全连接层的位置,也不是没有道理。不过在论文中,实现了全连接层的两个目标“整合信息”、“输出结果”的实际上是跟在MLP layers后的全局平均池化层(Global Average Pooling)。在NiN论文中,作者也明确表示,用来替代全连接层的是GAP层,我们来看看GAP层是如何运作的。

在这里插入图片描述

从之前1x1卷积核的例子来看,不难发现,只要在网络架构的最后能够将输出结果变成softmax函数可接受的格式,比如(n_class,1),并且确定用于生成这些输入值的信息是从之前的特征图中整合出来的,那任意架构在理论上来说都足以替代全连接层。GAP层就是这样的一个例子。GAP层的本质是池化层,它使用池化方式是平均池化,它的职责就是将上一层传入的无论多少特征图都转化成(n_class,1, 1)结构。为了能够将无论什么尺寸的特征图化为1x1的尺寸,GAP层所使用的核尺寸就等于输入的特征图尺寸。在NiN网络中,最后一个卷积层的输出是(10, 7, 7),因此全局平均池化层的核尺寸也是7x7,由于只能扫描一次,因此全局平均池化层不设置参数步长,一般也不会设置padding。

在这里插入图片描述

在PyTorch中,没有专门的GAP类,但我们可以使用普通的平均池化层,并令这个池化层的核尺寸为上层输入的特征图尺寸,以此来模拟全局平均池化。

data = torch.ones(10,7,7)
gap = nn.AvgPool2d(7)
gap(data).shape

使用1x1卷积核连接GAP的方式,NiN网络中完全没有使用全连接层,这让NiN网络整体的参数量减少不少,同时,GAP作为池化层,没有任何需要学习的参数,这让GAP的抗过拟合能力更强。在论文中,作者们还做了对比实验,证明GAP方法抗过拟合的能力更强:

在这里插入图片描述

import torch
from torch import nn
from torchinfo import summary
data = torch.ones(size=(10,3,32,32))
class NiN(nn.Module):
    def __init__(self):
        super().__init__()
        
        self.block1 = 
nn.Sequential(nn.Conv2d(3,192,5,padding=2),nn.ReLU(inplace=True)
                                   ,nn.Conv2d(192,160,1),nn.ReLU(inplace=True)
                                   ,nn.Conv2d(160,96,1),nn.ReLU(inplace=True)
                                   ,nn.MaxPool2d(3,stride=2)
                                   ,nn.Dropout(0.25) 
                                   )
        #在原论文中并没有标明dropout的p为多少,一般来说,用于卷积层的dropout上的p值都会较
小,因此设置了0.25              
        self.block2 = 
nn.Sequential(nn.Conv2d(96,192,5,padding=2),nn.ReLU(inplace=True)
                                   ,nn.Conv2d(192,192,1),nn.ReLU(inplace=True)
                                   ,nn.Conv2d(192,192,1),nn.ReLU(inplace=True)
                                   ,nn.MaxPool2d(3,stride=2)
                                   ,nn.Dropout(0.25)
                                   )
        self.block3 = 
nn.Sequential(nn.Conv2d(192,192,3,padding=1),nn.ReLU(inplace=True)
                                   ,nn.Conv2d(192,192,1),nn.ReLU(inplace=True)
                                   ,nn.Conv2d(192,10,1),nn.ReLU(inplace=True)
                                   ,nn.AvgPool2d(7,stride=1)
                                   ,nn.Softmax(dim=1)
                                   )
    def forward(self,x):
        output = self.block3(self.block2(self.block1(x)))
        return output

net = NiN()
net(data).shape
summary(net,(10,3,32,32))    

作为9层卷积层、最大特征图数目达到192的网络,NiN的参数量在百万之下,可以说都是归功于没有使用全连接层。不过,1x1卷积层所带来的参数量也不少,因此NiN可以说是在各方面都中规中矩的网络。

从今天的眼光来看,NiN网络最大的贡献就是在于让人们意识到了1x1卷积层可能的用途,并且将“舍弃线性层”的议题摆在了研究者面前。受到NiN网络启发而诞生的GoogLeNet以及ResNet都使用了1x1卷积层,并且在各种消减参数的操作下使网络变得更加深。

到这里,我们已经讲述了好几种降低参数量的操作,你还知道其他常用的降低参数量或计算量的手段吗?欢迎随时在群内与小伙伴们分享最新的论文和研究成果。从下一节开始,我们将开始了解视觉领域最前沿的数个模型。

三 前沿网络 state-of-the-art models

在深度学习的领域,最前沿、最先进的架构被称为state-of-the-art models,简写为SOTA,我将其翻译为“前沿网络”。每个学者都希望自己论文中的模型是SOTA model,也有不少人声称自己的工作成功达到了SOTA level,甚至为了达到SOTA level对数据进行一些微妙的操作,但真正能够进入人们的视野、并被广泛认可为优质架构的架构其实只是凤毛麟角。我们之前提到的VGG可以算是准SOTA level的架构,也是所有接近或达到SOTA level的架构中唯一一个只使用普通卷积层的架构,可以说是将思路的简洁性发挥到了极致。当然,简单的思路也导致VGG的参数量过于巨大,在VGG诞生的同时,学者们本着“网络更深、参数更少”的基本思路,创造了众多优质架构和模型,我们将在本节中仔细介绍他们。

在这里插入图片描述

1 GoogLeNet(Inception V1)

1.1 动机与思路

VGG非常优秀,但它在ILSVRC上拿到的最高名次是亚军,在VGG登场的2014年,力压群雄拿到冠军的是在ImageNet数据集上达到6.7%错误率的GoogLeNet。GoogLeNet由谷歌团队与众多大学合作研发,发表于论文《Going deeper with convolutions》,整篇论文语言精练简单,从标题到内容都彰显着谷歌团队加深卷积网络结构的决心,读起来非常有趣。受到NiN网络的启发,谷歌引入了一种全新的网络架构:Inception block,并将使用Inception V1的网络架构称为GoogLeNet(虽然从名字上来看致敬了LeNet5算法,但GoogLeNet已经基本看不出LeNet那种经典的卷积+池化+全连接的结构了)。

Inception直译是“起始时间”,也是电影《盗梦空间》的英文名称。或许谷歌团队是无心插柳,但Inception块的出现成为了深度视觉发展历史上的一个新的起点。从2014年的竞赛结果来看,Inception V1的效果只比VGG19好一点点(只比VGG降低了0.6%的错误率),两个架构在深度上也没有差太多,但在之后的研究中,Inception展现出比VGG强大许多的潜力——不仅需要的参数量少很多,架构可以达到的上限也更高。随着架构的迭代更新,Inception V3和V4已经是典型的SOTA模型,可以在ImageNet数据集上达到3%的错误率,但VGG在ILSVRC上的表现基本就是模型的极限了。

在这里插入图片描述

接下来,让我们来认识一下GoogLeNet和Inception V1。GoogLeNet在设计之初就采用了一种与传统CNN完全不同的构建思路。自从LeNet5定下了卷积、池化、线性层串联的基本基调,研究者们在相当长的一段时间内都在这条道路上探索,最终抵达的终点就是VGG。VGG找出了能够最大程度加大模型深度、增强模型学习能力的架构,并且利用巧妙的参数设计让特征图的尺寸得以控制,但VGG以及其他串联架构的缺点也是显而易见的,最关键的(甚至有些老生常谈的)一点就是参数过多,各层之间的链接过于“稠密”(Dense),计算量过大,并且很容易过拟合。为了解决这个问题,我们之前已经提出了多种方法,其中最主流的是:
1、使用我们在上一节中提出的分组卷积、舍弃全连接层等用来消减参数量的操作,让神经元与神经元之间、或特征图与特征图之间的连接数变少,从而让网络整体变得“稀疏”
2、引入随机的稀疏性。例如,使用类似于Dropout的方式来随机地让特征矩阵或权重矩阵中的部分数据为0
3、引入GPU进行计算

在2014年之前,以上操作就是我们目前为止接触的所有架构在减少参数量、防止过拟合上做出的努力。其中NiN主要使用方法1,AlexNet和VGG主要使用方法2和3,但这些方法其实都存在一定的问题:首先,分组卷积等操作虽然能够有效减少参数量,却也会让架构的学习水平变得不稳定。在神经网络由稠密变得稀疏(Sparse)的过程中,网络的学习能力会波动甚至会下降,并且网络的稀疏性与学习能力之间的下降关系是不明确的,即我们无法精确控制稀疏的程度来把握网络的学习能力,只能靠孜孜不倦的尝试来测试学习能力较强的架构。

其次,随机的稀疏性与GPU计算之间其实是存在巨大矛盾的。现代硬件不擅长处理在随机或非均匀稀疏的数据上的计算,并且这种不擅长在矩阵计算上表现得尤其明显。这与现代硬件查找、缓存的具体流程有关,当数据表现含有不均匀的稀疏性时(即数据中0的分布不太均匀时),即便实际需要的计算量是原来的1/100,也无法弥补数据查找(finds)和缓存缺失(cache misses)所带来的时间延迟。简单来说,GPU
擅长的是简单大量的计算操作,不同计算之间的相似性越高,GPU的计算性能就越能发挥出来,这种“相似性”表现在数据的分布相似(例如,都是偏态分布)、计算方式相似(例如,都是先相乘再相加)等方方面面。卷积操作本来就是一种涉及到大量矩阵运算的计算方式,当随机的稀疏性被放入权重矩阵或特征矩阵当中,每次计算时的数据分布都会迥然不同,这会严重拉长权重或特征相关计算所需要的时间。

相对的,稠密的连接却可以以更快的速度被计算。这并不是说稀疏的网络整体计算时间会更长,而是说在相同参数量/连接数下,稠密的结构比稀疏的结构计算更快。

此时就需要权衡了——稠密结构的学习能力更强,但会因为参数量过于巨大而难以训练。稀疏结构的参数量少,但是学习能力会变得不稳定,并且不能很好地利用现有计算资源。在2013年的时候,按分布让权重为0的Dropout刚刚诞生(2012年发表论文),在每层输出之后调整数据分布的Batch Normlization还没有诞生(2015年发表论文),创造VGG架构的团队选择了传统道路,即在学习能力更强的稠密架构上增加Dropout,但GoogLeNet团队的思路是:使用普通卷积、池化层这些稠密元素组成的块去无限逼近(approximate)一个稀疏架构,从而构造一种参数量与稀疏网络相似的稠密网络。这种思路的核心不是通过减少连接、减少扫描次数等“制造空隙”的方式来降低稠密网络的参数量,而是直接在架构设计上找出一种参数量非常少的稠密网络。

在数学中,我们常常使用稀疏的方式去逼近稠密的结构(这种操作叫做稀疏估计 sparse approximation),但反过来用稠密结构去近似稀疏架构的情况却几乎没有,因此能否真正实现这种“逼近”是不得而知的,不过这种奇思妙想正是谷歌作为一个科技公司能够持续繁荣的根基之一。在GoogLeNet的论文中,作者们表示,在拓扑学中,几何图形或空间在连续改变形状后还能保持性状不变,这说明不同的结构可以提供相似的属性。同时,也有论文表示,稀疏数据可以被聚类成携带高度相似信息的密集数据来加速硬件计算,考虑到神经元和特征图的本质其实都是数据的组合,那稀疏的神经元应该也可以被聚类成携带高度相似信息的密集神经元,如果神经元可以被聚类,那这很可能说明稀疏
架构在一定程度上应该可以被稠密架构所替代。虽然从数学上很难证明这种替代是否真的能“严格等价”,但从直觉上来说是可以说得通的。不得不说,比起之前诞生的传统网络,GoogLeNet的这个思路切入点是在大气层。

基于这样的基本理念,GoogLeNet团队使用了一个复杂的网络架构构造算法,并让算法向着“使用稠密成分逼近稀疏架构”的方向进行训练,产出了数个可能有效的密集架构。在进行了大量的实验之后,他们选出了学习能力最强的密集架构及其相关参数,这个架构就是Inception块和GoogLeNet。鉴于Inception块诞生的过程,我们很难以个人身份对GoogLeNet进行“改变卷积核尺寸”或“改变输出特征图数量”这个层面的调参。基于架构构造算法以及大量的实验,其架构的精妙程度已经远远超出个人可以对卷积神经网络做的任何操作,因此也不再需要更多的调参了。

1.2 InceptionV1

我们来看Inception V1的具体结构。与之前VGG和AlexNet中从上向下串联卷积层的方式不同,Inception块使用了卷积层、池化层并联的方式。在一个Inception块中存在4条线路,每条线路可以被叫做一个分枝(branch):第一条线路上只有一个1x1卷积层,只负责降低通道数;第二条路线由一个1x1卷积层和一个3x3卷积层组成,本质上是希望使用3x3卷积核进行特征提取,但先使用1x1卷积核降低通道数以此来降低参数量和计算量(降低模型的复杂度);第三条线路由一个1x1卷积层和一个5x5卷积层组成,其基本思路与第二条线路一致;最后一条线路由一个3x3池化层和一个1x1卷积层组成,将池化也当做一种特征提取的方式,并在池化后使用1x1卷积层来降低通道数。不难注意到,所有的线路都使用了巧妙的参数组合,让特征图的尺寸保持不变,因此在四条线路分别输出结果之后,Inception块将四种方式生成的特征图拼接在一起,形成一组完整的特征图,这组完整的特征图与普通卷积生成的特征图在结构、计算方式上并无区别,因此可以被轻松地输入任意卷积、池化或全连接的结构。在论文中,GoogLeNet自然是使用了224x224的ImageNet数据集,不过在下面的架构图中我们使用了尺寸较小的特征图进行表示。

在这里插入图片描述

虽然我们不知道谷歌使用的网络架构构造算法具体是如何得出Inception架构的,但这种架构的优势是显而易见的:
首先,同时使用多种卷积核可以确保各种类型和层次的信息都被提取出来。在普通的卷积网络中,我们必须选择不同尺寸的过滤器(卷积核、池化核)对图像进行特征提取。1x1卷积核可以最大程度提取像素与像素之间的位置信息,尺寸较大的卷积核则更多可以提取相邻像素之间的联系信息,最大池化层则可以提取出局部中最关键的信息,但在串联结构中,对同一张图片/特征图,我们只能选择一个过滤器来使用,这意味着我们很可能会损失其他过滤器可以提取出的信息。而在Inception中,我们一次性使用了全部可能的方式,因此无需再去考虑究竟哪一种提取方式才是最好的,在输出的时候,Inception将所有核提取出来的特征图堆积整合,确保提取出的信息是最全面的。

其次,并联的卷积池化层计算效率更高。串联的卷积计算必须一层一层进行,但并联的卷积/池化层可以同时进行计算,这种将特征提取的过程并行处理的方式可以极速加快计算的运行效率。同时,由于每个元素之间都是稠密连接,并不存在任何类似于分组卷积那样减少连接数量的操作,使得inception可以高效利用现有硬件在稠密矩阵上的计算性能。

大量使用1x1卷积层来整合信息,既实现了“聚类信息”又实现了大规模降低参数量,让特征图数量实现了前所未有的增长。出现在每一条线路的1x1卷积层承担了调整特征图数目的作用,它可以自由将特征图上的信息聚合为更少的特征图,让特征图信息之间的聚合更加“密集”。同时,每个1x1卷积核之后都跟着ReLU激活函数,这增加了一次使用非线性方式处理数据的机会,某种程度上也是增加了网络的“深度”。除此之外,1x1卷积层最重要的作用是控制住了整体的参数量,从而解放了特征图的数量。这一点可以从VGG和GoogLeNet整体架构的参数量上轻松看出来。

下面分别展示了VGG16的架构和GoogLeNet的完整架构。不难发现,在VGG中,当特征图的尺寸是14x14,输入特征图数量是512,输出特征图数量也是512时,一个卷积层的参数量大约是230万。而在GoogLeNet中,相同特征图尺寸、相同输入与输出特征图数量下的inception的参数量大约是45万上下,普通卷积层的1/5还少。考虑到inception中使用了5x5卷积核,而VGG中一直都是3x3卷积核,这种参数差异是不可思议的。巨大的参数量让VGG中可以使用的最大特征图数量是512,但在GoogLeNet中这个数量却达到了1024,并且还有数个输出832个特征图的inception块。这些差异毫无疑问都是使用1x1卷积核带来的。

在这里插入图片描述

(上图,VGG16架构;下图,GoogLeNet主体架构)

在这里插入图片描述

上图是GoogLeNet的主体架构。Inception内部是稠密部件的并联,而整个GoogLeNet则是数个Inception块与传统卷积结构的串联。这张架构图来自GoogLeNet的原始论文,其中patch_size就是过滤器的尺寸,3x3 reduce和5x5 reduce就是指inception块中3x3和5x5卷积层之前的1x1卷积层的输出量,pool proj中写的数字实际上是池化层后的1x1卷积层的输出量。与其他架构图相似,虽然没有被展示出来,但在每一个卷积层之后都有ReLU激活函数;同样的,从输出层的特征图尺寸来看,应该有不少卷积层中都含有padding,但无论在论文或架构中都没有被展示出来。当我们来查看GoogLeNet的架构图时,可能很容易就注意到以下几点:
1、在inception的前面有着几个传统的卷积层,并且第一个卷积层采用了和LeNet相似的处理方法:先利用较大的卷积核大幅消减特征图的尺寸,当特征图尺寸下降到28x28后再使用inception进行处理。如果将卷积+池化看做一个block(块),那inception之前已有两个blocks了,所以Inception的编号是从3开始。其中,block3、4、和5分别有2个、5个、2个Inception。

2、Inception中虽然已经包含池化层,但inception之后还是有用来让特征图尺寸减半的池化层,并且和VGG一样,让特征图尺寸减半的池化层也是5个,最终将特征图尺寸缩小为7x7。不难发现,在GoogLeNet的主体架构中,Inception实际上取代了传统架构中卷积层的地位,不过inception中有2层卷积层,因此网络总体有22层,比VGG19多了三层。
3、在架构的最后,使用了核尺寸为7x7的平均池化层。考虑到此时的特征图尺寸已经是7x7,这个池化层实际上一个用来替代全连接层的全局平均池化层,这和NiN中的操作一样。在全局平均池化层的最后,又跟上了一个线性层,用于输出softmax的结果。

如果将inception看做卷积层,那GoogLeNet的主体架构也不是标新立异的类型。不过,除了主体架构之外,GoogLeNet还使用了“辅助分类器”(auxiliary classifier)以提升模型的性能。辅助分类器是除了主体架构中的softmax分类器之外,另外存在的两个分类器。在整体架构中,这两个分类器的输入分别是inception4a和inception4d的输出结果,他们的结构如下:

在这里插入图片描述

将主体架构与辅助分类器结合,我们可以得到GoogLeNet的完整架构(见下图,该架构同样来自于原始论文,注意该架构下方是输入,上方是输出)。如果架构图不足够清晰,可以看附件中的单独的图片文件。

在谷歌团队测试GoogLeNet网络性能的实验中,他们注意到稍微浅一些的GoogLeNet也有非常好的表现,因此他们认为位于中层的inception输出的特征应该对分类结果至关重要,如果能够在迭代中加重这些中层inception输出的特征的权重,就可能将模型引导向更好的反向。因此,他们将位于中间的inceptions的结果使用辅助分类器导出,并让两个辅助分类器和最终的分类器一共输出三个softmax结果、依次计算三个损失函数的值,并将三个损失加权平均得到最终的损失。如此,只要基于最终的损失进行反向传播,就可以加重在训练过程中中层inceptions输出结果的权重了。这种思想有点类似于传统机器学习算法中的“集成”思想,一个GoogLeNet实际上集成了两个浅层网络和一个深层网络的结果来进行学习和判断,在一个架构中间增加集成的思想,不得不说是GoogLeNet的一大亮点。

值得一提的是,在论文的架构图中包含了一种叫做局部响应归一化(Local Response Normalization,LRN)的功能,这个功能最初是在AlexNet的架构中被使用,但我们从来没有说明过它的细节。主要是因为LRN是一个饱受争议、又对模型效果提升没有太多作用的功能,现在已基本被BN所替代(使用BN的inception被称为Inception V2)。同时,GoogLeNet的论文中也并没有给出LRN的具体细节,因此现在实现GoogLeNet的各个深度学习框架也基本上不考虑LRN的存在了。相对的,我们把所有的LRN层删掉后,在每个卷积层的后面加上了BN层,以确保更好的拟合效果。

在这里插入图片描述

1.3 GoogLeNet的复现

现在,我们来实现一下这个完整架构。GoogLeNet是我们遇见的第一个串联元素中含有更多复杂成分的网络,因此我们需要先单独定义几个单独的元素,之后才能够使用我们熟悉的建立类的方式来复现架构。首先,能够在主体网络中省略掉所有的激活函数,我们需要定义新的基础卷积层。这个卷积层是包含激活函数以及BN层的卷积层。这样定义能够帮助我们大幅度减少最后在整合好的GoogLeNet中会出现的ReLU函数以及BN层。

import torch
from torch import nn
from torchinfo import summary
class BasicConv2d(nn.Module):
    #在这里我们要改掉原来会输入参数默认值的习惯,而使用定义类型的方式,同时将**kwargs也放到
init中继承
    #**kwargs代表了“所需要的全部参数”,由于现在的架构变得复杂,我们不太可能将每个需要用的参数
都写在定义中
    #因此,我们继承**kwargs来获得所需类的全部参数
    def __init__(self, in_channels: int, out_channels: int,**kwargs): 
        super().__init__()
        self.conv = nn.Sequential(nn.Conv2d(in_channels, out_channels, 
bias=False, **kwargs) 
                                  #同样写上**kwargs
                                 ,nn.BatchNorm2d(out_channels)
                                 ,nn.ReLU(inplace=True))
    def forward(self,x):
        x = self.conv(x)
        return x


#测试
BasicConv2d(2,10,kernel_size=3) #这里的输入数据是随意输入的,只是为了我们所写的类能够跑通
#试试看如果不写**kwargs,会发生什么?为什么会有这种情况?
#再试试看谢谢其他参数,如stride,padding呢?

接下来,我们需要定义Inception块。由于Inception块中是并联的结构,存在4个branchs,所以我们不能使用nn.Sequential进行打包,而是要使用原始的self.的形式。在Inception块中,所有卷积、池化层的输入、输出以及核大小都需要我们进行输入,因此我们可以使用原论文中架构图上的英文作为参数的名称,如此,在填写架构中具体的数字时,我们只需要照着架构图一行一行填就可以了。

class Inception(nn.Module):
    def __init__(self
                 ,in_channels: int
                 ,ch1x1: int
                 ,ch3x3red: int
                 ,ch3x3: int
                 ,ch5x5red: int
                 ,ch5x5: int
                 ,pool_proj: int):
        super().__init__()
        self.branch1 = BasicConv2d(in_channels, ch1x1, kernel_size=1)
        
        self.branch2 = nn.Sequential(BasicConv2d(in_channels, ch3x3red, 
kernel_size=1)
                                     ,BasicConv2d(ch3x3red, ch3x3, 
kernel_size=3, padding=1))
        self.branch3 = nn.Sequential(BasicConv2d(in_channels, ch5x5red, 
kernel_size=1),
                                     BasicConv2d(ch5x5red, ch5x5, kernel_size=5, 
padding=2))
        
        self.branch4 = nn.Sequential(nn.MaxPool2d(kernel_size=3, stride=1, 
padding=1, ceil_mode=True),
                                     BasicConv2d(in_channels, pool_proj, 
kernel_size=1))
    def forward(self, x):
        branch1 = self.branch1(x)
        branch2 = self.branch2(x)
        branch3 = self.branch3(x)
        branch4 = self.branch4(x)
        outputs = [branch1, branch2, branch3, branch4]
        return torch.cat(outputs, 1)
#torch.cat函数的用法
a = torch.ones(2,4)
b = torch.zeros(2,5)
torch.cat([a,b],dim=1)
a = torch.ones(4,2)
b = torch.zeros(5,2)
torch.cat([a,b],dim=0)
#测试
Inception(256,64,96,128,16,32,32) #这是inception3a的参数

接下来,还需要单独定义的是辅助分类器(Auxiliary Classifier)的类。辅助分类器的结构其实与我们之前所写的传统卷积网络很相似,因此我们可以使用nn.Sequential来进行打包,并将分类器分成.features_和.clf_两部分来进行构建:

class AuxClf(nn.Module):
    def __init__(self,in_channels: int,num_classes: int,**kwargs):
        super().__init__()
        self.features_ = nn.Sequential(nn.AvgPool2d(5,stride=3),
                                       BasicConv2d(in_channels, 128, 
kernel_size=1))
        self.clf_ = nn.Sequential(nn.Linear(4*4*128, 1024)
                                 ,nn.ReLU(inplace=True)
                                 ,nn.Dropout(0.7)
                                 ,nn.Linear(1024, num_classes))
    def forward(self, x):
        x = self.features_(x)
        x = x.view(-1,4*4*128)
        x = self.clf_(x)
        return x
#测试
AuxClf(512,1000)

在定义好三个单独的类后,我们再依据GoogLeNet的完整架构将所有内容实现。虽然GoogLeNet的主体结构是串联,但由于存在辅助分类器,我们无法在使nn.Sequential时单独将辅助分类器的结果提取出来。如果按照辅助分类器存在的地方对架构进行划分,又会导致架构整体在层次上与GoogLeNet的架构图有较大的区别,因此我们最终还是使用了self.的形式。在我们自己使用GoogLeNet时,我们不一定总要使用辅助分类器。如果不使用辅助分类器,我们则可以使用nn.Sequential来打包整个代码。包含辅助分类器的具体代码如下:

class GoogLeNet(nn.Module):
    def __init__(self,num_classes: int = 1000,blocks=None):
        super().__init__()
    
        #我们可以自由输入三种不同类型的类的名字
        #使用这种方法,当我们修改或重新定义任意的类时,我们只需要修改列表中的元素,而不需要修改
整个架构
        if blocks is None:
            blocks = [BasicConv2d, Inception, AuxClf]
        conv_block = blocks[0]
        inception_block = blocks[1]
        aux_clf_block = blocks[2]
        
        #block1
        self.conv1 = conv_block(3, 64, kernel_size=7, stride=2, padding=3)
        self.maxpool1 = nn.MaxPool2d(3, stride=2, ceil_mode=True)
        
        #block2
        self.conv2 = conv_block(64, 64, kernel_size=1)
        self.conv3 = conv_block(64, 192, kernel_size=3, padding=1)
        self.maxpool2 = nn.MaxPool2d(3, stride=2, ceil_mode=True)
        
        #block3
        self.inception3a = inception_block(192, 64, 96, 128, 16, 32, 32)
        self.inception3b = inception_block(256, 128, 128, 192, 32, 96, 64)
        self.maxpool3 = nn.MaxPool2d(3, stride=2, ceil_mode=True)
        
        #block4
        self.inception4a = inception_block(480, 192, 96, 208, 16, 48, 64)
        self.inception4b = inception_block(512, 160, 112, 224, 24, 64, 64)
        self.inception4c = inception_block(512, 128, 128, 256, 24, 64, 64)
        self.inception4d = inception_block(512, 112, 144, 288, 32, 64, 64)
        self.inception4e = inception_block(528, 256, 160, 320, 32, 128, 128)
        self.maxpool4 = nn.MaxPool2d(3, stride=2, ceil_mode=True)
        
        #block5
        self.inception5a = inception_block(832, 256, 160, 320, 32, 128, 128)
        self.inception5b = inception_block(832, 384, 192, 384, 48, 128, 128)
        
        #auxclf
        self.aux1 = aux_clf_block(512, num_classes)
        self.aux2 = aux_clf_block(528, num_classes)
        
        #clf
        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
        #自适应平均池化,可以自动为我们输出(1,1)尺寸的特征图
        #在这里就相当于全局平均池化了
        self.dropout = nn.Dropout(0.4)
        self.fc = nn.Linear(1024, num_classes)
    def forward(self, x):
        #block1
        x = self.maxpool1(self.conv1(x))
        
        #block2
        x = self.maxpool2(self.conv3(self.conv2(x)))
        
        #block3
        x = self.inception3a(x)
        x = self.inception3b(x)
        x = self.maxpool3(x)
        #block4
        x = self.inception4a(x)
        aux1 = self.aux1(x)
        x = self.inception4b(x)
        x = self.inception4c(x)
        x = self.inception4d(x)
        aux2 = self.aux2(x)
        x = self.inception4e(x)
        x = self.maxpool4(x)
        
        #block5
        x = self.inception5a(x)
        x = self.inception5b(x)    
        #clf
        x = self.avgpool(x)
        x = torch.flatten(x, 1)
        x = self.dropout(x)
        x = self.fc(x)
        
        return x, aux2, aux1
#建立数据,测试
x = torch.ones(10,3,224,224)
net = GoogLeNet()
fc2, fc1 ,fc0 = net(x)
fc2.shape
fc1.shape
fc0.shape
summary(net,(10,3,224,224),device="cpu")         

运行不报错,则说明我们的架构建立成功了。使用Summary查看最后的结果,可以看到一个长得不可思议的层次结构。这个层次结构中至少有三层,最内部的是普通卷积层构成的分枝branch,然后是inception块,然后是inception块和其他结构组成的blocks。在这种情况下,依赖于Summary来查看架构层次已经是不太可能的事儿了,因此我们可以使用summary函数自带的参数depth来调整显示层次的深度:

summary(net,(10,3,224,224),device="cpu",depth=1)

这样架构就清晰多了。虽然架构复杂,但这个网络实际只有22层卷积层,加上池化层只有27层,和几百层的网络们比起来不算什么。并且,GoogLeNet只有1300万参数量(并且从summary的结果可以看出,其中700万参数都是由分类器上的全连接层带来的,如果不使用辅助分类器,则GoogLeNet的参数量大约只有600万左右),而16层的VGG的参数量是1.3亿,两者参数相差超过10倍。从计算量来看,GoogLeNet也全面碾压了VGG本身。如下图所示,GoogLeNet所需的总计算量不足一个G,而VGG却需要15个G。在2014年的ILSVRC上,GoogLeNet只以微小的优势打败了VGG19,但其计算效率以及在架构上带来的革新是VGG19无法替代的。

在这里插入图片描述

(上图为GoogLeNet参数量及计算量,下图为VGG16参数量及计算量)

在这里插入图片描述

GoogLeNet的架构完美地展现了谷歌团队在设计inception时所忠于的逻辑:使用密集的成分去接近稀疏的架构,不仅能够像稀疏架构一样参数很少,还能够充分利用密集成分在现代硬件上的计算效率。在2014年之后,谷歌团队数次改进了Inception块和GoogLeNet整体的排布。今天,这一族最强大的是InceptionV3。在经过适当训练之后,Inception V3可以在ImageNet2012年数据集上拿到4.2%的错误率,我们自己手写的一个普通的V3模型也能够在ImageNet数据集上拿到6.5%的错误率,是现在视觉届仅次于深层残差网络(100层以上)的架构。

2 ResNet

自从2012年AlexNet登场之后,卷积网络在ILSVRC上的错误率就逐年下降,在Inception和GoogLeNet诞生之后,CNN在ImageNet数据集上能够达到的水平已经非常接近人类识别的表现(约5%)。然而,即便算法已经基本达到人类水平,有两个瓶颈一直没有被突破:第一,网络能够达到的最大深度依然很浅,VGG是19层,GoogLeNet也没有超过25层,从理论上来说,CNN应该还有巨大的潜力。第二,深度网络的训练难度太大,虽然强行堆叠卷积层或inception让网络加深非常容易,但加深后的网络往往收敛困难,损失很高,精度很低。在2012年之后,众多研究都在“帮助深度神经网络收敛”这一部分取得了巨大的成效——学者们对神经网络的各个训练流程都做出了改变,现在我们可以通过参数初始化、学习率调度、Batch Normalization、动量法、随机梯度下降等手段来帮助神经网络更好地收敛。然而,让深度网络取得更好的精度这一方面却进展缓慢。

在这里插入图片描述

为什么深度网络在现有训练流程下会退化呢?一种广泛的猜测是,随着网络加深,网络的学习能力逐渐增强,从而引起了过拟合,导致测试集上的误差上升。但这个观点很快就被否认了。如果深层网络的问题是过拟合,那深层网络应该只在测试集上表现糟糕,并且在训练集上表现出非凡的精度,但事实上,从上图的实验结果来看,深度网络的训练误差和测试误差都高于浅层网络,其训练集和测试集的精度表
现都很糟糕。这说明深度网络的损失更高、精度更低并不是过拟合造成的。更合理的推测是,深层网络中的函数关系本质上就比浅层网络中的函数关系更复杂、更难拟合(fit),因此深层网络本质上就比浅层网络更难优化和训练。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

从学术的角度来看,在浅层网络后增加恒等函数可以说是一个重要的“灵感”,我们无法证明它的正确性,却可以一定程度上理解这个现象为何会发生——如果增加深度就会因无法训练导致精度下降,那能够让精度不下降的恒等函数可能真的就是最好的选择。2015年,基于这样的实验和结论,微软AI研究中心提出了全新的深度网络架构:残差网络ResNet,其基本思想可以如下概括:假设增加深度用的最优结构就是恒等函数,利用恒等函数的性质,将用于加深网络深度的结构向更容易拟合和训练的方向设计,从根源上降低深度网络的训练难度。

具体怎么操作呢?VGG用来加深网络的结构是重复的卷积层,GoogLeNet用来加深网络的结构是Inception块,而在残差网络中,这个结构块是“残差块”(Residual unit),也可以译作残差单元。在残差网络中,我们将众多残差单元与普通卷积层串联,以实现“在浅层网络后堆叠某种结构、以增加深度”的目的。

在这里插入图片描述

在这里插入图片描述

残差单元还可以大幅加速训练和运算速度。在进行参数初始化时,我们常常使用0初始化。对残差单元而言,参数被初始为0,就意味着整个残差单元被初始化为恒等函数。如果恒等函数就是最优的加深网络深度的结构,那许多残差单元在初始化时就被设置在了自己的最优结果上,许多单元甚至不再需要被训练了,这会大大加快整体网络的训练速度。除此之外,残差单元可以在上层卷积层还未训练的时候就迅速
将数据信息传递到下一层中。对于普通网络而言,如果上层网络网络还没有经过训练,信息是无法传递到下一层的,但对于残差块来说,最优状况下,训练的部分输出的结果应该非常接近0,因此即便卷积层还没有经过训练,我们直接将原始的 通过跳跃连接传递到下一层,对下一层而言应该也是一个不错的输入。因此在残差单元中,信息的传递速度会异常快速。因为卷积层接近于恒等函数,在对残差网络进行反向传播时,梯度也可以更快速地通过跳跃链接从后往前传递。

在这里插入图片描述

在这里插入图片描述
对于50层及以上的残差网络,每个残差单元中有三个卷积层,分别是1x1卷积,3x3卷积以及1x1卷积。你应该对这个结构感觉到熟悉,因为这是我们之前在讲解1x1卷积时详细提到过的瓶颈架构bottleneck。瓶颈架构是为了降低参数量而存在的,对于50层以上的残差网络而言,瓶颈架构可以大规模降低参数,让残差网络整体的参数量得到控制。瓶颈架构中也带有跳跃连接,因此瓶颈架构也是一种残差单元,不过为了方便区分,我们统一将含有1x1卷积层的残差单元称为瓶颈架构。

在这里插入图片描述

从架构上来看,残差网络似乎并不是太难,毕竟网络中的所有元素都是我们曾经学过、使用过、实现过的内容。然而,残差网络的复现已经远远超出深度学习新手的水平,如果你只观察架构图、在没有任何代码参考的情况下自行对网络进行探索性构建,那理想的时间大约是3-5个工作日,因为残差网络复现过程中会有大量新手很难想象的细节问题,我们来一一说明。

  • padding
    首先,和其他表格类的架构图一致,残差网络的架构中也隐藏了许多默认信息,例如每个卷积层后的ReLU激活函数以及Batch Normalization,例如每一层的padding和stride。幸运的是,我们只有1x1和3x3两种卷积核,为了保持特征图的尺寸不变,1x1卷积核搭配的padding都为0,3x3卷积核搭配的padding都为1。

  • stride
    如图所示,随着残差网络的加深,特征图的尺寸也是在逐渐变小的。与GoogLeNet一样,当残差网络的输入图像为224x224时,特征图总共缩小了5次,每次长宽都折半,最终尺寸为7x7。这让网络的整体架构也被分成了5个部分,每个部分里重复的残差单元是一致的。在残差网络中,我们把这5个部分称为layers,每个layers中包含的残差单元或瓶颈结构是一个个block(块)。通常来说,让特征图尺寸降维的工作是池化层的任务,但残差网络中只在最初出现了一个重叠池化层,在残差单元或瓶颈结构中都不存在池化层,这说明5次降维任务中的其他4次都是由步长为2的卷积层来完成的。而这些步长为2的卷积层分别在conv1、conv3_x、conv4_x以及conv5_x中。对于conv3、4、5这三个layers来说,这个卷积层就是本层中第一个残差单元或瓶颈结构的第一个卷积层。在这些卷积层中,步长需要被设置为2。

在这里插入图片描述

1122122222222222222

在这里插入图片描述

在这里插入图片描述

在之前我们学习的任意网络中,特征图的尺寸都会发生一定的变化,但没有一种架构的特征图变化像残差网络这样频繁和多样。之前我们遇见过的、最常见的变化是“翻倍”,随着网络深度加深,特征图的数量由128翻倍至256,在至512,最终可达2048。之前,无论一个网络架构中有多少相似的层或相似的元素,我们都通过从输入到输出向下罗列层的方式将网络进行呈现,以求网络架构与架构图尽量相似。因此,特征图数量的变化可以被详细地展现在每个卷积层的连接上。我们唯一需要保证的,就是上个卷积层的输出特征图数量与下个卷积层的输入特征图数量一致。然而这种方式在残差网络上并行不通。残差
网络比我们之前复现过的任何网络都要深得多,虽然我们可能列举几个相连的层,但我们不可能列举101或者152层网络。因此,我们必须使用具有一定通用性的代码来实现不同的层。这意味着,我们不能按照每层的输入、输出的方式来控制卷积的输入和输出,而不同的卷积层需要共享我们输入的超参数,同时,超参数的数量不能很多。当网络结构是高度重复时,通用性代码往往会比较简单。然而,在特征图频繁、多样变化的情况下,事情就变得复杂了,因为我们必须要想办法区别不同场景下的特征图数量的变化规律。对于50层以下的残差网络来说,增加深度的结构是残差单元,因此特征图数量会存在以下2种情况:

1、保持不变。conv1与conv2_x连接时,每个layers内部不同的残差单元在连接时,特征图数量是不变的
2、翻倍。在不同的layers之间连接时,下一个layers输出的特征图数量是上一个layers输出数量的2倍。

对于50层以上的残差网络来说,事情会更复杂。深层残差网络中用来增加深度的结构是带有跳跃连接的瓶颈架构,一个瓶颈架构中涉及到3个卷积层,特征图数量会存在如下4种情况:

1、保持不变。conv1与conv2_x连接时,一个瓶颈架构内嵌两个卷积层连接时,特征图数量保持不变。
2、变成上层输出数量的四倍。在一个瓶颈架构内,前两个卷积层共享特征图数目(如我们之前所说,使用middle_out进行表示),但是第三个1x1卷积层的输出特征图数量是4 * middle_out。
3、变成上层输出数量的1/4。在一个layers内,上一个瓶颈架构与下一个瓶颈架构连接时,特征图数目需要从4 * middle_out恢复成middle_out。
3、变成上层输出数量的1/2。在不同的layers之间连接时,上一个瓶颈架构输出的特征图数量是下一个瓶颈架构输出特征图数量的2倍。

在代码中,我们必须想办法区别上述所有不同场景中不同的特征图数量变化。在这些数量变化中,只要有一层报错,整个残差网络就无法运行。同时,我们的代码还必须考虑到以上提到的padding、stride、初始化、BN等所有的因素。理想状况下,我们还希望不同层的残差网络可以共享一个类,在一个类的基础上,我们使用不同的参数来控制内部结构及所有卷积层上的变化,并且代码要尽量简洁和清晰。现在你知道为什么复现残差网络有点超出初学者的能力范围了吧,在这个过程中想要整理出清晰的逻辑并不是一件容易的事儿。

在这里插入图片描述

现在我们就来复现一下残差网络,我们将创造一个通用的类,在这个类上输入相关的参数,就可以实现上图中展现的五种残差网络。和复现GoogLeNet时一样,我们先从简单的、可以打包的元素开始定义。残差网络中的卷积层虽然变化多端,但其实只有两种:3x3卷积层与1x1卷积层,并且我们知道,每个卷积层后面都需要跟上BN层,而BN层上可以完成参数初始化。我们就从这里开始写:

#导入需要的库
import torch
import torch.nn as nn
#为了以更丰富的手段实现残差网络,我们将导入一些数据类型,这些类型可以在定义类的时候帮助我们定义参
数类型
from typing import Type, Union, List, Optional
from torchinfo import summary
'''
首先定义包含初始化功能的3x3卷积+bn和1x1卷积+bn
注意,并不是每一个BN层都需要初始化,只有位于残差单元和瓶颈架构的最后一层卷积层后的BN才需要初始化
所以我们可以将是否初始化设置为一个超参数initialzero
如何设置超参数的默认值呢?将大多数时候会发生的情况设置为默认值。
例如,只有少数层才需要初始化,因此我们将initalzero的默认参数值设置为False
只有少数层承担降低特征图尺寸的职责,大部分时候步长都为1,因此stride的默认值为1
'''
def conv3x3(in_, out_ #输入和输出特征图数量,为了控制特征图的变化而必须存在的参数
           , stride=1 #ResNet使用卷积层来对特征图进行降维,因此需要控制步长
           , initialzero = False):
    bn = nn.BatchNorm2d(out_)
    #进行判断:需要进行初始化吗?
    #bn.weight就是gamma的取值
    if initialzero == True:
        nn.init.constant_(bn.weight, 0)
    return nn.Sequential(nn.Conv2d(in_, out_, kernel_size=3
                                   , stride=stride, padding=1,bias=False)
                       ,bn)
 #kernel_size一定是3,搭配的padding一定是1,我们将这些参数写死,确保这些参数无法进行修改

def conv1x1(in_, out_, stride=1, initialzero = False):
    bn = nn.BatchNorm2d(out_)
    if initialzero == True:
        nn.init.constant_(bn.weight, 0)
    return nn.Sequential(nn.Conv2d(in_, out_, kernel_size=1
                                   , stride=stride, padding=0,bias=False)
                         ,bn)
 #kernel_size一定是1,搭配的padding一定是0
#查看函数返回的结果,虽然我们定义的是函数,但实际上最后返回的是一个nn.Sequential类
conv1x1(2,10,1,True)
    
#测试,initialzero参数是否有效
#随意设置的参数值
conv1x1(2,10,1,True)[1].weight
conv1x1(2,10,1,False)[1].weight

有了架构中的最小单位“卷积层”,我们就可以来写由卷积层构成的残差单元和瓶颈架构了。这两个架构块都需要定义成类,因为数据需要从中流过。

在这里插入图片描述

我们先来看残差单元。一个残差单元中只包含两个卷积层和一个加和功能。参照这张图,我们先将最基础的结构(不缩减特征图尺寸的残差单元)写出来。

在这里插入图片描述

在写foward函数时我们习惯于让x = self.function,但在残差单元中我们需要原始x的值,因此千万别将原始x进行覆盖。如果你担心自己会不小心覆盖掉原始x,也可以在foward函数一开始就添加类似于identity = x的语句,先将x的值保存在另一个变量当中,方便后续调用。

【1 初始化】在上面所写的结构的基础上,我们来考虑初始化的存在。根据初始化的相关理论,初始化只会发生在每个残差单元最后一个卷积层的bn层上,因此我们将最后一个层的参数initialzero设置为True,其他地方不做修改,因此其他卷积层中的0初始化功能并未开启。

【2 步长】接下来考虑步长stride的存在。残差网络使用stride=2的卷积层来给特征图降维,并且每次降维都发生在layers与layers之间。无论是残差单元还是瓶颈架构,一旦降维之后,一个layers内部的特征图尺寸是不变的,这说明降维任务是由每个layers中第一个残差单元或瓶颈架构上的第一个卷积层完成的。基于这个理解,我们将残差单元中第一个卷积层上的步长定义为参数stride1,并且让这个参数只影响架构中的第一个卷积层,也就是第一个conv3x3层。当stride1=1时,任何卷积层都不执行降维操作。当stride1=2时,只有第一个卷积层执行降维操作。后续的卷积层的stride参数不受stride1的影响,因为他们受到conv3x3和conv1x1中定义的默认值stride=1的控制。

还记得我们之前说过的F(x)与x相加的问题吗?每当卷积网络将特征图尺寸减半,跳跃连接上也需要存在1x1卷积层令x的尺寸减半,因此我们需要添加单独的skipconv。这个卷积层的步长与第一个conv3x3的步长一致,只有当第一个conv3x3的步长为2时,skipconv上的步长才需要为2,因此我们让skipconv的步长也等于stride1。同时,skipconv不是随时都需要存在的,只有当stride1=2的时候才需要skipconv,因此我们需要将forward函数中的内容改写为一个if条件:如果stride1不等于1,则执行skipconv,否则不执行。为此,还需要在__init__函数中设置属性self.stride1。

在这里插入图片描述

【3 特征图数量的变化】如我们之前所提到的,在使用残差单元的浅层残差网络里,每当我们利用步长=2来缩小特征图尺寸,特征图的数量也会翻倍。

在这里插入图片描述

观察18或34层的浅层残差网络,我们可以总计出如下规律:在layers与layers连接之处,步长总是为2,此时本层输出的特征图数量out_就等于上层的特征图数量in_ * 2。在conv1和conv2_x连接之处,以及一个layers内部不同的残差单元连接时,步长总是为1,此时本层输出的特征图数量out_就等于上层特征图数量in_。简单来说,在知道步长取值的前提下,我们只需要out_或in_中的任意一个参数,就可以计算另外一个参数。这可以帮助我们削减类的参数量。那究竟要保留out_还是in_作为输入的参数呢?答案是保留out_。你是否发现,深层和浅层残差网络在每个layers中共享的唯一数字就是卷积层上输出的特征图数目,如果保留out_,我们就有可能将现在正在写的残差单元类和之后要写的瓶颈架构类打包在一个类中,让他们共享参数out_。基于这个理解,我们将参数中的in_删除,并将代码改写为下图所示的样子:

在这里插入图片描述
在这里插入图片描述

代码如下:

class ResidualUnit(nn.Module):
    #根据是否将特征图尺寸折半,执行不同流程的残差单元
    #如果将特征图尺寸折半,则将输出特征图尺寸翻倍,并在skipconnection上放置1x1卷积网络
    #反之,则不改变特征图尺寸,也不使用任何1x1卷积网络
    def __init__(self, out_ : int, stride1 : int = 1,in_ : Optional[int] = 
None):
        '''
       stride1:第一个卷积层/跳跃连接中1x1卷积层的步长
       '''
        super().__init__()
        self.stride1 = stride1
        
        #是需要将特征图尺寸缩小的场合吗?在conv3_x, conv4_x和conv5_x的第一个残差单元中,需
要缩小特征图
        #是,则输出的特征图数量翻倍,输入量 = 输出量/2
        #否,则输出的特征图数量不变,输入量 = 输出量
        if stride1 != 1:
            in_ = int(out_/2)
        elsD:
            in_ = out_
            
        self.fit_ = nn.Sequential(conv3x3(in_,out_,stride1)
                               ,nn.ReLU(inplace=True)
                               ,conv3x3(out_,out_, initialzero = True))
        self.skipconv = conv1x1(in_, out_, stride1)
        self.relu = nn.ReLU(inplace=True)
        
    def forward(self,x):
        fx = self.fit_(x)
        if self.stride1 != 1:
            x = self.skipconv(x)
        hx = self.relu(fx + x)
        return hx
#测试
data = torch.ones(10,64,56,56) #特征图尺寸64x64,输入特征图数量64


conv3_x_18_0 = ResidualUnit(128,stride1=2) #特征图尺寸折半,特征图数量加倍
conv3_x_18_0(data).shape
conv2_x_18_0 = ResidualUnit(64) #特征图尺寸不变,特征图数量也不变
conv2_x_18_0(data).shape
#你是否注意到in_的存在?
ru = ResidualUnit(64,in_ = 64) #输入in_不影响ResidualUnit的任何行为
ru(data).shape

在这里插入图片描述
这段代码中有两处值得一提的地方:
1、瓶颈架构中有三种特征图数量,一种是整体架构的输入特征图数目in_,一种是瓶颈架构最终的特征图输出数量out_,还有一种是前两个卷积层所输出的特征图数量middle_out。意识到middle_out和in_以及out_都不同是正确复现瓶颈架构的关键。
2、对瓶颈架构而言,输入x和输出F(x)的特征图数量一定不一致,因此x必须要经过1x1卷积层的转化,因此skipconv是必须要存在的。

在这里插入图片描述

在此基础上,我们来看瓶颈架构中【1 特征图数量变化的问题】。与残差单元不同,瓶颈架构内部存在特征图数量的变化,不过由于我们能够将三个层串联起来写,通过给卷积层输出参数,我们可以完美规避掉串联的层之间输入输出不符导致出错的问题。因此更关键的还是瓶颈架构与瓶颈架构之间、layers与layers之间连接时的特征图数量变化。在conv3_x, conv4_x和conv5_x三个layers相互连接时,我们需要缩小特征图。若以middle_out为基准,每次缩小特征图时,上层输入特征图数量in_就等于本层middle_out的2倍。在一个layers中,不同的瓶颈架构在相互连接时,需要放大特征图,此时上层特征图数量in_就等于本层middle_out的4倍。还有一个特例是conv1与conv2_x连接的时候,此时特征图的大小不变,上层特征图数量in_就等于本层middle_out的值。围绕着唯一的参数middle_out,我们有数种方法可以实现这个逻辑。在这里,我采用了较为简单的一种方法:使用两个条件判断。

首先,我设置了选填参数in_,这个参数可以不填写,默认值为None。只有当瓶颈架构是conv1与conv2_x连接时,我才填写这个参数,并令这个参数等于64。除此之外,in_都等于None,且默认不是conv1与conv2_x之间的连接。当in_等于None时,一切就只分为两种情况了:当stride1=2,特征图尺寸下降时,或其他。基于此逻辑,我将代码改写为下图所示:

在这里插入图片描述

代码如下:

class Bottleneck(nn.Module):
    #瓶颈架构的结构更加复杂,存在多种特征图尺寸变化的情况
    def __init__(self, middle_out:int, stride1 : int = 1, in_ : Optional[int] = 
None):
        '''
       in_:输入瓶颈结构的特征图数量,仅在conv1之后紧跟的瓶颈结构才进行填写。其他时候不填写。
       stride1:第一个卷积层/跳跃连接中1x1卷积层的步长
       '''
        super().__init__()
        self.stride1 = stride1
        
        #最终的输出量 = 中间输出量的4倍
        out_ = 4 * middle_out
        
        if in_ == None:

            #不是conv1后紧跟的第一个瓶颈结构
            #是需要将特征图尺寸缩小的场合吗?在conv3_x, conv4_x和conv5_x的第一个瓶颈结构
中,需要缩小特征图
            #需要缩小特征图,则输入量 = 中间输出量 * 2  
            #不需要缩小特征图,则输入量 = 中间输出量 * 4
            if stride1 != 1:
                in_ = middle_out * 2
            elsD:
                in_ = middle_out * 4
                
        self.fit_ = nn.Sequential(conv1x1(in_,middle_out,stride1)
                                 ,nn.ReLU(inplace=True)
                                 ,conv3x3(middle_out,middle_out)
                                 ,nn.ReLU(inplace=True)
                                 ,conv1x1(middle_out,out_,initialzero = True))
                                  #最后一个1x1卷积层的输出一定是输入的4倍
        self.skipconv = conv1x1(in_, out_,stride1)
        self.relu = nn.ReLU(inplace=True)
        
    def forward(self,x):
        fx = self.fit_(x)
        #对瓶颈架构而言,输入x和输出F(x)的特征图数量一定不一致,因此x必须要经过1x1卷积层的转
化
        x = self.skipconv(x)
        hx = self.relu(fx + x)
        return hx
#测试
data1 = torch.ones(10,64,56,56) #特征图尺寸56x56,特征图数量64
#是conv1后紧跟的第一个瓶颈结构
conv2_x_101_0 = Bottleneck(in_ = 64, middle_out = 64) 
conv2_x_101_0(data1).shape
data2 = torch.ones(10,256,56,56)
#不是conv1后紧跟的第一个瓶颈结构,但是需要缩小特征图尺寸
conv3_x_101_0 = Bottleneck(middle_out = 128, stride1=2)
conv3_x_101_0(data2).shape #输出翻2倍并缩小特征图尺寸至一半
data3 = torch.ones(10,512,28,28)
#不是conv1后紧跟的第一个瓶颈结构,也不需要缩小特征图尺寸
conv3_x_101_1 = Bottleneck(128)
conv3_x_101_1(data3).shape #输出不变,特征图尺寸也不变


为了让残差单元和瓶颈架构的参数保持一致,我为残差单元的类也添加了可选参数in_,这样可以保证将ResidualUnit和Bottleneck两个类合并在一起时不会报错。不过根据残差单元的代码,这个in_的存在不会对类产生任何的影响,因为残差单元的执行代码中并没有任何会使用in_输入值或原始值的地方。一旦残差单元的代码运行,对象in_就会被重新赋值。

除了我们所使用的选填参数、if条件之外,还有许多其他写法都可以处理特征图数量变化引起的问题。最容易想到的一种就是让每个残差单元或瓶颈架构输出自己的out_值,再将这个值直接作为下层网络的输入值。当然,采取这种方法时,我们保留在ResidualUnit和Bottleneck两个类中的参数就不能是out_了(根据经验,如果采用这种思路,则必须同时保留in_和out_),同时在包装好的残差网络类中我们也需要写明提取上层out_并放入下层的代码。

现在,我们已经完成了残差单元和瓶颈架构的类(如下所示)。不难发现,虽然每个类内部的逻辑需要进行一些梳理,但我们完成的类只有3个参数:这个块中的输出的特征图数量/中间输出量,这个块中第一个卷积层的步长,以及选填的输入特征图数量。对于同一个layer,残差块中的输出特征图数目 = 瓶颈架构中的中间输出量,而步长其实隐性地决定了这个block在架构中是否位于需要降低特征图尺寸的位置。现在,我们需要将这两个类打包到一个更高级的类中,用来生成每个layers中所有的blocks。

class Bottleneck(middle_out, stride1, in_(optional))
class ResidualUnit(out_, stride1, in_(optional))

在这里插入图片描述

观察架构图。残差网络的每个layers中都存在大量重复的元素,在深层残差网络中,conv4_x中甚至将瓶颈架构重复了30次以上,如果能够使用代表数量的参数和for循环来对重复的部分进行控制,我们就可以大幅提高生成网络的效率。我们为新类命名为make_layers,具体代码如下:

def make_layers(block: Type[Union[ResidualUnit, Bottleneck]]
               , middle_out: int
               , blocks: int
               , afterconv1: bool = False):
    '''
   构建残差网络中layers的类
    
   block: 架构块的类型,可选ResidualUnit或Bottleneck。依据选择的架构块类型,可判断该残差
网络的深浅
   middle_out: ResidualUnit中输出的特征图数目/Bottleneck中的中间输出量,对两个block可混
用
   blocks:这个layer中的block数量
   afterconv1:这个layer是否紧接在conv1之后?
   '''
    
    layers = []
    #整个残差块或瓶颈结构的第一层
    #是conv1后的第一个block吗?
    if afterconv1 == True:
        #输入为64,middle_out也为64,且不改变特征图尺寸
        layers.append(block(middle_out, in_ = 64))
    else:
        #需要改变特征图尺寸,步长为2
        layers.append(block(middle_out, stride1 = 2))
    
    #重复的残差单元/瓶颈架构
    for i in range(blocks-1):
        layers.append(block(middle_out))
    return nn.Sequential(*layers)

这段代码非常简单易懂,需要重点说明一下的有两点:
1、从架构图上可以看出,每个layers中存在两种blocks,一种是影响特征图尺寸的、每个layers中的第一个块:0号block,另一种是0号block之后不断重复、并无变化的其他blocks。很明显,无论是特征图尺寸变化、衔接上一个block传出的输出数据,还是衔接普通卷积层conv1,0号block上可能存在各种各样的变化,因此需要单独处理。而其他重复的blocks,则可以使用for循环来一笔带过。

2、对于0号block,我们也有两种情况:紧跟在conv1后的、位于conv2_x上的block0,以及在conv2_x、3_x、4_x、5_x之间进行链接的block0。对于紧跟在conv1后的block,它不影响特征图尺寸,它的输入特征图数量一定等于64。而其他block0则都是步长为2的、可以将特征图尺寸折半的block0。

基于这个逻辑,我们可以对代码进行测试:

#测试 - 需要分别对残差块和瓶颈架构进行测试,并且需要对conv1后的首个架构,以及中间的架构进行测试
#注意检查:输入的数据结构是否正确,网络能否允许正确的数据结构输入,输入后产出的结构是否正确,包括
特征图尺寸是否变化、特征图数量是否变化,以及一个layers中所包含的blocks数量是否正确
#18层网络,紧跟在conv1之后的conv2_x,不改变特征图尺寸,且每层的输出都是64
datashape = (10,64,56,56) #特征图尺寸56x56,特征图数量64
conv2_x_18 = make_layers(ResidualUnit, middle_out = 64, blocks=2, 
afterconv1=True)
summary(conv2_x_18,datashape,depth=1,device="cpu")
#34层网络,conv4_x,缩小特征图尺寸,且每层的输出翻倍
datashape2 = (10,128,14,14)
conv2_x_34 = make_layers(ResidualUnit, middle_out = 256, blocks=6, 
afterconv1=False)
summary(conv2_x_34,datashape2,depth=1,device="cpu")
#101层网络,紧跟在conv1之后的conv2_x,不改变特征图尺寸,1x1和3x3卷积层的输出是64,整个瓶颈架
构的输出是256
conv2_x_101 = make_layers(Bottleneck, middle_out = 64, blocks=3, 
afterconv1=True)
summary(conv2_x_101,datashape,depth=3,device="cpu")
#101层网络,conv4_x,缩小特征图尺寸,且每层的输出翻4倍
datashape3 = (10,512,28,28)
conv4_x_101 = make_layers(Bottleneck, middle_out = 256, blocks=23, 
afterconv1=False)
summary(conv4_x_101,datashape3,depth=1,device="cpu")

现在我们已经具备了构建layers的能力,可以开始构建自己的残差网络了。定义残差网络的类ResNet可能是所有复现步骤中最简单的一个,它与我们之前熟悉的其他网络的定义方式非常类似。参照架构图,定义残差网络的代码如下:

class ResNet(nn.Module):
    def __init__(self,block: Type[Union[ResidualUnit, Bottleneck]]
                 ,layers: List[int]
                 ,num_classes : int
               ):
        '''
       block:要使用的用来加深深度的基本架构是?可以选择残差单元或瓶颈结构,两种都带有skipconnection
       layers:列表,每个层里具体有多少个块呢?可参考网络架构图。例如,34层的残差网络的layers = [3,4,6,3]
       num_classes:真实标签含有多少个类别?
       '''
        

		super().__init__()
        #架构中的第一部分,卷积+池化的经典组合
        self.layer1 = nn.Sequential(nn.Conv2d(3,64,kernel_size=7,stride=2,padding=3,bias=False)
                                   ,nn.BatchNorm2d(64)
                                   ,nn.ReLU(inplace=True)
                                   ,nn.MaxPool2d(kernel_size=3,stride=2,padding=1))
        
        #无论我们的block是哪个块,我们输入的数字都可以顺利使用
        self.layer2_x = make_layers(block,64,layers[0],afterconv1=True)
        self.layer3_x = make_layers(block,128,layers[1])
        self.layer4_x = make_layers(block,256,layers[2])
        self.layer5_x = make_layers(block,512,layers[3])
        
        #全局平均池化,也可以使用nn.AvgPool2d((7,7))来表示
        self.avgpool = nn.AdaptiveAvgPool2d((1,1))
        
        #使用瓶颈架构的深度残差网络最终的输出值包含2048个像素
        #而浅层残差网络最终的输出值包含512个像素
        if block == ResidualUnit:
            self.fc = nn.Linear(512,num_classes)
        else6:
            self.fc = nn.Linear(2048, num_classes)
    
    def forward(self,x):
        x = self.layer1(x)
        x = self.layer5_x(self.layer4_x(self.layer3_x(self.layer2_x(x))))
        x = self.avgpool(x)
        x = torch.flatten(x,1) #等效于 x = x.view(),此时我们有两种像素值选择,使用flatten能够使代码更简洁
        x = self.fc(x)

完成定义之后,即可进行测试:

datashape = (10,3,224,224)
res34 = ResNet(ResidualUnit, layers=[3,4,6,3], num_classes=1000)
summary(res34,datashape,depth=2,device="cpu")
res101 = ResNet(Bottleneck, layers=[3,4,23,3], num_classes=1000)
summary(res101,datashape,depth=2,device="cpu")
#你能数出有多少层吗?一个ResidualUnit中含有2个卷积层,一个Bottleneck中含有3个卷积层,卷积层
的数目 + 开头的conv1和结尾的线性层,就是整个网络的深度

到这里,我们就已经复现了整个残差网络。这段代码可能是我们目前为止完成的逻辑最复杂的代码。就代码本身来看,我们还有非常多的优化空间,但代码的核心框架以及基本逻辑已经非常接近PyTorch源码中所呈现的内容。还有许多精巧的方法可以用在残差网络的实现上,我们可以将这段代码保存,日后不断继续完善和修改它。

来看残差网络的模型大小和参数量。即便ResNet现在可以达到的深度非常深,但从参数和计算量的角度来看,它并不算是“巨型”的模型。34层的残差网络的参数量为2200万,152层的残差网络参数量也才刚刚过一个亿,比起VGG16和19超过1.4亿左右的参数,只能算是小巫见大巫。值得注意的是,残差网络的计算量很小,整体的mult-adds都在500MB以下,参数利用率高得令人惊奇。

在这里插入图片描述

在这里插入图片描述

从模型效果来看,残差网络毫无疑问是现有的最顶尖的模型之一,几乎所有大型数据集的跑分榜单前几名都是残差网络占据。除了我们已经学习的基本网络,残差网络还有许多有效、强大的变体,如ResNeXt(在残差网络上加入了并联结构),WideResNet(目前为止最强大的模型)等。同时,InceptionV4是结合了GoogLeNet与ResNet思路的强大架构,这些内容都非常值得一学。限于有限的篇幅,我们对于前沿网络的介绍不得不暂时止步于此,但还有非常多的内容可以供大家探索。在这里,我为大家提供ImageNet数据集上排名前列的架构名称(PyTorch官方提供),感兴趣的小伙伴可以顺着架构名称找到相关论文、持续学习(一个很好的参考:https://towardsdatascience.com/illustrated-10-cnn-architectures-95d78ace614d#b4ed)。在课程的下一部分,我们将把实现从具体的架构转向构建整个计算机视觉项目的流程。

在这里插入图片描述

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值