《动手学深度学习》
Table Of Contents
《动手学深度学习》
Table Of Contents

残差网络(ResNet)

让我们先思考一个问题:对神经网络模型添加新的层,充分训练后的模型是否只可能更有效地降低训练误差?理论上,原模型解的空间只是新模型解的空间的子空间。也就是说,如果我们能将新添加的层训练成恒等映射 \(f(x) = x\),新模型和原模型将同样有效。由于新模型可能得出更优的解来拟合训练数据集,添加层似乎更容易降低训练误差。然而在实践中,添加过多的层后训练误差往往不降反升。即使利用批量归一化带来的数值稳定性使得训练深层模型更加容易,这个问题仍然存在。针对这一问题,何恺明等人提出了残差网络(ResNet) [1]。它在 2015 年的 ImageNet 图像识别挑战赛夺魁,并深刻影响了后来的深度神经网络的设计。

残差块

让我们聚焦于神经网络局部。如图 5.9 所示,设输入为 \(\boldsymbol{x}\)。假设我们希望学出的理想映射为 \(f(\boldsymbol{x})\),以作为图 5.9 上方激活函数的输入。左图虚线框中部分需要直接拟合出该映射 \(f(\boldsymbol{x})\)。而右图虚线框中部分则需要拟合出残差(residual)映射 \(f(\boldsymbol{x})-\boldsymbol{x}\)。残差映射在实际中往往更容易优化。以本节开头提到的恒等映射作为我们希望学出的理想映射 \(f(\boldsymbol{x})\),并以 ReLU 作为激活函数。我们只需将图 5.9 中右图上方加权运算(例如仿射)的权重和偏差参数学成零,那么上方 ReLU 的输出就会与输入 \(\boldsymbol{x}\) 恒等。图 5.9 右图也是 ResNet 的基础块,即残差块(residual block)。在残差块中,输入可通过跨层的数据线路更快地向前传播。

设输入为\ :math:`\boldsymbol{x}`\ 。假设图中最上方ReLU的理想映射为\ :math:`f(\boldsymbol{x})`\ 。左图虚线框中部分需要直接拟合出该映射\ :math:`f(\boldsymbol{x})`\ 。而右图虚线框中部分需要拟合出残差映射\ :math:`f(\boldsymbol{x})-\boldsymbol{x}`\ 。

设输入为\(\boldsymbol{x}\)。假设图中最上方ReLU的理想映射为\(f(\boldsymbol{x})\)。左图虚线框中部分需要直接拟合出该映射\(f(\boldsymbol{x})\)。而右图虚线框中部分需要拟合出残差映射\(f(\boldsymbol{x})-\boldsymbol{x}\)

ResNet 沿用了 VGG 全 \(3\times 3\) 卷积层的设计。残差块里首先有两个有同样输出通道数的 \(3\times 3\) 卷积层。每个卷积层后接一个批量归一化层和 ReLU 激活函数。然后我们将输入跳过这两个卷积运算后直接加在最后的 ReLU 激活函数前。这样的设计要求两个卷积层的输出与输入形状一样,从而可以相加。如果想改变通道数,我们需要引入一个额外的 \(1\times 1\) 卷积层来将输入变换成需要的形状后再做相加运算。

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

In [1]:
import gluonbook as gb
from mxnet import gluon, init, nd
from mxnet.gluon import nn

class Residual(nn.Block):  # 本类已保存在 gluonbook 包中方便以后使用。
    def __init__(self, num_channels, use_1x1conv=False, strides=1, **kwargs):
        super(Residual, self).__init__(**kwargs)
        self.conv1 = nn.Conv2D(num_channels, kernel_size=3, padding=1,
                               strides=strides)
        self.conv2 = nn.Conv2D(num_channels, kernel_size=3, padding=1)
        if use_1x1conv:
            self.conv3 = nn.Conv2D(num_channels, kernel_size=1,
                                   strides=strides)
        else:
            self.conv3 = None
        self.bn1 = nn.BatchNorm()
        self.bn2 = nn.BatchNorm()

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

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

In [2]:
blk = Residual(3)
blk.initialize()
X = nd.random.uniform(shape=(4, 3, 6, 6))
blk(X).shape
Out[2]:
(4, 3, 6, 6)

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

