一文搞定卷積神經網絡——從原理到應用

1. 前言

​ 我之前寫過一篇關於BP神經網絡原理的文章,主要內容包括:BP神經網絡的反向傳播原理,以及簡單的代碼實現。本文和《BP 神經網絡入門:從原理到應用》一文一脈相承,因此,我希望讀者務必先看看這篇文章(鏈接在這裏:BP 神經網絡入門:從原理到應用,

​ 好多讀者都說,之前那篇BP神經網絡的文章,公式太多,閱讀體驗不好,所以本文我將盡量減少公式,加入更多的圖解和通俗解釋。

​ 本文主要包括以下內容:

  • 卷積神經網絡結構

  • 前向傳播原理

  • 反向傳播原理

  • 代碼實現

  • 模型測試

  • 簡單的優化設計方法;優化設計方法有很多,本文不做過多介紹,如果有必要,我會後面會寫個有關優化設計的課程。

2. 全連接BP神經網絡的缺點

​ 像全連接BP神經網絡一樣,卷積神經網絡,也有一定的生物學基礎(雖然,有時候確實感覺它的生物學解釋有些牽強)。

​ 我在BP 神經網絡入門:從原理到應用一文,實現了一個,識別圖片是不是貓的程序。在這個程序裏,我們把輸入的圖片“展平”了,“展平”是什麼意思呢?我們都知道,計算機存儲圖片,實際是存儲了一個W×H×DW\times H\times D 的數組(W,H,D分別表示寬,高和維數,彩色圖片包含RGB三維),如下圖所示,“展平”就是,把這個數組變成一列,然後,輸入神經網絡進行訓練:

在這裏插入圖片描述

​ 這種方法,在圖片尺寸較小的時候,還是可以接受的;但是,當圖片尺寸比較大的時候,由於圖像的像素點很多,神經網絡的 層與層之間 的連接數量,會爆炸式增長,大大降低網絡的性能。

​ 除此之外,把圖片數據展開成一列,破壞了圖片的空間信息,試想,如果把上圖中的貓圖片變成一列,再把這一列數據,以圖片的形式展示給你,你還能看出這表示一隻貓嗎?並且,有相關研究表明,人類大腦,在理解圖片信息的過程中,並不是同時觀察整個圖片,而是更傾向於,觀察部分特徵,然後根據特徵匹配、組合,得出整圖信息。以下圖爲例:

在這裏插入圖片描述

​ 在這張圖片中,你只能看到,貓的鼻子,1隻眼睛,2只耳朵和嘴,雖然沒有尾巴,爪子等其它信息,但是,你依然可以判斷,這裏有一隻貓。所以說,人在理解圖像信息的過程中,更專注於,局部特徵,以及這些特徵之間的組合。

​ 換句話說,在BP全連接神經網絡中,隱含層每一個神經元,都對輸入圖片 每個像素點 做出反應。這種機制包含了太多冗餘連接。爲了減少這些冗餘,只需要每個隱含神經元,對圖片的一小部分區域,做出反應就好了!卷積神經網絡,正是基於這種想法而實現的。

3. 卷積神經網絡基本單元

3.1 卷積(Convolution)

​ 如果,你學過信號處理,或者有相關專業的數學基礎,可能需要注意一下,這裏的“卷積”,和信號處理裏的卷積,不太一樣,雖然,在數學表達式形式上有一點類似。

​ 在卷積神經網絡中,“卷積”更像是一個,特徵提取算子。什麼是特徵提取算子呢?簡單來說就是,提取圖片紋理、邊緣等特徵信息的濾波器。下面,舉個簡單的例子,解釋一下 邊緣 特徵提取算子是怎麼工作的:

在這裏插入圖片描述

​ 比如有一張貓圖片,人類在理解這張圖片的時候,可能觀察到圓圓的眼睛,可愛的耳朵,於是,判斷這是一隻貓。但是,機器怎麼處理這個問題呢?傳統的計算機視覺方法,通常設計一些算子(特徵提取濾波器),來找到比如眼睛的邊界,耳朵的邊界,等信息,然後綜合這些特徵,得出結論——這是一隻貓。

​ 具體是怎麼做的呢:

在這裏插入圖片描述

​ 如上圖所示,假設,貓的上眼皮部分(框出部分),這部分的數據,展開後如圖中數組所示**(這裏,爲了敘述簡便,忽略了rgb三維通道,把圖像當成一個二維數組[類比灰度圖片]),我們使用一個算子,來檢測橫向邊沿**——這個算子,讓第一行的值減去第三行的值,得出結果。具體計算過程如下,比如,原圖左上角數據,經過這個算子:

200×1+199×1+189×1+203×0+23×0+34×0+177×(1)+12×(1)+22×(1)=328200\times1+199\times1+189\times1+203\times0+23\times0+34\times0+177\times(-1)+12\times(-1)+22\times(-1)=328

​ 然後,算子向右滑動一個像素,得到第二個輸出:533,依次向右滑動,最終得到第一行輸出;

​ 然後,向下滑動一個像素,到達第二行;在第二行,從左向右滑動,得到第二行輸出……

​ 動圖演示過程如下(圖片來自csdn):

在這裏插入圖片描述

​ 從輸出結果可以看出,在上眼皮處顏色較深,而眼皮上方和下方,無論是眼皮上方的毛髮,還是眼皮下方的眼白,顏色都很淺,因此,這樣做之後,如果濾波結果中有大值出現,就意味着,這裏出現了橫向邊沿。不斷在原圖上滑動這個算子,我們就可以,檢測到圖像中所有的橫向邊沿。傳統的機器視覺方法,就是設計更多更復雜的算子,檢測更多的特徵,最後,組合這些特徵,得出判斷結果。

​ 卷積神經網絡,做了類似的事情,所謂卷積,就是把一個算子在原圖上不斷滑動,得出濾波結果——這個結果,我們叫做“特徵圖”(Feature Map),這些算子被稱爲“卷積核”(Convolution Kernel)。不同的是,我們不必人工設計這些算子,而是使用隨機初始化,來得到很多卷積核(算子),然後通過反向傳播,優化這些卷積核,以期望得到更好的識別結果。

3.2 填充(Padding)和步長(Stride)

​ 你可能注意到了,剛剛,我們“滑動”卷積核之後,輸出變小了;具體來說,貓眼睛部分數據,尺寸是6×86\times 8,但是,經過一個3×33\times 3的卷積核之後,變成了4×64\times 6,這是因爲卷積核有尺寸,如下圖所示,當卷積核(橙色)滑動到邊界時,就無法向右繼續滑動了:

在這裏插入圖片描述

​ 可想而知,如果經過很多層卷積的話,輸出尺寸會變的很小,同時圖像邊緣信息,會迅速流失,這對模型的性能,有着不可忽視的影響。爲了減少卷積操作導致的,邊緣信息丟失,我們需要進行填充(Padding),即在原圖周圍,添加一圈值爲“0”的像素點(zero padding),這樣的話,輸出維度就和輸入維度一致了。

在這裏插入圖片描述

​ 還有一個很重要的因素是,步長(Stride),即卷積核每次滑動幾個像素。前面,我們默認,卷積核每次滑動一個像素,其實也可以,每次滑動兩個像素。其中,每次滑動的像素數,稱爲“步長”。

​ 你可能會有疑問,如果步長大於 1 ,那不也會造成輸出尺寸變小嗎?是的!但是這種情況下,不會“故意”丟失邊緣信息,即使有信息丟失,也是在整張圖片上,比較“溫和”地,捨棄了一些無關緊要的信息(反向傳播調節卷積核參數,使之“智能”地捨棄無關信息)。但是,不能頻繁使用步長爲2。因爲,如果輸出尺寸變得,過小的話,即使卷積核參數優化的再好,也會必可避免地丟失大量信息!所以,你可以看到,在應用卷積網絡的時候,通常使用步長爲 1 的卷積核。

​ **卷積核大小(f×ff\times f)**也可以變化,比如 1×11\times15×55\times5 等,此時,需要根據卷積核大小,來調節填充尺寸(Padding size)。一般來說,卷積核尺寸取奇數(因爲我們希望卷積核有一箇中心,便於處理輸出)。卷積核尺寸爲奇數時,填充尺寸可以根據以下公式確定:

padding_size=f12padding\_size=\frac{f-1}{2}

​ 其中,ff 表示卷積核大小。

​ 如果用ss 表示步長,ww 表示圖片寬度,hh 表示圖片高度,那麼輸出尺寸可以表示爲:

w_out=w+2×padding_sizefs+1w\_out=\frac{w+2\times padding\_size-f}{s}+1

h_out=h+2×padding_sizefs+1h\_out=\frac{h+2\times padding\_size-f}{s}+1

​ 下面提供一些動圖幫助大家理解(圖片來自CSDN):

類別 動圖
no padding, s=1, f=3 no padding, s=2, f=3
在這裏插入圖片描述 在這裏插入圖片描述
with padding, s=1, f=3 with padding, s=2, f=3
在這裏插入圖片描述 在這裏插入圖片描述

3.3 完整的卷積過程

​ 上面的卷積過程,沒有考慮,彩色圖片有rgb三維通道(Channel),如果考慮rgb通道,那麼,每個通道,都需要一個卷積核:

在這裏插入圖片描述

​ 當輸入有多個通道時,我們的卷積核也需要,有同樣數量的通道。以上圖爲例,輸入有RGB三個通道,我們的就卷積核,也有三個通道,只不過計算的時候,卷積核的每個通道,在對應通道滑動(卷積核最前面的通道在輸入圖片的紅色通道滑動,卷積核中間的通道在輸入圖片的綠色通道滑動,卷積核最後面的通道在輸入圖片的藍色通道滑動),三個通道的計算結果相加得到輸出。注意,輸出只有一個通道,因爲這裏我們只用了一個卷積核(只不過這個卷積核有三個通道)。用下面的動圖加深理解(圖片來自網絡,這裏我們用了 2 個卷積核,每個卷積核 3 個通道,輸出有 2 個通道):

圖片過大,上傳失敗

​ 三維展示如下(如果視頻無法加載可以點擊此鏈接查看):

3.4 池化(Pooling)

​ 池化(Pooling),有的地方也稱匯聚,實際是一個下采樣(Down-sample)過程。由於輸入的圖片尺寸可能比較大,這時候,我們需要下采樣,減小圖片尺寸。池化層,可以減小模型規模,提高運算速度,同時,提高所提取特徵的魯棒性。池化操作也有核大小 ff 和步長 ss 參數,參數意義和卷積相同。

​ 本文主要介紹最大池化(Max Pooling)和平均池化(Average Pooling)。

​ 所謂最大池化,就是對於f×ff\times f大小的池化核,選取原圖中數值最大的那個保留下來。比如,池化核 2×22\times2 大小,步長爲2的池化過程如下(左邊是池化前,右邊是池化後),對於每個池化區域都取最大值:

在這裏插入圖片描述

​ 最大池化最爲常用,並且一般都取2×22\times2的池化核代大小且步長爲2。

​ 平均池化是取每個區域的均值(平均池化現在很少使用):

在這裏插入圖片描述

3.5 激活函數

  本文主要介紹 2 種激活函數,分別是sigmoidsigmoidrelurelu函數,函數公式如下:
sigmoid(z)=11+ezsigmoid(z)=\frac{1}{1+e^{-z}}
relu(z)={zz>00z0relu(z)= \left\{ \begin{array}{rcl} z & z>0\\ 0&z\leq0\end{array} \right.
  函數圖如下:

在這裏插入圖片描述

sigmoid(z)sigmoid(z)
在這裏插入圖片描述
relu(z)relu(z)

補充說明

  引入激活函數的目的是,在模型中引入非線性。如果沒有激活函數,那麼,無論你的神經網絡有多少層,最終都是一個線性映射,單純的線性映射,無法解決線性不可分問題。引入非線性可以讓模型解決線性不可分問題。

  一般來說,在神經網絡的中間層,更加建議使用relurelu函數,兩個原因:

  • relurelu函數計算簡單,可以加快模型速度;
  • 由於反向傳播過程中,需要計算偏導數,通過求導可以得到,sigmoidsigmoid函數導數的最大值爲0.25,如果使用sigmoidsigmoid函數的話,每一層的反向傳播,都會使梯度最少變爲原來的四分之一,當層數比較多的時候可能會造成梯度消失,從而模型無法收斂。

4. 卷積神經網絡的前向傳播過程

​ 本節主要介紹,典型的卷積神經網絡模型結構。

在這裏插入圖片描述

​ 典型的卷積網絡結構,如上圖所示(注意觀察圖中顏色對應關係)。首先輸入圖像,然後卷積。卷積過程採用n個卷積核,每個卷積核都有三個通道(卷積核通道數,和輸入圖片的通道數目相同),輸出結果尺寸爲W×H×nW^{'}\times H^{'}\times n,這裏的輸出有n個通道,所以,下一層卷積,每個卷積核都要有n個通道;重複卷積-池化過程;模型的最後一層或兩層,一般爲全連接結構,輸出最終結果。全連接結構部分和BP神經網絡一樣。最後,我們需要設計合適的損失函數,反向傳播就是通過最小化損失函數,來優化網絡參數,使模型達到預期結果。

​ 我們把每次卷積之後,得到輸出稱爲“特徵圖(Feature map)”。

​ 我們定義以下符號,表示卷積網絡結構(這裏我儘可能模仿Tensorflow的API):

  • 卷積 conv2d(input, filter, strides, padding)
    • input:卷積輸入(H×W×CinH \times W \times C_{in}CinC_{in}指的是輸入的通道數)
    • filter:卷積核W (fheight×fwidth×Cin×Coutf_{height}\times f_{width}\times C_{in}\times C_{out}
    • strides:步長,一般爲1×1×1×11\times1\times1\times1
    • padding:指明padding的算法,‘SAME’或者‘VALID’
  • 激活 relu(conv)
    • conv:卷積結果
  • 偏置相加 add(conv, b)
    • conv:卷積的激活輸出
    • b:偏置係數
  • 池化 max_pool(value, ksize, strides, padding)
    • value:卷積輸出
    • ksize:池化尺寸,一般2×22\times2
    • strides:步長
    • padding:指明padding的算法,‘SAME’或者‘VALID’

5. 卷積神經網絡的反向傳播過程

​ 終於講到反向傳播了。現在大多數深度學習框架,都幫我們做好了誤差反傳的工作。大部分工程師,僅僅需要構建前向傳播過程,就可以了。但是,對於研究人員,有必要了解一下誤差反傳的原理。

​ 在BP神經網絡中,我們已經介紹過,全連接神經網絡的反向傳播過程。很多教程,在介紹卷積網絡反向傳播的時候,總是說,類比全連接神經網絡,然後一筆帶過。其實,卷積神經網絡的誤差反傳過程,要複雜得多。原因有以下幾點:

  • 卷積神經網絡的卷積核,每次滑動只與部分輸入相連,具有局部映射的特點,在誤差反傳的時候,需要確定卷積核的局部連接方式;

  • 卷積神經網絡的池化過程,丟失了大量信息,誤差反傳,需要恢復這些丟失的信息;

  • ……

      總之,卷積神經網絡的誤差反傳過程,很複雜。之前,在寫BP神經網絡那篇文章的時候,推導的公式過多,導致很多讀者反應,文章晦澀難懂;所以,這次我儘可能用通俗的語言來表述。
    
本小節的符號定義,和BP神經網絡一文相同,如果出現了新的符號,會在文中對應位置說明。

5.1 卷積層的誤差反傳

​ 假設損失函數爲LossLoss, 我們希望求解,損失函數對參數 W 和偏置 b 的梯度。

​ 首先,來看損失函數對卷積核 W 的梯度:

lthl^{th}層的卷積輸出爲:

Z[l]=conv2d(Zpool[l1],(W,b),strides=1,padding=SAME)      =mnWm,n[l1]Zpool_m,n[l1]+b[l]Z^{[l]}=conv2d(Z^{[l-1]}_{pool}, (W, b) , strides = 1, padding='SAME')\\ \ \ \ \ \ \ = \sum_m\sum_nW^{[l-1]}_{m^{'},n^{'}}Z^{[l-1]}_{pool\_m, n}+b^{[l]}

​ 其中,Zpool_m,n[l1]Z^{[l-1]}_{pool\_m, n}表示上一層池化的輸出;mm^{'}nn^{'}表示卷積核尺寸;mmnn 表示輸入的長和寬。

​ 卷積層反向傳播的基礎,依舊是鏈式法則,所以,我們必須搞清楚,權重W的局部連接方式。

​ 考慮前向傳播過程:

在這裏插入圖片描述

​ 可以發現,由於卷積核在輸入的特徵圖上滑動,所以,特徵圖的所有像素都參與了卷積核運算,與此對應,輸出特徵圖的每個像素,都和卷積核 W 直接相連。

​ 損失函數對參數W的梯度,根據鏈式法則:

LossWm,n[l]=i=0Hi=0HLossZi,j[l]Zi,j[l]Wm,n[l] =i=0Hi=0HdZi,j[l]Zi,j[l]Wm,n[l]\frac{\partial Loss}{\partial W^{[l]}_{m^{'}, n^{'}}}=\sum_{i=0}^{H}\sum_{i=0}^{H}\frac{\partial Loss}{\partial Z^{[l]}_{i, j}}\frac{\partial Z^{[l]}_{i, j}}{\partial W^{[l]}_{m^{'}, n^{'}}}\\ \qquad \ =\sum_{i=0}^{H}\sum_{i=0}^{H}dZ^{[l]}_{i,j}\frac{\partial Z^{[l]}_{i, j}}{\partial W^{[l]}_{m^{'}, n^{'}}}

​ 其中,Z表示沒有激活的輸出,dZ[l]dZ^{[l]}表示lthl^{th}層的誤差。現在我們來求解Zi,j[l]Wm,n[l]\frac{\partial Z^{[l]}_{i, j}}{\partial W^{[l]}_{m, n}}部分:

Zi,j[l]Wm,n[l]=Wm,n[l](mnWm,n[l1]Zpool_m,n[l1]+b[l]) =Wm,n[l](W0,0[l]Zpool_i+0,j+0[l1]+...+Wm,n[l]Zpool_i+m,j+n[l1]) =Zpool_i+m,j+n[l1]\frac{\partial Z^{[l]}_{i, j}}{\partial W^{[l]}_{m^{'}, n^{'}}}=\frac{\partial}{\partial W^{[l]}_{m^{'}, n^{'}}}(\sum_m\sum_nW^{[l-1]}_{m^{'},n^{'}}Z^{[l-1]}_{pool\_m, n}+b^{[l]})\\ \qquad \ = \frac{\partial}{\partial W^{[l]}_{m^{'}, n^{'}}}(W^{[l]}_{0,0}Z^{[l-1]}_{pool\_i+0, j+0}+ . . . +W^{[l]}_{m^{'},n^{'}}Z^{[l-1]}_{pool\_i+m^{'},j+n^{'}})\\ \qquad \ =Z^{[l-1]}_{pool\_i+m^{'},j+n^{'}}

​ 綜上,

LossWm,n[l]=i=0Hi=0HdZi,j[l]Zi,j[l]Wm,n[l]=i=0Hi=0HdZi,j[l]Zpool_i+m,j+n[l1]\frac{\partial Loss}{\partial W^{[l]}_{m^{'}, n^{'}}}=\sum_{i=0}^{H}\sum_{i=0}^{H}dZ^{[l]}_{i,j}\frac{\partial Z^{[l]}_{i, j}}{\partial W^{[l]}_{m^{'}, n^{'}}}\\ \qquad \quad=\sum_{i=0}^{H}\sum_{i=0}^{H}dZ^{[l]}_{i,j}Z^{[l-1]}_{pool\_i+m^{'},j+n^{'}}\\ \qquad \quad

​ 同理,可得:

Lossb[l]=i=0Hi=0HdZi,j[l]\frac{\partial Loss}{\partial b^{[l]}}=\sum_{i=0}^{H}\sum_{i=0}^{H}dZ^{[l]}_{i,j}

​ 下面來看看dZ[l]dZ^{[l]}如何求解:

dZi,j[l]=LossZi,j[l]=LossZ[l+1]Z[l+1]Z[l]=dZ[l+1]Z[l+1]Z[l]dZ^{[l]}_{i, j}=\frac{\partial Loss}{\partial Z^{[l]}_{i, j}}=\frac{\partial Loss}{\partial Z^{[l+1]}}\frac{\partial Z^{[l+1]}}{\partial Z^{[l]}}=dZ^{[l+1]}\frac{\partial Z^{[l+1]}}{\partial Z^{[l]}}

​ 也就是說我們需要求解Z[l+1]Z[l]\frac{\partial Z^{[l+1]}}{\partial Z^{[l]}},所以我們需要知道在lthl^{th}層的一個像素值如何影響卷積輸出:

在這裏插入圖片描述

​ 上圖說明,一個像素點,通過卷積運算,會影響輸出的一個區域(輸入特徵圖的藍色像素點,影響了輸出特徵圖的藍色虛線框出的部分)。所以反向傳播就是要找到,卷積核滑動過程中,一個像素點,如何通過 W 影響輸出,然後逐步應用鏈式法則即可。

5.2 池化層的誤差反傳

​ 池化層沒有參數需要學習,但是,由於池化層的下采樣操作損失了大量信息,因此,在反向傳播過程中,我們需要恢復這些信息。

​ 對於最大池化而言,我們需要知道在池化之前最大元素的位置:

在這裏插入圖片描述

​ 然後,在反向傳播的時候將對應值填入最大位置,這可以用池化後誤差乘以M得到,這樣一個數就可以恢復爲2×22\times 2的矩陣,對池化層誤差矩陣的每個元素都應用這樣的上採樣,我們就可以恢復維度信息,從而誤差可以順利反傳。

​ 對於平均池化而言,我們保存的是均值信息:

在這裏插入圖片描述

​ 反傳過程中依舊是用池化層誤差乘以dZ,將一個數恢復爲2×22\times 2的矩陣即,可恢復維度信息。

6. 代碼實現

​ 本小節,給出了Numpy實現卷積網絡的核心代碼。另外,因爲卷積神經網絡的計算過程很複雜,因此,使用自己實現的核心代碼效率很低。所以,完整模型的訓練採用TensorFlow庫來實現。

6.1 Numpy實現

6.1.1 卷積層前向傳播

​ 首先實現一個0填充的函數:

def zero_pad(X, pad):
    """零填充。
    
    Args:
    X: python numpy array of shape (m, n_H, n_W, n_C) representing a batch of m images
    pad: integer, amount of padding around each image on vertical and horizontal dimensions
    
    Returns:
    X_pad -- padded image of shape (m, n_H + 2*pad, n_W + 2*pad, n_C)
    """
    X_pad = np.pad(X, ((0,0),(pad,pad),(pad,pad),(0,0)), 'constant')
    return X_pad

​ 接下來實現一個單步卷積,即考慮全圖一個切片的卷積:

def conv_single_step(a_slice_prev, W, b):
    """"
    Args:
    a_slice_prev: slice of input data of shape (f, f, n_C_prev)
    W: Weight parameters contained in a window - matrix of shape (f, f, n_C_prev)
    b: Bias parameters contained in a window - matrix of shape (1, 1, 1)
    
    Returns:
    Z: a float value.
    """
	s = a_slice_prev * W
    Z = np.sum(s)
    Z = Z + b
    return Z

​ 完整的一步卷積,即卷積核在輸入特徵圖滑動:

def conv_forward(A_prev, W, b, hparameters):
    """前向傳播
    
    Args:
    A_prev: output activations of the previous layer, numpy array of shape (m, n_H_prev, n_W_prev, n_C_prev)
    W: Weights, numpy array of shape (f, f, n_C_prev, n_C)
    b: Biases, numpy array of shape (1, 1, 1, n_C)
    hparameters: a python dictionary containing "stride" and "pad"
        
    Returns:
    Z: conv output, numpy array of shape (m, n_H, n_W, n_C)
    cache: cache of values needed for the conv_backward() function
    """
    (m, n_H_prev, n_W_prev, n_C_prev) = A_prev.shape
    (f, f, n_C_prev, n_C) = W.shape
    stride = hparameters['stride']
    pad = hparameters['pad']
   	
    n_H = int((n_H_prev+2*pad-f)/stride) + 1
    n_W = int((n_W_prev+2*pad-f)/stride) + 1
    
    Z = np.zeros((m,n_H,n_W,n_C))
    
    A_prev_pad = zero_pad(A_prev, pad)
    
    for i in range(m):                              
        a_prev_pad = A_prev_pad[i,:,:,:]
        for h in range(n_H):  
            for w in range(n_W):  
                
                for c in range(n_C):
                    vert_start = h * stride
                    vert_end = vert_start+f
                    horiz_start = w * stride
                    horiz_end = horiz_start+f
                    
                    a_slice_prev = a_prev_pad[vert_start:vert_end,horiz_start:horiz_end, :]
                    Z[i, h, w, c] = conv_single_step(a_slice_prev, W[:,:,:,c], b[:,:,:,c])                       
    
    cache = (A_prev, W, b, hparameters)
    
    return Z, cache

6.1.2 卷積層反向傳播

def conv_backward(dZ, cache):
    """卷積層反向傳播
    
    Args:
    dZ: gradient of the cost with respect to the output of the conv layer (Z), numpy array of shape (m, n_H, n_W, n_C)
    cache: cache of values needed for the conv_backward(), output of conv_forward()
    
    Returns:
    dA_prev: gradient of the cost with respect to the input of the conv layer (A_prev), numpy array of shape (m, n_H_prev, n_W_prev, n_C_prev)
    dW: gradient of the cost with respect to the weights of the conv layer (W)
          numpy array of shape (f, f, n_C_prev, n_C)
    db: gradient of the cost with respect to the biases of the conv layer (b)
          numpy array of shape (1, 1, 1, n_C)
    """
    (A_prev, W, b, hparameters) = cache
    (m, n_H_prev, n_W_prev, n_C_prev) = A_prev.shape
    (f, f, n_C_prev, n_C) = W.shape
    stride = hparameters['stride']
    pad = hparameters['pad']
    (m, n_H, n_W, n_C) = dZ.shape
    
    dA_prev = np.zeros(A_prev.shape)                          
    dW = np.zeros(W.shape)
    db = np.zeros(b.shape)
    
    A_prev_pad = zero_pad(A_prev, pad)
    dA_prev_pad = zero_pad(dA_prev, pad)
    
    for i in range(m):    
        a_prev_pad = A_prev_pad[i]
        da_prev_pad = dA_prev_pad[i]
        
        for h in range(n_H):                   
            for w in range(n_W):               
                for c in range(n_C): 
                    vert_start = h * stride
                    vert_end = vert_start + f
                    horiz_start = w * stride
                    horiz_end = horiz_start + f
                    
                    a_slice = a_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :]
                    
                    da_prev_pad[vert_start:vert_end, horiz_start:horiz_end, :] += W[:, :, :, c] * dZ[i, h, w, c]
                    dW[:,:,:,c] += a_slice * dZ[i, h, w, c]
                    db[:,:,:,c] += dZ[i, h, w, c]
                    
        
        dA_prev[i, :, :, :] = da_prev_pad[pad:-pad, pad:-pad, :]
    
    return dA_prev, dW, db

6.1.3 池化前向傳播

​ 池化前向傳播:

def pool_forward(A_prev, hparameters, mode = "max"):
    """
    Args:
    A_prev: Input data, numpy array of shape (m, n_H_prev, n_W_prev, n_C_prev)
    hparameters: python dictionary containing "f" and "stride"
    mode: "max" or "average"
    
    Returns:
    A: output of the pool layer, a numpy array of shape (m, n_H, n_W, n_C)
    cache: cache used in the backward pass of the pooling layer, contains the input and hparameters 
    """
    (m, n_H_prev, n_W_prev, n_C_prev) = A_prev.shape
    
    f = hparameters["f"]
    stride = hparameters["stride"]
    
    n_H = int(1 + (n_H_prev - f) / stride)
    n_W = int(1 + (n_W_prev - f) / stride)
    n_C = n_C_prev
    
    A = np.zeros((m, n_H, n_W, n_C))              
    
    
    for i in range(m):                         
        for h in range(n_H):                     
            for w in range(n_W):                 
                for c in range (n_C):            
                    vert_start = h*stride
                    vert_end = vert_start+f
                    horiz_start = w*stride
                    horiz_end = horiz_start+f
                    
                    a_prev_slice = A_prev[i, vert_start:vert_end, horiz_start:horiz_end, c]
                    
                    if mode == "max":
                        A[i, h, w, c] = np.max(a_prev_slice)
                    elif mode == "average":
                        A[i, h, w, c] = np.mean(a_prev_slice)
    
    cache = (A_prev, hparameters)
    
    return A, cache

6.1.4 池化反向傳播

​ 首先,對於最大池化,我們需要記錄前向傳播過程中最大值在哪個位置:

def create_mask_from_window(x):
    """
    Args:
    x: Array of shape (f, f)
    
    Returns:
    maskL: Array of the same shape as window, contains a True at the position corresponding to the max entry of x.
    """
    mask = x == np.max(x)
    
    return mask

​ 對於平均池化,我們需要把誤差平均分到每個位置:

def distribute_value(dz, shape):
    """
    Args:
    dz: input scalar
    shape: the shape (n_H, n_W) of the output matrix for which we want to distribute the value of dz
    
    Returns:
    a: Array of size (n_H, n_W) for which we distributed the value of dz
    """
    (n_H, n_W) = shape
    average = dz / (n_H * n_W)
    a = np.ones((n_H, n_W)) * average
    return a

​ 接下來,我們實現完整的池化層反向傳播過程:

def pool_backward(dA, cache, mode = "max"):
    """
    Args:
    dA: gradient of cost with respect to the output of the pooling layer, same shape as A
    cache: cache output from the forward pass of the pooling layer, contains the layer's input and hparameters 
    mode: the pooling mode you would like to use, defined as a string ("max" or "average")
    
    Returns:
    dA_prev: gradient of cost with respect to the input of the pooling layer, same shape as A_prev
    """
    (A_prev, hparameters) = cache
    stride = hparameters['stride']
    f = hparameters['f']
    m, n_H_prev, n_W_prev, n_C_prev = A_prev.shape
    m, n_H, n_W, n_C = dA.shape
    
    dA_prev = np.zeros(A_prev.shape)
    
    for i in range(m):                       
        a_prev = A_prev[i]
        for h in range(n_H):                   
            for w in range(n_W):               
                for c in range(n_C):           
                    vert_start = h * stride
                    vert_end = vert_start + f
                    horiz_start = w * stride
                    horiz_end = horiz_start + f
                    
                    if mode == "max":
                        a_prev_slice = a_prev[vert_start:vert_end, horiz_start:horiz_end, c]
                        mask = create_mask_from_window(a_prev_slice)
                        dA_prev[i, vert_start: vert_end, horiz_start: horiz_end, c] += np.multiply(mask, dA[i, h, w, c])
                        
                    elif mode == "average":
                        da = dA[i, h, w, c]
                        shape = (f, f)
                        dA_prev[i, vert_start:vert_end, horiz_start:horiz_end, c] += distribute_value(da, shape)
                        
    return dA_prev

6.2 Tensorflow框架實現

​ 由於自己實現的卷積網絡核心代碼,計算速度過慢(只能在CPU運行,且包含太多for循環,效率過低),所以最終決定用TensorFlow來完成一個測試模型。

​ 我們選用TensorFlow實現一個CIFAR-10數據集分類的例子。CIFAR-10是一個用於圖片分類的數據集。此數據集由60000張圖片組成,包涵10類。其中50000張圖片用於訓練,10000張圖片用於測試。

​ 本文不會具體介紹TensorFlow的詳細用法,如果需要,可以參考TensoFlow官網教程,或者gitchat也有人寫過一些入門文章。TensorFlow實現深度學習模型一般包涵以下步驟:

  • 定義佔位符

  • 構建模型的前向傳播過程

  • 定義損失函數

  • 指定優化方法

  • 運行優化方法最小化損失函數

    我使用jupyter notebook編程環境。首先,導入必須的包,導入數據文件:

import os
import tensorflow as tf
import numpy as np
import math
import timeit
import matplotlib.pyplot as plt
gpu_no = '0'  # or '1'

os.environ["CUDA_VISIBLE_DEVICES"] = gpu_no
config = tf.ConfigProto()

config.gpu_options.allow_growth = True

%matplotlib inline

def load_cifar10(num_training=49000, num_validation=1000, num_test=10000):
   	"""
   	導入CIFAR數據集。
   	"""
    cifar10 = tf.keras.datasets.cifar10.load_data()
    (X_train, y_train), (X_test, y_test) = cifar10
    X_train = np.asarray(X_train, dtype=np.float32)
    y_train = np.asarray(y_train, dtype=np.int32).flatten()
    X_test = np.asarray(X_test, dtype=np.float32)
    y_test = np.asarray(y_test, dtype=np.int32).flatten()

    
    mask = range(num_training, num_training + num_validation)
    X_val = X_train[mask]
    y_val = y_train[mask]
    mask = range(num_training)
    X_train = X_train[mask]
    y_train = y_train[mask]
    mask = range(num_test)
    X_test = X_test[mask]
    y_test = y_test[mask]
    
    mean_pixel = X_train.mean(axis=(0, 1, 2), keepdims=True)
    std_pixel = X_train.std(axis=(0, 1, 2), keepdims=True)
    X_train = (X_train - mean_pixel) / std_pixel
    X_val = (X_val - mean_pixel) / std_pixel
    X_test = (X_test - mean_pixel) / std_pixel

    return X_train, y_train, X_val, y_val, X_test, y_test



X_train, y_train, X_val, y_val, X_test, y_test = load_cifar10()

class Dataset(object):
    def __init__(self, X, y, batch_size, shuffle=False):
        assert X.shape[0] == y.shape[0], 'Got different numbers of data and labels'
        self.X, self.y = X, y
        self.batch_size, self.shuffle = batch_size, shuffle

    def __iter__(self):
        N, B = self.X.shape[0], self.batch_size
        idxs = np.arange(N)
        if self.shuffle:
            np.random.shuffle(idxs)
        return iter((self.X[i:i+B], self.y[i:i+B]) for i in range(0, N, B))


train_dset = Dataset(X_train, y_train, batch_size=64, shuffle=True)
val_dset = Dataset(X_val, y_val, batch_size=64, shuffle=False)
test_dset = Dataset(X_test, y_test, batch_size=64)

​ 構建計算圖(前向傳播過程):

def conv2d(x, scope, W_shape, b_shape, stddev = 5e-2, stride = [1, 1, 1, 1], lambda_ = 0.005):
    """
    定義卷積層
    """
    with tf.variable_scope(scope, reuse = tf.AUTO_REUSE) as scope:
        
        initializer = tf.truncated_normal_initializer(stddev=stddev, dtype=tf.float32)
        
        W = tf.get_variable('W', W_shape, initializer=initializer, dtype=tf.float32)
        b = tf.get_variable('b', b_shape, initializer=tf.constant_initializer(0.0), dtype=tf.float32)
        
        tf.add_to_collection("l2_loss", tf.nn.l2_loss(W)*lambda_)
        conv = tf.nn.conv2d(x, W, stride, padding='SAME')
        conv = tf.add(tf.nn.relu(conv), b)
    return conv
    
def max_pool(x, ksize = [1, 2, 2,1], stride = [1, 2, 2, 1]):
    """
    池化層
    """
    out = tf.nn.max_pool(x, ksize, stride, padding='SAME')
    return out

def fully_connect(x, scope, indim, outdim, stddev=5e-2):
    """
    全連接層
    """
    with tf.variable_scope(scope, reuse=tf.AUTO_REUSE) as scope:
        reshape = tf.reshape(x, [tf.shape(x)[0], -1])
        
        initializer = tf.truncated_normal_initializer(stddev=stddev, dtype=tf.float32)
        
        W = tf.get_variable('W', [indim, outdim], initializer=initializer, dtype=tf.float32)
        b = tf.get_variable('b', [outdim], initializer=tf.constant_initializer(0.0), dtype=tf.float32)
        tf.add_to_collection("l2_loss", tf.nn.l2_loss(W)*0.004)
        fc_out = tf.nn.relu(tf.matmul(reshape, W) + b)
    return fc_out

def build_model(images):
	"""
	構建完整模型 
	conv1->relu->pool->norm->conv2->pool->norm->fully1->relu->fully2->relu->softmax
	"""
    temp_conv = conv2d(images, "conv1", [5, 5, 3, 64], [64], stddev = 5e-2)
    temp_pool = max_pool(temp_conv, [1, 3, 3, 1])
    temp_norm = tf.nn.lrn(temp_pool, 4, bias=1.0, alpha=0.001/9, beta=0.75)
    temp_conv = conv2d(temp_norm, "conv2", [5, 5, 64, 64], [64], stddev = 5e-2)
    temp_pool = max_pool(temp_conv)
    temp_norm = tf.nn.lrn(temp_pool, 4, bias=1.0, alpha=0.001/9, beta=0.75)
    
    fc = fully_connect(temp_pool, 'fully1', 8*8*64, 256)
    fc = fully_connect(fc, 'fully2', 256, 10)
    out = tf.nn.softmax(fc)
    
    return out

def loss(y_pred, y):
    """
    損失函數
    """
    labels = tf.reshape(tf.one_hot(tf.reshape(tf.cast(y, dtype=tf.int64),[1, -1]), 10), [-1, 10])
    softmax_loss = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits_v2(logits=y_pred, 
                                                                             labels=labels))
    loss = tf.reduce_sum(tf.get_collection("l2_loss")) + softmax_loss
    #loss = softmax_loss
    return loss

def accuracy(y_pred, y):
    """
    準確率計算
    """
    y_pred = tf.argmax(y_pred, axis=1)
    y = tf.reshape(tf.cast(y,tf.int64),[1, -1])
    acc = tf.reduce_mean(tf.cast(tf.equal(y_pred,y), tf.float32))
    return acc

def train(train_dst, sess, epochs=100):
    """
    訓練圖
    """
    train_accs = []
    val_accs = []
    
    #佔位符
    images = tf.placeholder(tf.float32, [None, 32, 32, 3])
    y_label = tf.placeholder(tf.float32, [None, 1])
    
    #模型
    model = build_model(images)
    y_pred = model
    #準確率
    acc = accuracy(y_pred, y_label)
    #損失函數
    losses = loss(y_pred, y_label)
    
    #優化器
    with tf.variable_scope('opt', reuse = tf.AUTO_REUSE):
        opt = tf.train.AdamOptimizer(learning_rate=0.0001, use_locking=True)
        train_step = opt.minimize(losses)
    
    #參數初始化
    init = tf.global_variables_initializer()
    sess.run(init)
    
    for epoch in range(epochs):
        #迭代訓練
        print("****************epoch "+str(epoch))
        l = 0
        for (X, y) in train_dst:
            y_pred_ = sess.run(model, feed_dict={images:X})
            #print(y_pred.shape,type(y_pred))
            l = sess.run(losses,  feed_dict={images:X, y_label: y.reshape((-1, 1))} )
            
            train_acc = sess.run(acc, feed_dict={images:X, y_label: y.reshape((-1, 1))})
            train_accs.append(train_acc)
            
            sess.run(train_step, feed_dict={images:X, y_label:y.reshape((-1, 1))})
            
        print("loss: " +str(l))
        
        if epoch%1==0:
            
            print("train_acc: "+str(train_acc))
            val_acc = 0
            for i, (X_v, y_v) in enumerate(val_dset):
                
                y_pred_val = sess.run(model,feed_dict={images: X_v})
                val_acc += sess.run(acc, feed_dict={images: X_v, y_label: y_v.reshape((-1, 1))})
            val_acc /= (i+1)
            val_accs.append(val_acc)
            print("val_acc: "+ str(val_acc))
    #測試集
    test_acc = 0
    for i, (X_t, y_t) in enumerate(test_dset):
        y_pred_test = sess.run(model, feed_dict={images: X_t})
        test_acc += sess.run(acc, feed_dict={images: X_t, y_label:y_t.reshape((-1, 1))})
    test_acc /= (i+1) 
    print("%%%%%%%%%%%%% Test accuracy")
    print(test_acc)
    return train_accs, val_accs, test_acc

​ 運行計算圖:

train_acc, val_acc, test = [], [], 0.0
with tf.Session(config=config) as sess:
    train_acc, val_acc, tes=train(train_dset, sess )

​ 訓練過程中,模型在訓練集的準確率變化如下:

在這裏插入圖片描述​ 在測試集的準確率爲69.7%。

​ 可以看出,模型表現還是可以的(我們僅僅迭代了100次),但是距離完美還有很大差距。如果想要更好的結果,需要設計更復雜的網絡結構,以及更加精細的調參過程。考慮到本文主要目的不是如何優化網絡,而是爲大家展示卷積網絡原理,所以沒有實現很複雜的網絡。

7. 一些優化方法

​ 本部分內容主要是作者個人理解,僅僅是爲了給讀者說一下優化方法的概念,不必深究。如果需要深入瞭解,可以查閱相關論文。

​ 常用的優化方法有正則化(Regularization),批規範化(Batch Normalization),動量法(Momentum),Adam梯度下降法等。這裏主要提一下正則化和規範化。

​ 所謂正則化,就是在損失函數裏,考慮網絡參數的複雜度(事實上,在我們剛剛的TensorFlow模型中已經用到了正則化)。舉個簡單的例子,如果你想要擬合一條曲線(圖片來自網絡):

在這裏插入圖片描述​ 假設你有數據集(X, Y),如上圖藍色圈所示(共N個數據)。你希望對這些數據,擬合一條曲線,如果你把擬合公式定義爲:

y^=f(x)=w1x+w2x2+w3x3+w4x4+w5x5+b\hat y=f(x)=w_1x+w_2x^2+w_3x^3+w_4x^4+w_5x^5+b

​ 損失函數定義爲:

loss=1Ni=1N(y^iyi)2loss=\frac{1}{N}\sum_{i=1}^N(\hat y_i-y_i)^2

​ 經過梯度下降法之後,得到紅色的擬合曲線。這時候,曲線經過所有的樣本點,loss爲0,可以說,完美地達到了你的要求。但是,可達鴨眉頭一皺,發現事情並不簡單。

在這裏插入圖片描述

​ 即使沒有機器學習基礎,只學過初等數學的人,也知道“過擬合”(Over fit)的概念。沒錯!這裏顯然過擬合了!這些參數w1w5w_1-w_5過於“肆意妄爲”,以至於得到一個很不靠譜的模型。

​ 正則化要做的,就是“約束”這些參數,防止模型過擬合。具體來說,加入正則化之後,損失函數變爲:

loss=1Ni=1N(y^iyi)2+λj=15wj2loss = \frac{1}{N}\sum_{i=1}^N(\hat y_i-y_i)^2+\lambda \sum_{j=1}^5w_j^2

​ 上述公式的最後一項被稱爲L2正則化項(可以看作是對模型參數複雜度的度量),λ\lambda 需要人工選取。用新的損失函數,我們可以得到綠色的擬合曲線,這條曲線顯然看起來更合理。

​ 那麼,你可能會問,爲什麼正則化項的加入,會產生這麼神奇的效果呢?先來看看,我們那條“過擬合”的紅色曲線,這條曲線彎彎曲曲的,曲線各點的斜率,劇烈變化(斜率是導數):

f(x)=w1+2w2x+3w3x2+4w4x3+5w5x4f^{'}(x)=w_1+2w_2x+3w_3x^2+4w_4x^3+5w_5x^4

​ 在x相同的時候,w的值越大,斜率變化越複雜。因此,當加入正則化項之後,參數w趨向變小,從而擬合曲線的斜率變化趨於平緩,換言之,擬合曲線變得更加“平滑”,因此在很大程度上解決了過擬合問題。總結一下,正則化主要是,讓參數W趨向於小值,讓模型趨向於簡單

​ 在深度神經網絡中,正則化依舊是有用的。在深度神經網絡中,除了上面提到的優點,正則化還趨向於讓W參數趨於“分散”,即,不會讓 W 矩陣的某個元素的絕對值過大或過小,而是趨向於,讓W矩陣的每個元素,都對模型有差不多的貢獻,從而可以在一定程度上,防止模型過擬合。

​ 下面,再來說一說批規範化(Batch Normalization)。批規範化,即每次輸入一批數據後,先對數據進行處理,使數據均值爲0,方差爲1。在卷積神經網絡中,批規範化一般用在激活函數之前(也可以在之後)。現在,一些論文研究了各種各樣的規範化,除了批規範化(Batch Normalization)還有層規範化(Layer Normalization),組規範化(Group Normalization)等。雖然目前對規範化的作用機理有各種解釋,但是從個人角度來看,大多數解釋都有些牽強。個人角度看,規範化的主要作用是,強行穩定各層特徵圖的分佈,使模型的訓練更加容易,並且,批規範化解決了模型對參數初始化過於敏感的問題。如下圖所示,藍色表示“等高線”(損失函數值相等的線),這些等高線的中心表示最低點(損失函數最小的地方)。規範化之前,你的特徵空間可能“又細又長”,梯度下降法的路徑,可能如下圖左所示,震盪幅度很大;規範化之後,由於各個方向的特徵比較均衡,因此梯度下降更加穩定。

在這裏插入圖片描述

​ 關於深度神經網絡的優化方法還有很多,比如Adam變步長算法,學習率衰減算法以及各種巧妙的初始化方法等。目前來看,深度神經網絡的可解釋性還比較差,並且,神經網絡的損失函數是非凸的,非凸優化的求解難度很大,因爲可能存在無數個局部最優點,而通常求解全局最優的複雜度是指數級。目前有研究表明,深度神經網絡模型很複雜,因此存在足夠多的局部最優點,而這些局部最優點接近全局最優,並且,由於深度神經網絡的損失函數,維度很高,因此存在大量鞍點,所以總有一個梯度方向,能朝着一個合理的局部最優點前進。因此,只要模型結構設計地合理,訓練方法得當,我們總能得到一個可接受的結果。

​ 總之,目前來看,深度神經網絡,黑箱的成分很大,不僅沒有足夠的數學理論支撐,而且,即使得到了一個優秀的模型,也很難說清楚模型爲什麼表現的好。所以,如果你讀論文比較多的話,就會發現,好多論文更像是得到好的實驗結果之後,再去嘗試爲模型添加理論解釋,而不是以理論突破來指導模型創新。

​ 添加各種優化算法之後的誤差反傳更加繁瑣,感興趣的讀者可以自行推導(強烈不建議,我自己推過,還是很麻煩的),本文不再贅述。

8. 後記

​ 卷積神經網絡,在圖像領域有着廣泛的應用,但是,目前可解釋性較差,就像一個“黑箱”一樣,雖然不斷進化,人們卻很難說清楚模型爲什麼表現地好。

​ 誤差反傳是深度神經網絡的核心,也是最難推理的部分。雖然誤差反傳的基礎是鏈式法則,但是當模型變得複雜的時候,試圖推導出每一層的誤差反傳解析式極其困難。現在,大多數深度學習框架都用到了一種叫做“自動微分”(Automatic Differentiation)的技術,來進行誤差反傳的工作。對於大多數工程師而言,不需要關心誤差反傳的過程。自動微分的知識可以參見書籍Evaluating Derivatives: Principles and Techniques of Algorithmic Differentiation, Second Edition

​ 另外,關於深度學習框架,我也有一點想說。目前流行的深度學習框架,主推TensorFlow(Google)和PyTorch(Facebook)。TensorFlow大而全,但是對新手不是很友好,應該說,由於其使用靜態計算圖(static graph),先構建計算圖,再計算,和普通的編程思路有所不同,大部分新手在學習TensorFlow的時候,會懵逼好一陣子。PyTorch對新手友好,使用動態計算圖(dynamic graph),使用PyTorch編寫代碼和普通的python代碼幾乎沒有區別,並且和numpy無縫結合(主要指張量和numpy.ndarray可以相互轉換),特別方便。我的建議是,如果你之前沒有用過任何深度學習框架,推薦你從PyTorch入手,當然,如果你是老手的話,就根據自己的需要選擇吧。

​ 好了,本次內容分享就到這裏了,謝謝大家。


參考資料:

[1] cs231n http://cs231n.stanford.edu/
[2] Coursera Deep learning https://www.coursera.org/specializations/deep-learning
[3] Deep Learning http://www.deeplearningbook.org/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章