深度學習經典網絡:ResNet及其變體(ResNet v1)

ResNetv1:https://arxiv.org/abs/1512.03385
pytorch 代碼:https://github.com/pytorch/vision/blob/master/torchvision/models/resnet.py
keras 代碼:https://github.com/keras-team/keras-applications/blob/master/keras_applications/resnet.py

1 ResNet v1要解決的問題

  深度神經網絡朝着網絡層數越來越深的方向發展。直覺上我們不難得出結論:增加網絡深度後,網絡可以進行更加複雜的特徵提取,因此更深的模型可以取得更好的結果。但事實並非如此,人們發現隨着網絡深度的增加,模型精度並不總是提升,並且這個問題顯然不是由過擬合(overfitting)造成的,因爲網絡加深後不僅測試誤差變高了,它的訓練誤差竟然也變高了。作者提出,這可能是因爲更深的網絡會伴隨梯度消失/爆炸問題,從而阻礙網絡的收斂。作者將這種加深網絡深度但網絡性能卻下降的現象稱爲退化問題(degradation problem)。導致網絡性能下降的原因是梯度消失問題, 神經網絡在反向傳播過程中要不斷地傳播梯度,而當網絡層數加深時,梯度在傳播過程中會逐漸消失(假如採用Sigmoid函數,對於幅度爲1的信號,每向後傳遞一層,梯度就衰減爲原來的0.25,層數越多,衰減越厲害),導致無法對前面網絡層的權重進行有效的調整。
  當傳統神經網絡的層數從20增加爲56時,網絡的訓練誤差和測試誤差均出現了明顯的增長,也就是說,網絡的性能隨着深度的增加出現了明顯的退化。ResNet就是爲了解決這種退化問題而誕生的,如圖1所示。
圖1 20層與56層傳統神經網絡在CIFAR上的訓練誤差和測試誤差

圖1 20層與56層傳統神經網絡在CIFAR上的訓練誤差和測試誤差

2 ResNet v1解決退化問題的方案

  一是通過在網絡中增加BatchNormalization層, 二是構建恆等映射(Identity mapping), 恆等映射的結構如圖2所示.


在這裏插入圖片描述

圖 2 ResNet v1殘差模塊示意圖
ResNet-v1的殘差單元進行以下的計算:

yl=h(xl)+F(xl,Wl)         (1)y_{l}=h(x_{l})+\mathcal{F}(x_{l},\mathcal{W}_{l}) \text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }(1)

xl+1=f(yl)         (2)x_{l+1}=f(y_{l}) \text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }(2)
這裏xlx_{l}是第ll個殘差單元的輸入特徵。W={Wl,k1kK}\mathcal{W}=\{ \text{W}_{l,k|1\leq{k}\leq{K}} \}是一個與第ll個殘差單元相關的權重和偏差的集合,KK是殘差單元內部的層的數量。F\mathcal{F}是殘差函數。函數ff是元素加和後的操作(ResNet-v1中採用的是ReLU)。函數hh是恆等映射h(xl)=xlh(x_{l})=x_{l}
  爲什麼是恆等映射呢,20層的網絡是56層網絡的一個子集,56層網絡的解空間包含着20層網絡的解空間。如果我們將56層網絡的最後36層全部短接,這些層進來是什麼出來也是什麼(也就是做一個恆等映射),那這個56層網絡不就等效於20層網絡了嗎,至少效果不會相比原先的20層網絡差吧。同樣是56層網絡,不引入恆等映射爲什麼就不行呢?因爲梯度消失現象使得網絡難以訓練,雖然網絡的深度加深了,但是實際上無法有效訓練網絡,訓練不充分的網絡不但無法提升性能,甚至降低了性能。
  關於殘差模塊的詳細思考將通過以下章節進行介紹。

3 ResNet v1網絡結構

ResNet主要有五種主要形式:Res18,Res34,Res50,Res101,Res152;整體網絡結構如圖3所示, 殘差單元如圖4所示, 部分網絡片段如圖5所示。
在這裏插入圖片描述

圖 3 ResNet v1不同網絡結構示意圖

在這裏插入圖片描述

圖 4 不同殘差單元示意圖

在這裏插入圖片描述

