第5章 - 卷积神经网络 - 书籍

本文最后更新于:3 个月前

[√] 第5章 - 卷积神经网络 - 书籍

卷积神经网络(Convolutional Neural Network,CNN)是受生物学上感受野机制的启发而提出的。

目前的卷积神经网络一般是由卷积层、汇聚层和全连接层交叉堆叠而成的前馈神经网络,有三个结构上的特性:局部连接、权重共享以及汇聚。这些特性使得卷积神经网络具有一定程度上的平移、缩放和旋转不变性。

和前馈神经网络相比,卷积神经网络的参数更少

卷积神经网络主要应用在图像和视频分析的任务上,其准确率一般也远远超出了其他的神经网络模型。近年来卷积神经网络也广泛地应用到自然语言处理、推荐系统等领域。

在学习本章内容前,建议您先阅读《神经网络与深度学习》第5章:卷积神经网络的相关内容,关键知识点如 图5.1 所示,以便更好的理解和掌握书中的理论知识在实践中的应用方法。

image-20221217201001298

本实践基于 《神经网络与深度学习》第5章:卷积神经网络 相关内容进行设计,主要包含两部分:

  • 模型解读:介绍卷积的原理、卷积神经网络的网络结构、残差连接的原理以及残差网络的网络结构,并使用简单卷积神经网络和残差网络,完成手写数字识别任务;
  • 案例与实践:基于残差网络ResNet18完成CIFAR-10图像分类任务。

[√] B ~> 5.1 - 卷积


全连接网络会出现的问题:(alec)

  1. 模型参数过多,容易发生过拟合。 在全连接前馈网络中,隐藏层的每个神经元都要跟该层所有输入的神经元相连接。随着隐藏层神经元数量的增多,参数的规模也会急剧增加,导致整个神经网络的训练效率非常低,也很容易发生过拟合。
  2. 难以提取图像中的局部不变性特征。 自然图像中的物体都具有局部不变性特征,比如尺度缩放、平移、旋转等操作不影响其语义信息。而全连接前馈网络很难提取这些局部不变性特征。

卷积神经网络有三个结构上的特性:局部连接、权重共享和汇聚。这些特性使得卷积神经网络具有一定程度上的平移、缩放和旋转不变性。和前馈神经网络相比,卷积神经网络的参数也更少。因此,通常会使用卷积神经网络来处理图像信息。(alec)

卷积是分析数学中的一种重要运算,常用于信号处理或图像处理任务。本节以二维卷积为例来进行实践。


[√] D => 5.1.1 - 二维卷积运算


在机器学习和图像处理领域,卷积的主要功能是在一个图像(或特征图)上滑动一个卷积核,通过卷积操作得到一组新的特征。

alec:

通过卷积得到一组新的特征

在计算卷积的过程中,需要进行卷积核的翻转,而这也会带来一些不必要的操作和开销。因此,在具体实现上,一般会以数学中的互相关(Cross-Correlatio)运算来代替卷积。

alec:

具体实现上,一般会以互相关操作来代替真正的卷积

卷积的主要作用是抽取特征,是否翻转并不影响特征抽取的能力

卷积核是可学习的参数

在神经网络中,卷积运算的主要作用是抽取特征,卷积核是否进行翻转并不会影响其特征抽取的能力。特别是当卷积核是可学习的参数时,卷积和互相关在能力上是等价的。因此,很多时候,为方便起见,会直接用互相关来代替卷积。


说明:

在本案例之后的描述中,除非特别声明,卷积一般指“互相关”。


对于一个输入矩阵$\mathbf X\in\Bbb{R}^{M\times N}$和一个滤波器$\mathbf W \in\Bbb{R}^{U\times V}$,它们的卷积为

$$y_{i,j}=\sum_{u=0}^{U-1} \sum_{v=0}^{V-1} w_{uv}x_{i+u,j+v}。(5.1)$$

图5.2 给出了卷积计算的示例。

image-20221217202933355

经过卷积运算后,最终输出矩阵大小则为

$$M’ = M - U + 1,(5.2)$$
$$N’ = N - V + 1.(5.3)$$

alec:

此处可以记忆帮助为,当卷积核大小为1x1的时候,输出的大小为M’ = M = M - 1 + 1,N’ = N = N - 1 + 1

可以发现,使用卷积处理图像,会有以下两个特性:

  1. 在卷积层(假设是第$l$层)中的每一个神经元都只和前一层(第$l-1$层)中某个局部窗口内的神经元相连,构成一个局部连接网络,这也就是卷积神经网络的局部连接特性。
  2. 由于卷积的主要功能是在一个图像(或特征图)上滑动一个卷积核,所以作为参数的卷积核$\mathbf W \in\Bbb{R}^{U\times V}$对于第$l$层的所有的神经元都是相同的,这也就是卷积神经网络的权重共享特性。

alec:

每一个神经元都只和前一层(第$l-1$层)中某个局部窗口内的神经元相连,构成一个局部连接网络


[√] D => 5.1.2 - 二维卷积算子


在本书后面的实现中,算子都继承paddle.nn.Layer,并使用支持反向传播的飞桨API进行实现,这样我们就可以不用手工写backward()的代码实现。


根据公式(5.1),我们首先实现一个简单的二维卷积算子,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import paddle
import paddle.nn as nn

class Conv2D(nn.Layer):
def __init__(self, kernel_size,
weight_attr=paddle.ParamAttr(initializer=nn.initializer.Assign(value=[[0., 1.],[2., 3.]]))):
super(Conv2D, self).__init__()
# 使用'paddle.create_parameter'创建卷积核
# 使用'paddle.ParamAttr'进行参数初始化
self.weight = paddle.create_parameter(shape=[kernel_size,kernel_size],
dtype='float32',
attr=weight_attr)
def forward(self, X):
"""
输入:
- X:输入矩阵,shape=[B, M, N],B为样本数量
输出:
- output:输出矩阵
"""
u, v = self.weight.shape
output = paddle.zeros([X.shape[0], X.shape[1] - u + 1, X.shape[2] - v + 1])
for i in range(output.shape[1]):
for j in range(output.shape[2]):
output[:, i, j] = paddle.sum(X[:, i:i+u, j:j+v]*self.weight, axis=[1,2])
return output

# 随机构造一个二维输入矩阵
paddle.seed(100)
inputs = paddle.to_tensor([[[1.,2.,3.],[4.,5.,6.],[7.,8.,9.]]])

conv2d = Conv2D(kernel_size=2)
outputs = conv2d(inputs)
print("input: {}, \noutput: {}".format(inputs, outputs))

1
2
3
4
5
6
7
input: Tensor(shape=[1, 3, 3], dtype=float32, place=CPUPlace, stop_gradient=True,
[[[1., 2., 3.],
[4., 5., 6.],
[7., 8., 9.]]]),
output: Tensor(shape=[1, 2, 2], dtype=float32, place=CPUPlace, stop_gradient=False,
[[[25., 31.],
[43., 49.]]])

[√] D => 5.1.3 - 二维卷积的参数量和计算量


[√] F -> 参数量

由于二维卷积的运算方式为在一个图像(或特征图)上滑动一个卷积核,通过卷积操作得到一组新的特征。所以参数量仅仅与卷积核的尺寸有关,对于一个输入矩阵$\mathbf X\in\Bbb{R}^{M\times N}$和一个滤波器$\mathbf W \in\Bbb{R}^{U\times V}$,卷积核的参数量为$U\times V$。

假设有一幅大小为$32\times 32$的图像,如果使用全连接前馈网络进行处理,即便第一个隐藏层神经元个数为1,此时该层的参数量也高达$1025$个,此时该层的计算过程如 图5.3 所示。

image-20221217204505790

alec:

使用全连接由前一层推得后一层的一个神经元,假设前一层的图像大小为32*32,而不算三个图层通道,也需要参数量为32 * 32 + 1 = 1025个参数

可以想像,随着隐藏层神经元数量的变多以及层数的加深,使用全连接前馈网络处理图像数据时,参数量会急剧增加。

如果使用卷积进行图像处理,当卷积核为$3\times 3$时,参数量仅为$9$,相较于全连接前馈网络,参数量少了非常多。

alec:

卷积运算方式的形象化理解就是,首先其实二维和一维在本质上没区别,只不过是把一行改成一行一行的;其次,全连接的方式,就是相当远给上一层的每个特征点一个VIP的待遇,即32x32个像素点每个像素点都配一个权重参数,当使用卷积的时候,相当于一个固定大小的卷积核,反复的运用在一个32x32的图像上,反复利用,因此节省了参数。(为什么可以共享呢?因为图像具有平移的特征不变形等,因此可以使用共享卷积核的方式来提取特征。)


[√] F -> 计算量

在卷积神经网络中运算时,通常会统计网络总的乘加运算次数作为计算量(FLOPs,floating point of operations),来衡量整个网络的运算速度。对于单个二维卷积,计算量的统计方式为:

alec:

计算量(FLOPs,浮点运算次数)是用来衡量运算次数的,比如不同算力的显卡,FLOPs就不同。

对于输出得到的特征图,一个点是通过一次卷积运算得来的,假设卷积核大小是$U\times V$,则需要$U\times V$ 次

$M’\times N’$大小的特征图需要的运算次数为$$FLOPs=M’\times N’\times U\times V。$$

$$FLOPs=M’\times N’\times U\times V。(5.4)$$

其中$M’\times N’$表示输出特征图的尺寸,即输出特征图上每个点都要与卷积核$\mathbf W \in\Bbb{R}^{U\times V}$进行$U\times V$次乘加运算。对于一幅大小为$32\times 32$的图像,使用$3\times 3$的卷积核进行运算可以得到以下的输出特征图尺寸:

$$M’ = M - U + 1 = 30$$
$$N’ = N - V + 1 = 30$$

此时,计算量为:

$$FLOPs=M’\times N’\times U\times V=30\times 30\times 3\times 3=8100$$


[√] D => 5.1.4 - 感受野


[√] F -> 感受野定义

输出特征图上每个点的数值,是由输入图片上大小为$U\times V$的区域的元素与卷积核每个元素相乘再相加得到的,所以输入图像上$U\times V$区域内每个元素数值的改变,都会影响输出点的像素值。我们将这个区域叫做输出特征图上对应点的感受野。

感受野内每个元素数值的变动,都会影响输出点的数值变化。比如$3\times3$卷积对应的感受野大小就是$3\times3$,如 图5.4 所示。

alec:

输出特征图上的一个点就代表一个神经元计算得来的。有多少个特征点,就相当于有多少个神经元。神经元从前面一层收集信息,然后当兴奋达到阈值的时候,触发冲动,通过激活函数,得到一个值往后传。

对于全连接来说,该输出值是通过前面一层的全部的像素点计算得来的,对于卷积来说,该特征点,只是由卷积核收集的卷积核当前位置对应的感受野内的像素点的信息计算得来的。

image-20221217205612951

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

image-20221217205629584

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

alec:

随着层数的加深,深层特征图上的一个像素点,层层递进之后,这个像素点能够感受到输入图片的一个很大的区域。即感受野大。

因此这个深层像素点含有的是更高维的特征,而浅层像素点由于感受野小,只能含有低维的特征。

感受野可以理解为每层特征上的一个点在输入层图像上能够感受多大范围的区域。

浅层的特征图上的像素点感受的小感受野,随着层层递进,深层的一个像素点能够感受一个大范围内的信息。


[√] D => 5.1.5 - 卷积的变种


在卷积的标准定义基础上,还可以引入卷积核的滑动步长和零填充来增加卷积的多样性,从而更灵活地进行特征抽取。


[√] F -> 5.1.5.1 - 步长

在卷积运算的过程中,有时会希望跳过一些位置来降低计算的开销,也可以把这一过程看作是对标准卷积运算输出的下采样

在计算卷积时,可以在所有维度上每间隔$S$个元素计算一次,$S$称为卷积运算的步长(Stride),也就是卷积核在滑动时的间隔。

alec:

我们在一幅图片缩小之后,仍然知道这个图片想要表达的信息,这种现象可以看出图像的像素信息具有冗余性。

因此为了减少计算的开销,可以在卷积计算的过程中,对于被卷积核计算的特征图像,可以跳着卷积,以降低计算开销。

在所有维度上每间隔$S$个元素计算一次,$S$称为卷积运算的步长(Stride),也就是卷积核在滑动时的间隔。

