卷积


卷积的需求

对于一个普通的神经网络模型,假设我们有一个足够充分的照片数据集,数据集中是拥有标注的照片,每张照片具有百万级像素,这意味着网络的每次输入都有一百万个维度。如果考虑上像素的RGB值,那么输入维度就变成了原来的三倍,这个参数量是十分恐怖的。

然而,如今人类和机器都能很好地区分猫和狗。这是因为图像中本就拥有丰富的结构,而这些结构可以被人类和机器学习模型使用。卷积神经网络(Convolutional Neural Networks,CNN)就是机器学习利用自然图像中一些已知结构的创造性方法。

卷积神经网络的架构主要依靠下述两个想法

  1. 平移不变性:不管检测对象出现在图像中的哪个位置,神经网络的前面几层应该对相同的图像区域具有相似的反应
  2. 局部性:神经网络的前面几层应该只探索输入图像中的局部区域,而不过度在意图像中相隔较远区域

卷积神经网络是含有卷积层(Convolutional Layer)的神经网络,最常见的为二维卷积层。它有高和宽两个空间维度,常用来处理图像数据。

卷积层

对于两个函数 f,g:RnRf,g:\mathbb{R}^n\to\mathbb{R},其卷积定义为

(fg)(x)=Rnf(z)g(xz)dz(f*g)(\boldsymbol{x})=\int_{\mathbb{R}^n} f(\boldsymbol{z})g(\boldsymbol{x-z})\mathrm{d}\boldsymbol{z}

卷积就是把一个函数“翻转”并移位,测量 ffgg 之间的重叠。对于离散对象,积分就变成求和。

卷积神经网络中的卷积层实际上是一个特殊的全连接层。如果把一个图片当做二维输入矩阵 X\boldsymbol{X},其输出层也是一个二维矩阵 H\boldsymbol{H},此时考虑把二维权重矩阵 W\boldsymbol{W} 变成四维张量 W\mathcal{W},并加入偏置矩阵 U\boldsymbol{U}。此时,一个全连接层的变换可以表示为

Hi,j=Ui,j+klWi,j,k,lXk,l\boldsymbol{H}_{i,j}=\boldsymbol{U}_{i,j}+\sum_{k}\sum_{l}\mathcal{W}_{i,j,k,l}\boldsymbol{X}_{k,l}

下面考虑如何实现平移不变性局部性。首先做变量代换

a=ki,b=lja=k-i,\quad b=l-j

因此可以构造一个新的四维张量

Vi,j,a,b=Wi,j,i+a,j+b\boldsymbol{V}_{i,j,a,b}=\mathcal{W}_{i,j,i+a,j+b}

为了方便继续进行,我们对各个参数进行解释

  • Hi,j\boldsymbol{H}_{i,j}:输出的二维矩阵的 (i,j)(i,j) 元素
  • Ui,j\boldsymbol{U}_{i,j}:对应输出 Hi,j\boldsymbol{H}_{i,j} 的偏置标量
  • klWi,j,k,lXk,l=abVi,j,a,bXi+a,j+b\displaystyle\sum_{k}\sum_{l}\mathcal{W}_{i,j,k,l}\boldsymbol{X}_{k,l}=\sum_{a}\sum_{b}\boldsymbol{V}_{i,j,a,b}\boldsymbol{X}_{i+a,j+b}:以输入 Xi,j\boldsymbol{X}_{i,j} 为中心的一片区域的加权求和

由于平移不变性,检测对象在 X\boldsymbol{X} 中的平移应该仅仅导致结果在 H\boldsymbol{H} 的平移,因此权重 U,V\boldsymbol{U},\boldsymbol{V} 不应该依赖于下标 i,ji,j,因此需要修改全连接层的表示形式,把 U,V\boldsymbol{U},\boldsymbol{V} 看做与 i,ji,j 无关的张量。

Hi,j=u+abVa,bXi+a,j+b\boldsymbol{H}_{i,j}=u+\sum_{a}\sum_{b}\boldsymbol{V}_{a,b}\boldsymbol{X}_{i+a,j+b}

