ResNet

残差网络(Residual Network, ResNet)是何恺明等人于 2015 年提出的一系列网络结构,并借此获得了 ILSVRC 2015 数个项目的冠军。 为了解决深层网络难以训练的问题,ResNet 引入了残差学习的概念,并在网络结构中引入了“超前”连接,让激活值可以经过较少的卷积层就可以传到输出。

实践证明这种方式是非常有效的。 使用残差结构以后,更深层的网络可以训练出更高的分类准确率。

背景

深层网络的退化问题

在 ResNet 出现之前,改进神经网络的主流逻辑是堆叠更多的层,加深网络的结构。 理论上,堆叠更多的层可以增强网络的表达能力,深层网络应该可以取得不差于浅层网络的性能。 然而实验却给出了相反的结论:使用相同的梯度下降方法对网络进行训练,深层网络取得的分类精度反而低于浅层网络。 这说明,虽然理论上深层网络的表达能力增强了,或者说可表达的函数的空间变大了,但是在这个更大的函数空间中找到最优解却更为困难。

在论文中,作者将这种现象称为深层网络的退化(degradation)现象。

残差学习

残差模块

基本模块使用两个卷积层构成残差项,每个卷积层都使用 3x3 的卷积核且附带 BN,第一个卷积层后附带 ReLU,第二个卷积层后不使用 ReLU,而是对求和的结果使用 ReLU 。

瓶颈模块使用三个卷积层构成残差项,其中第一和第三个卷积层使用 1x1 的卷积层对通道数进行变换,中间的卷积层在低通道数的情况下使用 3x3 的卷积核扩展感受野。这种设计主要是为了降低卷积层的运算量,因为卷积层的运算量正比于输入和输出通道数的乘积。与基本模块类似,前两个卷积层后连接有 BN 和 ReLU 层,第三个卷积后连接 BN 层并且在求和后应用 ReLU。

残差模块中的求和是逐个元素进行的。如果我们的卷积层使用 stride=1 和 padding=1 的配置,那么残差模块输出的特征图的形状就和输入特征图的形状相同,自然可以求和。

随着网络层数的加深,我们通常会降低特征图的空间分辨率,并加特征的通道数。例如输入 56x56 大小,64 通道的特征,输出 28x28 大小,128 通道的特征图。

为了保证求和的两个特征图在形状上完全相同,我们需要同时对直通分支和残差分支进行相同的操作。针对残差分支,我们通常在第一个卷积层使用 stride=2 进行空间降采样,并扩展输出的通道数。针对直通分支,我们会额外增加一个 1x1 的卷积,使用 stride=2 降低空间分辨率,并增加输出的通道数。

网络结构

一个完整的 ResNet 网络由若干残差模块连接构成,残差块的个数决定了网络的总层数,标准配置有 18、34、50、101、152 五种,在 ResNet-18 和 ResNet-34 中,残差模块使用基本模块,而在深层的 ResNet 结构中,残差模块使用瓶颈模块,以控制网络的总计算量。五种配置的详细结构如下图所示:

虽然层数有所不同,五种配置都可以分为五个阶段。第一个阶段为普通的卷积层,可以将 224x224 的图片转换为 112x112 分辨率、64 通道的特征图。

随后的四个阶段都是由残差模块级联而成的,在每个阶段的开始,我们需要对特征图进行空间降采样,并扩展通道数。其中第二阶段稍有特殊,在阶段开始使用池化进行降采样,其余阶段都是通过 stride=2 的卷积曾进行降采样。

经过五个阶段的降采样,224x224 的图象被缩小为 7x7 分辨率(1/32 比例),512 或 2048 通道的特征图。随后,一个 global average pooling 层和一个全连接层输出 1000 类的条件概率。

五种配置的不同之处仅在于残差模块的选取以及残差模块的个数。

torchvision 的实现

torchvision 在 resnet.py 中实现了上述五种标准的 ResNet 结构。 用户可以通过类似 model = resnet18() 的代码生成模型的实例。 这份代码实现了 ResNet 中的两种残差模块——基本模块 BasicBlock 和瓶颈模块 Bottleneck,并根据不同 ResNet 结构中残差模块的数量实现了五种结构。下面简单对实现进行讲解。

ResNet 的工厂类

