当前位置 博文首页 > JacobDale:小白的经典CNN复现(三):AlexNet

    JacobDale:小白的经典CNN复现(三):AlexNet

    作者:JacobDale 时间:2021-02-07 16:27

    小白的经典CNN复现(三):AlexNet

    锵锵——本系列的第三弹AlexNet终于是来啦(≧?≦),到了这里,我们的CNN的结构就基本上和现在我们经常使用或者接触的一些基本结构差不多了,并且从这一个经典模型开始,后面的模型的深度越来越高,使用的数据集也越来越大,训练难度也越来越高,模型的正确率也变得比较高,然后各个dalao们对于卷积的理解实际上也在不断加强。

    然鹅······你叫我回家以后咋训练嘛(╯‵□′)╯︵┻━┻。因为家里面就只有一个笔记本,显卡也就一个1050Ti的垃圾,虽然CPU还可以然而并没有什么卵用┓('?')┏。所以说可能寒假这段时间我在复现论文的时候就不拿论文提到的数据集来跑了,结果分析自然也就先放一放,主要是带着各位小伙伴们看一下论文的思路以及模型的具体结构就好咯。

    这篇论文相较于之前的LeNet-5的论文而言已经是少很多的了,也才刚刚9页而已。虽然内容不是很多,但是里面还是提到了很多非常有意思的思想,以及一些在当时来说比较先进的技术吧。最重要的是,这个代码很简单,复现起来没啥难度,这真是棒棒(≧?≦)。为了尽量让这个系列的博客看起来都差不多,所以接下来我还是会按照和之前博客结构差不多的结构来写一下这个博客:

    • 论文该怎么读:论文内容不多,不过还是有一些要重点看一下,有一些可以选择性略过

    • 论文要点简析:简单分析一下文章中有哪些先进的以及有意思的思想

    • 具体分析以及复现:带着大家一个模块一个模块把所有的部分一起实现

    • 结果简要说明:由于条件限制,没有使用实际的数据集,所以只能简单描述一下训练中有什么坑

    • 反思:模型很经典,既有值得学习的地方,也有值得反思的地方

    论文该怎么读?

    这篇论文非常经典,因为这个AlexNet基本算是将深度学习模型在比赛中的正确率提高到一个前所未有的高度,并且让人们意识到深度学习模型的构造形式以及独特优势,因此这篇论文的内容基本上是要通读的。不过为了进一步减轻各位萌新小伙伴们的负担,我们可以将里面与硬件相关的东西跳过不看。

    论文的大致结构及页码如下:

    • P1-2:深度学习模型以及AlexNet工作的基本介绍,大致了解即可

    • P2:ImageNet数据集以及大致的数据处理的介绍,这部分主要看一下图片的尺寸应该如何处理,以及做了一个零均值的处理,其他可以先跳过不看

    • P2-5:AlexNet的基本结构,这部分可以将3.2节跳过不看,因为当时很好的显卡也就只有3G显存,根本不够用,所以当时把这个模型拆开成两个在不同的GPU上分开跑的,现在随随便便找个GPU基本都够跑这个模型了,所以3.2节这个多GPU的部分可以跳过不看

    • P5-6:数据增强的方法,这部分简要了解即可

    • P6:具体的训练策略,这部分可以重点看看,因为这部分用到了许多有意思的思想

    • P7-9:结果分析、结论以及参考文献,简单了解即可

    论文要点简析

    这篇论文篇幅不多,但是这也算是深度学习进入现代的开篇之作吧,并且里面新奇的玩意儿还是蛮多的,总之废话少说,就让我带着大家一起看一看吧(是不是有QQ看点那个味了┓('?')┏):

    • 在数据处理部分,这一篇比较有意思的地方比较多,不过我们需要关注的大体就是以下的两个方面:

      • 预处理方面:在以往的传统机器学习模型、全连接网络以及LeNet-5的模型中,对于图片的处理常常都是将图片各个通道的像素值的均值和方差都计算出来,然后通过一定的处理,使得图片的像素值都处在一个均值为0,方差为1的一个大致区间中。然而在AlexNet中,我们只需要将图片各通道的像素均值算出,得到一个均值为0的区间即可。

      • 数据增强方面:虽然AlexNet的使用的数据集是ImageNet,里面的数据量非常庞大,但是论文指出,这个数据量对于模型来说还是比较少,也就是说还是存在比较大的过拟合的可能性。为此AlexNet提出了两个数据增强的方法:首先,由于图片的尺寸其实蛮大的,然后AlexNet的输入图片的尺寸实际上只要求224×224的尺寸,所以可以从图片上随机截出来一个224×224的图片作为输入,这样每一次训练对于同一张图片来说,输入网络的都是不同的数据,就相当于增加了训练集;其次,为了尽可能降低图片噪声(模糊以及不同光照条件)的影响,论文通过主成分分析(PCA)技术取得图片各通道像素的主成分,然后把这个主成分添加随机因数叠加到图片上去,从而增强数据。

    • 在网络的具体结构方面,AlexNet又引入了许多的比较先进的思想,并且有一部分的东西到现在还在用,当然啦还是有一部分东西被淘汰了,不过大体上讲还是都挺有意思的:

      • 激活函数方面:不同于在以前的全连接网络以及LeNet-5中常用的Sigmoid以及Tanh,在AlexNet中使用的函数为ReLU函数,并且论文提到,使用ReLU函数大概能将训练速度提高6倍左右(相较于传统的Tanh),并且基本解决了梯度消失现象。

      • 池化层的改进:在之前的LeNet-5以及LeNet-1989模型中,池化层的核在移动的过程中是不发生重叠的(比如说核尺寸为2,那么步长也为2,不发生重叠),但是在AlexNet中,作者提到如果池化的核在移动过程中发生重叠,会降低过拟合的风险。

      • Dropout:在全连接层中,AlexNet使用了Dropout技术,该技术也是为了降低模型的过拟合风险(话说我咋觉得这篇文章的大部分内容都是着重于降低过拟合风险呢┓('?')┏),大致的原理呢我会在后面的具体复现和分析部分里面简单的说一下,总之大家在这里先留个印象比较好。

      • 标准化处理:由于训练过程中数据的分布一定会发生比较大的变化,因此这里使用了一个叫做Local Response Normalize的标准化方法,用来将数据的分布进行重新计算,其实这个思想在后来演化成了BatchNormalization,并且后面也有论文指出,在AlexNet中使用的这个LRN它就是个垃圾(希望论文原作者不会看到这篇博客┓('?')┏)。

      • 损失函数:在AlexNet,正儿八经的交叉熵损失可算是用起来了,毕竟从概率论的角度上讲,交叉熵损失在这种典型的分类问题中肯定是比较合适的嘛。

      • 训练策略:训练中主要有两个比较需要重点关注的地方(除了两个GPU以外的内容):首先,在训练时使用的梯度下降法,是基于mini-batch以及动量(momentum)的SGD,从之前我在LeNet-5的末尾反思部分提到的内容可以看出,mini-batch思想肯定是十分重要的,然后关于动量的内容,等放到后面的具体复现以及分析的部分中再简单说一下;其次,为了在之后能让模型尽快收敛,学习率仍然是需要下降的,只不过下降的策略发生了小小的变化,以前的下降就是不考虑任何因素直接在指定的epoch处下降,而AlexNet中则是用在验证集上的错误率作为评价指标,当错误率不再下降时进行学习率的下降。

    总之这篇论文中大致的需要我们初学者小白们学习的内容就是这些了,当然如果大家对于其他的一些像多GPU训练等内容感兴趣的话,也可以再把其他的内容也读一读,不过在这篇博客中我就不管那些啦。那么接下来我们就开始进行论文的具体分析以及复现吧(≧?≦)

    具体分析以及复现

    这篇论文虽然说杂七杂八的东西蛮多的,但是实际上复现难度并不是很高,毕竟大部分的内容在Pytorch里面有现成的东西可以用,我们犯不上为了这个模型非要自己造个轮子(点名批评C++,干啥都要造个轮子,滑稽.jpg)。里面唯一需要我们自己写的东西就只有一个论文提到的LRN,不过这个也挺简单的啦。

    还是先介绍一下我复现所使用的环境吧:

    • 硬件:Intel i7-8750H,GTX1050Ti(垃圾笔记本所以ImageNet就别想了,跑个CIFAR-10都费劲┓('?')┏)

    • 软件:python 3.6.x,pytorch1.4.1,就anaconda那一套装好完事,操作系统是win10,因为代码中不涉及跨平台的部分,所以把里面的文件路径改一改应该是可以在Linux上正常运行的。平常用notepad++比较多,不太习惯用pycharm(不过我估计以后写大型项目还是会真香.jpg吧┓('?')┏)

    为了防止我们之后忘记引用模块,所以老规矩,我们这里把所有需要用到的模块和包全都一股脑放在开头:

    import torch
    import torch.nn as nn
    from torchvision import transforms as T
    from torchvision import datasets
    import torch.optim as optim
    from torch.utils.data import DataLoader
    

    可以看到我们相比之前的论文复现,多引用了一个DataLoader的类,这个类是为了我们进行数据的mini-batch划分用的,之后我们会简单介绍一下,这里就先不提啦

    在介绍具体的AlexNet之前,我们还是先把这篇论文使用的数据集及其处理方式简单介绍一下吧,毕竟我们只有知道数据集处理方式,才能更好理解模型的各种输入参数是怎么回事嘛。

    数据集简介及数据增强手段

    之所以把数据增强也放在这里,是希望大家能把所有关于数据处理的部分都一下子在这个部分了解了,就不用了来回来去翻论文了,是不是挺贴心?绝对不是因为我懒得再分一个小章节写了哦!

    这篇论文中使用的数据集是大名鼎鼎的ImageNet在ILSVRC2010的一个子集,在训练集里面大概有1.2millon个图片,验证集里有50000图片,测试集150000图片,数据集的压缩包我记得大概132G左右,解压完我估计更大,一共1000个分类,图片尺寸大小不一而且都很大,所以我家里这台破电脑实在是无能为力啊TAT,所以这篇论文的复现工作就先不正经训练了哈。

    这篇论文的数据预处理方式非常简单,就是将图片抠出中间的256×256的部分,并且减去各个通道的像素值的均值。并不对数据的方差做什么处理,就是这篇论文比较新鲜的地方了,我们知道在传统的模型中,我们大多数都比较喜欢让数据的分布满足一个均值为0,方差为1的一个大致分布,而这里论文作者就说了,“俺们的模型贼NB,0均值就够了,方差啥的无所谓,就是这么6”(大概这个意思啦,不是原文┓('?')┏)。

    大家如果读过论文的话,应该可以大致了解过AlexNet的模型的规模,实际上还是很大的。论文作者也提到过,对于这个如此庞大的模型(对于当时来说),即使使用的数据集这么大,模型的过拟合的风险还是蛮高的,因此仍然需要通过数据增强,来扩展训练集。所使用的数据增强方法如下:

    • 随机抠图:经过预处理后,我们得到了256×256的图片,为了进一步增强数据,论文提到从这个256×256的图片中,每一次训练都随机抠出来一个224×224的图片作为实际的网络输入,这样就保证每一次用某一张图片输入网络的时候,实际输入的图片都会稍稍有一点不一样,从而起到数据增强的作用。不过因为我们这里使用的数据集不是ImageNet而是cifar-10,后者的图片大小也就32×32,所以这里我就直接把图片扩展到224×224了,当然也可以先扩展到256×256然后自己随机裁剪一下,这个在PIL以及OpenCV中都是支持的。然后均值实际上在《Deep Learning with Pytorch》这本书里已经算过了,所以我就不算了,感兴趣的可以自己算一下。图片处理器的代码就放在下面:
    picProcessor = T.Compose([
        T.Resize(224),
        T.ToTensor(),
        T.Normalize(
            mean = [0.4915, 0.4823, 0.4468],
            std=[1.0, 1.0, 1.0]
        )
    ])
    
    • PCA成分叠加:论文提到,为了从像素层面进行数据的增强,首先先对整个训练集的各个通道的像素值计算各个通道的主成分,然后再将主成分添加随机数来叠加到训练集的图片上。这样每一次使用的图片即使是由上一个随机抠图的步骤抠到完全相同位置的图片,实际的像素内容也是不太一样的,因此起到了数据增强的作用。具体的叠加方式我建议大家看一下论文,因为这里复现工作没使用ImageNet数据集,就简单使用了一下cifar-10,所以这里提到的两个数据增强的方法就都不用了,如果大家有兴趣的话可以自己实现一下,反正PCA在python的sklearn库是已经实现了的,调包就完事了┓('?')┏

    这样,关于数据预处理以及数据增强的部分就基本上这样了,也基本没有什么实现难点,所以我们还是赶快进入模型重点吧( ̄▽ ̄)

    网络结构部分

    网络的结构其实蛮简单的,但是在读论文以及看论文附图的时候有两个比较要命的问题:

    • 论文是基于两个GPU进行的描述,但是我们现在没必要把这个模型分开,现在1050以上的显卡基本上显存都在4G以上,完全装得下的,所以我们要把论文里面的模型的卷积层的通道数扩大成两倍,这样才和论文的实际模型描述是一致的

    • 论文的模型附图······讲道理我当时看半天没看懂到底咋回事,然后去其他人的博客里找了找,才找到一个感觉是那么回事的图,所以如果原论文的图看不懂的,就来看我之后要用到的那个图。

    那么接下来我们就来看一下这个经典的AlexNet吧!

    网络整体结构

    如果大家看过原作论文了,那么应该对下面的图片感到很熟悉吧:

    怎么说呢,这个图片倒是把卷积层的卷积核尺寸以及特征图通道数都写的蛮清楚的,然而你会发现他的什么池化啊还有一些卷积计算的尺寸完全对不上,论文把图整的太简单了,所以导致我们后面的人在看的时候可能有点看不太懂。并且他这个图是放在两个GPU上的版本,而我们现在只用一个GPU就完全够用了,因此我们可以看一下下面的这个图:

    这个图就看起来很舒服了,不仅告诉你每一个卷积层的卷积核大小以及通道数,而且连输出的图片尺寸是咋算出来的都告诉了,阅读体验极佳,当然这个图的出处我会放在最后,大家可以去原博客里面去看一下。

    网络的大致结构已经放在这里了,那接下来我们就对每一个模块进行一个简单的介绍吧。由于原论文中并没有对每一个层进行命名,因此我们按照下面的方式对模型的每一层进行命名:

    • 所有的卷积层以C开头,后面的数字表示第几个卷积层,比如C3是第三个卷积层,并且不代表这是网络的第三层

    • 所有的池化层以S开头,数字的含义同上

    • 所有的全连接层以F开头,数字含义同上

    • 所有的LRN标准化层以N开头,数字含义同上

    命名方式搞定,下面我们来看一下每一个层的具体结构吧

    C1层

    之前我们提到过,输入图片的实际尺寸是[3, 224, 224]。并且在论文中提到,在作卷积的时候使用的卷积核的尺寸是11,虽然没有说明使用这个尺寸的目的,但是大致来看目的是在于让C1层能够尽可能地提取到足够的信息,从而让之后的卷积层能够提取到足够的组合信息。并且论文中提到,使用的移动步长stride为4,这一步长恰好使得感受野之间的距离也恰好是4。这样选取的目的在论文中并没有提及,在我看来有两个目的:首先是采用较大的步长,这样在卷积核较大的条件下依然可以保持一个较小的计算量,并且尽量降低输出特征图的尺寸;其次,采取较大的步长,使得卷积核取得的特征之间重叠较少,更有利于之后的处理获得更加多样的特征。

    关于C1层的输出特征图的尺寸,从论文中给出的图解来看应该是55×55,结合卷积核的尺寸和步长,卷积使用的padding = 2,输出的特征图的channel数应该是96。

    因此综合来看,在C1层使用的参数应如下:

    • in_channels: 3

    • out_channels: 96

    • kernel_size: 11

    • stride: 4

    • padding: 2

    卷积层由于Pytorch已经提供了类供我们调用,因此在这里就不附代码了。但是在这里需要注意的是,论文里提到在C1层会进行初始化处理,初始化的方法为:

    • 权重:使用均值为0,标准差为0.01的正态分布进行随机采样
    • 偏置:全部设置为0

    关于权重的初始化问题,由于论文中指定的参数初始化方法是需要满足N(0, 0.01^2),而之前我们使用的randn是满足的N(0, 1)的标准正态分布,为了能够让初始化的参数满足我们的要求,我们需要使用另一个初始化的函数:

    torch.normal(mean, std)
    

    这个函数的mean和std最好是tensor,随机生成的tensor的维度和mean以及std的维度一致,并且每一个元素都是满足对应位置的N(mean, std^2)的分布。

    举个例子:

    a=torch.normal(torch.tensor([0,1,2], torch.tensor([1,1,1])))
    

    那么a[0] ~ N(0, 1),a[1] ~ N(1, 1),a[2] ~ N(2, 1)

    论文提到,所有的权重都是一样的设置,所以我们可以之后整体用一个函数遍历,但是偏置的问题,好几个层是0,好几个层是1(作者是真的事多┓('?')┏),所以我们将偏置的设置放在C1层的定义之后:

    self.C1.bias.data = torch.zeros(self.C1.bias.data.size())
    

    关于这样的定义方式其实我们早就接触过很多了,只不过之前我们的写的时候使用一个循环,而且设置的不是偏置(bias)而是权重(weight),所以相关的内容建议大家看一下我的LeNet-1989这篇博客,里面对这个代码的含义有简单的介绍。

    论文里说明,在C1层之后使用ReLU作为激活函数,关于这个激活函数的特点,将在后面的章节中简单解释,在这里就不啰嗦啦。

    顺便提一下,经过C1层之后,特征图的尺寸为[96, 55, 55]

    N1层(LRN)

    由于激活函数使用的是ReLU函数,这个函数是没有上界的,这很有可能导致最后通过激活函数输出的数据分布过于极端。与此同时,论文的作者发现,对数据进行Normalize处理将有利于提高模型的泛化性能,降低过拟合的风险。所以在论文中作者提出了一个标准化方法,就是这里的LRN了。标准化使用的计算方法如下所示:

    \[b^i_{x,y}=a^i_{x,y}/(k+{\alpha}\sum^{min(N-1,i+n/2)}_{j=max(0,i-n/2)}(a^j_{x,y})^2)^{\beta} \]

    这个公式中的各个参数的含义其实比较简单,但是由于博客园这边的文字上下标编辑实在是有点emmmmm,所以这边我就简单写写,如果有什么疑问的话还是直接参照论文来看吧:

    • a表示输入,b表示输出

    • 上标为某个像素点所处的特征图的位置,从0开始

    • 下标(x,y)表示计算的点在特征图上的坐标

    • N为特征图的通道数,也就是channels

    • k,alpha,beta,n为人为选取的超参数,论文作者通过计算得到k = 2, alpha = 1.0e-4, beta = 0.75, n = 5。

    这个公式的形象化理解就是,在对某一个像素点进行标准化时,是使用它前后的n/2共n范围内的特征图的对应位置的像素点作为标准,来进行标准化的计算。这一层中没有可训练参数,而且所做的就是十分简单的索引切片以及简单的运算,所以实际上没有什么复现难度,虽然这个基本上是整篇下来唯一一个需要自己写的类┓('?')┏。那么下面我们就把代码贴上来呗:

    class LRN(nn.Module):
        def __init__(self, in_channels: int, k=2, n=5, alpha=1.0e-4, beta=0.75):
        
            super(LRN, self).__init__()
            self.in_channels = in_channels
            self.k = k
            self.n = n
            self.alpha = alpha
            self.beta = beta
            
        def forward(self, x):
            tmp = x.pow(2)
            div = torch.zeros(tmp.size()).to(device)
            
            for batch in range(tmp.size(0)):
                for channel in range(tmp.size(1)):
                    st = max(0, channel - self.n // 2)
                    ed = min(channel + self.n // 2, tmp.size(1)-1)+1
                    div[batch, channel] = tmp[batch, st:ed].sum(dim=0)
            
            out = x / (self.k + self.alpha * div).pow(self.beta)
            return out
    

    这部分如果大家有python基础的话还是蛮简单的,并且虽然这个系列的博客是面向小白的,不过大家跟着玩了这么久,肯定python也学了个七七八八了,所以这部分就不详解了,如果有不明白的地方,直接搜一下python的切片操作即可,基本上这部分代码里面初学者难以理解的就是里面的切片操作而已。

    接下来我们需要做的就只是调用这个类的初始化函数来进行对象的定义以及初始化即可,这个就之后完整代码里面说吧。

    S1层

    在这里论文作者又搞出一个骚操作,那就是让池化的核在移动的时候与之前发生重叠。具体来说,就是使用下面的参数:

    • kernel_size: 3

    • stride: 2

    选用的池化方式为最大化池化MaxPool。论文作者说,这么搞能够降低过拟合的风险(我读书不多,你可别骗我)。关于这一部分我因为读的论文啥的还不够多,所以关于这样做为什么能降低过拟合风险,我也不是太清楚,如果评论区有大佬能够指点一下那就太好了。

    经过S1的池化操作之后,输出的特征图的尺寸变为[96, 27, 27]

    C2层

    从这里开始,原论文就开始把模型往两个GPU上面搬了,但是因为咱们其实手头上的GPU大多数都足够用,并且及时GPU不够用,网上也有许多在线的GPU训练平台可以免费使用一些GPU进行训练,因此在这里我们不看论文上的图,而是看我上面给出的稍微清晰一点的彩图。其实读的时候就是将论文里面给出的卷积核的数量加倍就完事了,所以这里结合着论文以及上面的一张彩图来看一下C2层的具体参数:

    • in_channels: 96

    • out_channels: 256

    • kernel_size: 5

    • stride: 1

    • padding: 2

    在初始化时,权重的初始化方法和C1一致(应该说所有的权重初始化方法都是一致的),然后偏置是全部初始化为1

    经过这样的卷积处理,输出的特征图的尺寸变为[256, 27, 27]

    根据论文的描述,在C2之后也是有ReLU激活函数的。

    N2层

    根据论文介绍,在C2层使用激活函数激活之后,也需要使用LRN进行数据的标准化处理,由于我们之前已经介绍了LRN层的类代码内容,所以在这里不做过多的描述,直接调用一下初始化函数即可。

    S2层

    在经过N2层处理结束之后,我们需要对结果进行最大化池化。池化的参数和S1的参数是完全相同的,经过S2层之后,特征图的尺寸变为[256, 13, 13]

    C3层

    C3层就是我们之前经常接触的非常常见的尺寸的卷积层啦,所以在这里我们直接给出卷积层的参数:

    • in_channels: 256

    • out_channels: 384

    • kernel_size: 3

    • stride: 1

    • padding: 1

    初始化的时候,偏置全部初始化为0。

    输出的特征图的尺寸为[384, 13, 13]。在C3之后也是有ReLU激活函数的。

    C4层

    讲道理觉得,偷个懒真的是香啊,这么省事我真是谢谢论文作者啊233333。

    • in_channels: 384

    • out_channels: 384

    • kernel_size: 3

    • stride: 1

    • padding: 1

    初始化的时候,偏置全部初始化为1。

    输出特征图的尺寸为[384, 13, 13]。在C4之后同样有ReLU激活函数。

    C5层

    继续摸鱼233333

    • in_channels: 384

    • out_channels: 256

    • kernel_size: 3

    • stride: 1

    • padding: 1

    初始化的时候,偏置全部初始化为1。

    输出特征图的尺寸为[256, 13, 13]。在C5层之后同样有ReLU激活函数。

    S3层

    继续摸······啊摸不得了,差点就又把卷积层的那些参数粘过来了23333。

    在这里使用的基本的池化方法和S1和S2是完全一致的,所以就不说参数啦。输出的特征图的尺寸为[256, 6, 6]。

    F1层

    接下来的部分,论文中提到进入全连接的部分,应该说一直到VGG,网络都还是基本的“卷积+全连接”的模式,直到后面有论文提出,全连接就是个垃圾,我到最后都用池化一直搞到最后,性能也其实挺好(我忘记是全卷积网络还是什么网络提出来的了,等到我后面有空再去瞅瞅┓('?')┏)。

    AlexNet指出,全连接层这边有4096个神经元,考虑到从上层下来的特征图的尺寸为[256, 6, 6],而全连接层的输入要求图片的维度不考虑batch_size应该是个一维的,因此我们需要使用view操作对输入进行一个处理,变成[batch_size, 256*6*6]这样的形式,当然啦这个操作最好放在forward里面,这里就是提一下让大家注意一下。

    因此F1的基本参数应该是下面的样子:

    • in_features: 256*6*6

    • out_features: 4096

    初始化的时候,偏置全部初始化为1,并且所有的全连接层,偏置都是初始化为1。

    在F1层之后是要跟一个ReLU激活函数的。

    并且在论文里面指出,在F1和F2后面有Dropout操作,这个操作对于降低模型的过拟合风险真的是有奇效,具体内容等到之后和ReLU等一些骚操作一起说吧,这里大家先了解一下要用Dropout

    F2层

    这里也让我偷一下懒好啦( ̄▽ ̄)

    • in_features: 4096

    • out_features: 4096

    同样的,在F2之后也有ReLU以及Dropout。

    F3层

    继续摸鱼,你能拿我咋办┓('?')┏

    • in_features: 4096

    • out_features: 1000

    这里就只有一个ReLU函数啦,再往后我们就直接输出各个分类的计算结果了。

    那么到这里,网络的基本结构就介绍完成了,接下来,我们需要简单介绍一下激活函数和dropout操作,然后我们就可以着手构建AlexNet的基本代码啦

    ReLU激活函数

    不同于之前使用的Sigmoid以及Tanh函数,在AlexNet中使用的是ReLU函数,这个函数的公式如下所示:

    \[ReLU(x)=max(0,x) \]

    这个函数有一些比较有趣的东西(有好有坏),我们一个一个来说:

    • 梯度:这个函数的梯度相较于以前的那两个函数而言,或好或坏,有以下的四个性质:

      • 梯度数值:从函数的表达式来看,梯度就只有两个值,一个是1,一个是0。而Sigmoid函数梯度最大才刚0.25,因此对于Sigmoid以及Tanh函数来说,梯度消失现象十分明显,而在ReLU函数中,梯度下降现象就并不是那么严重,只要初始化没把所有计算数值都在负值区间中,那梯度就不会为0,所以理论上梯度消失现象是可以完全消除的。

      • 梯度计算难度:从函数表达式来看,ReLU函数在计算梯度的时候,其实就只是做了一个十分简单的条件判断,而Sigmoid以及Tanh在计算梯度的时候要计算很多比较恶心的指数运算,因此ReLU函数在计算梯度的时候,算力开销非常地少。根据论文中的描述,在cifar-10数据集上,ReLU函数的训练速度大概是使用Tanh函数的卷积网络的6倍左右,可以看出训练能力被提高了不少

      • 梯度爆炸:事实上,由于梯度的最大值为1,再加上函数本身没有上界,因此在训练过程中梯度爆炸现象还是挺容易发生的。因此在很多的论文以及博客中都有提及,在使用ReLU函数进行训练的时候,最好把学习率设置得小一点。

      • 神经元死亡:函数的梯度从表达式来看只有0和1两个值,也就是说,当网络参数的初始化不合理的时候(主要是权重),很可能导致激活函数的计算结果总是在0区间,也就是输出的梯度永远为0,永远也得不到更新。在某些情况这可能会提高模型速度,并降低过拟合风险,但是这也很有可能导致模型欠拟合(废话,全是初始参数没办法更新,这个和瞎猜有啥区别┓('?')┏),从而对模型产生很大影响。

    • 值域:这个函数的表达式已经在上面写过了,下面的图哪就是这个函数的图像啦:

      • 生物学含义:生物神经元在受到外来刺激之后的响应曲线和ReLU的基本形状比较相似,因此至少从生物学角度来讲,ReLU函数在进行神经仿生计算的时候,从可解释性以及仿生性来说是比较合适的;此外,根据脑神经科学的研究进展,人脑中的神经元在被激活时并不是全部激活,而是只有部分相关的区块被激活,而ReLU函数的一半区间上都是零值,这也就意味着在进行计算的时候,许多的神经元将会计算得到0值,也就是未激活状态,这也使得ReLU在进行数值计算的时候会更加高效快速。

      • 函数值分布:ReLU函数值不是以0为中心的,事实上根据图像以及表达式来看,ReLU函数的值一定是非零数,这就导致当参数的初始化不合理的时候,每一次计算出的梯度值的更新方向只能沿着恒正或者恒负的方向进行更新,从而降低训练速度。具体更多的介绍建议大家看一下知乎的专栏,我会把链接放在最后。

    总之ReLU函数就是有上面的或好或坏的基本特点啦,而且因为这个函数非常常用,所以在Pytorch中已经有现成的代码,我们直接安安稳稳地做个调包侠就完事了,做个咸鱼它不香么┓('?')┏。具体的调用方法和我们之前的激活函数的调用时完全一致的,在这里就不赘述了,有空自己去看看官方文档啦。

    Dropout

    这篇文章也是比较早地在深度模型中使用Dropout机制的论文了,虽然首次提出并不是这篇,我记得好像是Hitton老爷子的论文来着?总之原文如果大家有兴趣的话可以去找来读一读,在这里我主要为那些没有接触过的萌新小伙伴们简单一下机制以及效果,同样的,参考的博客链接我会放在最后,大家可以去点个关注啥的。

    在介绍Dropout之前我们还是先来看一下传统的全连接网络:

    从上面这张图可以看到,对于一个完整的全连接网络来说,里面参数太多,密密麻麻的一大片简直就是密集恐惧症的福音,无论是从计算成本还是从过拟合风险来说,都是相对来说比较差的。如果大家稍微接触过一些神经研究的话可能会知道,实际上在人的神经系统中,神经又不是全部一个一个地密密麻麻连在一起的,有些神经其实并不是相互连通的。因此,如果我们在训练过程中指定一个概率p,让每一个神经元都以p的概率被“杀死”,也就是不参与运算,那不就既减少了计算量,又降低了参数量,岂不美哉?这个思想实际就是Dropout做的事情。下图就是我们使用dropout后在某一个训练轮次中的全连接网络的示意:

    dropout

    可以看到,参数量少了,而且从网路的拓扑结构上看也和之前不太一样了。

    这样做的好处在我看来一共有以下几个:

    • 仿生学意义:较好地模拟了真实神经之间的连接情况。

    • 拓扑结构:改变了原有的拓扑结构,并且由于每一轮训练得到的实际网络结构都是由概率得到的,因此拓扑结构更加复杂,有可能会学到更加复杂的输入特征。

    • 减少神经元之间的共适应关系:在上一条中提到,神经元在每一个训练epoch中是否存在取决于概率p,因此在不同的训练epoch之间,某一个神经元可能有时候在有时候不在,这就导致在每次训练中,神经元之间的依赖关系并不是那么强,自然就降低了过拟合的风险。

    • 集成学习思想:在第二条中提到,每一轮epoch中的网络结构由于概率p的存在,实际上的连接情况是各有不同的,也就是说,最后训练得到的网络结构,实际上和训练了很多个不同的网络结构然后再堆到一起是差不多的。如果大家接触过集成学习的话应该会对这个思想感到比较熟悉,集成学习实际上就是把一大堆的分类器放在一起,然后在训练过程中不断修改各个分类器的得分权重,然后进行各自的参数调整。经过Dropout训练后的模型也相当于有许多个模型集成在一起进行结果的判断,而且当训练轮次epochs足够多的时候,相当于训练了2^n个模型,n为神经元个数,通过这么多模型进行共同判断,自然可以将过拟合风险显著降低。

    同样的,因为Dropout在现在的深度学习模型中非常常见,因此也在Pytorch中有现成的,调用方法也很简单,还是请大家自行翻阅一下Pytorch的官方文档看一下怎么用吧。相信跟着这个系列的博客的小伙伴们已经能比较熟练地阅读Pytorch的官方文档了吧。

    训练策略

    除了在网络的具体结构之外,AlexNet在训练使用的一些小策略上,也和之前的LeNet-5以及其他的传统机器学习模型有一些不同的地方。

    梯度下降方式

    在LeNet-5以及LeNet-1989中,论文作者使用的都是基于单个样本的简单SGD,具体的内容如果大家不太清楚的话,可以自行查阅相关的论文,或者是看一下我之前的博客(这应该不算打广告吧┓('?')┏)。然后呐,在AlexNet中作者基于单样本以及简单SGD进行了两个方面的改进:

    • 单样本改进——mini-batch:就如同我之前在LeNet-5的复现博客中提到的,基于所有样本的梯度下降如果看成是基于整个样本空间的期望的话,那么单样本的SGD就相当于从样本空间随机取得一个样本,把这个值作为期望的估计值。这样确实是引入了足够的随机性,但是问题在于,这也太粗糙了,随机过了头就很可能导致参数更新方向完全错了。其实仔细想一想我们平常如果想要获得某个数值的估计,一般都是取平均嘛,这样既有一定的随机性,同时又可以保证大致的方向是和整体期望近似相同的(好歹人家也是期望的无偏估计量嘛┓('?')┏)。而这也是这篇论文中所使用的mini-batch思想。在AlexNet中,由于使用的训练集有1.2millon张图片,因此mini-batch稍微取大一点点,对于随机性的影响并不是很大,在论文中使用的mini-batch为128。

    • 简单SGD改进——带动量的SGD:之前我们使用的SGD就是很简单的利用所在点的函数梯度(导数)来作为参数的更新依据。从更新方法上来看,如果我们出现了导数为0的点(驻点),那么SGD就会在这个点停止更新。如果这个点是极值点倒是还算运气好,但是当出现像下面的函数图像的时候,你就会怀疑人生:x立方

      这个函数图像实际上是三次曲线,为了让大家看得更清楚所以把图片稍微压扁了一点点。可以发现在x=0的邻域内,函数的图像十分平缓,这也就意味着函数在这附近的梯度很小,那基本上也就没办法被正常更新(看一下[-0.5, 0.5]的区间,导数基本上是0啊),同时(0, 0)这个点并不是极值点(事实上三次曲线没极值点┓('?')┏),也就是发生了“明明没训练好,但是模型自己就停止训练了”的尴尬问题。这个玩意在DeepLearning里面好像是叫鞍点问题。虽然举的例子是一个没有极值点的不太合适的例子,但是事实上在实际我们经常训练的其他的假设函数模型中,部分邻域内函数图像是这种情况的多了去了,这也是普通的SGD效果在复杂问题中一般很差的原因。因此在AlexNet中,作者使用了带有动量的SGD。带动量的SGD,通俗一点的理解就是带初速度的加速运动(是不是有高中物理内味儿了),具体的公式以及说明建议大家查阅一下相关的资料,我会把我读过的博客放在最后的链接中。

    学习率的下降

    不同于之前LeNet-5的直接按照epoch数来认为设置学习率的下降,AlexNet中将训练函数中的错误率作为评价的指标,当错误率停止下降的时候,就对学习率进行下降。这样根据某一指标进行学习率的动态下降,我觉得其实还行,就是稍微麻烦了一点。

    之前我们提到过,Pytorch专门提供了类用来方便我们的学习率下降,这里我们既可以像之前的LeNet-5一样,通过在优化器中的param_group字典来遍历参数进行人工修改,也可以直接调用专门的类来进行调整。为了让大家知道有这么些类能够用来调整学习率,所以在这里我们直接用现成的。由于我们之前已经导入过torch.optim了,所以这里我们直接用:

    scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='max', factor=0.1, patience=1)
    

    为大家解释一下这段代码:首先在torch.optim中存在这样一个包lr_scheduler,里面是我们所有的和学习率衰减有关的类,比如指定epoch下降、指数衰减以及这个指定参数衰减。参数含义在下面简单介绍一下啦:

    • optimizer:我们定义好的优化器对象

    • mode:一般就用两种模式:

      • 'min':当指标停止下降的时候,就进行学习率修改
      • 'max':当指标停止上升的时候,就进行学习率修改
      • 这里我们使用max的原因是,虽然论文里使用错误率不再下降进行的学习率修改,但是写代码的时候调整成正确率不再上升是完全等价的,所以这里就改成max啦
    • factor:当学习率进行修改的时候,就用lr = lr*factor进行修改

    • patience:容忍轮数:若经过了patience指定的轮数,评价指标还没有达到指定要求,就进行学习率修改。

    而在使用scheduler的时候,实际上和使用optimizer差不多。我们需要在optimizer进行step操作之后,对这个scheduler进行step操作,并将我们的正确率作为参数传给step(废话,不传参数怎么知道拿啥作指标┓('?')┏)。大致的伪代码看一下下面啦:

    optimizer.step()
    with torch.no_grad():
    	计算分类正确的个数acc
    accRate = acc / 验证集样本数
    scheduler.step(accRate)