动手学深度学习 5.11 残差网络(ResNet)

前言

从零开始学习ai文章系列计划是个人在《动手学深度学习》和《磨菇书》两本书的学习中的个人笔记,文章也会以课本中的章节分开,即每个章节一片笔记。我会尽量的把主要内容以及遇到的难点进行记录与解决,如果哪里有错误的欢迎指正。或者不清晰的可以直接查看原文部分。

《动手学深度学习》原文(课本):https://tangshusen.me/Dive-into-DL-PyTorch/#/

《动手学深度学习》代码:https://github.com/ShusenTang/Dive-into-DL-PyTorch

(由于有时候公式太多,可能会直接贴图片)


简单说残差网络是一种 2维图像方面加速学习的方法。

让我们先思考一个问题:对神经网络模型添加新的层,充分训练后的模型是否只可能更有效地降低训练误差?

理论上,原模型解的空间只是新模型解的空间的子空间。也就是说,如果我们能将新添加的层训练成恒等映射f(x)=x,新模型和原模型将同样有效。

由于新模型可能得出更优的解来拟合训练数据集,因此添加层似乎更容易降低训练误差。然而在实践中,添加过多的层后训练误差往往不降反升。

即使利用批量归一化带来的数值稳定性使训练深层模型更加容易,该问题仍然存在。针对这一问题,何恺明等人提出了残差网络(ResNet)。

1. 残差块

让我们聚焦于神经网络局部。如图5.9所示,设输入为x。假设我们希望学出的理想映射为f(x),从而作为图5.9上方激活函数的输入。

左图虚线框 中的部分需要直接拟合出该 映射f(x)

右图虚线框 中的部分则需要拟合出有关恒等映射的残差 映射f(x)−x

残差映射在实际中往往更容易优化。以本节开头提到的恒等映射作为我们希望学出的理想映射f(x)。我们只需将图5.9中右图虚线框内上方的加权运算(如仿射)的权重和偏差参数学成0,

实际中,当理想映射f(x)极接近于恒等映射时,残差映射也易于捕捉恒等映射的细微波动。图5.9右图也是ResNet的基础块,即残差块(residual block)。在残差块中,输入可通过跨层的数据线路更快地向前传播。

举个例子。

左图原本训练的目标函数为f(x)= f1(x),右边残差训练的函数为 f(x) = f1(x) = f2(x)+x 。那么我们残差块训练的目标函数是 f2(x)

假如目标函数是恒等函数 f(x)=x,那么我们目标函数中所有权重和偏差为0得到 f2(x)=0 即可。

残差块训练的是 x目标函数f(x) 之间相差的那一块,简单理解如果x与目标f(x)相近,我们只需要训练一点残差值即可,哲学上来说:如果你已经做得差不多了,那就别从头再来——只需要学会如何补上那一小步

你可以理解:x是一张 猫狗特征图片,要求输出猫的特征,那么与其从0开始训练这个网络层,不如在原有的图片基础上进行删改,只留下猫的特征。

可以从直觉上感受到工作量减少了,因此加速了收敛。

残差网络还有重要的一点经验性假设:在很多实际任务中,神经网络每一层的输出与输入之间差异往往不大(即“相近”)。

• 在图像处理中,连续的卷积层往往只做一些局部特征提取、微调,不会让特征发生翻天覆地的变化。

• 在语言模型、时间序列模型中,特征的逐层表示也通常是逐步演化,而非完全替换。

• 因此:直接让网络只学习“如何改进”输入,而不是重新建造一切,更高效、更稳定。

但是这不是绝对的,因此残差网络变种中也有在最后一步 f(x)=x+f2(x) 这给x加上可学习参数的权重变成 f(x)=wx+f2(x) 。

ResNet沿用了VGG全3×3卷积层的设计。残差块里首先有2个有相同输出通道数的3×3卷积层。每个卷积层后接一个批量归一化层和ReLU激活函数。

然后我们将输入跳过这两个卷积运算后直接加在最后的ReLU激活函数前。这样的设计要求两个卷积层的输出与输入形状一样,从而可以相加。如果想改变通道数,就需要引入一个额外的1×1卷积层来将输入变换成需要的形状后再做相加运算。

残差块的实现如下。它可以设定输出通道数、是否使用额外的1×1卷积层来修改通道数以及卷积层的步幅。

