论文阅读(2)ResNet:Deep Residual Learning for Image Recognition


  • 提出ResNet模型,深度残差神经网络模型。

  • 论文发表于2015年,2016年CVPR最佳论文

  • 作者何恺明、孙剑、张翔宇、任少卿(微软亚洲研究院)

参考:

ResNet为什么能解决网络退化问题 - 同济子豪兄 - bilibili

7.6. 残差网络(ResNet) — 动手学深度学习 2.0.0 documentation (d2l.ai)

6.1 ResNet网络结构,BN以及迁移学习详解_哔哩哔哩_bilibili

6.2 使用pytorch搭建ResNet并基于迁移学习训练_哔哩哔哩_bilibili

解决了什么问题

针对很深的神经网络模型很难训练这一问题,即网络退化问题(degradation)从实验数据可以看到,仅仅堆叠神经网络的层数形成的网络模型,在测试集上的表现还如不较少层的模型。

image-20230203213711276

使用残差神经网络后的情况如下

image-20230204103534565

采用了什么方法

identity mapping:恒等映射

弧线为shortcut connection

image-20230204104757561

为什么能起作用

直观理解

新加入的层可能会对模型的学习产生负面效果,也可能会产生正面效果,而使用残差网络,若新加入的层产生了负面效果就回退到原来的层,相当于直接忽略掉这一层的作用。用于解决梯度消失和梯度下降问题,使更深层的网络变得容易学习。

  • 传统线性结构网络难以拟合 “恒等映射”,什么都不做有时候很重要,skip connections可以让模型自行选择要不要更新

  • 恒等映射这一路的梯度为1,防止梯度消失

    在反向传播层层计算梯度的时候,每层计算的导数都很小,链式法则相乘之后梯度就变得几乎为0,即梯度消失,导致参数不能更新,网络停止学习。

  • 相当于多个网络的集成(类似dropout)

    image-20230204113053573

    而且剪掉右图中的几条路径并不影响整个网络的表现

  • 没什么好解释的,尝试出来的结果好就行了(炼丹嘛,不寒掺)

网络架构

在ImageNet数据集上,应用了152层的深度残差神经网络,下图为34层Resnet

image-20230204131152297

image-20230204110353745

  • 输入图片

    3通道224x224

  • 每一个卷积层之后应用批量归一化(batch normalization),激活函数之前

  • 当输入和输出维度相同时可以直接走shortcut connections(图中实线)

    当输入维度和输出维度不同时,有两种方法:1、添0 2、使用1x1的卷积改变通道数

  • 注意两个conv的接口处要完成两项任务,这两项任务是在定义网络时通过stride和padding实现的

    • 图片长宽尺寸变为原来的1/2
    • 通道数变为原来的2倍

复现网络模型

整体结构

整个残差网络模型的构建包括3部分

  • 残差块

    2个卷积层以及批量归一化和激活函数,包括对加x的处理

  • 由若干残差块构成的模块

    模块中的第一个残差块需要对输入进行额外处理,中间残差块保持数据维度不变

  • 其他部分

    预处理卷积层和全连接层

image-20230204135340013

定义残差块

一个残差块由两个卷积层构成,残差块中没有池化,但是有批量归一化BN和激活函数,其结构如下

image-20230204133106814

注意:BN先于ReLU使用,这个图中没有表现出来

残差块有两种类型

  1. 大层次内部的残差块
  2. 大层次交接处第一个残差块
image-20230204133642480

内部残差块输入维度和输出维度完全相同,且要加上x的维度和第二个卷积层输出维度完全相同,直接相加即可

交接处第一个残差块相比于内部残差块要多完成2项任务

  1. 改变输入维度,尺寸变为原来的1/2,通道数变为原来的2倍
  2. 输入x的维度与第二个卷积层输出的维度不同,要先对x使用1x1的卷积变换尺寸通道数才能与第二个卷积层的输出相加(1x1卷积,s=2,实现尺寸减半且改变通道数)
