第3章计算机视觉

3.1卷积神经网络基础
3.1.1概述

计算机视觉作为一门让机器学会如何去“看”的学科,具体地说,就是让机器去识别摄像机拍摄的图片或视频中的物体,检测出物体所在的位置,并对目标物体进行跟踪,从而理解并描述出图片或视频里的场景和故事,以此来模拟人脑视觉系统。因此,计算机视觉也通常被叫作机器视觉,其目的是建立能够从图像或者视频中“感知”信息的人工系统。
计算机视觉技术经过几十年的发展,已经在交通(车牌识别、道路违章抓拍)、安防(人脸闸机、小区监控)、金融(刷脸支付、柜台的自动票据识别)、医疗(医疗影像诊断)、工业生产(产品缺陷自动检测)等多个领域应用,影响或正在改变人们的日常生活和工业生产方式。未来,随着技术的不断演进,必将涌现出更多的产品和应用,为我们的生活创造更大的便利和更多的机会,如图3.1所示。

■
图3.1计算机视觉技术在各领域的应用


飞桨为计算机视觉任务提供了丰富的API,并通过底层优化和加速保证了这些API的性能。同时,飞桨还提供了丰富的模型库,覆盖图像分类、检测、分割、文字识别和视频理解等多个领域。用户可以直接使用这些API组建模型,也可以在飞桨提供的模型库基础上进行二次研发。
由于篇幅所限,本章将重点介绍计算机视觉的经典模型(卷积神经网络)和图像分类任务,而在第4章介绍目标检测。本章主要涵盖如下内容。
(1) 卷积神经网络。卷积神经网络(Convolutional Neural Network,CNN)是计算机视觉技术最经典的模型结构。本教程主要介绍卷积神经网络的常用模块,包括卷积、池化、激活函数、批归一化、暂退法等。

(2) 图像分类。介绍图像分类算法的经典模型结构,包括LeNet、AlexNet、VGG、GoogLeNet、ResNet,并通过眼疾筛查示例展示模型和算法的应用。

计算机视觉的发展历程要从生物视觉讲起。对于生物视觉的起源,目前学术界尚没有形成定论。有研究者认为最早的生物视觉形成于距今约7亿年前的水母之中,也有研究者认为生物视觉产生于距今约5亿年前寒武纪。
经过几亿年的演化,目前人类的视觉系统已经具备非常高的复杂度和强大的功能,人脑中神经元数目达到1000亿个,这些神经元通过突触互相连接,这样庞大的视觉神经网络使我们可以很轻松地观察周围的世界,如图3.2所示。

■
图3.2人类视觉感知



对人类来说,识别猫和狗是件非常容易的事。但对计算机来说,即使是一个精通编程的高手,也很难轻松写出具有通用性的程序(比如:  假设程序认为体型大的是狗,体型小的是猫,但由于拍摄角度不同,可能一张图片上猫占据的像素比狗还多)。那么,如何让计算机能像人一样看懂周围的世界呢?研究者尝试着从不同的角度去解决这个问题,由此也发展出一系列的子任务,如图3.3所示。


■
图3.3计算机视觉子任务示意图



(1) 图像分类,用于识别图像中物体的类别(如瓶子、杯子、立方体)。
(2) 目标检测,用于检测图像中每个物体的类别,并准确标出它们的位置。
(3) 图像语义分割,用于标出图像中每个像素点所属的类别,属于同一类别的像素点用一个颜色标识。
(4) 实例分割,值得注意的是,图3.3(b)中的目标检测任务只需要标注出物体位置,而图3.3(d)中的实例分割任务不仅要标注出物体位置,还需要标注出物体的外形轮廓。
在早期的图像分类任务中,通常是先人工提取图像特征,再用机器学习算法对这些特征进行分类,分类的结果强依赖于特征提取方法,往往只有经验丰富的研究者才能完成,如图3.4所示。

■
图3.4使用传统的机器学习方法完成图像分类任务示意


在这种背景下,基于神经网络的特征提取方法应运而生。Yan LeCun是最早将卷积神经网络应用到图像识别领域的,其主要逻辑是使用卷积神经网络提取图像特征,并对图像所属类别进行预测,通过训练数据不断调整网络参数,最终形成一套能自动提取图像特征并对这些特征进行分类的网络,如图3.5所示。

■
图3.5早期的卷积神经网络处理图像任务示意



这一方法在手写数字识别任务上取得了极大的成功,但在接下来的时间里,却没有得到很好的发展。其主要原因
一方面是数据集不完善,
训练出的模型泛化性较差,
只能处理简单任务,在大尺寸的数据集上效果比较差;  另一方面是硬件瓶颈,网络模型复杂时,计算速度会特别慢。

目前,随着互联网技术的不断进步,数据量呈现大规模的增长,越来越丰富的数据集不断涌现。另外,得益于硬件能力的提升,计算机的算力也越来越强大。不断有研究者将新的模型和算法应用到计算机视觉领域。由此催生了越来越丰富的模型结构和更加准确的精度,同时计算机视觉所处理的问题也越来越丰富,如分类、检测、分割、场景描述、图像生成和风格变换等,甚至还不仅仅局限于二维图片,也包括视频处理技术和三维视觉等。

3.1.2卷积神经网络
卷积神经网络是目前计算机视觉中使用最普遍的模型结构。
在第2章中,我们介绍了手写数字识别任务,
使用全连接网络进行特征提取
,即将一张图片上的所有像素点展开成一个一维向量输入网络,存在如下两个问题:  

(1) 输入数据的空间信息被丢失。空间上相邻的像素点往往具有相似的RGB值,RGB的各个通道之间的数据通常密切相关,但是转化成一维向量时,这些信息被丢失。同时,图像数据的形状信息中,可能隐藏着某种本质的模式,但是转变成一维向量输入全连接神经网络时,这些模式也会被忽略。
(2) 模型参数过多,容易发生过拟合。在手写数字识别示例中,每个像素点都要跟所有输出的神经元相连接。当图片尺寸变大时,输入神经元的个数会按图片尺寸的平方增大,导致模型参数过多,容易发生过拟合。
为了解决上述问题,我们引入卷积神经网络进行特征提取,既能提取到相邻像素点之间的特征模式,又能保证参数的个数不随图片尺寸变化。图3.6 是一个典型的卷积神经网络结构,多层卷积和池化层组合作用在输入图片上,在网络的最后通常会加入一系列全连接层,ReLU激活函数一般加在卷积或者全连接层的输出上,网络中通常还会加入暂退法来防止过拟合。



■
图3.6卷积神经网络经典结构




说明:  

在卷积神经网络中,计算范围是在像素点的空间邻域内进行的,卷积核参数的数目也远小于全连接层。卷积核本身与输入图片大小无关,它代表了对空间邻域内某种特征模式的提取。比如,有些卷积核提取物体边缘特征,有些卷积核提取物体拐角处的特征,图像上不同区域共享同一个卷积核。当输入图片大小不一样时,仍然可以使用同一个卷积核进行操作。





1. 卷积(Convolution)
本小节将为读者介绍卷积算法的原理和实现方案,并通过具体的示例展示如何使用卷积对图片进行操作,主要涵盖如下内容: 卷积计算; 填充(Padding); 步幅(Stride); 感受野(Receptive Field); 多输入通道、多输出通道和批量操作; 飞桨卷积API介绍和卷积算子应用举例。
1) 卷积计算
卷积是数学分析中的一种积分变换的方法,在图像处理中采用的是卷积的离散形式。这里需要说明的是,在卷积神经网络中,卷积层的实现方式实际上是数学中定义的互相关 (Crosscorrelation)运算,与数学分析中的卷积定义有所不同,具体的计算过程如图3.7所示。


■
图3.7卷积计算过程




说明:  

卷积核(Kernel)也被叫作滤波器(Filter),假设卷积核的高和宽分别为kh和kw,则将其称为kh×kw卷积,如3×5卷积就是指卷积核的高为3、宽为5。





(1) 如图3.7(a)所示,
输入数据是一个维度为3×3的二维数组
卷积核一个维度为2×2的二维数组。先将卷积核的左上角与输入数据的左上角(即输入数据的(0, 0)位置)对齐,把卷积核的每个元素与其位置对应的输入数据中的元素相乘,再把所有乘积相加,得到卷积输出的第一个结果为
0×1+1×2+2×4+3×5=25
(2) 如图3.7(b)所示,将卷积核向右滑动,让卷积核左上角与输入数据中的(0,1)位置对齐,同样将卷积核的每个元素与其位置对应的输入数据中的元素相乘,再把这4个乘积相加,得到卷积输出的第二个结果,即
0×2+1×3+2×5+3×6=31
(3) 如图3.7(c)所示,将卷积核向下滑动,让卷积核左上角与输入数据中的(1, 0)位置对齐,可以计算得到卷积输出的第三个结果,即
0×4+1×5+2×7+3×8=43
(4) 如图3.7(d)所示,将卷积核向右滑动,让卷积核左上角与输入数据中的(1, 1)位置对齐,可以计算得到卷积输出的第四个结果,即
0×5+1×6+2×8+3×9=49
卷积核的计算过程可以用下面的数学公式表示,其中a代表输入图片, b代表输出特征图,w是卷积核参数,它们都是二维数组,∑u,v 表示对卷积核参数进行遍历并求和,有
b[i,j]=∑u,va[i+u,j+v]·w[u,v]
举例说明,假如图3.7中卷积核大小是2×2,则u可以取0和1,v也可以取0和1,也就是说
b[i,j]=a[i+0,j+0]·w[0,0]+a[i+0,j+1]·w[0,1]+
a[i+1,j+0]·w[1,0]+a[i+1,j+1]·w[1,1]
读者可以自行验证,当[i,j]取不同值时,根据此公式计算的结果与图3.7中的例子是否一致。
【思考】当卷积核大小为3×3时,b和a之间的对应关系应该是怎样的?




说明:  

在卷积神经网络中,一个卷积算子除了上面描述的卷积过程外,还包括加上偏置项的操作。例如,假设偏置为1,则上面卷积计算的结果为
0×1+1×2+2×4+3×5+1=26

0×2+1×3+2×5+3×6+1=32

0×4+1×5+2×7+3×8+1=44

0×5+1×6+2×8+3×9+1=50





2) 填充
在上面的例子中,输入图片尺寸为3×3,输出图片尺寸为2×2,经过一次卷积之后,图片尺寸变小。卷积输出特征图的尺寸计算方法为
Hout=H-kh+1

Wout=W-kw+1
如果输入尺寸为4,卷积核大小为3时,输出尺寸为4-3+1=2。读者可以自行检查当输入图片和卷积核为其他尺寸时,上述计算式是否成立。当卷积核尺寸大于1时,输出特征图的尺寸会小于输入图片尺寸。如果经过多次卷积,输出图片尺寸会不断减小。为了避免卷积之后图片尺寸变小,通常会在图片的外围进行填充(Padding),如图3.8所示。

■
图3.8填充



(1) 如图3.8(a)所示,填充的大小为1,填充值为0。填充之后,输入图片尺寸从4×4变成了6×6,使用3×3的卷积核,输出图片尺寸为4×4。
(2) 如图3.8(b)所示,填充的大小为2,填充值为0。填充之后,输入图片尺寸从4×4变成了8×8,使用3×3的卷积核,输出图片尺寸为6×6。
如果在图片高度方向,在第一行之前填充ph1行,在最后一行之后填充ph2行;  在图片的宽度方向,在第一列之前填充pw1列,在最后一列之后填充pw2列;  则填充之后的图片尺寸为(H+ph1+ph2)×(W+pw1+pw2)。经过大小为kh×kw的卷积核操作后,输出图片的尺寸为
Hout=H+ph1+ph2-kh+1


Wout=W+pw1+pw2-kw+1
在卷积计算过程中,通常会在高度或者宽度的两侧采取等量填充,即ph1=ph2=ph,pw1=pw2=pw,上面计算公式也就变为
Hout=H+2ph-kh+1


Wout=W+2pw-kw+1

卷积核大小通常使用1、3、5、7这样的奇数,如果使用的填充大小为ph=(kh-1)/2、pw=(kw-1)/2,则卷积之后图像尺寸不变。例如,当卷积核大小为3时,padding大小为1,卷积之后图像尺寸不变;  同理,如果卷积核大小为5,使用padding的大小为2,也能保持图像尺寸不变。
3) 步幅
图3.8中卷积核每次滑动一个像素点,这是步幅为1的特殊情况。图3.9是步幅为2的卷积过程,卷积核在图片上移动时,每次移动大小为2个像素点。

