往期回顧
在上一篇文章中,我們已經(jīng)掌握了機器學(xué)習(xí)的基本套路,對模型、目標(biāo)函數(shù)、優(yōu)化算法這些概念有了一定程度的理解,而且已經(jīng)會訓(xùn)練單個的感知器或者線性單元了。在這篇文章中,我們將把這些單獨的單元按照一定的規(guī)則相互連接在一起形成神經(jīng)網(wǎng)絡(luò),從而奇跡般的獲得了強大的學(xué)習(xí)能力。我們還將介紹這種網(wǎng)絡(luò)的訓(xùn)練算法:反向傳播算法。最后,我們依然用代碼實現(xiàn)一個神經(jīng)網(wǎng)絡(luò)。如果您能堅持到本文的結(jié)尾,將會看到我們用自己實現(xiàn)的神經(jīng)網(wǎng)絡(luò)去識別手寫數(shù)字。現(xiàn)在請做好準(zhǔn)備,您即將雙手觸及到深度學(xué)習(xí)的大門。
神經(jīng)元
神經(jīng)元和感知器本質(zhì)上是一樣的,只不過我們說感知器的時候,它的激活函數(shù)是階躍函數(shù);而當(dāng)我們說神經(jīng)元時,激活函數(shù)往往選擇為sigmoid函數(shù)或tanh函數(shù)。如下圖所示:
計算一個神經(jīng)元的輸出的方法和計算一個感知器的輸出是一樣的。假設(shè)神經(jīng)元的輸入是向量,權(quán)重向量是
(偏置項是
),激活函數(shù)是sigmoid函數(shù),則其輸出
:
sigmoid函數(shù)的定義如下:
將其帶入前面的式子,得到
sigmoid函數(shù)是一個非線性函數(shù),值域是(0,1)。函數(shù)圖像如下圖所示
sigmoid函數(shù)的導(dǎo)數(shù)是:
可以看到,sigmoid函數(shù)的導(dǎo)數(shù)非常有趣,它可以用sigmoid函數(shù)自身來表示。這樣,一旦計算出sigmoid函數(shù)的值,計算它的導(dǎo)數(shù)的值就非常方便。
神經(jīng)網(wǎng)絡(luò)是啥
神經(jīng)網(wǎng)絡(luò)其實就是按照一定規(guī)則連接起來的多個神經(jīng)元。上圖展示了一個全連接(full connected, FC)神經(jīng)網(wǎng)絡(luò),通過觀察上面的圖,我們可以發(fā)現(xiàn)它的規(guī)則包括:
- 神經(jīng)元按照層來布局。最左邊的層叫做輸入層,負責(zé)接收輸入數(shù)據(jù);最右邊的層叫輸出層,我們可以從這層獲取神經(jīng)網(wǎng)絡(luò)輸出數(shù)據(jù)。輸入層和輸出層之間的層叫做隱藏層,因為它們對于外部來說是不可見的。
- 同一層的神經(jīng)元之間沒有連接。
- 第N層的每個神經(jīng)元和第N-1層的所有神經(jīng)元相連(這就是full connected的含義),第N-1層神經(jīng)元的輸出就是第N層神經(jīng)元的輸入。
- 每個連接都有一個權(quán)值。
上面這些規(guī)則定義了全連接神經(jīng)網(wǎng)絡(luò)的結(jié)構(gòu)。事實上還存在很多其它結(jié)構(gòu)的神經(jīng)網(wǎng)絡(luò),比如卷積神經(jīng)網(wǎng)絡(luò)(CNN)、循環(huán)神經(jīng)網(wǎng)絡(luò)(RNN),他們都具有不同的連接規(guī)則。
計算神經(jīng)網(wǎng)絡(luò)的輸出
神經(jīng)網(wǎng)絡(luò)實際上就是一個輸入向量到輸出向量
的函數(shù),即:
根據(jù)輸入計算神經(jīng)網(wǎng)絡(luò)的輸出,需要首先將輸入向量的每個元素
的值賦給神經(jīng)網(wǎng)絡(luò)的輸入層的對應(yīng)神經(jīng)元,然后根據(jù)式1依次向前計算每一層的每個神經(jīng)元的值,直到最后一層輸出層的所有神經(jīng)元的值計算完畢。最后,將輸出層每個神經(jīng)元的值串在一起就得到了輸出向量
。
接下來舉一個例子來說明這個過程,我們先給神經(jīng)網(wǎng)絡(luò)的每個單元寫上編號。
如上圖,輸入層有三個節(jié)點,我們將其依次編號為1、2、3;隱藏層的4個節(jié)點,編號依次為4、5、6、7;最后輸出層的兩個節(jié)點編號為8、9。因為我們這個神經(jīng)網(wǎng)絡(luò)是全連接網(wǎng)絡(luò),所以可以看到每個節(jié)點都和上一層的所有節(jié)點有連接。比如,我們可以看到隱藏層的節(jié)點4,它和輸入層的三個節(jié)點1、2、3之間都有連接,其連接上的權(quán)重分別為。那么,我們怎樣計算節(jié)點4的輸出值
呢?
為了計算節(jié)點4的輸出值,我們必須先得到其所有上游節(jié)點(也就是節(jié)點1、2、3)的輸出值。節(jié)點1、2、3是輸入層的節(jié)點,所以,他們的輸出值就是輸入向量本身。按照上圖畫出的對應(yīng)關(guān)系,可以看到節(jié)點1、2、3的輸出值分別是
。我們要求輸入向量的維度和輸入層神經(jīng)元個數(shù)相同,而輸入向量的某個元素對應(yīng)到哪個輸入節(jié)點是可以自由決定的,你偏非要把
賦值給節(jié)點2也是完全沒有問題的,但這樣除了把自己弄暈之外,并沒有什么價值。
一旦我們有了節(jié)點1、2、3的輸出值,我們就可以根據(jù)式1計算節(jié)點4的輸出值:
上式的是節(jié)點4的偏置項,圖中沒有畫出來。而
分別為節(jié)點1、2、3到節(jié)點4連接的權(quán)重,在給權(quán)重
編號時,我們把目標(biāo)節(jié)點的編號
放在前面,把源節(jié)點的編號
放在后面。
同樣,我們可以繼續(xù)計算出節(jié)點5、6、7的輸出值。這樣,隱藏層的4個節(jié)點的輸出值就計算完成了,我們就可以接著計算輸出層的節(jié)點8的輸出值
:
同理,我們還可以計算出的值。這樣輸出層所有節(jié)點的輸出值計算完畢,我們就得到了在輸入向量
時,神經(jīng)網(wǎng)絡(luò)的輸出向量
。這里我們也看到,輸出向量的維度和輸出層神經(jīng)元個數(shù)相同。
神經(jīng)網(wǎng)絡(luò)的矩陣表示
神經(jīng)網(wǎng)絡(luò)的計算如果用矩陣來表示會很方便(當(dāng)然逼格也更高),我們先來看看隱藏層的矩陣表示。
首先我們把隱藏層4個節(jié)點的計算依次排列出來:
接著,定義網(wǎng)絡(luò)的輸入向量和隱藏層每個節(jié)點的權(quán)重向量
。令
代入到前面的一組式子,得到:
現(xiàn)在,我們把上述計算的四個式子寫到一個矩陣?yán)锩妫總€式子作為矩陣的一行,就可以利用矩陣來表示它們的計算了。令
帶入前面的一組式子,得到
在式2中,是激活函數(shù),在本例中是
函數(shù);
是某一層的權(quán)重矩陣;
是某層的輸入向量;
是某層的輸出向量。式2說明神經(jīng)網(wǎng)絡(luò)的每一層的作用實際上就是先將輸入向量左乘一個數(shù)組進行線性變換,得到一個新的向量,然后再對這個向量逐元素應(yīng)用一個激活函數(shù)。
每一層的算法都是一樣的。比如,對于包含一個輸入層,一個輸出層和三個隱藏層的神經(jīng)網(wǎng)絡(luò),我們假設(shè)其權(quán)重矩陣分別為,每個隱藏層的輸出分別是
,神經(jīng)網(wǎng)絡(luò)的輸入為
,神經(jīng)網(wǎng)絡(luò)的輸入為
,如下圖所示:
則每一層的輸出向量的計算可以表示為:
這就是神經(jīng)網(wǎng)絡(luò)輸出值的計算方法。
神經(jīng)網(wǎng)絡(luò)的訓(xùn)練
現(xiàn)在,我們需要知道一個神經(jīng)網(wǎng)絡(luò)的每個連接上的權(quán)值是如何得到的。我們可以說神經(jīng)網(wǎng)絡(luò)是一個模型,那么這些權(quán)值就是模型的參數(shù),也就是模型要學(xué)習(xí)的東西。然而,一個神經(jīng)網(wǎng)絡(luò)的連接方式、網(wǎng)絡(luò)的層數(shù)、每層的節(jié)點數(shù)這些參數(shù),則不是學(xué)習(xí)出來的,而是人為事先設(shè)置的。對于這些人為設(shè)置的參數(shù),我們稱之為超參數(shù)(Hyper-Parameters)。
接下來,我們將要介紹神經(jīng)網(wǎng)絡(luò)的訓(xùn)練算法:反向傳播算法。
反向傳播算法(Back Propagation)
我們首先直觀的介紹反向傳播算法,最后再來介紹這個算法的推導(dǎo)。當(dāng)然讀者也可以完全跳過推導(dǎo)部分,因為即使不知道如何推導(dǎo),也不影響你寫出來一個神經(jīng)網(wǎng)絡(luò)的訓(xùn)練代碼。事實上,現(xiàn)在神經(jīng)網(wǎng)絡(luò)成熟的開源實現(xiàn)多如牛毛,除了練手之外,你可能都沒有機會需要去寫一個神經(jīng)網(wǎng)絡(luò)。
我們以監(jiān)督學(xué)習(xí)為例來解釋反向傳播算法。在零基礎(chǔ)入門深度學(xué)習(xí)(2) - 線性單元和梯度下降一文中我們介紹了什么是監(jiān)督學(xué)習(xí),如果忘記了可以再看一下。另外,我們設(shè)神經(jīng)元的激活函數(shù)為
函數(shù)(不同激活函數(shù)的計算公式不同,詳情見反向傳播算法的推導(dǎo)一節(jié))。
我們假設(shè)每個訓(xùn)練樣本為,其中向量
是訓(xùn)練樣本的特征,而
是樣本的目標(biāo)值。
首先,我們根據(jù)上一節(jié)介紹的算法,用樣本的特征,計算出神經(jīng)網(wǎng)絡(luò)中每個隱藏層節(jié)點的輸出
,以及輸出層每個節(jié)點的輸出
。
然后,我們按照下面的方法計算出每個節(jié)點的誤差項:
- 對于輸出層節(jié)點
,
其中,是節(jié)點
的誤差項,
是節(jié)點
的輸出值,
是樣本對應(yīng)于節(jié)點
的目標(biāo)值。舉個例子,根據(jù)上圖,對于輸出層節(jié)點8來說,它的輸出值是
,而樣本的目標(biāo)值是
,帶入上面的公式得到節(jié)點8的誤差項
應(yīng)該是:
- 對于隱藏層節(jié)點,
其中,是節(jié)點
的輸出值,
是節(jié)點
到它的下一層節(jié)點
的連接的權(quán)重,
是節(jié)點
的下一層節(jié)點
的誤差項。例如,對于隱藏層節(jié)點4來說,計算方法如下:
最后,更新每個連接上的權(quán)值:
其中,是節(jié)點
到節(jié)點
的權(quán)重,
是一個成為學(xué)習(xí)速率的常數(shù),
是節(jié)點
的誤差項,
是節(jié)點
傳遞給節(jié)點
的輸入。例如,權(quán)重
的更新方法如下:
類似的,權(quán)重的更新方法如下:
偏置項的輸入值永遠為1。例如,節(jié)點4的偏置項應(yīng)該按照下面的方法計算:
我們已經(jīng)介紹了神經(jīng)網(wǎng)絡(luò)每個節(jié)點誤差項的計算和權(quán)重更新方法。顯然,計算一個節(jié)點的誤差項,需要先計算每個與其相連的下一層節(jié)點的誤差項。這就要求誤差項的計算順序必須是從輸出層開始,然后反向依次計算每個隱藏層的誤差項,直到與輸入層相連的那個隱藏層。這就是反向傳播算法的名字的含義。當(dāng)所有節(jié)點的誤差項計算完畢后,我們就可以根據(jù)式5來更新所有的權(quán)重。
以上就是基本的反向傳播算法,并不是很復(fù)雜,您弄清楚了么?
<a name="an1"></a>反向傳播算法的推導(dǎo)
反向傳播算法其實就是鏈?zhǔn)角髮?dǎo)法則的應(yīng)用。然而,這個如此簡單且顯而易見的方法,卻是在Roseblatt提出感知器算法將近30年之后才被發(fā)明和普及的。對此,Bengio這樣回應(yīng)道:
很多看似顯而易見的想法只有在事后才變得顯而易見。
接下來,我們用鏈?zhǔn)角髮?dǎo)法則來推導(dǎo)反向傳播算法,也就是上一小節(jié)的式3、式4、式5。
前方高能預(yù)警——接下來是數(shù)學(xué)公式重災(zāi)區(qū),讀者可以酌情閱讀,不必強求。
按照機器學(xué)習(xí)的通用套路,我們先確定神經(jīng)網(wǎng)絡(luò)的目標(biāo)函數(shù),然后用隨機梯度下降優(yōu)化算法去求目標(biāo)函數(shù)最小值時的參數(shù)值。
我們?nèi)【W(wǎng)絡(luò)所有輸出層節(jié)點的誤差平方和作為目標(biāo)函數(shù):
其中,表示是樣本
的誤差。
然后,我們用文章零基礎(chǔ)入門深度學(xué)習(xí)(2) - 線性單元和梯度下降中介紹的隨機梯度下降算法對目標(biāo)函數(shù)進行優(yōu)化:
隨機梯度下降算法也就是需要求出誤差對于每個權(quán)重
的偏導(dǎo)數(shù)(也就是梯度),怎么求呢?
觀察上圖,我們發(fā)現(xiàn)權(quán)重僅能通過影響節(jié)點
的輸入值影響網(wǎng)絡(luò)的其它部分,設(shè)
是節(jié)點
的加權(quán)輸入,即
是
的函數(shù),而
是
的函數(shù)。根據(jù)鏈?zhǔn)角髮?dǎo)法則,可以得到:
上式中,是節(jié)點
傳遞給節(jié)點
的輸入值,也就是節(jié)點
的輸出值。
對于的推導(dǎo),需要區(qū)分輸出層和隱藏層兩種情況。
輸出層權(quán)值訓(xùn)練
對于輸出層來說,僅能通過節(jié)點
的輸出值
來影響網(wǎng)絡(luò)其它部分,也就是說
是
的函數(shù),而
是
的函數(shù),其中
。所以我們可以再次使用鏈?zhǔn)角髮?dǎo)法則:
考慮上式第一項:
考慮上式第二項:
將第一項和第二項帶入,得到:
如果令,也就是一個節(jié)點的誤差項
是網(wǎng)絡(luò)誤差對這個節(jié)點輸入的偏導(dǎo)數(shù)的相反數(shù)。帶入上式,得到:
上式就是式3。
將上述推導(dǎo)帶入隨機梯度下降公式,得到:
上式就是式5。
隱藏層權(quán)值訓(xùn)練
現(xiàn)在我們要推導(dǎo)出隱藏層的。
首先,我們需要定義節(jié)點的所有直接下游節(jié)點的集合
。例如,對于節(jié)點4來說,它的直接下游節(jié)點是節(jié)點8、節(jié)點9。可以看到
只能通過影響
再影響
。設(shè)
是節(jié)點
的下游節(jié)點的輸入,則
是
的函數(shù),而
是
的函數(shù)。因為
有多個,我們應(yīng)用全導(dǎo)數(shù)公式,可以做出如下推導(dǎo):
因為,帶入上式得到:
上式就是式4。
——數(shù)學(xué)公式警報解除——
至此,我們已經(jīng)推導(dǎo)出了反向傳播算法。需要注意的是,我們剛剛推導(dǎo)出的訓(xùn)練規(guī)則是根據(jù)激活函數(shù)是sigmoid函數(shù)、平方和誤差、全連接網(wǎng)絡(luò)、隨機梯度下降優(yōu)化算法。如果激活函數(shù)不同、誤差計算方式不同、網(wǎng)絡(luò)連接結(jié)構(gòu)不同、優(yōu)化算法不同,則具體的訓(xùn)練規(guī)則也會不一樣。但是無論怎樣,訓(xùn)練規(guī)則的推導(dǎo)方式都是一樣的,應(yīng)用鏈?zhǔn)角髮?dǎo)法則進行推導(dǎo)即可。
神經(jīng)網(wǎng)絡(luò)的實現(xiàn)
現(xiàn)在,我們要根據(jù)前面的算法,實現(xiàn)一個基本的全連接神經(jīng)網(wǎng)絡(luò),這并不需要太多代碼。我們在這里依然采用面向?qū)ο笤O(shè)計。
首先,我們先做一個基本的模型:
如上圖,可以分解出5個領(lǐng)域?qū)ο髞韺崿F(xiàn)神經(jīng)網(wǎng)絡(luò):
- Network 神經(jīng)網(wǎng)絡(luò)對象,提供API接口。它由若干層對象組成以及連接對象組成。
- Layer 層對象,由多個節(jié)點組成。
-
Node 節(jié)點對象計算和記錄節(jié)點自身的信息(比如輸出值
、誤差項
等),以及與這個節(jié)點相關(guān)的上下游的連接。
- Connection 每個連接對象都要記錄該連接的權(quán)重。
- Connections 僅僅作為Connection的集合對象,提供一些集合操作。
Node實現(xiàn)如下:
# 節(jié)點類,負責(zé)記錄和維護節(jié)點自身信息以及與這個節(jié)點相關(guān)的上下游連接,實現(xiàn)輸出值和誤差項的計算。
class Node(object):
def __init__(self, layer_index, node_index):
'''
構(gòu)造節(jié)點對象。
layer_index: 節(jié)點所屬的層的編號
node_index: 節(jié)點的編號
'''
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é)點的輸出值。如果節(jié)點屬于輸入層會用到這個函數(shù)。
'''
self.output = output
def append_downstream_connection(self, conn):
'''
添加一個到下游節(jié)點的連接
'''
self.downstream.append(conn)
def append_upstream_connection(self, conn):
'''
添加一個到上游節(jié)點的連接
'''
self.upstream.append(conn)
def calc_output(self):
'''
根據(jù)式1計算節(jié)點的輸出
'''
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é)點屬于隱藏層時,根據(jù)式4計算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é)點屬于輸出層時,根據(jù)式3計算delta
'''
self.delta = self.output * (1 - self.output) * (label - self.output)
def __str__(self):
'''
打印節(jié)點的信息
'''
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對象,為了實現(xiàn)一個輸出恒為1的節(jié)點(計算偏置項時需要)
class ConstNode(object):
def __init__(self, layer_index, node_index):
'''
構(gòu)造節(jié)點對象。
layer_index: 節(jié)點所屬的層的編號
node_index: 節(jié)點的編號
'''
self.layer_index = layer_index
self.node_index = node_index
self.downstream = []
self.output = 1
def append_downstream_connection(self, conn):
'''
添加一個到下游節(jié)點的連接
'''
self.downstream.append(conn)
def calc_hidden_layer_delta(self):
'''
節(jié)點屬于隱藏層時,根據(jù)式4計算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é)點的信息
'''
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對象,負責(zé)初始化一層。此外,作為Node的集合對象,提供對Node集合的操作。
class Layer(object):
def __init__(self, layer_index, node_count):
'''
初始化一層
layer_index: 層編號
node_count: 層所包含的節(jié)點個數(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)層是輸入層時會用到。
'''
for i in range(len(data)):
self.nodes[i].set_output(data[i])
def calc_output(self):
'''
計算層的輸出向量
'''
for node in self.nodes[:-1]:
node.calc_output()
def dump(self):
'''
打印層的信息
'''
for node in self.nodes:
print node
Connection對象,主要職責(zé)是記錄連接的權(quán)重,以及這個連接所關(guān)聯(lián)的上下游節(jié)點。
class Connection(object):
def __init__(self, upstream_node, downstream_node):
'''
初始化連接,權(quán)重初始化為是一個很小的隨機數(shù)
upstream_node: 連接的上游節(jié)點
downstream_node: 連接的下游節(jié)點
'''
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):
'''
計算梯度
'''
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對象,提供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對象,提供API。
class Network(object):
def __init__(self, layers):
'''
初始化一個全連接神經(jīng)網(wǎng)絡(luò)
layers: 二維數(shù)組,描述神經(jīng)網(wǎng)絡(luò)每層節(jié)點數(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)簽。每個元素是一個樣本的標(biāo)簽。
data_set: 二維數(shù)組,訓(xùn)練樣本特征。每個元素是一個樣本的特征。
'''
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ù),用一個樣本訓(xùn)練網(wǎng)絡(luò)
'''
self.predict(sample)
self.calc_delta(label)
self.update_weight(rate)
def calc_delta(self, label):
'''
內(nèi)部函數(shù),計算每個節(jié)點的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ù),更新每個連接權(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ù),計算每個連接的梯度
'''
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ò)在一個樣本下,每個連接上的梯度
label: 樣本標(biāo)簽
sample: 樣本輸入
'''
self.predict(sample)
self.calc_delta(label)
self.calc_gradient()
def predict(self, sample):
'''
根據(jù)輸入的樣本預(yù)測輸出值
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()
至此,實現(xiàn)了一個基本的全連接神經(jīng)網(wǎng)絡(luò)。可以看到,同神經(jīng)網(wǎng)絡(luò)的強大學(xué)習(xí)能力相比,其實現(xiàn)還算是很容易的。
梯度檢查
怎么保證自己寫的神經(jīng)網(wǎng)絡(luò)沒有BUG呢?事實上這是一個非常重要的問題。一方面,千辛萬苦想到一個算法,結(jié)果效果不理想,那么是算法本身錯了還是代碼實現(xiàn)錯了呢?定位這種問題肯定要花費大量的時間和精力。另一方面,由于神經(jīng)網(wǎng)絡(luò)的復(fù)雜性,我們幾乎無法事先知道神經(jīng)網(wǎng)絡(luò)的輸入和輸出,因此類似TDD(測試驅(qū)動開發(fā))這樣的開發(fā)方法似乎也不可行。
辦法還是有滴,就是利用梯度檢查來確認程序是否正確。梯度檢查的思路如下:
對于梯度下降算法:
來說,這里關(guān)鍵之處在于的計算一定要正確,而它是
對
的偏導(dǎo)數(shù)。而根據(jù)導(dǎo)數(shù)的定義:
對于任意的導(dǎo)數(shù)值,我們都可以用等式右邊來近似計算。我們把
看做是
的函數(shù),即
,那么根據(jù)導(dǎo)數(shù)定義,
應(yīng)該等于:
如果把設(shè)置為一個很小的數(shù)(比如
),那么上式可以寫成:
我們就可以利用式6,來計算梯度的值,然后同我們神經(jīng)網(wǎng)絡(luò)代碼中計算出來的梯度值進行比較。如果兩者的差別非常的小,那么就說明我們的代碼是正確的。
下面是梯度檢查的代碼。如果我們想檢查參數(shù)的梯度是否正確,我們需要以下幾個步驟:
- 首先使用一個樣本
對神經(jīng)網(wǎng)絡(luò)進行訓(xùn)練,這樣就能獲得每個權(quán)重的梯度。
- 將
加上一個很小的值(
),重新計算神經(jīng)網(wǎng)絡(luò)在這個樣本
下的
。
- 將
減上一個很小的值(
),重新計算神經(jīng)網(wǎng)絡(luò)在這個樣本
下的
。
- 根據(jù)式6計算出期望的梯度值,和第一步獲得的梯度值進行比較,它們應(yīng)該幾乎想等(至少4位有效數(shù)字相同)。
當(dāng)然,我們可以重復(fù)上面的過程,對每個權(quán)重都進行檢查。也可以使用多個樣本重復(fù)檢查。
def gradient_check(network, sample_feature, sample_label):
'''
梯度檢查
network: 神經(jīng)網(wǎng)絡(luò)對象
sample_feature: 樣本的特征
sample_label: 樣本的標(biāo)簽
'''
# 計算網(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)前樣本下每個連接的梯度
network.get_gradient(sample_feature, sample_label)
# 對每個權(quán)重做梯度檢查
for conn in network.connections.connections:
# 獲取指定連接的梯度
actual_gradient = conn.get_gradient()
# 增加一個很小的值,計算網(wǎng)絡(luò)的誤差
epsilon = 0.0001
conn.weight += epsilon
error1 = network_error(network.predict(sample_feature), sample_label)
# 減去一個很小的值,計算網(wǎng)絡(luò)的誤差
conn.weight -= 2 * epsilon # 剛才加過了一次,因此這里需要減去2倍
error2 = network_error(network.predict(sample_feature), sample_label)
# 根據(jù)式6計算期望的梯度值
expected_gradient = (error2 - error1) / (2 * epsilon)
# 打印
print 'expected gradient: \t%f\nactual gradient: \t%f' % (
expected_gradient, actual_gradient)
至此,會推導(dǎo)、會實現(xiàn)、會抓BUG,你已經(jīng)摸到深度學(xué)習(xí)的大門了。接下來還需要不斷的實踐,我們用剛剛寫過的神經(jīng)網(wǎng)絡(luò)去識別手寫數(shù)字。
神經(jīng)網(wǎng)絡(luò)實戰(zhàn)——手寫數(shù)字識別
針對這個任務(wù),我們采用業(yè)界非常流行的MNIST數(shù)據(jù)集。MNIST大約有60000個手寫字母的訓(xùn)練樣本,我們使用它訓(xùn)練我們的神經(jīng)網(wǎng)絡(luò),然后再用訓(xùn)練好的網(wǎng)絡(luò)去識別手寫數(shù)字。
手寫數(shù)字識別是個比較簡單的任務(wù),數(shù)字只可能是0-9中的一個,這是個10分類問題。
超參數(shù)的確定
我們首先需要確定網(wǎng)絡(luò)的層數(shù)和每層的節(jié)點數(shù)。關(guān)于第一個問題,實際上并沒有什么理論化的方法,大家都是根據(jù)經(jīng)驗來拍,如果沒有經(jīng)驗的話就隨便拍一個。然后,你可以多試幾個值,訓(xùn)練不同層數(shù)的神經(jīng)網(wǎng)絡(luò),看看哪個效果最好就用哪個。嗯,現(xiàn)在你可能明白為什么說深度學(xué)習(xí)是個手藝活了,有些手藝很讓人無語,而有些手藝還是很有技術(shù)含量的。
不過,有些基本道理我們還是明白的,我們知道網(wǎng)絡(luò)層數(shù)越多越好,也知道層數(shù)越多訓(xùn)練難度越大。對于全連接網(wǎng)絡(luò),隱藏層最好不要超過三層。那么,我們可以先試試僅有一個隱藏層的神經(jīng)網(wǎng)絡(luò)效果怎么樣。畢竟模型小的話,訓(xùn)練起來也快些(剛開始玩模型的時候,都希望快點看到結(jié)果)。
輸入層節(jié)點數(shù)是確定的。因為MNIST數(shù)據(jù)集每個訓(xùn)練數(shù)據(jù)是28*28的圖片,共784個像素,因此,輸入層節(jié)點數(shù)應(yīng)該是784,每個像素對應(yīng)一個輸入節(jié)點。
輸出層節(jié)點數(shù)也是確定的。因為是10分類,我們可以用10個節(jié)點,每個節(jié)點對應(yīng)一個分類。輸出層10個節(jié)點中,輸出最大值的那個節(jié)點對應(yīng)的分類,就是模型的預(yù)測結(jié)果。
隱藏層節(jié)點數(shù)量是不好確定的,從1到100萬都可以。下面有幾個經(jīng)驗公式:
因此,我們可以先根據(jù)上面的公式設(shè)置一個隱藏層節(jié)點數(shù)。如果有時間,我們可以設(shè)置不同的節(jié)點數(shù),分別訓(xùn)練,看看哪個效果最好就用哪個。我們先拍一個,設(shè)隱藏層節(jié)點數(shù)為300吧。
對于3層的全連接網(wǎng)絡(luò),總共有
個參數(shù)!神經(jīng)網(wǎng)絡(luò)之所以強大,是它提供了一種非常簡單的方法去實現(xiàn)大量的參數(shù)。目前百億參數(shù)、千億樣本的超大規(guī)模神經(jīng)網(wǎng)絡(luò)也是有的。因為MNIST只有6萬個訓(xùn)練樣本,參數(shù)太多了很容易過擬合,效果反而不好。
模型的訓(xùn)練和評估
MNIST數(shù)據(jù)集包含10000個測試樣本。我們先用60000個訓(xùn)練樣本訓(xùn)練我們的網(wǎng)絡(luò),然后再用測試樣本對網(wǎng)絡(luò)進行測試,計算識別錯誤率:
我們每訓(xùn)練10輪,評估一次準(zhǔn)確率。當(dāng)準(zhǔn)確率開始下降時(出現(xiàn)了過擬合)終止訓(xùn)練。
代碼實現(xiàn)
首先,我們需要把MNIST數(shù)據(jù)集處理為神經(jīng)網(wǎng)絡(luò)能夠接受的形式。MNIST訓(xùn)練集的文件格式可以參考官方網(wǎng)站,這里不在贅述。每個訓(xùn)練樣本是一個28*28的圖像,我們按照行優(yōu)先,把它轉(zhuǎn)化為一個784維的向量。每個標(biāo)簽是0-9的值,我們將其轉(zhuǎn)換為一個10維的one-hot向量:如果標(biāo)簽值為,我們就把向量的第
維(從0開始編號)設(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: 文件中的樣本個數(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ù),將一個值轉(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():
'''
獲得測試數(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ò)的輸出是一個10維向量,這個向量第個(從0開始編號)元素的值最大,那么
就是網(wǎng)絡(luò)的識別結(jié)果。下面是代碼實現(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
我們使用錯誤率來對網(wǎng)絡(luò)進行評估,下面是代碼實現(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)
最后實現(xiàn)我們的訓(xùn)練策略:每訓(xùn)練10輪,評估一次準(zhǔn)確率,當(dāng)準(zhǔn)確率開始下降時終止訓(xùn)練。下面是代碼實現(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()
在我的機器上測試了一下,1個epoch大約需要9000多秒,所以要對代碼做很多的性能優(yōu)化工作(比如用向量化編程)。訓(xùn)練要很久很久,可以把它上傳到服務(wù)器上,在tmux的session里面去運行。為了防止異常終止導(dǎo)致前功盡棄,我們每訓(xùn)練10輪,就把獲得參數(shù)值保存在磁盤上,以便后續(xù)可以恢復(fù)。(代碼略)
向量化編程
在經(jīng)歷了漫長的訓(xùn)練之后,我們可能會想到,肯定有更好的辦法!是的,程序員們,現(xiàn)在我們需要告別面向?qū)ο缶幊塘耍D(zhuǎn)而去使用另外一種更適合深度學(xué)習(xí)算法的編程方式:向量化編程。主要有兩個原因:一個是我們事實上并不需要真的去定義Node、Connection這樣的對象,直接把數(shù)學(xué)計算實現(xiàn)了就可以了;另一個原因,是底層算法庫會針對向量運算做優(yōu)化(甚至有專用的硬件,比如GPU),程序效率會提升很多。所以,在深度學(xué)習(xí)的世界里,我們總會想法設(shè)法的把計算表達為向量的形式。我相信優(yōu)秀的程序員不會把自己拘泥于某種(自己熟悉的)編程范式上,而會去學(xué)習(xí)并使用最為合適的范式。
下面,我們用向量化編程的方法,重新實現(xiàn)前面的全連接神經(jīng)網(wǎng)絡(luò)。
首先,我們需要把所有的計算都表達為向量的形式。對于全連接神經(jīng)網(wǎng)絡(luò)來說,主要有三個計算公式。
前向計算,我們發(fā)現(xiàn)式2已經(jīng)是向量化的表達了:
上式中的表示sigmoid函數(shù)。
反向計算,我們需要把式3和式4使用向量來表示:
在式8中,表示第l層的誤差項;
表示矩陣
的轉(zhuǎn)置。
我們還需要權(quán)重數(shù)組W和偏置項b的梯度計算的向量化表示。也就是需要把式5使用向量化表示:
其對應(yīng)的向量化表示為:
更新偏置項的向量化表示為:
現(xiàn)在,我們根據(jù)上面幾個公式,重新實現(xiàn)一個類:FullConnectedLayer。它實現(xiàn)了全連接層的前向和后向計算:
# 全連接層實現(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)重數(shù)組W
self.W = np.random.uniform(-0.1, 0.1,
(output_size, input_size))
# 偏置項b
self.b = np.zeros((output_size, 1))
# 輸出向量
self.output = np.zeros((output_size, 1))
def forward(self, input_array):
'''
前向計算
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):
'''
反向計算W和b的梯度
delta_array: 從上一層傳遞過來的誤差項
'''
# 式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
上面這個類一舉取代了原先的Layer、Node、Connection等類,不但代碼更加容易理解,而且運行速度也快了幾百倍。
現(xiàn)在,我們對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ò)實現(xiàn)預(yù)測
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)完成了又一次漫長的學(xué)習(xí)之旅。你現(xiàn)在應(yīng)該已經(jīng)明白了神經(jīng)網(wǎng)絡(luò)的基本原理,高興的話,你甚至有能力去動手實現(xiàn)一個,并用它解決一些問題。如果感到困難也不要氣餒,這篇文章是一個重要的分水嶺,如果你完全弄明白了的話,在真正的『小白』和裝腔作勢的『大牛』面前吹吹牛是完全沒有問題的。
作為深度學(xué)習(xí)入門的系列文章,本文也是上半場的結(jié)束。在這個半場,你掌握了機器學(xué)習(xí)、神經(jīng)網(wǎng)絡(luò)的基本概念,并且有能力去動手解決一些簡單的問題(例如手寫數(shù)字識別,如果用傳統(tǒng)的觀點來看,其實這些問題也不簡單)。而且,一旦掌握基本概念,后面的學(xué)習(xí)就容易多了。
在下半場,我們講介紹更多『深度』學(xué)習(xí)的內(nèi)容,我們已經(jīng)講了神經(jīng)網(wǎng)絡(luò)(Neutrol Network),但是并沒有講深度神經(jīng)網(wǎng)絡(luò)(Deep Neutrol Network)。Deep會帶來更加強大的能力,同時也帶來更多的問題。如果不理解這些問題和它們的解決方案,也不能說你入門了『深度』學(xué)習(xí)。
目前業(yè)界有很多開源的神經(jīng)網(wǎng)絡(luò)實現(xiàn),它們的功能也要強大的多,因此你并不需要事必躬親的去實現(xiàn)自己的神經(jīng)網(wǎng)絡(luò)。我們在上半場不斷的從頭發(fā)明輪子,是為了讓你明白神經(jīng)網(wǎng)絡(luò)的基本原理,這樣你就能非常迅速的掌握這些工具。在下半場的文章中,我們改變了策略:不會再去從頭開始去實現(xiàn),而是盡可能應(yīng)用現(xiàn)有的工具。
下一篇文章,我們介紹不同結(jié)構(gòu)的神經(jīng)網(wǎng)絡(luò),比如鼎鼎大名的卷積神經(jīng)網(wǎng)絡(luò),它在圖像和語音領(lǐng)域已然創(chuàng)造了諸多奇跡,在自然語言處理領(lǐng)域的研究也如火如荼。某種意義上說,它的成功大大提升了人們對于深度學(xué)習(xí)的信心。
好了,同學(xué)們累了吧,奉上美圖一張,放松一下心情!
參考資料
- Tom M. Mitchell, "機器學(xué)習(xí)", 曾華軍等譯, 機械工業(yè)出版社
- CS 224N / Ling 284, Neural Networks for Named Entity Recognition
- LeCun et al. Gradient-Based Learning Applied to Document Recognition 1998
相關(guān)文章
零基礎(chǔ)入門深度學(xué)習(xí)(1) - 感知器
零基礎(chǔ)入門深度學(xué)習(xí)(2) - 線性單元和梯度下降