卷积神经网络

定义

我们之前讨论的多层感知机十分适合处理表格数据,其中行对应样本,列对应特征。 对于表格数据,我们寻找的模式可能涉及特征之间的交互,但是我们不能预先假设任何与特征交互相关的先验结构。 此时,多层感知机可能是最好的选择,然而对于高维感知数据,这种缺少结构的网络可能会变得不实用

而图像中本就拥有丰富的结构,而这些结构可以被人类和机器学习模型使用。 卷积神经网络(convolutional neural networks,CNN)是机器学习利用自然图像中一些已知结构的创造性方法。


不变性

  1. 平移不变性(translation invariance):不管检测对象出现在图像中的哪个位置,神经网络的前面几层应该对相同的图像区域具有相似的反应,即为“平移不变性”。
  2. 局部性(locality):神经网络的前面几层应该只探索输入图像中的局部区域,而不过度在意图像中相隔较远区域的关系,这就是“局部性”原则。最终,可以聚合这些局部特征,以在整个图像级别进行预测。

多层感知机的限制

首先,多层感知机的输入是二维图像$\mathbf{X}$,其隐藏表示$\mathbf{H}$在数学上是一个矩阵,在代码中表示为二维张量。

其中$\mathbf{X}$和$\mathbf{H}$具有相同的形状。

为了方便理解,我们可以认为,无论是输入还是隐藏表示都拥有空间结构。

使用$[\mathbf{X}]_{i, j}$$[\mathbf{H}]_{i, j}$分别表示输入图像和隐藏表示中位置($i$,$j$)处的像素。

为了使每个隐藏神经元都能接收到每个输入像素的信息,我们将参数从权重矩阵(如同我们先前在多层感知机中所做的那样)替换为四阶权重张量$\mathsf{W}$。假设$\mathbf{U}$包含偏置参数,我们可以将全连接层形式化地表示为

  • (i, j) 想象为这个小方块的中心点在图像上的位置。
  • U[i,j] 是这个小方块在没有任何额外上下文信息时的特征(就像是你只看这个小方块,不考虑周围的情况)。
  • W[i,j,k,l] 是一个滤波器,它告诉我们应该如何考虑 (i, j) 周围的像素来增强我们对这个点的理解。比如,它可以是一个高斯滤波器,用于平滑图像并消除噪声。
  • (k, l) 是滤波器在图像上移动的位置,你可以想象为你手中有一个放大镜,你在图像上移动它,看不同部分的细节。

平移不变性

举个例子:

  1. 假设我们的特征检测器在位置$(i,j)$识别出一只小狗,并计算得到特征 $H[i,j]$
  2. 现在,如果我们将图像向右平移一个像素,那么小狗现在在位置 $(i,j+1)$
  3. 应用同样的特征检测器,如果我们计算得到的特征 $H[i,j+1]$与 $H[i,j]$相同,那么我们的检测器就具有平移不变性
  4. 在图像处理中,如果我们使用一种算法来识别图像中的物体,比如小狗,我们希望无论小狗出现在图像的哪个位置,算法都能准确识别它。如果一个算法具有平移不变性,那么即使小狗在图像的左上角或者右下角,或者任何位置,这个算法都能告诉我们”这是一只小狗”。换句话说,这个算法对于小狗的位置不敏感

这意味着检测对象在输入$\mathbf{X}$中的平移,应该仅导致隐藏表示$\mathbf{H}$中的平移。也就是说,$\mathsf{V}$和$\mathbf{U}$实际上不依赖于$(i, j)$的值,即$[\mathsf{V}]_{i, j, a, b} = [\mathbf{V}]_{a, b}$。并且$\mathbf{U}$是一个常数,比如$u$。因此,我们可以简化$\mathbf{H}$定义为:

这就是卷积(convolution)。我们是在使用系数$[\mathbf{V}]_{a, b}$对位置$(i, j)$附近的像素$(i+a, j+b)$进行加权得到$[\mathbf{H}]_{i, j}$。

注意,$[\mathbf{V}]_{a, b}$的系数比$[\mathsf{V}]_{i, j, a, b}$少很多,因为前者不再依赖于图像中的位置。这就是显著的进步!


局部性

如上所述,为了收集用来训练参数$[\mathbf{H}]_{i, j}$的相关信息,我们不应偏离到距$(i, j)$很远的地方。这意味着在$|a|> \Delta$或$|b| > \Delta$的范围之外,我们可以设置$[\mathbf{V}]_{a, b} = 0$。因此,我们可以将$[\mathbf{H}]_{i, j}$重写为