■
图3.9步幅为2的卷积过程 



当宽和高方向的步幅分别为sh和sw时,输出特征图尺寸的计算公式为
Hout=H+2ph-khsh+1

Wout=W+2pw-kwsw+1
假设输入图片尺寸是H×W=100×100,卷积核大小kh×kw=3×3,填充ph=pw=1,步幅为sh=sw=2,则输出特征图的尺寸为
Hout=100+2-32+1=50

Wout=100+2-32+1=50

4) 感受野
输出特征图上每个点的数值,是由输入图片上大小为kh×kw区域的元素与卷积核每个元素相乘再相加得到的,所以输入图像上kh×kw区域内每个元素数值的改变都会影响输出点的像素值。将这个区域叫作输出特征图上对应点的感受野。感受野内每个元素数值的变动,都会影响输出点的数值变化。如3×3卷积对应的感受野大小就是3×3,如图3.10所示。

■
图3.10感受野为3×3的卷积


而当通过两层3×3的卷积之后,感受野的大小将会增加到5×5,如图3.11所示。


■
图3.11感受野为5×5的卷积


因此,当增加卷积网络深度的同时,感受野将会增大,输出特征图中的一个像素点将会包含更多的图像语义信息。

5) 多输入通道、多输出通道和批量操作
前面介绍的卷积计算过程比较简单,实际应用时,处理的问题要复杂得多。例如,对于彩色图片有R、G、B这3个通道,需要处理多输入通道的场景。输出特征图往往也会具有多个通道,而且在神经网络的计算中常常是把一个批次的样本放在一起计算,所以卷积算子需要具有批量处理多输入和多输出通道数据的功能。下面分别介绍这几种场景的操作方式。
(1) 多输入通道场景。
上面的例子中,卷积层的数据是一个二维数组,但实际上一张彩色图片往往含有R、G、B 3个通道,要计算卷积的输出结果,卷积核的形式也会发生变化。假设输入图片的通道数为Cin,输入数据的形状是Cin×Hin×Win,计算过程如图3.12所示。

■
图3.12多输入通道计算过程



① 对每个通道分别设计一个二维数组作为卷积核,卷积核数组的形状是Cin×kh×kw。
② 对任一通道Cin∈[0,Cin),分别用大小为kh×kw的卷积核在大小为Hin×Win的二维数组上做卷积。
③ 将这Cin个通道的计算结果相加,得到的是一个形状为Hout×Wout的二维数组。

(2) 多输出通道场景。
一般来说,卷积操作的输出特征图也会具有多个通道Cout,这时需要设计Cout个维度为Cin×kh×kw的卷积核,卷积核数组的维度是Cout×Cin×kh×kw,如图3.13所示。

■
图3.13多输出通道计算过程


① 对任一输出通道Cout∈[0,Cout),分别使用上面描述的形状为Cin×kh×kw的卷积核对输入图片做卷积。
② 将这Cout个形状为Hout×Wout的二维数组拼接在一起,形成维度为Cout×Hout×Wout的三维数组。




说明:  

通常将卷积核的输出通道数叫作卷积核的个数。






(3) 批量操作。
在卷积神经网络的计算中,通常将
数据集划分成多个minibatch进行批量操作,即输入数据的维度是N×Cin×Hin×Win。由于会对每张图片使用同样的卷积核进行卷积操作,卷积核的维度与上面多输出通道的情况一样,仍然是Cout×Cin×kh×kw,输出特征图的维度是N×Cout×Hout×Wout,如图3.14所示。

■
图3.14批量操作



6) 飞桨卷积API介绍
飞桨卷积算子对应的API是paddle.nn.Conv2D,用户可以直接调用API进行计算,也可以在此基础上修改。Conv2D名称中的“2D”表明卷积核是二维的,多用于处理图像数据。类似地,也有Conv3D可以用于处理视频数据(图像的序列)。



class paddle.nn.Conv2D (in_channels, out_channels, kernel_size, stride=1, padding=0, dilation=1, groups=1, padding_mode=
'zeros', weight_attr=None, bias_attr=None, data_format='NCHW')




常用的参数如下。
① in_channels(int):  输入图像的通道数。
② out_channels(int):  卷积核的个数,和输出特征图通道数相同,相当于上文中的Cout。
③ kernel_size(int|list|tuple): 卷积核大小,可以是整数,如3,表示卷积核的高和宽均为3;  或者是两个整数的list,如[3,2],表示卷积核的高为3、宽为2。
④ stride(int|list|tuple,可选): 步长大小,可以是整数,默认值为1,表示垂直和水平滑动步幅均为1;  或者是两个整数的list,如[3,2],表示垂直滑动步幅为3,水平滑动步幅为2。
⑤ padding(int|list|tuple|str,可选):  填充大小,可以是整数,如1,表示竖直和水平边界填充大小均为1;  或者是两个整数的list,如[2,1],表示竖直边界填充大小为2,水平边界填充大小为1。
输入数据维度为[N,Cin,Hin,Win],输出数据维度为[N,out_channels,Hout,Wout],权重参数w的维度为[out_channels,Cin,filter_size_h,filter_size_w],偏置参数b的维度是[out_channels]。注意,即使输入只有一张灰度图片[Hin,Win],也需要处理成4个维度的输入向量[1,1,Hin,Win]。
7) 卷积算子应用举例
下面介绍卷积算子在图片中应用的3个示例,并观察其计算结果。
(1) 示例1——简单的黑白边界检测。
下面是使用Conv2D算子完成一个图像边界检测的任务。图像左边为光亮部分,右边为黑暗部分,需要检测出光亮与黑暗的分界处。可以设置宽度方向的卷积核为[1,0,-1],此卷积核会将宽度方向间隔为1的两个像素点的数值相减。当卷积核在图片上滑动时,如果它所覆盖的像素点位于亮度相同的区域,则左、右间隔为1的两个像素点数值的差为0。只有当卷积核覆盖的像素点有的处于光亮区域,有的处在黑暗区域时,左、右间隔为1的两个点像素值的差才不为0。将此卷积核作用到图片上,输出特征图上只有对应黑白分界线的地方像素值才不为0。结果输出如图3.15所示。具体代码如下: 



import matplotlib.pyplot as plt

import numpy as np

import paddle

from paddle.nn import Conv2D

from paddle.nn.initializer import Assign

%matplotlib inline



# 创建初始化权重参数w

w = np.array([1, 0, -1], dtype='float32')

# 将权重参数调整成维度为[cout, cin, kh, kw]的四维张量

w = w.reshape([1, 1, 1, 3])

# 创建卷积算子,设置输出通道数、卷积核大小和初始化权重参数

# kernel_size = [1, 3]表示kh = 1, kw=3

# 创建卷积算子的时候,通过参数属性weight_attr指定参数初始化方式

# 这里的初始化方式时,从numpy.ndarray初始化卷积参数

conv = Conv2D(in_channels=1, out_channels=1, kernel_size=[1, 3],

weight_attr=paddle.ParamAttr(

initializer=Assign(value=w)))



# 创建输入图片,图片左边的像素点取值为1,右边的像素点取值为0

img = np.ones([50,50], dtype='float32')


img[:, 30:] = 0.

# 将图片形状调整为[N, C, H, W]的形式

x = img.reshape([1,1,50,50])






# 将numpy.ndarray转化成paddle中的tensor

x = paddle.to_tensor(x)

# 使用卷积算子作用在输入图片上

y = conv(x)

# 将输出tensor转化为numpy.ndarray

out = y.numpy()

f = plt.subplot(121)

f.set_title('input image', fontsize=15)

plt.imshow(img, cmap='gray')

f = plt.subplot(122)

f.set_title('output featuremap', fontsize=15)

# 卷积算子Conv2D输出数据形状为[N, C, H, W]形式

# 此处N, C=1,输出数据形状为[1, 1, H, W],是4维数组

# 但是画图函数plt.imshow画灰度图时,只接受2维数组

# 通过numpy.squeeze函数将大小为1的维度消除

plt.imshow(out.squeeze(), cmap='gray')

plt.show()





■
图3.15简单的黑白边界检测输入结果


(2) 示例2——图像中物体边缘检测。
上面展示的是一个人为构造出来的简单图片使用卷积检测明暗分界处的例子,对于真实的图片,也可以使用合适的卷积核对它进行操作,用来检测物体的外形轮廓,观察输出特征图与原图之间的对应关系(图3.16),代码实现如下:  



import matplotlib.pyplot as plt

from PIL import Image

import numpy as np

import paddle

from paddle.nn import Conv2D

from paddle.nn.initializer import Assign

img = Image.open('./work/images/section1/000000098520.jpg')



# 设置卷积核参数

w = np.array([[-1,-1,-1], [-1,8,-1], [-1,-1,-1]], dtype='float32')/8

w = w.reshape([1, 1, 3, 3])

# 由于输入通道数是3,将卷积核的形状从[1,1,3,3]调整为[1,3,3,3]



w = np.repeat(w, 3, axis=1)

# 创建卷积算子,输出通道数为1,卷积核大小为3×3,

# 并使用上面设置好的数值作为卷积核权重的初始化参数






conv = Conv2D(in_channels=3, out_channels=1, kernel_size=[3, 3], 

weight_attr=paddle.ParamAttr(

initializer=Assign(value=w)))



# 将读入的图片转化为float32类型的numpy.ndarray

x = np.array(img).astype('float32')

# 图片读入成ndarray时,形状是[H, W, 3],

# 将通道这一维度调整到最前面

x = np.transpose(x, (2,0,1))

# 将数据形状调整为[N, C, H, W]格式

x = x.reshape(1, 3, img.height, img.width)

x = paddle.to_tensor(x)

y = conv(x)

out = y.numpy()

plt.figure(figsize=(20, 10))

f = plt.subplot(121)

f.set_title('input image', fontsize=15)

plt.imshow(img)

f = plt.subplot(122)

f.set_title('output feature map', fontsize=15)

plt.imshow(out.squeeze(), cmap='gray')

plt.show()





■
图3.16图像中物体边缘检测输出


(3) 示例3——图像均值模糊。

另一种比较常见的卷积核是用当前像素与它邻域内的像素取平均,这样可以使图像上噪声比较大的点变得更平滑(图3.17),代码如下:  



import paddle

import matplotlib.pyplot as plt

from PIL import Image

import numpy as np

from paddle.nn import Conv2D


from paddle.nn.initializer import Assign

# 读入图片并转成numpy.ndarray

# 换成灰度图






img = Image.open('./work/images/section1/000000355610.jpg').convert('L')

img = np.array(img)



# 创建初始化参数

w = np.ones([1, 1, 5, 5], dtype = 'float32')/25

conv = Conv2D(in_channels=1, out_channels=1, kernel_size=[5, 5], 

weight_attr=paddle.ParamAttr(

initializer=Assign(value=w)))

x = img.astype('float32')

x = x.reshape(1,1,img.shape[0], img.shape[1])

x = paddle.to_tensor(x)

y = conv(x)

out = y.numpy()



plt.figure(figsize=(20, 12))

f = plt.subplot(121)

f.set_title('input image')

plt.imshow(img, cmap='gray')



f = plt.subplot(122)

f.set_title('output feature map')

out = out.squeeze()

plt.imshow(out, cmap='gray')



plt.show()





■
图3.17图像均值模糊


3.2卷积神经网络的几种常用操作
3.2.1概述
第3.1节介绍了卷积的基本操作和计算方法。在实践中,为了进一步提升模型的计算效率和泛化能力,常会使用如下几种方法: 池化、批归一化、ReLU激活函数和暂退法。
3.2.2池化

池化是使用某一位置的相邻输出的总体统计特征代替网络在该位置的输出,其好处是当输入数据做出少量平移时,经过
池化后的大多数输出还能保持不变。比如,当识别一张图像是否是人脸时,需要知道人脸左边有一只眼睛,右边也有一只眼睛,而不需要知道眼睛的精确位置,这时通过池化某一片区域的像素点来得到总体统计特征会很有用。由于池化
后的特征图会变小,如果后面连接的是全连接层,能有效地减小神经元的个数,

节省存储空间并提高计算效率。如图3.18所示,将一个2×2的区域池化成一个像素点。通常有两种方法,即平均池化和最大池化。


■
图3.18平均池化和最大池化



(1) 图3.18(a)所示为平均池化。这里使用大小为2×2的池化窗口,每次移动的步长也为2,对池化窗口内的像素数值取平均,得到相应的输出特征图的像素值。

