在本节中,我们将研究Szegedy等人在2014年的论文《Going Deeper With Convolutions》中提出的GoogLeNet架构。这篇论文之所以重要,主要是:

  • 与AlexNet和VGGNet网络结构相比,模型非常小(整个权重文件大小约为28MB)。并且从论文中,我们可以看到作者使用Global Average Pooling代替了全连接层,一方面减小了模型的大小,另一方面加深了整个网络的深度。CNN中的大部分权重都来自于全连接FC层,如果删除FC层,那么模型权重个数会减少很多且可以节省计算的内存消耗。

  • Szegedy等人在构建整体网络结构时,利用了Network in Network(NIN)结构。在此之前的AlexNet、VGG等结构都是堆叠式神经网络,即其中一个网络层的输出直接输入到另一个网络层。原论文中,作者在搭建网络结构时,多次使用一个微结构——我们即将看到的Inception模块,该模块将输入分割成许多不同的分支,然后再重新连接成一个输出。

具体来说,Inception模块是对输入做了四个分支,分别用不同尺寸的filter进行卷积或者池化,最后再在特征维度上拼接到一起。直观感觉上在多个尺度上同时进行卷积,能提取到不同尺寸的特征。特征更为丰富也意味着最后分类判断时更为准确。

除了Inception模块,目前,研究者也提出了一些微结构模块,比如ResNet[24]中的Residual模块和squeezeNet中的Fire模块。在这节中,我们主要讨论Inception模块。一旦了解了Inception模块的组件以及功能,我们就可以自己实现一个小型的GoogLeNet——“MiniGoogLeNet”,然后,我们将在CIFAR-10数据集上训练“MiniGoogLeNet”网络结构。最后,我们将探讨cs231n课程的tiny ImageNet任务[4]——这个任务是斯坦福大学的cs231n卷积神经网络课程[39]的实践项目的一个任务,tiny ImagesNet任务只使用ImageNet的一部分数据。我们将在tiny ImageNet数据集上从头到尾训练一个GoogLeNet网络,并获得一个不错的名次。

Inception 模块

目前,一些state-of-the-art的卷积神经网络(如ResNet等)都使用了微结构——也称为network-in-network模块,最初由Lin等人提出。整体的网络结构是由一些微结构模块与传统的网络层(如CONV,POOL等)堆叠而成。本文中,我们主要讨论Inception模块。

图11.1 Inception模块
Inception模块的基本结构如图11.1所示,整个GoogLeNet网络结构就是由多个这样的Inception模块串联起来的。Inception模块的主要贡献有两个:
  • 使用1x1的卷积进行升降维
  • 在多个尺寸上同时进行卷积再聚合

Inception模块对输入做了四个分支,分别用不同尺寸的filter进行卷积或者池化,最后再在特征维度上拼接在一起,这种全新的结构有什么好处呢?Szegedy从多个角度进行解释:

  • 在卷积层中,我们很难确定使用多大的filter。5x5?3x3?1x1?既然很难决定,那么全部都学习,让模型决定哪个更好,在Inception模块中,包含了三种大小的filter,即5x5、3x3和1x1大小的filter。Inception模块对输入做了四个分支,并行计算四个分支,然后再将这四个分支的输出连接成一个整体模块的输出。GoogLeNet网络结构主要是由多个Inception模块和传统的网络层堆叠而成,总的来说,Inception模块使得GoogLeNet网络既能学习局部特征(小卷积),又能学习抽象特征(大卷积)。
  • 从直观感觉上在多个尺度上同时进行卷积,能提取到不同尺度的特征。特征更为丰富也意味着最后分类判断时更加准确。
  • 利用稀疏矩阵分解成密集矩阵计算的原理来加快收敛速度。举个例子下图左侧是个稀疏矩阵(很多元素都为0,不均匀分布在矩阵中),和一个2x2的矩阵进行卷积,需要对稀疏矩阵中的每一个元素进行计算;如果像右图那样把稀疏矩阵分解成2个子密集矩阵,再和2x2矩阵进行卷积,稀疏矩阵中0较多的区域就可以不用计算,计算量就大大降低。这个原理应用到inception上就是要在特征维度上进行分解!传统的卷积层的输入数据只和一种尺度(比如3x3)的卷积核进行卷积,输出固定维度(比如256个特征)的数据,所有256个输出特征基本上是均匀分布在3x3尺度范围上,这可以理解成输出了一个稀疏分布的特征集;而inception模块在多个尺度上提取特征(比如1x1,3x3,5x5),输出的256个特征就不再是均匀分布,而是相关性强的特征聚集在一起(比如1x1的的96个特征聚集在一起,3x3的96个特征聚集在一起,5x5的64个特征聚集在一起),这可以理解成多个密集分布的子特征集。这样的特征集中因为相关性较强的特征聚集在了一起,不相关的非关键特征就被弱化,同样是输出256个特征,inception方法输出的特征“冗余”的信息较少。用这样的“纯”的特征集层层传递最后作为反向计算的输入,自然收敛的速度更快。

  • Hebbin赫布原理。Hebbin原理是神经科学上的一个理论,解释了在学习的过程中脑中的神经元所发生的变化,用一句话概括就是fire togethter, wire together。赫布认为“两个神经元或者神经元系统,如果总是同时兴奋,就会形成一种‘组合’,其中一个神经元的兴奋会促进另一个的兴奋”。比如狗看到肉会流口水,反复刺激后,脑中识别肉的神经元会和掌管唾液分泌的神经元会相互促进,“缠绕”在一起,以后再看到肉就会更快流出口水。用在inception结构中就是要把相关性强的特征汇聚到一起。这有点类似上面的解释2,把1x1,3x3,5x5的特征分开。因为训练收敛的最终目的就是要提取出独立的特征,所以预先把相关性强的特征汇聚,就能起到加速收敛的作用。

从图11.1中,可以看到有多个1x1卷积模块,这样的卷积有什么用处呢?

  • 在相同尺寸的感受野中叠加更多的卷积,能提取到更丰富的特征。这个观点来自于Network in Network

上图左侧是是传统的卷积层结构(线性卷积),在一个尺度上只有一次卷积;上图右是Network in Network结构(NIN结构),先进行一次普通的卷积(比如3x3),紧跟再进行一次1x1的卷积,对于某个像素点来说1x1卷积等效于该像素点在所有特征上进行一次全连接的计算,所以右侧图的1x1卷积画成了全连接层的形式,需要注意的是NIN结构中无论是第一个3x3卷积还是新增的1x1卷积,后面都紧跟着激活函数(比如relu)。将两个卷积串联,就能组合出更多的非线性特征。举个例子,假设第1个3x3卷积+激活函数近似于f1(x)=ax2+bx+c,第二个1x1卷积+激活函数近似于f2(x)=mx2+nx+q,那f1(x)和f2(f1(x))比哪个非线性更强,更能模拟非线性的特征?答案是显而易见的。NIN的结构和传统的神经网络中多层的结构有些类似,后者的多层是跨越了不同尺寸的感受野(通过层与层中间加pool层),从而在更高尺度上提取出特征;NIN结构是在同一个尺度上的多层(中间没有pool层),从而在相同的感受野范围能提取更强的非线性。

  • 使用1x1卷积进行降维,降低了计算复杂度。下图中间3x3卷积和5x5卷积前的1x1卷积都起到了这个作用。当某个卷积层输入的特征数较多,对这个输入进行卷积运算将产生巨大的计算量;如果对输入先进行降维,减少特征数后再做卷积计算量就会显著减少。下图是优化前后两种方案的乘法次数比较,同样是输入一组有192个特征、32x32大小,输出256组特征的数据,第一张图直接用3x3卷积实现,需要192x256x3x3x32x32=452984832次乘法;第二张图先用1x1的卷积降到96个特征,再用3x3卷积恢复出256组特征,需要192x96x1x1x32x32+96x256x3x3x32x32=245366784次乘法,使用1x1卷积降维的方法节省了一半的计算量。有人会问,用1x1卷积降到96个特征后特征数不就减少了么,会影响最后训练的效果么?答案是否定的,只要最后输出的特征数不变(256组),中间的降维类似于压缩的效果,并不影响最终训练的结果。