此时,矩阵 U\boldsymbol{U} 退化为一个标量,权重矩阵 V\boldsymbol{V} 退化为一个二维张量,这极大地减少了参数个数,直观上展示了卷积神经网络的复杂度优势。其次,为了收集有关 Hi,j\boldsymbol{H}_{i,j},局部性原则要求输入信息采集不应该距离 Xi,j\boldsymbol{X}_{i,j} 过远,因此可以限制 a,ba,b 的求和范围

Hi,j=u+a=ΔΔb=ΔΔVa,bXi+a,j+b\boldsymbol{H}_{i,j}=u+\sum_{a=-\Delta}^{\Delta}\sum_{b=-\Delta}^{\Delta}\boldsymbol{V}_{a,b}\boldsymbol{X}_{i+a,j+b}

上式就是一个卷积层,其可以视作全连接层的一种特殊表示。

二维互相关

上述卷积层的定义式中,其所表达的运算实际上被称作互相关运算(Cross-Correlation)。在一个互相关运算中包含两个输入:输入张量 X\boldsymbol{X} 以及 核张量 K\boldsymbol{K}(Kernel),对于二维情况而言,设其维度分别为 nh×nw,kh×hwn_h\times n_w,k_h\times h_w,那么他们的互相关运算结果是一个二维张量,尺寸为

(nhkh+1)×(nwkw+1),khnh,kwnw(n_h-k_h+1)\times(n_w-k_w+1),\quad k_h\leqslant n_h,k_w\leqslant n_w

对于输出 Y\boldsymbol{Y},其 (i,j)(i,j) 元素可以视作两个矩阵的逐元素乘积

Yi,j=a=1khb=1kwKa,b×Xi+a1,j+b1\boldsymbol{Y}_{i,j}=\sum_{a=1}^{k_h}\sum_{b=1}^{k_w}\boldsymbol{K}_{a,b}\times\boldsymbol{X}_{i+a-1,j+b-1}

下图提供一个直观的计算例子。

如果考虑上偏置项 bRb\in\mathbb{R} 和广播机制,那么一个二维卷积层也可以表示为

Y=KX+b\boldsymbol{Y}=\boldsymbol{K}*\boldsymbol{X}+b

其中 * 表示互相关运算,权重 K,b\boldsymbol{K},b 是待训练参数,核张量的维度 kh,kwk_h,k_w 是超参数、

事实上,卷积与互相关是两个很相似的运算,维度他们的运算顺序是有所区别的,即核张量的各个维度被翻转了。假定下例中的符号假设和前文一致,卷积运算的结果为

Yi,j=a=1khb=1kwKkha+1,kwb+1×Xi+a,j+b\boldsymbol{Y}_{i,j}=\sum_{a=1}^{k_h}\sum_{b=1}^{k_w}\boldsymbol{K}_{k_h-a+1,k_w-b+1}\times\boldsymbol{X}_{i+a,j+b}

卷积层通常使用的是互相关运算,而不是卷积运算。

卷积层代码


互相关运算

互相关运算可以通过简单的循环算法实现,定义函数 corr2d

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):
h, w = K.shape
# 初始化输出张量 Y
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

借助上一小节图片中的例子,对函数进行验证

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]])
print(corr2d(X, K))

输出结果与例子一致。

1
2
tensor([[19., 25.],
[37., 43.]])

例子:边缘检测

对于一个黑白图片,假设每个像素的状态用 0,10,1 表示黑色或白色,现在想要检测图像中沿水平方向不同颜色的边缘。这种类似的问题称作边缘检测问题(Edge Detection),考虑构造一个 1×21\times 2 的核张量

1
K = torch.tensor([[1.0, -1.0]])
  • 当两个水平相邻的像素颜色相同时,输出结果为 00
  • 当两个水平相邻的像素颜色不同时,输出结果为 ±1\pm 1

现在,人工生成一个简单输入

1
2
3
X = torch.ones((6, 8))
X[:, 2:6] = 0
print(corr2d(X, K))

输出结果如下

