JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

網路如何學習:計算圖、反向傳播與自動微分

跟著一個誤差訊號反向流過整個網路,分配每個權重該負的責任——把連鎖律變成機械化的運算。

學習=沿著損失向下走

上一篇指南裡,損失函數給了我們一個單一的數字,用來評分網路的猜測有多錯:損失小代表預測好,損失大代表預測差。但替錯誤打分,只是故事的一半。學習,意味著要對它做點什麼——微調網路的權重,讓下一次的損失變得更小。這篇指南講的正是這套機制:網路如何把一個誤差數字,轉化成給每一個權重的精確指令。

在腦海裡記住這幅畫面。把損失想成一片起伏的地形。水平座標不是東西南北——而是網路的權重。權重的每一種設定都是地形上的一個位置,而那個位置的高度,就是這組權重對應的損失。訓練就像一位健行者站在這片地形的某處,置身濃霧之中,試圖走下最低的山谷,也就是損失最小的地方。健行者看不到整張地圖;他只能感覺到腳下這一小塊的坡度。

告訴健行者坡度的工具,就是梯度。梯度是一支指南針,它指向最陡的上坡方向——也就是讓損失爬升最快的方向。那正好和我們想要的相反,所以做法很簡單:往梯度的方向走一步,你就會朝下坡、朝更小的損失前進。讀出坡度、轉個身、踏出一步。重複。

梯度下降:站在損失地形上,讀出最陡上坡的梯度,然後往相反方向——下坡——一步步走,直到落入山谷。

一條彎曲的損失曲面,一個標記以小步逐步下降到最低點;箭頭顯示每一步都朝上坡坡度的反方向移動。

這個迴圈有個名字:梯度下降。剝到最核心,它就是重複:量測坡度,往下坡踏一小步。深度學習訓練裡其他幾乎一切,都只是繞著這一個想法在做帳。每一步的大小,由一個我們可以自己選的旋鈕控制,而這單單一個數字,竟然關係重大。

w \leftarrow w - \eta\,\frac{\partial L}{\partial w}

單一權重的梯度下降更新式。

把它當成一條指令,由左讀到右:用一個新值取代舊的權重 w。各部分是:w 是我們正在更新的一個權重(網路有數百萬個,這條規則對每一個都適用)。∂L/∂w 是梯度——它回答『如果我把這一個權重往上推一點點,損失 L 會變化多少?』值為 +2 代表損失上升的速度是你抬高 w 的兩倍(這個權重正把我們往上坡推);值為 −3 代表你抬高 w 時損失反而下降。η(希臘字母 eta)是學習率,也就是步長。那個減號正是整個訣竅:它讓我們逆著上坡的梯度移動,也就是往下坡走。具體來說:假設 w = 0.50、梯度 ∂L/∂w = +2.0、η = 0.1。那麼 w ← 0.50 − 0.1×2.0 = 0.50 − 0.20 = 0.30。我們把權重調低了,因為抬高它會抬高損失——完全正確。

所以梯度下降是簡單的部分——每個權重做一次減法而已。困難的部分、也就是這篇指南其餘篇幅要解決的,藏在那個 ∂L/∂w 裡。你究竟要如何計算梯度——也就是損失對數百萬個權重中每一個的斜率——而且要在一個多層網路上算得很快?用最直覺的方式硬幹是沒指望的。優雅的答案,就是計算圖與反向傳播。

用計算圖的視角看網路

在第 2 篇指南裡,我們讓資料在網路中向前流動——乘上權重、加上偏置、套用激活,一層接著一層——以產生一個預測。這個過程稱為前向傳播。為了讓梯度變得可處理,我們現在不把它看成一面矩陣代數的牆,而是重新框成一張接線圖:一張計算圖。一模一樣的計算,但畫面更能看出門道。

在計算圖裡,每個節點是一個小運算——一次乘法、一次加法、一個激活、或損失——而則是在節點之間流動的數值。資料從左邊進來,向右流動。我們取網路中最小但仍有意義的一片——一個神經元接進一個損失——把它一個節點一個節點地攤開來:

# A tiny computational graph: one neuron, then a loss.
# Inputs / parameters (the leaves of the graph):
x = 2.0      # an input feature (a pixel value, say)
w = 0.5      # the weight on that input  (we want dL/dw)
b = 0.1      # the bias

