深度學習筆記(基礎篇)——(三)神經網絡和反向傳播算法(BP)

往期回顧

在上一篇文章中,我們已經掌握了機器學習的基本套路,對模型、目標函數、優化算法這些概念有了一定程度的理解,而且已經會訓練單個的感知器或者線性單元了。在這篇文章中,我們將把這些單獨的單元按照一定的規則相互連接在一起形成神經網絡,從而奇蹟般的獲得了強大的學習能力。我們還將介紹這種網絡的訓練算法:反向傳播算法。最後,我們依然用代碼實現一個神經網絡。如果您能堅持到本文的結尾,將會看到我們用自己實現的神經網絡去識別手寫數字。現在請做好準備,您即將雙手觸及到深度學習的大門。

一、神經元

神經元和感知器本質上是一樣的,只不過我們說感知器的時候,它的激活函數是階躍函數;而當我們說神經元時,激活函數往往選擇爲sigmoid函數或tanh函數。如下圖所示:

計算一個神經元的輸出的方法和計算一個感知器的輸出是一樣的。假設神經元的輸入是向量\vec{x},權重向量是\vec{w}(偏置項是w_{0}),激活函數是sigmoid函數,則其輸出y:

sigmoid函數的導數是:

可以看到,sigmoid函數的導數非常有趣,它可以用sigmoid函數自身來表示。這樣,一旦計算出sigmoid函數的值,計算它的導數的值就非常方便。

1.1 神經網絡是啥

神經網絡其實就是按照一定規則連接起來的多個神經元。上圖展示了一個全連接(full connected, FC)神經網絡,通過觀察上面的圖,我們可以發現它的規則包括:

  • 神經元按照來佈局。最左邊的層叫做輸入層,負責接收輸入數據;最右邊的層叫輸出層,我們可以從這層獲取神經網絡輸出數據。輸入層和輸出層之間的層叫做隱藏層,因爲它們對於外部來說是不可見的
  • 同一層的神經元之間沒有連接。
  • 第N層的每個神經元和第N-1層的所有神經元相連(這就是full connected的含義),第N-1層神經元的輸出就是第N層神經元的輸入。
  • 每個連接都有一個權值

上面這些規則定義了全連接神經網絡的結構。事實上還存在很多其它結構的神經網絡,比如卷積神經網絡(CNN)、循環神經網絡(RNN),他們都具有不同的連接規則。

1.2 計算神經網絡的輸出

神經網絡實際上就是一個輸入向量\vec{x}到輸出向量\vec{y}的函數,即:

根據輸入計算神經網絡的輸出,需要首先將輸入向量\vec{x}的每個元素x_{i}的值賦給神經網絡的輸入層的對應神經元,然後根據式1依次向前計算每一層的每個神經元的值,直到最後一層輸出層的所有神經元的值計算完畢。最後,將輸出層每個神經元的值串在一起就得到了輸出向量\vec{y}。以上一層的輸出作爲下一層的輸入。這種網絡被稱爲前饋神經網絡

接下來舉一個例子來說明前饋這個過程,我們先給神經網絡的每個單元寫上編號。

1.3 神經網絡的矩陣表示

神經網絡的計算如果用矩陣來表示會很方便(當然逼格也更高),我們先來看看隱藏層的矩陣表示。

首先我們把隱藏層4個節點的計算依次排列出來:

現在,我們把上述計算的四個式子寫到一個矩陣裏面,每個式子作爲矩陣的一行,就可以利用矩陣來表示它們的計算了。令

    每一層的算法都是一樣的。比如,對於包含一個輸入層,一個輸出層和三個隱藏層的神經網絡,我們假設其權重矩陣分別爲W_{1},W_{2},W_{3},W_{4},每個隱藏層的輸出分別是\vec{a_{1}},\vec{a_{2}},\vec{a_{3}},神經網絡的輸入爲\vec{x},神經網絡的輸出爲\vec{y},如下圖所示:

