LeNet


背景

LeNet 是最早发布的卷积神经网络之一,因其在计算机视觉任务中的高效性能而受到广泛关注。 这个模型是由AT&T贝尔实验室的研究员 Yann LeCun 在 1989 年提出的,目的是识别图像中的手写数字。 当时 Yann LeCun 发表了第一篇通过反向传播成功训练卷积神经网络的研究,这项工作代表了十多年来神经网络研究开发的成果。

LeNet 首次引入了卷积层和池化层这两个核心组件,构建了现代 CNN 的基本结构范式。该网络以灰度图像作为输入,能够自动提取特征并输出图像中所包含的手写数字类别,在手写识别任务上取得了当时领先的准确率。

MNIST(Modified National Institute of Standard and Technology)数据集是由 Yann LeCun 及其同事于 1994 年创建一个大型手写数字数据库,包含 090\sim 9 十个数字。

MNIST 数据集的原始数据来源于美国国家标准和技术研究院,它们分别由 NIST 的员工和美国高中生手写的数字组成。原始的这两个数据集由128×128128\times 128 像素的黑白图像组成。LeCun 等人将其进行归一化和尺寸调整后得到的是 28×2828\times 28 的灰度图像。

点此前往阅读原文。

架构

总体来看,LeNet 或 LeNet-5 模型主要有两个部分

  1. 卷积编码层:由两个卷积层和两个平均池化层组成
  2. 全连接层密集块:由三个全连接层组成

下图和下表展示了整个模型的网络架构。

类型 输入尺寸 输出尺寸 核尺寸
INPUT 输入层 - 1×28×281\times28\times 28 -
C1 卷积层 1×28×281\times 28\times 28 6×28×286\times 28\times 28 5×55\times 5
S2 平均池化层 6×28×286\times 28\times 28 6×14×146\times14\times 14 2×22\times 2
C3 卷积层 6×14×146\times 14\times 14 16×10×1016\times 10\times 10 5×55\times 5
S4 平均池化层 16×10×1016\times 10\times 10 16×5×516\times 5\times 5 2×22\times 2
C5 全连接卷积层 16×5×516\times 5\times 5 120×1×1120\times 1\times 1 5×55\times 5
F6 sigmoid全连接层 120120 8484 -
OUTPUT softmax输出层 8484 1010 -

代码实现

为了实现上述层级结构,我们需要实例化一个 Sequential 块并将需要的层连接在一起。

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

# 激活函数选择 Sigmoid 函数,输入为单通道 28*28 的图片
net = nn.Sequential(
# C1 层,输入通道为 1,输出通道为 6,卷积核大小为 5,填充为 4
nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Sigmoid(),
# S2 层,平均池化,核大小为 2,步长为 2
nn.AvgPool2d(kernel_size=2, stride=2),
# C3 层,输入通道为 6,输出通道为 16,卷积核大小为 5
nn.Conv2d(6, 16, kernel_size=5), nn.Sigmoid(),
# S4 层,平均池化,核大小为 2,步长为 2
nn.AvgPool2d(kernel_size=2, stride=2),
# 展平层
nn.Flatten(),
# C5 层。输入为 16 * 5 * 5,输出为 120
nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(),
# F6 层,输入为 120,输出为 84
nn.Linear(120, 84), nn.Sigmoid(),
# 输出层,输入为 84,输出为 10
nn.Linear(84, 10))

使用 Fashion-MNIST 数据集在 GPU 上训练。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)

# 使用GPU计算模型在数据集上的精度
def evaluate_accuracy_gpu(net, data_iter, device=None): #@save
if isinstance(net, nn.Module):
net.eval() # 设置为评估模式
if not device:
device = next(iter(net.parameters())).device
# 正确预测的数量,总预测的数量
metric = d2l.Accumulator(2)
with torch.no_grad():
for X, y in data_iter:
if isinstance(X, 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]

为方便起,我们采用 Xavier 初始化,交叉熵损失函数和小批量随机梯度下降方法。

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
44
45
# 使用 GPU 训练模型
def train(net, train_iter, test_iter, num_epochs, lr, device):
# 使用 Xavier 均匀分布初始化模型参数
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))
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)}')

# 这里的学习率不应该过小
lr, num_epochs = 0.9, 10
train(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

得到的训练结果如下,准确率 acc = 0.818

调参

深度学习领域的一大技能便是调参,因为深度学习模型的性能很大程度上依赖于参数的调整。许多的新模型也大多是基于前人的研究,对某个方面进行了改动并得到了更好的表现结果。接下来介绍几个在 LeNet 基础上调参的方法。

  1. 尝试不同的学习率 lr 和训练轮数 num_epochs
  2. 尝试不同的初始化方法
  3. 尝试不同的批量大小 batch_size
  4. 尝试不同的激活函数,比如把 nn.Sigmoid() 改为 nn.ReLU(0)
  5. 尝试不同的卷积层参数,这一步需要考虑全局影响,避免代码报错
  6. 尝试不同的池化层类别,比如把 nn.AvgPool2d 改为 nn.MaxPool2d
  7. 尝试优化器与正则化

这里给出一个简单调参后的代码,修改了激活函数、初始化方法和学习率。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import torch
from torch import nn
from d2l import torch as d2l

# 选择 ReLU 激活函数
net = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.ReLU(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), nn.ReLU(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Flatten(),
nn.Linear(16 * 5 * 5, 120), nn.ReLU(),
nn.Linear(120, 84), nn.ReLU(),
nn.Linear(84, 10))

batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size=batch_size)

