ResNet一直都是非常卓越的效能級網路從 2015年誕生的原型ResNet一直到最近後續加了squeeze-and-excitation 模組的SEResNet, 因為殘差機制使得網路層能夠不斷的加深並且有效的防止效能退化的問題

今天老樣子先說原理後上程式碼和大家一起了解ResNet的理論和實際程式碼中的架構, 之後再說到其他變種

希望不會有小夥伴認為怎麼2015年的網路 都2019了還有人拿來說, 殘差結構可謂是經典中的經典, 但是有多少人能真正理解其背後含義?

好了, 廢話不多說 入正題

從發現的問題開始著手

ResNet解決傳統深度神經網路造成的問題

梯度消失

網路效能退化

待會我們在來說一下梯度為什麼會消失, ResNet又是怎麼解決的

先說一下網路效能退化吧, 從網路的層數深度來探討, 我們一方面希望層數越多越好, 這樣一來能夠從樣本學習到越多的東西,每一層更豐富, 但是當網路層不斷加深反而會衍生出網路效能退化的問題,有許多層是冗餘的, 不必要的, 關於

網路層數過多導致效能退化

的問題似乎討論度比較低, 有興趣推薦以下這篇, 建議看原文

Why is it hard to train-deep-neural-networks-degeneracy-not-vanishing-gradient-is-the-key

這邊還是稍微提一下, 原則上網路效能的下降不代表一定是梯度爆炸或者是消失所導致的, 這其實是兩個不同層面的問題, 但是這邊不細說

完整學習 ResNet 家族 ResNext, SEResNet程式碼實現- part1

那麼我們也不可能每一次都依照不同的任務重新的設計相對應的層數, 費時費力不符合成本, 於是ResNet問世了

直接來看ResNet的基本結構

這張圖一定看過很多次, 但從來沒好好理解過一次

完整學習 ResNet 家族 ResNext, SEResNet程式碼實現- part1

設輸入為

x

F

x

) 理解為

x

經過各種卷積以及Bn、ReLU的操作

那麼右邊的 identity 又是什麼呢?

我們透過網路退化問題可以瞭解到較淺層的layer表現的比深層的更好, 那麼為什麼我們不想個辦法跳過這些會導致網路退化的層呢?

於是就有了一條自己抄捷徑的shortcut, 也就是圖上的identity

那麼整個結構的表示式就可以為

H(x)=F(x)+x

H(x) 正是我們網路要學習的output

為了讓冗餘層(extra layer)能夠恆等對映前面的層, 就要讓F(x)學習為0,

懵了吧 我們看下去

我們如果想讓某些冗餘的層, 不要影響網路效能, 因此想了個辦法可以讓該冗餘層學習到的引數滿足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​)

(2)

x_l+1​=f(y_l​)

h

是恆等對映, 也就是右邊的shortcut

F

xl

​,

Wl

​) 是網路的一系列變化(conv1, bn, relu)

yl

​ 是輸出

f

就是輸出之後進行的ReLU function

我們讓網路學習F 為0,

h

xl

​) 和

f

yl

​) 都是恆等對映

所以

 h(x_l​)=x_l​

如果

f

也是, 那麼

xl

+1​≡

y

, 這裡≡ 表示恆等於的意思

公式(2)帶回原來(1)的式子得到

 x_l+1​=x_1​+F(x_l​,W_l​)

當再次傳入到下一個block的時候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​)

當從xl+2​傳入到下一個block的時候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​=x_l​+∑^{i=l}_{L−1}​F(x_i​,W_i)

所以任意深層的

XL

​的輸出, 都能表達前面

L

−1層 殘差模組的疊加 和 淺層的輸入特徵

xl

​,

那麼反向傳播的式子就會變成如下

完整學習 ResNet 家族 ResNext, SEResNet程式碼實現- part1

Loss對任一層進行更新的話

完整學習 ResNet 家族 ResNext, SEResNet程式碼實現- part1

現在回到我們前面說過梯度消失的問題

還是簡單的先了解一下

梯度消失的原因

梯度消失容易出現在深層的網路並且用了不合時宜的激勵函式例如sigmoid

