NN入門,手把手教你用Numpy手撕NN(二)
- 2019 年 10 月 3 日
- 筆記
這是一篇包含較少數學推導的NN入門文章
上篇文章中簡單介紹了如何手撕一個NN,但其中仍有可以改進的地方,將在這篇文章中進行完善。
誤差反向傳播
之前的NN計算梯度是利用數值微分法,雖容易實現,但是計算速度慢,這裡介紹的誤差反向傳播法
能夠高效計算權重參數的梯度的方法。
這裡將通過計算圖
的方法來講解反向傳播
計算圖
問題一:
小明在超市買了2個100元一個的蘋果,消費稅是10%,請計算支付金額
問題二:
小明在超市買了2個蘋果、3個橘子。其中,蘋果每個100元, 橘子每個150元。消費稅是10%,請計算支付金額。
從上面兩問計算圖的表示中可以很容易理解其計算原理,從左到右的計算稱為正向傳播
。同時,我們也可以利用這種方法進行反向傳播
。
再來思考一個問題:
問題1中,我們計算了購買2個蘋果時加上消費稅最終需要支付的金額。這裡,假設我們想知道蘋果價格的上漲會在多大程度上影響最終的支付金額,即求「支付金額關於蘋果的價格的導數」。
如上圖所示,反向傳播使用與正方向相反的箭頭(粗線)表示。反向傳播傳遞「局部導數」,將導數的值寫在箭頭的下方。在這個例子中,反向傳播從右向左傳遞導數的值為(1→1.1→2.2)。從這個結果中可知,「支付金額 關於蘋果的價格的導數」的值是2.2。這意味著,如果蘋果的價格上漲1元, 最終的支付金額會增加2.2元(嚴格地講,如果蘋果的價格增加某個微小值, 則最終的支付金額將增加那個微小值的2.2倍)。
可見,利用計算圖可以通過正向傳播與反向傳播高效地計算各個變數的導數值。
鏈式法則
上面提到的反向傳播實際上是基於鏈式法則進行的,這裡將介紹下其原理。
假設存在 (y=f(x)) 的計算,這個計算的反向傳播如下圖所示
如圖所示,反向傳播的計算順序是,將訊號E乘以節點的局部導數 ,然後將結果傳遞給下一個節點。通過這樣的計算,可以高效地求出導數的 值,這是反向傳播的要點。
然而,為什麼鏈式傳播在這裡會有效呢?
學過高數話,就知道什麼是鏈式傳播
如果某個函數由複合函數表示,則該複合函數的導數可以用構成複合函數的各個函數的導數的乘積表示。
以 (z=(x+y)^2) 為例,可以看成由下面兩個式子構成
[ z=t^2 \ t=x+y tag{1} ]
那麼
[ frac{partial z}{partial x} = frac{partial z}{partial t} frac{partial t}{partial x}tag{2} ]
現在來使用鏈式法則
[ frac{partial z}{partial t} = 2t\ frac{partial t}{partial x} = 1tag{3} ]
所以
[ frac{partial z}{partial x} = 2t cdot 1=2(x+y)tag{4} ]
將上式按照計算圖表示如下
這樣,我們的反向傳播就算介紹完了,下面將介紹簡單實現。
乘法層
class MulLayer: def __init__(self): self.x = None self.y = None def forward(self, x, y): self.x = x self.y = y out = x * y return out def backward(self, dout): dx = dout * self.y # 翻轉x和y dy = dout * self.x return dx, dy
__ init __()中會初始化實例變數x和y,它們用於保存正向傳播時的輸入值。 forward()接收x和y兩個參數,將它們相乘後輸出。backward()將從上游傳來的導數(dout)乘以正向傳播的翻轉值,然後傳給下游。
至於為什麼要翻轉x和y可能會有點迷惑,看下面這張圖再結合上面的例子就能理解了
使用
apple = 100 apple_num = 2 tax = 1.1 # layer mul_apple_layer = MulLayer() mul_tax_layer = MulLayer() # forward apple_price = mul_apple_layer.forward(apple, apple_num) price = mul_tax_layer.forward(apple_price, tax) print(price) # 220 # backward dprice = 1 dapple_price, dtax = mul_tax_layer.backward(dprice) dapple, dapple_num = mul_apple_layer.backward(dapple_price) print(dapple, dapple_num, dtax) # 2.2 110 200
加法層
class AddLayer: def __init__(self): pass def forward(self, x, y): out = x + y return out def backward(self, dout): dx = dout * 1 dy = dout * 1 return dx, dy
加法層不需要特意進行初始化,所以__ init __()中什麼也不運行。加法層的forward()接收x和y兩個參數,將它們相加後輸出。backward()將上游傳來的導數(dout)原封不動地傳遞給下游。
NN各層實現
前面介紹了如何利用計算圖來計算導數,下面將介紹如何利用計算圖來設計NN的其它層。
激活函數層
ReLU層
[ y=begin{cases} x,quad x > 0\ 0,quad x<=0 end{cases}tag{5} ]
對上式求導
[ frac{partial y}{partial x}=begin{cases} 1,quad x > 0\ 0,quad x<=0 end{cases}tag{6} ]
可以看出,如果正向傳播時的輸入x大於0,則反向傳播會將上游的值原封不動地傳給下游。反過來,如果正向傳播時的x小於等於0,則反向傳播中傳給下游的訊號將停在此處。計算圖表示如下
class Relu: def __init__(self): self.mask = None def forward(self, x): self.mask = (x <= 0) out = x.copy() out[self.mask] = 0 return out def backward(self, dout): dout[self.mask] = 0 dx = dout return dx
Sigmoid層
[ y=frac{1}{1+exp(-x)}tag{7} ]
其正向傳播的計算圖可以表示如下
那麼,依次進行求導
節點「/」
[ y=frac{1}{x} \ frac{partial y}{partial x} = -frac{1}{x^2}=-y^2tag{8} ]
節點「+」
原封不動傳給下一節點
節點「exp」
[ frac{partial y}{partial x} = exp(x)tag{9} ]
節點「x」
上一節點的傳來的導數乘上-1
最後計算圖表示如下
化簡一下就是
這裡還能將公式進一步化簡
[ begin{aligned} frac{partial L}{partial y}y^2exp(-x) &= frac{partial L}{partial y}frac{1}{(1+exp{-x})^2}exp(-x)\ &=frac{partial L}{partial y}frac{1}{1+exp{-x}}frac{exp{-x}}{1+exp{-1}}\ &= frac{partial L}{partial y}y(1-y) end{aligned} tag{10} ]
此時計算圖為
class Sigmoid: def __init__(self): self.out = None def forward(self, x): out = 1 / (1 + np.exp(-x)) self.out = out return out def backward(self, dout): dx = dout * (1.0 - self.out) * self.out return dx
Affine/Softmax層
Affine
神經網路的正向傳播中進行的矩陣的乘積運算在幾何學領域被稱為「仿射變換」 A。因此,這裡將進行仿射變換的處理實現為「Affine層」。
對於矩陣運算的反向傳播實現,涉及到了對矩陣求導,之前看到一篇很好的文章,裡面介紹了如何進行計算。這裡將直接給出結論,對下圖
[ frac{partial L}{partial X}=frac{partial L}{partial Y} cdot W^T \ frac{partial L}{partial W}=X^T cdot frac{partial L}{partial X} tag{11} ]
反向傳播的計算圖
上面只是以單個數據為對象,如果是N個數據一起進行正向傳播呢?先來看看計算圖表示
程式碼如下
Class Affine: def __init__(self, W, b): self.W = W self.b = b self.x = None self.dW = None self.db = None def forward(self, x): self.x = x out = np.dot(x, self.W) + self.b return out def backward(self, dout): dx = np.dot(dout, self.W.T) self.dW = np.dot(self.x.T, dout) self.db = np.sum(dout, axis=1) return dx
Softmax-with-Loss
Softmax-with-loss層由Softmax層與Cross Entropy Error層組合而成,其結構如下所示
公式表示
Softmax
[ y_k=frac{exp{(a_k)}}{sum^{n}_{i=1}exp{(a_i)}}tag{12} ]
Cross Entropy Error
[ L=-sum_{k}t_klogy_ktag{13} ]
Cross Entropy Error 反向傳播
- 初始值為1
- "x"節點的反向傳播將正向傳播時的輸入值翻轉,乘以上游傳來的導數後,再傳給下游
- "+"節點將上游傳來的導數原封不動傳給下游
- "log"節點的反向傳播如下式
[ y=logy\ frac{partial y}{partial x} = frac{1}{x} tag{14} ]
綜上,可求得Cross Entropy Error層的反向傳播的結果為 ((-frac{t_1}{y_1},-frac{t_2}{y_2},-frac{t_3}{y_3})) 。
Softmax層反向傳播
[ left. begin{gathered} y_i=frac{exp{(a_i)}}{S} \ -frac{t_i}{y_i}exp{(a_i)} end{gathered} right} implies -t_ifrac{S}{exp{(a_i)}}exp{(a_i)}=-t_iS tag{15} ]
"/"節點反向傳播為(-frac{1}{S^2})
所以,Softmax層中間最上面的結果為(frac{1}{S}(t_1+t_2+t_3)),由於(t_1, t_2, t_3)為one-hot表示,所以僅有一個的值為1,所以此處導數為(frac{1}{S})。
「/」節點後的”+"節點,原封不動傳遞上游的值,此時反向傳播計算圖如下所示
接著是中間的橫向"x"節點,將值翻轉後相乘
[ -frac{t_i}{y_i}frac{1}{S}=-frac{t_i}{exp{(a_i)}}tag{16} ]
然後就是"exp"節點
[ y=exp{(x)}\ frac{partial y}{partial x}=exp{(x)} tag{17} ]
根據上式,兩個分支輸入和乘以(exp(a_i))後的值就是所求的反向傳播值。
[ left. begin{gathered} y_i=frac{exp{(a_i)}}{S} \ (frac{1}{S}-frac{t_i}{exp{(a_i)}})exp{(a_i)} end{gathered} right} implies y_i-t_i tag{18} ]
到此為止,Softmax-with-Loss層的反向傳播就算好了,下面來看看程式碼
Class SoftWithLoss: def __init__(self): self.loss = None # 損失 self.y = None # softmax的輸出 self.t = None # 監督輸出(one-hot vector) def forward(self, x, t): self.t = t self.y = softmax(x) self.loss = cross_entropy_error(self.y, self.t) return self.loss def backward(self, dout=1): batch_size = self.t.shape[0] dx = (self.y - self.t) / batch_size # 請注意反向傳播時,需除以批的大小(batch_size) return dx
使用反向傳播的NN實現
上面介紹了各層如何利用反向傳播進行實現,這裡將介紹利用反向傳播構建NN。
神經網路學習步驟
-
步驟一(mini-batch)
從訓練數據中隨機選擇一部分數據
-
步驟二(計算梯度)
計算損失函數關於各個權重參數的梯度
-
步驟三(更新參數)
將權重參數沿梯度方向進行微小的更新
-
步驟四(重複)
重複步驟一、二、三
反向傳播將出現在步驟二中。
先來個簡單的兩層NN
import sys, os sys.path.append(os.pardir) import numpy as np from collections import OrderDict Class TwoLayerNet: def __init__(self, input_size, hidden_size, output_size, weight_init_std=0.01): # 初始化權重 self.params = {} self.params['W1'] = weight_init_std * np.random.randn(input_size, hidden_size) self.params['b1'] = np.zeros(hidden_size) self.params['W2'] = weight_init_std * np.random.randn(hidden_size, output_size) self.params['b2'] = np.zeros(output_size) # 生成層 self.layers = OrderdDict() self.layers['Affine1'] = Affine(self.params['W1'], self.params['b1']) self.layers['Relu1'] = Relu() self.layers['Affine2'] = Affine(self.params['W2'], self,params['b2']) self.lastLayer = SoftmaxWithLoss() def predict(self, x): for layer in self.layers.values(): x = layer.forward(x) return x # x: 輸入數據, t:監督數據 def loss(self, x, t): y = self.predict(x) return self.lastLayer.forward(x, y) def accuracy(self, x, t): y = self.predict(x) y = np.argmax(y, axis=1) if t.ndim != 1 : t = np.argmax(t, axis=1) accuracy = np.sum(y==t) / float(x.shape[0]) return accuracy # x:輸入數據,t:監督數據 微分法梯度計算 def numerical_gradient(self, x, t): loss_W = lambda W: self.loss(x, t) grads = {} grads['W1'] = numerical_gradient(loss_W, self.params['W1']) grads['b1'] = numerical_gradient(loss_W, self.params['b1']) grads['W2'] = numerical_gradient(loss_W, self.params['W2']) grads['b2'] = numerical_gradient(loss_W, self.params['b2']) return grads # x:輸入數據,t:監督數據 計算圖法梯度計算 def gradient(self, x, t): # forward self.loss(x, t) # backward dout = 1 dout = self.lastLayer.backward(dout) layers = list(self.layers.values()).reverse() for layer in layers: dout = layer.backward(dout) grads = {} grads['W1'] = self.layers['Affine1'].dW grads['b1'] = self.layers['Affine1'].db grads['W2'] = self.layers['Affine2'].dW grads['b2'] = self.layers['Affine2'].db return grads
這裡插入一點內容,我們之前使用的數值微分的優點是實現簡單,因此,一般情況下不太容易出錯。而誤差反向傳播法的實現很複雜,容易出錯。所以,經常會比較數值微分的結果和誤差反向傳播法的結果,以確認誤差反向傳播法的實現是否正確。確認數值微分求出的梯度結果和誤差反向傳播法求出的結果是否一致(嚴格地講,是非常相近)的操作稱為梯度確認(gradient check)。
程式碼實現如下
# 讀入數據 (x_train, t_train), (x_test, t_test) = load_mnist(normalize=True, one_ hot_label = True) network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10) x_batch = x_train[:3] t_batch = t_train[:3] grad_numerical = network.numerical_gradient(x_batch, t_batch) grad_backprop = network.gradient(x_batch, t_batch) # 求各個權重的絕對誤差的平均值 for key in grad_numerical.keys(): diff = np.average(np.abs(grad_backprop[key] - grad_numerical[key])) print(key + ":" + str(diff))
使用上面的網路進行學習
import sys, os sys.path.append(os.pardir) import numpy as np # 讀入數據 (x_train, t_train), (x_test, t_test) = load_mnist(normalize=True, one_hot_label=True) network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10) iters_num = 10000 train_size = x_train.shape[0] batch_size = 100 learning_rate = 0.1 train_loss_list = [] train_acc_list = [] test_acc_list = [] iter_per_epoch = max(train_size / batch_size, 1) for i in range(iters_num): batch_mask = np.random.choice(train_size, batch_size) x_batch = x_train[batch_mask] t_batch = t_train[batch_mask] # 通過誤差反向傳播法求梯度 grad = network.gradient(x_batch, t_batch) # 更新 for key in ('W1', 'b1', 'W2', 'b2'): network.params[key] -= learning_rate * grad[key] loss = network.loss(x_batch, t_batch) train_loss_list.append(loss) if i % iter_per_epoch == 0: train_acc = network.accuracy(x_train, t_train) test_acc = network.accuracy(x_test, t_test) train_acc_list.append(train_acc) test_acc_list.append(test_acc) print(train_acc, test_acc)
註:數據載入與微分求導的程式碼在上篇中已給出
這樣我們就完成了一個利用誤差反向傳播實現的簡單的兩層NN,當然,程式碼可以更加一般化,生成多層的全連接神經網路,可能將在後面的文章中給出其實現。
小節
這篇中介紹了基於反向傳播法,對上篇中實現的兩層神經網路進行了更進一步的優化。在NN的參數更新方面,還有待優化,其方法有許多,如SGD、Momentum、AdaGrad、Adam等方法;另外還有對於權重的初始值的設置,也有蠻多的研究;以及如何抑制過擬合等,這些都得去了解,並思考其中原理。
本文首發於我的知乎