def evaluate_accuracy_gpu(net, data_iter, device=None):
if isinstance(net, nn.Module):
net.eval()
if not device:
device = next(iter(net.parameters())).device
metric = d2l.Accumulator(2)
with torch.no_grad():
for X, y in data_iter:
if isinstance(X, list):
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]

def train(net, train_iter, test_iter, num_epochs, lr, device):
# 使用 He 初始化模型参数
def init_weights(m):
if type(m) == nn.Linear or type(m) == nn.Conv2d:
nn.init.kaiming_uniform_(m.weight, a=0, mode='fan_in', nonlinearity='relu')
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))
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)}')

# 修改学习率
lr, num_epochs = 0.5, 10
train(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

训练结果明显变佳 loss 0.259, train acc 0.902, test acc 0.875

调参更多情况下是一件考验耐心与运气的事情,可以自行上手尝试(#^.^#)

AlexNet


背景

20世纪末,限于数据和算力的发展瓶颈,深度学习模型的发展陷入了低谷。而就在21世纪初期,有两个关键事件发生,为深度学习提供了新的发展机遇。

  • 2006年:NVIDIA 推出了 CUDA 框架,让 GPU 成为并行计算引擎,训练速度提升数十倍
  • 2009年:李飞飞团队构建了包含 1400万张标注图像、1000个类别的 ImageNet 数据集

AlexNet 是由 Alex Krizhevsky, Ilya Sutskever, Geoffrey Hinton 在 2012 年 ImageNet 图像分类竞赛中提出的一种经典的卷积神经网络。当时,AlexNet 在 ImageNet 大规模视觉识别竞赛中取得了优异的成绩,其使用了两个显存为 3GB 的 NVIDIA GTX580 GPU 实现了快速卷积运算,把深度学习模型在比赛中的正确率提升到一个前所未有的高度。因此,它的出现对深度学习发展具有里程碑式的意义。

点此前往阅读原文。

架构

相比于传统的 LeNet 模型,AlexNet 的主要改进如下

  • 采用了超大规模的卷积神经网络模型
  • 采用 RGB 多输入通道实现彩色图片的训练
  • 加入了 Dropout
  • 采用了 ReLU 激活函数
  • 采用了最大池化层

有句话说得好,叫做量变引起质变

AlexNet 采用了十分大胆的卷积层参数,其架构图如下所示。

AlexNet 由八层组成:五个卷积层、两个全连接隐藏层和一个全连接输出层。需要注意的是,AlexNet 将 sigmoid 激活函数改为更简单的 ReLU 激活函数,缓解了梯度消失和计算量大的问题。

代码实现

由于 ImageNet 数据集非常大,在本地训练是非常耗时的。为了简便起见,我们采用 Fashion-MNIST 数据集和填充的形式对齐输入尺寸。更多关于 ImageNet 的信息可前往官网

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
import torch
from torch import nn
from d2l import torch as d2l

net = nn.Sequential(
# 这里使用一个 11*11 的更大窗口来捕捉对象,stride = 4
nn.Conv2d(1, 96, kernel_size=11, stride=4, padding=1), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2),
# 减小卷积窗口,padding = 2
nn.Conv2d(96, 256, kernel_size=5, padding=2), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2),
# 使用三个连续的卷积层和较小的卷积窗口
# 除了最后的卷积层,输出通道的数量进一步增加
# 在前两个卷积层之后,汇聚层不用于减少输入的高度和宽度
nn.Conv2d(256, 384, kernel_size=3, padding=1), nn.ReLU(),
nn.Conv2d(384, 384, kernel_size=3, padding=1), nn.ReLU(),
nn.Conv2d(384, 256, kernel_size=3, padding=1), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2),
nn.Flatten(),
#使用 dropout 层来减轻过拟合
nn.Linear(6400, 4096), nn.ReLU(),
nn.Dropout(p=0.5),
nn.Linear(4096, 4096), nn.ReLU(),
nn.Dropout(p=0.5),
# Fashion-MNIST 类别数为10,而 ImageNet 类别数为 1000
nn.Linear(4096, 10))

batch_size = 128
# 使用 resize = 224 填充尺寸
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=224)

lr, num_epochs = 0.01, 10
# 调用 d2l.train_ch6 函数训练
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

