零基礎(chǔ)入門深度學(xué)習(xí)(3) - 神經(jīng)網(wǎng)絡(luò)和反向傳播算法

往期回顧

在上一篇文章中,我們已經(jīng)掌握了機(jī)器學(xué)習(xí)的基本套路,對(duì)模型、目標(biāo)函數(shù)、優(yōu)化算法這些概念有了一定程度的理解,而且已經(jīng)會(huì)訓(xùn)練單個(gè)的感知器或者線性單元了。在這篇文章中,我們將把這些單獨(dú)的單元按照一定的規(guī)則相互連接在一起形成神經(jīng)網(wǎng)絡(luò),從而奇跡般的獲得了強(qiáng)大的學(xué)習(xí)能力。我們還將介紹這種網(wǎng)絡(luò)的訓(xùn)練算法:反向傳播算法。最后,我們依然用代碼實(shí)現(xiàn)一個(gè)神經(jīng)網(wǎng)絡(luò)。如果您能堅(jiān)持到本文的結(jié)尾,將會(huì)看到我們用自己實(shí)現(xiàn)的神經(jīng)網(wǎng)絡(luò)去識(shí)別手寫數(shù)字?,F(xiàn)在請(qǐng)做好準(zhǔn)備,您即將雙手觸及到深度學(xué)習(xí)的大門。

神經(jīng)元

神經(jīng)元和感知器本質(zhì)上是一樣的,只不過(guò)我們說(shuō)感知器的時(shí)候,它的激活函數(shù)是階躍函數(shù);而當(dāng)我們說(shuō)神經(jīng)元時(shí),激活函數(shù)往往選擇為sigmoid函數(shù)或tanh函數(shù)。如下圖所示:

計(jì)算一個(gè)神經(jīng)元的輸出的方法和計(jì)算一個(gè)感知器的輸出是一樣的。假設(shè)神經(jīng)元的輸入是向量\vec{x},權(quán)重向量是\vec{w}(偏置項(xiàng)是w_0),激活函數(shù)是sigmoid函數(shù),則其輸出y

y=sigmoid(\vec{w}^T\centerdot\vec{x})\qquad(式1)

sigmoid函數(shù)的定義如下:

sigmoid(x)=\frac{1}{1+e^{-x}}

將其帶入前面的式子,得到

y=\frac{1}{1+e^{-\vec{w}^T\centerdot\vec{x}}}

sigmoid函數(shù)是一個(gè)非線性函數(shù),值域是(0,1)。函數(shù)圖像如下圖所示

sigmoid函數(shù)的導(dǎo)數(shù)是:

\begin{align} &令y=sigmoid(x)\\ &則y'=y(1-y) \end{align}

可以看到,sigmoid函數(shù)的導(dǎo)數(shù)非常有趣,它可以用sigmoid函數(shù)自身來(lái)表示。這樣,一旦計(jì)算出sigmoid函數(shù)的值,計(jì)算它的導(dǎo)數(shù)的值就非常方便。

神經(jīng)網(wǎng)絡(luò)是啥

神經(jīng)網(wǎng)絡(luò)其實(shí)就是按照一定規(guī)則連接起來(lái)的多個(gè)神經(jīng)元。上圖展示了一個(gè)全連接(full connected, FC)神經(jīng)網(wǎng)絡(luò),通過(guò)觀察上面的圖,我們可以發(fā)現(xiàn)它的規(guī)則包括:

  • 神經(jīng)元按照來(lái)布局。最左邊的層叫做輸入層,負(fù)責(zé)接收輸入數(shù)據(jù);最右邊的層叫輸出層,我們可以從這層獲取神經(jīng)網(wǎng)絡(luò)輸出數(shù)據(jù)。輸入層和輸出層之間的層叫做隱藏層,因?yàn)樗鼈儗?duì)于外部來(lái)說(shuō)是不可見的。
  • 同一層的神經(jīng)元之間沒(méi)有連接。
  • 第N層的每個(gè)神經(jīng)元和第N-1層的所有神經(jīng)元相連(這就是full connected的含義),第N-1層神經(jīng)元的輸出就是第N層神經(jīng)元的輸入。
  • 每個(gè)連接都有一個(gè)權(quán)值

上面這些規(guī)則定義了全連接神經(jīng)網(wǎng)絡(luò)的結(jié)構(gòu)。事實(shí)上還存在很多其它結(jié)構(gòu)的神經(jīng)網(wǎng)絡(luò),比如卷積神經(jīng)網(wǎng)絡(luò)(CNN)、循環(huán)神經(jīng)網(wǎng)絡(luò)(RNN),他們都具有不同的連接規(guī)則。

計(jì)算神經(jīng)網(wǎng)絡(luò)的輸出

神經(jīng)網(wǎng)絡(luò)實(shí)際上就是一個(gè)輸入向量\vec{x}到輸出向量\vec{y}的函數(shù),即:

\vec{y} = f_{network}(\vec{x})

根據(jù)輸入計(jì)算神經(jīng)網(wǎng)絡(luò)的輸出,需要首先將輸入向量\vec{x}的每個(gè)元素x_i的值賦給神經(jīng)網(wǎng)絡(luò)的輸入層的對(duì)應(yīng)神經(jīng)元,然后根據(jù)式1依次向前計(jì)算每一層的每個(gè)神經(jīng)元的值,直到最后一層輸出層的所有神經(jīng)元的值計(jì)算完畢。最后,將輸出層每個(gè)神經(jīng)元的值串在一起就得到了輸出向量\vec{y}。

接下來(lái)舉一個(gè)例子來(lái)說(shuō)明這個(gè)過(guò)程,我們先給神經(jīng)網(wǎng)絡(luò)的每個(gè)單元寫上編號(hào)。

如上圖,輸入層有三個(gè)節(jié)點(diǎn),我們將其依次編號(hào)為1、2、3;隱藏層的4個(gè)節(jié)點(diǎn),編號(hào)依次為4、5、6、7;最后輸出層的兩個(gè)節(jié)點(diǎn)編號(hào)為8、9。因?yàn)槲覀冞@個(gè)神經(jīng)網(wǎng)絡(luò)是全連接網(wǎng)絡(luò),所以可以看到每個(gè)節(jié)點(diǎn)都和上一層的所有節(jié)點(diǎn)有連接。比如,我們可以看到隱藏層的節(jié)點(diǎn)4,它和輸入層的三個(gè)節(jié)點(diǎn)1、2、3之間都有連接,其連接上的權(quán)重分別為w_{41},w_{42},w_{43}。那么,我們?cè)鯓佑?jì)算節(jié)點(diǎn)4的輸出值a_4呢?

為了計(jì)算節(jié)點(diǎn)4的輸出值,我們必須先得到其所有上游節(jié)點(diǎn)(也就是節(jié)點(diǎn)1、2、3)的輸出值。節(jié)點(diǎn)1、2、3是輸入層的節(jié)點(diǎn),所以,他們的輸出值就是輸入向量\vec{x}本身。按照上圖畫出的對(duì)應(yīng)關(guān)系,可以看到節(jié)點(diǎn)1、2、3的輸出值分別是x_1,x_2,x_3。我們要求輸入向量的維度和輸入層神經(jīng)元個(gè)數(shù)相同,而輸入向量的某個(gè)元素對(duì)應(yīng)到哪個(gè)輸入節(jié)點(diǎn)是可以自由決定的,你偏非要把x_1賦值給節(jié)點(diǎn)2也是完全沒(méi)有問(wèn)題的,但這樣除了把自己弄暈之外,并沒(méi)有什么價(jià)值。

一旦我們有了節(jié)點(diǎn)1、2、3的輸出值,我們就可以根據(jù)式1計(jì)算節(jié)點(diǎn)4的輸出值a_4

\begin{align} a_4&=sigmoid(\vec{w}^T\centerdot\vec{x})\\ &=sigmoid(w_{41}x_1+w_{42}x_2+w_{43}x_3+w_{4b}) \end{align}

上式的w_{4b}是節(jié)點(diǎn)4的偏置項(xiàng),圖中沒(méi)有畫出來(lái)。而w_{41},w_{42},w_{43}分別為節(jié)點(diǎn)1、2、3到節(jié)點(diǎn)4連接的權(quán)重,在給權(quán)重w_{ji}編號(hào)時(shí),我們把目標(biāo)節(jié)點(diǎn)的編號(hào)j放在前面,把源節(jié)點(diǎn)的編號(hào)i放在后面。

同樣,我們可以繼續(xù)計(jì)算出節(jié)點(diǎn)5、6、7的輸出值a_5,a_6,a_7。這樣,隱藏層的4個(gè)節(jié)點(diǎn)的輸出值就計(jì)算完成了,我們就可以接著計(jì)算輸出層的節(jié)點(diǎn)8的輸出值y_1