下面我们来查看输入和输出形状一致的情况。

1
2
3
blk = Residual(3, 3)
X = torch.rand((4, 3, 6, 6))
print(blk(X).shape) # torch.Size([4, 3, 6, 6])

我们也可以在增加输出通道数的同时减半输出的高和宽。

1
2
3
blk = Residual(3, 6, use_1x1conv=True, stride=2)
X = torch.rand((4, 3, 6, 6))
print(blk(X).shape) # torch.Size([4, 6, 3, 3])

2. ResNet模型

ResNet的前两层跟之前介绍的GoogLeNet中的一样:在输出通道数为64、步幅为2的7×7卷积层后接步幅为2的3×3的最大池化层。不同之处在于ResNet每个卷积层后增加的批量归一化层。

1
2
3
4
5
net = 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))

GoogLeNet在后面接了4个由Inception块组成的模块。ResNet则使用4个由残差块组成的模块,每个模块使用若干个同样输出通道数的残差块。第一个模块的通道数同输入通道数一致。由于之前已经使用了步幅为2的最大池化层,所以无须减小高和宽。之后的每个模块在第一个残差块里将上一个模块的通道数翻倍,并将高和宽减半。

下面我们来实现这个模块。注意,这里对第一个模块做了特别处理。

1
2
3
4
5
6
7
8
9
10
def resnet_block(in_channels, out_channels, num_residuals, first_block=False):
if first_block:
assert in_channels == out_channels # 第一个模块的通道数同输入通道数一致
blk = []
for i in range(num_residuals):
if i == 0 and not first_block:
blk.append(Residual(in_channels, out_channels, use_1x1conv=True, stride=2))
else:
blk.append(Residual(out_channels, out_channels))
return nn.Sequential(*blk)

ResNet 第一模块输入输出通道一致,并不是卷积本身的限制,而是 残差恒等映射的设计选择。

简单说,就是残差块的最后一步需要与x相加,如果通道数一样的话,就可以直接相加,而通道数不一样的话,需要对x进行1x1网络的处理。

在网络的早期(尤其是第一组 block),作者希望 shortcut (即最终与f(x)相加的x) 保持纯粹的恒等映射(identity),也就是不引入任何新参数。这样做的好处是:当残差分支刚初始化时,网络至少可以退化为“恒等映射”,避免训练困难。

接着我们为ResNet加入所有残差块。这里每个模块使用两个残差块。

1
2
3
4
net.add_module("resnet_block1", resnet_block(64, 64, 2, first_block=True))
net.add_module("resnet_block2", resnet_block(64, 128, 2))
net.add_module("resnet_block3", resnet_block(128, 256, 2))
net.add_module("resnet_block4", resnet_block(256, 512, 2))

最后,与GoogLeNet一样,加入全局平均池化层后接上全连接层输出。

1
2
3
net.add_module("global_avg_pool", d2l.GlobalAvgPool2d()) # GlobalAvgPool2d的输出: (Batch, 512, 1, 1)

net.add_module("fc", nn.Sequential(d2l.FlattenLayer(), nn.Linear(512, 10)))

这里每个模块里有4个卷积层(不计算1×1卷积层),加上最开始的卷积层和最后的全连接层,共计18层。这个模型通常也被称为ResNet-18。

通过配置不同的通道数和模块里的残差块数可以得到不同的ResNet模型,例如更深的含152层的ResNet-152。

虽然ResNet的主体架构跟GoogLeNet的类似,但ResNet结构更简单,修改也更方便。这些因素都导致了ResNet迅速被广泛使用。

在训练ResNet之前,我们来观察一下输入形状在ResNet不同模块之间的变化。

1
2
3
4
X = torch.rand((1, 1, 224, 224))
for name, layer in net.named_children():
X = layer(X)
print(name, ' output shape:\t', X.shape)

3. 获取数据和训练模型

下面我们在Fashion-MNIST数据集上训练ResNet。

确实没想过效果这么好。

1
2
3
4
5
6
7
batch_size = 256
# 如出现“out of memory”的报错信息,可减小batch_size或resize
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96)

lr, num_epochs = 0.001, 5
optimizer = torch.optim.Adam(net.parameters(), lr=lr)
d2l.train_ch5(net, train_iter, test_iter, batch_size, optimizer, device, num_epochs)