1. 从全连接层到卷积

1.1 不变性

假设一个场景:需要制作一个检测器,在一张图片中检测一种特定物体。需要满足两个性质:

  1. 平移不变性:无论该物品在图片的哪个位置,都可以检测到;
  2. 局部性:检测器只需要关注图像中的局部区域,不过度关注其它无关区域。

1.2 多层感知机的限制

  • 对于图片(例如12M)像素的一维展开,包含36M的元素。若使用包含100个神经元的单隐藏层,模型就要3.6B元素,训练难度过大。
  • 卷积(convolution)计算:输入X为二维矩阵,输出的隐藏表示H仍为矩阵。参数包括权重矩阵V与偏置UH中的每一个元素都由权重矩阵V与输入X中相应区域元素的’点积’,再加上偏置所得到。(下图演示忽略了偏置计算)
    • 平移不变性:权重矩阵(又称为卷积核/滤波器)在每次计算中保持不变,以提取相同的模式。
    • 局部性:卷积核(kernel)的形状通常较小(|a|>△;|b|>△),即针对输入的局部区域进行特征提取。

image-20240731113235058

卷積神經網絡Convolutional Neural Network (CNN) | by 李謦伊| 謦伊的閱讀筆記| Medium

  • 如上计算中,模型参数的数量比全连接层参数少很多,降低了训练难度。

2. 图像卷积

2.1 互相关运算

  • CNN中的卷积与数学中的卷积概念不完全相同,本质上为互相关运算(cross-correlation)
  • 如下图,输入是3×3的二维张量;卷积核的高和宽都是2:
    • 卷积窗口从输入的左上角开始,从左到右,从上到下滑动;
    • 每次按对应位置的元素相乘,再求和得到单个标量结果;
    • 按上述规则滑动的计算结果,输出形状将小于输入形状。

image-20240731123437491

输出结果有时被称为特征映射。对于输出结果中的任一元素,其感受野指上一层中所有参与计算的输入元素。

  • 手动代码实现互相关运算
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import torch
from torch import nn
from d2l import torch as d2l

def corr2d(X, K):  #@save
    """计算二维互相关运算"""
    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]])
corr2d(X, K)

2.2 卷积层

  • 如前所述,卷积层中的两个被训练的参数是卷积核权重和标量偏置,在训练时,会被随机初始化;
  • 基于上述互相关运算函数,手动定义一个二维卷积层
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

2.3 图像中目标的边缘检测

  • 卷积层的简单应用:根据像素变化的位置,检测图像中不同颜色的边缘。
1
2
3
4
5
6
7
8
9
# 输入数据:下图左
X = torch.ones((6, 8))
X[:, 2:6] = 0

# 卷积核
K = torch.tensor([[1.0, -1.0]])

# 卷积计算:下图右
Y = corr2d(X, K)

image-20240731130517874

2.4 学习卷积核

  • 可通过torch的nn.Conv2d类快速定义一个卷积层
    • 该类的前两个参数分别用于设置输入与输出通道数;
    • kernel_size参数用于指定卷积核的高和宽。
  • 如下代码,将根据上述的输入X与输出Y,学习卷积核
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# 构造一个二维卷积层,它具有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  # 学习率

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}')
        
conv2d.weight.data.reshape((1, 2))
# tensor([[ 0.9938, -0.9841]])

3. 填充和步幅

3.1 填充

  • 可在输入图像的边界填充元素(通常为0),使得经卷积计算后的输出形状变大;

image-20240731132928512

  • 当卷积核的高宽为奇数(推荐)时,若填充的行数(一半在顶,一半在底)与列数(一半在左,一半在右)与卷积核的高宽少1,则输入与输出的形状相同。
  • 可通过nn.Conv2d类的padding参数设置填充。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import torch
from torch import nn

# 为了方便起见,我们定义了一个计算卷积层的函数。
# 此函数初始化卷积层权重,并对输入和输出提高和缩减相应的维数
def comp_conv2d(conv2d, X):
    # 这里的(1,1)表示批量大小和通道数都是1
    X = X.reshape((1, 1) + X.shape)  #(1, 1) + X.shape 长度为4的tuple
    Y = conv2d(X)
    # 省略前两个维度:批量大小和通道
    return Y.reshape(Y.shape[2:])

# 请注意,这里每边都填充了1行或1列,因此总共添加了2行或2列
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1)

X = torch.rand(size=(8, 8))
comp_conv2d(conv2d, X).shape
# torch.Size([8, 8])

