D2L学习笔记-卷积神经网络

  1. 1. 从全连接层到卷积
  2. 2. 图像卷积
  3. 3. 填充和步幅
  4. 4. 多输入多输出通道
  5. 5. 汇聚层(池化层)
  6. 6. LeNet

李沐动手学深度学习(PyTorch)课程学习笔记第五章:卷积神经网络。

1. 从全连接层到卷积

假设我们有一个足够充分的照片数据集,数据集中是拥有标注的照片,每张照片具有百万级像素,这意味着网络的每次输入都有一百万个维度。即使将隐藏层维度降低到1000,这个全连接层也将有十亿个参数。

假设我们想从一张图片中找到某个物体。合理的假设是:无论哪种方法找到这个物体,都应该和物体的位置无关。卷积神经网络正是将空间不变性(spatial invariance)的这一概念系统化,从而基于这个模型使用较少的参数来学习有用的表示:

  • 平移不变性(translation invariance):不管检测对象出现在图像中的哪个位置,神经网络的前面几层应该对相同的图像区域具有相似的反应,即为“平移不变性”。
  • 局部性(locality):神经网络的前面几层应该只探索输入图像中的局部区域,而不过度在意图像中相隔较远区域的关系,这就是“局部性”原则。最终,可以聚合这些局部特征,以在整个图像级别进行预测。

2. 图像卷积

严格来说,卷积层是个错误的叫法,因为它所表达的运算其实是互相关运算(cross-correlation),而不是卷积运算。在卷积层中,输入张量和核张量通过互相关运算产生输出张量。

在二维互相关运算中,卷积窗口从输入张量的左上角开始,从左到右、从上到下滑动。当卷积窗口滑动到新一个位置时,包含在该窗口中的部分张量与卷积核张量进行按元素相乘,得到的张量再求和得到一个单一的标量值,由此我们得出了这一位置的输出张量值。

我们可以自己实现如上过程:

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

def corr2d(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] = (X[i:i + h, j:j + w] * K).sum()
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]])
print(corr2d(X, K)) # tensor([[19., 25.], [37., 43.]])

卷积层对输入和卷积核权重进行互相关运算,并在添加标量偏置之后产生输出。所以,卷积层中的两个被训练的参数是卷积核权重和标量偏置。

我们可以基于上面定义的 corr2d 函数实现二维卷积层:

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

现在来看一下卷积层的一个简单应用:通过找到像素变化的位置,来检测图像中不同颜色的边缘。我们假设0为黑色像素,1为白色像素:

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

X 的内容如下:

1
2
3
4
5
6
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。当进行互相关运算时,如果水平相邻的两元素相同,则输出为零,否则输出为非零:

1
2
K = torch.tensor([[1.0, -1.0]])
Y = corr2d(X, K)

Y 的内容如下:

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

现在我们使用 PyTorch 的卷积层尝试通过正确结果 Y 是否能学习出我们之前自己构造出的卷积核参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 构造一个二维卷积层,它具有1个输入通道和1个输出通道,卷积核形状为(1, 2)
conv2d = nn.Conv2d(in_channels=1, out_channels=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 # 学习率

for i in range(10):
Y_hat = conv2d(X)
loss = (Y_hat - Y) ** 2 # 均方误差
conv2d.zero_grad()
loss.sum().backward()
# 迭代卷积核,手动实现梯度下降
conv2d.weight.data[:] -= lr * conv2d.weight.grad
if (i + 1) % 2 == 0:
print(f'epoch {i + 1}, loss {loss.sum():.3f}')

print(conv2d.weight.data.reshape((1, 2))) # tensor([[ 0.9756, -1.0059]])

3. 填充和步幅

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

1
2
3
4
# 请注意,这里每边都填充了1行或1列,因此总共添加了2行或2列
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1)
X = torch.rand(size=(1, 1, 8, 8))
print(conv2d(X).shape) # torch.Size([1, 1, 8, 8])

在计算互相关时,卷积窗口从输入张量的左上角开始,向下、向右滑动。在前面的例子中,我们默认每次滑动一个元素。但是,有时候为了高效计算或是缩减采样次数,卷积窗口可以跳过中间位置,每次滑动多个元素。我们将每次滑动元素的数量称为步幅(stride)。

1
2
3
4
5
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2)
print(conv2d(X).shape) # torch.Size([1, 1, 4, 4])

conv2d = nn.Conv2d(1, 1, kernel_size=(3, 5), padding=(0, 1), stride=(3, 4))
print(conv2d(X).shape) # torch.Size([1, 1, 2, 2])

4. 多输入多输出通道

当输入包含多个通道时,需要构造一个与输入数据具有相同输入通道数的卷积核,以便与输入数据进行互相关运算。

为了加深理解,我们实现一下多输入通道互相关运算。简而言之,我们所做的就是对每个通道执行互相关操作,然后将结果相加:

1
2
3
4
5
6
7
8
9
10
11
12
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 = 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]]])

print(corr2d_multi_in(X, K)) # tensor([[ 56., 72.], [104., 120.]])

到目前为止,不论有多少输入通道,我们还只有一个输出通道。在最流行的神经网络架构中,随着神经网络层数的加深,我们常会增加输出通道的维数,通过减少空间分辨率以获得更大的通道深度。直观地说,我们可以将每个通道看作对不同特征的响应。而现实可能更为复杂一些,因为每个通道不是独立学习的,而是为了共同使用而优化的。因此,多输出通道并不仅是学习多个单通道的检测器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def corr2d_multi_in_out(X, K):
# 迭代“K”的第0个维度,每次都对输入“X”执行互相关运算,最后将所有结果都叠加在一起
return torch.stack([corr2d_multi_in(X, k) for k in K], 0)