function, 它能將輸入的值轉換介於0-1之間, 我們都知道反向傳播是從最後一層向前求導來更新引數, 當更新到啟用層的地方時, sigmoid的導數會變的非常小趨近於0, 如下圖可見紅色的虛線就是求導後的sigmoid, 值最大並不超過0。25, 根據Chain rule, 每一層的導數相乘之後, 梯度將呈現指數形式的下降, 網路中每一次的啟用層導數相乘越乘越小, 也可以總結出越靠後面的層越不容易出現梯度消失的問題,

完整學習 ResNet 家族 ResNext, SEResNet程式碼實現- part1

那麼好在ResNet的indentity connection 這條捷徑並沒有經過任何啟用函式(反向式子中的1), 而是直接與block的輸出相加, 所以求導之後的值還是很大,無論權值怎麼乘,梯度都還是在正常的值, 實現網路層加深的可能

ResNet簡單總結

ResNet 結構中的short cut解決了梯度中連乘導致梯度消失的問題

更新冗餘層的引數只需要讓F(x) 學習0, 就能讓輸入等於輸出也就是 H(x) = x

現在你應該對ResNet有更深的瞭解了吧 要是想懂的更透徹

自行推導一下ResNet反向來觀察一下 “ 1 ”的作用

ResNet Pytorch程式碼實現

那麼具體ResNet如何在Pytorch中實現呢?

還是依照幾個思路來進行吧, 很多教學就是把整個程式碼一貼, 那還不如自己看原始碼就好

我們來手動實現一下ResNet-18 和ResNet-101吧, 光是會呼叫不值得一提

紅框處可見兩種不同層數的ResNet

藍框處可見不同結構的殘差block

完整學習 ResNet 家族 ResNext, SEResNet程式碼實現- part1

首先在設計的時候就要先設想好 如何用最便利的方式表達這麼多種層數的ResNet, 我們總不可能101層的真的寫一百另一層吧

觀察上圖就能發現都是block的輸出維度有做變化而已,ResNet 網路一共分為5個stage(看最左欄的conv1_x 到· conv5_x), 那麼從block的輸出通道也從64, 放大到512

我們首先import一下nn這個模組, 該模組已經封裝了定義ResNet所需要的所有函式, 非常之強大, 後續也不會import 其他的了

1import torch。nn as nn

然後我們發現到殘差模組中都有至少一個3x3 的卷積

那麼可以先定義一下conv3x3,

1def conv3x3(in_channel, out_channel, stride=1):

2 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層的

β

給取代了

具體原因請看論文 1502。03167。pdf Section 3。2 有說到

定義好block中3x3的卷積之後, 來定義一下整個Basicblock吧

完整學習 ResNet 家族 ResNext, SEResNet程式碼實現- part1

先有幾個思路在腦海中

定義一個類繼承nn。Module模組

類的初始化中, 定義所有會用到的屬性(conv, bn, relu)

定義forward function建立資料輸入到return的過程

該注意的細節已經在程式碼旁

1class BasicBlock(nn。Module):

2 expansion = 1 #主要是定義輸出通道的放大倍率, 在bottleneck會用上

3 def __init__(self, in_planes, out_planes, stride, downsample=None):

4 super(BasicBlock, self)。__init__() #記得繼承父類

5 self。conv1 = conv3x3(in_planes, out_planes, stride=stride)

6 self。bn1 = nn。BatchNorm2d(out_planes) #BN通常依據上一層輸出的維度做BN

7 self。conv2 = conv3x3(in_planes, out_planes, stride=stride)

8 self。bn2 = nn。BatchNorm2d(out_planes)

9 self。relu = nn。ReLU(inplace=True) #inplace表示對原資料修改, 而非產生新資料, 節省記憶體

10 self。downsample = downsample

11 self。stride = stride

12

13

14 def forward(self, x):

15 identity = x

16 x = self。conv1(x)

17 x = self。bn1(x)

18 x = self。relu(x)

19 x = self。conv2(x)

20 x = self。bn2(x)

21 out = self。relu(x)

22

23 if self。downsample is not None:

24 x = self。downsample(x)

25

26 out += identity

27 out = self。relu(out)

28 return out

這邊簡單說下downsample的作用是為了避免

y

=

F

xi

​,

Wi

​)+

x