简而言之, 上述公式是一个卷积层(convolutional layer),而卷积神经网络是包含卷积层的一类特殊的神经网络。

在深度学习研究社区中,$\mathbf{V}$被称为卷积核(convolution kernel)或者滤波器(filter),亦或简单地称之为该卷积层的权重,通常该权重是可学习的参数。

当图像处理的局部区域很小时,卷积神经网络与多层感知机的训练差异可能是巨大的:以前,多层感知机可能需要数十亿个参数来表示网络中的一层,而现在卷积神经网络通常只需要几百个参数,而且不需要改变输入或隐藏表示的维数。

参数大幅减少的代价是,我们的特征现在是平移不变的,并且当确定每个隐藏活性值时,每一层只包含局部的信息


卷积

在进一步讨论之前,我们先简要回顾一下为什么上面的操作被称为卷积。在数学中,两个函数(比如$f, g: \mathbb{R}^d \to \mathbb{R}$)之间的“卷积”被定义为

也就是说,卷积是当把一个函数“翻转”并移位$\mathbf{x}$时,测量$f$和$g$之间的重叠。

  • 函数 $g$ 是放大镜,定义了您关注的特定图形或模式。在图像处理中,这可以是一个卷积核,它突出或检测特定的特征,比如边缘或纹理。
  • 移动放大镜 对应于卷积操作中的“翻转并移位”步骤。您通过移动 $g$ 相对于$f$的位置(即改变 $x$),来检查 $f$ 在不同位置上如何与 $g$ 重叠。
  • 寻找特定图形 的过程就是计算卷积值的过程。在每个位置 $x$,通过对所有可能的$z$ 值进行积分(或在离散情况下求和),实际上是在测量$f$ 和 $g$ 在该位置的匹配程度。这个匹配程度表达为一个数值,即卷积结果$(f*g)(x)$

当为离散对象时,积分就变成求和。例如,对于由索引为$\mathbb{Z}$的、平方可和的、无限维向量集合中抽取的向量,我们得到以下定义:

对于二维张量,则为$f$的索引$(a, b)$和$g$的索引$(i-a, j-b)$上的对应加和:


通道

然而这种方法有一个问题:我们忽略了图像一般包含三个通道/三种原色(红色、绿色和蓝色)。

实际上,图像不是二维张量,而是一个由高度、宽度和颜色组成的三维张量,比如包含$1024 \times 1024 \times 3$个像素。前两个轴与像素的空间位置有关,而第三个轴可以看作每个像素的多维表示。

因此,我们将$\mathsf{X}$索引为$[\mathsf{X}]_{i, j, k}$。由此卷积相应地调整为$[\mathsf{V}]_{a,b,c}$,而不是$[\mathbf{V}]_{a,b}$。

此外,由于输入图像是三维的,我们的隐藏表示$\mathsf{H}$也最好采用三维张量。换句话说,对于每一个空间位置,我们想要采用一组而不是一个隐藏表示。这样一组隐藏表示可以想象成一些互相堆叠的二维网格。

因此,我们可以把隐藏表示想象为一系列具有二维张量的通道(channel)。这些通道有时也被称为特征映射,因为每个通道都向后续层提供一组空间化的学习特征。直观上可以想象在靠近输入的底层,一些通道专门识别边缘,而一些通道专门识别纹理。

为了支持输入$\mathsf{X}$和隐藏表示$\mathsf{H}$中的多个通道,我们可以在$\mathsf{V}$中添加第四个坐标,即$[\mathsf{V}]_{a, b, c, d}$。综上所述,

其中隐藏表示$\mathsf{H}$中的索引$d$表示输出通道,而随后的输出将继续以三维张量$\mathsf{H}$作为输入进入下一个卷积层。所以,公式可以定义具有多个通道的卷积层,而其中$\mathsf{V}$是该卷积层的权重。

具体来说,如果我们有一个输入数据张量和一个卷积层,其输入数据张量具有 $C_{in}$ 个通道(例如,彩色图像的3个通道),而卷积层要产生 $C_{out}$ 个输出特征映射(或通道),那么每个卷积核实际上需要是一个四维数组

  1. 输出通道数$C_{out}$:卷积层产生的特征映射数。
  2. 输入通道数 $C_{in}$:输入数据的通道数。
  3. 高度 H:卷积核在空间高度维度上的大小。
  4. 宽度 W:卷积核在空间宽度维度上的大小。