圖 5 ResNet v1 局部網絡示意圖
res18和res34採用圖4中左邊的殘差單元(BasicBlock) ,從res50網上採用右面的殘差單元(Bottleneck Block),整個網絡的流程大體分爲3步:
  • 1數據進入網絡後先經過輸入部分(conv1, bn1, relu, maxpool);

  • 然後進入中間卷積部分(layer1, layer2, layer3, layer4,這裏的layer對應我們之前所說的stage);

  • 最後數據經過一個平均池化和全連接層(avgpool, fc)輸出得到結果;

  從layer1-4, 每個層中的第一個殘差模塊的殘差部分採用步長爲2的卷積進行下采樣,所以在shortcut connection中要採用步長爲2的1×1的卷積進行維度的匹配,如圖5中的實線部分, 採用的計算方式爲H(x)=F(x)+Wx,其中W是卷積操作,用來調整x維度的。剩餘其他的殘差單元都採用步長爲1的卷積,shortcut connection直接連接輸入和輸出,如圖5中虛線部分,採用計算方式爲H(x)=F(x)+x。
ResNet v1網絡設計規律
整個ResNet不使用dropout,全部使用BN。此外,回到最初的這張細節圖,我們不難發現一些規律和特點:

  • 受VGG的啓發,卷積層主要是3×3卷積;
  • 對於相同的輸出特徵圖大小的層,即同一stage,具有相同數量的3x3濾波器;
  • 如果特徵地圖大小減半,濾波器的數量加倍以保持每層的時間複雜度;
  • 每個stage通過步長爲2的卷積層執行下采樣,而卻這個下采樣只會在每一個stage的第一個卷積完成,有且僅有一次。
  • 網絡以平均池化層和softmax的1000路全連接層結束,實際上工程上一般用自適應全局平均池化 (Adaptive Global Average Pooling);

相比傳統的分類網絡,這裏接的是池化,而不是全連接層。池化是不需要參數的,相比於全連接層可以砍去大量的參數。對於一個7x7的特徵圖,直接池化和改用全連接層相比,可以節省將近50倍的參數,作用有二:一是節省計算資源,二是防止模型過擬合,提升泛化能力;

Bottleneck結構和1×1卷積
ResNet50起,就採用Bottleneck結構,主要是引入1x1卷積。我們來看一下這裏的1x1卷積有什麼作用:

  • 對通道數進行升維和降維(跨通道信息整合),實現了多個特徵圖的線性組合,同時保持了原有的特徵圖大小;
  • 相比於其他尺寸的卷積核,可以極大地降低運算複雜度;
  • 如果使用兩個3x3卷積堆疊,只有一個relu,但使用1x1卷積就會有兩個relu,引入了更多的非線性映射;

我們來計算一下1*1卷積的計算量優勢:首先看上圖右邊的bottleneck結構,對於256維的輸入特徵,參數數目:1x1x256x64+3x3x64x64+1x1x64x256=69632,如果同樣的輸入輸出維度但不使用1x1卷積,而使用兩個3x3卷積的話,參數數目爲(3x3x256x256)x2=1179648。簡單計算下就知道了,使用了1x1卷積的bottleneck將計算量簡化爲原有的5.9%,收益超高。

4 ResNet v1殘差單元有效性的理解

從梯度反向傳播理解ResNet的有效性
緊接第二部分公式1和公式2, 如果ff是一個恆等映射:xl+1xlx_{l}+1≡x_{l},我們可以把公式2帶入公式1,得到:
xl+1=xl+F(xl,Wl)         (3)x_{l+1}=x_{l}+\mathcal{F}(x_{l},\mathcal{W}_{l}) \text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }(3)