这种也可以看做是对标准卷积的一个下采样。


在卷积的时候,通过步长来进行卷积是一种下采样操作;

然后通过池化来精炼信息,也可以看做是一种下采样操作;

下采样的目的都是精简、提取主要的信息,去除冗余性。

此时,对于一个输入矩阵$\mathbf X\in\Bbb{R}^{M\times N}$和一个滤波器$\mathbf W \in\Bbb{R}^{U\times V}$,它们的卷积为

$$y_{i,j}=\sum_{u=0}^{U-1} \sum_{v=0}^{V-1} w_{uv}x_{i\times S+u,j\times S+v},(5.5)$$

在二维卷积运算中,当步长$S=2$时,计算过程如 图5.6 所示。

image-20221217211039853

alec:

带步长的卷积计算公式,可以通过上面这个图像为8x8,步长为2,卷积核大小为2x2的来记忆。


[√] F -> 5.1.5.2 - 零填充(Zero Padding)

在卷积运算中,还可以对输入用零进行填充使得其尺寸变大。根据卷积的定义,如果不进行填充,当卷积核尺寸大于1时,输出特征会缩减。对输入进行零填充则可以对卷积核的宽度和输出的大小进行独立的控制。

alec:

通过零填充来控制输出特征图的大小。

在二维卷积运算中,零填充(Zero Padding)是指在输入矩阵周围对称地补上$P$个$0$。

图5.7 为使用零填充的示例。

image-20221217212520143

对于一个输入矩阵$\mathbf X\in\Bbb{R}^{M\times N}$和一个滤波器$\mathbf W \in\Bbb{R}^{U\times V}$,,步长为$S$,对输入矩阵进行零填充,那么最终输出矩阵大小则为

$$M’ = \frac{M + 2P - U}{S} + 1,(5.6)$$
$$N’ = \frac{N + 2P - V}{S} + 1.(5.7)$$

alec:

输出矩阵的大小,

M’ = (M + 2P - U) / S + 1。M是原图宽,P是填充边宽,U是卷积核大小,S是步长,+1。

引入步长和零填充后的卷积,参数量和计算量的统计方式与之前一致,参数量与卷积核的尺寸有关,为:$U\times V$,计算量与输出特征图和卷积核的尺寸有关,为:

$$FLOPs=M’\times N’\times U\times V=(\frac{M + 2P - U}{S} + 1)\times (\frac{N + 2P - V}{S} + 1)\times U\times V。(5.8)$$

一般常用的卷积有以下三类:

  1. 窄卷积:步长$S=1$,两端不补零$P=0$,卷积后输出尺寸为:

$$M’ = M - U + 1,(5.9)$$
$$N’ = N - V + 1.(5.10)$$

  1. 宽卷积:步长$S=1$,两端补零$P=U-1=V-1$,卷积后输出尺寸为:

$$M’ = M + U - 1,(5.11)$$
$$N’ = N + V - 1.(5.12)$$

  1. 等宽卷积:步长$S=1$,两端补零$P=\frac{(U-1)}{2}=\frac{(V-1)}{2}$,卷积后输出尺寸为:

$$M’ = M,(5.13)$$
$$N’ = N.(5.14)$$

alec:

在步长为1的时候,根据填充的长短,分为窄卷积(不填充)、等宽卷积、宽卷积。

通常情况下,在层数较深的卷积神经网络,比如:VGG、ResNet中,会使用等宽卷积保证输出特征图的大小不会随着层数的变深而快速缩减。例如:当卷积核的大小为$3\times 3$时,会将步长设置为$S=1$,两端补零$P=1$,此时,卷积后的输出尺寸就可以保持不变。在本章后续的案例中,会使用ResNet进行实验。

alec:

卷积神经网络的层数很深的时候,如果使用窄卷积,那么随着层数的加深,特征图会越来越小,变得很小。因此一般层数深的网络,会使用等宽卷积以保证特征图大小不变。等宽卷积,2P - U + 1 = 0,即填充和损耗相抵消了。P = (U-1)/2。

比如常见的3x3的卷积核,等宽卷积,则填充为P = (U-1)/2 = 1。

alec:

层数较深的卷积神经网络,比如VGG、ResNet


[√] D => 5.1.6 - 带步长和零填充的二维卷积算子


引入步长和零填充后,二维卷积算子代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Conv2D(nn.Layer):
def __init__(self, kernel_size, stride=1, padding=0,
weight_attr=paddle.ParamAttr(initializer=nn.initializer.Constant(value=1.0))):
super(Conv2D, self).__init__()
self.weight = paddle.create_parameter(shape=[kernel_size,kernel_size],
dtype='float32',
attr=weight_attr)
# 步长
self.stride = stride
# 零填充
self.padding = padding

def forward(self, X):
# 零填充
new_X = paddle.zeros([X.shape[0], X.shape[1]+2*self.padding, X.shape[2]+2*self.padding])
new_X[:, self.padding:X.shape[1]+self.padding, self.padding:X.shape[2]+self.padding] = X
u, v = self.weight.shape
# 零填充之后,计算输出特征图的shape
output_w = (new_X.shape[1] - u) // self.stride + 1
output_h = (new_X.shape[2] - v) // self.stride + 1
output = paddle.zeros([X.shape[0], output_w, output_h])
# 遍历计算输出特征图
for i in range(0, output.shape[1]):
for j in range(0, output.shape[2]):
output[:, i, j] = paddle.sum(
new_X[:, self.stride*i:self.stride*i+u, self.stride*j:self.stride*j+v]*self.weight,
axis=[1,2])
return output

inputs = paddle.randn(shape=[2, 8, 8])
conv2d_padding = Conv2D(kernel_size=3, padding=1)
outputs = conv2d_padding(inputs)
print("When kernel_size=3, padding=1 stride=1, input's shape: {}, output's shape: {}".format(inputs.shape, outputs.shape))
conv2d_stride = Conv2D(kernel_size=3, stride=2, padding=1)
outputs = conv2d_stride(inputs)
print("When kernel_size=3, padding=1 stride=2, input's shape: {}, output's shape: {}".format(inputs.shape, outputs.shape))
1
2
When kernel_size=3, padding=1 stride=1, input's shape: [2, 8, 8], output's shape: [2, 8, 8]
When kernel_size=3, padding=1 stride=2, input's shape: [2, 8, 8], output's shape: [2, 4, 4]

从输出结果看出,使用$3\times3$大小卷积,padding为1,当stride=1时,模型的输出特征图可以与输入特征图保持一致;当stride=2时,输出特征图的宽和高都缩小一倍。


[√] D => 5.1.7 - 使用卷积运算完成图像边缘检测任务


alec:

拉布拉斯算子常用语图像边缘检测提取

在图像处理任务中,常用拉普拉斯算子对物体边缘进行提取,拉普拉斯算子为一个大小为$3 \times 3$的卷积核,中心元素值是$8$,其余元素值是$-1$。

考虑到边缘其实就是图像上像素值变化很大的点的集合,因此可以通过计算二阶微分得到,当二阶微分为0时,像素值的变化最大。此时,对$x$方向和$y$方向分别求取二阶导数:

$$\frac{\delta^2 I}{\delta x^2} = I(i, j+1) - 2I(i,j) + I(i,j-1),(5.15)$$
$$\frac{\delta^2 I}{\delta y^2} = I(i+1, j) - 2I(i,j) + I(i-1,j).(5.16)$$

完整的二阶微分公式为:

$$\nabla^2I = \frac{\delta^2 I}{\delta x^2} + \frac{\delta^2 I}{\delta y^2} = - 4I(i,j) + I(i,j-1) + I(i, j+1) + I(i+1, j) + I(i-1,j),(5.17)$$

上述公式也被称为拉普拉斯算子,对应的二阶微分卷积核为:

$$\begin{bmatrix}
0 & 1 & 0 \
1 & -4 & 1 \
0 & 1 & 0 \
\end{bmatrix}$$

对上述算子全部求反也可以起到相同的作用,此时,该算子可以表示为:

$$\begin{bmatrix}
0 & -1 & 0 \
-1 & 4 & -1 \
0 & -1 & 0 \
\end{bmatrix}$$

也就是一个点的四邻域拉普拉斯的算子计算结果是自己像素值的四倍减去上下左右的像素的和,将这个算子旋转$45°$后与原算子相加,就变成八邻域的拉普拉斯算子,也就是一个像素自己值的八倍减去周围一圈八个像素值的和,做为拉普拉斯计算结果,此时,该算子可以表示为:

$$\begin{bmatrix}
-1 & -1 & -1 \
-1 & 8 & -1 \
-1 & -1 & -1 \
\end{bmatrix}$$


下面我们利用上面定义的Conv2D算子,构造一个简单的拉普拉斯算子,并对一张输入的灰度图片进行边缘检测,提取出目标的外形轮廓。

alec:

对拉普拉斯算子来说,如果覆盖的像素全部一样,那么最终的卷积结果就是0.检测不到东西。如果此处是高频信息,那么这个算子就能检测到东西。拉普拉斯算子卷积核所有的参数权重之和为0,因此可以检测出不均衡的高频信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
%matplotlib inline
import matplotlib.pyplot as plt
from PIL import Image
import numpy as np

# 读取图片
img = Image.open('./number.jpg').resize((256,256))

# 设置卷积核参数
w = np.array([[-1,-1,-1], [-1,8,-1], [-1,-1,-1]], dtype='float32')
# 创建卷积算子,卷积核大小为3x3,并使用上面的设置好的数值作为卷积核权重的初始化参数
conv = Conv2D(kernel_size=3, stride=1, padding=0,
weight_attr=paddle.ParamAttr(initializer=nn.initializer.Assign(value=w)))

# 将读入的图片转化为float32类型的numpy.ndarray
inputs = np.array(img).astype('float32')
print("bf to_tensor, inputs:",inputs)
# 将图片转为Tensor
inputs = paddle.to_tensor(inputs)
print("bf unsqueeze, inputs:",inputs)
inputs = paddle.unsqueeze(inputs, axis=0)
print("af unsqueeze, inputs:",inputs)
outputs = conv(inputs)
outputs = outputs.numpy()
# 可视化结果
plt.figure(figsize=(8, 4))
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(outputs.squeeze(), cmap='gray')
plt.savefig('conv-vis.pdf')
plt.show()

image-20221217220900496

从输出结果看,使用拉普拉斯算子,目标的边缘可以成功被检测出来。


[√] B ~> 5.2 - 卷积神经网络的基础算子


image-20221217221144575

从上图可以看出,卷积网络是由多个基础的算子组合而成。下面我们先实现卷积网络的两个基础算子:卷积层算子和汇聚层算子。


[√] D => 5.2.1 - 卷积算子


卷积层是指用卷积操作来实现神经网络中一层。为了提取不同种类的特征,通常会使用多个卷积核一起进行特征提取。

alec:

为了提取不同种类的特征,通常会使用多个卷积核一起进行特征提取。


[√] F -> 5.2.1.1 多通道卷积

在前面介绍的二维卷积运算中,卷积的输入数据是二维矩阵。但实际应用中,一幅大小为$M\times N$的图片中的每个像素的特征表示不仅仅只有灰度值的标量,通常有多个特征,可以表示为$D$维的向量,比如RGB三个通道的特征向量。因此,图像上的卷积操作的输入数据通常是一个三维张量,分别对应了图片的高度$M$、宽度$N$和深度$D$,其中深度$D$通常也被称为输入通道数$D$。如果输入如果是灰度图像,则输入通道数为1;如果输入是彩色图像,分别有$R、G、B$三个通道,则输入通道数为3。

此外,由于具有单个核的卷积每次只能提取一种类型的特征,即输出一张大小为$U\times V$的特征图(Feature Map)。而在实际应用中,我们也希望每一个卷积层能够提取多种不同类型的特征,所以一个卷积层通常会组合多个不同的卷积核来提取特征,经过卷积运算后会输出多张特征图,不同的特征图对应不同类型的特征。输出特征图的个数通常将其称为输出通道数$P$。

alec:

(1)输入图像的特征通道数,比如RGB图像的三层图像通道,称为输入通道数D

(2)一个卷积核只能提取一种类型的特征信息,因此我们在一个卷积层中,组合多个不同的卷积核,这样可以提取多张特征图,在一层卷积层中,放置P个卷积核,能够提取P种特征,将这P个输出特征图的个数称为输出通道数P

