1. 程式人生 > >人人都能用Python寫出LSTM-RNN的程式碼![你的神經網路學習最佳起步]

人人都能用Python寫出LSTM-RNN的程式碼![你的神經網路學習最佳起步]

0. 前言

本文翻譯自部落格: iamtrask.github.io ,這次翻譯已經獲得trask本人的同意與支援,在此特別感謝trask。本文屬於作者一邊學習一邊翻譯的作品,所以在用詞、理論方面難免會出現很多錯誤,假如您發現錯誤或者不合適的地方,可以給我留言,謝謝!

--- 2016.7.26 UPDATE ---

不涉及商業用途無須告知本人即可轉載,但請註明出處!

---

1. 概要

我的最佳學習法就是通過玩具程式碼,一邊除錯一邊學習理論。這篇部落格通過一個非常簡單的python玩具程式碼來講解迴圈神經網路。

那麼依舊是廢話少說,放‘碼’過來!

import copy, numpy as np
np.random.seed(0)

# compute sigmoid nonlinearity
def sigmoid(x):
    output = 1/(1+np.exp(-x))
    return output

# convert output of sigmoid function to its derivative
def sigmoid_output_to_derivative(output):
    return output*(1-output)


# training dataset generation
int2binary = {}
binary_dim = 8

largest_number = pow(2,binary_dim)
binary = np.unpackbits(
    np.array([range(largest_number)],dtype=np.uint8).T,axis=1)
for i in range(largest_number):
    int2binary[i] = binary[i]


# input variables
alpha = 0.1
input_dim = 2
hidden_dim = 16
output_dim = 1


# initialize neural network weights
synapse_0 = 2*np.random.random((input_dim,hidden_dim)) - 1
synapse_1 = 2*np.random.random((hidden_dim,output_dim)) - 1
synapse_h = 2*np.random.random((hidden_dim,hidden_dim)) - 1

synapse_0_update = np.zeros_like(synapse_0)
synapse_1_update = np.zeros_like(synapse_1)
synapse_h_update = np.zeros_like(synapse_h)

# training logic
for j in range(10000):
    
    # generate a simple addition problem (a + b = c)
    a_int = np.random.randint(largest_number/2) # int version
    a = int2binary[a_int] # binary encoding

    b_int = np.random.randint(largest_number/2) # int version
    b = int2binary[b_int] # binary encoding

    # true answer
    c_int = a_int + b_int
    c = int2binary[c_int]
    
    # where we'll store our best guess (binary encoded)
    d = np.zeros_like(c)

    overallError = 0
    
    layer_2_deltas = list()
    layer_1_values = list()
    layer_1_values.append(np.zeros(hidden_dim))
    
    # moving along the positions in the binary encoding
    for position in range(binary_dim):
        
        # generate input and output
        X = np.array([[a[binary_dim - position - 1],b[binary_dim - position - 1]]])
        y = np.array([[c[binary_dim - position - 1]]]).T

        # hidden layer (input ~+ prev_hidden)
        layer_1 = sigmoid(np.dot(X,synapse_0) + np.dot(layer_1_values[-1],synapse_h))

        # output layer (new binary representation)
        layer_2 = sigmoid(np.dot(layer_1,synapse_1))

        # did we miss?... if so by how much?
        layer_2_error = y - layer_2
        layer_2_deltas.append((layer_2_error)*sigmoid_output_to_derivative(layer_2))
        overallError += np.abs(layer_2_error[0])
    
        # decode estimate so we can print it out
        d[binary_dim - position - 1] = np.round(layer_2[0][0])
        
        # store hidden layer so we can use it in the next timestep
        layer_1_values.append(copy.deepcopy(layer_1))
    
    future_layer_1_delta = np.zeros(hidden_dim)
    
    for position in range(binary_dim):
        
        X = np.array([[a[position],b[position]]])
        layer_1 = layer_1_values[-position-1]
        prev_layer_1 = layer_1_values[-position-2]
        
        # error at output layer
        layer_2_delta = layer_2_deltas[-position-1]
        # error at hidden layer
        layer_1_delta = (future_layer_1_delta.dot(synapse_h.T) + \
            layer_2_delta.dot(synapse_1.T)) * sigmoid_output_to_derivative(layer_1)
        # let's update all our weights so we can try again
        synapse_1_update += np.atleast_2d(layer_1).T.dot(layer_2_delta)
        synapse_h_update += np.atleast_2d(prev_layer_1).T.dot(layer_1_delta)
        synapse_0_update += X.T.dot(layer_1_delta)
        
        future_layer_1_delta = layer_1_delta
    

    synapse_0 += synapse_0_update * alpha
    synapse_1 += synapse_1_update * alpha
    synapse_h += synapse_h_update * alpha    

    synapse_0_update *= 0
    synapse_1_update *= 0
    synapse_h_update *= 0
    
    # print out progress
    if(j % 1000 == 0):
        print "Error:" + str(overallError)
        print "Pred:" + str(d)
        print "True:" + str(c)
        out = 0
        for index,x in enumerate(reversed(d)):
            out += x*pow(2,index)
        print str(a_int) + " + " + str(b_int) + " = " + str(out)
        print "------------"