遞歸地,xl+2=xl+1+F(xl+1,Wl+1)=xl+F(xl,Wl)+F(xl+1,Wl+1)x_{l+2}=x_{l+1}+\mathcal{F}(x_{l+1},\mathcal{W}_{l+1})=x_{l}+\mathcal{F}(x_{l},\mathcal{W}_{l})+\mathcal{F}(x_{l+1},\mathcal{W}_{l+1}),我們將會有:
xL=xl+i=1L1F(xi,Wi)         (4)x_{L}=x_{l}+\sum_{i=1}^{L-1}\mathcal{F}(x_{i},\mathcal{W}_{i}) \text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }(4)
  對於any deeper unit LL和any shollower unit ll。公式4展現出了一些很好的特性。(i)LL的特徵能夠被表示爲l的特徵加一個殘差函數(這個殘差函數的形式爲 i=1L1F\sum_{i=1}^{L-1}\mathcal{F},這表明任意的LLll之間的模型都是一個殘差。(ii) 對於任意的單元LL,特徵都可以寫成xL=x0+i=0L1F(xi,Wi)x_{L}=x_{0}+\sum_{i=0}^{L-1}\mathcal{F}(x_{i},\mathcal{W}_{i})的形式,特徵是所有殘差函數的的輸出加x0x_{0}的和。這和plain網絡相反,plain網絡的特徵xLx_{L}是一系列的矩陣向量的乘積i=0L1Wix0\prod_{i=0}^{L-1}W_{i}{x}_0(忽略ReLU和BN)。
  等式4同樣也具有良好的反向傳播特性。假設損失函數爲E\mathcal{E},根據反向傳播的鏈式法則可以得到(等式5):
Exl=ExLxLxl=ExL(1+i=lL1F(xi,Wi)xl)         (5)\frac{\partial E}{\partial {{x}_{l}}}=\frac{\partial E}{\partial {{x}_{L}}}\frac{\partial {{x}_{L}}}{\partial {{x}_{l}}}=\frac{\partial E}{\partial{{x}_{L}}}\left(1+\frac{\partial {\sum_{i=l}^{L-1}\mathcal{F}({x}_{i}, \mathcal{W}_{i})}}{\partial {{x}_{l}}}\right) \text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }\text{ }(5)
  等式5表明梯度Exl\frac{\partial E}{\partial {{x}_{l}}}能夠可以被分解成兩個部分:其中ExL\frac{\partial E}{\partial {{x}_{L}}}直接傳遞信息而不涉及任何權重層,而另一部分ExL(i=lL1Fxl)\frac{\partial E}{\partial {{x}_{L}}}\left(\frac{\partial {\sum_{i=l}^{L-1}\mathcal{F}}}{\partial {{x}_{l}}}\right)表示通過權重層傳播的信息。ExL\frac{\partial E}{\partial {{x}_{L}}}保證了信息能夠直接傳回任意淺單元ll。等式5同樣表明了在一個mini-batch中梯度Exl\frac{\partial E}{\partial {{x}_{l}}}不可能出現消失的情況,因爲通常i=lL1Fxl\frac{\partial {\sum_{i=l}^{L-1}\mathcal{F}}}{\partial {{x}_{l}}}對於一個mini-batch的全部樣本不可能都爲-1。這意味着,哪怕權重是任意小的,也不可能出現梯度消失的情況。
等式4和等式5表明了,在前向和反向傳播階段,信號都能夠直接的從一個單元傳遞到其他任意一個單元。等式4的基礎是是兩個恆等映射:(i) skip connection h(xl)=xlh(x_{l})=x_{l},(ii) ff是一個恆等映射。
  我們從這個表達式5可以看出來:第l層的梯度裏,包含了第L層的梯度,通俗的說就是第L層的梯度直接傳遞給了第l層。因爲梯度消失問題主要是發生在淺層,這種將深層梯度直接傳遞給淺層的做法,有效緩解了深度神經網絡梯度消失的問題。
從降低數據中信息冗餘度的角度理解ResNet有效性
參考:ResNet的本質思考
從模型集成角度理解ResNet有效性
有一些研究表明,深層的殘差網絡可以看做是不同深度的淺層神經網絡的ensemble,訓練完一個深層網絡後,在測試的時候隨機去除某個網絡層,並不會使得網絡的性能有很大的退化,而對於VGG網絡來說,刪減任何一層都會造成模型的性能奔潰,如下圖。


在這裏插入圖片描述

圖 6 隨機去除網絡層對測試誤差的影響
以上都證明了殘差結構其實是多個更淺的網絡的集成,所以它的有效深度看起來

表面的那麼深,因此優化自然也沒有那麼難了。更多細節具體可參見NIPS論文:
Residual Networks Behave Like Ensembles of Relatively Shallow Networks

5 ResNet的常見改進

6 ResNet v1代碼

代碼採用tensorflow 2.0 的tf.keras模塊實現的,並參考pytorch相關代碼實現。

"""resnet in tensorflow 2.0 tf.keras
[1] Kaiming He, Xiangyu Zhang, Shaoqing Ren, Jian Sun.
    Deep Residual Learning for Image Recognition
    https://arxiv.org/abs/1512.03385v1
"""

import os
import tensorflow as tf
import tensorflow.keras as keras
from tensorflow.keras import layers, Sequential

os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'


class BasicBlock(keras.Model):

    expansion = 1
    def __init__(self, in_dims, filter_nums, stride=1):
        super(BasicBlock, self).__init__()
        self.resnet_block = Sequential([layers.Conv2D(filter_nums, (3, 3), strides=stride, padding='same', use_bias=False),
                                       layers.BatchNormalization(),
                                       layers.Activation('relu'),
                                       layers.Conv2D(filter_nums*BasicBlock.expansion, (3, 3), strides=1, padding='same', use_bias=False),
                                       layers.BatchNormalization()])
        self.relu = layers.Activation('relu')
        if stride != 1 or in_dims != filter_nums*BasicBlock.expansion:
            self.shortcut = Sequential([layers.Conv2D(filter_nums, (1, 1), strides=stride, padding='same', use_bias=False),
                                        layers.BatchNormalization()])
        else:
            self.shortcut = lambda x: x

    def call(self, inputs, training=None):
        out = self.relu(layers.add([self.resnet_block(inputs), self.shortcut(inputs)]))
        return out