image-20230204134052180

pytorch实现代码如下

import torch
from torch import nn


class Residual(nn.Module):
    def __init__(self, input_channels, output_channels, use_1x1conv=False, strides=1):
        super(Residual, self).__init__()
        # conv1: 实例化Residual时如果没指定默认s=1,可以指定s=2,根据公式使得图片尺寸变为原来的1/2
        self.conv1 = nn.Conv2d(input_channels, output_channels, kernel_size=3, padding=1, stride=strides)
        # conv2: stride默认为1,padding=1,根据公式conv2输出和输入维度完全相同
        self.conv2 = nn.Conv2d(output_channels, output_channels, kernel_size=3, padding=1)
        # 实例化此类时如果指定use_1x1conv,则表明本层是两大层交接处,需要改变x的尺寸和通道数保证和输出维度相同
        if use_1x1conv:
            self.conv3 = nn.Conv2d(input_channels, output_channels, kernel_size=1, stride=strides)
        else:
            self.conv3 = None
        self.bn1 = nn.BatchNorm2d(output_channels)
        self.bn2 = nn.BatchNorm2d(output_channels)

    def forward(self, x):
        y = nn.ReLU(self.bn1(self.conv1(x)))
        y = self.bn2(self.conv2(y))
        if self.conv3:
            x = self.conv3(x)
        y = y + x
        return nn.ReLU(y)

定义模块

实现代码如下

def resnet_block(input_channels, output_channels, num_residuals, first_block=False):
    blk = []  # blk为网络中模块的集合
    for i in range(num_residuals):
        if i == 0 and not first_block:  # 模块中的第一个残差块,第一个模块的第一个残差块不需要对输入做变换处理
            blk.append(Residual(input_channels, output_channels, use_1x1conv=True, strides=2))
        else:
            blk.append(Residual(output_channels, output_channels))  # 模块中的中间残差块保持数据维度不变,注意此处输入输出通道数均为output_channels
    return blk


# 以下定义了论文中提到的34层残差网络
# b1为预处理卷积和池化操作
b1 = nn.Sequential(
    nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3),
    nn.BatchNorm2d(64), nn.ReLU(),
    nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
)
b2 = nn.Sequential(*resnet_block(64, 64, 6, first_block=True))  # 第一个模块
b3 = nn.Sequential(*resnet_block(64, 128, 8))
b4 = nn.Sequential(*resnet_block(128, 256, 12))
b5 = nn.Sequential(*resnet_block(256, 512, 6))

net = nn.Sequential(b1, b2, b3, b4, b5,
                    nn.AdaptiveAvgPool2d((1, 1)),
                    nn.Flatten(), nn.Linear(512, 1000))

测试

X = torch.rand(size=(1, 3, 224, 224))
for layer in net:
    X = layer(X)
    print(layer.__class__.__name__, 'output shape:\t', X.shape)

运行结果

Sequential output shape:	 torch.Size([1, 64, 56, 56])
Sequential output shape:	 torch.Size([1, 64, 56, 56])
Sequential output shape:	 torch.Size([1, 128, 28, 28])
Sequential output shape:	 torch.Size([1, 256, 14, 14])
Sequential output shape:	 torch.Size([1, 512, 7, 7])
AdaptiveAvgPool2d output shape:	 torch.Size([1, 512, 1, 1])
Flatten output shape:	 torch.Size([1, 512])
Linear output shape:	 torch.Size([1, 1000])

补充(resnet50/101/152)

前面的内容及实现是针对resnet18/resnet34架构,resnet50/101/152架构在残差结构上有一些不同。

虽然内部实现由原来的2个卷积层变成了3个卷积层,但残差块作为一个整体还是实现将输入数据的宽高减半(第2层通过指定步长stride=2实现),通道增倍(第1层先将通道数降为原来的一半,第3层将通道数变为输入的4倍,泽整体来看是变为原来的2倍),且这样做的目的是通过1x1的卷积操作降维升维,减少参数的个数,resnet50/101/152层数相比于resnet18/34更多,如果还是沿用原来的残差结构,参数数量将过多。