執行輸出:
Error:[ 3.45638663]
Pred:[0 0 0 0 0 0 0 1]
True:[0 1 0 0 0 1 0 1]
9 + 60 = 1
------------
Error:[ 3.63389116]
Pred:[1 1 1 1 1 1 1 1]
True:[0 0 1 1 1 1 1 1]
28 + 35 = 255
------------
Error:[ 3.91366595]
Pred:[0 1 0 0 1 0 0 0]
True:[1 0 1 0 0 0 0 0]
116 + 44 = 72
------------
Error:[ 3.72191702]
Pred:[1 1 0 1 1 1 1 1]
True:[0 1 0 0 1 1 0 1]
4 + 73 = 223
------------
Error:[ 3.5852713]
Pred:[0 0 0 0 1 0 0 0]
True:[0 1 0 1 0 0 1 0]
71 + 11 = 8
------------
Error:[ 2.53352328]
Pred:[1 0 1 0 0 0 1 0]
True:[1 1 0 0 0 0 1 0]
81 + 113 = 162
------------
Error:[ 0.57691441]
Pred:[0 1 0 1 0 0 0 1]
True:[0 1 0 1 0 0 0 1]
81 + 0 = 81
------------
Error:[ 1.42589952]
Pred:[1 0 0 0 0 0 0 1]
True:[1 0 0 0 0 0 0 1]
4 + 125 = 129
------------
Error:[ 0.47477457]
Pred:[0 0 1 1 1 0 0 0]
True:[0 0 1 1 1 0 0 0]
39 + 17 = 56
------------
Error:[ 0.21595037]
Pred:[0 0 0 0 1 1 1 0]
True:[0 0 0 0 1 1 1 0]
11 + 3 = 14
------------

第一部分:什麼是神經元記憶?

正向的背一邊字母表……你能做到,對吧?

倒著背一遍字母表……唔……也許有點難。

那麼試試你熟悉的一首歌詞?……為什麼正常順序回憶的時候比倒著回憶更簡單呢?你能直接跳躍到第二小節的歌詞麼?……唔唔……同樣很難,是吧?

其實這很符合邏輯……你並不像計算機那樣把字母表或者歌詞像儲存在硬碟一樣的記住,你是把它們作為一個序列去記憶的。你很擅長於一個單詞一個單詞的去回憶起它們,這是一種條件記憶。你只有在擁有了前邊部分的記憶了以後,才能想起來後邊的部分。如果你對連結串列比較熟悉的話,OK,我們的記憶就和連結串列是類似的。