# Each line below is ONE node. It knows how to compute its
# output from its inputs (the forward direction), and --
# crucially -- it also knows its own LOCAL derivative rule.
z = w * x + b      # node: weighted sum     ->  z = 1.1
a = relu(z)        # node: activation       ->  a = 1.1  (z > 0)
L = loss(a, y)     # node: how wrong we are ->  one number
與第 2 篇相同的前向傳播,改寫成一串有標籤的節點。每個節點都會算出一個輸出,並記住一條簡單的局部導數規則。

這幅畫面之所以值得費這番功夫,原因在此。每個節點都是簡單的運算,而對於簡單的運算,我們對它的局部導數瞭若指掌。對加權和節點來說,微調 w,z 會以 x 的速率變化。對 ReLU 節點來說,當輸入為正時輸出以 1 的速率變化、為負時以 0 變化。損失節點也有它自己已知的斜率。這些單獨拿出來都不難。那個深刻的論斷——下一節會證明它——是:我們只要把連接路徑上的局部導數一路相乘,就能求出最終損失對任一權重的斜率。計算圖把一個嚇人的全域導數,化成一串瑣碎的局部導數。

反向傳播:一層一層的連鎖律

現在來到這篇指南的核心——我們慢慢走。我們手上有一個最終的損失,想知道埋在計算圖深處的每個權重,要為它負多少責任。背後的機制是微積分裡的連鎖律,但你不必怕它。把責任想成往回流動,像一場倒著跑的接力賽。損失把一根『責任接力棒』交給它前面那個節點;那個節點留下自己的一份,把接力棒乘上它自己的局部敏感度,再把剩下的往更後面傳。等接力棒傳到某個權重時,它已經被沿途每一段都縮放過了——而傳到的,正好就是那個權重的梯度。

我們把一個權重——就是剛才那張小圖裡的 w——從頭到尾追一遍。w 只透過唯一一條路徑影響損失:w 改變 z、z 改變激活值 a、a 改變損失 L。連鎖律說:要得到損失如何依賴 w,就把每一段如何依賴下一段的比率全部相乘。

\frac{\partial L}{\partial w} = \frac{\partial L}{\partial a}\cdot\frac{\partial a}{\partial z}\cdot\frac{\partial z}{\partial w}

權重 w 的責任 =(輸出端的責任)×(激活的局部斜率)×(餵給該權重的輸入)。

把這三個因子一個一個來看,沿著路徑由右往左讀。∂z/∂w 問的是『我抬高 w 時,加權和 z 變化得多快?』由於 z = w·x + b,把 w 抬高 1,z 就抬高 x——所以 ∂z/∂w = x,也就是餵給該權重的輸入。∂a/∂z 是激活自己的局部斜率:對 ReLU 來說,z > 0 時是 1、z < 0 時是 0(閘門打開或關上)。∂L/∂a 則是神經元的輸出 a 變化時損失變化多少——這就是從損失傳下來的責任接力棒。現在代入我們的數字:x = 2,所以 ∂z/∂w = 2;z = 1.1 > 0,所以 ReLU 閘門是開的、∂a/∂z = 1;再假設下游損失傳回 ∂L/∂a = 0.4。相乘:∂L/∂w = 0.4 × 1 × 2 = 0.8。這是一個真真切切、具體的梯度。把它代入更新式、取 η = 0.1,權重就動了:w ← 0.5 − 0.1×0.8 = 0.42。

注意我們重複使用了前向時就已經算好的值——x、z 的正負號、預測值 a。這正是反向傳播(backward pass)的引擎:先把網路前向跑一遍、記住每個節點的輸出;接著從損失出發、算出它的梯度,再把這個梯度往回推過整張圖,在每個節點乘上你能從存下來的前向值直接讀出的局部導數。偏置也由同一台機器一併算出:∂L/∂b = (∂L/∂a)(∂a/∂z)(∂z/∂b) = 0.4 × 1 × 1 = 0.4,因為抬高 b 會一對一地抬高 z(∂z/∂b = 1)。

這裡有個讓深度學習根本得以成立的關鍵收穫。你也許會想用實驗法量每個權重的梯度:把權重 1 推一下、整個網路重跑一遍、看損失變了多少;再把它放回去、推權重 2、再重跑,依此類推。對一百萬個權重來說,那就是每一步要跑一百萬次前向——完全沒指望。反向傳播只要在圖上做大約一趟反向掃描,就能算出所有梯度,代價大約等同一次前向傳播。答案一樣,卻便宜一百萬倍。這正是我們能訓練擁有數十億參數的網路的全部原因。

