[Pytorch]逐步解釋ResNet34程式碼

Posted by John on 2019-02-25
Words 2k and Reading Time 7 Minutes
Viewed Times

簡介

今天想來介紹如何用Pytorch寫出一個ResNet34,網路上有很多類似的文章,不過我一開始看的時候還沒辦法很理解為什麼Module的參數要這樣設?這裡的shortcut又是什麼意思?……於是後來終於理解後,想要寫一篇來介紹ResNet code,順便確認自己是有理解的。

ResNet簡介

他是2015年ILSVRC的冠軍,特色就是提出了一個可以到處插進別人家module的residual block,該block的設計有效的解決了當網路層過深的時候資訊無法有效地往下傳遞的問題(因為一直不斷地被壓縮),想知道細節的話就來看[DL]淺談CNN在Object Classification上的各種架構 這篇吧,我把過去每一年有代表性的CNN架構都做了粗淺的介紹。

所以這裡就不細說了,我就只把ResNet34跟Residual Block的架構提出來,到時候可以跟code相互對照。你問為什麼叫ResNet34?因為他只有34層阿孩子(當初論文提出了許多不同層數的ResNet)。

resnet1

residual

Code解析

source code的部份可以參照torchvision裡面內建的code來研究,不過在這裡我推薦這本書深度学习框架PyTorch:入门与实践,作者人很好的將書本內容跟code都放上去了,想好好研讀pytorch的可以去好好看一看。 我也參照了這個書籍作者在第四章的最後面練習寫了ResNet34,並加上一些註釋希望可以更加清楚的理解,code放在github上,下面也會擷取部分的code來進行講解。 首先想先講講最近練習Pytorch的過程中很大的感觸是:Pytorch和Keras很大的不同點在於CNN的寫法,寫Pytorch的人必須要很夠很清楚的知道Input/Output Channel、Kernel、Padding、Stride彼此之間的關係。

不過其實有個公式可以幫助你去推出這些參數該用什麼:

不管是Convolution或是Maxpool都可以套用這個公式來得到shape的關係,至於channel的關係則是透過out_channel的資訊就可以得到。 好,接下來要開始一段一段的介紹code在幹嘛了:

Code解析 - ResidualBlock

觀察上面的架構圖會發現其實ResidualBlock都是兩層的Conv組成,所以為了方便我們可以先把這兩層合併成一個Module,方便後面呼叫。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ResidualBlock(nn.Module): 
def __init__(self, in_channel, out_channel, stride=1, shortcut=None):
super(ResidualBlock, self).__init__()
self.left = nn.Sequential(
nn.Conv2d(in_channel, out_channel, 3, stride, 1, bias=False), # bias=False是因為bias再BN中已經有了,如果stride=2則shape會變成一半
nn.BatchNorm2d(out_channel), nn.ReLU(),
nn.Conv2d(out_channel, out_channel, 3, 1, 1, bias=False), # shape前後仍然相同
nn.BatchNorm2d(out_channel),
)
self.right = shortcut # 根據情況是否做出增維或是縮小shape
def forward(self, x):
out = self.left(x)
residual = x if self.right is None else self.right(x)
out = out + residual
out = F.relu(out)
return out

從第一個Conv開始講起: nn.Conv2d(in_channel, out_channel, 3, stride, 1, bias=False)

  • 首先,為什麼這裡(包含之後)的Conv bias=False呢?
    • 因為BatchNorm中就提供了Bias的效果,所以這裡就不需要了
  • 第三個參數是kernel size,設置成3是因為作者的架構設定,不過為什麼stride要使用變數和padding=1?
    • 請先看一下架構圖,會發現第一個Residual Block沒有【/2】這個符號,但後面有一些Block有,【/2】代表要將圖片的shape縮小一半,所以為了應付這兩種不同情況,才把stride弄成變數。 不妨可以自己推推看上面的公式,在InSize=x、kernel=3和padding=1(dilation=1,這個先不用管,這裡不會用到)的情況下,stride=1和stride=2的差別會是什麼(答案是stride=1時shape不變,stride=2時shape會縮小一半)。

官方的sourcecode上是寫nn.ReLU(inplace=True),和nn.ReLU()差別在哪?

  • inplace會直接改變原本tensor內的值,節省記憶體的存取,不過在某些情況(?)可能會有問題,實際上有沒有加並不會影響結果

