ARK · 理解深度學習CHAPTER 06 / 21

CHAPTER 06 / 21 · PART 2 · 訓練的機制:損失、最佳化與泛化

擬合模型:梯度下降怎麼找到谷底

Fitting models

拿到梯度以後怎麼用它調參數——方向、步長、地景的形狀、隨機性、動量與逐座標縮放。

§01學習重點

§02課程內容

一、把「訓練」改寫成一個下坡問題

第 5 章結束的地方,我們手上有一個數字。給定一組參數,把訓練集裡每一筆資料餵進模型、算出它與答案的差距、取平均,就得到一個純量,這個數字叫損失(loss)。它越小,代表模型在這批資料上配得越貼合。

$$ \mathcal{L}[\boldsymbol\phi] \;=\; \frac{1}{I}\sum_{i=1}^{I}\ell\bigl(f[x_i,\boldsymbol\phi],\;y_i\bigr) $$

逐項拆解:\(I\) 是訓練資料的筆數;\(x_i\) 與 \(y_i\) 是第 \(i\) 筆的輸入與正確答案;\(f[x_i,\boldsymbol\phi]\) 是模型在目前這組參數下對它的預測;\(\ell(\cdot,\cdot)\) 是單筆損失,第 5 章導出的那幾種都可以放進這個位置;外面的 \(\frac{1}{I}\sum\) 把所有筆平均起來;\(\boldsymbol\phi\) 是模型全部可調參數排成的一個向量。順帶一提,很多教材把這裡寫成總和、不除以 \(I\)。兩種寫法只差一個正的常數倍,讓損失最小的那組參數完全一樣;本課一律取平均,因為這樣一來,不同批次大小、不同資料量算出來的損失數字才能直接相比,第四節談批次時會直接用到這件事。

於是訓練的目標可以寫成一行:

$$ \hat{\boldsymbol\phi} \;=\; \arg\min_{\boldsymbol\phi}\;\mathcal{L}[\boldsymbol\phi] $$

這裡有一個座標軸的切換,值得停下來講清楚,因為它是本章每一張圖的閱讀方式。先前幾章要展示「模型長什麼樣」時,橫軸放的是輸入 \(x\)、縱軸放的是輸出 \(y\)。從這一章開始,橫軸換成參數,縱軸放損失。一組參數對應到橫軸上的一個點,那一點的高度就是這組參數造成的損失。這樣畫出來的那個高低起伏的東西,叫損失地景(loss landscape)。資料在這張圖裡完全看不見——它已經被吸收進地景的形狀裡了。換一批資料,地景就換一個形狀,但軸還是參數軸。

少數情況下,這個極小值可以直接解出來。譬如模型是輸入的線性函數、損失取平方誤差,那麼把導數設成零可以解出一組線性方程,一步就得到答案。深度網路沒有這種好事:把幾百萬個非線性函數複合在一起之後,那組方程解不開。剩下的辦法只有一種——從某一點出發,反覆改善

這個「反覆改善」的骨架只有兩步,而且會一路用到本章結束:

  1. 在目前這組參數的位置,量一次「往哪個方向會讓損失變大最快」;
  2. 依照這個方向的反向,把參數挪動一小段。

第一步量出來的東西叫梯度。梯度怎麼算出來,是第 7 章的主題——那一章會講反向傳播與參數初始化。本章從頭到尾把梯度當成「已經有辦法算出來的東西」:你要的話,把損失函數對每個參數手動微分也能得到它,只是在幾百萬個參數的規模下必須有更聰明的做法。這一章要問的是另一半問題:梯度拿到手之後,該怎麼用它

二、方向與步長:梯度下降的兩個零件

先把「方向」講乾淨。偏導數(partial derivative)\(\partial\mathcal{L}/\partial\phi_j\) 的意思是:把其他所有參數釘住不動,只讓第 \(j\) 個參數動一點點,損失會以多快的速率跟著變。它是一個帶正負號的數:正的表示「把這個參數調大,損失會上升」,負的表示「調大反而會下降」。

把所有參數的偏導數排成一個向量,就得到梯度:

$$ \mathbf{g} \;=\; \frac{\partial\mathcal{L}}{\partial\boldsymbol\phi} \;=\; \begin{bmatrix} \partial\mathcal{L}/\partial\phi_0 \\ \partial\mathcal{L}/\partial\phi_1 \\ \vdots \\ \partial\mathcal{L}/\partial\phi_{N-1} \end{bmatrix} $$

逐項拆解:\(N\) 是參數總數,所以 \(\mathbf{g}\) 跟 \(\boldsymbol\phi\) 一樣長,每個參數配一格;第 \(j\) 格記的是「只動第 \(j\) 個參數時,損失的變化率」。

現在是本章第一個容易記反的地方:梯度指的是最陡上升的方向,不是下坡方向。為什麼?把參數往某個單位方向 \(\mathbf{u}\) 挪動一小段 \(\delta\),損失的變化量近似等於 \(\delta\) 乘上 \(\mathbf{g}\) 與 \(\mathbf{u}\) 的內積。內積在兩個向量指同一邊時最大,所以「讓損失上升最快」的那個 \(\mathbf{u}\) 就是 \(\mathbf{g}\) 自己的方向。既然要往下走,就取它的反向。這就是更新式裡那個負號的全部來歷:

$$ \boldsymbol\phi_{t+1} \;\leftarrow\; \boldsymbol\phi_{t} \;-\; \alpha\,\mathbf{g}_{t} $$

逐項拆解:下標 \(t\) 是迭代(iteration)的編號,\(\boldsymbol\phi_t\) 是走了 \(t\) 步之後的參數;\(\mathbf{g}_t\) 是在 \(\boldsymbol\phi_t\) 這一點量到的梯度;\(\alpha\) 是一個正的純量,叫學習率(learning rate);箭頭表示「把右邊算出來的值指派給左邊」。

第二個容易記反的地方在 \(\alpha\)。學習率常被說成「決定走多遠」,但這句話只對一半。真正的位移長度是 \(\alpha\) 與梯度長度的乘積

$$ \lVert \boldsymbol\phi_{t+1}-\boldsymbol\phi_{t}\rVert \;=\; \alpha \cdot \lVert\mathbf{g}_t\rVert $$

同一個 \(\alpha\),在陡的地方會走很長一段,在平緩的地方自動縮成一小步。這個性質有好的一面——快到谷底時步子自然收小,不容易衝過頭;也有壞的一面——卡在很平的一片區域時,步子會小到幾乎不動,而那片區域可能離最低點還很遠。這是固定步長最根本的缺陷,後面幾節的每一種改良都在回應它。

停止的判準也就順著出來了:梯度長度小於某個門檻就收手。理由是零梯度代表「四周各個方向都沒有一階的下坡」,再走也沒有明顯的改善。但要記住這只是「局部沒有下坡」,不是「這裡就是最低點」——第三節會把這個縫隙撐開。