則每一層的輸出向量的計算可以表示爲:

這就是神經網絡輸出值的計算方法。

二、神經網絡的訓練

現在,我們需要知道一個神經網絡的每個連接上的權值是如何得到的。我們可以說神經網絡是一個模型,那麼這些權值就是模型的參數,也就是模型要學習的東西。然而,一個神經網絡的連接方式、網絡的層數、每層的節點數這些參數,則不是學習出來的,而是人爲事先設置的。對於這些人爲設置的參數,我們稱之爲超參數(Hyper-Parameters)

我們希望有⼀個算法,能讓我們找到權重和偏置,以至於網絡的輸出 y(x) 能夠擬合所有的訓練輸入 x。爲了量化我們如何實現這個目標,我們定義一個代價函數(有時被稱爲損失或目標函數):

                                           

        這裏 w 表示所有的網絡中權重的集合,b 是所有的偏置,n 是訓練輸入數據的個數,a 是表示當輸入爲 x 時輸出的向量,求和則是在總的訓練輸入 x 上進行的。當然,輸出 a 取決於 x, w和 b,但是爲了保持符號的簡潔性,我沒有明確地指出這種依賴關係。符號 ∥v∥ 是指向量 v 的模。我們把 C 稱爲二次代價函數;有時也稱被稱爲均方誤差或者 MSE。我們訓練神經網絡的目的是找到能最小化二次代價函數C(w,b)的權重和偏置

接下來,我們將要介紹神經網絡的訓練算法:反向傳播算法。

反向傳播算法(Back Propagation)

我們首先直觀的介紹反向傳播算法,最後再來介紹這個算法的推導。當然讀者也可以完全跳過推導部分,因爲即使不知道如何推導,也不影響你寫出來一個神經網絡的訓練代碼。事實上,現在神經網絡成熟的開源實現多如牛毛,除了練手之外,你可能都沒有機會需要去寫一個神經網絡。

我們以監督學習爲例來解釋反向傳播算法。在零基礎入門深度學習(2) - 線性單元和梯度下降一文中我們介紹了什麼是監督學習,如果忘記了可以再看一下。另外,我們設神經元的激活函數f爲sigmoid函數(不同激活函數的計算公式不同,詳情見反向傳播算法的推導一節)。

我們假設每個訓練樣本爲(\vec{x},\vec{t})(\vec{x},\vec{t}),其中向量是訓練樣本的特徵,而\vec{t}是樣本的目標值。

首先,我們根據上一節介紹的算法,用樣本的特徵\vec{x},計算出神經網絡中每個隱藏層節點的輸出a_{i},以及輸出層每個節點的輸出y_{i}

然後,我們按照下面的方法計算出每個節點的誤差項\delta _{i}

  • 對於輸出層節點i,

我們已經介紹了神經網絡每個節點誤差項的計算和權重更新方法。顯然,計算一個節點的誤差項,需要先計算每個與其相連的下一層節點的誤差項。這就要求誤差項的計算順序必須是從輸出層開始,然後反向依次計算每個隱藏層的誤差項直到與輸入層相連的那個隱藏層。這就是反向傳播算法的名字的含義。當所有節點的誤差項計算完畢後,我們就可以根據式5來更新所有的權重。

以上就是基本的反向傳播算法,並不是很複雜,您弄清楚了麼?

反向傳播算法的推導

反向傳播算法其實就是鏈式求導法則的應用。然而,這個如此簡單且顯而易見的方法,卻是在Roseblatt提出感知器算法將近30年之後才被髮明和普及的。對此,Bengio這樣迴應道:

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

接下來,我們用鏈式求導法則來推導反向傳播算法,也就是上一小節的式3式4式5

前方高能預警——接下來是數學公式重災區,讀者可以酌情閱讀,不必強求。