裡面的shortcut是什麼?

  • 裡面有 self.right = shortcutresidual = x if self.right is None else self.right(x) 這兩行,不過並沒有定義shortcut(他是一個參數),那shortcut到底是什麼?
  • 剛剛也說了,在架構圖中,有時候會對圖片做縮小shape或是改變圖片維度的操作,但為了能夠把residual block左右邊加起來,必須確保兩邊的資料維度和大小是相同的,shortcut會在另一段被定義,不過說白了他其實就是1d的cnn,用來改變維度或形狀用的Conv。 如果呼叫ResidualBlock呼叫時沒有從外面傳shortcut參數,代表該次block不需要對資料作維度的改變,那就會用原本的資料(residual = x if self.right is None...)。

Code解析 - ResNet的init()

1
2
3
4
5
6
7
8
9
10
11
12
13
class ResNet(nn.Module): 
def __init__(self, num_classes=1000):
super(ResNet, self).__init__()
self.pre_layer = nn.Sequential(
nn.Conv2d(3, 64, 7, 2, 3, bias=False), #為了使shape變一半,stride必須是2,在固定kernel=7下由公式推得padding=3
nn.BatchNorm2d(64),
nn.ReLU(),
nn.MaxPool2d(3, 2, 1) , #為了使shape變一半,stride必須是2,在固定kernel=3下由公式推得padding=1
)
self.layer1 = self._make_layer(64, 64, 3)
self.layer2 = self._make_layer(64, 128, 4, stride=2) # 對照架構圖,第二段後每次都會將shape再度縮小一半
self.layer3 = self._make_layer(128, 256, 6, stride=2)
self.layer4 = self._make_layer(256, 512, 3, stride=2)

pre_layer的Conv參數(3, 64, 7, 2, 3)怎麼來的?

  • 3是input,這是第一個Conv,所以input的維度就是圖片的RGB channel=3,64(output)跟7(kernel)是架構設定好的,stride=2是因為這邊要將圖片的shape縮小一半(注意到架構圖中這一層有【/2】),那這時候padding要用多少呢?套一下公式(OutSize=x/2、InSize=x、padding=y)就可以求出padding=3。
  • 同理,pre_layer的MaxPool2d也是一樣,kernel=3是預設,為了縮小圖片stride=2,套入公式得出padding=1。

關於self._make_layer()這個function

  • 觀看架構圖可以發現Residual Block一直在重複,所以後面會用一個function包起來,只要傳數字就可以製造出指定數量的Block。並且有些Block開頭需要縮小圖片shape,所以stride也是一個參數,當stride=2時代表需要縮小圖片大小至一半。

Code解析 - ResNet的_make_layer()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ResNet(nn.Module): 
...
def _make_layer(self, in_channel, out_channel, block_num, stride=1):
# shortcut的部份必須和該block最後一層維度相同,所以這裡做1d conv增加維度
# 並且根據有沒有縮小shape(stride=2)做相同的動作
shortcut = nn.Sequential(
nn.Conv2d(in_channel, out_channel, 1, stride, bias=False),
nn.BatchNorm2d(out_channel),
)
layers = [] # 第一次的ResidualBlock可能會縮小shape(根據stride),所以要獨立出來做
layers.append(ResidualBlock(in_channel, out_channel, stride, shortcut)) #注意這邊都是第二次以後的ResidualBlock,所以不會有維度或大小不同的問題,參數跟shortcut都不用做
for i in range(1, block_num):
layers.append(ResidualBlock(out_channel, out_channel))
return nn.Sequential(*layers)

shortcut?

  • 這邊定義了上面提到的shortcut,說白了就是1d的cnn(因為kernel=1),把維度變成跟out_channel一樣之後才能被相加。
  • stride可以在圖片要被縮小的時候進行相同的操作,才不會有Residual Block左邊的圖片大小已經縮小了(還記得Residual Block可以指定stride參數嗎?),右邊卻沒跟著縮小的情況發生。

後面的layer好像都在做一樣的事情(將Residual Block加入list中),不過為什麼第一次要獨立出來寫?

  • 跟上面一樣的問題,為了應付有時候shape必須被縮小的時候可以根據stride產生不同的Residual Block,你會發現後面迴圈內的Residual Block都沒有stride這個參數,因為都是用default=1的值(接在後面的層都不需要縮小)。

後面的部分就剩下forward()了,這邊相較簡單多了,我有把資料經過每一層後的維度都印出來,方便理解,所以就不細講了~


>