Inception

接下来,我们看看Inception的各个分支组件,如图11.1所示

图11.1 Inception模块
**注意**: 在每个CONV层之后都会使用一个激活函数(ReLU)。为了节省空间,这个激活函数没有在图11.1中显示。当我们实现GoogLeNet时,我们会在Inception模块中使用激活函数。

Inception模块中的第一个分支是由多个1x1大小的filters组成,1x1卷积可以从输入层中学习到局部特征。

第二个分支中,先对输入层使用了1x1的卷积,不仅可以学习局部特征,而且还可以起到降维的作用。当某个卷积层输入的特征数较多,对这个输入进行卷积运算将产生巨大的计算量;如果对输入先进行降维,减少特征数后再做卷积计算量就会显著减少。因此,在第二个分支中,先使用1x1的CONV后再使用3x3的卷积,整体的计算量小于直接使用3x3的卷积的计算量。

第三个分支类似于第二个分支结构,只不过第三个分支中使用的是5x5的卷积。

第四个分支跟之前的分支不太一样,先对输入层使用了3x3的max pooling,注意,pooling层的步长为1,作者认为pooling也能起到提取特征的作用,而且pooling后没有减少数据的尺寸。然后紧接着1x1的卷积。

最后,将四个分支的输出在特征维度上连接在一起组成一个inception模块的输出。特别注意,在实现过程中,需要通过零填充,以确保每个分支的输出具有相同的大小,从而可以拼接在一起。

Miniception

GoogLeNet网络由图11.1所示的Inception模块和传统的网络层堆叠组成,并且在ImageNet数据集(输入特征图像为224x224x3)获得了惊人的结果。实际中,我们的数据集并没有ImageNet那么大,所以对于小数据集(或特征图像大小很小),我们将简化Inception模块。

"Miniception"模块主要从@ericjang11@pluskid的一条推文中了解到,他们在训练CIFAR-10数据集时使用了一个更小的Inception变体,如图11.2——来自于Zhang等人2017年出版的《Understanding Deep Learning Requires Re-Thinking Generalization》。

图11.2 miniception 模块结构以及整个网络结构
图11.2显示了上部分展示了各个模块的组件,下部分显示了整个MiniGoogLeNet模型结构,其中:
  • conv_module:由卷积层,BN层和激活函数组成。
  • inception_model:由1x1卷积和3x3卷积组成
  • downsample_module:由3x3卷积(步长为2)和3x3的max pooling(步长为2)组成

这些模块堆叠在一起组成了一个MiniGoogLeNet网络结构,如图11.2下所示。另外,作者在激活函数之前增加了BN层(可能是因为Szegedy等人也同样处理了),而在实际中我们搭建CNN模型结构时,一般建议是把BN层放在激活函数之后。在本节中,我们主要按照原作者的方式进行,将BN放在激活函数之前,以便复现结果,当然在个人实验中,可以尝试将两者位置调换下,看看性能如何。

在下一节中,我们将实现MiniGoogLeNet架构,并在CIFAR-10数据集上进行训练。

MiniGoogLeNet on CIFAR-10

首先,我们将使用Miniception模块实现MiniGoogLeNet网络架构。然后,我们将在CIFAR-10数据集上训练MiniGoogLeNet结构。

MiniGoogLeNet

首先,我们开始搭建MiniGoogLeNet网络结构。在pyimagesearch项目中的nn.conv模块中新建一个名为minigooglenet.py,如下目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
--- pyimagesearch
| |--- __init__.py
| |--- callbacks
| |--- io
| |--- nn
| | |--- __init__.py
| | |--- conv
| | | |--- __init__.py
| | | |--- alexnet.py
| | | |--- lenet.py
| | | |--- minigooglenet.py
| | | |--- minivggnet.py
| | | |--- fcheadnet.py
| | | |--- shallownet.py
| |--- preprocessing
| |--- utils

打开minigooglenet.py,并写入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# -*- coding: utf-8 -*-
# 加载所需模块
from keras.layers import BatchNormalization
from keras.layers import Conv2D
from keras.layers import AveragePooling2D
from keras.layers import MaxPooling2D
from keras.layers import Activation
from keras.layers import Dropout
from keras.layers import Dense
from keras.layers import Flatten
from keras.layers import Input
from keras.layers import concatenate
from keras.models import Model
from keras import backend as K

需要注意的是,前面我们提到了GoogLeNet并不是传统的堆叠式神经网络,因此,我们不能使用keras中Sequential类,而是使用keras的另一个模型类Model,使用Model类,我们可以轻松的完成有分支的微结构,比如Inception模块,另外,concatenate函数可以按照给定的维度方向将多个输入进行连接。

我们将按图11.2所示的结构,搭建MiniGoogLeNet模型。首先,我们定义conv_module:

1
2
3
4
5
6
7
8
class MiniGoogLeNet:
@staticmethod
def conv_module(x,K,kX,kY,stride,chanDim,padding='same'):
# define a CONV => BN => RELU pattern
x = Conv2D(K,(kX,kY),strides = stride,padding=padding)(x)
x = BatchNormalization(axis=chanDim)(x)
x = Activation('relu')(x)
return x

conv_module主要由卷积层,BN层和激活层组成。其中:

  • x: 网络层的输入
  • K: CONV层的filter个数
  • kX和kY: filter的大小
  • stride: 步长
  • padding: 填充模式,默认是’same’,即保持输入跟输出的大小一致。

从上,我们看到,Model类与Sequential类构建模型的方式不一样,Sequential类主要使用的是model.add模式,而Model类主要是使用是function()(x)形式,如下模板:

1
output = Layer(parameters)(input)

我们在搭建非堆叠式网络时,主要使用该模板进行构建。整个conv_module结构如图11.3所示:

图11.3 conv_module
整个流程为CONV => BN => ACT,**注意**,这个模块没有任何分支。

下面,我们定义Inception_module:

1
2
3
4
5
6
7
@staticmethod
def inception_module(x,numK1x1,numK3x3,chanDim):
# 拼接两个CONV层
conv_1x1 = MiniGoogLeNet.conv_module(x,numK1x1,1,1,(1,1),chanDim)
conv_3x3 = MiniGoogLeNet.conv_module(x,numK3x3,3,3,(1,1),chanDim)
x = concatenate([conv_1x1,conv_3x3],axis = chanDim)
return x

其中:

  • x: 输入层
  • numK1x1:1x1的filter个数
  • numK3x3:3x3的filter个数
  • chanDim:通道维度

注意:Mininception模块主要两组卷积分支组成——1x1的CONV和3x3的CONV。整个inception_module结构如图11.4所示:

图11.4 inception_module
接下来,我们定义downsample_module,即降低输入层的维度:
1
2
3
4
5
6
7
@staticmethod
def downsample_module(x,K,chanDim):
# 定义CONV和POOL,并拼接
conv_3x3 = MiniGoogLeNet.conv_module(x,K,3,3,(2,2),chanDim,padding='valid')
pool = MaxPooling2D((3,3),strides=(2,2))(x)
x = concatenate([conv_3x3,pool],axis = chanDim)
return x

其中:

  • x: 输入层
  • K:filter的个数
  • chanDim: 特征维度

整个downsample_module结构如图11.5所示:

图11.5 downsample_module
接下来,我们将以上组件进行拼接一起,搭建MiniGoogLeNet结构:
1
2
3
4
5
6
7
@staticmethod
def build(width,height,depth,classes):
inputShape =(height,width,depth)
chanDim = -1
if K.image_data_format() == "channels_first":
inputShape = (depth,height,width)
chanDim = 1

其中:

  • width:特征图像的宽度
  • height:特征图像的高度
  • depth:通道数
  • classes:类别个数

定义MiniGoogLeNet的输入层和第一个conv_module:

1
2
3
# 输入层和第一个CONV层
inputs= Input(shape = inputShape)
x = MiniGoogLeNet.conv_module(inputs,96,3,3,(1,1),chanDim)

