严格来讲,卷积运算实际上是互相关(cross-correlation)运算,如下图所示:
设输入大小为 ( n h , n w ) (n_h,n_w) (nh,nw),卷积核大小为 ( k h , k w ) (k_h,k_w) (kh,kw),则输出大小为 ( n h − k h + 1 , n w − k w + 1 ) (n_h-k_h+1,n_w-k_w+1) (nh−kh+1,nw−kw+1)
接下来实现卷积运算:
class Conv2D(nn.Module):
def __init__(self, kernel_size):
super().__init__()
# 随机初始化卷积核的权重和偏置
self.weight = nn.Parameter(torch.randn(kernel_size))
self.bias = nn.Parameter(torch.zeros(1))
def forward(self, X):
return self.corr2d(X, self.weight) + self.bias
def corr2d(self, X, K):
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] = torch.sum(X[i:i + h, j:j + w] * K)
return Y
测试:
torch.manual_seed(42)
conv2d = Conv2D((3, 3))
X = torch.randn(5, 5)
Y = conv2d(X)
print(Y)
# tensor([[-2.1781, 0.0133, -0.8048],
# [ 1.3521, -0.5629, 0.0579],
# [-3.0277, 0.6740, 0.7058]], grad_fn=)
先创建一个黑白图,假设 1 1 1 代表白色, 0 0 0 代表黑色:
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 ) (1,2) (1,2) 的卷积核。当进行互相关运算时,如果水平相邻的两元素相同,则输出为零,否则输出为非零。
conv2d = Conv2D((1, 2))
conv2d.weight.data = torch.tensor([[-1., 1.]])
conv2d(X)
# 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.]], grad_fn=)
该卷积核只能检测垂直边缘,无法检测水平边缘。
我们可以使用 nn.Conv2d
来更方便地构造卷积层,格式如下:
nn.Conv2d(in_channels, out_channels, kernel_size, stride=1, padding=0, bias=True)
这里仅列出了常用的参数。其中 in_channels
表示输入通道数,out_channels
表示输出通道数,kernel_size
为卷积核的大小(元组形式),stride
为步幅,padding
为填充,bias
表示是否开启偏置。
输入的形状为 ( B , C i n , H i n , W i n ) (B, C_{in},H_{in},W_{in}) (B,Cin,Hin,Win)(或 ( C i n , H i n , W i n ) (C_{in},H_{in},W_{in}) (Cin,Hin,Win)),输出的形状为 ( B , C o u t , H o u t , W o u t ) (B, C_{out},H_{out},W_{out}) (B,Cout,Hout,Wout)(或 ( C o u t , H o u t , W o u t ) (C_{out},H_{out},W_{out}) (Cout,Hout,Wout)),其中 B B B 是 batch size。
X = torch.ones((6, 8))
X[:, 2:6] = 0
X = X.reshape((1, 6, 8))
conv2d = nn.Conv2d(1, 1, (1, 2), bias=False)
conv2d.weight.data[0, 0] = torch.tensor([[-1., 1.]])
conv2d(X)
# 结果与1.1中的一样
注意只有 conv2d.weight.data[0, 0]
才是有效的,conv2d.weight[0, 0].data
无效。
如果我们只需寻找黑白边缘,那么以上卷积核足以完成任务。然而,当有了更复杂数值的卷积核,或者连续的卷积层时,我们不可能手动设计卷积核。那么我们是否可以学习由 X
生成 Y
的卷积核呢?
答案是可以的。
# 给定 X 和 Y
X = torch.ones(6, 8)
Y = torch.zeros(6, 7)
X[:, 2:6] = 0
Y[:, 1] = -1
Y[:, -2] = 1
X = X.reshape((1, 6, 8))
Y = Y.reshape((1, 6, 7))
# 初始化
conv2d = nn.Conv2d(1, 1, (1, 2), bias=False)
optimizer = torch.optim.SGD(conv2d.parameters(), lr=1e-2)
num_epochs = 100
# 学习
for epoch in range(num_epochs):
Y_hat = conv2d(X)
loss = torch.norm(Y_hat - Y) ** 2
# 反向传播
optimizer.zero_grad()
loss.backward()
optimizer.step()
print(conv2d.weight.data.reshape((1, 2)))
# tensor([[-1.0000, 1.0000]])
可以看出结果与我们预先使用的卷积核几乎是完全相同的。
假设一张图片转化为张量后,其形状为 n c × n h × n w n_c\times n_h\times n_w nc×nh×nw,其中 n c n_c nc 是通道个数,对于RGB彩图应有 n c = 3 n_c=3 nc=3。自然地,卷积核的形状应为 n c × k h × k w n_c\times k_h\times k_w nc×kh×kw,即卷积核的通道数应与输入的通道数保持一致。
只用一个卷积核,则卷积之后的输出的形状为 ( n h − k h + 1 , n w − k w + 1 ) (n_h-k_h+1, n_w-k_w+1) (nh−kh+1,nw−kw+1),即三维卷积之后得到了二维的张量,而这个二维的张量就称作特征图(feature map)。
二维卷积可以形象的理解为两个网格正方形按元素相乘后再全部相加,从而三维卷积就是两个网格正方体按元素相乘后再全部相加。
对于特征图上的某个元素 x x x,其感受野(receptive field)则是指在正向传播期间可能影响计算 x x x 的所有元素(来自所有先前层)。
在应用多层卷积时,我们常常丢失边缘像素。 由于我们通常使用小卷积核,因此对于任何单个卷积,我们可能只会丢失几个像素。 但随着我们应用许多连续卷积层,累积丢失的像素数就多了。 解决这个问题的简单方法即为填充(padding):在输入图像的边界填充元素(通常填充元素是 0 0 0)。
为简便起见,接下来我们假设图片和卷积核均为正方形,尺寸分别为 n × n n\times n n×n 和 f × f f\times f f×f。假设在图片周围补 p p p 圈 0 0 0,则输出尺寸为
( n − f + 2 p + 1 ) × ( n − f + 2 p + 1 ) (n-f+2p+1)\times (n-f+2p+1) (n−f+2p+1)×(n−f+2p+1)
通常卷积核的高度和宽度均为奇数,因此若填充 ( f − 1 ) / 2 (f-1)/2 (f−1)/2 圈 0 0 0,则得到的特征图尺寸与输入尺寸一致。
考虑 32 × 32 32\times 32 32×32 的单通道图像,设卷积核的尺寸为 5 × 5 5\times5 5×5,若填充选择 2 2 2,则输出尺寸不变:
X = torch.randn(1, 32, 32)
conv2d = nn.Conv2d(1, 1, (5, 5), padding=2)
Y = conv2d(X)
Y.shape
# torch.Size([1, 32, 32])
在前面的例子中,我们默认每次滑动一个元素。 但是,有时候为了高效计算或是缩减采样次数,卷积窗口可以跳过中间位置,每次滑动多个元素。
我们将每次滑动元素的数量称为步幅(stride),步幅分为垂直步幅和水平步幅两种,这里我们假设垂直步幅和水平步幅相同。
设步幅为 s s s,则输出尺寸为
⌊ n − f + 2 p s + 1 ⌋ × ⌊ n − f + 2 p s + 1 ⌋ \left\lfloor\frac{n-f+2p}{s}+1\right\rfloor\times\left\lfloor\frac{n-f+2p}{s}+1\right\rfloor ⌊sn−f+2p+1⌋×⌊sn−f+2p+1⌋
依然考虑 32 × 32 32\times 32 32×32 的单通道图像,设卷积核的尺寸为 5 × 5 5\times5 5×5,若填充为 3 3 3,步幅为 3 3 3,则输出尺寸应为 12 × 12 12\times 12 12×12:
X = torch.randn(1, 32, 32)
conv2d = nn.Conv2d(1, 1, (5, 5), stride=3, padding=3)
Y = conv2d(X)
Y.shape
# torch.Size([1, 12, 12])
通常我们的输入都是多通道的,例如对于一张RGB图片来说,其形状为 3 × n h × n w 3\times n_h\times n_w 3×nh×nw,本章节我们考虑更一般的形式,即假设输入的形状为 c i × n h × n w c_i\times n_h\times n_w ci×nh×nw。
当输入包含多个通道时,需要构造一个与输入数据具有相同输入通道数的卷积核,以便与输入数据进行三维卷积,即单个卷积核的形状应当为 c i × k h × k w c_i\times k_h\times k_w ci×kh×kw。在填充 0 0 0,步幅 1 1 1 的前提下,该卷积核与原图进行三维卷积后可以得到一个形状为 ( n h − k h + 1 , n w − k w + 1 ) (n_h-k_h+1,n_w-k_w+1) (nh−kh+1,nw−kw+1) 的特征图。若有多个卷积核,则会产生多个特征图,而输出的特征图的数量即为输出通道的个数,记为 c o c_o co。因此一组卷积核的形状可以表示为 c o × c i × k h × k w c_o\times c_i\times k_h\times k_w co×ci×kh×kw。
例如,现有一张 28 × 28 28\times 28 28×28 的RGB图,转化为张量后的形状为 3 × 28 × 28 3\times 28\times28 3×28×28,即输入通道数为 3 3 3。若输出通道数为 6 6 6,这意味着我们有 6 6 6 个形状为 3 × k h × k w 3\times k_h\times k_w 3×kh×kw 的卷积核,卷积后可得到 6 6 6 个特征图。若固定卷积核的大小为 5 × 5 5\times 5 5×5,使用默认的填充和步幅,则输出的形状为 6 × 24 × 24 6\times 24\times 24 6×24×24。接下来使用程序进行验证:
X = torch.randn(3, 28, 28)
conv2d = nn.Conv2d(3, 6, (5, 5))
Y = conv2d(X)
Y.shape
# torch.Size([6, 24, 24])
图像在卷积神经网络中进行传递时,通常 n h , n w n_h,n_w nh,nw 会逐渐减小, n c n_c nc 会逐渐增大。
与卷积层类似,汇聚层运算由一个固定形状的窗口组成,该窗口根据其步幅大小在输入的所有区域上滑动,为滑动窗口遍历的每个位置计算一个输出。不同于卷积层,汇聚层中不包含任何参数,并且汇聚运算是确定性的,我们通常计算滑动窗口中所有元素的最大值或平均值。
汇聚层常用的超参数:滑动窗口的形状,填充和步幅。
torch.manual_seed(0)
X = torch.randint(1, 10, (1, 5, 5)).to(torch.float)
X
# tensor([[[9., 1., 3., 7., 8.],
# [7., 8., 2., 2., 1.],
# [9., 3., 7., 4., 2.],
# [3., 1., 1., 6., 4.],
# [9., 3., 9., 3., 9.]]])
pool2d = nn.MaxPool2d(3, stride=1)
pool2d(X)
# tensor([[[9., 8., 8.],
# [9., 8., 7.],
# [9., 9., 9.]]])
pool2d = nn.AvgPool2d(3, stride=1)
pool2d(X)
# tensor([[[5.4444, 4.1111, 4.0000],
# [4.5556, 3.7778, 3.2222],
# [5.0000, 4.1111, 5.0000]]])
汇聚层的输出通道数与输入通道数相同。