- 6.1. 从全连接层到卷积 — 动手学深度学习 2.0.0-beta1 documentation (d2l.ai)
先了解一个例子,如果我们想用MLP解决猫狗分类的问题:
假设我们有一个足够充分的照片数据集,数据集中是拥有标注的照片,每张照片具有百万级像素,这意味着网络的每次输入都有一百万个维度。 即使将隐藏层维度降低到1000,这个全连接层也将有20^6 * 10^3=10^9个参数,想要训练这个模型将不可实现。
有些读者可能会反对这个观点,认为要求百万像素的分辨率可能不是必要的。 然而,即使分辨率减小为十万像素,使用1000个隐藏单元的隐藏层也可能不足以学习到良好的图像特征,在真实的系统中我们仍然需要数十亿个参数。 此外,拟合如此多的参数还需要收集大量的数据。
如何解决这个问题呢?
图像中本就拥有丰富的结构,而这些结构可以被人类和机器学习模型使用。 **卷积神经网络(convolutional neural networks,CNN)**是机器学习利用自然图像中一些已知结构的创造性方法。
下面讲述两个重要原则:
卷积是特殊的全连接层
1、在欧几里得几何中,平移是一种几何变换,表示把一幅图像或一个空间中的每一个点在相同方向移动相同距离。
- 比如对图像分类任务来说,图像中的目标不管被移动到图片的哪个位置,得到的结果(标签)应该是相同的,这就是卷积神经网络中的平移不变性。
2、平移不变性意味着系统产生完全相同的响应(输出),不管它的输入是如何平移的 。平移同变性(translation equivariance)意味着系统在不同位置的工作原理相同,但它的响应随着目标位置的变化而变化 。
- 比如,实例分割任务,就需要平移同变性,目标如果被平移了,那么输出的实例掩码也应该相应地变化。
3、为什么卷积神经网络具有平移不变性
- 简单地说,卷积+最大池化约等于平移不变性。
首先了解一下“二维交叉相关”的概念,了解一下卷积是怎么工作的
例子,不同的卷积操作可以产生不同的效果,如:
二者唯一的区别在于卷积多了几个“负号”,但由于对称性,在实际使用中没有区别
严格来说,卷积层是个错误的叫法,因为它所表达的运算其实是互相关运算(cross-correlation),而不是卷积运算。
在卷积层中,输入张量和核张量通过互相关运算产生输出张量。
接下来,我们在corr2d
函数中实现如上过程,该函数接受输入张量X
和卷积核张量K
,并返回输出张量Y
。
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 = 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)
结果展示:
tensor([[19., 25.],
[37., 43.]])
卷积层对输入和卷积核权重进行互相关运算,并在添加标量偏置之后产生输出。 所以,卷积层中的两个被训练的参数是卷积核权重和标量偏置。
就像我们之前随机初始化全连接层一样,在训练基于卷积层的模型时,我们也随机初始化卷积核权重。
基于上面定义的corr2d
函数实现二维卷积层。
__init__
构造函数中,将weight
和bias
声明为两个模型参数。corr2d
函数并添加偏置。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
PS:高度和宽度分别为h和w的卷积核可以被称为h×w卷积或h×w卷积核。 我们也将带有h×w卷积核的卷积层称为h×w卷积层。
检测图像中不同颜色的边缘:通过找到像素变化的位置,来检测图像中不同颜色的边缘。
1、首先,我们构造一个6×8像素的黑白图像。中间四列为黑色(0),其余像素为白色(1)。
X = torch.ones((6, 8))
X[:, 2:6] = 0
X
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.]])
2、接下来,我们构造一个高度为1、宽度为2的卷积核K
。
当进行互相关运算时,如果水平相邻的两元素相同,则输出为零,否则输出为非零。
K = torch.tensor([[1.0, -1.0]])
3、现在,我们对参数X
(输入)和K
(卷积核)执行互相关运算。 如下所示,输出Y
中的1代表从白色到黑色的边缘,-1代表从黑色到白色的边缘,其他情况的输出为0。
Y = corr2d(X, K)
Y
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.]])
4、现在我们将输入的二维图像转置,再进行如上的互相关运算。
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.]])
结论: 其输出如下,之前检测到的垂直边缘消失了。 不出所料,这个卷积核K
只可以检测垂直边缘,无法检测水平边缘。
当有了更复杂数值的卷积核,或者连续的卷积层时,我们不可能手动设计滤波器。那么我们是否可以学习由
X
生成Y
的卷积核呢?
现在让我们看看是否可以通过仅查看“输入-输出”对来学习由X
生成Y
的卷积核。为了简单起见,我们在此使用内置的二维卷积层,并忽略偏置。
# 构造一个二维卷积层,它具有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 10.749
epoch 4, loss 1.871
epoch 6, loss 0.341
epoch 8, loss 0.069
epoch 10, loss 0.016
在10次迭代之后,误差已经降到足够低。现在我们来看看我们所学的卷积核的权重张量。
conv2d.weight.data.reshape((1, 2))
输出:
tensor([[ 0.9923, -0.9740]])
细心的你一定会发现,我们学习到的卷积核权重非常接近我们之前定义的卷积核K
。
回想一下我们在 6.1节中观察到的互相关和卷积运算之间的对应关系。 为了得到正式的卷积运算输出,我们需要执行 (6.1.6)中定义的严格卷积运算,而不是互相关运算。 幸运的是,它们差别不大,我们只需水平和垂直翻转二维卷积核张量,然后对输入张量执行互相关运算。
值得注意的是,由于卷积核是从数据中学习到的,因此无论这些层执行严格的卷积运算还是互相关运算,卷积层的输出都不会受到影响。 为了说明这一点,假设卷积层执行互相关运算并学习 图6.2.1中的卷积核,该卷积核在这里由矩阵K表示。 假设其他条件不变,当这个层执行严格的卷积时,学习的卷积核K′在水平和垂直翻转之后将与K相同。 也就是说,当卷积层对 图6.2.1中的输入和K′执行严格卷积运算时,将得到与互相关运算 图6.2.1中相同的输出。
为了与深度学习文献中的标准术语保持一致,我们将继续把“互相关运算”称为卷积运算,尽管严格地说,它们略有不同。 此外,对于卷积核张量上的权重,我们称其为元素。
如在 6.1.4.1节中所述, 图6.2.1中输出的卷积层有时被称为特征映射(feature map),因为它可以被视为一个输入映射到下一层的空间维度的转换器。 在卷积神经网络中,对于某一层的任意元素x,其感受野(receptive field)是指在前向传播期间可能影响x计算的所有元素(来自所有先前层)。
请注意,感受野可能大于输入的实际大小。让我们用 图6.2.1为例来解释感受野: 给定2×2卷积核,阴影输出元素值19的感受野是输入阴影部分的四个元素。 假设之前输出为Y,其大小为2×2,现在我们在其后附加一个卷积层,该卷积层以Y为输入,输出单个元素z。 在这种情况下,Y上的z的感受野包括Y的所有四个元素,而输入的感受野包括最初所有九个输入元素。 因此,当一个特征图中的任意元素需要检测更广区域的输入特征时,我们可以构建一个更深的网络。
卷积层中控制输出大小的两个超参数:填充和步幅
在应用多层卷积时,我们常常丢失边缘像素。 由于我们通常使用小卷积核,因此对于任何单个卷积,我们可能只会丢失几个像素。 但随着我们应用许多连续卷积层,累积丢失的像素数就多了。 解决这个问题的简单方法即为填充(padding):在输入图像的边界填充元素(通常填充元素是0)。
公式:
Q:卷积神经网络中卷积核的高度和宽度通常为奇数,例如1、3、5或7。
A:选择奇数的好处是:
X
,当满足: 1. 卷积核的大小是奇数; 2. 所有边的填充行数和列数相同; 3. 输出与输入具有相同高度和宽度 则可以得出:输出Y[i, j]
是通过以输入X[i, j]
为中心,与卷积核进行互相关计算得到的。比如,在下面的例子中,我们创建一个高度和宽度为3的二维卷积层,并在所有侧边填充1个像素。给定高度和宽度为8的输入,则输出的高度和宽度也是8。
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
torch.Size([8, 8])
当卷积核的高度和宽度不同时,我们可以填充不同的高度和宽度,使输出和输入具有相同的高度和宽度。在如下示例中,我们使用高度为5,宽度为3的卷积核,高度和宽度两边的填充分别为2和1。
conv2d = nn.Conv2d(1, 1, kernel_size=(5, 3), padding=(2, 1))
comp_conv2d(conv2d, X).shape
torch.Size([8, 8])
在计算互相关时,卷积窗口从输入张量的左上角开始,向下、向右滑动。 在前面的例子中,我们默认每次滑动一个元素。 但是,有时候为了高效计算或是缩减采样次数,卷积窗口可以跳过中间位置,每次滑动多个元素。
我们将每次滑动元素的数量称为步幅(stride)。
下面,我们将高度和宽度的步幅设置为2,从而将输入的高度和宽度减半。
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2)
comp_conv2d(conv2d, X).shape
torch.Size([4, 4])
接下来,看一个稍微复杂的例子。
conv2d = nn.Conv2d(1, 1, kernel_size=(3, 5), padding=(0, 1), stride=(3, 4))
comp_conv2d(conv2d, X).shape
torch.Size([2, 2])
卷积层另外一个重要的超参数:通道数
多输入通道的计算规则:
为了加深理解,我们实现一下多输入通道互相关运算。 简而言之,我们所做的就是对每个通道执行互相关操作,然后将结果相加。
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
,以验证互相关运算的输出。
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)
结果展示:
tensor([[ 56., 72.],
[104., 120.]])
到目前为止,不论有多少输入通道,我们还只有一个输出通道。在最流行的神经网络架构中,随着神经网络层数的加深,我们常会增加输出通道的维数,通过减少空间分辨率以获得更大的通道深度。
直观地说,我们可以将每个通道看作是对不同特征的响应。而现实可能更为复杂一些,因为每个通道不是独立学习的,而是为了共同使用而优化的。因此,多输出通道并不仅是学习多个单通道的检测器。
PS:输入通道书核输出通道数是随意的,没啥相关性
Q:为什么要采用多个输入和输出通道?
A:
代码实现:
实现一个计算多个通道的输出的互相关函数
def corr2d_multi_in_out(X, K):
# 迭代“K”的第0个维度,每次都对输入“X”执行互相关运算。
# 最后将所有结果都叠加在一起
return torch.stack([corr2d_multi_in(X, k) for k in K], 0)
通过将核张量K
与K+1
(K
中每个元素加1)和K+2
连接起来,构造了一个具有3个输出通道的卷积核。
K = torch.stack((K, K + 1, K + 2), 0)
K.shape
torch.Size([3, 2, 2, 2])
下面,我们对输入张量X
与卷积核张量K
执行互相关运算。现在的输出包含3个通道,第一个通道的结果与先前输入张量X
和多输入单输出通道的结果一致。
corr2d_multi_in_out(X, K)
tensor([[[ 56., 72.],
[104., 120.]],
[[ 76., 100.],
[148., 172.]],
[[ 96., 128.],
[192., 224.]]])
们使用全连接层实现1×1卷积。 请注意,我们需要对输入和输出的数据形状进行调整。
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
。让我们用一些样本数据来验证这一点。
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
拓展:二维卷积层
下图描述了二维卷积层的输入与输出
总结:
通常当我们处理图像时,我们希望逐渐降低隐藏表示的空间分辨率、聚集信息,这样随着我们在神经网络中层叠的上升,每个神经元对其敏感的感受野(输入)就越大。
本节将介绍汇聚(pooling)层,它具有双重目的:降低卷积层对位置的敏感性,同时降低对空间降采样表示的敏感性。
不同于卷积层中的输入与卷积核之间的互相关计算,汇聚层不包含参数。 相反,池运算是确定性的,我们通常计算汇聚窗口中所有元素的最大值或平均值。这些操作分别称为最大汇聚层(maximum pooling)和平均汇聚层(average pooling)。
池化窗口形状为 2×2 的最大池化层。着色部分是第一个输出元素,以及用于计算这个输出的输入元素: max(0,1,3,4)=4.
池化窗口形状为p×q的池化层称为p×q池化层,池化操作称为p×q池化。
- 6.5. 汇聚层 — 动手学深度学习 2.0.0-beta1 documentation (d2l.ai)
与卷积层一样,池化层也可以改变输出形状。和以前一样,我们可以通过填充和步幅以获得所需的输出形状。
总结:
在下面的代码中的pool2d
函数,我们实现池化层的前向传播。 这类似于 6.2节中的corr2d
函数。 然而,这里我们没有卷积核,输出为输入中每个区域的最大值或平均值。
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
我们可以构建 图6.5.1中的输入张量X
,验证二维最大汇聚层的输出。
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))
tensor([[4., 5.],
[7., 8.]])
此外,我们还可以验证平均汇聚层。
pool2d(X, (2, 2), 'avg')
tensor([[2., 3.],
[5., 6.]])
小结:
链接:6.6. 卷积神经网络(LeNet) — 动手学深度学习 2.0.0-beta1 documentation (d2l.ai)
背景知识:
LeNet,它是最早发布的卷积神经网络之一,因其在计算机视觉任务中的高效性能而受到广泛关注。 这个模型是由AT&T贝尔实验室的研究员Yann LeCun在1989年提出的(并以其命名),目的是识别图像 [LeCun et al., 1998]中的手写数字。
LeNet被广泛用于自动取款机(ATM)机中,帮助识别处理支票的数字。MNIST手写数字识别数据集就是当时提出的
概念补充:
Feature Map(特征图)是输入图像经过神经网络卷积产生的结果,表征的是神经空间内一种特征;其分辨率大小取决于先前卷积核的步长 。
网络结构:
通过下面的LeNet代码,你会相信用深度学习框架实现此类模型非常简单。我们只需要实例化一个Sequential
块并将需要的层连接在一起。
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。通过在每一层打印输出的形状,我们可以检查模型,以确保其操作与我们期望的 图6.6.2一致。
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)
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])