D2L-6-Convolutional Neural Networks

本文最后更新于:1 年前

图像数据的每个样本都由一个二维像素网格组成, 每个像素可能是一个或者多个数值,取决于是黑白还是彩色图像。 到目前为止,我们处理这类结构丰富的数据的方式还不够有效。 我们仅仅通过将图像数据展平成一维向量而忽略了每个图像的空间结构信息,再将数据送入一个全连接的多层感知机中。 因为这些网络特征元素的顺序是不变的,因此最优的结果是利用先验知识,即利用相近像素之间的相互关联性,从图像数据中学习得到有效的模型。

卷积神经网络(convolutional neural network,CNN)是一类强大的、为处理图像数据而设计的神经网络。 基于卷积神经网络架构的模型在计算机视觉领域中已经占主导地位,当今几乎所有的图像识别、目标检测或语义分割相关的学术竞赛和商业应用都以这种方法为基础。

卷积神经网络需要的参数少于全连接架构的网络,而且卷积也很容易用GPU并行计算。 因此卷积神经网络除了能够高效地采样从而获得精确的模型,还能够高效地计算。 久而久之,从业人员越来越多地使用卷积神经网络。即使在通常使用循环神经网络的一维序列结构任务上(例如音频、文本和时间序列分析),卷积神经网络也越来越受欢迎。 通过对卷积神经网络一些巧妙的调整,也使它们在图结构数据和推荐系统中发挥作用。

从全连接层到卷积

多层感知机十分适合处理表格数据,其中行对应样本,列对应特征。 对于表格数据,我们寻找的模式可能涉及特征之间的交互,但是我们不能预先假设任何与特征交互相关的先验结构。 此时,多层感知机可能是最好的选择,然而对于高维感知数据,这种缺少结构的网络可能会变得不实用。卷积神经网络(convolutional neural networks,CNN)是机器学习利用自然图像中一些已知结构的创造性方法。

不变性

  • 假设我们想从一张图片中找到某个物体。 合理的假设是:无论哪种方法找到这个物体,都应该和物体的位置无关。 卷积神经网络正是将空间不变性(spatial invariance)的这一概念系统化,从而基于这个模型使用较少的参数来学习有用的表示。
    • 平移不变性(translation invariance):不管检测对象出现在图像中的哪个位置,神经网络的前面几层应该对相同的图像区域具有相似的反应,即为“平移不变性”。
    • 局部性(locality):神经网络的前面几层应该只探索输入图像中的局部区域,而不过度在意图像中相隔较远区域的关系,这就是“局部性”原则。最终,可以聚合这些局部特征,以在整个图像级别进行预测。

多层感知机的限制

建议参考一下:简单理解卷积神经网络

  • 多层感知机的输入是二维图像$\mathbf{X}$,其隐藏表示$\mathbf{H}$在数学上是一个矩阵,在代码中表示为二维张量。 其中$\mathbf{X}$和$\mathbf{H}$具有相同的形状。 为了方便理解,我们可以认为,无论是输入还是隐藏表示都拥有空间结构。
  • 使用$[\mathbf{X}]{i, j}$和$[\mathbf{H}]{i, j}$分别表示输入图像和隐藏表示中位置$(i,j)$处的像素。 为了使每个隐藏神经元都能接收到每个输入像素的信息,我们将参数从权重矩阵(如同我们先前在多层感知机中所做的那样)替换为四阶权重张量$\mathsf{W}$。假设$\mathbf{U}$包含偏置参数,我们可以将全连接层形式化地表示为

