作者: 阿斯顿·张(Aston Zhang) 李沐(Mu Li) [美] 扎卡里·C. 立顿(Zachary C. Lipton) [德] 亚历山大·J. 斯莫拉(Alexander J. Smola)
作者: 阿斯顿·张(Aston Zhang) 李沐(Mu Li) [美] 扎卡里·C. 立顿(Zachary C. Lipton) [德] 亚历山大·J. 斯莫拉(Alexander J. Smola)
本章将介绍卷积神经网络,它是近年来深度学习能在计算机视觉领域取得突破性成果的基石。它也逐渐在被其他诸如自然语言处理、推荐系统和语音识别等领域广泛使用。我们将先描述卷积神经网络中卷积层和池化层的工作原理,并解释填充、步幅、输入通道和输出通道的含义。在掌握了这些基础知识以后,我们将探究数个具有代表性的深度卷积神经网络的设计思路。这些模型包括最早提出的AlexNet,以及后来的使用重复元素的网络(VGG)、网络中的网络(NiN)、含并行连结的网络(GoogLeNet)、残差网络(ResNet)和稠密连接网络(DenseNet)。它们中有不少在过去几年的ImageNet比赛(一个著名的计算机视觉竞赛)中大放异彩。虽然深度模型看上去只是具有很多层的神经网络,然而获得有效的深度模型并不容易。有幸的是,本章阐述的批量归一化和残差网络为训练和设计深度模型提供了两类重要思路。
##5.1 二维卷积层
{-:-}扫码直达讨论区
卷积神经网络(convolutional neural network)是含有卷积层(convolutional layer)的神经网络。本章中介绍的卷积神经网络均使用最常见的二维卷积层。它有高和宽两个空间维度,常用来处理图像数据。本节中,我们将介绍简单形式的二维卷积层的工作原理。
###5.1.1 二维互相关运算
虽然卷积层得名于卷积(convolution)运算,但我们通常在卷积层中使用更加直观的互相关(cross-correlation)运算。在二维卷积层中,一个二维输入数组和一个二维核(kernel)数组通过互相关运算输出一个二维数组。我们用一个具体例子来解释二维互相关运算的含义。如图5-1所示,输入是一个高和宽均为3的二维数组。我们将该数组的形状记为3×3或(3, 3)。核数组的高和宽分别为2。该数组在卷积计算中又称卷积核或过滤器(filter)。卷积核窗口(又称卷积窗口)的形状取决于卷积核的高和宽,即2×2。图5-1中的阴影部分为第一个输出元素及其计算所使用的输入和核数组元素:0×0+1×1+3×2+4×3=19。
图5-1 二维互相关运算
在二维互相关运算中,卷积窗口从输入数组的最左上方开始,按从左往右、从上往下的顺序,依次在输入数组上滑动。当卷积窗口滑动到某一位置时,窗口中的输入子数组与核数组按元素相乘并求和,得到输出数组中相应位置的元素。图5-1中的输出数组的高和宽分别为2,其中的4个元素由二维互相关运算得出:
{-:-}
下面我们将上述过程实现在corr2d
函数里。它接受输入数组X
与核数组K
,并输出数组Y
。
In [1]: from mxnet import autograd, nd
from mxnet.gluon import nn
def corr2d(X, K): # 本函数已保存在d2lzh包中方便以后使用
h, w = K.shape
Y = nd.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1))
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
Y[i, j] = (X[i: i + h, j: j + w] * K).sum()
return Y
我们可以构造图5-1中的输入数组X
、核数组K
来验证二维互相关运算的输出。
In [2]: X = nd.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]])
K = nd.array([[0, 1], [2, 3]])
corr2d(X, K)
Out[2]:
[[19. 25.]
[37. 43.]]
<NDArray 2x2 @cpu(0)>
###5.1.2 二维卷积层
二维卷积层将输入和卷积核做互相关运算,并加上一个标量偏差来得到输出。卷积层的模型参数包括了卷积核和标量偏差。在训练模型的时候,通常我们先对卷积核随机初始化,然后不断迭代卷积核和偏差。
下面基于corr2d
函数来实现一个自定义的二维卷积层。在构造函数init
里,我们声明weight
和bias
这两个模型参数。前向计算函数forward
则是直接调用corr2d
函数再加上偏差。
In [3]: class Conv2D(nn.Block):
def __init__(self, kernel_size, **kwargs):
super(Conv2D, self).__init__(**kwargs)
self.weight = self.params.get('weight', shape=kernel_size)
self.bias = self.params.get('bias', shape=(1,))
def forward(self, x):
return corr2d(x, self.weight.data()) + self.bias.data()
卷积窗口形状为p×q的卷积层称为p×q卷积层。同样,p×q卷积或p×q卷积核说明卷积核的高和宽分别为p和q。
###5.1.3 图像中物体边缘检测
下面我们来看一个卷积层的简单应用——检测图像中物体的边缘,即找到像素变化的位置。首先我们构造一张6×8的图像(即高和宽分别为6像素和8像素的图像)。它中间4列为黑(0),其余为白(1)。
In [4]: X = nd.ones((6, 8))
X[:, 2:6] = 0
X
Out[4]:
[[1. 1. 0. 0. 0. 0. 1. 1.]
[1. 1. 0. 0. 0. 0. 1. 1.]
[1. 1. 0. 0. 0. 0. 1. 1.]
[1. 1. 0. 0. 0. 0. 1. 1.]
[1. 1. 0. 0. 0. 0. 1. 1.]
[1. 1. 0. 0. 0. 0. 1. 1.]]
<NDArray 6x8 @cpu(0)>
然后我们构造一个高和宽分别为1和2的卷积核K
。当它与输入做互相关运算时,如果横向相邻元素相同,输出为0;否则输出为非0。
In [5]: K = nd.array([[1, -1]])
下面将输入X
和我们设计的卷积核K
做互相关运算。可以看出,我们将从白到黑的边缘和从黑到白的边缘分别检测成了1和-1。其余部分的输出全是0。
In [6]: Y = corr2d(X, K)
Y
Out[6]:
[[ 0. 1. 0. 0. 0. -1. 0.]
[ 0. 1. 0. 0. 0. -1. 0.]
[ 0. 1. 0. 0. 0. -1. 0.]
[ 0. 1. 0. 0. 0. -1. 0.]
[ 0. 1. 0. 0. 0. -1. 0.]
[ 0. 1. 0. 0. 0. -1. 0.]]
<NDArray 6x7 @cpu(0)>
由此,我们可以看出,卷积层可通过重复使用卷积核有效地表征局部空间。
###5.1.4 通过数据学习核数组
最后我们来看一个例子,它使用物体边缘检测中的输入数据X
和输出数据Y
来学习我们构造的核数组K
。我们首先构造一个卷积层,将其卷积核初始化成随机数组。接下来在每一次迭代中,我们使用平方误差来比较Y
和卷积层的输出,然后计算梯度来更新权重。简单起见,这里的卷积层忽略了偏差。
虽然我们之前构造了Conv2D
类,但由于corr2d
使用了对单个元素赋值([i, j]=
)的操作因而无法自动求梯度。下面我们使用Gluon提供的Conv2D
类来实现这个例子。
In [7]: # 构造一个输出通道数为1(将在5.3节介绍通道), 核数组形状是(1, 2)的二维卷积层
conv2d = nn.Conv2D(1, kernel_size=(1, 2))
conv2d.initialize()
# 二维卷积层使用4维输入输出, 格式为(样本, 通道, 高, 宽), 这里批量大小(批量中的样本数)和通
# 道数均为1
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
for i in range(10):
with autograd.record():
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2
l.backward()
# 简单起见, 这里忽略了偏差
conv2d.weight.data()[:] -= 3e-2 * conv2d.weight.grad()
if (i + 1) % 2 == 0:
print('batch %d, loss %.3f' % (i + 1, l.sum().asscalar()))
batch 2, loss 4.949
batch 4, loss 0.831
batch 6, loss 0.140
batch 8, loss 0.024
batch 10, loss 0.004
可以看到,10次迭代后误差已经降到了一个比较小的值。现在来看一下学习到的核数组。
In [8]: conv2d.weight.data().reshape((1, 2))
Out[8]:
[[ 0.9895 -0.9873705]]
<NDArray 1x2 @cpu(0)>
可以看到,学习到的核数组与我们之前定义的核数组K
较接近。
###5.1.5 互相关运算和卷积运算
实际上,卷积运算与互相关运算类似。为了得到卷积运算的输出,我们只需将核数组左右翻转并上下翻转,再与输入数组做互相关运算。可见,卷积运算和互相关运算虽然类似,但如果它们使用相同的核数组,对于同一个输入,输出往往并不相同。
那么,你也许会好奇卷积层为何能使用互相关运算替代卷积运算。其实,在深度学习中核数组都是学习出来的:卷积层无论使用互相关运算或卷积运算都不影响模型预测时的输出。为了解释这一点,假设卷积层使用互相关运算学习出图5-1中的核数组。设其他条件不变,使用卷积运算学习出的核数组即图5-1中的核数组按上下、左右翻转。也就是说,图5-1中的输入与学习出的已翻转的核数组再做卷积运算时,依然得到图5-1中的输出。为了与大多数深度学习文献一致,如无特别说明,本书中提到的卷积运算均指互相关运算。
###5.1.6 特征图和感受野
二维卷积层输出的二维数组可以看作输入在空间维度(宽和高)上某一级的表征,也叫特征图(feature map)。影响元素x的前向计算的所有可能输入区域(可能大于输入的实际尺寸)叫作x的感受野(receptive field)。以图5-1为例,输入中阴影部分的4个元素是输出中阴影部分元素的感受野。我们将图5-1中形状为2×2的输出记为Y,并考虑一个更深的卷积神经网络:将Y与另一个形状为2×2的核数组做互相关运算,输出单个元素z。那么,z在Y上的感受野包括Y的全部4个元素,在输入上的感受野包括其中全部9个元素。可见,我们可以通过更深的卷积神经网络使特征图中单个元素的感受野变得更加广阔,从而捕捉输入上更大尺寸的特征。
我们常使用“元素”一词来描述数组或矩阵中的成员。在神经网络的术语中,这些元素也可称为“单元”。当含义明确时,本书不对这两个术语做严格区分。
小结
- 二维卷积层的核心计算是二维互相关运算。在最简单的形式下,它对二维输入数据和卷积核做互相关运算然后加上偏差。
练习
(1)构造一个输入图像
X
,令它有水平方向的边缘。如何设计卷积核K
来检测图像中水平边缘?如果是对角方向的边缘呢?(2)试着对我们自己构造的
Conv2D
类进行自动求梯度,会有什么样的错误信息?在该类的forward
函数里,将corr2d
函数替换成nd.Convolution
类使得自动求梯度变得可行。(3)如何通过变化输入和核数组将互相关运算表示成一个矩阵乘法?
(4)如何构造一个全连接层来进行物体边缘检测?
##5.2 填充和步幅
{-:-}扫码直达讨论区
在5.1节的例子里,我们使用高和宽为3的输入与高和宽为2的卷积核得到高和宽为2的输出。一般来说,假设输入形状是,卷积核窗口形状是
,那么输出形状将会是
{-:-}
所以卷积层的输出形状由输入形状和卷积核窗口形状决定。本节我们将介绍卷积层的两个超参数,即填充和步幅。它们可以对给定形状的输入和卷积核改变输出形状。
###5.2.1 填充
填充(padding)是指在输入高和宽的两侧填充元素(通常是0元素)。图5-2里我们在原输入高和宽的两侧分别添加了值为0的元素,使得输入高和宽从3变成了5,并导致输出高和宽由2增加到4。图5-2中的阴影部分为第一个输出元素及其计算所使用的输入和核数组元素:。
图5-2 在输入的高和宽两侧分别填充了0元素的二维互相关计算
一般来说,如果在高的两侧一共填充ph行,在宽的两侧一共填充pw列,那么输出形状将会是
{-:-}
也就是说,输出的高和宽会分别增加ph和pw。
在很多情况下,我们会设置和
来使输入和输出具有相同的高和宽。这样会方便在构造网络时推测每个层的输出形状。假设这里kh是奇数,我们会在高的两侧分别填充 ph / 2行。如果kh是偶数,一种可能是在输入的顶端一侧填充
行,而在底端一侧填充
行。在宽的两侧填充同理。
卷积神经网络经常使用奇数高和宽的卷积核,如1、3、5和7,所以两端上的填充个数相等。对任意的二维数组X
,设它的第i
行第j
列的元素为X[i, j]
。当两端上的填充个数相等,并使输入和输出具有相同的高和宽时,我们就知道输出Y[i, j]
是由输入以X[i, j]
为中心的窗口同卷积核进行互相关计算得到的。
下面的例子里我们创建一个高和宽为3的二维卷积层,然后设输入高和宽两侧的填充数分别为1。给定一个高和宽为8的输入,我们发现输出的高和宽也是8。
In [1]: from mxnet import nd
from mxnet.gluon import nn
# 定义一个函数来计算卷积层。它初始化卷积层权重, 并对输入和输出做相应的升维和降维
def comp_conv2d(conv2d, X):
conv2d.initialize()
# (1, 1)代表批量大小和通道数(5.3节将介绍)均为1
X = X.reshape((1, 1) + X.shape)
Y = conv2d(X)
return Y.reshape(Y.shape[2:]) # 排除不关心的前两维:批量和通道
# 注意这里是两侧分别填充1行或列, 所以在两侧一共填充2行或列
conv2d = nn.Conv2D(1, kernel_size=3, padding=1)
X = nd.random.uniform(shape=(8, 8))
comp_conv2d(conv2d, X).shape
Out[1]: (8, 8)
当卷积核的高和宽不同时,我们也可以通过设置高和宽上不同的填充数使输出和输入具有相同的高和宽。
In [2]: # 使用高为5、宽为3的卷积核。在高和宽两侧的填充数分别为2和1
conv2d = nn.Conv2D(1, kernel_size=(5, 3), padding=(2, 1))
comp_conv2d(conv2d, X).shape
Out[2]: (8, 8)
###5.2.2 步幅
在5.1节里我们介绍了二维互相关运算。卷积窗口从输入数组的最左上方开始,按从左往右、从上往下的顺序,依次在输入数组上滑动。我们将每次滑动的行数和列数称为步幅(stride)。
目前我们看到的例子里,在高和宽两个方向上步幅均为 1。我们也可以使用更大步幅。图 5-3 展示了在高上步幅为 3、在宽上步幅为 2 的二维互相关运算。可以看到,输出第一列第二个元素时,卷积窗口向下滑动了3行,而在输出第一行第二个元素时卷积窗口向右滑动了2列。当卷积窗口在输入上再向右滑动2列时,由于输入元素无法填满窗口,无结果输出。图5-3中的阴影部分为输出元素及其计算所使用的输入和核数组元素:、
。
图5-3 高和宽上步幅分别为3和2的二维互相关运算
一般来说,当高上步幅为,宽上步幅为
时,输出形状为
{-:-}
如果设置 和
,那么输出形状将简化为
。
更进一步,如果输入的高和宽能分别被高和宽上的步幅整除,那么输出形状将是
。
下面我们令高和宽上的步幅均为2,从而使输入的高和宽减半。
In [3]: conv2d = nn.Conv2D(1, kernel_size=3, padding=1, strides=2)
comp_conv2d(conv2d, X).shape
Out[3]: (4, 4)
接下来是一个稍微复杂点儿的例子。
In [4]: conv2d = nn.Conv2D(1, kernel_size=(3, 5), padding=(0, 1), strides=(3, 4))
comp_conv2d(conv2d, X).shape
Out[4]: (2, 2)
为了表述简洁,当输入的高和宽两侧的填充数分别为和
时,我们称填充为(
)。特别地,当
时,填充为p。当在高和宽上的步幅分别为
和
时,我们称步幅为(
)。特别地,当
时,步幅为s。在默认情况下,填充为0,步幅为1。
小结
- 填充可以增加输出的高和宽。这常用来使输出与输入具有相同的高和宽。
练习
(1)对本节最后一个例子通过形状计算公式来计算输出形状,看看是否和实验结果一致。
(2)在本节实验中,试一试其他的填充和步幅组合。
##5.3 多输入通道和多输出通道
{-:-}扫码直达讨论区
5.1节和5.2节里我们用到的输入和输出都是二维数组,但真实数据的维度经常更高。例如,彩色图像在高和宽2个维度外还有RGB(红、绿、蓝)3个颜色通道。假设彩色图像的高和宽分别是h和w(像素),那么它可以表示为一个 的多维数组。我们将大小为3的这一维称为通道(channel)维。本节我们将介绍含多个输入通道或多个输出通道的卷积核。
###5.3.1 多输入通道
当输入数据含多个通道时,我们需要构造一个输入通道数与输入数据的通道数相同的卷积核,从而能够与含多通道的输入数据做互相关运算。假设输入数据的通道数为ci,那么卷积核的输入通道数同样为ci。设卷积核窗口形状为。当
时,我们知道卷积核只包含一个形状为
的二维数组。当
时,我们将会为每个输入通道各分配一个形状为
的核数组。把这ci个数组在输入通道维上连结,即得到一个形状为
的卷积核。由于输入和卷积核各有ci个通道,我们可以在各个通道上对输入的二维数组和卷积核的二维核数组做互相关运算,再将这ci个互相关运算的二维输出按通道相加,得到一个二维数组。这就是含多个通道的输入数据与多输入通道的卷积核做二维互相关运算的输出。
图5-4展示了含2个输入通道的二维互相关计算的例子。在每个通道上,二维输入数组与二维核数组做互相关运算,再按通道相加即得到输出。图5-4中阴影部分为第一个输出元素及其计算所使用的输入和核数组元素:。
图5-4 含2个输入通道的互相关计算
接下来我们实现含多个输入通道的互相关运算。我们只需要对每个通道做互相关运算,然后通过add_n
函数来进行累加。
In [1]: import d2lzh as d2l
from mxnet import nd
def corr2d_multi_in(X, K):
# 首先沿着X和K的第0维(通道维)遍历。然后使用*将结果列表变成add_n函数的位置参数
# (positional argument)来进行相加
return nd.add_n(*[d2l.corr2d(x, k) for x, k in zip(X, K)])
我们可以构造图 5-4 中的输入数组X
、核数组K
来验证互相关运算的输出。
In [2]: X = nd.array([[[0, 1, 2], [3, 4, 5], [6, 7, 8]],
[[1, 2, 3], [4, 5, 6], [7, 8, 9]]])
K = nd.array([[[0, 1], [2, 3]], [[1, 2], [3, 4]]])
corr2d_multi_in(X, K)
Out[2]:
[[ 56. 72.]
[104. 120.]]
<NDArray 2x2 @cpu(0)>
###5.3.2 多输出通道
当输入通道有多个时,因为我们对各个通道的结果做了累加,所以不论输入通道数是多少,输出通道数总是为1。设卷积核输入通道数和输出通道数分别为和
,高和宽分别为
和
。如果希望得到含多个通道的输出,我们可以为每个输出通道分别创建形状为
的核数组。将它们在输出通道维上连结,卷积核的形状即
。在做互相关运算时,每个输出通道上的结果由卷积核在该输出通道上的核数组与整个输入数组计算而来。
下面我们实现一个互相关运算函数来计算多个通道的输出。
In [3]: def corr2d_multi_in_out(X, K):
# 对K的第0维遍历, 每次同输入X做互相关计算。所有结果使用stack函数合并在一起
return nd.stack(*[corr2d_multi_in(X, k) for k in K])
我们将核数组K
同K1
(K
中每个元素加一)和K2
连结在一起来构造一个输出通道数为3的卷积核。
In [4]: K = nd.stack(K, K + 1, K + 2)
K.shape
Out[4]: (3, 2, 2, 2)
下面我们对输入数组X
与核数组K
做互相关运算。此时的输出含有3个通道,其中第一个通道的结果与之前输入数组X
与多输入通道、单输出通道核的计算结果一致。
In [5]: corr2d_multi_in_out(X, K)
Out[5]:
[[[ 56. 72.]
[104. 120.]]
[[ 76. 100.]
[148. 172.]]
[[ 96. 128.]
[192. 224.]]]
<NDArray 3x2x2 @cpu(0)>
###5.3.3 1×1卷积层
最后我们讨论卷积窗口形状为 的多通道卷积层。我们通常称之为 1× 卷积层,并将其中的卷积运算称为1×1 卷积。因为使用了最小窗口,1×1卷积失去了卷积层可以识别高和宽维度上相邻元素构成的模式的功能。实际上,1×1卷积的主要计算发生在通道维上。图5-5展示了使用输入通道数为3、输出通道数为2的1×1卷积核的互相关计算。值得注意的是,输入和输出具有相同的高和宽。输出中的每个元素来自输入中在高和宽上相同位置的元素在不同通道之间的按权重累加。假设我们将通道维当作特征维,将高和宽维度上的元素当成数据样本,那么1×1 卷积层的作用与全连接层等价。
图5-5 使用输入通道数为3、输出通道数为2的1 × 1卷积核的互相关计算。输入和输出具有相同的高和宽
下面我们使用全连接层中的矩阵乘法来实现1×1卷积。这里需要在矩阵乘法运算前后对数据形状做一些调整。
In [6]: def corr2d_multi_in_out_1x1(X, K):
c_i, h, w = X.shape
c_o = K.shape[0]
X = X.reshape((c_i, h * w))
K = K.reshape((c_o, c_i))
Y = nd.dot(K, X) # 全连接层的矩阵乘法
return Y.reshape((c_o, h, w))
经验证,做1×1卷积时,以上函数与之前实现的互相关运算函数corr2d_multi_in_out
等价。
In [7]: X = nd.random.uniform(shape=(3, 3, 3))
K = nd.random.uniform(shape=(2, 3, 1, 1))
Y1 = corr2d_multi_in_out_1x1(X, K)
Y2 = corr2d_multi_in_out(X, K)
(Y1 - Y2).norm().asscalar() < 1e-6
Out[7]: True
在之后的模型里我们将会看到1×1卷积层被当作保持高和宽维度形状不变的全连接层使用。于是,我们可以通过调整网络层之间的通道数来控制模型复杂度。
小结
- 使用多通道可以拓展卷积层的模型参数。
练习
(1)假设输入形状为
,且使用形状为
、填充为(
)、步幅为(
)的卷积核。那么这个卷积层的前向计算分别需要多少次乘法和加法?
(2)翻倍输入通道数
和输出通道数
会增加多少倍计算?翻倍填充呢?
(3)如果卷积核的高和宽
,能减少多少计算?
(4)本节最后一个例子中的变量
Y1
和Y2
完全一致吗?原因是什么?(5)当卷积窗口不为1×1时,如何用矩阵乘法实现卷积计算?
##5.4 池化层
{-:-}扫码直达讨论区
回忆一下,在5.1节里介绍的图像物体边缘检测应用中,我们构造卷积核从而精确地找到了像素变化的位置。设任意二维数组X
的i
行j
列的元素为X[i, j]
。如果我们构造的卷积核输出Y[i, j]=1
,那么说明输入中X[i, j]
和X[i, j+1]
数值不一样。这可能意味着物体边缘通过这两个元素之间。但实际图像里,我们感兴趣的物体不会总出现在固定位置:即使我们连续拍摄同一个物体也极有可能出现像素位置上的偏移。这会导致同一个边缘对应的输出可能出现在卷积输出Y
中的不同位置,进而对后面的模式识别造成不便。
在本节中我们介绍池化(pooling)层,它的提出是为了缓解卷积层对位置的过度敏感性。
###5.4.1 二维最大池化层和平均池化层
同卷积层一样,池化层每次对输入数据的一个固定形状窗口(又称池化窗口)中的元素计算输出。不同于卷积层里计算输入和核的互相关性,池化层直接计算池化窗口内元素的最大值或者平均值。该运算也分别叫作最大池化或平均池化。在二维最大池化中,池化窗口从输入数组的最左上方开始,按从左往右、从上往下的顺序,依次在输入数组上滑动。当池化窗口滑动到某一位置时,窗口中的输入子数组的最大值即输出数组中相应位置的元素。
图5-6 池化窗口形状为2 × 2的最大池化
图5-6展示了池化窗口形状为2×2的最大池化,阴影部分为第一个输出元素及其计算所使用的输入元素。输出数组的高和宽分别为2,其中的4个元素由取最大值运算max得出:
{-:-}
二维平均池化的工作原理与二维最大池化类似,但将最大运算符替换成平均运算符。池化窗口形状为的池化层称为
池化层,其中的池化运算叫作
池化。
让我们再次回到本节开始提到的物体边缘检测的例子。现在我们将卷积层的输出作为2×2最大池化的输入。设该卷积层输入是X
、池化层输出为Y
。无论是X[i, j]
和X[i, j+1]
值不同,还是X[i, j+1]
和X[i, j+2]
不同,池化层输出均有Y[i, j]=1
。也就是说,使用22最大池化层时,只要卷积层识别的模式在高和宽上移动不超过一个元素,我们依然可以将它检测出来。
下面把池化层的前向计算实现在pool2d
函数里。它与5.1节里corr2d
函数非常类似,唯一的区别在计算输出Y
上。
In [1]: from mxnet import nd
from mxnet.gluon import nn
def pool2d(X, pool_size, mode='max'):
p_h, p_w = pool_size
Y = nd.zeros((X.shape[0] - p_h + 1, X.shape[1] - p_w + 1))
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
if mode == 'max':
Y[i, j] = X[i: i + p_h, j: j + p_w].max()
elif mode == 'avg':
Y[i, j] = X[i: i + p_h, j: j + p_w].mean()
return Y
我们可以构造图5-6中的输入数组X
来验证二维最大池化层的输出。
In [2]: X = nd.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]])
pool2d(X, (2, 2))
Out[2]:
[[4. 5.]
[7. 8.]]
<NDArray 2x2 @cpu(0)>
同时我们实验一下平均池化层。
In [3]: pool2d(X, (2, 2), 'avg')
Out[3]:
[[2. 3.]
[5. 6.]]
<NDArray 2x2 @cpu(0)>
###5.4.2 填充和步幅
同卷积层一样,池化层也可以在输入的高和宽两侧的填充并调整窗口的移动步幅来改变输出形状。池化层填充和步幅与卷积层填充和步幅的工作机制一样。我们将通过nn
模块里的二维最大池化层MaxPool2D
来演示池化层填充和步幅的工作机制。我们先构造一个形状为(1, 1, 4, 4)的输入数据,前两个维度分别是批量和通道。
In [4]: X = nd.arange(16).reshape((1, 1, 4, 4))
X
Out[4]:
[[[[ 0. 1. 2. 3.]
[ 4. 5. 6. 7.]
[ 8. 9. 10. 11.]
[12. 13. 14. 15.]]]]
<NDArray 1x1x4x4 @cpu(0)>
默认情况下,MaxPool2D
实例里步幅和池化窗口形状相同。下面使用形状为(3, 3)的池化窗口,默认获得形状为(3, 3)的步幅。
In [5]: pool2d = nn.MaxPool2D(3)
pool2d(X) # 因为池化层没有模型参数, 所以不需要调用参数初始化函数
Out[5]:
[[[[10.]]]]
<NDArray 1x1x1x1 @cpu(0)>
我们可以手动指定步幅和填充。
In [6]: pool2d = nn.MaxPool2D(3, padding=1, strides=2)
pool2d(X)
Out[6]:
[[[[ 5. 7.]
[13. 15.]]]]
<NDArray 1x1x2x2 @cpu(0)>
当然,我们也可以指定非正方形的池化窗口,并分别指定高和宽上的填充和步幅。
In [7]: pool2d = nn.MaxPool2D((2, 3), padding=(1, 2), strides=(2, 3))
pool2d(X)
Out[7]:
[[[[ 0. 3.]
[ 8. 11.]
[12. 15.]]]]
<NDArray 1x1x3x2 @cpu(0)>
###5.4.3 多通道
在处理多通道输入数据时,池化层对每个输入通道分别池化,而不是像卷积层那样将各通道的输入按通道相加。这意味着池化层的输出通道数与输入通道数相等。下面我们将数组X
和X+1
在通道维上连结来构造通道数为2的输入。
In [8]: X = nd.concat(X, X + 1, dim=1)
X
Out[8]:
[[[[ 0. 1. 2. 3.]
[ 4. 5. 6. 7.]
[ 8. 9. 10. 11.]
[12. 13. 14. 15.]]
[[ 1. 2. 3. 4.]
[ 5. 6. 7. 8.]
[ 9. 10. 11. 12.]
[13. 14. 15. 16.]]]]
<NDArray 1x2x4x4 @cpu(0)>
池化后,我们发现输出通道数仍然是 2。
In [9]: pool2d = nn.MaxPool2D(3, padding=1, strides=2)
pool2d(X)
Out[9]:
[[[[ 5. 7.]
[13. 15.]]
[[ 6. 8.]
[14. 16.]]]]
<NDArray 1x2x2x2 @cpu(0)>
小结
- 最大池化和平均池化分别取池化窗口中输入元素的最大值和平均值作为输出。
练习
(1)分析池化层的计算复杂度。假设输入形状为
,我们使用形状为
的池化窗口,而且使用(
)填充和(
)步幅。这个池化层的前向计算复杂度有多大?
(2)想一想,最大池化层和平均池化层在作用上可能有哪些区别?
(3)你觉得最小池化层这个想法有没有意义?
##5.5 卷积神经网络(LeNet)
{-:-}扫码直达讨论区
在3.9节里我们构造了一个含单隐藏层的多层感知机模型来对Fashion-MNIST数据集中的图像进行分类。每张图像高和宽均是28像素。我们将图像中的像素逐行展开,得到长度为784的向量,并输入进全连接层中。然而,这种分类方法有一定的局限性。
(1)图像在同一列邻近的像素在这个向量中可能相距较远。它们构成的模式可能难以被模型识别。
(2)对于大尺寸的输入图像,使用全连接层容易造成模型过大。假设输入是高和宽均为1 000像素的彩色照片(含3个通道)。即使全连接层输出个数仍是256,该层权重参数的形状是3 000 000×256:它占用了大约3 GB的内存或显存。这带来过复杂的模型和过高的存储开销。
卷积层尝试解决这两个问题。一方面,卷积层保留输入形状,使图像的像素在高和宽两个方向上的相关性均可能被有效识别;另一方面,卷积层通过滑动窗口将同一卷积核与不同位置的输入重复计算,从而避免参数尺寸过大。
卷积神经网络就是含卷积层的网络。本节里我们将介绍一个早期用来识别手写数字图像的卷积神经网络——LeNet [31]。这个名字来源于LeNet论文的第一作者Yann LeCun。LeNet展示了通过梯度下降训练卷积神经网络可以达到手写数字识别在当时最先进的结果。这个奠基性的工作第一次将卷积神经网络推上舞台,为世人所知。
###5.5.1 LeNet模型
LeNet分为卷积层块和全连接层块两个部分。下面我们分别介绍这两个模块。
卷积层块里的基本单位是卷积层后接最大池化层:卷积层用来识别图像里的空间模式,如线条和物体局部,之后的最大池化层则用来降低卷积层对位置的敏感性。卷积层块由两个这样的基本单位重复堆叠构成。在卷积层块中,每个卷积层都使用5×5的窗口,并在输出上使用 sigmoid 激活函数。第一个卷积层输出通道数为6,第二个卷积层输出通道数则增加到16。这是因为第二个卷积层比第一个卷积层的输入的高和宽要小,所以增加输出通道使两个卷积层的参数尺寸类似。卷积层块的两个最大池化层的窗口形状均为2×2,且步幅为2。由于池化窗口与步幅形状相同,池化窗口在输入上每次滑动所覆盖的区域互不重叠。
卷积层块的输出形状为( 批量大小 , 通道 , 高 , 宽 )。当卷积层块的输出传入全连接层块时,全连接层块会将小批量中每个样本变平(flatten)。也就是说,全连接层的输入形状将变成二维,其中第一维是小批量中的样本,第二维是每个样本变平后的向量表示,且向量长度为通道、高和宽的乘积。全连接层块含3个全连接层。它们的输出个数分别是 120、84 和 10,其中 10 为输出的类别个数。
下面我们通过Sequential
类来实现 LeNet 模型。
In [1]: import d2lzh as d2l
import mxnet as mx
from mxnet import autograd, gluon, init, nd
from mxnet.gluon import loss as gloss, nn
import time
net = nn.Sequential()
net.add(nn.Conv2D(channels=6, kernel_size=5, activation='sigmoid'),
nn.MaxPool2D(pool_size=2, strides=2),
nn.Conv2D(channels=16, kernel_size=5, activation='sigmoid'),
nn.MaxPool2D(pool_size=2, strides=2),
# Dense会默认将(批量大小, 通道, 高, 宽)形状的输入转换成
# (批量大小, 通道 * 高 * 宽)形状的输入
nn.Dense(120, activation='sigmoid'),
nn.Dense(84, activation='sigmoid'),
nn.Dense(10))
接下来我们构造一个高和宽均为 28 的单通道数据样本,并逐层进行前向计算来查看每个层的输出形状。
In [2]: X = nd.random.uniform(shape=(1, 1, 28, 28))
net.initialize()
for layer in net:
X = layer(X)
print(layer.name, 'output shape:\t', X.shape)
conv0 output shape: (1, 6, 24, 24)
pool0 output shape: (1, 6, 12, 12)
conv1 output shape: (1, 16, 8, 8)
pool1 output shape: (1, 16, 4, 4)
dense0 output shape: (1, 120)
dense1 output shape: (1, 84)
dense2 output shape: (1, 10)
可以看到,在卷积层块中输入的高和宽在逐层减小。卷积层由于使用高和宽均为 5 的卷积核,从而将高和宽分别减小 4,而池化层则将高和宽减半,但通道数则从 1 增加到16。全连接层则逐层减少输出个数,直到变成图像的类别数 10。
###5.5.2 训练模型
下面我们来实验 LeNet 模型。实验中,我们仍然使用 Fashion-MNIST 作为训练数据集。
In [3]: batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)
因为卷积神经网络计算比多层感知机要复杂,建议使用GPU来加速计算。我们尝试在gpu(0)
上创建NDArray
,如果成功则使用gpu(0)
,否则仍然使用 CPU。
In [4]: def try_gpu(): # 本函数已保存在d2lzh包中方便以后使用
try:
ctx = mx.gpu()
_ = nd.zeros((1,), ctx=ctx)
except mx.base.MXNetError:
ctx = mx.cpu()
return ctx
ctx = try_gpu()
ctx
Out[4]: gpu(0)
相应地,我们对3.6节中描述的evaluate_accuracy
函数略作修改。由于数据刚开始存在CPU使用的内存上,当ctx
变量代表GPU及相应的显存时,我们通过4.6节中介绍的as_in_context
函数将数据复制到显存上,例如gpu(0)
。
In [5]: # 本函数已保存在d2lzh包中方便以后使用。该函数将被逐步改进:它的完整实现将在9.1节中描述
def evaluate_accuracy(data_iter, net, ctx):
acc_sum, n = nd.array([0], ctx=ctx), 0
for X, y in data_iter:
# 如果ctx代表GPU及相应的显存, 将数据复制到显存上
X, y = X.as_in_context(ctx), y.as_in_context(ctx).astype('f loat32')
acc_sum += (net(X).argmax(axis=1) == y).sum()
n += y.size
return acc_sum.asscalar() / n
我们同样对3.6节中定义的train_ch3
函数略作修改,确保计算使用的数据和模型同在内存或显存上。
In [6]: # 本函数已保存在d2lzh包中方便以后使用
def train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
num_epochs):
print('training on', ctx)
loss = gloss.SoftmaxCrossEntropyLoss()
for epoch in range(num_epochs):
train_l_sum, train_acc_sum, n, start = 0.0, 0.0, 0, time.time()
for X, y in train_iter:
X, y = X.as_in_context(ctx), y.as_in_context(ctx)
with autograd.record():
y_hat = net(X)
l = loss(y_hat, y).sum()
l.backward()
trainer.step(batch_size)
y = y.astype('f loat32')
train_l_sum += l.asscalar()
train_acc_sum += (y_hat.argmax(axis=1) == y).sum().asscalar()
n += y.size
test_acc = evaluate_accuracy(test_iter, net, ctx)
print('epoch %d, loss %.4f, train acc %.3f, test acc %.3f, '
'time %.1f sec'
% (epoch + 1, train_l_sum / n, train_acc_sum / n, test_acc,
time.time() - start))
我们重新将模型参数初始化到设备变量ctx
之上,并使用 Xavier 随机初始化。损失函数和训练算法则依然使用交叉熵损失函数和小批量随机梯度下降。
In [7]: lr, num_epochs = 0.9, 5
net.initialize(force_reinit=True, ctx=ctx, init=init.Xavier())
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr})
train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx, num_epochs)
training on gpu(0)
epoch 1, loss 2.3205, train acc 0.100, test acc 0.174, time 1.9 sec
epoch 2, loss 2.0349, train acc 0.215, test acc 0.505, time 1.7 sec
epoch 3, loss 0.9928, train acc 0.605, test acc 0.689, time 1.7 sec
epoch 4, loss 0.7733, train acc 0.700, test acc 0.731, time 1.7 sec
epoch 5, loss 0.6794, train acc 0.731, test acc 0.755, time 1.7 sec
小结
- 卷积神经网络就是含卷积层的网络。
练习
尝试基于LeNet构造更复杂的网络来提高分类准确率。例如,调整卷积窗口大小、输出通道数、激活函数和全连接层输出个数。在优化方面,可以尝试使用不同的学习率、初始化方法以及增加迭代周期。
##5.6 深度卷积神经网络(AlexNet)
{-:-}扫码直达讨论区
在 LeNet 提出后的将近20年里,神经网络一度被其他机器学习方法超越,如支持向量机。虽然 LeNet 可以在早期的小数据集上取得好的成绩,但是在更大的真实数据集上的表现并不尽如人意。一方面,神经网络计算复杂。虽然20世纪90年代也有过一些针对神经网络的加速硬件,但并没有像之后 GPU 那样大量普及。因此,训练一个多通道、多层和有大量参数的卷积神经网络在当年很难完成。另一方面,当年研究者还没有大量深入研究参数初始化和非凸优化算法等诸多领域,导致复杂的神经网络的训练通常较困难。
我们在上一节看到,神经网络可以直接基于图像的原始像素进行分类。这种称为端到端(end-to-end)的方法节省了很多中间步骤。然而,在很长一段时间里更流行的是研究者通过勤劳与智慧设计并生成的手工特征。这类图像分类研究的主要流程是:
(1)获取图像数据集;
(2)使用已有的特征提取函数生成图像的特征;
(3)使用机器学习模型对图像的特征分类。
当时认为的机器学习部分仅限最后这一步。如果那时候跟机器学习研究者交谈,他们会认为机器学习既重要又优美。优雅的定理证明了许多分类器的性质。机器学习领域生机勃勃、严谨而且极其有用。然而,如果跟计算机视觉研究者交谈,则是另外一幅景象。他们会告诉你图像识别里“不可告人”的现实是:计算机视觉流程中真正重要的是数据和特征。也就是说,使用较干净的数据集和较有效的特征甚至比机器学习模型的选择对图像分类结果的影响更大。
###5.6.1 学习特征表示
既然特征如此重要,它该如何表示呢?
我们已经提到,在相当长的时间里,特征都是基于各式各样手工设计的函数从数据中提取的。事实上,不少研究者通过提出新的特征提取函数不断改进图像分类结果。这一度为计算机视觉的发展做出了重要贡献。
然而,另一些研究者则持异议。他们认为特征本身也应该由学习得来。他们还相信,为了表征足够复杂的输入,特征本身应该分级表示。持这一想法的研究者相信,多层神经网络可能可以学得数据的多级表征,并逐级表示越来越抽象的概念或模式。以图像分类为例,并回忆5.1节中物体边缘检测的例子。在多层神经网络中,图像的第一级的表示可以是在特定的位置和角度是否出现边缘;而第二级的表示说不定能够将这些边缘组合出有趣的模式,如花纹;在第三级的表示中,也许上一级的花纹能进一步汇合成对应物体特定部位的模式。这样逐级表示下去,最终,模型能够较容易根据最后一级的表示完成分类任务。需要强调的是,输入的逐级表示由多层模型中的参数决定,而这些参数都是学出来的。
尽管一直有一群执着的研究者不断钻研,试图学习视觉数据的逐级表征,然而很长一段时间里这些野心都未能实现。这其中有诸多因素值得我们一一分析。
####1.缺失要素一:数据
包含许多特征的深度模型需要大量的有标签的数据才能表现得比其他经典方法更好。限于早期计算机有限的存储和20世纪90年代有限的研究预算,大部分研究只基于小的公开数据集。例如,不少研究论文基于加州大学欧文分校(UCI)提供的若干个公开数据集,其中许多数据集只有几百至几千张图像。这一状况在2010年前后兴起的大数据浪潮中得到改善。特别是,2009年诞生的ImageNet数据集包含了1 000大类物体,每类有多达数千张不同的图像。这一规模是当时其他公开数据集无法与之相提并论的。ImageNet 数据集同时推动计算机视觉和机器学习研究进入新的阶段,使此前的传统方法不再有优势。
####2.缺失要素二:硬件
深度学习对计算资源要求很高。早期的硬件计算能力有限,这使训练较复杂的神经网络变得很困难。然而,通用GPU的到来改变了这一格局。很久以来,GPU都是为图像处理和计算机游戏设计的,尤其是针对大吞吐量的矩阵和向量乘法,从而服务于基本的图形变换。值得庆幸的是,这其中的数学表达与深度网络中的卷积层的表达类似。通用GPU这个概念在2001年开始兴起,涌现出诸如OpenCL和CUDA之类的编程框架。这使得GPU也在2010年前后开始被机器学习社区使用。
###5.6.2 AlexNet
2012年AlexNet横空出世。这个模型的名字来源于论文第一作者的姓名Alex Krizhevsky[30]。AlexNet 使用了 8 层卷积神经网络,并以很大的优势赢得了ImageNet 2012图像识别挑战赛。它首次证明了学习到的特征可以超越手工设计的特征,从而一举打破计算机视觉研究的前状。
AlexNet与LeNet的设计理念非常相似,但也有显著的区别。
第一,与相对较小的LeNet相比,AlexNet包含8层变换,其中有5层卷积和2层全连接隐藏层,以及1个全连接输出层。下面我们来详细描述这些层的设计。
AlexNet第一层中的卷积窗口形状是11×11。因为ImageNet中绝大多数图像的高和宽均比MNIST图像的高和宽大10倍以上,ImageNet图像的物体占用更多的像素,所以需要更大的卷积窗口来捕获物体。第二层中的卷积窗口形状减小到5×5,之后全采用3×3。此外,第一、第二和第五个卷积层之后都使用了窗口形状为3×3、步幅为2的最大池化层。而且,AlexNet使用的卷积通道数也数十倍于LeNet中的卷积通道数。
紧接着最后一个卷积层的是两个输出个数为4 096的全连接层。这两个巨大的全连接层带来将近1 GB的模型参数。由于早期显存的限制,最早的AlexNet使用双数据流的设计使一块GPU只需要处理一半模型。幸运的是,显存在过去几年得到了长足的发展,因此通常我们不再需要这样的特别设计了。
第二,AlexNet将sigmoid激活函数改成了更加简单的ReLU激活函数。一方面,ReLU激活函数的计算更简单,例如它并没有sigmoid激活函数中的求幂运算。另一方面,ReLU激活函数在不同的参数初始化方法下使模型更容易训练。这是由于当sigmoid激活函数输出极接近0或1时,这些区域的梯度几乎为0,从而造成反向传播无法继续更新部分模型参数;而ReLU激活函数在正区间的梯度恒为1。因此,若模型参数初始化不当,sigmoid函数可能在正区间得到几乎为0的梯度,从而令模型无法得到有效训练。
第三,AlexNet通过丢弃法(参见3.13节)来控制全连接层的模型复杂度。而LeNet并没有使用丢弃法。
第四,AlexNet引入了大量的图像增广,如翻转、裁剪和颜色变化,从而进一步扩大数据集来缓解过拟合。我们将在后面的9.1节详细介绍这种方法。
下面我们实现稍微简化过的AlexNet。
In [1]: import d2lzh as d2l
from mxnet import gluon, init, nd
from mxnet.gluon import data as gdata, nn
import os
import sys
net = nn.Sequential()
# 使用较大的11 × 11窗口来捕获物体。同时使用步幅4来较大幅度减小输出高和宽。这里使用的输出通
# 道数比LeNet中的也要大很多
net.add(nn.Conv2D(96, kernel_size=11, strides=4, activation='relu'),
nn.MaxPool2D(pool_size=3, strides=2),
# 减小卷积窗口, 使用填充为2来使得输入与输出的高和宽一致, 且增大输出通道数
nn.Conv2D(256, kernel_size=5, padding=2, activation='relu'),
nn.MaxPool2D(pool_size=3, strides=2),
# 连续3个卷积层, 且使用更小的卷积窗口。除了最后的卷积层外, 进一步增大了输出通道数。
# 前两个卷积层后不使用池化层来减小输入的高和宽
nn.Conv2D(384, kernel_size=3, padding=1, activation='relu'),
nn.Conv2D(384, kernel_size=3, padding=1, activation='relu'),
nn.Conv2D(256, kernel_size=3, padding=1, activation='relu'),
nn.MaxPool2D(pool_size=3, strides=2),
# 这里全连接层的输出个数比LeNet中的大数倍。使用丢弃层来缓解过拟合
nn.Dense(4096, activation="relu"), nn.Dropout(0.5),
nn.Dense(4096, activation="relu"), nn.Dropout(0.5),
# 输出层。由于这里使用Fashion-MNIST, 所以用类别数为10, 而非论文中的1000
nn.Dense(10))
我们构造一个高和宽均为 224 的单通道数据样本来观察每一层的输出形状。
In [2]: X = nd.random.uniform(shape=(1, 1, 224, 224))
net.initialize()
for layer in net:
X = layer(X)
print(layer.name, 'output shape:\t', X.shape)
conv0 output shape: (1, 96, 54, 54)
pool0 output shape: (1, 96, 26, 26)
conv1 output shape: (1, 256, 26, 26)
pool1 output shape: (1, 256, 12, 12)
conv2 output shape: (1, 384, 12, 12)
conv3 output shape: (1, 384, 12, 12)
conv4 output shape: (1, 256, 12, 12)
pool2 output shape: (1, 256, 5, 5)
dense0 output shape: (1, 4096)
dropout0 output shape: (1, 4096)
dense1 output shape: (1, 4096)
dropout1 output shape: (1, 4096)
dense2 output shape: (1, 10)
###5.6.3 读取数据集
虽然论文中 AlexNet 使用 ImageNet 数据集,但因为 ImageNet 数据集训练时间较长,我们仍用前面的 Fashion-MNIST 数据集来演示AlexNet。读取数据的时候我们额外做了一步将图像高和宽扩大到 AlexNet 使用的图像高和宽—— 224。这个可以通过Resize
实例来实现。也就是说,我们在ToTensor
实例前使用Resize
实例,然后使用Compose
实例来将这两个变换串联以方便调用。
In [3]: # 本函数已保存在d2lzh包中方便以后使用
def load_data_fashion_mnist(batch_size, resize=None, root=os.path.join(
'~', '.mxnet', 'datasets', 'fashion-mnist')):
root = os.path.expanduser(root) # 展开用户路径'~'
transformer = []
if resize:
transformer += [gdata.vision.transforms.Resize(resize)]
transformer += [gdata.vision.transforms.ToTensor()]
transformer = gdata.vision.transforms.Compose(transformer)
mnist_train = gdata.vision.FashionMNIST(root=root, train=True)
mnist_test = gdata.vision.FashionMNIST(root=root, train=False)
num_workers = 0 if sys.platform.startswith('win32') else 4
train_iter = gdata.DataLoader(
mnist_train.transform_f irst(transformer), batch_size, shuff le=True,
num_workers=num_workers)
test_iter = gdata.DataLoader(
mnist_test.transform_f irst(transformer), batch_size, shuff le=False,
num_workers=num_workers)
return train_iter, test_iter
batch_size = 128
# 如出现“out of memory”的报错信息, 可减小batch_size或resize
train_iter, test_iter = load_data_fashion_mnist(batch_size, resize=224)
###5.6.4 训练模型
这时候我们可以开始训练AlexNet了。相对于5.5节的LeNet,这里的主要改动是使用了更小的学习率。
In [4]: lr, num_epochs, ctx = 0.01, 5, d2l.try_gpu()
net.initialize(force_reinit=True, ctx=ctx, init=init.Xavier())
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr})
d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx, num_epochs)
training on gpu(0)
epoch 1, loss 1.3030, train acc 0.510, test acc 0.767, time 18.5 sec
epoch 2, loss 0.6450, train acc 0.759, test acc 0.810, time 17.4 sec
epoch 3, loss 0.5298, train acc 0.803, test acc 0.831, time 17.4 sec
epoch 4, loss 0.4664, train acc 0.828, test acc 0.851, time 17.5 sec
epoch 5, loss 0.4252, train acc 0.845, test acc 0.867, time 17.3 sec
小结
- AlexNet 与 LeNet 结构类似,但使用了更多的卷积层和更大的参数空间来拟合大规模数据集 ImageNet。它是浅层神经网络和深度神经网络的分界线。
练习
(1)尝试增加迭代周期。跟 LeNet的结果相比,AlexNet的结果有什么区别?为什么?
(2)AlexNet对Fashion-MNIST 数据集来说可能过于复杂。试着简化模型来使训练更快,同时保证准确率不明显下降。
(3)修改批量大小,观察准确率和内存或显存的变化。
##5.7 使用重复元素的网络(VGG)
{-:-}扫码直达讨论区
AlexNet 在 LeNet 的基础上增加了3个卷积层。但 AlexNet 作者对它们的卷积窗口、输出通道数和构造顺序均做了大量的调整。虽然 AlexNet 指明了深度卷积神经网络可以取得出色的结果,但并没有提供简单的规则以指导后来的研究者如何设计新的网络。我们将在本章的后续几节里介绍几种不同的深度网络设计思路。
本节介绍VGG,它的名字来源于论文作者所在的实验室Visual Geometry Group [48]。VGG提出了可以通过重复使用简单的基础块来构建深度模型的思路。
###5.7.1 VGG块
VGG 块的组成规律是:连续使用数个相同的填充为1、窗口形状为3×3的卷积层后接上一个步幅为2、窗口形状为2×2的最大池化层。卷积层保持输入的高和宽不变,而池化层则对其减半。我们使用vgg_block
函数来实现这个基础的VGG块,它可以指定卷积层的数量num_convs
和输出通道数num_channels
。
In [1]: import d2lzh as d2l
from mxnet import gluon, init, nd
from mxnet.gluon import nn
def vgg_block(num_convs, num_channels):
blk = nn.Sequential()
for _ in range(num_convs):
blk.add(nn.Conv2D(num_channels, kernel_size=3,
padding=1, activation='relu'))
blk.add(nn.MaxPool2D(pool_size=2, strides=2))
return blk
###5.7.2 VGG网络
与AlexNet和LeNet 一样,VGG 网络由卷积层模块后接全连接层模块构成。卷积层模块串联数个vgg_block
,其超参数由变量conv_arch
定义。该变量指定了每个 VGG 块里卷积层个数和输出通道数。全连接模块则与 AlexNet 中的一样。
现在我们构造一个VGG网络。它有 5 个卷积块,前2块使用单卷积层,而后3块使用双卷积层。第一块的输出通道是 64,之后每次对输出通道数翻倍,直到变为 512。因为这个网络使用了 8 个卷积层和 3 个全连接层,所以经常被称为 VGG-11。
In [2]: conv_arch = ((1, 64), (1, 128), (2, 256), (2, 512), (2, 512))
下面我们实现 VGG-11。
In [3]: def vgg(conv_arch):
net = nn.Sequential()
# 卷积层部分
for (num_convs, num_channels) in conv_arch:
net.add(vgg_block(num_convs, num_channels))
# 全连接层部分
net.add(nn.Dense(4096, activation='relu'), nn.Dropout(0.5),
nn.Dense(4096, activation='relu'), nn.Dropout(0.5),
nn.Dense(10))
return net
net = vgg(conv_arch)
下面构造一个高和宽均为 224 的单通道数据样本来观察每一层的输出形状。
In [4]: net.initialize()
X = nd.random.uniform(shape=(1, 1, 224, 224))
for blk in net:
X = blk(X)
print(blk.name, 'output shape:\t', X.shape)
sequential1 output shape: (1, 64, 112, 112)
sequential2 output shape: (1, 128, 56, 56)
sequential3 output shape: (1, 256, 28, 28)
sequential4 output shape: (1, 512, 14, 14)
sequential5 output shape: (1, 512, 7, 7)
dense0 output shape: (1, 4096)
dropout0 output shape: (1, 4096)
dense1 output shape: (1, 4096)
dropout1 output shape: (1, 4096)
dense2 output shape: (1, 10)
可以看到,每次我们将输入的高和宽减半,直到最终高和宽变成 7 后传入全连接层。与此同时,输出通道数每次翻倍,直到变成 512。因为每个卷积层的窗口大小一样,所以每层的模型参数尺寸和计算复杂度与输入高、输入宽、输入通道数和输出通道数的乘积成正比。VGG 这种高和宽减半以及通道翻倍的设计使多数卷积层都有相同的模型参数尺寸和计算复杂度。
###5.7.3 训练模型
因为 VGG-11 计算上比 AlexNet 更加复杂,出于测试的目的我们构造一个通道数更小,或者说更窄的网络在 Fashion-MNIST 数据集上进行训练。
In [5]: ratio = 4
small_conv_arch = [(pair[0], pair[1] // ratio) for pair in conv_arch]
net = vgg(small_conv_arch)
除了使用了稍大些的学习率,模型训练过程与5.6节的AlexNet 中的类似。
In [6]: lr, num_epochs, batch_size, ctx = 0.05, 5, 128, d2l.try_gpu()
net.initialize(ctx=ctx, init=init.Xavier())
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr})
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=224)
d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
num_epochs)
training on gpu(0)
epoch 1, loss 0.9239, train acc 0.665, test acc 0.853, time 38.7 sec
epoch 2, loss 0.4129, train acc 0.850, test acc 0.879, time 37.0 sec
epoch 3, loss 0.3373, train acc 0.877, test acc 0.899, time 37.0 sec
epoch 4, loss 0.2937, train acc 0.892, test acc 0.906, time 37.0 sec
epoch 5, loss 0.2640, train acc 0.903, test acc 0.912, time 37.0 sec
小结
- VGG-11 通过 5 个可以重复使用的卷积块来构造网络。根据每块里卷积层个数和输出通道数的不同可以定义出不同的 VGG 模型。
练习
(1)与 AlexNet 相比,VGG 通常计算慢很多,也需要更多的内存或显存。试分析原因。
(2)尝试将 Fashion-MNIST 中图像的高和宽由 224 改为 96。这在实验中有哪些影响?
(3)参考 VGG 论文里的表 1 来构造 VGG 其他常用模型,如VGG-16和VGG-19 [48]。
##5.8 网络中的网络(NiN)
{-:-}扫码直达讨论区
5.5节至5.7节介绍的 LeNet、AlexNet 和 VGG 在设计上的共同之处是:先以由卷积层构成的模块充分抽取空间特征,再以由全连接层构成的模块来输出分类结果。其中,AlexNet 和 VGG 对 LeNet 的改进主要在于如何对这两个模块加宽(增加通道数)和加深。本节我们介绍网络中的网络(NiN)[33]。它提出了另外一个思路,即串联多个由卷积层和“全连接”层构成的小网络来构建一个深层网络。
###5.8.1 NiN块
我们知道,卷积层的输入和输出通常是四维数组(样本,通道,高,宽),而全连接层的输入和输出则通常是二维数组(样本, 特征)。如果想在全连接层后再接上卷积层,则需要将全连接层的输出变换为四维。回忆在5.3节里介绍的1×1卷积层。它可以看成全连接层,其中空间维度(高和宽)上的每个元素相当于样本,通道相当于特征。因此,NiN使用1×1卷积层来替代全连接层,从而使空间信息能够自然传递到后面的层中去。图5-7对比了NiN同AlexNet 和 VGG 等网络在结构上的主要区别。
图5-7 左图是AlexNet 和 VGG 的网络结构局部,右图是 NiN 的网络结构局部
NiN 块是 NiN 中的基础块。它由一个卷积层加两个充当全连接层的1×1卷积层串联而成。其中第一个卷积层的超参数可以自行设置,而第二和第三个卷积层的超参数一般是固定的。
In [1]: import d2lzh as d2l
from mxnet import gluon, init, nd
from mxnet.gluon import nn
def nin_block(num_channels, kernel_size, strides, padding):
blk = nn.Sequential()
blk.add(nn.Conv2D(num_channels, kernel_size,
strides, padding, activation='relu'),
nn.Conv2D(num_channels, kernel_size=1, activation='relu'),
nn.Conv2D(num_channels, kernel_size=1, activation='relu'))
return blk
###5.8.2 NiN模型
NiN 是在 AlexNet 问世不久后提出的。它们的卷积层设定有类似之处。NiN 使用卷积窗口形状分别为11×11、5×5和3×3的卷积层,相应的输出通道数也与 AlexNet 中的一致。每个 NiN 块后接一个步幅为 2、窗口形状为3×3的最大池化层。
除使用NiN 块以外,NiN 还有一个设计与 AlexNet显著不同:NiN 去掉了 AlexNet 最后的3个全连接层,取而代之地,NiN 使用了输出通道数等于标签类别数的 NiN 块,然后使用全局平均池化层对每个通道中所有元素求平均并直接用于分类。这里的全局平均池化层即窗口形状等于输入空间维形状的平均池化层。NiN 的这个设计的好处是可以显著减小模型参数尺寸,从而缓解过拟合。然而,该设计有时会造成获得有效模型的训练时间的增加。
In [2]: net = nn.Sequential()
net.add(nin_block(96, kernel_size=11, strides=4, padding=0),
nn.MaxPool2D(pool_size=3, strides=2),
nin_block(256, kernel_size=5, strides=1, padding=2),
nn.MaxPool2D(pool_size=3, strides=2),
nin_block(384, kernel_size=3, strides=1, padding=1),
nn.MaxPool2D(pool_size=3, strides=2), nn.Dropout(0.5),
# 标签类别数是10
nin_block(10, kernel_size=3, strides=1, padding=1),
# 全局平均池化层将窗口形状自动设置成输入的高和宽
nn.GlobalAvgPool2D(),
# 将四维的输出转成二维的输出, 其形状为(批量大小, 10)
nn.Flatten())
我们构建一个数据样本来查看每一层的输出形状。
In [3]: X = nd.random.uniform(shape=(1, 1, 224, 224))
net.initialize()
for layer in net:
X = layer(X)
print(layer.name, 'output shape:\t', X.shape)
sequential1 output shape: (1, 96, 54, 54)
pool0 output shape: (1, 96, 26, 26)
sequential2 output shape: (1, 256, 26, 26)
pool1 output shape: (1, 256, 12, 12)
sequential3 output shape: (1, 384, 12, 12)
pool2 output shape: (1, 384, 5, 5)
dropout0 output shape: (1, 384, 5, 5)
sequential4 output shape: (1, 10, 5, 5)
pool3 output shape: (1, 10, 1, 1)
f latten0 output shape: (1, 10)
###5.8.3 训练模型
我们依然使用 Fashion-MNIST 数据集来训练模型。NiN 的训练与 AlexNet 和 VGG 的类似,但这里使用的学习率更大。
In [4]: lr, num_epochs, batch_size, ctx = 0.1, 5, 128, d2l.try_gpu()
net.initialize(force_reinit=True, ctx=ctx, init=init.Xavier())
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr})
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=224)
d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
num_epochs)
training on gpu(0)
epoch 1, loss 2.2635, train acc 0.153, test acc 0.147, time 24.6 sec
epoch 2, loss 1.3903, train acc 0.499, test acc 0.699, time 23.5 sec
epoch 3, loss 0.8132, train acc 0.707, test acc 0.737, time 23.4 sec
epoch 4, loss 0.6420, train acc 0.765, test acc 0.798, time 23.5 sec
epoch 5, loss 0.5659, train acc 0.795, test acc 0.817, time 23.4 sec
小结
- NiN 重复使用由卷积层和代替全连接层的1×1卷积层构成的 NiN 块来构建深层网络。
练习
(1)调节超参数,提高分类准确率。
(2)为什么 NiN 块里要有两个1×1卷积层?去除其中的一个,观察并分析实验现象。
##5.9 含并行连结的网络(GoogLeNet)
{-:-}扫码直达讨论区
在 2014 年的 ImageNet 图像识别挑战赛中,一个名叫GoogLeNet的网络结构大放异彩[54]。它虽然在名字上向 LeNet 致敬,但在网络结构上已经很难看到 LeNet 的影子。GoogLeNet 吸收了 NiN 中网络串联网络的思想,并在此基础上做了很大改进。在随后的几年里,研究人员对 GoogLeNet 进行了数次改进,本节将介绍这个模型系列的第一个版本。
###5.9.1 Inception块
GoogLeNet中的基础卷积块叫作Inception块,得名于同名电影《盗梦空间》(Inception)。与上一节介绍的NiN块相比,这个基础块在结构上更加复杂,如图5-8所示。
图5-8 Inception块的结构
由图5-8可以看出,Inception块里有4条并行的线路。前3条线路使用窗口大小分别是1×1、3×3和5×5的卷积层来抽取不同空间尺寸下的信息,其中中间2个线路会对输入先做1×1卷积来减少输入通道数,以降低模型复杂度。第四条线路则使用3×3最大池化层,后接1×1卷积层来改变通道数。4条线路都使用了合适的填充来使输入与输出的高和宽一致。最后我们将每条线路的输出在通道维上连结,并输入接下来的层中去。
Inception块中可以自定义的超参数是每个层的输出通道数,我们以此来控制模型复杂度。
In [1]: import d2lzh as d2l
from mxnet import gluon, init, nd
from mxnet.gluon import nn
class Inception(nn.Block):
# c1 - c4为每条线路里的层的输出通道数
def __init__(self, c1, c2, c3, c4, **kwargs):
super(Inception, self).__init__(**kwargs)
# 线路1, 单1 x 1卷积层
self.p1_1 = nn.Conv2D(c1, kernel_size=1, activation='relu')
# 线路2, 1 x 1卷积层后接3 x 3卷积层
self.p2_1 = nn.Conv2D(c2[0], kernel_size=1, activation='relu')
self.p2_2 = nn.Conv2D(c2[1], kernel_size=3, padding=1,
activation='relu')
# 线路3, 1 x 1卷积层后接5 x 5卷积层
self.p3_1 = nn.Conv2D(c3[0], kernel_size=1, activation='relu')
self.p3_2 = nn.Conv2D(c3[1], kernel_size=5, padding=2,
activation='relu')
# 线路4, 3 x 3最大池化层后接1 x 1卷积层
self.p4_1 = nn.MaxPool2D(pool_size=3, strides=1, padding=1)
self.p4_2 = nn.Conv2D(c4, kernel_size=1, activation='relu')
def forward(self, x):
p1 = self.p1_1(x)
p2 = self.p2_2(self.p2_1(x))
p3 = self.p3_2(self.p3_1(x))
p4 = self.p4_2(self.p4_1(x))
return nd.concat(p1, p2, p3, p4, dim=1) # 在通道维上连结输出
###5.9.2 GoogLeNet模型
GoogLeNet 跟 VGG 一样,在主体卷积部分中使用5个模块(block),每个模块之间使用步幅为2的3×3最大池化层来减小输出高宽。第一模块使用一个 64 通道的7×7卷积层。
In [2]: b1 = nn.Sequential()
b1.add(nn.Conv2D(64, kernel_size=7, strides=2, padding=3, activation='relu'),
nn.MaxPool2D(pool_size=3, strides=2, padding=1))
第二模块使用2个卷积层:首先是 64 通道的1×1卷积层,然后是将通道增大3倍的3×3卷积层。它对应 Inception 块中的第二条线路。
In [3]: b2 = nn.Sequential()
b2.add(nn.Conv2D(64, kernel_size=1, activation='relu'),
nn.Conv2D(192, kernel_size=3, padding=1, activation='relu'),
nn.MaxPool2D(pool_size=3, strides=2, padding=1))
第三模块串联2个完整的 Inception 块。第一个 Inception 块的输出通道数为 64+128+32+32=256,其中4条线路的输出通道数比例为64 : 128 : 32 : 32=2 : 4 : 1 : 1。其中第二、第三条线路先分别将输入通道数减小至96/192=1/2和16/192=1/12后,再接上第二层卷积层。第二个 Inception块输出通道数增至 128=192=96=64=480,每条线路的输出通道数之比为 128 : 192 : 96 : 64=4 : 6 : 3 : 2。其中第二、第三条线路先分别将输入通道数减小至128/256=1/2和32/256=1/8。
In [4]: b3 = nn.Sequential()
b3.add(Inception(64, (96, 128), (16, 32), 32),
Inception(128, (128, 192), (32, 96), 64),
nn.MaxPool2D(pool_size=3, strides=2, padding=1))
第四模块更加复杂。它串联了5个Inception 块,其输出通道数分别是192+208+48+64=512、160+224+64+64=512、128+256+64+64=512、112+288+64+64=528 和256+320+128+128=832。这些线路的通道数分配和第三模块中的类似,首先是含3×3卷积层的第二条线路输出最多通道,其次是仅含1×1卷积层的第一条线路,之后是含5×5卷积层的第三条线路和含3×3最大池化层的第四条线路。其中第二、第三条线路都会先按比例减小通道数。这些比例在各个 Inception 块中都略有不同。
In [5]: b4 = nn.Sequential()
b4.add(Inception(192, (96, 208), (16, 48), 64),
Inception(160, (112, 224), (24, 64), 64),
Inception(128, (128, 256), (24, 64), 64),
Inception(112, (144, 288), (32, 64), 64),
Inception(256, (160, 320), (32, 128), 128),
nn.MaxPool2D(pool_size=3, strides=2, padding=1))
第五模块有输出通道数为256+320+128+128=832 和 384+384+128+128=1024的两个Inception块。其中每条线路的通道数的分配思路和第三、第四模块中的一致,只是在具体数值上有所不同。需要注意的是,第五模块的后面紧跟输出层,该模块同NiN一样使用全局平均池化层来将每个通道的高和宽变成1。最后我们将输出变成二维数组后接上一个输出个数为标签类别数的全连接层。
In [6]: b5 = nn.Sequential()
b5.add(Inception(256, (160, 320), (32, 128), 128),
Inception(384, (192, 384), (48, 128), 128),
nn.GlobalAvgPool2D())
net = nn.Sequential()
net.add(b1, b2, b3, b4, b5, nn.Dense(10))
GoogLeNet模型的计算复杂,而且不如VGG那样便于修改通道数。本节里我们将输入的高和宽从224降到96来简化计算。下面演示各个模块之间的输出的形状变化。
In [7]: X = nd.random.uniform(shape=(1, 1, 96, 96))
net.initialize()
for layer in net:
X = layer(X)
print(layer.name, 'output shape:\t', X.shape)
sequential0 output shape: (1, 64, 24, 24)
sequential1 output shape: (1, 192, 12, 12)
sequential2 output shape: (1, 480, 6, 6)
sequential3 output shape: (1, 832, 3, 3)
sequential4 output shape: (1, 1024, 1, 1)
dense0 output shape: (1, 10)
###5.9.3 训练模型
我们使用高和宽均为96像素的图像来训练GoogLeNet模型。训练使用的图像依然来自Fashion-MNIST数据集。
In [8]: lr, num_epochs, batch_size, ctx = 0.1, 5, 128, d2l.try_gpu()
net.initialize(force_reinit=True, ctx=ctx, init=init.Xavier())
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr})
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96)
d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
num_epochs)
training on gpu(0)
epoch 1, loss 1.7187, train acc 0.357, test acc 0.727, time 27.1 sec
epoch 2, loss 0.5887, train acc 0.780, test acc 0.830, time 23.8 sec
epoch 3, loss 0.4362, train acc 0.835, test acc 0.862, time 23.5 sec
epoch 4, loss 0.3698, train acc 0.860, test acc 0.868, time 23.5 sec
epoch 5, loss 0.3336, train acc 0.874, test acc 0.885, time 23.7 sec
小结
- Inception块相当于一个有4条线路的子网络。它通过不同窗口形状的卷积层和最大池化层来并行抽取信息,并使用1×1卷积层减少通道数从而降低模型复杂度。
练习
(1)GoogLeNet有数个后续版本。尝试实现并运行它们,然后观察实验结果。这些后续版本包括加入批量归一化层(5.10节将介绍)[25]、对Inception块做调整[55]和加入残差连接(5.11节将介绍)[53]。
(2)对比AlexNet、VGG和NiN、GoogLeNet的模型参数尺寸。为什么后两个网络可以显著减小模型参数尺寸?
##5.10 批量归一化
{-:-}扫码直达讨论区
本节我们介绍批量归一化(batch normalization)层,它能让较深的神经网络的训练变得更加容易[25]。在3.16节里,我们对输入数据做了标准化处理:处理后的任意一个特征在数据集中所有样本上的均值为0、标准差为1。标准化处理输入数据使各个特征的分布相近:这往往更容易训练出有效的模型。
通常来说,数据标准化预处理对于浅层模型就足够有效了。随着模型训练的进行,当每层中参数更新时,靠近输出层的输出较难出现剧烈变化。但对深层神经网络来说,即使输入数据已做标准化,训练中模型参数的更新依然很容易造成靠近输出层输出的剧烈变化。这种计算数值的不稳定性通常令我们难以训练出有效的深度模型。
批量归一化的提出正是为了应对深度模型训练的挑战。在模型训练时,批量归一化利用小批量上的均值和标准差,不断调整神经网络中间输出,从而使整个神经网络在各层的中间输出的数值更稳定。批量归一化和5.11节将要介绍的残差网络为训练和设计深度模型提供了两类重要思路。
###5.10.1 批量归一化层
对全连接层和卷积层做批量归一化的方法稍有不同。下面我们将分别介绍这两种情况下的批量归一化。
####1.对全连接层做批量归一化
我们先考虑如何对全连接层做批量归一化。通常,我们将批量归一化层置于全连接层中的仿射变换和激活函数之间。设全连接层的输入为u,权重参数和偏差参数分别为W和b,激活函数为φ。设批量归一化的运算符为BN。那么,使用批量归一化的全连接层的输出为
{-:-}φ(BN(x))
其中批量归一化输入x由仿射变换
{-:-}x=Wu+b
得到。考虑一个由m个样本组成的小批量,仿射变换的输出为一个新的小批量 。它们正是批量归一化层的输入。对于小批量
中任意样本
,批量归一化层的输出同样是d维向量
{-:-}
并由以下几步求得。首先,对小批量求均值和方差:
{-:-}
其中的平方计算是按元素求平方。接下来,使用按元素开方和按元素除法对 标准化:
{-:-}
这里ε>0是一个很小的常数,保证分母大于0。在上面标准化的基础上,批量归一化层引入了两个可以学习的模型参数,拉伸(scale)参数γ和偏移(shift)参数β。这两个参数和 形状相同,皆为d维向量。它们与
分别做按元素乘法(符号
)和加法计算:
{-:-}
至此,我们得到了 的批量归一化的输出
。值得注意的是,可学习的拉伸和偏移参数保留了不对
做批量归一化的可能:此时只需学出
和
。我们可以对此这
样理解:如果批量归一化无益,理论上讲,学出的模型可以不使用批量归一化。
####2.对卷积层做批量归一化
对卷积层来说,批量归一化发生在卷积计算之后、应用激活函数之前。如果卷积计算输出多个通道,我们需要对这些通道的输出分别做批量归一化,且每个通道都拥有独立的拉伸和偏移参数,并均为标量。设小批量中有m个样本。在单个通道上,假设卷积计算输出的高和宽分别为p和q。我们需要对该通道中个元素同时做批量归一化。对这些元素做标准化计算时,我们使用相同的均值和方差,即该通道中
个元素的均值和方差。
####3.预测时的批量归一化
使用批量归一化训练时,我们可以将批量大小设得大一点,从而使批量内样本的均值和方差的计算都较为准确。将训练好的模型用于预测时,我们希望模型对于任意输入都有确定的输出。因此,单个样本的输出不应取决于批量归一化所需要的随机小批量中的均值和方差。一种常用的方法是通过移动平均估算整个训练数据集的样本均值和方差,并在预测时使用它们得到确定的输出。可见,和丢弃层一样,批量归一化层在训练模式和预测模式下的计算结果也是不一样的。
###5.10.2 从零开始实现
下面我们通过NDArray
来实现批量归一化层。
In [1]: import d2lzh as d2l
from mxnet import autograd, gluon, init, nd
from mxnet.gluon import nn
def batch_norm(X, gamma, beta, moving_mean, moving_var, eps, momentum):
# 通过autograd来判断当前模式是训练模式还是预测模式
if not autograd.is_training():
# 如果是在预测模式下, 直接使用传入的移动平均所得的均值和方差
X_hat = (X - moving_mean) / nd.sqrt(moving_var + eps)
else:
assert len(X.shape) in (2, 4)
if len(X.shape) == 2:
# 使用全连接层的情况, 计算特征维上的均值和方差
mean = X.mean(axis=0)
var = ((X - mean) ** 2).mean(axis=0)
else:
# 使用二维卷积层的情况, 计算通道维上(axis=1)的均值和方差。这里我们需要保持
# X的形状以便后面可以做广播运算
mean = X.mean(axis=(0, 2, 3), keepdims=True)
var = ((X - mean) ** 2).mean(axis=(0, 2, 3), keepdims=True)
# 训练模式下用当前的均值和方差做标准化
X_hat = (X - mean) / nd.sqrt(var + eps)
# 更新移动平均的均值和方差
moving_mean = momentum * moving_mean + (1.0 - momentum) * mean
moving_var = momentum * moving_var + (1.0 - momentum) * var
Y = gamma * X_hat + beta # 拉伸和偏移
return Y, moving_mean, moving_var
接下来,我们自定义一个BatchNorm
层。它保存参与求梯度和迭代的拉伸参数gamma
和偏移参数beta
,同时也维护移动平均得到的均值和方差,以便能够在模型预测时被使用。BatchNorm
实例所需指定的num_features
参数对于全连接层来说应为输出个数,对于卷积层来说则为输出通道数。该实例所需指定的num_dims
参数对于全连接层和卷积层来说分别为2和4。
In [2]: class BatchNorm(nn.Block):
def __init__(self, num_features, num_dims, **kwargs):
super(BatchNorm, self).__init__(**kwargs)
if num_dims == 2:
shape = (1, num_features)
else:
shape = (1, num_features, 1, 1)
# 参与求梯度和迭代的拉伸和偏移参数, 分别初始化成0和1
self.gamma = self.params.get('gamma', shape=shape, init=init.One())
self.beta = self.params.get('beta', shape=shape, init=init.Zero())
# 不参与求梯度和迭代的变量, 全在内存上初始化成0
self.moving_mean = nd.zeros(shape)
self.moving_var = nd.zeros(shape)
def forward(self, X):
# 如果X不在内存上, 将moving_mean和moving_var复制到X所在显存上
if self.moving_mean.context != X.context:
self.moving_mean = self.moving_mean.copyto(X.context)
self.moving_var = self.moving_var.copyto(X.context)
# 保存更新过的moving_mean和moving_var
Y, self.moving_mean, self.moving_var = batch_norm(
X, self.gamma.data(), self.beta.data(), self.moving_mean,
self.moving_var, eps=1e-5, momentum=0.9)
return Y
###5.10.3 使用批量归一化层的LeNet
下面我们修改5.5节介绍的LeNet模型,从而应用批量归一化层。我们在所有的卷积层或全连接层之后、激活层之前加入批量归一化层。
In [3]: net = nn.Sequential()
net.add(nn.Conv2D(6, kernel_size=5),
BatchNorm(6, num_dims=4),
nn.Activation('sigmoid'),
nn.MaxPool2D(pool_size=2, strides=2),
nn.Conv2D(16, kernel_size=5),
BatchNorm(16, num_dims=4),
nn.Activation('sigmoid'),
nn.MaxPool2D(pool_size=2, strides=2),
nn.Dense(120),
BatchNorm(120, num_dims=2),
nn.Activation('sigmoid'),
nn.Dense(84),
BatchNorm(84, num_dims=2),
nn.Activation('sigmoid'),
nn.Dense(10))
下面我们训练修改后的模型。
In [4]: lr, num_epochs, batch_size, ctx = 1.0, 5, 256, d2l.try_gpu()
net.initialize(ctx=ctx, init=init.Xavier())
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr})
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
num_epochs)
training on gpu(0)
epoch 1, loss 0.6675, train acc 0.760, test acc 0.824, time 3.6 sec
epoch 2, loss 0.3946, train acc 0.858, test acc 0.813, time 3.4 sec
epoch 3, loss 0.3477, train acc 0.874, test acc 0.740, time 3.3 sec
epoch 4, loss 0.3215, train acc 0.884, test acc 0.867, time 3.3 sec
epoch 5, loss 0.3015, train acc 0.890, test acc 0.823, time 3.4 sec
最后我们查看第一个批量归一化层学习到的拉伸参数gamma
和偏移参数beta
。
In [5]: net[1].gamma.data().reshape((-1,)), net[1].beta.data().reshape((-1,))
Out[5]: (
[2.0340614 1.5274717 1.7007711 1.2053087 1.5917673 1.7429659]
<NDArray 6 @gpu(0)>,
[ 1.1765741 0.02335754 0.4149146 0.60519356 -0.2102287 -1.936496 ]
<NDArray 6 @gpu(0)>)
###5.10.4 简洁实现
与我们刚刚自己定义的BatchNorm
类相比,Gluon中nn
模块定义的BatchNorm
类使用起来更加简单。它不需要指定自己定义的BatchNorm
类中所需的num_features
和num_dims
参数值。在Gluon中,这些参数值都将通过延后初始化而自动获取。下面我们用Gluon实现使用批量归一化的LeNet。
In [6]: net = nn.Sequential()
net.add(nn.Conv2D(6, kernel_size=5),
nn.BatchNorm(),
nn.Activation('sigmoid'),
nn.MaxPool2D(pool_size=2, strides=2),
nn.Conv2D(16, kernel_size=5),
nn.BatchNorm(),
nn.Activation('sigmoid'),
nn.MaxPool2D(pool_size=2, strides=2),
nn.Dense(120),
nn.BatchNorm(),
nn.Activation('sigmoid'),
nn.Dense(84),
nn.BatchNorm(),
nn.Activation('sigmoid'),
nn.Dense(10))
使用同样的超参数进行训练。
In [7]: net.initialize(ctx=ctx, init=init.Xavier())
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr})
d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
num_epochs)
training on gpu(0)
epoch 1, loss 0.6382, train acc 0.774, test acc 0.833, time 2.1 sec
epoch 2, loss 0.3904, train acc 0.859, test acc 0.854, time 2.1 sec
epoch 3, loss 0.3448, train acc 0.875, test acc 0.855, time 1.9 sec
epoch 4, loss 0.3198, train acc 0.884, test acc 0.842, time 2.0 sec
epoch 5, loss 0.2970, train acc 0.891, test acc 0.880, time 2.1 sec
小结
- 在模型训练时,批量归一化利用小批量上的均值和标准差,不断调整神经网络的中间输出,从而使整个神经网络在各层的中间输出的数值更稳定。
BatchNorm
类使用起来简单、方便。
练习
(1)能否将批量归一化前的全连接层或卷积层中的偏差参数去掉?为什么?(提示:回忆批量归一化中标准化的定义。)
(2)尝试调大学习率。同5.5节中未使用批量归一化的LeNet相比,现在是不是可以使用更大的学习率?
(3)尝试将批量归一化层插入LeNet的其他地方,观察并分析结果的变化。
(4)尝试一下不学习拉伸参数
gamma
和偏移量参数beta
(构造的时候加入参数grad_req='null'
来避免计算梯度),观察并分析结果。(5)查看
BatchNorm
类的文档来了解更多使用方法,例如,如何在训练时使用基于全局平均的均值和方差。
##5.11 残差网络(ResNet)
让我们先思考一个问题:对神经网络模型添加新的层,充分训练后的模型是否只可能更有效地降低训练误差?理论上,原模型解的空间只是新模型解的空间的子空间。也就是说,如果我们能将新添加的层训练成恒等映射,新模型和原模型将同样有效。由于新模型可能得出更优的解来拟合训练数据集,因此添加层似乎更容易降低训练误差。然而在实践中,添加过多的层后训练误差往往不降反升。即使利用批量归一化带来的数值稳定性使训练深层模型更加容易,该问题仍然存在。针对这一问题,何恺明等人提出了残差网络(ResNet)[19]。它在2015年的ImageNet图像识别挑战赛夺魁,并深刻影响了后来的深度神经网络的设计。
###5.11.1 残差块
让我们聚焦于神经网络局部。如图5-9所示,设输入为x。假设我们希望学出的理想映射为,从而作为图5-9最上方激活函数的输入。左图虚线框中的部分需要直接拟合出该映射
,而右图虚线框中的部分则需要拟合出有关恒等映射的残差映射
。残差映射在实际中往往更容易优化。以本节开头提到的恒等映射作为我们希望学出的理想映射
。我们只需将图5-9中右图虚线框内上方的加权运算(如仿射)的权重和偏差参数学成0,那么
即为恒等映射。实际中,当理想映射
极接近于恒等映射时,残差映射也易于捕捉恒等映射的细微波动。图5-9右图也是ResNet的基础块,即残差块(residual block)。在残差块中,输入可通过跨层的数据线路更快地向前传播。
图5-9 设输入为x。假设图中最上方激活函数输入的理想映射为f (x)。左图虚线框中的部分需要直接拟合出该映射f (x),而右图虚线框中的部分需要拟合出有关恒等映射的残差映射f (x) - x
ResNet沿用了VGG全3×3卷积层的设计。残差块里首先有2个有相同输出通道数的3×3卷积层。每个卷积层后接一个批量归一化层和ReLU激活函数。然后我们将输入跳过这2个卷积运算后直接加在最后的ReLU激活函数前。这样的设计要求2个卷积层的输出与输入形状一样,从而可以相加。如果想改变通道数,就需要引入一个额外的1×1卷积层来将输入变换成需要的形状后再做相加运算。
残差块的实现如下。它可以设定输出通道数、是否使用额外的1×1卷积层来修改通道数以及卷积层的步幅。
In [1]: import d2lzh as d2l
from mxnet import gluon, init, nd
from mxnet.gluon import nn
class Residual(nn.Block): # 本类已保存在d2lzh包中方便以后使用
def __init__(self, num_channels, use_1x1conv=False, strides=1, **kwargs):
super(Residual, self).__init__(**kwargs)
self.conv1 = nn.Conv2D(num_channels, kernel_size=3, padding=1,
strides=strides)
self.conv2 = nn.Conv2D(num_channels, kernel_size=3, padding=1)
if use_1x1conv:
self.conv3 = nn.Conv2D(num_channels, kernel_size=1,
strides=strides)
else:
self.conv3 = None
self.bn1 = nn.BatchNorm()
self.bn2 = nn.BatchNorm()
def forward(self, X):
Y = nd.relu(self.bn1(self.conv1(X)))
Y = self.bn2(self.conv2(Y))
if self.conv3:
X = self.conv3(X)
return nd.relu(Y + X)
下面我们来查看输入和输出形状一致的情况。
In [2]: blk = Residual(3)
blk.initialize()
X = nd.random.uniform(shape=(4, 3, 6, 6))
blk(X).shape
Out[2]: (4, 3, 6, 6)
我们也可以在增加输出通道数的同时减半输出的高和宽。
In [3]: blk = Residual(6, use_1x1conv=True, strides=2)
blk.initialize()
blk(X).shape
Out[3]: (4, 6, 3, 3)
###5.11.2 ResNet模型
ResNet的前两层跟之前介绍的GoogLeNet中的一样:在输出通道数为64、步幅为2的7×7卷积层后接步幅为2的3×3的最大池化层。不同之处在于ResNet每个卷积层后增加的批量归一化层。
In [4]: net = nn.Sequential()
net.add(nn.Conv2D(64, kernel_size=7, strides=2, padding=3),
nn.BatchNorm(), nn.Activation('relu'),
nn.MaxPool2D(pool_size=3, strides=2, padding=1))
GoogLeNet在后面接了4个由Inception块组成的模块。ResNet则使用4个由残差块组成的模块,每个模块使用若干个同样输出通道数的残差块。第一个模块的通道数同输入通道数一致。由于之前已经使用了步幅为2的最大池化层,所以无须减小高和宽。之后的每个模块在第一个残差块里将上一个模块的通道数翻倍,并将高和宽减半。
下面我们来实现这个模块。注意,这里对第一个模块做了特别处理。
In [5]: def resnet_block(num_channels, num_residuals, f irst_block=False):
blk = nn.Sequential()
for i in range(num_residuals):
if i == 0 and not f irst_block:
blk.add(Residual(num_channels, use_1x1conv=True, strides=2))
else:
blk.add(Residual(num_channels))
return blk
接着我们为ResNet加入所有残差块。这里每个模块使用2个残差块。
In [6]: net.add(resnet_block(64, 2, f irst_block=True),
resnet_block(128, 2),
resnet_block(256, 2),
resnet_block(512, 2))
最后,与GoogLeNet一样,加入全局平均池化层后接上全连接层输出。
In [7]: net.add(nn.GlobalAvgPool2D(), nn.Dense(10))
这里每个模块里有4个卷积层(不计算1×1卷积层),加上最开始的卷积层和最后的全连接层,共计18层。这个模型通常也被称为ResNet-18。通过配置不同的通道数和模块里的残差块数可以得到不同的ResNet模型,例如更深的含152层的ResNet-152。虽然ResNet的主体架构跟GoogLeNet的类似,但ResNet结构更简单,修改也更方便。这些因素都导致了ResNet迅速被广泛使用。
在训练ResNet之前,我们来观察一下输入形状在ResNet不同模块之间的变化。
In [8]: X = nd.random.uniform(shape=(1, 1, 224, 224))
net.initialize()
for layer in net:
X = layer(X)
print(layer.name, 'output shape:\t', X.shape)
conv5 output shape: (1, 64, 112, 112)
batchnorm4 output shape: (1, 64, 112, 112)
relu0 output shape: (1, 64, 112, 112)
pool0 output shape: (1, 64, 56, 56)
sequential1 output shape: (1, 64, 56, 56)
sequential2 output shape: (1, 128, 28, 28)
sequential3 output shape: (1, 256, 14, 14)
sequential4 output shape: (1, 512, 7, 7)
pool1 output shape: (1, 512, 1, 1)
dense0 output shape: (1, 10)
###5.11.3 训练模型
下面我们在Fashion-MNIST数据集上训练ResNet。
In [9]: lr, num_epochs, batch_size, ctx = 0.05, 5, 256, d2l.try_gpu()
net.initialize(force_reinit=True, ctx=ctx, init=init.Xavier())
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr})
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96)
d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
num_epochs)
training on gpu(0)
epoch 1, loss 0.4848, train acc 0.829, test acc 0.890, time 15.7 sec
epoch 2, loss 0.2539, train acc 0.906, test acc 0.910, time 14.4 sec
epoch 3, loss 0.1909, train acc 0.930, test acc 0.916, time 14.4 sec
epoch 4, loss 0.1442, train acc 0.947, test acc 0.919, time 14.3 sec
epoch 5, loss 0.1072, train acc 0.962, test acc 0.912, time 14.4 sec
小结
- 残差块通过跨层的数据通道从而能够训练出有效的深度神经网络。
练习
(1)参考ResNet论文的表1来实现不同版本的ResNet [19]。
(2)对于比较深的网络,ResNet论文中介绍了一个“瓶颈”架构来降低模型复杂度。尝试实现它[19]。
(3)在ResNet的后续版本里,作者将残差块里的“卷积、批量归一化和激活”结构改成了“批量归一化、激活和卷积”,实现这个改进(见参考文献[20],图1)。
##5.12 稠密连接网络(DenseNet)
{-:-}扫码直达讨论区
ResNet中的跨层连接设计引申出了数个后续工作。本节介绍其中的一个——稠密连接网络(DenseNet)[24]。它与ResNet的主要区别如图5-10所示。
图5-10 ResNet(左)与DenseNet(右)在跨层连接上的主要区别:使用相加和使用连结
图5-10中将部分前后相邻的运算抽象为模块A和模块B。与ResNet的主要区别在于,DenseNet里模块B的输出不是像ResNet那样和模块A的输出相加,而是在通道维上连结。这样模块A的输出可以直接传入模块B后面的层。在这个设计里,模块A直接跟模块B后面的所有层连接在了一起。这也是它被称为“稠密连接”的原因。
DenseNet的主要构建模块是稠密块(dense block)和过渡层(transition layer)。前者定义了输入和输出是如何连结的,后者则用来控制通道数,使之不过大。
###5.12.1 稠密块
DenseNet使用了ResNet改良版的“批量归一化、激活和卷积”结构(参见5.11节的练习),我们首先在conv_block
函数里实现这个结构。
In [1]: import d2lzh as d2l
from mxnet import gluon, init, nd
from mxnet.gluon import nn
def conv_block(num_channels):
blk = nn.Sequential()
blk.add(nn.BatchNorm(), nn.Activation('relu'),
nn.Conv2D(num_channels, kernel_size=3, padding=1))
return blk
稠密块由多个conv_block
组成,每块使用相同的输出通道数。但在前向计算时,我们将每块的输入和输出在通道维上连结。
In [2]: class DenseBlock(nn.Block):
def __init__(self, num_convs, num_channels, **kwargs):
super(DenseBlock, self).__init__(**kwargs)
self.net = nn.Sequential()
for _ in range(num_convs):
self.net.add(conv_block(num_channels))
def forward(self, X):
for blk in self.net:
Y = blk(X)
X = nd.concat(X, Y, dim=1) # 在通道维上将输入和输出连结
return X
在下面的例子中,我们定义一个有2个输出通道数为10的卷积块。使用通道数为3的输入时,我们会得到通道数为 的输出。卷积块的通道数控制了输出通道数相对于输入通道数的增长,因此也被称为增长率(growth rate)。
In [3]: blk = DenseBlock(2, 10)
blk.initialize()
X = nd.random.uniform(shape=(4, 3, 8, 8))
Y = blk(X)
Y.shape
Out[3]: (4, 23, 8, 8)
###5.12.2 过渡层
由于每个稠密块都会带来通道数的增加,使用过多则会带来过于复杂的模型。过渡层用来控制模型复杂度。它通过1×1卷积层来减小通道数,并使用步幅为2的平均池化层减半高和宽,从而进一步降低模型复杂度。
In [4]: def transition_block(num_channels):
blk = nn.Sequential()
blk.add(nn.BatchNorm(), nn.Activation('relu'),
nn.Conv2D(num_channels, kernel_size=1),
nn.AvgPool2D(pool_size=2, strides=2))
return blk
对上一个例子中稠密块的输出使用通道数为10的过渡层。此时输出的通道数减为10,高和宽均减半。
In [5]: blk = transition_block(10)
blk.initialize()
blk(Y).shape
Out[5]: (4, 10, 4, 4)
###5.12.3 DenseNet模型
我们来构造DenseNet模型。DenseNet首先使用同ResNet一样的单卷积层和最大池化层。
In [6]: net = nn.Sequential()
net.add(nn.Conv2D(64, kernel_size=7, strides=2, padding=3),
nn.BatchNorm(), nn.Activation('relu'),
nn.MaxPool2D(pool_size=3, strides=2, padding=1))
类似于ResNet接下来使用的4个残差块,DenseNet使用的是4个稠密块。同ResNet一样,我们可以设置每个稠密块使用多少个卷积层。这里我们设成4,从而与5.11节的ResNet-18保持一致。稠密块里的卷积层通道数(即增长率)设为32,所以每个稠密块将增加128 个通道。
ResNet里通过步幅为2的残差块在每个模块之间减小高和宽。这里我们则使用过渡层来减半高和宽,并减半通道数。
In [7]: num_channels, growth_rate = 64, 32 # num_channels为当前的通道数
num_convs_in_dense_blocks = [4, 4, 4, 4]
for i, num_convs in enumerate(num_convs_in_dense_blocks):
net.add(DenseBlock(num_convs, growth_rate))
# 上一个稠密块的输出通道数
num_channels += num_convs * growth_rate
# 在稠密块之间加入通道数减半的过渡层
if i != len(num_convs_in_dense_blocks) - 1:
num_channels //= 2
net.add(transition_block(num_channels))
同ResNet一样,最后接上全局池化层和全连接层来输出。
In [8]: net.add(nn.BatchNorm(), nn.Activation('relu'), nn.GlobalAvgPool2D(),
nn.Dense(10))
###5.12.4 训练模型
由于这里使用了比较深的网络,本节里我们将输入高和宽从224降到96来简化计算。
In [9]: lr, num_epochs, batch_size, ctx = 0.1, 5, 256, d2l.try_gpu()
net.initialize(ctx=ctx, init=init.Xavier())
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr})
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96)
d2l.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx,
num_epochs)
training on gpu(0)
epoch 1, loss 0.5387, train acc 0.808, test acc 0.862, time 14.7 sec
epoch 2, loss 0.3157, train acc 0.885, test acc 0.875, time 13.0 sec
epoch 3, loss 0.2646, train acc 0.903, test acc 0.891, time 13.0 sec
epoch 4, loss 0.2375, train acc 0.914, test acc 0.899, time 13.0 sec
epoch 5, loss 0.2124, train acc 0.923, test acc 0.915, time 13.0 sec
小结
- 在跨层连接上,不同于ResNet中将输入与输出相加,DenseNet在通道维上连结输入与输出。
练习
(1)DenseNet论文中提到的一个优点是模型参数比ResNet的更小,这是为什么?
(2)DenseNet被人诟病的一个问题是内存或显存消耗过多。真的会这样吗?可以把输入形状换成224224,来看看实际的消耗。
(3)实现DenseNet论文中的表1提出的不同版本的DenseNet [24]。
本文节选自《动手学深度学习》,更多详情请戳:https://www.epubit.com/book/detail/38286