第7章 反向傳播算法
The longer you can look back, the farther you can look forward. - 丘吉爾
第 6 章我們已經系統地介紹完基礎的神經網絡算法:從輸入和輸出的表示開始,介紹感知機的模型,介紹多輸入、多輸出的全連接網絡層,然後擴展至多層神經網絡;介紹了針對不同的問題場景下輸出層的設計,最後介紹常用的損失函數,及實現方法。
本章我們將從理論層面學習神經網絡中的核心算法之一:反向傳播算法(Backpropagation,BP)。實際上,反向傳播算法在 1960 年代早期就已經被提出,然而並沒有引起業界重視。1970 年,Seppo Linnainmaa 在其碩士論文中提出了自動鏈式求導方法,並將反向傳播算法實現在計算機上。1974 年,Paul Werbos 在其博士論文中首次提出了將反向傳播算法應用到神經網絡的可能性,但遺憾的是,Paul Werbos 並沒有後續的相關研究發表。實際上,Paul Werbos 認爲,這種研究思路對解決感知機問題是有意義的,但是由於人工智能寒冬,這個圈子大體已經失去解決那些問題的信念。直到 10 年後,1986 年,Geoffrey Hinton 等人在神經網絡上應用反向傳播算法 (Rumelhart, Hinton, & Williams,1986),使得反向傳播算法在神經網絡中煥發出勃勃生機。
有了深度學習框架自動求導、自動更新權值的功能,算法設計者幾乎不需要對反向傳播算法有深入的瞭解也可以搭建複雜的模型和網絡,通過優化工具方便地訓練網絡模型。但是反向傳播算法和梯度下降算法是神經網絡的核心算法,深刻理解其工作原理十分重要。我們先回顧導數、梯度等數學概念,然後推導常用激活函數、損失函數的梯度形式,並開始逐漸推導感知機、多層神經網絡的梯度傳播方式。
7.1 導數與梯度
在高中階段,我們就接觸到導數(Derivative)的概念,它被定義爲自變量𝑥在處產生一個微小擾動∆𝑥後,函數輸出值的增量∆𝑦與自變量增量∆𝑥的比值在∆𝑥趨於 0 時的極限𝑎,如果存在,𝑎即爲在𝑥0處的導數:
函數的導數可以記爲或。從幾何角度來看,一元函數在某處的導數就是函數的切線在此處的斜率,函數值沿着𝑥方向的變化率。考慮物理學中例子:自由落體運動的位移函
數的表達式,位移對時間的導數,考慮到速度𝑣定義爲位移的變化率,因此𝑣 = gt,位移對時間的導數即爲速度。
實際上,(方向)導數是一個非常寬泛的概念,只是因爲我們以前接觸到的函數大多是一元函數,自變量∆𝑥只有 2 個方向:𝑥+,𝑥−。當函數的自變量大於一個時,函數的導數拓展爲函數值沿着任意∆𝒙方向的變化率。導數本身是標量,沒有方向,但是導數表徵了函數值在某個方向∆𝒙的變化率。在這些任意∆𝒙方向中,沿着座標軸的幾個方向比較特殊,此時的導數也叫做偏導數(Partial Derivative)。對於一元函數,導數記爲;對於多元函數的偏導數,記爲等。偏導數是導數的特例,也沒有方向。
考慮爲多元函數的神經網絡模型,比如 shape 爲[784, 256]的權值矩陣 W,它包含了784×256 個連接權值,我們需要求出 784*256 個偏導數。需要注意的是,在數學公式中,我們一般要討論的自變量是𝒙,但是在神經網絡中,𝒙一般用來表示輸入,比如圖片,文本,語音數據等,網絡的自變量是網絡參數集𝜃 = {𝑤1, 𝑏1, 𝑤2, 𝑏2, … },我們關心的也是誤差對網絡參數的偏導數 等。它其實就是函數輸出ℒ沿着某個自變量𝜃𝑖變化方向上的導數,即偏導數。利用梯度下降算法優化網絡時,需要求出網絡的所有偏導數。我們把函數所有偏導數寫成向量形式:
此時梯度下降算法可以按着向量形式進行更新:
梯度下降算法一般是尋找函數的最小值,有時希望求解函數的最大值,如增強學習中希望最大化獎勵函數,則可按着梯度方向更新
即可,這種方式的更新稱爲梯度上升算法。其中向量叫做函數的梯度(Gradient),它由所有偏導數組成,表徵方向,梯度的方向表示函數值上升最快的方向,梯度的反向表示函數值下降最快的方向。
通過梯度下降算法並不能保證得到全局最優解,這主要是目標函數的非凸性造成的。考慮圖 7.1 非凸函數,深藍色區域爲極小值區域,不同的優化軌跡可能得到不同的最優數值解。
神經網絡的模型表達式非常複雜,模型參數量可達千萬、數億級別,幾乎所有的神經網絡的優化問題都是依賴於深度學習框架去自動計算每層的梯度,然後採用梯度下降算法循環迭代優化網絡的參數直至性能滿足需求。
在介紹多層神經網絡的反向傳播算法之前,我們先介紹導數的常見屬性,常見激活函數、損失函數的梯度推導,然後再推導多層神經網絡的梯度傳播規律。
7.2 導數常見性質
本節介紹常見的求導法則和樣例,爲神經網絡函數求導鋪墊。
7.2.1 基本函數的導數
❑ 常數函數 c 導數爲 0,如𝑦 = 2函數導數𝑦′ = 0
❑ 線性函數𝑦 = 𝑎 ∗ 𝑥 + 𝑐 導數爲𝑎,如函數𝑦 = 2 ∗ 𝑥 + 1導數𝑦′ = 2
❑ 冪函數導數爲𝑎 ∗ ,如𝑦 = 函數𝑦′ = 2 ∗ 𝑥
❑ 指數函數 導數爲𝑎𝑥 ∗ 𝑙𝑛 𝑎,如𝑦 =函數𝑦′ = ∗ 𝑙𝑛 𝑒 =
❑ 對數函數導數爲 ,如𝑦 = 𝑙𝑛 𝑥函數𝑦′ =
7.2.2 常用導數性質
❑ 函數加減 (𝑓 + 𝑔)′ = 𝑓′ + 𝑔′
❑ 函數相乘 (𝑓𝑔)′ = 𝑓′ ∗ 𝑔 + 𝑓 ∗ 𝑔′
❑ 函數相除
❑ 複合函數的導數 考慮複合函數𝑓(𝑔(𝑥)),令𝑢 = 𝑔(𝑥),其導數
7.2.3 導數求解實戰
考慮目標函數ℒ = 𝑥 ∗ 𝑤2 + 𝑏2,則
考慮目標函數ℒ = 𝑥 ∗ + ,則
考慮目標函數,令𝑔 = 𝑥𝑤 + 𝑏 − y
考慮目標函數ℒ = 𝑎 𝑙𝑛(𝑥𝑤 + 𝑏),令𝑔 = 𝑥𝑤 + 𝑏,則
7.3 激活函數導數
7.3.1 Sigmoid 函數導數
回顧 Sigmoid 函數表達式:
我們來推導 Sigmoid 函數的導數表達式:
可以看到,Sigmoid 函數的導數表達式最終可以表達爲激活函數的輸出值的簡單運算,利用這一性質,在神經網絡的梯度計算中,通過緩存每層的 Sigmoid 函數輸出值,即可在需要的時候計算出其導數。Sigmoid 函數的導數曲線如圖 7.2 所示。
爲了不使用 TensorFlow 的自動求導功能,本章將使用 Numpy 實現一個通過反向傳播算法優化的多層神經網絡,因此本章的實現部分我們使用 Numpy 演示,實現 Sigmoid 函數的導數:
import numpy as np # 導入 numpy
def sigmoid(x): # sigmoid 函數
return 1 / (1 + np.exp(-x))
def derivative(x): # sigmoid 導數的計算
return sigmoid(x)*(1-sigmoid(x))
7.3.2 ReLU 函數導數
回顧 ReLU 函數的表達式:
它的導數推導非常簡單,直接可得
可以看到,ReLU 函數的導數計算簡單,x 大於等於零的時候,導數值恆爲 1,在反向傳播的時候,它既不會放大梯度,造成梯度爆炸(Gradient exploding);也不會縮小梯度,造成梯度彌散(Gradient vanishing)。ReLU 函數的導數曲線如圖 7.3 所示。
在 ReLU 函數被廣泛應用之前,神經網絡中激活函數採用 Sigmoid 居多,但是 Sigmoid函數容易出現梯度彌散現象,當網絡的層數增加後,較前層的參數由於梯度值非常微小,參數長時間得不到(有效)更新,無法訓練深層神經網絡,導致神經網絡的研究一直停留在淺層;隨着 ReLU 函數的提出,很好的解決了梯度彌散的現象,神經網絡的層數能夠地達到較深層數,如 AlexNet 中採用了 ReLU 激活函數,層數達到了 8 層,後續提出的上百層的卷積神經網絡也多是採用 ReLU 激活函數。
通過 Numpy,我們可以方便地實現 ReLU 函數的導數:
def derivative(x): # ReLU 函數的導數
d = np.array(x, copy=True) # 用於保存梯度的張量
d[x < 0] = 0 # 元素爲負的導數爲 0
d[x >= 0] = 1 # 元素爲正的元素導數爲 1
return d
7.3.3 LeakyReLU 函數導數
回顧 LeakyReLU 函數的表達式:
它的導數可以推導爲
它和 ReLU 函數的不同之處在於,當 x 小於零時,LeakyReLU 函數的導數值並不爲 0,而是𝑝,p 一般設置爲一個較小的數值,如 0.01 或 0.02,LeakyReLU 函數的導數曲線如圖 7.4所示。
LeakyReLU 函數有效的克服了 ReLU 函數的缺陷,使用也比較廣泛。我們可以通過Numpy 實現 LeakyReLU 函數的導數如下:
# 其中 p 爲 LeakyReLU 的負半段斜率
def derivative(x, p):
dx = np.ones_like(x) # 創建梯度張量
dx[x < 0] = p # 元素爲負的導數爲 p
return dx
7.3.4 Tanh 函數梯度
回顧 tanh 函數的表達式:
它的導數推導爲
tanh 函數及其導數曲線如圖 7.5 所示。
在 Numpy 中,可以實現 Tanh 函數的導數如下:
def sigmoid(x): # sigmoid 函數實現
return 1 / (1 + np.exp(-x))
def tanh(x): # tanh 函數實現
return 2*sigmoid(2*x) - 1
def derivative(x): # tanh 導數實現
return 1-tanh(x)**2
7.4 損失函數梯度
前面已經介紹了常見的損失函數,我們這裏推導均方誤差損失函數和交叉熵損失函數的梯度表達式。
7.4.1 均方誤差函數梯度
均方差損失函數表達式爲:
則它的偏導數可以展開爲
利用鏈式法則分解爲
即
僅當𝑘 = 𝑖時才爲 1,其他點都爲 0,也就是說,只與第 號節點相關,與其他節點無關。因此上式中的求和符號可以去掉。均方差的導數可以推導爲:
7.4.2 交叉熵函數梯度
可以參考文章:https://blog.csdn.net/weixin_35770067/article/details/105138185
在計算交叉熵損失函數時,一般將Softmax函數與交叉熵函數統一實現。我們先推導Softmax 函數的梯度,再推導交叉熵函數的梯度。
Softmax 梯度
回顧 Softmax 函數的表達式:
它的功能是將 K個輸出節點的值轉換爲概率,並保證概率之和爲 1,如圖 7.6 所示。
回顧
函數的導數
對於 Softmax 函數,,下面我們根據𝑖 = 𝑗時和𝑖 ≠ 𝑗來分別推導Softmax 函數的梯度。
❑ 當𝑖 = 𝑗時 Softmax 函數的偏導數可以展開爲
提取公共項=
拆分爲 2 部分=
可以看到,它們即是 2 個概率值的相乘,同時𝑝𝑖 = 𝑝j := 𝑝𝑖(1 − 𝑝j )
❑ 當𝑖 ≠ 𝑗時 展開 Softmax 函數爲
去掉 0 項,並分解爲 2 項
可以看到,雖然 Softmax 函數的梯度推導稍複雜,但是最終的結果還是很簡潔的:
交叉熵梯度
考慮交叉熵損失函數的表達式
我們直接來推導最終損失函數對網絡輸出 logits 變量的偏導數,展開爲
將𝑙𝑜𝑔 ℎ複合函數利用鏈式法則分解爲
即
其中即爲我們已經推導的 Softmax 函數的偏導數。
將求和符號分開爲𝑘 = 𝑖以及𝑘 ≠ 𝑖的 2 種情況,並代入求解的公式,可得
進一步化簡爲
提供公共項
完成交叉熵函數的梯度推導。
特別地,對於分類問題中 y 通過 one-hot 編碼的方式,則有
因此交叉熵的偏導數可以進一步簡化爲
7.5 全連接層梯度
在介紹完梯度的基礎知識後,我們正式地進入到神經網絡的反向傳播算法的推導中。實際使用中的神經網絡的結構多種多樣,我們將以全連接層,激活函數採用 Sigmoid 函數,誤差函數爲 Softmax+MSE 損失函數的神經網絡爲例,推導其梯度傳播方式。
7.5.1 單個神經元梯度
對於採用 Sigmoid 激活函數的神經元模型,它的數學模型可以寫爲
其中變量的上標表示層數,如表示第一個隱藏層的輸出,𝑥表示網絡的輸入,我們以權值𝑤的偏導數推導爲例。
爲了方便演示,我們將神經元模型繪製如圖 7.7 所示,圖中未畫出偏置𝑏,輸入節點數爲J。其中輸入第𝑗個節點到輸出的權值連接記爲,上標表示權值屬於的層數,下標表示當前連接的起始節點號和終止節點號,如下標𝑗1表示上一層的第𝑗號節點到當前層的 1 號節點。經過激活函數𝜎之前的變量叫做,經過激活函數𝜎之後的變量叫,由於只有一個輸出節點, = 。輸出與真實標籤之間計算誤差,誤差記爲ℒ。
如果我們採用均方差誤函數,考慮到單個神經元只有一個輸出,那麼損失可以表
達爲
其中𝑡爲真實標籤值,添加1/2並不影響梯度的方向,計算更簡便。我們以權值連接的第𝑗 ∈[1,𝐽]號節點的權值爲例,考慮損失函數ℒ對其的偏導數 :
將分解,考慮到 Sigmoid 函數的導數:
寫成,繼續推導 :
考慮 ,可得:
從上式可以看到,誤差對權值的偏導數只與輸出值、真實值𝑡以及當前權值連接的輸入有關。
7.5.2 全連接層梯度
我們把單個神經元模型推廣到單層全連接層的網絡上,如圖 7.8 所示。輸入層通過一個全連接層得到輸出向量𝒐1,與真實標籤向量𝒕計算均方誤差。輸入節點數爲 ,輸出節點數爲 。
多輸出的全連接網絡層模型與單個神經元模型不同之處在於,它多了很多的輸出節點,同樣的每個輸出節點分別對應到真實標籤。均方誤差可以表達爲
由於 只與節點有關聯,上式中的求和符號可以去掉,即𝑖 = 𝑘:
將代入
考慮 Sigmoid 函數的導數𝜎′ = 𝜎(1 − 𝜎):
將記爲:
最終可得
由此可以看到,某條連接上面的連接,只與當前連接的輸出節點,對應的真實值節點的標籤,以及對應的輸入節點有關。
我們令,則可以表達爲
其中變量表徵連接線的終止節點的梯度傳播的某種特性,使用表示後,偏導數只與當前連接的起始節點,終止節點處有關,理解起來比較直觀。後續我們將會在看到在循環推導梯度中的作用。
現在我們已經推導完單層神經網絡(即輸出層)的梯度傳播方式,接下來我們嘗試推導倒數第二層的梯度傳播公式,完成了導數第二層的傳播推導,可以循環往復推導所有隱藏層的梯度傳播公式,從而完成所有參數的梯度計算。
在介紹反向傳播算法之前,我們先學習一個導數傳播的核心法則:鏈式法則。
7.6 鏈式法則
前面我們介紹了輸出層的梯度 計算方法,我們現在來介紹鏈式法則,它是能在不顯式推導神經網絡的數學表達式的情況下,逐層推導梯度的核心公式,非常重要。
其實我們前面在推導梯度的過程中已經或多或少地用到了鏈式法則。考慮複合函數,則可由和推導出:
考慮多元複合函數,𝑧 = 𝑓(𝑥, 𝑦),其中𝑥 = 𝑔(𝑡), 𝑦 = ℎ(𝑡),那麼的導數可以由和等推導出,具體表達爲
例如,,令𝑥 = 2𝑡 + 1, ,則
神經網絡的損失函數ℒ來自於各個輸出節點,如下圖 7.9 所示,其中輸出節點又與隱藏層的輸出節點相關聯,因此鏈式法則非常適合於神經網絡的梯度推導。讓我們來考慮損失函數ℒ如何應用鏈式法則。
前向傳播時,數據經過傳到倒數第二層的節點,再傳播到輸出層的節點。在每層只有一個節點時, 可以利用鏈式法則,逐層分解爲:
其中 可以由誤差函數直接推導出,可以由全連接層公式推導出,的導數即爲輸入。可以看到,通過鏈式法則,我們不需要顯式計算的具體數學表達式,直接可以將偏導數分解,層層迭代即可推導出。
我們簡單使用 TensorFlow 來體驗鏈式法則的魅力:
import tensorflow as tf
#構建待優化變量
x=tf.constant(1.)
w1=tf.constant(2.)
b1=tf.constant(1.)
w2=tf.constant(2.)
b2=tf.constant(1.)
# 構建梯度記錄器
with tf.GradientTape(persistent=True) as tape:
# 非tf.Variable類型的張量需要人爲設置記錄梯度信息
tape.watch([w1,b1,w2,b2])
#構建兩層線性網絡
y1=x*w1+b1
y2=y1*w2+b2
# 獨立求解出各個偏導數
dy2_dy1=tape.gradient(y2,[y1])[0]
dy1_dw1=tape.gradient(y1,[w1])[0]
dy2_dw1=tape.gradient(y2,[w1])[0]
#驗證連式法則
print(dy2_dy1*dy1_dw1)
print(dy2_dw1)
我們通過自動求導功能計算出,和,藉助鏈式法則我們可以推斷*與應該是相等的,他們的計算結果如下:
tf.Tensor(2.0, shape=(), dtype=float32)
tf.Tensor(2.0, shape=(), dtype=float32)
可以看到,偏導數的傳播是符合鏈式法則的。
7.7 反向傳播算法
現在我們來推導隱藏層的反向傳播算法。簡單回顧一下輸出層的偏導數公式:
考慮網絡倒數第二層的偏導數 ,如圖 7.10 所示,輸出層節點數爲 K,輸出爲;倒數第二層節點數爲 ,輸出爲;倒數第三層的節點數爲 ,輸出爲.
首先將均方差誤差函數展開:
由於ℒ通過每個輸出節點與 相關聯,故此處不能去掉求和符號,運用鏈式法則將均方
差函數拆解:
將代入
利用 Sigmoid 函數的導數𝜎′ = 𝜎(1 − 𝜎)進一步分解
將寫回形式,並利用鏈式法則,將分解
其中,因此
考慮到 與 k 無關,可提取公共項
進一步利用,並利用 Sigmoid 導數將拆分爲
其中的導數可直接推導出爲,上式可寫爲
其中,則 的表達式可簡寫爲
我們仿照輸出層的書寫方式,將定義爲
此時可以寫爲當前連接的起始節點的輸出值與終止節點 j 的梯度信息的簡單相乘運算:
通過定義𝛿變量,每一層的梯度表達式變得更加清晰簡潔,其中𝛿可以簡單理解爲當前連接對誤差函數的貢獻值。
我們來小結一下每層的偏導數的計算公式。
輸出層:
倒數第二層:
倒數第三層:
其中爲倒數第三層的輸入,即倒數第四層的輸出。
依照此規律,只需要循環迭代計算每一層每個節點的等值即可求得當前層的偏導數,從而得到每層權值矩陣的梯度,再通過梯度下降算法迭代優化網絡參數即可。
至此,反向傳播算法介紹完畢。
接下來我們會進行兩個實戰:第一個實戰是採用 TensorFlow 提供的自動求導來優化Himmelblau 函數的極小值;第二個實戰是基於 Numpy 實現反向傳播算法,並完成多層神經網絡的二分類任務訓練.
7.8 Himmelblau 函數優化實戰
Himmelblau 函數是用來測試優化算法的常用樣例函數之一,它包含了兩個自變量𝑥, 𝑦,數學表達式是:
首先我們通過如下實現 Himmelblau 函數的表達式:
def himmelblau(x):
# himmelblau 函數實現
return (x[0] ** 2 + x[1] - 11) ** 2 + (x[0] + x[1] ** 2 - 7) ** 2
通過 np.meshgrid
函數(TensorFlow 中也有 meshgrid 函數)生成二維平面網格點座標:
x = np.arange(-6, 6, 0.1)
y = np.arange(-6, 6, 0.1)
print('x,y range:', x.shape, y.shape)
# 生成 x-y 平面採樣網格點,方便可視化
X, Y = np.meshgrid(x, y)
print('X,Y maps:', X.shape, Y.shape)
Z = himmelblau([X, Y]) # 計算網格點上的函數值
並利用 Matplotlib 庫可視化 Himmelblau 函數,如圖 7.11 所示。
# 繪製 himmelblau 函數曲面
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
fig = plt.figure('himmelblau')
ax = fig.gca(projection='3d')
ax.plot_surface(X, Y, Z)
ax.view_init(60, -30)
ax.set_xlabel('x')
ax.set_ylabel('y')
plt.show()
圖 7.12 爲 Himmelblau 函數的等高線,大致可以看出,它共有 4 個局部極小值點,並且局部極小值都是 0,所以這 4 個局部極小值也是全局最小值。我們可以通過解析的方法計算出局部極小值座標,他們分別是(3,2), (−2 805, 3 131), (−3 779, −3 283), (3 584, −1 848)。
在已經知道極小值解的情況下,我們現在來用梯度下降算法來優化 Himmelblau 函數的數值極小值解。
利用 TensorFlow 自動求導來求出函數在𝑥, 𝑦的梯度,並循環迭代更新:
#參數的初始值對優化的影響不容忽視,可以通過嘗試不同的初始化值,
#檢驗函數優化的極小值情況
# [1.,0.],[-4,0.],[4,0.]
x=tf.constant([4.,0.])#初始化參數
for step in range(200):#循環優化200次
with tf.GradientTape() as tape:#梯度跟蹤
tape.watch([x])#加入梯度跟蹤列表
y=himmelblau(x)#前向傳播
# 反向傳播
grads=tape.gradient(y,[x])[0]
#跟新參數,0.01爲學習率
x=x-0.01*grads
# 打印優化的極小值
if step % 20==19:
print('step{}:x={},f(x)={}'.format(step,x.numpy(),y.numpy()))
經過 200 次迭代更新後,我們可以找到一個極小值解,此時函數值接近於 0:
step199:x=[ 3.5844283 -1.8481264],f(x)=2.2737367544323206e-13
這與我們的解析之一(3 584, −1 848)幾乎一樣。
實際上,通過改變網絡參數的初始化狀態,我們可以得到多種極小值解。參數的初始化狀態是可能影響梯度下降算法的搜索軌跡的,甚至有可能搜索出完全不同的數值解,如表格 7.1 所示。這個例子就比較好的解釋了不同的初始狀態對梯度下降算法的影響。
7.9 反向傳播算法實戰
本節我們將利用前面介紹的多層全連接層的梯度推導結果,直接利用 Python 循環計算每一層的梯度,並按着梯度下降算法手動更新。由於 TensorFlow 具有自動求導功能,我們選擇沒有自動求導功能的 Numpy 實現我們的網絡,並利用 Numpy 手動計算梯度並更新。
需要注意的是,我們推導的梯度傳播公式是針對於多層全連接層,只有 Sigmoid 一種激活函數,並且損失函數爲均方差的網絡類型。對於其他類型的網絡,比如激活函數採用ReLU,損失函數採用交叉熵的網絡,需要重新推導梯度傳播表達式,方法一樣。正是因爲手動推導梯度的方法侷限性較大,在實踐中採用極少,更多的是利用自動求導工具計算。
我們將實現一個 4 層的全連接網絡實現二分類任務,網絡輸入節點數爲 2,隱藏層的節點數設計爲:25,50,25,輸出層 2 個節點,分別表示屬於類別 1 的概率和類別 2 的概率,如圖 7.13 所示。
我們並沒有採用 Softmax 函數將網絡輸出概率值之和進行約束,而是直接利用均方差誤差函數計算與 One-hot 編碼的真實標籤之間的誤差,所有的網絡激活函數全部採用 Sigmoid 函數,這些設計都是爲了能直接利用我們的梯度推導公式。
7.9.1 數據集
我們通過 scikit-learn
庫提供的便捷工具生成 2000 個線性不可分的 2 分類數據集,數據的特徵長度爲 2,採樣出的數據分佈如圖 7.14 所示,所有的紅色點爲一類,所有的藍色點爲一類,可以看到數據的分佈呈月牙狀,並且是是線性不可分的,無法用線性網絡獲得較好效果。爲了測試網絡的性能,我們按着7: 3比例切分訓練集和測試集,其中2000 ∗ 0 3 =600個樣本點用於測試,不參與訓練,剩下的 1400 個點用於網絡的訓練。
數據集的採集使用 scikit-learn
提供的 make_moons
函數生成:
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
N_SAMPLES = 2000 # 採樣點數
TEST_SIZE = 0.3 # 測試數量比率
# 利用工具函數直接生成數據集
X, y = make_moons(n_samples = N_SAMPLES, noise=0.2, random_state=100)
# 將 2000 個點按着 7:3 分割爲訓練集和測試集
X_train, X_test, y_train, y_test = train_test_split(X, y,
test_size=TEST_SIZE, random_state=42)
print(X.shape, y.shape)
可以通過如下可視化代碼繪製數據集的分佈,如圖 7.14 所示。
import seaborn as sns
from matplotlib import cm
def make_plot(X, y, plot_name, file_name=None, XX=None, YY=None, preds=None,dark=False):
if (dark):
plt.style.use('dark_background')
else:
sns.set_style("whitegrid")
plt.figure(figsize=(16,12))
axes = plt.gca()
axes.set(xlabel="$x_1$", ylabel="$x_2$")
plt.title(plot_name, fontsize=30)
plt.subplots_adjust(left=0.20)
plt.subplots_adjust(right=0.80)
if(XX is not None and YY is not None and preds is not None):
plt.contourf(XX, YY, preds.reshape(XX.shape), 25, alpha = 1,cmap=cm.Spectral)
plt.contour(XX, YY, preds.reshape(XX.shape), levels=[.5],cmap="Greys", vmin=0, vmax=.6)
# 繪製散點圖,根據標籤區分顏色
plt.scatter(X[:, 0], X[:, 1], c=y.ravel(), s=40, cmap=plt.cm.Spectral,edgecolors='none')
plt.savefig('dataset.svg')
plt.close()
# 調用 make_plot 函數繪製數據的分佈,其中 X 爲 2D 座標,y 爲標籤
make_plot(X, y, "Classification Dataset Visualization ")
plt.show()
7.9.2 網絡層
通過新建類 Layer
實現一個網絡層,需要傳入網絡層的數據節點數,輸出節點數,激活函數類型等參數,權值 weights
和偏置張量 bias
在初始化時根據輸入、輸出節點數自動生成並初始化:
class Layer:
# 全連接網絡層
def __init__(self,n_input,n_neurons,activation=None,weights=None,bias=None):
"""
:param n_input: 輸入節點數
:param n_neurons: 輸出節點數
:param activation: 激活函數類型
:param weights: 權值張量,默認類內部生成
:param bias: 偏置,默認類內部生成
"""
# 通過正態分佈初始化網絡權值,初始化非常重要,不合適的初始化將導致網咯不收斂
# np.random.rand():返回一個或一組服從“0~1”均勻分佈的隨機樣本值。隨機樣本取值範圍是[0, 1)。
self.weights=weights if weights is not None else np.random.rand(n_input,n_neurons)*np.sqrt(1/n_neurons)
self.bias=bias if bias is not None else np.random.rand(n_neurons)*0.1
self.activation=activation #激活函數類型,如'sigmoid'
self.last_activation=None # 激活函數的輸出值 o
self.error=None # 用於計算當前層的delta變量的中間值
self.delta=None # 記錄當前層的delta變量,用於計算梯度
實現網絡層的前向傳播如下:
def activate(self,x):
# 前向傳播
r=np.dot(x,self.weights)+self.bias #x@w+b
# 通過激活函數,得到全連接層的輸出 o
self.last_activation=self._apply_activation(r)
return self.last_activation
其中 self._apply_activation
實現了不同的激活函數的前向計算過程:
def _apply_activation(self,r):
# 計算激活函數的輸出
if self.activation is None:
return r # 無激活函數,直接返回
# ReLu激活函數
elif self.activation=='relu':
return np.maximum(r,0)
# tanh
elif self.activation=='tanh':
return np.tanh(r)
# sigmoid
elif self.activation=='sigmoid':
return 1/(1+np.exp(-r))
return r
針對於不同的激活函數,它們的導數計算實現如下:
def apply_activation_derivative(self,r):
# 計算激活函數的導數
# 無激活函數,導數爲1
if self.activation is None:
return np.ones_like(r)
#ReLu函數的導數實現
elif self.activation=='relu':
grad=np.array(r,copy=True)
grad[r>0]=1
grad[r<=0]=0
return grad
# tanh函數的導數實現
elif self.activation=='tanh':
return 1-r**2
# sigmoid函數的導數實現
elif self.activation=='sigmoid':
return r*(1-r)
return r
可以看到,Sigmoid 函數的導數實現爲𝑟 ∗ (1 − 𝑟),其中𝑟即爲𝜎(𝑧)。
7.9.3 網絡模型
實現單層網絡類後,我們實現網絡模型的類 NeuralNetwork
,它內部維護各層的網絡層Layer
類對象,可以通過 add_layer
函數追加網絡層,實現如下:
class NeuralNetwork:
# 神經網絡大類
def __init__(self):
self._layers=[] #網絡層對象列表
def add_layers(self,layer):
# 追加網絡層
self._layers.append(layer)
網絡的前向傳播只需要循環調用各網絡層對象的前向計算函數即可:
def feed_forward(self,X):
# 前向傳播
for layer in self._layers:
# 依次通過各個網絡層
X=layer.activate(X)
return X
實例化網絡對象,添加 4 層全連接層:
nn=NeuralNetwork()# 實例化網絡類
nn.add_layer(Layer(2,25,'sigmoid')) #隱藏層1,2=>25
nn.add_layer(Layer(25,50,'sigmoid'))#隱藏層1,25=>50
nn.add_layer(Layer(50,25,'sigmoid'))#隱藏層1,50=>25
nn.add_layer((Layer(25,2,'sigmoid')))#隱藏層1,25=>2
網絡模型的反向傳播實現稍複雜,需要從最末層開始,計算每層的𝛿變量,根據我們推導的梯度公式,將計算出的𝛿變量存儲在 Layer 類的 delta 變量中。
def backpropagation(self, X, y, learning_rate):
# 反向傳播算法實現
# 前向計算,得到輸出值
output = self.feed_forward(X)
for i in reversed(range(len(self._layers))): # 反向循環
layer = self._layers[i] # 得到當前層對象
# 如果是輸出層
if layer == self._layers[-1]: # 對於輸出層
layer.error = y - output # 計算 2 分類任務的均方差的導數
# 關鍵步驟:計算最後一層的 delta,參考輸出層的梯度公式
layer.delta = layer.error *layer.apply_activation_derivative(output)
else: # 如果是隱藏層
next_layer = self._layers[i + 1] # 得到下一層對象
layer.error = np.dot(next_layer.weights, next_layer.delta)
# 關鍵步驟:計算隱藏層的 delta,參考隱藏層的梯度公式
layer.delta = layer.error *layer.apply_activation_derivative(layer.last_activation)
…# 代碼接下面
在反向計算完每層的𝛿變量後,只需要按着= 公式計算每層的梯度,並更新網絡參數即可。由於代碼中的 delta 計算的是−𝛿,因此更新時使用了加號。
def backpropagation(self, X, y, learning_rate):
… # 代碼接上面
# 循環更新權值
for i in range(len(self._layers)):
layer = self._layers[i]
# o_i 爲上一網絡層的輸出
o_i = np.atleast_2d(X if i == 0 else self._layers[i -1].last_activation)
# 梯度下降算法,delta 是公式中的負數,故這裏用加號
layer.weights += layer.delta * o_i.T * learning_rate
因此,在 backpropagation 函數中,反向計算每層的𝛿變量,並根據梯度公式計算每層參數的梯度值,按着梯度下降算法完成一次參數的更新。
7.9.4 網絡訓練
我們的二分類任務網絡設計爲 2 個輸出節點,因此需要將真實標籤 y 進行 one-hot 編碼:
def train(self, X_train, X_test, y_train, y_test, learning_rate,
max_epochs):
# 網絡訓練函數
# one-hot 編碼
y_onehot = np.zeros((y_train.shape[0], 2))
y_onehot[np.arange(y_train.shape[0]), y_train] = 1
將 one-hot 編碼後的真實標籤與網絡的輸出計算均方差,並調用反向傳播函數更新網絡參數,循環迭代訓練集 1000 遍:
mses = []
for i in range(max_epochs): # 訓練 1000 個 epoch
for j in range(len(X_train)): # 一次訓練一個樣本
self.backpropagation(X_train[j], y_onehot[j], learning_rate)
if i % 10 == 0:
# 打印出 MSE Loss
mse = np.mean(np.square(y_onehot - self.feed_forward(X_train)))
mses.append(mse)
print('Epoch: #%s, MSE: %f' % (i, float(mse)))
# 統計並打印準確率
print('Accuracy: %.2f%%' % (self.accuracy(self.predict(X_test),y_test.flatten()) * 100))
return mses
7.9.5 網絡性能
我們將每個 epoch 的損失ℒ記錄下,並繪製爲曲線,如圖 7.15 所示。
在訓練完 1000 個 epoch 後,在測試集 600 個樣本上得到的準確率爲
Epoch: #990, MSE: 0.024335
Accuracy: 97.67%
可以看到,通過手動計算梯度公式並手動更新網絡參數的方式,我們在簡單的二分類任務上也能獲得了較低的錯誤率。通過精調網絡超參數,還可以獲得更好的網絡性能。
在每個 epoch,我們在測試集上完成一次準確度測試,並繪製成曲線,如圖 7.16 所示。
通過這個手動實現的二分類全連接網絡,相信讀者朋友們能夠更加深刻地體會到深度學習框架在算法實現中的角色。沒有諸如 TensorFlow 這些框架,我們同樣能夠實現複雜的神經網絡,但是靈活性、穩定性、開發效率和計算效率都較差,基於這些深度學習框架進行算法設計與訓練,將大大提升算法開發人員的工作效率。同時我們也能意識到,框架只是一個工具,更重要的是,我們對算法本身的理解,這纔是算法開發者最重要的能力。
參考文獻
1.https://github.com/dragen1860/Deep-Learning-with-TensorFlow-book/tree/master/ch07