(2) 图3.18(b)所示为最大池化。对池化窗口覆盖区域内的像素取最大值,得到输出特征图的像素值。
当池化窗口在图片上滑动时,会得到整张输出特征图。池化窗口的大小称为池化大小,用kh×kw表示。在卷积神经网络中用得比较多的是窗口大小为2×2,步长也为2的池化。与卷积核类似,池化窗口在图片上滑动时,每次移动的步长称为步幅,当宽和高方向的移动大小不一样时,分别用sh和sw表示。也可以对需要进行池化的图片进行填充,填充方式与卷积类似,假设在第一行之前填充ph1行,在最后一行后面填充ph2行。在第一列之前填充pw1列,在最后一列之后填充pw2列,则池化层的输出特征图大小为

Hout=H+ph1+ph2-khsh+1


Wout=W+pw1+pw2-kwsw+1

在卷积神经网络中,通常使用2×2大小的池化窗口,步幅也使用2,填充为0,则输出特征图的尺寸为
Hout=H2


Wout=W2

通过这种方式的池化,输出特征图的高和宽都减半,但通道数不会改变。
3.2.3ReLU激活函数
在第2章中,普遍使用Sigmoid函数作激活函数。在神经网络发展的早期,Sigmoid函数用得比较多,而目前用的较多得激活函数是ReLU。这是因为Sigmoid函数在反向传播过程中容易造成梯度的衰减。让我们仔细观察Sigmoid函数的形式就能发现这一问题。

Sigmoid激活函数的定义为
y=11+e-x

ReLU激活函数的定义为
y=0,(x<0)

x,(x≥0)

下面的程序画出了Sigmoid和ReLU函数的曲线图(图3.19):  


■
图3.19Sigmoid与ReLU函数的曲线图





# ReLU和Sigmoid激活函数示意图

import numpy as np

import matplotlib.pyplot as plt

import matplotlib.patches as patches

plt.figure(figsize=(10, 5))



# 创建数据x

x = np.arange(-10, 10, 0.1)



# 计算Sigmoid函数

s = 1.0 / (1 + np.exp(0. - x))



# 计算ReLU函数

y = np.clip(x, a_min=0., a_max=None)



#####################################

# 如下部分为画图代码

f = plt.subplot(121)

plt.plot(x, s, color='r')

currentAxis=plt.gca()






plt.text(-9.0, 0.9, r'$y=Sigmoid(x)$', fontsize=13)

currentAxis.xaxis.set_label_text('x', fontsize=15)

currentAxis.yaxis.set_label_text('y', fontsize=15)



f = plt.subplot(122)

plt.plot(x, y, color='g')

plt.text(-3.0, 9, r'$y=ReLU(x)$', fontsize=13)

currentAxis=plt.gca()

currentAxis.xaxis.set_label_text('x', fontsize=15)

currentAxis.yaxis.set_label_text('y', fontsize=15)



plt.show()




梯度消失现象

在神经网络中,将经过反向传播之后,梯度值衰减到接近于零的现象称为梯度消失现象。
从上面的函数曲线可以看出,当x为较大的正数时,Sigmoid函数数值非常接近于1,函数曲线变得很平滑,在这些区域
Sigmoid函数的导数接近0。当x为较小的负数时,Sigmoid函数值非常接近0,函数曲线也很平滑,在这些区域Sigmoid函数的导数也接近0。只有当x的取值在0附近时,Sigmoid函数的导数才比较大。可以对Sigmoid函数求导数,结果为

dydx=-1(1+e-x)2·d(e-x)dx=12+ex+e-x

从上面的式子可以看出,Sigmoid函数的导数dydx最大值为14。前向传播时,y=Sigmoid(x);  而在反向传播过程中,x的梯度等于y的梯度乘以Sigmoid函数的导数
,即
Lx=Ly·yx

使得x的梯度数值最大也不会超过y的梯度的14。

由于最开始是将神经网络的参数随机初始化的,因此x很有可能取值在数值很大或者很小的区域,这些地方都可能造成
Sigmoid函数的导数接近0,导致x的梯度接近0;  即使x取值在接近0的地方,按上面的分析,经过Sigmoid函数反向传播之后,x的梯度不超过y梯度的14,如果有多层网络使用了Sigmoid激活函数,则比较靠后的那些层梯度将衰减到非常小的值。

ReLU函数则不同,当x<0时,ReLU函数的导数为0。当x≥0时,ReLU函数的导数为1,能够将y的梯度完整地传递给x,而不会引起梯度消失。

3.2.4批归一化

批归一化(Batch Normalization,BatchNorm)是由Ioffe和Szegedy于2015年提出的,已被广泛应用在深度学习中,其目的是对神经网络中间层的输出进行标准化处理,使中间层的输出更加稳定。

通常我们会对神经网络的数据进行标准化处理,处理后的样本数据满足均值为0、方差为1的统计分布,这是因为当输入数据的分布比较固定时,有利于算法的稳定和收敛。对于深度神经网络来说,由于参数是不断更新的,即使输入数据已经做过标准化处理,但是对于比较靠后的那些层,其接收到的输入仍然是剧烈变化的,通常会导致数值不稳定,模型很难收敛。
批归一化能够使神经网络中间层的输出变得更加稳定,并有如下3个优点。

① 使学习快速进行(能够使用较大的学习率)。
② 降低模型对初始值的敏感性。
③ 从一定程度上抑制过拟合。
批归一化主要思路是在训练时以minibatch为单位,对神经元的数值进行归一化,使数据的分布满足均值为0、方差为1。具体计算过程如下。

1)  计算minibatch内样本的均值
μB←1m∑mi=1x(i)
式中: x(i)为minibatch中的第i个样本。
例如,输入minibatch包含3个样本,每个样本有2个特征,分别为

x(1)=(1,2),x(2)=(3,6),x(3)=(5,10)

对每个特征分别计算minibatch内样本的均值,即

μB0=1+3+53=3,μB1=2+6+103=6

则样本均值为

μB=(μB0,μB1)=(3,6)

2)  计算minibatch内样本的方差

σ2B←1m∑mi=1(x(i)-μB)2

上面的计算公式先计算一个批次内样本的均值μB和方差σ2B,然后再对输入数据做归一化,将其调整成均值为0、方差为1的分布。

对于上述给定的输入数据x(1)、x(2)、x(3),可以计算出每个特征对应的方差,即

σ2B0=13·((1-3)2+(3-3)2+(5-3)2)=83


σ2B1=13·((2-6)2+(6-6)2+(10-6)2)=323

则样本方差为
σ2B=(σ2B0,σ2B1)=83,323

3)  计算标准化之后的输出
x^(i)←x(i)-μB(σ2B+ε)

式中: ε为一个微小值(如10-7),其主要作用是为了防止分母为0。

对于上述给定的输入数据x(1)、x(2)、x(3),可以计算出标准化之后的输为
x^(1)=1-383,2-6323=-32,-32


x^(2)=3-383,6-6323=(0,0)


x^(1)=5-383,10-6323=32,32

读者可以自行验证由x^(1)、x^(2)、x^(3)构成的minibatch是否满足均值为0、方差为1的分布。

如果强行限制输出层的分布是标准化的,可能会导致某些特征的丢失,所以在标准化之后,
批归一化会紧接着对数据做缩放和平移,即

yi←γx^i+β

式中: γ和β为可学习的参数,可以赋初始值γ=1、β=0,在训练过程中不断学习调整。
上面列出的是批归一化方法的计算逻辑,下面针对两种类型的输入数据格式分别举例。飞桨支持输入数据的维度大小为2、3、4、5这4种情况,这里给出的是维度大小为2和4的示例。
(1) 示例1当输入数据形状是[N,K]时,一般对应全连接层的输出,示例代码如下:  
这种情况下会分别对K的每一个分量计算N个样本的均值和方差,数据和参数对应如下:  
① 输入x, [N, K]。
② 输出y, [N, K]。
③ 均值μB,[K, ]。
④ 方差σ2B, [K, ]。
⑤ 缩放参数γ, [K, ]。
⑥ 平移参数β, [K, ]。




# 输入数据形状是 [N, K]时的示例

import numpy as np

import paddle

from  paddle.nn import BatchNorm1D

# 创建数据

data = np.array([[1,2,3], [4,5,6], [7,8,9]]).astype('float32')

# 使用BatchNorm1D计算归一化的输出

# 输入数据维度[N, K],num_features等于K

bn = BatchNorm1D(num_features=3)    

x = paddle.to_tensor(data)

y = bn(x)

print('output of BatchNorm1D Layer: \n {}'.format(y.numpy()))







# 使用NumPy计算均值、方差和归一化的输出

# 这里对第0个特征进行验证

a = np.array([1,4,7])

a_mean = a.mean()

a_std = a.std()

b = (a - a_mean) / a_std

print('std {}, mean {}, \n output {}'.format(a_mean, a_std, b))




(2) 示例2当输入数据形状是[N,C,H,W]时, 一般对应卷积层的输出,示例代码如下:  
这种情况下会沿着C这一维度进行展开,分别对每一个通道计算N个样本中总共N×H×W个像素点的均值和方差,数据和参数对应如下。
① 输入x, [N, C, H, W]。
② 输出y, [N, C, H, W]。
③ 均值μB,[C, ]。
④ 方差σ2B, [C, ]。
⑤ 缩放参数γ, [C, ]。
⑥ 平移参数β, [C, ]。



# 输入数据形状是[N, C, H, W]时的batchnorm示例

import numpy as np

import paddle

from paddle.nn import BatchNorm2D



# 设置随机数种子,这样可以保证每次运行结果一致



np.random.seed(100)

# 创建数据

data = np.random.rand(2,3,3,3).astype('float32')

# 使用BatchNorm2D计算归一化的输出

# 输入数据维度[N, C, H, W],num_features等于C

bn = BatchNorm2D(num_features=3)

x = paddle.to_tensor(data)

y = bn(x)

print('input of BatchNorm2D Layer: \n {}'.format(x.numpy()))

print('output of BatchNorm2D Layer: \n {}'.format(y.numpy()))



# 取出data中第0通道的数据

# 使用NumPy计算均值、方差及归一化的输出

a = data[:, 0, :, :]

a_mean = a.mean()

a_std = a.std()

b = (a - a_mean) / a_std

print('channel 0 of input data: \n {}'.format(a))

print('std {}, mean {}, \n output: \n {}'.format(a_mean, a_std, b))






小窍门:  

可能有读者会问:  “批归一化里面不是还要对标准化之后的结果做仿射变换吗,怎么使用NumPy计算的结果与批归一化算子一致?” 这是因为批归一化算子里面自动设置初始值γ=1、β=0,这时仿射变换相当于是恒等变换。在训练过程中这两个参数会不断学习,这时仿射变换就会起作用。





4)  预测时使用批归一化
上面介绍了在训练过程中使用批归一化对一批样本进行归一化的方法,但如果使用同样的方法对需要预测的一批样本进行归一化,则预测结果会出现不确定性。
例如,样本A、样本B作为一批样本计算均值和方差,与样本A、样本C和样本D作为一批样本计算均值和方差,得到的结果一般来说是不同的。那么样本A的预测结果就会变得不确定,这对预测过程来说是不合理的。解决方法是在训练过程中将大量样本的均值和方差保存下来,预测时直接使用保存好的值而不再重新计算。实际上,在批归一化的具体实现中,训练时会计算均值和方差的移动平均值。在飞桨中,默认是采用如下方式计算,即

saved_μB←saved_μB×0.9+μB×(1-0.9)


saved_σ2B←saved_σ2B×0.9+σ2B×(1-0.9)

在训练过程的最开始,将saved_μB和saved_σ2B设置为0,每次输入一批新的样本,计算出μB和σ2B,然后通过上面的公式
在训练过程中不断
更新saved_μB和saved_σ2B,并作为批归一化层的参数保存下来。预测时将会加载参数saved_μB和saved_σ2B,用它们来代替μB和σ2B。

3.2.5暂退法
暂退法(Dropout)是深度学习中一种常用的抑制过拟合的方法,其做法是在神经网络学习过程中,随机删除一部分神经元。训练时,随机选出一部分神经元,将其输出设置为0,这些神经元将不对外传递信号。

图3.20是暂退法示意图,图3.20(a)是完整的神经网络,图3.20(b)是应用了暂退法之后的网络结构。应用暂退法之后,会将标有的神经元从网络中删除,让它们不向后面的层传递信号。在学习过程中,丢弃哪些神经元是随机决定的,因此模型不会过度依赖某些神经元,能一定程度上抑制过拟合。

