Andrew Kirillov 著
Conmajia 譯
2019 年 1 月 15 日原文發表於 CodeProject(2018 年 10 月 28 日). 中文版有小幅修改,已獲作者本人授權.
本文介紹了如何使用 ANNT 神經網絡庫生成卷積神經網絡進行圖像分類識別.
簡介
本文繼續上一篇《前饋全連接神經網絡》,討論使用 ANNT 生成卷積神經網絡,並應用到圖像分類處理任務中. 在《前饋》中,我介紹了隨機梯度下降(SGD)、誤差反向傳播(EBP)等算法,還引入了一個 MNIST 手寫文字識別的簡單例子. 例子雖然簡單,但還是達到了 96.5% 的准確率. 這篇文章里,我打算介紹一個不同的人工神經網絡架構:卷積神經網絡(convolutional neural networks,CNN). 這是專為計算機視覺領域設計的架構,適宜處理諸如圖像分類、圖像識別之類的任務. 文中附帶的例子里,我把手寫文字分類識別准確率提高到了 99%.
卷積神經網絡由 Yann LeCun[1] 在 1998 年提出. 然而那時候公眾和業界對人工智能相關領域的關注度很低,他的研究在當時無人問津. 直到 14 年后,在 ImageNet 比賽中獲勝團隊使用了這一架構拔得頭籌,這才引起了廣泛的關注. 隨后 CNN 一飛沖天,迅速流行起來,並應用到了大量計算機視覺領域研究中. 如今,最先進的卷積神經網絡算法在進行圖像識別時,甚至可以超過人類肉眼識別的准確率.
理論背景
前饋全連接人工神經網絡的思路來源於對生物細胞的生理連接規律的研究. 類似的,卷積網絡則是從動物大腦的學習方式獲得靈感. 1950 年代至 1960 年代,Hubel 和 Wiesel 的研究揭示了貓與猴子的大腦皮層中負責視覺的部分包含了能響應極小視野的神經元. 如果眼睛不動,視覺刺激影響單個神經元放電的視覺空間區域稱為感受野(receptive field). 相鄰的細胞有相似和重疊的接收區. 感受野的大小和位置在整個大腦皮層上有系統的變化,從而形成完整的視覺空間圖.
在 Hubel 等的論文中,他們描述了大腦中兩種基本類型的視覺神經細胞,簡單細胞和復雜細胞,每種的行為方式都不同. 例如,當識別到某個固定區域里呈某一角度的線條時,簡單細胞就會激活. 復雜細胞的感受野更大,其輸出對其中的特定位置不敏感. 這些細胞即便在視網膜的位置發生了變化也會繼續對某種刺激作出反應.
1980 年,日本的福島邦彥提出了種層次化的神經網絡模型,命名為新認知機(neocongnitron). 這個模型受簡單和復雜細胞的概念的啟發,新認知者能夠通過學習物體的形狀來識別模式.
再后來,1998 年,Yann LeCun 等人引入了卷積神經網絡. 第一版的 CNN 叫做 Lenet-5,能夠分類手寫數字.
卷積網絡的架構
在開始構建卷積神經網絡的細節之前,先來看神經網絡的組成基礎. 正如前一篇文章提到的,人工神經網絡的許多概念可以作為單獨的實體來實現,用於執行推理和訓練階段的計算. 由於核心結構已經在前面的文章中列出,這里我將直接在頂層添加模塊,然后把它們粘在一起.
卷積層
卷積層是卷積神經網絡的核心部分. 它假定輸入是具有一定寬度、高度和深度的三維形狀. 對於第一個卷積層,它通常是一個圖像,最常見的深度是 1(灰度圖像)或 3(帶 RGB 通道的彩色圖像). 前一層生成一組特征映射(這里的深度是輸入特征映射的數量)輸入到后一層. 這里假設需要處理深度為 1 的輸入,然后轉換為二維結構.
所以,卷積層所做的,本質上是一個具有核的圖像卷積,一種非常常見的圖像處理操作. 例如,可以用來模糊化或者銳化圖像. 但討論卷積網絡時並不關心這些. 根據使用的核,圖像卷積可以用來尋找圖像中的某些特征,如垂直、水平邊緣,角或圓等更復雜的特征. 想想我前面介紹的視覺皮層中簡單細胞的概念?
現在來計算一下卷積. 假設有 \(n\)×\(m\)(高度×寬度)、矩陣 \(\mathbf{K}\)(核)和 \(\mathbf{I}\)(圖像),那么卷積可以寫成這些矩陣的點積:
舉個例子,對於 3×3 的矩陣,可以這么計算它們的卷積:
式 \((1)\) 的卷積定義是從信號處理領域借鑒過來的,核經過了垂直和水平翻轉. 更直接的計算方法是 \(\mathbf{K}\) 和 \(\mathbf{I}\) 不進行翻轉,直接進行正常點積. 這種操作稱為互相關,定義如下:
在信號處理里,卷積和互相關具有不同的性質,並且用於不同的目的. 但是在圖像處理和神經網絡里,這些差異變得很細微,通常使用互相關來計算. 對於神經網絡來說,這點差異並不重要. 稍后可以看到,這些“卷積”核實際上是神經網絡需要學習的權重. 所以,由網絡決定哪個核需要學習,翻轉還是不翻轉.
好了,現在知道了如何計算兩個相同大小的矩陣的卷積. 但是實際圖像處理中這種福利局很少有,一般通常是一個 3×3、5×5、7×7 等大小的正方形矩陣作為核,而圖像可以是任意大小的. 那么怎么計算圖像卷積呢?為了計算圖像卷積,在整個圖像上移動核,並在每個可能位置計算加權和. 圖像處理中,這個概念被稱為滑動窗口,從圖像的左上角開始,計算這一小區域(大小和核相同)的卷積. 然后將核右移一個像素,計算出另一個卷積. 不斷重復,完成第一行每個位置的計算,然后從第二行開始,繼續重復前面的計算. 這樣,當整個圖像處理后,就能得到一個特征圖,其中包含了原圖每個位置的卷積值.
圖 1 說明了圖像卷積的計算過程. 對於 8×8 的輸入圖像(input image)和 3×3 的核(kernel),計算得到 6×6 的特征圖(feature map).

