图像的卷积
引用翻译:《动手学深度学习》
现在我们已经了解了卷积层在理论上是如何工作的,我们准备看看这在实践中是如何工作的。由于我们通过卷积神经网络对图像数据的适用性来激励它,我们将在我们的例子中坚持使用图像数据,并开始重新审视我们在上一节中介绍的卷积层。我们注意到,严格来说,卷积层是一个轻微的误称,因为操作通常表示为交叉关联。
一、交叉相关运算
在卷积层中,一个输入数组和一个相关核数组被结合起来,通过交叉相关操作产生一个输出数组。让我们看看这在二维空间是如何工作的。在我们的例子中,输入是一个高度为3,宽度为3的二维数组,我们将数组的形状标记为3×3或(3,3)。核心数组的高度和宽度都是2。在深度学习研究界,这个数组的常见名称包括内核和过滤器。内核窗口(也被称为卷积窗口)的形状是由内核的高度和宽度精确给出的(这里是2×2)。
图:二维交叉相关操作。阴影部分是第一个输出元素和用于其计算的输入和内核阵列元素。 0×0+1×1+3×2+4×3=19 .
在二维交叉相关操作中,我们从卷积窗口位于输入阵列的左上角开始,然后从左到右和从上到下在输入阵列上滑动。当卷积窗口滑动到某一位置时,该窗口所包含的输入子阵列与内核阵列相乘(从元素上看),所得的阵列相加产生一个单一的标量值。这个结果正是相应位置上的输出数组的值。这里,输出数组的高度为2,宽度为2,四个元素来自二维交叉相关操作。
请注意,沿着每条轴线,输出都比输入略小。因为内核的宽度大于1,而且我们只能对内核完全适合于图像的位置进行交叉相关计算,输出的大小由输入大小𝐻×𝑊减去卷积内核的大小ℎ×𝑤,即(𝐻-ℎ+1)×(𝑊-𝑤+1)给出。这是由于我们需要足够的空间在图像上 "移动 "卷积核(稍后我们将看到如何通过在图像边界周围填充零来保持大小不变,从而有足够的空间来移动核)。接下来,我们在corr2d函数中实现上述过程。它接受带有核数组K的输入数组X,并输出数组Y。
import torch
from torch import nn
def corr2d(X, K):
h, w = K.shape # 卷积核的大小
print('h,w: ',h,w)
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来验证上述二维交叉相关操作的实现的输出。
X = torch.Tensor([[0, 1, 2], [3, 4, 5], [6, 7, 8]])
K = torch.Tensor([[0, 1], [2, 3]])
Y = corr2d(X, K)
print('Y: ' ,Y)
h,w: 2 2
卷积的部分 : tensor([[0., 1.],
[3., 4.]])
卷积的部分 : tensor([[1., 2.],
[4., 5.]])
卷积的部分 : tensor([[3., 4.],
[6., 7.]])
卷积的部分 : tensor([[4., 5.],
[7., 8.]])
Y: tensor([[19., 25.],
[37., 43.]])
二、卷积层
卷积层对输入和核进行交叉关联,并增加一个标量偏置来产生一个输出。卷积层的参数正是 构成核和标量偏置的值。在训练基于卷积层的模型时,我们通常会随机初始化核,就像我们对全连接层一样。
我们现在已经准备好在上面定义的corr2d函数的基础上实现一个二维卷积层。
在__init__构造函数中,我们声明权重和偏置是两个模型参数。前向计算函数forward调用corr2d函数并添加偏置。与ℎ×𝑤交叉相关一样,我们也把卷积层称为ℎ×𝑤卷积。
class Conv2D(nn.Module):
def __init__(self, kernel_size, **kwargs):
super(Conv2D, self).__init__(**kwargs)
# 传入kernel_size卷积核大小
self.weight = torch.rand(kernel_size,dtype=torch.float32,requires_grad=True)
self.bias = torch.zeros((1,),dtype=torch.float32,requires_grad=True)
def forward(self, x):
return corr2d(x, self.weight) + self.bias
三、图像中的物体边缘检测
让我们看看卷积层的一个简单应用:通过寻找像素变化的位置来检测图像中物体的边缘。首先,我们构建一个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.]])
接下来,我们构建一个高度为1、宽度为2的内核K。当我们对输入进行交叉相关操作时,如果水平相邻的元素相同,则输出为0,否则,输出为非零。
K = torch.Tensor([[1, -1]])
print('K: ',K)
K: tensor([[ 1., -1.]])
输入X和我们设计的内核K来进行交叉相关操作。正如你所看到的,我们将检测到从白色到黑色的边缘为1,从黑色到白色的边缘为-1。其余的输出为0。
Y = corr2d(X, K)
print('Y: ',Y)
h,w: 1 2
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) # 将X转置,如果水平相邻的元素相同,则输出为0,否则,输出为非零。这样便检测不了
h,w: 1 2
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.]])
同时转置X和K,则边缘出现了:
# 同时转置X和K,则边缘出现了
corr2d(X.t(), K.t())
h,w: 2 1
tensor([[ 0., 0., 0., 0., 0., 0.],
[ 1., 1., 1., 1., 1., 1.],
[ 0., 0., 0., 0., 0., 0.],
[ 0., 0., 0., 0., 0., 0.],
[ 0., 0., 0., 0., 0., 0.],
[-1., -1., -1., -1., -1., -1.],
[ 0., 0., 0., 0., 0., 0.]])
四、学习一个内核
如果我们知道这正是我们所要寻找的,那么通过有限差分[1, -1]来设计一个边缘检测器是很好的。然而,当我们看到更大的核,并考虑到连续的卷积层时,可能无法精确地手动指定每个滤波器应该做什么。
现在让我们看看我们是否可以通过只看(输入,输出)对来学习从X生成Y的内核。我们首先构建一个卷积层,并将其内核初始化为一个随机数组。接下来,在每个迭代中,我们将使用平方误差来比较Y和卷积层的输出,然后计算梯度来更新权重。为了简单起见,在这个卷积层中,我们将忽略偏置。
我们之前构建了Conv2D类。但这里我们使用的是Pytorch库中的nn.Conv2D。我们创建的自定义类Conv2D也可以类似地使用。
# 构建一个具有1个输出通道的卷积层(通道将在下一节介绍),内核阵列形状为(1,2)
conv2d = nn.Conv2d(1,1, kernel_size=(1, 2),bias=False) # 为简单起见,忽略偏见
# 二维卷积层使用四维输入和输出,格式为(例子通道,高度,宽度),其中批次大小(批次中的例子数量)和通道数量都是1
X = X.reshape((1, 1, 6, 8)) # 此前的输入X
print('X: ',X)
Y = Y.reshape((1, 1, 6, 7)) # 经过一次corr2d的输出Y(作为标准标签,去学习卷积核参数)
print('Y: ',Y)
for i in range(10):
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2 # 定义的损失函数,平方差距离
conv2d.zero_grad()
l.sum().backward() # 反向传播计算
# 为了简单起见,我们在这里忽略了偏差的问题
conv2d.weight.data[:] -= 3e-2 * conv2d.weight.grad # 更新权重
if (i + 1) % 2 == 0:
print('batch %d, loss %.3f' % (i + 1, l.sum())) # 输出
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.]]]])
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.]]]])
batch 2, loss 3.774
batch 4, loss 0.676
batch 6, loss 0.131
batch 8, loss 0.029
batch 10, loss 0.008
正如你所看到的,在10次迭代之后,误差已经下降到一个很小的数值。现在我们来看看我们学到的内核阵列。
conv2d.weight.data.reshape((1, 2))
tensor([[ 1.0121, -0.9671]])
考虑偏置时:
# 构造一个核数组形状是(1, 2)的二维卷积层
conv2d = Conv2D(kernel_size=(1, 2))
step = 20
lr = 0.01
for i in range(step):
Y_hat = conv2d(X)
l = ((Y_hat - Y) ** 2).sum()
l.backward()
# 梯度下降
conv2d.weight.data -= lr * conv2d.weight.grad
conv2d.bias.data -= lr * conv2d.bias.grad
# 梯度清0
conv2d.weight.grad.fill_(0)
conv2d.bias.grad.fill_(0)
if (i + 1) % 5 == 0:
print('Step %d, loss %.3f' % (i + 1, l.item()))
h,w: 1 2
h,w: 1 2
h,w: 1 2
h,w: 1 2
h,w: 1 2
Step 5, loss 5.881
h,w: 1 2
h,w: 1 2
h,w: 1 2
h,w: 1 2
h,w: 1 2
Step 10, loss 1.410
h,w: 1 2
h,w: 1 2
h,w: 1 2
h,w: 1 2
h,w: 1 2
Step 15, loss 0.367
h,w: 1 2
h,w: 1 2
h,w: 1 2
h,w: 1 2
h,w: 1 2
Step 20, loss 0.099
# 输出结果
print("weight: ", conv2d.weight.data)
print("bias: ", conv2d.bias.data)
weight: tensor([[ 0.9268, -0.9145]])
bias: tensor([-0.0069])
事实上,学到的内核阵列与我们之前定义的内核阵列K非常接近。
五、交叉相关和卷积
实际上,卷积运算与互相关运算类似。为了得到卷积运算的输出,我们只需将核数组左右翻转并上下翻转,再与输入数组做互相关运算。可见,卷积运算和互相关运算虽然类似,但如果它们使用相同的核数组,对于同一个输入,输出往往并不相同。
那么,你也许会好奇卷积层为何能使用互相关运算替代卷积运算。其实,在深度学习中核数组都是学出来的:卷积层无论使用互相关运算或卷积运算都不影响模型预测时的输出。为了解释这一点。为了与大多数深度学习文献一致,如无特别说明,本书中提到的卷积运算均指互相关运算。
六、总结
二维卷积层的核心计算是一个二维交叉相关操作。在其最简单的形式中,它对二维输入数据和内核进行交叉相关操作,然后增加一个偏置。
我们可以设计一个内核来检测图像中的边缘。
我们可以通过数据来学习内核。
七、练习
1、构建一个具有对角线边缘的图像X。
- 如果你对它应用内核K,会发生什么?
- 如果你将X转置,会发生什么?
- 如果你转置K会发生什么?
①构建对角的图像矩阵
# 构建对角的图像矩阵
Z = torch.ones((8, 8))
for i in range(len(Z)):
Z[i, i] = 0
print('Z:',Z)
Z: tensor([[0., 1., 1., 1., 1., 1., 1., 1.],
[1., 0., 1., 1., 1., 1., 1., 1.],
[1., 1., 0., 1., 1., 1., 1., 1.],
[1., 1., 1., 0., 1., 1., 1., 1.],
[1., 1., 1., 1., 0., 1., 1., 1.],
[1., 1., 1., 1., 1., 0., 1., 1.],
[1., 1., 1., 1., 1., 1., 0., 1.],
[1., 1., 1., 1., 1., 1., 1., 0.]])
②应用卷积核K进行处理Z
# 应用卷积核K进行处理Z
corr2d(Z, K)
h,w: 1 2
tensor([[-1., 0., 0., 0., 0., 0., 0.],
[ 1., -1., 0., 0., 0., 0., 0.],
[ 0., 1., -1., 0., 0., 0., 0.],
[ 0., 0., 1., -1., 0., 0., 0.],
[ 0., 0., 0., 1., -1., 0., 0.],
[ 0., 0., 0., 0., 1., -1., 0.],
[ 0., 0., 0., 0., 0., 1., -1.],
[ 0., 0., 0., 0., 0., 0., 1.]])
③如果你将Z转置,会发生什么
# 如果你将Z转置,会发生什么
corr2d(Z.t(), K)
# 无变化
h,w: 1 2
输出:
tensor([[-1., 0., 0., 0., 0., 0., 0.],
[ 1., -1., 0., 0., 0., 0., 0.],
[ 0., 1., -1., 0., 0., 0., 0.],
[ 0., 0., 1., -1., 0., 0., 0.],
[ 0., 0., 0., 1., -1., 0., 0.],
[ 0., 0., 0., 0., 1., -1., 0.],
[ 0., 0., 0., 0., 0., 1., -1.],
[ 0., 0., 0., 0., 0., 0., 1.]])
④转置K会发生什么?
# 如果你转置K会发生什么?
corr2d(Z, K.t())
# 次对角线会变在上方去
h,w: 2 1
tensor([[-1., 1., 0., 0., 0., 0., 0., 0.],
[ 0., -1., 1., 0., 0., 0., 0., 0.],
[ 0., 0., -1., 1., 0., 0., 0., 0.],
[ 0., 0., 0., -1., 1., 0., 0., 0.],
[ 0., 0., 0., 0., -1., 1., 0., 0.],
[ 0., 0., 0., 0., 0., -1., 1., 0.],
[ 0., 0., 0., 0., 0., 0., -1., 1.]])
2、你如何通过改变输入和内核数组来表示一个交叉相关操作为矩阵乘法?
卷积计算为什么要转成矩阵乘法?
为了加速运算,传统的卷积核依次滑动的计算方法很难加速。
转化为矩阵乘法之后,就可以调用各种线性代数运算库,CUDA里面的矩阵乘法实现。这些矩阵乘法都是极限优化过的,比暴力计算快很多倍。
3、手动设计一些内核。
- 二次导数的核的形式是什么?
- 什么是拉普拉斯算子的核?
- 什么是积分的内核?
- 为了得到程度为𝑑的导数,内核的最小尺寸是多少?