反傳遞算法
如前文所述,《梯度下降法》是《深度學習神經網路》背後的學習算法,但是再輸入變數很多時,純粹靠《多次前向計算》的《梯度下降法》速度會過慢,因此我們需要使用《反傳遞算法》更有效率的計算《梯度》。
節點 Node
反傳遞算法是用來快速計算神經網路中梯度的方法,方法是把每個變數都變成一個具有(值 v + 梯度 g) 的節點 (node),其結構如下:
class Node {
constructor(v = 0, g = 0) {
this.v = v // 輸出值 (f(x))
this.g = g // 梯度值 (偏微分)
}
}
閘 Gate
神經網路中的每個運算,都被表示成一種閘 (Gate),這些閘可以進行正向 forward 計算,也可以反向 backward 傳遞梯度,以下是 Gate 的類別定義。
class Gate {
constructor(o, x, y, f, gfx, gfy) {
this.p = {o:o, x:x, y:y, f:f, gfx:gfx, gfy:gfy||gfx} // 當 gfy 沒設定時,就代表 gfy = gfx
}
forward() {
let p = this.p
p.o.v = p.f(p.x.v, p.y.v)
}
backward() {
let p = this.p
p.x.g = p.gfx(p.o.v)
p.y.g = p.gfy(p.o.v)
}
}
對於每個 gate ,函數 f 是用來進行《正向傳遞》forward() 計算的。
舉例而言,我們可以使用以下指令定義一個乘法閘 gmul
// f gfx gfy
let gmul = new Gate(o, x, y, (x,y)=>x*y, (x,y)=>y, (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 當中。
const net = new nn.Net()
let x = net.variable(2)
let y = net.variable(1)
let x2 = net.mul(x, x)
let y2 = net.mul(y, y)
let o = net.add(x2, y2)
我們只要呼叫網路的正向傳遞函數 net.forward() 就能從輸入值開始,經過每個閘的前向傳遞 gate.forward() 計算出結果 。
class Net {
constructor () {
this.gates = []
}
variable (v, g) {
return new Node(v, g)
}
op (x, y, f, gfx, gfy) {
let o = new Node()
let g = new Gate(o, x, y, f, gfx, gfy)
this.gates.push(g)
this.o = o
return o
}
add (x, y) { return this.op(x, y, (x,y)=>x+y, (x,y)=>1) }
mul (x, y) { return this.op(x, y, (x,y)=>x*y, (x,y)=>y, (x,y)=>x) }
forward() { // 正向傳遞計算結果
for (let gate of this.gates) {
gate.forward()
}
return this.o
}
backward() { // 反向傳遞計算梯度
this.o.g = 1 // 設定輸出節點 o 的梯度為 1
for (let i=this.gates.length-1; i>=0; i--) { // 反向傳遞計算每個節點 Node 的梯度 g
let gate = this.gates[i]
gate.backward()
}
}
watch (nodes) {
this.nodes = nodes
}
dump() {
return this.nodes
}
}
當我們想知道該網路的輸入 (x,y) 應該調整多少,才能朝梯度方向前進時,我們只要呼叫 net.backward() ,就能透過反向傳遞計算出每個節點的梯度。
程式執行
有了上述概念後,讓我們來看個範例好了,以下這個網路是計算 $o=x^2+y^2
$ 的函數,我們希望能透過《梯度下降+反傳遞算法》找出其最低點。
檔案: f.js
const nn = require('../../nn')
const net = new nn.Net()
let x = net.variable(2)
let y = net.variable(1)
let x2 = net.mul(x, x)
let y2 = net.mul(y, y)
let o = net.add(x2, y2)
net.watch({x,y,x2,y2,o})
module.exports = new nn.FNet(net, {x:x, y:y})
該程式的主程式很簡單,只是對該網路呼叫梯度下降法進行優化而已。
檔案: optimizeEx.js
const nn = require('../../nn')
const f = require('./f')
nn.optimize(f, {x:1, y:1})
執行的結果如下:
$ cd example/02-backprop
$ node .\gradientEx
p= {x:2.0000, y:1.0000} f(p)= 5
gp= { x: 4, y: 1 }
p= {x:1.9600, y:0.9900} f(p)= 4.8217
gp= { x: 3.8415999999999997, y: 0.9801 }
p= {x:1.9216, y:0.9802} f(p)= 4.653275148657
gp= { x: 3.692485069056, y: 0.960790079601 }
p= {x:1.8847, y:0.9706} f(p)= 4.493987190929792
gp= { x: 3.5519401090757823, y: 0.9420470818540095 }
// ... 中間省略 ....
p= {x:0.0479, y:0.0467} f(p)= 0.004475389263812065
gp= { x: 0.00228988872537307, y: 0.0021855005384389947 }
p= {x:0.0478, y:0.0467} f(p)= 0.004471155300865204
gp= { x: 0.002287697698821976, y: 0.0021834576020432284 }
p= {x:0.0478, y:0.0467} f(p)= 0.004466927345179781
gp= { x: 0.002285509815916863, y: 0.002181417529262918 }
上述程式最後找到了 p={x:0.0478, y:0.0467} 這個非常接近 (0,0) 的點,正確地找到了函數 $x^2+y^2
$ 的最低點 f(0,0)=0。
現在、您應該已經可以理解《梯度下降法與反傳遞算法》的運作原理了。
雖然《反傳遞算法》早在 1963 年就由 Vapnik 提出了,但是卻直到 1986 年時才由於 Hinton 等人在語音辨識上的實作成果而引發注意。
接著《神經網路》又沉寂了十幾年,到了 2011 年 Hinton 的學生用 CNN 捲積神經網路在影像辨識大賽中用 GPU 加速而大勝,再次導致神經網路進化為深度學習而爆紅。
而這一切背後運作的《反傳遞算法》,經歷了整整一甲子,並沒有多大的改變,只要您理解了這個算法的原理,就能輕鬆踏進《深度學習的神經網路領域》了!