在 Day 4 中,如果你嘗試將 OR 與 AND 換成其他邏輯閘,就會發現 XOR 與 XNOR 無法用單層感知器實現。原因是單層感知器的本質,其實就是在二維平面上劃出一條直線,把資料分開;然而 XOR 與 XNOR 的特性,並無法用單一直線完成區分。這也就是為什麼昨天提到的多層感知器特別重要,它能透過隱藏層增加非線性,使得模型能在更高維度的空間中進行分類,進而解決這類問題,而在今天我要來教你該如何用 NumPy 產生多層感知器的神經網路結構。
準備資料的方式與Day 4一樣,不過在這裡我們要將y_data給更換成XOR的邏輯,這樣才能進行監督是學習。
x_data = np.array([[0,0],[0,1],[1,0],[1,1]], dtype=float)
y_data = np.array([0,1,1,0], dtype=float)
在建立神經網路模型時,網路層數的加深會顯著提高對參數設定的敏感性。若參數選擇不當,模型可能在訓練過程中出現無法收斂的情況,影響預測效果。在本次實作的多層感知器模型中,我們選用了 ReLU 作為隱藏層的激勵函數。由於 ReLU 的輸出特性會將所有小於零的數值歸零,因此在初始化偏置項時,我們刻意加入一個微小的正值,以減少神經元死亡(Dead Neuron)
的風險,確保隱藏層的神經元仍能有效參與學習。
所謂「神經元死亡(Dead Neuron)」,是指在訓練過程中,某些使用 ReLU 激勵函數的神經元長期輸出為 0,進而無法參與誤差反向傳播,對模型的學習與預測毫無貢獻。
我們可以先定義 ReLU 與輸出層使用的 sigmoid 函數,讓後續模型建構時更加清楚前向傳播的過程。
def relu(x):
return np.maximum(0, x)
def sigmoid(x):
x_clip = np.clip(x, -60, 60)
return 1.0 / (1.0 + np.exp(-x_clip))
接下來在初始化權重時我們採用 He 初始化方法(He initialization)
,這有助於保持訊息在前向傳播過程中的穩定性。He 初始化是專門為 ReLU 類激活函數設計的權重初始化方式,核心想法是由於 ReLU 會將一半輸入壓成 0,若不調整權重分佈,訊號的方差會在逐層傳遞時衰減,因此該設計的目的是經過 ReLU 後,讓輸出的方差大致與輸入保持一致,避免梯度消失或爆炸。
He, K., Zhang, X., Ren, S., & Sun, J. (2015). Delving deep into rectifiers: Surpassing human-level performance on imagenet classification. In Proceedings of the IEEE international conference on computer vision (pp. 1026-1034).
class MLP:
def __init__(self, input_dim, hidden_dim):
self.W1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim)
self.b1 = np.full((1, hidden_dim), 0.01) # 微小正值,避免神經元死亡
self.W2 = np.random.randn(hidden_dim, 1) * np.sqrt(2.0 / hidden_dim)
self.b2 = np.zeros((1, 1))
def forward(self, X):
self.x = X
self.h_pre = X @ self.W1 + self.b1
self.h = relu(self.h_pre)
self.z = self.h @ self.W2 + self.b2
self.y = sigmoid(self.z)
return self.y
__call__ = forward
而這樣的設計主要就能讓模型可以讓後續有更好的訓練基礎
同樣地我們會先逐步拆解出 ReLU 的梯度以及 Sigmoid 的梯度推導公式以方便後續使用。
def relu_grad(x):
g = np.zeros_like(x)
g[x > 0] = 1.0
return g
def sigmoid_grad(s):
return s * (1.0 - s)
接下來我們將昨日推導出的反向傳播公式整合進模型中。但需要注意這次的設計是將計算梯度的邏輯直接寫在模型的內部方法中,這樣做可以讓參數的更新流程更為簡潔和集中。
def compute_grads(self, y_true):
# 均方誤差 (MSE):L = (1/N) * sum((y - t)^2)
y_true = y_true.reshape(-1, 1)
y_pred = self.y
N = y_pred.shape[0]
# 計算 Loss 值
loss_val = np.mean((y_pred - y_true) ** 2)
# 反向傳播
dy = (y_pred - y_true)
dz = dy * sigmoid_grad(y_pred)
dW2 = self.h.T @ dz
db2 = np.sum(dz, axis=0, keepdims=True)
dh = dz @ self.W2.T
dh_pre = dh * relu_grad(self.h_pre)
dW1 = self.x.T @ dh_pre
db1 = np.sum(dh_pre, axis=0, keepdims=True)
grads = {"dW1": dW1, "db1": db1, "dW2": dW2, "db2": db2}
return grads, loss_val
接下來,我們只需實作梯度下降法來更新模型參數。本模型中需要調整的參數包括輸入層到隱藏層的權重與偏差(W1 與 b1
),以及隱藏層到輸出層的權重與偏差(W2
與 b2
)。
class GD:
def __init__(self, model, lr=0.05):
self.model = model
self.lr = lr
def step(self, grads):
self.model.W2 -= self.lr * grads["dW2"]
self.model.b2 -= self.lr * grads["db2"]
self.model.W1 -= self.lr * grads["dW1"]
self.model.b1 -= self.lr * grads["db1"]
在這裡與先前相比變化不大,只是將 compute_grads
移入模型內部。這樣一來,模型在輸出時就不必再依賴內部參數來進行計算。我們只需將 y_data
傳入 compute_grads
,就能透過優化器直接更新模型參數。
epochs = 5000
for epoch in range(epochs):
_ = model(x_data)
grads = model.compute_grads(y_data)
optimizer.step(grads)
print("訓練完成!")
y_pred = model(x_data)
print("Raw predictions:", y_pred.ravel())
print("Pred labels :", (y_pred >= 0.5).astype(int).ravel())
print("True labels :", y_data.astype(int))
輸出結果:
訓練完成!
Raw predictions: [0.03090754 0.96877249 0.97453328 0.02507739]
Pred labels : [0 1 1 0]
True labels : [0 1 1 0]
由於我們的模型使用了 Sigmoid 函數作為輸出層的啟用函數,因此預測結果會被壓縮到 0 到 1 之間。正因如此,在進行分類判斷時,我們通常會以 0.5 作為分界點——大於 0.5 的視為正類(1),小於等於 0.5 的則歸為負類(0)。
而這樣子我們就可以看到,模型已經能學會處理比單層感知器更高維度、更複雜的預測結果,該模型不僅能解決 XOR 這類經典的非線性問題,它的設計理念也成為現今許多深度學習模型的重要基礎,這一點我們會在後續持續地看見其方式的身影。
到目前為止,我們一步步推導出多層感知器如何解決 XOR 問題,從數學公式到 NumPy 實作都完整走過了一遍。雖然這樣能幫助我們理解深度學習的本質,但你可能也發現了 光是手動推導梯度與更新規則,就已經相當繁瑣且耗時。如果每次要做更複雜的模型都得如此,效率會非常低。
這也是為什麼我們需要更高效的深度學習框架。因此明天開始我將帶你安裝並使用 PyTorch,重新構建一次 MLP 模型來解決 XOR 問題。透過 PyTorch,你將能體驗到自動微分與高效訓練的便利,並大幅減少程式碼與數學推導的負擔。