使用 FashionMNIST 可能无法体现 AlexNet 的全部实力,这里给出本人的训练结果。

1
2
loss 0.332, train acc 0.879, test acc 0.877
1447.8 examples/sec on cuda:0

VGG 网络


块架构

虽然 AlexNet 证明了深层神经网络卓有成效,但它没有提供一个通用的模板来指导后续的研究人员设计新的网络。经典卷积神经网络的基本组成部分是下面的这个序列

  1. 带填充以保持分辨率的卷积层
  2. 非线性激活函数
  3. 池化层

近十几年来,研究人员开始从单个神经元的角度思考这个问题,也就是寻找卷积神经网络中的块架构(Block)。VGG架构 由牛津大学研究所VGG提出,斩获该年 ImageNet 竞赛中定位任务的第一名和分类任务的第二名。

在最初的论文中,研究团队给出了如下形式的块架构

  1. 若干个卷积层,核尺寸为 3×33\times 3,填充为 11
  2. 一个最大池化层,核尺寸为 2×22\times 2,步幅为 22

这样,一个 VGG 块的作用效果可以看做输出分辨率减半,重复使用多个 VGG 块的网络架构也被称作 VGG 网络模型。VGG 团队目前已经提出多种核参数的块架构,每一种块架构的训练效果都有所差别。

LeNet, AlexNet 等模型架构都可以视作使用了 VGG 块的网络架构,因此 VGG 网络是一个更具普遍性的卷积神经网络模版,以供后人在此基础上进行微调和测试。

代码实现

原始 VGG 网络有5个卷积块,其中前两个块各有一个卷积层,后三个块各包含两个卷积层。 第一个模块有 64 个输出通道,每个后续模块将输出通道数量翻倍,直到该数字达到512。由于该网络使用 8 个卷积层和 3 个全连接层,因此它通常被称为 VGG-11。这个模型的参数量比 AlexNet 还要大,因此这里仅仅给出代码和李沐大神提供的训练结果。

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
44
45
46
47
48
49
import torch
from torch import nn
from d2l import torch as d2l

# 定义 VGG 块
def vgg_block(num_convs, in_channels, out_channels):
layers = []
for _ in range(num_convs):
layers.append(nn.Conv2d(in_channels, out_channels,
kernel_size=3, padding=1))
layers.append(nn.ReLU())
in_channels = out_channels
layers.append(nn.MaxPool2d(kernel_size=2,stride=2))
return nn.Sequential(*layers)

# 定义 VGG-11 模型
conv_arch = ((1, 64), (1, 128), (2, 256), (2, 512), (2, 512))

def vgg(conv_arch):
conv_blks = []
in_channels = 1
# 卷积层部分
for (num_convs, out_channels) in conv_arch:
conv_blks.append(vgg_block(num_convs, in_channels, out_channels))
in_channels = out_channels

return nn.Sequential(
*conv_blks, nn.Flatten(),
# 全连接层部分
nn.Linear(out_channels * 7 * 7, 4096), nn.ReLU(), nn.Dropout(0.5),
nn.Linear(4096, 4096), nn.ReLU(), nn.Dropout(0.5),
nn.Linear(4096, 10))

net = vgg(conv_arch)

# 构建一个通道数较小的网络模型
ratio = 4
small_conv_arch = [(pair[0], pair[1] // ratio) for pair in conv_arch]
net = vgg(small_conv_arch)

# 为方便训练,使用一个较大的学习率
lr, num_epochs, batch_size = 0.05, 10, 128
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=224)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())
'''
课程提供的参考训练结果如下
loss 0.178, train acc 0.935, test acc 0.920
2463.7 examples/sec on cuda:0
'''

NiN 网络


NiN 块

LeNet、AlexNet和VGG都有一个共同的设计模式:通过一系列的卷积层与池化层来提取空间结构特征;然后通过全连接层对特征的表征进行处理。NiN块(Network in Network)给出了一个截然不同的处理方法:在每个像素的通道上分别使用多层感知机。

NiN 块由新加坡国立大学2014年提出。NiN 块以一个普通卷积层开始,后面是两个 1×11\times 1 的卷积层,这样就组成了一个基本的块架构。其优势主要可以分为下面两点

  1. 降低了计算复杂度
  2. 增加了网络卷积层的非线性能力

NiN 网络的架构采用了和 AlexNet 类似的卷积层参数,但他们之间的一个显著区别是 NiN 完全取消了全连接层。相反,NiN 使用一个 NiN 块,其输出通道数等于标签类别的数量。最后放一个全局平均池化层(GAP),生成一个对数几率。

点此前往阅读原文。

代码实现

这里只给出 NiN 架构的 net,训练代码见前文

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
import torch
from torch import nn
from d2l import torch as d2l

# 定义 NiN 块
def nin_block(in_channels, out_channels, kernel_size, strides, padding):
return nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size, strides, padding),
nn.ReLU(),
nn.Conv2d(out_channels, out_channels, kernel_size=1), nn.ReLU(),
nn.Conv2d(out_channels, out_channels, kernel_size=1), nn.ReLU())