第一个conv_module模块中由96个3x3的filters组成。在第一个conv_module之后我们堆叠两个inception模块,以及一个downsample_module:

1
2
3
4
# 两个Inception和一个downsample层
x = MiniGoogLeNet.inception_module(x,32,32,chanDim)
x = MiniGoogLeNet.inception_module(x,32,48,chanDim)
x = MiniGoogLeNet.downsample_module(x,80,chanDim)

第一个inception模块中,包含32个1x1的filters和32个3x3的filters,因此第一个inception模块的输出有K = 32+32=64个filters。

第二个inception模块中,包含32个1x1的filters和48个3x3的filters,因此第二个inception模块的输出有K = 32+48=80个filters。

downsample_module对输入进行降维,但是保持filters的个数不变。

接下来,我们将四个Inception模块叠加在一起,让GoogLeNet学习更深入、更丰富的特征:

1
2
3
4
5
6
# 四个Inception和一个downsample层
x = MiniGoogLeNet.inception_module(x,112,48,chanDim)
x = MiniGoogLeNet.inception_module(x,96,64,chanDim)
x = MiniGoogLeNet.inception_module(x,80,80,chanDim)
x = MiniGoogLeNet.inception_module(x,48,96,chanDim)
x = MiniGoogLeNet.downsample_module(x,96,chanDim)

注意:这里inception模块中的1x1的filters和3x3的filters的个数变化,有的inception模块中1x1的filter的个数大于3x3的filter的个数,而有的inception模块中3x3的filter的个数大于1x1的filter的个数。Szegedy等人经过多次实验证明了这种交替变化模式是有效的。后面,我们搭建更深的GoogLeNet结构时,也会看到这种变化。

如图11.2所示,接着我们将堆叠两个inception模块,之后拼接average pooling层和dropout层:

1
2
3
4
5
# 两个Inception和global POOL ,dropout
x = MiniGoogLeNet.inception_module(x,176,160,chanDim)
x = MiniGoogLeNet.inception_module(x,176,160,chanDim)
x = AveragePooling2D((7,7))(x)
x = Dropout(0.5)(x) #防止过拟合

最后一个inception模块输出的特征图像大小为7x7x336,经过7x7的average pooling层之后,特征图像大小变为1x1x336。在AlexNet和VGG之前,基本上所有的基于神经网络的机器学习算法都要在卷积层之后添加全连接来进行特征的向量化,但是我们注意到,全连接层有一个非常致命的弱点就是参数量过大,特别是与最后一个卷积层相连的全连接层。那么我们有没有办法将其替代呢?当然有,就是GAP(global average pooling)——对每一个feature map内部取平均,将每一个feature map变成一个值,从而多少个feature map就变成了多少维的向量,然后就可以直接输入到softmax中。因此,上面我们得到了1x1x336的特征图像,所以没必要使用全连接层,直接将1x1x336拉平成一个336维度的向量,如下图所示:

最后,添加分类器——software:

1
2
3
4
5
6
7
# softmax分类器
x = Flatten()(x)
x = Dense(classes)(x)
x = Activation('softmax')(x)
# 建立模型
model = Model(inputs,x,name='googlenet')
return model

以上,完成了整个MinGoogLeNet网络结构的搭建,接下来,我们将在CIFAR-10数据集上进行训练。

Training MiniGoogLeNet on CIFAR-10

新建一个名为googlenet_cifar10.py文件,并写入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# -*- coding: utf-8 -*-
#加载所需模块
import matplotlib
matplotlib.use("Agg")

from sklearn.preprocessing import LabelBinarizer
from pyimagesearch.nn.conv import minigooglenet as MGN
from pyimagesearch.callbacks import trainingmonitor as TM
from keras.preprocessing.image import ImageDataGenerator
from keras.callbacks import LearningRateScheduler
from keras.optimizers import SGD
from keras.datasets import cifar10
import numpy as np
import argparse
import os

与之前不同的是,这里我们新加载了一个LearningRateScheduler类的,这意味着优化器将以一个特定的学习率进行训练。我们将对学习率进行多项式衰减,计算公式如下:

α=α0(1e/emax)p\alpha = \alpha_0 * (1 - e / e_{max}) ^p

其中α0\alpha_0为初始学习率,e为当前epoch,emax为我们一开始设定的最大迭代次数,p为多项式的幂。通过公式,我们可以看到每个epoch的学习率不是固定的,并且随着迭代不断深度,学习率将逐渐衰减为零。

在实际应用中,我们往往将p设置为1.0,即为线性衰减模式。图11.6显示了对不同的p值所做的实验结果,在迭代次数,初始学习率固定的情况下,随着p值的增加,学习率下降得越快。

图11.6 不同p值实验
实现这个学习率衰减函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
# 总的迭代次数
NUM_EPOCHS = 70
#初始学习率
INIT_LR = 5e-3

def poly_decay(epoch):
# 初始最大迭代次数和学习率
maxEpochs = NUM_EPOCHS
baseLR = INIT_LR
power = 1.0
# 以多项式的方式衰减学习率
alpha = baseLR * (1 - (epoch / float(maxEpochs))) ** power
return alpha

其中:

  • epoch:为当前训练的epoch

解析命令行参数:

1
2
3
4
5
# 定义命令行参数
ap = argparse.ArgumentParser()
ap.add_argument('-m','--model',required=True,help = 'path to output model')
ap.add_argument('-o','--output',required=True,help='path to output directory (logs,plots,etc.)')
args = vars(ap.parse_args())

其中:

  • model: 训练好的模型保存文件路径
  • output: 输出保存路径,比如log、plots等

从磁盘读取CIFAR-10数据集,并进行零均值化和标签编码处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 加载训练数据集和测试数据集
print("[INFO] loading CIFAR-10 data...")
((trainX,trainY),(testX,testY)) = cifar10.load_data()
trainX = trainX.astype("float")
testX = testX.astype("float")

#计算均值
mean = np.mean(trainX,axis = 0)
trainX -= mean
testX -= mean

# 标签编码化
lb = LabelBinarizer()
trainY = lb.fit_transform(trainY)
testY = lb.fit_transform(testY)

为了提高精度和防止过拟合,我们增加数据增强处理:

1
2
3
4
5
# 数据增强
aug = ImageDataGenerator(width_shift_range=0.1,
height_shift_range = 0.1,
horizontal_flip = True,
fill_mode = 'nearest')

回调函数列表中,TrainingMonitor函数功能主要是监控训练过程指标的变化,LearningRateScheduler主要是更新学习率:

1
2
3
4
5
# 回调,监控
figPath = os.path.sep.join([args['output'],'{}.png'.format(os.getpid())])
jsonPath = os.path.sep.join([args['output'],"{}.json".format(os.getpid())])
callbacks = [TM.TrainingMonitor(figPath,jsonPath = jsonPath),
LearningRateScheduler(poly_decay)]

最后,我们开始训练网络:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 初始化优化器和模型
print("[INFO] compiling model...")
opt= SGD(lr = INIT_LR,momentum = 0.9)
model = MGN.MiniGoogLeNet.build(width=32,height = 32,depth=3,classes = 10)
model.compile(loss = 'categorical_crossentropy',optimizer=opt,metrics = ['accuracy'])
# 训练网络
print("[INFO] training network...")
model.fit_generator(aug.flow(trainX,trainY,batch_size = 64),
validation_data = (testX,testY),steps_per_epoch = len(trainX) // 64,
epochs = NUM_EPOCHS,callbacks = callbacks,verbose = 1)

# 保存模型到磁盘中
print("[INFO] serializing network...")
model.save(args['model'])

初始化学习率lr=INIT_LR,一旦训练开始,学习率将通过learningrescheduler进行更新。

实验1

在给定数据集情况下,我们往往需要通过多次实验,以获得一个良好的结果,如何根据上一次实验的结果,调整下一次实验的方案,这个过程很重要。一开始,当我们刚刚开始涉足深度学习时候——仅仅看到一段代码、理解它的功能、执行它并查看输出就足够了。