降维升维减少参数个数的例子如下图所示

image-20230216105917561

resnet18/34与resnet50/101/152架构残差结构对比如下表所示

resnet18/34 resnet50/101/152
卷积层1 宽高减半 通道数增倍 降维(通道数减半)
卷积层2 保持数据维度 宽高减半
卷积层3 升维(通道数变为4倍)

pytorch实现

此代码在pytorch官方提供的代码上略作修改完成的,用同一套代码模式实现了resnet18/34和resnet50/101/152架构。我只能说:太妙了。这种面向对象编程的思想发挥的淋漓尽致,把模型的一些部分抽象出来成为类,构成一个零件。把模型可以复用的部分也提取出来,然后把不同部分的零件拿出来组装,体会到了工程的思想

对于resnet50/101/152架构的理解,主要抓住以下关键点(本质观点同resnet18/34相同,实现细节上有差异)

  1. 残差块3层卷积,各层作用(见上表)
  2. 每一个模块(大层次,共4个)由若干不同参数(通道数)的残差块构成
  3. 大层次第1个残差块需要对数据维度进行处理
    • 尺寸(宽高)减半,通道数先降维为原来的1/2
    • 第1个大层次的第1个残差块不需要对数据做处理
image-20230216162658316
import torch.nn as nn
import torch


# 定义resnet18和resnet34的残差结构(由2个通道数相同的卷积层构成)
class BasicBlock(nn.Module):
    # 用于表示残差结构中输出通道和输入通道的比例,为1表示残差结构输出通道数和输入通道数相同
    expansion = 1

    def __init__(self,
                 in_channels,  # 残差结构输入通道数
                 out_channels,  # 输出通道数
                 stride=1,  # 默认s=1
                 down_sample=None  # 是否要使用1x1的卷积进行下采样(将输入通道调整为输出通道数,以进行恒等映射)
                 ):
        super(BasicBlock, self).__init__()
        # 默认s=1,可用通过参数指定s=2,大层次第1个残差结构的第1层卷积需要将输入图片的高宽减半(s=2实现)
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.relu = nn.ReLU()
        # res18/34 残差结构中第2个卷积层保持上一层的维度不变(k=3,s=1,p=1)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)
        self.down_sample = down_sample

    def forward(self, x):
        identity = x
        if self.down_sample is not None:  # 使用1x1卷积
            identity = self.down_sample(x)

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

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

        out += identity
        out = self.relu(out)
        return out


# 定义resnet50/101/152 残差结构
class Bottleneck(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1, down_sample=None):
        # resnet 50/101/152 残差结构中最后一层卷积层通道数是第一层通道数的4倍
        expansion = 4
        super(Bottleneck, self).__init__()
        # padding默认为0
        # out_channels指的是残差结构中第1、2卷积层通道数
        # out_channels是in_channels的1/2,输出通道数是out_channels的4倍,这也就是expansion
        # 残差结构整体实现了输出通道数为输入通道数的2倍
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)
        # 指定s=2可实现将宽高减半,注意与resnet18/34的区别,这里是在第2层实现的宽高减半操作
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)

        self.conv3 = nn.Conv2d(out_channels, out_channels * self.expansion, kernel_size=1, stride=1, bias=False)
        self.bn3 = nn.BatchNorm2d(out_channels * self.expansion)
        self.relu = nn.ReLU()
        self.down_sample = down_sample

    def forward(self, x):
        identity = x
        if self.down_sample is not None:
            identity = self.down_sample(x)

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

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

        out = self.conv3(x)
        out = self.bn3(out)

        out += identity
        out = self.relu(out)
        return out