(3)举例:RGB图像,3层通道,3x128x128,使用16个大小为3x3的卷积核,进行等宽卷积,得到16x128x128,其中每张128x128都是一类特定于卷积核的特征。


说明:

《神经网络与深度学习》将Feature Map翻译为“特征映射”,这里翻译为“特征图”。


假设一个卷积层的输入特征图$\mathbf X\in \mathbb{R}^{D\times M\times N}$,其中$(M,N)$为特征图的尺寸,$D$代表通道数;卷积核为$\mathbf W\in \mathbb{R}^{P\times D\times U\times V}$,其中$(U,V)$为卷积核的尺寸,$D$代表输入通道数,$P$代表输出通道数。


说明:

在实践中,根据目前深度学习框架中张量的组织和运算性质,这里特征图的大小为$D\times M\times N$,和《神经网络与深度学习》中$M\times N \times D$的定义并不一致。
相应地,卷积核$W$的大小为$\mathbb{R}^{P\times D\times U\times V}$。


[√] F -> 一张输出特征图的计算

对于$D$个输入通道,分别对每个通道的特征图$\mathbf X^d$设计一个二维卷积核$\mathbf W^{p,d}$,并与对应的输入特征图$\mathbf X^d$进行卷积运算,再将得到的$D$个结果进行加和,得到一张输出特征图$\mathbf Z^p$。计算方式如下:

$$
\mathbf Z^p = \sum_{d=1}^D \mathbf W^{p,d} \otimes \mathbf X^d + b^p,(5.18)
$$

$$
\mathbf Y^p = f(\mathbf Z^p)。(5.19)
$$

其中$p$表示输出特征图的索引编号,$\mathbf W^{p,d} \in \mathbb{R}^{U\times V}$为二维卷积核,$b^p$为标量偏置,$f(·)$为非线性激活函数,一般用ReLU函数。

说明:

在代码实现时,通常将非线性激活函数放在卷积层算子外部。

公式(5.13)对应的可视化如图5.9所示。

image-20221217223939981


[√] F -> 多张输出特征图的计算

对于大小为$D\times M\times N$的输入特征图,每一个输出特征图都需要一组大小为$\mathbf W\in \mathbb{R}^{D\times U\times V}$的卷积核进行卷积运算。使用$P$组卷积核分布进行卷积运算,得到$P$个输出特征图$\mathbf Y^1, \mathbf Y^2,\cdots,\mathbf Y^P$。然后将$P$个输出特征图进行拼接,获得大小为$P\times M’ \times N’$的多通道输出特征图。上面计算方式的可视化如下图5.10所示。

image-20221217224049920


[√] F -> 5.2.1.2 - 多通道卷积层算子

根据上面的公式,多通道卷积卷积层的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
class Conv2D(nn.Layer):
# in_channels 是上一层特征的通道数
# out_channels 是本层卷积层卷积之后得到的特征图的通道数(个数),即本层有out_channels组卷积核,得到out_channels个特征图
def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0,
# 权重初始化1,偏置初始化为0
weight_attr=paddle.ParamAttr(initializer=nn.initializer.Constant(value=1.0)),
bias_attr=paddle.ParamAttr(initializer=nn.initializer.Constant(value=0.0))):
super(Conv2D, self).__init__()
# 创建卷积核
# 卷积核shape=[out_channels, in_channels, kernel_size,kernel_size]
# 有out_channels组卷积核,每组卷积核有in_channels个通道,每个卷积核大小为kernel_size,kernel_size
self.weight = paddle.create_parameter(shape=[out_channels, in_channels, kernel_size,kernel_size],
dtype='float32',
attr=weight_attr)
# 创建偏置
self.bias = paddle.create_parameter(shape=[out_channels, 1],
dtype='float32',
attr=bias_attr)
self.stride = stride
self.padding = padding
# 输入通道数 # 输入通道数为上一层特征图的通道数
self.in_channels = in_channels
# 输出通道数 # 输入通道数为本层卷积核的个数
self.out_channels = out_channels

# 基础卷积运算
# 单组卷积运算
def single_forward(self, X, weight):
# 零填充
new_X = paddle.zeros([X.shape[0], X.shape[1]+2*self.padding, X.shape[2]+2*self.padding])
new_X[:, self.padding:X.shape[1]+self.padding, self.padding:X.shape[2]+self.padding] = X
u, v = weight.shape
output_w = (new_X.shape[1] - u) // self.stride + 1
output_h = (new_X.shape[2] - v) // self.stride + 1
output = paddle.zeros([X.shape[0], output_w, output_h])
for i in range(0, output.shape[1]):
for j in range(0, output.shape[2]):
output[:, i, j] = paddle.sum(
new_X[:, self.stride*i:self.stride*i+u, self.stride*j:self.stride*j+v]*weight,
axis=[1,2])
return output

#out_channels组卷积核运算
def forward(self, inputs):
"""
输入:
- B张图,每张图D个通道,长宽为M、N
- inputs:输入矩阵,shape=[B, D, M, N]
- P组,d个通道,长宽为UV
- weights:P组二维卷积核,shape=[P, D, U, V]
- bias:P个偏置,shape=[P, 1]
"""
feature_maps = []
# 进行多次多输入通道卷积运算
p=0
for w, b in zip(self.weight, self.bias): # P个(w,b),每次计算一个特征图Zp # 遍历P个卷积核,每个卷积核都对这些图片进行卷积特征提取
multi_outs = []
# 循环计算每个输入特征图对应的卷积结果
for i in range(self.in_channels): # 遍历B张图
single = self.single_forward(inputs[:,i,:,:], w[i])
multi_outs.append(single)
# print("Conv2D in_channels:",self.in_channels,"i:",i,"single:",single.shape)
# 将所有卷积结果相加
feature_map = paddle.sum(paddle.stack(multi_outs), axis=0) + b #Zp
feature_maps.append(feature_map)
# print("Conv2D out_channels:",self.out_channels, "p:",p,"feature_map:",feature_map.shape)
p+=1
# 将所有Zp进行堆叠
out = paddle.stack(feature_maps, 1)
return out

#1张图,2个通道,长宽3x3
inputs = paddle.to_tensor([[[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]],
[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]]])
conv2d = Conv2D(in_channels=2, out_channels=3, kernel_size=2)
print("inputs shape:",inputs.shape)#1 * 2 * 3 * 3
outputs = conv2d(inputs)
print("Conv2D outputs shape:",outputs.shape)# 1 * 3 * 2 * 2

# 比较与paddle API运算结果
conv2d_paddle = nn.Conv2D(in_channels=2, out_channels=3, kernel_size=2,
weight_attr=paddle.ParamAttr(initializer=nn.initializer.Constant(value=1.0)),
bias_attr=paddle.ParamAttr(initializer=nn.initializer.Constant(value=0.0)))
outputs_paddle = conv2d_paddle(inputs)
# 自定义算子运算结果
print('Conv2D outputs:', outputs)
# paddle API运算结果
print('nn.Conv2D outputs:', outputs_paddle)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
inputs shape: [1, 2, 3, 3]
Conv2D outputs shape: [1, 3, 2, 2]
Conv2D outputs: Tensor(shape=[1, 3, 2, 2], dtype=float32, place=CPUPlace, stop_gradient=False,
[[[[20., 28.],
[44., 52.]],

[[20., 28.],
[44., 52.]],

[[20., 28.],
[44., 52.]]]])
nn.Conv2D outputs: Tensor(shape=[1, 3, 2, 2], dtype=float32, place=CPUPlace, stop_gradient=False,
[[[[20., 28.],
[44., 52.]],

[[20., 28.],
[44., 52.]],

[[20., 28.],
[44., 52.]]]])

[√] F -> 5.2.1.3 - 卷积算子的参数量和计算量

参数量

对于大小为$D\times M\times N$的输入特征图,使用$P$组大小为$\mathbf W\in \mathbb{R}^{D\times U\times V}$的卷积核进行卷积运算,参数量计算方式为:
$$
parameters = P \times D \times U \times V + P.(5.20)
$$

其中,最后的$P$代表偏置个数。例如:输入特征图大小为$3\times 32\times 32$,使用$6$组大小为$3\times 3\times 3$的卷积核进行卷积运算,参数量为:
$$
parameters = 6 \times 3 \times 3 \times 3 + 6= 168.
$$

计算量

对于大小为$D\times M\times N$的输入特征图,使用$P$组大小为$\mathbf W\in \mathbb{R}^{D\times U\times V}$的卷积核进行卷积运算,计算量计算方式为:

$$FLOPs=M’\times N’\times P\times D\times U\times V + M’\times N’\times P。(5.21)$$

其中$M’\times N’\times P$代表加偏置的计算量,即输出特征图上每个点都要与$P$组卷积核$\mathbf W\in \mathbb{R}^{D\times U\times V}$进行$U\times V\times D$次乘法运算后再加上偏置。比如对于输入特征图大小为$3\times 32\times 32$,使用$6$组大小为$3\times 3\times 3$的卷积核进行卷积运算,计算量为:

$$FLOPs=M’\times N’\times P\times D\times U\times V + M’\times N’\times P= 30\times 30\times 3\times 3\times 6\times 3 + 30\times 30\times 6= 151200$$


[√] D => 5.2.2 - 汇聚层算子


alec:

汇聚层的作用是进行特征选择,降低特征数量,从而减少参数数量。

汇聚之后特征图会变小,提取出精简主要的信息。

汇聚层的作用是进行特征选择,降低特征数量,从而减少参数数量。由于汇聚之后特征图会变得更小,如果后面连接的是全连接层,可以有效地减小神经元的个数,节省存储空间并提高计算效率。

常用的汇聚方法有两种,分别是:平均汇聚和最大汇聚。(均匀池化和最大池化)

  • 平均汇聚:将输入特征图划分为$2\times2$大小的区域,对每个区域内的神经元活性值取平均值作为这个区域的表示;
  • 最大汇聚:使用输入特征图的每个子区域内所有神经元的最大活性值作为这个区域的表示。

图5.11 给出了两种汇聚层的示例。

image-20221217225846462

汇聚层输出的计算尺寸与卷积层一致,对于一个输入矩阵$\mathbf X\in\Bbb{R}^{M\times N}$和一个运算区域大小为$U\times V$的汇聚层,步长为$S$,对输入矩阵进行零填充,那么最终输出矩阵大小则为

$$M’ = \frac{M + 2P - U}{S} + 1,(5.20)$$
$$N’ = \frac{N + 2P - V}{S} + 1.(5.21)$$

alec:

池化层和卷积层的输出矩阵大小的计算公式是一样的

由于过大的采样区域会急剧减少神经元的数量,也会造成过多的信息丢失。目前,在卷积神经网络中比较典型的汇聚层是将每个输入特征图划分为$2\times2$大小的不重叠区域,然后使用最大汇聚的方式进行下采样。

alec:

池化中经典的池化方式是使用2*2大小

由于汇聚是使用某一位置的相邻输出的总体统计特征代替网络在该位置的输出,所以其好处是当输入数据做出少量平移时,经过汇聚运算后的大多数输出还能保持不变。比如:当识别一张图像是否是人脸时,我们需要知道人脸左边有一只眼睛,右边也有一只眼睛,而不需要知道眼睛的精确位置,这时候通过汇聚某一片区域的像素点来得到总体统计特征会显得很有用。这也就体现了汇聚层的平移不变特性。

alec:

图像稍微平移之后,汇聚之后基本还是那张图像,体现了汇聚层的平移不变性。


[√] F -> 汇聚层的参数量和计算量

由于汇聚层中没有参数,所以参数量为$0$;

最大汇聚中,没有乘加运算,所以计算量为$0$,

而平均汇聚中,输出特征图上每个点都对应了一次求平均运算。

使用飞桨实现一个简单的汇聚层,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Pool2D(nn.Layer):
def __init__(self, size=(2,2), mode='max', stride=1):
super(Pool2D, self).__init__()
# 汇聚方式
self.mode = mode
self.h, self.w = size
self.stride = stride

