ResNet残差网络和pytorch源码分析

论文:Deep Residual Learning for Image Recognition

网络深度对模型的准确性是至关重要的,更深的层可以学习到更加丰富和抽象的特征信息,Resnet论文也通过大量的实验证明可以通过增加网络深度的方式来提高准确率。但是如果只是简单堆叠更多层,会出现一个退化问题:随着网络的加深,准确率会先达到饱和而后快速下降,而且这种退化不是由过拟合引起的。并且通过实验发现更深的网络有着更高的训练误差和测试误差。在Resnet论文中通过34层简单深层网络的优化难度不是梯度消失引起的,它的误差既不是前向信号消失也不是反向信号消失,推测深度简单网络可能有指数级低收敛特性,影响了训练误差的降低。并说原因将来会继续研究,所以深层模型的优化问题不能简单的说成过拟合梯度消失或者梯度爆炸问题,具体是什么还要多从别的研究中找找。

                                   

论文前面就提出过一种构建更深层模型的解决方案,就是通过恒等映射的方式拷贝浅层模型来获得一个深层模型,例如一层通过H(x)=x来计算输出层,这样获得的模型至少不会比浅层模型差。H(x)=x这样并没有什么意义,Resnet是怎么做的呢?

Resnet通过拟合残差映射来实现。在常规网络中输入x,通过变换H(x)=g(Wx+b),输出H(x),其中g为激活函数,这样在较深的网络中很难训练。Resnet不是直接得到H(x)而是训练残差F(x)=H(x)-x,也就是输入x,首先得到输出F(x),然后通过H(x)=F(x)+x得到最终一个残差块的输出,当然F(x)可能是两层或这三层的计算结果。然后通过叠加残差块,构造成50层、100层甚至1000层更深的网络。如下图为一个残差块

                                                                               

这种残差块的意义或者原理在哪呢,最直观的看,残差块通过跳跃链接直接将x拷贝到了神经网络的深层(上面是两层之后),这样x的特征信息没有沿主路传输而是直接到达更深的层次。上面残差块的计算过程是:假设输入为,输出为 :

                                                                     

其中  ,如果训练过程中应用权重衰减使得 为0矩阵, 也为0矩阵这样F(x)就为0,再使用激活函数Relu会得到什么结果呢?因为 在上一层输出时已经经过Rule非线性激活,所有 一定是个非负数,这样,残差网络就学到了恒等映射,这样模型就不会比浅层的差,而且模型更会学习到更多有用的信息。直白来说残差网络要么学习到了有用的信息要么是一个恒等映射,怎样都至少不比浅层的差。

残差网络通过F(x)+x这种跳跃连接的方式,直接将F(x)于x相加,这样比起一般的深度模型并没有加入任何外部参数也没有增加计算复杂度。但是随着网络不断的前向计算x的尺寸实在减小而通道实在加深的,这样x与F(x)的维度并不统一不能直接相加,这时候就需要乘以一个权值矩阵w将x维度变换为F相同,也就是F(x)+wx,注意只有在尺度变化的时候才对x使用线性变换,论文也有过实验对所有的残差块都用F(x)+wx,但是在准确性上没有什么提高反而增加了计算的复杂度。所以只有在x维度变化时才加入一个线性变换,用于连接。

关于残差块的设计。论文中给出了两种不同的残差块以及不同网络深度的模型设计

                                                            

 

                          

残差块是可以有不同的形式,论文中实验了两层和三层的函数,对于单层的残差块类似于线性层y=Wx+x,没有什么优势。更深的网络的残差块是三层的,三层分别是是1×1,3×3和1×1卷积,其中1×1层负责减小然后增加(恢复)维度,使3×3层成为具有较小输入/输出维度的瓶颈。