■
图3.20暂退法示意图



在预测场景时会向前传递所有神经元的信号,可能会引出一个新的问题:  训练时由于部分神经元被随机丢弃了,输出数据的总大小会变小。比如:  计算其L1范数会比不使用暂退法时变小,但是预测时却没有丢弃神经元,这将导致训练和预测时数据的分布不一样。为了解决这个问题,飞桨支持如下两种方法。
1) downgrade_in_infer
训练时以比例r随机丢弃一部分神经元,不向后传递它们的信号;  预测时向后传递所有神经元的信号,但是将每个神经元上的数值乘以(1-r)。
2) upscale_in_train
训练时以比例r随机丢弃一部分神经元,不向后传递它们的信号,但是将那些被保留的神经元上的数值除以(1-r);  预测时向后传递所有神经元的信号,不做任何处理。
在飞桨Dropout API中,通过mode参数来指定用哪种方式对神经元进行操作:  



paddle.nn.Dropout(p=0.5, axis=None, mode="upscale_in_train", name=None




主要参数如下: 
p(float): 将输入节点置为0的概率,即丢弃概率,默认值为0.5。该参数对元素的丢弃概率是针对于每一个元素而言
的,而不是对所有的元素而言。举例说,假设矩阵内有12个数字,经过概率为0.5的dropout未必一定有6个零。

mode(str): 暂退法的实现方式,有“downscale_in_infer”和“upscale_in_train”两种,默认是“upscale_in_train”。



说明:  

不同框架对于dropout的默认处理方式可能不同,读者可以查看飞桨API文档了解详情。




下面这段程序展示了经过暂退法之后输出数据的形式:  



# dropout操作

import paddle

import numpy as np



# 设置随机数种子,这样可以保证每次运行结果一致

np.random.seed(100)

# 创建数据[N, C, H, W],一般对应卷积层的输出

data1 = np.random.rand(2,3,3,3).astype('float32')

# 创建数据[N, K],一般对应全连接层的输出

data2 = np.arange(1,13).reshape([-1, 3]).astype('float32')

# 使用dropout作用在输入数据上

x1 = paddle.to_tensor(data1)

# downgrade_in_infer模式下

drop11 = paddle.nn.Dropout(p = 0.5, mode = 'downscale_in_infer')

droped_train11 = drop11(x1)

# 切换到eval模式。在动态图模式下,使用eval()切换到求值模式,该模式禁用了dropout

drop11.eval()






droped_eval11 = drop11(x1)

# upscale_in_train模式下

drop12 = paddle.nn.Dropout(p = 0.5, mode = 'upscale_in_train')

droped_train12 = drop12(x1)

# 切换到eval模式

drop12.eval()

droped_eval12 = drop12(x1)



x2 = paddle.to_tensor(data2)

drop21 = paddle.nn.Dropout(p = 0.5, mode = 'downscale_in_infer')

droped_train21 = drop21(x2)

# 切换到eval模式

drop21.eval()

droped_eval21 = drop21(x2)

drop22 = paddle.nn.Dropout(p = 0.5, mode = 'upscale_in_train')

droped_train22 = drop22(x2)

# 切换到eval模式

drop22.eval()

droped_eval22 = drop22(x2)




从上述代码的输出可以发现,经过暂退法之后,张量中的某些元素变为0,这就是暂退法实现的功能,通过随机将输入数据的元素置0,消除或减弱了神经元节点间的联合适应性,增强模型的泛化能力。
作业
(1) 计算下面卷积中共有多少次乘法和加法操作:  

输入数据形状是[10,3,224,224],卷积核kh=kw=3,输出通道数为64,步幅stride=1,填充ph=pw=1。则完成该卷积共需要做多少次乘法和加法操作?
(提示:  先看输出一个像素点需要做多少次乘法和加法操作,然后再计算总共需要的操作次数。)
(2) 计算下面网络层的输出数据和参数的形状。
网络结构定义如下面的代码,输入数据形状是[10,3,224,224],请分别计算每一层的输出数据形状以及各层包含的参数形状:  



# 定义 SimpleNet 网络结构

import paddle

from  paddle.nn import Conv2D, MaxPool2D, Linear

import paddle.nn.functional as F



class SimpleNet(paddle.nn.Layer):

def init(self, num_classes=1):

#super(SimpleNet, self).init(name_scope)

self.conv1 = Conv2D(in_channels=3, out_channels=6, kernel_size=5, stride=1, padding=2)

self.max_pool1 = MaxPool2D(kernel_size=2, tride=2)

self.conv2 = Conv2D(in_channels=6, out_channels=16, kernel_size=5, stride=1, padding=2)






self.max_pool2 = MaxPool2D(kernel_size=2, tride=2)

self.fc1 = Linear(in_features=50176, out_features=64)

self.fc2 = Linear(in_features=64, out_features=num_classes)        



def forward(self, x):

x = self.conv1(x)

x = F.relu(x)

x = self.max_pool1(x)

x = self.conv2(x)

x = F.relu(x)

x = self.max_pool2(x)

x = paddle.reshape(x, [x.shape[0], -1])

x = self.fc1(x)

x = F.sigmoid(x)

x = self.fc2(x)

return x




提示,第一层卷积conv1,各项参数为
Cin=3,Cout=6,Kh=Kw=5,ph=pw=2,stride=1
则卷积核权重参数w的形状是[Cout,Cin,Kh,Kw]=[6,3,5,5],个数6×3×5×5=450。
偏置参数b的形状是:  [Cout],偏置参数的个数是6。
输出特征图的大小是:  
Hout=224+2×2-5+1=224,Wout=224+2×2-5+1=224
输出特征图的形状是
[N,Cout,Hout,Wout]=[10,6,224,224]
请将表3.1补充完整。



表3.1数据记录表



名称w形状w参数个数b形状b参数个数输出形状
conv1[6,3,5,5]450[6]6[10, 6, 224, 224]
pool1无无无无[10, 6, 112, 112]
conv2

pool2

fc1

fc2

3.3图像分类
3.3.1概述
图像分类是根据图像的语义信息对不同类别的图像进行区分,是计算机视觉的核心任务,也是物体检测、图像分割、物体跟踪、行为分析、人脸识别等其他高层次视觉任务的基础。图像分类在许多领域都有着广泛的应用,如安防领域的人脸识别和智能视频分析、交通领域的交通场景识别、互联网领域基于内容的图像检索和相册自动归类、医学领域的图像识别等。


本节将基于眼疾分类数据集iChallengePM对图像分类领域的经典卷积神经网络进行剖析,介绍如何应用这些基础模块构建卷积神经网络,解决图像分类问题。涵盖如下卷积神经网络:

(1) LeNet。Yan LeCun等于1998年第一次将卷积神经网络应用到图像分类任务上,在手写数字识别任务上取得了巨大成功。
(2) AlexNet。Alex Krizhevsky等在2012年提出了AlexNet, 并应用在大尺寸图片数据集ImageNet上,获得了2012年ImageNet比赛冠军(ImageNet Large Scale Visual Recognition Challenge,ILSVRC)。
(3) VGG。Simonyan和Zisserman于2014年提出了VGG网络结构,这是当前最流行的卷积神经网络之一,由于其结构简单、应用性极强而深受研究者欢迎。
(4) GoogLeNet。Christian Szegedy等在2014年提出了GoogLeNet,并获得了2014年ImageNet比赛冠军。
(5) ResNet。Kaiming He等在2015年提出了ResNet,通过引入残差模块加深网络层数,在ImagNet数据集上的识别错误率降低到3.6%,超越了人眼识别水平。ResNet的设计思想深刻地影响了后来的深度神经网络的设计。
3.3.2LeNet
LeNet是最早的卷积神经网络之一。1998年,Yan LeCun第一次将LeNet卷积神经网络应用到图像分类上,在手写数字识别任务中取得了巨大成功。LeNet通过连续使用卷积和池化层的组合提取图像特征,其架构如图3.21所示,这里展示的是用于MNIST手写体数字识别任务中的LeNet5模型。


■
图3.21LeNet网络结构更新



(1) 第一模块:  包含卷积核5×5的6通道卷积和池化窗口为2×2的池化。卷积提取图像中包含的特征模式(激活函数使用Sigmoid),图像尺寸从28减小到24。经过池化层可以降低输出特征图对空间位置的敏感性,图像尺寸减到12。

(2) 第二模块:  和第一模块尺寸相同,通道数由6增加为16。卷积操作使图像尺寸减小到8,经过池化后变成4。

(3) 第三模块:  包含卷积核为4×4的120通道卷积。卷积之后的图像尺寸减小到1,但是通道数增加为120。将经过第3次卷积提取到的特征图输入到全连接层。第一个全连接层的输出神经元的个数是64,第二个全连接层的输出神经元个数是分类标签的类别数,对于手写数字识别其大小是10。然后使用Softmax激活函数即可计算出每个类别的预测概率。



提示:  

卷积层的输出特征图如何当作全连接层的输入使用呢?
卷积层的输出数据格式是[N,C,H,W],在输入全连接层时,会自动将数据拉平,也就是对每个样本自动将其转化为长度为K的向量。
其中K=C×H×W,一个minibatch的数据维度变成N×K的二维向量。





1. LeNet在手写数字识别上的应用
LeNet网络构建的实现代码如下:  



# 导入需要的包

import paddle

import numpy as np

from paddle.nn import Conv2D, MaxPool2D, Linear



## 组网

import paddle.nn.functional as F



# 定义 LeNet 网络结构

class LeNet(paddle.nn.Layer):

def init(self, num_classes=1):

super(LeNet, self).init()

# 创建卷积和池化层

# 创建第1个卷积层

self.conv1 = Conv2D(in_channels=1, out_channels=6, kernel_size=5)



self.max_pool1 = MaxPool2D(kernel_size=2, stride=2)

# 尺寸的逻辑:池化层未改变通道数;当前通道数为6

# 创建第2个卷积层

self.conv2 = Conv2D(in_channels=6, out_channels=16, kernel_size=5)

self.max_pool2 = MaxPool2D(kernel_size=2, stride=2)

# 创建第3个卷积层

self.conv3 = Conv2D(in_channels=16, out_channels=120, kernel_size=4)

# 尺寸的逻辑:输入层将数据拉平[B,C,H,W] -> [B,C*H*W]

# 输入size是[28,28],经过3次卷积和两次池化之后,C*H*W等于120

self.fc1 = Linear(in_features=120, out_features=64)

# 创建全连接层,第一个全连接层的输出神经元个数为64, 第二个全连接层输出神经元个

# 数为分类标签的类别数

self.fc2 = Linear(in_features=64, out_features=num_classes)

# 网络的前向计算过程

def forward(self, x):

x = self.conv1(x)

# 每个卷积层使用sigmoid激活函数,后面跟着一个2×2的池化

x = F.sigmoid(x)

x = self.max_pool1(x)

x = F.sigmoid(x)

x = self.conv2(x)

x = self.max_pool2(x)






x = self.conv3(x)

# 尺寸的逻辑:输入层将数据拉平[B,C,H,W] -> [B,C*H*W]

x = paddle.reshape(x, [x.shape[0], -1])

x = self.fc1(x)

x = F.sigmoid(x)

x = self.fc2(x)

return x




飞桨会根据图片数据的尺寸和卷积核大小自动推断中间层数据W和H等
。下面的程序使用随机数作为输入,查看经过LeNet5的每一层作用之后,输出数据的形状。




# 输入数据形状是 [N, 1, H, W]

# 这里用np.random创建一个随机数组作为输入数据

x = np.random.randn(*[3,1,28,28])

x = x.astype('float32')



# 创建LeNet类的实例,指定模型名称和分类的类别数目

m = LeNet(num_classes=10)

# 通过调用LeNet从基类继承的sublayers()函数,查看LeNet中所包含的子层

print(m.sublayers())

x = paddle.to_tensor(x)

for item in m.sublayers():

# item是LeNet类中的一个子层,查看经过子层之后的输出数据形状

try:

x = item(x)

except:

x = paddle.reshape(x, [x.shape[0], -1])

x = item(x)



if len(item.parameters())==2:

# 查看卷积层和全连接层的数据和参数的形状,其中item.parameters()[0]是权重参数w,

# item.parameters()[1]是偏置参数b

print(item.full_name(), x.shape, item.parameters()[0].shape, item.parameters()[1].shape)

else:

# 池化层没有参数

print(item.full_name(), x.shape)





卷积Conv2D的填充大小默认为0,步长默认为1,当输入形状为[B×1×28×28]时,B是batch size,经过第一层卷积(kernel_size=5, out_channels=6)和
最大池化之后,得到形状为[B×6×12×12]的特征图;  经过第二层卷积(kernel_size=5, out_channels=16)和最大池化之后,得到形状为[B×16×4×4]的特征图;  经过第三层卷积(out_channels=120, kernel_size=4)之后,得到形状为[B×120×1×1]的特征图,在全连接层计算之前,将输入特征从卷积得到的四维特征改造到格式为[B, 120×1×1]的特征,这也是LeNet中第一层全连接层输入形状为120的原因。
LeNet5手写数字识别的训练过程代码实现如下:



import os

import random

import paddle






import numpy as np



# 定义训练过程

def train(model):



# 开启0号GPU训练

use_gpu = True

paddle.set_device('gpu:0') if use_gpu else paddle.set_device('cpu')

print('start training ... ')

model.train()

epoch_num = 5

opt = paddle.optimizer.Momentum(learning_rate=0.001, momentum=0.9, parameters=model.parameters())



# 使用飞桨数据读取器


train_loader = paddle.batch(paddle.dataset.mnist.train(), batch_size=10)

valid_loader = paddle.batch(paddle.dataset.mnist.test(), batch_size=10)

for epoch in range(epoch_num):

for batch_id, data in enumerate(train_loader()):

# 调整输入数据形状和类型

x_data = np.array([item[0] for item in data], dtype='float32').reshape(-1, 1, 28, 28)

y_data = np.array([item[1] for item in data], dtype='int64').reshape(-1, 1)

# 将numpy.ndarray转化成Tensor

img = paddle.to_tensor(x_data)

label = paddle.to_tensor(y_data)

# 计算模型输出

logits = model(img)

# 计算损失函数

loss = F.softmax_with_cross_entropy(logits, label)



avg_loss = paddle.mean(loss)



if batch_id % 1000 == 0:

print("epoch: {}, batch_id: {}, loss is: {}".format(epoch, batch_id, avg_loss.numpy()))

avg_loss.backward()

opt.step()

opt.clear_grad()



model.eval()

accuracies = []

losses = []

for batch_id, data in enumerate(valid_loader()):

# 调整输入数据形状和类型

x_data = np.array([item[0] for item in data], dtype='float32').reshape(-1, 1, 28, 28)

y_data = np.array([item[1] for item in data], dtype='int64').reshape(-1, 1)

# 将numpy.ndarray转化成Tensor

img = paddle.to_tensor(x_data)

label = paddle.to_tensor(y_data)

# 计算模型输出

logits = model(img)

pred = F.softmax(logits)






# 计算损失函数

loss = F.softmax_with_cross_entropy(logits, label)

acc = paddle.metric.accuracy(pred, label)

accuracies.append(acc.numpy())

losses.append(loss.numpy())

print("[validation] accuracy/loss: {}/{}".format(np.mean(accuracies), np.mean(losses)))

model.train()



# 保存模型参数

paddle.save(model.state_dict(), 'mnist.pdparams')

# 创建模型

model = LeNet(num_classes=10)

# 启动训练过程

train(model)




通过运行结果可以看出,LeNet在手写数字识别MNIST验证数据集上的准确率高达92%以上,那么对于其他数据集效果如何呢?下面通过眼疾识别数据集iChallengePM验证一下。
2. LeNet在眼疾识别数据集iChallengePM上的应用

iChallengePM是百度大脑和中山大学中山眼科中心联合举办的iChallenge比赛中,提供的关于病理性近视(Pathologic Myopia,PM)的医疗类数据集,包含1200个受试者的眼底视网膜图片,训练集、验证集和测试集数据各400张。下面详细介绍LeNet在iChallengePM上的训练过程。




说明:  

如今近视已经成为困扰人们健康的一项全球性负担,在近视人群中,有超过35%的人患有重度近视。近视将会导致眼睛的光轴被拉长,有可能引起视网膜或者络网膜的病变。随着近视度数的不断加深,高度近视有可能引发病理性病变,这将会导致视网膜或者络网膜发生退化、视盘区域萎缩、漆裂样纹损害、Fuchs斑等症状。因此,及早发现近视患者眼睛的病变并采取治疗手段,显得非常重要。





1) 数据集准备
iChallengePM数据集包括如下3个文件。

