在我們昨天的做法中其實是後續的改良方法,因為單層感知器並不是透過損失函數與梯度下降來更新權重,而是依照感知器學習規則 (Perceptron Learning Rule)
進行調整。這種規則的邏輯是當預測輸出為 0、而目標值為 1 時,就會增加權重,讓輸出更接近 1。
但是這樣的更新方式其實相對直觀卻顯得粗糙也缺乏靈活性,因為我們不可能對每個標籤產生新的規則,且也缺乏嚴謹的數學意義,因此我將透過程式實作,讓你更直觀地理解昨天的方式為何能夠取代感知器學習規則。
在 Python 中,[]
表示的是列表 (list)
,而不是數學上的矩陣(Array)
,所以如果要進行矩陣或向量計算,建議使用 NumPy
函式庫,讓我我們方便後續的運算,因此我們的輸入與標籤可以如此定義。輸入與輸出資料:
import numpy as np
x_data = np.array([
[0, 0],
[0, 1],
[1, 0],
[1, 1]
])
y_data = np.array([0, 1, 1, 1])
print("x_data shape:", x_data.shape)
print("y_data shape:", y_data.shape)
輸出結果:
x_data shape: (4, 2)
y_data shape: (4,)
在這個步驟中,我們必須先弄清楚輸入資料 x_data
的實際意義。這一點非常重要,因為在任何模型中,都需要理解每個維度所代表的涵義,否則在設計或使用模型時,可能會因為誤解輸入而導致錯誤。
對於 x_data
其維度為 (data_size, feature)
,表示共有 4 筆資料 (data_size=4),而每筆資料包含 2 個特徵 (feature=2)。對於 y_data
則是 4 個對應的標籤值 (答案),對應 x_data
的 4 筆資料。
在單層感知器中,模型在前向傳播時需要兩個可訓練的參數權重與偏置。因此在模型初始化時,我們必須先定義這兩個參數以便後續使用。接著我們需要定義前向傳播的計算方式,也就是透過公式y=f(WX+b)
完成計算,因此我們的模型可以如此定義
class Perceptron:
def __init__(self, input_size):
# 隨機初始化w與b
self.w = np.random.randn(input_size)
self.b = np.random.randn() # 可為0
def forward(self, x):
logit = np.dot(x, self.w) + self.b # 前向傳播公式
y = (logit >= 0).astype(int)
return logit
def __call__(self, x):
return self.forward(x)
model = Perceptron(input_size=x_data.shape[1])
y_pred = model(x_data)
output = (y_pred < 0).astype(int) # 激勵函數
print(output)
輸出結果:
[0 0 0 0] # 不一定是這個因為權重是隨機初始化
在這裡可以看到,模型的輸出結果並非我們預期的[0 1 1 1]
,所以接下來的步驟就是調整模型的權重來改善預測表現。在程式設計上我們透過覆寫 __call__
方法來呼叫 forward
,如此一來,就能以 model(...)
的形式呼叫,而不需要明確撰寫 model.forward(...)
,讓程式碼更加簡潔直觀。
在這裡我們採用 MSE Loss作為損失函數。它的主要作用是計算模型輸出與真實值之間的誤差,並進一步獲得該誤差對權重與偏置的梯度方向與大小。由於我們在昨日已經推導出完整並簡化後的公式,因此這裡可以直接套用簡化後的過程進行計算:
def perceptron_grad(x, y_pred, y_true):
err = (y_pred - y_true)
grad_w = x.T @ err / x.shape[0] # 平均每個損失
grad_b = err.mean() # 平均每個損失
return grad_w, grad_b
grad_w, grad_b = perceptron_grad(x_data, y_pred, y_data)
print('權重梯度:', grad_w)
print('偏置梯度:', grad_b)
輸出結果:
權重梯度: [0. 0.]
偏置梯度: 0.25
與先前逐筆樣本計算不同,這裡我們採用了批量(Batch
運算。在批量模式下,模型同時處理多筆樣本資料,因此每一步所回傳的損失值必須取平均值,避免因樣本數量而影響梯度的大小。
前面我們已經成功計算出權重與偏置的梯度,接下來的步驟就是利用梯度下降法來更新參數,這裡我們透過建立一個簡單的類別,將學習率與更新規則封裝起來,並觀察執行前後的差異:
class GD:
def __init__(self, model, lr=1e-3):
self.model = model
self.lr = lr
def step(self, grad_w, grad_b):
self.model.w -= self.lr * grad_w
self.model.b -= self.lr * grad_b
optimizer = GD(model, lr=0.1)
y_pred = model(x_data)
print("更新前的 w:", model.w)
print("更新前的 b:", model.b)
optimizer.step(grad_w, grad_b)
print("更新後的 w:", model.w)
print("更新後的 b:", model.b)
輸出結果:
更新前的 w: [1.25326675 1.9396813 ]
更新前的 b: 1.4974228700042376
更新後的 w: [1.25326675 1.9396813 ]
更新後的 b: 1.4724228700042377
在反向傳播完成後,我們可以觀察到偏置已經更新,但權重卻完全沒有變動。這代表傳入的 grad_w
幾乎是一個全接近零的陣列。對於淺層網路來說,這種情況通常與初始化方式、學習率或激勵函數有關。
在這裡問題的根源來自所使用的階梯函數作為激勵函數。由於它在 0 處不可微分,且在其他區域梯度為零,導致權重無法透過梯度下降有效更新。 正因如此這類不可微或梯度為零的函數在深度學習中幾乎已經被淘汰。
不過由於我們的任務還是較簡單,因此我們還是能夠正常訓練出模型,而在訓練模型的方式我們只需要設定訓練次數並重複使用上述動作即可完成最基本的步驟。
epochs = 100
for epoch in range(epochs):
y_pred = model(x_data) # 前向傳播
grad_w, grad_b = perceptron_grad(x_data, y_pred, y_data) # 計算梯度
optimizer.step(grad_w, grad_b) # 更新參數
print('訓練完成!')
y_pred = model(x_data) # 預測結果
print(y_pred)
輸出結果:
訓練完成!
[0 1 1 1]
到這裡,我們就已經成功實作出一個符合前兩天所學理論的簡單 AI 模型,而本次的完整程式碼將會放置在這裡後續有相關內容也會持續更新在這一儲存庫中!
明天我們將進一步探討單層感知器的延伸以及其背後的數學公式,並更清楚地體現其中的線性組合WX+b的意義。同時我也會介紹另一種激勵函數的使用方式,讓大家比較在 0 處可微與不可微 的差異,進一步理解為什麼選擇適當的激勵函數對模型訓練如此重要。