1. 程式人生 > 其它 >反向傳播神經網路極簡入門

反向傳播神經網路極簡入門

我一直在找一份簡明的神經網路入門,然而在中文圈裡並沒有找到。直到我看到了這份162行的Python實現,以及對應的油管視訊之後,我才覺得這就是我需要的極簡入門資料。這份極簡入門筆記不需要突觸的圖片做裝飾,也不需要贅述神經網路的發展歷史;要推導有推導,要程式碼有程式碼,關鍵是,它們還對得上。對於欠缺的背景知識,利用斯坦福大學的神經網路wiki進行了補全。

單個神經元

神經網路是多個“神經元”(感知機)的帶權級聯,神經網路演算法可以提供非線性的複雜模型,它有兩個引數:權值矩陣{Wl}和偏置向量{bl},不同於感知機的單一向量形式,{Wl}是複數個矩陣,{bl}是複數個向量,其中的元素分別屬於單個層,而每個層的組成單元,就是神經元。

神經元

神經網路是由多個“神經元”(感知機)組成的,每個神經元圖示如下:

這其實就是一個單層感知機,其輸入是由

和+1組成的向量,其輸出為

,其中f是一個啟用函式,模擬的是生物神經元在接受一定的刺激之後產生興奮訊號,否則刺激不夠的話,神經元保持抑制狀態這種現象。這種由一個閥值決定兩個極端的函式有點像示性函式,然而這裡採用的是Sigmoid函式,其優點是連續可導。

Sigmoid函式

常用的Sigmoid有兩種——

單極性Sigmoid函式

或者寫成

其影象如下

雙極性Sigmoid函式

或者寫成

把第一個式子分子分母同時除以ez,令x=-2z就得到第二個式子了,換湯不換藥。

其影象如下

從它們兩個的值域來看,兩者名稱裡的極性應該指的是正負號。從導數來看,它們的導數都非常便於計算:

對於

,對於tanh,有

視訊作者Ryan還擔心觀眾微積分學的不好,細心地給出了1/(1+e^-x)求導的過程:

一旦知道了f(z),就可以直接求f'(z),所以說很方便。

本Python實現使用的就是1/(1+e^-x)

def sigmoid(x):
    """
    sigmoid 函式,1/(1+e^-x)
    :param x:
    :return:
    """
    return 1.0/(1.0+math.exp(-x))
 
 
def dsigmoid(y):
    """
    sigmoid 函式的導數
    :param y:
    :return:
    """
    return y * (1 - y)

也可以使用雙曲正切函式tanh

def sigmoid(x):
    """
    sigmoid 函式,tanh 
    :param x:
    :return:
    """
    return math.tanh(x)

其導數對應於:

def dsigmoid(y):
    """
    sigmoid 函式的導數
    :param y:
    :return:
    """
    return 1.0 - y ** 2

神經網路模型

神經網路就是多個神經元的級聯,上一級神經元的輸出是下一級神經元的輸入,而且訊號在兩級的兩個神經元之間傳播的時候需要乘上這兩個神經元對應的權值。例如,下圖就是一個簡單的神經網路:

其中,一共有一個輸入層,一個隱藏層和一個輸出層。輸入層有3個輸入節點,標註為+1的那個節點是偏置節點,偏置節點不接受輸入,輸出總是+1。

定義上標為層的標號,下標為節點的標號,則本神經網路模型的引數是:

,其中

是第l層的第j個節點與第l+1層第i個節點之間的連線引數(或稱權值);

表示第l層第i個偏置節點。這些符號在接下來的前向傳播將要用到。

前向傳播

雖然標題是《(誤差)後向傳播神經網路入門》,但這並不意味著可以跳過前向傳播的學習。因為如果後向傳播對應訓練的話,那麼前向傳播就對應預測(分類),並且訓練的時候計算誤差也要用到預測的輸出值來計算誤差。

定義

為第l層第i個節點的啟用值(輸出值)。當l=1時,

。前向傳播的目的就是在給定模型引數

的情況下,計算l=2,3,4…層的輸出值,直到最後一層就得到最終的輸出值。具體怎麼算呢,以上圖的神經網路模型為例:

這沒什麼稀奇的,核心思想是這一層的輸出乘上相應的權值加上偏置量代入啟用函式等於下一層的輸入,一句大白話,所謂中文偽碼。

另外,追求好看的話可以把括號裡面那個老長老長的加權和定義為一個引數:

表示第l層第i個節點的輸入加權和,比如