比喻: 調律師要把一根弦調到跟音叉一致。他不看儀器讀數,而是讓兩個音同時響,用耳朵聽。兩個頻率只要不完全一樣,合起來的音量就會週期性地一起一伏,這個起伏叫拍頻(beat)。拍頻每秒打幾下,正比於兩個頻率差多少:差得多就打得急,差得少就打得慢。這正是梯度長度在做的事——它不只說往哪邊,還帶著「還差多遠」的分量。至於該往哪邊轉,他把栓稍微鬆一點、聽拍頻是變快還是變慢,這相當於讀偏導數的正負號。而他手上真正轉了多少,是兩件事的乘積:他心裡的謹慎程度(學習率),乘上耳朵聽到的急促程度(梯度長度)。拍頻慢到幾乎聽不出來,他就收手,那就是「梯度小於門檻就停」。這個比喻在一處明確失準:調律有一個外部的、可以查的正確答案——音叉的頻率是規定好的,拍頻真的歸零就是真的準了。損失地景沒有這種東西。損失降到最低只代表「對這批訓練資料而言最貼合」,它不保證模型對沒看過的資料也對,也沒有任何外部參考能告訴你這個谷底是不是你要的那一個。第 8 章整章都在處理這個落差。

把上面兩件事放進一個最小的例子裡跑一遍。以下是一個自造的教學用例:假設你在調一根弦,調音栓每轉 1 格,音高改變 4 個單位;你聽了 24 次拍頻,每次都估出一個「還差多少個單位」的讀數 \(d_i\),這些讀數彼此不完全一致(耳朵有誤差)。把轉角記為 \(\phi\),損失就是轉了 \(\phi\) 格之後、與這 24 個讀數的平均平方偏差 \(\mathcal{L}[\phi] = \frac{1}{24}\sum_i (4\phi - d_i)^2\)。這條式子的導數可以手算:對 \(\phi\) 微分得到 \(\frac{1}{24}\sum_i 8(4\phi-d_i)\)。

PYTHON
import numpy as np

rng = np.random.default_rng(0)

# 假設:調音栓每轉 1 格音高改變 4 個單位;
# 24 次拍頻讀數各自估出「還差多少個單位」
d = 26.0 + rng.normal(0, 1.5, 24)


def loss(phi):                       # 平均平方偏差
    return float(np.mean((4.0 * phi - d) ** 2))


def grad(phi):                       # 手算導數:8 × 平均偏差
    return float(np.mean(8.0 * (4.0 * phi - d)))


print(f"谷底位置 phi* = {d.mean() / 4:.4f},谷底損失 = {loss(d.mean() / 4):.4f}")
print(f"phi=0 處的梯度 = {grad(0.0):.2f};"
      f"phi=5.96 處的梯度 = {grad(5.9572):.2f}")

for a in (0.002, 0.02, 0.07):
    phi = 0.0
    print(f"\n學習率 {a}")
    print(f"  第一步位移 = {abs(a * grad(0.0)):.4f}")
    for step in range(12):
        phi = phi - a * grad(phi)
        if step < 3 or step == 11:
            print(f"  第 {step + 1:2d} 步:phi={phi:9.3f}  損失={loss(phi):12.3f}")

實跑輸出:谷底在 \(\phi^\ast = 6.4572\),谷底損失 1.6207(不是零——24 個讀數彼此矛盾,再怎麼轉都消不掉這個殘差)。在 \(\phi=0\) 處梯度是 -206.63,在 \(\phi=5.9572\)(離谷底只剩半格)處是 -16.00。同樣的學習率 0.02,前者走 4.1326 格,後者只走 0.32 格——這就是「位移=學習率×梯度長度」的實證。

三個學習率走出三種命運。\(\alpha=0.002\) 第一步只挪 0.4133 格,跑滿 12 步還停在 \(\phi=3.537\)、損失 138.025,離谷底遠得很。\(\alpha=0.02\) 第一步跨 4.1326 格,第 3 步損失就降到 3.073,12 步後停在 6.457、損失 1.621,等於到位了。\(\alpha=0.07\) 第一步衝了 14.4641 格,直接越過谷底衝到對面山坡上,那裡的梯度更大、方向相反,於是下一步彈得更遠——12 步後跑到 \(\phi=-78.873\),損失 116502.351。這不是收斂慢,是發散

發散的門檻可以算出來。這個損失的二階導數(曲率)是常數 32,把更新式整理一下會發現,每走一步,與谷底的距離就乘上 \(1-32\alpha\)。要讓距離縮小,必須 \(\lvert 1-32\alpha\rvert<1\),也就是 \(\alpha<1/16=0.0625\)。0.02 在裡面,0.07 在外面,跟實跑結果對得上。作業一會請你把這條推導親手走一遍。

那能不能不要猜學習率,每一步都現場找一個最好的?可以,這個做法叫線搜尋(line search):確定下降方向之後,沿著這條線試好幾個不同的步長,看哪一個讓損失降得最多,就採用那一個。在小問題上這招很有效。放到神經網路上就不划算了——每試一個步長都要把整個訓練集重新前向計算一次損失,試五個步長等於多付五倍的成本,而省下來的通常補不回來。所以實務上的主流做法是反過來的:學習率交給人設定,再用後面幾節的技巧讓它不必設得那麼準。

三、地景的形狀:凸、非凸,還有鞍點

上一節的例子有一個沒說破的好性質:那條損失曲線只有一個谷。從哪裡出發都會滑到同一個地方。有這種性質的函數叫凸函數(convex function),它的判準很直觀——在曲面上任取兩點,把它們用一條直線段連起來,這條弦一定不會穿到曲面下面去:

$$ \mathcal{L}\bigl[\lambda\boldsymbol\phi_a+(1-\lambda)\boldsymbol\phi_b\bigr] \;\le\; \lambda\,\mathcal{L}[\boldsymbol\phi_a]+(1-\lambda)\,\mathcal{L}[\boldsymbol\phi_b] $$

逐項拆解:\(\boldsymbol\phi_a\)、\(\boldsymbol\phi_b\) 是參數空間裡任意兩點;\(\lambda\) 在 0 與 1 之間滑動,\(\lambda\boldsymbol\phi_a+(1-\lambda)\boldsymbol\phi_b\) 就是這兩點之間連線上的點;不等號左邊是曲面在那一點的高度,右邊是弦在同一位置的高度。「弦永遠在曲面上方(或剛好貼著)」對所有兩點都成立,這個函數就是凸的。

凸給你兩項保證,都很值錢。第一,任何一個局部的最低點同時就是全域的最低點——換句話說,「另有更低處」的假谷根本不存在(如果還想要求最低點是唯一的一個點,需要比凸更強的條件,本課用不到)。第二,從哪裡出發都到得了那個最低值,起點不影響你最後拿到的損失。線性模型配平方損失就是凸的,這也是那套方法在統計上用了一個多世紀還很安穩的原因。

深度網路不是。只要中間夾了非線性的激活函數,再把好幾層複合起來,損失對參數的關係就不再是凸的。這一點無法迴避,只能面對。非凸地景裡有三種「梯度等於零」的點,必須分清楚:

局部極小與鞍點的關鍵差別在於有沒有出口。鞍點是有出口的——只要你朝對的方向走,損失就會繼續降。那它為什麼還被當成麻煩?因為鞍點附近通常很平,梯度小到接近零,而上一節說過「位移=學習率×梯度長度」,梯度小就代表步子小。於是參數會在那附近磨蹭非常多步,損失曲線看起來已經躺平,你很容易判斷成「訓練收斂了」而提早收手。這叫假收斂,它不是演算法壞掉,是觀測指標騙了你。作業二會請你親手做出一次假收斂,看看它長什麼樣。

那有沒有辦法直接判斷手上這個零梯度點是三者中的哪一個?有,看二階導數(second derivative):把所有參數兩兩配對的二階偏導數排成一個方陣,從它的性質可以分類。問題是這個方陣的大小是參數數目的平方——一百萬個參數就是一兆個數字,光存下來就不可能,更別說做後續運算。所以本課完全不走這條路;想看完整分類與相關的最佳化方法,## 原書對照 節指了頁碼。