resnet 子模块对外有五个主要接口: resnet18resnet34resnet50resnet101resnet152 (还有一些 ResNeXt 相关的,这里暂时也不考虑)分别对应五种不同的 ResNet 结构。这五个接口是五个函数接口,我们以 ResNet18 举例,它的实现如下:

def resnet18(pretrained: bool = False, progress: bool = True, **kwargs: Any) -> ResNet:
    return _resnet('resnet18', BasicBlock, [2, 2, 2, 2], pretrained, progress,
                   **kwargs)

当用户调用 resnet18 时,程序会根据预先设置的残差模块类型 BasicBlock 以及四个阶段中残差模块的个数 [2, 2, 2, 2] 根据工厂函数 _resnet 构建一个 ResNet18 的实例。 其他函数的实现与 resnet18 类似,只不过使用的模块类型和残差模块的个数有所不同。

工厂函数 _resnet 中调用了工厂类 ResNet 实现 ResNet 的计算逻辑,并处理了加载预训练参数的逻辑。

ResNet 类型是 ResNet 模型的主体实现,它的前传函数给出了 ResNet 从输入到输出的计算逻辑,如下所示。 为了清楚,我们将每段代码的具体意义以注释的形式标注在对应的位置。

class ResNet(nn.Module):
    def __init__(
        self,
        block,   # 控制使用 BasicBlock 还是 BottleNeck
        layers,  # 控制每个阶段中使用多少个残差块
        ## 省略一些参数 ##
        ):
        ## 省略一些代码 ##
        # 实现了四个残差的阶段
        self.layer1 = self._make_layer(block, 64, layers[0])
        self.layer2 = self._make_layer(block, 128, layers[1], stride=2,
                                       dilate=replace_stride_with_dilation[0])
        self.layer3 = self._make_layer(block, 256, layers[2], stride=2,
                                       dilate=replace_stride_with_dilation[1])
        self.layer4 = self._make_layer(block, 512, layers[3], stride=2,
                                       dilate=replace_stride_with_dilation[2])
        ## 省略一些代码 ##

    def _make_layer(self, block: Type[Union[BasicBlock, Bottleneck]], planes: int, blocks: int,
                    stride: int = 1, dilate: bool = False):
        ## 省略一些代码 ##

    def _forward_impl(self, x: Tensor) -> Tensor:
        # conv1 或 称为stem 的部分
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.maxpool(x)

        # conv2 到 conv5 的部分
        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)

        # GAP 和分类器的部分
        x = self.avgpool(x)
        x = torch.flatten(x, 1)
        x = self.fc(x)

        return x

    def forward(self, x: Tensor) -> Tensor:
        return self._forward_impl(x)

工厂类型 ResNet 的实现非常直接,可以很容易地和我们上面讲到的 ResNet 结构对应起来。

conv1 的部分比较简单,conv2 到 conv5 的部分则通过 _make_layer 生成,并在 _forward_impl 函数中函数中串联起来。 _make_layer 将多个残差模块串联在一起,构成一个 ResNet 的 conv 阶段,具体的实现及相应的注释如下:

class ResNet(nn.Module):

    def _make_layer(self, block: Type[Union[BasicBlock, Bottleneck]], planes: int, blocks: int,
                    stride: int = 1, dilate: bool = False) -> nn.Sequential:
        norm_layer = self._norm_layer   # 默认是  nn.BatchNorm2d
        downsample = None

        ## 省略 dilation 的配置部分 ##

        if stride != 1 or self.inplanes != planes * block.expansion:
            # 对如 conv3 到 conv 5,即 layer2 到 layer4,stride 是 2
            # 这时使用 stride=2 的一个 1x1 的卷积层作为直通分支的降采样操作
            downsample = nn.Sequential(
                conv1x1(self.inplanes, planes * block.expansion, stride),
                norm_layer(planes * block.expansion),
            )

        layers = []

        # 第一个模块会根据情况使用降采样
        # block 是 BasicBlock 或 Bottleneck
        layers.append(block(self.inplanes, planes, stride, downsample, self.groups,
                            self.base_width, previous_dilation, norm_layer))

        self.inplanes = planes * block.expansion    # Basic block 的 expansion 是 1,BottleNeck 的是 4

        # 后几个残差模块不适用将采样,stride为默认值1
        for _ in range(1, blocks):
            layers.append(block(self.inplanes, planes, groups=self.groups,
                                base_width=self.base_width, dilation=self.dilation,
                                norm_layer=norm_layer))

        return nn.Sequential(*layers)

