陳鍾誠

Version 1.0

反傳遞算法

如前文所述,《梯度下降法》是《深度學習神經網路》背後的學習算法,但是再輸入變數很多時,純粹靠《多次前向計算》的《梯度下降法》速度會過慢,因此我們需要使用《反傳遞算法》更有效率的計算《梯度》。

以下程式碼片段來自下列網址:

節點 Node

反傳遞算法是用來快速計算神經網路中梯度的方法,方法是把每個變數都變成一個具有(值 v + 梯度 g) 的節點 (node),其結構如下:

class Node:
    def __init__(self, v = 0, g = 0):
        self.v = v # 輸出值 (f(x))
        self.g = g # 梯度值 (偏微分)

    def __str__(self):
        return 'v:{self.v} g:{self.g}'.format(self=self)

閘 Gate

神經網路中的每個運算,都被表示成一種閘 (Gate),這些閘可以進行正向 forward 計算,也可以反向 backward 傳遞梯度,以下是 Gate 的類別定義。

class Gate:
    def __init__(self, o, x, y, f, gfx, gfy):
        self.o = o
        self.x = x
        self.y = y
        self.f = f
        self.gfx = gfx
        self.gfy = gfy

    def forward(self):
        self.o.v = self.f(self.x.v, self.y.v)
        return self.o.v

    def backward(self):
        x, y, o, gfx, gfy = self.x, self.y, self.o, self.gfx, self.gfy
        x.g += gfx(x.v,y.v) * o.g
        y.g += gfy(x.v,y.v) * o.g

    def adjust(self, step=0.001): # 朝逆梯度的方向走一小步
        x, y = self.x, self.y
        x.v -= step*x.g
        y.v -= step*y.g
        x.g = 0
        y.g = 0

對於每個 gate ,函數 f 是用來進行《正向傳遞》forward() 計算的。

舉例而言,我們可以使用以下指令定義一個乘法閘 gmul

gmul = new Gate(x, y, lambda x,y:x*y, lambda x,y:y, lambda x,y:x)

其中的 f(x,y)=>x*y 是 gmul 的正向計算函數,會計算出 o.v = x.v * y.v 的結果,放在節點 o 中。

而 gfx(x,y)=>y 與 gfy(x,y)=>x 是 gmul 的反向計算函數,會根據 o 節點的《值與梯度》 (o.v, o.g) 反向計算出 x, y 的梯度 x.g 與 y.g 。

前向計算函數 f 是個乘法閘 f(x,y)=x*y,應該很容易理解。但是反向計算函數 gfx 與 gfy 為何是 gfx(x,y)=>y 與 gfx(x,y)=>x 呢?

反傳遞的原理

梯度的反向傳遞原理在 Andrej Karpathy 的 Hacker’s guide to Neural Networks 一文中有詳盡的說明,讓我們在此簡述其精要。

對於 f(x,y) = xy 而言,假如 f 的梯度為 f.g ,那麼由於:

\frac{\partial{f(x,y)}}{\partial{x}} = \frac{\partial{xy}}{\partial{x}} = y

因此 x 的梯度應該是 x.g = y * f.g。

同理、 y 的梯度 y.g = x * f.g 。

所以只需要知道 f.g 與 f(x,y)=xy,就可以逆向推出 x.g 與 y.g。

這就是所謂的《梯度反傳遞》。

而這也是為何上述程式中 gfx(x,y)=>y 與 gfy(x,y)=>x 的原因。

讓我們考慮一個更複雜的兩層式網路如下圖,該網路是計算 f = (x+y) * z 這個算式。

其中的 q = x+y, 而 f = q*z。

根據偏微分的鏈鎖規則,我們可以用以下數學式描述 f, q, x 之間的梯度關係。

\frac{\partial{f(q,z)}}{\partial{x}} = \frac{\partial{q(x,y)}}{\partial{x}} \frac{\partial{f(q,z)}}{\partial{q}}

由於 f 為輸出,我們可以先將 f 的梯度 f.g 設定為 1,那麼就可以推斷其他變數的梯度值。

(註:其實輸出 f.g 可以設為一的非零值,代表梯度下降的移動長度,對於有《樣本與標準答案》的學習,可以將 f.g 設為輸出與標準答案的差距)

\frac{\partial{f(q,z)}}{\partial{q}} = \frac{\partial{qz}}{\partial{q}} =z
x.g = \frac{\partial{f(q,z)}}{\partial{x}} = \frac{\partial{f(q,z)}}{\partial{q}} \frac{\partial{q(x,y)}}{\partial{x}}=\frac{\partial{qz}}{\partial{q}} \frac{\partial{x+y}}{\partial{x}}=z*1