net = nn.Sequential(
nin_block(1, 96, kernel_size=11, strides=4, padding=0),
nn.MaxPool2d(3, stride=2),
nin_block(96, 256, kernel_size=5, strides=1, padding=2),
nn.MaxPool2d(3, stride=2),
nin_block(256, 384, kernel_size=3, strides=1, padding=1),
nn.MaxPool2d(3, stride=2),
nn.Dropout(0.5),
# 标签类别数是10
nin_block(384, 10, kernel_size=3, strides=1, padding=1),
nn.AdaptiveAvgPool2d((1, 1)),
# 将四维的输出转成二维的输出,其形状为(批量大小,10)
nn.Flatten())

NiN 架构对后续深度学习网络模型有着一定的影响。

GoogLeNet


Inception 块

在2014年的 ImageNet 图像识别挑战赛中,Christian Szegedy 等人提出的 GoogLeNet 的网络架构大放异彩。在GoogLeNet中,基本的卷积块被称为 Inception 块,其借鉴了 NiN 块的形式。点此前往阅读原文。

有趣的是,这个名字来源于《盗梦空间》(Inception)

Inception 块由四条并行路径组成。 前三条路径使用核尺寸为 1×1,3×31\times1,3\times 35×55\times 5 卷积层,从不同空间大小中提取信息。中间的两条路径在输入上执行 1×11\times 1 卷积,以减少通道数,从而降低模型的复杂性。第四条路径使用 3×33\times 3 最大池化层,然后使用 1×11\times 1 卷积层来改变通道数。

这四条路径都使用合适的填充来使输入与输出的高和宽一致,最后我们将每条线路的输出在通道维度上连结,并构成 Inception 块的输出。在 Inception 块中,通常调整的超参数是每层输出通道数。

架构

GoogLeNet 一共使用9个 Inception 块和全局平均池化层的堆叠来生成其估计值。Inception 块之间的最大池化层可降低维度。 第一个模块类似于 AlexNet 和 LeNet,Inception 块的组合从 VGG 继承,全局平均池化层避免了在最后使用全连接层。

代码实现

定义一个 Inception 块,实现并行卷积

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
from torch.nn import functional as F
from d2l import torch as d2l

class Inception(nn.Module):
# c1--c4是每条路径的输出通道数
def __init__(self, in_channels, c1, c2, c3, c4, **kwargs):
super(Inception, self).__init__(**kwargs)
# 线路1,单1x1卷积层
self.p1_1 = nn.Conv2d(in_channels, c1, kernel_size=1)
# 线路2,1x1卷积层后接3x3卷积层
self.p2_1 = nn.Conv2d(in_channels, c2[0], kernel_size=1)
self.p2_2 = nn.Conv2d(c2[0], c2[1], kernel_size=3, padding=1)
# 线路3,1x1卷积层后接5x5卷积层
self.p3_1 = nn.Conv2d(in_channels, c3[0], kernel_size=1)
self.p3_2 = nn.Conv2d(c3[0], c3[1], kernel_size=5, padding=2)
# 线路4,3x3最大汇聚层后接1x1卷积层
self.p4_1 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)
self.p4_2 = nn.Conv2d(in_channels, c4, kernel_size=1)

def forward(self, x):
p1 = F.relu(self.p1_1(x))
p2 = F.relu(self.p2_2(F.relu(self.p2_1(x))))
p3 = F.relu(self.p3_2(F.relu(self.p3_1(x))))
p4 = F.relu(self.p4_2(self.p4_1(x)))
# 在通道维度上连结输出
return torch.cat((p1, p2, p3, p4), dim=1)