# ResNet整体架构,包括进入残差之前的卷积和池化,残差之后的全连接
class ResNet(nn.Module):
    def __init__(self,
                 block,  # 指定使用的残差块(resnet18/34:BasicBlock;resnet50/101/152:Bottleneck)
                 block_nums,  # 大层次残差块个数: List
                 num_classes=1000,  # 输出分类个数
                 include_top=True  # 为搭建更复杂的网络提供的参数
                 ):
        super(ResNet, self).__init__()
        self.include_top = include_top
        self.in_channels = 64  # 残差部分输入通道数

        # 残差部分之前的卷积和池化处理
        self.conv1 = nn.Conv2d(3, self.in_channels, kernel_size=7, stride=2, padding=3, bias=False)
        self.bn1 = nn.BatchNorm2d(self.in_channels)
        self.max_pool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)

        # 4个残差大层次
        self.layer1 = self._make_layer(block, 64, block_nums[0])
        self.layer2 = self._make_layer(block, 128, block_nums[1], stride=2)
        self.layer3 = self._make_layer(block, 256, block_nums[2], stride=2)
        self.layer4 = self._make_layer(block, 512, block_nums[3], stride=2)

        # 后序平均池化和全连接层
        if self.include_top:
            self.avgpool = nn.AdaptiveAvgPool2d((1, 1))  # output size = (1, 1)
            self.fc = nn.Linear(512 * block.expansion, num_classes)

        # 初始化权重
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')

    # _mack_layer 构建残差大层次(由若干个指定的残差块组成)
    # 要考虑到是否需要使用1x1卷积(指的是在恒等映射时要不要修改原始输入数据的维度)
    # 此函数很妙,分别举两种架构的一个例子容易理解,代码是如何实现抽象和复用的
    # 重要的是理解expansion的妙处和down_sample的条件判断与步长处理
    def _make_layer(self,
                    block,      # 残差块类型(resnet18/34:BasicBlock;resnet50/101/152:Bottleneck)
                    channel,    # 残差块主干通道数(BasicBlock两层卷积通道数均相同,Bottleneck前两层卷积通道数)
                    block_num,  # 大层次中残差块个数
                    stride=1):  # 大层次残差块中第一个块的步长(默认为1)
        down_sample = None  # 使用1x1卷积的对象
        if stride != 1 or self.in_channel != channel * block.expansion:
            down_sample = nn.Sequential(
                nn.Conv2d(self.in_channel, channel * block.expansion, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(channel * block.expansion))

        layers = []
        layers.append(block(self.in_channel,
                            channel,
                            downsample=down_sample,
                            stride=stride))
        self.in_channel = channel * block.expansion

        # 处理大层次内部残差块(除第一个残差块以外的残差块)
        for _ in range(1, block_num):
            layers.append(block(self.in_channel, channel))

        return nn.Sequential(*layers)

    def forward(self, x):
        x = self.conv1(x)
        x = self.bn1(x)
        x = self.relu(x)
        x = self.maxpool(x)

        x = self.layer1(x)
        x = self.layer2(x)
        x = self.layer3(x)
        x = self.layer4(x)

        if self.include_top:
            x = self.avgpool(x)
            x = torch.flatten(x, 1)
            x = self.fc(x)

        return x


def resnet34(num_classes=1000, include_top=True):
    # https://download.pytorch.org/models/resnet34-333f7ec4.pth
    return ResNet(BasicBlock, [3, 4, 6, 3], num_classes=num_classes, include_top=include_top)


def resnet50(num_classes=1000, include_top=True):
    # https://download.pytorch.org/models/resnet50-19c8e357.pth
    return ResNet(Bottleneck, [3, 4, 6, 3], num_classes=num_classes, include_top=include_top)


def resnet101(num_classes=1000, include_top=True):
    # https://download.pytorch.org/models/resnet101-5d3b4d8f.pth
    return ResNet(Bottleneck, [3, 4, 23, 3], num_classes=num_classes, include_top=include_top)
posted @ 2023-02-04 15:09  dctwan  阅读(855)  评论(0编辑  收藏  举报