用一個自造的例子把非凸看清楚。還是調律的世界,只是加進一件真實的事:一根弦如果比目標高了整整一個八度,用耳朵聽也會覺得「對」——因為相差八度的兩個音共用一組泛音,八度剛好時那組泛音之間的拍頻同樣會消失。所以「聽起來準」這件事帶有週期性。假設一個八度剛好等於 12 格轉角,我們就可以寫出一個帶週期性的單筆損失:一項罰「音域跑掉」,一項罰「跟這次讀數的相位對不上」,後者每 12 格重複一次。

PYTHON
import numpy as np

rng = np.random.default_rng(0)
P, C, A = 12.0, 0.014, 0.9        # 週期 12 格;音域項與週期項的權重
d = 26.0 + rng.normal(0, 1.5, 24)


def li(phi, dd):                   # 單筆損失:音域項 + 八度週期項
    return C * (phi - dd) ** 2 + A * (1 - np.cos(2 * np.pi * (phi - dd) / P))


def gi(phi, dd):
    return 2 * C * (phi - dd) + A * (2 * np.pi / P) * np.sin(2 * np.pi * (phi - dd) / P)


def loss(phi, idx=None):
    return float(np.mean(li(phi, d if idx is None else d[idx])))


def grad(phi, idx=None):
    return float(np.mean(gi(phi, d if idx is None else d[idx])))


grid = np.linspace(8.0, 44.0, 36001)
g = np.array([grad(p) for p in grid])
cross = np.where(np.diff(np.sign(g)) != 0)[0]
for k in cross:
    kind = "極小" if g[k] < 0 < g[k + 1] else "極大"
    print(f"梯度為零處 phi={grid[k]:7.3f}  損失={loss(grid[k]):.4f}  {kind}")

print()
for start in (13.0, 17.0, 22.0, 30.0, 35.0, 39.0):
    phi = start
    for _ in range(600):
        phi -= 0.6 * grad(phi)
    print(f"起點 {start:5.1f} → 落點 {phi:7.3f}  損失 {loss(phi):.4f}  "
          f"|梯度|={abs(grad(phi)):.2e}")

實跑輸出:在 8 到 44 格之間,梯度為零的點有五個——15.518(極小,損失 1.9443)、18.817(極大)、25.856(極小,損失 0.2003)、32.914(極大)、36.190(極小,損失 1.9623)。三個極小裡只有中間那個是全域最低。六個起點的下降結果乾淨俐落:13.0 和 17.0 落在 15.518,22.0 和 30.0 落在 25.856,35.0 和 39.0 落在 36.190。每一個落點的梯度長度都在 \(10^{-15}\) 的量級,也就是說演算法非常確信自己完成了任務——三分之二的起點卻停在損失是最低值九倍以上的地方(1.94431.96230.2003)。

有人會問:那就多試幾個起點,跑很多次挑最好的不就好了?在這個只有一個參數的例子裡當然可以。但參數一多,這條路就斷了。假設一個模型有 100 萬個參數,就算你只想在每個維度上取兩個值來試,組合數也是 2 的 100 萬次方——這個數字大到無法用任何方式列舉。「窮舉」和「多起點重跑」在深度學習的規模上都不是選項,這正是下一節要引入隨機性的真正理由。

最後留一個伏筆,也順帶把話說得誠實些。非凸只是讓你失去了「一定找得到最好解」這個保證,它不等於訓練不起來。真正動手訓練過就會發現,實際碰上的麻煩遠比上面這套分析預告的少:換一組隨機種子重跑同一個模型,最後停下來的損失往往落在差不多的水準,很少有人真的被某一個糟糕的局部極小卡死。理論分析預告災難、實作卻大致相安無事——這道落差目前還沒有一套令人滿意的解釋,第 20 章會把已知的線索整理一遍。本章只需要你記住:訓練收斂了,只代表你停在一處坡度為零的地方;那裡是不是整片地景最低的一點,得靠這個保證以外的東西來說服自己。

四、隨機性:為什麼每次只看一小把資料反而更好

到目前為止,每算一次梯度我們都用了全部 \(I\) 筆資料,這叫全體梯度(full batch)。它有兩個問題。第一個是成本:資料集有一千萬筆時,走一步就要掃過一千萬筆,一天走不了幾步。第二個問題更根本——上一節已經看到了——它完全是確定性的,起點決定終點,沒有任何機會離開一個不好的谷。

改法出乎意料地簡單:每一步只隨機抽一小把資料來算梯度。這一小把叫小批次(minibatch),實務上直接說批次(batch)。

$$ \mathbf{g}_t \;=\; \frac{1}{\lvert\mathcal{B}_t\rvert}\sum_{i\in\mathcal{B}_t}\frac{\partial\ell_i}{\partial\boldsymbol\phi} $$

逐項拆解:\(\mathcal{B}_t\) 是第 \(t\) 步抽中的那一批資料的索引集合;\(\lvert\mathcal{B}_t\rvert\) 是這一批有幾筆,也就是批次大小;\(\ell_i\) 是第 \(i\) 筆的單筆損失;式子把批內每一筆的梯度加起來,再除以批次大小取平均。更新式一個字都不用改,仍然是 \(\boldsymbol\phi_{t+1}\leftarrow\boldsymbol\phi_t-\alpha\mathbf{g}_t\)。用這種梯度做的下降,叫隨機梯度下降(stochastic gradient descent),簡稱 SGD。

抽樣通常採「不放回」:把訓練集打散成一批一批,用完一批換下一批,全部用完了再重新打散。完整走過訓練集一次叫一個週期(epoch)。三個詞的關係是純算術:一個週期裡的迭代次數等於資料筆數除以批次大小。舉個假設的數字:假設訓練集有 6,400 筆、批次大小取 32,那麼一個週期就是 200 次迭代;跑 15 個週期,總共走了 3,000 步。批次大小如果一路加到等於資料筆數,每個週期就只剩 1 次迭代,SGD 也就退化成本節開頭那個確定性的全體梯度下降——兩者不是兩種演算法,是同一個演算法的兩端

這裡要澄清一個非常常見的誤解:SGD 的隨機性不是有人在梯度上加亂數。梯度本身照原本的方式老實計算,一點雜訊都沒有加。隨機的只有一件事——這一步剛好抽到了哪些樣本

上面那句話還可以再往前推一步,推出來的圖像對後面幾節都很好用。既然梯度完全由批內那幾筆算出來,那你這一步真正在下降的,就不是全體那個損失,而是那幾筆自己撐起來的損失——抽到這三筆,你踩的就是這三筆撐起來的地景;換三筆,地景換一副樣子,谷底的位置也挪一點點。於是參數走的每一步本身沒有任何隨機成分:它精確地順著當下那個損失的坡往下走。隨機的只有「當下那個損失是哪一個」。這個圖像立刻解釋了兩件事——為什麼軌跡是曲折的(腳下的坡向一直在變),以及為什麼它平均起來仍然對(各批次的期望梯度就是全體梯度,偏差在多步之後互相抵銷)。

