Day 30 訓練迴圈
引言
Day 29 我們搭出了第一個神經網路,呼叫 model(x) 就能得到預測值。不過目前為止,預測的數字其實是「隨機猜的」:模型的權重是隨機初始化的,沒有人告訴它什麼是對、什麼是錯。要讓模型真的學會預測,得再請兩個角色上場:損失函式(loss function)告訴模型「現在錯得多嚴重」,最佳化器(optimizer)則根據梯度把參數往「錯得比較少」的方向調整。
把資料、模型、損失函式、最佳化器四個角色串起來,就是所謂的「訓練迴圈」(training loop)。今天的內容會用三個小節,從損失函式與最佳化器的概念開始,再寫出一個能實際跑起來、並且看得到損失下降的訓練流程。最後我們會把所有零件組合成一段可在 CPU 上執行的完整範例,並討論如何評估模型在沒看過的資料上的表現。
損失函式:衡量預測與目標的差距
損失函式接受兩個引數:模型的預測值(logits)與真實標籤(targets),回傳一個純量張量表示「平均錯了多少」。PyTorch 把常見的損失函式收在 torch.nn 裡,最常用的兩個是 nn.CrossEntropyLoss 與 nn.MSELoss。
nn.CrossEntropyLoss 適合多類別分類:輸入是模型最後一層的原始輸出(不需要先手動 softmax),目標是類別索引(整數 0、1、2…)。它會在內部套用 softmax 再算負對數似然(negative log-likelihood),數值上等同於「預測的機率分布」與「真實分布」之間的距離。
import torch
import torch.nn as nn
loss_fn = nn.CrossEntropyLoss()
# 模型輸出:3 筆、每筆 4 個類別的分數
logits = torch.tensor([[2.0, 1.0, 0.5, 0.3],
[0.1, 3.0, 0.2, 0.5],
[0.4, 0.2, 1.5, 2.1]])
# 真實標籤:第 0、1、3 類
targets = torch.tensor([0, 1, 3])
loss = loss_fn(logits, targets)
print(loss.item()) # 輸出約 0.46
nn.MSELoss(均方誤差)適合回歸任務:輸入與目標都是連續值,計算「兩者差值的平方」再取平均。
loss_fn = nn.MSELoss()
pred = torch.tensor([2.5, 0.0, 2.0])
target = torch.tensor([3.0, -0.5, 2.0])
loss = loss_fn(pred, target)
print(loss.item()) # 輸出約 0.1667
選擇損失函式的原則很直觀:「任務在預測什麼、就用對應的損失」。分類任務用 CrossEntropyLoss,回歸任務用 MSELoss,二元分類也可以用 BCEWithLogitsLoss(內建 sigmoid 的二元交叉熵)。
最佳化器:依照梯度更新參數
有了損失,接著要把「錯」回傳給模型參數,這就是 autograd 的工作;參數拿到梯度之後,要決定「往哪個方向更新多少」,這就是最佳化器的工作。PyTorch 把常用最佳化器收在 torch.optim,最常見的兩個是 AdamW 與 SGD。
import torch
import torch.nn as nn
model = nn.Linear(in_features=4, out_features=2)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
建構最佳化器時,要把「要更新的參數」與「學習率(learning rate,lr)」傳進去。AdamW 是 Adam 的改良版,對權重做「權重衰減」(weight decay)的正則化,有助於避免過擬合,是目前深度學習裡的預設選擇之一。SGD(隨機梯度下降)則是最經典的方法,加上動量(momentum)後在很多場景依然好用;學習率的設定經驗值大約在 1e-3 到 1e-2 之間。如果對學習率沒有概念,從 1e-3 開始嘗試,通常是個安全的起點。
訓練時最佳化器有三個標準動作:清空梯度、計算新梯度、更新參數。
optimizer.zero_grad() # 清空上一輪的梯度
loss.backward() # 反向傳播算出新梯度
optimizer.step() # 用梯度更新參數
optimizer.zero_grad() 一定要記得呼叫,因為 autograd 預設會把梯度累加到 .grad 上;如果忘記清零,第二輪的梯度就會疊在第一輪之上,更新方向會錯亂。順帶一提,不同的最佳化器在內部會保留各自的狀態,例如 AdamW 會記錄梯度的移動均值與變異數;如果在訓練中途想要重置這些狀態(例如切換到不同的學習率策略),可以呼叫 optimizer.state 來檢查,或直接建立一份新的最佳化器。
完整訓練迴圈
把前面的角色全部組裝起來。我們用一個合成的「可分類資料集」做示範:先隨機產生 120 筆 4 維特徵,並設計 3 個類別的中心點,再讓模型學習把每筆資料分到正確的類別。整個流程在 CPU 上就能執行,輸出是「3 類別的分類結果」,概念延伸到 MNIST 完全一樣。
import torch
import torch.nn as nn
device = "cuda" if torch.cuda.is_available() else "cpu"
# ---------- 1. 合成資料 ----------
torch.manual_seed(0)
centers = torch.tensor([[-2.0, -2.0, -2.0, -2.0],
[ 0.0, 0.0, 0.0, 0.0],
[ 2.0, 2.0, 2.0, 2.0]])
features, targets = [], []
for c in range(3):
features.append(centers[c] + 0.5 * torch.randn(40, 4))
targets.append(torch.full((40,), c, dtype=torch.long))
X = torch.cat(features).to(device) # shape: [120, 4]
y = torch.cat(targets).to(device) # shape: [120]
# ---------- 2. 模型 ----------
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)
# ---------- 3. 損失與最佳化器 ----------
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-2)
# ---------- 4. 訓練迴圈 ----------
for epoch in range(1, 6):
model.train() # 切到訓練模式
optimizer.zero_grad() # 清空梯度
logits = model(X) # 前向傳播
loss = loss_fn(logits, y)
loss.backward() # 反向傳播
optimizer.step() # 更新參數
print(f"epoch {epoch}: loss = {loss.item():.4f}")
執行結果大致如下(隨機初始化的差異,每次數字會略有不同):
epoch 1: loss = 1.0982
epoch 2: loss = 0.8521
epoch 3: loss = 0.5104
epoch 4: loss = 0.2861
epoch 5: loss = 0.1537
可以看到 loss 從 1.1 一路下降到 0.15 左右,這就是模型在「學會分類」的訊號。model.train() 會啟用 dropout、BatchNorm 的訓練行為;如果模型有這些層,呼叫 forward 前別忘了切換模式。
驗證與評估
訓練的最終目的是讓模型在「沒看過的資料」上也表現得好,因此需要把資料切出一部分當驗證集,並在每個 epoch 結束時評估模型在這份資料上的表現。實務上,model.eval() 把模型切到評估模式(關閉 dropout 之類的隨機性),並用 torch.no_grad() 包住評估程式碼,避免建立計算圖浪費記憶體。
model.eval()
with torch.no_grad():
val_logits = model(X)
val_loss = loss_fn(val_logits, y).item()
val_pred = val_logits.argmax(dim=1)
val_acc = (val_pred == y).float().mean().item()
print(f"val loss = {val_loss:.4f}, val acc = {val_acc:.4f}")
這個範例刻意把訓練和驗證用同一份資料(合成資料太短),目的是把流程示範清楚;真實情境中應該另外保留一份驗證集,並用 DataLoader 批次讀取。整套訓練流程到這裡就完整了:前向傳播算 logits、損失函式衡量差距、反向傳播算梯度、最佳化器更新參數;評估時切到 eval 模式並關閉梯度。
學習率排程器:動態調整學習率
訓練一個模型有時長達數小時甚至數天,全程都用同一個學習率往往不是最佳策略。常見的做法是隨著訓練推進逐步調降學習率:訓練前期用較大的學習率快速收斂,後期用較小的學習率微調,避免在最低點附近震盪。PyTorch 把這些策略收在 torch.optim.lr_scheduler,最直覺的選擇是 StepLR:每 N 個 epoch 把學習率乘上一個小於 1 的係數。
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-2)
scheduler = torch.optim.lr_scheduler.StepLR(
optimizer, step_size=3, gamma=0.5 # 每 3 個 epoch 把學習率乘 0.5
)
for epoch in range(1, 11):
model.train()
# ... 正常的訓練步驟 ...
scheduler.step() # 每個 epoch 結束時更新
print(optimizer.param_groups[0]["lr"])
除了 StepLR,常用的還有 CosineAnnealingLR(餘弦退火,把學習率沿著餘弦曲線慢慢降到 0)、ReduceLROnPlateau(當驗證損失不再下降時自動降低學習率)。對初學者來說,先熟悉 StepLR 已經足以應付大多數任務,等到 Day 38 討論超參數調整時,再視狀況嘗試其他策略。
結語
今天把訓練流程的四大角色湊齊:CrossEntropyLoss 衡量分類預測的誤差,AdamW 依梯度更新參數,optimizer.zero_grad() → loss.backward() → optimizer.step() 是每一步訓練的標準動作,model.eval() 與 torch.no_grad() 則保證驗證階段既正確又有效率。雖然今天的資料是合成的,但整套架構可以直接搬到 MNIST、CIFAR-10 等真實資料集上。
明天,我們會用這套流程在 MNIST 手寫數字資料集上做第一次實戰。把 torchvision.datasets.MNIST、DataLoader、MLP 模型、CrossEntropyLoss 與 AdamW 全部串起來,實際把辨識率訓練到 9 成以上,並學會怎麼評估模型的好壞。
留言
張貼留言