F

xi

​,

Wi

​)+

x

相加的部分因為維度不同沒法相加 所進行的一個轉換, 那麼式子會變成

y

=

F

xi

​,

Wi

​)+

Ws

x

, 這個到後面定義網路主架構的時候在提

那麼接下來可以定義一下另一種殘差模組Bottleneck, 加入了conv1x1 減少了引數量, 主要給網路層數較深的使用

來說明一下與Basic不同的地方

expansion = 4 :請看圖中的藍框可以發現bottleneck的最後一層1x1輸出的維度是第1(conv1x1), 2(conv3x3)層的四倍, 因此放大倍率為4

主結構變成 1x1, 3x3, 1x1

1class Bottleneck(nn。Module):

2 expansion = 4 #注意最後一層的out_channel要乘上放大倍率

3 def __init__(self, in_planes, out_planes, stride, downsample=None):

4 super(Bottleneck, self)。__init__()

5 self。conv1 = conv1x1(in_planes, out_planes, stride=stride)

6 self。bn1 = nn。BatchNorm2d(out_planes) #for conv2

7 self。conv2 = conv3x3(in_planes, out_planes, stride=stride)

8 self。bn2 = nn。BatchNorm2d(out_planes) #for conv2

9 self。conv3 = conv1x1(in_planes, out_planes * self。expansion, stride=stride)

10 self。bn3 = nn。BatchNorm2d(out_planes * self。expansion) #for conv3

11 self。relu = nn。ReLU(inplace=True)

12 self。downsample = downsample

13 self。stride = stride

14

15 def forward(self, x):

16 identity = x

17 x = self。conv1(x)

18 x = self。bn(x)

19 x = self。relu(x)

20

21 x = self。conv2(x)

22 x = self。bn(x)

23 x = self。relu(x)

24

25 x = self。conv3(x)

26 out = self。bn3(x)

27

28 if self。downsample is not None:

29 identity = self。downsample(x)

30

31 out += identity

32 out = self。relu(out)

33

34 return out

接下來只要定義ResNet的網路主體就可以了

1class ResNet(nn。Module):

2 def __init__(self, block, stages, num_classes=1000, zero_init_residual=False):

3 super(ResNet, self)。__init__()

4 self。inplanes = 64 #第一個stage通道數一定是64, 因為先經過(64, 7, 7)的conv1

5 self。conv1 = nn。Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)

6 self。bn1 = nn。BatchNorm2d(64)

7 self。relu = nn。ReLU(inplace=True)

8 self。maxpool = nn。MaxPool2d(kernel_size=3, stride=2, padding=1)

9 self。layer1 = self。_make_layer(block, 64, stages[0], stride=1)

10 self。layer2 = self。_make_layer(block, 128, stages[1], stride=2)

11 self。layer3 = self。_make_layer(block, 256, stages[2], stride=2)

12 self。layer4 = self。_make_layer(block, 512, stages[3], stride=2)

13

14 self。avgpool = nn。AdaptiveAvgPool2d((1, 1)) #算是一種global average pooling

15 self。fc = nn。Linear(512*block。expansion, num_classes)

16 # 最後一層實現全連線

17 #輸入就是前一層的輸出(512, 1, 1), 輸出就是類別數

18

19 for m in self。modules():

20 if isinstance(m, nn。Conv2d):#只要是卷積都操作, 都對weight和bias進行kaiming初始化

21 nn。init。kaiming_normal_(m。weight, mode=‘fan_out’, nonlinearity=‘relu’)

22 elif isinstance(m, nn。BatchNorm2d):#bn層都權重初始化為1, bias=0

23 nn。init。constant_(m。weight, 1)

24 nn。init。constant_(m。bias, 0)

25 ‘’‘

26 根據以下論文, 在每個block最後的一個BN進行權重0值初始化

27 有助於提升精度

28 https://arxiv。org/abs/1706。02677

29 ’‘’

30 if zero_init_residual:

31 for m in self。modules():

32 if isinstance(m, Bottleneck): #如果是例項bottleneck的話

33 nn。init。constant_(m。bn3。weight, 0)

34 elif isinstance(m, BasicBlock):

35 nn。init_constant_(m。bn2。weight, 0)

36