图像卷积

互相关运算

我们暂时忽略通道(第三维)这一情况,看看如何处理二维图像数据和隐藏表示。在图中,输入是高度为3、宽度为3的二维张量(即形状为3×3)。卷积核的高度和宽度都是2,而卷积核窗口(或卷积窗口)的形状由内核的高度和宽度决定(即2×2)

../_images/correlation.svg

二维互相关运算中,卷积窗口从输入张量的左上角开始,从左到右、从上到下滑动。 当卷积窗口滑动到新一个位置时,包含在该窗口中的部分张量与卷积核张量进行按元素相乘,得到的张量再求和得到一个单一的标量值,由此我们得出了这一位置的输出张量值。

注意,输出大小略小于输入大小。这是因为卷积核的宽度和高度大于1, 而卷积核只与图像中每个大小完全适合的位置进行互相关运算,稍后,我们将看到如何通过在图像边界周围填充零来保证有足够的空间移动卷积核,从而保持输出大小不变

corr2d函数中实现如上过程,该函数接受输入张量X和卷积核张量K,并返回输出张量Y

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import torch
from torch import nn
from d2l import torch as d2l

def corr2d(X, K): #@save
"""计算二维互相关运算"""
# 获取卷积核K的高度和宽度
h, w = K.shape

# 初始化输出特征图Y,其尺寸根据输入X的尺寸和卷积核K的尺寸计算得出
# 输出特征图的每个维度都比输入X对应维度小卷积核尺寸加一
Y = torch.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]):
# 在输入X上对应位置切片,与卷积核K进行元素乘法后求和
# 这一步实际上是在计算卷积核K与输入X在(i, j)位置的互相关
Y[i, j] = (X[i:i + h, j:j + w] * K).sum()

# 返回计算得到的输出特征图Y
return Y


卷积层

卷积层对输入和卷积核权重进行互相关运算,并在添加标量偏置之后产生输出。 所以,卷积层中的两个被训练的参数是卷积核权重V和标量偏置U。 就像我们之前随机初始化全连接层一样,在训练基于卷积层的模型时,我们也随机初始化卷积核权重。

基于上面定义的corr2d函数实现二维卷积层。在__init__构造函数中,将weightbias声明为两个模型参数。前向传播函数调用corr2d函数并添加偏置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import torch
from torch import nn

class Conv2D(nn.Module):
def __init__(self, kernel_size):
super().__init__()
# 初始化卷积核权重为随机值,大小由kernel_size参数指定
self.weight = nn.Parameter(torch.rand(kernel_size))
# 初始化偏置为0,大小为1
self.bias = nn.Parameter(torch.zeros(1))

def forward(self, x):
# 在前向传播时,使用corr2d函数进行互相关运算
# 将输入x和权重self.weight作为参数传递给corr2d
# 然后将结果加上偏置self.bias
return corr2d(x, self.weight) + self.bias


边缘检测

如下是卷积层的一个简单应用:通过找到像素变化的位置,来检测图像中不同颜色的边缘。 首先,我们构造一个$6×8$像素的黑白图像。中间四列为黑色($0$),其余像素为白色($1$)。

1
2
3
4
5
6
7
8
9
10
11
X = torch.ones((6, 8))
X[:, 2:6] = 0
"""
输出结果:
tensor([[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.]])
"""

接下来,我们构造一个高度为1、宽度为2的卷积核K。当进行互相关运算时,如果水平相邻的两元素相同,则输出为零,否则输出为非零。

1
K = torch.tensor([[1.0, -1.0]])

现在,我们对参数X(输入)和K(卷积核)执行互相关运算。 如下所示,输出Y中的1代表从白色到黑色的边缘,-1代表从黑色到白色的边缘,其他情况的输出为0。

1
2
3
4
5
6
7
8
9
Y = corr2d(X, K)
"""
tensor([[ 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.]])
"""

现在我们将输入的二维图像转置,再进行如上的互相关运算。 其输出如下,之前检测到的垂直边缘消失了。 不出所料,这个卷积核K只可以检测垂直边缘,无法检测水平边缘

1
2
3
4
5
6
7
8
9
10
11
corr2d(X.t(), K)
"""
tensor([[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.]])
"""

学习卷积核