比喻: 調律師要判斷一根弦準不準,其實有好幾種聽法:跟音叉比、跟低八度的那根弦比、跟五度上的那根比、或者把已經調好的一整組弦挨個比過去。每一種比法都是一次量測,都會給出「該往哪轉、轉多少」的一個意見。全部聽完再取平均,意見最可靠,可是一根弦就要聽上幾十次;只聽其中一種,意見有偏差,但三秒鐘就能動手。更關鍵的是,不同比法常常給出不完全一致的答案——五度那組聽起來已經準了,八度那組還嫌低半個拍。這種不一致不是誰聽錯,它反映的是「不同的量測各自認定了一個略微不同的正確位置」。批次雜訊就是這麼來的:不是有人往梯度裡撒亂數,而是你這一步只問了一部分資料,那部分資料心目中的谷底,跟全體的谷底本來就差一點點。這個比喻在一處失準:調律師挑哪一種聽法是有講究的,老手會刻意挑最容易聽出拍頻的音程,避開難分辨的。小批次剛好相反——它必須是隨機抽的,而「隨機」本身就是它一半的功用。如果每次都固定挑那批「最有代表性」的樣本,你會得到一個更穩定的方向,同時也弄丟了接下來要講的那個好處。

加雜訊為什麼反而有幫助? 這是本章最反直覺的一條,值得單獨想清楚。純粹的下降有一個致命的性質:它對起點完全依賴。上一節那六個起點各自滑進最近的谷,沒有任何一個有機會換一個谷看看——因為在局部極小處,全體梯度精確地等於零,更新式算出來的位移也精確地等於零,參數再也不會動。但單一批次的梯度在同一個點上並不等於零:那一批資料認定的谷底不在這裡,所以它會給出一個非零的推力。這股推力有時候剛好把參數推過一道分隔,落進隔壁的谷。沒有雜訊,這件事的機率是零;有雜訊,機率就不是零了。

這是可以直接量給你看的。拿上一節那個非凸損失,從錯誤的那個谷底 36.190 出發,用同一個學習率跑同樣多的週期,只改批次大小(注意批次越小,同樣的週期數就等於走了越多步,這是這個做法本來就附帶的另一項好處)。

先說清楚一件事,免得你以為下面那張表是天然長成這樣:程式裡的學習率 5.0 是我刻意挑的。 跳不跳得出去對學習率非常敏感,5.0 剛好讓「跳得出去」與「跳不出去」的分界落在下表的批次範圍中間,階梯才看得清楚。我把 3.0 到 7.0 都跑過一遍:取 4.0 會變成只有批次大小 1 跳得出去(0,0,0,0,20),取 6.0 會變成批次 1 和 2 都全中(0,0,0,20,20)——批次越小越容易跳出去這個方向從頭到尾沒有反轉過,變的只是階梯有幾階。挑數字是為了讓現象顯出來,不是為了讓結論成立,這兩件事要分清楚。

PYTHON
import numpy as np

rng = np.random.default_rng(0)
P, C, A, I = 12.0, 0.014, 0.9, 24
d = 26.0 + rng.normal(0, 1.5, I)


def grad(phi, idx=None):
    dd = d if idx is None else d[idx]
    return float(np.mean(2 * C * (phi - dd)
                         + A * (2 * np.pi / P) * np.sin(2 * np.pi * (phi - dd) / P)))


def train(batch, seed, start=36.190, alpha=5.0, epochs=60):
    r = np.random.default_rng(seed)
    phi = start
    for _ in range(epochs):
        order = r.permutation(I)                 # 不放回:走完一輪再重抽
        for k in range(0, I, batch):
            phi -= alpha * grad(phi, order[k:k + batch])
    return phi


for batch in (24, 8, 4, 2, 1):
    ends = np.array([train(batch, 100 + s) for s in range(20)])
    escaped = int(np.sum(np.abs(ends - 25.856) < 4.0))
    print(f"批次大小 {batch:2d}(每週期 {I // batch:2d} 次迭代):"
          f"20 次重跑有 {escaped:2d} 次走到全域谷底;末位中位數 {np.median(ends):7.3f}")

print()
for phi in (20.0, 30.0, 36.19):
    r = np.random.default_rng(1)
    s = np.array([grad(phi, r.permutation(I)[:3]) for _ in range(4000)])
    print(f"phi={phi:6.2f}:全體梯度={grad(phi):+.4f}  "
          f"四千個隨機批次:平均={s.mean():+.4f}  標準差={s.std():.4f}")

實跑輸出的上半段是一張很乾淨的表:批次大小 24(就是全體梯度)在 20 次重跑裡 0 次走到全域谷底,中位數停在 36.190 一動不動;批次大小 8 也是 0 次,中位數 36.097;批次大小 4 是 0 次,35.856;批次大小 2 變成 9 次;批次大小 1 是 20 次全中,中位數 25.672。批次越小,雜訊越強,跨出錯誤谷地的機率越高。要注意這兩者不成比例:批次梯度的雜訊強度大致隨批次大小的平方根遞減,從 4 減到 2 只是個溫和的變化,成功率卻從 0 跳到將近一半。能不能跨過一道分隔,對雜訊強度極度敏感——差一點點就是跳得過去與跳不過去的分別。

「大致」這兩個字要補一句才算誠實,否則會跟上面剛講過的話打架。單純寫成與 \(\sqrt{B}\) 成反比的話,批次大小加到 24(也就是整個訓練集)時算出來的雜訊還有 \(0.031\),可是那時候每個週期只抽得出一批、抽的又是全部資料,梯度根本不會變——雜訊應該是零才對。差在哪裡?差在我們是不放回抽樣:批次越大,一批裡就佔掉母體越大的比例,能剩下的變化空間越小。正確的式子要乘上一個有限母體修正(finite population correction):

$$ \mathrm{std}[\mathbf{g}_t] \;=\; \frac{\sigma}{\sqrt{B}}\sqrt{\frac{I-B}{I-1}} $$

逐項拆解:\(B\) 是批次大小,\(I\) 是訓練集筆數,\(\sigma\) 是單筆梯度在整個訓練集上的標準差。前半截 \(\sigma/\sqrt{B}\) 就是「平方根遞減」那條規律;後面那個根號小於等於 1,而且在 \(B=I\) 時剛好是 \(0\)——這正好接回上面那句「批次加到等於資料筆數就退化成確定性的全體梯度下降」。拿上面那個 grad 函式,在 \(\phi=30\) 處用 r = np.random.default_rng(7) 開一個亂數源,跑 grad(30.0, r.permutation(I)[:B]) 抽二十萬個隨機批次;六個 \(B\) 由小到大依序共用同一個 r,中途不要重建它(重建會換掉抽樣序列,第四位小數就對不上了)。量出來的標準差是 0.15290(B=1)、0.10556(2)、0.07121(4)、0.04499(8)、0.03179(12)、0.00000(24),與這條式子給的 0.15265 / 0.10557 / 0.07118 / 0.04502 / 0.03183 / 0.00000 對到小數點後第三位(第四位的零頭是抽二十萬次還會剩下的取樣誤差,換個種子會在那一位上跳動);只用 \(\sigma/\sqrt{B}\) 則一路高估(0.15265 / 0.10794 / 0.07633 / 0.05397 / 0.04407 / 0.03116),到 \(B=I\) 更是直接矛盾。你也可以不靠抽樣:\(I=24\)、\(B\) 很小時,把 \(\binom{24}{B}\) 種批次全部列出來直接算,得到的就是式子右邊那組數,一位不差。\(B\) 遠小於 \(I\) 時修正項接近 1,這也是為什麼平常可以只講平方根那半截。

下半段驗證了另一件事。在 \(\phi=36.19\)(那個局部極小)處,全體梯度是 -0.0000,四千個隨機批次的平均是 -0.0020——兩者對上了,與理論相符:批次梯度是全體梯度的無偏估計(unbiased estimate),也就是它平均起來不會系統性地偏向任何一邊。但同一批數字的標準差是 0.1256平均是零,個別不是零,這正是推力的來源。在 \(\phi=20.0\) 與 \(\phi=30.0\) 這兩個非零梯度的位置,全體梯度 -0.1909+0.4296 也分別對上了批次平均的 -0.1886+0.4307