按照機器學習的通用套路,我們先確定神經網絡的目標函數,然後用隨機梯度下降優化算法去求目標函數最小值時的參數值。

我們取網絡所有輸出層節點的誤差平方和作爲目標函數:

——數學公式警報解除——

至此,我們已經推導出了反向傳播算法。需要注意的是,我們剛剛推導出的訓練規則是根據激活函數是sigmoid函數、平方和誤差、全連接網絡、隨機梯度下降優化算法。如果激活函數不同、誤差計算方式不同、網絡連接結構不同、優化算法不同,則具體的訓練規則也會不一樣。但是無論怎樣,訓練規則的推導方式都是一樣的,應用鏈式求導法則進行推導即可

神經網絡的實現

完整代碼請參考GitHub: https://github.com/hanbt/learn_dl/blob/master/bp.py (python2.7)

現在,我們要根據前面的算法,實現一個基本的全連接神經網絡,這並不需要太多代碼。我們在這裏依然採用面向對象設計。

首先,我們先做一個基本的模型:

如上圖,可以分解出5個領域對象來實現神經網絡:

  • Network 神經網絡對象,提供API接口。它由若干層對象組成以及連接對象組成。
  • Layer 層對象,由多個節點組成。
  • Node 節點對象計算和記錄節點自身的信息(比如輸出值a、誤差項\delta等),以及與這個節點相關的上下游的連接。
  • Connection 每個連接對象都要記錄該連接的權重。
  • Connections 僅僅作爲Connection的集合對象,提供一些集合操作。

Node實現如下:

# 節點類,負責記錄和維護節點自身信息以及與這個節點相關的上下游連接,實現輸出值和誤差項的計算。
class Node(object):
    def __init__(self, layer_index, node_index):
        '''
        構造節點對象。
        layer_index: 節點所屬的層的編號
        node_index: 節點的編號
        '''
        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):
        '''
        設置節點的輸出值。如果節點屬於輸入層會用到這個函數。
        '''
        self.output = output
    def append_downstream_connection(self, conn):
        '''
        添加一個到下游節點的連接
        '''
        self.downstream.append(conn)
    def append_upstream_connection(self, conn):
        '''
        添加一個到上游節點的連接
        '''
        self.upstream.append(conn)
    def calc_output(self):
        '''
        根據式1計算節點的輸出
        '''
        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):
        '''
        節點屬於隱藏層時,根據式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):
        '''
        節點屬於輸出層時,根據式3計算delta
        '''
        self.delta = self.output * (1 - self.output) * (label - self.output)
    def __str__(self):
        '''
        打印節點的信息
        '''
        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對象,爲了實現一個輸出恆爲1的節點(計算偏置項\omega _{b}時需要)

class ConstNode(object):
    def __init__(self, layer_index, node_index):
        '''
        構造節點對象。
        layer_index: 節點所屬的層的編號
        node_index: 節點的編號
        '''    
        self.layer_index = layer_index
        self.node_index = node_index
        self.downstream = []
        self.output = 1
    def append_downstream_connection(self, conn):
        '''
        添加一個到下游節點的連接
        '''       
        self.downstream.append(conn)
    def calc_hidden_layer_delta(self):
        '''
        節點屬於隱藏層時,根據式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):
        '''
        打印節點的信息
        '''
        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對象,負責初始化一層。此外,作爲Node的集合對象,提供對Node集合的操作。

class Layer(object):
    def __init__(self, layer_index, node_count):
        '''
        初始化一層
        layer_index: 層編號
        node_count: 層所包含的節點個數
        '''
        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):
        '''
        設置層的輸出。當層是輸入層時會用到。
        '''
        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對象,主要職責是記錄連接的權重,以及這個連接所關聯的上下游節點。