$$
\begin{split}\begin{aligned} \left[\mathbf{H}\right]{i, j} &= [\mathbf{U}]{i, j} + \sum_k \sum_l[\mathsf{W}]{i, j, k, l} [\mathbf{X}]{k, l}\ &= [\mathbf{U}]{i, j} +
\sum_a \sum_b [\mathsf{V}]
{i, j, a, b} [\mathbf{X}]_{i+a, j+b}.\end{aligned}\end{split}
$$

  • 输入输出在MLP里面为向量,现在为矩阵。因此之前矩阵只有一个维度,现在有两个维度。全连接层重新写一下,作成一个二维的输入输出捏!!!原本权重为二维,输入输出为一维向量,可以理解为从输入和输出分别选一个节点。现在权重变为4维,输入输出为二维矩阵,可以理解成从输入和输出分别选一个包含宽高的节点。这个$W$本质就是一个具有Reshape功能的4D张量。
  • 说白了其实就是一个,一维到二维的拓展和处理,就清楚了。无非是原来的MLP都是单层的输入和输出,现在是矩阵的输入和输出嘛!然后重新索引这件事,由于两个矩阵位置一一对应,这里我们完全可以通过这种重新构建索引的方式,从W -> V,同时改变下标索引规则。为下面公式的进一步演化做铺垫。
  • 从$\mathsf{W}$到$\mathsf{V}$的转换只是形式上的转换,因为在这两个四阶张量的元素之间存在一一对应的关系。 我们只需重新索引下标(k,l),使$k = i+a,\ l = j+b$,由此可得$[\mathsf{V}]{i, j, a, b} = [\mathsf{W}]{i, j, i+a, j+b}$。 索引a和b通过在正偏移和负偏移之间移动覆盖了整个图像。 对于隐藏表示中任意给定位置(i,j)处的像素值$[\mathbf{H}]{i, j}$,可以通过在$x$中以(i,j)为中心对像素进行加权求和得到,加权使用的权重为$[\mathsf{V}]{i, j, a, b}$。

平移不变性

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

$$
[\mathbf{H}]{i, j} = u + \sum_a\sum_b [\mathbf{V}]{a, b} [\mathbf{X}]_{i+a, j+b}.
$$

这就是卷积(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}$少很多,因为前者不再依赖于图像中的位置。这就是显著的进步!

  • 说白了就是V这个图像的卷积核(识别器),不应该随着X的位置变化,对于图片的哪个位置,识别的方式应该都是一样的。也就是上面的V应该和X中的像素点i,j的位置没有关系!!!因此,i,j根据这个特性,直接从公式里面拿掉了。

局部性

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

$$
[\mathbf{H}]{i, j} = u + \sum{a = -\Delta}^{\Delta} \sum_{b = -\Delta}^{\Delta} [\mathbf{V}]{a, b} [\mathbf{X}]{i+a, j+b}.
$$

卷积层(convolutional layer),而卷积神经网络是包含卷积层的一类特殊的神经网络。 在深度学习研究社区中,$V$被称为卷积核(convolution kernel)或者滤波器(filter),亦或简单地称之为该卷积层的权重,通常该权重是可学习的参数。 当图像处理的局部区域很小时,卷积神经网络与多层感知机的训练差异可能是巨大的:以前,多层感知机可能需要数十亿个参数来表示网络中的一层,而现在卷积神经网络通常只需要几百个参数,而且不需要改变输入或隐藏表示的维数。 参数大幅减少的代价是,我们的特征现在是平移不变的,并且当确定每个隐藏活性值时,每一层只包含局部的信息。 以上所有的权重学习都将依赖于归纳偏置。当这种偏置与现实相符时,我们就能得到样本有效的模型,并且这些模型能很好地泛化到未知数据中。 但如果这偏置与现实不符时,比如当图像不满足平移不变时,我们的模型可能难以拟合我们的训练数据。

  • 说白了就是V这个图像的卷积核(识别器),识别的位置应该有限,不应该是整个图片,而应该是图片的一部分,因此a, b的范围现在也有了限制,也就是V的大小会被限制!

卷积

  • 在数学中,两个函数(比如$f, g: \mathbb{R}^d \to \mathbb{R}$)之间的“卷积”被定义为:

$$
(f * g)(\mathbf{x}) = \int f(\mathbf{z}) g(\mathbf{x}-\mathbf{z}) d\mathbf{z}.
$$

  • 也就是说,卷积是当把一个函数“翻转”并移位$x$时,测量$f$和$g$之间的重叠。 当为离散对象时,积分就变成求和。例如,对于由索引为$\mathbb{Z}$的、平方可和的、无限维向量集合中抽取的向量,我们得到以下定义:

$$
(f * g)(i) = \sum_a f(a) g(i-a).
$$

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

$$
(f * g)(i, j) = \sum_a\sum_b 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)。 这些通道有时也被称为特征映射(feature maps),因为每个通道都向后续层提供一组空间化的学习特征。 直观上可以想象在靠近输入的底层,一些通道专门识别边缘,而一些通道专门识别纹理。
  • 为了支持输入$\mathsf{X}$和隐藏表示$\mathsf{H}$中的多个通道,我们可以在$\mathsf{V}$中添加第四个坐标,即$[\mathsf{V}]_{a, b, c, d}$。综上所述,

$$
[\mathsf{H}]{i,j,d} = \sum{a = -\Delta}^{\Delta} \sum_{b = -\Delta}^{\Delta} \sum_c [\mathsf{V}]{a, b, c, d} [\mathsf{X}]{i+a, j+b, c},
$$

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

图像卷积

互相关运算

  • 卷积层是个错误的叫法,因为它所表达的运算其实是互相关运算(cross-correlation),而不是卷积运算。 在卷积层中,输入张量和核张量通过互相关运算产生输出张量。首先,我们暂时忽略通道(第三维)这一情况,看看如何处理二维图像数据和隐藏表示。在 ,输入是高度为3、宽度为3的二维张量(即形状为3×3)。卷积核的高度和宽度都是2,而卷积核窗口(或卷积窗口)的形状由内核的高度和宽度决定(即2×2)。

../_images/correlation.svg

二维互相关运算。阴影部分是第一个输出元素,以及用于计算输出的输入张量元素和核张量元素:0×0+1×1+3×2+4×3=19.

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

$$
\begin{split}0\times0+1\times1+3\times2+4\times3=19,\
1\times0+2\times1+4\times2+5\times3=25,\
3\times0+4\times1+6\times2+7\times3=37,\
4\times0+5\times1+7\times2+8\times3=43.\end{split}
$$

  • 注意,输出大小略小于输入大小。这是因为卷积核的宽度和高度大于1, 而卷积核只与图像中每个大小完全适合的位置进行互相关运算。 所以,输出大小等于输入大小$n_h \times n_w$减去卷积核大小$k_h \times k_w$,即:

$$
(n_h-k_h+1) \times (n_w-k_w+1).
$$

  • 这是因为我们需要足够的空间在图像上“移动”卷积核。稍后,我们将看到如何通过在图像边界周围填充零来保证有足够的空间移动卷积核,从而保持输出大小不变。 接下来,我们在corr2d函数中实现如上过程,该函数接受输入张量X和卷积核张量K,并返回输出张量Y
1
2
3
4
5
6
7
8
9
10
11
12
import torch
from torch import nn
from d2l import torch as d2l

def corr2d(X, K): #@save
"""计算二维互相关运算"""
h, w = K.shape
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]):
Y[i, j] = (X[i:i + h, j:j + w] * K).sum()
return Y
  • 输入张量X和卷积核张量K,我们来验证上述二维互相关运算的输出。
