搜尋課程與筆記

搜尋筆記

輸入主題、關鍵字或單元編號,例如 Flow Matching、U4。

    按 Esc 關閉 · ⌘ / Ctrl K 開啟搜尋

    L4.1 16 分鐘閱讀 2026年10月

    L4.1 生產線出了瑕疵,怎麼知道是哪一站的責任?

    本篇重用2 篇

    一條長長的生產線

    前面每個更新都需要梯度,但參數很多時要怎麼算?想像一條有 50 站的生產線,最後成品被扣了 3 分。我們問的不是誰應受責備,而是敏感度:第 17 站的溫度改一點,扣分會變多少?

    有一個笨辦法:把第 17 站調高一點,整條線重跑一次,看扣分差多少。 這行得通,但 50 站就要重跑 50 次;如果每一站有 20 個參數,就是 1000 次。

    而這正是我們的處境。霧中下山,一步該走多大:梯度下降的一頁 以來每一個方法都需要 ∇θL\nabla_\theta L,而 θ\theta 有幾萬、幾百萬、幾十億個分量。逐個試一遍是不可能的。

    一條長長的生產線出了瑕疵,
    怎麼一次算出每一站的責任,
    而不是每一站各重跑一次?

    課堂提問Q1

    把「每一站的責任」翻成數學。鏈式法則給了一串連乘——那為什麼還會有「便宜」與「貴」的差別?兩種乘法順序各是什麼,各要多少運算?

    先想一想,再展開看整理後的答案

    先寫下來。 一個 DD 層的網路是一串函數的合成:

    L=ℓ(hD),hℓ=fℓ(hℓ−1;θℓ),h0=x.L=\ell\big(h_D\big),\quad h_\ell=f_\ell(h_{\ell-1};\theta_\ell),\quad h_0=x .

    鏈式法則(漂流的溫度計:全導數、微分穿過積分與 JVP)給

    ∂L∂θℓ=∂L∂hD⏟1×nD∂hD∂hD−1⋯∂hℓ+1∂hℓ⏟一串 Jacobian∂hℓ∂θℓ⏟nℓ×∣θℓ∣.\frac{\partial L}{\partial \theta_\ell} =\underbrace{\frac{\partial L}{\partial h_D}}_{1\times n_D} \underbrace{\frac{\partial h_D}{\partial h_{D-1}}\cdots\frac{\partial h_{\ell+1}}{\partial h_\ell}}_{\text{一串 Jacobian}} \underbrace{\frac{\partial h_\ell}{\partial \theta_\ell}}_{n_\ell\times|\theta_\ell|}.

    式子本身沒有歧義,但「先乘哪一對」有。 矩陣連乘滿足結合律,所以答案一樣;成本不一樣。

    從左往右(反向模式)。 先算最左邊那個 1×nD1\times n_D 的列向量,再一路往右乘:

    (( ( 列向量×JD)×JD−1 )⋯⏟每一步都是「向量 × 矩陣」)\big(\underbrace{(\,(\,\text{列向量}\times J_D)\times J_{D-1}\,)\cdots}_{\text{每一步都是「向量 × 矩陣」}}\big)

    每一步的成本是 O(n2)O(n^2)(一個長度 nn 的向量乘一個 n×nn\times n 的矩陣),走完 DD 層是 O(Dn2)O(Dn^2)——和一次前向傳播同一個量級。而且走這一趟的過程中,每一層的 ∂L/∂hℓ\partial L/\partial h_\ell 都被算出來了,所以所有層的參數梯度一次拿齊。

    若先把中間 Jacobian 全部乘成矩陣,等寬情況可能要 O(Dn3)O(Dn^3);但這不是前向模式必須做的事。前向模式可以只傳一個方向 h˙ℓ=Jℓh˙ℓ−1\dot h_\ell=J_\ell\dot h_{\ell-1},一次也只需和前向相近的成本。差別是:要對全部 PP 個參數求導,它通常需要 PP 個方向;純量損失的反向模式只需一個輸出方向。

    關鍵是損失是純量。 最左邊那一項是 1×nD1\times n_D 的列向量,右邊則是 nℓ×∣θℓ∣n_\ell\times|\theta_\ell| 的大矩陣。從窄的那一端開始乘,中間產物一直是向量;從寬的那一端開始,中間產物是矩陣。

    用生產線的話說:從成品往回問「這一站多影響一點會怎樣」,一次就把整條線的責任分完;從每一站往前推「我動一下會怎樣」,每一站都要推一遍。

    因此反向模式省掉的是「為每個參數各重跑一次」的倍數。PP 並沒有從絕對成本消失:模型越大,前向與反向本身通常也越貴。

    兩個方向,兩種問句

    自動微分的兩種模式,可以用它們各自回答的問句記住:

    • 前向模式(JVP,Jacobian-vector product):「輸入沿著這個方向動一點,輸出會怎麼動?」一次前向掃描,成本 ≈\approx 一次前向,回答一個輸入方向。輸入有 PP 個維度就要掃 PP 次。
    • 反向模式(VJP,vector-Jacobian product):「輸出的這個組合,對每一個輸入各有多敏感?」一次反向掃描,成本 ≈\approx 常數倍的前向,回答一個輸出方向,但一次給出全部 PP 個輸入的答案。

    於是規則很簡單:

    f:RP→Rm⟹前向的成本∝P,反向的成本∝m.f:\mathbb R^{P}\to\mathbb R^{m} \quad\Longrightarrow\quad \text{前向的成本}\propto P,\qquad \text{反向的成本}\propto m .

    訓練時 m=1m=1(損失是一個純量)而 PP 是幾百萬,所以反向壓倒性地便宜。但這個結論會隨著問題翻轉:如果你要的是一整個 Jacobian(mm 很大),或者輸入只有兩三個維度,前向模式反而便宜。

    「反向傳播」就是「把反向模式自動微分用在 m=1m=1 的損失上」,沒有多一分神秘。

    同一張計算圖,兩個掃描方向

    圖說: 純量損失的反向模式讓中間量保持為向量,一次掃描即可取得所有參數梯度。

    一次反向就把全部拿到

    把上面的成本算式驗證一次。同一個網路、同一個位置、同一個梯度,兩種算法:

    # 反向傳播:一次前向 + 一次反向
    G, _ = mlp.grads(P, X, y)
    
    # 有限差分:每個參數各擾動兩次,重跑整個前向
    for i in range(n_params):
        vp = v0.copy(); vp[i] += eps
        vm = v0.copy(); vm[i] -= eps
        g_fd[i] = (loss(unflat(P, vp)) - loss(unflat(P, vm))) / (2*eps)
      參數     33  反向傳播   0.38 ms   有限差分     5.2 ms   倍數  13.8×   相對差 2.22e-10
      參數    321  反向傳播   0.26 ms   有限差分    32.5 ms   倍數 123.0×   相對差 4.29e-10
      參數   2497  反向傳播   0.35 ms   有限差分   294.3 ms   倍數 840.5×   相對差 8.07e-10

    三件事同時被證實了。

    這些小模型的反向時間相近,可能受固定開銷與量測噪聲影響;不能推論大模型的時間也不增加。要驗證常數倍關係,應比較同一模型的前向與反向成本,再觀察它們隨規模如何變化。

    有限差分線性成長(5.2→32.5→2945.2\to32.5\to294 ms,參數約 10×10\times/8×8\times,時間約 6×6\times/9×9\times),倍數因此一路長到 840×840\times。真實網路的 PP 是 10610^6 到 101110^{11},那個倍數就是同樣的數量級——不是「慢一點」,是「不可能」。

    兩者算出來的是同一個梯度(相對差 10−1010^{-10})。這一欄很重要:它說明反向傳播不是近似,是精確的導數(在浮點誤差之內)。它和有限差分的差別純粹是算法,不是精度取捨。

    反向傳播不是一個近似技巧,是同一個導數的一種便宜算法。

    補充代價是記憶體:activation 要留著

    反向那一趟需要每一層的中間值(前向算出來的 hℓh_\ell),因為 Jacobian 依賴它們。所以反向傳播用時間換記憶體:時間是一次前向的常數倍,記憶體是 O(D×每層的大小)O(D\times\text{每層的大小})。

    因此可以用 gradient checkpointing:只存部分中間值,反向時重算其餘部分,以更多計算換較少記憶體。改善比例取決於切點、架構與實作;混合精度則是另一種降低儲存量的做法。

    可以直接記成:反向傳播把「PP 次前向」換成了「一次前向 + 一次反向 + 一份 activation」。前兩項是時間,第三項是記憶體——而深度學習框架的工程史,有一大半是在管理第三項。

    什麼時候還是要用數值梯度

    反向傳播全面勝出之後,有限差分只剩一個用途,但那個用途很重要:檢查你自己寫的反向對不對(gradient checking)。手寫一層新的運算時,反向的公式很容易差一個轉置、一個因子或一個和的軸。

    做法是挑幾十個座標,比對兩者。而這時步長 hh 要選對——它有一個 U 形:

    中央差分的相對誤差對步長(48 寬的網路,和反向傳播比)
      h=1e-01   1.087e-03
      h=1e-03   1.091e-07
      h=1e-05   1.671e-11      ← 最好
      h=1e-07   1.449e-09
      h=1e-09   1.343e-07
      h=1e-11   1.223e-05

    兩邊各有一個誤差來源,方向相反。

    左邊是截斷誤差。 中央差分的 Taylor 展開(導航的三十秒:Taylor 展開與「假設直線」 的三階項)給

    L(θ+h)−L(θ−h)2h=L′(θ)+h26L′′′(θ)+O(h4),\frac{L(\theta+h)-L(\theta-h)}{2h}=L'(\theta)+\frac{h^2}{6}L'''(\theta)+O(h^4),

    誤差 ∝h2\propto h^2。實測從 h=10−1h=10^{-1} 到 10−310^{-3} 誤差掉了約 10410^{4} 倍(1.1×10−3→1.1×10−71.1\times10^{-3}\to1.1\times10^{-7})——正好是 h2h^2。

    右邊是捨入誤差。 分子是兩個接近的數相減,雙精度浮點只有約 16 位有效數字,所以分子的絕對誤差大約是 ∣L∣×10−16|L|\times10^{-16},除以 2h2h 之後是 ∝1/h\propto1/h。實測從 h=10−7h=10^{-7} 到 10−1110^{-11} 誤差長了約 10410^{4} 倍——正好是 1/h1/h。

    這次例子的最佳 hh 約為 10−510^{-5},不是通用常數。檢查時可用雙精度掃幾個步長,固定隨機性,避開不可微點,並同時看絕對與相對誤差;梯度很接近零時,單看相對誤差尤其容易誤判。

    有限差分的精度有一個下限,而且那個下限不在 h→0h\to0。

    注意用單精度做 gradient checking 會失敗

    上面的 U 形谷底位置由浮點的有效位數決定。雙精度(float64)約 16 位,谷底在 h≈10−5h\approx10^{-5}、誤差 ≈10−11\approx10^{-11};單精度(float32)只有約 7 位,谷底移到 h≈10−2h\approx10^{-2} 附近而且谷底的誤差只到 10−310^{-3} 量級。

    後果是:在單精度下做 gradient checking,正確的實作也會「失敗」——你看到 10−310^{-3} 的相對誤差,分不出那是捨入還是真的寫錯。所以規則是:gradient checking 一律在雙精度下做,而且只檢查那一層的運算,不要連著整個大模型一起檢查(層數越多,LL 的量級越大,捨入誤差跟著放大)。

    每一站的責任怎麼被分下去

    回到那條生產線。反向那一趟在做的事,逐站來看是:

    1. 最後一站:成品扣了 3 分,∂L/∂hD\partial L/\partial h_D 是「輸出每動一單位,扣分動多少」。
    2. 往前一站:拿到後面傳來的「你的輸出有多重要」(一個向量),乘上這一站的 Jacobian,得到「我的輸入有多重要」——傳給更前面一站。
    3. 同時:把那個向量乘上 ∂hℓ/∂θℓ\partial h_\ell/\partial\theta_\ell,得到這一站自己的參數該怎麼更新。

    每一站只需要知道兩件事:自己的局部導數,以及後面傳回來的那個向量。它完全不需要知道整條線長什麼樣子。這就是為什麼深度學習框架可以讓你任意接一張計算圖——每個運算只要實作自己的前向與 VJP,組合的事情框架會處理。

    這一切依賴什麼。 一,每一站可微(實務上「幾乎處處可微」就夠了,ReLU 在 0 的那一點框架直接指定一個值)。二,計算圖是有向無環的——有迴圈就要展開(這正是 RNN 的 backpropagation through time,展開之後記憶體隨序列長度成長)。三,中間值留得住;留不住就要重算(見上面的 Remark)。

    mlp.py 裡那三十行

    同一張地圖從不同地方出發:損失曲面與初始化 用的那個 numpy MLP,反向那一段現在可以逐行讀了:

    def grads(P, X, y, skip=False):
        out, hs = fwd(P, X, skip)          # 前向:順便把每一層的中間值 hs 留著
        g = 2*(out - y[:,None])/len(y)     # ∂L/∂h_D:損失對最後一層輸出的導數
        G = []
        for i in range(len(P)-1, -1, -1):  # 從最後一層往前走
            W, b = P[i]; h = hs[i]
            G.append((h.T @ g, g.sum(0)))  # 這一站自己的責任:∂L/∂W_i、∂L/∂b_i
            if i > 0:
                d = g @ W.T                # 往前傳:∂L/∂h_{i-1} 的線性部分
                z = hs[i-1] @ P[i-1][0] + P[i-1][1]
                d = d * (1 - np.tanh(z)**2)  # 再乘上 tanh 的導數
                g = d
        return list(reversed(G)), ((out - y[:,None])**2).mean()

    hs 保存前向中間值;g 是每筆資料的上游梯度,batch 放在一起時是一個矩陣,而不是整個參數 Jacobian。每圈同時收集本層參數梯度、把敏感度往前傳。這段程式示範普通 MLP,雖有 skip 參數,並未包含殘差分支的反向公式。

    h.T @ g 這一行要分開看:它同時完成了「對 batch 求和」與「∂h/∂W\partial h/\partial W 的乘法」。因為 hℓ=hℓ−1Wh_{\ell}=h_{\ell-1}W,所以 ∂L/∂W=hℓ−1⊤ ∂L/∂hℓ\partial L/\partial W=h_{\ell-1}^\top\,\partial L/\partial h_\ell——前向用到的那個中間值,在反向時變成乘數。這就是為什麼 activation 非留不可。

    刻意違反:把那一行 1−tanh⁡2(z)1-\tanh^2(z) 拿掉。 這是手寫反向最常見的錯誤之一(忘記乘激發函數的導數)。這種 bug 不會讓程式崩潰,也不會讓損失完全不降——它給出的是一個「方向大致對但比例錯掉」的向量,訓練看起來還在動,只是慢很多、而且對學習率異常敏感。

    這正是 gradient checking 存在的理由:上面的 U 形告訴你,用 h=10−5h=10^{-5} 比對幾十個座標,正確的實作會給 10−1110^{-11} 量級的相對誤差,漏乘一項會給 10−110^{-1} 量級——兩者差十個數量級,不可能看錯。

    兩個方向的掃描,與有限差分的 U 形

    互動 demo:反向傳播那條線幾乎水平,有限差分那條線性往上;下面的 V 形谷底大約在 h = 10⁻⁵。

    檢查理解

    想一想

    一個團隊說「我們的模型只有 5000 個參數,用有限差分算梯度就好,不必實作反向傳播」。最合理的評論是:

    想一想

    關於「前向模式(JVP)沒有用」這個說法:

    想一想

    在中央差分的 U 形裡,hh 從 10−510^{-5} 再往下調到 10−1110^{-11},相對誤差從 1.7×10−111.7\times10^{-11} 變成 1.2×10−51.2\times10^{-5}。原因是:

    想一想

    下列哪一句不對?

    參考文獻

    1. Rumelhart, D., Hinton, G., Williams, R. Learning representations by back-propagating errors. Nature 1986.(把反向傳播帶進神經網路的那篇;它的貢獻不是鏈式法則,是把它組織成一個可以套在任意深度上的演算法。)
    2. Griewank, A., Walther, A. Evaluating Derivatives. 2nd ed. SIAM 2008.(自動微分的標準參考:前向/反向模式的成本分析、checkpointing 的理論。)
    3. Baydin, A. et al. Automatic Differentiation in Machine Learning: a Survey. JMLR 2018.(把 AD 與數值微分、符號微分分清楚;本篇「反向傳播不是近似」那一節的來源。)