\begin{align} y_1&=sigmoid(\vec{w}^T\centerdot\vec{x})\\ &=sigmoid(w_{84}a_4+w_{85}a_5+w_{86}a_6+w_{87}a_7+w_{8b}) \end{align}

同理,我們還可以計(jì)算出y_2的值。這樣輸出層所有節(jié)點(diǎn)的輸出值計(jì)算完畢,我們就得到了在輸入向量\vec{x}=\begin{bmatrix}x_1\\x_2\\x_3\end{bmatrix}時(shí),神經(jīng)網(wǎng)絡(luò)的輸出向量\vec{y}=\begin{bmatrix}y_1\\y_2\end{bmatrix}。這里我們也看到,輸出向量的維度和輸出層神經(jīng)元個(gè)數(shù)相同

神經(jīng)網(wǎng)絡(luò)的矩陣表示

神經(jīng)網(wǎng)絡(luò)的計(jì)算如果用矩陣來(lái)表示會(huì)很方便(當(dāng)然逼格也更高),我們先來(lái)看看隱藏層的矩陣表示。

首先我們把隱藏層4個(gè)節(jié)點(diǎn)的計(jì)算依次排列出來(lái):

a_4=sigmoid(w_{41}x_1+w_{42}x_2+w_{43}x_3+w_{4b})\\ a_5=sigmoid(w_{51}x_1+w_{52}x_2+w_{53}x_3+w_{5b})\\ a_6=sigmoid(w_{61}x_1+w_{62}x_2+w_{63}x_3+w_{6b})\\ a_7=sigmoid(w_{71}x_1+w_{72}x_2+w_{73}x_3+w_{7b})\\

接著,定義網(wǎng)絡(luò)的輸入向量\vec{x}和隱藏層每個(gè)節(jié)點(diǎn)的權(quán)重向量\vec{w_j}。令

\begin{align} \vec{x}&=\begin{bmatrix}x_1\\x_2\\x_3\\1\end{bmatrix}\\ \vec{w}_4&=[w_{41},w_{42},w_{43},w_{4b}]\\ \vec{w}_5&=[w_{51},w_{52},w_{53},w_{5b}]\\ \vec{w}_6&=[w_{61},w_{62},w_{63},w_{6b}]\\ \vec{w}_7&=[w_{71},w_{72},w_{73},w_{7b}]\\ f&=sigmoid \end{align}

代入到前面的一組式子,得到:

\begin{align} a_4&=f(\vec{w_4}\centerdot\vec{x})\\ a_5&=f(\vec{w_5}\centerdot\vec{x})\\ a_6&=f(\vec{w_6}\centerdot\vec{x})\\ a_7&=f(\vec{w_7}\centerdot\vec{x}) \end{align}

現(xiàn)在,我們把上述計(jì)算a_4,a_5,a_6,a_7的四個(gè)式子寫到一個(gè)矩陣?yán)锩?,每個(gè)式子作為矩陣的一行,就可以利用矩陣來(lái)表示它們的計(jì)算了。令

\vec{a}= \begin{bmatrix} a_4 \\ a_5 \\ a_6 \\ a_7 \\ \end{bmatrix},\qquad W= \begin{bmatrix} \vec{w}_4 \\ \vec{w}_5 \\ \vec{w}_6 \\ \vec{w}_7 \\ \end{bmatrix}= \begin{bmatrix} w_{41},w_{42},w_{43},w_{4b} \\ w_{51},w_{52},w_{53},w_{5b} \\ w_{61},w_{62},w_{63},w_{6b} \\ w_{71},w_{72},w_{73},w_{7b} \\ \end{bmatrix} ,\qquad f( \begin{bmatrix} x_1\\ x_2\\ x_3\\ .\\ .\\ .\\ \end{bmatrix})= \begin{bmatrix} f(x_1)\\ f(x_2)\\ f(x_3)\\ .\\ .\\ .\\ \end{bmatrix}

帶入前面的一組式子,得到

\vec{a}=f(W\centerdot\vec{x})\qquad (式2)

式2中,f是激活函數(shù),在本例中是sigmoid函數(shù);W是某一層的權(quán)重矩陣;\vec{x}是某層的輸入向量;\vec{a}是某層的輸出向量。式2說(shuō)明神經(jīng)網(wǎng)絡(luò)的每一層的作用實(shí)際上就是先將輸入向量左乘一個(gè)數(shù)組進(jìn)行線性變換,得到一個(gè)新的向量,然后再對(duì)這個(gè)向量逐元素應(yīng)用一個(gè)激活函數(shù)。

每一層的算法都是一樣的。比如,對(duì)于包含一個(gè)輸入層,一個(gè)輸出層和三個(gè)隱藏層的神經(jīng)網(wǎng)絡(luò),我們假設(shè)其權(quán)重矩陣分別為W_1,W_2,W_3,W_4,每個(gè)隱藏層的輸出分別是\vec{a}_1,\vec{a}_2,\vec{a}_3,神經(jīng)網(wǎng)絡(luò)的輸入為\vec{x},神經(jīng)網(wǎng)絡(luò)的輸入為\vec{y},如下圖所示:

則每一層的輸出向量的計(jì)算可以表示為:

\begin{align} &\vec{a}_1=f(W_1\centerdot\vec{x})\\ &\vec{a}_2=f(W_2\centerdot\vec{a}_1)\\ &\vec{a}_3=f(W_3\centerdot\vec{a}_2)\\ &\vec{y}=f(W_4\centerdot\vec{a}_3)\\ \end{align}

這就是神經(jīng)網(wǎng)絡(luò)輸出值的計(jì)算方法。

神經(jīng)網(wǎng)絡(luò)的訓(xùn)練

現(xiàn)在,我們需要知道一個(gè)神經(jīng)網(wǎng)絡(luò)的每個(gè)連接上的權(quán)值是如何得到的。我們可以說(shuō)神經(jīng)網(wǎng)絡(luò)是一個(gè)模型,那么這些權(quán)值就是模型的參數(shù),也就是模型要學(xué)習(xí)的東西。然而,一個(gè)神經(jīng)網(wǎng)絡(luò)的連接方式、網(wǎng)絡(luò)的層數(shù)、每層的節(jié)點(diǎn)數(shù)這些參數(shù),則不是學(xué)習(xí)出來(lái)的,而是人為事先設(shè)置的。對(duì)于這些人為設(shè)置的參數(shù),我們稱之為超參數(shù)(Hyper-Parameters)。

接下來(lái),我們將要介紹神經(jīng)網(wǎng)絡(luò)的訓(xùn)練算法:反向傳播算法。

反向傳播算法(Back Propagation)

我們首先直觀的介紹反向傳播算法,最后再來(lái)介紹這個(gè)算法的推導(dǎo)。當(dāng)然讀者也可以完全跳過(guò)推導(dǎo)部分,因?yàn)榧词共恢廊绾瓮茖?dǎo),也不影響你寫出來(lái)一個(gè)神經(jīng)網(wǎng)絡(luò)的訓(xùn)練代碼。事實(shí)上,現(xiàn)在神經(jīng)網(wǎng)絡(luò)成熟的開源實(shí)現(xiàn)多如牛毛,除了練手之外,你可能都沒(méi)有機(jī)會(huì)需要去寫一個(gè)神經(jīng)網(wǎng)絡(luò)。

我們以監(jiān)督學(xué)習(xí)為例來(lái)解釋反向傳播算法。在零基礎(chǔ)入門深度學(xué)習(xí)(2) - 線性單元和梯度下降一文中我們介紹了什么是監(jiān)督學(xué)習(xí),如果忘記了可以再看一下。另外,我們?cè)O(shè)神經(jīng)元的激活函數(shù)fsigmoid函數(shù)(不同激活函數(shù)的計(jì)算公式不同,詳情見反向傳播算法的推導(dǎo)一節(jié))。

我們假設(shè)每個(gè)訓(xùn)練樣本為(\vec{x},\vec{t}),其中向量\vec{x}是訓(xùn)練樣本的特征,而\vec{t}是樣本的目標(biāo)值。

首先,我們根據(jù)上一節(jié)介紹的算法,用樣本的特征\vec{x},計(jì)算出神經(jīng)網(wǎng)絡(luò)中每個(gè)隱藏層節(jié)點(diǎn)的輸出a_i,以及輸出層每個(gè)節(jié)點(diǎn)的輸出y_i。

然后,我們按照下面的方法計(jì)算出每個(gè)節(jié)點(diǎn)的誤差項(xiàng)\delta_i

  • 對(duì)于輸出層節(jié)點(diǎn)i,

\delta_i=y_i(1-y_i)(t_i-y_i)\qquad(式3)

