1、图像卷积
1. 互相关运算
严格来说,卷积层是个错误的叫法,因为它所表达的运算其实是互相关运算(cross-correlation),而不是卷积运算。在卷积层中,输入张量和核张量通过(互相关运算)产生输出张量。
首先,我们暂时忽略通道(第三维)这一情况,看看如何处理二维图像数据和隐藏表示。输入是高度为
3
3
3、宽度为
3
3
3的二维张量(即形状为
3
×
3
3 \times 3
3×3)。卷积核的高度和宽度都是
2
2
2,而卷积核窗口(或卷积窗口)的形状由内核的高度和宽度决定(即
2
×
2
2 \times 2
2×2)。
二维互相关运算。阴影部分是第一个输出元素,以及用于计算输出的输入张量元素和核张量元素: 0 × 0 + 1 × 1 + 3 × 2 + 4 × 3 = 19 0\times0+1\times1+3\times2+4\times3=19 0×0+1×1+3×2+4×3=19.
在二维互相关运算中,卷积窗口从输入张量的左上角开始,从左到右、从上到下滑动。
当卷积窗口滑动到新一个位置时,包含在该窗口中的部分张量与卷积核张量进行按元素相乘,得到的张量再求和得到一个单一的标量值,由此我们得出了这一位置的输出张量值。
在如上例子中,输出张量的四个元素由二维互相关运算得到,这个输出高度为
2
2
2、宽度为
2
2
2,如下所示:
0 × 0 + 1 × 1 + 3 × 2 + 4 × 3 = 19 , 1 × 0 + 2 × 1 + 4 × 2 + 5 × 3 = 25 , 3 × 0 + 4 × 1 + 6 × 2 + 7 × 3 = 37 , 4 × 0 + 5 × 1 + 7 × 2 + 8 × 3 = 43. 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. 0×0+1×1+3×2+4×3=19,1×0+2×1+4×2+5×3=25,3×0+4×1+6×2+7×3=37,4×0+5×1+7×2+8×3=43.
注意,输出大小略小于输入大小。这是因为卷积核的宽度和高度大于1,而卷积核只与图像中每个大小完全适合的位置进行互相关运算。所以,输出大小等于输入大小 n h × n w n_h \times n_w nh×nw减去卷积核大小 k h × k w k_h \times k_w kh×kw,即:
( n h − k h + 1 ) × ( n w − k w + 1 ) . (n_h-k_h+1) \times (n_w-k_w+1). (nh−kh+1)×(nw−kw+1).
这是因为我们需要足够的空间在图像上“移动”卷积核。稍后,我们将看到如何通过在图像边界周围填充零来保证有足够的空间移动卷积核,从而保持输出大小不变。
接下来,我们在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() # 将X与K进行点积运算后再求和,然后将结果存入Y中
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.]])
2. 卷积层
卷积层对输入和卷积核权重进行互相关运算,并在添加标量偏置之后产生输出。所以,卷积层中的两个被训练的参数是卷积核权重和标量偏置。就像我们之前随机初始化全连接层一样,在训练基于卷积层的模型时,我们也随机初始化卷积核权重。
基于上面定义的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
高度和宽度分别为 h h h和 w w w的卷积核可以被称为 h × w h \times w h×w卷积或 h × w h \times w h×w卷积核。我们也将带有 h × w h \times w h×w卷积核的卷积层称为 h × w h \times w h×w卷积层。
3. 边缘检测示例
(1)规定卷积核示例
如下是[卷积层的一个简单应用:]通过找到像素变化的位置,来(检测图像中不同颜色的边缘)。首先,我们构造一个 6 × 8 6\times 8 6×8像素的黑白图像。中间四列为黑色( 0 0 0),其余像素为白色( 1 1 1)。
(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.]])
接下来,我们构造一个高度为
1
1
1、宽度为
2
2
2的卷积核K
。当进行互相关运算时,如果水平相邻的两元素相同,则输出为零,否则输出为非零。
(2)卷积核
K = torch.tensor([[1.0, -1.0]])
K
tensor([[ 1., -1.]])
现在,我们对参数X
(输入)和K
(卷积核)执行互相关运算。如下所示,[输出Y
中的1代表从白色到黑色的边缘,-1代表从黑色到白色的边缘],其他情况的输出为
0
0
0。
(3)检测后的图像
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.]])
现在我们将输入的二维图像转置,再进行如上的互相关运算。其输出如下,之前检测到的垂直边缘消失了。不出所料,这个[卷积核K
只可以检测垂直边缘],无法检测水平边缘。
水平检测效果:
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.]])
(2)学习卷积核参数
如果我们只需寻找黑白边缘,那么以上[1, -1]
的边缘检测器足以。然而,当有了更复杂数值的卷积核,或者连续的卷积层时,我们不可能手动设计滤波器。那么我们是否可以[学习由X
生成Y
的卷积核]呢?
现在让我们看看是否可以通过仅查看**“输入-输出”**对来学习由X
生成Y
的卷积核。我们先构造一个卷积层,并将其卷积核初始化为随机张量。 接下来,在每次迭代中,我们比较Y
与卷积层输出的平方误差,然后计算梯度来更新卷积核。为了简单起见,我们在此使用内置的二维卷积层,并忽略偏置。
# 构造一个二维卷积层,它具有1个输出通道和形状为(1,2)的卷积核
conv2d = nn.Conv2d(1,1, kernel_size=(1, 2), bias=False)
# 这个二维卷积层使用四维输入和输出格式(批量大小、通道、高度、宽度),
# 其中批量大小和通道数都为1
X = X.reshape((1, 1, 6, 8)) # 将之前的X转换形式
Y = Y.reshape((1, 1, 6, 7)) # 将之前得到的Y转换形式
lr = 3e-2 # 学习率
for i in range(10):
Y_hat = conv2d(X)
print(f'i:{i}, {Y_hat}')
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}, weight {conv2d.weight.data}')
nn.Conv2d是PyTorch中的一个类,用于定义二维卷积层。它是神经网络模块(nn.Module)的子类之一,用于构建卷积神经网络。
在卷积神经网络中,卷积层是一种常用的层类型,它通过在输入数据上滑动一个固定大小的窗口(卷积核)来提取特征。nn.Conv2d类提供了一个方便的接口来定义卷积层,可以指定输入通道数、输出通道数、卷积核大小、步长等参数。
具体来说,nn.Conv2d类的构造函数包含以下参数:
-
in_channels(输入通道数):
- 表示输入特征图的深度(通道数)。
- 通常情况下,输入通道数与上一层的输出通道数相等。
-
out_channels(输出通道数):
- 表示卷积核的数量,即卷积层提取的特征数量。
- 可以根据需要设置不同的输出通道数,以获取不同数量的特征。
-
kernel_size(卷积核大小):
- 可以是一个整数或一个元组。
- 如果是一个整数,则表示卷积核的高度和宽度相等。
- 如果是一个元组,则表示卷积核的高度和宽度可以不同。
- 通常情况下,卷积核的大小会根据输入数据的特征结构和任务需求进行选择。
-
stride(卷积核滑动步长):
- 可以是一个整数或一个元组。
- 如果是一个整数,则表示在水平和垂直方向上的步长大小相等。
- 如果是一个元组,则表示在水平和垂直方向上的步长大小可以不同。
- 步长决定了卷积核在输入特征图上滑动的距离,影响输出特征图的尺寸。
-
padding(填充大小):
- 可以是一个整数或一个元组。
- 如果是一个整数,则表示在输入特征图的边缘填充的大小相等。
- 如果是一个元组,则表示在输入特征图的边缘填充的大小可以不同。
- 填充操作可以用于保持输入和输出特征图的尺寸一致,或者控制卷积操作的感受野大小。
-
dilation(膨胀率):
- 可以是一个整数或一个元组。
- 如果是一个整数,则表示卷积核内部元素之间的间距大小相等。
- 如果是一个元组,则表示卷积核内部元素之间的间距大小可以不同。
- 膨胀率可以用于控制卷积核的感受野大小,以获取更大范围的上下文信息。
-
groups(通道分组数量):
- 可以用于实现分组卷积操作。
- 将输入通道分成多个组,并为每个组分配一个卷积核。
- 通常情况下,不使用分组卷积,将groups设置为1即可。
-
bias(是否使用偏置项):
- 可以设置为True或False。
- 如果设置为True,则卷积操作中会使用偏置项。
- 如果设置为False,则卷积操作中不会使用偏置项。
关于weight和bias的初始化:
(1)默认情况下,nn.Conv2d类会使用正态分布(均值为0,标准差为0.01)来初始化卷积核参数。这是PyTorch中的一种常见的初始化方法,也称为Xavier初始化。可以通过设置nn.Conv2d类的weight属性来自定义初始化方法。例如,可以使用torch.nn.init模块中的函数来初始化卷积核参数,如torch.nn.init.xavier_uniform_()、torch.nn.init.kaiming_normal_()等。
(2)默认情况下,nn.Conv2d类中的偏置项(bias)参数会被初始化为零。这意味着在创建nn.Conv2d实例时,偏置项的初始值将全为零。 可以通过设置nn.Conv2d类的bias属性来自定义偏置项的初始化方法。同样,可以使用torch.nn.init模块中的函数来初始化偏置项,如torch.nn.init.zeros_()、torch.nn.init.ones_()等。
通过使用nn.Conv2d类,可以方便地构建卷积神经网络,并在训练和推理过程中对输入数据进行卷积操作,从而提取特征并实现各种图像处理任务。
示例
# 构造一个二维卷积层,它具有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))
X, Y
(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.]]]]),
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.]]]]))
最后学习到的卷积核参数
conv2d.weight.data.reshape((1, 2))
tensor([[ 1.0486, -0.9313]])
发现很解决我们之前示例里的卷积核参数了。
2、填充和步幅
本节我们将介绍填充(padding)和步幅(stride)。假设以下情景: 有时,在应用了连续的卷积之后,我们最终得到的输出远小于输入大小。这是由于卷积核的宽度和高度通常大于 1 所导致的。比如,一个 240×240像素的图像,经过 10层 5×5 的卷积后,将减少到 200×200像素。如此一来,原始图像的边界丢失了许多有用信息。而填充是解决此问题最有效的方法(边缘信息相比于中间信息使用次数过少,为了增多边缘信息使用次数对边缘进行填充 ,让原始的边缘相对变成中间信息,而边缘填充0不会对图像数值有贡献影响。); 有时,我们可能希望大幅降低图像的宽度和高度。例如,如果我们发现原始的输入分辨率十分冗余。步幅则可以在这类情况下提供帮助。
1. 填充
如上所述,在应用多层卷积时,我们常常丢失边缘像素。由于我们通常使用小卷积核,因此对于任何单个卷积,我们可能只会丢失几个像素。但随着我们应用许多连续卷积层,累积丢失的像素数就多了。
解决这个问题的简单方法即为填充(padding):在输入图像的边界填充元素(通常填充元素是
0
0
0)。
例如,在 :numref:img_conv_pad
中,我们将
3
×
3
3 \times 3
3×3输入填充到
5
×
5
5 \times 5
5×5,那么它的输出就增加为
4
×
4
4 \times 4
4×4。阴影部分是第一个输出元素以及用于输出计算的输入和核张量元素:
0
×
0
+
0
×
1
+
0
×
2
+
0
×
3
=
0
0\times0+0\times1+0\times2+0\times3=0
0×0+0×1+0×2+0×3=0。
通常,如果我们添加 p h p_h ph行填充(大约一半在顶部,一半在底部)和 p w p_w pw列填充(左侧大约一半,右侧一半),则输出形状将为
( n h − k h + p h + 1 ) × ( n w − k w + p w + 1 ) 。 (n_h-k_h+p_h+1)\times(n_w-k_w+p_w+1)。 (nh−kh+ph+1)×(nw−kw+pw+1)。
这意味着输出的高度和宽度将分别增加 p h p_h ph和 p w p_w pw。
在许多情况下,我们需要设置 p h = k h − 1 p_h=k_h-1 ph=kh−1和 p w = k w − 1 p_w=k_w-1 pw=kw−1,使输入和输出具有相同的高度和宽度。这样可以在构建网络时更容易地预测每个图层的输出形状。
如果 k h k_h kh是奇数,我们将在高度的两侧填充 p h / 2 p_h/2 ph/2行。(k_h为奇,p_h就为偶)
如果 k h k_h kh是偶数,则一种可能性是在输入顶部填充 ⌈ p h / 2 ⌉ \lceil p_h/2\rceil ⌈ph/2⌉行,在底部填充 ⌊ p h / 2 ⌋ \lfloor p_h/2\rfloor ⌊ph/2⌋行(上面比下面多一行)。同理,我们填充宽度的两侧。
卷积神经网络中卷积核的高度和宽度通常为奇数,例如1、3、5或7。选择奇数的好处是,保持空间维度的同时,我们可以在顶部和底部填充相同数量的行,在左侧和右侧填充相同数量的列。
此外,使用奇数的核大小和填充大小也提供了书写上的便利。对于任何二维张量X
,当满足:
- 卷积核的大小是奇数;
- 所有边的填充行数和列数相同;
- 输出与输入具有相同高度和宽度
则可以得出:输出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列,卷积核为3x3
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])
2. 步幅
在计算互相关时,卷积窗口从输入张量的左上角开始,向下、向右滑动。在前面的例子中,我们默认每次滑动一个元素。但是,有时候为了高效计算或是缩减采样次数,卷积窗口可以跳过中间位置,每次滑动多个元素。
我们将每次滑动元素的数量称为步幅(stride)。到目前为止,我们只使用过高度或宽度为
1
1
1的步幅,那么如何使用较大的步幅呢?
图中是垂直步幅为
3
3
3,水平步幅为
2
2
2的二维互相关运算。着色部分是输出元素以及用于输出计算的输入和内核张量元素:
0
×
0
+
0
×
1
+
1
×
2
+
2
×
3
=
8
0\times0+0\times1+1\times2+2\times3=8
0×0+0×1+1×2+2×3=8、
0
×
0
+
6
×
1
+
0
×
2
+
0
×
3
=
6
0\times0+6\times1+0\times2+0\times3=6
0×0+6×1+0×2+0×3=6。
可以看到,为了计算输出中第一列的第二个元素和第一行的第二个元素,卷积窗口分别向下滑动三行和向右滑动两列。但是,当卷积窗口继续向右滑动两列时,没有输出,因为输入元素无法填充窗口(除非我们添加另一列填充)。
通常,当垂直步幅为 s h s_h sh、水平步幅为 s w s_w sw时,输出形状为
⌊ ( n h − k h + p h + s h ) / s h ⌋ × ⌊ ( n w − k w + p w + s w ) / 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. ⌊(nh−kh+ph+sh)/sh⌋×⌊(nw−kw+pw+sw)/sw⌋.
如果我们设置了 p h = k h − 1 p_h=k_h-1 ph=kh−1和 p w = k w − 1 p_w=k_w-1 pw=kw−1,则输出形状将简化为 ⌊ ( n h + s h − 1 ) / s h ⌋ × ⌊ ( n w + s w − 1 ) / s w ⌋ \lfloor(n_h+s_h-1)/s_h\rfloor \times \lfloor(n_w+s_w-1)/s_w\rfloor ⌊(nh+sh−1)/sh⌋×⌊(nw+sw−1)/sw⌋。更进一步,如果输入的高度和宽度可以被垂直和水平步幅整除,则输出形状将为 ( n h / s h ) × ( n w / s w ) (n_h/s_h) \times (n_w/s_w) (nh/sh)×(nw/sw)。
下面,我们[将高度和宽度的步幅设置为2],从而将输入的高度和宽度减半。
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2)
comp_conv2d(conv2d, X).shape # 原X是(8, 8)
torch.Size([4, 4]) # (8-3+1+2)/2 = 4
接下来,看一个稍微复杂的例子。
# (k_h, k_2) = (3, 5)、(p_h, p_w) = (0, 1)、(s_h, s_w) = (3, 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]) # (8-3+0+3)/3 = 2、(8-5+1+4)/4 = 2
为了简洁起见,当输入高度和宽度两侧的填充数量分别为 p h p_h ph和 p w p_w pw时,我们称之为填充 ( p h , p w ) (p_h, p_w) (ph,pw)。当 p h = p w = p p_h = p_w = p ph=pw=p时,填充是 p p p。同理,当高度和宽度上的步幅分别为 s h s_h sh和 s w s_w sw时,我们称之为步幅 ( s h , s w ) (s_h, s_w) (sh,sw)。 特别地,当 s h = s w = s s_h = s_w = s sh=sw=s时,我们称步幅为 s s s。默认情况下,填充为0,步幅为1。在实践中,我们很少使用不一致的步幅或填充,也就是说,我们通常有 p h = p w p_h = p_w ph=pw和 s h = s w s_h = s_w sh=sw。
3、多输多通道输出
当我们添加通道时,我们的输入和隐藏的表示都变成了三维张量。例如,每个RGB输入图像具有的形状。我们将这个大小为的轴称为通道(channel)维度。本节将更深入地研究具有多输入和多输出通道的卷积核。
1. 多输入通道
当输入包含多个通道时,需要构造一个与输入数据具有相同输入通道数的卷积核,以便与输入数据进行互相关运算。假设输入的通道数为 c i c_i ci,那么卷积核的输入通道数也需要为 c i c_i ci。如果卷积核的窗口形状是 k h × k w k_h\times k_w kh×kw,那么当 c i = 1 c_i=1 ci=1时,我们可以把卷积核看作形状为 k h × k w k_h\times k_w kh×kw的二维张量。
然而,当 c i > 1 c_i>1 ci>1时,我们卷积核的每个输入通道将包含形状为 k h × k w k_h\times k_w kh×kw的张量。将这些张量 c i c_i ci连结在一起可以得到形状为 c i × k h × k w c_i\times k_h\times k_w ci×kh×kw的卷积核。由于输入和卷积核都有 c i c_i ci个通道,我们可以对每个通道输入的二维张量和卷积核的二维张量进行互相关运算,再对通道求和(将 c i c_i ci的结果相加)得到二维张量。这是多通道输入和多输入通道卷积核之间进行二维互相关运算的结果。
我们演示了一个具有两个输入通道的二维互相关运算的示例。阴影部分是第一个输出元素以及用于计算这个输出的输入和核张量元素: ( 1 × 1 + 2 × 2 + 4 × 3 + 5 × 4 ) + ( 0 × 0 + 1 × 1 + 3 × 2 + 4 × 3 ) = 56 (1\times1+2\times2+4\times3+5\times4)+(0\times0+1\times1+3\times2+4\times3)=56 (1×1+2×2+4×3+5×4)+(0×0+1×1+3×2+4×3)=56。
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)
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)
2. 多输出通道
到目前为止,不论有多少输入通道,我们还只有一个输出通道。然而,每一层有多个输出通道是至关重要的。在最流行的神经网络架构中,随着神经网络层数的加深,我们常会增加输出通道的维数,通过减少空间分辨率以获得更大的通道深度。直观地说,我们可以将每个通道看作对不同特征的响应。而现实可能更为复杂一些,因为每个通道不是独立学习的,而是为了共同使用而优化的。因此,多输出通道并不仅是学习多个单通道的检测器。
用 c i c_i ci和 c o c_o co分别表示输入和输出通道的数目,并让 k h k_h kh和 k w k_w kw为卷积核的高度和宽度。为了获得多个通道的输出,我们可以为每个输出通道创建一个形状为 c i × k h × k w c_i\times k_h\times k_w ci×kh×kw的卷积核张量,这样卷积核的形状是 c o × c i × k h × k w c_o\times c_i\times k_h\times k_w co×ci×kh×kw。在互相关运算中,每个输出通道先获取所有输入通道,再以对应该输出通道的卷积核计算出结果。
def corr2d_multi_in_out(X, K):
# 迭代“K”的第0个维度,每次都对输入“X”执行互相关运算。
# 最后将所有结果都叠加在一起
return torch.stack([corr2d_multi_in(X, k) for k in K], 0)
K = torch.stack((K, K + 1, K + 2), 0)
K.shape
torch.Size([3, 2, 2, 2]) # C0=3、Ci=2
torch.stack
:是一个函数,它用于将多个张量沿着一个新的维度进行堆叠。堆叠的方式可以是垂直堆叠(垂直方向上的堆叠)或水平堆叠(水平方向上的堆叠)。这个函数的输入是一个张量的列表,输出是一个新的张量。
在垂直堆叠的情况下,输入张量的形状必须是相同的,而输出张量的形状将是输入张量形状的扩展,其中新的维度将等于输入张量的数量。例如,如果输入张量的形状是(N, D),那么输出张量的形状将是(len(inputs), N, D),其中len(inputs)是输入张量的数量。
在水平堆叠的情况下,输入张量的形状可以不同,但是它们的维度必须是一致的。输出张量的形状将是输入张量形状的扩展,其中新的维度将等于输入张量的数量。例如,如果输入张量的形状是(N, D1)和(N, D2),那么输出张量的形状将是(len(inputs), N, D1+D2)。
torch.stack函数在深度学习中常用于将多个特征张量或多个样本的标签张量进行堆叠,以便进行批处理操作或其他需要多个张量作为输入的操作。
拓展资料:【Pytorch基础】torch.stack()函数解析
下面,我们对输入张量X与卷积核张量K执行互相关运算。现在的输出包含 3个通道,第一个通道的结果与先前输入张量X和多输入单输出通道的结果一致。
corr2d_multi_in_out(X, K)
tensor([[[ 56., 72.],
[104., 120.]],
[[ 76., 100.],
[148., 172.]],
[[ 96., 128.],
[192., 224.]]])
3. 1*1的卷积层
1 × 1 1 \times 1 1×1卷积,即 k h = k w = 1 k_h = k_w = 1 kh=kw=1,看起来似乎没有多大意义。毕竟,卷积的本质是有效提取相邻像素间的相关特征,而 1 × 1 1 \times 1 1×1卷积显然没有此作用。尽管如此, 1 × 1 1 \times 1 1×1仍然十分流行,经常包含在复杂深层网络的设计中。下面,让我们详细地解读一下它的实际作用。
因为使用了最小窗口, 1 × 1 1\times 1 1×1卷积失去了卷积层的特有能力——在高度和宽度维度上,识别相邻元素间相互作用的能力。其实 1 × 1 1\times 1 1×1卷积的唯一计算发生在通道上。
下图中 展示了使用 1 × 1 1\times 1 1×1卷积核与 3 3 3个输入通道和 2 2 2个输出通道的互相关计算。
这里输入和输出具有相同的高度和宽度,输出中的每个元素都是从输入图像中同一位置的元素的线性组合。我们可以将
1
×
1
1\times 1
1×1卷积层看作在每个像素位置应用的全连接层,以
c
i
c_i
ci个输入值转换为
c
o
c_o
co个输出值。因为这仍然是一个卷积层,所以跨像素的权重是一致的。
同时, 1 × 1 1\times 1 1×1卷积层需要的权重维度为 c o × c i c_o\times c_i co×ci,再额外加上一个偏置。
下面,我们使用全连接层实现 1 × 1 1 \times 1 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
1\times 1
1×1卷积运算时,上述函数相当于先前实现的互相关函数corr2d_multi_in_out
。让我们用一些样本数据来验证这一点。
X = torch.normal(0, 1, (3, 3, 3)) # Ci=3、h=3、w=3
K = torch.normal(0, 1, (2, 3, 1, 1)) # C0=2、Ci=3
Y1 = corr2d_multi_in_out_1x1(X, K)
Y2 = corr2d_multi_in_out(X, K)
assert float(torch.abs(Y1 - Y2).sum()) < 1e-6
总结来说,1*1卷积核的作用:(1)通过卷积核的个数,控制通道的放缩;(2)相当于是一个全连接的过程,可以引入非线性,增强网络的表达能力。
拓展阅读:1x1卷积的作用、【深度学习】CNN 中 1x1 卷积核的作用
4、汇聚层
通常当我们处理图像时,我们希望逐渐降低隐藏表示的空间分辨率、聚集信息,这样随着我们在神经网络中层叠的上升,每个神经元对其敏感的感受野(输入)就越大。
而我们的机器学习任务通常会跟全局图像的问题有关(例如,“图像是否包含一只猫呢?”),所以我们最后一层的神经元应该对整个输入的全局敏感。通过逐渐聚合信息,生成越来越粗糙的映射,最终实现学习全局表示的目标,同时将卷积图层的所有优势保留在中间层。
此外,当检测较底层的特征时,我们通常希望这些特征保持某种程度上的平移不变性。例如,如果我们拍摄黑白之间轮廓清晰的图像X
,并将整个图像向右移动一个像素,即Z[i, j] = X[i, j + 1]
,则新图像Z
的输出可能大不相同。而在现实中,随着拍摄角度的移动,任何物体几乎不可能发生在同一像素上。即使用三脚架拍摄一个静止的物体,由于快门的移动而引起的相机振动,可能会使所有物体左右移动一个像素(除了高端相机配备了特殊功能来解决这个问题)。
本节将介绍汇聚(pooling)层,它具有双重目的:降低卷积层对位置的敏感性,同时降低对空间降采样表示的敏感性。
1. 最大汇聚层和平均汇聚层
与卷积层类似,汇聚层运算符由一个固定形状的窗口组成,该窗口根据其步幅大小在输入的所有区域上滑动,为固定形状窗口(有时称为汇聚窗口)遍历的每个位置计算一个输出。
然而,不同于卷积层中的输入与卷积核之间的互相关计算,汇聚层不包含参数。相反,池运算是确定性的,我们通常计算汇聚窗口中所有元素的最大值或平均值。这些操作分别称为最大汇聚层(maximum pooling)和平均汇聚层(average pooling)。
在这两种情况下,与互相关运算符一样,汇聚窗口从输入张量的左上角开始,从左往右、从上往下的在输入张量内滑动。在汇聚窗口到达的每个位置,它计算该窗口中输入子张量的最大值或平均值。计算最大值或平均值是取决于使用了最大汇聚层还是平均汇聚层。
图中的输出张量的高度为
2
2
2,宽度为
2
2
2。这四个元素为每个汇聚窗口中的最大值:
max ( 0 , 1 , 3 , 4 ) = 4 , max ( 1 , 2 , 4 , 5 ) = 5 , max ( 3 , 4 , 6 , 7 ) = 7 , max ( 4 , 5 , 7 , 8 ) = 8. \max(0, 1, 3, 4)=4,\\ \max(1, 2, 4, 5)=5,\\ \max(3, 4, 6, 7)=7,\\ \max(4, 5, 7, 8)=8.\\ max(0,1,3,4)=4,max(1,2,4,5)=5,max(3,4,6,7)=7,max(4,5,7,8)=8.
汇聚窗口形状为 p × q p \times q p×q的汇聚层称为 p × q p \times q p×q汇聚层,汇聚操作称为 p × q p \times q p×q汇聚。
回到本节开头提到的对象边缘检测示例,现在我们将使用卷积层的输出作为
2
×
2
2\times 2
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
2\times 2
2×2最大汇聚层,即使在高度或宽度上移动一个元素,卷积层仍然可以识别到模式。
在下面的代码中的pool2d
函数,我们(实现汇聚层的前向传播)。这类似于 :numref:sec_conv_layer
中的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
示例
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.]])
2. 汇聚层的填充和步幅
与卷积层一样,汇聚层也可以改变输出形状。和以前一样,我们可以通过填充和步幅以获得所需的输出形状。 下面,我们用深度学习框架中内置的二维最大汇聚层,来演示汇聚层中填充和步幅的使用。 我们首先构造了一个输入张量X,它有四个维度,其中样本数和通道数都是1。
X = torch.arange(16, dtype=torch.float32).reshape((1, 1, 4, 4))
X
tensor([[[[ 0., 1., 2., 3.],
[ 4., 5., 6., 7.],
[ 8., 9., 10., 11.],
[12., 13., 14., 15.]]]])
默认情况下,深度学习框架中的步幅与汇聚窗口的大小相同。 因此,如果我们使用形状为(3, 3)的汇聚窗口,那么默认情况下,我们得到的步幅形状为(3, 3)。
pool2d = nn.MaxPool2d(3)
pool2d(X)
tensor([[[[10.]]]])
填充和步幅可以手动设定。我们可以设定一个任意大小的矩形汇聚窗口,并分别设定填充和步幅的高度和宽度。
# 生成一个2×3的池化核,每次滑动2列3行,在右侧填充了1列。
pool2d = nn.MaxPool2d((2, 3), stride=(2, 3), padding=(0, 1))
pool2d(X)
tensor([[[[ 5., 7.],
[13., 15.]]]])
nn.MaxPool2d
函数的输入是一个四维的张量,通常表示为 (batch_size, channels, height, width) ,其中batch_size表示输入的样本数量,channels表示输入特征图的通道数,height和width表示特征图的高度和宽度。
3. 汇聚层的多个通道
在处理多通道输入数据时,汇聚层在每个输入通道上单独运算,而不是像卷积层一样在通道上对输入进行汇总。 这意味着汇聚层的输出通道数与输入通道数相同。 下面,我们将在通道维度上连结张量X和X + 1,以构建具有2个通道的输入。
X = torch.cat((X, X + 1), 1)
X
tensor([[[[ 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.]]]])
如下所示,汇聚后输出通道的数量仍然是2。
pool2d = nn.MaxPool2d(3, padding=1, stride=2)
pool2d(X)
tensor([[[[ 5., 7.],
[13., 15.]],
[[ 6., 8.],
[14., 16.]]]])
参考文章:6.1. 从全连接层到卷积
5、LeNet
本节将介绍LeNet,它是最早发布的卷积神经网络之一,因其在计算机视觉任务中的高效性能而受到广泛关注。这个模型是由AT&T贝尔实验室的研究员Yann LeCun在1989年提出的(并以其命名),目的是识别图像 中的手写数字。当时,Yann LeCun发表了第一篇通过反向传播成功训练卷积神经网络的研究,这项工作代表了十多年来神经网络研究开发的成果。
当时,LeNet取得了与支持向量机(support vector machines)性能相媲美的成果,成为监督学习的主流方法。LeNet被广泛用于自动取款机(ATM)机中,帮助识别处理支票的数字。时至今日,一些自动取款机仍在运行Yann LeCun和他的同事Leon Bottou在上世纪90年代写的代码呢!
总体来看,(LeNet(LeNet-5)由两个部分组成:)
- 卷积编码器:由两个卷积层组成;
- 全连接层密集块:由三个全连接层组成。
每个卷积块中的基本单元是一个卷积层、一个sigmoid激活函数和平均汇聚层。请注意,虽然ReLU和最大汇聚层更有效,但它们在20世纪90年代还没有出现。每个卷积层使用 5 × 5 5\times 5 5×5卷积核和一个sigmoid激活函数。这些层将输入映射到多个二维特征输出,通常同时增加通道的数量。第一卷积层有6个输出通道,而第二个卷积层有16个输出通道。每个 2 × 2 2\times2 2×2池操作(步幅2)通过空间下采样将维数减少4倍。卷积的输出形状由批量大小、通道数、高度、宽度决定。
为了将卷积块的输出传递给稠密块,我们必须在小批量中展平每个样本。换言之,我们将这个四维输入转换成全连接层所期望的二维输入。这里的二维表示的第一个维度索引小批量中的样本,第二个维度给出每个样本的平面向量表示。LeNet的稠密块有三个全连接层,分别有120、84和10个输出。因为我们在执行分类任务,所以输出层的10维对应于最后输出结果的数量。
通过下面的LeNet代码,可以看出用深度学习框架实现此类模型非常简单。我们只需要实例化一个Sequential
块并将需要的层连接在一起。
1. 模型
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(), # 输入1通道,输出6通道,卷积核5x5,一遍各填充2行
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
28 \times 28
28×28的单通道(黑白)图像通过LeNet。通过在每一层打印输出的形状,我们可以[检查模型],以确保其操作与我们期望的 :numref:img_lenet_vert
一致。
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])
2、模型训练
获取数据集
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)
为了进行评估,我们需要对 3.6节中描述的evaluate_accuracy函数进行轻微的修改。 由于完整的数据集位于内存中,因此在模型使用GPU计算数据集之前,我们需要将其复制到显存中。
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]
模型训练函数
#@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)
# 使用GPU训练
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) # 使用GPU训练
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)}')
训练和评估
lr, num_epochs = 0.9, 10
train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())
参考文章:6.6. 卷积神经网络(LeNet)