1
2
3
4
5
6
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\boldsymbol{K} 只可以检测垂直边缘,无法检测水平边缘。一般的,更复杂的边缘检测问题需要更复杂的卷积核。

卷积层

卷积层中的两个被训练的参数是卷积核权重和偏置标量。基于全连接层的初始化方法,在训练基于卷积层的模型时,我们也随机初始化卷积核权重。

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

学习卷积核

对于输入 X\boldsymbol{X} 和给定的输出 Y\boldsymbol{Y},能否通过机器学习方法找出一个合适的卷积核 K\boldsymbol{K} 呢?我们先构造一个卷积层,并将其卷积核初始化为随机张量。接下来,在每次迭代中,我们比较 Y\boldsymbol{Y} 与卷积层输出的平方误差,然后计算梯度来更新卷积核。为了简单起见,我们在此使用 PyTorch 内置的二维卷积层函数,并忽略偏置项。

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
# 采用边缘检测小节的例子
X = torch.ones((6, 8))
X[:, 2:6] = 0
K = torch.tensor([[1.0, -1.0]])
Y= corr2d(X, K)

# 构造一个二维卷积层,它具有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 # 学习率

# 训练次数 epochs = 10
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}')
print('kernel:', K)
print('prediction:', conv2d.weight.data.reshape((1, 2)))

某次训练结果如下,可以看出训练效果竟然相当好。

1
2
3
4
5
6
7
epoch 2, loss 5.572
epoch 4, loss 0.990
epoch 6, loss 0.189
epoch 8, loss 0.041
epoch 10, loss 0.011
kernel: tensor([[ 1., -1.]])
prediction: tensor([[ 0.9797, -0.9963]])

事实上,采用互相关运算和卷积运算得到的结果一般是差别不大的。

填充与步幅


填充

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

填充的基本思想是:在输入的边界填充一些元素(通常是 00)。

假设进行 php_h 行填充和 pwp_w 列填充,那么输出维度为

(nhkh+ph+1)×(nwkw+pw+1),khnh,kwnw(n_h-k_h+p_h+1)\times(n_w-k_w+p_w+1),\quad k_h\leqslant n_h,k_w\leqslant n_w

可以看到,输出的行和列分别增加了 ph,pwp_h,p_w。通常情况下取 ph=kh1,pw=kw1p_h=k_h-1,p_w=k_w-1,此时输入和输出的形状不发生改变。填充满足如下原则

  • kh,kwk_h,k_w 为偶数时,在上下两侧填充 kh2\dfrac{k_h}{2} 行,左右两侧填充 kw2\dfrac{k_w}{2}
  • kh,kwk_h,k_w 为奇数时,在上下方向添加 kh2\lceil\dfrac{k_h}{2}\rceilkh2\lfloor\dfrac{k_h}{2}\rfloor 行,列同理

步幅

在计算互相关时,卷积窗口从输入张量的左上角开始,向下、向右滑动,而这个滑动的距离默认为 11,也被称作步幅,按照方向分为垂直步幅水平步幅

与填充相反,当我们需要进行高效计算或者缩减采样次数时,可以采用修改步幅的方法。设 sh,sws_h,s_w 分别表示垂直步幅和水平步幅,那么输出的形状为

nhkh+phsh+1×nwkw+pwsw+1\lfloor\frac{n_h-k_h+p_h}{s_h}+1\rfloor\times\lfloor\frac{n_w-k_w+p_w}{s_w}+1\rfloor

由于原尺寸和步幅不一定是整除关系,因此要向下取整。当填充数 ph=kh1,pw=kw1p_h=k_h-1,p_w=k_w-1 ,并且输入尺寸能够被步幅整除时,有输出尺寸

nhsh×nwsw\frac{n_h}{s_h}\times\frac{n_w}{s_w}

代码实现

为了去掉输入的批量大小和通道这两个维度,先定义函数 comp_conv2d,然后执行填充行为。

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:])

# padding 表示每边都填充了1行或1列,因此总共添加了2行或2列
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1)
X = torch.rand(size=(8, 8))
# 输出维度是 (n_h - k_h + p_h + 1) * (n_w - k_w + p_w + 1)
# 此时 n_h = n_w = 8, k_h = k_w = 3, p_h = p_w = 2
print(comp_conv2d(conv2d, X).shape)