为方便起见,我们结合架构图将 GoogLeNet 拆成五个部分

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
b1 = nn.Sequential(nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

b2 = nn.Sequential(nn.Conv2d(64, 64, kernel_size=1),
nn.ReLU(),
nn.Conv2d(64, 192, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

b3 = nn.Sequential(Inception(192, 64, (96, 128), (16, 32), 32),
Inception(256, 128, (128, 192), (32, 96), 64),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

b4 = nn.Sequential(Inception(480, 192, (96, 208), (16, 48), 64),
Inception(512, 160, (112, 224), (24, 64), 64),
Inception(512, 128, (128, 256), (24, 64), 64),
Inception(512, 112, (144, 288), (32, 64), 64),
Inception(528, 256, (160, 320), (32, 128), 128),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

b5 = nn.Sequential(Inception(832, 256, (160, 320), (32, 128), 128),
Inception(832, 384, (192, 384), (48, 128), 128),
nn.AdaptiveAvgPool2d((1,1)),
nn.Flatten())

net = nn.Sequential(b1, b2, b3, b4, b5, nn.Linear(1024, 10))

上述卷积块的输入、输出通道数和尺寸请自行推导验证。

批量归一化


数据处理

机器学习领域中,一个良好的模型训练过程通常遵循一个重要的假设:独立同分布假设,即假设训练数据和测试数据是满足相同分布的。这是通过训练数据获得的模型能够在测试集获得好的效果的一个基本保障。因此,在把数据喂给机器学习模型之前,一般需要进行数据预处理步骤。

如果每批训练(Epoch)数据的分布各不相同,那么网络需要在每次迭代中去学习适应不同的分布,这样将会大大降低网络的训练速度。这对于深度网络的训练是一个非常复杂的过程,因为只要网络的前面几层发生微小的改变,那么这些微小的改变在后面的层就会被累积放大下去。一旦网络某一层的输入数据的分布发生改变,那么这一层网络就需要去适应学习这个新的数据分布,所以如果训练过程中,训练数据的分布一直在发生变化,那么将会影响网络的训练速度。

为解决这一问题,IoffeSzegedy论文《Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift》中给出了一种可行的解决方法,即批量归一法(Batch Normalization, BN)。他们主张把输入数据视作均值为0、方差为1的随机变量样本,以加速训练收敛,并缓解过拟合问题。

批量归一化

从形式上来说,用 xB\boldsymbol{x}\in\mathcal{B} 表示一个来自小批量 B\mathcal{B} 的输入,批量规范化 BN\mathrm{BN} 的形式如下

BN(x)=γxμ^Bσ^B+β\mathrm{BN}(\boldsymbol{x})=\boldsymbol{\gamma}\circ \frac{\boldsymbol{x}-\boldsymbol{\hat{\mu}}_{\mathcal{B}}}{\boldsymbol{\hat{\sigma}}_\mathcal{B}}+\boldsymbol{\beta}

在上式中,符号规定为

  • μ^B\boldsymbol{\hat{\mu}}_{\mathcal{B}}:小批量 B\mathcal{B} 的样本均值
  • σ^B\boldsymbol{\hat{\sigma}}_{\mathcal{B}}:小批量 B\mathcal{B} 的样本标准差
  • γ\boldsymbol{\gamma}:拉伸参数,可学习的超参数
  • β\boldsymbol{\beta}:偏移参数,可学习的超参数

形式上来说,样本均值和标准差的计算公式如下

μ^B=1BxBx,σ^B=1BxB(xμ^B)2+ε\boldsymbol{\hat{\mu}}_{\mathcal{B}}=\frac{1}{|\mathcal{B}|}\sum_{\boldsymbol{x}\in\mathcal{B}}\boldsymbol{x},\quad \boldsymbol{\hat{\sigma}}_{\mathcal{B}}=\frac{1}{|\mathcal{B}|}\sum_{\boldsymbol{x}\in\mathcal{B}}(\boldsymbol{x}-\boldsymbol{\hat{\mu}}_{\mathcal{B}})^2+\boldsymbol{\varepsilon}

其中 ε\boldsymbol{\varepsilon} 是一个较小的正向量,避免 BN\mathrm{BN} 操作除以一个趋于0的数。

批量归一化可以视作一个独立的层级结构,主要作用于两种情况

  • 对于全连接层:通常将其置于仿射变换和激活函数之间,按照特征维度进行处理。

h=σ(BN(Wx+b))\boldsymbol{h}=\sigma(\mathrm{BN}(\boldsymbol{Wx}+\boldsymbol{b}))

  • 对于卷积层:置于卷积层之后和非线性激活函数之前,或卷积层输入之前,按照输出通道维度进行处理。

由于全连接层和卷积层的输入维度不同,在进行数据处理时是有所差异的。

  • 设全连接层的输入为二维张量(样本数 mm、特征数 nn),那么 BN\mathrm{BN} 操作会执行 nn
  • 设卷积层的输入为四维张量(样本数 mm,输入高度 hh,输入宽度 ww,输出通道数 coc_{o}),那么 BN\mathrm{BN} 操作会执行 coc_o

变量维护

我们希望批量归一化不仅仅能够在有数据支持的训练阶段发挥作用,也希望其在预测阶段能够加速运算。然而在预测阶段中,我们是没有样本数据支持的,这就需要我们通过变量维护,观察训练阶段中每一批量的样本均值 μ^B\boldsymbol{\hat{\mu}}_{\mathcal{B}}σ^B\boldsymbol{\hat{\sigma}}_{\mathcal{B}} 的变化趋势,最终给出 μ^\boldsymbol{\hat{\mu}}σ^\boldsymbol{\hat{\sigma}} 作为预测阶段的“样本均值”和“样本标准差”。我们可以把这种变量维护看做一个隐式过程,对于第 tt 次训练和小批量 B\mathcal{B},有维护公式

μ^t=(1M)μ^t1+Mμ^B,σ^t=(1M)σ^t1+Mσ^B\hat{\boldsymbol{\mu}}_t=(1-M)\hat{\boldsymbol{\mu}}_{t-1}+M\boldsymbol{\hat{\mu}}_{\mathcal{B}},\quad \hat{\boldsymbol{\sigma}}_t=(1-M)\hat{\boldsymbol{\sigma}}_{t-1}+M\boldsymbol{\hat{\sigma}}_{\mathcal{B}}

其中 M(0<M<1)M(0<M<1) 是一个人为设置的参数,马上会再次提到。当我们完成所有轮次的训练后,得到的 μ^\boldsymbol{\hat{\mu}}σ^\boldsymbol{\hat{\sigma}} 就是我们变量维护的结果。因此,我们可以发现 BN\mathrm{BN} 操作和 Dropout 是很类似的,他们在训练阶段和测试阶段的运行原理都有所差别。

代码实现

通过调用函数 nn.BatchNorm1dnn.BatchNorm2d,可以快速实现全连接层和卷积层的批量归一化。

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

# 使用批量归一化的 LeNet-5 模型
net = nn.Sequential(
# 对于卷积层,使用 nn.BatchNorm2d(num_channels),在卷积层和激活函数之间
nn.Conv2d(1, 6, kernel_size=5), nn.BatchNorm2d(6), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), nn.BatchNorm2d(16), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2), nn.Flatten(),
# 对于全连接层,使用 nn.BatchNorm1d(num_features),在仿射层和激活函数之间
nn.Linear(256, 120), nn.BatchNorm1d(120), nn.Sigmoid(),
nn.Linear(120, 84), nn.BatchNorm1d(84), nn.Sigmoid(),
nn.Linear(84, 10))

值得一提的是,上小节中介绍的参数 MM 被定义为 momentum,默认设置为 0.10.1。上述函数更一般的用法为

1
2
3
4
# 全连接层的批量归一化
nn.BatchNorm1d(num_features, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True, device=None, dtype=None)
# 卷积层的批量归一化
nn.BatchNorm2d(num_features, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True, device=None, dtype=None)

ResNet


网络退化

考虑这样一个抽象问题:对于某个神经网络架构 F\mathcal{F}fFf\in\mathcal{F} 表示在该架构下训练数据,得到的所有参数的一组解。那么,整个深度学习领域的核心问题就是在探讨:对于某个问题 PP,如何寻找一个最优参数对应的 ff^*。当 fFf^*\in\mathcal{F} 时,我们可以通过训练找到这个最优解;但现实情况往往是,我们无法快速地确定一个框架 F\mathcal{F},使得 fFf^*\in\mathcal{F}。为此,我们可以尝试不同的架构 F1,F2,\mathcal{F}_1,\mathcal{F}_2,\cdots,并分别找到其架构内的局部最优解 f(Fi)f^*(\mathcal{F}_i),使得 f(Fi)f^*(\mathcal{F}_i) 不断逼近真实最优解 ff^*

下图给出了两种不同的架构模式,架构 Fi\mathcal{F}_i 覆盖的区域表示其通过训练得到的全体 fFif\in\mathcal{F_i},区域大小表示了架构的复杂度

  • 左图是一个非嵌套模式,我们无法保证架构集合 {Fn}\{\mathcal{F}_n\} 越来越逼近 ff^*
  • 右图是一个嵌套模式,我们可以保证架构集合 {Fn}\{\mathcal{F}_n\} 逼近 ff^* 的效果不会变差

对于嵌套模式,由于 Fn1Fn\mathcal{F}_{n-1}\subset \mathcal{F}_n,因此 f(Fn)f^*(\mathcal{F}_n) 一定不比 f(Fn1)f^*(\mathcal{F}_{n-1}) 差。这表明我们在不断增加模型复杂度时,应该尽可能保持架构的嵌套模式或恒等映射成立,这从根本上保证了新的复杂模型架构不会比旧的简单模型架构差

神经网络的退化(Degradation)是指在深度学习模型中,随着网络层数的增加或模型的复杂度增加,模型的性能并没有如预期的那样持续提高,反而出现了性能下降的现象。很多时候,这是因为模型架构的更新组成了一个非嵌套模式,从而出现了上述提到的问题。为了解决这个问题,我们需要找到一种实现嵌套模式的架构更新方法。

神经网络的退化不等于模型过拟合,因为这表现为训练误差和测试误差都很高。

残差块

残差块(Residual Block)是深度学习中一种关键结构,主要用于解决深层神经网络的训练难题。这个概念首次出现于 Kaiming He 等人在 2015 年的论文《Deep Residual Learning for Image Recognition》中,其提出了一种里程碑式的模型架构:残差网络(ResNet)。

论文指出,传统的神经网络模型主要存在两个问题

  • 模型层数增加时,可能出现梯度爆炸或消失
  • 模型复杂度增加时,可能出现退化问题。

残差块采用了恒等映射思想,借助我们上述提到的嵌套模式构造了一种增加网络复杂度(层数)而不导致模型退化或梯度消失的方法。假设现有网络架构给定了输出 x\boldsymbol{x},现在想要在此输出上堆叠新的非线性层结构 H\mathcal{H},那么输出就变成了 f(x)f(\boldsymbol{x}),其中 fHf\in\mathcal{H}

假设最优解为 f(H)f^{*}(\mathcal{H}),传统观点主张直接拟合 f(H)f^*(\mathcal{H}),而残差块主张优先拟合 g=f(H)xHg=f^{*}(\mathcal{H})-\boldsymbol{x}\in\mathcal{H},并把输出改为 g(x)+xg(\boldsymbol{x})+\boldsymbol{x},其中 gg 称为残差映射

很多情况下,残差映射是更容易被拟合的。而如果我们不需要架构 H\mathcal{H} 发挥作用,此时的最优解退化为恒等映射 f(x)=xf^*(\boldsymbol{x})=\boldsymbol{x},那么就有 g(x)=0g(\boldsymbol{x})=\boldsymbol{0},这只需要令 H\mathcal{H} 内所有权重和偏置参数为0即可。可以看出,整个模型架构的复杂化本质上就是一个嵌套模式,保证了更复杂的模型一定不会比原先差。下图的左右架构图分别展示了传统方法和残差块方法。

可以这样理解残差块的创新点:把训练目标改成残差形式,并在最后补上这个差,本质上没有弱化原架构对最优解的逼近程度。在此基础上,我们通过增加新的非线性架构 H\mathcal{H} 增加了模型的复杂度,解决了模型退化的问题,并且有可能进一步优化模型的当前表现。

残差块还有效解决了梯度消失的问题。对于梯度

oW(l)=oh(L1)h(L1)h(L2)h(l+1)h(l)h(l)W(l),l=1,2,L\frac{\partial \boldsymbol{o}}{\partial \boldsymbol{W}^{(l)}}=\frac{\partial \boldsymbol{o}}{\partial \boldsymbol{h}^{(L-1)}}\frac{\partial \boldsymbol{h}^{(L-1)}}{\partial \boldsymbol{h}^{(L-2)}}\cdots\frac{\partial \boldsymbol{h}^{(l+1)}}{\partial \boldsymbol{h}^{(l)}}\frac{\partial \boldsymbol{h}^{(l)}}{\partial \boldsymbol{W}^{(l)}},\quad l=1,2\cdots,L

当我们引入残差结构时,由于残差块的加法操作,每一项的梯度会变成一个单位矩阵 I\boldsymbol{I} 和残差项梯度的和。即使残差项的梯度很小,多出的 I\boldsymbol{I} 可以保证整个链式法则乘积不会因为多个小数值的乘积而导致梯度消失,打破了梯度消失对网络深度的限制。

ResNet 使用的残差块沿用了 VGG 完整的 3×33\times 3 卷积层设计。残差块里首先有 2 个有相同输出通道数的 3×33\times 3 卷积层,每个卷积层后接一个批量规范化层 BN\mathrm{BN}ReLU\mathrm{ReLU} 激活函数。然后我们通过跨层数据通路,跳过这 2 个卷积运算,将输入直接加在最后的 ReLU\mathrm{ReLU} 激活函数前。

这样的设计要求 2 个卷积层的输出与输入形状一样,从而使它们可以相加。 如果想改变通道数,就需要引入一个额外的 1×11\times 1 卷积层来将输入变换成需要的形状后再做相加运算。我们可以在残差块的两条并行路线使用一个步幅为 2 的卷积层,使得每一个残差块的输出尺寸减半,这种残差块称为减半残差块

代码实现

我们以 ResNet-18 模型为例,其前几层和 GoogLeNet 一样,在输出通道数为64、步幅为2的卷积层后,接步幅为2的的最大池化层。不同之处在于 ResNet 为每个卷积层后增加了批量规范化层。接着,ResNet-18 使用了 4 个由残差块组成的模块,每个模块使用若干个同样输出通道数的残差块。

第一个模块 R1R1 由两个普通残差块组成,由于之前已经使用了步幅为2的最大汇聚层,所以无须减小高和宽。之后的三个模块 R2,R3,R4R2,R3,R4 先后使用一个减半残差块和普通残差块,以此将上一个模块的通道数翻倍,并将输出分辨率的尺度减半。

最后,使用一个全局平均池化层和全连接层并输出。由于每个模块有 4 个卷积层,加上输入部分的 7×77\times7 卷积层和最后一个全连接层,一共有 18 层,因此得名 ResNet-18。更加复杂的模型架构可参考原始论文。

定义一个残差块函数 Residual 如下

  • 对于普通残差块,设置参数 use_1x1conv=False
  • 对于减半残差块,设置参数 use_1x1conv=Truestrides=2
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
from torch.nn import functional as F
from d2l import torch as d2l

class Residual(nn.Module):
def __init__(self, input_channels, num_channels,
use_1x1conv=False, strides=1):
super().__init__()
self.conv1 = nn.Conv2d(input_channels, num_channels,
kernel_size=3, padding=1, stride=strides)
self.conv2 = nn.Conv2d(num_channels, num_channels,
kernel_size=3, padding=1)
if use_1x1conv:
self.conv3 = nn.Conv2d(input_channels, num_channels,
kernel_size=1, stride=strides)
else:
self.conv3 = None
self.bn1 = nn.BatchNorm2d(num_channels)
self.bn2 = nn.BatchNorm2d(num_channels)

def forward(self, X):
Y = F.relu(self.bn1(self.conv1(X)))
Y = self.bn2(self.conv2(Y))
if self.conv3:
X = self.conv3(X)
Y += X
return F.relu(Y)

定义输入部分的卷积层 b1 和四个模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 输入部分
b1 = nn.Sequential(nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
nn.BatchNorm2d(64), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

# 使用残差块的4个模块
def resnet_block(input_channels, num_channels, num_residuals,
first_block=False):
blk = []
for i in range(num_residuals):
if i == 0 and not first_block:
blk.append(Residual(input_channels, num_channels,
use_1x1conv=True, strides=2))
else:
blk.append(Residual(num_channels, num_channels))
return blk

# 第一个模块不使用减半残差块,设置 first_block=True
b2 = nn.Sequential(*resnet_block(64, 64, 2, first_block=True))
b3 = nn.Sequential(*resnet_block(64, 128, 2))
b4 = nn.Sequential(*resnet_block(128, 256, 2))
b5 = nn.Sequential(*resnet_block(256, 512, 2))

接着加入全局平均池化层和全连接层输出层,便可以进行训练。

1
2
3
4
5
6
7
net = nn.Sequential(b1, b2, b3, b4, b5,
nn.AdaptiveAvgPool2d((1,1)),
nn.Flatten(), nn.Linear(512, 10))

lr, num_epochs, batch_size = 0.05, 10, 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

训练效果是比较出色的 loss 0.012, train acc 0.997, test acc 0.893

DenseNet


稠密块

在 ResNet 中,残差块将原来的模型分解为线性部分 x\boldsymbol{x} 和非线性部分 g(x)g(\boldsymbol{x}),而当我们将这种分解做得更激进时,便得到了一个稠密块(Dense Block)。2017年 Gao Huang 等人发表的论文《Densely Connected Convolutional Networks》中,构造了基于稠密块的稠密卷积神经网络 DenseNet

顾名思义,在一个稠密块中,层与层之间都是稠密连接的。在前向传播中,优先遍历的层输出作为后续每一个层的一个输入,采用批量规范化、激活和卷积的架构。DenseNet 为了保证稠密块的输入输出通道数相匹配,同时降低模型复杂度,还引入了过渡层(Transition Layer)的结构。

代码实现

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import torch
from torch import nn
from d2l import torch as d2l

def conv_block(input_channels, num_channels):
return nn.Sequential(
nn.BatchNorm2d(input_channels), nn.ReLU(),
nn.Conv2d(input_channels, num_channels, kernel_size=3, padding=1))

# 定义稠密块
class DenseBlock(nn.Module):
def __init__(self, num_convs, input_channels, num_channels):
super(DenseBlock, self).__init__()
layer = []
for i in range(num_convs):
layer.append(conv_block(
num_channels * i + input_channels, num_channels))
self.net = nn.Sequential(*layer)

def forward(self, X):
for blk in self.net:
Y = blk(X)
# 连接通道维度上每个块的输入和输出
X = torch.cat((X, Y), dim=1)
return X

# 定义过渡层
def transition_block(input_channels, num_channels):
return nn.Sequential(
nn.BatchNorm2d(input_channels), nn.ReLU(),
nn.Conv2d(input_channels, num_channels, kernel_size=1),
nn.AvgPool2d(kernel_size=2, stride=2))

# 定义 DenseNet 模型
b1 = nn.Sequential(
nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
nn.BatchNorm2d(64), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

num_channels, growth_rate = 64, 32 # num_channels为当前的通道数
num_convs_in_dense_blocks = [4, 4, 4, 4]
blks = []
for i, num_convs in enumerate(num_convs_in_dense_blocks):
blks.append(DenseBlock(num_convs, num_channels, growth_rate))
# 上一个稠密块的输出通道数
num_channels += num_convs * growth_rate
# 在稠密块之间添加一个转换层,使通道数量减半
if i != len(num_convs_in_dense_blocks) - 1:
blks.append(transition_block(num_channels, num_channels // 2))
num_channels = num_channels // 2

net = nn.Sequential(
b1, *blks,
nn.BatchNorm2d(num_channels), nn.ReLU(),
nn.AdaptiveAvgPool2d((1, 1)),
nn.Flatten(),
nn.Linear(num_channels, 10))

# 训练模型
lr, num_epochs, batch_size = 0.1, 10, 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())