但是,当我们深入学习时,我们将使用更高级的模型架构以及面对更加有挑战性的问题,这时候,我们需要了解模型背后的学习过程,需要检查结果,然后根据结果是否需要进一步更新参数,这将对我们提高实验能力很有帮助。

在我们的第一个实验中,令初始学习率为1e-3并且使用线性衰减进行更新,优化器为SGD算法,总的迭代次数设置为70——这里可能有一个疑问,为何epochs为70,而不是更大或者更小,主要是:

  • 先验知识。当你阅读了数百篇深度学习论文、博客文章和教程以及多次实验之后,你将会发现一些数据集的模式。在本例中,从大家对CIFAR-10数据集的实验中可知,训练CIFAR-10数据集所需的epochs大约在50-100次左右,而且网络结构越深(有正则化情况下),学习率就越低,这通常会使我们的网络训练时间更长。所以,对于第一个实验,我们将epochs设置为70,然后我们可以根据实验的结果来决定是否应该使用更多/更少的epochs。

  • 避免过拟合。从之前的实验中,我们知道,在训练CIFAR-10数据集时,即使加入了正则化和数据增强方法,我们最终仍然会发生过拟合问题。因此,我们在实验中将epochs设置为70,而不是80-100左右——这个区间可能更容易发生过拟合问题。

运行以下命令,进行训练网络:

1
$ python googlenet_cifar10.py --output output --model output/minigooglenet_cifar10.hdf5

从打印的结果以及图11.7左上,可知70次迭代训练之后,模型的准确度为88.15%。另外,从图中可以看到在第40次epoch之后,train_loss和val_loss曲线基本上成相对比例下降且在第70次epoch,train_loss和val_loss似乎还有下降的趋势。因此,在给定epochs下,我们可以尝试加快训练速度,即加大学习率。

实验2

在第二个实验中,我们将增大学习率,令lr=1e-2,其余保持不变。然后运行下面命令进行第二次训练网络:

1
$ python googlenet_cifar10.py --output output --model output/minigooglenet_cifar10.hdf5

结果如图11.7右上角所示,在70次迭代训练结束之后,模型的准确率大约为92.19%,虽然准确率比第一个实验提高了一点,但是,我们详细观察train_loss变化曲线,很明显在第60次迭代之后,train_loss接近0,也就是说准确度接近100%。

虽然我们提高了模型在验证集上的准确度,但是,很明显我们发生了过拟合——在第20次epoch之后,train_loss和val_loss之间的差距很明显变大。

结合两个实验,或许我们可以对学习率取一个1e-3到1e-2的中间值,以达到一个平衡点,比如设置lr=5e-3,这样,或许可以获得一个不错的准确度,又能一定程度上降低过拟合风险。

图11.7 三次实验结果
* 值得注意的是,如果你发现模型的训练损失为0.0和准确度为100%,这时候,你一定要密切关注模型在验证数据集上的loss和accuracy曲线变化。如果你发现train_loss和val_loss之间存在明显的差距,那么肯定是模型发生了过拟合。这时候需要对模型的参数进行调整,引入更多的正则化技术,调整学习率等。*

实验3

在第三个实验中,将学习率调整为5e-3,其余保持不变。运行以下命令:

1
$ python googlenet_cifar10.py --output output --model output/minigooglenet_cifar10.hdf5

结果如图11.7下所示,在70次迭代训练之后,模型的准确度为90.79%——低于第二个实验,高于第一个实验。虽然同样也发生了过拟合问题,但是是可以接受的。

更重要的是,训练损失并没有完全降到零,训练精度也没有达到100%。而且train_acc和val_acc之间的差距是合理的。

在这一点上,我们可以认为这个实验是可以接受的(或许我们应该做更多的实验来减少过拟合问题)——我们已经成功地在CIFAR-10数据集上训练MiniGoogLeNet模型,并且准确度达到了90%以上,比之前所有关于CIFAR-10的实验结果都要好。

从实验2和实验3中,我们很明显的看到模型发生了过拟合,因此,我们可以考虑增加更多的正则化技术,下一节中,我们将对模型权重应用l2正则化以降低过拟合。

接下来,我们将在tiny ImageNet数据集中训练GoogLeNet模型——这里我们不再使用MinGoogLeNet模型架构,而是重新定义一个更深的GoogLeNet网络结构,类似于原始论文中架构。

Tiny ImageNet

图11.8 tiny inagenet数据集样本
tiny ImageNet视觉识别挑战(如图11.8)是cs231n斯坦福大学关于卷积神经网络课程的实践一部分[39]。学生们可以对该数据集从零开始训练CNN进行分类,也可以通过微调进行迁移学习(不允许通过特征提取进行迁移学习)。

tiny ImageNet数据集实际上是完整的ImageNet数据集的子集(因此不能使用特征提取),它包含200个不同的类。对于一张图片,若果我们进行随机猜测,则准确率为1/200 = 0.5%,因此,CNN模型至少需要获得0.5%才能证明它具有区分能力。

每个类包含500张训练图像、50张验证图像和50张测试图像。由于我们无法访问用于评估tiny ImageNet测试图像的服务器,所以我们将使用一部分训练集来形成我们自己的测试集,以便我们可以评估算法的性能。

**需要注意的是:**tiny imagenet数据集中的所有图片大小为64x64x3.

在某些方面,经过调整图像大小的tiny ImageNet比ILSVR(图像更大)更具挑战性。在ILSVRC中,我们可以自由地调整图像大小、裁剪等操作。然而,对于tiny ImageNet,丢弃了很多图片。因此,在tiny ImageNet上获得一个合理的rank-1和rank-5的准确度并不像我们想象的那么容易。

在接下来的几小节中,我们将学习如何获取tiny ImageNet数据集,了解其结构,并创建用于训练、验证和测试图像的HDF5文件。

下载Tiny ImageNet数据集

该数据集可以从官网地址下载。

Tiny ImageNet目录结构

解压数据压缩包之后,可以看到整个数据目录结构如下:

1
2
3
4
5
6
--- tiny-imagenet-200
| |--- test
| |--- train
| |--- val
| |--- wnids.txt
| |--- words.txt

train目录包含了200个子目录,每一个子目录名字都是由n和数字组成,对应WordNet(词典)ID。每个WordNet ID映射到一个特定的单词/对象。我们可以从words.txt文件中遍历wordNet ID获取对应的标签名称。

在开始训练GoogLeNet之前,我们首先需要编写一个脚本来解析这些文件并将它们转换为HDF5格式。

Tiny ImageNet HDF5

为了好维护项目,我们需要养成一个良好的习惯——一个项目一个目录,首先定义GoogLeNet项目目录结构:

1
2
3
4
5
6
7
8
9
10
--- deepergooglenet
| |--- config
| | |--- __init__.py
| | |--- tiny_imagenet_config.py
| |--- build_tiny_imagenet.py
| |--- rank_accuracy.py
| |--- train.py
| |--- output/
| | |--- checkpoints/
| | |--- tiny-image-net-200-mean.json

其中:

  • tiny_imagenet_config.py: 配置文件
  • build_tiny_imagenet.py:将tiny ImageNet数据转化为HDF5数据集
  • rank_accuracy.py: 计算rank-N准确度
  • train.py:训练模型

首先,我们对整个项目的配置文件进行设置,打开tiny_imagenet_config.py,并写入以下代码:

1
2
3
4
5
6
7
8
9
# -*- coding: utf-8 -*-
from os import path

#训练数据集和验证数据集路径
TRAIN_IAMGES = "../datasets/tiny-imagenet-200/train"
VAL_IMAGES = "../datasets/tiny-imagenet-200/val/images"

# 验证数据集与标签映射文件
VAL_MAPPINGS = "../datasets/tiny-imagenet-200/val/val_annotations.txt"

接下来,定义词典路径:

1
2
3
# WordNet hierarchy文件路径
WORDNET_IDS = '../datasets/tiny-imagenet-200/wnids.txt'
WORD_LABELS = '../datasets/tiny-imagenet-200/words.txt'