把批次的好處整理成一張清單:每一步便宜很多,所以同樣的時間可以走多很多步;每一步仍然改善了某一部分資料上的表現;有機會跳出不好的谷;在鞍點附近的平坦區不容易久留,因為雜訊會把它推開;經驗上訓練出來的模型泛化也常常比較好。代價則是三方取捨——批次越小雜訊越強(跳得出去,但也抖得厲害)、每步的資訊量越少、而且硬體做平行運算時效率越差。這裡沒有「越小越好」的答案,它是一個要試的超參數。

還有一句話要說清楚:嚴格意義上,SGD 沒有收斂保證。雜訊永遠在,參數永遠在抖。實務上大家講的「收斂」是另一件事——梯度變得很小,而且下一節的學習率排程把步子收到夠小,使參數不再有明顯變動。這是一個工程上的判準,不是一個數學上的定理。

五、把步子收小,把方向記住:排程與動量

上一節最後那句話直接引出第一個改良:學習率不必從頭到尾一樣大。訓練早期,你對地景一無所知,需要大步走、把整片區域探一探;訓練後期,你已經在對的谷裡了,需要的是小步微調,把最後那點誤差磨掉。讓學習率隨訓練進程遞減的做法叫學習率排程(learning rate schedule)。最常見的形式簡單到不像話:每過固定的幾個週期,就把學習率乘上一個小於 1 的固定衰減因子。回到調律的畫面,這就是老手快完工時把手勁收小的那個動作——他不會用一開始那種轉幅去修最後半個拍。排程與批次大小其實是耦合的(兩者的比值會共同影響收斂行為),不過本課點到為止。

第二個改良要對付的是另一個現象,先描述現象再給它名字。想像一個又長又窄的谷:沿著某個方向兩壁很陡,沿著另一個方向坡度極緩。純梯度下降在這種地形上會做出一件很蠢的事:梯度絕大部分指向陡的那一側,所以每一步幾乎都在左右橫跨、來回撞牆,而真正要走的那條長軸——緩的那個方向——一步只推進一點點。結果就是步數大量浪費在振盪上。

動量(momentum)的想法是:不要只看這一步的梯度,把過去幾步的梯度也算進來,取一個加權平均

$$ \mathbf{m}_{t+1} \;\leftarrow\; \beta\,\mathbf{m}_{t} + (1-\beta)\,\mathbf{g}_{t}, \qquad \boldsymbol\phi_{t+1} \;\leftarrow\; \boldsymbol\phi_{t} - \alpha\,\mathbf{m}_{t+1} $$

逐項拆解:\(\mathbf{m}_t\) 是動量項,跟參數一樣長的向量,初始化為全零;\(\beta\) 是介於 0 與 1 之間的衰減係數;\(\mathbf{g}_t\) 是這一步算出來的梯度。第一條式子把「舊的動量」與「新的梯度」按 \(\beta\) 與 \(1-\beta\) 混合,兩個權重加起來剛好是 1,所以 \(\mathbf{m}\) 確確實實是一個加權平均,不會因為混合而放大或縮小整體尺度。第二條式子把原本乘在梯度上的學習率,改乘在動量上。\(\beta=0\) 時第一條退化成 \(\mathbf{m}=\mathbf{g}\),整組式子就變回第二節那條單純的更新式。

順帶一提,很多框架把第一條寫成 \(\mathbf{m}\leftarrow\beta\mathbf{m}+\mathbf{g}\),少了 \((1-\beta)\)。那只是差一個常數倍的尺度,可以被學習率吸收掉,機制完全一樣;本課採用權重和為 1 的寫法,因為「動量就是過去梯度的加權平均」這個直覺只有在這個寫法下才字面成立。

把第一條式子往回展開(記得 \(\mathbf{m}_0=\mathbf{0}\)),就看得出它到底在平均什麼:

$$ \mathbf{m}_{t+1} \;=\; (1-\beta)\sum_{k=0}^{t}\beta^{\,k}\,\mathbf{g}_{t-k} $$

逐項拆解:\(k=0\) 是最新的梯度,權重 \(1-\beta\);\(k=1\) 是上一步的,權重 \(\beta(1-\beta)\);越往回權重越小,按 \(\beta\) 的次方衰減。所以 \(\beta\) 調的是回顧視窗有多長,不是步子有多大——這是「動量就是把學習率調大」這個誤解的正解。

這個加權平均為什麼能治振盪?因為在陡的那個方向上,連續幾步的梯度方向是一正一負反覆跳的,加權平均之後大量互相抵銷;而在緩的那個方向上,連續幾步的梯度一直指同一邊,加權平均之後不但沒被抵銷,還累積成一個比單步更大的有效推力。該抵銷的抵銷、該累積的累積,一個式子同時做到兩件事。

還是用調律的畫面:這就是調律師手上的慣性。他不會每聽到一次拍頻就從零開始重新決定要轉多少,手上多少維持著一個「正在往這邊轉」的趨勢;耳朵給的訊號時好時壞,這股趨勢會把它們自動抹平。

動量還有一個變體叫 Nesterov 動量,差異只有一處:它不在目前的位置量梯度,而是先照著動量往前推一步、在那個預測落點量梯度,再拿這個梯度更新動量。直覺上這讓它能提早看到前方的坡向變化,做出修正而不是等撞上了才反應。要強調的是差別只在量測位置,不在步子大小。

下面這段程式做兩件事:先看清楚那個加權平均的權重長什麼樣,再把純下降與幾種 \(\beta\) 放在同一個長寬懸殊的谷上比。這個谷也是自造的教學例:假設你同時要調兩根弦,一根低音、一根高音,高音弦對同樣的轉幅反應大得多,損失寫成 \(\mathcal{L}[\boldsymbol\phi]=\frac{1}{2}(0.4\,\phi_0^2+20\,\phi_1^2)\),兩個方向的陡峭程度剛好差 50 倍。

PYTHON
import numpy as np

np.seterr(all="ignore")

for beta in (0.5, 0.9, 0.99):                     # 動量把權重放在多久以前的梯度上
    w = np.array([(1 - beta) * beta ** k for k in range(5000)])
    c = np.cumsum(w)
    print(f"beta={beta}: 最新那個梯度佔 {w[0]:.3f};"
          f"累積到一半要回看 {int(np.argmax(c >= 0.5)) + 1} 個梯度;"
          f"到九成要回看 {int(np.argmax(c >= 0.9)) + 1} 個;權重總和 {w.sum():.4f}")

K = np.array([0.4, 20.0])                         # 兩個方向的陡峭程度差 50 倍
START = np.array([10.0, 1.0])
loss = lambda p: float(0.5 * np.sum(K * p ** 2))


def run(beta, alpha, steps=250):                  # beta=0 時就是純梯度下降
    p, m, tr = START.copy(), np.zeros(2), [START.copy()]
    for _ in range(steps):
        m = beta * m + (1 - beta) * (K * p)
        p = p - alpha * m
        tr.append(p.copy())
    return np.array(tr)


def settle(tr, thr=0.01):                         # 最後一次高於門檻的步數+1
    hi = [i for i, p in enumerate(tr) if not np.isfinite(loss(p)) or loss(p) >= thr]
    return 10 ** 9 if hi[-1] + 1 >= len(tr) else hi[-1] + 1