然而,這並不意味著當你不唱歌時,你的記憶中就沒有這首歌。而是說,當你試圖直接記憶起某個中間的部分,你需要花費一定的時間在你的腦海中尋找(也許是在一大堆神經元裡尋找)。大腦開始在這首歌裡到處尋找你想要的中間部分,但是大腦之前並沒有這麼做過,所以它並沒有一個能夠指向中間這部分的索引。這就像住在一個附近都是岔路/死衚衕的地方,你從大路上到某人的房子很簡單,因為你經常那樣走。但是把你丟在一家人的後院裡,你卻怎麼也找不到正確的道路了。可見你的大腦並不是用“方位”去尋找,而是通過一首歌的開頭所在的神經元去尋找的。如果你想了解更多關於大腦的知識,可以訪問:

http://www.human-memory.net/processes_recall.html

就像連結串列一樣,記憶這樣去儲存是很有效的。這樣可以通過腦神經網路很好的找到相似的屬性、優勢。一些過程、難題、表示、查詢也可以通過這種短期/偽條件記憶序列儲存的方式,使其更加的高效。

去記憶一些資料是序列的事情(其實就是意味著你有些東西需要去記住!),假設有一個跳跳球,每個資料點就是你眼中跳跳球運動的一幀影象。如果你想訓練一個神經網路去預測下一幀球會在哪裡,那麼知道上一幀球在哪裡就會對你的預測很有幫助!這樣的序列資料就是我們為什麼要搭建一個迴圈神經網路。那麼,一個神經網路怎麼記住它之前的時間它看到了什麼呢?

神經網路有隱藏層,一般來講,隱藏層的狀態只跟輸入資料有關。所以一般來說一個神經網路的資訊流就會像下面所示的這樣:

input -> hidden ->output

這很明顯,確定的輸入產生確定的隱藏層,確定的隱藏層產生確定的輸出層。這是一種封閉系統。但是,記憶改變了這種模式!記憶意味著隱藏層是,當前時刻的輸入與隱藏層前一時刻的一種組合。

( input + prev_hidden ) -> hidden -> output

為什麼是隱藏層呢?其實技術上來說我們可以這樣:

( input + prev_input ) -> hidden -> output

然而,我們遺漏了一些東西。我建議你認真想想這兩個資訊流的不同。給你點提示,演繹一下它們分別是怎麼運作的。這裡呢,我們給出4步的迴圈神經網路流程看看它怎麼從之前的隱藏層得到資訊。

( input + empty_hidden ) -> hidden -> output

( input + prev_hidden   ) -> hidden -> output

( input + prev_hidden   ) -> hidden -> output

( input + prev_hidden   ) -> hidden -> output

然後,我們再給出4步,從輸入層怎麼得到資訊。

( input + empty_input ) -> hidden -> output

( input + prev_input    ) -> hidden -> output

( input + prev_input    ) -> hidden -> output

( input + prev_input    ) -> hidden -> output

或許,如果我把一些部分塗上顏色,一些東西就顯而易見了。那麼我們再看看這4步隱藏層的迴圈:

( input + empty_hidden ) ->hidden -> output

( input + prev_hidden   ) ->hidden -> output

( input + prev_hidden   ) ->hidden -> output

( input + prev_hidden   ) ->hidden -> output

……以及,4步輸入層的迴圈:

( input + empty_input ) -> hidden -> output

( input + prev_input    ) -> hidden -> output

( input + prev_input    ) -> hidden -> output

( input + prev_input    ) -> hidden -> output

看一下最後一個隱藏層(第四行)。在隱藏層迴圈中,我們可以看到所有見過的輸入的存在。但是在輸入層迴圈中,我們僅僅能發現上次與本次的輸入。這就是為什麼我們用隱藏層迴圈建模。隱藏層迴圈能學習它到底去記憶什麼,但是輸入層迴圈僅僅能記住上次的資料點。

現在我們對比一下這兩種方法,通過反向的字母表與歌詞中間部分的練習。隱藏層根據越來越多的輸入持續的改變,而且,我們到達這些隱藏狀態的唯一方式就是沿著正確的輸入序列。現在就到了很重要的一點,輸出由隱藏層決定,而且只有通過正確的輸入序列才能到達隱藏層。是不是很相似?