1
2
3
X = torch.tensor([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
K = torch.tensor([[0.0, 1.0], [2.0, 3.0]])
corr2d(X, K)

卷积层

  • 卷积层对输入和卷积核权重进行互相关运算,并在添加标量偏置之后产生输出。 所以,卷积层中的两个被训练的参数是卷积核权重和标量偏置。 就像我们之前随机初始化全连接层一样,在训练基于卷积层的模型时,我们也随机初始化卷积核权重。基于上面定义的corr2d函数实现二维卷积层。在__init__构造函数中,将weightbias声明为两个模型参数。前向传播函数调用corr2d函数并添加偏置。
1
2
3
4
5
6
7
8
class Conv2D(nn.Module):
def __init__(self, kernel_size):
super().__init__()
self.weight = nn.Parameter(torch.rand(kernel_size))
self.bias = nn.Parameter(torch.zeros(1))

def forward(self, x):
return corr2d(x, self.weight) + self.bias

高度和宽度分别为$h$和$w$的卷积核可以被称为$h \times w$卷积或$h \times w$卷积核。 我们也将带有$h \times w$卷积核的卷积层称为$h \times w$卷积层。

图像中目标的边缘检测

  • 如下是卷积层的一个简单应用:通过找到像素变化的位置,来检测图像中不同颜色的边缘。 首先,我们构造一个6×8像素的黑白图像。中间四列为黑色(0),其余像素为白色(1)。
1
2
3
4
5
6
7
8
9
10
11
X = torch.ones((6, 8))
X[:, 2:6] = 0
X

# result
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
Y = corr2d(X, K)
Y
  • 现在我们将输入的二维图像转置,再进行如上的互相关运算。 其输出如下,之前检测到的垂直边缘消失了。 不出所料,这个卷积核K只可以检测垂直边缘,无法检测水平边缘。
1
corr2d(X.t(), K)

学习卷积核

  • 如果我们只需寻找黑白边缘,那么以上[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
# 构造一个二维卷积层,它具有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}')

# result
epoch 2, loss 15.223
epoch 4, loss 5.325
epoch 6, loss 2.029
epoch 8, loss 0.805
epoch 10, loss 0.326
  • 在10次迭代之后,误差已经降到足够低。现在我们来看看我们所学的卷积核的权重张量。
1
2
3
4
conv2d.weight.data.reshape((1, 2))

# result
tensor([[ 1.0486, -0.9313]])

细心的读者一定会发现,我们学习到的卷积核权重非常接近我们之前定义的卷积核K

互相关和卷积

  • 为了得到正式的卷积运算输出,我们需要执行严格卷积运算,而不是互相关运算。 幸运的是,它们差别不大,我们只需水平和垂直翻转二维卷积核张量,然后对输入张量执行互相关运算。
  • 为了与深度学习文献中的标准术语保持一致,我们将继续把“互相关运算”称为卷积运算,尽管严格地说,它们略有不同。 此外,对于卷积核张量上的权重,我们称其为元素

特征映射和感受野

  • 输出的卷积层有时被称为特征映射(feature map),因为它可以被视为一个输入映射到下一层的空间维度的转换器。 在卷积神经网络中,对于某一层的任意元素$x$,其感受野(receptive field)是指在前向传播期间可能影响$x$计算的所有元素(来自所有先前层)。
  • 感受野可能大于输入的实际大小。尤其多层叠加的时候,以前面$2 \times 2$的卷积核图为例子。19是前面的$2 \times 2$来的,而$2 \times 2$的每一个元素,又是上一层的$2 \times 2$来的。输入的感受野包括最初所有九个输入元素。 因此,当一个特征图中的任意元素需要检测更广区域的输入特征时,我们可以构建一个更深的网络。

填充和步幅

  • 输入的高度和宽度都为3,卷积核的高度和宽度都为2,生成的输出表征的维数为2×2。 假设输入形状为$n_h\times n_w$,卷积核形状为$k_h\times k_w$,那么输出形状将是$(n_h-k_h+1) \times (n_w-k_w+1)$。 因此,卷积的输出形状取决于输入形状和卷积核的形状。

填充

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

../_images/conv-pad.svg

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

$$
(n_h-k_h+p_h+1)\times(n_w-k_w+p_w+1)。
$$

这意味着输出的高度和宽度将分别增加$p_h$和$p_w$。

  • 在许多情况下,我们需要设置$p_h=k_h-1$和$p_w=k_w-1$,使输入和输出具有相同的高度和宽度。 这样可以在构建网络时更容易地预测每个图层的输出形状。
    • $k_h$是奇数,我们将在高度的两侧填充$p_h/2$行。
    • $k_h$是偶数,则一种可能性是在输入顶部填充$\lceil p_h/2\rceil$行,在底部填充$\lfloor p_h/2\rfloor$行。同理,我们填充宽度的两侧。
  • 卷积神经网络中卷积核的高度和宽度通常为奇数,例如1、3、5或7。 选择奇数的好处是,保持空间维度的同时,我们可以在顶部和底部填充相同数量的行,在左侧和右侧填充相同数量的列。使用奇数的核大小和填充大小也提供了书写上的便利。对于任何二维张量X,当满足: 1. 卷积核的大小是奇数; 2. 所有边的填充行数和列数相同; 3. 输出与输入具有相同高度和宽度 则可以得出:输出Y[i, j]是通过以输入X[i, j]为中心,与卷积核进行互相关计算得到的。
  • 例子:
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

我们创建一个高度和宽度为3的二维卷积层,并在所有侧边填充1个像素。给定高度和宽度为8的输入,则输出的高度和宽度也是8。

  • 当卷积核的高度和宽度不同时,我们可以填充不同的高度和宽度,使输出和输入具有相同的高度和宽度。在如下示例中,我们使用高度为5,宽度为3的卷积核,高度和宽度两边的填充分别为2和1。
1
2
conv2d = nn.Conv2d(1, 1, kernel_size=(5, 3), padding=(2, 1))
comp_conv2d(conv2d, X).shape

步幅

  • 计算互相关时,卷积窗口从输入张量的左上角开始,向下、向右滑动。 在前面的例子中,我们默认每次滑动一个元素。 但是,有时候为了高效计算或是缩减采样次数,卷积窗口可以跳过中间位置,每次滑动多个元素。
  • 我们将每次滑动元素的数量称为步幅(stride)。到目前为止,我们只使用过高度或宽度为1的步幅,那么如何使用较大的步幅呢?

../_images/conv-stride.svg

卷积窗口分别向下滑动三行和向右滑动两列。

  • 通常,当垂直步幅为$s_h$、水平步幅为$s_w$时,输出形状为:

$$
\lfloor(n_h-k_h+p_h+s_h)/s_h\rfloor \times \lfloor(n_w-k_w+p_w+s_w)/s_w\rfloor.
$$

  • 如果我们设置了$p_h=k_h-1$和$p_w=k_w-1$,则输出形状将简化为$\lfloor(n_h+s_h-1)/s_h\rfloor \times \lfloor(n_w+s_w-1)/s_w\rfloor$。 更进一步,如果输入的高度和宽度可以被垂直和水平步幅整除,则输出形状将为$(n_h/s_h) \times (n_w/s_w)$。下面,我们将高度和宽度的步幅设置为2,从而将输入的高度和宽度减半。
1
2
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2)
comp_conv2d(conv2d, X).shape
  • 看一个稍微复杂的例子。
1
2
conv2d = nn.Conv2d(1, 1, kernel_size=(3, 5), padding=(0, 1), stride=(3, 4))
comp_conv2d(conv2d, X).shape

为了简洁起见,当输入高度和宽度两侧的填充数量分别为$p_h$和$p_w$时,我们称之为填充($p_h$, $p_w$)。当$p_h = p_w = p$时,填充是$p$。同理,当高度和宽度上的步幅分别为$s_h$和$s_w$时,我们称之为步幅$(s_h, s_w)$。特别地,当$s_h = s_w = s$时,我们称步幅为$s$。默认情况下,填充为0,步幅为1。在实践中,我们很少使用不一致的步幅或填充,也就是说,我们通常有$p_h = p_w$和$s_h = s_w$。

总结

  • 填充和步幅都是超参数
    • 填充是在输入周围添加额外的行/列,控制输出形状的减少量。
    • 步幅是每次滑动核窗口时的行/列的步长,可以成倍的减少输出的形状。

多输入多输出通道

  • 构成每个图像的多个通道和多层卷积层。例如彩色图像具有标准的RGB通道来代表红、绿和蓝。 但是到目前为止,我们仅展示了单个输入和单个输出通道的简化例子。 这使得我们可以将输入、卷积核和输出看作二维张量。当我们添加通道时,我们的输入和隐藏的表示都变成了三维张量。例如,每个RGB输入图像具有$3\times h\times w$的形状。我们将这个大小为3的轴称为通道(channel)维度。本节将更深入地研究具有多输入和多输出通道的卷积核。

多输入通道

  • 当输入包含多个通道时,需要构造一个与输入数据具有相同输入通道数的卷积核,以便与输入数据进行互相关运算。假设输入的通道数为$c_i$,那么卷积核的输入通道数也需要为$c_i$。如果卷积核的窗口形状是$k_h\times k_w$,那么当$c_i=1$时,我们可以把卷积核看作形状为$k_h\times k_w$的二维张量。
  • 当$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
2
3
4
5
6
import torch
from d2l import torch as d2l

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
6
7
8
9
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)

# result
tensor([[ 56., 72.],
[104., 120.]])

image-20230805112451511

c是channel,代表的含义是,根据不同的channel in具有各自的卷积核进行卷积操作,最后把对应位置所有的卷积结果求和。就构造了这样的一个多输入,单输出的模型。

多输出通道

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

  • 用$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
3
4
K = torch.stack((K, K + 1, K + 2), 0)
K.shape

# result torch.Size([3, 2, 2, 2])
  • 下面,我们对输入张量X与卷积核张量K执行互相关运算。现在的输出包含3个通道,第一个通道的结果与先前输入张量X和多输入单输出通道的结果一致。
1
corr2d_multi_in_out(X, K)

image-20230805112615735

和上面类似,c是channel,代表的含义是,针对我们输出的每一个channel out,都有一套对应channel in的卷积核,进行上述的卷积求和操作。说白了就是多输入 -> 单输出这种模式,重复了channel out次,就构造了新的多输入 -> 多输出模式。

总结

  • 每个输出通道可以识别特定模式
  • 输入通道核识别并组合输入中的模式

感觉就像乐高一样,下层识别局部纹理啊,特征啊之类的。上层组合这些纹理和特征,进行整体识别,大粒度!

$1 \times 1$卷积层

  • $k_h = k_w = 1$看起来似乎没有多大意义。下面,让我们详细地解读一下它的实际作用。因为使用了最小窗口,1×1卷积失去了卷积层的特有能力——在高度和宽度维度上,识别相邻元素间相互作用的能力。 其实1×1卷积的唯一计算发生在通道上。

../_images/conv-1x1.svg

使用1×1卷积核与3个输入通道和2个输出通道的互相关计算。 这里输入和输出具有相同的高度和宽度,输出中的每个元素都是从输入图像中同一位置的元素的线性组合。 我们可以将1×1卷积层看作在每个像素位置应用的全连接层,以$c_i$个输入值转换为$c_o$个输出值。 因为这仍然是一个卷积层,所以跨像素的权重是一致的。 同时,1×1卷积层需要的权重维度为$c_o\times c_i$,再额外加上一个偏置。

  • 互相关计算使用了具有3个输入通道和2个输出通道的 1×1 卷积核。其中,输入和输出具有相同的高度和宽度。
  • 不识别空间模式,只是融合通道。
  • 相当于输入形状为$n_{h}n_{w} \times c_{i}$,权重为$c_{o} \times c_{i}$的全连接层。没有做空间模式识别,不就是全连接层吗?既可以看作是全连接层,又可以看作是卷积层。
  • 我们使用全连接层实现1×1卷积。 请注意,我们需要对输入和输出的数据形状进行调整。
1
2
3
4
5
6
7
8
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 = torch.matmul(K, X)
return Y.reshape((c_o, h, w))
  • 当执行1×1卷积运算时,上述函数相当于先前实现的互相关函数corr2d_multi_in_out。让我们用一些样本数据来验证这一点。
1
2
3
4
5
6
X = torch.normal(0, 1, (3, 3, 3))
K = torch.normal(0, 1, (2, 3, 1, 1))

Y1 = corr2d_multi_in_out_1x1(X, K)
Y2 = corr2d_multi_in_out(X, K)
assert float(torch.abs(Y1 - Y2).sum()) < 1e-6

二维卷积层

  • 输入$\mathbf{X}:\ c_{i} \times n_{h} \times n_{w}$ -> 3D

  • 核心$\mathbf{W}:\ c_{o} \times c_{i} \times k_{h} \times k_{w}$ -> 4D

  • 输入$\mathbf{B}:\ c_{0} \times c_{i}$ -> 2D

  • 输入$\mathbf{Y}:\ c_{0} \times m_{h} \times m_{w}$ -> 3D

  • 计算复杂度$O(c_{i}c_{o}k_{h}k_{w}m_{h}m_{w})$

image-20230805114811965

  • 总结:
    • 输出通道数是卷积层的超参数
    • 每个输入通道有独立的二维卷积核,所有通道结果相加得到一个输出通道结果
    • 每个输出通道有独立的三维卷积核

池化层(汇聚层)

  • 随着我们在神经网络中层叠的上升,每个神经元对其敏感的感受野(输入)就越大。
  • 我们的机器学习任务通常会跟全局图像的问题有关,所以我们最后一层的神经元应该对整个输入的全局敏感。通过逐渐聚合信息,生成越来越粗糙的映射,最终实现学习全局表示的目标,同时将卷积图层的所有优势保留在中间层。
  • 汇聚(pooling)层,它具有双重目的:降低卷积层对位置的敏感性,同时降低对空间降采样表示的敏感性。

最大池化层和平均池化层

  • 汇聚层运算符由一个固定形状的窗口组成,该窗口根据其步幅大小在输入的所有区域上滑动,为固定形状窗口(有时称为汇聚窗口)遍历的每个位置计算一个输出。 然而,不同于卷积层中的输入与卷积核之间的互相关计算,汇聚层不包含参数。 相反,池运算是确定性的,我们通常计算汇聚窗口中所有元素的最大值或平均值。这些操作分别称为最大汇聚层(maximum pooling)和平均汇聚层(average pooling)。
  • 与互相关运算符一样,汇聚窗口从输入张量的左上角开始,从左往右、从上往下的在输入张量内滑动。在汇聚窗口到达的每个位置,它计算该窗口中输入子张量的最大值或平均值。计算最大值或平均值是取决于使用了最大汇聚层还是平均汇聚层。

../_images/pooling.svg

  • 输出张量的高度为2,宽度为2。这四个元素为每个汇聚窗口中的最大值:

$$
\begin{split}\max(0, 1, 3, 4)=4,\
\max(1, 2, 4, 5)=5,\
\max(3, 4, 6, 7)=7,\
\max(4, 5, 7, 8)=8.\\end{split}
$$

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

  • 我们将使用卷积层的输出作为2×2最大汇聚的输入。 设置卷积层输入为X,汇聚层输出为Y。 无论X[i, j]X[i, j + 1]的值相同与否,或X[i, j + 1]X[i, j + 2]的值相同与否,汇聚层始终输出Y[i, j] = 1。 也就是说,使用2×2最大汇聚层,即使在高度或宽度上移动一个元素,卷积层仍然可以识别到模式。
  • 在下面的代码中的pool2d函数,我们实现汇聚层的前向传播。 这类似于 6.2节中的corr2d函数。 然而,这里我们没有卷积核,输出为输入中每个区域的最大值或平均值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import torch
from torch import nn
from d2l import torch as d2l

def pool2d(X, pool_size, mode='max'):
p_h, p_w = pool_size
Y = torch.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
  • 验证二维最大汇聚层的输出。
1
2
3
4
5
6
7
8
9
10
11
X = torch.tensor([[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]])
pool2d(X, (2, 2))

# result
tensor([[4., 5.],
[7., 8.]])

pool2d(X, (2, 2), 'avg')
# result
tensor([[2., 3.],
[5., 6.]])

填充和步幅

和卷积类似的超参数,没有kernel,或者说学习的参数!不会融合多输入通道,每个通道做一次池化,输入和输出是一样的昂!!!

  • 与卷积层一样,汇聚层也可以改变输出形状。和以前一样,我们可以通过填充和步幅以获得所需的输出形状。 下面,我们用深度学习框架中内置的二维最大汇聚层,来演示汇聚层中填充和步幅的使用。 我们首先构造了一个输入张量X,它有四个维度,其中样本数和通道数都是1。
1
2
X = torch.arange(16, dtype=torch.float32).reshape((1, 1, 4, 4))
X
  • 默认情况下,深度学习框架中的步幅与汇聚窗口的大小相同。 因此,如果我们使用形状为(3, 3)的汇聚窗口,那么默认情况下,我们得到的步幅形状为(3, 3)
1
2
3
4
pool2d = nn.MaxPool2d(3)
pool2d(X)

# result tensor([[[[10.]]]])
  • 填充和步幅可以手动设定。
1
2
3
4
5
6
pool2d = nn.MaxPool2d(3, padding=1, stride=2)
pool2d(X)

# result
tensor([[[[ 5., 7.],
[13., 15.]]]])
  • 当然,我们可以设定一个任意大小的矩形汇聚窗口,并分别设定填充和步幅的高度和宽度。
1
2
3
4
5
6
pool2d = nn.MaxPool2d((2, 3), stride=(2, 3), padding=(0, 1))
pool2d(X)

# result
tensor([[[[ 5., 7.],
[13., 15.]]]])

多个通道

  • 在处理多通道输入数据时,汇聚层在每个输入通道上单独运算,而不是像卷积层一样在通道上对输入进行汇总。 这意味着汇聚层的输出通道数与输入通道数相同。 下面,我们将在通道维度上连结张量XX + 1,以构建具有2个通道的输入。
1
2
X = torch.cat((X, X + 1), 1)
X
  • 汇聚后输出通道的数量仍然是2。
1
2
pool2d = nn.MaxPool2d(3, padding=1, stride=2)
pool2d(X)

卷积神经网络(LeNet)

LeNet

  • LeNet(LeNet-5)由两个部分组成:
    • 卷积编码器:由两个卷积层组成;
    • 全连接层密集块:由三个全连接层组成。

../_images/lenet.svg

LeNet中的数据流。输入是手写数字,输出为10种可能结果的概率。

  • 这里的特征图,就是我们口中所说的Feature Map
  • 每个卷积块中的基本单元是一个卷积层、一个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
import torch
from torch import nn
from d2l import torch as d2l

net = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Flatten(),
nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(),
nn.Linear(120, 84), nn.Sigmoid(),
nn.Linear(84, 10))

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

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