class Connection(object):
    def __init__(self, upstream_node, downstream_node):
        '''
        初始化連接,權重初始化爲是一個很小的隨機數
        upstream_node: 連接的上游節點
        downstream_node: 連接的下游節點
        '''
        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):
        '''
        獲取當前的梯度
        '''
        return self.gradient
    def update_weight(self, rate):
        '''
        根據梯度下降算法更新權重
        '''
        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):
        '''
        初始化一個全連接神經網絡
        layers: 二維數組,描述神經網絡每層節點數
        '''
        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):
        '''
        訓練神經網絡
        labels: 數組,訓練樣本標籤。每個元素是一個樣本的標籤。
        data_set: 二維數組,訓練樣本特徵。每個元素是一個樣本的特徵。
        '''
        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):
        '''
        內部函數,用一個樣本訓練網絡
        '''
        self.predict(sample)
        self.calc_delta(label)
        self.update_weight(rate)
    def calc_delta(self, label):
        '''
        內部函數,計算每個節點的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):
        '''
        內部函數,更新每個連接權重
        '''
        for layer in self.layers[:-1]:
            for node in layer.nodes:
                for conn in node.downstream:
                    conn.update_weight(rate)
    def calc_gradient(self):
        '''
        內部函數,計算每個連接的梯度
        '''
        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):
        '''
        獲得網絡在一個樣本下,每個連接上的梯度
        label: 樣本標籤
        sample: 樣本輸入
        '''
        self.predict(sample)
        self.calc_delta(label)
        self.calc_gradient()
    def predict(self, sample):
        '''
        根據輸入的樣本預測輸出值
        sample: 數組,樣本的特徵,也就是網絡的輸入向量
        '''
        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):
        '''
        打印網絡信息
        '''
        for layer in self.layers:
            layer.dump()

至此,實現了一個基本的全連接神經網絡。可以看到,同神經網絡的強大學習能力相比,其實現還算是很容易的。

梯度檢查

怎麼保證自己寫的神經網絡沒有BUG呢?事實上這是一個非常重要的問題。一方面,千辛萬苦想到一個算法,結果效果不理想,那麼是算法本身錯了還是代碼實現錯了呢?定位這種問題肯定要花費大量的時間和精力。另一方面,由於神經網絡的複雜性,我們幾乎無法事先知道神經網絡的輸入和輸出,因此類似TDD(測試驅動開發)這樣的開發方法似乎也不可行。

辦法還是有滴,就是利用梯度檢查來確認程序是否正確。梯度檢查的思路如下:

對於梯度下降算法:

def gradient_check(network, sample_feature, sample_label):
    '''
    梯度檢查
    network: 神經網絡對象
    sample_feature: 樣本的特徵
    sample_label: 樣本的標籤
    '''
    # 計算網絡誤差
    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)))
    # 獲取網絡在當前樣本下每個連接的梯度
    network.get_gradient(sample_feature, sample_label)
    # 對每個權重做梯度檢查    
    for conn in network.connections.connections: 
        # 獲取指定連接的梯度
        actual_gradient = conn.get_gradient()
        # 增加一個很小的值,計算網絡的誤差
        epsilon = 0.0001
        conn.weight += epsilon
        error1 = network_error(network.predict(sample_feature), sample_label)
        # 減去一個很小的值,計算網絡的誤差
        conn.weight -= 2 * epsilon # 剛纔加過了一次,因此這裏需要減去2倍
        error2 = network_error(network.predict(sample_feature), sample_label)
        # 根據式6計算期望的梯度值
        expected_gradient = (error2 - error1) / (2 * epsilon)
        # 打印
        print 'expected gradient: \t%f\nactual gradient: \t%f' % (
            expected_gradient, actual_gradient)

至此,會推導、會實現、會抓BUG,你已經摸到深度學習的大門了。接下來還需要不斷的實踐,我們用剛剛寫過的神經網絡去識別手寫數字。

神經網絡實戰——手寫數字識別

針對這個任務,我們採用業界非常流行的MNIST數據集。MNIST大約有60000個手寫字母的訓練樣本,我們使用它訓練我們的神經網絡,然後再用訓練好的網絡去識別手寫數字。

手寫數字識別是個比較簡單的任務,數字只可能是0-9中的一個,這是個10分類問題。

超參數的確定

我們首先需要確定網絡的層數和每層的節點數。關於第一個問題,實際上並沒有什麼理論化的方法,大家都是根據經驗來拍,如果沒有經驗的話就隨便拍一個。然後,你可以多試幾個值,訓練不同層數的神經網絡,看看哪個效果最好就用哪個。嗯,現在你可能明白爲什麼說深度學習是個手藝活了,有些手藝很讓人無語,而有些手藝還是很有技術含量的。

不過,有些基本道理我們還是明白的,我們知道網絡層數越多越好,也知道層數越多訓練難度越大。對於全連接網絡,隱藏層最好不要超過三層。那麼,我們可以先試試僅有一個隱藏層的神經網絡效果怎麼樣。畢竟模型小的話,訓練起來也快些(剛開始玩模型的時候,都希望快點看到結果)。

輸入層節點數是確定的。因爲MNIST數據集每個訓練數據是28*28的圖片,共784個像素,因此,輸入層節點數應該是784,每個像素對應一個輸入節點。

輸出層節點數也是確定的。因爲是10分類,我們可以用10個節點,每個節點對應一個分類。輸出層10個節點中,輸出最大值的那個節點對應的分類,就是模型的預測結果。

隱藏層節點數量是不好確定的,從1到100萬都可以。下面有幾個經驗公式:

因此,我們可以先根據上面的公式設置一個隱藏層節點數。如果有時間,我們可以設置不同的節點數,分別訓練,看看哪個效果最好就用哪個。我們先拍一個,設隱藏層節點數爲300吧。

模型的訓練和評估

MNIST數據集包含10000個測試樣本。我們先用60000個訓練樣本訓練我們的網絡,然後再用測試樣本對網絡進行測試,計算識別錯誤率:

我們每訓練10輪,評估一次準確率。當準確率開始下降時(出現了過擬合)終止訓練。

代碼實現

首先,我們需要把MNIST數據集處理爲神經網絡能夠接受的形式。MNIST訓練集的文件格式可以參考官方網站,這裏不在贅述。每個訓練樣本是一個28*28的圖像,我們按照行優先,把它轉化爲一個784維的向量。每個標籤是0-9的值,我們將其轉換爲一個10維的one-hot向量:如果標籤值爲n,我們就把向量的第n維(從0開始編號)設置爲0.9,而其它維設置爲0.1。例如,向量[0.1,0.1,0.9,0.1,0.1,0.1,0.1,0.1,0.1,0.1]表示值2。

下面是處理MNIST數據的代碼:

#!/usr/bin/env python
# -*- coding: UTF-8 -*-
import struct
from bp import *
from datetime import datetime
# 數據加載器基類
class Loader(object):
    def __init__(self, path, count):
        '''
        初始化加載器
        path: 數據文件路徑
        count: 文件中的樣本個數
        '''
        self.path = path
        self.count = count
    def get_file_content(self):
        '''
        讀取文件內容
        '''
        f = open(self.path, 'rb')
        content = f.read()
        f.close()
        return content
    def to_int(self, byte):
        '''
        將unsigned byte字符轉換爲整數
        '''
        return struct.unpack('B', byte)[0]
# 圖像數據加載器
class ImageLoader(Loader):
    def get_picture(self, content, index):
        '''
        內部函數,從文件中獲取圖像
        '''
        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):
        '''
        內部函數,將圖像轉化爲樣本的輸入向量
        '''
        sample = []
        for i in range(28):
            for j in range(28):
                sample.append(picture[i][j])
        return sample
    def load(self):
        '''
        加載數據文件,獲得全部樣本的輸入向量
        '''
        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
# 標籤數據加載器
class LabelLoader(Loader):
    def load(self):
        '''
        加載數據文件,獲得全部樣本的標籤向量
        '''
        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):
        '''
        內部函數,將一個值轉換爲10維標籤向量
        '''
        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():
    '''
    獲得訓練數據集
    '''
    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():
    '''
    獲得測試數據集
    '''
    image_loader = ImageLoader('t10k-images-idx3-ubyte', 10000)
    label_loader = LabelLoader('t10k-labels-idx1-ubyte', 10000)
    return image_loader.load(), label_loader.load()

網絡的輸出是一個10維向量,這個向量第n個(從0開始編號)元素的值最大,那麼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

最後實現我們的訓練策略:每訓練10輪,評估一次準確率,當準確率開始下降時終止訓練。下面是代碼實現:

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多秒,所以要對代碼做很多的性能優化工作(比如用向量化編程)。訓練要很久很久,可以把它上傳到服務器上,在tmux的session裏面去運行。爲了防止異常終止導致前功盡棄,我們每訓練10輪,就把獲得參數值保存在磁盤上,以便後續可以恢復。(代碼略)

向量化編程

完整代碼請參考GitHub: https://github.com/hanbt/learn_dl/blob/master/fc.py (python2.7)

在經歷了漫長的訓練之後,我們可能會想到,肯定有更好的辦法!是的,程序員們,現在我們需要告別面向對象編程了,轉而去使用另外一種更適合深度學習算法的編程方式:向量化編程。主要有兩個原因:一個是我們事實上並不需要真的去定義Node、Connection這樣的對象,直接把數學計算實現了就可以了;另一個原因,是底層算法庫會針對向量運算做優化(甚至有專用的硬件,比如GPU),程序效率會提升很多。所以,在深度學習的世界裏,我們總會想法設法的把計算表達爲向量的形式。我相信優秀的程序員不會把自己拘泥於某種(自己熟悉的)編程範式上,而會去學習並使用最爲合適的範式。

下面,我們用向量化編程的方法,重新實現前面的全連接神經網絡

首先,我們需要把所有的計算都表達爲向量的形式。對於全連接神經網絡來說,主要有三個計算公式。

現在,我們根據上面幾個公式,重新實現一個類:FullConnectedLayer。它實現了全連接層的前向和後向計算:

# 全連接層實現類
class FullConnectedLayer(object):
    def __init__(self, input_size, output_size, 
                 activator):
        '''
        構造函數
        input_size: 本層輸入向量的維度
        output_size: 本層輸出向量的維度
        activator: 激活函數
        '''
        self.input_size = input_size
        self.output_size = output_size
        self.activator = activator
        # 權重數組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):
        '''
        使用梯度下降算法更新權重
        '''
        self.W += learning_rate * self.W_grad
        self.b += learning_rate * self.b_grad

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

現在,我們對Network類稍作修改,使之用到FullConnectedLayer:

# Sigmoid激活函數類
class SigmoidActivator(object):
    def forward(self, weighted_input):
        return 1.0 / (1.0 + np.exp(-weighted_input))
    def backward(self, output):
        return output * (1 - output)
# 神經網絡類
class Network(object):
    def __init__(self, layers):
        '''
        構造函數
        '''
        self.layers = []
        for i in range(len(layers) - 1):
            self.layers.append(
                FullConnectedLayer(
                    layers[i], layers[i+1],
                    SigmoidActivator()
                )
            )
    def predict(self, sample):
        '''
        使用神經網絡實現預測
        sample: 輸入樣本
        '''
        output = sample
        for layer in self.layers:
            layer.forward(output)
            output = layer.output
        return output
    def train(self, labels, data_set, rate, epoch):
        '''
        訓練函數
        labels: 樣本標籤
        data_set: 輸入樣本
        rate: 學習速率
        epoch: 訓練輪數
        '''
        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)

現在,Network類也清爽多了,用我們的新代碼再次訓練一下MNIST數據集吧。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章