37

38 def forward(self, x):

39 x = self。conv1(x)

40 x = self。bn1(x)

41 x = self。relu(x)

42 x = self。maxpool(x)

43

44 x = self。layer1(x)

45 x = self。layer2(x)

46 x = self。layer3(x)

47 x = self。layer4(x)

48

49 x = self。avgpool(x) #after pooling shape(N, 1, 1)

50 x = x。view(x。size(0), -1)

51 out = self。fc(x)

52

53 return out

54

55

56

57

58 def _make_layer(self, block, out_planes, blocks, stride=1):

59 downsample = None

60 if stride !=1 or self。inplanes != out_planes * block。expansion:

61 downsample = nn。Sequential(

62 conv1x1(self。inplanes, out_planes* block。expansion, stride),

63 nn。BatchNorm2d(out_planes * block。expansion)

64 )

65 layers = [] #空列表

66 layers。append(block(self。inplanes, out_planes, stride, downsample)) #新增進第一個block,

67 self。inplanes = out_planes * block。expansion

68 #確保上一層輸出與下一層的輸入通道數相同

69

70 for i in range(1, blocks): #blocks(設定每stage多少blocks), 有幾個block就新增blocks-1個(前面已經新增第一個block)

71 layers。append(block(self。inplanes, out_planes, stride))

72

73 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一樣, 能將各種操作封裝到一個變數中

1downsample = nn。Sequential(

2 conv1x1(self。inplanes, out_planes* block。expansion, stride),

3 nn。BatchNorm2d(out_planes * block。expansion)

4 )

接下來就是依照結構設計將block裝進列表

1layers =[] #空列表

2layers。append(block(self。inplanes, out_planes, stride, downsample)) #新增進第一個block,

3self。inplanes = out_planes * block。expansion

4#確保上一層輸出與下一層的輸入通道數相同

5

6

7for i in range(1, blocks): #blocks(設定每stage多少blocks), 有幾個block就新增blocks-1個(前面已經新增第一個block)

8 layers。append(block(self。inplanes, out_planes, stride))

9

10return nn。Sequential(*layers)

其中

self。inplanes = out_planes * block。expansion

的用意如下圖

可以確定第一組bottleneck

輸出

為1024, 第二組bottleneck的

輸入

也同樣為1024, 要是少了這組程式碼輸入將全為統一通道數64, 不信的可以試試

完整學習 ResNet 家族 ResNext, SEResNet程式碼實現- part1

最終我們主體的兩大部分都已經完成了

ResNet主體和block(basic / bottleneck)的部分

在來依照需求組裝就行

例如ResNet 18, stage 2 ~ stage5 每個stage都是兩組block

那麼重新定義函式

1def ResNet18(pretrained = False, **kwargs):

2 model = ResNet(BasicBlock, [2, 2, 2, 2], **kwargs)

3 if pretrained:

4 print(“Just a test, show download from mode_zoo url”)

5 return model

6

7

8def ResNet101(pretrained = False, **kwargs):

9 model = ResNet(Bottleneck, [3, 4, 23, 3], **kwargs)

10 if pretrained:

11 print(“Just a test, show download from mode_zoo url”)

12 return model

pretrained 這個形參依據bool值判別是否進行載入預訓練模型, 這邊只是練習就不寫上了, 有興趣可以看torchvision。model裡怎麼呼叫的

**kwargs 留下可以新增引數的空間, 例如

num_classes=1000

zero_init_residual=False

可以看到呼叫ResNet這個類, 並且指定需要傳入的block類別, 然後利用列表將每一個stage的blocks數目裝進

然後就沒有然後了, 搞定 !

還是推薦大家照著結構圖自己動手擼一次會更有印象

Part2準備寫一下ResNet的變種 ResNext系列, 後續還有Squeeze-Excitation搭配ResNet的變種

一樣會先說一下網路結構在用程式碼實現

參考

https://

arxiv。org/pdf/1512。0338

5。pdf

https://

arxiv。org/pdf/1502。0316

7。pdf

https://

towardsdatascience。com/

the-vanishing-gradient-problem-69bf08b15484

https://

towardsdatascience。com/

residual-blocks-building-blocks-of-resnet-fd90ca15d6ec