概要:直接上程式碼是最有效的學習方式。這篇教程通過由一段簡短的 python 程式碼實現的非常簡單的例項來講解 BP 反向傳播演算法。
程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 |
X = np.array([ [0,0,1],[0,1,1],[1,0,1],[1,1,1] ]) y = np.array([[0,1,1,0]]).T syn0 = 2*np.random.random((3,4)) - 1 syn1 = 2*np.random.random((4,1)) - 1 for j in xrange(60000): l1 = 1/(1+np.exp(-(np.dot(X,syn0)))) l2 = 1/(1+np.exp(-(np.dot(l1,syn1)))) l2_delta = (y - l2)*(l2*(1-l2)) l1_delta = l2_delta.dot(syn1.T) * (l1 * (1-l1)) syn1 += l1.T.dot(l2_delta) syn0 += X.T.dot(l1_delta) |
當然,上述程式可能過於簡練了。下面我會將其簡要分解成幾個部分進行探討。
第一部分:一個簡潔的神經網路
一個用 BP 演算法訓練的神經網路嘗試著用輸入去預測輸出。
考慮以上情形:給定三列輸入,試著去預測對應的一列輸出。我們可以通過簡單測量輸入與輸出值的資料來解決這一問題。這樣一來,我們可以發現最左邊的一列輸入值和輸出值是完美匹配/完全相關的。直觀意義上來講,反向傳播演算法便是通過這種方式來衡量資料間統計關係進而得到模型的。下面直入正題,動手實踐。
2 層神經網路:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
import numpy as np # sigmoid function def nonlin(x,deriv=False): if(deriv==True): return x*(1-x) return 1/(1+np.exp(-x)) # input dataset X = np.array([ [0,0,1], [0,1,1], [1,0,1], [1,1,1] ]) # output dataset y = np.array([[0,0,1,1]]).T # seed random numbers to make calculation # deterministic (just a good practice) np.random.seed(1) # initialize weights randomly with mean 0 syn0 = 2*np.random.random((3,1)) - 1 for iter in xrange(10000): # forward propagation l0 = X l1 = nonlin(np.dot(l0,syn0)) # how much did we miss? l1_error = y - l1 # multiply how much we missed by the # slope of the sigmoid at the values in l1 l1_delta = l1_error * nonlin(l1,True) # update weights syn0 += np.dot(l0.T,l1_delta) print "Output After Training:" print l1 |
Output After Training:
[[ 0.00966449]
[ 0.00786506]
[ 0.99358898]
[ 0.99211957]]
變數 | 定義說明 | |
---|---|---|
X | 輸入資料集,形式為矩陣,每 1 行代表 1 個訓練樣本。 | |
y | 輸出資料集,形式為矩陣,每 1 行代表 1 個訓練樣本。 | |
l0 | 網路第 1 層,即網路輸入層。 | |
l1 | 網路第 2 層,常稱作隱藏層。 | |
syn0 | 第一層權值,突觸 0 ,連線 l0 層與 l1 層。 | |
* | 逐元素相乘,故兩等長向量相乘等同於其對等元素分別相乘,結果為同等長度的向量。 | |
– | 元素相減,故兩等長向量相減等同於其對等元素分別相減,結果為同等長度的向量。 | |
x.dot(y) | 若 x 和 y 為向量,則進行點積操作;若均為矩陣,則進行矩陣相乘操作;若其中之一為矩陣,則進行向量與矩陣相乘操作。 |
正如在“訓練後結果輸出”中看到的,程式正確執行!在描述具體過程之前,我建議讀者事先去嘗試理解並執行下程式碼,對演算法程式的工作方式有一個直觀的感受。最好能夠在 ipython notebook 中原封不動地跑通以上程式(或者你想自己寫個指令碼也行,但我還是強烈推薦 notebook )。下面是對理解程式有幫助的幾個關鍵地方:
- 對比 l1 層在首次迭代和最後一次迭代時的狀態。
- 仔細察看 “nonlin” 函式,正是它將一個概率值作為輸出提供給我們。
- 仔細觀察在迭代過程中,l1_error 是如何變化的。
- 將第 36 行中的表示式拆開來分析,大部分祕密武器就在這裡面。
- 仔細理解第 39 行程式碼,網路中所有操作都是在為這步運算做準備。
下面,讓我們一行一行地把程式碼過一遍。
建議:用兩個螢幕來開啟這篇部落格,這樣你就能對照著程式碼來閱讀文章。在部落格撰寫時,我也正是這麼做的。 :)
第 1 行:這裡匯入一個名叫 numpy 的線性代數工具庫,它是本程式中唯一的外部依賴。
第 4 行:這裡是我們的“非線性”部分。雖然它可以是許多種函式,但在這裡,使用的非線性對映為一個稱作 “sigmoid” 的函式。Sigmoid 函式可以將任何值都對映到一個位於 0 到 1 範圍內的值。通過它,我們可以將實數轉化為概率值。對於神經網路的訓練, Sigmoid 函式也有其它幾個非常不錯的特性。
第 5 行: 注意,通過 “nonlin” 函式體還能得到 sigmod 函式的導數(當形參 deriv 為 True 時)。Sigmoid 函式優異特性之一,在於只用它的輸出值便可以得到其導數值。若 Sigmoid 的輸出值用變數 out 表示,則其導數值可簡單通過式子 out *(1-out) 得到,這是非常高效的。
若你對求導還不太熟悉,那麼你可以這樣理解:導數就是 sigmod 函式曲線在給定點上的斜率(如上圖所示,曲線上不同的點對應的斜率不同)。有關更多導數方面的知識,可以參考可汗學院的導數求解教程。
第 10 行:這行程式碼將我們的輸入資料集初始化為 numpy 中的矩陣。每一行為一個“訓練例項”,每一列的對應著一個輸入節點。這樣,我們的神經網路便有 3 個輸入節點,4 個訓練例項。
第 16 行:這行程式碼對輸出資料集進行初始化。在本例中,為了節省空間,我以水平格式( 1 行 4 列)定義生成了資料集。“.T” 為轉置函式。經轉置後,該 y 矩陣便包含 4 行 1 列。同我們的輸入一致,每一行是一個訓練例項,而每一列(僅有一列)對應一個輸出節點。因此,我們的網路含有 3 個輸入, 1 個輸出。
第 20 行:為你的隨機數設定產生種子是一個良好的習慣。這樣一來,你得到的權重初始化集仍是隨機分佈的,但每次開始訓練時,得到的權重初始集分佈都是完全一致的。這便於觀察你的策略變動是如何影響網路訓練的。
第 23 行:這行程式碼實現了該神經網路權重矩陣的初始化操作。用 “syn0” 來代指 “零號突觸”(即“輸入層-第一層隱層”間權重矩陣)。由於我們的神經網路只有 2 層(輸入層與輸出層),因此只需要一個權重矩陣來連線它們。權重矩陣維度為(3,1),是因為神經網路有 3 個輸入和 1 個輸出。換種方式來講,也就是 l0 層大小為 3 , l1 層大小為 1 。因此,要想將 l0 層的每個神經元節點與 l1 層的每個神經元節點相連,就需要一個維度大小為(3,1)的連線矩陣。:)
同時,要注意到隨機初始化的權重矩陣均值為 0 。關於權重的初始化,裡面可有不少學問。因為我們現在還只是練習,所以在權值初始化時設定均值為 0 就可以了。
另一個認識就是,所謂的“神經網路”實際上就是這個權值矩陣。雖然有“層” l0 和 l1 ,但它們都是基於資料集的瞬時值,即層的輸入輸出狀態隨不同輸入資料而不同,這些狀態是不需要儲存的。在學習訓練過程中,只需儲存 syn0 權值矩陣。
第 25 行:本行程式碼開始就是神經網路訓練的程式碼了。本 for 迴圈迭代式地多次執行訓練程式碼,使得我們的網路能更好地擬合訓練集。
第 28 行:可知,網路第一層 l0 就是我們的輸入資料,關於這點,下面作進一步闡述。還記得 X 包含 4 個訓練例項(行)吧?在該部分實現中,我們將同時對所有的例項進行處理,這種訓練方式稱作“整批”訓練。因此,雖然我們有 4 個不同的 l0 行,但你可以將其整體視為單個訓練例項,這樣做並沒有什麼差別。(我們可以在不改動一行程式碼的前提下,一次性裝入 1000 個甚至 10000 個例項)。
第 29 行:這是神經網路的前向預測階段。基本上,首先讓網路基於給定輸入“試著”去預測輸出。接著,我們將研究預測效果如何,以至於作出一些調整,使得在每次迭代過程中網路能夠表現地更好一點。
(4 x 3) dot (3 x 1) = (4 x 1)
本行程式碼包含兩個步驟。首先,將 l0 與 syn0 進行矩陣相乘。然後,將計算結果傳遞給 sigmoid 函式。具體考慮到各個矩陣的維度:
(4 x 3) dot (3 x 1) = (4 x 1)
矩陣相乘是有約束的,比如等式靠中間的兩個維度必須一致。而最終產生的矩陣,其行數為第一個矩陣的行數,列數則為第二個矩陣的列數。
由於裝入了 4 個訓練例項,因此最終得到了 4 個猜測結果,即一個(4 x 1)的矩陣。每一個輸出都對應,給定輸入下網路對正確結果的一個猜測。也許這也能直觀地解釋:為什麼我們可以“載入”任意數目的訓練例項。在這種情況下,矩陣乘法仍是奏效的。
第 32 行:這樣,對於每一輸入,可知 l1 都有對應的一個“猜測”結果。那麼通過將真實的結果(y)與猜測結果(l1)作減,就可以對比得到網路預測的效果怎麼樣。l1_error 是一個有正數和負陣列成的向量,它可以反映出網路的誤差有多大。
第 36 行:現在,我們要碰到乾貨了!這裡就是祕密武器所在!本行程式碼資訊量比較大,所以將它拆成兩部分來分析。
第一部分:求導
1 |
nonlin(l1,True) |
如果 l1 可表示成 3 個點,如下圖所示,以上程式碼就可產生圖中的三條斜線。注意到,如在 x=2.0 處(綠色點)輸出值很大時,及如在x=-1.0 處(紫色點)輸出值很小時,斜線都非常十分平緩。如你所見,斜度最高的點位於 x=0 處(藍色點)。這一特性非常重要。另外也可發現,所有的導數值都在 0 到 1 範圍之內。
整體認識:誤差項加權導數值
1 |
l1_delta = l1_error * nonlin(l1,True) |
當然,“誤差項加權導數值”這個名詞在數學上還有更為嚴謹的描述,不過我覺得這個定義準確地捕捉到了演算法的意圖。 l1_error 是一個(4,1)大小的矩陣,nonlin(l1,True)返回的便是一個(4,1)的矩陣。而我們所做的就是將其“逐元素地”相乘,得到的是一個(4,1)大小的矩陣 l1_delta ,它的每一個元素就是元素相乘的結果。
當我們將“斜率”乘上誤差時,實際上就在以高確信度減小預測誤差。回過頭來看下 sigmoid 函式曲線圖!當斜率非常平緩時(接近於 0),那麼網路輸出要麼是一個很大的值,要麼是一個很小的值。這就意味著網路十分確定是否是這種情況,或是另一種情況。然而,如果網路的判定結果對應(x = 0.5,y = 0.5)附近時,它便就不那麼確定了。對於這種“似是而非”預測情形,我們對其做最大的調整,而對確定的情形則不多做處理,乘上一個接近於 0 的數,則對應的調整量便可忽略不計。
第 39 行:現在,更新網路已準備就緒!下面一起來看下一個簡單的訓練示例。
在這個訓練示例中,我們已經為權值更新做好了一切準備。下面讓我們來更新最左邊的權值(9.5)。
權值更新量 = 輸入值 * l1_delta
對於最左邊的權值,在上式中便是 1.0 乘上 l1_delta 的值。可以想得到,這對權值 9.5 的增量是可以忽略不計的。為什麼只有這麼小的更新量呢?是因為我們對於預測結果十分確信,而且預測結果有很大把握是正確的。誤差和斜率都偏小時,便意味著一個較小的更新量。考慮所有的連線權值,這三個權值的增量都是非常小的。
然而,由於採取的是“整批”訓練的機制,因此上述更新步驟是在全部的 4 個訓練例項上進行的,這看上去也有點類似於影像。那麼,第 39 行做了什麼事情呢?在這簡單的一行程式碼中,它共完成了下面幾個操作:首先計算每一個訓練例項中每一個權值對應的權值更新量,再將每個權值的所有更新量累加起來,接著更新這些權值。親自推導下這個矩陣相乘操作,你便能明白它是如何做到這一點的。
重點結論:
現在,我們已經知曉神經網路是如何進行更新的。回過頭來看看訓練資料,作一些深入思考。 當輸入和輸出均為 1 時,我們增加它們間的連線權重;當輸入為 1 而輸出為 0 時,我們減小其連線權重。
因此,在如下 4 個訓練示例中,第一個輸入結點與輸出節點間的權值將持續增大或者保持不變,而其他兩個權值在訓練過程中表現為同時增大或者減小(忽略中間過程)。這種現象便使得網路能夠基於輸入與輸出間的聯絡進行學習。
第二部分:一個稍顯複雜的問題
考慮如下情形:給定前兩列輸入,嘗試去預測輸出列。一個關鍵點在於這兩列與輸出不存在任何關聯,每一列都有 50% 的機率預測結果為 1 ,也有 50% 的機率預測為 0 。
那麼現在的輸出模式會是怎樣呢?看起來似乎與第三列毫不相關,其值始終為 1 。而第 1 列和第 2 列可以有更為清晰的認識,當其中 1 列值為1(但不同時為 1 !)時,輸出便為 1 。這邊是我們要找的模式!
以上可以視為一種“非線性”模式,因為單個輸入與輸出間不存在一個一對一的關係。而輸入的組合與輸出間存在著一對一的關係,在這裡也就是列 1 和列 2 的組合。
信不信由你,影像識別也是一種類似的問題。若有 100 張尺寸相同的菸斗圖片和腳踏車圖片,那麼,不存在單個畫素點位置能夠直接說明某張圖片是腳踏車還是菸斗。單純從統計角度來看,這些畫素可能也是隨機分佈的。然而,某些畫素的組合卻不是隨機的,也就是說,正是這種組合才形成了一輛腳踏車或者是一個人。
我們的策略
由上可知,畫素組合後的產物與輸出存在著一對一的關係。為了先完成這種組合,我們需要額外增加一個網路層。第一層對輸入進行組合,然後以第一層的輸出作為輸入,通過第二層的對映得到最終的輸出結果。在給出具體實現之前,我們來看下這張表格。
權重隨機初始化好後,我們便得到了層1的隱態值。注意到什麼了嗎?第二列(第二個隱層結點)已經同輸出有一定的相關度了!雖不是十分完美,但也可圈可點。無論你是否相信,尋找這種相關性在神經網路訓練中佔了很大比重。(甚至可以認定,這也是訓練神經網路的唯一途徑),隨後的訓練要做的便是將這種關聯進一步增大。syn1 權值矩陣將隱層的組合輸出對映到最終結果,而在更新 syn1 的同時,還需要更新 syn0 權值矩陣,以從輸入資料中更好地產生這些組合。
註釋:通過增加更多的中間層,以對更多關係的組合進行建模。這一策略正是廣為人們所熟知的“深度學習”,因為其正是通過不斷增加更深的網路層來建模的。
3 層神經網路:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
import numpy as np def nonlin(x,deriv=False): if(deriv==True): return x*(1-x) return 1/(1+np.exp(-x)) X = np.array([[0,0,1], [0,1,1], [1,0,1], [1,1,1]]) y = np.array([[0], [1], [1], [0]]) np.random.seed(1) # randomly initialize our weights with mean 0 syn0 = 2*np.random.random((3,4)) - 1 syn1 = 2*np.random.random((4,1)) - 1 for j in xrange(60000): # Feed forward through layers 0, 1, and 2 l0 = X l1 = nonlin(np.dot(l0,syn0)) l2 = nonlin(np.dot(l1,syn1)) # how much did we miss the target value? l2_error = y - l2 if (j% 10000) == 0: print "Error:" + str(np.mean(np.abs(l2_error))) # in what direction is the target value? # were we really sure? if so, don't change too much. l2_delta = l2_error*nonlin(l2,deriv=True) # how much did each l1 value contribute to the l2 error (according to the weights)? l1_error = l2_delta.dot(syn1.T) # in what direction is the target l1? # were we really sure? if so, don't change too much. l1_delta = l1_error * nonlin(l1,deriv=True) syn1 += l1.T.dot(l2_delta) syn0 += l0.T.dot(l1_delta) |
變數 | 定義說明 |
---|---|
X | 輸入資料集,形式為矩陣,每 1 行代表 1 個訓練樣本。 |
y | 輸出資料集,形式為矩陣,每 1 行代表 1 個訓練樣本。 |
l0 | 網路第 1 層,即網路輸入層。 |
l1 | 網路第 2 層,常稱作隱藏層。 |
l2 | 假定為網路最後一層,隨著訓練進行,其輸出應該逐漸接近正確結果 |
syn0 | 第一層權值,突觸 0 ,連線 l0 層與 l1 層。 |
syn1 | 第二層權值,突觸 1 ,連線 l1 層與 l2 層。 |
l2_error | 該值說明了神經網路預測時“丟失”的數目。 |
l2_delta | 該值為經確信度加權後的神經網路的誤差,除了確信誤差很小時,它近似等於預測誤差。 |
l1_error | 該值為 l2_delta 經 syn1 加權後的結果,從而能夠計算得到中間層/隱層的誤差。 |
l1_delta | 該值為經確信度加權後的神經網路 l1 層的誤差,除了確信誤差很小時,它近似等於 l1_error 。 |
一切看起來都如此熟悉!這只是用這樣兩個先前的實現相互堆疊而成的,第一層(l1)的輸出就是第二層的輸入。唯一所出現的新事物便是第 43 行程式碼。
第 43 行:通過對 l2 層的誤差進行“置信度加權”,構建 l1 層相應的誤差。為了做到這點,只要簡單的通過 l2 與 l1 間的連線權值來傳遞誤差。這種做法也可稱作“貢獻度加權誤差”,因為我們學習的是,l1 層每一個結點的輸出值對 l2 層節點誤差的貢獻程度有多大。接著,用之前 2 層神經網路實現中的相同步驟,對 syn0 權值矩陣進行更新。
第三部分:總結與展望
個人建議:
如果你想認真弄懂神經網路,給你一點建議:憑藉記憶嘗試去重構這個網路。我知道這聽起來有一些瘋狂,但確實會有幫助的。如果你想能基於新的學術文章創造任意結構的神經網路,或者讀懂不同網路結構的樣例程式,我覺得這項訓練會是一個殺手鐗。即使當你在使用一些開源框架時,比如 Torch ,Caffe 或者 Theano ,這也會有所幫助的。在執行這種練習之前,我接觸神經網路有好幾年了。而這段時間也是我在這一領域所作的做好的投資(也沒有花費很長時間)。
工作展望
這個示例仍需附加一些其它功能,才能真正與業內最佳的網路結構相媲美。如果你想進一步改進你的網路,這裡給出一些參考點。(後續可能還有更新)
學習速率
想要從事機器學習方面的工作?
學習機器學習最好的途徑,就是找一份相關的工作,這樣你就能更專業地去實踐機器學習。找工作時,建議你去檢視下 Digital Reasoning 上面的職位,也儘管在我的 LinkedIn 上給我發訊息。我很樂意傾聽你對職業生涯的規劃,也可以幫助你評估 Digital Reasoning 上的職位是否合適。
假如裡面的職位你覺得都不太合適,繼續找找看!機器學習,是如今職場中最有價值的一項技能。