3.2 步幅

  • 在上述示例中,卷积窗口滑动的长度(步幅)为1;
  • 为了高效计算或是缩减采样次数,可增大滑动的幅度;
  • 当卷积核高宽为奇数,且填充比卷积核形状少1时,则输出的形状大小为输入形状除以步幅
  • 可通过nn.Conv2d类的stride参数设置
1
2
3
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2)
comp_conv2d(conv2d, X).shape
# torch.Size([4, 4])

4. 多输入多输出通道

4.1 多输入通道

  • 图片通常包含三个通道(红绿蓝三原色),即输入数据有三个维度。其中,前两个轴与像素的空间位置有关,而第三个轴可以看作每个像素的多维表示。
  • 此时,每个通道都有一个卷积核,最后的输出是所有通道卷积结果的和。

image-20240731142412991

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

# X表示多通道输入
# K表示多通道卷积核
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]]])

corr2d_multi_in(X, K)

4.2 多输出通道

  • 在上述的多通道输入中,每个通道只有一个卷积核,最后得到一个通道的输出;
  • 可以在每个通道中建立多个(o)卷积核。此时,对于i个输入通道,就共有o × i个卷积核;
  • 然后按照4.1计算方法,可以得到o个输出通道的结果;
  • 如下,i=1,o=4
image-20240804113644154
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# X表示多通道输入
# K表示多通道卷积核【4个维度】
def corr2d_multi_in_out(X, K):
    # 迭代“K”的第0个维度,每次都对输入“X”执行互相关运算。
    # 最后将所有结果都叠加在一起(沿新的第一维度(dim=0)堆叠起来)
    return torch.stack([corr2d_multi_in(X, k) for k in K], 0) #k三个维度,参考4.1

# 之前的K是三维的多个(等于对应通道数)卷积核
K = torch.stack((K, K + 1, K + 2), 0)
K.shape
# torch.Size([3, 2, 2, 2])
# i=2; o=3

corr2d_multi_in_out(X, K).shape
# torch.Size([3, 2, 2])

可以将每个输出通道认为是提取的一种特征模式,供下一神经网络层组合学习。(https://poloclub.github.io/cnn-explainer/)

image-20240804114145239

4.3 1×1卷积层

  • 卷积的本质是有效提取相邻像素间的相关特征,而1×1卷积显然没有此作用;
  • 1×1卷积受欢迎的原因是作为融合通道使用,直观上是改变了输入通道的数量。

image-20240731150756245

  • 代码实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#从全连接层的角度,实现1×1卷积
def corr2d_multi_in_out_1x1(X, K):
    c_i, h, w = X.shape
    c_o = K.shape[0]
    X = X.reshape((c_i, h * w)) #每个通道拉平为一个向量,此时X为一个矩阵
    K = K.reshape((c_o, c_i))
    # 全连接层中的矩阵乘法
    Y = torch.matmul(K, X)  #(c_o, h * w)
    return Y.reshape((c_o, h, w))

X = torch.normal(0, 1, (3, 3, 3)) #3个输入通道
K = torch.normal(0, 1, (2, 3, 1, 1)) #2×3个卷积核

Y1 = corr2d_multi_in_out_1x1(X, K)

#按常规卷积层角度的实现
Y2 = corr2d_multi_in_out(X, K)
assert float(torch.abs(Y1 - Y2).sum()) < 1e-6

5. 汇聚层

5.1 最大汇聚层和平均汇聚层

  • 汇聚层(pooling)又称池化层,降低卷积层对位置的敏感性。
  • 它与卷积操作类似,由一个固定窗口组成,在输入数据中滑动,并计算得到输出。
  • 不同之处在于,汇聚层没有学习参数,为确定性计算;主要分为如下两种
    • 最大汇聚层:汇聚窗口覆盖区域内的最大值;
    • 平均汇聚层:汇聚窗口覆盖区域内的平均值;

image-20240731231553212

  • 手动代码实现(类似上述的2.1处)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import torch
from torch import nn
from d2l import torch as d2l

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]])
pool2d(X, (2, 2))
pool2d(X, (2, 2), 'avg')

5.2 填充和步幅

  • 默认情况下,深度学习框架中的步幅与汇聚窗口的大小相同(即每次滑动区域没有重叠);
  • 可设置nn.MaxPool2d/nn.AvgPool2d类的参数:
    • 第一个参数为窗口的大小
    • padding与stride参数分别制定填充与步幅
    • 默认stride与窗口的大小相同
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
X = torch.arange(16, dtype=torch.float32).reshape((1, 1, 4, 4)) #转换为4维的输入
X

pool2d = nn.MaxPool2d(3)
pool2d(X) #只返回一个值

# 其它灵活设置
pool2d = nn.MaxPool2d(3, padding=1, stride=2)
pool2d(X) 