for beta in (0.0, 0.5, 0.6, 0.9):
    n, a = min((settle(run(beta, float(x))), float(x)) for x in np.geomspace(0.01, 2.0, 60))
    tr = run(beta, a)
    flips = int(np.sum(np.diff(np.sign(tr[:41, 1])) != 0))
    print(f"beta={beta}: 最佳學習率 {a:.4f} → 第 {n:3d} 步後損失穩定低於 0.01;"
          f"前 40 步陡方向變號 {flips:2d} 次")

上半段的實跑輸出把 \(\beta\) 的角色釘死了:\(\beta=0.5\) 時最新那個梯度就佔 0.500 的權重,累積權重過半只要回看 1 個梯度,到九成要 4 個;\(\beta=0.9\) 時分別是 7 個和 22 個;\(\beta=0.99\) 時要回看 69 個和 230 個。三種設定的權重總和都是 1.0000\(\beta\) 越大,記憶越長,方向越平滑,但對新資訊的反應也越遲鈍。

下半段在那個 50 倍懸殊的谷上,各自掃一遍學習率取最好的成績。純下降(\(\beta=0\))最佳學習率 0.0944,要走到第 99 步損失才穩定低於 0.01,而且前 40 步裡陡方向每一步都變號,40 步變號 40 次——它是一路撞牆撞過去的。\(\beta=0.9\) 用同一個學習率 0.0944,只要 65 步,變號次數從 40 掉到 6:振盪被抹掉了。\(\beta=0.6\) 配上更大的學習率 0.3631 最快,20 步就到位,但變號 33 次——它換到的是「敢跨大步」而不是「走得平穩」。這說明 \(\beta\) 不是越大越好,它跟學習率是一組要一起調的旋鈕。

六、逐座標縮放與 Adam

上一節那個「又長又窄」的谷,現在可以給它正式的名字了:不同方向的陡峭程度相差好幾個數量級,這種地景叫病態條件(ill-conditioned)。它給單一學習率出了一道無解的題:學習率要夠小才不會在陡的方向上發散,可是同一個學習率放到緩的方向上,就慢得沒有盡頭。動量緩解了這個問題,但它對每個座標仍然用同一個 \(\alpha\)。

還是那兩根弦:低音弦轉一格音高只挪一點點,高音弦轉一格挪很多。如果你規定自己「兩根弦都用同樣的轉幅」,那麼對高音弦太粗暴、對低音弦太磨蹭。合理的做法當然是每根弦各用各的轉幅。放到最佳化上,就是:每個座標各配一個自己的步長

怎麼知道某個座標「最近有多陡」?最便宜的線索就是它自己的梯度大小。把梯度平方(去掉正負號、只留大小),開根號,再拿它去除梯度本身:

$$ \phi_{j,\,t+1} \;\leftarrow\; \phi_{j,\,t} - \alpha\,\frac{g_{j,\,t}}{\sqrt{g_{j,\,t}^{2}}} $$

逐項拆解:下標 \(j\) 表示第 \(j\) 個座標,這條式子是逐座標各做各的;分子是這個座標的梯度,分母是同一個數的平方再開根號,也就是它的絕對值。結果只剩下正負號,於是每個座標不管陡緩,一律走 \(\alpha\) 這麼長。這解決了尺度問題,卻換來一個新毛病:到了極小點附近也還是走整整 \(\alpha\),於是參數在谷底左右彈跳,永遠停不下來。

Adam 的做法是把上一節的加權平均同時套在兩個量上:梯度本身、以及梯度的平方。

$$ m_{j,\,t+1} \leftarrow \beta_1 m_{j,\,t} + (1-\beta_1)\,g_{j,\,t}, \qquad v_{j,\,t+1} \leftarrow \beta_2 v_{j,\,t} + (1-\beta_2)\,g_{j,\,t}^{2} $$
$$ \hat{m} = \frac{m_{j,\,t+1}}{1-\beta_1^{\,t+1}}, \qquad \hat{v} = \frac{v_{j,\,t+1}}{1-\beta_2^{\,t+1}}, \qquad \phi_{j,\,t+1} \leftarrow \phi_{j,\,t} - \alpha\,\frac{\hat{m}}{\sqrt{\hat{v}}+\epsilon} $$

逐項拆解,這一節符號密集,一個一個交代:

偏誤修正是怎麼回事?\(m\) 和 \(v\) 都是從零開始累積的。第 1 步時 \(m=(1-\beta_1)g\),若 \(\beta_1=0.9\),它只有真實梯度的十分之一——不是因為梯度真的小,純粹是因為「歷史」還是空的,被零拖低了。除以 \(1-\beta_1^{\,t}\) 剛好把這個系統性的低估補回來。隨著 \(t\) 變大,\(\beta_1^{\,t}\) 趨近於零,修正因子趨近於 1,影響自動消失。所以它不是可有可無的裝飾,它專門修訓練最前面那幾十步。

現在講本章第二個最容易搞錯的地方:Adam 沒有用到二階資訊。 「梯度的平方」和「二階導數」是兩件完全不同的東西,卻極常被當成同一件事。二階導數描述的是曲率——曲線在這一點彎得多急;對二次函數來說它是一個常數,走到哪裡都一樣。而 \(\sqrt{\hat{v}}\) 追蹤的是梯度的大小,同一條曲線上,站的位置不同它就不同。用一個反例就能把它們分開:取一個很陡的谷(曲率 20)站在 \(\phi=1\),再取一個很緩的谷(曲率 2)站在 \(\phi=10\)。這兩處的梯度都剛好是 20。真正用到二階資訊的方法(例如牛頓法,它把梯度除以曲率)在這兩處會走出完全不同的距離;Adam 只看得到梯度,因此它在這兩處的第一步會一模一樣

PYTHON
import numpy as np

np.seterr(all="ignore")
K = np.array([0.4, 20.0])
START = np.array([10.0, 1.0])
loss = lambda p: float(0.5 * np.sum(K * p ** 2))


def settle(tr, thr=0.01):
    hi = [i for i, p in enumerate(tr) if not np.isfinite(loss(p)) or loss(p) >= thr]
    return 10 ** 9 if hi[-1] + 1 >= len(tr) else hi[-1] + 1


def adam(alpha, b1=0.9, b2=0.999, eps=1e-8, steps=250):
    p, m, v, tr = START.copy(), np.zeros(2), np.zeros(2), [START.copy()]
    for t in range(1, steps + 1):
        g = K * p
        m = b1 * m + (1 - b1) * g                 # 梯度的移動平均
        v = b2 * v + (1 - b2) * g ** 2            # 平方梯度的移動平均
        p = p - alpha * (m / (1 - b1 ** t)) / (np.sqrt(v / (1 - b2 ** t)) + eps)
        tr.append(p.copy())
    return np.array(tr)


n, a = min((settle(adam(float(x))), float(x)) for x in np.geomspace(0.01, 2.0, 60))
tr = adam(a)
print(f"Adam: 最佳學習率 {a:.4f} → 第 {n} 步後損失穩定低於 0.01;"
      f"前 40 步陡方向變號 {int(np.sum(np.diff(np.sign(tr[:41, 1])) != 0))} 次")