(1) training.zip:  训练集的图片和标签。
(2) validation.zip:  验证集的图片。
(3) valid_gt.zip:  验证集的标签。

2) 查看数据集图片
iChallengePM中既有病理性近视患者的眼底图片,也有非病理性近视患者的图片,命名规则如下。
(1) 病理性近视(PM):  文件名以P开头。
(2) 非病理性近视(nonPM):  
① 高度近视(high myopia),文件名以H开头; 
② 正常眼睛(normal),文件名以N开头。
将病理性患者的图片作为正样本,标签为1;   非病理性患者的图片作为负样本,标签为0。从数据集中选取两张图片,通过LeNet提取特征,构建分类器,对正负样本进行分类,并将图片显示出来(图3.22)。代码实现如下:  




import os

import numpy as np

import matplotlib.pyplot as plt

%matplotlib inline

from PIL import Image

# 数据存储路径可根据实际路径更新

DATADIR = '/home/aistudio/work/palm/PALM-Training400/PALM-Training400'

# 文件名以N开头的是正常眼底图片,以P开头的是病变眼底图片

file1 = 'N0012.jpg'

file2 = 'P0095.jpg'



# 读取图片

img1 = Image.open(os.path.join(DATADIR, file1))

img1 = np.array(img1)

img2 = Image.open(os.path.join(DATADIR, file2))

img2 = np.array(img2)



# 画出读取的图片1


plt.figure(figsize=(16, 8))



f = plt.subplot(121)

f.set_title('Normal', fontsize=20)

plt.imshow(img1)

f = plt.subplot(122)

f.set_title('PM', fontsize=20)

plt.imshow(img2)

plt.show()





■
图3.22程序执行结果


3) 定义数据读取器
使用OpenCV从磁盘读入图片,将每张图缩放到224×224,并且将像素值调整到[-1,1]之间,代码如下:  



import cv2

import random

import numpy as np

import os



# 对读入的图像数据进行预处理

def transform_img(img):

# 将图片尺寸缩放到 224x224

img = cv2.resize(img, (224, 224))

# 读入的图像数据格式是[H, W, C]

# 使用转置操作将其变成[C, H, W]

img = np.transpose(img, (2,0,1))

img = img.astype('float32')

# 将数据范围调整到[-1.0, 1.0]之间

img = img / 255.

img = img * 2.0 - 1.0

return img



# 定义训练集数据读取器

def data_loader(datadir, batch_size=10, mode = 'train'):



# 将datadir目录下的文件列出来,每个文件都要读入

filenames = os.listdir(datadir)

def reader():

if mode == 'train':

# 训练时随机打乱数据顺序

random.shuffle(filenames)

batch_imgs = []

batch_labels = []

for name in filenames:

filepath = os.path.join(datadir, name)

img = cv2.imread(filepath)

img = transform_img(img)

if name[0] == 'H' or name[0] == 'N':

# H开头的文件名表示高度近视,N开头的文件名表示正常视力

# 高度近视和正常视力的样本都不是病理性的,属于负样本,标签为0

label = 0

elif name[0] == 'P':

# P开头的是病理性近视,属于正样本,标签为1

label = 1

else:

raise('Not excepted file name')

# 每读取一个样本的数据,就将其放入数据列表中

batch_imgs.append(img)

batch_labels.append(label)

if len(batch_imgs) == batch_size:

# 当数据列表的长度等于batch_size时,把这些数据当作一个minibatch,并作

# 为数据生成器的一个输出

imgs_array = np.array(batch_imgs).astype('float32')






labels_array = np.array(batch_labels).astype('float32').reshape(-1, 1)

yield imgs_array, labels_array

batch_imgs = []

batch_labels = []



if len(batch_imgs) > 0:

# 剩余样本数目不足一个batch_size的数据,一起打包成一个minibatch

imgs_array = np.array(batch_imgs).astype('float32')

labels_array = np.array(batch_labels).astype('float32').reshape(-1, 1)

yield imgs_array, labels_array



return reader



# 定义验证集数据读取器

def valid_data_loader(datadir, csvfile, batch_size=10, mode='valid'):

# 训练集读取时通过文件名来确定样本标签,验证集则通过csvfile来读取每个图片对应的
标签

# 请查看解压后的验证集标签数据,观察csvfile文件里面所包含的内容

# csvfile文件所包含的内容格式如下,每一行代表一个样本,其中第一列是图片id,第二列是

# 文件名,第三列是图片标签,第四列和第五列是Fovea的坐标,与分类任务无关

# ID,imgName,Label,Fovea_X,Fovea_Y

# 1,V0001.jpg,0,1157.74,1019.87

# 2,V0002.jpg,1,1285.82,1080.47

# 打开包含验证集标签的csvfile,并读入其中的内容

filelists = open(csvfile).readlines()



def reader():

batch_imgs = []

batch_labels = []

for line in filelists[1:]:

line = line.strip().split(',')

name = line[1]

label = int(line[2])

# 根据图片文件名加载图片,并对图像数据作预处理

filepath = os.path.join(datadir, name)

img = cv2.imread(filepath)

img = transform_img(img)

# 每读取一个样本的数据,就将其放入数据列表中

batch_imgs.append(img)

batch_labels.append(label)

if len(batch_imgs) == batch_size:

# 当数据列表的长度等于batch_size时,把这些数据当作一个mini-batch,并

# 作为数据生成器的一个输出

imgs_array = np.array(batch_imgs).astype('float32')

labels_array = np.array(batch_labels).astype('float32').reshape(-1, 1)

yield imgs_array, labels_array

batch_imgs = []

batch_labels = []



if len(batch_imgs) > 0:

# 剩余样本数目不足一个batch_size的数据,一起打包成一个mini-batch

imgs_array = np.array(batch_imgs).astype('float32')

labels_array = np.array(batch_labels).astype('float32').reshape(-1, 1)

yield imgs_array, labels_array



return reader




4) 启动训练
定义训练过程,代码如下: 



import os

import random

import paddle

import numpy as np



DATADIR = '/home/aistudio/work/palm/PALM-Training400/PALM-Training400'

DATADIR2 = '/home/aistudio/work/palm/PALM-Validation400'

CSVFILE = '/home/aistudio/labels.csv'




定义训练过程



def train_pm(model, optimizer):

# 开启0号GPU训练

use_gpu = True

paddle.set_device('gpu:0') if use_gpu else paddle.set_device('cpu')



print('start training ... ')

model.train()

epoch_num = 5

# 定义数据读取器,训练数据读取器和验证数据读取器

train_loader = data_loader(DATADIR, batch_size=10, mode='train')

valid_loader = valid_data_loader(DATADIR2, CSVFILE)

for epoch in range(epoch_num):

for batch_id, data in enumerate(train_loader()):

x_data, y_data = data

img = paddle.to_tensor(x_data)

label = paddle.to_tensor(y_data)

# 运行模型前向计算,得到预测值

logits = model(img)

loss = F.binary_cross_entropy_with_logits(logits, label)

avg_loss = paddle.mean(loss)



if batch_id % 10 == 0:

print("epoch: {}, batch_id: {}, loss is: {}".format(epoch, batch_id, avg_loss.numpy()))

# 反向传播,更新权重,清除梯度

avg_loss.backward()

optimizer.step()

optimizer.clear_grad()



model.eval()

accuracies = []

losses = []

for batch_id, data in enumerate(valid_loader()):

x_data, y_data = data

img = paddle.to_tensor(x_data)

label = paddle.to_tensor(y_data)

# 运行模型前向计算,得到预测值

logits = model(img)






# 二分类,Sigmoid计算后的结果以0.5为阈值分两个类别

# 计算Sigmoid后的预测概率,进行loss计算

pred = F.sigmoid(logits)

loss = F.binary_cross_entropy_with_logits(logits, label)

# 计算预测概率小于0.5的类别

pred2 = pred * (-1.0) + 1.0

# 得到两个类别的预测概率,并沿第一个维度级联


pred = paddle.concat([pred2, pred], axis=1)

acc = paddle.metric.accuracy(pred, paddle.cast(label, dtype='int64'))



accuracies.append(acc.numpy())

losses.append(loss.numpy())

print("[validation] accuracy/loss: {}/{}".format(np.mean(accuracies), np.mean(losses)))

model.train()



paddle.save(model.state_dict(), 'palm.pdparams')