其中,\delta_i是節(jié)點(diǎn)i的誤差項(xiàng),y_i是節(jié)點(diǎn)i輸出值t_i是樣本對(duì)應(yīng)于節(jié)點(diǎn)i目標(biāo)值。舉個(gè)例子,根據(jù)上圖,對(duì)于輸出層節(jié)點(diǎn)8來(lái)說(shuō),它的輸出值是y_1,而樣本的目標(biāo)值是t_1,帶入上面的公式得到節(jié)點(diǎn)8的誤差項(xiàng)\delta_8應(yīng)該是:

\delta_8=y_1(1-y_1)(t_1-y_1)

  • 對(duì)于隱藏層節(jié)點(diǎn),

\delta_i=a_i(1-a_i)\sum_{k\in{outputs}}w_{ki}\delta_k\qquad(式4)

其中,a_i是節(jié)點(diǎn)i的輸出值,w_{ki}是節(jié)點(diǎn)i到它的下一層節(jié)點(diǎn)k的連接的權(quán)重,\delta_k是節(jié)點(diǎn)i的下一層節(jié)點(diǎn)k的誤差項(xiàng)。例如,對(duì)于隱藏層節(jié)點(diǎn)4來(lái)說(shuō),計(jì)算方法如下:

\delta_4=a_4(1-a_4)(w_{84}\delta_8+w_{94}\delta_9)

最后,更新每個(gè)連接上的權(quán)值:

w_{ji}\gets w_{ji}+\eta\delta_jx_{ji}\qquad(式5)

其中,w_{ji}是節(jié)點(diǎn)i到節(jié)點(diǎn)j的權(quán)重,\eta是一個(gè)成為學(xué)習(xí)速率的常數(shù),\delta_j是節(jié)點(diǎn)j的誤差項(xiàng),x_{ji}是節(jié)點(diǎn)i傳遞給節(jié)點(diǎn)j的輸入。例如,權(quán)重w_84的更新方法如下:

w_{84}\gets w_{84}+\eta\delta_8 a_4

類似的,權(quán)重w_{41}的更新方法如下:

w_{41}\gets w_{41}+\eta\delta_4 x_1

偏置項(xiàng)的輸入值永遠(yuǎn)為1。例如,節(jié)點(diǎn)4的偏置項(xiàng)w_{4b}應(yīng)該按照下面的方法計(jì)算:

w_{4b}\gets w_{4b}+\eta\delta_4

我們已經(jīng)介紹了神經(jīng)網(wǎng)絡(luò)每個(gè)節(jié)點(diǎn)誤差項(xiàng)的計(jì)算和權(quán)重更新方法。顯然,計(jì)算一個(gè)節(jié)點(diǎn)的誤差項(xiàng),需要先計(jì)算每個(gè)與其相連的下一層節(jié)點(diǎn)的誤差項(xiàng)。這就要求誤差項(xiàng)的計(jì)算順序必須是從輸出層開始,然后反向依次計(jì)算每個(gè)隱藏層的誤差項(xiàng),直到與輸入層相連的那個(gè)隱藏層。這就是反向傳播算法的名字的含義。當(dāng)所有節(jié)點(diǎn)的誤差項(xiàng)計(jì)算完畢后,我們就可以根據(jù)式5來(lái)更新所有的權(quán)重。

以上就是基本的反向傳播算法,并不是很復(fù)雜,您弄清楚了么?

<a name="an1"></a>反向傳播算法的推導(dǎo)

反向傳播算法其實(shí)就是鏈?zhǔn)角髮?dǎo)法則的應(yīng)用。然而,這個(gè)如此簡(jiǎn)單且顯而易見的方法,卻是在Roseblatt提出感知器算法將近30年之后才被發(fā)明和普及的。對(duì)此,Bengio這樣回應(yīng)道:

很多看似顯而易見的想法只有在事后才變得顯而易見。

接下來(lái),我們用鏈?zhǔn)角髮?dǎo)法則來(lái)推導(dǎo)反向傳播算法,也就是上一小節(jié)的式3、式4、式5

前方高能預(yù)警——接下來(lái)是數(shù)學(xué)公式重災(zāi)區(qū),讀者可以酌情閱讀,不必強(qiáng)求。

按照機(jī)器學(xué)習(xí)的通用套路,我們先確定神經(jīng)網(wǎng)絡(luò)的目標(biāo)函數(shù),然后用隨機(jī)梯度下降優(yōu)化算法去求目標(biāo)函數(shù)最小值時(shí)的參數(shù)值。

我們?nèi)【W(wǎng)絡(luò)所有輸出層節(jié)點(diǎn)的誤差平方和作為目標(biāo)函數(shù):

E_d\equiv\frac{1}{2}\sum_{i\in outputs}(t_i-y_i)^2

其中,E_d表示是樣本d的誤差。

然后,我們用文章零基礎(chǔ)入門深度學(xué)習(xí)(2) - 線性單元和梯度下降中介紹的隨機(jī)梯度下降算法對(duì)目標(biāo)函數(shù)進(jìn)行優(yōu)化:

w_{ji}\gets w_{ji}-\eta\frac{\partial{E_d}}{\partial{w_{ji}}}

隨機(jī)梯度下降算法也就是需要求出誤差E_d對(duì)于每個(gè)權(quán)重w_{ji}的偏導(dǎo)數(shù)(也就是梯度),怎么求呢?

觀察上圖,我們發(fā)現(xiàn)權(quán)重w_{ji}僅能通過(guò)影響節(jié)點(diǎn)j的輸入值影響網(wǎng)絡(luò)的其它部分,設(shè)net_j是節(jié)點(diǎn)j加權(quán)輸入,即

\begin{align} net_j&=\vec{w_j}\centerdot\vec{x_j}\\ &=\sum_{i}{w_{ji}}x_{ji} \end{align}

E_dnet_j的函數(shù),而net_jw_{ji}的函數(shù)。根據(jù)鏈?zhǔn)角髮?dǎo)法則,可以得到:

\begin{align} \frac{\partial{E_d}}{\partial{w_{ji}}}&=\frac{\partial{E_d}}{\partial{net_j}}\frac{\partial{net_j}}{\partial{w_{ji}}}\\ &=\frac{\partial{E_d}}{\partial{net_j}}\frac{\partial{\sum_{i}{w_{ji}}x_{ji}}}{\partial{w_{ji}}}\\ &=\frac{\partial{E_d}}{\partial{net_j}}x_{ji} \end{align}
上式中,x_{ji}是節(jié)點(diǎn)i傳遞給節(jié)點(diǎn)j的輸入值,也就是節(jié)點(diǎn)i的輸出值。

對(duì)于\frac{\partial{E_d}}{\partial{net_j}}的推導(dǎo),需要區(qū)分輸出層隱藏層兩種情況。

輸出層權(quán)值訓(xùn)練

對(duì)于輸出層來(lái)說(shuō),net_j僅能通過(guò)節(jié)點(diǎn)j的輸出值y_j來(lái)影響網(wǎng)絡(luò)其它部分,也就是說(shuō)E_dy_j的函數(shù),而y_jnet_j的函數(shù),其中y_j=sigmoid(net_j)。所以我們可以再次使用鏈?zhǔn)角髮?dǎo)法則:

\begin{align} \frac{\partial{E_d}}{\partial{net_j}}&=\frac{\partial{E_d}}{\partial{y_j}}\frac{\partial{y_j}}{\partial{net_j}}\\ \end{align}

考慮上式第一項(xiàng):

\begin{align} \frac{\partial{E_d}}{\partial{y_j}}&=\frac{\partial}{\partial{y_j}}\frac{1}{2}\sum_{i\in outputs}(t_i-y_i)^2\\ &=\frac{\partial}{\partial{y_j}}\frac{1}{2}(t_j-y_j)^2\\ &=-(t_j-y_j) \end{align}

考慮上式第二項(xiàng):

\begin{align} \frac{\partial{y_j}}{\partial{net_j}}&=\frac{\partial sigmoid(net_j)}{\partial{net_j}}\\ &=y_j(1-y_j)\\ \end{align}

將第一項(xiàng)和第二項(xiàng)帶入,得到:

\frac{\partial{E_d}}{\partial{net_j}}=-(t_j-y_j)y_j(1-y_j)

如果令\delta_j=-\frac{\partial{E_d}}{\partial{net_j}},也就是一個(gè)節(jié)點(diǎn)的誤差項(xiàng)\delta是網(wǎng)絡(luò)誤差對(duì)這個(gè)節(jié)點(diǎn)輸入的偏導(dǎo)數(shù)的相反數(shù)。帶入上式,得到:

\delta_j=(t_j-y_j)y_j(1-y_j)

上式就是式3。

將上述推導(dǎo)帶入隨機(jī)梯度下降公式,得到:

\begin{align} w_{ji}&\gets w_{ji}-\eta\frac{\partial{E_d}}{\partial{w_{ji}}}\\ &=w_{ji}+\eta(t_j-y_j)y_j(1-y_j)x_{ji}\\ &=w_{ji}+\eta\delta_jx_{ji} \end{align}

上式就是式5。

隱藏層權(quán)值訓(xùn)練

現(xiàn)在我們要推導(dǎo)出隱藏層的\frac{\partial{E_d}}{\partial{net_j}}。

首先,我們需要定義節(jié)點(diǎn)j的所有直接下游節(jié)點(diǎn)的集合Downstream(j)。例如,對(duì)于節(jié)點(diǎn)4來(lái)說(shuō),它的直接下游節(jié)點(diǎn)是節(jié)點(diǎn)8、節(jié)點(diǎn)9??梢钥吹?img class="math-inline" src="https://math.jianshu.com/math?formula=net_j" alt="net_j" mathimg="1">只能通過(guò)影響Downstream(j)再影響E_d。設(shè)net_k是節(jié)點(diǎn)j的下游節(jié)點(diǎn)的輸入,則E_dnet_k的函數(shù),而net_knet_j的函數(shù)。因?yàn)?img class="math-inline" src="https://math.jianshu.com/math?formula=net_k" alt="net_k" mathimg="1">有多個(gè),我們應(yīng)用全導(dǎo)數(shù)公式,可以做出如下推導(dǎo):

\begin{align} \frac{\partial{E_d}}{\partial{net_j}}&=\sum_{k\in Downstream(j)}\frac{\partial{E_d}}{\partial{net_k}}\frac{\partial{net_k}}{\partial{net_j}}\\ &=\sum_{k\in Downstream(j)}-\delta_k\frac{\partial{net_k}}{\partial{net_j}}\\ &=\sum_{k\in Downstream(j)}-\delta_k\frac{\partial{net_k}}{\partial{a_j}}\frac{\partial{a_j}}{\partial{net_j}}\\ &=\sum_{k\in Downstream(j)}-\delta_kw_{kj}\frac{\partial{a_j}}{\partial{net_j}}\\ &=\sum_{k\in Downstream(j)}-\delta_kw_{kj}a_j(1-a_j)\\ &=-a_j(1-a_j)\sum_{k\in Downstream(j)}\delta_kw_{kj} \end{align}

因?yàn)?img class="math-inline" src="https://math.jianshu.com/math?formula=%5Cdelta_j%3D-%5Cfrac%7B%5Cpartial%7BE_d%7D%7D%7B%5Cpartial%7Bnet_j%7D%7D" alt="\delta_j=-\frac{\partial{E_d}}{\partial{net_j}}" mathimg="1">,帶入上式得到:

\delta_j=a_j(1-a_j)\sum_{k\in Downstream(j)}\delta_kw_{kj}

上式就是式4。

——數(shù)學(xué)公式警報(bào)解除——

至此,我們已經(jīng)推導(dǎo)出了反向傳播算法。需要注意的是,我們剛剛推導(dǎo)出的訓(xùn)練規(guī)則是根據(jù)激活函數(shù)是sigmoid函數(shù)、平方和誤差、全連接網(wǎng)絡(luò)、隨機(jī)梯度下降優(yōu)化算法。如果激活函數(shù)不同、誤差計(jì)算方式不同、網(wǎng)絡(luò)連接結(jié)構(gòu)不同、優(yōu)化算法不同,則具體的訓(xùn)練規(guī)則也會(huì)不一樣。但是無(wú)論怎樣,訓(xùn)練規(guī)則的推導(dǎo)方式都是一樣的,應(yīng)用鏈?zhǔn)角髮?dǎo)法則進(jìn)行推導(dǎo)即可。

神經(jīng)網(wǎng)絡(luò)的實(shí)現(xiàn)

現(xiàn)在,我們要根據(jù)前面的算法,實(shí)現(xiàn)一個(gè)基本的全連接神經(jīng)網(wǎng)絡(luò),這并不需要太多代碼。我們?cè)谶@里依然采用面向?qū)ο笤O(shè)計(jì)。

首先,我們先做一個(gè)基本的模型:

如上圖,可以分解出5個(gè)領(lǐng)域?qū)ο髞?lái)實(shí)現(xiàn)神經(jīng)網(wǎng)絡(luò):

  • Network 神經(jīng)網(wǎng)絡(luò)對(duì)象,提供API接口。它由若干層對(duì)象組成以及連接對(duì)象組成。
  • Layer 層對(duì)象,由多個(gè)節(jié)點(diǎn)組成。
  • Node 節(jié)點(diǎn)對(duì)象計(jì)算和記錄節(jié)點(diǎn)自身的信息(比如輸出值a、誤差項(xiàng)\delta等),以及與這個(gè)節(jié)點(diǎn)相關(guān)的上下游的連接。
  • Connection 每個(gè)連接對(duì)象都要記錄該連接的權(quán)重。
  • Connections 僅僅作為Connection的集合對(duì)象,提供一些集合操作。

Node實(shí)現(xiàn)如下:

# 節(jié)點(diǎn)類,負(fù)責(zé)記錄和維護(hù)節(jié)點(diǎn)自身信息以及與這個(gè)節(jié)點(diǎn)相關(guān)的上下游連接,實(shí)現(xiàn)輸出值和誤差項(xiàng)的計(jì)算。
class Node(object):
    def __init__(self, layer_index, node_index):
        '''
        構(gòu)造節(jié)點(diǎn)對(duì)象。
        layer_index: 節(jié)點(diǎn)所屬的層的編號(hào)
        node_index: 節(jié)點(diǎn)的編號(hào)
        '''
        self.layer_index = layer_index
        self.node_index = node_index
        self.downstream = []
        self.upstream = []
        self.output = 0
        self.delta = 0

    def set_output(self, output):
        '''
        設(shè)置節(jié)點(diǎn)的輸出值。如果節(jié)點(diǎn)屬于輸入層會(huì)用到這個(gè)函數(shù)。
        '''
        self.output = output

    def append_downstream_connection(self, conn):
        '''
        添加一個(gè)到下游節(jié)點(diǎn)的連接
        '''
        self.downstream.append(conn)

    def append_upstream_connection(self, conn):
        '''
        添加一個(gè)到上游節(jié)點(diǎn)的連接
        '''
        self.upstream.append(conn)

    def calc_output(self):
        '''
        根據(jù)式1計(jì)算節(jié)點(diǎn)的輸出
        '''
        output = reduce(lambda ret, conn: ret + conn.upstream_node.output * conn.weight, self.upstream, 0)
        self.output = sigmoid(output)

    def calc_hidden_layer_delta(self):
        '''
        節(jié)點(diǎn)屬于隱藏層時(shí),根據(jù)式4計(jì)算delta
        '''
        downstream_delta = reduce(
            lambda ret, conn: ret + conn.downstream_node.delta * conn.weight,
            self.downstream, 0.0)
        self.delta = self.output * (1 - self.output) * downstream_delta

    def calc_output_layer_delta(self, label):
        '''
        節(jié)點(diǎn)屬于輸出層時(shí),根據(jù)式3計(jì)算delta
        '''
        self.delta = self.output * (1 - self.output) * (label - self.output)

    def __str__(self):
        '''
        打印節(jié)點(diǎn)的信息
        '''
        node_str = '%u-%u: output: %f delta: %f' % (self.layer_index, self.node_index, self.output, self.delta)
        downstream_str = reduce(lambda ret, conn: ret + '\n\t' + str(conn), self.downstream, '')
        upstream_str = reduce(lambda ret, conn: ret + '\n\t' + str(conn), self.upstream, '')
        return node_str + '\n\tdownstream:' + downstream_str + '\n\tupstream:' + upstream_str 

ConstNode對(duì)象,為了實(shí)現(xiàn)一個(gè)輸出恒為1的節(jié)點(diǎn)(計(jì)算偏置項(xiàng)w_b時(shí)需要)

class ConstNode(object):
    def __init__(self, layer_index, node_index):
        '''
        構(gòu)造節(jié)點(diǎn)對(duì)象。
        layer_index: 節(jié)點(diǎn)所屬的層的編號(hào)
        node_index: 節(jié)點(diǎn)的編號(hào)
        '''    
        self.layer_index = layer_index
        self.node_index = node_index
        self.downstream = []
        self.output = 1

    def append_downstream_connection(self, conn):
        '''
        添加一個(gè)到下游節(jié)點(diǎn)的連接
        '''       
        self.downstream.append(conn)

    def calc_hidden_layer_delta(self):
        '''
        節(jié)點(diǎn)屬于隱藏層時(shí),根據(jù)式4計(jì)算delta
        '''
        downstream_delta = reduce(
            lambda ret, conn: ret + conn.downstream_node.delta * conn.weight,
            self.downstream, 0.0)
        self.delta = self.output * (1 - self.output) * downstream_delta

    def __str__(self):
        '''
        打印節(jié)點(diǎn)的信息
        '''
        node_str = '%u-%u: output: 1' % (self.layer_index, self.node_index)
        downstream_str = reduce(lambda ret, conn: ret + '\n\t' + str(conn), self.downstream, '')
        return node_str + '\n\tdownstream:' + downstream_str