那麼有什麼實質的區別呢?我們考慮一下我們要預測歌詞中的下一個詞,假如碰巧在不同的地方有兩個相同的詞,“輸出層迴圈”就會使你回憶不起來下面的歌詞到底是什麼了。仔細想想,如果一首歌有一句“我愛你”,以及“我愛蘿蔔”,記憶網路現在試圖去預測下一個詞,那它怎麼知道“我愛”後邊到底是什麼?可能是“你”,也可能是“蘿蔔”。所以記憶網路必須要知道更多的資訊,去識別這到底是歌詞中的那一段。而“隱藏層迴圈”不會讓你忘記歌詞,就是通過這個原理。它巧妙地記住了它看到的所有東西(記憶更巧妙地是它能隨時間逐漸忘卻)。想看看它是怎麼運作的,猛戳這裡:http://karpathy.github.io/2015/05/21/rnn-effectiveness/

好的,現在停下來,然後確認你的腦袋是清醒的。

第二部分:RNN - 神經網路記憶

現在我們已經對這個問題有個直觀的認識了,讓我們下潛的更深一點(什麼鬼,你在逗我?)。就像在反向傳播這篇博文(http://blog.csdn.net/zzukun/article/details/49556715)裡介紹的那樣,輸入資料決定了我們神經網路的輸入層。每行輸入資料都被用來產生隱含層(通過正向傳播),然後用每個隱含層生成輸出層(假設只有一層隱含層)。就像我們剛才看到的,記憶意味著隱含層是輸入與上一次隱含層的組合。那麼怎麼組合呢?其實就像神經網路的其他傳播方法,用一個矩陣就行了,這個矩陣定義了之前隱含層與當前的關係。


從這張圖中能看出來很多東西。這裡只有三個權值矩陣,其中兩個很相似(名字也一樣)。SYNAPSE_0把輸入資料傳播到隱層,SYNAPSE_1把隱層資料傳播到輸出層。新的矩陣(SYNAPSE_h……要迴圈的),把隱含層(layer_1)傳播到下一個時間點的隱含層(仍舊是layer_1)。

好的,現在停下來,然後確認你的腦袋是清醒的。


上邊的GIF圖展現出迴圈神經網路的奧祕,以及一些非常、非常重要的性質。圖中描述了4個時間步數,第一個僅僅受到輸入資料的影響,第二個把第二個輸入與第一個的隱含層混合,如此繼續。有人可能會注意到,在這種方式下,第四個網路“滿了”。這樣推測的話,第五步不得不選擇一個某個節點去替代掉它。是的,這很正確。這就是記憶的“容量”概念。正如你所期望的,更多的隱含層節點能夠儲存更多的記憶,並使記憶保持更長的時間。同樣這也是網路學習去忘記無關的記憶並且記住重要的記憶。你在能從第三步中看出點什麼不?為什麼有更多的綠色節點呢?

另外需要注意的是,隱含層是輸入與輸出中間的一道柵欄。事實上,輸出已經不再是對應於輸入的一個函式。輸入只是改變了記憶中儲存的東西,而且輸出僅僅依賴於記憶!告訴你另外一個有趣的事情,如果上圖中的第2,3,4步沒有輸入,隨著時間的流逝,隱含層仍然會改變。

好的,好的,我知道你已經停下來了,不過一定要保證剛才的內容你已經差不多理解了。

第三部分:基於時間的反向傳播

那麼現在問題來了,迴圈神經網路怎麼學習的呢?看下面的圖片,黑色的是預測,誤差是亮黃色,導數是芥末色的(暗黃色)。


網路通過從1到4的全部傳播(通過任意長度的整個序列),然後從4到1反向傳播所有的導數值。你也可以認為這僅僅是正常神經網路的一個有意思的變形,除了我們在各自的地方複用了相同的權值(突觸synapses 0,1,h)。其他的地方都是很普通的反向傳播。

第四部分:我們的玩具程式碼

我們現在使用迴圈神經網路去建模二進位制加法。你看到下面的序列了麼?上邊這倆在方框裡的,有顏色的1是什麼意思呢?


框框中彩色的1表示“攜帶位”。當每個位置的和溢位時(需要進位),它們“攜帶這個‘1’”。我們就是要教神經網路學習去記住這個“攜帶位”。當“和”需要它,它需要去“攜帶這個‘1’”。

二進位制加法從右邊到左邊進行計算,我們試圖通過上邊的數字,去預測橫線下邊的數字。我們想讓神經網路遍歷這個二進位制序列並且記住它攜帶這個1與沒有攜帶這個1的時候,這樣的話網路就能進行正確的預測了。不要迷戀於這個問題本身,因為神經網路事實上也不在乎。就當作我們有兩個在每個時間步數上的輸入(1或者0加到每個數字的開頭),這兩個輸入將會傳播到隱含層,隱含層會記住是否有攜帶位。預測值會考慮所有的資訊,然後去預測每個位置(時間步數)正確的值。

下面我推薦同時開啟兩個這個頁面,這樣就可以一邊看程式碼,一邊看下面的解釋。我就是這麼寫這篇文章的。

Lines 0-2:匯入依賴包,設定隨機數生成的種子。我們只需要兩個依賴包,numpy和copy。numpy是為了矩陣計算,copy用來拷貝東西。

Line 15:這一行聲明瞭一個查詢表,這個表是一個實數與對應二進位制表示的對映。二進位制表示將會是我們網路的輸入與輸出,所以這個查詢表將會幫助我們將實數轉化為其二進位制表示。

Line 16:這裡設定了二進位制數的最大長度。如果一切都除錯好了,你可以把它調整為一個非常大的數。

Line 18:這裡計算了跟二進位制最大長度對應的可以表示的最大十進位制數。

Line 19:這裡生成了十進位制數轉二進位制數的查詢表,並將其複製到int2binary裡面。雖然說這一步不是必需的,但是這樣的話理解起來會更方便。

Line 26:這裡設定了學習速率。

Line 27:我們要把兩個數加起來,所以我們一次要輸入兩位字元。如此以來,我們的網路就需要兩個輸入。

Line 28:這是隱含層的大小,回來儲存“攜帶位”。需要注意的是,它的大小比原理上所需的要大。自己嘗試著調整一下這個值,然後看看它如何影響收斂速率。更高的隱含層維度會使訓練變慢還是變快?更多或是更少的迭代次數?

Line 29:我們只是預測和的值,也就是一個數。如此,我們只需一個輸出。

Line 33:這個權值矩陣連線了輸入層與隱含層,如此它就有“imput_dim”行以及“hidden_dim”列(假如你不改引數的話就是2×16)。

Line 34:這個權值矩陣連線了隱含層與輸出層,如此它就有“hidden_dim”行以及“output_dim”列(假如你不改引數的話就是16×1)。

Line 35:這個權值矩陣連線了前一時刻的隱含層與現在時刻的隱含層。它同樣連線了當前時刻的隱含層與下一時刻的隱含層。如此以來,它就有隱含層維度大小(hidden_dim)的行與隱含層維度大小(hidden_dim)的列(假如你沒有修改引數就是16×16)。

Line 37-39:這裡儲存權值更新。在我們積累了一些權值更新以後,我們再去更新權值。這裡先放一放,稍後我們再詳細討論。

Line 42:我們迭代訓練樣例10000次。

Line 45:這裡我們要隨機生成一個在範圍內的加法問題。所以我們生成一個在0到最大值一半之間的整數。如果我們允許網路的表示超過這個範圍,那麼把兩個數加起來就有可能溢位(比如一個很大的數導致我們的位數不能表示)。所以說,我們只把加法要加的兩個數字設定在小於最大值的一半。

Line 46:我們查詢a_int對應的二進位制表示,然後把它存進a裡面。

Line 48:原理同45行。

Line 49:原理同46行。

Line 52:我們計算加法的正確結果。

Line 53:把正確結果轉化為二進位制表示。

Line 56:初始化一個空的二進位制陣列,用來儲存神經網路的預測值(便於我們最後輸出)。你也可以不這樣做,但是我覺得這樣使事情變得更符合直覺。

Line 58:重置誤差值(這是我們使用的一種記錄收斂的方式……可以參考之前關於反向傳播與梯度下降的文章)

Line 60-61:這兩個list會每個時刻不斷的記錄layer 2的導數值與layer 1的值。

Line 62:在0時刻是沒有之前的隱含層的,所以我們初始化一個全為0的。

Line 65:這個迴圈是遍歷二進位制數字。

Line 68:X跟圖片中的“layer_0”是一樣的,X陣列中的每個元素包含兩個二進位制數,其中一個來自a,一個來自b。它通過position變數從a,b中檢索,從最右邊往左檢索。所以說,當position等於0時,就檢索a最右邊的一位和b最右邊的一位。當position等於1時,就向左移一位。

Line 69:跟68行檢索的方式一樣,但是把值替代成了正確的結果(0或者1)。

Line 72:這裡就是奧妙所在!一定一定一定要保證你理解這一行!!!為了建立隱含層,我們首先做了兩件事。第一,我們從輸入層傳播到隱含層(np.dot(X,synapse_0))。然後,我們從之前的隱含層傳播到現在的隱含層(np.dot(prev_layer_1.synapse_h))。在這裡,layer_1_values[-1]就是取了最後一個存進去的隱含層,也就是之前的那個隱含層!然後我們把兩個向量加起來!!!!然後再通過sigmoid函式。

那麼,我們怎麼結合之前的隱含層資訊與現在的輸入呢?當每個都被變數矩陣傳播過以後,我們把資訊加起來。

Line 75:這行看起來很眼熟吧?這跟之前的文章類似,它從隱含層傳播到輸出層,即輸出一個預測值。

Line 78:計算一下預測誤差(預測值與真實值的差)。

Line 79:這裡我們把導數值存起來(上圖中的芥末黃),即把每個時刻的導數值都保留著。

Line 80:計算誤差的絕對值,並把它們加起來,這樣我們就得到一個誤差的標量(用來衡量傳播)。我們最後會得到所有二進位制位的誤差的總和。

Line 86:將layer_1的值拷貝到另外一個數組裡,這樣我們就可以下一個時間使用這個值。

Line 90:我們已經完成了所有的正向傳播,並且已經計算了輸出層的導數,並將其存入在一個列表裡了。現在我們需要做的就是反向傳播,從最後一個時間點開始,反向一直到第一個。

Line 92:像之前那樣,檢索輸入資料。

Line 93:從列表中取出當前的隱含層。

Line 94:從列表中取出前一個隱含層。

Line 97:從列表中取出當前輸出層的誤差。

Line 99:這一行計算了當前隱含層的誤差。通過當前之後一個時間點的誤差和當前輸出層的誤差計算。

Line 102-104:我們已經有了反向傳播中當前時刻的導數值,那麼就可以生成權值更新的量了(但是還沒真正的更新權值)。我們會在完成所有的反向傳播以後再去真正的更新我們的權值矩陣,這是為什麼呢?因為我們要用權值矩陣去做反向傳播。如此以來,在完成所有反向傳播以前,我們不能改變權值矩陣中的值。

Line 109-115:現在我們就已經完成了反向傳播,得到了權值要更新的量,所以就趕快更新權值吧(別忘了重置update變數)!

Line 118-end:這裡僅僅是一些輸出日誌,便於我們觀察中間的計算過程與效果。

第五步分:建議與評論

如果您有什麼疑問、意見與建議可以直接留言評論,或者給我email([email protected]),或直接聯絡trask本人,感謝您的支援!