def forward(self, x):
output_w = (x.shape[2] - self.w) // self.stride + 1
output_h = (x.shape[3] - self.h) // self.stride + 1
#alec: 池化只对长宽进行池化,其余的维度保持不变
output = paddle.zeros([x.shape[0], x.shape[1], output_w, output_h])
# 汇聚
for i in range(output.shape[2]):
for j in range(output.shape[3]):
# 最大汇聚
if self.mode == 'max':
output[:, :, i, j] = paddle.max(
x[:, :, self.stride*i:self.stride*i+self.w, self.stride*j:self.stride*j+self.h],
axis=[2,3])
# 平均汇聚
elif self.mode == 'avg':
output[:, :, i, j] = paddle.mean(
x[:, :, self.stride*i:self.stride*i+self.w, self.stride*j:self.stride*j+self.h],
axis=[2,3])

return output

inputs = paddle.to_tensor([[[[1.,2.,3.,4.],[5.,6.,7.,8.],[9.,10.,11.,12.],[13.,14.,15.,16.]]]])
pool2d = Pool2D(stride=2)
outputs = pool2d(inputs)
print("input: {}, \noutput: {}".format(inputs.shape, outputs.shape))#(1,1,4,4),(1,1,2,2)

# 比较Maxpool2D与paddle API运算结果
maxpool2d_paddle = nn.MaxPool2D(kernel_size=(2,2), stride=2)
outputs_paddle = maxpool2d_paddle(inputs)
# 自定义算子运算结果
print('Maxpool2D outputs:', outputs)
# paddle API运算结果
print('nn.Maxpool2D outputs:', outputs_paddle)

# 比较Avgpool2D与paddle API运算结果
avgpool2d_paddle = nn.AvgPool2D(kernel_size=(2,2), stride=2)
outputs_paddle = avgpool2d_paddle(inputs)
pool2d = Pool2D(mode='avg', stride=2)
outputs = pool2d(inputs)
# 自定义算子运算结果
print('Avgpool2D outputs:', outputs)
# paddle API运算结果
print('nn.Avgpool2D outputs:', outputs_paddle)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
input: [1, 1, 4, 4], 
output: [1, 1, 2, 2]
Maxpool2D outputs: Tensor(shape=[1, 1, 2, 2], dtype=float32, place=CPUPlace, stop_gradient=True,
[[[[6. , 8. ],
[14., 16.]]]])
nn.Maxpool2D outputs: Tensor(shape=[1, 1, 2, 2], dtype=float32, place=CPUPlace, stop_gradient=True,
[[[[6. , 8. ],
[14., 16.]]]])
Avgpool2D outputs: Tensor(shape=[1, 1, 2, 2], dtype=float32, place=CPUPlace, stop_gradient=True,
[[[[3.50000000 , 5.50000000 ],
[11.50000000, 13.50000000]]]])
nn.Avgpool2D outputs: Tensor(shape=[1, 1, 2, 2], dtype=float32, place=CPUPlace, stop_gradient=True,
[[[[3.50000000 , 5.50000000 ],
[11.50000000, 13.50000000]]]])

[√] 5.3 - 基于LeNet实现手写体数字识别实验


在本节中,我们实现经典卷积网络LeNet-5,并进行手写体数字识别任务。


[√] 5.3.1 - 数据


手写体数字识别是计算机视觉中最常用的图像分类任务,让计算机识别出给定图片中的手写体数字(0-9共10个数字)。由于手写体风格差异很大,因此手写体数字识别是具有一定难度的任务。

我们采用常用的手写数字识别数据集:MNIST数据集。MNIST数据集是计算机视觉领域的经典入门数据集,包含了60,000个训练样本和10,000个测试样本。这些数字已经过尺寸标准化并位于图像中心,图像是固定大小($28\times28$像素)。图5.12给出了部分样本的示例。

image-20221218103120923

为了节省训练时间,本节选取MNIST数据集的一个子集进行后续实验,数据集的划分为:

  • 训练集:1,000条样本
  • 验证集:200条样本
  • 测试集:200条样本

MNIST数据集分为train_set、dev_set和test_set三个数据集,每个数据集含两个列表分别存放了图片数据以及标签数据。比如train_set包含:

  • 图片数据:[1 000, 784]的二维列表,包含1 000张图片。每张图片用一个长度为784的向量表示,内容是 $28\times 28$ 尺寸的像素灰度值(黑白图片)。
  • 标签数据:[1 000, 1]的列表,表示这些图片对应的分类标签,即0~9之间的数字。

观察数据集分布情况,代码实现如下:

1
2
3
4
5
6
7
8
9
10
import json
import gzip

# 打印并观察数据集分布情况
train_set, dev_set, test_set = json.load(gzip.open('./mnist.json.gz'))
train_images, train_labels = train_set[0][:1000], train_set[1][:1000]
dev_images, dev_labels = dev_set[0][:200], dev_set[1][:200]
test_images, test_labels = test_set[0][:200], test_set[1][:200]
train_set, dev_set, test_set = [train_images, train_labels], [dev_images, dev_labels], [test_images, test_labels]
print('Length of train/dev/test set:{}/{}/{}'.format(len(train_set[0]), len(dev_set[0]), len(test_set[0])))
1
Length of train/dev/test set:1000/200/200

可视化观察其中的一张样本以及对应的标签,代码如下所示:

1
2
3
4
5
6
7
8
9
image, label = train_set[0][0], train_set[1][0]
image, label = np.array(image).astype('float32'), int(label)
# 原始图像数据为长度784的行向量,需要调整为[28,28]大小的图像
image = np.reshape(image, [28,28])
image = Image.fromarray(image.astype('uint8'), mode='L')
print("The number in the picture is {}".format(label))
plt.figure(figsize=(5, 5))
plt.imshow(image)
plt.savefig('conv-number5.pdf')

image-20221218103843285


[√] 5.3.1.1 - 数据预处理

图像分类网络对输入图片的格式、大小有一定的要求,数据输入模型前,需要对数据进行预处理操作,使图片满足网络训练以及预测的需要。本实验主要应用了如下方法:

  • 调整图片大小:LeNet网络对输入图片大小的要求为 $32\times 32$ ,而MNIST数据集中的原始图片大小却是 $28\times 28$ ,这里为了符合网络的结构设计,将其调整为$32 \times 32$;
  • 规范化: 通过规范化手段,把输入图像的分布改变成均值为0,标准差为1的标准正态分布,使得最优解的寻优过程明显会变得平缓,训练过程更容易收敛。

在飞桨中,提供了部分视觉领域的高层API,可以直接调用API实现简单的图像处理操作。通过调用paddle.vision.transforms.Resize调整大小;调用paddle.vision.transforms.Normalize进行标准化处理;使用paddle.vision.transforms.Compose将两个预处理操作进行拼接。


alec:

  • 通过resize调整图像大小
  • 通过normalize标准化处理图像,使得图像像素的分布变为标准正态分布
  • 使用compose将两个操作拼接

代码实现如下:

1
2
3
4
from paddle.vision.transforms import Compose, Resize, Normalize

# 数据预处理
transforms = Compose([Resize(32), Normalize(mean=[127.5], std=[127.5], data_format='CHW')])

将原始的数据集封装为Dataset类,以便DataLoader调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import random
import paddle.io as io

class MNIST_dataset(io.Dataset):
# 将数据的预处理修改操作封装在Dataset类中,在Dataset返回数据的时候,顺便进行了数据的修改预处理操作
def __init__(self, dataset, transforms, mode='train'):
self.mode = mode
self.transforms =transforms
self.dataset = dataset

def __getitem__(self, idx):
# 获取图像和标签
image, label = self.dataset[0][idx], self.dataset[1][idx]
image, label = np.array(image).astype('float32'), int(label)
image = np.reshape(image, [28,28])
image = Image.fromarray(image.astype('uint8'), mode='L')
image = self.transforms(image)

return image, label

def __len__(self):
return len(self.dataset[0])
1
2
3
4
5
6
# 固定随机种子
random.seed(0)
# 加载 mnist 数据集
train_dataset = MNIST_dataset(dataset=train_set, transforms=transforms, mode='train')
test_dataset = MNIST_dataset(dataset=test_set, transforms=transforms, mode='test')
dev_dataset = MNIST_dataset(dataset=dev_set, transforms=transforms, mode='dev')

[√] 5.3.2 - 模型构建


LeNet-5虽然提出的时间比较早,但它是一个非常成功的神经网络模型。

基于LeNet-5的手写数字识别系统在20世纪90年代被美国很多银行使用,用来识别支票上面的手写数字。LeNet-5的网络结构如图5.13所示。

image-20221218105333147

我们使用上面定义的卷积层算子和汇聚层算子构建一个LeNet-5模型。


这里的LeNet-5和原始版本有4点不同:

  1. C3层没有使用连接表来减少卷积数量。
  2. 汇聚层使用了简单的平均汇聚,没有引入权重和偏置参数以及非线性激活函数。
  3. 卷积层的激活函数使用ReLU函数。
  4. 最后的输出层为一个全连接线性层。

网络共有7层,包含3个卷积层、2个汇聚层以及2个全连接层的简单卷积神经网络接,受输入图像大小为$32\times 32=1, 024$,输出对应10个类别的得分。
具体实现如下:

alec:

所谓的维度,不过就是一层层的每个箱子里面套了几个箱子,所以不要畏难。

比如,[1,1,32,32] 这个数据,

就是一个32x32的图片,放到一个箱子里,然后再放到一个箱子里

[2,3,32,32]

这个就是3个箱子里分别放一张32x32的图片,然后这个三个箱子在变成两份分别放到两个箱子里


lenet5,3个卷积层、2个全连接层,一共5层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import paddle.nn.functional as F

class Model_LeNet(nn.Layer):
def __init__(self, in_channels, num_classes=10):
super(Model_LeNet, self).__init__()
# 卷积层:输出通道数为6,卷积核大小为5×5
# 本层有6个卷积核提取6种特征
self.conv1 = Conv2D(in_channels=in_channels, out_channels=6, kernel_size=5, weight_attr=paddle.ParamAttr())
# 汇聚层:汇聚窗口为2×2,步长为2
self.pool2 = Pool2D(size=(2,2), mode='max', stride=2)
# 本层有16个卷积核提取16种特征
# 卷积层:输入通道数为6,输出通道数为16,卷积核大小为5×5,步长为1
self.conv3 = Conv2D(in_channels=6, out_channels=16, kernel_size=5, stride=1, weight_attr=paddle.ParamAttr())
# 汇聚层:汇聚窗口为2×2,步长为2
self.pool4 = Pool2D(size=(2,2), mode='avg', stride=2)
# 本层有120个卷积核提取120种特征
# 卷积层:输入通道数为16,输出通道数为120,卷积核大小为5×5
self.conv5 = Conv2D(in_channels=16, out_channels=120, kernel_size=5, stride=1, weight_attr=paddle.ParamAttr())
# 全连接层:输入神经元为120,输出神经元为84
# 将120种特征,推测得到84种特征
# 输出有84个神经元收集信息
self.linear6 = nn.Linear(120, 84)
# 84种特征,映射得到10个类别的概率
# 全连接层:输入神经元为84,输出神经元为类别数
# 输出有10个神经元收集信息
self.linear7 = nn.Linear(84, num_classes)
#alec:前面的卷积层+池化看做特征提取,后面的全连接层看做线性分类

def forward(self, x):
# C1:卷积层+激活函数
output = F.relu(self.conv1(x))
# S2:汇聚层
output = self.pool2(output)
# C3:卷积层+激活函数
output = F.relu(self.conv3(output))
# S4:汇聚层
output = self.pool4(output)
# C5:卷积层+激活函数
output = F.relu(self.conv5(output))
# 输入层将数据拉平[B,C,H,W] -> [B,CxHxW]
# 全连接层将数据拉平,每张图像提取到的所有的特征,组合连成一个一位特征向量,然后使用全连接层将这个向量分析映射推测
output = paddle.squeeze(output, axis=[2,3])
# F6:全连接层
# 卷积层之后需要激活,全连接层之后也要激活,否则就是线性函数了
output = F.relu(self.linear6(output))
# F7:全连接层
# 最后一层全连接之后不需要激活
output = self.linear7(output)
return output

下面测试一下上面的LeNet-5模型,构造一个形状为 [1,1,32,32]的输入数据送入网络,观察每一层特征图的形状变化。代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 这里用np.random创建一个随机数组作为输入数据
inputs = np.random.randn(*[1,1,32,32])
inputs = inputs.astype('float32')

