0、前言
何凯明等人在2015年提出的ResNet,在ImageNet比赛classification任务上获得第一名,获评CVPR2016最佳论文。因为它“简单与实用”并存,之后许多目标检测、图像分类任务都是建立在ResNet的基础上完成的,成为计算机视觉领域重要的基石结构。
- 本文对ResNet的论文进行简单梳理,并对其网络结构进行分析,然后对Torchvision版的ResNet代码进行解读,最后对ResNet训练自有网络进行简单介绍,相关参考链接附在文末;
- 论文连接:Deep Residual Learning for Image Recognition
- 代码链接:https://github.com/pytorch/vision/blob/master/torchvision/models/resnet.py
- 本文所有代码解读均基于PyTroch 1.0,Python3;
- 本文为原创文章,初次完成于2019.01,最后更新于2019.03;
1、ResNet要解决什么问题?
自从深度神经网络在ImageNet大放异彩之后,后来问世的深度神经网络就朝着网络层数越来越深的方向发展。直觉上我们不难得出结论:增加网络深度后,网络可以进行更加复杂的特征提取,因此更深的模型可以取得更好的结果。
但事实并非如此,人们发现随着网络深度的增加,模型精度并不总是提升,并且这个问题显然不是由过拟合(overfitting)造成的,因为网络加深后不仅测试误差变高了,它的训练误差竟然也变高了。作者提出,这可能是因为更深的网络会伴随梯度消失/爆炸问题,从而阻碍网络的收敛。作者将这种加深网络深度但网络性能却下降的现象称为退化问题(degradation problem)。
Is learning better networks as easy as stacking more layers? An obstacle to answering this question was the notorious problem of vanishing/exploding gradients [1, 9], which hamper convergence from the beginning.
Unexpectedly, such degradation is not caused by overfitting, and adding more layers to a suitably deep model leads to higher training error.
文中给出的实验结果进一步描述了这种退化问题:当传统神经网络的层数从20增加为56时,网络的训练误差和测试误差均出现了明显的增长,也就是说,网络的性能随着深度的增加出现了明显的退化。ResNet就是为了解决这种退化问题而诞生的。
图120层与56层传统神经网络在CIFAR上的训练误差和测试误差
2、ResNet怎么解决网络退化问题
随着网络层数的增加,梯度爆炸和梯度消失问题严重制约了神经网络的性能,研究人员通过提出包括Batch normalization在内的方法,已经一定程度上缓解了这个问题,但依然不足以满足需求。
This problem,however, has been largely addressed by normalized initialization [23, 9, 37, 13] and intermediate normalization layers[16], which enable networks with tens of layers to start converging for stochastic gradient descent (SGD) with backpropagation [22].
作者想到了构建恒等映射(Identity mapping)来解决这个问题,问题解决的标志是:增加网络层数,但训练误差不增加。为什么是恒等映射呢,我是这样子想的:20层的网络是56层网络的一个子集,56层网络的解空间包含着20层网络的解空间。如果我们将56层网络的最后36层全部短接,这些层进来是什么出来也是什么(也就是做一个恒等映射),那这个56层网络不就等效于20层网络了吗,至少效果不会相比原先的20层网络差吧。同样是56层网络,不引入恒等映射为什么就不行呢?因为梯度消失现象使得网络难以训练,虽然网络的深度加深了,但是实际上无法有效训练网络,训练不充分的网络不但无法提升性能,甚至降低了性能。
There exists a solution by construction to the deeper model: the added layers are identity mapping, and the other layers are copied from the learned shallower model. The existence of this constructed solution indicates that a deeper model should produce no higher training error than its shallower counterpart.
图2 残差学习基本单元
那怎么构建恒等映射呢?简单地说,原先的网络输入x,希望输出H(x)。现在我们改一改,我们令H(x)=F(x)+x,那么我们的网络就只需要学习输出一个残差F(x)=H(x)-x。作者提出,学习残差F(x)=H(x)-x会比直接学习原始特征H(x)简单的多。
3、ResNet网络结构与代码实现
ResNet主要有五种主要形式:Res18,Res34,Res50,Res101,Res152;
如下图所示,每个网络都包括三个主要部分:输入部分、输出部分和中间卷积部分(中间卷积部分包括如图所示的Stage1到Stage4共计四个stage)。尽管ResNet的变种形式丰富,但都遵循上述的结构特点,网络之间的不同主要在于中间卷积部分的block参数和个数存在差异。下面我们以ResNet18为例,看一下整个网络的实现代码是怎样的。
图3.1 ResNet结构总览
- 网络整体结构
我们通过调用resnet18( )函数来生成一个具体的model,而resnet18函数则是借助ResNet类来构建网络的。
class ResNet(nn.Module):
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)
# 输出
x = self.avgpool(x)
x = x.view(x.size(0), -1)
x = self.fc(x)
return x
# 生成一个res18网络
def resnet18(pretrained=False, **kwargs):
model = ResNet(BasicBlock, [2, 2, 2, 2], **kwargs)
if pretrained:
model.load_state_dict(model_zoo.load_url(model_urls['resnet18']))
return model
在ResNet类中的forward( )函数规定了网络数据的流向:
(1)数据进入网络后先经过输入部分(conv1, bn1, relu, maxpool);
(2)然后进入中间卷积部分(layer1, layer2, layer3, layer4,这里的layer对应我们之前所说的stage);
(3)最后数据经过一个平均池化和全连接层(avgpool, fc)输出得到结果;
具体来说,resnet18和其他res系列网络的差异主要在于layer1~layer4,其他的部件都是相似的。
- 网络输入部分
所有的ResNet网络输入部分是一个size=7x7, stride=2的大卷积核,以及一个size=3x3, stride=2的最大池化组成,通过这一步,一个224x224的输入图像就会变56x56大小的特征图,极大减少了存储所需大小。
self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)
self.bn1 = nn.BatchNorm2d(64)
self.relu = nn.ReLU(inplace=True)
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
输入层特征图数据变化
- 网络中间卷积部分
中间卷积部分主要是下图中的蓝框部分,通过3*3卷积的堆叠来实现信息的提取。红框中的[2, 2, 2, 2]和[3, 4, 6, 3]等则代表了bolck的重复堆叠次数。
ResNet结构细节
刚刚我们调用的resnet18( )函数中有一句 ResNet(BasicBlock, [2, 2, 2, 2], **kwargs),这里的[2, 2, 2, 2]与图中红框是一致的,如果你将这行代码改为 ResNet(BasicBlock, [3, 4, 6, 3], **kwargs), 那你就会得到一个res34网络。
- 残差块实现
下面我们来具体看一下一个残差块是怎么实现的,如下图所示的basic-block,输入数据分成两条路,一条路经过两个3*3卷积,另一条路直接短接,二者相加经过relu输出,十分简单。
basic_block
class BasicBlock(nn.Module):
expansion = 1
def __init__(self, inplanes, planes, stride=1, downsample=None):
super(BasicBlock, self).__init__()
self.conv1 = conv3x3(inplanes, planes, stride)
self.bn1 = nn.BatchNorm2d(planes)
self.relu = nn.ReLU(inplace=True)
self.conv2 = conv3x3(planes, planes)
self.bn2 = nn.BatchNorm2d(planes)
self.downsample = downsample
self.stride = stride
def forward(self, x):
identity = x
out = self.conv1(x)
out = self.bn1(out)
out = self.relu(out)
out = self.conv2(out)
out = self.bn2(out)
if self.downsample is not None:
identity = self.downsample(x)
out += identity
out = self.relu(out)
return out
bascic_block 数据走向
代码比较清晰,不做分析了,主要提一个点:downsample,它的作用是对输入特征图大小进行减半处理,每个stage都有且只有一个downsample。后面我们再详细介绍。
- 网络输出部分
网络输出部分很简单,通过全局自适应平滑池化,把所有的特征图拉成1*1,对于res18来说,就是1x512x7x7 的输入数据拉成 1x512x1x1,然后接全连接层输出,输出节点个数与预测类别个数一致。
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512 * block.expansion, num_classes)
至此,整体网络结构代码分析结束,更多细节,请看torchvision源码。
4、Bottleneck结构和1*1卷积
ResNet50起,就采用Bottleneck结构,主要是引入1x1卷积。我们来看一下这里的1x1卷积有什么作用:
- 对通道数进行升维和降维(跨通道信息整合),实现了多个特征图的线性组合,同时保持了原有的特征图大小;
- 相比于其他尺寸的卷积核,可以极大地降低运算复杂度;
- 如果使用两个3x3卷积堆叠,只有一个relu,但使用1x1卷积就会有两个relu,引入了更多的非线性映射;
Basicblock和Bottleneck结构
我们来计算一下1*1卷积的计算量优势:首先看上图右边的bottleneck结构,对于256维的输入特征,参数数目:1x1x256x64+3x3x64x64+1x1x64x256=69632,如果同样的输入输出维度但不使用1x1卷积,而使用两个3x3卷积的话,参数数目为(3x3x256x256)x2=1179648。简单计算下就知道了,使用了1x1卷积的bottleneck将计算量简化为原有的5.9%,收益超高。
5、ResNet的网络设计规律
整个ResNet不使用dropout,全部使用BN。此外,回到最初的这张细节图,我们不难发现一些规律和特点:
- 受VGG的启发,卷积层主要是3×3卷积;
- 对于相同的输出特征图大小的层,即同一stage,具有相同数量的3x3滤波器;
- 如果特征地图大小减半,滤波器的数量加倍以保持每层的时间复杂度;(这句是论文和现场演讲中的原话,虽然我并不理解是什么意思)
- 每个stage通过步长为2的卷积层执行下采样,而却这个下采样只会在每一个stage的第一个卷积完成,有且仅有一次。
- 网络以平均池化层和softmax的1000路全连接层结束,实际上工程上一般用自适应全局平均池化 (Adaptive Global Average Pooling);
从图中的网络结构来看,在卷积之后全连接层之前有一个全局平均池化 (Global Average Pooling, GAP) 的结构,这个结构最早出自经典论文: Network In Network
In this paper, we propose another strategy called global average pooling to replace the traditional fully connected layers in CNN. The idea is to generate one feature map for each corresponding category of the classification task in the last mlpconv layer. Instead of adding fully connected layers on top of the feature maps, we take the average of each feature map, and the resulting vector is fed directly into the softmax layer. One advantage of global average pooling over the fully connected layers is that it is more native to the convolution structure by enforcing correspondences between feature maps and categories. Thus the feature maps can be easily interpreted as categories confidence maps. Another advantage is that there is no parameter to optimize in the global average pooling thus overfitting is avoided at this layer. Futhermore, global average pooling sums out the spatial information, thus it is more robust to spatial translations of the input.
We can see global average pooling as a structural regularizer that explicitly enforces feature maps to be confidence maps of concepts (categories).This is made possible by the mlpconv layers, as they makes better approximation to the confidence maps than GLMs.
总结如下:
- 相比传统的分类网络,这里接的是池化,而不是全连接层。池化是不需要参数的,相比于全连接层可以砍去大量的参数。对于一个7x7的特征图,直接池化和改用全连接层相比,可以节省将近50倍的参数,作用有二:一是节省计算资源,二是防止模型过拟合,提升泛化能力;
- 这里使用的是全局平均池化,但我觉得大家都有疑问吧,就是为什么不用最大池化呢?这里解释很多,我查阅到的一些论文的实验结果表明平均池化的效果略好于最大池化,但最大池化的效果也差不到哪里去。实际使用过程中,可以根据自身需求做一些调整,比如多分类问题更适合使用全局最大池化(道听途说,不作保证)。如果不确定话还有一个更保险的操作,就是最大池化和平均池化都做,然后把两个张量拼接,让后续的网络自己学习权重使用。
6、如何改造得到自己的ResNet?
我举一个简单的例子
from torchvision.models.resnet import *
def get_net():
model = resnet18(pretrained=True)
model.avgpool = nn.AdaptiveAvgPool2d((1, 1))
model.fc = nn.Sequential(
nn.BatchNorm1d(512*1),
nn.Linear(512*1, 你的分类类别数),
)
return model
代码简单解读一下:
- 首先,通过torchvision导入相关的函数
- 通过resnet18( )实例化一个模型,并使用imagenet预训练权重
- 将平均池化修改为自适应全局平均池化,避免输入特征尺寸不匹配
- 修改全连接层,主要是修改分类类别数,并加入BN1d
这样子,不仅可以根据自己的需求改造网络,还能最大限度的使用现成的预训练权重。需要注意的是,这里的nn.BatchNorm1d(512*1)是很必要的,初学者可以尝试删除这个部件感受一下区别。在我曾经的实验里面,loss会直接爆炸。
7、ResNet的常见改进
- 改进一:改进downsample部分,减少信息流失。前面说过了,每个stage的第一个conv都有下采样的步骤,我们看左边第一张图左侧的通路,input数据进入后在会经历一个stride=2的1*1卷积,将特征图尺寸减小为原先的一半,请注意1x1卷积和stride=2会导致输入特征图3/4的信息不被利用,因此ResNet-B的改进就是就是将下采样移到后面的3x3卷积里面去做,避免了信息的大量流失。ResNet-D则是在ResNet-B的基础上将identity部分的下采样交给avgpool去做,避免出现1x1卷积和stride同时出现造成信息流失。ResNet-C则是另一种思路,将ResNet输入部分的7x7大卷积核换成3个3x3卷积核,可以有效减小计算量,这种做法最早出现在Inception-v2中。其实这个ResNet-C 我比较疑惑,ResNet论文里说它借鉴了VGG的思想,使用大量的小卷积核,既然这样那为什么第一部分依旧要放一个7x7的大卷积核呢,不知道是出于怎样的考虑,但是现在的多数网络都把这部分改成3个3x3卷积核级联。
ResNet的三种改进
- 改进二:ResNet V2。这是由ResNet原班人马打造的,主要是对ResNet部分组件的顺序进行了调整。各种魔改中常见的预激活ResNet就是出自这里。
ResNet V2
原始的resnet是上图中的a的模式,我们可以看到相加后需要进入ReLU做一个非线性激活,这里一个改进就是砍掉了这个非线性激活,不难理解,如果将ReLU放在原先的位置,那么残差块输出永远是非负的,这制约了模型的表达能力,因此我们需要做一些调整,我们将这个ReLU移入了残差块内部,也就是图e的模式。这里的细节比较多,建议直接阅读原文:Identity Mappings in Deep Residual Networks ,就先介绍这么多。
8.1、从模型集成角度理解ResNet的有效性
ResNet 中其实是存在着很多路径的集合,整个ResNet类似于多个网络的集成学习,证据是删除部分ResNet的网络结点,不影响整个网络的性能,但是在VGG上做同样的事请网络立刻崩溃,由此可见相比其他网络ResNet对于部分路径的缺失不敏感。更多细节具体可参见NIPS论文: Residual Networks Behave Like Ensembles of Relatively Shallow Networks ;
模型集成假说
破坏性实验
8.2、从梯度反向传播角度理解ResNet的有效性
残差结构使得梯度反向传播时,更不易出现梯度消失等问题,由于Skip Connection的存在,梯度能畅通无阻地通过各个Res blocks,下面我们来推导一下 ResNet v2 的反向传播过程。
原始的残差公式是这样子的,函数F表示一个残差函数,函数f表示激活函数,:
ResNet v2 使用恒等映射,且相加后不使用激活函数,因此可得到:
递归得到第L层的表达式:
反向传播求第l层梯度:
我们从这个表达式可以看出来:第l层的梯度里,包含了第L层的梯度,通俗的说就是第L层的梯度直接传递给了第l层。因为梯度消失问题主要是发生在浅层,这种将深层梯度直接传递给浅层的做法,有效缓解了深度神经网络梯度消失的问题。
8.3、其他假说汇总
(1)差分放大器假说
残差结构可以放大输入中微小的扰动,因此更加灵敏;
(2)自适应深度(https://www.zhihu.com/question/293243905/answer/484708047)
传统的conv模块是很难通过学习变成恒等的,因为大家学过信号与系统都知道,恒等的话filter的冲激响应要为一个冲激函数,而神经网络本质是学概率分布 局部一层不太容易变成恒等,而resnet加入了这种模块给了神经网络学习恒等映射的能力。所以我个人理解resnet除了减弱梯度消失外,我还理解为这是一种自适应深度,也就是网络可以自己调节层数的深浅,不需要太深时,中间恒等映射就多,需要时恒等映射就少。当然了,实际的神经网络并不会有这么强的局部特性,它的训练结果基本是一个整体,并不一定会出现我说的某些block就是恒等的情况
9、总结
ResNet是当前计算机视觉领域的基石结构,是初学者无法绕开的网络模型,仔细阅读论文和源码并进行实验是极有必要的。
最后,强烈推荐大家看一下何凯明的现场演讲,有助于更好地理解ResNet。