因此當 f.g 已知的時候,我們可以算出 q.g ,由於 f = q*z ,因此 q 的梯度 q.g = z*f.g (當 f.g = 1 時,則 q.g = z)。

接著我們可以透過 q=x+y 來計算 x 的梯度,得到 x.g=q.g*f.g 來計算 x 的梯度 x.g ,於是同樣得到 x.g=z*f.g (因為 $\frac{\partial{q}}{\partial{x}}=\frac{\partial{x+y}}{\partial{x}}=1$ )。

經由這種反傳遞的方式,我們就能從輸出端一路到推回中間層、然後再推回輸入端,計算出每個節點的梯度。

網路 Net

透過這些 Node + Gate 的組合,我們可以設計出能計算任何函數的神經網路 Net。

舉例而言,以下網路 net 會計算 $x^2+y^2$ ,其輸出結果會放在節點 o 當中 (其初始點為 x=1, y=3)。

from net import Net
net = Net()

x = net.variable(1)
y = net.variable(3)
x2 = net.mul(x, x)
y2 = net.mul(y, y)
o  = net.add(x2, y2)

我們只要呼叫網路的正向傳遞函數 net.forward() 就能從輸入值開始,經過每個閘的前向傳遞 gate.forward() 計算出結果 。

class Net:
    def __init__ (self):
        self.gates = []

    def variable (self, v, g=0):
        return Node(v, g)

    def op (self, x, y, f, gfx, gfy):
        o = Node()
        g = Gate(o, x, y, f, gfx, gfy)
        self.gates.append(g)
        self.o = o
        return o

    def add (self, x, y):
        return self.op(x, y, lambda x,y:x+y, lambda x,y:1, lambda x,y:1) 
    def mul (self, x, y):
        return self.op(x, y, lambda x,y:x*y, lambda x,y:y, lambda x,y:x) 

    def forward(self): # 正向傳遞計算結果
        for gate in self.gates:
            gate.forward()
        return self.o.v

    def backward(self): # 反向傳遞計算梯度 
        self.o.g = 1 # 設定輸出節點 o 的梯度為 1
        for gate in reversed(self.gates): # 反向傳遞計算每個節點 Node 的梯度 g
            gate.backward()

    def adjust(self, step=0.01): # 朝逆梯度的方向走一小步
        for gate in self.gates:
            gate.adjust(step)

當我們想知道該網路的輸入 (x,y) 應該調整多少,才能朝梯度方向前進時,我們只要呼叫 net.backward() ,就能透過反向傳遞計算出每個節點的梯度。

程式執行

有了上述概念後,讓我們來看個範例好了,以下這個網路是計算 $o=x^2+y^2$ 的函數,我們希望能透過《梯度下降+反傳遞算法》找出其最低點。

檔案: https://github.com/ccccourse/ai/blob/master/python/03-neuralnet/net2.py

from net import Net
net = Net()

x = net.variable(1)
y = net.variable(3)
x2 = net.mul(x, x)
y2 = net.mul(y, y)
o  = net.add(x2, y2)

net.gradient_descendent()
print('x=', x.v, 'y=', y.v)

該程式的主程式很簡單,只是對該網路呼叫梯度下降法進行優化而已。

執行的結果如下:

PS D:\ccc\course\ai\python\03-neuralnet> python net2.py
0  =>  10
1  =>  9.216
2  =>  8.4934656
3  =>  7.827577896960003
4  =>  7.213895789838339
5  =>  6.648326359915014
6  =>  6.127097573297678
7  =>  5.646733123551139
8  =>  5.2040292466647315
9  =>  4.796033353726214
...
95  =>  0.004280892060076547
96  =>  0.003945270122566546
97  =>  0.003635960944957329
98  =>  0.003350901606872675
99  =>  0.003088190920893857
x= 0.01687031935884968 y= 0.050610958076549

上述程式最後找到了 (x= 0.01687031935884968, y= 0.050610958076549) 這個非常接近 (0,0) 的點,正確地找到了函數 $x^2+y^2$ 的最低點 f(0,0)=0。

現在、您應該已經可以理解《梯度下降法與反傳遞算法》的運作原理了。

雖然《反傳遞算法》早在 1963 年就由 Vapnik 提出了,但是卻直到 1986 年時才由於 Hinton 等人在語音辨識上的實作成果而引發注意。

接著《神經網路》又沉寂了十幾年,到了 2011 年 Hinton 的學生用 CNN 捲積神經網路在影像辨識大賽中用 GPU 加速而大勝,再次導致神經網路進化為深度學習而爆紅。

而這一切背後運作的《反傳遞算法》,經歷了整整一甲子,並沒有多大的改變,只要您理解了這個算法的原理,就能輕鬆踏進《深度學習的神經網路領域》了!