如果我们只需寻找黑白边缘,那么以上[1, -1]的边缘检测器足以。然而,当有了更复杂数值的卷积核,或者连续的卷积层时,我们不可能手动设计滤波器。那么我们是否可以学习X生成Y的卷积核呢?

现在让我们看看是否可以通过仅查看“输入-输出”对来学习由X生成Y的卷积核。 我们先构造一个卷积层,并将其卷积核初始化为随机张量。接下来,在每次迭代中,我们比较Y与卷积层输出的平方误差,然后计算梯度来更新卷积核。为了简单起见,我们在此使用内置的二维卷积层,并忽略偏置。

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
# 构造一个二维卷积层,它具有1个输出通道和形状为(1,2)的卷积核
conv2d = nn.Conv2d(1,1, kernel_size=(1, 2), bias=False)

# 这个二维卷积层使用四维输入和输出格式(批量大小、通道、高度、宽度),
# 其中批量大小和通道数都为1
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
lr = 3e-2 # 学习率

for i in range(10):
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2
conv2d.zero_grad()
l.sum().backward()
# 迭代卷积核
conv2d.weight.data[:] -= lr * conv2d.weight.grad
if (i + 1) % 2 == 0:
print(f'epoch {i+1}, loss {l.sum():.3f}')

"""
epoch 2, loss 6.422
epoch 4, loss 1.225
epoch 6, loss 0.266
epoch 8, loss 0.070
epoch 10, loss 0.022
"""

在10次迭代之后,误差已经降到足够低。现在我们来看看我们所学的卷积核的权重张量。

1
2
conv2d.weight.data.reshape((1, 2))
#tensor([[ 1.0010, -0.9739]])

和之前设置的卷积核权重非常接近


填充与步幅

填充

假设以下情景: 有时,在应用了连续的卷积之后,我们最终得到的输出远小于输入大小。这是由于卷积核的宽度和高度通常大于1所导致的。比如,一个240×240像素的图像,经过10层5×5的卷积后,将减少到200×200像素。如此一来,原始图像的边界丢失了许多有用信息。而填充是解决此问题最有效的方法

在应用多层卷积时,我们常常丢失边缘像素。 由于我们通常使用小卷积核,因此对于任何单个卷积,我们可能只会丢失几个像素。 但随着我们应用许多连续卷积层,累积丢失的像素数就多了。 解决这个问题的简单方法即为填充(padding):在输入图像的边界填充元素(通常填充元素是0)

../_images/conv-pad.svg

通常,如果我们添加$p_ℎ$行填充(大约一半在顶部,一半在底部)和$p_w$列填充(左侧大约一半,右侧一半),则输出形状将为

许多情况下,我们需要设置$p_ℎ=k_ℎ−1$和$p_w=k_w−1$,使输入和输出具有相同的高度和宽度。 这样可以在构建网络时更容易地预测每个图层的输出形状

卷积神经网络中卷积核的高度和宽度通常为奇数,例如1、3、5或7。 选择奇数的好处是,保持空间维度的同时,我们可以在顶部和底部填充相同数量的行,在左侧和右侧填充相同数量的列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import torch
from torch import nn


# 为了方便起见,我们定义了一个计算卷积层的函数。
# 此函数初始化卷积层权重,并对输入和输出提高和缩减相应的维数
def comp_conv2d(conv2d, X):
# 这里的(1,1)表示批量大小和通道数都是1
X = X.reshape((1, 1) + X.shape)
Y = conv2d(X)
# 省略前两个维度:批量大小和通道
return Y.reshape(Y.shape[2:])

# 请注意,这里每边都填充了1行或1列,因此总共添加了2行或2列
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1)
X = torch.rand(size=(8, 8))
comp_conv2d(conv2d, X).shape

当卷积核的高度和宽度不同时,我们可以填充不同的高度和宽度,使输出和输入具有相同的高度和宽度


步幅

在计算互相关时,卷积窗口从输入张量的左上角开始,向下、向右滑动。 在前面的例子中,我们默认每次滑动一个元素。 但是,有时候为了高效计算或是缩减采样次数,卷积窗口可以跳过中间位置,每次滑动多个元素,每次滑动元素的数量称为步幅(stride)

图中是垂直步幅为3,水平步幅为2的二维互相关运算。 着色部分是输出元素以及用于输出计算的输入和内核张量元素

../_images/conv-stride.svg

1
2
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=(2,3))
comp_conv2d(conv2d, X).shape