# 创建Model_LeNet类的实例,指定模型名称和分类的类别数目
model = Model_LeNet(in_channels=1, num_classes=10)
# 通过调用LeNet从基类继承的sublayers()函数,查看LeNet中所包含的子层
print(model.sublayers())
x = paddle.to_tensor(inputs)
for item in model.sublayers():
# item是LeNet类中的一个子层
# 查看经过子层之后的输出数据形状
try:
x = item(x)
except:
# 如果是最后一个卷积层输出,需要展平后才可以送入全连接层
x = paddle.reshape(x, [x.shape[0], -1]) # alec:将数据展平,将一个图像的所有特征合为一维,然后再送入全连接层
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)
1
2
3
4
5
6
7
8
9
# 模型结构
[Conv2D(), Pool2D(), Conv2D(), Pool2D(), Conv2D(), Linear(in_features=120, out_features=84, dtype=float32), Linear(in_features=84, out_features=10, dtype=float32)]
conv2d_6 [1, 6, 28, 28] [6, 1, 5, 5] [6, 1]
pool2d_2 [1, 6, 14, 14]
conv2d_7 [1, 16, 10, 10] [16, 6, 5, 5] [16, 1]
pool2d_3 [1, 16, 5, 5]
conv2d_8 [1, 120, 1, 1] [120, 16, 5, 5] [120, 1]
linear_0 [1, 84] [120, 84] [84]
linear_1 [1, 10] [84, 10] [10]

从输出结果看,

  • 对于大小为$32 \times32$的单通道图像,先用6个大小为$5 \times5$的卷积核对其进行卷积运算,输出为6个$28 \times28$大小的特征图;
  • 6个$28 \times28$大小的特征图经过大小为$2 \times2$,步长为2的汇聚层后,输出特征图的大小变为$14 \times14$;
  • 6个$14 \times14$大小的特征图再经过16个大小为$5 \times5$的卷积核对其进行卷积运算,得到16个$10 \times10$大小的输出特征图;
  • 16个$10 \times10$大小的特征图经过大小为$2 \times2$,步长为2的汇聚层后,输出特征图的大小变为$5 \times5$;
  • 16个$5 \times5$大小的特征图再经过120个大小为$5 \times5$的卷积核对其进行卷积运算,得到120个$1 \times1$大小的输出特征图;
  • 此时,将特征图展平成1维,则有120个像素点,经过输入神经元个数为120,输出神经元个数为84的全连接层后,输出的长度变为84。
  • 再经过一个全连接层的计算,最终得到了长度为类别数的输出结果。

考虑到自定义的Conv2DPool2D算子中包含多个for循环,所以运算速度比较慢。飞桨框架中,针对卷积层算子和汇聚层算子进行了速度上的优化,这里基于paddle.nn.Conv2Dpaddle.nn.MaxPool2Dpaddle.nn.AvgPool2D构建LeNet-5模型,对比与上边实现的模型的运算速度。代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Paddle_LeNet(nn.Layer):
def __init__(self, in_channels, num_classes=10):
super(Paddle_LeNet, self).__init__()
# 卷积层:输出通道数为6,卷积核大小为5*5
self.conv1 = nn.Conv2D(in_channels=in_channels, out_channels=6, kernel_size=5)
# 汇聚层:汇聚窗口为2*2,步长为2
self.pool2 = nn.MaxPool2D(kernel_size=2, stride=2)
# 卷积层:输入通道数为6,输出通道数为16,卷积核大小为5*5
self.conv3 = nn.Conv2D(in_channels=6, out_channels=16, kernel_size=5)
# 汇聚层:汇聚窗口为2*2,步长为2
self.pool4 = nn.AvgPool2D(kernel_size=2, stride=2)
# 卷积层:输入通道数为16,输出通道数为120,卷积核大小为5*5
self.conv5 = nn.Conv2D(in_channels=16, out_channels=120, kernel_size=5)
# 全连接层:输入神经元为120,输出神经元为84
self.linear6 = nn.Linear(in_features=120, out_features=84)
# 全连接层:输入神经元为84,输出神经元为类别数
self.linear7 = nn.Linear(in_features=84, out_features=num_classes)

def forward(self, x):
# C1:卷积层+激活函数
output = F.relu(self.conv1(x))
# S2:汇聚层
output = self.pool2(output)
# C3:卷积层+激活函数
output = F.relu(self.conv3(output))
# S4:汇聚层
output = self.pool4(output)
# C5:卷积层+激活函数
output = F.relu(self.conv5(output))
# 输入层将数据拉平[B,C,H,W] -> [B,CxHxW]
output = paddle.squeeze(output, axis=[2,3])
# F6:全连接层
output = F.relu(self.linear6(output))
# F7:全连接层
output = self.linear7(output)
return output

测试两个网络的运算速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import time

# 这里用np.random创建一个随机数组作为测试数据
inputs = np.random.randn(*[1,1,32,32])
inputs = inputs.astype('float32')
x = paddle.to_tensor(inputs)

# 创建Model_LeNet类的实例,指定模型名称和分类的类别数目
model = Model_LeNet(in_channels=1, num_classes=10)
# 创建Paddle_LeNet类的实例,指定模型名称和分类的类别数目
paddle_model = Paddle_LeNet(in_channels=1, num_classes=10)

# 计算Model_LeNet类的运算速度
model_time = 0
for i in range(60):
strat_time = time.time()
out = model(x)
end_time = time.time()
# 预热10次运算,不计入最终速度统计
if i < 10:
continue
model_time += (end_time - strat_time)
avg_model_time = model_time / 50
print('Model_LeNet speed:', avg_model_time, 's')

# 计算Paddle_LeNet类的运算速度
paddle_model_time = 0
for i in range(60):
strat_time = time.time()
paddle_out = paddle_model(x)
end_time = time.time()
# 预热10次运算,不计入最终速度统计
if i < 10:
continue
paddle_model_time += (end_time - strat_time)
avg_paddle_model_time = paddle_model_time / 50

print('Paddle_LeNet speed:', avg_paddle_model_time, 's')
1
2
Model_LeNet speed: 3.515837616920471 s
Paddle_LeNet speed: 0.0010264301300048828 s

可以看出,paddle中的模型速度,要远快于自定义的模型速度

这里还可以令两个网络加载同样的权重,测试一下两个网络的输出结果是否一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 这里用np.random创建一个随机数组作为测试数据
inputs = np.random.randn(*[1,1,32,32])
inputs = inputs.astype('float32')
x = paddle.to_tensor(inputs)

# 创建Model_LeNet类的实例,指定模型名称和分类的类别数目
model = Model_LeNet(in_channels=1, num_classes=10)
# 获取网络的权重
params = model.state_dict()
# 自定义Conv2D算子的bias参数形状为[out_channels, 1]
# paddle API中Conv2D算子的bias参数形状为[out_channels]
# 需要进行调整后才可以赋值
for key in params:
if 'bias' in key:
params[key] = params[key].squeeze()
# 创建Paddle_LeNet类的实例,指定模型名称和分类的类别数目
paddle_model = Paddle_LeNet(in_channels=1, num_classes=10)
# 将Model_LeNet的权重参数赋予给Paddle_LeNet模型,保持两者一致
paddle_model.set_state_dict(params)

# 打印结果保留小数点后6位
paddle.set_printoptions(6)
# 计算Model_LeNet的结果
output = model(x)
print('Model_LeNet output: ', output)
# 计算Paddle_LeNet的结果
paddle_output = paddle_model(x)
print('Paddle_LeNet output: ', paddle_output)
1
2
3
4
5
6
Model_LeNet output:  Tensor(shape=[1, 10], dtype=float32, place=CPUPlace, stop_gradient=False,
[[ 0.102497, 0.072102, 0.024969, -0.224923, -0.019524, 0.124833,
0.220763, 0.095593, -0.003534, 0.108034]])
Paddle_LeNet output: Tensor(shape=[1, 10], dtype=float32, place=CPUPlace, stop_gradient=False,
[[ 0.102497, 0.072102, 0.024969, -0.224923, -0.019524, 0.124833,
0.220763, 0.095593, -0.003534, 0.108034]])

可以看到,输出结果是一致的。

这里还可以统计一下LeNet-5模型的参数量和计算量。


[√] 参数量

按照公式(5.18)进行计算,可以得到:

  • 第一个卷积层的参数量为:$6 \times 1 \times 5 \times 5 + 6 = 156$;
  • 第二个卷积层的参数量为:$16 \times 6 \times 5 \times 5 + 16 = 2416$;
  • 第三个卷积层的参数量为:$120 \times 16 \times 5 \times 5 + 120= 48120$;
  • 第一个全连接层的参数量为:$120 \times 84 + 84= 10164$;
  • 第二个全连接层的参数量为:$84 \times 10 + 10= 850$;

所以,LeNet-5总的参数量为$61706$。

在飞桨中,还可以使用paddle.summaryAPI自动计算参数量。

1
2
3
model = Paddle_LeNet(in_channels=1, num_classes=10)
params_info = paddle.summary(model, (1, 1, 32, 32))
print(params_info)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
---------------------------------------------------------------------------
Layer (type) Input Shape Output Shape Param #
===========================================================================
Conv2D-22 [[1, 1, 32, 32]] [1, 6, 28, 28] 156
MaxPool2D-4 [[1, 6, 28, 28]] [1, 6, 14, 14] 0
Conv2D-23 [[1, 6, 14, 14]] [1, 16, 10, 10] 2,416
AvgPool2D-4 [[1, 16, 10, 10]] [1, 16, 5, 5] 0
Conv2D-24 [[1, 16, 5, 5]] [1, 120, 1, 1] 48,120
Linear-11 [[1, 120]] [1, 84] 10,164
Linear-12 [[1, 84]] [1, 10] 850
===========================================================================
Total params: 61,706
Trainable params: 61,706
Non-trainable params: 0
---------------------------------------------------------------------------
Input size (MB): 0.00
Forward/backward pass size (MB): 0.06
Params size (MB): 0.24
Estimated Total Size (MB): 0.30
---------------------------------------------------------------------------

{'total_params': 61706, 'trainable_params': 61706}

可以看到,结果与公式推导一致。


[√] 计算量

按照公式(5.19)进行计算,可以得到:

  • 第一个卷积层的计算量为:$28\times 28\times 5\times 5\times 6\times 1 + 28\times 28\times 6=122304$;
  • 第二个卷积层的计算量为:$10\times 10\times 5\times 5\times 16\times 6 + 10\times 10\times 16=241600$;
  • 第三个卷积层的计算量为:$1\times 1\times 5\times 5\times 120\times 16 + 1\times 1\times 120=48120$;
  • 平均汇聚层的计算量为:$16\times 5\times 5=400$
  • 第一个全连接层的计算量为:$120 \times 84 = 10080$;
  • 第二个全连接层的计算量为:$84 \times 10 = 840$;

所以,LeNet-5总的计算量为$423344$。

在飞桨中,还可以使用paddle.flopsAPI自动统计计算量。

1
2
FLOPs = paddle.flops(model, (1, 1, 32, 32), print_detail=True)
print(FLOPs)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<class 'paddle.nn.layer.conv.Conv2D'>'s flops has been counted
Cannot find suitable count function for <class 'paddle.nn.layer.pooling.MaxPool2D'>. Treat it as zero FLOPs.
<class 'paddle.nn.layer.pooling.AvgPool2D'>'s flops has been counted
<class 'paddle.nn.layer.common.Linear'>'s flops has been counted
+--------------+-----------------+-----------------+--------+--------+
| Layer Name | Input Shape | Output Shape | Params | Flops |
+--------------+-----------------+-----------------+--------+--------+
| conv2d_21 | [1, 1, 32, 32] | [1, 6, 28, 28] | 156 | 122304 |
| max_pool2d_3 | [1, 6, 28, 28] | [1, 6, 14, 14] | 0 | 0 |
| conv2d_22 | [1, 6, 14, 14] | [1, 16, 10, 10] | 2416 | 241600 |
| avg_pool2d_3 | [1, 16, 10, 10] | [1, 16, 5, 5] | 0 | 400 |
| conv2d_23 | [1, 16, 5, 5] | [1, 120, 1, 1] | 48120 | 48120 |
| linear_10 | [1, 120] | [1, 84] | 10164 | 10080 |
| linear_11 | [1, 84] | [1, 10] | 850 | 840 |
+--------------+-----------------+-----------------+--------+--------+
Total Flops: 423344 Total Params: 61706
423344