另外,Kaiming He还有一篇文章对不同残差单元的设计对性能的影响分析,主要分析Relu函数的位置不同分为预激活和后激活,文章连接:https://arxiv.org/abs/1603.05027,这篇文章将激活函数(ReLU和BN)移到权值层之前,形成一种“预激活(pre-activation)”的方式,而不是常规的“后激活(post-activation)”方式,这样就设计出了一种新的残差单元(⻅图1(b))。基于这种新的单元在CIFAR-10/100数据集上使用1001层残差网络进行训练,发现新的残差网络比之前的更容易训练并且泛化性能更好。另外还考察了200层新残差网络在ImageNet上的表现,原先的残差网络在这个层数之后开始出现过拟合的现象。

                                                      

可以去看看这篇文章,接下来还是接着Resnet的原始论文讲。

关于shortcut(有叫跳跃连接也有叫快捷连接),论文中也实验了三种方式,1) 零填充shortcut用来增加维度,所有的shortcut是没有参数的;(2)投影shortcut用来增加维度,其它的shortcut是恒等的,也就是前面说的维度相同做恒等变换,不同做线性变换;3)所有的快捷连接都是投影。实验表明3比2好,2比1好,但是它们的差别是细微的,对于解决退化问题不是很重要,从减小内存和时间复杂度以及模型大小,2的方式是最佳的,后来大多数的resnet的实现都是使用方式2和三层的残差块。

50层ResNet:我们用3层瓶颈块替换34层网络中的每一个2层块,得到了一个50层ResNet。我们使用选项B来增加维度。该模型有38亿FLOP。

101层和152层ResNet:我们通过使用更多的3层瓶颈块来构建101层和152层ResNets。值得注意的是,尽管深度显著增加,但152层ResNet(113亿FLOP)仍然比VGG-16/19网络(153/196亿FLOP)具有更低的复杂度。

50/101/152层ResNet比34层ResNet的准确性要高得多。并且没有观察到退化问题,因此可以从增加深度中获得显著的准确性收益。所有评估指标都能证明深度的收益。

论文中还做了层响应分析

                                            

分析每一层在bn层之后,Relu之前的3×3卷积层响应的标准偏差,ResNet的响应比其对应的简单网络的响应更小。这些结果支持了论文的想法,残差函数通常具有比非残差函数更接近零。更深的ResNet具有较小的响应幅度,如图中ResNet-20,56和110。当层数更多时,单层ResNet趋向于更少地修改信号。

下面看一下pytorch的官方实现

forward是pytorch定义网络的执行接口,先从forward函数开始

    def _forward_impl(self, x):
        # See note [TorchScript super()]
        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 = torch.flatten(x, 1)
        x = self.fc(x)

        return x

    def forward(self, x):
        return self._forward_impl(x)

先执行一个conv1卷积层,之后是一个bn层,relu层和maxpool(x)层

self.conv1 = nn.Conv2d(3, self.inplanes, kernel_size=7, stride=2, padding=3,
                               bias=False)

conv1是一个7×7步长为2的卷积,滤波器个数是inplanes,源码中给的是64。

之后是layer1,layer2,layer3和layer4,以101层的resnet为例,对应的分别是  、 和

        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])

看_make_layer函数

def _make_layer(self, block, planes, blocks, stride=1, dilate=False):
        norm_layer = self._norm_layer//bn层
        downsample = None
        previous_dilation = self.dilation
        if dilate:
            self.dilation *= stride
            stride = 1
        if stride != 1 or self.inplanes != planes * block.expansion:
         //这里可以看到stride不等于1,说明长宽会缩放,self.inplanes != planes * block.expansion不相等说明输入输出的通道数不同,这样就要使用downsample线性变换之后才能跟残差块的输出相加
            downsample = nn.Sequential(
                conv1x1(self.inplanes, planes * block.expansion, stride),
                norm_layer(planes * block.expansion),
            )

        layers = []
        layers.append(block(self.inplanes, planes, stride, downsample, self.groups,
                            self.base_width, previous_dilation, norm_layer))
        self.inplanes = planes * block.expansion
        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)

