智慧型控制 Intelligent Control

訓練深度網路的現代技巧

原課程的倒傳遞網路只有一個隱藏層。照同樣的做法把網路疊深,會發現它「訓練不動」:誤差降得極慢,或停在很差的地方。這一頁說明問題出在哪裡,以及現在的標準解法。最後可以在網頁上直接訓練網路,比較各種做法。

梯度消失 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}\) 倍。前面的層幾乎學不到東西,這就是梯度消失。反過來若權值太大,梯度會逐層放大而爆炸。

-505-1012sigmoid導數最大 0.25-505-1012tanh導數最大 1-505-1012ReLUz > 0 時導數恆為 1函數導數

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]$$

下圖是 20 層、每層 64 個神經元的網路實算(5 組亂數的幾何平均,verify_dl.py 核對):

15101520-4-3-2-101第幾層各層淨輸入 z 的均方根(log₁₀)15101520-16-12-8-40第幾層權值梯度大小(log₁₀)sigmoid+Xaviertanh+XavierReLU+XavierReLU+He

最佳化器 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 起算造成的偏小。下圖比較三者在一個狹長碗形函數上的路徑:

-5-4-3-2-101-101最小值起點SGD(lr 0.019)60 步後離最小值 1.42Momentum(lr 0.008,β 0.85)60 步後離最小值 0.00838Adam(lr 0.3)60 步後離最小值 0.137f(x, y) = (x² + 100y²)/2y 方向陡、x 方向平

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 個帶雜訊的點說明:

01-1011 次多項式欠擬合01-1013 次多項式剛好01-10112 次多項式過擬合03691200.51多項式次數誤差(RMS)藍:訓練資料(15 點)紅:沒看過的測試資料灰虛線:真正的函數 sin 2πx;藍點:加了雜訊的 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。