Layer對(duì)象,負(fù)責(zé)初始化一層。此外,作為Node的集合對(duì)象,提供對(duì)Node集合的操作。

class Layer(object):
    def __init__(self, layer_index, node_count):
        '''
        初始化一層
        layer_index: 層編號(hào)
        node_count: 層所包含的節(jié)點(diǎn)個(gè)數(shù)
        '''
        self.layer_index = layer_index
        self.nodes = []
        for i in range(node_count):
            self.nodes.append(Node(layer_index, i))
        self.nodes.append(ConstNode(layer_index, node_count))

    def set_output(self, data):
        '''
        設(shè)置層的輸出。當(dāng)層是輸入層時(shí)會(huì)用到。
        '''
        for i in range(len(data)):
            self.nodes[i].set_output(data[i])

    def calc_output(self):
        '''
        計(jì)算層的輸出向量
        '''
        for node in self.nodes[:-1]:
            node.calc_output()

    def dump(self):
        '''
        打印層的信息
        '''
        for node in self.nodes:
            print node

Connection對(duì)象,主要職責(zé)是記錄連接的權(quán)重,以及這個(gè)連接所關(guān)聯(lián)的上下游節(jié)點(diǎn)。

class Connection(object):
    def __init__(self, upstream_node, downstream_node):
        '''
        初始化連接,權(quán)重初始化為是一個(gè)很小的隨機(jī)數(shù)
        upstream_node: 連接的上游節(jié)點(diǎn)
        downstream_node: 連接的下游節(jié)點(diǎn)
        '''
        self.upstream_node = upstream_node
        self.downstream_node = downstream_node
        self.weight = random.uniform(-0.1, 0.1)
        self.gradient = 0.0

    def calc_gradient(self):
        '''
        計(jì)算梯度
        '''
        self.gradient = self.downstream_node.delta * self.upstream_node.output

    def get_gradient(self):
        '''
        獲取當(dāng)前的梯度
        '''
        return self.gradient

    def update_weight(self, rate):
        '''
        根據(jù)梯度下降算法更新權(quán)重
        '''
        self.calc_gradient()
        self.weight += rate * self.gradient

    def __str__(self):
        '''
        打印連接信息
        '''
        return '(%u-%u) -> (%u-%u) = %f' % (
            self.upstream_node.layer_index, 
            self.upstream_node.node_index,
            self.downstream_node.layer_index, 
            self.downstream_node.node_index, 
            self.weight)

Connections對(duì)象,提供Connection集合操作。

class Connections(object):
    def __init__(self):
        self.connections = []

    def add_connection(self, connection):
        self.connections.append(connection)

    def dump(self):
        for conn in self.connections:
            print conn

Network對(duì)象,提供API。

class Network(object):
    def __init__(self, layers):
        '''
        初始化一個(gè)全連接神經(jīng)網(wǎng)絡(luò)
        layers: 二維數(shù)組,描述神經(jīng)網(wǎng)絡(luò)每層節(jié)點(diǎn)數(shù)
        '''
        self.connections = Connections()
        self.layers = []
        layer_count = len(layers)
        node_count = 0;
        for i in range(layer_count):
            self.layers.append(Layer(i, layers[i]))
        for layer in range(layer_count - 1):
            connections = [Connection(upstream_node, downstream_node) 
                           for upstream_node in self.layers[layer].nodes
                           for downstream_node in self.layers[layer + 1].nodes[:-1]]
            for conn in connections:
                self.connections.add_connection(conn)
                conn.downstream_node.append_upstream_connection(conn)
                conn.upstream_node.append_downstream_connection(conn)


    def train(self, labels, data_set, rate, iteration):
        '''
        訓(xùn)練神經(jīng)網(wǎng)絡(luò)
        labels: 數(shù)組,訓(xùn)練樣本標(biāo)簽。每個(gè)元素是一個(gè)樣本的標(biāo)簽。
        data_set: 二維數(shù)組,訓(xùn)練樣本特征。每個(gè)元素是一個(gè)樣本的特征。
        '''
        for i in range(iteration):
            for d in range(len(data_set)):
                self.train_one_sample(labels[d], data_set[d], rate)

    def train_one_sample(self, label, sample, rate):
        '''
        內(nèi)部函數(shù),用一個(gè)樣本訓(xùn)練網(wǎng)絡(luò)
        '''
        self.predict(sample)
        self.calc_delta(label)
        self.update_weight(rate)

    def calc_delta(self, label):
        '''
        內(nèi)部函數(shù),計(jì)算每個(gè)節(jié)點(diǎn)的delta
        '''
        output_nodes = self.layers[-1].nodes
        for i in range(len(label)):
            output_nodes[i].calc_output_layer_delta(label[i])
        for layer in self.layers[-2::-1]:
            for node in layer.nodes:
                node.calc_hidden_layer_delta()

    def update_weight(self, rate):
        '''
        內(nèi)部函數(shù),更新每個(gè)連接權(quán)重
        '''
        for layer in self.layers[:-1]:
            for node in layer.nodes:
                for conn in node.downstream:
                    conn.update_weight(rate)

    def calc_gradient(self):
        '''
        內(nèi)部函數(shù),計(jì)算每個(gè)連接的梯度
        '''
        for layer in self.layers[:-1]:
            for node in layer.nodes:
                for conn in node.downstream:
                    conn.calc_gradient()

    def get_gradient(self, label, sample):
        '''
        獲得網(wǎng)絡(luò)在一個(gè)樣本下,每個(gè)連接上的梯度
        label: 樣本標(biāo)簽
        sample: 樣本輸入
        '''
        self.predict(sample)
        self.calc_delta(label)
        self.calc_gradient()

    def predict(self, sample):
        '''
        根據(jù)輸入的樣本預(yù)測(cè)輸出值
        sample: 數(shù)組,樣本的特征,也就是網(wǎng)絡(luò)的輸入向量
        '''
        self.layers[0].set_output(sample)
        for i in range(1, len(self.layers)):
            self.layers[i].calc_output()
        return map(lambda node: node.output, self.layers[-1].nodes[:-1])

    def dump(self):
        '''
        打印網(wǎng)絡(luò)信息
        '''
        for layer in self.layers:
            layer.dump()

至此,實(shí)現(xiàn)了一個(gè)基本的全連接神經(jīng)網(wǎng)絡(luò)。可以看到,同神經(jīng)網(wǎng)絡(luò)的強(qiáng)大學(xué)習(xí)能力相比,其實(shí)現(xiàn)還算是很容易的。

梯度檢查

怎么保證自己寫的神經(jīng)網(wǎng)絡(luò)沒(méi)有BUG呢?事實(shí)上這是一個(gè)非常重要的問(wèn)題。一方面,千辛萬(wàn)苦想到一個(gè)算法,結(jié)果效果不理想,那么是算法本身錯(cuò)了還是代碼實(shí)現(xiàn)錯(cuò)了呢?定位這種問(wèn)題肯定要花費(fèi)大量的時(shí)間和精力。另一方面,由于神經(jīng)網(wǎng)絡(luò)的復(fù)雜性,我們幾乎無(wú)法事先知道神經(jīng)網(wǎng)絡(luò)的輸入和輸出,因此類似TDD(測(cè)試驅(qū)動(dòng)開發(fā))這樣的開發(fā)方法似乎也不可行。

辦法還是有滴,就是利用梯度檢查來(lái)確認(rèn)程序是否正確。梯度檢查的思路如下:

對(duì)于梯度下降算法:

w_{ji}\gets w_{ji}-\eta\frac{\partial{E_d}}{\partial{w_{ji}}}

來(lái)說(shuō),這里關(guān)鍵之處在于\frac{\partial{E_d}}{\partial{w_{ji}}}的計(jì)算一定要正確,而它是E_d對(duì)w_{ji}偏導(dǎo)數(shù)。而根據(jù)導(dǎo)數(shù)的定義:

f'(\theta)=\lim_{\epsilon->0}\frac{f(\theta+\epsilon)-f(\theta-\epsilon)}{2\epsilon}

對(duì)于任意\theta的導(dǎo)數(shù)值,我們都可以用等式右邊來(lái)近似計(jì)算。我們把E_d看做是w_{ji}的函數(shù),即E_d(w_{ji}),那么根據(jù)導(dǎo)數(shù)定義,\frac{\partial{E_d(w_{ji})}}{\partial{w_{ji}}}應(yīng)該等于:

\frac{\partial{E_d(w_{ji})}}{\partial{w_{ji}}}=\lim_{\epsilon->0}\frac{f(w_{ji}+\epsilon)-f(w_{ji}-\epsilon)}{2\epsilon}

如果把\epsilon設(shè)置為一個(gè)很小的數(shù)(比如10^{-4}),那么上式可以寫成:

\frac{\partial{E_d(w_{ji})}}{\partial{w_{ji}}}\approx\frac{f(w_{ji}+\epsilon)-f(w_{ji}-\epsilon)}{2\epsilon}\qquad(式6)

我們就可以利用式6,來(lái)計(jì)算梯度\frac{\partial{E_d}}{\partial{w_{ji}}}的值,然后同我們神經(jīng)網(wǎng)絡(luò)代碼中計(jì)算出來(lái)的梯度值進(jìn)行比較。如果兩者的差別非常的小,那么就說(shuō)明我們的代碼是正確的。

下面是梯度檢查的代碼。如果我們想檢查參數(shù)w_{ji}的梯度是否正確,我們需要以下幾個(gè)步驟:

  1. 首先使用一個(gè)樣本d對(duì)神經(jīng)網(wǎng)絡(luò)進(jìn)行訓(xùn)練,這樣就能獲得每個(gè)權(quán)重的梯度。
  2. w_{ji}加上一個(gè)很小的值(10^{-4}),重新計(jì)算神經(jīng)網(wǎng)絡(luò)在這個(gè)樣本d下的E_{d+}。
  3. w_{ji}減上一個(gè)很小的值(10^{-4}),重新計(jì)算神經(jīng)網(wǎng)絡(luò)在這個(gè)樣本d下的E_{d-}
  4. 根據(jù)式6計(jì)算出期望的梯度值,和第一步獲得的梯度值進(jìn)行比較,它們應(yīng)該幾乎想等(至少4位有效數(shù)字相同)。

當(dāng)然,我們可以重復(fù)上面的過(guò)程,對(duì)每個(gè)權(quán)重w_{ji}都進(jìn)行檢查。也可以使用多個(gè)樣本重復(fù)檢查。

def gradient_check(network, sample_feature, sample_label):
    '''
    梯度檢查
    network: 神經(jīng)網(wǎng)絡(luò)對(duì)象
    sample_feature: 樣本的特征
    sample_label: 樣本的標(biāo)簽
    '''
    # 計(jì)算網(wǎng)絡(luò)誤差
    network_error = lambda vec1, vec2: \
            0.5 * reduce(lambda a, b: a + b, 
                      map(lambda v: (v[0] - v[1]) * (v[0] - v[1]),
                          zip(vec1, vec2)))

    # 獲取網(wǎng)絡(luò)在當(dāng)前樣本下每個(gè)連接的梯度
    network.get_gradient(sample_feature, sample_label)

    # 對(duì)每個(gè)權(quán)重做梯度檢查    
    for conn in network.connections.connections: 
        # 獲取指定連接的梯度
        actual_gradient = conn.get_gradient()
    
        # 增加一個(gè)很小的值,計(jì)算網(wǎng)絡(luò)的誤差
        epsilon = 0.0001
        conn.weight += epsilon
        error1 = network_error(network.predict(sample_feature), sample_label)
    
        # 減去一個(gè)很小的值,計(jì)算網(wǎng)絡(luò)的誤差
        conn.weight -= 2 * epsilon # 剛才加過(guò)了一次,因此這里需要減去2倍
        error2 = network_error(network.predict(sample_feature), sample_label)
    
        # 根據(jù)式6計(jì)算期望的梯度值
        expected_gradient = (error2 - error1) / (2 * epsilon)
    
        # 打印
        print 'expected gradient: \t%f\nactual gradient: \t%f' % (
            expected_gradient, actual_gradient)

至此,會(huì)推導(dǎo)、會(huì)實(shí)現(xiàn)、會(huì)抓BUG,你已經(jīng)摸到深度學(xué)習(xí)的大門了。接下來(lái)還需要不斷的實(shí)踐,我們用剛剛寫過(guò)的神經(jīng)網(wǎng)絡(luò)去識(shí)別手寫數(shù)字。

神經(jīng)網(wǎng)絡(luò)實(shí)戰(zhàn)——手寫數(shù)字識(shí)別

針對(duì)這個(gè)任務(wù),我們采用業(yè)界非常流行的MNIST數(shù)據(jù)集。MNIST大約有60000個(gè)手寫字母的訓(xùn)練樣本,我們使用它訓(xùn)練我們的神經(jīng)網(wǎng)絡(luò),然后再用訓(xùn)練好的網(wǎng)絡(luò)去識(shí)別手寫數(shù)字。

手寫數(shù)字識(shí)別是個(gè)比較簡(jiǎn)單的任務(wù),數(shù)字只可能是0-9中的一個(gè),這是個(gè)10分類問(wèn)題。

超參數(shù)的確定

我們首先需要確定網(wǎng)絡(luò)的層數(shù)和每層的節(jié)點(diǎn)數(shù)。關(guān)于第一個(gè)問(wèn)題,實(shí)際上并沒(méi)有什么理論化的方法,大家都是根據(jù)經(jīng)驗(yàn)來(lái)拍,如果沒(méi)有經(jīng)驗(yàn)的話就隨便拍一個(gè)。然后,你可以多試幾個(gè)值,訓(xùn)練不同層數(shù)的神經(jīng)網(wǎng)絡(luò),看看哪個(gè)效果最好就用哪個(gè)。嗯,現(xiàn)在你可能明白為什么說(shuō)深度學(xué)習(xí)是個(gè)手藝活了,有些手藝很讓人無(wú)語(yǔ),而有些手藝還是很有技術(shù)含量的。

不過(guò),有些基本道理我們還是明白的,我們知道網(wǎng)絡(luò)層數(shù)越多越好,也知道層數(shù)越多訓(xùn)練難度越大。對(duì)于全連接網(wǎng)絡(luò),隱藏層最好不要超過(guò)三層。那么,我們可以先試試僅有一個(gè)隱藏層的神經(jīng)網(wǎng)絡(luò)效果怎么樣。畢竟模型小的話,訓(xùn)練起來(lái)也快些(剛開始玩模型的時(shí)候,都希望快點(diǎn)看到結(jié)果)。

輸入層節(jié)點(diǎn)數(shù)是確定的。因?yàn)镸NIST數(shù)據(jù)集每個(gè)訓(xùn)練數(shù)據(jù)是28*28的圖片,共784個(gè)像素,因此,輸入層節(jié)點(diǎn)數(shù)應(yīng)該是784,每個(gè)像素對(duì)應(yīng)一個(gè)輸入節(jié)點(diǎn)。

輸出層節(jié)點(diǎn)數(shù)也是確定的。因?yàn)槭?0分類,我們可以用10個(gè)節(jié)點(diǎn),每個(gè)節(jié)點(diǎn)對(duì)應(yīng)一個(gè)分類。輸出層10個(gè)節(jié)點(diǎn)中,輸出最大值的那個(gè)節(jié)點(diǎn)對(duì)應(yīng)的分類,就是模型的預(yù)測(cè)結(jié)果。

隱藏層節(jié)點(diǎn)數(shù)量是不好確定的,從1到100萬(wàn)都可以。下面有幾個(gè)經(jīng)驗(yàn)公式:

\begin{align} &m=\sqrt{n+l}+\alpha\\ &m=log_2n\\ &m=\sqrt{nl}\\ &m:隱藏層節(jié)點(diǎn)數(shù)\\ &n:輸入層節(jié)點(diǎn)數(shù)\\ &l:輸出層節(jié)點(diǎn)數(shù)\\ &\alpha:1到10之間的常數(shù) \end{align}

因此,我們可以先根據(jù)上面的公式設(shè)置一個(gè)隱藏層節(jié)點(diǎn)數(shù)。如果有時(shí)間,我們可以設(shè)置不同的節(jié)點(diǎn)數(shù),分別訓(xùn)練,看看哪個(gè)效果最好就用哪個(gè)。我們先拍一個(gè),設(shè)隱藏層節(jié)點(diǎn)數(shù)為300吧。

對(duì)于3層784*300*10的全連接網(wǎng)絡(luò),總共有300*(784+1)+10*(300+1)=238510個(gè)參數(shù)!神經(jīng)網(wǎng)絡(luò)之所以強(qiáng)大,是它提供了一種非常簡(jiǎn)單的方法去實(shí)現(xiàn)大量的參數(shù)。目前百億參數(shù)、千億樣本的超大規(guī)模神經(jīng)網(wǎng)絡(luò)也是有的。因?yàn)镸NIST只有6萬(wàn)個(gè)訓(xùn)練樣本,參數(shù)太多了很容易過(guò)擬合,效果反而不好。

