今天我們一樣要來用Numpy手刻一下多層感知器這個神經網路。不過今天我對昨天神經網路結構圖進行了一些簡單的改造,目的是為了讓神經網路更加有效,並減少調參的部分。在接下來的內容中,我將會告訴你如何建立和使用這些改進後的神經網路。
在這次的程式撰寫邏輯上,基本上與我們在第4天接觸到的寫法相似。但是這次的寫法我會以批次運算取代透過for迴圈逐一運算每個輸入與輸出。因此運算難度和模型建構的難度都有所增加,因此,如果遇到看不懂的程式碼部分,可以先回去看看第4天的程式碼寫法,這樣會比較容易理解今天的內容。
初始化的動作與單層感知器基本上並無太大的差異,但我們在此增加了一個 hidden_shape
參數,該參數代表輸入層連接了多少個隱藏層。例如在昨天的例子中,我們使用了 2 個隱藏層神經元,因此在這裡該參數的設定為 2。
import numpy as np
class MLP:
def __init__(self, input_shape, hidden_shape=2, output_shape=1, learning_rate=1):
# 初始化權重和偏移量
self.W1 = np.random.randn(input_shape, hidden_shape)
self.b1 = np.zeros((1, hidden_shape))
self.W2 = np.random.randn(hidden_shape, output_shape)
self.b2 = np.zeros((1, output_shape))
# 初始化學習率
self.learning_rate = learning_rate
接下來我們針對前向傳播的公式做了一些調整,具體來說就是在輸入層到隱藏層之間加入sigmoid函數,其目的是讓模型能更好地解決非線性的問題。
def sigmoid(self, x):
return 1 / (1 + np.exp(-x))
def forward(self, x):
# 前向傳播:計算每層的輸出
self.z1 = np.dot(x, self.W1) + self.b1
self.a1 = self.sigmoid(self.z1)
self.z2 = np.dot(self.a1, self.W2) + self.b2
self.a2 = self.sigmoid(self.z2)
return self.a2
我們昨天學習到反向公式是非常繁瑣的。而在這些公式中,你可能發現了只要看到與𝜕𝑧
相關的部分,都會產生y * (1 - y)
這個公式。事實上這個動作就是Sigmoid函數的求導過程。因此我們可以寫一個sigmoid_derivative
函數,這樣當我們需要使用Sigmoid函數的導數時,就可以直接調用這個函數。
def sigmoid_derivative(self, y):
return y * (1 - y)
在這裡同樣地,我們保留尚未被簡化前的損失函數,但要注意的是由於我們是以批次為單位進行計算,因此我們實際上返回的是每一個計算誤差的平均值。
def loss_function(self, y_hat, y):
# 計算均方誤差 (MSE) 損失
return np.mean(0.5 * (y - y_hat) ** 2)
而在定義公式時我們只需要輸入昨天的公式即可,首先我們先將隱藏層到輸出層的權重梯度公式定義為delta2
,而輸出層到隱藏層的公式定義為delta1
。不過由於我們在一開始在輸出層到隱藏層之間多加了一個sigmoid函數,因此在定義delta1
的公式時,我們需要多計算一個sigmoid_derivative
。
def backward(self, x, y):
# 計算梯度並更新權重和偏移量
m = x.shape[0]
delta2 = (self.a2 - y) * self.sigmoid_derivative(self.a2)
dW2 = (1 / m) * np.dot(self.a1.T, delta2)
db2 = (1 / m) * np.sum(delta2, axis=0, keepdims=True)
delta1 = np.dot(delta2, self.W2.T) * self.sigmoid_derivative(self.a1)
dW1 = (1 / m) * np.dot(x.T, delta1)
db1 = (1 / m) * np.sum(delta1, axis=0, keepdims=True)
這裡有一個需要注意的地方,由於我們採用的是批量運算,因此所有的梯度都應該計算其平均值。所以在程式碼中我們使用到1/m
來計算當前批量內每一個梯度的平均值,這樣才能夠正確地更新權重與偏移量,而更新其參數的方式,我們同樣採用梯度下降法來調整參數。
# 更新權重與偏移量
self.W2 -= self.learning_rate * dW2
self.b2 -= self.learning_rate * db2
self.W1 -= self.learning_rate * dW1
self.b1 -= self.learning_rate * db1
而在進行預測時我們需要預測的目標是類別,但調用前向傳播 forward()
函數只會產生一個介於 0 到 1 之間的數值。因此我們可以選擇這個數值的中間值作為類別的預測結果。到這邊我們已經完成模型的建立了
def predict(self, x):
# 預測時直接進行前向傳播
y = self.forward(x) > 0.5
return y.astype(int)
在這次的訓練方式中,由於是批量運算因此我們不需要撰寫一個 for 迴圈逐筆將資料傳送給模型進行運算,因此可以直接移除該迴圈的部分,其他的計算方式和顯示方式則與先前相同。
def training(model, x_train, y_train, epochs=100):
for epoch in range(epochs):
y_hat = model.forward(x_train)
loss = model.loss_function(y_hat, y_train)
model.backward(x_train, y_train)
if epoch % 1000 == 0:
print(f'Epoch {epoch}, Loss: {loss:.5f}')
print('訓練完成!')
最後我們準備XOR的訓練數據,並建立多層感知器模型進行訓練。在這裡我們將epochs
設定得比較高,這是因為這次的運算難度較高。為了確保模型能夠收斂所以增加了訓練的次數。
# XOR 訓練數據
x_train = np.array([[0, 0], [0, 1], [1, 0], [1, 1]])
y_train_XOR = np.array([[0], [1], [1], [0]])
# 建立 MLP 模型
model = MLP(input_shape=x_train.shape[1])
# 訓練模型
training(model, x_train, y_train_XOR, epochs=10000)
# 模型訓練後預測結果
print("\n模型訓練後預測結果:")
pred = model.predict(x_train) # 一次預測而非單筆資料預測
for x, y in zip(x_train, pred):
print(f'輸入: {x}, 預測輸出: {y}')
# -----輸出-----
Epoch 9000, Loss: 0.00030
訓練完成!
模型訓練後預測結果:
輸入: [0 0], 預測輸出: [0]
輸入: [0 1], 預測輸出: [1]
輸入: [1 0], 預測輸出: [1]
輸入: [1 1], 預測輸出: [0]
這時我們將會發現,即使更換成其他邏輯閘也能訓練出正確的結果。這就是為什麼現在的神經網路模型需要增加更多的層數和神經元數量。例如在我們這個任務中,我們讓其訓練的曲線在平面上進行轉彎的動作。這一發展就延續到現今像是ChatGPT、Stable Diffusion等技術,使其能在更高維的空間中進行運算。
在這短短幾天內我們不只學習到了單層與多層感知器的詳細結構,還學會了如何透過矩陣計算來減少 for 迴圈的次數以加快模型運算速度,同時我們還證明了這些不同模型的反向傳播過程。而即使不完全理解這些證明也不用擔心,隨著後續神經網路變得越來越複雜,這些反向傳播的證明將變得不太重要,因為有函式庫可以幫助我們進行計算。因此我們實際上需要理解的是這些模型在前向傳播中所使用的技術,這才是在深度學習技術中最重要的事情。
若你理解了這些內容後,不妨試著調整一些超參數的設定。例如我們可以增加或減少隱藏層神經元的數量,也可以調整學習率和訓練次數等。我們學習的目標是了解如何調整這些參數,使其能夠產生最低的損失值。這個調整參數的經驗與過程在我們優化模型時非常重要。
本文中的程式碼都放置在我的GitHub中:
Learning-AI-in-30-Days-by-Using-Math-for-Better-Understanding