圖 1 的 3×3 卷積核是設計來查找對象的左邊緣的(從滑動窗口的中心看,右側有一條垂直直線). 特征圖中的高正值表示存在要查找的特征,零表示沒有特征. 對於這個例子,負值表示存在“反轉”特征,也就是對象的右邊緣.
當計算卷積時,輸出特征映射的大小比原圖小. 使用的核越大,得到的特征圖就越小. 對於 \(n\)×\(m\) 大小的核,輸入圖像的大小將丟失 \((n-1)\)×\((m-1)\). 因此,上面的例子如果用 5×5 的核,那特征圖將只有 4×4. 多數情況下,需要特征圖和原圖等大,這時就要填充特征圖,一般用 0 填充. 假設原圖大小為 8×8,而核為 5×5,那么需要先把原圖填充到 12×12,添加 4 個額外的行和列,每側各 2 行/列.
現在,讀者應該已經可以計算卷積了. 接下來要研究這些內容怎樣運用到前面定義的卷積層中. 為了保持簡單,繼續使用圖 1 的例子. 在這種情況下,輸入層有 64 個節點,卷積層有 36 個神經元. 和全連接層不同的是,卷積層的神經元只與前一層的一小部分神經元相連. 卷積層中的每個神經元的連接數與它所實現的卷積核中的權重數相同,在上面的例子中是 9 個連接(核大小 3×3). 因為假定卷積層的輸入具有二維形狀(一般是三維的,我這里簡化一下,便於研究),所以這些連接是對先前神經元的矩形組進行的,該組神經元的形狀與使用中的內核相同. 以前連接的神經元組對於卷積層的每個神經元是不同的,但是它確實與相鄰的神經元重疊. 使用滑動窗口法計算圖像卷積時,這些連接的方式與選擇原圖像素的方式相同.
忽略全連接層和卷積層的神經元與前一層的連接數不同,並且這些連接具有一定的結構這樣的事實后,這兩個層可以看作基本相同的:計算輸入的加權和以產生輸出. 不過還有一個區別,就是卷積層的神經元共享權重. 因此,如果一個層做一個 3×3 的卷積,它只有一組權重,即 9. 每個神經元都共享這個權重,用於計算加權和. 而且,盡管沒有提到,卷積層也為加權和增加了偏差值,這也是共享的. 表 1 總結了全連接層和卷積層之間的區別:
全連接層 | 卷積層 |
---|---|
不假設輸入結構 | 假設輸入為 2D 形狀(通常是 3D) |
每個神經元都連接到前一層所有神經元 每神經元 64 個連接 |
每個神經元連接到前一層的矩形組,連接數等於卷積核的權重數 每神經元 9 個連接 |
每個神經元有自身的權重和偏差值 共 2304 權重,36 偏差值 |
共享權重和偏差值 共 9 權重,1 偏差值 |
前面的思考都基於卷積層的輸入和輸出都是二維這個假設. 但是實際上通常輸入和輸出都具有三維形狀. 首先,從輸出開始,每個卷積層計算不止一個卷積. 設計人工神經網絡時,可以對它所能做的卷積數量進行配置,每個卷積使用自己的一組權重(核)和偏差值,從而生成不同的特征圖. 前面提到過,不同的核可以用來尋找不同的特征直線、曲線、角等. 因此,通常會求得一些特征圖,以突出不同特征. 這些圖的計算方法很簡單,只要在卷積層中添加額外的神經元群,這些神經元以單核的方式連接到輸入端,就可以完成卷積的計算. 盡管這些神經元具有相同的連接模式,但它們共享不同的權重和偏差值. 還是用上面的例子,假設將卷積層配置為執行 5 個卷積,每個執行 3×3,這種情況下,輸出數量(神經元數量)是 36×5=180. 5 組神經元組織成二維形狀並重復相同的連接模式,每組都有自己的權重/偏差集,於是可得 45 個權重和 5 個偏差值.
來討論一下輸入的三維性質. 對於第一層卷積層,多半都是些圖像,要么是灰度圖(2D),要么是 RGB 彩圖(3D). 對於后續的卷積層,輸入的深度等於前一層計算的特征圖的數量(卷積的數量). 輸入深度越大,與前一層連接的數量越多,卷積層中的神經元數量就越少. 此時使用的實際上是 3D 的卷積核,大小為 \(n\)×\(m\)×\(d\),\(d\) 是輸入深度. 可以認為每個神經元都從各自的輸入特征圖增加了額外的連接. 2D 輸入的情況下,每個神經元連接到輸入特征圖的 \(n\)×\(m\) 矩形區域. 3D 輸入的情況下,每個神經元連接的是這些區域同樣的位置,只是它們具有來自不同輸入特征圖的數字 \(d\).
現在已經將卷積層推廣到了三維上,也提到了偏差值,針對卷積核每個 \((x,y)\),式 \((1)\) 可以表示為:
總結一下卷積層的參數. 在生成全連接層時,只用到輸入神經元數量和輸出神經元數量兩個參數. 生成卷積層時,不需要指定輸出的數量,只用指定輸入的形狀,\(h\)×\(w\)×\(d\),以及核的形狀 \(n\)×\(m\) 和數量 \(z\). 因此,有 6 個數字:
- \(w\):輸入特征圖的寬度
- \(h\):輸入特征圖的高度
- \(d\):輸入深度(特征圖的數量)
- \(m\):卷積核寬度
- \(n\):卷積核高度
- \(z\):卷積核數量(輸出特征圖的數量)
卷積核的實際大小取決於指定的輸入,因此可以得到 \(z\) 個 \(n\)×\(m\)×\(d\) 大小的卷積核,假設沒有填充輸入,這時輸出的大小應為 \((h-n+1)\)×\((w-m+1)\)×\(z\).
上面是計算輸出的概念性內容,接下來訓練卷積層時,還會再次提到.
ReLU 激活函數
ReLU 激活函數也就是 rectifier 激活函數,對卷積神經網絡來說,它不是什么新東西. 隨着更深層次的神經網絡的興起,它得到了廣泛的推廣.
深度神經網絡遇到的問題之一就是消失梯度問題. 當使用基於梯度的學習算法和反向傳播算法訓練人工神經網絡時,每個神經網絡的權重都與當前權重相關的誤差函數偏導數成比例變化. 問題是在某些情況下,梯度值可能小到權重值不會改變. 這一問題的原因之一是使用傳統的激活函數,如 sigmoid 和 \(\tanh\). 這些函數的梯度在 \((0,1)\) 范圍內,大部分的值接近於 0. 由於誤差的偏導數是用鏈式法則計算出來的,對於一個 \(n\) 層網絡,這些小數字會乘上 \(n\) 次,梯度將呈指數遞減. 結果就是,深度神經網絡在訓練“前面的”層時非常緩慢.
ReLU 函數的定義為 \(f(x)=x^+=max(0,x)\). 它最大的優點是,對於 \(x>0\) 的值,它的導數總是 1,所以它允許更好的梯度傳播,從而加快深度人工神經網絡的訓練速度. 和 sigmoid 和 \(\tanh\) 相比,它的計算效率更高,速度更快.
![]() |
![]() |
|
|
雖然 ReLU 函數存在一些潛在的問題,但到目前為止,它依然是深度神經網絡中最成功最廣泛的激活函數之一.
池化層
實踐中經常會為卷積層生成一個池化層(pooling layer). 池化的目的是減少輸入的空間尺寸,減少神經網絡中的參數和計算量. 這也有助於控制過擬合(over-fitting).
最常見的池化技術是平均池化和最大池化. 以最大池化為例,使用 2×2 大小過濾器,跨距為 2 的 MAX
池化. 對於 \(n\)×\(m\) 的輸入,通過將輸入中的每個 2×2 區域替換為單個值(該區域中 4 個值的最大值),得到 \(\dfrac{n}{2}\times\dfrac{m}{2}\) 的結果. 通過設置與池化區域大小相等的跨距,可以保證這些區域相鄰而不重疊. 圖 3 演示了用於 6×6 輸入圖的過程.