for name, kappa, phi in (("陡谷 曲率 20", 20.0, 1.0), ("緩谷 曲率  2", 2.0, 10.0)):
    m = v = 0.0
    print(f"{name}:牛頓步(梯度/曲率)= {kappa * phi / kappa:5.1f}")
    for t in range(1, 4):
        g = kappa * phi
        m, v = 0.9 * m + 0.1 * g, 0.999 * v + 0.001 * g ** 2
        den = np.sqrt(v / (1 - 0.999 ** t))
        step = 0.5 * (m / (1 - 0.9 ** t)) / (den + 1e-8)
        phi -= step
        print(f"   第 {t} 步:梯度={g:7.3f}  Adam 分母={den:7.3f}  位移={step:.4f}")

實跑輸出的第一行:Adam 在同一個病態谷上最佳學習率 0.2317,第 63 步之後損失穩定低於 0.01,前 40 步陡方向只變號 4 次——振盪壓得比上一節的動量還乾淨。步數 63 跟 \(\beta=0.9\) 動量的 65 幾乎打平,都輸給調得剛好的 \(\beta=0.6\) 動量(20 步)。這個結果值得誠實地說出來:在這個乾淨的二次型上,Adam 並沒有比調好的動量快。它真正的賣點在別處——你不必為每個座標各調一次學習率,而在幾百萬個參數、各層梯度尺度天差地遠的真實網路上,這件事的價值遠比幾十步的差距大。而且這兩者在式子上其實是親戚:把 Adam 的分母凍結成一個不再變動的常數,更新式就退回成上一節的動量法(那個常數會被學習率吸收掉)。它們各有勝場,本課不下絕對結論。

第二段輸出把「不是二階」釘死了。陡谷(曲率 20,起點 \(\phi=1\))的牛頓步是 1.0,緩谷(曲率 2,起點 \(\phi=10\))的牛頓步是 10.0,差 10 倍。而 Adam 在兩處的第 1 步梯度都是 20.000、分母都是 20.000、位移都是 0.5000——一模一樣。往後幾步兩邊才因為梯度變化速度不同而分開(陡谷第 3 步位移掉到 0.3698,緩谷還在 0.4977),但那反映的是梯度怎麼變,不是曲率是多少。Adam 全程只呼叫一階梯度,一次都沒有碰過二階導數。

最後把整章收在一個視角上。這一章從頭到尾沒有動過模型本身——參數 \(\boldsymbol\phi\) 是模型的,會被訓練改變;而學習率、批次大小、排程的衰減因子、動量的 \(\beta\)(Adam 的 \(\beta_1\) 與 \(\beta_2\))、以及「用哪一種演算法」這個選擇本身,全部都是超參數(hyperparameter):它們不會被訓練改變,是你在訓練開始前就得決定的東西。本章示範的兩次「掃一遍學習率取最好」,就是超參數搜尋最原始的樣子;怎麼有系統地做這件事、怎麼避免在搜尋過程中把測試集用掉,是第 8 章的主題。

還有一句誠實話要留在這裡:選最佳化器與調它的超參數,到今天仍然是經驗活。本章給了你判斷的依據——地景大概是什麼形狀、雜訊在幫忙還是在搗亂、振盪是不是主要瓶頸——但沒有一套配方可以照抄。看到任何宣稱「某某演算法在所有情況下都最好」的說法,先想想它是在什麼地景上測的。

§03原書對照

原書第 6 章只處理「拿到梯度之後怎麼走」這一半,梯度本身怎麼算留到下一章。本課沿用了同樣的切法,但把示範例整個換掉,也把幾段推導壓成結論,以下按頁碼指出值得回頭翻的地方。

第一處是可以親手驗算的推導。原書在 p.78 到 p.80 之間,把一維線性迴歸從模型式子、平方損失、一路寫到兩個參數各自的偏導數表達式,接著在 p.79 用四張並排的圖把同一段下降過程畫成資料散布、損失曲面、亮度熱圖,以及逐次改善的擬合線,訓練集只有十二筆。想看公式與圖怎麼對上的讀者,那三頁資訊密度很高;習題 6.1 也要求自行補完這段微分。

第二處是原書的招牌示範。pp.80–82 用一個只有兩個參數的非線性模型(正弦乘上高斯型衰減,作者稱為 Gabor 模型)造出一片崎嶇的損失地景,訓練資料是二十八筆帶雜訊的樣本。p.82 的圖把好幾個局部極小、全域極小與一個鞍點同時標在一張圖上,並列出每個極小值各自對應的模型長什麼樣,其中全域極小的損失值是 0.64。本課依版權政策自造了等效示範,但這張圖把不同落點各自對應到什麼模型攤開來並列的方式仍值得一看。

第三處是隨機性的第二種讀法。pp.84–85 提出:每一批資料其實定義了一個略微不同的損失函數,所以隨機梯度下降可以理解成在一片不斷變形的地景上做確定性下降。p.84 用批次大小三的例子把四種情況並排,包括「對批次損失是下坡、對全體損失卻是上坡」的那一格。pp.85–86 接著列出隨機性的數項好處,比本課展開得完整。

第四處是兩組幾何圖。p.87 用五個編號點把傳統動量與 Nesterov 動量的先後順序畫在同一張圖上,p.88 給出對應的更新式;p.89 則用四格圖比較固定步長、只保留符號的正規化步長,以及 Adam 三者在一個長寬懸殊的谷地上的軌跡差異,p.90 補上偏誤修正項為什麼必要。

還有兩段推理值得留意。原書在 p.83 先論證:既然窮舉參數空間與多次隨機重啟在動輒百萬參數的模型上都不可行,隨機性就成了唯一便宜的出路——那一頁是整章動機的樞紐,值得專程翻一次。而 p.91 的腳註把「局部極小與鞍點究竟造成多大麻煩」整個推遲到第 20 章,並直言深度網路在實務上出乎意料地好訓練,那則腳註本身就值得一讀。

最後是註記與習題。p.92 用 Hessian 矩陣的特徵值把梯度為零的點分成極小、極大與鞍點三類,也說明了牛頓法為什麼在百萬參數的規模下不可行;pp.92–93 介紹用夾擠法做線搜尋。pp.93–94 是一份自適應演算法的族譜,從 AdaGrad、RMSProp、AdaDelta 一路到 Adam 與其後的多個修正版本,並整理了 SGD 與 Adam 孰優的爭論;p.94 另外提到用 SAT 求解器窮舉離散化參數空間的另類取徑。習題 6.1 至 6.11 分布在 pp.94–95,其中 6.9 是批次、迭代與週期的換算題,6.11 問一百萬個參數的 Hessian 會有多大。原書另配了五份可互動的線上筆記本,編號 6.1 至 6.5,依序涵蓋線搜尋、梯度下降、隨機梯度下降、動量與 Adam,位置標在對應正文的邊欄。原書第 6 章對應印刷頁 pp.77–95。

§04作業和解答

作業一:算出學習率的穩定上限

沿用第二節那個單參數損失 \(\mathcal{L}[\phi]=\frac{1}{I}\sum_i(4\phi-d_i)^2\),記 \(\bar{d}\) 為所有讀數的平均、\(\phi^\ast=\bar{d}/4\) 為谷底位置。(a)證明每做一次梯度下降更新,「與谷底的距離」就會乘上一個固定因子,並寫出這個因子。(b)由此求出學習率的穩定上限。(c)預測 \(\alpha=0.05\) 與 \(\alpha=0.0625\) 分別會發生什麼事,再用程式驗證(起點取 \(\phi=0\))。

解答 SOLUTION

