浙大人工智慧演算法與系統課程作業指南系列(一)再續:口罩識別的神經網路結構
好啦好啦~各位親愛的小夥伴們,我 JD 又回來辣~為了進一步幫助大家通過這個作業來為以後的AI研究做準備,在這裡我打算簡單介紹一下課程程式碼提供的MobileNetV1的程式碼,雖然挺簡單的,我甚至在懷疑幹嘛要提供這麼個沒啥卵用的示例┓( ´∀` )┏。閒話不多說了,我們直接開講!
為了更加方便小夥伴們讀程式碼,我把裡面沒啥卵用的註釋,以及和模型本身沒什麼太大關係的部分全都刪除了,所以和課程提供的原始碼會稍稍有一點點不一樣,不過沒啥大問題:
import torch
import torch.nn as nn
import torch.nn.functional as F
class MobileNetV1(nn.Module):
def __init__(self, classes=2):
super(MobileNetV1, self).__init__()
self.mobilebone = nn.Sequential(
self._conv_bn(3, 32, 2),
self._conv_dw(32, 64, 1),
)
self.avg_pool = nn.AdaptiveAvgPool2d(1)
self.fc = nn.Linear(64, classes)
for m in self.modules():
if isinstance(m, nn.Conv2d):
n = m.kernel_size[0] * m.kernel_size[1] * m.out_channels
m.weight.data.normal_(0, (2. / n) ** .5)
if isinstance(m, nn.BatchNorm2d):
m.weight.data.fill_(1)
m.bias.data.zero_()
def forward(self, x):
x = self.mobilebone(x)
x = self.avg_pool(x)
x = x.view(x.size(0), -1)
out = self.fc(x)
return out
def _conv_bn(self, in_channel, out_channel, stride):
return nn.Sequential(
nn.Conv2d(in_channel, out_channel, 3, stride, padding=1, bias=False),
nn.BatchNorm2d(out_channel),
nn.ReLU(inplace=True),
)
def _conv_dw(self, in_channel, out_channel, stride):
return nn.Sequential(
nn.Conv2d(in_channel, out_channel, 1, 1, 0, bias=False),
nn.BatchNorm2d(out_channel),
nn.ReLU(inplace=False),
)
這個程式碼的具體位置是在notebook裡面的torch_py/MobileNet.py,很好找啊。很快啊,啪的一下就點開看就完事了。這可能是我這個系列到此為止一次性貼的最多的程式碼了欸······程式碼很長,但是我們時間也很長啊,一點點來嘛
在開始讀這個MobileNetV1這個類之前,我們先來看看他繼承的這個類 nn.Module。其實呢,Pytorch提供了很多神經網路的基本模型,比如全連線模型nn.Linear(),卷積層nn.Conv2d(),還有我們會在下一個作業裡面見到的長短期記憶網路nn.LSTM()等等。但是有的時候這些並不能完全滿足你的需要,有可能你需要把很多這些子網路組合起來拼成一個大的網路,或者乾脆自己寫點神奇的結構,那這個時候你就需要自定義一個類,讓這個類繼承nn.Module,只有這樣之後你的自定義網路才能讓Pytorch正常地識別並執行。
順便說一下,和之前兩篇文章不一樣的是,這回讀程式碼的前置要提及的東西會比較多,如果新的讀者比較瞭解的話,直接往下翻吧,如果是從頭跟著讀到現在的小萌新們,還是要好好讀好好看哈。
在讀這個系列的我的隨筆的時候,我在第一篇裡說了些前提條件,就是說你要對神經網路的基本結構有一些大致的瞭解,而且下面的內容也會涉及到這些,所以如果大家還沒有了解神經網路的基本結構,最好去找些其他文章做個入門,然後再接著回到我的碗裡來(滑稽.jpg)。
下面我們要簡單介紹一下在這個網路裡面接觸到的,也是神經網路中最基本的兩個結構:線性全連線層(nn.Linear),二維卷積核(nn.Conv2d)。
我們先給出一個十分簡單的全連線層的引數列表吧~
nn.Linear(input_size, output_size)
這個引數列表也是和上面程式碼裡基本一致的。下面是一張簡單的全連線神經網路的圖片:
對於全連線層,相信各位小夥伴們並不陌生,如果大家對神經網路稍微有過一些學習的話,大家可能就會知道,神經網路的運算,或者說這些神經層,實際上都是做了矩陣乘法,也就是說實際上上面那幅圖就只是一堆的矩陣乘法運算的視覺化而已,從這個角度上來看,這個Linear實際上儲存的東西應該是下圖的紅色方框中的部分:
然後通過這個圖,我們來看一下Linear層的引數吧~
input_size:Linear的輸入端有幾個神經元,或者說對應的輸入有幾個特徵
output_size:Linear的輸出端有幾個神經元,或者說對應的輸出有幾個特徵
以輸入層和隱藏層之間的那個Linear為例,input_size = 3,output_size = 4
全連線層就是這麼簡單,接下來呢我們來看一下卷積層吧~下面是一張輸入圖片和卷積核的關係:
在這個圖片裡面,下面的藍色的部分為實際的圖片,上面的綠色部分就是我們的卷積核啦~我們可以看到卷積核一般都是一個方形的。卷積運算實際上就是把卷積核上的值和下面圖片裡面的畫素值做一個線性組合,然後把得到的值放到新的圖片的對應位置。為了得到一張新的圖,下面藍色的圖片總會大吼一聲:“卷積核!上來,自己動”(好快的車車⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄),然後卷積核就會按照一個給定的步長進行移動,先沿著行,再沿著列。在基本介紹完之後,我們來看一下二維卷積核的API引數列表吧!
nn.Conv2d(in_channel, out_channel, kernel_size, stride, padding, bias)
在Deeping Learning領域裡,卷積核的專業術語叫做kernel,這也就是引數裡面那個kernel_size裡面那個kernel。在第一篇中我們提到過,輸入神經網路的圖片的尺寸必須是(batch_size, channels, height, width)的尺寸,而卷積核的輸入引數in_channel以及out_channel就是對應的圖片尺寸中的channels這個維度。
kernel_size這個引數也很好理解,Conv2d預設卷積核是一個正方形的,因此我們傳入的這個引數實際上就是這個卷積核的邊長。可能有小夥伴就會問了,如果我就是想要一個矩形的咋辦?我只想說你是在難為我胖虎(不是),事實上你只需要把你想要的尺寸用(height, width)的元組形式穿進去就好啦。bias這個引數是一個布林型別,因為我們的卷積運算是一個線性運算類似於wx + b,bias就是設定是否在計算的時候有那個b(我沒在罵人!警察叔叔我冤枉啊!)
還剩下兩個引數stride和padding沒有說,別急嘛,馬上就說。stride引數是卷積核移動的步長,在預設情況下卷積核在行列兩個移動方向都是等長的,也就是說你輸入一個數進去就完事了。如果你非要步長不相等,和上面的kernel_size的處理方法一樣,自己動。然後接下來是這個padding,對應上面那個卷積神經網路的圖,實際就是藍色圖周圍那一圈虛線的小框框。padding的實際作用就是在每一個維度的兩端都加上你的padding的值,比如說圖片是5x5的,當padding=1的時候,圖片會在左右一行的左右+1,一列的上下+1,也就變成了7x7,並且填入的畫素值都是0。
小夥伴可能會問了,為啥要加一個這個padding啊?我們先來舉個例子。假設我們的圖片是4x4的,卷積核是3x3的,stride = 1,padding = 0。我們會發現通過卷積核的移動,輸出的圖片尺寸變成了2x2,卷積核在圖片上動完以後圖片變小了(好傢伙這破路都能開,警察叔叔就是這個人!)。而早期的神經網路在設計的時候,希望輸入輸出的圖片尺寸儘量是一致的,所以會用padding操作把原圖片變大一點,然後再卷積,就還是和之前一樣了。(雖然後來好多模型都是要讓圖片變小一點)
這個時候我們來讀上面神經網路的程式碼,就會稍微容易一點點咯······大概吧┓( ´∀` )┏
首先是這一段:
self.mobilebone = nn.Sequential(
self._conv_bn(3, 32, 2),
self._conv_dw(32, 64, 1),
)
好傢伙上來就當場自閉。我們先來讀一下里面兩個函式對應的程式碼,因為裡面兩個函式對應的程式碼結構是完全一樣的,所以我們就以第一個為例大概說一下:
def _conv_bn(self, in_channel, out_channel, stride):
return nn.Sequential(
nn.Conv2d(in_channel, out_channel, 3, stride, padding=1, bias=False),
nn.BatchNorm2d(out_channel),
nn.ReLU(inplace=True),
)
吶吶,卷積核部分就和我們之前說的一樣吧,然後下面還有兩個運算,BatchNorm2d的功能主要是對我們的卷積得到的輸出做一個批標準化,讓我們的輸出能夠在一個合理的分佈區間內,在一定程度上可以加快我們的訓練速度,並且稍微降低過擬合的風險,原理就不細說了;而ReLU函式是一個啟用函式,實際上是對每一個數值做了這樣的一個運算:ReLU(x) = max(0, x),這樣的啟用函式相較於傳統的Sigmoid函式以及Tanh函式來說,運算快,求導容易,而且在一定程度上解決了梯度消失問題。但是這個函式也是存在一些缺陷的,比如由於x < 0時導數為0,所以可能會殺死一部分神經元(沒梯度咋更新嘛┓( ´∀` )┏);輸出不是以0為中心;資料會不斷膨脹等等,具體的還是去參考一些其他大佬的部落格好了。
然後這三個部分都被由一個nn.Sequential包了起來,當我們令一個物件net = nn.Sequential(a, b, c)時,我們就相當於將a, b, c打包都歸給了net,就組成了一個稍微大一點的子網路。這也順便解釋了一下mobilebone部分的那個程式碼段。OK我們繼續~
self.avg_pool = nn.AdaptiveAvgPool2d(1)
在卷積神經網路中,為了進一步降低我們在網路中的特徵數來降低網路引數數量,我們會在卷積之後進行一個池化操作(Pooling),實際上池化也是特殊的卷積,主要目的就是為了降維。在正常來說我們應該用通常的池化操作MaxPool2d(kernel_size, stride),來規定我們在進行池化的時候尺寸還有步長是什麼。但是這就需要我們在寫程式碼的時候,自己心裡要清楚到每個位置圖片輸出和輸入的尺寸是啥,然後自己算池化的核尺寸以及步長。幸運的是,Pytorch直接幫我們把這個問題解決了,這個AdaptiveAvgPool2d(out_size)的引數,就是你想要輸出的圖片的最後尺寸(height, width),Pytorch會自動幫你算出來池化的核還有步長。
下面還有一行定義全連線層的程式碼,這個之前已經講得很清楚了吧,引數還有視覺化的東西都有提到了,還不懂的話建議直接打死,哼╭(╯^╰)╮
下面還有一個對所有的引數進行初始化的一些程式碼,這些首先從功能上很容易讀懂,其次是初始化的方法上稍微涉及到了MobileNetV1的原理上的東西,所以直接Pass。(懶人行為)總之,一個卷積神經網路的基本結構大致上都是這樣:好幾個卷積層連在一起,然後池化之後後面接一個全連線層,就可以作為最後的輸出了,雖然有一些網路不太一樣(比如ResNet等),不過大部分都差不多。
然後是這個模型中的非常重要的部分,就是下面的這個函式:
def forward(self, x):
x = self.mobilebone(x)
x = self.avg_pool(x)
x = x.view(x.size(0), -1)
out = self.fc(x)
return out
首先是這個函式本身。在Pytorch中,所有繼承了nn.Module的類都會繼承其中的方法,其中有一個方法是__call__,這個方法會自動呼叫類裡面的forward方法,並且執行一些其他的功能。也就是說,當你自定義了一個自己的神經網路結構的時候,如果想讓這個網路正常執行,一定要在這個類中定義一個forward方法,一定要是這個名字哈。
當我們執行model(x)的時候,就會自行呼叫forward(x)函式。但是說了這麼久,我們輸入資料到底是什麼樣子的呢?emmmmm,如果我們去檢視一下我們的資料集中的檔案,我們會發現給出的圖片都是三通道的,160x160的圖片。這個尺寸一定要記住,因為這和下面的我們神經網路的結構有關係。
這個程式碼的內部結構裡面,前兩行的mobilebone還有avg_pool的程式碼都已經講過了,就不多說了,但是下面的x.view()又是什麼呢?在講這個之前,我們最好先回過頭來,看一下我們這個整體的網路裡面到底每一層輸出的尺寸都是什麼,那麼下面我就只把和網路結構有關的程式碼放在一起,並且用註釋的方式給出輸入輸出的圖片的尺寸,並且為了方便起見,我們將batch_size用B表示。:
self.mobilebone = nn.Sequential(
#input: [B, 3, 160, 160]
nn._conv_bn(3, 32, 2)
#output: [B, 32, 80, 80]
#input: [B, 32, 80, 80]
nn._conv_dw(32, 64, 1)
#output: [B, 64, 80, 80]
)
#total output: [B, 64, 80, 80]
#input: [B, 64, 80, 80]
self.avg_pool = nn.AdaptiveAvgPool(1)
#output: [B, 64, 1, 1]
#input: [B, 64, 1, 1], 尺寸不對辣~
self.fc = nn.Linear(64, classes = 2)
我們可以發現,當到全連線層那裡的時候,根據我們之前的描述,全連線層接受的尺寸應該是[batch_size, input_size = 64]這樣的型別,很顯然這邊多出來兩個。萌新小夥伴們可能會再次懵逼,哎呀這可咋辦嘛Σ(っ°Д°;)っ。嗯······在介紹view的功能之前,我們最好是先對Pytorch中tensor的儲存方式進行簡單的介紹。
真要說起來的話,tensor的儲存方式和C++陣列的儲存方式是差不多的,tensor也是要在記憶體中佔據一段連續的儲存空間,然後通過陣列名來表示陣列的首地址,然後通過[]運算子來獲取元素的位置,與首地址進行計算之後得到元素的準確位置,進而取值。也就是說,在Pytorch的tensor中,有這樣一個思想:不管你一個tensor是啥維度的,底層資料都一模一樣,改變的只是我怎麼看這個tensor罷了,這個思想很重要。而view(檢視)函式就是反應這個思想的一個功能。
雖然資料是一個[B, 64, 1, 1]的資料,但是在底層資料都是按照一個順序進行順序儲存的,現在我們需要將它轉化成[B, 64]的資料,那我把後面兩維丟掉不就完事了。然後在view函式的引數其實很有趣,每一個位置就對應的要輸出的tensor的維度,比如在第一個出現的數字,就對應著輸出的第0維的尺寸;當給出的數字是-1的時候,函式會自動按照其他的維度的尺寸以及tensor的元素個數,自動地幫你算出這個位置的數字,哎呀媽呀賊貼心好嗎。
所以在forward函式中的下面的句子:
x = x.view(x.size(0), -1)
本質上講,就是把圖片從[B, 64, 1, 1],轉化為[B, s],然後元素個數是B * 64 * 1 * 1 = B * 64,第0維尺寸為B,那s = 64可不是理所當然(MVP:所以愛會消失是嗎,大誤)
到這裡,我們的神經網路基本結構就介紹完了。基本上在理解包括前兩篇在內的所有內容之後,如果小夥伴們想自己寫一個資料處理步驟、訓練函式以及神經網路模型,基本上不會有太大的問題,最多可能就是在一些小的函式使用上查查Pytorch的文件然後學一學。這個作業也就基本上可以寫了。但是呢,這個作業其實,還! 有! 坑! 至於是什麼坑,我們放在下一篇繼續說吧,大家下次見~