论文阅读(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)从实验数据可以看到,仅仅堆叠神经网络的层数形成的网络模型,在测试集上的表现还如不较少层的模型。
使用残差神经网络后的情况如下
采用了什么方法
identity mapping:恒等映射
弧线为shortcut connection
为什么能起作用
直观理解
新加入的层可能会对模型的学习产生负面效果,也可能会产生正面效果,而使用残差网络,若新加入的层产生了负面效果就回退到原来的层,相当于直接忽略掉这一层的作用。用于解决梯度消失和梯度下降问题,使更深层的网络变得容易学习。
-
传统线性结构网络难以拟合 “恒等映射”,什么都不做有时候很重要,skip connections可以让模型自行选择要不要更新
-
恒等映射这一路的梯度为1,防止梯度消失
在反向传播层层计算梯度的时候,每层计算的导数都很小,链式法则相乘之后梯度就变得几乎为0,即梯度消失,导致参数不能更新,网络停止学习。
-
相当于多个网络的集成(类似dropout)
而且剪掉右图中的几条路径并不影响整个网络的表现
-
没什么好解释的,尝试出来的结果好就行了
(炼丹嘛,不寒掺)
网络架构
在ImageNet数据集上,应用了152层的深度残差神经网络,下图为34层Resnet
-
输入图片
3通道224x224
-
每一个卷积层之后应用批量归一化(batch normalization),激活函数之前
-
当输入和输出维度相同时可以直接走shortcut connections(图中实线)
当输入维度和输出维度不同时,有两种方法:1、添0 2、使用1x1的卷积改变通道数
-
注意两个conv的接口处要完成两项任务,这两项任务是在定义网络时通过stride和padding实现的
- 图片长宽尺寸变为原来的1/2
- 通道数变为原来的2倍
复现网络模型
整体结构
整个残差网络模型的构建包括3部分
-
残差块
2个卷积层以及批量归一化和激活函数,包括对加x的处理
-
由若干残差块构成的模块
模块中的第一个残差块需要对输入进行额外处理,中间残差块保持数据维度不变
-
其他部分
预处理卷积层和全连接层
定义残差块
一个残差块由两个卷积层构成,残差块中没有池化,但是有批量归一化BN和激活函数,其结构如下
注意:BN先于ReLU使用,这个图中没有表现出来
残差块有两种类型
- 大层次内部的残差块
- 大层次交接处第一个残差块
内部残差块输入维度和输出维度完全相同,且要加上x的维度和第二个卷积层输出维度完全相同,直接相加即可
交接处第一个残差块相比于内部残差块要多完成2项任务
- 改变输入维度,尺寸变为原来的1/2,通道数变为原来的2倍
- 输入x的维度与第二个卷积层输出的维度不同,要先对x使用1x1的卷积变换尺寸和通道数才能与第二个卷积层的输出相加(1x1卷积,s=2,实现尺寸减半且改变通道数)
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更多,如果还是沿用原来的残差结构,参数数量将过多。
降维升维减少参数个数的例子如下图所示
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相同,实现细节上有差异)
- 残差块3层卷积,各层作用(见上表)
- 每一个模块(大层次,共4个)由若干不同参数(通道数)的残差块构成
- 大层次第1个残差块需要对数据维度进行处理
- 尺寸(宽高)减半,通道数先降维为原来的1/2
- 第1个大层次的第1个残差块不需要对数据做处理
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)