。那麼該節點的輸出可以寫作

於是就得到一個好看的形式:

在這個好看的形式下,前向傳播可以簡明扼要地表示為:

在Python實現中,對應如下方法:

def runNN(self, inputs):
        """
        前向傳播進行分類
        :param inputs:輸入
        :return:類別
        """
        if len(inputs) != self.ni - 1:
            print 'incorrect number of inputs'
 
        for i in range(self.ni - 1):
            self.ai[i] = inputs[i]
 
        for j in range(self.nh):
            sum = 0.0
            for i in range(self.ni):
                sum += ( self.ai[i] * self.wi[i][j] )
            self.ah[j] = sigmoid(sum)
 
        for k in range(self.no):
            sum = 0.0
            for j in range(self.nh):
                sum += ( self.ah[j] * self.wo[j][k] )
            self.ao[k] = sigmoid(sum)
 
        return self.ao

其中,ai、ah、ao分別是輸入層、隱藏層、輸出層,而wi、wo則分別是輸入層到隱藏層、隱藏層到輸出層的權值矩陣。在本Python實現中,將偏置量一併放入了矩陣,這樣進行線性代數運算就會方便一些。

後向傳播

後向傳播指的是在訓練的時候,根據最終輸出的誤差來調整倒數第二層、倒數第三層……第一層的引數的過程。

符號定義

在Ryan的講義中,符號定義與斯坦福前向傳播講義相似但略有不同:

:第l層第j個節點的輸入。

:從第l-1層第i個節點到第l層第j個節點的權值。

:Sigmoid函式。

:第l層第j個節點的偏置。

:第l層第j個節點的輸出。

:輸出層第j個節點的目標值(Target value)。

輸出層權值調整

給定訓練集

和模型輸出

(這裡沒有上標l是因為這裡在討論輸出層,l是固定的),輸出層的輸出誤差(或稱損失函式吧)定義為:

其實就是所有例項對應的誤差的平方和的一半,訓練的目標就是最小化該誤差。怎麼最小化呢?看損失函式對引數的導數

唄。

將E的定義代入該導數:

無關變數拿出來:

看到這裡大概明白為什麼非要把誤差定義為誤差平方和的一半了吧,就是為了好看,數學家都是外貌協會的。

=

(輸出層的輸出等於輸入代入Sigmoid函式)這個關係代入有:

對Sigmoid求導有:

要開始耍小把戲了,由於輸出層第k個節點的輸入

等於上一層第j個節點的輸出

,而上一層的輸出

是與到輸出層的權值變數無關的,所以對

求權值變數

的偏導數直接等於其本身,也就是說:

=

=

然後將上面用過的

=

代進去就得到最終的:

為了表述方便將上式記作:

其中:

隱藏層權值調整

依然採用類似的方法求導,只不過求的是關於隱藏層和前一層的權值引數的偏導數:

老樣子:

還是老樣子:

還是把Sigmoid弄進去:

=

代進去,並且將導數部分拆開:

又要耍把戲了,輸出層的輸入等於上一層的輸出乘以相應的權值,亦即

=

,於是得到:

把最後面的導數挪到前面去,接下來要對它動刀了:

再次利用

=

,這對j也成立,代進去:

再次利用

=

,j換成i,k換成j也成立,代進去:

利用剛才定義的

,最終得到:

其中:

我們還可以仿照

的定義來定義一個

,得到:

其中

偏置的調整

因為沒有任何節點的輸出流向偏置節點,所以偏置節點不存在上層節點到它所對應的權值引數,也就是說不存在關於權值變數的偏導數。雖然沒有流入,但是偏置節點依然有輸出(總是+1),該輸出到下一層某個節點的時候還是會有權值的,對這個權值依然需要更新。

我們可以直接對偏置求導,發現:

原視訊中說∂O/∂θ=1,這是不對的,作者也在講義中修正了這個錯誤,∂O/∂θ=O(1–O)。

然後再求

,後面的導數等於

,代進去有

其中,

後向傳播演算法步驟

  • 隨機初始化引數,對輸入利用前向傳播計算輸出。
  • 對每個輸出節點按照下式計算delta:
  • 對每個隱藏節點按照下式計算delta:
  • 計算梯度

,並更新權值引數和偏置引數:

。這裡的

是學習率,影響訓練速度。

後向傳播演算法實現

