訓練深度網路的現代技巧
原課程的倒傳遞網路只有一個隱藏層。照同樣的做法把網路疊深,會發現它「訓練不動」:誤差降得極慢,或停在很差的地方。這一頁說明問題出在哪裡,以及現在的標準解法。最後可以在網頁上直接訓練網路,比較各種做法。
梯度消失 Vanishing Gradients
第 \(l\) 層的淨輸入與輸出寫成 \(\mathbf z^l = \mathbf W^l \mathbf a^{l-1} + \mathbf b^l\)、\(\mathbf a^l = f(\mathbf z^l)\)。倒傳遞把誤差對淨輸入的梯度(敏感度)\(\boldsymbol\delta^l = \partial L / \partial \mathbf z^l\) 由後往前傳:
$$\boldsymbol\delta^{l} = \left(\mathbf W^{l+1}\right)^{\mathsf T}\boldsymbol\delta^{l+1} \odot f'(\mathbf z^{l}),\qquad \frac{\partial L}{\partial \mathbf W^l} = \boldsymbol\delta^l \left(\mathbf a^{l-1}\right)^{\mathsf T}$$每往前一層,梯度就多乘一次權值與一次 \(f'\)。sigmoid(即 logsig)的導數 \(f'(z) = f(z)(1 - f(z))\) 最大只有 0.25;連乘 10 層,即使權值大小約為 1,前面幾層的梯度也只剩後面的 \(0.25^{10} \approx 10^{-6}\) 倍。前面的層幾乎學不到東西,這就是梯度消失。反過來若權值太大,梯度會逐層放大而爆炸。
ReLU(rectified linear unit)\(f(z) = \max(0, z)\) 在 \(z > 0\) 時導數恆為 1,梯度可以原封不動往前傳,而且計算很便宜。2010 年代起,它取代 S 形函數成為隱藏層的預設選擇。
初始化 Initialization
梯度不消失、不爆炸,前提是每一層的訊號大小差不多。設輸入 \(a_j\) 與權值 \(w_{ij}\) 互相獨立、權值平均為 0,則一個神經元的淨輸入
$$z_i = \sum_{j=1}^{n_{\text{in}}} w_{ij}a_j \quad\Rightarrow\quad \operatorname{Var}(z_i) = n_{\text{in}}\,\operatorname{Var}(w)\,\mathrm E[a^2]$$- Xavier(Glorot,2010):tanh 在原點附近近似線性,\(\mathrm E[a^2] \approx \operatorname{Var}(z)\)。取 \(\operatorname{Var}(w) = 1/n_{\text{in}}\)(或兼顧反向的 \(2/(n_{\text{in}} + n_{\text{out}})\)),各層變異數就維持不變。
- He(2015):ReLU 把一半的輸入砍成 0,\(\mathrm E[a^2] = \operatorname{Var}(z)/2\),所以要取 \(\operatorname{Var}(w) = 2/n_{\text{in}}\),把減半補回來。用均勻分布時,就是在 \(\pm\sqrt{6/n_{\text{in}}}\) 之間取亂數。
下圖是 20 層、每層 64 個神經元的網路實算(5 組亂數的幾何平均,verify_dl.py 核對):
- sigmoid:第 1 層的權值梯度只有第 20 層的約 \(10^{-13}\) 倍,前面的層完全學不動。
- ReLU 配 Xavier:淨輸入每層大約縮小 \(1/\sqrt 2\),到第 20 層只剩約 0.0009,訊號幾乎消失。
- ReLU 配 He:淨輸入的均方根從 1.4 到第 20 層仍有 0.89,訊號與梯度都維持在合理範圍。
最佳化器 Optimizers
小批次隨機梯度下降(SGD):每次只用一小批(例如 32 筆)資料估計梯度,就更新一次。比起原課程每回合用全部資料只更新一次的 traingd,更新次數多得多;梯度的隨機性還有助於跳出不好的區域。
$$\mathbf w \leftarrow \mathbf w - \eta\,\mathbf g,\qquad \mathbf g = \frac{1}{|B|}\sum_{q\in B}\nabla_{\mathbf w} L_q$$動量(momentum):累積過去的梯度當作「速度」,在一致的方向上越走越快,來回振盪的方向則互相抵消:
$$\mathbf m \leftarrow \beta\mathbf m + \mathbf g,\qquad \mathbf w \leftarrow \mathbf w - \eta\,\mathbf m$$Adam(2014):同時追蹤梯度的平均 \(\mathbf m\) 與平方的平均 \(\mathbf v\),每個參數用自己的步長。梯度一直很大的方向,步長自動縮小:
$$\mathbf m \leftarrow \beta_1\mathbf m + (1-\beta_1)\mathbf g,\quad \mathbf v \leftarrow \beta_2\mathbf v + (1-\beta_2)\mathbf g^2,\quad \mathbf w \leftarrow \mathbf w - \eta\,\frac{\mathbf m/(1-\beta_1^t)}{\sqrt{\mathbf v/(1-\beta_2^t)} + \epsilon}$$其中 \(\beta_1 = 0.9\)、\(\beta_2 = 0.999\)、\(\epsilon = 10^{-8}\) 是常用預設值。分母的 \(1-\beta^t\) 修正剛開始時 \(\mathbf m\)、\(\mathbf v\) 由 0 起算造成的偏小。下圖比較三者在一個狹長碗形函數上的路徑:
y 方向比 x 方向陡 100 倍(條件數 κ = 100)。SGD 的學習率必須小於 \(2/\kappa = 0.02\),否則在陡的方向發散;可是這麼小的步長,在平的 x 方向每步只前進 1.9%,60 步後離最小值還有 1.42。動量法(\(\eta = 0.008\)、\(\beta = 0.85\))把一致的 x 方向累積成速度,60 步後只剩 0.008;Adam 為 0.14。實際的網路有上百萬個參數,各方向的陡峭程度差異更大,這正是動量與 Adam 成為標準做法的原因。
過擬合與正規化 Overfitting and Regularization
深度網路的參數很多,很容易把訓練資料「背」下來,對沒看過的資料卻表現很差,稱為過擬合。下圖用多項式擬合 15 個帶雜訊的點說明:
3 次多項式在沒看過的資料上誤差最小(0.24);12 次多項式幾乎穿過每個訓練點,測試誤差卻高達上千。所以一定要把資料分成訓練集、驗證集(調超參數、決定何時停止)與測試集(最後評估),並使用正規化方法:
| 方法 | 做法 |
|---|---|
| 提早停止 | 驗證誤差開始上升就停止訓練(原課程的 traingd 也有這個選項) |
| 權值衰減(L2) | 損失加上 \(\lambda\|\mathbf w\|^2\),讓權值保持小,函數較平滑 |
| Dropout(2014) | 訓練時每次隨機讓一部分神經元輸出 0(比例 p),逼網路不依賴少數神經元;測試時全部使用 |
| 批次正規化(2015) | 把每層的淨輸入用小批次的平均與標準差正規化,再乘上可學習的 γ、加上 β:\(\hat z = \gamma\,\frac{z - \mu_B}{\sqrt{\sigma_B^2 + \epsilon}} + \beta\)。訓練更穩,可用較大的學習率 |
| 資料擴增 | 對影像做平移、翻轉、裁切等,等於免費增加訓練資料 |
在網頁上訓練網路 Try It
下面的動畫在瀏覽器裡直接訓練一個多層網路,把平面上兩類點分開:輸出用 sigmoid 表示屬於橘類的機率,損失為交叉熵,每次用 20 筆資料更新。可以切換資料、激活函數、最佳化器、層數與學習率:
同樣的設定訓練 150 回合的結果(網路 2–8–8–1,螺旋資料為 2–8–8–8–1;verify_dl.py 以與動畫相同的程式碼計算):
| 資料 | ReLU+Adam(0.01) | tanh+Adam(0.01) | ReLU+SGD(0.1) | sigmoid+SGD(0.1) |
|---|---|---|---|---|
| 兩月形 | 100% | 100% | 100% | 93.5% |
| 螺旋 | 100% | 94.5% | 78.5% | 50%(沒學到) |
| 同心圓 | 100% | 100% | 100% | 69.5% |
最難的螺旋資料上,sigmoid+SGD 只有 50%,等於亂猜;換成 ReLU+Adam 就能完全分開。
PyTorch 版本 In PyTorch
同樣的實驗用 PyTorch 只要幾十行。nn.Sequential 疊出網路,loss.backward() 自動算梯度,opt.step() 更新權值:
# 螺旋資料分類:比較激活函數與最佳化器(3 個隱藏層、每層 8 個神經元、小批次 20、150 回合)
import math
import torch
from torch import nn
def spiral(n=200, seed=7):
g = torch.Generator().manual_seed(seed)
c = torch.arange(n) % 2 # 兩類交錯
s = 0.25 + 3.2 * torch.rand(n, generator=g)
ang = s * math.pi + c * math.pi
r = s / 3.45 * 1.8
x = torch.stack([r * torch.cos(ang), r * torch.sin(ang)], 1) + 0.05 * torch.randn(n, 2, generator=g)
return x, c.float().unsqueeze(1)
x, y = spiral()
ACT = {"ReLU": nn.ReLU, "tanh": nn.Tanh, "sigmoid": nn.Sigmoid}
for act, opt_name, lr in [("ReLU", "Adam", 0.01), ("tanh", "Adam", 0.01), ("ReLU", "SGD", 0.1), ("sigmoid", "SGD", 0.1)]:
torch.manual_seed(1)
layers, width = [], 2
for _ in range(3):
layers += [nn.Linear(width, 8), ACT[act]()]
width = 8
net = nn.Sequential(*layers, nn.Linear(8, 1)) # 輸出不加 sigmoid:交給 BCEWithLogitsLoss,數值較穩
opt = (torch.optim.Adam if opt_name == "Adam" else torch.optim.SGD)(net.parameters(), lr=lr)
loss_fn = nn.BCEWithLogitsLoss() # 二元交叉熵
for epoch in range(150):
for idx in torch.randperm(len(x)).split(20): # 每回合洗牌、每 20 筆更新一次
opt.zero_grad()
loss_fn(net(x[idx]), y[idx]).backward()
opt.step()
with torch.no_grad():
acc = ((net(x) > 0).float() == y).float().mean().item()
loss = loss_fn(net(x), y).item()
print(f"{act:7s} + {opt_name:4s}(lr {lr}):交叉熵 {loss:.3f},準確率 {acc:.1%}")⬇ 下載 spiral_torch.py(Python 3+PyTorch)
實際執行的輸出:
ReLU + Adam(lr 0.01):交叉熵 0.136,準確率 97.0%
tanh + Adam(lr 0.01):交叉熵 0.311,準確率 86.0%
ReLU + SGD (lr 0.1):交叉熵 0.653,準確率 59.0%
sigmoid + SGD (lr 0.1):交叉熵 0.693,準確率 50.0%
PyTorch 的預設初始化與資料亂數和上面的動畫不同,數字不會完全一樣,但趨勢相同:ReLU+Adam 最好,sigmoid+SGD 學不起來。程式各部分的意義見下一頁 自動微分與 PyTorch。