池化層的過濾器和跨距值. 例如一些應用程序使用具有 2 跨距的 3×3 大小過濾器這樣存在部分重疊的池化. 一般來說跨距不會大於過濾器大小,圖像里很多內容會完全丟失.
池化層使用二維特征圖,但並且不影響輸入深度. 如果輸入包含由前一個卷積層生成的 10 個特征圖,那么池化將分別應用於每個圖. 所以通過池化,能生成相同數量的特征圖,但尺寸更小.
建立卷積神經網絡
多數情況下,卷積網絡從卷積層開始,卷積層執行初始特征的提取,然后是全連接層,后者執行最終的分類.
以 LeNet-5 為例. 這是 Yann LeCun 提出的卷積神經網絡結構,並應用於手寫數字分類. 它輸入 32×32 的灰度圖像,產生 10 個值的向量,這些值代表數字屬於某一類(數字 0 到 9)的概率. 表 2 總結了網絡的結構、輸出的尺寸和可訓練參數(權重+偏差)的數量.
層類型 | 可訓練參數 | 輸出大小 |
輸入圖像 | 32×32×1 | |
卷積層 1,核大小 5×5,核數量 6 ReLU 激活 |
156 | 28×28×6 |
最大池化 1 | 14×14×6 | |
卷積層 2,核大小 5×5,核數量 16 ReLU 激活函數 |
416 | 10×10×16 |
最大池化 2 | 5×5×16 | |
卷積層 3,核大小 5×5,核數量 120 | 3120 | 1×1×120 |
全連接層 1,輸入 120,輸出 84 Sigmoid 激活函數 |
10164 | 84 |
全連接層 2,輸入 84,輸出 10 SoftMax 激活函數 |
850 | 10 |
這里只有 14706 個可訓練參數,算是非常簡單的卷積神經網絡結構了. 業界實用的更復雜的深度神經網絡,包含了超過幾百萬個訓練參數.
訓練卷積網絡
到目前為止,本文還只局限於推導卷積神經網絡,即計算給定輸入的輸出. 但是要從中得到有意義的東西,需要先對網絡進行訓練. 對於圖像處理中的卷積算子,卷積核通常是人工設計的,具有特定的用途,比如查找物體邊緣,銳化圖像或是模糊圖像等. 設計正確的卷積核來執行所需的任務是一個耗時的過程. 但是對於卷積神經網絡,情況卻完全不同. 在設計這種網絡時,只用考慮層數、完成的卷積的數量和大小等,而不會設置這些卷積核. 相反,網絡將在訓練階段學習這些內容. 從本質上說,這些核只不過是權重.
卷積人工網絡的訓練使用與全連接網絡訓練完全相同的算法——隨機梯度下降和反向傳播. 正如《前饋》中寫到的,為了計算神經網絡誤差的偏導數,可以使用鏈式法則. 這樣可以為任何可訓練層的權重變化定義完整的方程. 我將針對神經網絡每個構建塊(building block),比如全連接和卷積層、激活函數、成本函數等,寫一些小點的方程,而不是那種一個式子占半頁紙的大玩意兒.
通過鏈式法則,可以發現神經網絡的每個構建塊都將其誤差梯度計算為輸出相對於輸入的偏導數,並與后面塊的誤差梯度相乘. 要記住,信息流是向后移動的,所以計算要從最后一個塊開始,然后流到前一個塊,即第一個塊. 訓練階段的最后一個塊始終是一個成本函數,它將誤差梯度作為成本(其輸出)相對於神經網絡輸出(成本函數的輸入)的導數進行計算. 這可以通過以下方式定義:
所有其他構建塊都從下一個塊中獲取誤差梯度,並乘以其輸出相對於輸入的偏導數.
回憶一下全連接網絡的導數. 首先,從 MSE 成本函數相對於網絡輸出的誤差梯度開始(\(y_i\) 為網絡產生的輸出,\(t_i\) 為目標輸出):
當誤差梯度通過 sigmoid 激活函數后移時,它會以這種方式重新計算(這里的 \(o_i\) 是 sigmoid 的輸出),這是從下一塊(無所謂是什么,也可以是成本函數或多層網絡中的另一層)得到的梯度乘以 sigmoid 的導數:
或者,如果使用 \(\tanh\) 作為激活函數,則:
當需要通過一個全連接層向后傳播誤差梯度時,鑒於每個輸入輸出都各自相連,可以得到一個偏導數的和:
其中,\(n\) 是全連接層中的神經元數,\(i\)、\(j\) 分別表示第 \(i\) 個輸出和第 \(j\) 個輸入.
由於全連接層是一個可訓練的層,它不僅需要將誤差梯度向后傳遞給前一個層,還需要計算權重. 使用上述定義的命名約定,權重和偏差的計算規則可以寫成(經典SGD):
上面的方程實際上都是《前饋》中反向傳播的內容. 為什么我要再寫一遍?首先是要提醒一下基礎知識,其次,我用了不同的方式重寫,其中每個構建塊定義自己的誤差梯度反向傳播方程. 《前饋》里給出的權重方程有助於理解基本知識以及鏈規則的工作原理,但是作為一個單一的方程,它沒法通用. 如果成本函數不是 MSE 呢?如果需要 \(\tanh\) 或者 ReLU 激活函數而不是 sigmoid 呢?本文介紹的方法更加靈活,允許以各種方式混合人工神經網絡的構建塊,並在不假設哪一層之后進行激活,使用哪一個成本函數的情況下進行培訓. 此外,這樣的寫法和我實際的 C++ 代碼實現類似,我把不同的構建塊實現為單獨的類,在訓練過程中讓它們各自計算前向傳遞和后向傳遞.
交叉熵成本函數
卷積神經網絡最常用的用途之一是圖像分類. 給定一個圖像,網絡需要把它分類到相互排斥的類里去. 比如手寫數字分類,有 10 個可能的類對應於從 0 到 9 的數字. 或者可以訓練一個網絡來識別汽車、卡車、輪船、飛機等交通工具. 這種分類的要點是,每個輸入圖像必須只屬於一個類別.
在處理多類分類問題時,人工神經網絡輸出的類數應當與要區分的類數相同. 在訓練階段,目標輸出是獨熱編碼的,也就是用零向量表示,在與類對應的索引處,只有一個元素設置為值“1”。例如,對於 4 類分類的任務,目標輸出可能是:第 2 類 \(\{0、1、0、0\}\)、第 4 類 \(\{0、0、0、1\}\) 等. 任何目標輸出都不允許將多個元素設置為“1”或其他非零值. 這可以看作是目標概率,即 \(\{0、1、0、0\}\) 輸出意味着輸入屬於第 2 類的概率為 100%,以及屬於其他類的概率為 0%.
上面說的是理想情況,實際訓練中的神經網絡輸出不會是非黑即白這么極端,比如它可以輸出 0.3、0.35、0.25、0.1 之類的小數. 這些輸出對應着不同的實際含義. 這表示神經網絡沒法十分清楚判斷目標應該分到哪一類,它只能根據計算得到的概率分析,第 2 類的概率有0.35,也就是 35% 的可能性,而且這是 4 個輸出中最高的,那么它將猜測這很可能應該屬於第 2 類.
所以說,需要一個成本函數來量化目標和實際輸出之間的差異,並指導神經網絡計算其參數. 在處理互斥類的概率模型時,通常需要處理預測概率和真實值(ground-truth)概率. 這種情況下,最常見的選擇是交叉熵成本函數(cross-entropy). 交叉熵是信息論當中的概念. 通過最小化交叉熵,通過最小化額外的數據比特量,用估計的概率 \(y_i\) 對出現概率分布 \(t_i\)(目標或實際分布)的某些事件進行編碼. 為了最小化交叉熵,需要使估計概率與實際概率相同.
交叉熵成本函數定義如下:
其中,\(t_i\) 是目標輸出,\(y_i\) 是神經網絡輸出.
對上式求導,成本函數對神經網絡輸出的偏導數為:
這就得到了可以代替 MSE 的交叉熵成本函數. 接下來可以開始處理其他構建塊並觀察誤差梯度是如何反向傳播的.
SoftMax 激活函數
《前饋》中已經介紹過在分類問題中用到的神經網絡最后一層使用 sigmoid 作為激活函數. 它的輸出值域為 \((0,1)\),可以理解為從 0% 到 100% 表示的概率. 如果神經網絡輸出層采用 sigmoid,它的確可能得到接近於真實值的概率. 但是現在要處理的是互斥類,很多情況下 sigmoid 的輸出是無意義的. 比如上面的 4 類分類例子:一個輸出向量是 ${0.6,0.55,0.1,0.1},這是用 sigmoid 可能得到的結果. 問題在哪?乍一看,這表明應該是第 1 類(60% 概率),但是第 2 類的可能性也很大(55%). 而且這個輸出結果有一個很大的問題,它的各概率和達到了 1.35,也就是目標屬於這 4 類之一的可能性是 135%. 這在物理上是毫無意義的!
這里要指出兩個問題:第一,各分類概率和應為 100%,不能多,也不能少. 第二,對於難以識別的分類目標,如果目標既像第 1 類,又像第 2 類,那么怎么能確定 60% 這么高的概率一定是可信的?
為了解決這兩個問題,需要用到另一個激活函數:SoftMax. SoftMax 類似 sigmoid,值域也是 \((0,1)\). 不同的是,它處理整個輸入向量而不是其中的單個值,這就保證了輸出向量(概率)的和恆為 1. SoftMax 定義為:
將上面的例子改用 SoftMax 進行處理后,輸出向量變得更合理了:\(\{0.316,0.3,0.192,0.192\}\). 可以看到,向量中各概率的和等於 1,也就是 100%. 最可能的第 1 類,它的概率也不再高得離譜,只有 31.6%.
和其他激活函數一樣,SoftMax 也需要定義它的梯度反向傳播方程:
表 2 里可以看到 LeNet-5 神經網絡架構中包含了全連接層和 sigmoid 激活函數. 這兩者的方程也定義完畢,現在就可以繼續討論其他構建塊了.
ReLU 激活函數
前面提到過,ReLU 激活函數在深度神經網絡經常用到,它對於大於 0 的輸入向量梯度恆為 1,所以能保證誤差梯度在網絡中更好地傳播. 現在來定義它的梯度反向傳播方程:
池化層
為了盡量簡潔地說明誤差梯度如何通過池化層反向傳播,假設使用的池化層卷積核大小 2×2,跨度 2,不填充輸入(只池化有效位置). 這個假設意味着每個輸出特征圖的值都是基於 4 個值計算得到的.
盡管池化層假設輸入向量是二維數據,但是下面的數學定義也可以處理輸入輸出是一維向量的情況. 首先定義 \(\mathrm{i2j}(i)\) 函數,這個函數接受輸入向量第 \(i\) 個值(作為索引),並返回輸出向量對應的第 \(j\) 個值(作為索引). 由於每個輸出都是用 4 個輸入值計算出來的,所以這意味着有 4 個 \(i\) 會讓 \(\mathrm{i2j}(i)\) 函數輸出同一個 \(j\).
先從最大池化開始,定義誤差梯度反向傳播方程之前,還有一件事要做. 在正向傳遞時,計算神經網絡的輸出也會用與輸出向量長度相同的最大索引值(max indices)向量填充池化層. 如果輸出向量包含對應輸入值的最大值,則最大索引值向量包含最大值的索引. 綜上所述,可以定義最大池化層的梯度反向傳播方程:
其中,\(p\) 是最大索引值向量.
對平均池化來說,就更簡單了:
其中,\(q\) 是卷積核大小,在這個例子里,\(q=4\).
卷積層
最后來定義卷積層的反向傳播過程. 牢記一點,它和全連接層的區別就在於共享權重和偏差值.
從卷積層的權重計算開始. 對於全連接層,誤差對權重 \(\omega_{i,j}\) 的偏導數等於下一個塊的誤差梯度乘以相應的輸入值 \(\delta_i^{(k+1)}x_j\). 這是因為每個輸入/輸出連接都在全連接層中分配了自己的權重,而全連接層是不共享的. 但是卷積層和這不一樣,圖 4 顯示了卷積核的每個權重都用於多個輸入/輸出連接. 圖中的例子,突出顯示的卷積核權重每個使用了 9 次,對應輸入圖像中的 9 個不同位置. 因此,與權重有關的誤差的偏導數也需要有 9 個.