可以看到,结果与公式推导一致。


[√] 5.3.3 - 模型训练


使用交叉熵损失函数,并用随机梯度下降法作为优化器来训练LeNet-5网络。
用RunnerV3在训练集上训练5个epoch,并保存准确率最高的模型作为最佳模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import paddle.optimizer as opt
from nndl import RunnerV3, metric

paddle.seed(100)
# 学习率大小
lr = 0.1
# 批次大小
batch_size = 64
# 加载数据
train_loader = io.DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
dev_loader = io.DataLoader(dev_dataset, batch_size=batch_size)
test_loader = io.DataLoader(test_dataset, batch_size=batch_size)
# 定义LeNet网络
# 自定义算子实现的LeNet-5
model = Model_LeNet(in_channels=1, num_classes=10)
# 飞桨API实现的LeNet-5
# model = Paddle_LeNet(in_channels=1, num_classes=10)
# 定义优化器
# alec:优化器选择随机梯度下降方法,并设置优化器的学习率、并赋予模型的参数
optimizer = opt.SGD(learning_rate=lr, parameters=model.parameters())
# 定义损失函数
loss_fn = F.cross_entropy
# 定义评价指标
metric = metric.Accuracy(is_logist=True)
# 实例化 RunnerV3 类,并传入训练配置。
# 传入模型、优化器、损失函数、评价指标
runner = RunnerV3(model, optimizer, loss_fn, metric)
# 启动训练
log_steps = 15
eval_steps = 15
runner.train(train_loader, dev_loader, num_epochs=5, log_steps=log_steps,
eval_steps=eval_steps, save_path="best_model.pdparams")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Train] epoch: 0/5, step: 0/80, loss: 2.28238
[Train] epoch: 0/5, step: 15/80, loss: 2.08087
[Evaluate] dev score: 0.56500, dev loss: 2.03516
[Evaluate] best accuracy performence has been updated: 0.00000 --> 0.56500
[Train] epoch: 1/5, step: 30/80, loss: 1.48617
[Evaluate] dev score: 0.58000, dev loss: 1.34470
[Evaluate] best accuracy performence has been updated: 0.56500 --> 0.58000
[Train] epoch: 2/5, step: 45/80, loss: 1.61891
[Evaluate] dev score: 0.61000, dev loss: 1.24050
[Evaluate] best accuracy performence has been updated: 0.58000 --> 0.61000
[Train] epoch: 3/5, step: 60/80, loss: 0.64030
[Evaluate] dev score: 0.81000, dev loss: 0.54010
[Evaluate] best accuracy performence has been updated: 0.61000 --> 0.81000
[Train] epoch: 4/5, step: 75/80, loss: 0.39027
[Evaluate] dev score: 0.83000, dev loss: 0.46494
[Evaluate] best accuracy performence has been updated: 0.81000 --> 0.83000
[Evaluate] dev score: 0.82500, dev loss: 0.41483
[Train] Training done!

可视化观察训练集与验证集的损失变化情况。

1
2
from nndl import plot_training_loss_acc
plot_training_loss_acc(runner, 'cnn-loss1.pdf')

image-20221218172300769


[√] 5.3.4 - 模型评价


使用测试数据对在训练过程中保存的最佳模型进行评价,观察模型在测试集上的准确率以及损失变化情况。

1
2
3
4
5
6
# 加载最优模型
runner.load_model('best_model.pdparams')
# 模型评价
# alec:模型加载参数,并传入dataloder进行评价
score, loss = runner.evaluate(test_loader)
print("[Test] accuracy/loss: {:.4f}/{:.4f}".format(score, loss))
1
[Test] accuracy/loss: 0.8600/0.4435

[√] 5.3.5 - 模型预测


同样地,我们也可以使用保存好的模型,对测试集中的某一个数据进行模型预测,观察模型效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 获取测试集中第一条数据
X, label = next(test_loader())
logits = runner.predict(X)
# 多分类,使用softmax计算预测概率
pred = F.softmax(logits)
# 获取概率最大的类别
pred_class = paddle.argmax(pred[1]).numpy()
label = label[1][0].numpy()
# 输出真实类别与预测类别
print("The true category is {} and the predicted category is {}".format(label[0], pred_class[0]))
# 可视化图片
plt.figure(figsize=(2, 2))
image, label = test_set[0][1], test_set[1][1]
image= np.array(image).astype('float32')
image = np.reshape(image, [28,28])
image = Image.fromarray(image.astype('uint8'), mode='L')
plt.imshow(image)
plt.savefig('cnn-number2.pdf')

image-20221218172350294


[√] 5.4 - 基于残差网络的手写体数字识别实验


alec:

  • 残差网络是给非线性层增加直连边
  • 目的地为了缓解网络层数很深的时候,梯度消失问题
  • 残差单元在一个或多个神经层f()的输入和输出之间加上一个直连边
  • 在残差网络中,将目标函数$h(x)$拆为了两个部分:恒等函数$x$和残差函数$h(x)-x$,其中恒等函数直接将输入送到输出那里,然后和残差函数加起来
  • 典型的残差单元是由多个级联的卷积层和一个直连边组成。
  • (网络层数加深,深层的特征图都是含有的高级特征,但是可能高层也需要底层的一些特征,因此通过残差直连边传过来,这样才能充分的利用数据去学习信息,否则高层拿到的都是前一层传过来的,信息获取不充分可能就会被蒙蔽导致数据分析、提取不正确)、
  • 一个残差网络通常有很多个残差单元堆叠而成。
  • ResNet18是一个非常经典的残差网络。

残差网络(Residual Network,ResNet)是在神经网络模型中给非线性层增加直连边的方式来缓解梯度消失问题,从而使训练深度神经网络变得更加容易。

在残差网络中,最基本的单位为残差单元

假设$f(\mathbf x;\theta)$为一个或多个神经层,残差单元在$f()$的输入和输出之间加上一个直连边

不同于传统网络结构中让网络$f(x;\theta)$去逼近一个目标函数$h(x)$,在残差网络中,将目标函数$h(x)$拆为了两个部分:恒等函数$x$和残差函数$h(x)-x$

$$
\mathrm{ResBlock}_f(\mathbf x) = f(\mathbf x;\theta) + \mathbf x,(5.22)
$$

其中$\theta$为可学习的参数。

一个典型的残差单元如图5.14所示,由多个级联的卷积层和一个跨层的直连边组成。

image-20221218121636764

一个残差网络通常有很多个残差单元堆叠而成。下面我们来构建一个在计算机视觉中非常典型的残差网络:ResNet18,并重复上一节中的手写体数字识别任务。


[√] 5.4.1 - 模型构建


在本节中,我们先构建ResNet18的残差单元,然后在组建完整的网络。


[√] 5.4.1.1 - 残差单元

alec:

  • 残差单元包裹的非线性层的输入和输出形状大小应该一致。如果一个卷积层的输入特征图和输出特征图的通道数不一致,则其输出与输入特征图无法直接相加。
  • 如何使得残差单元的输入输出大小形状一致:可以使用1×1大小的卷积将输入特征图的通道数映射为与级联卷积输出特征图的一致通道数。
  • 1$\times$1 卷积的作用:
    • 不考虑输入数据局部信息之间的关系
    • 实现信息的跨通道整合
    • 实现数据的通道数降维或升维

这里,我们实现一个算子ResBlock来构建残差单元,其中定义了use_residual参数,用于在后续实验中控制是否使用残差连接。


残差单元包裹的非线性层的输入和输出形状大小应该一致。如果一个卷积层的输入特征图和输出特征图的通道数不一致,则其输出与输入特征图无法直接相加。为了解决上述问题,我们可以使用$1 \times 1$大小的卷积将输入特征图的通道数映射为与级联卷积输出特征图的一致通道数。

$1 \times 1$卷积:与标准卷积完全一样,唯一的特殊点在于卷积核的尺寸是$1 \times 1$,也就是不去考虑输入数据局部信息之间的关系,而把关注点放在不同通道间。通过使用$1 \times 1$卷积,可以起到如下作用:

  • 实现信息的跨通道交互与整合。考虑到卷积运算的输入输出都是3个维度(宽、高、多通道),所以$1 \times 1$卷积实际上就是对每个像素点,在不同的通道上进行线性组合,从而整合不同通道的信息;
  • 对卷积核通道数进行降维和升维,减少参数量。经过$1 \times 1$卷积后的输出保留了输入数据的原有平面结构,通过调控通道数,从而完成升维或降维的作用;
  • 利用$1 \times 1$卷积后的非线性激活函数,在保持特征图尺寸不变的前提下,大幅增加非线性。

alec:

  • 在残差单元中,如果残差函数的输出和输入形状不一致,则通过1x1卷积,改变直连边输入数据的形状,使得残差函数的输出和输入的形状一致,然后才能相加。注意使用1x1改变的是输入在直连边的形状,不是改变的残差函数的输出的形状。
  • 残差块中每个卷积层的后面,接一个批量规范化层
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# 残差单元的定义
class ResBlock(nn.Layer):
def __init__(self, in_channels, out_channels, stride=1, use_residual=True):
"""
残差单元
输入:
- in_channels:输入通道数
- out_channels:输出通道数
- stride:残差单元的步长,通过调整残差单元中第一个卷积层的步长来控制
- use_residual:用于控制是否使用残差连接
"""
super(ResBlock, self).__init__()
self.stride = stride
self.use_residual = use_residual
# 等宽卷积,不改变长宽
# 第一个卷积层,卷积核大小为3×3,可以设置不同输出通道数以及步长
self.conv1 = nn.Conv2D(in_channels, out_channels, 3, padding=1, stride=self.stride, bias_attr=False)
# 第二个卷积层,卷积核大小为3×3,不改变输入特征图的形状,步长为1
self.conv2 = nn.Conv2D(out_channels, out_channels, 3, padding=1, bias_attr=False)

# 如果输出和输入的形状不一致,则使用1x1卷积,来改变输入的通道数,使得形状一致
# 如果conv2的输出和此残差块的输入数据形状不一致,则use_1x1conv = True
# 当use_1x1conv = True,添加1个1x1的卷积作用在输入数据上,使其形状变成跟conv2一致
# 如果输入和输出通道数不一致,则通过1x1卷积使得通道数保持一致。
if in_channels != out_channels or stride != 1:
self.use_1x1conv = True
else:
self.use_1x1conv = False
# 当残差单元包裹的非线性层输入和输出通道数不一致时,需要用1×1卷积调整通道数后再进行相加运算
if self.use_1x1conv:
self.shortcut = nn.Conv2D(in_channels, out_channels, 1, stride=self.stride, bias_attr=False)

# 每个卷积层后会接一个批量规范化层,批量规范化的内容在7.5.1中会进行详细介绍
self.bn1 = nn.BatchNorm2D(out_channels)
self.bn2 = nn.BatchNorm2D(out_channels)
if self.use_1x1conv:
self.bn3 = nn.BatchNorm2D(out_channels)

def forward(self, inputs):
y = F.relu(self.bn1(self.conv1(inputs))) # 卷积、批量规范化、激活函数
y = self.bn2(self.conv2(y)) # 卷积、批量规范化
if self.use_residual:
if self.use_1x1conv: # 如果为真,对inputs进行1×1卷积,将形状调整成跟conv2的输出y一致
shortcut = self.shortcut(inputs) # 1x1卷积调整形状、批量规范化
shortcut = self.bn3(shortcut)
else: # 否则直接将inputs和conv2的输出y相加
shortcut = inputs
y = paddle.add(shortcut, y) # 将残差函数的输入 + 输入
out = F.relu(y) # 激活
return out

alec:

残差单元的基本结构:

Y = A + B,A是残差函数的输出,B是直连边的输出

A’ = 输入 -> 等宽卷积 -> 批量规范化 -> 激活函数 -> 等宽卷积 -> 批量规范化

B’ = 输入 (-> 1x1卷积调整形状 -> 批量规范化)

Y = (A’ + B’)-> 激活函数


[√] 5.4.1.2 - 残差网络的整体结构

alec:

残差网络就是将多个残差单元串联起来的深网络。

残差网络就是将很多个残差单元串联起来构成的一个非常深的网络。ResNet18 的网络结构如图5.16所示。

image-20221218173529787

