小白的經典CNN復現(三):AlexNet

JacobDale發表於2021-02-07

小白的經典CNN復現(三):AlexNet

鏘鏘——本系列的第三彈AlexNet終於是來啦(≧∀≦),到了這裡,我們的CNN的結構就基本上和現在我們經常使用或者接觸的一些基本結構差不多了,並且從這一個經典模型開始,後面的模型的深度越來越高,使用的資料集也越來越大,訓練難度也越來越高,模型的正確率也變得比較高,然後各個dalao們對於卷積的理解實際上也在不斷加強。

然鵝······你叫我回家以後咋訓練嘛(╯‵□′)╯︵┻━┻。因為家裡面就只有一個筆記本,顯示卡也就一個1050Ti的垃圾,雖然CPU還可以然而並沒有什麼卵用┓('∀')┏。所以說可能寒假這段時間我在復現論文的時候就不拿論文提到的資料集來跑了,結果分析自然也就先放一放,主要是帶著各位小夥伴們看一下論文的思路以及模型的具體結構就好咯。

這篇論文相較於之前的LeNet-5的論文而言已經是少很多的了,也才剛剛9頁而已。雖然內容不是很多,但是裡面還是提到了很多非常有意思的思想,以及一些在當時來說比較先進的技術吧。最重要的是,這個程式碼很簡單,復現起來沒啥難度,這真是棒棒(≧∀≦)。為了儘量讓這個系列的部落格看起來都差不多,所以接下來我還是會按照和之前部落格結構差不多的結構來寫一下這個部落格:

  • 論文該怎麼讀:論文內容不多,不過還是有一些要重點看一下,有一些可以選擇性略過

  • 論文要點簡析:簡單分析一下文章中有哪些先進的以及有意思的思想

  • 具體分析以及復現:帶著大家一個模組一個模組把所有的部分一起實現

  • 結果簡要說明:由於條件限制,沒有使用實際的資料集,所以只能簡單描述一下訓練中有什麼坑

  • 反思:模型很經典,既有值得學習的地方,也有值得反思的地方

論文該怎麼讀?

這篇論文非常經典,因為這個AlexNet基本算是將深度學習模型在比賽中的正確率提高到一個前所未有的高度,並且讓人們意識到深度學習模型的構造形式以及獨特優勢,因此這篇論文的內容基本上是要通讀的。不過為了進一步減輕各位萌新小夥伴們的負擔,我們可以將裡面與硬體相關的東西跳過不看。

論文的大致結構及頁碼如下:

  • P1-2:深度學習模型以及AlexNet工作的基本介紹,大致瞭解即可

  • P2:ImageNet資料集以及大致的資料處理的介紹,這部分主要看一下圖片的尺寸應該如何處理,以及做了一個零均值的處理,其他可以先跳過不看

  • P2-5:AlexNet的基本結構,這部分可以將3.2節跳過不看,因為當時很好的顯示卡也就只有3G視訊記憶體,根本不夠用,所以當時把這個模型拆開成兩個在不同的GPU上分開跑的,現在隨隨便便找個GPU基本都夠跑這個模型了,所以3.2節這個多GPU的部分可以跳過不看

  • P5-6:資料增強的方法,這部分簡要了解即可

  • P6:具體的訓練策略,這部分可以重點看看,因為這部分用到了許多有意思的思想

  • P7-9:結果分析、結論以及參考文獻,簡單瞭解即可

論文要點簡析

這篇論文篇幅不多,但是這也算是深度學習進入現代的開篇之作吧,並且裡面新奇的玩意兒還是蠻多的,總之廢話少說,就讓我帶著大家一起看一看吧(是不是有QQ看點那個味了┓('∀')┏):

  • 在資料處理部分,這一篇比較有意思的地方比較多,不過我們需要關注的大體就是以下的兩個方面:

    • 預處理方面:在以往的傳統機器學習模型、全連線網路以及LeNet-5的模型中,對於圖片的處理常常都是將圖片各個通道的畫素值的均值和方差都計算出來,然後通過一定的處理,使得圖片的畫素值都處在一個均值為0,方差為1的一個大致區間中。然而在AlexNet中,我們只需要將圖片各通道的畫素均值算出,得到一個均值為0的區間即可。

    • 資料增強方面:雖然AlexNet的使用的資料集是ImageNet,裡面的資料量非常龐大,但是論文指出,這個資料量對於模型來說還是比較少,也就是說還是存在比較大的過擬合的可能性。為此AlexNet提出了兩個資料增強的方法:首先,由於圖片的尺寸其實蠻大的,然後AlexNet的輸入圖片的尺寸實際上只要求224×224的尺寸,所以可以從圖片上隨機截出來一個224×224的圖片作為輸入,這樣每一次訓練對於同一張圖片來說,輸入網路的都是不同的資料,就相當於增加了訓練集;其次,為了儘可能降低圖片噪聲(模糊以及不同光照條件)的影響,論文通過主成分分析(PCA)技術取得圖片各通道畫素的主成分,然後把這個主成分新增隨機因數疊加到圖片上去,從而增強資料。

  • 在網路的具體結構方面,AlexNet又引入了許多的比較先進的思想,並且有一部分的東西到現在還在用,當然啦還是有一部分東西被淘汰了,不過大體上講還是都挺有意思的:

    • 啟用函式方面:不同於在以前的全連線網路以及LeNet-5中常用的Sigmoid以及Tanh,在AlexNet中使用的函式為ReLU函式,並且論文提到,使用ReLU函式大概能將訓練速度提高6倍左右(相較於傳統的Tanh),並且基本解決了梯度消失現象。

    • 池化層的改進:在之前的LeNet-5以及LeNet-1989模型中,池化層的核在移動的過程中是不發生重疊的(比如說核尺寸為2,那麼步長也為2,不發生重疊),但是在AlexNet中,作者提到如果池化的核在移動過程中發生重疊,會降低過擬合的風險。

    • Dropout:在全連線層中,AlexNet使用了Dropout技術,該技術也是為了降低模型的過擬合風險(話說我咋覺得這篇文章的大部分內容都是著重於降低過擬合風險呢┓('∀')┏),大致的原理呢我會在後面的具體復現和分析部分裡面簡單的說一下,總之大家在這裡先留個印象比較好。

    • 標準化處理:由於訓練過程中資料的分佈一定會發生比較大的變化,因此這裡使用了一個叫做Local Response Normalize的標準化方法,用來將資料的分佈進行重新計算,其實這個思想在後來演化成了BatchNormalization,並且後面也有論文指出,在AlexNet中使用的這個LRN它就是個垃圾(希望論文原作者不會看到這篇部落格┓('∀')┏)。

    • 損失函式:在AlexNet,正兒八經的交叉熵損失可算是用起來了,畢竟從概率論的角度上講,交叉熵損失在這種典型的分類問題中肯定是比較合適的嘛。

    • 訓練策略:訓練中主要有兩個比較需要重點關注的地方(除了兩個GPU以外的內容):首先,在訓練時使用的梯度下降法,是基於mini-batch以及動量(momentum)的SGD,從之前我在LeNet-5的末尾反思部分提到的內容可以看出,mini-batch思想肯定是十分重要的,然後關於動量的內容,等放到後面的具體復現以及分析的部分中再簡單說一下;其次,為了在之後能讓模型儘快收斂,學習率仍然是需要下降的,只不過下降的策略發生了小小的變化,以前的下降就是不考慮任何因素直接在指定的epoch處下降,而AlexNet中則是用在驗證集上的錯誤率作為評價指標,當錯誤率不再下降時進行學習率的下降。