多输入多输出通道

当我们添加通道时,我们的输入和隐藏的表示都变成了三维张量。例如,每个RGB输入图像具有$3×ℎ×w$的形状。我们将这个大小为3的轴称为通道(channel)维度。本节将更深入地研究具有多输入和多输出通道的卷积核

多输入通道

当输入包含多个通道时,需要构造一个与输入数据具有相同输入通道数的卷积核,以便与输入数据进行互相关运算。假设输入的通道数为$c_i$,那么卷积核的输入通道数也需要为$c_i$。

然而,当$c_i>1$时,我们卷积核的每个输入通道将包含形状为$k_h\times k_w$的张量。将这些张量$c_i$连结在一起可以得到形状为$c_i\times k_h\times k_w$的卷积核。由于输入和卷积核都有$c_i$个通道,我们可以对每个通道输入的二维张量和卷积核的二维张量进行互相关运算,再对通道求和(将$c_i$的结果相加)得到二维张量。这是多通道输入和多输入通道卷积核之间进行二维互相关运算的结果。

../_images/conv-multi-in.svg

在上图,我们演示了一个具有两个输入通道的二维互相关运算的示例。阴影部第一个输出元素以及用于计算这个输出的输入和核张量元素:$(1\times1+2\times2+4\times3+5\times4)+(0\times0+1\times1+3\times2+4\times3)=56$。

为了加深理解,我们实现一下多输入通道互相关运算。 简而言之,我们所做的就是对每个通道执行互相关操作,然后将结果相加。

1
2
3
def corr2d_multi_in(X, K):
# 先遍历“X”和“K”的第0个维度(通道维度),再把它们加在一起
return sum(d2l.corr2d(x, k) for x, k in zip(X, K))

可以构造与图中的值相对应的输入张量X和核张量K,以验证互相关运算的输出。

1
2
3
4
5
X = torch.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]]])
K = torch.tensor([[[0.0, 1.0], [2.0, 3.0]], [[1.0, 2.0], [3.0, 4.0]]])

corr2d_multi_in(X, K)

多输出通道

在最流行的神经网络架构中,随着神经网络层数的加深,我们常会增加输出通道的维数,通过减少空间分辨率以获得更大的通道深度。直观地说,我们可以将每个通道看作对不同特征的响应。而现实可能更为复杂一些,因为每个通道不是独立学习的,而是为了共同使用而优化的。因此,多输出通道并不仅是学习多个单通道的检测器。

用$c_i$和$c_o$分别表示输入和输出通道的数目,并让$k_h$和$k_w$为卷积核的高度和宽度。为了获得多个通道的输出,我们可以为每个输出通道创建一个形状为$c_i\times k_h\times k_w$的卷积核张量,这样卷积核的形状是$c_o\times c_i\times k_h\times k_w$。在互相关运算中,每个输出通道先获取所有输入通道,再以对应该输出通道的卷积核计算出结果。

1
2
3
4
def corr2d_multi_in_out(X, K):
# 迭代“K”的第0个维度,每次都对输入“X”执行互相关运算。
# 最后将所有结果都叠加在一起
return torch.stack([corr2d_multi_in(X, k) for k in K], 0)

通过将核张量KK+1K中每个元素加$1$)和K+2连接起来,构造了一个具有$3$个输出通道的卷积核。

1
2
K = np.stack((K, K + 1, K + 2), 0)
K.shape

下面,我们对输入张量X与卷积核张量K执行互相关运算。现在的输出包含3个通道,第一个通道的结果与先前输入张量X和多输入单输出通道的结果一致。

1
2
3
4
5
6
7
8
9
10
11
corr2d_multi_in_out(X, K)
"""
tensor([[[ 56., 72.],
[104., 120.]],

[[ 76., 100.],
[148., 172.]],

[[ 96., 128.],
[192., 224.]]])
"""

1X1卷积层

$1 \times 1$卷积,即$k_h = k_w = 1$,看起来似乎没有多大意义。

毕竟,卷积的本质是有效提取相邻像素间的相关特征,而$1 \times 1$卷积显然没有此作用。尽管如此,$1 \times 1$仍然十分流行,经常包含在复杂深层网络的设计中。下面,让我们详细地解读一下它的实际作用。

因为使用了最小窗口,$1\times 1$卷积失去了卷积层的特有能力——在高度和宽度维度上,识别相邻元素间相互作用的能力。其实$1\times 1$卷积的唯一计算发生在通道上。