看里面的这句 if stride != 1 or self.inplanes != planes * block.expansion
        这里可以看到stride不等于1,说明长宽会缩放,self.inplanes != planes * block.expansion不相等说明输入输出的通道数不同,这样就要使用downsample线性变换之后才能跟残差块的输出相加

类block如或是50层以上就等于类Bottleneck,以下的等于类BasicBlock,我们看Bottleneck

class Bottleneck(nn.Module):
//一个残差块的计算
    # Bottleneck in torchvision places the stride for downsampling at 3x3 convolution(self.conv2)
    # while original implementation places the stride at the first 1x1 convolution(self.conv1)
    # according to "Deep residual learning for image recognition"https://arxiv.org/abs/1512.03385.
    # This variant is also known as ResNet V1.5 and improves accuracy according to
    # https://ngc.nvidia.com/catalog/model-scripts/nvidia:resnet_50_v1_5_for_pytorch.

    expansion = 4

    def __init__(self, inplanes, planes, stride=1, downsample=None, groups=1,
                 base_width=64, dilation=1, norm_layer=None):
        super(Bottleneck, self).__init__()
        if norm_layer is None:
            norm_layer = nn.BatchNorm2d
        width = int(planes * (base_width / 64.)) * groups
        # Both self.conv2 and self.downsample layers downsample the input when stride != 1
        self.conv1 = conv1x1(inplanes, width)
        self.bn1 = norm_layer(width)
        self.conv2 = conv3x3(width, width, stride, groups, dilation)
        self.bn2 = norm_layer(width)
        self.conv3 = conv1x1(width, planes * self.expansion)
        self.bn3 = norm_layer(planes * self.expansion)
        self.relu = nn.ReLU(inplace=True)
        self.downsample = downsample
        self.stride = stride

    def forward(self, x):
        identity = x

        out = self.conv1(x) //1×1卷积
        out = self.bn1(out)
        out = self.relu(out)

        out = self.conv2(out) //3*3卷积
        out = self.bn2(out)
        out = self.relu(out)

        out = self.conv3(out) //1×1卷积
        out = self.bn3(out)

        if self.downsample is not None:
            identity = self.downsample(x) //投影变换

        out += identity              //连接
        out = self.relu(out)

        return out

这部分就是一个残差块的计算,1×1卷积--3*3卷积--1*1卷积中间接bn层和Relu层得到输出out,最后维度的不同会使用downsample做线性变化之后与out维度相同再相加作为最后残差块的输出。在网络的构建中使用不同的残差块叠加,例如  表示四个相同的残差块叠加,程序中只有在第一个残差块中使用对输入x使用线性变换,所有对于101层的resnet中总共也就使用了4次。

layer4之后是平均池化,这里用的不是常说的最大池化,池化层之后是一个全连接层。

self.avgpool = nn.AdaptiveAvgPool2d((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')
            elif isinstance(m, (nn.BatchNorm2d, nn.GroupNorm)):
                nn.init.constant_(m.weight, 1)
                nn.init.constant_(m.bias, 0)

卷积层用的是kaiming_normal_初始化,bn层weight初始化为1,bias初始化为0.

另外源码中还对没一个残差块的最后一个bn层单拿出来初始化为0

       # Zero-initialize the last BN in each residual branch,
        # so that the residual branch starts with zeros, and each residual block behaves like an identity.
        # This improves the model by 0.2~0.3% according to https://arxiv.org/abs/1706.02677
if zero_init_residual:
            for m in self.modules():
                if isinstance(m, Bottleneck):
                    nn.init.constant_(m.bn3.weight, 0)
                elif isinstance(m, BasicBlock):
                    nn.init.constant_(m.bn2.weight, 0)

这也算一种小trick吧,源码中给了使用这样方法参考的文献(https://arxiv.org/abs/1706.02677),并且这种简单方式是使模型性能提高了0.2~0.3% 。

 

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值