其中为了便于理解,可以将ResNet18网络划分为6个模块:

  • 第一模块:包含了一个步长为2,大小为$7 \times 7$的卷积层,卷积层的输出通道数为64,卷积层的输出经过批量归一化、ReLU激活函数的处理后,接了一个步长为2的$3 \times 3$的最大汇聚层;
  • 第二模块:包含了两个残差单元,经过运算后,输出通道数为64,特征图的尺寸保持不变;
  • 第三模块:包含了两个残差单元,经过运算后,输出通道数为128,特征图的尺寸缩小一半;
  • 第四模块:包含了两个残差单元,经过运算后,输出通道数为256,特征图的尺寸缩小一半;
  • 第五模块:包含了两个残差单元,经过运算后,输出通道数为512,特征图的尺寸缩小一半;
  • 第六模块:包含了一个全局平均汇聚层,将特征图变为$1 \times 1$的大小,最终经过全连接层计算出最后的输出。

ResNet18模型的代码实现如下:

定义模块1:

1
2
3
4
5
6
7
8
# 卷积+批量归一化+激活+最大汇聚
def make_first_module(in_channels):
# 模块一:7*7卷积、批量规范化、汇聚
# 64个卷积核,得到64张特征图,
m1 = nn.Sequential(nn.Conv2D(in_channels, 64, 7, stride=2, padding=3),
nn.BatchNorm2D(64), nn.ReLU(),
nn.MaxPool2D(kernel_size=3, stride=2, padding=1))
return m1

定义模块二到模块五。

1
2
3
4
5
6
7
8
9
10
def resnet_module(input_channels, out_channels, num_res_blocks, stride=1, use_residual=True):
blk = []
# 根据num_res_blocks,循环生成残差单元
for i in range(num_res_blocks):
if i == 0: # 创建模块中的第一个残差单元
blk.append(ResBlock(input_channels, out_channels,
stride=stride, use_residual=use_residual))
else: # 创建模块中的其他残差单元
blk.append(ResBlock(out_channels, out_channels, use_residual=use_residual))
return blk

封装模块二到模块五。

1
2
3
4
5
6
7
8
9
10
def make_modules(use_residual):
# 模块二:包含两个残差单元,输入通道数为64,输出通道数为64,步长为1,特征图大小保持不变
m2 = nn.Sequential(*resnet_module(64, 64, 2, stride=1, use_residual=use_residual))
# 模块三:包含两个残差单元,输入通道数为64,输出通道数为128,步长为2,特征图大小缩小一半。
m3 = nn.Sequential(*resnet_module(64, 128, 2, stride=2, use_residual=use_residual))
# 模块四:包含两个残差单元,输入通道数为128,输出通道数为256,步长为2,特征图大小缩小一半。
m4 = nn.Sequential(*resnet_module(128, 256, 2, stride=2, use_residual=use_residual))
# 模块五:包含两个残差单元,输入通道数为256,输出通道数为512,步长为2,特征图大小缩小一半。
m5 = nn.Sequential(*resnet_module(256, 512, 2, stride=2, use_residual=use_residual))
return m2, m3, m4, m5

定义完整网络。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 定义完整网络
class Model_ResNet18(nn.Layer):
def __init__(self, in_channels=3, num_classes=10, use_residual=True):
super(Model_ResNet18,self).__init__()
m1 = make_first_module(in_channels)
m2, m3, m4, m5 = make_modules(use_residual)
# 封装模块一到模块6
self.net = nn.Sequential(m1, m2, m3, m4, m5,
# 模块六:汇聚层、全连接层
nn.AdaptiveAvgPool2D(1), nn.Flatten(), nn.Linear(512, num_classes) )

def forward(self, x):
return self.net(x)

这里同样可以使用paddle.summary统计模型的参数量。

1
2
3
model = Model_ResNet18(in_channels=1, num_classes=10, use_residual=True)
params_info = paddle.summary(model, (1, 1, 32, 32))
print(params_info)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
-------------------------------------------------------------------------------
Layer (type) Input Shape Output Shape Param #
===============================================================================
Conv2D-28 [[1, 1, 32, 32]] [1, 64, 16, 16] 3,200
BatchNorm2D-1 [[1, 64, 16, 16]] [1, 64, 16, 16] 256
ReLU-1 [[1, 64, 16, 16]] [1, 64, 16, 16] 0
MaxPool2D-5 [[1, 64, 16, 16]] [1, 64, 8, 8] 0
Conv2D-29 [[1, 64, 8, 8]] [1, 64, 8, 8] 36,864
BatchNorm2D-2 [[1, 64, 8, 8]] [1, 64, 8, 8] 256
Conv2D-30 [[1, 64, 8, 8]] [1, 64, 8, 8] 36,864
BatchNorm2D-3 [[1, 64, 8, 8]] [1, 64, 8, 8] 256
ResBlock-1 [[1, 64, 8, 8]] [1, 64, 8, 8] 0
Conv2D-31 [[1, 64, 8, 8]] [1, 64, 8, 8] 36,864
BatchNorm2D-4 [[1, 64, 8, 8]] [1, 64, 8, 8] 256
Conv2D-32 [[1, 64, 8, 8]] [1, 64, 8, 8] 36,864
BatchNorm2D-5 [[1, 64, 8, 8]] [1, 64, 8, 8] 256
ResBlock-2 [[1, 64, 8, 8]] [1, 64, 8, 8] 0
Conv2D-33 [[1, 64, 8, 8]] [1, 128, 4, 4] 73,728
BatchNorm2D-6 [[1, 128, 4, 4]] [1, 128, 4, 4] 512
Conv2D-34 [[1, 128, 4, 4]] [1, 128, 4, 4] 147,456
BatchNorm2D-7 [[1, 128, 4, 4]] [1, 128, 4, 4] 512
Conv2D-35 [[1, 64, 8, 8]] [1, 128, 4, 4] 8,192
BatchNorm2D-8 [[1, 128, 4, 4]] [1, 128, 4, 4] 512
ResBlock-3 [[1, 64, 8, 8]] [1, 128, 4, 4] 0
Conv2D-36 [[1, 128, 4, 4]] [1, 128, 4, 4] 147,456
BatchNorm2D-9 [[1, 128, 4, 4]] [1, 128, 4, 4] 512
Conv2D-37 [[1, 128, 4, 4]] [1, 128, 4, 4] 147,456
BatchNorm2D-10 [[1, 128, 4, 4]] [1, 128, 4, 4] 512
ResBlock-4 [[1, 128, 4, 4]] [1, 128, 4, 4] 0
Conv2D-38 [[1, 128, 4, 4]] [1, 256, 2, 2] 294,912
BatchNorm2D-11 [[1, 256, 2, 2]] [1, 256, 2, 2] 1,024
Conv2D-39 [[1, 256, 2, 2]] [1, 256, 2, 2] 589,824
BatchNorm2D-12 [[1, 256, 2, 2]] [1, 256, 2, 2] 1,024
Conv2D-40 [[1, 128, 4, 4]] [1, 256, 2, 2] 32,768
BatchNorm2D-13 [[1, 256, 2, 2]] [1, 256, 2, 2] 1,024
ResBlock-5 [[1, 128, 4, 4]] [1, 256, 2, 2] 0
Conv2D-41 [[1, 256, 2, 2]] [1, 256, 2, 2] 589,824
BatchNorm2D-14 [[1, 256, 2, 2]] [1, 256, 2, 2] 1,024
Conv2D-42 [[1, 256, 2, 2]] [1, 256, 2, 2] 589,824
BatchNorm2D-15 [[1, 256, 2, 2]] [1, 256, 2, 2] 1,024
ResBlock-6 [[1, 256, 2, 2]] [1, 256, 2, 2] 0
Conv2D-43 [[1, 256, 2, 2]] [1, 512, 1, 1] 1,179,648
BatchNorm2D-16 [[1, 512, 1, 1]] [1, 512, 1, 1] 2,048
Conv2D-44 [[1, 512, 1, 1]] [1, 512, 1, 1] 2,359,296
BatchNorm2D-17 [[1, 512, 1, 1]] [1, 512, 1, 1] 2,048
Conv2D-45 [[1, 256, 2, 2]] [1, 512, 1, 1] 131,072
BatchNorm2D-18 [[1, 512, 1, 1]] [1, 512, 1, 1] 2,048
ResBlock-7 [[1, 256, 2, 2]] [1, 512, 1, 1] 0
Conv2D-46 [[1, 512, 1, 1]] [1, 512, 1, 1] 2,359,296
BatchNorm2D-19 [[1, 512, 1, 1]] [1, 512, 1, 1] 2,048
Conv2D-47 [[1, 512, 1, 1]] [1, 512, 1, 1] 2,359,296
BatchNorm2D-20 [[1, 512, 1, 1]] [1, 512, 1, 1] 2,048
ResBlock-8 [[1, 512, 1, 1]] [1, 512, 1, 1] 0
AdaptiveAvgPool2D-1 [[1, 512, 1, 1]] [1, 512, 1, 1] 0
Flatten-1 [[1, 512, 1, 1]] [1, 512] 0
Linear-15 [[1, 512]] [1, 10] 5,130
===============================================================================
Total params: 11,185,034
Trainable params: 11,165,834
Non-trainable params: 19,200
-------------------------------------------------------------------------------
Input size (MB): 0.00
Forward/backward pass size (MB): 1.05
Params size (MB): 42.67
Estimated Total Size (MB): 43.73
-------------------------------------------------------------------------------

{'total_params': 11185034, 'trainable_params': 11165834}

使用paddle.flops统计模型的计算量。