\boldsymbol{\delta}^{(l)} = \left( W^{(l+1)} \right)^{\top} \boldsymbol{\delta}^{(l+1)} \;\odot\; g'\!\left( z^{(l)} \right)

整層的反向步驟,一次寫給該層所有神經元。

這就是同一條單一權重的連鎖律,只是改成一次寫給整層神經元。δ^{(l)}(讀作『第 l 層的 delta』)是抵達第 l 層的責任向量——每個神經元一個數字,說明該神經元的活化前值 z 要為損失負多少責任。把右邊當成兩步驟的食譜來讀。第一步,(W^{(l+1)})^⊤ δ^{(l+1)}:拿下一層已經算好的責任 δ^{(l+1)},把它沿著那一層的權重矩陣往回拉(轉置 ⊤ 只是把每個下游的責任,路由回當初餵給它的那些神經元)。第二步,⊙ g'(z^{(l)}):符號 ⊙ 代表逐元素相乘,而 g'(z^{(l)}) 是本層在每個神經元處的激活斜率——於是我們用每個神經元實際上有多敏感,去把守(gate)傳進來的責任。用白話說就是:把下一層的責任沿權重拉回來,再用本層的激活斜率把守它。從輸出往輸入一層一層套用這條公式,你就填好了每一個 δ——也就填好了每一個權重的梯度。

自動微分:框架如何替你完成

當你用 PyTorch 或 TensorFlow 時,你從來不必自己寫連鎖律——你建好模型、呼叫像是 loss.backward() 的東西,然後每一個梯度就這麼出現了。那麼這台引擎在做什麼?它在執行自動微分(autodiff)。先把自動微分不是什麼講清楚,會很有幫助。

不是符號微分——它不會把你的網路硬塞進代數裡,去產生一條巨大的封閉形式導數公式。對真實網路而言,那條公式會爆炸成數百萬項,而且根本沒必要。它也不是有限差分——它不會靠著把每個輸入推動一個極小的 ε、再重跑一遍來估斜率,∂L/∂w ≈ (L(w+ε) − L(w))/ε。那個數值技巧既慢(每個權重要重跑一次——正是反向傳播所避免的)又不準(捨入誤差會吃掉答案)。自動微分是第三種東西:既精確便宜。

它實際在做的事:在前向傳播時,它記錄下計算圖——你執行的每一個運算都被依序記下,連同它看到的輸入、以及那個運算的局部導數規則。然後在回程時,它把這些存下來的局部導數一個節點一個節點地反向套用,完全照連鎖律所規定的去相乘與累加。那一趟反向掃描,正是推廣到任意圖的反向傳播——它甚至有個正式名稱,叫反向模式自動微分(reverse-mode autodiff)。反向傳播是它用在神經網路上的特例;反向模式自動微分則是同一個想法,適用於任何你寫得出來的計算。

import torch

# Leaves we want gradients for: requires_grad=True tells autodiff
# to record every operation that touches them.
x = torch.tensor(2.0)
w = torch.tensor(0.5, requires_grad=True)
b = torch.tensor(0.1, requires_grad=True)

# FORWARD PASS -- autodiff silently builds the graph as we go.
z = w * x + b          # node logged: multiply + add
a = torch.relu(z)      # node logged: relu
L = (a - 1.0) ** 2     # node logged: a simple squared-error loss

# BACKWARD PASS -- replay the recorded graph in reverse.
L.backward()

print(w.grad)   # = dL/dw, computed by the chain rule automatically
print(b.grad)   # = dL/db, from the very same backward sweep
你只寫前向計算;.backward() 會重播記錄下來的圖,並用連鎖律把每一個 .grad 填好。

要記住的心智模型:把你寫的每一個運算,想成都悄悄留下一張便利貼,上面寫著『這是我的局部導數』。呼叫 .backward() 時,它會從損失往回走過這一疊便利貼、一路相乘,並把結果存進每個參數的 .grad 欄位裡。接著你的最佳化器讀取這些 .grad 值,套用第 1 節的 w ← w − η ∂L/∂w 那一步。前向是記錄、反向是微分、邁步是學習。

一個完整的訓練步驟,從頭到尾