總之這篇論文中大致的需要我們初學者小白們學習的內容就是這些了,當然如果大家對於其他的一些像多GPU訓練等內容感興趣的話,也可以再把其他的內容也讀一讀,不過在這篇部落格中我就不管那些啦。那麼接下來我們就開始進行論文的具體分析以及復現吧(≧∀≦)

具體分析以及復現

這篇論文雖然說雜七雜八的東西蠻多的,但是實際上覆現難度並不是很高,畢竟大部分的內容在Pytorch裡面有現成的東西可以用,我們犯不上為了這個模型非要自己造個輪子(點名批評C++,幹啥都要造個輪子,滑稽.jpg)。裡面唯一需要我們自己寫的東西就只有一個論文提到的LRN,不過這個也挺簡單的啦。

還是先介紹一下我復現所使用的環境吧:

  • 硬體:Intel i7-8750H,GTX1050Ti(垃圾筆記本所以ImageNet就別想了,跑個CIFAR-10都費勁┓('∀')┏)

  • 軟體:python 3.6.x,pytorch1.4.1,就anaconda那一套裝好完事,作業系統是win10,因為程式碼中不涉及跨平臺的部分,所以把裡面的檔案路徑改一改應該是可以在Linux上正常執行的。平常用notepad++比較多,不太習慣用pycharm(不過我估計以後寫大型專案還是會真香.jpg吧┓('∀')┏)

為了防止我們之後忘記引用模組,所以老規矩,我們這裡把所有需要用到的模組和包全都一股腦放在開頭:

import torch
import torch.nn as nn
from torchvision import transforms as T
from torchvision import datasets
import torch.optim as optim
from torch.utils.data import DataLoader

可以看到我們相比之前的論文復現,多引用了一個DataLoader的類,這個類是為了我們進行資料的mini-batch劃分用的,之後我們會簡單介紹一下,這裡就先不提啦

在介紹具體的AlexNet之前,我們還是先把這篇論文使用的資料集及其處理方式簡單介紹一下吧,畢竟我們只有知道資料集處理方式,才能更好理解模型的各種輸入引數是怎麼回事嘛。

資料集簡介及資料增強手段

之所以把資料增強也放在這裡,是希望大家能把所有關於資料處理的部分都一下子在這個部分了解了,就不用了來回來去翻論文了,是不是挺貼心?絕對不是因為我懶得再分一個小章節寫了哦!

這篇論文中使用的資料集是大名鼎鼎的ImageNet在ILSVRC2010的一個子集,在訓練集裡面大概有1.2millon個圖片,驗證集裡有50000圖片,測試集150000圖片,資料集的壓縮包我記得大概132G左右,解壓完我估計更大,一共1000個分類,圖片尺寸大小不一而且都很大,所以我家裡這臺破電腦實在是無能為力啊TAT,所以這篇論文的復現工作就先不正經訓練了哈。

這篇論文的資料預處理方式非常簡單,就是將圖片摳出中間的256×256的部分,並且減去各個通道的畫素值的均值。並不對資料的方差做什麼處理,就是這篇論文比較新鮮的地方了,我們知道在傳統的模型中,我們大多數都比較喜歡讓資料的分佈滿足一個均值為0,方差為1的一個大致分佈,而這裡論文作者就說了,“俺們的模型賊NB,0均值就夠了,方差啥的無所謂,就是這麼6”(大概這個意思啦,不是原文┓('∀')┏)。

大家如果讀過論文的話,應該可以大致瞭解過AlexNet的模型的規模,實際上還是很大的。論文作者也提到過,對於這個如此龐大的模型(對於當時來說),即使使用的資料集這麼大,模型的過擬合的風險還是蠻高的,因此仍然需要通過資料增強,來擴充套件訓練集。所使用的資料增強方法如下:

  • 隨機摳圖:經過預處理後,我們得到了256×256的圖片,為了進一步增強資料,論文提到從這個256×256的圖片中,每一次訓練都隨機摳出來一個224×224的圖片作為實際的網路輸入,這樣就保證每一次用某一張圖片輸入網路的時候,實際輸入的圖片都會稍稍有一點不一樣,從而起到資料增強的作用。不過因為我們這裡使用的資料集不是ImageNet而是cifar-10,後者的圖片大小也就32×32,所以這裡我就直接把圖片擴充套件到224×224了,當然也可以先擴充套件到256×256然後自己隨機裁剪一下,這個在PIL以及OpenCV中都是支援的。然後均值實際上在《Deep Learning with Pytorch》這本書裡已經算過了,所以我就不算了,感興趣的可以自己算一下。圖片處理器的程式碼就放在下面:
picProcessor = T.Compose([
    T.Resize(224),
    T.ToTensor(),
    T.Normalize(
        mean = [0.4915, 0.4823, 0.4468],
        std=[1.0, 1.0, 1.0]
    )
])
  • PCA成分疊加:論文提到,為了從畫素層面進行資料的增強,首先先對整個訓練集的各個通道的畫素值計算各個通道的主成分,然後再將主成分新增隨機數來疊加到訓練集的圖片上。這樣每一次使用的圖片即使是由上一個隨機摳圖的步驟摳到完全相同位置的圖片,實際的畫素內容也是不太一樣的,因此起到了資料增強的作用。具體的疊加方式我建議大家看一下論文,因為這裡復現工作沒使用ImageNet資料集,就簡單使用了一下cifar-10,所以這裡提到的兩個資料增強的方法就都不用了,如果大家有興趣的話可以自己實現一下,反正PCA在python的sklearn庫是已經實現了的,調包就完事了┓('∀')┏

這樣,關於資料預處理以及資料增強的部分就基本上這樣了,也基本沒有什麼實現難點,所以我們還是趕快進入模型重點吧( ̄▽ ̄)

網路結構部分

網路的結構其實蠻簡單的,但是在讀論文以及看論文附圖的時候有兩個比較要命的問題:

  • 論文是基於兩個GPU進行的描述,但是我們現在沒必要把這個模型分開,現在1050以上的顯示卡基本上視訊記憶體都在4G以上,完全裝得下的,所以我們要把論文裡面的模型的卷積層的通道數擴大成兩倍,這樣才和論文的實際模型描述是一致的

  • 論文的模型附圖······講道理我當時看半天沒看懂到底咋回事,然後去其他人的部落格裡找了找,才找到一個感覺是那麼回事的圖,所以如果原論文的圖看不懂的,就來看我之後要用到的那個圖。

那麼接下來我們就來看一下這個經典的AlexNet吧!

網路整體結構

如果大家看過原作論文了,那麼應該對下面的圖片感到很熟悉吧:

怎麼說呢,這個圖片倒是把卷積層的卷積核尺寸以及特徵圖通道數都寫的蠻清楚的,然而你會發現他的什麼池化啊還有一些卷積計算的尺寸完全對不上,論文把圖整的太簡單了,所以導致我們後面的人在看的時候可能有點看不太懂。並且他這個圖是放在兩個GPU上的版本,而我們現在只用一個GPU就完全夠用了,因此我們可以看一下下面的這個圖:

這個圖就看起來很舒服了,不僅告訴你每一個卷積層的卷積核大小以及通道數,而且連輸出的圖片尺寸是咋算出來的都告訴了,閱讀體驗極佳,當然這個圖的出處我會放在最後,大家可以去原部落格裡面去看一下。

網路的大致結構已經放在這裡了,那接下來我們就對每一個模組進行一個簡單的介紹吧。由於原論文中並沒有對每一個層進行命名,因此我們按照下面的方式對模型的每一層進行命名:

  • 所有的卷積層以C開頭,後面的數字表示第幾個卷積層,比如C3是第三個卷積層,並且不代表這是網路的第三層

  • 所有的池化層以S開頭,數字的含義同上

  • 所有的全連線層以F開頭,數字含義同上

  • 所有的LRN標準化層以N開頭,數字含義同上

命名方式搞定,下面我們來看一下每一個層的具體結構吧

C1層

之前我們提到過,輸入圖片的實際尺寸是[3, 224, 224]。並且在論文中提到,在作卷積的時候使用的卷積核的尺寸是11,雖然沒有說明使用這個尺寸的目的,但是大致來看目的是在於讓C1層能夠儘可能地提取到足夠的資訊,從而讓之後的卷積層能夠提取到足夠的組合資訊。並且論文中提到,使用的移動步長stride為4,這一步長恰好使得感受野之間的距離也恰好是4。這樣選取的目的在論文中並沒有提及,在我看來有兩個目的:首先是採用較大的步長,這樣在卷積核較大的條件下依然可以保持一個較小的計算量,並且儘量降低輸出特徵圖的尺寸;其次,採取較大的步長,使得卷積核取得的特徵之間重疊較少,更有利於之後的處理獲得更加多樣的特徵。

關於C1層的輸出特徵圖的尺寸,從論文中給出的圖解來看應該是55×55,結合卷積核的尺寸和步長,卷積使用的padding = 2,輸出的特徵圖的channel數應該是96。

因此綜合來看,在C1層使用的引數應如下:

  • in_channels: 3

  • out_channels: 96

  • kernel_size: 11

  • stride: 4

  • padding: 2

卷積層由於Pytorch已經提供了類供我們呼叫,因此在這裡就不附程式碼了。但是在這裡需要注意的是,論文裡提到在C1層會進行初始化處理,初始化的方法為:

  • 權重:使用均值為0,標準差為0.01的正態分佈進行隨機取樣
  • 偏置:全部設定為0

關於權重的初始化問題,由於論文中指定的引數初始化方法是需要滿足N(0, 0.01^2),而之前我們使用的randn是滿足的N(0, 1)的標準正態分佈,為了能夠讓初始化的引數滿足我們的要求,我們需要使用另一個初始化的函式:

torch.normal(mean, std)

這個函式的mean和std最好是tensor,隨機生成的tensor的維度和mean以及std的維度一致,並且每一個元素都是滿足對應位置的N(mean, std^2)的分佈。

舉個例子:

a=torch.normal(torch.tensor([0,1,2], torch.tensor([1,1,1])))

那麼a[0] ~ N(0, 1),a[1] ~ N(1, 1),a[2] ~ N(2, 1)

論文提到,所有的權重都是一樣的設定,所以我們可以之後整體用一個函式遍歷,但是偏置的問題,好幾個層是0,好幾個層是1(作者是真的事多┓('∀')┏),所以我們將偏置的設定放在C1層的定義之後:

self.C1.bias.data = torch.zeros(self.C1.bias.data.size())

關於這樣的定義方式其實我們早就接觸過很多了,只不過之前我們的寫的時候使用一個迴圈,而且設定的不是偏置(bias)而是權重(weight),所以相關的內容建議大家看一下我的LeNet-1989這篇部落格,裡面對這個程式碼的含義有簡單的介紹。

論文裡說明,在C1層之後使用ReLU作為啟用函式,關於這個啟用函式的特點,將在後面的章節中簡單解釋,在這裡就不囉嗦啦。

順便提一下,經過C1層之後,特徵圖的尺寸為[96, 55, 55]

N1層(LRN)

由於啟用函式使用的是ReLU函式,這個函式是沒有上界的,這很有可能導致最後通過啟用函式輸出的資料分佈過於極端。與此同時,論文的作者發現,對資料進行Normalize處理將有利於提高模型的泛化效能,降低過擬合的風險。所以在論文中作者提出了一個標準化方法,就是這裡的LRN了。標準化使用的計算方法如下所示:

\[b^i_{x,y}=a^i_{x,y}/(k+{\alpha}\sum^{min(N-1,i+n/2)}_{j=max(0,i-n/2)}(a^j_{x,y})^2)^{\beta} \]

這個公式中的各個引數的含義其實比較簡單,但是由於部落格園這邊的文字上下標編輯實在是有點emmmmm,所以這邊我就簡單寫寫,如果有什麼疑問的話還是直接參照論文來看吧:

  • a表示輸入,b表示輸出

  • 上標為某個畫素點所處的特徵圖的位置,從0開始

  • 下標(x,y)表示計算的點在特徵圖上的座標

  • N為特徵圖的通道數,也就是channels

  • k,alpha,beta,n為人為選取的超引數,論文作者通過計算得到k = 2, alpha = 1.0e-4, beta = 0.75, n = 5。

這個公式的形象化理解就是,在對某一個畫素點進行標準化時,是使用它前後的n/2共n範圍內的特徵圖的對應位置的畫素點作為標準,來進行標準化的計算。這一層中沒有可訓練引數,而且所做的就是十分簡單的索引切片以及簡單的運算,所以實際上沒有什麼復現難度,雖然這個基本上是整篇下來唯一一個需要自己寫的類┓('∀')┏。那麼下面我們就把程式碼貼上來唄:

class LRN(nn.Module):
    def __init__(self, in_channels: int, k=2, n=5, alpha=1.0e-4, beta=0.75):
    
        super(LRN, self).__init__()
        self.in_channels = in_channels
        self.k = k
        self.n = n
        self.alpha = alpha
        self.beta = beta
        
    def forward(self, x):
        tmp = x.pow(2)
        div = torch.zeros(tmp.size()).to(device)
        
        for batch in range(tmp.size(0)):
            for channel in range(tmp.size(1)):
                st = max(0, channel - self.n // 2)
                ed = min(channel + self.n // 2, tmp.size(1)-1)+1
                div[batch, channel] = tmp[batch, st:ed].sum(dim=0)
        
        out = x / (self.k + self.alpha * div).pow(self.beta)
        return out

這部分如果大家有python基礎的話還是蠻簡單的,並且雖然這個系列的部落格是面向小白的,不過大家跟著玩了這麼久,肯定python也學了個七七八八了,所以這部分就不詳解了,如果有不明白的地方,直接搜一下python的切片操作即可,基本上這部分程式碼裡面初學者難以理解的就是裡面的切片操作而已。

接下來我們需要做的就只是呼叫這個類的初始化函式來進行物件的定義以及初始化即可,這個就之後完整程式碼裡面說吧。

S1層

在這裡論文作者又搞出一個騷操作,那就是讓池化的核在移動的時候與之前發生重疊。具體來說,就是使用下面的引數:

  • kernel_size: 3

  • stride: 2

選用的池化方式為最大化池化MaxPool。論文作者說,這麼搞能夠降低過擬合的風險(我讀書不多,你可別騙我)。關於這一部分我因為讀的論文啥的還不夠多,所以關於這樣做為什麼能降低過擬合風險,我也不是太清楚,如果評論區有大佬能夠指點一下那就太好了。

經過S1的池化操作之後,輸出的特徵圖的尺寸變為[96, 27, 27]

C2層

從這裡開始,原論文就開始把模型往兩個GPU上面搬了,但是因為我們們其實手頭上的GPU大多數都足夠用,並且及時GPU不夠用,網上也有許多線上的GPU訓練平臺可以免費使用一些GPU進行訓練,因此在這裡我們不看論文上的圖,而是看我上面給出的稍微清晰一點的彩圖。其實讀的時候就是將論文裡面給出的卷積核的數量加倍就完事了,所以這裡結合著論文以及上面的一張彩圖來看一下C2層的具體引數:

  • in_channels: 96

  • out_channels: 256

  • kernel_size: 5

  • stride: 1

  • padding: 2

在初始化時,權重的初始化方法和C1一致(應該說所有的權重初始化方法都是一致的),然後偏置是全部初始化為1

經過這樣的卷積處理,輸出的特徵圖的尺寸變為[256, 27, 27]

根據論文的描述,在C2之後也是有ReLU啟用函式的。

N2層

根據論文介紹,在C2層使用啟用函式啟用之後,也需要使用LRN進行資料的標準化處理,由於我們之前已經介紹了LRN層的類程式碼內容,所以在這裡不做過多的描述,直接呼叫一下初始化函式即可。

S2層

在經過N2層處理結束之後,我們需要對結果進行最大化池化。池化的引數和S1的引數是完全相同的,經過S2層之後,特徵圖的尺寸變為[256, 13, 13]

C3層

C3層就是我們之前經常接觸的非常常見的尺寸的卷積層啦,所以在這裡我們直接給出卷積層的引數:

  • in_channels: 256

  • out_channels: 384

  • kernel_size: 3

  • stride: 1

  • padding: 1

初始化的時候,偏置全部初始化為0。

輸出的特徵圖的尺寸為[384, 13, 13]。在C3之後也是有ReLU啟用函式的。

C4層

講道理覺得,偷個懶真的是香啊,這麼省事我真是謝謝論文作者啊233333。

  • in_channels: 384

  • out_channels: 384

  • kernel_size: 3

  • stride: 1

  • padding: 1

初始化的時候,偏置全部初始化為1。

輸出特徵圖的尺寸為[384, 13, 13]。在C4之後同樣有ReLU啟用函式。

C5層

繼續摸魚233333

  • in_channels: 384

  • out_channels: 256

  • kernel_size: 3

  • stride: 1

  • padding: 1

初始化的時候,偏置全部初始化為1。

輸出特徵圖的尺寸為[256, 13, 13]。在C5層之後同樣有ReLU啟用函式。

S3層

繼續摸······啊摸不得了,差點就又把卷積層的那些引數粘過來了23333。

在這裡使用的基本的池化方法和S1和S2是完全一致的,所以就不說引數啦。輸出的特徵圖的尺寸為[256, 6, 6]。

F1層

接下來的部分,論文中提到進入全連線的部分,應該說一直到VGG,網路都還是基本的“卷積+全連線”的模式,直到後面有論文提出,全連線就是個垃圾,我到最後都用池化一直搞到最後,效能也其實挺好(我忘記是全卷積網路還是什麼網路提出來的了,等到我後面有空再去瞅瞅┓('∀')┏)。

AlexNet指出,全連線層這邊有4096個神經元,考慮到從上層下來的特徵圖的尺寸為[256, 6, 6],而全連線層的輸入要求圖片的維度不考慮batch_size應該是個一維的,因此我們需要使用view操作對輸入進行一個處理,變成[batch_size, 256*6*6]這樣的形式,當然啦這個操作最好放在forward裡面,這裡就是提一下讓大家注意一下。

因此F1的基本引數應該是下面的樣子:

  • in_features: 256*6*6

  • out_features: 4096

初始化的時候,偏置全部初始化為1,並且所有的全連線層,偏置都是初始化為1。

在F1層之後是要跟一個ReLU啟用函式的。

並且在論文裡面指出,在F1和F2後面有Dropout操作,這個操作對於降低模型的過擬合風險真的是有奇效,具體內容等到之後和ReLU等一些騷操作一起說吧,這裡大家先了解一下要用Dropout

F2層

這裡也讓我偷一下懶好啦( ̄▽ ̄)

  • in_features: 4096

  • out_features: 4096

同樣的,在F2之後也有ReLU以及Dropout。

F3層

繼續摸魚,你能拿我咋辦┓('∀')┏

  • in_features: 4096

  • out_features: 1000

這裡就只有一個ReLU函式啦,再往後我們就直接輸出各個分類的計算結果了。

那麼到這裡,網路的基本結構就介紹完成了,接下來,我們需要簡單介紹一下啟用函式和dropout操作,然後我們就可以著手構建AlexNet的基本程式碼啦

ReLU啟用函式

不同於之前使用的Sigmoid以及Tanh函式,在AlexNet中使用的是ReLU函式,這個函式的公式如下所示:

\[ReLU(x)=max(0,x) \]

這個函式有一些比較有趣的東西(有好有壞),我們一個一個來說:

  • 梯度:這個函式的梯度相較於以前的那兩個函式而言,或好或壞,有以下的四個性質:

    • 梯度數值:從函式的表示式來看,梯度就只有兩個值,一個是1,一個是0。而Sigmoid函式梯度最大才剛0.25,因此對於Sigmoid以及Tanh函式來說,梯度消失現象十分明顯,而在ReLU函式中,梯度下降現象就並不是那麼嚴重,只要初始化沒把所有計算數值都在負值區間中,那梯度就不會為0,所以理論上梯度消失現象是可以完全消除的。

    • 梯度計算難度:從函式表示式來看,ReLU函式在計算梯度的時候,其實就只是做了一個十分簡單的條件判斷,而Sigmoid以及Tanh在計算梯度的時候要計算很多比較噁心的指數運算,因此ReLU函式在計算梯度的時候,算力開銷非常地少。根據論文中的描述,在cifar-10資料集上,ReLU函式的訓練速度大概是使用Tanh函式的卷積網路的6倍左右,可以看出訓練能力被提高了不少

    • 梯度爆炸:事實上,由於梯度的最大值為1,再加上函式本身沒有上界,因此在訓練過程中梯度爆炸現象還是挺容易發生的。因此在很多的論文以及部落格中都有提及,在使用ReLU函式進行訓練的時候,最好把學習率設定得小一點。

    • 神經元死亡:函式的梯度從表示式來看只有0和1兩個值,也就是說,當網路引數的初始化不合理的時候(主要是權重),很可能導致啟用函式的計算結果總是在0區間,也就是輸出的梯度永遠為0,永遠也得不到更新。在某些情況這可能會提高模型速度,並降低過擬合風險,但是這也很有可能導致模型欠擬合(廢話,全是初始引數沒辦法更新,這個和瞎猜有啥區別┓('∀')┏),從而對模型產生很大影響。

  • 值域:這個函式的表示式已經在上面寫過了,下面的圖哪就是這個函式的影像啦:

    • 生物學含義:生物神經元在受到外來刺激之後的響應曲線和ReLU的基本形狀比較相似,因此至少從生物學角度來講,ReLU函式在進行神經仿生計算的時候,從可解釋性以及仿生性來說是比較合適的;此外,根據腦神經科學的研究進展,人腦中的神經元在被啟用時並不是全部啟用,而是隻有部分相關的區塊被啟用,而ReLU函式的一半區間上都是零值,這也就意味著在進行計算的時候,許多的神經元將會計算得到0值,也就是未啟用狀態,這也使得ReLU在進行數值計算的時候會更加高效快速。

    • 函式值分佈:ReLU函式值不是以0為中心的,事實上根據影像以及表示式來看,ReLU函式的值一定是非零數,這就導致當引數的初始化不合理的時候,每一次計算出的梯度值的更新方向只能沿著恆正或者恆負的方向進行更新,從而降低訓練速度。具體更多的介紹建議大家看一下知乎的專欄,我會把連結放在最後。

總之ReLU函式就是有上面的或好或壞的基本特點啦,而且因為這個函式非常常用,所以在Pytorch中已經有現成的程式碼,我們直接安安穩穩地做個調包俠就完事了,做個鹹魚它不香麼┓('∀')┏。具體的呼叫方法和我們之前的啟用函式的呼叫時完全一致的,在這裡就不贅述了,有空自己去看看官方文件啦。

Dropout

這篇文章也是比較早地在深度模型中使用Dropout機制的論文了,雖然首次提出並不是這篇,我記得好像是Hitton老爺子的論文來著?總之原文如果大家有興趣的話可以去找來讀一讀,在這裡我主要為那些沒有接觸過的萌新小夥伴們簡單一下機制以及效果,同樣的,參考的部落格連結我會放在最後,大家可以去點個關注啥的。

在介紹Dropout之前我們還是先來看一下傳統的全連線網路:

從上面這張圖可以看到,對於一個完整的全連線網路來說,裡面引數太多,密密麻麻的一大片簡直就是密集恐懼症的福音,無論是從計算成本還是從過擬合風險來說,都是相對來說比較差的。如果大家稍微接觸過一些神經研究的話可能會知道,實際上在人的神經系統中,神經又不是全部一個一個地密密麻麻連在一起的,有些神經其實並不是相互連通的。因此,如果我們在訓練過程中指定一個概率p,讓每一個神經元都以p的概率被“殺死”,也就是不參與運算,那不就既減少了計算量,又降低了引數量,豈不美哉?這個思想實際就是Dropout做的事情。下圖就是我們使用dropout後在某一個訓練輪次中的全連線網路的示意:

dropout

可以看到,引數量少了,而且從網路的拓撲結構上看也和之前不太一樣了。

這樣做的好處在我看來一共有以下幾個:

  • 仿生學意義:較好地模擬了真實神經之間的連線情況。

  • 拓撲結構:改變了原有的拓撲結構,並且由於每一輪訓練得到的實際網路結構都是由概率得到的,因此拓撲結構更加複雜,有可能會學到更加複雜的輸入特徵。

  • 減少神經元之間的共適應關係:在上一條中提到,神經元在每一個訓練epoch中是否存在取決於概率p,因此在不同的訓練epoch之間,某一個神經元可能有時候在有時候不在,這就導致在每次訓練中,神經元之間的依賴關係並不是那麼強,自然就降低了過擬合的風險。

  • 整合學習思想:在第二條中提到,每一輪epoch中的網路結構由於概率p的存在,實際上的連線情況是各有不同的,也就是說,最後訓練得到的網路結構,實際上和訓練了很多個不同的網路結構然後再堆到一起是差不多的。如果大家接觸過整合學習的話應該會對這個思想感到比較熟悉,整合學習實際上就是把一大堆的分類器放在一起,然後在訓練過程中不斷修改各個分類器的得分權重,然後進行各自的引數調整。經過Dropout訓練後的模型也相當於有許多個模型整合在一起進行結果的判斷,而且當訓練輪次epochs足夠多的時候,相當於訓練了2^n個模型,n為神經元個數,通過這麼多模型進行共同判斷,自然可以將過擬合風險顯著降低。

同樣的,因為Dropout在現在的深度學習模型中非常常見,因此也在Pytorch中有現成的,呼叫方法也很簡單,還是請大家自行翻閱一下Pytorch的官方文件看一下怎麼用吧。相信跟著這個系列的部落格的小夥伴們已經能比較熟練地閱讀Pytorch的官方文件了吧。

訓練策略

除了在網路的具體結構之外,AlexNet在訓練使用的一些小策略上,也和之前的LeNet-5以及其他的傳統機器學習模型有一些不同的地方。

梯度下降方式

在LeNet-5以及LeNet-1989中,論文作者使用的都是基於單個樣本的簡單SGD,具體的內容如果大家不太清楚的話,可以自行查閱相關的論文,或者是看一下我之前的部落格(這應該不算打廣告吧┓('∀')┏)。然後吶,在AlexNet中作者基於單樣本以及簡單SGD進行了兩個方面的改進:

  • 單樣本改進——mini-batch:就如同我之前在LeNet-5的復現部落格中提到的,基於所有樣本的梯度下降如果看成是基於整個樣本空間的期望的話,那麼單樣本的SGD就相當於從樣本空間隨機取得一個樣本,把這個值作為期望的估計值。這樣確實是引入了足夠的隨機性,但是問題在於,這也太粗糙了,隨機過了頭就很可能導致引數更新方向完全錯了。其實仔細想一想我們平常如果想要獲得某個數值的估計,一般都是取平均嘛,這樣既有一定的隨機性,同時又可以保證大致的方向是和整體期望近似相同的(好歹人家也是期望的無偏估計量嘛┓('∀')┏)。而這也是這篇論文中所使用的mini-batch思想。在AlexNet中,由於使用的訓練集有1.2millon張圖片,因此mini-batch稍微取大一點點,對於隨機性的影響並不是很大,在論文中使用的mini-batch為128。

  • 簡單SGD改進——帶動量的SGD:之前我們使用的SGD就是很簡單的利用所在點的函式梯度(導數)來作為引數的更新依據。從更新方法上來看,如果我們出現了導數為0的點(駐點),那麼SGD就會在這個點停止更新。如果這個點是極值點倒是還算運氣好,但是當出現像下面的函式影像的時候,你就會懷疑人生:x立方

    這個函式影像實際上是三次曲線,為了讓大家看得更清楚所以把圖片稍微壓扁了一點點。可以發現在x=0的鄰域內,函式的影像十分平緩,這也就意味著函式在這附近的梯度很小,那基本上也就沒辦法被正常更新(看一下[-0.5, 0.5]的區間,導數基本上是0啊),同時(0, 0)這個點並不是極值點(事實上三次曲線沒極值點┓('∀')┏),也就是發生了“明明沒訓練好,但是模型自己就停止訓練了”的尷尬問題。這個玩意在DeepLearning裡面好像是叫鞍點問題。雖然舉的例子是一個沒有極值點的不太合適的例子,但是事實上在實際我們經常訓練的其他的假設函式模型中,部分鄰域內函式影像是這種情況的多了去了,這也是普通的SGD效果在複雜問題中一般很差的原因。因此在AlexNet中,作者使用了帶有動量的SGD。帶動量的SGD,通俗一點的理解就是帶初速度的加速運動(是不是有高中物理內味兒了),具體的公式以及說明建議大家查閱一下相關的資料,我會把我讀過的部落格放在最後的連結中。

學習率的下降

不同於之前LeNet-5的直接按照epoch數來認為設定學習率的下降,AlexNet中將訓練函式中的錯誤率作為評價的指標,當錯誤率停止下降的時候,就對學習率進行下降。這樣根據某一指標進行學習率的動態下降,我覺得其實還行,就是稍微麻煩了一點。

之前我們提到過,Pytorch專門提供了類用來方便我們的學習率下降,這裡我們既可以像之前的LeNet-5一樣,通過在優化器中的param_group字典來遍歷引數進行人工修改,也可以直接呼叫專門的類來進行調整。為了讓大家知道有這麼些類能夠用來調整學習率,所以在這裡我們直接用現成的。由於我們之前已經匯入過torch.optim了,所以這裡我們直接用:

scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='max', factor=0.1, patience=1)

為大家解釋一下這段程式碼:首先在torch.optim中存在這樣一個包lr_scheduler,裡面是我們所有的和學習率衰減有關的類,比如指定epoch下降、指數衰減以及這個指定引數衰減。引數含義在下面簡單介紹一下啦:

  • optimizer:我們定義好的優化器物件

  • mode:一般就用兩種模式:

    • 'min':當指標停止下降的時候,就進行學習率修改
    • 'max':當指標停止上升的時候,就進行學習率修改
    • 這裡我們使用max的原因是,雖然論文裡使用錯誤率不再下降進行的學習率修改,但是寫程式碼的時候調整成正確率不再上升是完全等價的,所以這裡就改成max啦
  • factor:當學習率進行修改的時候,就用lr = lr*factor進行修改

  • patience:容忍輪數:若經過了patience指定的輪數,評價指標還沒有達到指定要求,就進行學習率修改。

而在使用scheduler的時候,實際上和使用optimizer差不多。我們需要在optimizer進行step操作之後,對這個scheduler進行step操作,並將我們的正確率作為引數傳給step(廢話,不傳引數怎麼知道拿啥作指標┓('∀')┏)。大致的虛擬碼看一下下面啦:

optimizer.step()
with torch.no_grad():
	計算分類正確的個數acc
accRate = acc / 驗證集樣本數
scheduler.step(accRate)

也就是說,要遵循train --> val --> scheduler.step() 的操作順序,這是在Pytorch的官方文件上註明的,如果感興趣的話,建議大家看一下官方文件上對應的函式以及類的用法。

具體程式碼

因為這一次的程式碼一來它很長,二來除了一些訓練策略換了幾個包調,其他的都和之前介紹的LeNet-5啥的差不太多,因此我們這裡就把模型和訓練函式全部分隔開,並且就不像之前一樣一步一步拆開講每一個部分的程式碼是怎麼回事了。程式碼註釋為了方便讓大家讀,所以都是中文,所以如果是一直跟著這個系列的萌新小夥伴們,讀起來應該沒有什麼大問題吧……大概,嗯。

網路模型部分

import torch
import torch.nn as nn

device = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu") #指定執行裝置,因為這個模型還是挺大的,所以CPU上會非——常——慢,建議放在GPU上跑

class LRN(nn.Module):
    def __init__(self, in_channels: int, k=2, n=5, alpha=1.0e-4, beta=0.75):
    #把所有用的到的引數進行賦值,引數名和論文裡面是基本一致的
        super(LRN, self).__init__()
        self.in_channels = in_channels #特徵圖的通道數,就是論文裡面的N,這裡是為了讓引數含義比較易讀所以寫的這個
        self.k = k
        self.n = n
        self.alpha = alpha
        self.beta = beta
        
    def forward(self, x):
        tmp = x.pow(2)
        div = torch.zeros(tmp.size()).to(device) #這裡必須要放到指定的device上,這是因為其他的tensor都是在device上,如果放到device上的話,會導致div和其他的tensor在不同的device上,無法運算,程式會在下面計算out = x / ...的位置報錯
        
        for batch in range(tmp.size(0)):
            for channel in range(tmp.size(1)):
                st = max(0, channel - self.n // 2) #這裡必須要用‘//’而不是‘int(a/b)’的形式,這是因為在Pytorch中即使使用了int進行強轉,也會發生實際型別是float而不是int的問題
                ed = min(channel + self.n // 2, tmp.size(1)-1)+1
                div[batch, channel] = tmp[batch, st:ed].sum(dim=0) #切片操作
        
        out = x / (self.k + self.alpha * div).pow(self.beta)
        return out

class AlexNet(nn.Module):

    def __init__(self, nclass): #nclass:用於確定最終的分類問題的分類數
        
        super(AlexNet, self).__init__()
        #為了不寫那麼多的ReLU、MaxPool2d以及Dropout,就在這裡複用一下程式碼好了
        self.act = nn.ReLU(True)
        self.pool = nn.MaxPool2d(kernel_size=3, stride=2)
        self.dropout = nn.Dropout(0.5)
        
        self.C1 = nn.Conv2d(3, 96, kernel_size=11, stride=4, padding=2)
        self.C1.bias.data = torch.zeros(self.C1.bias.data.size()) #對偏置的初始化,已經在上面說得很清楚了
        self.N1 = LRN(96)
        
        self.C2 = nn.Conv2d(96, 256, kernel_size=5, stride=1, padding=2)
        self.C2.bias.data = torch.ones(self.C2.bias.data.size())
        self.N2 = LRN(256)
        
        self.C3 = nn.Conv2d(256, 384, kernel_size=3, stride=1, padding=1)
        self.C3.bias.data = torch.zeros(self.C3.bias.data.size())
        
        self.C4 = nn.Conv2d(384, 384, kernel_size=3, stride=1, padding=1)
        self.C4.bias.data = torch.ones(self.C4.bias.data.size())
        
        self.C5 = nn.Conv2d(384, 256, kernel_size=3, stride=1, padding=1)
        self.C5.bias.data = torch.ones(self.C5.bias.data.size())
        
        self.F1 = nn.Linear(256*6*6, 4096)
        self.F2 = nn.Linear(4096, 4096)
        self.F3 = nn.Linear(4096, nclass)
        
        for m in self.modules(): #權重以及線性層偏置初始化
            if isinstance(m, nn.Conv2d) or isinstance(m, nn.Linear):
                m.weight.data = torch.normal(torch.zeros(m.weight.data.size()), torch.ones(m.weight.data.size()) * 0.01) #N(0, 0.01^2), 具體函式說明在C1層中已經說明
                if isinstance(m, nn.Linear):
                    m.bias.data = torch.ones(m.bias.data.size())
    
    def forward(self, x):
    
        x = self.pool(self.N1(self.act(self.C1(x))))
        x = self.pool(self.N2(self.act(self.C2(x))))
        
        x = self.act(self.C3(x))
        x = self.act(self.C4(x))
        
        x = self.pool(self.act(self.C5(x)))
        
        x = x.view(-1, 256*6*6)
        
        x = self.dropout(self.act(self.F1(x)))
        x = self.dropout(self.act(self.F2(x)))
        
        x = self.act(self.F3(x))
        return x

訓練部分

由於ImageNet訓練一下的話我可能整個假期都要搭進去了,所以這裡我們使用的資料集是cifar-10,所以有一些訓練用的引數會和實際的AlexNet不太一樣,有修改的部分我會寫在註釋裡面,大家不用著急哈。

import torch
import torch.nn as nn
from torchvision import transforms as T
from torchvision import datasets
import torch.optim as optim
from torch.utils.data import DataLoader

from AlexNet import AlexNet, device

picProcessor = T.Compose([
    T.Resize(224),
    T.ToTensor(),
    T.Normalize(
        mean = [0.4915, 0.4823, 0.4468], #這個是cifar-10的均值均值可以google或者看一下《Deep Learning with Pytorch》這本書,實在不行自己算也行。ImageNet的均值也是可以google一下找到的
        std=[1.0, 1.0, 1.0] #論文提到不用對標準差進行處理,所以這裡就寫1.0就行啦
    )
])

def train(epochs, model, optimizer, loss_fn, scheduler, trainSet, testSet):
    
    lossList = []
    testAcc = []
    
    for epoch in range(epochs):
        
        lossSum = 0.0
        print("epoch:{:d}/{:d}".format(epoch, epochs)) #老規矩,看一下模型是不是在跑
        
        model.train()
        for idx, (img, label) in enumerate(trainSet):
            
            img = img.to(device)
            label = label.to(device)
            
            optimizer.zero_grad()
            out = model(img)
            loss = loss_fn(out, label)
            loss.backward()
            optimizer.step()
            
            lossSum += loss.item()
            if (idx+1) % 10 == 0: print("batch:{:d}/{:d} --> loss:{:.4f}".format(idx+1, len(trainSet), loss.item()))
            #print("batch:{:d}/{:d} --> loss:{:.6f}".format(idx+1, len(trainSet), loss.item())) #如果10個batch顯示一次有一點慢,就用這個句子,每個batch都顯示一下
            
            
        model.eval() #這裡必須有這個句子,由於把dropout中的一些引數鎖死
        accNum = 0
        testNum = 0
        with torch.no_grad():
            for idx, (img, label) in enumerate(testSet):
            
                testNum += label.shape[0]
                img = img.to(device)
                label = label.to(device)
                
                out = model(img)
                preds = out.argmax(dim=1)
                accNum += int((preds == label).sum())
                
            testAcc.append(accNum / testNum)
        if scheduler is not None:
            scheduler.step(accNum / testNum) #這個就是之前提到的step啦
            
        torch.save(model.state_dict(), 'F:\\Code_Set\\Python\\PaperExp\\AlexNet\\Models\\epoch-{:d}_loss-{:.6f}_acc-{:.2%}.pth'.format(epochs, lossList[-1], testAcc[-1])) #所有對應路徑上的資料夾必須存在,否則會報錯,torch.save是不會給你自動建立資料夾的。你也不希望訓練了半天結果儲存不成功報錯了吧
            
if __name__ == "__main__":
    model = AlexNet(10).to(device) #cifar-10就只有10個分類,所以這裡nclass給的是10
    
    #將整個資料集進行讀取和預處理
    path = "F:\\Code_Set\\Python\\DLLearing\\cifar10-dataset\\" #根據自己的實際資料集進行定位
    cifar10_train = datasets.CIFAR10(path, train=True, download=False, transform=picProcessor) #第一次使用記得把download設定為True
    cifar10_test = datasets.CIFAR10(path, train = False, download = False, transform=picProcessor)
    
    trainSet = DataLoader(cifar10_train, batch_size=128, shuffle=True) #如果電腦的顯示卡不是太好,最好把batch_size調整為64或者32,實在不行就調成16
    testSet = DataLoader(cifar10_test, batch_size=128, shuffle=True)
    
    epochs = 10 #ImageNet是訓練了90多個epoch,但是cifar-10沒必要
    optimizer = optim.SGD(model.parameters(), lr=1.0e-2, momentum=0.9, weight_decay=5.0e-4) #實際執行cifar-10的時候,lr=1.0e-4, 否則會發生梯度爆炸以及全部神經元死亡的問題,導致loss下降到2.3附近的時候停止下降,這個引數記得調啊
    
    scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='max', factor=0.1, patience=1)
    loss_fn = nn.CrossEntropyLoss()
    
    train(epochs, model, optimizer, loss_fn, scheduler, trainSet, testSet)

結果簡要說明

由於ImageNet太大了,我這個破本實在是無能為力,而且cifar-10這個資料集我也就跑了一個epoch看了看沒有bug,畢竟本的效能垃圾,而且時間成本有點高,所以可能程式碼裡面還有一些執行到後面才會出現的bug我沒有發現,到時候回了實驗室我可能會再重新跑一下看看有沒有問題,大家主要看一下各個部分的程式碼的思路就好了。

這裡面還是有一些需要避開的坑的:

  • tensor所處的device問題:這一部分我在LRN的程式碼註釋中已經詳細解釋了

  • 訓練的初始化引數:偏置無所謂,但是對於權重的初始化,要麼按照論文上來,要麼按照Pytorch提供的預設初始化來。如果想自定義初始化,一定要讓初始化值在0附近,我嘗試過哪怕是在N(0, 0.1^2)的分佈下,在cifar-10資料集上都會發生十分嚴重的梯度爆炸問題,不過在ImageNet上可能不會發生,畢竟cifar-10我是強制從[32, 32]擴張到[224, 224]的,可能問題就出在這裡。

  • 初始學習率:在ImageNet上可能可以把lr初始化為0.01,但是在cifar-10上,可能也是上面我提到的圖片縮放問題,使得lr=0.01時,學習率過大,使得訓練過程中神經元全部死亡導致整個模型在loss達到2.3058的時候停止學習,學習率不再下降。

  • scheduler可能還是有點小bug吧,因為我只試了一個epoch,所以scheduler因為沒有滿足patience所以沒報錯,如果大家執行有問題,就乾脆直接手動在optimizer的param_group裡面進行手動更改好啦,具體的方法在LeNet-5裡面有說。

反正訓練了一個epoch以後,損失函式確實在下降,從3左右下降到了0.5左右,而且確實還有下降趨勢,至於效果到底如何,還是大家自己執行著試試看好啦。

反思

AlexNet作為Deep Learning的一篇代表作,裡面確實有許多值得我們學習以及借鑑的內容,與此同時,限於當時大家對於深度學習模型的瞭解還沒有現在這麼深刻,所以這篇論文中也有一些實際上作用不大,或者說是隻適合當前模型但是沒有泛用性的一些東西。同時,因為我也沒有讀過很多的論文,所以也有一些讀完這篇論文也沒太明白的部分,如果哪位大佬能解讀一下的話希望能在評論區指點一下,不勝感激。

優點總結:

  • 在訓練時採用了SGD + momentum的方式:這種方式將前一時刻的梯度與動量,與當前時刻的梯度進行疊加,作為更新的依據。這種做法在一定程度上解決了深度學習的鞍點問題,並且雖然作用不是很大,但是也在一定程度上有利於跳出區域性最優解。雖然momentum不是在這篇論文中提出的,但是這也算是在DL領域中,人們把梯度下降優化方法納入視線的重要論文。

  • 在訓練時採用了mini-batch:在批量梯度下降以及單樣本的SGD做了折中,從統計意義上既具有單樣本SGD的隨機性梯度的優勢,同時也有批量梯度下降的梯度準確性的優勢,並且mini-batch也在一定程度上利用了硬體的平行計算優勢。合理選擇一個mini-batch的batch_size,也算是目前深度模型的需要合理解決的一個問題吧。

  • 使用了ReLU函式:ReLU函式的特性,從理論上解決了以前常用的Sigmoid以及Tanh的梯度消失的問題,並且由於函式形式簡單,所以梯度求解以及函式值求解都比S和T兩個函式都簡單很多,使得模型的訓練變得高效很多。

  • 學習率下降策略的改變:之前的LeNet-5中使用的學習率下降策略就是簡單的按照指定的輪次epoch進行下降,但是這樣做的一個問題就是,無從在訓練過程中判斷是否學習率下降是合理的,只能通過多次訓練找到規律後再進行調整。而通過某一個訓練中的指標判斷是否進行學習率下降,這就在一定程度上,讓學習率的下降和實際訓練的關係更加密切。

  • 資料增強方法:雖然我用的cifar-10訓練所以沒有使用PCA進行資料增強,但是我個人覺得,通過在原圖上隨機疊加主成分向量的資料增強方式,比起隨機新增椒鹽噪聲或者白噪聲來說,可能與自然條件上因為光照等因素造成的圖片失真更加近似,因此這種基於PCA的資料增強方式可能比較有效。

不足點反思

  • ReLU函式是否合理:正如之前的內容說到的,ReLU函式確實具有很多的優點,同時如果大家看過上面的結果簡要分析中的內容,或者是把上面的程式碼中的一些註釋後面提到的小引數改一下,其實就會明白ReLU函式對於資料分佈、初始化引數以及學習率上的要求很嚴格。當初始化引數的絕對值比較大,或者學習率比較大的時候,就很容易發生梯度爆炸以及神經元死亡的許多比較嚴重的問題。目前也有許多人提出了基於ReLU的改進方案,比如Leaky ReLU,ELU等。詳細的內容在最後的知乎專欄連結裡是有的,大家可以自己點開看一看,順便給大佬點個關注啥的(我是不是該向他要點廣告費啥的┓('∀')┏)。

  • LRN標準化是否有必要:在其他研究人員的後續研究,以及我看到的一些其他的博主的部落格中,作者們都提出,LRN對於模型的泛化能力其實並沒有什麼特別的作用,在一些場合甚至會導致模型的正確率降低。其實從LRN的公式定義上看,其實LRN確實有一些缺陷:

    • LRN在進行資料處理的時候,基於的是資料所在的特徵圖的附近的通道對應的相同位置的畫素資料,首先從取樣的角度上講,基於的是某個指定的鄰域而不是資料整體,這就導致資料分佈從一個較為有意義的分佈變成了一個基於隨機取樣的隨機分佈,導致資料分佈發生了巨大變化,這對於模型計算有利還是有害,這一點因為我看的論文還不夠,所以不太清楚到底是有利還是有害

    • LRN實際上引入了許多的超引數,這就導致調參變得更加困難,如果把這些引數變成了可訓練引數,那這相當於認為增加了模型的表現能力,過擬合風險反而更大

    • LRN只是讓大的資料更小一點,小的資料更大一點,但是從統計學意義上講,這一做法可能並沒有什麼比較成熟的理論支撐(也有可能是我還沒看到相關的論文或者部落格,如果真的有的話,我就把這一段刪了)

  • SGD+momentum的侷限性:關於這一演算法的圖解以及相關講解的部落格,我放在這篇部落格的最下面。從公式上看,SGD+momentum相當於是有初速度的加速運動,而且這個初速度是從之前的各個時刻的動量不斷指數平均累加下來的,但是就有一個問題。比如對於函式y=x^4來說,這個函式的[1, +∞)以及(-∞, -1]區間上的導數值都非常大,而SGD+momentum不管你大不大,只要是之前的就統統指數平均累加,這就導致如果是從一個離原點較遠的點出發進行梯度更新,就導致在一兩步之後累計的梯度就變得非常大,很有可能發生那種在最優值附近反覆橫跳的現象(就和學習率太大的表現差不多,這也是為什麼學習率有點大的時候即使使用cifar-10也會梯度爆炸),因此不能只是單獨地累加動量。

仍不理解的問題

在LeNet-5中,作者採用的卷積結構是卷積 --> 池化 --> 啟用函式。而在這個AlexNet論文中,作者使用的卷積結構是卷積 --> 啟用函式 -->池化。當然如果使用的ReLU函式以及MaxPool進行處理,池化和啟用函式的相對位置是什麼樣的並不重要,因為MaxPool(ReLU(x)) = ReLU(MaxPool(x))。但是如果我們使用的是其他的函式以及池化方式,那麼不同的激化以及池化順序對於模型表現到底有什麼作用?對應的底層意義到底是什麼?如果有大佬能在評論區指點一下的話那就太好了。

那麼,大體上講這篇AlexNet的復現工作就基本結束了,雖然實際上並沒有做太多的復現工作233333。而且大家需要注意的是,Pytorch本身已經包含了AlexNet的程式碼,並且可以下載對應的預訓練模型,但是實際上Pytorch使用的AlexNet模型相較於論文來說十分簡單,並且dropout的位置以及一些池化方法也和論文字身的描述並不相同,但是作為參考來說是非常夠用了,如果有哪些位置不太明白的話,可以開啟原始碼瞅一眼,雖然一行註釋都沒有哈。

下一篇的復現工作,同樣也是沒辦法在我這個破筆記本上跑啊,所以可能在結果分析部分還是和這篇一樣有點水,看個思路就完事了。因為我看論文習慣先按時間順序看,然後再把內容之間有更新迭代關係的放在一起再看,所以從時間上看下一篇要進行復現的模型是比較有名的GoogLeNet,或者也可以叫做InceptionV1。這篇論文裡面也是有相當多的sao操作,復現難度稍微高一點,所以可能要讓各位小夥伴們再多等一段時間了哈,大家彆著急,慢慢等。

那就下篇見吧(*・ω< )

參考內容:

  1. AlexNet的實際結構圖解:https://blog.csdn.net/stu14jzzhan/article/details/91835508
  2. 關於ReLU等啟用函式的分析:https://zhuanlan.zhihu.com/p/172254089
  3. 關於Dropout的解析:https://www.cnblogs.com/sddai/p/10127849.html
  4. 交叉熵的簡析:https://zhuanlan.zhihu.com/p/115277553
  5. SGD+momentum:https://blog.csdn.net/tsyccnh/article/details/76270707

相關文章