对于步幅的设置,只需要在 nn.Conv2dstride 项中设置步幅即可。

1
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2)

更多有关 nn.Conv2d 的用法详见后文。

通道


多输入通道

到目前为止,前面展示的都是单个输入和单个输出通道的简化例子。这使得我们可以将输入、卷积核和输出看作二维张量。前面提到,对于一些彩色图像,我们需要使用RGB三个通道组成,输入 X\boldsymbol{X} 就一个三维张量。在这里,额外的一个维度记作通道(Channel)。

接下来的讨论先忽略掉偏置项。当输入包含多个通道时,需要构造一个与输入数据具有相同输入通道数的卷积核,以便与输入数据进行互相关运算。设输入通道数为 cic_{i},那么输入张量的尺寸为 ci×nh×nwc_{i}\times n_h\times n_w,每一个卷积核张量的尺寸为 ci×kh×kwc_{i}\times k_h\times k_w,那么卷积后的结果就是把每个通道输入的二维张量和对应卷积核做互相关运算,然后求和,即有

Y=KX=k=1ciKk,:,:Xk,:,:\boldsymbol{Y}=\boldsymbol{K}*\boldsymbol{X}=\sum_{k=1}^{c_i}\boldsymbol{K}_{k,:,:}*\boldsymbol{X}_{k,:,:}

多输出通道

随着神经网络层数的加深,我们常会增加某个卷积层的输出通道个数,通过减少空间分辨率以获得更大的通道深度。直观地说,我们可以将每个通道看作对不同特征的响应。

cic_icoc_o 分别表示输入和输出通道的个数,其他参数含义不变。我们可以为每个输出通道创建一个尺寸为 ci×kh×kwc_i\times k_h\times k_w 的卷积核张量,因此整个卷积核张量 K\boldsymbol{K} 的尺寸为 co×ci×kh×kwc_o\times c_i\times k_h\times k_w

在整个运算过程中,每个输出通道先获取所有的输入通道的信息,即三维输入张量 X\boldsymbol{X},再与对应的三维卷积核张量进行运算,然后从该通道输出。设输出 Y\boldsymbol{Y} 是一个三维张量,即有

Yi,:,:=Ki,:,:,:X,i=1,2,,co\boldsymbol{Y}_{i,:,:}=\boldsymbol{K}_{i,:,:,:}*\boldsymbol{X},\quad i=1,2,\cdots,c_o

多通道的好处是可以识别特定的模式(Pattren)。比如对于手写数字识别,不同数字的笔画(横、竖、弧线)都是一种特殊的模式,而每个数字都是由不同的模式组合而成的。

每个输入通道核负责识别并组合输入模式,每个输出通道可以识别特定模式。通道数 ci,coc_i,c_o 是卷积神经网络训练过程中很重要的超参数。

1*1 卷积层

kh=kw=1k_h=k_w=1 时,该卷积层就变成一个 1×11\times 1卷积层,此时其失去了识别相邻元素之间相互作用的能力。但是对于多通道问题而言,其优势在于可以融合通道,即把每个像素位置 cic_i 个输入转化为 coc_o 个输入,此时整个卷积层等价于一个输入形状为 nhnw×cin_hn_w\times c_i,权重形状为 co×cic_o\times c_i 的全连接层。

多通道代码实现

对于多输入通道,只需要对每一个通道分别计算然后求和即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import torch
from d2l import torch as d2l

def corr2d_multi_in(X, K):
# 先遍历 X 和 K 的第 0 个维度(通道维度),再把它们加在一起
# 使用 zip 函数解压
return sum(d2l.corr2d(x, k) for x, k in zip(X, K))

# X 的维度为 3
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 的维度为 3
K = torch.tensor([[[0.0, 1.0], [2.0, 3.0]], [[1.0, 2.0], [3.0, 4.0]]])

print(corr2d_multi_in(X, K))

代码计算结果和手动验算结果一致。