def backPropagate(self, targets, N, M):
        """
        後向傳播演算法
        :param targets: 例項的類別 
        :param N: 本次學習率
        :param M: 上次學習率
        :return: 最終的誤差平方和的一半
        """
        # http://www.youtube.com/watch?v=aVId8KMsdUU&feature=BFa&list=LLldMCkmXl4j9_v0HeKdNcRA
 
        # 計算輸出層 deltas
        # dE/dw[j][k] = (t[k] - ao[k]) * s'( SUM( w[j][k]*ah[j] ) ) * ah[j]
        output_deltas = [0.0] * self.no
        for k in range(self.no):
            error = targets[k] - self.ao[k]
            output_deltas[k] = error * dsigmoid(self.ao[k])
 
        # 更新輸出層權值
        for j in range(self.nh):
            for k in range(self.no):
                # output_deltas[k] * self.ah[j] 才是 dError/dweight[j][k]
                change = output_deltas[k] * self.ah[j]
                self.wo[j][k] += N * change + M * self.co[j][k]
                self.co[j][k] = change
 
        # 計算隱藏層 deltas
        hidden_deltas = [0.0] * self.nh
        for j in range(self.nh):
            error = 0.0
            for k in range(self.no):
                error += output_deltas[k] * self.wo[j][k]
            hidden_deltas[j] = error * dsigmoid(self.ah[j])
 
        # 更新輸入層權值
        for i in range(self.ni):
            for j in range(self.nh):
                change = hidden_deltas[j] * self.ai[i]
                # print 'activation',self.ai[i],'synapse',i,j,'change',change
                self.wi[i][j] += N * change + M * self.ci[i][j]
                self.ci[i][j] = change
 
        # 計算誤差平方和
        # 1/2 是為了好看,**2 是平方
        error = 0.0
        for k in range(len(targets)):
            error = 0.5 * (targets[k] - self.ao[k]) ** 2
        return error

注意不同於上文的單一學習率

,這裡有兩個學習率N和M。N相當於上文的

,而M則是在用上次訓練的梯度更新權值時的學習率。這種同時考慮最近兩次迭代得到的梯度的方法,可以看做是對單一學習率的改進。

另外,這裡並沒有出現任何更新偏置的操作,為什麼?

因為這裡的偏置是單獨作為一個偏置節點放到輸入層裡的,它的值(輸出,沒有輸入)固定為1,它的權值已經自動包含在上述權值調整中了。

如果將偏置作為分別繫結到所有神經元的許多值,那麼則需要進行偏置調整,而不需要權值調整(此時沒有偏置節點)。

哪個方便,當然是前者了,這也導致了大部分神經網路實現都採用前一種做法。

完整的實現

已開源到了Github上:https://github.com/hankcs/neural_net

這一模組的原作者是Neil Schemenauer,我做了些註釋。

直接執行bpnn.py即可得到輸出:

Combined error 0.171204877501
Combined error 0.190866985872
Combined error 0.126126875154
Combined error 0.0658488960415
Combined error 0.0353249077599
Combined error 0.0214428399072
Combined error 0.0144886807614
Combined error 0.0105787745309
Combined error 0.00816264126944
Combined error 0.00655731212209
Combined error 0.00542964723539
Combined error 0.00460235328667
Combined error 0.00397407912435
Combined error 0.00348339081276
Combined error 0.00309120476889
Combined error 0.00277163178862
Combined error 0.00250692771135
Combined error 0.00228457151714
Combined error 0.00209550313514
Combined error 0.00193302192499
Inputs: [0, 0] --> [0.9982333356008245] 	Target [1]
Inputs: [0, 1] --> [0.9647325217906978] 	Target [1]
Inputs: [1, 0] --> [0.9627966274767186] 	Target [1]
Inputs: [1, 1] --> [0.05966109502803293] 	Target [0]

IBM利用Neil Schemenauer的這一模組(舊版)做了一個識別程式碼語言的例子,我將其更新到新版,已經整合到了專案中。

要執行測試的話,執行命令

code_recognizer.py testdata.200

即可得到輸出:

ERROR_CUTOFF = 0.01
INPUTS = 20
ITERATIONS = 1000
MOMENTUM = 0.1
TESTSIZE = 500
OUTPUTS = 3
TRAINSIZE = 500
LEARNRATE = 0.5
HIDDEN = 8
Targets: [1, 0, 0] -- Errors: (0.000 OK)   (0.001 OK)   (0.000 OK)   -- SUCCESS!

值得一提的是,這裡的HIDDEN = 8指的是隱藏層的節點個數,不是層數,層數多了就變成DeepLearning了。