(a)先把梯度化簡。\(\mathcal{L}[\phi]=\frac{1}{I}\sum_i(4\phi-d_i)^2\),對 \(\phi\) 微分得 \(\frac{1}{I}\sum_i 8(4\phi-d_i)=32\phi-8\bar{d}=32(\phi-\phi^\ast)\)。代進更新式:\(\phi_{t+1}=\phi_t-\alpha\cdot 32(\phi_t-\phi^\ast)\)。兩邊同時減 \(\phi^\ast\),得 \(\phi_{t+1}-\phi^\ast=(1-32\alpha)(\phi_t-\phi^\ast)\)。所以固定因子就是 \(1-32\alpha\)。這裡的 32 不是巧合,它正是這個損失的二階導數。

(b)距離要縮小,必須 \(\lvert 1-32\alpha\rvert<1\),即 \(0<\alpha<2/32=0.0625\)。因子為正時(\(\alpha<0.03125\))單調靠近,為負時(\(0.03125<\alpha<0.0625\))左右跨越但幅度遞減。

(c)\(\alpha=0.05\) 的因子是 \(-0.6\),預測是每一步跨過谷底、幅度乘 0.6。實跑(\(\phi_0=0\),\(\phi^\ast=6.4572\))前六步為 10.33154.13267.85205.62036.95936.1559,確實在谷底兩側交替逼近,10 步後距谷底只剩 0.0390。\(\alpha=0.0625\) 的因子恰好是 \(-1\),預測是永遠震盪、幅度一點都不縮。實跑輸出正是 12.9144-0.012.9144-0.0⋯⋯無限重複,10 步後與谷底的距離仍是 6.4572,跟第 0 步一樣。這是穩定與發散之間那條線本身。(本題數值已用 numpy 重算核對。)

作業二:親手做出一次假收斂

考慮自造的雙參數損失 \(\mathcal{L}[\boldsymbol\phi]=\phi_0^2-2\phi_1^2\)。(a)求出梯度為零的點,並判斷它是極小、極大還是鞍點,寫出判斷依據。(b)從 \(\boldsymbol\phi_0=(0.5,\,10^{-3})\) 出發、學習率 0.05 跑 60 步,記錄每一步的梯度長度;找出梯度長度的最小值出現在第幾步。(c)解釋為什麼第 15 到 25 步之間會讓人誤判成收斂。(d)如果第二個參數的初始值恰好是 0,會發生什麼事?

解答 SOLUTION

(a)梯度是 \((2\phi_0,\,-4\phi_1)\),只有在原點才同時為零。沿著 \(\phi_1=0\) 這條線看,損失是 \(\phi_0^2\),原點是極小;沿著 \(\phi_0=0\) 這條線看,損失是 \(-2\phi_1^2\),原點是極大。同一點在不同方向上一個是最低、一個是最高——這就是鞍點的定義。

(b)更新式逐座標分開:\(\phi_0\) 每步乘 \(1-2\alpha=0.9\)(收縮),\(\phi_1\) 每步乘 \(1+4\alpha=1.2\)(放大)。實跑結果:起點的梯度長度是 1.000008,第 10 步降到 0.349557,第 18 步達到最小值 0.184036,之後開始回升——第 25 步 0.388279、第 30 步 0.950451、第 40 步 5.879105、第 60 步 225.390057

(c)第 15 到 25 步這段時間裡,梯度長度只剩起點的五分之一左右且還在下降,損失也很接近零(第 20 步是 +0.000756),若你只盯著這兩個數字,會判斷「已經到平坦處,可以收工了」。實際上此時 \(\phi_1\) 正在以每步 1.2 倍的速度悄悄長大,只是它還很小,貢獻的梯度暫時被 \(\phi_0\) 那邊的收縮蓋過去。所以假收斂的成因不是演算法失效,而是你監看的那兩個指標在那段區間裡確實都在改善。實務上的對策是多跑一段再判斷,並且監看的不只是損失一個數字。

(d)\(\phi_1=0\) 時,第二個座標的梯度是 \(-4\times 0=0\),更新量為零,於是 \(\phi_1\) 永遠留在 0。演算法會沿著 \(\phi_1=0\) 這條線乾淨地收斂到原點,然後回報梯度為零、任務完成——停在鞍點上。真實訓練不會這麼倒楣,因為隨機初始化與批次雜訊都會給它一個非零的擾動;這也正是第四節說「雜訊降低卡在鞍點的機率」的具體機制。

作業三:Adam 的分母不是曲率

取兩個一維二次損失:\(\mathcal{L}_A[\phi]=\frac{1}{2}\cdot 20\,\phi^2\)(起點 \(\phi=1\)),\(\mathcal{L}_B[\phi]=\frac{1}{2}\cdot 2\,\phi^2\)(起點 \(\phi=10\))。(a)兩者在起點的梯度各是多少?曲率各是多少?(b)牛頓法(把梯度除以曲率)在這兩處各走多遠?(c)Adam 在第一步各走多遠(\(\alpha=0.5\),\(\beta_1=0.9\),\(\beta_2=0.999\))?請把 \(\hat{m}\) 與 \(\sqrt{\hat{v}}\) 算出來說明為什麼。(d)某個座標的 \(\sqrt{\hat{v}}\) 小到 \(10^{-6}\),比較 \(\epsilon\) 放在根號外與放在根號內的分母差多少,並說明規格為什麼要求放在外面。

解答 SOLUTION

(a)\(\mathcal{L}_A\) 的梯度是 \(20\phi\),在 \(\phi=1\) 處等於 20,曲率(二階導數)是 20。\(\mathcal{L}_B\) 的梯度是 \(2\phi\),在 \(\phi=10\) 處也等於 20,曲率是 2。梯度相同,曲率差 10 倍——這一組就是專門用來拆穿誤解的反例。

(b)牛頓步是梯度除以曲率:\(20/20=1.0\) 與 \(20/2=10.0\),差 10 倍。這才叫「用了二階資訊」——同樣的梯度,在緩的谷裡就該走遠一點。

(c)第 1 步時 \(m=(1-\beta_1)g=0.1\times 20=2\),偏誤修正 \(\hat{m}=m/(1-\beta_1^1)=2/0.1=20\),恰好等於 \(g\)。同理 \(v=(1-\beta_2)g^2=0.001\times 400=0.4\),\(\hat{v}=v/(1-\beta_2^1)=0.4/0.001=400\),所以 \(\sqrt{\hat{v}}=20=\lvert g\rvert\)。位移是 \(\alpha\hat{m}/(\sqrt{\hat{v}}+\epsilon)\approx 0.5\times 20/20=0.5000\)。兩個谷完全一樣,因為 Adam 從頭到尾只看得到 \(g\),而兩處的 \(g\) 相同。實跑輸出兩邊都是「梯度 20.000、分母 20.000、位移 0.5000」。(順帶一提,這也證明了第 1 步的位移剛好等於 \(\alpha\),與梯度大小無關。)

(d)\(\epsilon\) 放在根號外時,分母是 \(10^{-6}+10^{-8}=1.010\times 10^{-6}\),跟原本的 \(10^{-6}\) 幾乎沒差別,\(\epsilon\) 安分地只做「防止除以零」這一件事。放在根號內時,分母變成 \(\sqrt{10^{-12}+10^{-8}}=1.000\times 10^{-4}\)——比原本大了一百倍,\(\epsilon\) 從一個保險裝置變成了主導這一步位移的因素。根本原因是量綱:\(\hat{v}\) 是梯度的平方,\(\sqrt{\hat{v}}\) 才回到梯度的單位。\(\epsilon\) 要跟它相加才有意義,也才挑得準。(本題三小題的數值都用 numpy 重算核對過。)

§05參考資料