../_images/conv-1x1.svg

图中展示了使用$1\times 1$卷积核与$3$个输入通道和$2$个输出通道的互相关计算。

这里输入和输出具有相同的高度和宽度,输出中的每个元素都是从输入图像中同一位置的元素的线性组合。

我们可以将$1\times 1$卷积层看作在每个像素位置应用的全连接层,以$c_i$个输入值转换为$c_o$个输出值。因为这仍然是一个卷积层,所以跨像素的权重是一致的。同时,$1\times 1$卷积层需要的权重维度为$c_o\times c_i$,再额外加上一个偏置。

下面,我们使用全连接层实现$1×1$卷积。 请注意,我们需要对输入和输出的数据形状进行调整。

1
2
3
4
5
6
7
8
9
def corr2d_multi_in_out_1x1(X, K):
#图中的c_i=3,h=1,w=1,c_o=2
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 = torch.matmul(K, X)
return Y.reshape((c_o, h, w))

汇聚层

本节将介绍汇聚(pooling)层,它具有双重目的:降低卷积层对位置的敏感性,同时降低对空间降采样表示的敏感性。

最大汇聚层和平均汇聚层

与卷积层类似,汇聚层运算符由一个固定形状的窗口组成,该窗口根据其步幅大小在输入的所有区域上滑动,为固定形状窗口(有时称为汇聚窗口)遍历的每个位置计算一个输出。 然而,不同于卷积层中的输入与卷积核之间的互相关计算,汇聚层不包含参数。 相反,池运算是确定性的,我们通常计算汇聚窗口中所有元素的最大值或平均值。这些操作分别称为最大汇聚层平均汇聚层

../_images/pooling.svg

在这两种情况下,与互相关运算符一样,汇聚窗口从输入张量的左上角开始,从左往右、从上往下的在输入张量内滑动。在汇聚窗口到达的每个位置,它计算该窗口中输入子张量的最大值或平均值。计算最大值或平均值是取决于使用了最大汇聚层还是平均汇聚层

汇聚窗口形状为$p \times q$的汇聚层称为$p \times q$汇聚层,汇聚操作称为$p \times q$汇聚。

回到本节开头提到的对象边缘检测示例,现在我们将使用卷积层的输出作为$2\times 2$最大汇聚的输入。设置卷积层输入为X,汇聚层输出为Y

无论X[i, j]X[i, j + 1]的值相同与否,或X[i, j + 1]X[i, j + 2]的值相同与否,汇聚层始终输出Y[i, j] = 1。也就是说,使用$2\times 2$最大汇聚层,即使在高度或宽度上移动一个元素,卷积层仍然可以识别到模式。

在下面的代码中的pool2d函数,我们实现汇聚层的前向传播。这类似于中的corr2d函数。然而,这里我们没有卷积核,输出为输入中每个区域的最大值或平均值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import torch  
from torch import nn
from d2l import torch as d2l

# 定义pool2d函数,它执行池化操作。
def pool2d(X, pool_size, mode='max'):
# X是输入的特征图,pool_size是池化窗口的大小,mode决定池化的类型(最大或平均)。
p_h, p_w = pool_size
# 将pool_size元组解包到p_h和p_w,分别代表池化窗口的高度和宽度。
# 初始化输出特征图Y,其大小根据输入X的尺寸和池化窗口的大小计算得出。
Y = torch.zeros((X.shape[0] - p_h + 1, X.shape[1] - p_w + 1))
# 通过两层循环遍历输出特征图Y的每一个元素。
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
if mode == 'max': # 如果池化模式为最大池化,
# 则将输入特征图X的对应窗口区域内的最大值赋给Y的当前位置。
Y[i, j] = X[i: i + p_h, j: j + p_w].max()
elif mode == 'avg': # 如果池化模式为平均池化,
# 则将输入特征图X的对应窗口区域内的平均值赋给Y的当前位置。
Y[i, j] = X[i: i + p_h, j: j + p_w].mean()
return Y # 返回最终经过池化操作的输出特征图Y。

在处理多通道输入数据时,汇聚层在每个输入通道上单独运算,而不是像卷积层一样在通道上对输入进行汇总。 这意味着汇聚层的输出通道数与输入通道数相同


神经网络(LeNet)

介绍

总体来看,LeNet(LeNet-5)由两个部分组成:

  • 卷积编码器:由两个卷积层组成;
  • 全连接层密集块:由三个全连接层组成

