無論即將到來的是大數據時代還是人工智能時代,亦或是傳統行業使用人工智能在雲上處理大數據的時代,作為一個有理想有追求的程序員,不懂深度學習(Deep Learning)這個超熱的技術,會不會感覺馬上就out了?現在救命稻草來了,《零基礎入門深度學習》系列文章旨在講幫助愛編程的你從零基礎達到入門級水平。零基礎意味着你不需要太多的數學知識,只要會寫程序就行了,沒錯,這是專門為程序員寫的文章。雖然文中會有很多公式你也許看不懂,但同時也會有更多的代碼,程序員的你一定能看懂的(我周圍是一群狂熱的Clean Code程序員,所以我寫的代碼也不會很差)。
文章列表
零基礎入門深度學習(1) - 感知器
零基礎入門深度學習(2) - 線性單元和梯度下降
零基礎入門深度學習(3) - 神經網絡和反向傳播算法
零基礎入門深度學習(4) - 卷積神經網絡
零基礎入門深度學習(5) - 循環神經網絡
零基礎入門深度學習(6) - 長短時記憶網絡(LSTM)
零基礎入門深度學習(7) - 遞歸神經網絡
往期回顧
在前面的文章中,我們介紹了全連接神經網絡,以及它的訓練和使用。我們用它來識別了手寫數字,然而,這種結構的網絡對於圖像識別任務來說並不是很合適。本文將要介紹一種更適合圖像、語音識別任務的神經網絡結構——卷積神經網絡(Convolutional Neural Network, CNN)。說卷積神經網絡是最重要的一種神經網絡也不為過,它在最近幾年大放異彩,幾乎所有圖像、語音識別領域的重要突破都是卷積神經網絡取得的,比如谷歌的GoogleNet、微軟的ResNet等,打敗李世石的AlphaGo也用到了這種網絡。本文將詳細介紹卷積神經網絡以及它的訓練算法,以及動手實現一個簡單的卷積神經網絡。
一個新的激活函數——Relu
最近幾年卷積神經網絡中,激活函數往往不選擇sigmoid或tanh函數,而是選擇relu函數。Relu函數的定義是:
Relu函數圖像如下圖所示:
Relu函數作為激活函數,有下面幾大優勢:
- 速度快 和sigmoid函數需要計算指數和倒數相比,relu函數其實就是一個max(0,x),計算代價小很多。
- 減輕梯度消失問題 回憶一下計算梯度的公式。其中,是sigmoid函數的導數。在使用反向傳播算法進行梯度計算時,每經過一層sigmoid神經元,梯度就要乘上一個。從下圖可以看出,函數最大值是1/4。因此,乘一個會導致梯度越來越小,這對於深層網絡的訓練是個很大的問題。而relu函數的導數是1,不會導致梯度變小。當然,激活函數僅僅是導致梯度減小的一個因素,但無論如何在這方面relu的表現強於sigmoid。使用relu激活函數可以讓你訓練更深的網絡。
- 稀疏性 通過對大腦的研究發現,大腦在工作的時候只有大約5%的神經元是激活的,而采用sigmoid激活函數的人工神經網絡,其激活率大約是50%。有論文聲稱人工神經網絡在15%-30%的激活率時是比較理想的。因為relu函數在輸入小於0時是完全不激活的,因此可以獲得一個更低的激活率。
全連接網絡 VS 卷積網絡
全連接神經網絡之所以不太適合圖像識別任務,主要有以下幾個方面的問題:
- 參數數量太多 考慮一個輸入1000*1000像素的圖片(一百萬像素,現在已經不能算大圖了),輸入層有1000*1000=100萬節點。假設第一個隱藏層有100個節點(這個數量並不多),那么僅這一層就有(1000*1000+1)*100=1億參數,這實在是太多了!我們看到圖像只擴大一點,參數數量就會多很多,因此它的擴展性很差。
- 沒有利用像素之間的位置信息 對於圖像識別任務來說,每個像素和其周圍像素的聯系是比較緊密的,和離得很遠的像素的聯系可能就很小了。如果一個神經元和上一層所有神經元相連,那么就相當於對於一個像素來說,把圖像的所有像素都等同看待,這不符合前面的假設。當我們完成每個連接權重的學習之后,最終可能會發現,有大量的權重,它們的值都是很小的(也就是這些連接其實無關緊要)。努力學習大量並不重要的權重,這樣的學習必將是非常低效的。
- 網絡層數限制 我們知道網絡層數越多其表達能力越強,但是通過梯度下降方法訓練深度全連接神經網絡很困難,因為全連接神經網絡的梯度很難傳遞超過3層。因此,我們不可能得到一個很深的全連接神經網絡,也就限制了它的能力。
那么,卷積神經網絡又是怎樣解決這個問題的呢?主要有三個思路:
- 局部連接 這個是最容易想到的,每個神經元不再和上一層的所有神經元相連,而只和一小部分神經元相連。這樣就減少了很多參數。
- 權值共享 一組連接可以共享同一個權重,而不是每個連接有一個不同的權重,這樣又減少了很多參數。
- 下采樣 可以使用Pooling來減少每層的樣本數,進一步減少參數數量,同時還可以提升模型的魯棒性。
對於圖像識別任務來說,卷積神經網絡通過盡可能保留重要的參數,去掉大量不重要的參數,來達到更好的學習效果。
接下來,我們將詳述卷積神經網絡到底是何方神聖。
卷積神經網絡是啥
首先,我們先獲取一個感性認識,下圖是一個卷積神經網絡的示意圖:
網絡架構
如圖1所示,一個卷積神經網絡由若干卷積層、Pooling層、全連接層組成。你可以構建各種不同的卷積神經網絡,它的常用架構模式為:
INPUT -> [[CONV]*N -> POOL?]*M -> [FC]*K
也就是N個卷積層疊加,然后(可選)疊加一個Pooling層,重復這個結構M次,最后疊加K個全連接層。
對於圖1展示的卷積神經網絡:
INPUT -> CONV -> POOL -> CONV -> POOL -> FC -> FC
按照上述模式可以表示為:
INPUT -> [[CONV]*1 -> POOL]*2 -> [FC]*2
也就是:N=1, M=2, K=2
。
三維的層結構
從圖1我們可以發現卷積神經網絡的層結構和全連接神經網絡的層結構有很大不同。全連接神經網絡每層的神經元是按照一維排列的,也就是排成一條線的樣子;而卷積神經網絡每層的神經元是按照三維排列的,也就是排成一個長方體的樣子,有寬度、高度和深度。
對於圖1展示的神經網絡,我們看到輸入層的寬度和高度對應於輸入圖像的寬度和高度,而它的深度為1。接着,第一個卷積層對這幅圖像進行了卷積操作(后面我們會講如何計算卷積),得到了三個Feature Map。這里的"3"可能是讓很多初學者迷惑的地方,實際上,就是這個卷積層包含三個Filter,也就是三套參數,每個Filter都可以把原始輸入圖像卷積得到一個Feature Map,三個Filter就可以得到三個Feature Map。至於一個卷積層可以有多少個Filter,那是可以自由設定的。也就是說,卷積層的Filter個數也是一個超參數。我們可以把Feature Map可以看做是通過卷積變換提取到的圖像特征,三個Filter就對原始圖像提取出三組不同的特征,也就是得到了三個Feature Map,也稱做三個通道(channel)。
繼續觀察圖1,在第一個卷積層之后,Pooling層對三個Feature Map做了下采樣(后面我們會講如何計算下采樣),得到了三個更小的Feature Map。接着,是第二個卷積層,它有5個Filter。每個Fitler都把前面下采樣之后的3個**Feature Map卷積在一起,得到一個新的Feature Map。這樣,5個Filter就得到了5個Feature Map。接着,是第二個Pooling,繼續對5個Feature Map進行下采樣**,得到了5個更小的Feature Map。
圖1所示網絡的最后兩層是全連接層。第一個全連接層的每個神經元,和上一層5個Feature Map中的每個神經元相連,第二個全連接層(也就是輸出層)的每個神經元,則和第一個全連接層的每個神經元相連,這樣得到了整個網絡的輸出。
至此,我們對卷積神經網絡有了最基本的感性認識。接下來,我們將介紹卷積神經網絡中各種層的計算和訓練。
卷積神經網絡輸出值的計算
卷積層輸出值的計算
我們用一個簡單的例子來講述如何計算卷積,然后,我們抽象出卷積層的一些重要概念和計算方法。
假設有一個5*5的圖像,使用一個3*3的filter進行卷積,想得到一個3*3的Feature Map,如下所示:
為了清楚的描述卷積計算過程,我們首先對圖像的每個像素進行編號,用表示圖像的第行第列元素;對filter的每個權重進行編號,用表示第行第列權重,用表示filter的偏置項;對Feature Map的每個元素進行編號,用表示Feature Map的第行第列元素;用表示激活函數(這個例子選擇relu函數作為激活函數)。然后,使用下列公式計算卷積:
例如,對於Feature Map左上角元素來說,其卷積計算方法為:
計算結果如下圖所示:
接下來,Feature Map的元素的卷積計算方法為:
計算結果如下圖所示:
可以依次計算出Feature Map中所有元素的值。下面的動畫顯示了整個Feature Map的計算過程:
上面的計算過程中,步幅(stride)為1。步幅可以設為大於1的數。例如,當步幅為2時,Feature Map計算如下:
我們注意到,當步幅設置為2的時候,Feature Map就變成2*2了。這說明圖像大小、步幅和卷積后的Feature Map大小是有關系的。事實上,它們滿足下面的關系:
在上面兩個公式中,是卷積后Feature Map的寬度;是卷積前圖像的寬度;是filter的寬度;是Zero Padding數量,Zero Padding是指在原始圖像周圍補幾圈0,如果的值是1,那么就補1圈0;是步幅;是卷積后Feature Map的高度;是卷積前圖像的寬度。式2和式3本質上是一樣的。
以前面的例子來說,圖像寬度,filter寬度,Zero Padding,步幅,則
說明Feature Map寬度是2。同樣,我們也可以計算出Feature Map高度也是2。
前面我們已經講了深度為1的卷積層的計算方法,如果深度大於1怎么計算呢?其實也是類似的。如果卷積前的圖像深度為D,那么相應的filter的深度也必須為D。我們擴展一下式1,得到了深度大於1的卷積計算公式:
在式4中,D是深度;F是filter的大小(寬度或高度,兩者相同);表示filter的第層第行第列權重;表示圖像的第層第行第列像素;其它的符號含義和式1是相同的,不再贅述。
我們前面還曾提到,每個卷積層可以有多個filter。每個filter和原始圖像進行卷積后,都可以得到一個Feature Map。因此,卷積后Feature Map的深度(個數)和卷積層的filter個數是相同的。
下面的動畫顯示了包含兩個filter的卷積層的計算。我們可以看到7*7*3輸入,經過兩個3*3*3filter的卷積(步幅為2),得到了3*3*2的輸出。另外我們也會看到下圖的Zero padding是1,也就是在輸入元素的周圍補了一圈0。Zero padding對於圖像邊緣部分的特征提取是很有幫助的。
以上就是卷積層的計算方法。這里面體現了局部連接和權值共享:每層神經元只和上一層部分神經元相連(卷積計算規則),且filter的權值對於上一層所有神經元都是一樣的。對於包含兩個3*3*3的fitler的卷積層來說,其參數數量僅有(3*3*3+1)*2=56個,且參數數量與上一層神經元個數無關。與全連接神經網絡相比,其參數數量大大減少了。
用卷積公式來表達卷積層計算
不想了解太多數學細節的讀者可以跳過這一節,不影響對全文的理解。
式4的表達很是繁冗,最好能簡化一下。就像利用矩陣可以簡化表達全連接神經網絡的計算一樣,我們利用卷積公式可以簡化卷積神經網絡的表達。
下面我們介紹二維卷積公式。
設矩陣,,其行、列數分別為、、、,則二維卷積公式如下:
且,滿足條件。
我們可以把上式寫成
如果我們按照式5來計算卷積,我們可以發現矩陣A實際上是filter,而矩陣B是待卷積的輸入,位置關系也有所不同:
從上圖可以看到,A左上角的值與B對應區塊中右下角的值相乘,而不是與左上角的相乘。因此,數學中的卷積和卷積神經網絡中的『卷積』還是有區別的,為了避免混淆,我們把卷積神經網絡中的『卷積』操作叫做互相關(cross-correlation)操作。
卷積和互相關操作是可以轉化的。首先,我們把矩陣A翻轉180度,然后再交換A和B的位置(即把B放在左邊而把A放在右邊。卷積滿足交換率,這個操作不會導致結果變化),那么卷積就變成了互相關。
如果我們不去考慮兩者這么一點點的區別,我們可以把式5代入到式4:
其中,是卷積層輸出的feature map。同式4相比,式6就簡單多了。然而,這種簡潔寫法只適合步長為1的情況。
Pooling層輸出值的計算
Pooling層主要的作用是下采樣,通過去掉Feature Map中不重要的樣本,進一步減少參數數量。Pooling的方法很多,最常用的是Max Pooling。Max Pooling實際上就是在n*n的樣本中取最大值,作為采樣后的樣本值。下圖是2*2 max pooling:
除了Max Pooing之外,常用的還有Mean Pooling——取各樣本的平均值。
對於深度為D的Feature Map,各層獨立做Pooling,因此Pooling后的深度仍然為D。
全連接層
全連接層輸出值的計算和上一篇文章零基礎入門深度學習(3) - 神經網絡和反向傳播算法講過的全連接神經網絡是一樣的,這里就不再贅述了。
卷積神經網絡的訓練
和全連接神經網絡相比,卷積神經網絡的訓練要復雜一些。但訓練的原理是一樣的:利用鏈式求導計算損失函數對每個權重的偏導數(梯度),然后根據梯度下降公式更新權重。訓練算法依然是反向傳播算法。
我們先回憶一下上一篇文章零基礎入門深度學習(3) - 神經網絡和反向傳播算法介紹的反向傳播算法,整個算法分為三個步驟:
- 前向計算每個神經元的輸出值(表示網絡的第個神經元,以下同);
- 反向計算每個神經元的誤差項,在有的文獻中也叫做敏感度(sensitivity)。它實際上是網絡的損失函數對神經元加權輸入的偏導數,即
- ;
- 計算每個神經元連接權重的梯度(表示從神經元連接到神經元的權重),公式為
- ,其中,表示神經元的輸出。
最后,根據梯度下降法則更新每個權重即可。
對於卷積神經網絡,由於涉及到局部連接、下采樣的等操作,影響到了第二步誤差項的具體計算方法,而權值共享影響了第三步權重的梯度的計算方法。接下來,我們分別介紹卷積層和Pooling層的訓練算法。
卷積層的訓練
對於卷積層,我們先來看看上面的第二步,即如何將誤差項傳遞到上一層;然后再來看看第三步,即如何計算filter每個權值的梯度。
卷積層誤差項的傳遞
最簡單情況下誤差項的傳遞
我們先來考慮步長為1、輸入的深度為1、filter個數為1的最簡單的情況。
假設輸入的大小為3*3,filter大小為2*2,按步長為1卷積,我們將得到2*2的feature map。如下圖所示:
在上圖中,為了描述方便,我們為每個元素都進行了編號。用表示第層第行第列的誤差項;用表示filter第行第列權重,用表示filter的偏置項;用表示第層第行第列神經元的輸出;用表示第行神經元的加權輸入;用表示第層第行第列的誤差項;用表示第層的激活函數。它們之間的關系如下:
上式中,、、都是數組,是由組成的數組,表示卷積操作。
在這里,我們假設第中的每個值都已經算好,我們要做的是計算第層每個神經元的誤差項。
根據鏈式求導法則:
我們先求第一項
。我們先來看幾個特例,然后從中總結出一般性的規律。
例1,計算
,僅與的計算有關:
因此:
例2,計算
,與和的計算都有關:
因此:
例3,計算
,與、、和的計算都有關:
因此:
從上面三個例子,我們發揮一下想象力,不難發現,計算
,相當於把第層的sensitive map周圍補一圈0,在與180度翻轉后的filter進行cross-correlation,就能得到想要結果,如下圖所示:
因為卷積相當於將filter旋轉180度的cross-correlation,因此上圖的計算可以用卷積公式完美的表達:
上式中的表示第層的filter的權重數組。也可以把上式的卷積展開,寫成求和的形式:
現在,我們再求第二項
。因為
所以這一項極其簡單,僅求激活函數的導數就行了。
將第一項和第二項組合起來,我們得到最終的公式:
也可以將式7寫成卷積的形式:
其中,符號表示element-wise product,即將矩陣中每個對應元素相乘。注意式8中的、、都是矩陣。
以上就是步長為1、輸入的深度為1、filter個數為1的最簡單的情況,卷積層誤差項傳遞的算法。下面我們來推導一下步長為S的情況。
卷積步長為S時的誤差傳遞
我們先來看看步長為S與步長為1的差別。
如上圖,上面是步長為1時的卷積結果,下面是步長為2時的卷積結果。我們可以看出,因為步長為2,得到的feature map跳過了步長為1時相應的部分。因此,當我們反向計算誤差項時,我們可以對步長為S的sensitivity map相應的位置進行補0,將其『還原』成步長為1時的sensitivity map,再用式8進行求解。
輸入層深度為D時的誤差傳遞
當輸入深度為D時,filter的深度也必須為D,層的通道只與filter的通道的權重進行計算。因此,反向計算誤差項時,我們可以使用式8,用filter的第通道權重對第層sensitivity map進行卷積,得到第層通道的sensitivity map。如下圖所示:
filter數量為N時的誤差傳遞
filter數量為N時,輸出層的深度也為N,第個filter卷積產生輸出層的第個feature map。由於第層每個加權輸入都同時影響了第層所有feature map的輸出值,因此,反向計算誤差項時,需要使用全導數公式。也就是,我們先使用第個filter對第層相應的第個sensitivity map進行卷積,得到一組N個層的偏sensitivity map。依次用每個filter做這種卷積,就得到D組偏sensitivity map。最后在各組之間將N個偏sensitivity map 按元素相加,得到最終的N個層的sensitivity map:
以上就是卷積層誤差項傳遞的算法,如果讀者還有所困惑,可以參考后面的代碼實現來理解。
卷積層filter權重梯度的計算
我們要在得到第層sensitivity map的情況下,計算filter的權重的梯度,由於卷積層是權重共享的,因此梯度的計算稍有不同。
如上圖所示,是第層的輸出,是第層filter的權重,是第層的sensitivity map。我們的任務是計算的梯度,即
。
為了計算偏導數,我們需要考察權重對的影響。權重項通過影響的值,進而影響。我們仍然通過幾個具體的例子來看權重項對的影響,然后再從中總結出規律。
例1,計算
:
從上面的公式看出,由於權值共享,權值對所有的都有影響。是、、...的函數,而、、...又是的函數,根據全導數公式,計算
就是要把每個偏導數都加起來:
例2,計算
:
通過查看與的關系,我們很容易得到:
實際上,每個權重項都是類似的,我們不一一舉例了。現在,是我們再次發揮想象力的時候,我們發現計算
規律是:
也就是用sensitivity map作為卷積核,在input上進行cross-correlation,如下圖所示:
最后,我們來看一看偏置項的梯度
。通過查看前面的公式,我們很容易發現:
也就是偏置項的梯度就是sensitivity map所有誤差項之和。
對於步長為S的卷積層,處理方法與傳遞**誤差項*是一樣的,首先將sensitivity map『還原』成步長為1時的sensitivity map,再用上面的方法進行計算。
獲得了所有的梯度之后,就是根據梯度下降算法來更新每個權重。這在前面的文章中已經反復寫過,這里就不再重復了。
至此,我們已經解決了卷積層的訓練問題,接下來我們看一看Pooling層的訓練。
Pooling層的訓練
無論max pooling還是mean pooling,都沒有需要學習的參數。因此,在卷積神經網絡的訓練中,Pooling層需要做的僅僅是將誤差項傳遞到上一層,而沒有梯度的計算。
Max Pooling誤差項的傳遞
如下圖,假設第層大小為4*4,pooling filter大小為2*2,步長為2,這樣,max pooling之后,第層大小為2*2。假設第層的值都已經計算完畢,我們現在的任務是計算第層的值。
我們用表示第層的加權輸入;用表示第層的加權輸入。我們先來考察一個具體的例子,然后再總結一般性的規律。對於max pooling:
也就是說,只有區塊中最大的才會對的值產生影響。我們假設最大的值是,則上式相當於:
那么,我們不難求得下面幾個偏導數:
因此:
而:
現在,我們發現了規律:對於max pooling,下一層的誤差項的值會原封不動的傳遞到上一層對應區塊中的最大值所對應的神經元,而其他神經元的誤差項的值都是0。如下圖所示(假設、、、為所在區塊中的最大輸出值):
Mean Pooling誤差項的傳遞
我們還是用前面屢試不爽的套路,先研究一個特殊的情形,再擴展為一般規律。
如上圖,我們先來考慮計算。我們先來看看如何影響。
根據上式,我們一眼就能看出來:
所以,根據鏈式求導法則,我們不難算出:
同樣,我們可以算出、、:
現在,我們發現了規律:對於mean pooling,下一層的誤差項的值會平均分配到上一層對應區塊中的所有神經元。如下圖所示:
上面這個算法可以表達為高大上的克羅內克積(Kronecker product)的形式,有興趣的讀者可以研究一下。
其中,是pooling層filter的大小,、都是矩陣。
至此,我們已經把卷積層、Pooling層的訓練算法介紹完畢,加上上一篇文章講的全連接層訓練算法,您應該已經具備了編寫卷積神經網絡代碼所需要的知識。為了加深對知識的理解,接下來,我們將展示如何實現一個簡單的卷積神經網絡。
卷積神經網絡的實現
完整代碼請參考GitHub: https://github.com/hanbt/learn_dl/blob/master/cnn.py (python2.7)
現在,我們親自動手實現一個卷積神經網絡,以便鞏固我們所學的知識。
首先,我們要改變一下代碼的架構,『層』成為了我們最核心的組件。這是因為卷積神經網絡有不同的層,而每種層的算法都在對應的類中實現。
這次,我們用到了在python中編寫算法經常會用到的numpy包。為了使用numpy,我們需要先將numpy導入:
import numpy as np
卷積層的實現
卷積層初始化
我們用ConvLayer類來實現一個卷積層。下面的代碼是初始化一個卷積層,可以在構造函數中設置卷積層的超參數。
class ConvLayer(object):
def __init__(self, input_width, input_height,
channel_number, filter_width,
filter_height, filter_number,
zero_padding, stride, activator,
learning_rate):
self.input_width = input_width
self.input_height = input_height
self.channel_number = channel_number
self.filter_width = filter_width
self.filter_height = filter_height
self.filter_number = filter_number
self.zero_padding = zero_padding
self.stride = stride
self.output_width = \
ConvLayer.calculate_output_size(
self.input_width, filter_width, zero_padding,
stride)
self.output_height = \
ConvLayer.calculate_output_size(
self.input_height, filter_height, zero_padding,
stride)
self.output_array = np.zeros((self.filter_number,
self.output_height, self.output_width))
self.filters = []
for i in range(filter_number):
self.filters.append(Filter(filter_width,
filter_height, self.channel_number))
self.activator = activator
self.learning_rate = learning_rate
calculate_output_size函數用來確定卷積層輸出的大小,其實現如下:
@staticmethod
def calculate_output_size(input_size,
filter_size, zero_padding, stride):
return (input_size - filter_size +
2 * zero_padding) / stride + 1
Filter類保存了卷積層的參數以及梯度,並且實現了用梯度下降算法來更新參數。
class Filter(object):
def __init__(self, width, height, depth):
self.weights = np.random.uniform(-1e-4, 1e-4,
(depth, height, width))
self.bias = 0
self.weights_grad = np.zeros(
self.weights.shape)
self.bias_grad = 0
def __repr__(self):
return 'filter weights:\n%s\nbias:\n%s' % (
repr(self.weights), repr(self.bias))
def get_weights(self):
return self.weights
def get_bias(self):
return self.bias
def update(self, learning_rate):
self.weights -= learning_rate * self.weights_grad
self.bias -= learning_rate * self.bias_grad
我們對參數的初始化采用了常用的策略,即:權重隨機初始化為一個很小的值,而偏置項初始化為0。
Activator類實現了激活函數,其中,forward方法實現了前向計算,而backward方法則是計算導數。比如,relu函數的實現如下:
class ReluActivator(object):
def forward(self, weighted_input):
#return weighted_input
return max(0, weighted_input)
def backward(self, output):
return 1 if output > 0 else 0
卷積層前向計算的實現
ConvLayer類的forward方法實現了卷積層的前向計算(即計算根據輸入來計算卷積層的輸出),下面是代碼實現:
def forward(self, input_array):
'''
計算卷積層的輸出
輸出結果保存在self.output_array
'''
self.input_array = input_array
self.padded_input_array = padding(input_array,
self.zero_padding)
for f in range(self.filter_number):
filter = self.filters[f]
conv(self.padded_input_array,
filter.get_weights(), self.output_array[f],
self.stride, filter.get_bias())
element_wise_op(self.output_array,
self.activator.forward)
上面的代碼里面包含了幾個工具函數。element_wise_op函數實現了對numpy數組進行按元素操作,並將返回值寫回到數組中,代碼如下:
# 對numpy數組進行element wise操作
def element_wise_op(array, op):
for i in np.nditer(array,
op_flags=['readwrite']):
i[...] = op(i)
conv函數實現了2維和3維數組的卷積,代碼如下:
def conv(input_array,
kernel_array,
output_array,
stride, bias):
'''
計算卷積,自動適配輸入為2D和3D的情況
'''
channel_number = input_array.ndim
output_width = output_array.shape[1]
output_height = output_array.shape[0]
kernel_width = kernel_array.shape[-1]
kernel_height = kernel_array.shape[-2]
for i in range(output_height):
for j in range(output_width):
output_array[i][j] = (
get_patch(input_array, i, j, kernel_width,
kernel_height, stride) * kernel_array
).sum() + bias
padding函數實現了zero padding操作:
# 為數組增加Zero padding
def padding(input_array, zp):
'''
為數組增加Zero padding,自動適配輸入為2D和3D的情況
'''
if zp == 0:
return input_array
else:
if input_array.ndim == 3:
input_width = input_array.shape[2]
input_height = input_array.shape[1]
input_depth = input_array.shape[0]
padded_array = np.zeros((
input_depth,
input_height + 2 * zp,
input_width + 2 * zp))
padded_array[:,
zp : zp + input_height,
zp : zp + input_width] = input_array
return padded_array
elif input_array.ndim == 2:
input_width = input_array.shape[1]
input_height = input_array.shape[0]
padded_array = np.zeros((
input_height + 2 * zp,
input_width + 2 * zp))
padded_array[zp : zp + input_height,
zp : zp + input_width] = input_array
return padded_array
卷積層反向傳播算法的實現
現在,是介紹卷積層核心算法的時候了。我們知道反向傳播算法需要完成幾個任務:
- 將誤差項傳遞到上一層。
- 計算每個參數的梯度。
- 更新參數。
以下代碼都是在ConvLayer類中實現。我們先來看看將誤差項傳遞到上一層的代碼實現。
def bp_sensitivity_map(self, sensitivity_array,
activator):
'''
計算傳遞到上一層的sensitivity map
sensitivity_array: 本層的sensitivity map
activator: 上一層的激活函數
'''
# 處理卷積步長,對原始sensitivity map進行擴展
expanded_array = self.expand_sensitivity_map(
sensitivity_array)
# full卷積,對sensitivitiy map進行zero padding
# 雖然原始輸入的zero padding單元也會獲得殘差
# 但這個殘差不需要繼續向上傳遞,因此就不計算了
expanded_width = expanded_array.shape[2]
zp = (self.input_width +
self.filter_width - 1 - expanded_width) / 2
padded_array = padding(expanded_array, zp)
# 初始化delta_array,用於保存傳遞到上一層的
# sensitivity map
self.delta_array = self.create_delta_array()
# 對於具有多個filter的卷積層來說,最終傳遞到上一層的
# sensitivity map相當於所有的filter的
# sensitivity map之和
for f in range(self.filter_number):
filter = self.filters[f]
# 將filter權重翻轉180度
flipped_weights = np.array(map(
lambda i: np.rot90(i, 2),
filter.get_weights()))
# 計算與一個filter對應的delta_array
delta_array = self.create_delta_array()
for d in range(delta_array.shape[0]):
conv(padded_array[f], flipped_weights[d],
delta_array[d], 1, 0)
self.delta_array += delta_array
# 將計算結果與激活函數的偏導數做element-wise乘法操作
derivative_array = np.array(self.input_array)
element_wise_op(derivative_array,
activator.backward)
self.delta_array *= derivative_array
expand_sensitivity_map方法就是將步長為S的sensitivity map『還原』為步長為1的sensitivity map,代碼如下:
def expand_sensitivity_map(self, sensitivity_array):
depth = sensitivity_array.shape[0]
# 確定擴展后sensitivity map的大小
# 計算stride為1時sensitivity map的大小
expanded_width = (self.input_width -
self.filter_width + 2 * self.zero_padding + 1)
expanded_height = (self.input_height -
self.filter_height + 2 * self.zero_padding + 1)
# 構建新的sensitivity_map
expand_array = np.zeros((depth, expanded_height,
expanded_width))
# 從原始sensitivity map拷貝誤差值
for i in range(self.output_height):
for j in range(self.output_width):
i_pos = i * self.stride
j_pos = j * self.stride
expand_array[:,i_pos,j_pos] = \
sensitivity_array[:,i,j]
return expand_array
create_delta_array是創建用來保存傳遞到上一層的sensitivity map的數組。
def create_delta_array(self):
return np.zeros((self.channel_number,
self.input_height, self.input_width))
接下來,是計算梯度的代碼。
def bp_gradient(self, sensitivity_array):
# 處理卷積步長,對原始sensitivity map進行擴展
expanded_array = self.expand_sensitivity_map(
sensitivity_array)
for f in range(self.filter_number):
# 計算每個權重的梯度
filter = self.filters[f]
for d in range(filter.weights.shape[0]):
conv(self.padded_input_array[d],
expanded_array[f],
filter.weights_grad[d], 1, 0)
# 計算偏置項的梯度
filter.bias_grad = expanded_array[f].sum()
最后,是按照梯度下降算法更新參數的代碼,這部分非常簡單。
def update(self):
'''
按照梯度下降,更新權重
'''
for filter in self.filters:
filter.update(self.learning_rate)
卷積層的梯度檢查
為了驗證我們的公式推導和代碼實現的正確性,我們必須要對卷積層進行梯度檢查。下面是代嗎實現:
def init_test():
a = np.array(
[[[0,1,1,0,2],
[2,2,2,2,1],
[1,0,0,2,0],
[0,1,1,0,0],
[1,2,0,0,2]],
[[1,0,2,2,0],
[0,0,0,2,0],
[1,2,1,2,1],
[1,0,0,0,0],
[1,2,1,1,1]],
[[2,1,2,0,0],
[1,0,0,1,0],
[0,2,1,0,1],
[0,1,2,2,2],
[2,1,0,0,1]]])
b = np.array(
[[[0,1,1],
[2,2,2],
[1,0,0]],
[[1,0,2],
[0,0,0],
[1,2,1]]])
cl = ConvLayer(5,5,3,3,3,2,1,2,IdentityActivator(),0.001)
cl.filters[0].weights = np.array(
[[[-1,1,0],
[0,1,0],
[0,1,1]],
[[-1,-1,0],
[0,0,0],
[0,-1,0]],
[[0,0,-1],
[0,1,0],
[1,-1,-1]]], dtype=np.float64)
cl.filters[0].bias=1
cl.filters[1].weights = np.array(
[[[1,1,-1],
[-1,-1,1],
[0,-1,1]],
[[0,1,0],
[-1,0,-1],
[-1,1,0]],
[[-1,0,0],
[-1,0,1],
[-1,0,0]]], dtype=np.float64)
return a, b, cl
def gradient_check():
'''
梯度檢查
'''
# 設計一個誤差函數,取所有節點輸出項之和
error_function = lambda o: o.sum()
# 計算forward值
a, b, cl = init_test()
cl.forward(a)
# 求取sensitivity map,是一個全1數組
sensitivity_array = np.ones(cl.output_array.shape,
dtype=np.float64)
# 計算梯度
cl.backward(a, sensitivity_array,
IdentityActivator())
# 檢查梯度
epsilon = 10e-4
for d in range(cl.filters[0].weights_grad.shape[0]):
for i in range(cl.filters[0].weights_grad.shape[1]):
for j in range(cl.filters[0].weights_grad.shape[2]):
cl.filters[0].weights[d,i,j] += epsilon
cl.forward(a)
err1 = error_function(cl.output_array)
cl.filters[0].weights[d,i,j] -= 2*epsilon
cl.forward(a)
err2 = error_function(cl.output_array)
expect_grad = (err1 - err2) / (2 * epsilon)
cl.filters[0].weights[d,i,j] += epsilon
print 'weights(%d,%d,%d): expected - actural %f - %f' % (
d, i, j, expect_grad, cl.filters[0].weights_grad[d,i,j])
上面代碼值得思考的地方在於,傳遞給卷積層的sensitivity map是全1數組,留給讀者自己推導一下為什么是這樣(提示:激活函數選擇了identity函數:)。讀者如果還有困惑,請寫在文章評論中,我會回復。
運行上面梯度檢查的代碼,我們得到的輸出如下,期望的梯度和實際計算出的梯度一致,這證明我們的算法推導和代碼實現確實是正確的。
以上就是卷積層的實現。
Max Pooling層的實現
max pooling層的實現相對簡單,我們直接貼出全部代碼如下:
class MaxPoolingLayer(object):
def __init__(self, input_width, input_height,
channel_number, filter_width,
filter_height, stride):
self.input_width = input_width
self.input_height = input_height
self.channel_number = channel_number
self.filter_width = filter_width
self.filter_height = filter_height
self.stride = stride
self.output_width = (input_width -
filter_width) / self.stride + 1
self.output_height = (input_height -
filter_height) / self.stride + 1
self.output_array = np.zeros((self.channel_number,
self.output_height, self.output_width))
def forward(self, input_array):
for d in range(self.channel_number):
for i in range(self.output_height):
for j in range(self.output_width):
self.output_array[d,i,j] = (
get_patch(input_array[d], i, j,
self.filter_width,
self.filter_height,
self.stride).max())
def backward(self, input_array, sensitivity_array):
self.delta_array = np.zeros(input_array.shape)
for d in range(self.channel_number):
for i in range(self.output_height):
for j in range(self.output_width):
patch_array = get_patch(
input_array[d], i, j,
self.filter_width,
self.filter_height,
self.stride)
k, l = get_max_index(patch_array)
self.delta_array[d,
i * self.stride + k,
j * self.stride + l] = \
sensitivity_array[d,i,j]
全連接層的實現和上一篇文章類似,在此就不再贅述了。至此,你已經擁有了實現了一個簡單的卷積神經網絡所需要的基本組件。對於卷積神經網絡,現在有很多優秀的開源實現,因此我們並不需要真的自己去實現一個。貼出這些代碼的目的是為了讓我們更好的了解卷積神經網絡的基本原理。
卷積神經網絡的應用
MNIST手寫數字識別
LeNet-5是實現手寫數字識別的卷積神經網絡,在MNIST測試集上,它取得了0.8%的錯誤率。LeNet-5的結構如下:
關於LeNet-5的詳細介紹,網上的資料很多,因此就不再重復了。感興趣的讀者可以嘗試用我們自己實現的卷積神經網絡代碼去構造並訓練LeNet-5(當然代碼會更復雜一些)。
小節
由於卷積神經網絡的復雜性,我們寫出了整個系列目前為止最長的一篇文章,相信讀者也和作者一樣累的要死。卷積神經網絡是深度學習最重要的工具(我猶豫要不要寫上『之一』呢),付出一些辛苦去理解它也是值得的。如果您真正理解了本文的內容,相當於邁過了入門深度學習最重要的一到門檻。在下一篇文章中,我們介紹深度學習另外一種非常重要的工具:循環神經網絡,屆時我們的系列文章也將完成過半。每篇文章都是一個過濾器,對於堅持到這里的讀者們,入門深度學習曙光已現,加油。
參考資料
- CS231n Convolutional Neural Networks for Visual Recognition
- ReLu (Rectified Linear Units) 激活函數
- Jake Bouvrie, Notes on Convolutional Neural Networks, 2006
- Ian Goodfellow, Yoshua Bengio, Aaron Courville, Deep Learning, MIT Press, 2016