1
2
tensor([[ 56.,  72.],
[104., 120.]])

为了实现多通道输出,我们需要遍历 K 的第 0 维,得到每个输出通道对应的卷积层。

1
2
3
4
5
6
7
8
def corr2d_multi_in_out(X, K):
# 迭代 K 的第 0 个维度,每次都对输入 X 执行互相关运算
# 最后将所有结果都叠加在一起
return torch.stack([corr2d_multi_in(X, k) for k in K], 0)
# 创建 3 个输出通道卷积核,此时 K 的维度为 4
K = torch.stack((K, K + 1, K + 2), 0)

print(corr2d_multi_in_out(X, K))

输出结果是一个三维张量,一共有三个输出通道。

1
2
3
4
5
6
7
8
tensor([[[ 56.,  72.],
[104., 120.]],

[[ 76., 100.],
[148., 172.]],

[[ 96., 128.],
[192., 224.]]])

最后是 1×11\times 1 卷积层的实现,使用基本的全连接层代码即可。

1
2
3
4
5
6
7
8
9
def corr2d_multi_in_out_1x1(X, K):
c_i, h, w = X.shape
c_o = K.shape[0]
# 注意 X 和 K 的维度
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))

最后来总结一下多通道情况下,各个张量的维度。

  • 输入 X\boldsymbol{X}ci×nh×nwc_i\times n_h\times n_w
  • K\boldsymbol{K}co×ci×nh×nwc_o\times c_i\times n_h\times n_w
  • 偏置 B\boldsymbol{B}coc_o
  • 输出 Y\boldsymbol{Y}co×mh×hwc_o\times m_h\times h_w,和输入张量、核张量、填充操作和步幅有关

PyTorch 的广播机制下,整个模型可以记作

Y=KX+B\boldsymbol{Y}=\boldsymbol{K}*\boldsymbol{X}+\boldsymbol{B}

池化层


池化

二维卷积层的操作可以视作卷积核在输入上滚动并不断计算加权和。有时候,这种滚动计算操作可以不仅仅是互相关运算,比如我们可以挑选卷积核当前滚动区域内最大的数值作为结果输出。这种层叫做汇聚层池化层(Pooling),其自主定义了一种类似卷积的滚动形式的池化运算

池化层和卷积层一样拥有一个固定尺寸的空间邻域,一般情况下为矩形形状。当池化层对空间邻域的池化运算定义为所有元素的最大值时,称其为最大池化层;当该池化运算定义为所有元素的平均值时,称其为平均池化层。这两种是最常用的池化层形式。

池化层的主要优点之一是减轻卷积层对位置的过度敏感,通过对特征进行统计处理(最大值、平均值等)从而形成新的特征图,可以有效降低模型的复杂度,防止过拟合

有关池化层,其具有以下性质

  • 可修改空间邻域的尺寸 ph×pwp_h\times p_w(此处符号不是填充的行列数)
  • 同样具有填充行为与步幅修改
  • 没有可以学习的参数
  • 每个输入通道应用池化层后,从对应输出通道输出,因此 ci=coc_i=c_o
  • 能够降低模型复杂度,有效避免过拟合

代码实现

我们以最大池化层和平均池化层为例,定义池化层函数 pool2d

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import torch
from torch import nn
from d2l import torch as d2l

# 通过修改 mode 改变池化运算
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

对于具有填充行为、步幅修改和多通道的池化层,可以调用 nn.MaxPool2d 函数

1
2
3
4
5
X = torch.arange(16, dtype=torch.float32).reshape((1, 1, 4, 4))
X = torch.cat((X, X + 1), 1)
# 池化尺寸为 3*3,填充为 2,步幅为 2
pool2d = nn.MaxPool2d(3, padding=1, stride=2)
print(pool2d(X))

输出结果如下,可自行计算验证。

1
2
3
4
5
tensor([[[[ 5.,  7.],
[13., 15.]],

[[ 6., 8.],
[14., 16.]]]])

至此,有关卷积神经网络的基本概念已经介绍完毕,接下来到达战场的是各种卷积神经网络模型。