pool2d = nn.MaxPool2d((2, 3), stride=(2, 3), padding=(0, 1))
pool2d(X)

5.3 多个通道

  • 对于多个通道,汇聚层将在每个通道单独运算,分别作为输出;即输出通道与输入通道数相同。
1
2
3
4
5
6
# 将原有X的第二个维度上,增加一组数据;即两个输入通道
X = torch.cat((X, X + 1), 1)
X

pool2d = nn.MaxPool2d(3, padding=1, stride=2)
pool2d(X) #汇聚后输出通道的数量仍然是2。

6 卷积神经网络(LeNet)

6.1 LeNet

  • LeNet是最早发布的卷积神经网络之一,用于是被图像的手写数字,由AT&T贝尔实验室的研究员Yann LeCun在1989年提出;
  • 主要分为卷积编码器(两个卷积层)与全连接层密集块(三个全连接层)两部分;

image-20240801211808356

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

net = nn.Sequential(
    nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Conv2d(6, 16, kernel_size=5), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Flatten(),
    nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(),
    nn.Linear(120, 84), nn.Sigmoid(),
    nn.Linear(84, 10))
  • 模型组成特点:
    • 每个卷积层与全连接层(输出层除外)后均连接一个激活函数;
    • 第一个卷积层有6个输出通道,第二个卷积层有16个输出通道;
    • 第一个卷积层通过填充使得输出形状不变,第二个卷积层未使用;
    • 汇聚层的步幅设置使得输出的高宽形状减半;
    • 在接入全连接层之前需要将图像输出展开为一维的形式。

为了构造高性能的卷积神经网络,通常对卷积层进行排列,逐渐降低其表示的空间分辨率,同时增加通道数

1
2
3
4
X = torch.rand(size=(1, 1, 28, 28), dtype=torch.float32)
for layer in net:
    X = layer(X)
    print(layer.__class__.__name__,'output shape: \t',X.shape)

image-20240801221129822

6.2 模型训练

  • 数据迭代器
1
2
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)
  • 定义GPU版本的准确率评价指标
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def evaluate_accuracy_gpu(net, data_iter, device=None): #@save
    """使用GPU计算模型在数据集上的精度"""
    if isinstance(net, nn.Module):
        net.eval()  # 设置为评估模式
        if not device:
            device = next(iter(net.parameters())).device #与模型的device保持一致
    # 正确预测的数量,总预测的数量
    metric = d2l.Accumulator(2)
    with torch.no_grad():
        for X, y in data_iter:
            if isinstance(X, list): #判断是否为list
                # BERT微调所需的(之后将介绍)
                X = [x.to(device) for x in X]
            else:
                X = X.to(device)
            y = y.to(device)
            metric.add(d2l.accuracy(net(X), y), y.numel())
    return metric[0] / metric[1]
  • 定义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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#@save
def train_ch6(net, train_iter, test_iter, num_epochs, lr, device):
    """用GPU训练模型(在第六章定义)"""
    def init_weights(m):  #模型参数初始化
        if type(m) == nn.Linear or type(m) == nn.Conv2d:
            nn.init.xavier_uniform_(m.weight)
    net.apply(init_weights)
    print('training on', device)
    net.to(device)
    optimizer = torch.optim.SGD(net.parameters(), lr=lr)
    loss = nn.CrossEntropyLoss()
    animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs],
                            legend=['train loss', 'train acc', 'test acc'])
    timer, num_batches = d2l.Timer(), len(train_iter)
    for epoch in range(num_epochs):
        # 训练损失之和,训练准确率之和,样本数
        metric = d2l.Accumulator(3)
        # 训练模型
        net.train()
        for i, (X, y) in enumerate(train_iter):
            timer.start()
            optimizer.zero_grad()
            X, y = X.to(device), y.to(device)
            y_hat = net(X)
            l = loss(y_hat, y)
            l.backward()
            optimizer.step()
            with torch.no_grad():
                metric.add(l * X.shape[0], d2l.accuracy(y_hat, y), X.shape[0])
            # 下述代码用于可视化
            timer.stop()
            train_l = metric[0] / metric[2]
            train_acc = metric[1] / metric[2]
            if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1:
                animator.add(epoch + (i + 1) / num_batches,
                             (train_l, train_acc, None))
        #每次epoch后的测试集评价
        test_acc = evaluate_accuracy_gpu(net, test_iter)
        animator.add(epoch + 1, (None, None, test_acc))
    print(f'loss {train_l:.3f}, train acc {train_acc:.3f}, '
          f'test acc {test_acc:.3f}')
    print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec '
          f'on {str(device)}')
  • 开始训练
1
2
lr, num_epochs = 0.9, 10
train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())