../_images/lenet-vert.svg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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)

# result
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个。 同时,每个汇聚层的高度和宽度都减半。最后,每个全连接层减少维数,最终输出一个维数与结果分类数相匹配的输出。

模型训练

  • 现在我们已经实现了LeNet,让我们看看LeNet在Fashion-MNIST数据集上的表现。
1
2
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)
  • 虽然卷积神经网络的参数较少,但与深度的多层感知机相比,它们的计算成本仍然很高,因为每个参数都参与更多的乘法。 通过使用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]
  • 为了使用GPU,我们还需要一点小改动。 与定义的train_epoch_ch3不同,在进行正向和反向传播之前,我们需要将每一小批量数据移动到我们指定的设备(例如GPU)上。
  • 训练函数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
#@save
def train_ch6(net, train_iter, test_iter, num_epochs, lr, device):
"""用GPU训练模型(在第六章定义)"""
def init_weights(m):
if type(m) == nn.Linear or type(m) == nn.Conv2d:
nn.init.xavier_uniform_(m.weight)
net.apply(init_weights)
print('training on', device)
net.to(device)
optimizer = torch.optim.SGD(net.parameters(), lr=lr)
loss = nn.CrossEntropyLoss()
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):
# 训练损失之和,训练准确率之和,样本数
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
lr, num_epochs = 0.9, 10
train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

# result
loss 0.467, train acc 0.825, test acc 0.821
88556.9 examples/sec on cuda:0

../_images/output_lenet_4a2e9e_67_1.svg

References

  1. 官网教程
  2. 简单理解卷积神经网络
  3. 卷积层
  4. 卷积填充和步幅 Q&A
  5. CNN Explainer
  6. LeNet Q&A

D2L-6-Convolutional Neural Networks
https://alexanderliu-creator.github.io/2023/08/03/d2l-6-convolutional-neural-networks/
作者
Alexander Liu
发布于
2023年8月3日
许可协议