ResNet一直都是非常卓越的性能级网络从 2015年诞生的原型ResNet一直到最近后续加了squeeze-and-excitation 模块的SEResNet, 因为残差机制使得网络层能够不断的加深并且有效的防止性能退化的问题
今天老样子先说原理后上代码和大家一起了解ResNet的理论和实际代码中的架构, 之后再说到其他变种
希望不会有小伙伴认为怎么2015年的网络 都2019了还有人拿来说, 残差结构可谓是经典中的经典, 但是有多少人能真正理解其背后含义?
好了, 废话不多说 入正题
从发现的问题开始着手
待会我们在来说一下梯度为什么会消失, ResNet又是怎么解决的
先说一下网络性能退化吧, 从网络的层数深度来探讨, 我们一方面希望层数越多越好, 这样一来能够从样本学习到越多的东西,每一层更丰富, 但是当网络层不断加深反而会衍生出网络性能退化的问题,有许多层是冗余的, 不必要的, 关于网络层数过多导致性能退化的问题似乎讨论度比较低, 有兴趣推荐以下这篇, 建议看原文
Why is it hard to train-deep-neural-networks-degeneracy-not-vanishing-gradient-is-the-key
这边还是稍微提一下, 原则上网络性能的下降不代表一定是梯度爆炸或者是消失所导致的, 这其实是两个不同层面的问题, 但是这边不细说
那么我们也不可能每一次都依照不同的任务重新的设计相对应的层数, 费时费力不符合成本, 于是ResNet问世了
直接来看ResNet的基本结构
这张图一定看过很多次, 但从来没好好理解过一次
设输入为 x x x
F ( x ) F(x) F(x) 理解为 x x x 经过各种卷积以及Bn、ReLU的操作
那么右边的 identity 又是什么呢?
我们透过网络退化问题可以了解到较浅层的layer表现的比深层的更好, 那么为什么我们不想个办法跳过这些会导致网络退化的层呢?
于是就有了一条自己抄捷径的shortcut, 也就是图上的identity
那么整个结构的表达式就可以为
H ( x ) = F ( x ) + x H(x) = F(x) + x H(x)=F(x)+x
H(x) 正是我们网络要学习的output
懵了吧 我们看下去
我们如果想让某些冗余的层, 不要影响网络性能, 因此想了个办法可以让该冗余层学习到的参数满足H(x) = x, 什么意思呢? 就是输入是x, 输出之后还是x, 没有变 !
我们来仔细看一下表达式 H(x) = F(x) + x, 我们如果能让网络学习让F(x) 为0,不就能让H(x) = x, 也就是输入等于输出了吗?这比让网络去学习H(x) = x还要简单的多, 因为网络层的权值初始化都会趋近于0, 原来就已经很靠近0了, 何必绕一大圈,并且透过ReLU的激活, 让负值为0, 能够加速让F(x)更接近0,这样当然快速的多,所以让F(x)学习为0来更新冗余层的参数肯定是比较快速的
我们重新将表达式整理成以下
(1) y l = h ( x l ) + F ( x l , W l ) y_l = h(x_l) + F(x_l, W_l) yl=h(xl)+F(xl,Wl)
(2) x l + 1 = f ( y l ) x_{l+1} = f(y_l) xl+1=f(yl)
h h h是恒等映射, 也就是右边的shortcut
F ( x l , W l ) F(x_l, W_l) F(xl,Wl) 是网络的一系列变化(conv1, bn, relu)
y l y_l yl 是输出
f f f 就是输出之后进行的ReLU function
我们让网络学习F 为0, h ( x l ) h(x_l) h(xl) 和 f ( y l ) f(y_l) f(yl) 都是恒等映射
所以
h ( x l ) = x l h(x_l) = x_l h(xl)=xl
如果 f f f 也是, 那么 x l + 1 ≡ y x_{l+1} ≡ y xl+1≡y , 这里≡ 表示恒等于的意思
公式(2)带回原来(1)的式子得到
x l + 1 = x 1 + F ( x l , W l ) x_{l+1} = x_1 + F(x_l, W_l) xl+1=x1+F(xl,Wl)
当再次传入到下一个block的时候 x l + 2 x_{l+2} xl+2
x l + 2 = x l + 1 + F ( x l + l , W l + l ) = x l + F ( x l , W l ) + F ( x l + 1 , W l + 1 ) x_{l+2}=x_{l+1}+F(x_{l+l},W_{l+l}) \\=x_l+F(x_l,W_l)+F(x_{l+1},W_{l+1}) xl+2=xl+1+F(xl+l,Wl+l)=xl+F(xl,Wl)+F(xl+1,Wl+1)
当从 x l + 2 x_{l+2} xl+2传入到下一个block的时候 x l + 3 x_{l+3} xl+3
x l + 3 = x l + 2 + F ( x l + 2 , W l + 2 ) = x 1 + F ( x 1 , W 1 ) + F ( x l + 1 , W l + 1 ) + F ( x l + 2 , W l + 2 ) x_{l+3} = x_{l+2} + F(x{l+2}, W_{l+2}) \\=x_1 + F(x_1, W_1) + F(x_{l+1}, W_{l+1})+F(x_{l+2}, W_{l+2)} xl+3=xl+2+F(xl+2,Wl+2)=x1+F(x1,W1)+F(xl+1,Wl+1)+F(xl+2,Wl+2)
依照这样循环下去
因此通式可以表达为
x L = x l + ∑ i = l L − 1 F ( x i , W i ) x_L = x_l + \sum^{L-1}_{i=l}F(x_i, Wi) xL=xl+∑i=lL−1F(xi,Wi)
所以任意深层的 X L X_L XL的输出, 都能表达前面 L − 1 L-1 L−1层 残差模块的叠加 和 浅层的输入特征 x l x_l xl,
那么反向传播的式子就会变成如下
Loss对任一层进行更新的话
现在回到我们前面说过梯度消失的问题
还是简单的先了解一下梯度消失的原因
梯度消失容易出现在深层的网络并且用了不合时宜的激励函数例如sigmoid
function, 它能将输入的值转换介于0-1之间, 我们都知道反向传播是从最后一层向前求导来更新参数, 当更新到激活层的地方时, sigmoid的导数会变的非常小趋近于0, 如下图可见红色的虚线就是求导后的sigmoid, 值最大并不超过0.25, 根据Chain rule, 每一层的导数相乘之后, 梯度将呈现指数形式的下降, 网络中每一次的激活层导数相乘越乘越小, 也可以总结出越靠后面的层越不容易出现梯度消失的问题,
那么好在ResNet的indentity connection 这条捷径并没有经过任何激活函数(反向式子中的1), 而是直接与block的输出相加, 所以求导之后的值还是很大,无论权值怎么乘,梯度都还是在正常的值, 实现网络层加深的可能
现在你应该对ResNet有更深的了解了吧 要是想懂的更透彻
自行推导一下ResNet反向来观察一下 " 1 "的作用
那么具体ResNet如何在Pytorch中实现呢?
还是依照几个思路来进行吧, 很多教学就是把整个代码一贴, 那还不如自己看源码就好
我们来手动实现一下ResNet-18 和ResNet-101吧, 光是会调用不值得一提
红框处可见两种不同层数的ResNet
蓝框处可见不同结构的残差block
首先在设计的时候就要先设想好 如何用最便利的方式表达这么多种层数的ResNet, 我们总不可能101层的真的写一百另一层吧
观察上图就能发现都是block的输出维度有做变化而已,ResNet 网络一共分为5个stage(看最左栏的conv1_x 到· conv5_x), 那么从block的输出通道也从64, 放大到512
我们首先import一下nn这个模块, 该模块已经封装了定义ResNet所需要的所有函数, 非常之强大, 后续也不会import 其他的了
import torch.nn as nn
然后我们发现到残差模块中都有至少一个3x3 的卷积
那么可以先定义一下conv3x3,
def conv3x3(in_channel, out_channel, stride=1):
return nn.Conv2d(in_channel, out_channel, stride=stride, kernel_size=3, padding=1, bias=False)
return的地方直接返回一个Conv2d的输出, 卷积核预设为3, padding值为1
这边注意到bias的部分为False(预设是True)
是因为已经被激活函数前的BatchNorm层的 β \beta β 给取代了
具体原因请看论文 1502.03167.pdf Section 3.2 有说到
定义好block中3x3的卷积之后, 来定义一下整个Basicblock吧
先有几个思路在脑海中
该注意的细节已经在代码旁
class BasicBlock(nn.Module):
expansion = 1 #主要是定义输出通道的放大倍率, 在bottleneck会用上
def __init__(self, in_planes, out_planes, stride, downsample=None):
super(BasicBlock, self).__init__() #记得继承父类
self.conv1 = conv3x3(in_planes, out_planes, stride=stride)
self.bn1 = nn.BatchNorm2d(out_planes) #BN通常依据上一层输出的维度做BN
self.conv2 = conv3x3(in_planes, out_planes, stride=stride)
self.bn2 = nn.BatchNorm2d(out_planes)
self.relu = nn.ReLU(inplace=True) #inplace表示对原数据修改, 而非产生新数据, 节省内存
self.downsample = downsample
self.stride = stride
def forward(self, x):
identity = x
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.conv2(x)
x = self.bn2(x)
out = self.relu(x)
if self.downsample is not None:
x = self.downsample(x)
out += identity
out = self.relu(out)
return out
这边简单说下downsample的作用是为了避免 y = F ( x i , W i ) + x y = F(x_i, W_i) + x y=F(xi,Wi)+x 中 F ( x i , W i ) + x F(x_i, W_i) + x F(xi,Wi)+x相加的部分因为维度不同没法相加 所进行的一个转换, 那么式子会变成 y = F ( x i , W i ) + W s x y = F(x_i, W_i) + W_sx y=F(xi,Wi)+Wsx, 这个到后面定义网络主架构的时候在提
那么接下来可以定义一下另一种残差模块Bottleneck, 加入了conv1x1 减少了参数量, 主要给网络层数较深的使用
来说明一下与Basic不同的地方
expansion = 4 :请看图中的蓝框可以发现bottleneck的最后一层1x1输出的维度是第1(conv1x1), 2(conv3x3)层的四倍, 因此放大倍率为4
主结构变成 1x1, 3x3, 1x1
class Bottleneck(nn.Module):
expansion = 4 #注意最后一层的out_channel要乘上放大倍率
def __init__(self, in_planes, out_planes, stride, downsample=None):
super(Bottleneck, self).__init__()
self.conv1 = conv1x1(in_planes, out_planes, stride=stride)
self.bn1 = nn.BatchNorm2d(out_planes) #for conv2
self.conv2 = conv3x3(in_planes, out_planes, stride=stride)
self.bn2 = nn.BatchNorm2d(out_planes) #for conv2
self.conv3 = conv1x1(in_planes, out_planes * self.expansion, stride=stride)
self.bn3 = nn.BatchNorm2d(out_planes * self.expansion) #for conv3
self.relu = nn.ReLU(inplace=True)
self.downsample = downsample
self.stride = stride
def forward(self, x):
identity = x
x = self.conv1(x)
x = self.bn(x)
x = self.relu(x)
x = self.conv2(x)
x = self.bn(x)
x = self.relu(x)
x = self.conv3(x)
out = self.bn3(x)
if self.downsample is not None:
identity = self.downsample(x)
out += identity
out = self.relu(out)
return out
接下来只要定义ResNet的网络主体就可以了
class ResNet(nn.Module):
def __init__(self, block, stages, num_classes=1000, zero_init_residual=False):
super(ResNet, self).__init__()
self.inplanes = 64 #第一个stage通道数一定是64, 因为先经过(64, 7, 7)的conv1
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)
self.layer1 = self._make_layer(block, 64, stages[0], stride=1)
self.layer2 = self._make_layer(block, 128, stages[1], stride=2)
self.layer3 = self._make_layer(block, 256, stages[2], stride=2)
self.layer4 = self._make_layer(block, 512, stages[3], stride=2)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) #算是一种global average pooling
self.fc = nn.Linear(512*block.expansion, num_classes)
# 最后一层实现全连接
#输入就是前一层的输出(512, 1, 1), 输出就是类别数
for m in self.modules():
if isinstance(m, nn.Conv2d):#只要是卷积都操作, 都对weight和bias进行kaiming初始化
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
elif isinstance(m, nn.BatchNorm2d):#bn层都权重初始化为1, bias=0
nn.init.constant_(m.weight, 1)
nn.init.constant_(m.bias, 0)
'''
根据以下论文, 在每个block最后的一个BN进行权重0值初始化
有助于提升精度
https://arxiv.org/abs/1706.02677
'''
if zero_init_residual:
for m in self.modules():
if isinstance(m, Bottleneck): #如果是实例bottleneck的话
nn.init.constant_(m.bn3.weight, 0)
elif isinstance(m, BasicBlock):
nn.init_constant_(m.bn2.weight, 0)
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) #after pooling shape(N, 1, 1)
x = x.view(x.size(0), -1)
out = self.fc(x)
return out
def _make_layer(self, block, out_planes, blocks, stride=1):
downsample = None
if stride !=1 or self.inplanes != out_planes * block.expansion:
downsample = nn.Sequential(
conv1x1(self.inplanes, out_planes* block.expansion, stride),
nn.BatchNorm2d(out_planes * block.expansion)
)
layers = [] #空列表
layers.append(block(self.inplanes, out_planes, stride, downsample)) #添加进第一个block,
self.inplanes = out_planes * block.expansion
#确保上一层输出与下一层的输入通道数相同
for i in range(1, blocks): #blocks(设定每stage多少blocks), 有几个block就添加blocks-1个(前面已经添加第一个block)
layers.append(block(self.inplanes, out_planes, stride))
return nn.Sequential(*layers)
这边要特别说一下_make_layer这个函数
我个人认为能想出这样结构来简单的实现各种层数是很牛的
这个函数的功能主要是将ResNet 的 stage 2~5实现, 利用for loop将每一个stage需要的block装进去
首先条件式判断
if stride !=1 or self.inplanes != out_planes * block.expansion:
输入通道数不等于输出通道数时定义downsample
这里用到nn的Sequential这个类, 就类似于我们定义的forward一样, 能将各种操作封装到一个变量中
downsample = nn.Sequential(
conv1x1(self.inplanes, out_planes* block.expansion, stride),
nn.BatchNorm2d(out_planes * block.expansion)
)
接下来就是依照结构设计将block装进列表
layers =[] #空列表
layers.append(block(self.inplanes, out_planes, stride, downsample)) #添加进第一个block,
self.inplanes = out_planes * block.expansion
#确保上一层输出与下一层的输入通道数相同
for i in range(1, blocks): #blocks(设定每stage多少blocks), 有几个block就添加blocks-1个(前面已经添加第一个block)
layers.append(block(self.inplanes, out_planes, stride))
return nn.Sequential(*layers)
其中self.inplanes = out_planes * block.expansion
的用意如下图
可以确定第一组bottleneck输出为1024, 第二组bottleneck的输入也同样为1024, 要是少了这组代码输入将全为统一通道数64, 不信的可以试试
最终我们主体的两大部分都已经完成了
ResNet主体和block(basic / bottleneck)的部分
在来依照需求组装就行
例如ResNet 18, stage 2 ~ stage5 每个stage都是两组block
那么重新定义函数
def ResNet18(pretrained = False, **kwargs):
model = ResNet(BasicBlock, [2, 2, 2, 2], **kwargs)
if pretrained:
print("Just a test, show download from mode_zoo url")
return model
def ResNet101(pretrained = False, **kwargs):
model = ResNet(Bottleneck, [3, 4, 23, 3], **kwargs)
if pretrained:
print("Just a test, show download from mode_zoo url")
return model
pretrained 这个形参依据bool值判别是否进行加载预训练模型, 这边只是练习就不写上了, 有兴趣可以看torchvision.model里怎么调用的
**kwargs 留下可以添加参数的空间, 例如num_classes=1000
, zero_init_residual=False
然后就没有然后了, 搞定 !
Part2准备写一下ResNet的变种 ResNext系列
一样会先说一下网络结构在用代码实现
参考
https://towardsdatascience.com/the-vanishing-gradient-problem-69bf08b15484)
https://towardsdatascience.com/residual-blocks-building-blocks-of-resnet-fd90ca15d6ec