如图所示:

../_images/lenet.svg

每个卷积块中的基本单元是一个卷积层、一个sigmoid激活函数和平均汇聚层。请注意,虽然ReLU和最大汇聚层更有效,但它们在20世纪90年代还没有出现。每个卷积层使用$5×5$卷积核和一个sigmoid激活函数。这些层将输入映射到多个二维特征输出,通常同时增加通道的数量。第一卷积层有6个输出通道,而第二个卷积层有16个输出通道。每个$2×2$池操作(步幅2)通过空间下采样将维数减少4倍。卷积的输出形状由批量大小、通道数、高度、宽度决定。

为了将卷积块的输出传递给稠密块,我们必须在小批量中展平每个样本。换言之,我们将这个四维输入转换成全连接层所期望的二维输入。这里的二维表示的第一个维度索引小批量中的样本,第二个维度给出每个样本的平面向量表示。LeNet的稠密块有三个全连接层,分别有120、84和10个输出。因为我们在执行分类任务,所以输出层的10维对应于最后输出结果的数量。

通过下面的LeNet代码,可以看出用深度学习框架实现此类模型非常简单。我们只需要实例化一个Sequential块并将需要的层连接在一起。

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

# 定义一个序贯模型,即各个层按顺序执行
net = nn.Sequential(
# 第一层是二维卷积层,输入通道为1,输出通道为6,卷积核为5x5,边缘填充为2
nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Sigmoid(),
# 第二层是平均池化层,卷积核为2x2,步幅为2
nn.AvgPool2d(kernel_size=2, stride=2),
# 第三层是二维卷积层,输入通道为6,输出通道为16,卷积核为5x5(无填充,默认为0)
nn.Conv2d(6, 16, kernel_size=5), nn.Sigmoid(),
# 第四层是平均池化层,卷积核为2x2,步幅为2
nn.AvgPool2d(kernel_size=2, stride=2),
# 扁平化层,将多维输入一维化,用于从卷积层到全连接层的过渡
nn.Flatten(),
# 全连接层,输入特征数为16*5*5,输出特征数为120
nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(),
# 另一个全连接层,输入特征数为120,输出特征数为84
nn.Linear(120, 84), nn.Sigmoid(),
# 最后一个全连接层,输入特征数为84,输出特征数为10(通常对应于分类任务的类别数)
nn.Linear(84, 10)
)

我们对原始模型做了一点小改动,去掉了最后一层的高斯激活。除此之外,这个网络与最初的LeNet-5一致。

下面,我们将一个大小为28×28的单通道(黑白)图像通过LeNet。通过在每一层打印输出的形状,我们可以检查模型,以确保其操作与我们期望的一致。

../_images/lenet-vert.svg

1
2
3
4
X = torch.rand(size=(1, 1, 28, 28), dtype=torch.float32)
for layer in net:
X = layer(X)
print(layer.__class__.__name__,'output shape: \t',X.shape)
1
2
3
4
5
6
7
8
9
10
11
12
Conv2d output shape:         torch.Size([1, 6, 28, 28])
Sigmoid output shape: torch.Size([1, 6, 28, 28])
AvgPool2d output shape: torch.Size([1, 6, 14, 14])
Conv2d output shape: torch.Size([1, 16, 10, 10])
Sigmoid output shape: torch.Size([1, 16, 10, 10])
AvgPool2d output shape: torch.Size([1, 16, 5, 5])
Flatten output shape: torch.Size([1, 400])
Linear output shape: torch.Size([1, 120])
Sigmoid output shape: torch.Size([1, 120])
Linear output shape: torch.Size([1, 84])
Sigmoid output shape: torch.Size([1, 84])
Linear output shape: torch.Size([1, 10])

请注意,在整个卷积块中,与上一层相比,每一层特征的高度和宽度都减小了。 第一个卷积层使用2个像素的填充,来补偿$5×5$卷积核导致的特征减少。 相反,第二个卷积层没有填充,因此高度和宽度都减少了4个像素。 随着层叠的上升,通道的数量从输入时的1个,增加到第一个卷积层之后的6个,再到第二个卷积层之后的16个。 同时,每个汇聚层的高度和宽度都减半。最后,每个全连接层减少维数,最终输出一个维数与结果分类数相匹配的输出。


模型训练

1
2
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)