paddle.save(optimizer.state_dict(), 'palm.pdopt')




定义评估过程,代码如下: 



def evaluation(model, params_file_path):



# 开启0号GPU预估

use_gpu = True

paddle.set_device('gpu:0') if use_gpu else paddle.set_device('cpu')



#加载模型参数

model_state_dict = paddle.load(params_file_path)

model.load_dict(model_state_dict)



model.eval()

eval_loader = data_loader(DATADIR, 

batch_size=10, mode='eval')



acc_set = []

avg_loss_set = []

for batch_id, data in enumerate(eval_loader()):

x_data, y_data = data

img = paddle.to_tensor(x_data)

label = paddle.to_tensor(y_data)

y_data = y_data.astype(np.int64)

label_64 = paddle.to_tensor(y_data)

# 计算预测和准确率

prediction, acc = model(img, label_64)

# 计算损失函数值

loss = F.binary_cross_entropy_with_logits(prediction, label)

avg_loss = paddle.mean(loss)

acc_set.append(float(acc.numpy()))

avg_loss_set.append(float(avg_loss.numpy()))

# 求平均准确率

acc_val_mean = np.array(acc_set).mean()






avg_loss_val_mean = np.array(avg_loss_set).mean()



print('loss={}, acc={}'.format(avg_loss_val_mean, acc_val_mean))




网络训练,代码如下:  



import paddle

import numpy as np

from paddle.nn import Conv2D, MaxPool2D, Linear, Dropout

import paddle.nn.functional as F



# 定义 LeNet 网络结构

class LeNet(paddle.nn.Layer):

def init(self, num_classes=1):

super(LeNet, self).init()



# 创建卷积和池化层块,每个卷积层使用sigmoid激活函数,后面跟着一个2×2的池化

self.conv1 = Conv2D(in_channels=3, out_channels=6, kernel_size=5)

self.max_pool1 = MaxPool2D(kernel_size=2, stride=2)

self.conv2 = Conv2D(in_channels=6, out_channels=16, kernel_size=5)

self.max_pool2 = MaxPool2D(kernel_size=2, stride=2)

# 创建第3个卷积层

self.conv3 = Conv2D(in_channels=16, out_channels=120, kernel_size=4)

# 创建全连接层,第一个全连接层的输出神经元个数为64

self.fc1 = Linear(in_features=300000, out_features=64)

# 第二个全连接层输出神经元个数为分类标签的类别数

self.fc2 = Linear(in_features=64, out_features=num_classes)



# 网络的前向计算过程

def forward(self, x, label=None):

x = self.conv1(x)

x = F.sigmoid(x)

x = self.max_pool1(x)

x = self.conv2(x)

x = F.sigmoid(x)

x = self.max_pool2(x)

x = self.conv3(x)

x = F.sigmoid(x)

x = paddle.reshape(x, [x.shape[0], -1])

x = self.fc1(x)

x = F.sigmoid(x)

x = self.fc2(x)

if label is not None:

acc = paddle.metric.accuracy(input=x, label=label)

return x, acc

else:

return x




对比本章最初定义的LeNet,发现两个LeNet的第一个全连接层的输入特征维度不同,一个是120,另一个是30000,这是由输入数据的形状不同引起的。手写数字识别的图像输入形状比较小,第三层卷积之前的特征维度是[B, 120×1×1],但是眼疾识别数据的输入数据形状较大,形状为[B, 120×50×50]。因此,不同的输入大小会影响卷积后全连接层的形状。




# 创建模型

model = LeNet(num_classes=1)

# 启动训练过程



opt = paddle.optimizer.Momentum(learning_rate=0.001, momentum=0.9, parameters=model.parameters())

train_pm(model, optimizer=opt)

evaluation(model, params_file_path="palm.pdparams")




从运行结果可以看出,在眼疾筛查数据集iChallengePM上,LeNet的Loss很难下降,模型没有收敛。这是因为MNIST数据集的图片尺寸比较小(28×28),但是眼疾识别数据集图片尺寸比较大(原始图片尺寸约为2000×2000,经过缩放之后变成224×224),LeNet模型很难进行有效分类。这说明在图片尺寸比较大时,LeNet在图像分类任务上存在局限性。
3.3.3AlexNet

通过上面的实际训练可以看到,虽然LeNet在手写数字识别数据集上取得了很好的结果,但在更大的数据集上表现却并不好。自从1998年LeNet问世以来,接下来10几年的时间里,神经网络并没有在计算机视觉领域取得很好的结果,反而一度被其他算法所超越,原因主要有两方面: 一是神经网络的计算比较复杂,对当时计算机的算力来说,训练神经网络是件非常耗时的事情;  
二是当时还没有专门针对神经网络做算法和训练技巧的优化,神经网络的收敛是件非常困难的事情。

随着技术的进步和发展,计算机的算力越来越强大,尤其是在GPU并行计算能力的推动下,复杂神经网络的计算也变得更加容易实施。另外,互联网上涌现出越来越多的数据,极大地丰富了数据库。同时也有越来越多的研究人员开始专门针对神经网络做算法和模型的优化,Alex Krizhevsky等提出的AlexNet以很大优势获得了2012年ImageNet比赛的冠军。这一成果极大地激发了产业界对神经网络的兴趣,开创了使用深度神经网络解决图像问题的途径,随后也在这一领域涌现出越来越多的优秀成果。
AlexNet与LeNet相比,具有更深的网络结构,包含5层卷积和3层全连接,同时使用了如下3种方法改进模型的训练过程。

(1) 数据增强。数据增强是深度学习任务中常见的优化方法,通过对原始数据
随机加一些变化,如平移、缩放、裁剪、旋转、翻转或者增减亮度等,产生一系列与原始图片相似但又不完全相同的样本,从而扩大训练数据集。通过这种方式,可以随机改变训练样本,避免模型过度依赖于某些属性,能从一定程度上抑制过拟合。

(2) 使用暂退法抑制过拟合。
(3) 使用ReLU激活函数,减少梯度消失现象。
AlexNet网络结构如图3.23所示。

■
图3.23AlexNet网络结构示意图


AlexNet在眼疾筛查数据集iChallengePM上具体实现的代码如下:  



import paddle

import numpy as np

from paddle.nn import Conv2D, MaxPool2D, Linear, Dropout

## 组网

import paddle.nn.functional as F



# 定义 AlexNet 网络结构

class AlexNet(paddle.nn.Layer):

def init(self, num_classes=1):

super(AlexNet, self).init()

# AlexNet与LeNet一样也会同时使用卷积层和池化层提取图像特征

# 与LeNet不同的是激活函数换成了‘relu’

self.conv1 = Conv2D(in_channels=3, out_channels=96, kernel_size=11, stride=4, padding=5)

self.max_pool1 = MaxPool2D(kernel_size=2, stride=2)

self.conv2 = Conv2D(in_channels=96, out_channels=256, kernel_size=5, stride=1, padding=2)

self.max_pool2 = MaxPool2D(kernel_size=2, stride=2)

self.conv3 = Conv2D(in_channels=256, out_channels=384, kernel_size=3, stride=1, padding=1)

self.conv4 = Conv2D(in_channels=384, out_channels=384, kernel_size=3, stride=1, padding=1)

self.conv5 = Conv2D(in_channels=384, out_channels=256, kernel_size=3, stride=1, padding=1)

self.max_pool5 = MaxPool2D(kernel_size=2, stride=2)



self.fc1 = Linear(in_features=12544, out_features=4096)

self.drop_ratio1 = 0.5

self.drop1 = Dropout(self.drop_ratio1)

self.fc2 = Linear(in_features=4096, out_features=4096)

self.drop_ratio2 = 0.5

self.drop2 = Dropout(self.drop_ratio2)

self.fc3 = Linear(in_features=4096, out_features=num_classes)

# 定义前向计算

def forward(self, x):

x = self.conv1(x)

x = F.relu(x)

x = self.max_pool1(x)

x = self.conv2(x)

x = F.relu(x)

x = self.max_pool2(x)

x = self.conv3(x)

x = F.relu(x)






x = self.conv4(x)

x = F.relu(x)

x = self.conv5(x)

x = F.relu(x)

x = self.max_pool5(x)

x = paddle.reshape(x, [x.shape[0], -1])

x = self.fc1(x)

x = F.relu(x)


# 在全连接之后使用dropout抑制过拟合

x = self.drop1(x)

x = self.fc2(x)

x = F.relu(x)

# 在全连接之后使用dropout抑制过拟合

x = self.drop2(x)

x = self.fc3(x)

return x




启动模型训练,代码如下: 



# 创建模型

model = AlexNet()

# 启动训练过程

opt = paddle.optimizer.Adam(learning_rate=0.001, parameters=model.parameters())

train_pm(model, optimizer=opt)




从运行结果可以发现,Loss能有效下降,经过5个回合的训练,在验证集上的准确率可以达到94%左右。
3.3.4VGG

VGG是当前最流行的CNN模型之一,2014年由Simonyan和Zisserman提出,其命名来源于论文作者所在的实验室Visual Geometry Group。AlexNet模型通过构造多层网络,取得了较好的效果,但是并没有给出深度神经网络设计的方向。VGG通过使用一系列大小为3×3的小尺寸卷积核和池化层构造深度卷积神经网络,并取得了较好的效果。VGG模型因为结构简单、应用性极强而广受研究者欢迎,尤其是它的网络结构设计方法,为构建深度神经网络提供了方向。

图3.24所示为VGG16的网络结构示意图,有13层卷积和3层全连接层。VGG网络的设计严格使用3×3的卷积层和池化层来提取特征,并在网络的最后面使用3层全连接层,将最后一层全连接层的输出作为分类的预测。在VGG中每层卷积将使用ReLU作为激活函数,在全连接层之后
添加dropout来抑制过拟合。使用小的卷积核能够有效地减少参数的数量,使得训练和测试变得更
加高效。比如: 使用两层3×3卷积层,可以得到感受野大小为5×5的特征图,而比使用卷积核大小为5×5的卷积层需要更少的参数。由于卷积核比较小,可以堆叠更多的卷积层,加深网络的深度,这对于图像分类任务来说是有利的。VGG模型的成功证明了增加网络的深度,可以更好地学习图像中的特征模式。

■
图3.24VGG网络结构示意图

VGG在眼疾识别数据集iChallengePM上的具体实现代码如下:  



import numpy as np

import paddle

from paddle.nn import Conv2D, MaxPool2D, BatchNorm2D, Linear



# 定义vgg网络

class VGG(paddle.nn.Layer):

def init(self):

super(VGG, self).init()



in_channels = [3, 64, 128, 256, 512, 512]

# 定义第一个卷积块,包含两个卷积

self.conv1_1 = Conv2D(in_channels=in_channels[0], out_channels=in_channels[1], kernel_size=3, padding=1, stride=1)

self.conv1_2 = Conv2D(in_channels=in_channels[1], out_channels=in_channels[1], kernel_size=3, padding=1, stride=1)

# 定义第二个卷积块,包含两个卷积

self.conv2_1 = Conv2D(in_channels=in_channels[1], out_channels=in_channels[2], kernel_size=3, padding=1, stride=1)

self.conv2_2 = Conv2D(in_channels=in_channels[2], out_channels=in_channels[2], kernel_size=3, padding=1, stride=1)

# 定义第三个卷积块,包含3个卷积

self.conv3_1 = Conv2D(in_channels=in_channels[2], out_channels=in_channels[3], kernel_size=3, padding=1, stride=1)

self.conv3_2 = Conv2D(in_channels=in_channels[3], out_channels=in_channels[3], kernel_size=3, padding=1, stride=1)

self.conv3_3 = Conv2D(in_channels=in_channels[3], out_channels=in_channels[3], kernel_size=3, padding=1, stride=1)

# 定义第四个卷积块,包含3个卷积

self.conv4_1 = Conv2D(in_channels=in_channels[3], out_channels=in_channels[4], kernel_size=3, padding=1, stride=1)

self.conv4_2 = Conv2D(in_channels=in_channels[4], out_channels=in_channels[4], kernel_size=3, padding=1, stride=1)

self.conv4_3 = Conv2D(in_channels=in_channels[4], out_channels=in_channels[4], kernel_size=3, padding=1, stride=1)

# 定义第五个卷积块,包含3个卷积

self.conv5_1 = Conv2D(in_channels=in_channels[4], out_channels=in_channels[5], kernel_size=3, padding=1, stride=1)

self.conv5_2 = Conv2D(in_channels=in_channels[5], out_channels=in_channels[5], kernel_size=3, padding=1, stride=1)






