Day 29 建立第一個神經網路
引言
前兩天我們理解了 autograd 怎麼算梯度,也學會了用 Dataset 與 DataLoader 把資料送進模型。不過目前為止,模型這端還是空白的:沒有「可學習的參數」、沒有「從輸入走到輸出的運算流程」。今天要把這塊補上,帶大家用 PyTorch 的 torch.nn 模組搭出第一個神經網路。
PyTorch 把神經網路抽象成 nn.Module 這個類別。只要繼承它,定義好每一層(layer)與 forward(),模型就自動擁有兩件重要的事:一是所有可學習參數的清單,可以交給最佳化器更新;二是呼叫模型就像呼叫函式一樣直覺,輸入張量、得到預測。今天會分三個小節,從最基礎的類別骨架開始,逐步加入全連接層、激活函式,並用一個實際可執行的範例示範前向傳播。
nn.Module:把模型寫成類別
PyTorch 的模型都繼承自 torch.nn.Module。最基本的寫法是:在建構式(__init__)裡宣告每一層,在 forward() 裡定義資料如何流過這些層。完成後只要寫 model(x),就會自動呼叫 forward() 並回傳預測值。
我們先寫一個最陽春的版本:一個輸入層直接接到輸出層,沒有任何隱藏層或激活函式。
import torch
import torch.nn as nn
class LinearModel(nn.Module):
def __init__(self):
super().__init__()
# 一個全連接層:輸入 4 維、輸出 2 維
self.fc = nn.Linear(in_features=4, out_features=2)
def forward(self, x):
return self.fc(x)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = LinearModel().to(device)
print(model)
# 輸出:
# LinearModel(
# (fc): Linear(in_features=4, out_features=2, bias=True)
# )
這段程式有三個關鍵點:super().__init__() 必須呼叫,否則內部的參數註冊機制不會啟動;self.fc 是 nn.Linear 的實例,模組會自動把它內部的權重與偏差註冊成可學習參數;.to(device) 把整個模型搬到指定裝置,之後輸入張量也要在同一個裝置上才能運算。
想知道模型有哪些參數,可以用 parameters() 或 named_parameters()。
for name, param in model.named_parameters():
print(name, param.shape)
# 輸出:
# fc.weight torch.Size([2, 4])
# fc.bias torch.Size([2])
fc.weight 的形狀是 [輸出數, 輸入數],符合線性變換的習慣:y = x · Wᵀ + b。這個清單稍後會交給 torch.optim 裡的最佳化器,由它負責在每一步訓練中更新這些參數。
nn.Linear 與激活函式
只靠一層線性變換,無論疊幾層,輸出都是輸入的線性組合,這樣的模型沒辦法表達真實世界中複雜的非線性關係。要讓網路「變聰明」,必須在每一層之間插入激活函式(activation function),把線性結果再經過一次非線性映射。最常見的選擇是 ReLU。
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(in_features=4, out_features=8)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(in_features=8, out_features=2)
def forward(self, x):
x = self.fc1(x)
x = self.relu(x)
x = self.fc2(x)
return x
這個小型 MLP(Multi-Layer Perceptron,多層感知器)有兩個全連接層,中間夾了一個 ReLU:先把 4 維的輸入升維到 8 維,通過 ReLU 後再壓回 2 維。ReLU 的公式是 f(x) = max(0, x),把所有負數歸零、正數保留;它計算簡單、收斂穩定,是深度學習裡最常用的激活函式之一。
其他常見的激活函式還包括 nn.Sigmoid(把值壓到 0 到 1 之間,常用於二元分類的輸出層)、nn.Tanh(把值壓到 −1 到 1 之間)、nn.LeakyReLU(讓負數保留一個小斜率,避免神經元「死亡」)。實務上,隱藏層先用 ReLU 通常是安全的起點。
用前向傳播產生預測
模型定義好之後,送進資料就能得到預測值。我們用隨機產生的 3 筆輸入示範,並把中間層的數值印出來,幫助理解資料在網路中怎麼流動。
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(in_features=4, out_features=8)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(in_features=8, out_features=3)
def forward(self, x):
x = self.fc1(x)
x = self.relu(x)
x = self.fc2(x)
return x
model = MLP().to(device)
# 3 筆輸入、每筆 4 個特徵
x = torch.randn(3, 4).to(device)
y = model(x)
print("輸入形狀:", x.shape) # torch.Size([3, 4])
print("輸出形狀:", y.shape) # torch.Size([3, 3])
print(y)
輸出形狀的變化是 [3, 4] → [3, 8] → [3, 3],第一個維度維持「批次大小」不變,後面的維度隨層的設定改變。這個規則在之後的 CNN、RNN 都一樣:批次軸永遠在最前面,這也是為什麼 DataLoader 預設會沿著第一個維度堆疊資料。
預測出來的數值還沒經過 softmax 之類的轉換,純粹是線性層的輸出;如果要做多類別分類,後面會再接 nn.CrossEntropyLoss,它會在內部自動套用 softmax,免去自己額外處理的麻煩(細節會在 Day 30 提到)。
用 GPU 跑一次前向傳播
前面介紹過 device = "cuda" if torch.cuda.is_available() else "cpu" 這行,這是 PyTorch 在 2024 年最常見的裝置切換寫法。我們可以把這個模型實際搬到 GPU 上跑一次,確認 tensor 與 model 都在同一個裝置上。
import torch
import torch.nn as nn
device = "cuda" if torch.cuda.is_available() else "cpu"
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(4, 16)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(16, 3)
def forward(self, x):
return self.fc2(self.relu(self.fc1(x)))
model = MLP().to(device)
x = torch.randn(2, 4, device=device)
y = model(x)
print(next(model.parameters()).device) # 輸出:cuda:0 或 cpu
print(y.device) # 與模型參數相同
注意:nn.Module 不會自動把輸入張量搬到正確的裝置,所以輸入的 x 必須明確地放到 device 上。常見的寫法是 x = x.to(device),在 Day 28 的 DataLoader 迴圈裡會大量出現。如果忘了搬,會出現 RuntimeError: Expected all tensors to be on the same device 這種錯誤訊息,這也是初學者常見的 bug 之一。
參數初始化的小知識
PyTorch 的 nn.Linear 在建立時會自動用 Kaiming Uniform 初始化權重(適合搭配 ReLU),這對大多數任務來說已經是合理的起點。如果想自訂初始化方式,可以在 __init__ 之後用 nn.init 模組覆寫。
import torch.nn.init as init
linear = nn.Linear(8, 16)
init.xavier_uniform_(linear.weight) # 改用 Xavier 初始化
init.zeros_(linear.bias) # 偏差通常初始化為 0
不過在大多數情況下,使用 PyTorch 的預設初始化就夠了,等到遇到「loss 一直是 NaN」或「模型完全不收斂」這類特殊狀況,再回頭檢查初始化即可。Day 38 會更深入討論模型設計與超參數調整。
用 nn.Sequential 快速拼模型
如果模型的層與層之間只是「依序串接」,PyTorch 提供更簡潔的寫法:nn.Sequential。把每一層依序放進它的建構式,forward() 就會自動依序執行,不需另外定義。這種寫法適合用於結構單純的分類器或特徵萃取器;但碰到需要分支、殘差連接等複雜結構時,仍要回到 nn.Module 自己寫 forward()。
import torch.nn as nn
seq_model = nn.Sequential(
nn.Linear(4, 16),
nn.ReLU(),
nn.Linear(16, 8),
nn.ReLU(),
nn.Linear(8, 3),
)
x = torch.randn(2, 4)
print(seq_model(x).shape) # torch.Size([2, 3])
可以看到 nn.Sequential 版本的程式碼更短、更直觀;但少了 named_parameters() 容易閱讀的層級名稱(例如 fc1.weight vs 2.weight),debug 時稍微不便。實務上可以混搭:外層用 nn.Module 管理整個模型,內部某些重複區塊用 nn.Sequential 簡化寫法,兼顧可讀性與彈性。
結語
今天我們把模型這一端補齊了。nn.Module 提供統一的骨架,讓我們能把每一層宣告在 __init__ 裡、把資料流向寫在 forward() 裡;nn.Linear 負責線性變換,nn.ReLU 之類的激活函式則讓網路有能力學會非線性關係。三層的 MLP 雖然簡單,卻已經具備深度學習模型的關鍵元素:可學習參數、前向傳播、與 .to(device) 的裝置切換。
明天,我們會把資料、模型、損失函式、最佳化器四個角色湊齊,寫出第一個能實際「學習」的訓練迴圈。會看到損失從一開始的高數字一步步收斂,模型的預測也跟著穩定下來。
留言
張貼留言