模型的訓(xùn)練和評(píng)估

MNIST數(shù)據(jù)集包含10000個(gè)測(cè)試樣本。我們先用60000個(gè)訓(xùn)練樣本訓(xùn)練我們的網(wǎng)絡(luò),然后再用測(cè)試樣本對(duì)網(wǎng)絡(luò)進(jìn)行測(cè)試,計(jì)算識(shí)別錯(cuò)誤率:

錯(cuò)誤率=\frac{錯(cuò)誤預(yù)測(cè)樣本數(shù)}{總樣本數(shù)}

我們每訓(xùn)練10輪,評(píng)估一次準(zhǔn)確率。當(dāng)準(zhǔn)確率開始下降時(shí)(出現(xiàn)了過(guò)擬合)終止訓(xùn)練。

代碼實(shí)現(xiàn)

首先,我們需要把MNIST數(shù)據(jù)集處理為神經(jīng)網(wǎng)絡(luò)能夠接受的形式。MNIST訓(xùn)練集的文件格式可以參考官方網(wǎng)站,這里不在贅述。每個(gè)訓(xùn)練樣本是一個(gè)28*28的圖像,我們按照行優(yōu)先,把它轉(zhuǎn)化為一個(gè)784維的向量。每個(gè)標(biāo)簽是0-9的值,我們將其轉(zhuǎn)換為一個(gè)10維的one-hot向量:如果標(biāo)簽值為n,我們就把向量的第n維(從0開始編號(hào))設(shè)置為0.9,而其它維設(shè)置為0.1。例如,向量[0.1,0.1,0.9,0.1,0.1,0.1,0.1,0.1,0.1,0.1]表示值2。

下面是處理MNIST數(shù)據(jù)的代碼:

#!/usr/bin/env python
# -*- coding: UTF-8 -*-

import struct
from bp import *
from datetime import datetime


# 數(shù)據(jù)加載器基類
class Loader(object):
    def __init__(self, path, count):
        '''
        初始化加載器
        path: 數(shù)據(jù)文件路徑
        count: 文件中的樣本個(gè)數(shù)
        '''
        self.path = path
        self.count = count

    def get_file_content(self):
        '''
        讀取文件內(nèi)容
        '''
        f = open(self.path, 'rb')
        content = f.read()
        f.close()
        return content

    def to_int(self, byte):
        '''
        將unsigned byte字符轉(zhuǎn)換為整數(shù)
        '''
        return struct.unpack('B', byte)[0]


# 圖像數(shù)據(jù)加載器
class ImageLoader(Loader):
    def get_picture(self, content, index):
        '''
        內(nèi)部函數(shù),從文件中獲取圖像
        '''
        start = index * 28 * 28 + 16
        picture = []
        for i in range(28):
            picture.append([])
            for j in range(28):
                picture[i].append(
                    self.to_int(content[start + i * 28 + j]))
        return picture

    def get_one_sample(self, picture):
        '''
        內(nèi)部函數(shù),將圖像轉(zhuǎn)化為樣本的輸入向量
        '''
        sample = []
        for i in range(28):
            for j in range(28):
                sample.append(picture[i][j])
        return sample

    def load(self):
        '''
        加載數(shù)據(jù)文件,獲得全部樣本的輸入向量
        '''
        content = self.get_file_content()
        data_set = []
        for index in range(self.count):
            data_set.append(
                self.get_one_sample(
                    self.get_picture(content, index)))
        return data_set


# 標(biāo)簽數(shù)據(jù)加載器
class LabelLoader(Loader):
    def load(self):
        '''
        加載數(shù)據(jù)文件,獲得全部樣本的標(biāo)簽向量
        '''
        content = self.get_file_content()
        labels = []
        for index in range(self.count):
            labels.append(self.norm(content[index + 8]))
        return labels

    def norm(self, label):
        '''
        內(nèi)部函數(shù),將一個(gè)值轉(zhuǎn)換為10維標(biāo)簽向量
        '''
        label_vec = []
        label_value = self.to_int(label)
        for i in range(10):
            if i == label_value:
                label_vec.append(0.9)
            else:
                label_vec.append(0.1)
        return label_vec


def get_training_data_set():
    '''
    獲得訓(xùn)練數(shù)據(jù)集
    '''
    image_loader = ImageLoader('train-images-idx3-ubyte', 60000)
    label_loader = LabelLoader('train-labels-idx1-ubyte', 60000)
    return image_loader.load(), label_loader.load()


def get_test_data_set():
    '''
    獲得測(cè)試數(shù)據(jù)集
    '''
    image_loader = ImageLoader('t10k-images-idx3-ubyte', 10000)
    label_loader = LabelLoader('t10k-labels-idx1-ubyte', 10000)
    return image_loader.load(), label_loader.load()

網(wǎng)絡(luò)的輸出是一個(gè)10維向量,這個(gè)向量第n個(gè)(從0開始編號(hào))元素的值最大,那么n就是網(wǎng)絡(luò)的識(shí)別結(jié)果。下面是代碼實(shí)現(xiàn):

def get_result(vec):
    max_value_index = 0
    max_value = 0
    for i in range(len(vec)):
        if vec[i] > max_value:
            max_value = vec[i]
            max_value_index = i
    return max_value_index

我們使用錯(cuò)誤率來(lái)對(duì)網(wǎng)絡(luò)進(jìn)行評(píng)估,下面是代碼實(shí)現(xiàn):

def evaluate(network, test_data_set, test_labels):
    error = 0
    total = len(test_data_set)

    for i in range(total):
        label = get_result(test_labels[i])
        predict = get_result(network.predict(test_data_set[i]))
        if label != predict:
            error += 1
    return float(error) / float(total)

最后實(shí)現(xiàn)我們的訓(xùn)練策略:每訓(xùn)練10輪,評(píng)估一次準(zhǔn)確率,當(dāng)準(zhǔn)確率開始下降時(shí)終止訓(xùn)練。下面是代碼實(shí)現(xiàn):

def train_and_evaluate():
    last_error_ratio = 1.0
    epoch = 0
    train_data_set, train_labels = get_training_data_set()
    test_data_set, test_labels = get_test_data_set()
    network = Network([784, 300, 10])
    while True:
        epoch += 1
        network.train(train_labels, train_data_set, 0.3, 1)
        print '%s epoch %d finished' % (now(), epoch)
        if epoch % 10 == 0:
            error_ratio = evaluate(network, test_data_set, test_labels)
            print '%s after epoch %d, error ratio is %f' % (now(), epoch, error_ratio)
            if error_ratio > last_error_ratio:
                break
            else:
                last_error_ratio = error_ratio


if __name__ == '__main__':
    train_and_evaluate()

在我的機(jī)器上測(cè)試了一下,1個(gè)epoch大約需要9000多秒,所以要對(duì)代碼做很多的性能優(yōu)化工作(比如用向量化編程)。訓(xùn)練要很久很久,可以把它上傳到服務(wù)器上,在tmux的session里面去運(yùn)行。為了防止異常終止導(dǎo)致前功盡棄,我們每訓(xùn)練10輪,就把獲得參數(shù)值保存在磁盤上,以便后續(xù)可以恢復(fù)。(代碼略)

向量化編程

在經(jīng)歷了漫長(zhǎng)的訓(xùn)練之后,我們可能會(huì)想到,肯定有更好的辦法!是的,程序員們,現(xiàn)在我們需要告別面向?qū)ο缶幊塘?,轉(zhuǎn)而去使用另外一種更適合深度學(xué)習(xí)算法的編程方式:向量化編程。主要有兩個(gè)原因:一個(gè)是我們事實(shí)上并不需要真的去定義Node、Connection這樣的對(duì)象,直接把數(shù)學(xué)計(jì)算實(shí)現(xiàn)了就可以了;另一個(gè)原因,是底層算法庫(kù)會(huì)針對(duì)向量運(yùn)算做優(yōu)化(甚至有專用的硬件,比如GPU),程序效率會(huì)提升很多。所以,在深度學(xué)習(xí)的世界里,我們總會(huì)想法設(shè)法的把計(jì)算表達(dá)為向量的形式。我相信優(yōu)秀的程序員不會(huì)把自己拘泥于某種(自己熟悉的)編程范式上,而會(huì)去學(xué)習(xí)并使用最為合適的范式。

下面,我們用向量化編程的方法,重新實(shí)現(xiàn)前面的全連接神經(jīng)網(wǎng)絡(luò)。