self.conv5_3 = Conv2D(in_channels=in_channels[5], out_channels=in_channels[5], kernel_size=3, padding=1, stride=1)



# 使用Sequential 将全连接层和relu组成一个线性结构(fc + relu)

# 当输入为224×224时,经过5个卷积块和池化层后,特征维度变为[512×7×7]

self.fc1 = paddle.nn.Sequential(paddle.nn.Linear(512 * 7 * 7, 4096), paddle.nn.ReLU())

self.drop1_ratio = 0.5

self.dropout1 = paddle.nn.Dropout(self.drop1_ratio, mode='upscale_in_train')



# 使用Sequential 将全连接层和relu组成一个线性结构(fc + relu)

self.fc2 = paddle.nn.Sequential(paddle.nn.Linear(4096, 4096), paddle.nn.ReLU())



self.drop2_ratio = 0.5

self.dropout2 = paddle.nn.Dropout(self.drop2_ratio, mode='upscale_in_train')

self.fc3 = paddle.nn.Linear(4096, 1)



self.relu = paddle.nn.ReLU()

self.pool = MaxPool2D(stride=2, kernel_size=2)



def forward(self, x):

x = self.relu(self.conv1_1(x))

x = self.relu(self.conv1_2(x))

x = self.pool(x)



x = self.relu(self.conv2_1(x))

x = self.relu(self.conv2_2(x))

x = self.pool(x)



x = self.relu(self.conv3_1(x))

x = self.relu(self.conv3_2(x))

x = self.relu(self.conv3_3(x))

x = self.pool(x)



x = self.relu(self.conv4_1(x))

x = self.relu(self.conv4_2(x))

x = self.relu(self.conv4_3(x))

x = self.pool(x)



x = self.relu(self.conv5_1(x))

x = self.relu(self.conv5_2(x))

x = self.relu(self.conv5_3(x))

x = self.pool(x)



x = paddle.flatten(x, 1, -1)

x = self.dropout1(self.relu(self.fc1(x)))

x = self.dropout2(self.relu(self.fc2(x)))

x = self.fc3(x)

return x




启动模型训练,代码如下: 



# 创建模型

model = VGG()






opt = paddle.optimizer.Momentum(learning_rate=0.001, momentum=0.9, parameters=model.parameters())



# 启动训练过程

train_pm(model, opt)




从运行结果可以发现,Loss能有效地下降,经过5个回合的训练,在验证集上的准确率可以达到94%左右。

3.3.5GoogLeNet

GoogLeNet是2014年ImageNet比赛的冠军,它的主要特点是网络不仅有深度,还在横向上具有“宽度”。由于图像信息在空间尺寸上的巨大差异,如何选择合适的卷积核大小来提取特征就显得比较困难了。空间分布范围更广的图像信息适合用较大的卷积核来提取其特征,而空间分布范围较小的图像信息则适合用较小的卷积核来提取其特征。为了解决这个问题,GoogLeNet提出了一种被称为Inception模块的方案,如图3.25所示。




说明:  

① Google的研究人员为了向LeNet致敬,特地将模型命名为GoogLeNet。
② Inception一词来源于电影《盗梦空间》(Inception)。





■
图3.25Inception模块结构示意图



图3.25(a)是Inception模块的基本设计思想,使用3个不同大小的卷积核对输入图片进行卷积操作,并
使用最大池化,将这4个操作的输出沿着通道这一维度进行拼接,构成的输出特征图像将会包含经过不同大小的卷积核提取出来的特征。Inception模块采用多通路(MultiPath)的设计形式,每个支路使用不同大小的卷积核,最终输出特征图像的通道数是每个支路输出通道数的总和,这将会导致输出通道数变得很大,尤其是使用多个Inception模块串联操作时,模型参数量会变得非常巨大。为了减小参数量,Inception模块使用了图3.25(b)中的设计方式,在每个
卷积核大小为
3×3和5×5的卷积层之前,增加卷积核大小为1×1的卷积层来控制输出通道数;  在最大池化层后面增加1×1卷积层以减小输出通道数。下面这段程序是Inception模块的具体实现方式,可以对照图3.25(b)和代码一起阅读。



提示:  

可能有读者会问,经过3×3的最大池化之后图像尺寸不会减小吗?为什么还能与另外3个卷积输出的特征图像进行拼接?这是因为池化操作可以指定窗口大小Kh=Kw=3,pool_stride=1和pool_padding=1,输出特征图尺寸可以保持不变。




Inception模块的具体实现代码如下:  



import numpy as np

import paddle

from paddle.nn import Conv2D, MaxPool2D, AdaptiveAvgPool2D, Linear

## 组网

import paddle.nn.functional as F



# 定义Inception块

class Inception(paddle.nn.Layer):

def init(self, c0, c1, c2, c3, c4, **kwargs):

'''

Inception模块的实现代码,



c1,图3.25(b)中第一条支路1×1卷积的输出通道数,数据类型是整数

c2,图3.25(b)中第二条支路卷积的输出通道数,数据类型是tuple或list 

其中c2[0]是1×1卷积的输出通道数,c2[1]是3×3

c3,图3.25(b)中第三条支路卷积的输出通道数,数据类型是tuple或list

其中c3[0]是1×1卷积的输出通道数,c3[1]是5×5

c4,图3.25(b)中第四条支路卷积的输出通道数,其中c4[0]是3×3池化的输出通道数,c4[1]是1×1


'''

super(Inception, self).init()

# 依次创建Inception模块每条支路上使用的操作

self.p1_1 = Conv2D(in_channels=c0,out_channels=c1, kernel_size=1)

self.p2_1 = Conv2D(in_channels=c0,out_channels=c2[0], kernel_size=1)

self.p2_2 = Conv2D(in_channels=c2[0],out_channels=c2[1], kernel_size=3, padding=1)

self.p3_1 = Conv2D(in_channels=c0,out_channels=c3[0], kernel_size=1)

self.p3_2 = Conv2D(in_channels=c3[0],out_channels=c3[1], kernel_size=5, padding=2)

self.p4_1 = MaxPool2D(kernel_size=3, stride=1, padding=1)

self.p4_2 = Conv2D(in_channels=c0,out_channels=c4, kernel_size=1)



def forward(self, x):

# 支路1只包含一个1×1卷积

p1 = F.relu(self.p1_1(x))

# 支路2包含 1×1卷积 + 3×3卷积

p2 = F.relu(self.p2_2(F.relu(self.p2_1(x))))

# 支路3包含 1×1卷积 + 5×5卷积

p3 = F.relu(self.p3_2(F.relu(self.p3_1(x))))

# 支路4包含 最大池化和1×1卷积



p4 = F.relu(self.p4_2(self.p4_1(x)))

# 将每个支路的输出特征图拼接在一起作为最终的输出结果

return paddle.concat([p1, p2, p3, p4], axis=1)





GoogLeNet的网络结构如图3.26所示,在主体卷积部分中使用5个模块
(Block),每个模块之间使用步幅为2,池化窗口大小为3×3的最大池化层来减小输出高宽。

■
图3.26GoogLeNet模型网络结构示意图


(1) 第一模块使用一个64通道的卷积核大小为7×7的卷积层。

(2) 第二模块使用2个卷积层:首先是64通道的卷积核大小为1×1的卷积层,然后是将通道增大3倍的卷积核大小为3×3的卷积层。

(3) 第三模块串联2个完整的Inception模块。
(4) 第四模块串联了5个Inception模块。
(5) 第五模块串联了2 个Inception模块。
(6) 第六模块的前面紧跟输出层,使用全局平均池化层来将每个通道的高和宽变成1,最后接上一个输出个数为标签类别数的全连接层。




说明:  

本书在原作者的论文中增加了图3.26所示的Softmax 1和Softmax 2两个辅助分类器,训练时将3个分类器的损失函数进行加权求和,以缓解梯度消失现象。这里的程序作了简化,没有加入辅助分类器。





GoogLeNet的具体实现代码如下:  



import numpy as np

import paddle

from paddle.nn import Conv2D, MaxPool2D, AdaptiveAvgPool2D, Linear

## 组网

import paddle.nn.functional as F



# 定义Inception块

class Inception(paddle.nn.Layer):

def init(self, c0, c1, c2, c3, c4, **kwargs):

super(Inception, self).init()

# 依次创建Inception块每条支路上使用的操作

self.p1_1 = Conv2D(in_channels=c0,out_channels=c1, kernel_size=1, stride=1)

self.p2_1 = Conv2D(in_channels=c0,out_channels=c2[0], kernel_size=1, stride=1)

self.p2_2 = Conv2D(in_channels=c2[0],out_channels=c2[1], kernel_size=3, padding=1, stride=1)

self.p3_1 = Conv2D(in_channels=c0,out_channels=c3[0], kernel_size=1, stride=1)

self.p3_2 = Conv2D(in_channels=c3[0],out_channels=c3[1], kernel_size=5, padding=2, stride=1)

self.p4_1 = MaxPool2D(kernel_size=3, stride=1, padding=1)

self.p4_2 = Conv2D(in_channels=c0,out_channels=c4, kernel_size=1, stride=1)

 

def forward(self, x):

# 支路1只包含一个1x1卷积

p1 = F.relu(self.p1_1(x))

# 支路2包含 1x1卷积 + 3x3卷积

p2 = F.relu(self.p2_2(F.relu(self.p2_1(x))))

# 支路3包含 1x1卷积 + 5x5卷积

p3 = F.relu(self.p3_2(F.relu(self.p3_1(x))))

# 支路4包含 最大池化和1x1卷积

p4 = F.relu(self.p4_2(self.p4_1(x)))

# 将每个支路的输出特征图拼接在一起作为最终的输出结果

return paddle.concat([p1, p2, p3, p4], axis=1)



class GoogLeNet(paddle.nn.Layer):

def init(self):

super(GoogLeNet, self).init()

# GoogLeNet包含5个模块,每个模块后面紧跟一个池化层

# 第一个模块包含1个卷积层

self.conv1 = Conv2D(in_channels=3,out_channels=64, kernel_size=7, padding=3, stride=1)

# 3x3最大池化



self.pool1 = MaxPool2D(kernel_size=3, stride=2, padding=1)

# 第二个模块包含2个卷积层

self.conv2_1 = Conv2D(in_channels=64,out_channels=64, kernel_size=1, stride=1)

self.conv2_2 = Conv2D(in_channels=64,out_channels=192, kernel_size=3, padding=1, stride=1)

# 3x3最大池化

self.pool2 = MaxPool2D(kernel_size=3, stride=2, padding=1)

# 第三个模块包含2个Inception块

self.block3_1 = Inception(192, 64, (96, 128), (16, 32), 32)

self.block3_2 = Inception(256, 128, (128, 192), (32, 96), 64)

# 3x3最大池化

self.pool3 = MaxPool2D(kernel_size=3, stride=2, padding=1)






# 第四个模块包含5个Inception块

self.block4_1 = Inception(480, 192, (96, 208), (16, 48), 64)

self.block4_2 = Inception(512, 160, (112, 224), (24, 64), 64)

self.block4_3 = Inception(512, 128, (128, 256), (24, 64), 64)

self.block4_4 = Inception(512, 112, (144, 288), (32, 64), 64)

self.block4_5 = Inception(528, 256, (160, 320), (32, 128), 128)

# 3x3最大池化

self.pool4 = MaxPool2D(kernel_size=3, stride=2, padding=1)

# 第五个模块包含2个Inception块

self.block5_1 = Inception(832, 256, (160, 320), (32, 128), 128)

self.block5_2 = Inception(832, 384, (192, 384), (48, 128), 128)

# 全局池化,用的是global_pooling,不需要设置pool_stride

self.pool5 = AdaptiveAvgPool2D(output_size=1)

self.fc = Linear(in_features=1024, out_features=1)



def forward(self, x):

x = self.pool1(F.relu(self.conv1(x)))

x = self.pool2(F.relu(self.conv2_2(F.relu(self.conv2_1(x)))))

x = self.pool3(self.block3_2(self.block3_1(x)))

x = self.block4_3(self.block4_2(self.block4_1(x)))

x = self.pool4(self.block4_5(self.block4_4(x)))

x = self.pool5(self.block5_2(self.block5_1(x)))

x = paddle.reshape(x, [x.shape[0], -1])

x = self.fc(x)

return x



# 创建模型

model = GoogLeNet()

print(len(model.parameters()))

opt = paddle.optimizer.Momentum(learning_rate=0.001, momentum=0.9, parameters=model.parameters(), weight_decay=0.001)