In [3]:
blk = Residual(6, use_1x1conv=True, strides=2)
blk.initialize()
blk(X).shape
Out[3]:
(4, 6, 3, 3)

ResNet 模型

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

In [4]:
net = nn.Sequential()
net.add(nn.Conv2D(64, kernel_size=7, strides=2, padding=3),
        nn.BatchNorm(), nn.Activation('relu'),
        nn.MaxPool2D(pool_size=3, strides=2, padding=1))

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

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

In [5]:
def resnet_block(num_channels, num_residuals, first_block=False):
    blk = nn.Sequential()
    for i in range(num_residuals):
        if i == 0 and not first_block:
            blk.add(Residual(num_channels, use_1x1conv=True, strides=2))
        else:
            blk.add(Residual(num_channels))
    return blk

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

In [6]:
net.add(resnet_block(64, 2, first_block=True),
        resnet_block(128, 2),
        resnet_block(256, 2),
        resnet_block(512, 2))

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

In [7]:
net.add(nn.GlobalAvgPool2D(), nn.Dense(10))

这里每个模块里有 4 个卷积层(不计算 \(1\times 1\) 卷积层),加上最开始的卷积层和最后的全连接层,共计 18 层。这个模型也通常被称为 ResNet-18。通过配置不同的通道数和模块里的残差块数我们可以得到不同的 ResNet 模型,例如更深的含 152 层的 ResNet-152。虽然 ResNet 的主体架构跟 GoogLeNet 的类似,但 ResNet 结构更加简单,修改也更加方便。这些因素都导致了 ResNet 迅速被广泛使用。

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

In [8]:
X = nd.random.uniform(shape=(1, 1, 224, 224))
net.initialize()
for layer in net:
    X = layer(X)
    print(layer.name, 'output shape:\t', X.shape)
conv5 output shape:      (1, 64, 112, 112)
batchnorm4 output shape:         (1, 64, 112, 112)
relu0 output shape:      (1, 64, 112, 112)
pool0 output shape:      (1, 64, 56, 56)
sequential1 output shape:        (1, 64, 56, 56)
sequential2 output shape:        (1, 128, 28, 28)
sequential3 output shape:        (1, 256, 14, 14)
sequential4 output shape:        (1, 512, 7, 7)
pool1 output shape:      (1, 512, 1, 1)
dense0 output shape:     (1, 10)

获取数据并训练

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

In [9]:
lr, num_epochs, batch_size, ctx = 0.05, 5, 256, gb.try_gpu()
net.initialize(force_reinit=True, ctx=ctx, init=init.Xavier())
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr})
train_iter, test_iter = gb.load_data_fashion_mnist(batch_size, resize=96)
gb.train_ch5(net, train_iter, test_iter, batch_size, trainer, ctx, num_epochs)
training on gpu(0)
epoch 1, loss 0.4856, train acc 0.831, test acc 0.886, time 60.2 sec
epoch 2, loss 0.2498, train acc 0.907, test acc 0.906, time 57.0 sec
epoch 3, loss 0.1832, train acc 0.933, test acc 0.895, time 57.0 sec
epoch 4, loss 0.1354, train acc 0.950, test acc 0.903, time 57.0 sec
epoch 5, loss 0.1030, train acc 0.963, test acc 0.909, time 57.1 sec

小结

  • 残差块通过跨层的数据通道从而能够训练出有效的深度神经网络。
  • ResNet 深刻影响了后来的深度神经网络的设计。

练习

  • 参考 ResNet 论文的表 1 来实现不同版本的 ResNet [1]。
  • 对于比较深的网络, ResNet 论文中介绍了一个“瓶颈”架构来降低模型复杂度。尝试实现它 [1]。
  • 在 ResNet 的后续版本里,作者将残差块里的“卷积、批量归一化和激活”结构改成了“批量归一化、激活和卷积”,实现这个改进([2],图 1)。

扫码直达讨论区

image0

参考文献

[1] He, K., Zhang, X., Ren, S., & Sun, J. (2016). Deep residual learning for image recognition. In Proceedings of the IEEE conference on computer vision and pattern recognition (pp. 770-778).

[2] He, K., Zhang, X., Ren, S., & Sun, J. (2016, October). Identity mappings in deep residual networks. In European Conference on Computer Vision (pp. 630-645). Springer, Cham.