残差模块

下面我们简要介绍下 BasicBlock 的实现,BottleNeck 和 BasicBlock 的实现逻辑类似。 都是通过几个 Conv2D 卷积层串连实现残差部分。 如果需要降采样,则 conv1 的 stride 在 __init__ 中配置为 2,否则为 1。 同时,直通通路会使用 _make_layer 中生成并传入的 downsample 模块减半空间分辨率,并倍增通道数。 需要注意的是,最后一个 ReLU 再直通和残差求和之后,而不是求和之前。

class BasicBlock(nn.Module):
    expansion: int = 1

    def __init__(
        self,
        # 忽略部分参数
        stride: int = 1,
        downsample: Optional[nn.Module] = None,
        # 忽略部分参数
    ) -> None:
        # 忽略部分代码
        self.conv1 = conv3x3(inplanes, planes, stride)
        # 忽略部分代码
        self.downsample = downsample
        self.stride = stride

    def forward(self, x: Tensor) -> Tensor:
        identity = x

        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)

        out = self.conv2(out)
        out = self.bn2(out)

        # 如果需要将采样,即 `_make_layer` 函数传入一个 1x1 s=2 的卷积模块时,使用这个卷积模块作为直通通路的计算
        # 如果不需要降采样,直接将输入相加到输出上
        if self.downsample is not None:
            identity = self.downsample(x)

        out += identity

        # 需要注意的是,先求和再计算 ReLU
        out = self.relu(out)

        return out

ResNet 的改进

作为目前最实用的模型之一,ResNet 的一些细节改进也层出不穷。 本节介绍一些针对 ResNet 结构的改进,在通用或者特定的情况下可以提高图像分类准确率。

1x1 卷积的降采样

原始的 ResNet 会使用 1x1,stride=2 的卷积层对特征图进行空间上的降采样,产生长宽仅有 1/2 的下一层特征图。 这种降采样方式会在两种场合下使用:

如果使用这种卷积层,前一层特征图上 3/4 的位置的特征值并没有参与运算。 为了能更充分地利用前一层产生的特征,我们可以

  1. 针对直通分支,我们先使用 Average Pooling 进行降采样,再使用 1x1 的卷积层转换特征的通道数。

  2. 针对残差分支,我们应用 1x1 卷积降低通道数时使用 stride=1,即不进行空间降采样,在第二个卷积层,即 3x3 的卷积层再使用 stride=2 进行降采样。torchvision 工具包采取了这种方式。

这两种改进方式都以少量增加的计算量为代价,实现了对前层特征的充分利用。

针对残差分支改进后的结构通称为 ResNet v1b,针对二者都进行改进的结构称为 ResNet v1d。

stem 结构

我们称残差块之前的结构为 stem (茎)。 在标准的 RseNet 结构中,stem 由一个卷积层和一个池化层构成,其中卷积核的大小为 7x7。

对 ResNet 结构的一种改进使用 3 个 3x3 的卷积层替代这个 7x7 的卷积层,可以在相同感受野的情况下增加层数并减少参数个数。类似的技巧在 VGGNet 、Inception 中也有所使用。 在原始的 ResNet 结构中,第一个卷积层是 64 通道的,如果使用 3 个 3x3 的卷积层,则前两个卷积层会使用 32 通道,第三个卷积层使用 64 通道。

另外,如果将 ResNet 应用于 CIFAR10 等图像分辨率较小的数据集,也可以使用单个 3x3 卷积代替 7x7 的卷积。

基于 v1b 进行 stem 改进后的结构也通称为 ResNet v1c。

提前激活(pre-activation)

ResNet 的提前激活版本是何恺明等人与 2016 年 ECCV 上提出的一种对 ResNet 的改进。在原始的 ResNet 结构中,残差分支和直通分支求和后,会应用 ReLU 激活函数,之后再输入下一个残差模块。 如果我们剔除残差分支,单独考虑直通分支,出去降采样等操作之外,直通分支上有一系列的非线性函数。

论文认为,直通分支对网络的训练是非常重要的,因而将 ReLU 从求和后移动到了残差分支。这样直通分支上就没有任何非线性函数,输入信号也可以通过直通分支传递到网络的任意深度。

这种改进也通称为 ResNet v2。

参考资料

Last updated