class Bottleneck(keras.Model):

    expansion = 4
    def __init__(self, in_dims, filter_nums, stride):
        super(Bottleneck, self).__init__()
        self.resnet_block = Sequential([layers.Conv2D(filter_nums, (1, 1), strides=1, use_bias=False),
                                       layers.BatchNormalization(),
                                       layers.Activation('relu'),
                                       layers.Conv2D(filter_nums, (3, 3), strides=stride, padding='same', use_bias=False),
                                       layers.BatchNormalization(),
                                       layers.Activation('relu'),
                                       layers.Conv2D(filter_nums*Bottleneck.expansion, (1, 1), strides=1, use_bias=False),
                                       layers.BatchNormalization()])
        self.relu = layers.Activation('relu')
        if stride != 1 or in_dims != filter_nums*Bottleneck.expansion:
            self.shortcut = Sequential([layers.Conv2D(filter_nums*Bottleneck.expansion, (1, 1), strides=stride, use_bias=False),
                                        layers.BatchNormalization()])
        else:
            self.shortcut = lambda x: x


    def call(self, inputs, training=None):
        out = self.relu(layers.add([self.resnet_block(inputs), self.shortcut(inputs)]))
        return out


class ResNet(keras.Model):

    def __init__(self, block, num_blocks, num_classes=100):
        super(ResNet, self).__init__()
        self.in_dims = 64
        self.conv1 = Sequential([layers.Conv2D(64, (7, 7), strides=2, padding='same', use_bias=False),
                                 layers.BatchNormalization(),
                                 layers.Activation('relu'),
                                 layers.MaxPool2D((3, 3), strides=2, padding='same')])
        self.conv2_x = self._make_layer(block, 64, num_blocks[0], stride=1)
        self.conv3_x = self._make_layer(block, 128, num_blocks[1], stride=2)
        self.conv4_x = self._make_layer(block, 256, num_blocks[2], stride=2)
        self.conv5_x = self._make_layer(block, 512, num_blocks[3], stride=2)
        self.avg_pool = layers.GlobalAveragePooling2D()
        self.fc = layers.Dense(num_classes)

    def call(self, inputs, training=None):
        out = self.conv1(inputs)
        out = self.conv2_x(out)
        out = self.conv3_x(out)
        out = self.conv4_x(out)
        out = self.conv5_x(out)
        out = self.avg_pool(out)
        out = self.fc(out)
        return out

    def _make_layer(self, block, filter_num, num_block, stride):
        """make resnet layer, the layer contains more than one residual block.
        The conv stride of first block of conv2_x - conv5_x is 2, others are 1.

        Args:
            block: block type, BasicBLock or Bottleneck.
            filter_num: output channels of the layer.
            num_block: how many blocks per layer.
            stride: the stride of the first block of the layer.

        Returns:
            return the resnet layer

        """
        strides = [1] * (num_block - 1)
        layers = []
        layers.append(block(self.in_dims, filter_num, stride=stride))
        self.in_dims = filter_num * block.expansion
        for s in strides:
            layers.append(block(self.in_dims, filter_num, s))
        return Sequential([*layers])


def resnet18():
    """return the ResNet 18 object"""
    return ResNet(BasicBlock, [2, 2, 2, 2])


def resnet34():
    """return the ResNet 34 object"""
    return ResNet(BasicBlock, [3, 4, 6, 3])


def resnet50():
    """return the ResNet 50 object"""
    return ResNet(Bottleneck, [3, 4, 6, 3])


def resnet101():
    """return the ResNet 101 object"""
    return ResNet(Bottleneck, [3, 4, 23, 3])


def resnet152():
    """return the ResNet 152 object"""
    return ResNet(Bottleneck, [3, 8, 36, 3])

if __name__ == '__main__':
    model = resnet101()
    model.build(input_shape=(None, 224, 224, 3))
    print(model.predict(tf.ones([3, 224, 224, 3])).shape)
    model.summary()

References

1、ResNet v2論文筆記
2、詳解深度學習之經典網絡架構(六):ResNet 兩代(ResNet v1和ResNet v2)
3、對ResNet的一些本質思考
4、ResNet及其變種的結構梳理、有效性分析與代碼解讀
5、https://github.com/weiaicunzai/pytorch-cifar100

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章