由于我们无法获取tiny imagenet测试数据集的标签,因此,需要从train数据集中划分一部分当做测试集:

1
2
3
# 从train数据中构造test数据
NUM_CLASSES = 200
NUM_TEST_IMAGES = 50 * NUM_CLASSES

定义hdf5数据保存路径:

1
2
3
4
# 定义输出路径
TRAIN_HDF5 = "../datasets/tiny-imagenet-200/hdf5/train.hdf5"
VAL_HDF5 = "../datasets/tiny-imagenet-200/hdf5/val.hdf5"
TEST_HDF5 = "../datasets/tiny-imagenet-200/hdf5/test.hdf5"

RGB均值文件保路径:

1
2
# 数据均值文件
DATASET_MEAN = "output/tiny-image-net-200-mean.json"

模型输出和日志/图保存路径:

1
2
3
4
5
# 输出路径和性能结果
OUTPUT_PATH = "output"
MODEL_PATH = path.sep.join([OUTPUT_PATH,"checkpoints/epoch_70.hdf5"])
FIG_PATH = path.sep.join([OUTPUT_PATH,'deepergooglenet_tinyimagenet.png"])
JSON_PATH = path.sep.join([OUTPUT_PATH,'deepergooglenet_tinyimagenet.json'])

接下来,我们将数据转化为HDF5数据集,打开build_tiny_imagenet.py,并写入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
# -*- coding: utf-8 -*-
# 加载所需模块
from config import tiny_imagenet_config as config
from sklearn.preprocessing import LabelEncoder
from sklearn.model_selection import train_test_split
from pyimagesearch.io import hdf5datasetwriter as HDFW
from imutils import paths
import numpy as np
import progressbar
import json
import cv2
import os

获取数据路径并提取对应的标签信息:

1
2
3
4
5
6
7
# 获取训练数据
trainPaths = list(paths.list_images(config.TRAIN_IMAGES))
# 提取对应标签
trainLabels = [p.split(os.path.sep)[-3] for p in trainPaths]
# one-hot 编码
le = LabelEncoder()
trainLabels = le.fit_transform(trainLabels)

需要注意:数据目录格式需要满足以下结构:

1
tiny-imagenet-200/train/{wordnet_id}/{unique_filename}.JPG

将数据划分为训练集跟测试集(由于我们没有真实的测试数据集对应的标签):

1
2
3
4
5
6
# 数据分割
split = train_test_split(trainPaths,trainLabels,
test_size = config.NUM_TEST_IMAGES,
stratify = trainLabels,
random_state = 42)
(trainPaths,testPaths,trainLabels,testLabels) = split

处理验证集数据:

1
2
3
4
5
# 读取验证书籍,并映射对应标签
M = open(config.VAL_MAPPINGS).read().strip().split("\n")
M = [r.split("\t")[:2] for r in M]
valPaths = [os.path.sep.join([config.VAL_PATHS,m[0]]) for m in M]
valLabels = le.transform([m[1] for m in M])

分别将训练数据集、验证数据集和测试数据集写入HDF5数据集中:

1
2
3
4
5
6
7
8
9
# 遍历数据元祖
for (dType,paths,labels,outputPath) in datasets:
# 初始化HDF5写入
print("[INFO] building {} ....".format(outputPath))
writer = HDFW.HDF5DatasetWriter((len(paths),64,64,3),outputPath)

# 初始化进度条
widgets = ['Building Dataset: ',progressbar.Percentage()," ", progressbar.Bar()," ",progressbar.ETA()]
pbar = progressbar.ProgressBar(maxval = len(paths), widgets = widgets).start()

遍历每一张图片,并写入HDF5中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 遍历图像路径
for (i,(path,label)) in enumerate(zip(paths,labels)):
# 从磁盘中读取数据
image = cv2.imread(path)

#计算均值
if dType == "train":
(b,g,r) = cv2.mean(image)[:3]
R.append(r)
G.append(g)
B.append(b)
# 将图像跟标签写入HDF5中
writer.add([image],[label])
pbar.update(i)

# 关闭数据库
pbar.finish()
writer.close()

同样,如果是训练数据集中,我们进行计算RGB通道均值,并将其保存到磁盘文件中:

1
2
3
4
5
6
#保存均值文件
print("[INFO] serializing means...")
D = {"R":np.mean(R),"G":np.mean(G),"B":np.mean(B)}
f = open(config.DATASET_MEAN,'w')
f.write(json.dumps(D))
f.close()

执行以下命令,以完成数据转换过程:

1
2
3
4
5
6
7
8
$ python build_tiny_imagenet.py
[INFO] building ../datasets/tiny-imagenet-200/hdf5/train.hdf5...
Building Dataset: 100% |####################################| Time: 0:00:36
[INFO] building ../datasets/tiny-imagenet-200/hdf5/val.hdf5...
Building Dataset: 100% |####################################| Time: 0:00:04
[INFO] building ../datasets/tiny-imagenet-200/hdf5/test.hdf5...
Building Dataset: 100% |####################################| Time: 0:00:05
[INFO] serializing means...

使用h5py模块对文件进行检验:

1
2
3
4
5
6
7
8
9
10
>>> import h5py
>>> filenames = ["train.hdf5", "val.hdf5", "test.hdf5"]
>>> for filename in filenames:
... db = h5py.File(filename, "r")
... print(db["images"].shape)
... db.close()
...
(90000, 64, 64, 3)
(10000, 64, 64, 3)
(10000, 64, 64, 3)

我们将在这些HDF5数据集上训练GoogLeNet模型。

DeeperGoogLeNet on Tiny ImageNet

前面我们完成了对tiny imagenet的HDF5数据集形式转换,下面,我们将对其进行训练GooLeNet网络——注意,这里我们不是训练MiniGoogLeNet网络,因此我们需要重新定义inception模块(使用原始结构,即图11.1)。

首先,我们将重新定义inception模块,并使用新的inception模块搭建更深的GoogLeNet模型,然后在tiny imagenet数据集上训练该网络结构,最后,对模型进行评估性能。

Implementing DeeperGoogLeNet

图11.9 GoogLeNet结构图
我们将按图11.9显示的结构实现GoogLeNet网络,与原始的论文中的GoogLeNet网络结构有两个主要的区别:
  • 在第一个CONV层中,我们不使用7x7个且步长为2x2的filter,而是使用5x5且步长为1x1的filter。因为tiny imagenet图像数据的大小为64x64x3,从而我们定义的GoogLeNet网络结构只接受大小为64x64x3的输入图像,而原论文中的GoogLeNet接受的是224x224x3的输入图像,如果我们使用7x7个且步长为2x2的filter,则我们将过快地减少输入尺寸。

  • 稍微地简化下GoogLeNet,即少了两个inception模块——在Szegedy的原始论文中,在avergae pooling操作之前增加了两个inception模块。

接下来,实现GoogLeNet模型,首先,在pyimagesearch项目中nn.conv子模块中,新建一个名为deeergooglenet.py文件,如下目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
--- pyimagesearch
| |--- __init__.py
| |--- callbacks
| |--- io
| |--- nn
| | |--- __init__.py
| | |--- conv
| | | |--- __init__.py
| | | |--- alexnet.py
| | | |--- deepergooglenet.py
| | | |--- lenet.py
| | | |--- minigooglenet.py
| | | |--- minivggnet.py
| | | |--- fcheadnet.py
| | | |--- shallownet.py
| |--- preprocessing
| |--- utils

打开deeergooglenet.py,并写入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# -*- coding: utf-8 -*-
# 加载所需模块
from keras.layers import BatchNormalization
from keras.layers import Conv2D
from keras.layers import AveragePooling2D
from keras.layers import MaxPooling2D
from keras.layers import Activation
from keras.layers import Dropout
from keras.layers import Dense
from keras.layers import Flatten
from keras.models import Model
from keras.layers import Input
from keras.layers import concatenate
from keras.regularizers import l2
from keras import backend as K

接下来,搭建整个网络的主体架构,首先,我们定义一个conv_module函数,该函数负责接收输入层,执行CONV => BN => RELU,然后返回输出。通常,我倾向于将BN放在RELU之后,但是由于我们正在复制Szegedy等工作,所以把BN放在激活层之前,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class DeeperGoogLeNet:
@staticmethod
def conv_module(x,K,kX,kY,stride,chanDim,
padding='same',reg=0.0005,name=None):
# 初始化名称
(convName,bnName,actName) = (None,None,None)
if name is not None:
convName = name + "_conv"
bnName = name +"_bn"
actName = name +"_act"
# CONV=>BN=>RELU
x = Conv2D(K,(kX,kY),strides = stride,padding=padding,
kernel_regularizer = l2(reg),name = convName)(x)
x = BatchNormalization(axis = chanDim,name= bnName)(x)
x = Activation('relu',name = actName)(x)
return x

其中:

  • x: 网络层的输入。
  • K: 卷积层的filters个数。
  • kX和kY: filter的大小
  • stride: 步长,通常我们使用1x1,若果需要降低维度,可以使用更大的步长。
  • chanDim: 通道维度
  • padding: 填充方式
  • reg: L2正则项系数。
  • name: 网络层名称

conv_module结构如下:

图11.10 conv_module
接下来,定义inception_module,按照图11.1进行设计:
1
2
3
4
5
6
@staticmethod
def inception_module(x,num1x1,num3x3Reduce,num3x3,
num5x5Reduce,num5x5,num1x1Proj,chanDim,stage,reg=0.0005):
# 定义inception模块中的第一个分支,即1x1卷积
first = DeeperGoogLeNet.conv_module(x,num1x1,1,1,
(1,1),chanDim,reg = reg,name = stage+"_first")

其中:

  • num1x1:第一个分支中1x1的filter的个数
  • num3x3Reduce:第二个分支中1x1的filter的个数,这里变量名称主要是表明下一层是3x3的卷积层
  • num3x3:第二个分支中,3x3的filter的个数
  • num5x5Reduce:第三个分支中,1x1的filter的个数
  • num5x5:第三个分支中,5x5的filter的个数
  • num1x1Proj:第四个分支中,1x1的filter的个数
  • chanDim:通道维度
  • stage:第几阶段
  • reg:l2正则系数,默认为0.0005

Inception模块对输入做了四个分支,分别用不同尺寸的filter进行卷积或者池化,最后再在特征维度上拼接到一起.

第一个分支仅仅执行一系列的1x1大小的卷积——主要学习局部特征。

第二个分支首先通过1x1的卷积来实现降维,然后通过3x3的卷积进行扩展——对应num3x3Reduce

1
2
3
4
5
# 定义Inception模块的第二分支
# 主要由1x1和3x3卷积组成
second= DeeperGoogLeNet.conv_module(x,num3x3Reduce,1,1,
(1,1),chanDim,reg = reg,name = stage+"_second1")
second = DeeperGoogLeNet.conv_module(second,num3x3,3,3,(1,1),chanDim,reg = reg,name = stage+"_second2")

第三个分支与第二个分支类似,只是将3x3的卷积换成5x5的卷积:

1
2
3
4
5
6
# 定义Inception模块的第三分支
#主要是由5x5和1x1组成
third = DeeperGoogLeNet.conv_module(x,num5x5Reduce,1,1,
(1,1),chanDim,reg=reg,name=stage+'_third1')
third = DeeperGoogLeNet.conv_module(third,num5x5,5,5,(1,1),
chanDim,reg=reg,name=stage+'_third2')

Inception模块的第四个分支,先进行pooling操作,然后进行1x1的卷积:

1
2
3
4
5
6
#定义Inception模块的第四分支
#主要由1x1卷积和maxPooling组成
fourth = MaxPooling2D((3,3),strides=(1,1),
padding='same',name=stage+'_pool')(x)
fourth = DeeperGoogLeNet.conv_module(fourth,num1x1Proj,
1,1,(1,1),chanDim,reg=reg,name=stage+'fourth')

作者认为max pooling也有提取特征的作用,所以这一分支使用了max pooling层。2014年,大多数(如果不是全部的话)在ImageNet数据集中获得高性能的卷积神经网络都采用了max pooling。因此,人们认为CNN应该应用max pooling。虽然GoogLeNet在Inception模块之外也应用了max pooling,但是Szegedy等人将max pooling应用到inception模块的一个分支中。

接下来,我们将各个分支的输出进行拼接,整合成一个inception模块的输出:

1
2
3
# 将四个分支拼接在一起
x = concatenate([first,second,third,fourth],axis=chanDim, name=stage+'_mixed')
return x

我们将参数设置为::

  • num1x1=64
  • num3x3Reduce=96
  • num3x3=128
  • num5x5Reduce=16
  • num5x5=32
  • num1x1Proj=32

并对inception模块的详细结构进行可视化,如11.11所示:

图11.11 完整的inception 模块
通过三种不同大小的filters,即1x1、3x3和5x5,inception模块可以同时学习到通用(5x5和3x3)的特征和局部(1x1)的特征。在训练的优化过程,模型会自动对分支和层进行优化——本质上可以理解为一个“通用的”模块,在给定迭代次数情况下,它将学习到最佳的特性集合(小卷积学到的局部特征,大卷积学习到的抽象特征)。图11.11显示Inception模块的输出是256——这是每个分支输出拼接而成,64+128+32+32 = 256。

定义好了inception_module,接下来构建完整的DeeperGoogLeNet网络结构:

1
2
3
4
5
6
7
8
9
10
@staticmethod
def build(width,height,depth,classes,reg = 0.0005):
# 初始化shape
inputShape = (height,width,depth)
chanDim = -1

#判断keras后端
if K.iamge_data_format() == "channels_first":
inputShape = (depth,height,width)
chanDim = 1

其中:

  • width:输入图像的宽度
  • height:输入图像的高度
  • depth:输入图像的深度
  • classes:数据的类别个数
  • reg:l2正则系数

根据上面的图11.9,第一个block将执行CONV =>POOL => (CONV * 2) =>POOL的序列:

1
2
3
4
5
6
7
8
9
# 定义模型的输入层,卷积层,POOL层等,
# 主要是Inception模块之前
# CONV => POOL =>(CONV * 2)=>POOL
inputs = Input(shape = inputShape)
x = DeeperGoogLeNet.conv_module(inputs,64,5,5,(1,1),chanDim,reg=reg,name='block1')
x = MaxPooling2D((3,3),strides=(2,2),padding='same',name='pool1')(x)
x = DeeperGoogLeNet.conv_module(x,64,1,1,(1,1),chanDim,reg=reg,name='block2')
x = DeeperGoogLeNet.conv_module(x,192,3,3,(1,1),chanDim,reg=reg,name='block3')
x = MaxPooling2D((3,3),strides=(2,2),padding='same',name='pool2')(x)

接下来,我们加入两个inception模块(3a和3b),然后,紧接着max pooling层,即inception => inception => pool序列:

1
2
3
4
5
# 在POOL层之后,接着两个Inception层
x = DeeperGoogLeNet.inception_module(x,64,96,128,16,,32,32,chanDim,'3a',reg=reg)
x = DeeperGoogLeNet.inception_module(x,128,128,192,32,96,64,chanDim,'3b',reg=reg)
x = MaxPooling2D((3,3),strides=(2,2),padding='same',
name='pool3')(x)

整个网络中的所有参数值都是直接取自于Szegedy等原论文——作者在多次实验调参之后得到的。仔细观察,我们会发现inception模块的通用模式:

  • Inception模块的第一个分支中1x1的filters的个数小于或等于第二个分支和第三个分支中1x1的filters的个数。
  • 第二个分支和第三个分支中,1x1的filters的个数总是小于3x3的filters的个数或者5x5的filters的个数。
  • 第二个分支的filters总是大于第三个分支的filters,既可以减小模型的大小,又可以提高训练和预测速度。
  • 第四个分支的1x1的filters的个数总是小于第一个分支的1x1的filters的个数。
  • 无论哪个分支,随着网络深度越深,filters的个数越多(或者保持不变)。

总的来说,我们仍然遵循与之前的CNNs相同的经验法则——网络越深,特征图像越小,而filters的个数越多。

接下来,我们将5个Inception模块(4a-4e)叠加在一起,然后紧接着一个max pooling层,这样网络变得越深,将学习到更加丰富的特征:

1
2
3
4
5
6
7
# 在POOLing层之后,紧接着5个Inception模块
x = DeeperGoogLeNet.inception_module(x,192,96,208,16,48,64,chanDim,'4a',reg=reg)
x = DeeperGoogLeNet.inception_module(x,160,112,224,24,64,64,chanDim,'4b',reg=reg)
x = DeeperGoogLeNet.inception_module(x,128,128,256,24,84,64,chanDim,'4c',reg=reg)
x = DeeperGoogLeNet.inception_module(x,112,144,288,32,64,64,chanDim,'4d',reg = reg)
x = DeeperGoogLeNet.inception_module(x,256,160,320,32,128,128,chanDim,'4e',reg=reg)
x = MaxPooling2D((3,3),strides=(2,2),padding='same',name='pool4')(x)

最后一个max pooling层的输出为4x4xclases。以往,到这一步,我们会使用全连接层进行向量化,而由于全连接层参数巨大且需要消耗巨大的内存,为了同样得到向量化的结果,我们使用4x4的average pooling将特征图像大小调整为1x1xclasses:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 定义avg Pool层,dropout层
x = AveragePooling2D((4,4),name='pool5')(x)
x = Dropout(0.4,name='do')(x)

# softmax分类器
x = Flatten(name='flatten')(x)
x = Dense(classes,kernel_regularizer=l2(reg),
name='labels')(x)
x = Activation('softmax',name='softmax')(x)

# 创建模型
model = Model(inputs,x,name='googlenet')

return model

: 一般,对于dropout层的概率我们通常设置为50%,这里概率值为40%,主要是原始论文中也为40%。

training DeeperGoogLeNet on Tiny ImageNet

完成了整个GoogLeNet模型结构搭建之后,接下来,我们将在tiny imagenet数据集上进行训练,并对性能进行评估。

新建一个名为train.py文件,并写入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# -*- coding: utf-8 -*-
# 加载所需模块

import matplotlib
matplotlib.use("Agg")
from config import tiny_imagenet_config as config
from pyimagesearch.preprocessing import imagetoarraypreprocessor as ITA
from pyimagesearch.preprocessing import simplespreprocessor as SP
from pyimagesearch.preprocessing import meanpreprocessor as MP
from pyimagesearch.callbacks import epochcheckpoint as ECP
from pyimagesearch.callbacks import trainingmonitor as TM
from pyimagesearch.io import hdf5datasetgenerator as HDFG
from pyimagesearch.nn.conv import deepergooglenet as DGN
from keras.preprocessing.image import ImageDataGenerator
from keras.optimizers import Adam
from keras.models import load_model
import keras.backend as K
import argparse
import json

解析命令行参数:

1
2
3
4
5
6
7
8
9
10
11

# 解析命令行参数
ap = argparse.ArgumentParser()
ap.add_argument('-c','--checkpoints',required=True,
help='path to output checkpoint directory')
ap.add_argument('-m','--model',type=str,
help='path to *specific* model checkpoint to load')
ap.add_argument('-s','--start_epoch',type = int,default=0,
help='epoch to restart training at')
args=vars(ap.parse_args())

其中:

  • checkpoints:监控模型训练过程,我们经常在训练模型时,希望保存某一时刻训练的模型,以方便下次迭代接着训练,这时候我们就需要使用到checkpoints
  • model: 模型保存路径
  • start_epoch:下次训练开始的epoch

为了获得良好的准确率以及降低过拟合风险,我们对训练数据进行数据增强处理:

1
2
3
4
5
6
7
# 数据增强
aug = ImageDataGenerator(rotation_range=18,zoom_range=0.15,
width_shift_range=0.2,height_shift_range=0.2,shear_range=0.15,
horizontal_flip=True,fill_mode='nearest')

# 加载 RGB均值文件
means = json.loads(open(config.DATASET_MEAN).read())

初始化图像预处理以及训练数据集和验证数据集:

1
2
3
4
5
6
7
8
9
10
# 初始化预处理
sp = SP.SimplePreprocessor(64,64) # 调整图像大小
mp = MP.MeanPreprocessor(means['R'],means['G'],means['B']) # 零均值化
iap = ITA.ImageToArrayPreprocessor() # 转化为array数组

# 训练数据集和验证书籍生成器
trainGen = HDFG.HDF5DatasetGenerator(config.TRAIN_HDF5,64,aug = aug,
preprocessors=[sp,mp,iap],classes=config.NUM_CLASSES)
valGen = HDFG.HDF5DatasetGenerator(config.VAL_HDF5,64,
preprocessors=[sp,mp.iap],classes=config.NUM_CLASSES)

需要注意的是:上面数据生成过程中,我们将原始图像大小调整为64x64x3。

接下来,我们开始训练DeeperGoogLeNet网络:

1
2
3
4
5
6
7
8
9
# 如果不存在checkpoints 模型,则直接初始化模型
if args['model'] is None:
print("[INFO] compiling model....")
model = DGN.DeeperGoogLeNet.build(width=64,height=64,depth=3,classes=config.NUM_CLASSES,reg=0.0002)
# 优化器
opt=Adam(1e-3)
# 编译模型
model.compile(loss='categorical_crossentropy',optimizer=opt,
metrics=['accuracy'])

若未指定需要加载的checkpoints模型,我们从头开始训练GoogLeNet,否则,我们需要从磁盘中加载checkpoints模型,然后接着训练:

: 后面的实验中将看到为什么我们将正则化系数设置为0.0002

1
2
3
4
5
6
7
8
9
# 否则,直接从磁盘中加载checkpoint模型,接着训练
else:
print("[INFO] loading {}...".format(args['model']))
model = load_model(args['model'])

# 更新学习率
print("[INFO] old learning rate:{}".format(K.get_value(model.optimizer.lr)))
K.set_value(model.optimizer.lr,1e-5)
print("[INFO] new learning rate: {}".format(K.get_value(model.optimizer.lr)))

初始化回调函数:EpochCheckpoint主要是默认每5次epoch将模型保存到磁盘中,TrainingMonitor主要是用于监控训练过程:

1
2
3
4
5
6
# 回调函数
callbacks = [
ECP.EpochCheckpoint(args['checkpoints'],every=5,startAt = args['start_epoch']),
TM.TrainingMonitor(config.FIG_PATH,joinPath=config.JSON_PATH,startAt = args['start_epoch'])
]

训练网络:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#训练网络
model.fit_generator(
trainGen.generator(),
steps_per_epoch = trainGen.numImages // 64,
validation_data = valGen.generator(),
validation_steps = valGen.numImages // 64,
epochs = 10,
max_queue_size = 64 * 2,
callbacks = callbacks,
verbose = 1)

# 关闭数据库
trainGen.close()
valGen.close()

我们将根据网络训练过程中的loss/accuracy变化曲线来决定epochs个数,以及将根据模型的性能是否更新学习率或者增加early stopping技术。

性能评估

新建一个名为rank_accuracy.py脚本,并写入以下代码:

1
2
3
4
5
6
7
8
9
10
# -*- coding: utf-8 -*-
# 加载所需模块
from config import tiny_imagenet_config as config
from pyimagesearch.preprocessing import imagetoarraypreprocessor as ITA
from pyimagesearch.preprocessing import simplespreprocessor as SP
from pyimagesearch.preprocessing import meanpreprocessor as MP
from pyimagesearch.io import hdf5datasetgenerator as HDFG
from pyimagesearch.utils.ranked import rank5_accuracy
from keras.models import load_model
import json

同train数据集预处理过程一样:

1
2
3
4
5
6
7
8
9
10
# 加载RGB均值文件
means = json.loads(open(config.DATASET_MEAN).read())

# 初始化预处理
sp = SP.SimplePreprocessor(64,64)
mp = MP.MeanPreprocessor(means['R'],means['G'],means['B'])
iap = ITA.ImageToArrayPreprocessor()

# 初始化测试数据集生成器
testGen = HDFG.HDF5DatasetGenerator(config.TEST_HDF5,64,preprocessors = [sp,mp,iap],classes=config.NUM_CLASSES)

加载训练好的模型对test数据集进行预测:

1
2
3
# 加载预训练好的模型
print("[INFO] loading model ...")
model = load_model(config.MODEL_PATH)

之前在训练过程中,我们设置了checkpoints,因此,我们可以加载checkpoints模型对test数据集进行预测,从而可以观察随着epochs的增加,准确度是如何变化的。

计算rank-1和rank-5准确度:

1
2
3
4
5
6
7
8
9
10
11
12
# 对测试集进行预测
print("[INFO] predicting on test data...")
predictions = model.predict_generator(testGen.generator(),steps = testGen.numImages // 64,max_queue_size = 64 * 2)

# 计算rank-1和rank5准确度
(rank1,rank5) = rank5_accuracy(predictions,testGen.db['labels'])
print("[INFO] rank-1: {:.2f}%".format(rank1 * 100))
print("[INFO] rank-5: {:.2f}%".foramt(rank5 * 100))

#关闭数据库
testGen.close()

DeeperGoogLeNet Experiments

表11.1 不同epoch对应不同的学习率
下面,我们将在tiny imagenet数据集上进行四个独立的实验——每次训练不同参数的DeeperGoogLeNet。在每次实验后,我们对实验结果进行评估,然后根据结果正确地更新超参数或者网络架构以提高准确度。

说明:整个实验调参过程对于新手而言很有帮助,想提高深度学习算法的效果,需要多次进行实验。另外,在训练网络过程中,应该关注哪些参数,以及如何去优化参数。

最后,需要注意的是,有些实验需要对deepergooglenet.py和train.py代码进行修改。

DeeperGoogLeNet: 实验1

假我们第一次在tiny ImageNet数据集上训练一个网络,在给定网络框架时,我们该使用多深的网络结构?我们知道原始的GoogLeNet在是完整的imagnet数据集上训练,考虑到tiny imagenet数据只是imagenet数据集的一部分,一开始我们可能会觉得并不需要训练一个像原GoogLeNet那么深的网络,因此,我们删除DeeperGoogLeNet网络中的4a-4e模块(inception模块),从而得到一个更浅的网络架构。

在第七章中,我们提到在第一次实验中,我们应该首先尝试SGD训练网络,如果有需要,我们可以选择更高级的优化算法。因此,首先,我们使用初始学习率为1e-2,动量为0.9的SGD算法进行训练网络。

需要注意:以下实验需要根据条件对代码进行修改。

运行以下命令进行训练:

1
$ python train.py --checkpoints output/checkpoints

接下来,我们将根据表11.1所示的学习率进行实验——意味着在25次epoch后,停止训练,然后降低了学习率为1e-3,并将epochs重新设置为10,运行以下命令接着训练:

1
2
$ python train.py --checkpoints output/checkpoints \
--model output/checkpoints/epoch_25.hdf5 --start_epoch 25

在第35次epoch,停止训练,并降低学习率为1e-4,epochs为10,然后运行以下命令接着训练:

1
2
3
$ python train.py --checkpoints output/checkpoints \
--model output/checkpoints/epoch_35.hdf5 --start_epoch 35

三次实验结果如图11.12所示:

图11.12 三次实验结果
从图中可知大约在第15个epoch之后,train_loss和val_loss出现了差距。到第25次epoch时,差距变得更明显了。所以,我们把学习率降低了一个数量级,带来的效果是提高了accuracy和减小了loss。但是我们仔细观察loss和accuracy曲线变化,在这之后,模型基本停滞了,loss和Accuracy曲线基本上没有什么变化,甚至在第35次epoch,我们再次降低学习率为1e-4,也没有带来明显的效果。

在第40次epoch结束时,模型完全停滞,loss和Accuracy曲线完全没有变化。如果我们不再尝试更低的学习率话,那么我们可以在45次epoch就停止训练。在本实验中,从图可知,在第65次epoch之后(loss和Accuracy没有发生变化),我们停止了训练网络,并且当前模型在验证集上的rank-1的准确率为52.25%。在降低学习率情况下,模型的性能趋向于稳定,如果我们想要提高模型的准确率,那么我们还需要尝试其他方法,比如我们在第7章中提高的更高级的优化算法。

DeeperGoogLeNet: 实验2

在第二个实验中,我们将使用更高级的优化算法——Adam。对于tiny imagenet数据集,一开始我们会认为模型深度已经够深了,没必要再加深网络(一方面我们也不知道应该对模型加多深,另一方面深度学习网络本身训练相当耗时),因此我们从优化器方面入手。Adam算法的默认初始学习率1e-3,我们将按照表11.2来调整学习率。

表11.2 左:实验2, 右:实验3
整个训练过程如图11.12(右上角)所示,看起来似乎与实验1很相似。一开始,val_loss迅速下降,但是,到第10次epoch之后,train_loss明显要小于val_loss,并且差距有可能进一步拉大,因此,我们在第20次epoch降低了学习率(否则有可能会发生过拟合)。在第20次epoch降低学习率之后,甚至在第30次epoch第二次降低学习率,val_acc似乎没有提高,模型仍然是停滞状态。但是,在第40次epoch结束时,我们仔细对比第一个实验,发现val_loss比第一个实验低,准确率更高。

通过将SGD替换为Adam,模型在验证集上的rank-1准确度提高到了54.20%,增加了近2%。但是,在第一次降低学习率之后,仍然存在模型停滞问题。

DeeperGoogLeNet: Experiment #3

在实验2中,我们使用了更高级的优化算法,但是仍然存在模型停滞问题,调整学习率,val_acc却没有发生变化,或许是由于网络不够深,无法捕捉到tiny imagenet数据集中更有区分能力的特征。因此,我们决定加深模型深度,新增inception模块(4a-4e),使得GoogLeNet网络变得更深,能够学习到更深、更具有区分的特征。

在实验2的基础上,我们在实验3中,Adam使用默认的学习率1e-3,l2正则系数调整为0.0002,学习率更新方式按照表11.2(右)进行。

整个实验结果如11.12(下)所示,从图中可以发现,模型即没有发生停滞也没有发生严重的过拟合问题,并且模型在验证集上的rank-1准确度为55.77%,比实验2提高了1.5%左右。

接下来,我们加载训练好的模型,并对测试集进行预测:

1
2
3
4
5
$ python rank_accuracy.py
[INFO] loading model...
[INFO] predicting on test data...
[INFO] rank-1: 54.38%
[INFO] rank-5: 78.96%

从结果中,可知rank-1准确度为54.38%,对应错误率为1-0.5438 =0.4562,由图11.13所示,该结果可以在tiny imagenet排行榜上获得第7名。

图11.13 tiny imagenet排行榜
对于有兴趣进一步提高DeeperGoogLeNet准确度的读者,可以尝试进行以下实验:
  • 将conv_module更改为使用CONV => RELU => BN,而不是使用原始的CONV => BN=> RELU。
  • 尝试使用ELUs代替ReLUs,可能会提升0.5~11% 左右。

总结

在本章中,首选,我们回顾了Szgedy等的工作,介绍了著名的Inception模块。Inception模块是一个微型体系结构的例子,目前state-of-the-art的卷积神经网络倾向于使用某种形式的微结构。

然后,我们应用Inception模块创建了两个不同结构的GoogLeNet网络:

  • 一个在cifar-10数据上进行训练——MiniGoogLeNet
  • 一个在tiny imagenet数据上训练——DeeperGoogLeNet

在CIFAR-10数据上,准确率为90.81%,比之前任何实验的结果都好。在tiny ImageNet数据集上,rank-1准确率为54.38%和rank-5准确率为78.96%,在tiny ImageNet排行榜上获得第7名(前几名都是使用在ImageNet数据集上预先训练过的网络进行微调得到)。

本文完整代码下载地址: github