技巧| 如何只用NumPy碼一個神經網路
Keras、TensorFlow、PyTorch 等高階框架可以幫助我們快速構建複雜模型。深入研究並理解其中的理念很有價值。不久前,本文作者發表了一篇文章(參見《資源 | 來自獨秀同學的深度網路數學筆記,還不快收藏?》),簡明扼要地解釋了神經網路的工作原理,但那篇文章偏向於數學理論知識。所以作者打算以一種更實際的方式來跟進這一話題。他們嘗試只使用 NumPy 構建一個全運算的神經網路,通過解決簡單的分類問題來測試模型,並將其與 Keras 構建的神經網路進行效能比較。
注:本文將包含大量用 Python 編寫的程式碼片段。希望讀起來不會太無聊。:)所有原始碼都可以在作者的 GitHub 上找到。連結:https://github.com/SkalskiP/ILearnDeepLearning.py
圖 1 :密集神經網路架構
磨刀不誤砍柴工
在開始程式設計之前,需要先整理一個基本的路線圖。我們的目標是建立一個程式,該程式能建立一個擁有特定架構(層的數量和大小以及啟用函式都是確定的)的密集連線神經網路。圖 1 給出了網路的示例。最重要的是,網路必須可訓練且能進行預測。
圖 2 :神經網路框圖
上圖顯示了在訓練神經網路時需要執行的操作。它還顯示了在單次迭代的不同階段,需要更新和讀取多少引數。構建正確的資料結構並熟練地管理其狀態是任務中最困難的部分之一。
圖 3 :l 層的權值矩陣 W 和偏置向量 b 的維數。
神經網路層初始化
首先初始化每一層的權值矩陣 W 和偏置向量 b。在圖 3 中。先準備一個為係數分配適當維數的清單。上標 [l] 表示當前層的索引 (從 1 數起),值 n 表示給定層中的單位數。假設描述 NN 架構的資訊將以類似 Snippet 1 的列表形式傳遞到程式中,列表的每一項是一個描述單個網路層基本引數的字典:input_dim 是輸入層訊號向量的大小,output_dim 是輸出層啟用向量的大小,activation 是在內層使用的啟用函式。
nn_architecture = [ {"input_dim": 2, "output_dim": 4, "activation": "relu"}, {"input_dim": 4, "output_dim": 6, "activation": "relu"}, {"input_dim": 6, "output_dim": 6, "activation": "relu"}, {"input_dim": 6, "output_dim": 4, "activation": "relu"}, {"input_dim": 4, "output_dim": 1, "activation": "sigmoid"}, ]
Snippet 1:包含描述特定神經網路引數的列表。該列表對應圖 1 所示的 NN。
如果你對這個話題很熟悉,你可能已經在腦海中聽到一個焦慮的聲音:「嘿,嘿!這裡有問題!有些領域是不必要的……」是的,這次你內心的聲音是對的。前一層輸出的向量是下一層的輸入,所以實際上只知道一個向量的大小就足夠了。但我特意使用以下符號來保持所有層之間目標的一致性,使那些剛接觸這一課題的人更容易理解程式碼。
def init_layers(nn_architecture, seed = 99):
np.random.seed(seed)
number_of_layers = len(nn_architecture)
params_values = {}
for idx, layer in enumerate(nn_architecture):
layer_idx = idx + 1
layer_input_size = layer["input_dim"]
layer_output_size = layer["output_dim"]
params_values['W' + str(layer_idx)] = np.random.randn(
layer_output_size, layer_input_size) * 0.1
params_values['b' + str(layer_idx)] = np.random.randn(
layer_output_size, 1) * 0.1
return params_values
Snippet 2:初始化權值矩陣和偏置向量值的函式。
最後是這一部分最主要的任務——層引數初始化。看過 Snippet 2 上的程式碼並對 NumPy 有一定經驗的人會發現,矩陣 W 和向量 b 被小的隨機數填充。這種做法並非偶然。權值不能用相同的數字初始化,不然會出現「對稱問題」。如果所有權值一樣,不管輸入 X 是多少,隱藏層中的所有單位都相同。在某種程度上,我們在初始階段就會陷入死迴圈,無論訓練模型時間多長、網路多深都無法逃脫。線性代數是不會被抵消的。
在第一次迭代中,使用較小的數值可以提高演算法效率。通過圖 4 所示的 sigmoid 函式圖可以看到,對於較大數值,它幾乎是平的,這十分影響 NN 的學習速度。總之,使用小隨機數進行引數初始化是一種簡單的方法,能保證我們的演算法有足夠好的起點。準備好的引數值儲存在帶有唯一標定其父層的 python 字典中。字典在函式末尾返回,因此演算法的下一步是訪問它的內容。
圖 4:演算法中使用的啟用函式。
啟用函式
機器之心Synced啟用函式小程式
我們將使用的函式中,有幾個函式非常簡單但功能強大。啟用函式可以寫在一行程式碼中,但卻能使神經網路表現出自身所需的非線性效能和可表達性。「沒有它們,我們的神經網路就會變成由多個線性函式組合而成的線性函式。」可選啟用函式很多,但在這個專案中,我決定使用這兩種——sigmoid 和 ReLU。為了能夠得到完整迴圈並同時進行前向和反向傳播,我們還需要求導。
def sigmoid(Z):
return 1/(1+np.exp(-Z))
def relu(Z):
return np.maximum(0,Z)
def sigmoid_backward(dA, Z):
sig = sigmoid(Z)
return dA * sig * (1 - sig)
def relu_backward(dA, Z):
dZ = np.array(dA, copy = True)
dZ[Z <= 0] = 0;
return dZ;
Snippet 3:ReLU 和 Sigmoid 啟用函式及其導數。
前向傳播
設計好的神經網路有一個簡單的架構。資訊以 X 矩陣的形式沿一個方向傳遞,穿過隱藏的單元,從而得到預測向量 Y_hat。為了便於閱讀,我將前向傳播分解為兩個單獨的函式——對單個層進行前向傳播和對整個 NN 進行前向傳播。
def single_layer_forward_propagation(A_prev, W_curr, b_curr, activation="relu"):
Z_curr = np.dot(W_curr, A_prev) + b_curr
if activation is "relu":
activation_func = relu
elif activation is "sigmoid":
activation_func = sigmoid
else:
raise Exception('Non-supported activation function')
return activation_func(Z_curr), Z_curr
Snippet 4:單層前向傳播步驟
這部分程式碼可能是最容易理解的。給定上一層的輸入訊號,我們計算仿射變換 Z,然後應用選定的啟用函式。通過使用 NumPy,我們可以利用向量化——一次性對整個層和整批示例執行矩陣運算。這減少了迭代次數,大大加快了計算速度。除了計算矩陣 A,我們的函式還返回一箇中間值 Z。作用是什麼呢?答案如圖 2 所示。我們需要在反向傳播中用到 Z。
圖 5 :在前向傳播中使用的單個矩陣的維數。
使用預設好的一層前向函式後,就可以輕鬆地構建整個前向傳播。這個函式稍顯複雜,它的作用不僅是預測,還要管理中間值的集合。它返回 Python 字典,其中包含為特定層計算的 A 和 Z 值。
def full_forward_propagation(X, params_values, nn_architecture):
memory = {}
A_curr = X
for idx, layer in enumerate(nn_architecture):
layer_idx = idx + 1
A_prev = A_curr
activ_function_curr = layer["activation"]
W_curr = params_values["W" + str(layer_idx)]
b_curr = params_values["b" + str(layer_idx)]
A_curr, Z_curr = single_layer_forward_propagation(A_prev, W_curr, b_curr, activ_function_curr)
memory["A" + str(idx)] = A_prev
memory["Z" + str(layer_idx)] = Z_curr
return A_curr, memory
Snippnet 5:完整前向傳播步驟
損失函式
為了觀察進度,保證正確方向,我們通常需要計算損失函式的值。「一般來說,損失函式用來表徵我們與『理想』解決方案的距離。」我們根據要解決的問題來選擇損失函式,像 Keras 這樣的框架會有多種選擇。因為我計劃測試我們的 NN 在兩類點上的分類,所以選擇二進位制交叉熵,它定義如下。為了獲得更多學習過程的資訊,我決定引入一個計算準確率的函式。
Snippnet 6:損失函式和準確率計算
反向傳播
許多缺乏經驗的深度學習愛好者認為反向傳播是一種難以理解的演算法。微積分和線性代數的結合常常使缺乏數學基礎的人望而卻步。所以如果你無法馬上理解,也不要擔心。相信我,我們都經歷過這個過程。
def single_layer_backward_propagation(dA_curr, W_curr, b_curr, Z_curr, A_prev, activation="relu"):
m = A_prev.shape[1]
if activation is "relu":
backward_activation_func = relu_backward
elif activation is "sigmoid":
backward_activation_func = sigmoid_backward
else:
raise Exception('Non-supported activation function')
dZ_curr = backward_activation_func(dA_curr, Z_curr)
dW_curr = np.dot(dZ_curr, A_prev.T) / m
db_curr = np.sum(dZ_curr, axis=1, keepdims=True) / m
dA_prev = np.dot(W_curr.T, dZ_curr)
return dA_prev, dW_curr, db_curr
Snippnet 7:單層反向傳播步驟
人們常常混淆反向傳播與梯度下降,但實際上這是兩個獨立的問題。前者的目的是有效地計算梯度,而後者是利用計算得到的梯度進行優化。在 NN 中,我們計算關於引數的代價函式梯度(之前討論過),但是反向傳播可以用來計算任何函式的導數。這個演算法的本質是在已知各個函式的導數後,利用微分學中的鏈式法則計算出結合成的函式的導數。對於一層網路,這個過程可用下面的公式描述。本文主要關注的是實際實現,故省略推導過程。通過公式可以看出,預先記住中間層的 A 矩陣和 Z 矩陣的值是十分必要的。
圖 6:一層中的前向和反向傳播。
就像前向傳播一樣,我決定將計算分為兩個獨立的函式。第一個函式(Snippnet7)側重一個單獨的層,可以歸結為用 NumPy 重寫上面的公式。第二個表示完全反向傳播,主要在三個字典中讀取和更新值。然後計算預測向量(前向傳播結果)的代價函式導數。這很簡單,它只是重述了下面的公式。然後從末端開始遍歷網路層,並根據圖 6 所示的圖計算所有引數的導數。最後,函式返回 python 字典,其中就有我們想求的梯度。
def full_backward_propagation(Y_hat, Y, memory, params_values, nn_architecture):
grads_values = {}
m = Y.shape[1]
Y = Y.reshape(Y_hat.shape)
dA_prev = - (np.divide(Y, Y_hat) - np.divide(1 - Y, 1 - Y_hat));
for layer_idx_prev, layer in reversed(list(enumerate(nn_architecture))):
layer_idx_curr = layer_idx_prev + 1
activ_function_curr = layer["activation"]
dA_curr = dA_prev
A_prev = memory["A" + str(layer_idx_prev)]
Z_curr = memory["Z" + str(layer_idx_curr)]
W_curr = params_values["W" + str(layer_idx_curr)]
b_curr = params_values["b" + str(layer_idx_curr)]
dA_prev, dW_curr, db_curr = single_layer_backward_propagation(
dA_curr, W_curr, b_curr, Z_curr, A_prev, activ_function_curr)
grads_values["dW" + str(layer_idx_curr)] = dW_curr
grads_values["db" + str(layer_idx_curr)] = db_curr
return grads_values
Snippnet 8:全反向傳播步驟
更新引數值
該方法的目標是利用梯度優化來更新網路引數,以使目標函式更接近最小值。為了實現這項任務,我們使用兩個字典作為函式引數:params_values 儲存引數的當前值;grads_values 儲存根據引數計算出的代價函式導數。雖然該優化演算法非常簡單,只需對每一層應用下面的方程即可,但它可以作為更高階優化器的一個良好起點,所以我決定使用它,這也可能是我下一篇文章的主題。
def update(params_values, grads_values, nn_architecture, learning_rate):
for layer_idx, layer in enumerate(nn_architecture):
params_values["W" + str(layer_idx)] -= learning_rate * grads_values["dW" + str(layer_idx)]
params_values["b" + str(layer_idx)] -= learning_rate * grads_values["db" + str(layer_idx)]
return params_values;
Snippnet 9:利用梯度下降更新引數值
組合成型
任務中最困難的部分已經過去了,我們已經準備好了所有必要的函式,現在只需把它們按正確的順序組合即可。為了更好地理解操作順序,需要對照圖 2 的表。該函式經過訓練和期間的權值變化返回了最優權重。只需要使用接收到的權重矩陣和一組測試資料即可執行完整的前向傳播,從而進行預測。
def train(X, Y, nn_architecture, epochs, learning_rate):
params_values = init_layers(nn_architecture, 2)
cost_history = []
accuracy_history = []
for i in range(epochs):
Y_hat, cashe = full_forward_propagation(X, params_values, nn_architecture)
cost = get_cost_value(Y_hat, Y)
cost_history.append(cost)
accuracy = get_accuracy_value(Y_hat, Y)
accuracy_history.append(accuracy)
grads_values = full_backward_propagation(Y_hat, Y, cashe, params_values, nn_architecture)
params_values = update(params_values, grads_values, nn_architecture, learning_rate)
return params_values, cost_history, accuracy_history
Snippnet 10:訓練模型
David vs Goliath
現在可以檢驗我們的模型在簡單的分類問題上的表現了。我生成了一個由兩類點組成的資料集,如圖 7 所示。然後讓模型學習對兩類點分類。為了便於比較,我還在高階框架中編寫了 Keras 模型。兩種模型具有相同的架構和學習速率。儘管如此,這樣對比還是稍有不公,因為我們準備的測試太過於簡單。最終,NumPy 模型和 Keras 模型在測試集上的準確率都達到了 95%,但是我們的模型需要多花幾十倍的時間才能達到這樣的準確率。在我看來,這種狀態主要是由於缺乏適當的優化。
圖 7:測試資料集
圖 8:兩種模型實現的分類邊界視覺化
原文連結:https://towardsdatascience.com/lets-code-a-neural-network-in-plain-numpy-ae7e74410795