首先,我們需要把所有的計(jì)算都表達(dá)為向量的形式。對(duì)于全連接神經(jīng)網(wǎng)絡(luò)來(lái)說(shuō),主要有三個(gè)計(jì)算公式。

前向計(jì)算,我們發(fā)現(xiàn)式2已經(jīng)是向量化的表達(dá)了:

\vec{a}=\sigma(W\centerdot\vec{x})\qquad (式2)

上式中的\sigma表示sigmoid函數(shù)。

反向計(jì)算,我們需要把式3式4使用向量來(lái)表示:

\vec{\delta}=\vec{y}(1-\vec{y})(\vec{t}-\vec{y})\qquad(式7)\\ \vec{\delta^{(l)}}=\vec{a}^{(l)}(1-\vec{a}^{(l)})W^T\delta^{(l-1)}\qquad(式8)

式8中,\delta^{(l)}表示第l層的誤差項(xiàng);W^T表示矩陣W的轉(zhuǎn)置。

我們還需要權(quán)重?cái)?shù)組W和偏置項(xiàng)b的梯度計(jì)算的向量化表示。也就是需要把式5使用向量化表示:

w_{ji}\gets w_{ji}+\eta\delta_jx_{ji}\qquad(式5)

其對(duì)應(yīng)的向量化表示為:

W \gets W + \eta\vec{\delta}\vec{x}^T\qquad(式9)

更新偏置項(xiàng)的向量化表示為:

\vec \gets \vec + \eta\vec{\delta}\qquad(式10)

現(xiàn)在,我們根據(jù)上面幾個(gè)公式,重新實(shí)現(xiàn)一個(gè)類:FullConnectedLayer。它實(shí)現(xiàn)了全連接層的前向和后向計(jì)算:

# 全連接層實(shí)現(xiàn)類
class FullConnectedLayer(object):
    def __init__(self, input_size, output_size, 
                 activator):
        '''
        構(gòu)造函數(shù)
        input_size: 本層輸入向量的維度
        output_size: 本層輸出向量的維度
        activator: 激活函數(shù)
        '''
        self.input_size = input_size
        self.output_size = output_size
        self.activator = activator
        # 權(quán)重?cái)?shù)組W
        self.W = np.random.uniform(-0.1, 0.1,
            (output_size, input_size))
        # 偏置項(xiàng)b
        self.b = np.zeros((output_size, 1))
        # 輸出向量
        self.output = np.zeros((output_size, 1))

    def forward(self, input_array):
        '''
        前向計(jì)算
        input_array: 輸入向量,維度必須等于input_size
        '''
        # 式2
        self.input = input_array
        self.output = self.activator.forward(
            np.dot(self.W, input_array) + self.b)

    def backward(self, delta_array):
        '''
        反向計(jì)算W和b的梯度
        delta_array: 從上一層傳遞過(guò)來(lái)的誤差項(xiàng)
        '''
        # 式8
        self.delta = self.activator.backward(self.input) * np.dot(
            self.W.T, delta_array)
        self.W_grad = np.dot(delta_array, self.input.T)
        self.b_grad = delta_array

    def update(self, learning_rate):
        '''
        使用梯度下降算法更新權(quán)重
        '''
        self.W += learning_rate * self.W_grad
        self.b += learning_rate * self.b_grad

上面這個(gè)類一舉取代了原先的Layer、Node、Connection等類,不但代碼更加容易理解,而且運(yùn)行速度也快了幾百倍。

現(xiàn)在,我們對(duì)Network類稍作修改,使之用到FullConnectedLayer:

# 神經(jīng)網(wǎng)絡(luò)類
class Network(object):
    def __init__(self, layers):
        '''
        構(gòu)造函數(shù)
        '''
        self.layers = []
        for i in range(len(layers) - 1):
            self.layers.append(
                FullConnectedLayer(
                    layers[i], layers[i+1],
                    SigmoidActivator()
                )
            )

    def predict(self, sample):
        '''
        使用神經(jīng)網(wǎng)絡(luò)實(shí)現(xiàn)預(yù)測(cè)
        sample: 輸入樣本
        '''
        output = sample
        for layer in self.layers:
            layer.forward(output)
            output = layer.output
        return output

    def train(self, labels, data_set, rate, epoch):
        '''
        訓(xùn)練函數(shù)
        labels: 樣本標(biāo)簽
        data_set: 輸入樣本
        rate: 學(xué)習(xí)速率
        epoch: 訓(xùn)練輪數(shù)
        '''
        for i in range(epoch):
            for d in range(len(data_set)):
                self.train_one_sample(labels[d], 
                    data_set[d], rate)

    def train_one_sample(self, label, sample, rate):
        self.predict(sample)
        self.calc_gradient(label)
        self.update_weight(rate)

    def calc_gradient(self, label):
        delta = self.layers[-1].activator.backward(
            self.layers[-1].output
        ) * (label - self.layers[-1].output)
        for layer in self.layers[::-1]:
            layer.backward(delta)
            delta = layer.delta
        return delta

    def update_weight(self, rate):
        for layer in self.layers:
            layer.update(rate)

現(xiàn)在,Network類也清爽多了,用我們的新代碼再次訓(xùn)練一下MNIST數(shù)據(jù)集吧。

小結(jié)

至此,你已經(jīng)完成了又一次漫長(zhǎng)的學(xué)習(xí)之旅。你現(xiàn)在應(yīng)該已經(jīng)明白了神經(jīng)網(wǎng)絡(luò)的基本原理,高興的話,你甚至有能力去動(dòng)手實(shí)現(xiàn)一個(gè),并用它解決一些問(wèn)題。如果感到困難也不要?dú)怵H,這篇文章是一個(gè)重要的分水嶺,如果你完全弄明白了的話,在真正的『小白』和裝腔作勢(shì)的『大?!幻媲按荡蹬J峭耆珱](méi)有問(wèn)題的。

作為深度學(xué)習(xí)入門的系列文章,本文也是上半場(chǎng)的結(jié)束。在這個(gè)半場(chǎng),你掌握了機(jī)器學(xué)習(xí)、神經(jīng)網(wǎng)絡(luò)的基本概念,并且有能力去動(dòng)手解決一些簡(jiǎn)單的問(wèn)題(例如手寫數(shù)字識(shí)別,如果用傳統(tǒng)的觀點(diǎn)來(lái)看,其實(shí)這些問(wèn)題也不簡(jiǎn)單)。而且,一旦掌握基本概念,后面的學(xué)習(xí)就容易多了。

在下半場(chǎng),我們講介紹更多『深度』學(xué)習(xí)的內(nèi)容,我們已經(jīng)講了神經(jīng)網(wǎng)絡(luò)(Neutrol Network),但是并沒(méi)有講深度神經(jīng)網(wǎng)絡(luò)(Deep Neutrol Network)。Deep會(huì)帶來(lái)更加強(qiáng)大的能力,同時(shí)也帶來(lái)更多的問(wèn)題。如果不理解這些問(wèn)題和它們的解決方案,也不能說(shuō)你入門了『深度』學(xué)習(xí)。

目前業(yè)界有很多開源的神經(jīng)網(wǎng)絡(luò)實(shí)現(xiàn),它們的功能也要強(qiáng)大的多,因此你并不需要事必躬親的去實(shí)現(xiàn)自己的神經(jīng)網(wǎng)絡(luò)。我們?cè)谏习雸?chǎng)不斷的從頭發(fā)明輪子,是為了讓你明白神經(jīng)網(wǎng)絡(luò)的基本原理,這樣你就能非常迅速的掌握這些工具。在下半場(chǎng)的文章中,我們改變了策略:不會(huì)再去從頭開始去實(shí)現(xiàn),而是盡可能應(yīng)用現(xiàn)有的工具。

下一篇文章,我們介紹不同結(jié)構(gòu)的神經(jīng)網(wǎng)絡(luò),比如鼎鼎大名的卷積神經(jīng)網(wǎng)絡(luò),它在圖像和語(yǔ)音領(lǐng)域已然創(chuàng)造了諸多奇跡,在自然語(yǔ)言處理領(lǐng)域的研究也如火如荼。某種意義上說(shuō),它的成功大大提升了人們對(duì)于深度學(xué)習(xí)的信心。

好了,同學(xué)們累了吧,奉上美圖一張,放松一下心情!

參考資料

  1. Tom M. Mitchell, "機(jī)器學(xué)習(xí)", 曾華軍等譯, 機(jī)械工業(yè)出版社
  2. CS 224N / Ling 284, Neural Networks for Named Entity Recognition
  3. LeCun et al. Gradient-Based Learning Applied to Document Recognition 1998

相關(guān)文章

零基礎(chǔ)入門深度學(xué)習(xí)(1) - 感知器
零基礎(chǔ)入門深度學(xué)習(xí)(2) - 線性單元和梯度下降

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容