这是我在看代码的时候忽然发现到的一个问题,同时一查也是某TOP厂的一个面试问题,一个我以前从没注意到的一个细节性问题。也就是说(以二维卷积为例),CNN中的conv2d层执行的并不是数学上的卷积计算,而是数学上的互相关计算。那为什么可以用互相关运算代替这里面的卷积运算呢?
卷积层的详细过程
在大多数的深度学习文献中,关于卷积计算的介绍一定是这样的:
如下图所示,输入是一个高和宽均为3的二维数组。我们将该数组的形状记为
3
×
3
3 \times 3
3×3或(3,3)。核数组的高和宽分别为2。该数组在卷积计算中又称卷积核或过滤器(filter)。卷积核窗口(又称卷积窗口)的形状取决于卷积核的高和宽,即
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。
在一次conv2d的计算过程中,卷积窗口从输入数组的最左上方开始,按从左往右、从上往下的顺序,依次在输入数组上滑动。当卷积窗口滑动到某一位置时,窗口中的输入子数组与核数组按元素相乘并求和,得到输出数组中相应位置的元素。上图中的输出数组高和宽分别为2,其中的4个元素计算出来的为:
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.
以上是大部分深度学习文献中介绍卷积层原理的内容,那么,进一部思考你会发现,上面的计算是数学上的互相关运算,并不是数学上的卷积运算,少了一个什么呢?翻转!也就是说,将卷积核上下,左右翻转180度,再相乘求和才是卷积计算。那么这里面为什么可以用互相关代替卷积呢?其实二维卷积的核心计算就是二维互相关运算,使用互相关代替卷积是不影响模型预测结果的,具体的我们通过一个conv模型训练的实例来解释:
这里我们借助MXNet中的autograd模块来实现自动求导,以及通过gluon中的nn模块,继承Block类来构造模型。
from mxnet import autograd, nd
from mxnet.gluon import nn
def corr2d(X, K):
h, w = K.shape
Y = nd.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
corr2d函数为上面原理中讲到的互相关操作,下面自定义卷积层,通过forward定义前向传播的操作:
class Conv2D(nn.Block):
def __init__(self, kernel_size, **kwargs):
super(Conv2D, self).__init__(**kwargs)
self.weight = self.params.get('weight', shape=kernel_size)
self.bias = self.params.get('bias', shape=(1,))
def forward(self, x):
return corr2d(x, self.weight.data()) + self.bias.data()
下面来定义输入和输出,首先我们构造一张6×8 的图像(即高和宽分别为6像素和8像素的图像)。它中间4列为黑(0),其余为白(1)。然后构造一个高和宽分别为1和2的卷积核,下面将输入X和我们设计的卷积核K做互相关运算。得到输出Y。
X = nd.ones((6, 8))
X[:, 2:6] = 0
K = nd.array([[1, -1]])
Y = corr2d(X, K)
训练的目的是不断更新初始化的卷积核参数,使得输入和训练网络中的卷积核运算后,结果接近Y(这里我们使用L2范数损失,并在更新的时候引入一个惩罚系数)
# 构造一个输出通道数为1,核数组形状是(1, 2)的二维卷积层
conv2d = nn.Conv2D(1, kernel_size=(1, 2))
conv2d.initialize() #参数初始化
# 数据reshape成(样本, 通道, 高, 宽)的图像格式
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
batch=15
for i in range(batch):
with autograd.record():
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2
l.backward()
conv2d.weight.data()[:] -= 3e-2 * conv2d.weight.grad() #3e-2为惩罚系数
if (i + 1) % 2 == 0:
print('batch %d, loss %.3f' % (i + 1, l.sum().asscalar()))
每两个batch打印一下损失:
batch 2, loss 4.949
batch 4, loss 0.831
batch 6, loss 0.140
batch 8, loss 0.024
batch 10, loss 0.004
batch 12, loss 0.001
batch 14, loss 0.000
经过15次迭代损失最小化,来看一下学习的效果:
conv2d.weight.data().reshape((1, 2))
输出:
[[ 0.9984094 -0.9991071]]
<NDArray 1x2 @cpu(0)>
可以看出已经非常接近于K了。通过这个实例我们应该清楚模型训练的是什么,再来思考之前的问题,为什么说卷积计算被互相关计算替代后不影响模型预测输出呢:因为即使是我们实例中的Y是由X和翻转后的K得到的(此时是数学上的卷积计算),那么训练出的模型卷积核参数就近似等于翻转后的K,那么我们用模型去做预测的时候,又是将输入X和近似翻转后的K作一次卷积,相当于翻转之后的卷积核又做了一次翻转,那不就相当于没有翻转吗,那不就相当于只是做了互相关计算吗,至于说模型训练结果的好坏只是影响训练的卷积核和本来的卷积核数组之间的近似程度,卷积计算只是在互相关计算得到的卷积核基础上做了翻转而已,而预测时候的再一次卷积操作则使得这两个计算操作得出的结果是一样的。
所以说,这里面的互相关计算是可以代替卷积计算的,不仅不影响结果,还减少了计算量(不需要翻转),而我们平时在深度学习中的卷积计算,实际上在数学上都算是互相关计算罢了。