虽然卷积神经网络的参数较少,但与深度的多层感知机相比,它们的计算成本仍然很高,因为每个参数都参与更多的乘法。 通过使用GPU,可以用它加快训练。

为了进行评估,我们需要对evaluate_accuracy函数进行轻微的修改。 由于完整的数据集位于内存中,因此在模型使用GPU计算数据集之前,我们需要将其复制到显存中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def evaluate_accuracy_gpu(net, data_iter, device=None): #@save
"""使用GPU计算模型在数据集上的精度"""
if isinstance(net, nn.Module):
net.eval() # 设置为评估模式
if not device:
device = next(iter(net.parameters())).device
# 正确预测的数量,总预测的数量
metric = d2l.Accumulator(2)
with torch.no_grad():
for X, y in data_iter:
if isinstance(X, list):
# BERT微调所需的(之后将介绍)
X = [x.to(device) for x in X]
else:
X = X.to(device)
y = y.to(device)
metric.add(d2l.accuracy(net(X), y), y.numel())
return metric[0] / metric[1]

如下所示,训练函数train_ch6也类似于中定义的train_ch3。 由于我们将实现多层神经网络,因此我们将主要使用高级API。 以下训练函数假定从高级API创建的模型作为输入,并进行相应的优化。 我们使用之前介绍的Xavier随机初始化模型参数。 与全连接层一样,我们使用交叉熵损失函数和小批量随机梯度下降。

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
#@save
def train_ch6(net, train_iter, test_iter, num_epochs, lr, device):
"""用GPU训练模型(在第六章定义)"""

def init_weights(m):
# 初始化模型的权重,使用Xavier初始化方法,适用于线性层和卷积层
if type(m) == nn.Linear or type(m) == nn.Conv2d:
nn.init.xavier_uniform_(m.weight)

net.apply(init_weights) # 将权重初始化应用到网络的每个模块

print('training on', device) # 打印正在使用的设备,GPU或CPU
net.to(device) # 将模型转移到指定的设备上

# 定义优化器,这里使用SGD(随机梯度下降)
optimizer = torch.optim.SGD(net.parameters(), lr=lr)

# 定义损失函数,这里使用交叉熵损失,适合多分类问题
loss = nn.CrossEntropyLoss()

# 使用d2l库的Animator绘制训练过程中的损失和准确率
animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs],
legend=['train loss', 'train acc', 'test acc'])

timer, num_batches = d2l.Timer(), len(train_iter) # 初始化计时器和计算批次数

for epoch in range(num_epochs): # 训练过程中遍历每一个epoch
# 初始化累加器,用于记录损失和准确率
metric = d2l.Accumulator(3) # 训练损失之和,训练准确率之和,样本数

net.train() # 将网络设置为训练模式

for i, (X, y) in enumerate(train_iter): # 遍历训练数据集
timer.start() # 开始计时

optimizer.zero_grad() # 清空梯度
X, y = X.to(device), y.to(device) # 将数据转移到指定的设备
y_hat = net(X) # 前向传播,计算预测值
l = loss(y_hat, y) # 计算损失
l.backward() # 反向传播,计算梯度
optimizer.step() # 更新权重

with torch.no_grad(): # 不计算梯度,节省计算资源
# 更新累加器中的损失和准确率
metric.add(l * X.shape[0], d2l.accuracy(y_hat, y), X.shape[0])
timer.stop() # 停止计时

train_l = metric[0] / metric[2] # 计算平均训练损失
train_acc = metric[1] / metric[2] # 计算平均训练准确率

# 定期更新动画中的训练损失和训练准确率
if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1:
animator.add(epoch + (i + 1) / num_batches,
(train_l, train_acc, None))

test_acc = evaluate_accuracy_gpu(net, test_iter) # 在测试集上评估模型的准确率
animator.add(epoch + 1, (None, None, test_acc)) # 更新动画中的测试准确率

# 打印训练损失、训练准确率和测试准确率
print(f'loss {train_l:.3f}, train acc {train_acc:.3f}, '
f'test acc {test_acc:.3f}')

# 打印每秒可以处理的样本数,表示训练效率
print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec '
f'on {str(device)}')

现在,我们训练和评估LeNet-5模型。

1
2
3
4
5
6
7
lr, num_epochs = 0.9, 10
train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

"""
loss 0.464, train acc 0.827, test acc 0.782
57969.2 examples/sec on cuda:0
"""

../_images/output_lenet_4a2e9e_64_1.svg