1
2
FLOPs = paddle.flops(model, (1, 1, 32, 32), print_detail=True)
print(FLOPs)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<class 'paddle.nn.layer.conv.Conv2D'>'s flops has been counted
<class 'paddle.nn.layer.norm.BatchNorm2D'>'s flops has been counted
<class 'paddle.nn.layer.activation.ReLU'>'s flops has been counted
Cannot find suitable count function for <class 'paddle.nn.layer.pooling.MaxPool2D'>. Treat it as zero FLOPs.
<class 'paddle.nn.layer.pooling.AdaptiveAvgPool2D'>'s flops has been counted
Cannot find suitable count function for <class 'paddle.fluid.dygraph.nn.Flatten'>. Treat it as zero FLOPs.
<class 'paddle.nn.layer.common.Linear'>'s flops has been counted
+-----------------------+-----------------+-----------------+---------+---------+
| Layer Name | Input Shape | Output Shape | Params | Flops |
+-----------------------+-----------------+-----------------+---------+---------+
| conv2d_27 | [1, 1, 32, 32] | [1, 64, 16, 16] | 3200 | 819200 |
| batch_norm2d_0 | [1, 64, 16, 16] | [1, 64, 16, 16] | 256 | 32768 |
| re_lu_0 | [1, 64, 16, 16] | [1, 64, 16, 16] | 0 | 0 |
| max_pool2d_4 | [1, 64, 16, 16] | [1, 64, 8, 8] | 0 | 0 |
| conv2d_28 | [1, 64, 8, 8] | [1, 64, 8, 8] | 36864 | 2359296 |
| conv2d_29 | [1, 64, 8, 8] | [1, 64, 8, 8] | 36864 | 2359296 |
| batch_norm2d_1 | [1, 64, 8, 8] | [1, 64, 8, 8] | 256 | 8192 |
| batch_norm2d_2 | [1, 64, 8, 8] | [1, 64, 8, 8] | 256 | 8192 |
| conv2d_30 | [1, 64, 8, 8] | [1, 64, 8, 8] | 36864 | 2359296 |
| conv2d_31 | [1, 64, 8, 8] | [1, 64, 8, 8] | 36864 | 2359296 |
| batch_norm2d_3 | [1, 64, 8, 8] | [1, 64, 8, 8] | 256 | 8192 |
| batch_norm2d_4 | [1, 64, 8, 8] | [1, 64, 8, 8] | 256 | 8192 |
| conv2d_32 | [1, 64, 8, 8] | [1, 128, 4, 4] | 73728 | 1179648 |
| conv2d_33 | [1, 128, 4, 4] | [1, 128, 4, 4] | 147456 | 2359296 |
| conv2d_34 | [1, 64, 8, 8] | [1, 128, 4, 4] | 8192 | 131072 |
| batch_norm2d_5 | [1, 128, 4, 4] | [1, 128, 4, 4] | 512 | 4096 |
| batch_norm2d_6 | [1, 128, 4, 4] | [1, 128, 4, 4] | 512 | 4096 |
| batch_norm2d_7 | [1, 128, 4, 4] | [1, 128, 4, 4] | 512 | 4096 |
| conv2d_35 | [1, 128, 4, 4] | [1, 128, 4, 4] | 147456 | 2359296 |
| conv2d_36 | [1, 128, 4, 4] | [1, 128, 4, 4] | 147456 | 2359296 |
| batch_norm2d_8 | [1, 128, 4, 4] | [1, 128, 4, 4] | 512 | 4096 |
| batch_norm2d_9 | [1, 128, 4, 4] | [1, 128, 4, 4] | 512 | 4096 |
| conv2d_37 | [1, 128, 4, 4] | [1, 256, 2, 2] | 294912 | 1179648 |
| conv2d_38 | [1, 256, 2, 2] | [1, 256, 2, 2] | 589824 | 2359296 |
| conv2d_39 | [1, 128, 4, 4] | [1, 256, 2, 2] | 32768 | 131072 |
| batch_norm2d_10 | [1, 256, 2, 2] | [1, 256, 2, 2] | 1024 | 2048 |
| batch_norm2d_11 | [1, 256, 2, 2] | [1, 256, 2, 2] | 1024 | 2048 |
| batch_norm2d_12 | [1, 256, 2, 2] | [1, 256, 2, 2] | 1024 | 2048 |
| conv2d_40 | [1, 256, 2, 2] | [1, 256, 2, 2] | 589824 | 2359296 |
| conv2d_41 | [1, 256, 2, 2] | [1, 256, 2, 2] | 589824 | 2359296 |
| batch_norm2d_13 | [1, 256, 2, 2] | [1, 256, 2, 2] | 1024 | 2048 |
| batch_norm2d_14 | [1, 256, 2, 2] | [1, 256, 2, 2] | 1024 | 2048 |
| conv2d_42 | [1, 256, 2, 2] | [1, 512, 1, 1] | 1179648 | 1179648 |
| conv2d_43 | [1, 512, 1, 1] | [1, 512, 1, 1] | 2359296 | 2359296 |
| conv2d_44 | [1, 256, 2, 2] | [1, 512, 1, 1] | 131072 | 131072 |
| batch_norm2d_15 | [1, 512, 1, 1] | [1, 512, 1, 1] | 2048 | 1024 |
| batch_norm2d_16 | [1, 512, 1, 1] | [1, 512, 1, 1] | 2048 | 1024 |
| batch_norm2d_17 | [1, 512, 1, 1] | [1, 512, 1, 1] | 2048 | 1024 |
| conv2d_45 | [1, 512, 1, 1] | [1, 512, 1, 1] | 2359296 | 2359296 |
| conv2d_46 | [1, 512, 1, 1] | [1, 512, 1, 1] | 2359296 | 2359296 |
| batch_norm2d_18 | [1, 512, 1, 1] | [1, 512, 1, 1] | 2048 | 1024 |
| batch_norm2d_19 | [1, 512, 1, 1] | [1, 512, 1, 1] | 2048 | 1024 |
| adaptive_avg_pool2d_0 | [1, 512, 1, 1] | [1, 512, 1, 1] | 0 | 1024 |
| flatten_0 | [1, 512, 1, 1] | [1, 512] | 0 | 0 |
| linear_14 | [1, 512] | [1, 10] | 5130 | 5120 |
+-----------------------+-----------------+-----------------+---------+---------+
Total Flops: 35529728 Total Params: 11185034
35529728

==验证残差连接对卷积神经网络的促进作用:==

为了验证残差连接对深层卷积神经网络的训练可以起到促进作用,接下来先使用ResNet18(use_residual设置为False)进行手写数字识别实验,再添加残差连接(use_residual设置为True),观察实验对比效果。

alec:

resnet18,包含输入1个卷积层、8个残差单元(16个卷积层)、1个全连接层,合计18层


[√] 5.4.2 - 没有残差连接的ResNet18


为了验证残差连接的效果,先使用没有残差连接的ResNet18进行实验。


[√] 5.4.2.1 - 模型训练

使用训练集和验证集进行模型训练,共训练5个epoch。在实验中,保存准确率最高的模型作为最佳模型。代码实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from nndl import plot

paddle.seed(100)
# 学习率大小
lr = 0.005
# 批次大小
batch_size = 64
# 加载数据
train_loader = io.DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
dev_loader = io.DataLoader(dev_dataset, batch_size=batch_size)
test_loader = io.DataLoader(test_dataset, batch_size=batch_size)
# 定义网络,不使用残差结构的深层网络
model = Model_ResNet18(in_channels=1, num_classes=10, use_residual=False)
# 定义优化器
optimizer = opt.SGD(learning_rate=lr, parameters=model.parameters())
# 实例化RunnerV3
runner = RunnerV3(model, optimizer, loss_fn, metric)
# 启动训练
log_steps = 15
eval_steps = 15
runner.train(train_loader, dev_loader, num_epochs=5, log_steps=log_steps,
eval_steps=eval_steps, save_path="best_model.pdparams")
# 可视化观察训练集与验证集的Loss变化情况
plot_training_loss_acc(runner, 'cnn-loss2.pdf')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[Train] epoch: 0/5, step: 0/80, loss: 2.66104
[Train] epoch: 0/5, step: 15/80, loss: 1.84262
[Evaluate] dev score: 0.21500, dev loss: 2.21904
/opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages/paddle/nn/layer/norm.py:653: UserWarning: When training, we now always track global mean and variance.
"When training, we now always track global mean and variance.")
[Evaluate] best accuracy performence has been updated: 0.00000 --> 0.21500
[Train] epoch: 1/5, step: 30/80, loss: 1.14590
[Evaluate] dev score: 0.57500, dev loss: 1.42718
[Evaluate] best accuracy performence has been updated: 0.21500 --> 0.57500
[Train] epoch: 2/5, step: 45/80, loss: 0.51383
[Evaluate] dev score: 0.77500, dev loss: 0.80278
[Evaluate] best accuracy performence has been updated: 0.57500 --> 0.77500
[Train] epoch: 3/5, step: 60/80, loss: 0.39311
[Evaluate] dev score: 0.80500, dev loss: 0.60274
[Evaluate] best accuracy performence has been updated: 0.77500 --> 0.80500
[Train] epoch: 4/5, step: 75/80, loss: 0.12544
[Evaluate] dev score: 0.86500, dev loss: 0.53689
[Evaluate] best accuracy performence has been updated: 0.80500 --> 0.86500
[Evaluate] dev score: 0.80500, dev loss: 0.54756
[Train] Training done!

image-20221218175932196


[√] 5.4.2.2 - 模型评价

使用测试数据对在训练过程中保存的最佳模型进行评价,观察模型在测试集上的准确率以及损失情况。代码实现如下

1
2
3
4
5
# 加载最优模型
runner.load_model('best_model.pdparams')
# 模型评价
score, loss = runner.evaluate(test_loader)
print("[Test] accuracy/loss: {:.4f}/{:.4f}".format(score, loss))
1
[Test] accuracy/loss: 0.8300/0.5282

从输出结果看,对比LeNet-5模型评价实验结果,准确率下降、损失升高。得出结论,网络层级加深后,训练效果不升反降。

lenet5的评价分数:

[Test] accuracy/loss: 0.8600/0.4435


[√] 5.4.3 - 带残差连接的ResNet18


[√] 5.4.3.1 - 模型训练

使用带残差连接的ResNet18重复上面的实验,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 学习率大小
lr = 0.01
# 批次大小
batch_size = 64
# 加载数据
train_loader = io.DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
dev_loader = io.DataLoader(dev_dataset, batch_size=batch_size)
test_loader = io.DataLoader(test_dataset, batch_size=batch_size)
# 定义网络,通过指定use_residual为True,使用残差结构的深层网络
model = Model_ResNet18(in_channels=1, num_classes=10, use_residual=True)
# 定义优化器
optimizer = opt.SGD(learning_rate=lr, parameters=model.parameters())
# 实例化RunnerV3
runner = RunnerV3(model, optimizer, loss_fn, metric)
# 启动训练
log_steps = 15
eval_steps = 15
runner.train(train_loader, dev_loader, num_epochs=5, log_steps=log_steps,
eval_steps=eval_steps, save_path="best_model.pdparams")

# 可视化观察训练集与验证集的Loss变化情况
plot_training_loss_acc(runner, 'cnn-loss3.pdf')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[Train] epoch: 0/5, step: 0/80, loss: 2.93406
[Train] epoch: 0/5, step: 15/80, loss: 0.74154
[Evaluate] dev score: 0.56500, dev loss: 1.40920
[Evaluate] best accuracy performence has been updated: 0.00000 --> 0.56500
[Train] epoch: 1/5, step: 30/80, loss: 0.20007
[Evaluate] dev score: 0.80000, dev loss: 0.55951
[Evaluate] best accuracy performence has been updated: 0.56500 --> 0.80000
[Train] epoch: 2/5, step: 45/80, loss: 0.02310
[Evaluate] dev score: 0.84500, dev loss: 0.41989
[Evaluate] best accuracy performence has been updated: 0.80000 --> 0.84500
[Train] epoch: 3/5, step: 60/80, loss: 0.01396
[Evaluate] dev score: 0.84500, dev loss: 0.41457
[Train] epoch: 4/5, step: 75/80, loss: 0.01147
[Evaluate] dev score: 0.83500, dev loss: 0.40551
[Evaluate] dev score: 0.84500, dev loss: 0.40260
[Train] Training done!

image-20221218180737876


[√] 5.4.3.2 - 模型评价

使用测试数据对在训练过程中保存的最佳模型进行评价,观察模型在测试集上的准确率以及损失情况。

1
2
3
4
5
# 加载最优模型
runner.load_model('best_model.pdparams')
# 模型评价
score, loss = runner.evaluate(test_loader)
print("[Test] accuracy/loss: {:.4f}/{:.4f}".format(score, loss))
1
[Test] accuracy/loss: 0.8950/0.4043

image-20221218180923475

对比lenet5和不加残差连接的resnet18,模型的准确率和损失都有提升。


[√] 5.4.4 - 与高层API实现版本的对比实验


对于Reset18这种比较经典的图像分类网络,飞桨高层API中都为大家提供了实现好的版本,大家可以不再从头开始实现。这里为高层API版本的resnet18模型和自定义的resnet18模型赋予相同的权重,并使用相同的输入数据,观察输出结果是否一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from paddle.vision.models import resnet18
import warnings
#warnings.filterwarnings("ignore")

# 使用飞桨HAPI中实现的resnet18模型,该模型默认输入通道数为3,输出类别数1000
hapi_model = resnet18()
# 自定义的resnet18模型
model = Model_ResNet18(in_channels=3, num_classes=1000, use_residual=True)

# 获取网络的权重
params = hapi_model.state_dict()
# 用来保存参数名映射后的网络权重
new_params = {}
# 将参数名进行映射
for key in params:
if 'layer' in key:
if 'downsample.0' in key:
new_params['net.' + key[5:8] + '.shortcut' + key[-7:]] = params[key]
elif 'downsample.1' in key:
new_params['net.' + key[5:8] + '.shorcutt' + key[23:]] = params[key]
else:
new_params['net.' + key[5:]] = params[key]
elif 'conv1.weight' == key:
new_params['net.0.0.weight'] = params[key]
elif 'bn1' in key:
new_params['net.0.1' + key[3:]] = params[key]
elif 'fc' in key:
new_params['net.7' + key[2:]] = params[key]

# 将飞桨HAPI中实现的resnet18模型的权重参数赋予自定义的resnet18模型,保持两者一致
model.set_state_dict(new_params)

# 这里用np.random创建一个随机数组作为测试数据
inputs = np.random.randn(*[1,3,32,32])
inputs = inputs.astype('float32')
x = paddle.to_tensor(inputs)

output = model(x)
hapi_out = hapi_model(x)

# 计算两个模型输出的差异
diff = output - hapi_out
# 取差异最大的值
max_diff = paddle.max(diff)
print(max_diff)
1
2
Tensor(shape=[1], dtype=float32, place=CUDAPlace(0), stop_gradient=False,
[0.])

可以看到,高层API版本的resnet18模型和自定义的resnet18模型输出结果是一致的,也就说明两个模型的实现完全一样。