現在所有零件都到齊了。我們把它們組裝成一個你能背下來的迴圈——這個迴圈反覆執行,就能把一個隨機的網路變成一個會看的網路。訓練的一步,會依序做四件事,然後移到下一批影像。

  1. 前向傳播 — 用前向傳播把一批影像推過網路,得到它目前的預測。
  2. 計算損失 — 用損失函數拿那些預測去對照真實標籤打分,把所有誤差收攏成一個數字。
  3. 反向傳播 — 跑反向傳播/自動微分,求出那個損失對每一個權重的梯度。
  4. 更新 — 對每一個權重做一次梯度下降,w ← w − η ∂L/∂w。然後帶著下一批資料回到步驟 1。
訓練迴圈:前向去預測、損失去打分、反向去分配責任、更新去改善——然後在下一批上重複。

一張循環圖:資料餵進前向傳播產生預測,損失替它們打分,反向傳播產生梯度,接著進行權重更新,再有一條箭頭繞回去餵入下一批資料。

兩個輕量帶過的詞彙。我們很少一次只餵一張影像;我們會把一個批次(batch)(或小批次(mini-batch))——一小撮影像,比如 32 或 64 張——放在一次前向加反向的掃描裡一起處理,這在 GPU 上更快,也能給出更平穩的平均梯度。當迴圈把訓練集裡每一張影像都跑過一遍,那就是一個回合(epoch)。訓練通常會跑很多個回合,迴圈總共會轉上數千甚至數百萬次。

\begin{aligned} \hat{y} &= \mathrm{forward}(x) \\[2pt] L &= \mathrm{loss}(\hat{y},\, y) \\[2pt] g &= \mathrm{backward}(L) \\[2pt] w &\leftarrow w - \eta\,\frac{\partial L}{\partial w} \end{aligned}

四行就是一個訓練步驟。重複數千次,這個區塊就是訓練本身。

一行一行地看,這就是那四個階段。ŷ = forward(x) 把一批 x 推過網路得到預測 ŷ(階段 1)。L = loss(ŷ, y) 拿它們去對照真實標籤 y 打分,產生那一個損失數字(階段 2)。g = backward(L) 是反向傳播,產生 g,也就是每個權重的梯度 ∂L/∂w 的集合(階段 3)。而 w ← w − η ∂L/∂w 把每個權重往下坡推一小步(階段 4)。這四行的區塊,一批接一批地跑上許多回合,就是訓練一個神經網路的全部動作。下一篇指南所講的一切,都是要讓這個迴圈可靠地收斂,而不是卡住或炸開。

預告可能出錯之處:梯度消失

我們用一個誠實的警告來收尾,為最後一篇指南鋪路。再看一次反向傳播:一個深層權重的梯度,是一串局部斜率的乘積,責任每要穿過一層就多一個因子——∂L/∂w =(斜率)×(斜率)×(斜率)× …,一路往回延伸。乘積是脆弱的。如果這些因子裡有很多都小於 1,那麼越往深處走,乘積就越會塌向零。

這就是梯度消失(vanishing gradient)問題,而sigmoid 激活是經典的元兇。回想第 2 篇:sigmoid 在輸入很大的正值或負值時會變得平坦;在那些平坦區域,它的局部斜率幾乎是零(就算在最好的情況,斜率也只到 0.25 的頂點)。把十層這樣的層串在一起,責任就被乘上大約 0.2 共十次:0.2^10 ≈ 0.0000001。等誤差訊號傳到前面幾層時,它已經縮到幾乎什麼都不剩——於是最前面的層幾乎不更新、實質上停止學習,偏偏這些層正是該去學最基本視覺特徵的那幾層。

鏡像的另一種失敗是梯度爆炸(exploding gradients):如果那些因子持續大於 1,乘積反而會指數式地暴增,權重一下子跳動巨大的幅度——損失發散,往往一路衝到 NaN。這兩種病症有著你剛剛學到的同一個根源:反向傳播會把一長串數字相乘,而一長串數字的乘積,不是縮成塵土、就是爆掉,除非有什麼東西讓每個因子都維持在接近 1。

那麼,我們要怎麼讓每層的因子維持在接近 1?答案的一部分你已經瞥見了:把會飽和的 sigmoid 換成修正線性單元(ReLU),它對每個正輸入的斜率都是乾乾淨淨的 1,能阻止訊號在打開的閘門中衰減。但光是選激活函數還不夠——你還必須讓權重從正確的尺度起步,好讓訊號在最初的第一步,穿過每一層時既不放大也不縮小。把這個初始尺度調對,以及讓訓練真正收斂並泛化的更廣工藝,正是本軌道下一篇、也是最後一篇指南要談的東西。