和處理池化層時類似,這里忽略了卷積層處理的是二維/三維數據這一事實,而假設它們是普通的向量/數組(就像 C++ 編程時用到的那樣). 對於上面的示例,第一個權重(紅線框出)應用於輸入 \(\{1,2,3,5,6,7,9,10,11,13,14,15\}\),而第四個權重應用於輸入 \(\{6,7,8,10,11,12,14,15,16\}\). 用 \(r_i\) 表示每個權重使用的輸入索引向量. 另外定義 \(\mathrm{i2o}(i,j)\) 函數,它為第 \(i\) 個權重和第 \(j\) 個輸入提供輸出值索引. 上圖中有幾個例子,\(\mathrm{i2o}(1,1)=1\)、\(\mathrm{i2o}(4,6)=1\)、\(\mathrm{i2o}(1,11)=9\)、\(\mathrm{i2o}(4,16)=9\). 根據這些約定,可以定義卷積網絡的權重:
上面的玩意兒有什么意義?嗯,它的意義很豐富. 你想得越多,意義就越多. 這里的目標是為所有輸出取誤差梯度(因為每個核的權重用於計算所有的輸出),然后將它們乘以相應的輸入. 盡管有多個核,但是它們都以相同的模式應用,所以即使需要計算不同核的權重,權重輸入向量也保持不變. 然而,\(\mathrm{i2o}(i,j)\) 是每個核特定的,它可以使用核的索引作為額外的參數進行擴展.
更新偏差值要簡單得多. 由於每個核/偏差都用於計算輸出值,所以只需為當前核生成的特性圖的誤差梯度求和即可:
其中,\(s\) 是特性圖.
現在來求卷積層誤差梯度反向傳播的最終方程. 這意味要計算與層輸入相關的誤差偏導數. 每個輸入元素可以多次用於生成要素圖的輸出值,它的使用次數可以與卷積核中的元素數(權重數)相同. 但是,有些輸入只能用於一個輸出,比如二維特征圖的四角. 還要記住,每個輸入特征圖都可以用不同的核進行多次處理,從而生成更多的輸出圖. 假設另一組名為 \(\gamma_i\) 的輔助向量,用於保存第 \(i\) 個輸入所貢獻的輸出索引. 再定義 \(\mathrm{i2w}(i,j)\) 函數,它返回連接第 \(i\) 個輸入到第 \(j\) 個輸出的權重. 還是以圖 4 為例,有:\(\mathrm{i2w}(1,1)=1\)、\(\mathrm{i2w}(6,1)=4\)、\(\mathrm{i2w}(16,9)=4\). 利用這些定義,誤差梯度通過卷積層后向傳播的方程可以寫為:
數學分析到此結束,所有需要計算的內容都已經完成了.
ANNT 庫
卷積人工神經網絡很大程度上是基於《前饋》所述的全連接網絡實現的設計集. 所有核心類都保持原樣,只實現了新的構建塊,允許將它們構建成卷積神經網絡. 新的類關系圖如下所示,跟原來的沒有什么區別.