# 启动训练过程

train_pm(model, opt)




从运行结果可以发现,Loss能有效地下降,经过5个回合的训练,在验证集上的准确率可以达到95%左右。
3.3.6ResNet
ResNet是2015年ImageNet比赛的冠军,将识别错误率降低到3.6%,这个结果甚至超出了正常人眼识别的精度。
通过前面几个经典模型学习可以发现,随着深度学习的不断发展,
神经网络的
层数越来越深,网络结构也越来越复杂。那么是否加深网络结构就一定会得到更好的效果呢?从理论上来说,假设新增加的层都是恒等映射,只要原有的层学习出跟原模型一样的参数,
那么
深模型结构就能达到原模型结构的效果。换句话说,原模型的解只是深模型解的子空间,在深模型解的空间里应该能找到比原模型解对应的子空间更好的结果。但是实践表明,增加网络的层数之后,训练误差往往不降反升。

Kaiming He等提出了残差网络ResNet来解决上述问题,其基本思想如图3.27所示。
(1) 图3.27(a)表示增加网络时,将x映射成y=F(x)输出。
(2) 图3.27(b)对图3.27(a)作了改进,输出y=F(x)+x。这时不是直接学习输出特征y的表示,而是学习y-x。
① 如果想学习出原模型的表示,只需将F(x)的参数全部设置为0,则y=x是恒等映射。
② F(x)=y-x也叫作残差项,如果x→y的映射接近恒等映射,那么图3.27(b)中通过学习残差项也比图3.27(a)学习完整映射形式更加容易。
图3.27(b)所示的结构是残差网络的基础
模块,也叫作残差块(Residual block)。输入x通过跨层连接,能更快地向前传播数据,或者向后
更新梯度。通俗的比喻,综艺节目上有一个“传声筒”的游戏,排在队首的嘉宾把看到的影视片段表演给后面一个嘉宾,经过四五个嘉宾后,最后一个嘉宾如果能表演出更多原剧的内容,就能取得高分。我们常常会发现,刚开始的嘉宾往往表演出最多的信息(类似于Loss),而随着表演的传递,有效的表演信息越来越少(类似于梯度弥散)。如果每个嘉宾都能看到原始的影视片段,那么相信传声筒的效果会好很多。类似地,由于ResNet每层都存在直连的旁路,相当于每一层都和最终的损失有“直接对话”的机会,自然可以更好地解决梯度弥散的问题。

■
图3.27残差块设计思想


残差块的具体设计方案如图3.28所示,这种设计方案也常称为瓶颈结构(BottleNeck)。1×1的卷积核可以非常方便地调整中间层的通道数,在进入3×3的卷积层之前减少通道数(256→64),经过该卷积层后再

■
图3.28残差块设计方案示意


恢复通道数(64→256),可以显著减少网络的参数量。这个结构(256→64→256)像一个中间细两头粗的瓶颈,所以被称为“BottleNeck”。


图3.29示出了ResNet50的结构,共包含49层卷积和1层全连接,所以称为ResNet50。

■
图3.29ResNet50模型网络结构示意图


ResNet50的具体实现代码如下:  



import numpy as np

import paddle

import paddle.nn as nn

import paddle.nn.functional as F



# ResNet中使用了BatchNorm层,在卷积层的后面加上BatchNorm以提升数值稳定性

# 定义卷积批归一化块

class ConvBNLayer(paddle.nn.Layer):

def init(self,

num_channels,

num_filters,

filter_size,

stride=1,

groups=1,

act=None):



"""

num_channels, 卷积层的输入通道数

num_filters, 卷积层的输出通道数

stride, 卷积层的步幅

groups, 分组卷积的组数,默认groups=1不使用分组卷积

"""

super(ConvBNLayer, self).init()



# 创建卷积层

self._conv = nn.Conv2D(

in_channels=num_channels,

out_channels=num_filters,

kernel_size=filter_size,

stride=stride,

padding=(filter_size - 1) // 2,

groups=groups,

bias_attr=False)







# 创建BatchNorm层

self._batch_norm = paddle.nn.BatchNorm2D(num_filters)




self.act = act



def forward(self, inputs):

y = self._conv(inputs)

y = self._batch_norm(y)

if self.act == 'leaky':

y = F.leaky_relu(x=y, negative_slope=0.1)

elif self.act == 'relu':

y = F.relu(x=y)

return y



# 定义残差块

# 每个残差块会对输入图片做3次卷积,然后跟输入图片进行短接

# 如果残差块中第三次卷积输出特征图的形状与输入不一致,则对输入图片做1x1卷积,将其输出

# 形状调整成一致

class BottleneckBlock(paddle.nn.Layer):

def init(self,

num_channels,

num_filters,

stride,

shortcut=True):

super(BottleneckBlock, self).init()

# 创建第一个卷积层 1x1

self.conv0 = ConvBNLayer(

num_channels=num_channels,

num_filters=num_filters,

filter_size=1,

act='relu')

# 创建第二个卷积层 3x3

self.conv1 = ConvBNLayer(

num_channels=num_filters,

num_filters=num_filters,

filter_size=3,

stride=stride,

act='relu')

# 创建第三个卷积 1x1,但输出通道数乘以4

self.conv2 = ConvBNLayer(

num_channels=num_filters,

num_filters=num_filters * 4,

filter_size=1,

act=None)



# 如果conv2的输出跟此残差块的输入数据形状一致,则shortcut=True

# 否则shortcut = False,添加1个1x1的卷积作用在输入数据上,使其形状变成跟conv2一致

if not shortcut:

self.short = ConvBNLayer(

num_channels=num_channels,

num_filters=num_filters * 4,

filter_size=1,

stride=stride)



self.shortcut = shortcut









self._num_channels_out = num_filters * 4



def forward(self, inputs):

y = self.conv0(inputs)

conv1 = self.conv1(y)

conv2 = self.conv2(conv1)



# 如果shortcut=True,直接将inputs跟conv2的输出相加

# 否则需要对inputs进行一次卷积,将形状调整成跟conv2输出一致

if self.shortcut:

short = inputs

else:

short = self.short(inputs)



y = paddle.add(x=short, y=conv2)

y = F.relu(y)

return y



# 定义ResNet模型

class ResNet(paddle.nn.Layer):

def init(self, layers=50, class_dim=1):

"""



layers, 网络层数,可以是50、101或者152

class_dim,分类标签的类别数

"""

super(ResNet, self).init()

self.layers = layers

supported_layers = [50, 101, 152]

assert layers in supported_layers, \

"supported layers are {} but input layer is {}".format(supported_layers, layers)



if layers == 50:

#ResNet50包含多个模块,其中第2~5个模块分别包含3、4、6、3个残差块

depth = [3, 4, 6, 3]

elif layers == 101:

#ResNet101包含多个模块,其中第2~5个模块分别包含3、4、23、3个残差块

depth = [3, 4, 23, 3]

elif layers == 152:

#ResNet152包含多个模块,其中第2~5个模块分别包含3、8、36、3个残差块

depth = [3, 8, 36, 3]



# 残差块中用到的卷积的输出通道数

num_filters = [64, 128, 256, 512]



# ResNet的第一个模块,包含1个7x7卷积,后面跟着一个最大池化层

self.conv = ConvBNLayer(

num_channels=3,

num_filters=64,

filter_size=7,

stride=2,

act='relu')







self.pool2d_max = nn.MaxPool2D(

kernel_size=3,

stride=2,

padding=1)



# ResNet的第2~5个模块c2、c3、c4、c5

self.bottleneck_block_list = []

num_channels = 64

for block in range(len(depth)):

shortcut = False

for i in range(depth[block]):

bottleneck_block = self.add_sublayer(

'bb_%d_%d' % (block, i),

BottleneckBlock(

num_channels=num_channels,

num_filters=num_filters[block],

stride=2 if i == 0 and block != 0 else 1,# c3、c4、c5将会在第一

# 个残差块使用stride=2;其余所有残差块stride=1

shortcut=shortcut))

num_channels = bottleneck_block._num_channels_out

self.bottleneck_block_list.append(bottleneck_block)

shortcut = True



# 在c5的输出特征图上使用全局池化

self.pool2d_avg = paddle.nn.AdaptiveAvgPool2D(output_size=1)



# stdv用来作为全连接层随机初始化参数的方差

import math

stdv = 1.0 / math.sqrt(2048 * 1.0)



# 创建全连接层,输出大小为类别数目,经过残差网络的卷积和全局池化后,

# 卷积特征的维度是[B,2048,1,1],故最后一层全连接的输入维度是2048

self.out = nn.Linear(in_features=2048, out_features=class_dim,

weight_attr=paddle.ParamAttr(

initializer=paddle.nn.initializer.Uniform(-stdv, stdv)))



def forward(self, inputs):

y = self.conv(inputs)

y = self.pool2d_max(y)

for bottleneck_block in self.bottleneck_block_list:

y = bottleneck_block(y)

y = self.pool2d_avg(y)

y = paddle.reshape(y, [y.shape[0], -1])

y = self.out(y)

return y




启动模型训练,代码如下: 



# 创建模型

model = ResNet()

# 定义优化器







opt = paddle.optimizer.Momentum(learning_rate=0.001, momentum=0.9, parameters=model.parameters(), weight_decay=0.001)

# 启动训练过程

train_pm(model, opt)




从运行结果可以发现,Loss能有效地下降,经过5个回合的训练,在验证集上的准确率可以达到95%左右。
3.3.7使用飞桨高层API直接调用图像分类网络
飞桨开源框架2.0版本支持全新升级的API体系,除了基础API外,还支持高层API。通过高低融合实现灵活组网,让飞桨API更简洁、更易用、更强大。高层API支持paddle.vision.models接口,实现了对常用模型的封装,包括ResNet、VGG、MobileNet、LeNet等。使用高层API调用这些网络,可以快速完成神经网络的训练和Finetune。



import paddle

from paddle.vision.models import resnet50



# 调用高层API的resnet50模型

model = resnet50()

# 设置pretrained参数为True,可以加载resnet50在imagenet数据集上的预训练模型

# model = resnet50(pretrained=True)



# 随机生成一个输入

x = paddle.rand([1, 3, 224, 224])

# 得到残差50的计算结果

out = model(x)

# 打印输出的形状,由于resnet50默认的是1000分类

# 所以输出shape是[1x1000]

print(out.shape)




使用paddle.vision中的模型可以简单、快速地构建一个深度学习任务,如下示例,仅14行代码即可实现ResNet50在Cifar10数据集上的训练: 



# 从paddle.vision.models 模块中import 残差网络、VGG网络、LeNet网络

from paddle.vision.models import resnet50, vgg16, LeNet

from paddle.vision.datasets import Cifar10

from paddle.optimizer import Momentum

from paddle.regularizer import L2Decay

from paddle.nn import CrossEntropyLoss

from paddle.metric import Accuracy

from paddle.vision.transforms import Transpose



# 确保从paddle.vision.datasets.Cifar10中加载的图像数据是np.ndarray类型

paddle.vision.set_image_backend('cv2')

# 调用resnet50模型

model = paddle.Model(resnet50(pretrained=False, num_classes=10))



# 使用Cifar10数据集







train_dataset = Cifar10(mode='train', transform=Transpose())

val_dataset = Cifar10(mode='test', transform=Transpose())

# 定义优化器

optimizer = Momentum(learning_rate=0.01,

momentum=0.9,

weight_decay=L2Decay(1e-4),

parameters=model.parameters())

# 进行训练前准备

model.prepare(optimizer, CrossEntropyLoss(), Accuracy(topk=(1, 5)))

# 启动训练

model.fit(train_dataset,

val_dataset,

epochs=50,

batch_size=64,

save_dir="./output",

num_workers=8)




小结
本节向读者介绍了几种经典的图像分类模型,分别是LeNet、AlexNet、VGG、GoogLeNet和ResNet,并将它们应用到眼疾筛查数据集上。除了LeNet不适合大尺寸的图像分类问题外,其他几个模型在此数据集上损失函数都能显著下降,在验证集上的预测精度在95%左右。如果读者有兴趣,可以进一步调整学习率和训练轮数等超参数,观察是否能够得到更高的精度。此外,还介绍了高层API直接调用常用深度神经网络的方法,方便开发者们快速完成深度学习网络迭代。
作业
如果将LeNet的中间层的激活函数Sigmoid换成ReLU,在眼底筛查数据集上将会得到什么样的结果?Loss是否能收敛?ReLU和Sigmoid之间的区别是引起结果不同的原因吗?