📒
OpenMMLab Book
  • 前言
  • Image Classification
    • 概述
      • LeNet-5
      • 数据集
      • 评价指标
    • 早期方法
      • AlexNet
      • VGGNet
      • Inception
    • 主流模型及技术
      • BN
      • ResNet
      • ResNeXt
      • SENet
    • 移动端模型
      • MobileNet
      • MobileNet v2
      • ShuffleNet
      • ShuffleNet v2
  • Object Detection
    • 概述
      • 问题定义
      • 朴素方法
      • 一些概念
      • 算法评价
    • 早期方法
      • RCNN
      • YOLO
    • 两阶段方法
      • Fast R-CNN
      • RPN
      • Faster R-CNN
      • Mask R-CNN
    • FPN
    • 单阶段方法
      • SSD
      • YOLO v2
      • YOLO v3
      • RetinaNet
    • 新的方向
      • Cascade R-CNN
      • FCOS
  • Semantic Segmentation
    • 概述
    • FCN
    • PSPNet
Powered by GitBook
On this page
  • 背景
  • 深层网络的退化问题
  • 残差学习
  • 残差模块
  • 网络结构
  • torchvision 的实现
  • ResNet 的工厂类
  • 残差模块
  • ResNet 的改进
  • 1x1 卷积的降采样
  • stem 结构
  • 提前激活(pre-activation)
  • 参考资料

Was this helpful?

  1. Image Classification
  2. 主流模型及技术

ResNet

PreviousBNNextResNeXt

Last updated 4 years ago

Was this helpful?

残差网络(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 的实现

ResNet 的工厂类

resnet 子模块对外有五个主要接口: resnet18,resnet34,resnet50,resnet101,resnet152 (还有一些 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。

参考资料

我们可以从理论的角度更清楚地阐述退化问题。 考虑一个浅层的神经网络,它表达了一个含参数的函数 f(x;w0)f(x; w_0)f(x;w0​) 。 如果我们叠加一个新的层 g(z,w)g(z, w)g(z,w) ,则这个深了一层的网络表达了两个函数的复合函数,即 F(x;w0,w)=g(f(x;w0);w)F(x; w_0, w) = g(f(x; w_0); w)F(x;w0​,w)=g(f(x;w0​);w)。

如果我们通过设置参数 www 使得 ggg 成为一个恒等函数,例如使用恒等映射对应的卷积核,那么这个深一层的网络就可以表达出所有浅层网络可以表达的函数。 如果我们对使 ggg 产生一些变化,那么复合函数 FFF 就可以表达出更多的函数,而如果这些函数中有可以提高分类精度的函数,那么深层网络就可以在数据集上取得更高的精度。 然而实验结果却意味着,直接学习出这样的 ggg 或许并不容易。

基于这个观察,论文作者提出了残差学习的概念。既然让优化器学习出一个近似恒等的映射 ggg 并不容易,那么我们将 g(z;w)g(z; w)g(z;w) 显式表达成一个 g(z;w)=z+h(z;w)g(z; w) = z + h(z; w)g(z;w)=z+h(z;w) 的形式,即恒等项与一个复杂项的求和。当 hhh 为恒零函数时, ggg 可以表达一个恒等映射,而作者猜想,通过学习 hhh 得到想要的映射ggg的难度要低于直接学习 $$g$ 。这就是残差学习的概念。

残差形式可以很直接地被一个神经网络表示,我们只需要用若干层叠加的子网络表示 h(z;w)h(z; w)h(z;w) 部分,再将这个子网络的输入和输出相加,就可以让这个网络表示 z+h(z;w)z + h(z; w)z+h(z;w) 。

在 ResNet 结构中,残差块 hhh 的实现方式有两种,一种称为基本模块(basic block)一种称为瓶颈模块(bottleneck block)。

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

ResNet 原始论文:

汇总相关改进的论文:

ResNet v2:

resnet.py
Deep Residual Learning for Image Recognition
Bag of Tricks for Image Classification with Convolutional Neural Networks
Identity Mappings in Deep Residual Networks