# 通过将核张量K与K+1(K中每个元素加1)和K+2连接起来,构造了一个具有3个输出通道的卷积核
K = torch.stack((K, K + 1, K + 2), 0)
print(K.shape) # torch.Size([3, 2, 2, 2])

print(corr2d_multi_in_out(X, K))
# tensor([[[ 56., 72.],
# [104., 120.]],
# [[ 76., 100.],
# [148., 172.]],
# [[ 96., 128.],
# [192., 224.]]])

PS:1*1卷积层通常用于调整网络层的通道数量和控制模型复杂性,其失去了卷积层的特有能力:在高度和宽度维度上,识别相邻元素间相互作用的能力。

5. 汇聚层(池化层)

汇聚层(pooling layer)具有双重目的:降低卷积层对位置的敏感性,同时降低对空间降采样表示的敏感性。

与卷积层类似,汇聚层运算符由一个固定形状的窗口组成,该窗口根据其步幅大小在输入的所有区域上滑动(从左至右、从上至下),为固定形状窗口(有时称为汇聚窗口)遍历的每个位置计算一个输出。然而,不同于卷积层中的输入与卷积核之间的互相关计算,汇聚层不包含参数。相反,池运算是确定性的,我们通常计算汇聚窗口中所有元素的最大值或平均值。这些操作分别称为最大汇聚层(maximum pooling)和平均汇聚层(average pooling)。

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

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]])
print(pool2d(X, (2, 2))) # tensor([[4., 5.], [7., 8.]])

与卷积层一样,汇聚层也可以改变输出形状。和以前一样,我们可以通过填充和步幅以获得所需的输出形状。下面,我们用深度学习框架中内置的二维最大汇聚层,来演示汇聚层中填充和步幅的使用。

默认情况下,深度学习框架中的步幅与汇聚窗口的大小相同。因此,如果我们使用形状为 (3, 3) 的汇聚窗口,那么默认情况下,我们得到的步幅形状为 (3, 3)

1
2
3
4
5
6
7
X = torch.arange(16, dtype=torch.float32).reshape((1, 1, 4, 4))

pool2d = nn.MaxPool2d(kernel_size=3)
print(pool2d(X)) # tensor([[[[10.]]]])

pool2d = nn.MaxPool2d(3, padding=1, stride=2)
print(pool2d(X)) # tensor([[[[ 5., 7.], [13., 15.]]]])

在处理多通道输入数据时,汇聚层在每个输入通道上单独运算,而不是像卷积层一样在通道上对输入进行汇总。这意味着汇聚层的输出通道数与输入通道数相同。下面,我们将在通道维度上连结张量 XX + 1,以构建具有2个通道的输入:

1
2
3
4
5
6
X = torch.cat((X, X + 1), dim=1)
print(pool2d(X))
# tensor([[[[ 5., 7.],
# [13., 15.]],
# [[ 6., 8.],
# [14., 16.]]]])

6. LeNet

本节将介绍 LeNet,它是最早发布的卷积神经网络之一。总体来看,LeNet(LeNet-5)由两个部分组成:

  • 卷积编码器:由两个卷积层组成。
  • 全连接层密集块:由三个全连接层组成。

每个卷积块中的基本单元是一个卷积层、一个 Sigmoid 激活函数和平均汇聚层。请注意,虽然 ReLU 和最大汇聚层更有效,但它们在20世纪90年代还没有出现。每个卷积层使用5×5卷积核和一个 Sigmoid 激活函数。这些层将输入映射到多个二维特征输出,通常同时增加通道的数量。第一卷积层有6个输出通道,而第二个卷积层有16个输出通道。每个2×2池化操作(步幅2)通过空间下采样将维数减少4倍。卷积的输出形状由批量大小、通道数、高度、宽度决定。

虽然卷积神经网络的参数较少,但与深度的多层感知机相比,它们的计算成本仍然很高,因为每个参数都参与更多的乘法。通过使用 GPU,可以用它加快训练。

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
28
import torch
from torch import nn
import torchvision
from torch.utils import data
from torchvision import transforms
from util.functions import train_classifier

net = nn.Sequential(
nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5, padding=2), nn.Sigmoid(), # (6, 28, 28)
nn.AvgPool2d(kernel_size=2, stride=2), # (6, 14, 14)
nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5), nn.Sigmoid(), # (16, 10, 10)
nn.AvgPool2d(kernel_size=2, stride=2), # (16, 5, 5)
nn.Flatten(), # (16 * 5 * 5,)
nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(), # (120,)
nn.Linear(120, 84), nn.Sigmoid(), # (84,)
nn.Linear(84, 10)) # (10,)

batch_size = 64
trans = transforms.ToTensor()
mnist_train = torchvision.datasets.MNIST(root="../data", train=True, transform=trans, download=True)
mnist_test = torchvision.datasets.MNIST(root="../data", train=False, transform=trans, download=True)
train_iter = data.DataLoader(mnist_train, batch_size, shuffle=True, num_workers=0)
test_iter = data.DataLoader(mnist_test, batch_size, shuffle=True, num_workers=0)

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
lr, num_epochs = 0.2, 30

train_classifier(net, train_iter, test_iter, num_epochs, lr, device, '../logs/LeNet_train_log')

下一章:现代卷积神经网络