與以前的設置方式類似,新的構建塊負責計算正向傳遞上的輸出和反向傳遞上傳播誤差梯度(以及在可訓練層的情況下計算初始權重). 因此,所有的神經網絡訓練代碼都可以原樣照搬. 和其他代碼一樣,新的構建塊盡可能使用了 SIMD 指令向量化計算,以及 OpenMP 並行計算.
編譯源碼
源碼里附帶 MSVC(2015版)文件和 GCC make 文件. 用 MSVC 非常簡單,每個例子的解決方案文件都包括例子本身和庫的項目,編譯也只需點擊一下按鈕. 如果使用 GCC,則需要運行 make 來編譯程序.
使用例程
分析了那么久的原理和數學推導,是時候開始實踐並實際生成一些用於圖像分類任務的網絡了,例如分類識別手寫數字和汽車、卡車、輪船、飛機之類不同的對象.
MNIST 手寫數字分類
第一個例子是對 MNIST 數據庫里的手寫數字進行分類. 這個數據庫包含了 60000 個神經網絡訓練樣本和 10000 個測試樣本. 圖 6 展示了其中的一部分.

例子使用的卷積神經網絡的結構與 LeNet-5 網絡非常相似,只是規模小得多. 它只有一個全連接網絡:
Conv(32x32x1, 5x5x6 ) -> ReLU -> AvgPool(2x2)
Conv(14x14x6, 5x5x16 ) -> ReLU -> AvgPool(2x2)
Conv(5x5x16, 5x5x120) -> ReLU
FC(120, 10) -> SoftMax
上面設置了每個卷積層的輸入大小以及它們執行的卷積的大小和數量,全連接層的輸入/輸出數量. 接下來生成卷積神經網絡.
// 連接表用於指定第一卷積層要使用的由第二層生成的特征圖
vector<bool> connectionTable( {
true, true, true, false, false, false,
false, true, true, true, false, false,
false, false, true, true, true, false,
false, false, false, true, true, true,
true, false, false, false, true, true,
true, true, false, false, false, true,
true, true, true, true, false, false,
false, true, true, true, true, false,
false, false, true, true, true, true,
true, false, false, true, true, true,
true, true, false, false, true, true,
true, true, true, false, false, true,
true, true, false, true, true, false,
false, true, true, false, true, true,
true, false, true, true, false, true,
true, true, true, true, true, true
} );
// 准備卷積神經網絡
shared_ptr<XNeuralNetwork> net = make_shared<XNeuralNetwork>( );
net->AddLayer( make_shared<XConvolutionLayer>( 32, 32, 1, 5, 5, 6 ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XAveragePooling>( 28, 28, 6, 2 ) );
net->AddLayer( make_shared<XConvolutionLayer>( 14, 14, 6, 5, 5, 16, connectionTable ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XAveragePooling>( 10, 10, 16, 2 ) );
net->AddLayer( make_shared<XConvolutionLayer>( 5, 5, 16, 5, 5, 120 ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 120, 10 ) );
net->AddLayer( make_shared<XLogSoftMaxActivation>( ) );
從源碼可以清楚看到上面的神經網絡配置是如何轉換成代碼的,只是這個連接表是首次出現的. 這很容易理解,從網絡結構和代碼可以看出,第一層做 6 個卷積,因此生成 6 個特征圖;第二層做 16 個卷積. 在某些情況下,需要配置層的卷積只在輸入特征映射的子集上操作. 如代碼所示,第二層的前 6 個卷積使用第一層生成的 3 個特征圖的不同模式,接下來的 9 個卷積使用 4 個特征圖的不同模式. 最后一個卷積使用第一層的所有 6 個特征映射. 這樣做是為了減少要訓練的參數數量,並確保第二層的不同特征圖不會基於相同的輸入特征圖.
當創建卷積網絡時,可以像處理全連接網絡一樣進行操作:創建一個訓練內容,指定成本函數和權重的優化器,然后全部傳遞給一個助手類,由它運行訓練/驗證循環並測試.
// 生成訓練內容,用到了 Adam 優化器和負對數似然函數(SoftMax)
shared_ptr<XNetworkTraining> netTraining = make_shared<XNetworkTraining>( net,
make_shared<XAdamOptimizer>( 0.002f ),
make_shared<XNegativeLogLikelihoodCost>( ) );
// 使用助手類訓練神經網絡分類
XClassificationTrainingHelper trainingHelper( netTraining, argc, argv );
trainingHelper.SetValidationSamples( validationImages, encodedValidationLabels, validationLabels );
trainingHelper.SetTestSamples( testImages, encodedTestLabels, testLabels );
// 20 世代, 每批 50 樣本
trainingHelper.RunTraining( 20, 50, trainImages, encodedTrainLabels, trainLabels );
下面是輸出,顯示了訓練進度和測試數據集的最終結果分類精度. 可以看到精度達到了 99.01%,比起《前饋》中 96.55% 的精度更准確了.
MNIST handwritten digits classification example with Convolution ANN
Loaded 60000 training data samples
Loaded 10000 test data samples
Samples usage: training = 50000, validation = 10000, test = 10000
Learning rate: 0.0020, Epochs: 20, Batch Size: 50
Before training: accuracy = 5.00% (2500/50000), cost = 2.3175, 34.324s
Epoch 1 : [==================================================] 123.060s
Training accuracy = 97.07% (48536/50000), cost = 0.0878, 32.930s
Validation accuracy = 97.49% (9749/10000), cost = 0.0799, 6.825s
Epoch 2 : [==================================================] 145.140s
Training accuracy = 97.87% (48935/50000), cost = 0.0657, 36.821s
Validation accuracy = 97.94% (9794/10000), cost = 0.0669, 5.939s
...
Epoch 19 : [==================================================] 101.305s
Training accuracy = 99.75% (49877/50000), cost = 0.0077, 26.094s
Validation accuracy = 98.96% (9896/10000), cost = 0.0684, 6.345s
Epoch 20 : [==================================================] 104.519s
Training accuracy = 99.73% (49865/50000), cost = 0.0107, 28.545s
Validation accuracy = 99.02% (9902/10000), cost = 0.0718, 7.885s
Test accuracy = 99.01% (9901/10000), cost = 0.0542, 5.910s
Total time taken : 3187s (53.12min)
CIFAR10 圖片分類
第二個示例對來自 CIFAR-10 數據集的 32×32 彩色圖像進行分類. 這個數據集包含 60000 個圖像,其中 50000 個用於訓練,另外 10000 個用於測試. 圖像分為 10 類:飛機、汽車、鳥、貓、鹿、狗、青蛙、馬、船和卡車. 圖 7 展示了部分內容.

可以看到,CIFAR-10 數據集比 MNIST 手寫數字復雜得多. 首先,圖像是彩色的. 其次,它們不那么明顯. 有些圖如果不經提醒,我都認不出來. 網絡的結構變得更大了,但並不是說它變得更深了,而是執行卷積和訓練權重的數量在增加. 它的網絡結構如下:
Conv(32x32x3, 5x5x32, BorderMode::Same) -> ReLU -> MaxPool -> BatchNorm
Conv(16x16x32, 5x5x32, BorderMode::Same) -> ReLU -> MaxPool -> BatchNorm
Conv(8x8x32, 5x5x64, BorderMode::Same) -> ReLU -> MaxPool -> BatchNorm
FC(1024, 64) -> ReLU -> BatchNorm
FC(64, 10) -> SoftMax
將上述神經網絡結構轉化為代碼,得到以下結果:
// 准備卷積神經網絡
shared_ptr<XNeuralNetwork> net = make_shared<XNeuralNetwork>( );
net->AddLayer( make_shared<XConvolutionLayer>( 32, 32, 3, 5, 5, 32, BorderMode::Same ) );
net->AddLayer( make_shared<XMaxPooling>( 32, 32, 32, 2 ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XBatchNormalization>( 16, 16, 32 ) );
net->AddLayer( make_shared<XConvolutionLayer>( 16, 16, 32, 5, 5, 32, BorderMode::Same ) );
net->AddLayer( make_shared<XMaxPooling>( 16, 16, 32, 2 ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XBatchNormalization>( 8, 8, 32 ) );
net->AddLayer( make_shared<XConvolutionLayer>( 8, 8, 32, 5, 5, 64, BorderMode::Same ) );
net->AddLayer( make_shared<XMaxPooling>( 8, 8, 64, 2 ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XBatchNormalization>( 4, 4, 64 ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 4 * 4 * 64, 64 ) );
net->AddLayer( make_shared<XReLuActivation>( ) );
net->AddLayer( make_shared<XBatchNormalization>( 64, 1, 1 ) );
net->AddLayer( make_shared<XFullyConnectedLayer>( 64, 10 ) );
net->AddLayer( make_shared<XLogSoftMaxActivation>( ) );
剩下部分代碼和前面的例子類似,也是生成訓練內容,傳遞給助手類執行. 下面是這個例子的輸出:
CIFAR-10 dataset classification example with Convolutional ANN
Loaded 50000 training data samples
Loaded 10000 test data samples
Samples usage: training = 43750, validation = 6250, test = 10000
Learning rate: 0.0010, Epochs: 20, Batch Size: 50
Before training: accuracy = 9.91% (4336/43750), cost = 2.3293, 844.825s
Epoch 1 : [==================================================] 1725.516s
Training accuracy = 48.25% (21110/43750), cost = 1.9622, 543.087s
Validation accuracy = 47.46% (2966/6250), cost = 2.0036, 77.284s
Epoch 2 : [==================================================] 1742.268s
Training accuracy = 54.38% (23793/43750), cost = 1.3972, 568.358s
Validation accuracy = 52.93% (3308/6250), cost = 1.4675, 76.287s
...
Epoch 19 : [==================================================] 1642.750s
Training accuracy = 90.34% (39522/43750), cost = 0.2750, 599.431s
Validation accuracy = 69.07% (4317/6250), cost = 1.2472, 81.053s
Epoch 20 : [==================================================] 1708.940s
Training accuracy = 91.27% (39931/43750), cost = 0.2484, 578.551s
Validation accuracy = 69.15% (4322/6250), cost = 1.2735, 81.037s
Test accuracy = 68.34% (6834/10000), cost = 1.3218, 122.455s
Total time taken : 48304s (805.07min)
前面提到了,CIFAR-10 數據集來得更復雜!計算的結果遠遠達不到 MNIST 那樣 99% 的准確度:訓練集的准確度約 91%,測試/驗證的准確度約 68-69%. 就是這樣的精度,區區 20 個世代的計算就花了我 13 個小時!這也說明了,對於卷積網絡來說,(如果不用分布式集群或者超級計算機)普通 PC 僅僅使用 CPU 來計算顯然不夠看.
結論
本文中討論了用 ANNT 庫生成卷積神經網絡. 在這一點上,它只能生成相對簡單的網絡,到目前為止,還不支持生產更高級、更流行的架構. 但是正如 CIFAR-10 一例中看到的,一旦神經網絡變大,就需要更多的計算能力來進行訓練,所以僅僅使用 CPU 是不夠的(目前我只實現了用 CPU 計算網絡). 隨着學習深入,這個弱點還會不斷放大. 所以接下來我會優先研究如何實現 GPU 計算. 至於更復雜的神經網絡架構,先往后放一放.
現在已經討論了全連接和卷積的神經網絡,在接下來的文章里,我將介紹遞歸神經網絡(recurrent neural networks)架構.
如果想關注 ANNT 庫的進展,或者挖掘更多的代碼,可以在 Github 上找到這個項目.
許可
本文以及任何相關的源代碼和文件都是根據 GNU通用公共許可證(GPLv3)授權.
關於作者
Andrew Kirillov,來自英國🇬🇧,目前就職於 IBM.
Yann LeCun,中文名楊立昆,Facebook 首席人工智能專家,人工智能研究院院長,被稱為“深度學習三大巨頭”之一,另外兩位是 Geoffrey Hinton 和 Yoshua Bengio. ↩︎