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

CHAPTER 03 / 21 · PART 1 · 地基:從監督式學習到深度網路

淺層網路:折線如何拼出任何形狀

Shallow neural networks

用一層被夾斷的直線拼出任意連續形狀,並看清通用逼近定理保證了什麼、又刻意沒保證什麼。

§01學習重點

§02課程內容

一、直線的天花板,與一個會折的零件

第 2 章把監督式學習整理成一件事:先劃定一個函數族,再依據資料從族裡挑一個成員。當時用的族是所有一次函數,只有兩個參數——一個斜率、一個截距。這個族小得可以整族攤開來看,也因此它的天花板一眼可見:一次函數從頭到尾只有一個斜率。 輸入軸上不管走到哪裡,多給一單位的輸入,換回來的輸出增量都是同一個數。

真實世界的關係很少這麼聽話。木料含水率每降一個百分點,板材收縮的幅度並不固定:剛開始烘的時候縮得快,接近平衡含水率之後幾乎不再動。要描述這種「前段陡、後段緩」的關係,一條直線做不到——你可以讓它整體陡一點或緩一點,就是不能讓它在中途改變主意。

要跨過這道天花板有兩條路。一條是換一個更複雜的固定形式,例如改用高次多項式;另一條是準備一批很簡單的零件,靠數量組合堆出複雜度。深度學習走的是後面這條,而且它挑的零件簡單到有點難以置信:

$$ a[z] \;=\; \max(0,\, z) $$

逐項拆解:\(z\) 是丟進來的一個實數;\(\max(0, z)\) 的意思是「\(0\) 和 \(z\) 兩個數裡取比較大的那個」。所以 \(z\) 是正的就原樣送出去,是負的就一律送出 \(0\)。這個函數叫修正線性單元(rectified linear unit),一般直接唸縮寫 ReLU;本課沿用原書的記號慣例,用方括號 \(a[\cdot]\) 表示「激活函數作用在什麼上」,跟一般的乘法括號區分開。

它的形狀就是兩段直線:左半邊貼著橫軸躺平,右半邊沿著四十五度角往上,兩段在原點接起來。這個「兩段直線接成一條」的性質叫分段線性(piecewise linear)——每一段自己是直的,整條卻不是。

這裡有一個大二學生非常容易卡住的地方,值得停下來講清楚。分段線性不等於線性。 判斷一個函數是不是線性的,看的是它整條式子能不能寫成「常數乘以輸入再加一個常數」這一種形式,而 ReLU 寫不成——你找不到一組固定的斜率和截距同時對付得了左右兩邊。所以 ReLU 是不折不扣的非線性函數,儘管它每一小段內部都直得不能再直。這一點在第三小節會變成整章最要緊的一件事。

還有兩個小性質先擺著,後面會各用到一次。

第一,ReLU 在零點沒有導數。左邊逼近時斜率是 \(0\),右邊逼近時斜率是 \(1\),兩邊對不起來,所以那一點的切線並不唯一。這在數學上是個真問題,在實作上不是:程式在 \(z=0\) 時就取一個值(多數框架取 \(0\)),而參數恰好讓某筆輸入的預活化剛剛好等於零的機率極低,就算真的碰上了也只影響那一步。

第二,對任何正數 \(\alpha\),把輸入放大 \(\alpha\) 倍,輸出也剛好放大 \(\alpha\) 倍:

$$ a[\alpha z] \;=\; \max(0,\,\alpha z) \;=\; \alpha \max(0,\, z) \;=\; \alpha\, a[z] $$

逐項拆解:因為 \(\alpha > 0\),\(\alpha z\) 的正負號跟 \(z\) 一樣,所以「該不該夾斷」的判斷不會被改變;沒被夾斷的那一邊,數值就照著放大 \(\alpha\) 倍。這個性質叫非負齊次性(positive homogeneity),作業三會用它造出一個很有意思的麻煩。

二、一個隱藏單元,就是一條被夾斷的直線

有了零件,來裝第一個。取一個輸入 \(x\),先做一次一次函數的運算,再把結果丟進 ReLU:

$$ z_d \;=\; w_d\, x + b_d, \qquad h_d \;=\; a[z_d] \;=\; \max(0,\, z_d) $$

逐項拆解:下標 \(d\) 是這個零件的編號;\(w_d\) 是權重(weight),也就是那條直線的斜率;\(b_d\) 是偏置(bias),也就是截距。\(z_d\) 這個中間量叫預活化(pre-activation),意思是「還沒經過激活函數的值」;\(h_d\) 叫活化(activation),是經過之後的值。這整個零件——一次仿射運算加上一次激活——叫一個隱藏單元(hidden unit),文獻裡也常叫它神經元(neuron);「隱藏」是因為它的值既不是你餵進去的輸入,也不是你讀出來的輸出,夾在中間看不見。

預活化與活化是兩個不同的量,別把它們混在一起。前者可正可負,後者永遠不小於零;後面數線性區時要看的是預活化的正負號,而算輸出時用的是活化值。

畫出來 \(h_d\) 長什麼樣?它是一條被夾斷的直線:某個位置以前平貼在零上,某個位置以後沿著斜率 \(w_d\) 走。那個轉折的位置叫折點(kink),它出現在預活化恰好過零的地方,也就是解 \(w_d x + b_d = 0\):

$$ x^{*}_d \;=\; -\,\frac{b_d}{w_d} $$

逐項拆解:\(x^{*}_d\) 是第 \(d\) 個單元的折點在輸入軸上的座標;它由該單元的偏置除以權重再取負號決定,跟其他單元完全無關。這條式子小,但它是本章後面所有計數的根。順帶一提,\(w_d\) 的正負號決定哪一邊是平的:\(w_d > 0\) 時左平右斜,\(w_d < 0\) 時左斜右平。而 \(w_d\) 的大小只影響斜的那一段有多陡,不影響折點在哪——想把折點往右挪,得動 \(b_d\)。

比喻: 木桶匠手上只有筆直的木板,卻要圍出一個圓。他的做法不是把板子壓彎,而是把每片板的兩側刨出一點角度,讓它跟隔壁那片接得上——圓是靠一圈接縫「折」出來的,不是靠任何一片板彎出來的。一個隱藏單元就是這樣一片板:它自己從頭到尾筆直,唯一會做的事是在某個位置轉折一次。這個比喻在一個地方明確失準:桶匠是先量好桶徑、算好板寬,接縫位置在開工前就定了;網路的折點位置藏在 \(w_d\) 與 \(b_d\) 裡,是訓練一路挪出來的,開工時沒有人知道它們最後會落在哪。

一片板圍不出東西,得要一整圈。淺層網路(shallow network)做的就是把 \(D\) 個這樣的單元並排放好,再把它們的活化值加權相加:

$$ y \;=\; b_0 \;+\; \sum_{d=1}^{D} v_d\, h_d \;=\; b_0 \;+\; \sum_{d=1}^{D} v_d\, a\bigl[w_d x + b_d\bigr] $$

逐項拆解:\(v_d\) 是輸出層給第 \(d\) 個單元的權重,\(b_0\) 是輸出層的偏置——它們跟隱藏層的 \(w_d\)、\(b_d\) 同樣是「權重」與「偏置」,只是屬於後面那一層,用下標 \(0\) 把輸出端那個偏置標出來。這些數字合起來就是這個模型的參數 \(\boldsymbol\phi\),一共 \(3D + 1\) 個(每個單元三個,加上一個輸出偏置)。注意 \(\boldsymbol\phi\) 和 \(h_d\) 是性質完全不同的東西:\(\boldsymbol\phi\) 會在訓練過程中被調整,但換一筆輸入不會變;\(h_d\) 根本不是可調的參數,它是每餵進一筆新的 \(x\) 就要重算一次的中間值。

整個運算可以拆成三個步驟複述:第一步,把同一個輸入丟進 \(D\) 條各自獨立的一次函數,得到 \(D\) 個預活化;第二步,每個預活化各自過一次 ReLU,把負的夾成零;第三步,把 \(D\) 個活化值加權相加,再加一個偏置。 這三步之外沒有別的東西了。習慣上把輸入 \(x\) 那一排叫輸入層、\(h_d\) 那一排叫隱藏層(hidden layer)、\(y\) 那一排叫輸出層;「淺層」的定義非常機械:恰好一個隱藏層。第 4 章要處理的深度網路,差別只在隱藏層不只一個。

第三步裡的兩種參數各管一件事,分清楚以後看曲線會清楚很多。\(b_0\) 管整條函數的高度——它把整個圖形上下平移,不改變任何一段的斜率。\(v_d\) 管第 \(d\) 片板的縮放與翻轉:\(v_d\) 變大,那一片板貢獻的傾斜就更明顯;\(v_d\) 是負的,那一片板貢獻的就是往下折而不是往上折。折點在哪由隱藏層決定,折多少由輸出層決定,兩件事分屬兩層。

最後補一個容易被跳過的問題:激活函數非得是非線性的嗎?答案是非得,而且理由乾淨得幾乎像在耍賴。假設把 ReLU 換成一個線性激活 \(a[z] = cz\),那麼

$$ y \;=\; b_0 + \sum_{d=1}^{D} v_d\, c\,(w_d x + b_d) \;=\; \Bigl(\sum_{d=1}^{D} c\,v_d w_d\Bigr) x \;+\; \Bigl(b_0 + \sum_{d=1}^{D} c\,v_d b_d\Bigr) $$

逐項拆解:把括號展開之後,含 \(x\) 的項全部收在一起、常數項全部收在一起,整條式子塌成「某個常數乘 \(x\) 再加某個常數」——也就是一條直線。不管你放幾個單元、疊幾層,結果都一樣。原因是仿射映射對複合是封閉的:一次函數接一次函數還是一次函數,接一百次也還是。所以無論網路多大,只要中間沒有非線性,它的表達力永遠只有第 2 章那個兩參數的族那麼大。

這句話反過來就是本章的核心:激活函數是整個網路唯一的非線性來源。 網路能表達什麼形狀,完全取決於那些夾斷發生在哪裡。

三、數一數:活躍樣式、線性區,與斜率從哪裡來

現在把注意力放到「夾斷」上。對某一筆輸入 \(x\),第 \(d\) 個單元只有兩種狀態:預活化為正,這個單元活躍(active),它的活化值就是那條直線的值;預活化為負或零,這個單元不活躍(inactive),它的活化值是 \(0\),等於對輸出完全沒有貢獻。

把 \(D\) 個單元的活躍與否依序排成一串 \(0\) 與 \(1\),就得到這筆輸入的活躍樣式(activation pattern)。這個東西很重要,因為它決定了輸出的算法:只要活躍樣式不變,哪些單元有貢獻、哪些沒有就不變,輸出就是這些單元那幾條直線的固定加權和——也就是一條直線

於是輸入軸被自然地切成好幾段,每一段內部活躍樣式固定不變,輸出在那一段內是線性的。這樣的一段叫一個線性區(linear region)。線性區與活躍樣式是一一對應的:換一個區就換一種樣式,換一種樣式就換到另一個區。

那某個線性區的斜率是多少?把式子微分一次就看得出來:不活躍的單元貢獻 \(0\),活躍的單元貢獻 \(v_d \cdot w_d\)。所以

$$ s_R \;=\; \sum_{d \,\in\, \mathcal{A}_R} v_d\, w_d $$

逐項拆解:\(R\) 是我們正在看的那一個線性區,\(s_R\) 是它的斜率;\(\mathcal{A}_R\) 是「在這一區裡活躍的那些單元」所成的集合,所以求和只跑遍這些單元;每個活躍單元貢獻的是它的輸入權重與輸出權重的乘積。這解釋了一件常被忽略的事——如果某一區裡所有單元都不活躍,那一區的斜率就是 \(0\),輸出是一條水平線。一維淺層網路會不會在遠處出現這種全零的區,條件很明確:只有當每個單元的斜的那一半都朝同一邊時才會——這時往反方向走夠遠,每個單元都落在自己平的那一半,同時被夾成零;只要有一個單元朝相反方向,兩端就都還有單元活躍,遠處是斜率不同的直線而不是水平線(下面那段程式的 \(w = [1.5, -1.0, 0.7]\) 正是後者:最左與最右兩個線性區的活躍樣式是 010101,兩端都不是全零)。這就是為什麼網路對訓練範圍以外的輸入不可信:它在那裡只是一條平的或斜的直線,沒有任何理由碰巧是對的。

拿三個單元跑一遍就都看見了:

PYTHON
import numpy as np

# 教學用例:三個隱藏單元的淺層網路(一維輸入、一維輸出)
w = np.array([1.5, -1.0, 0.7])     # 隱藏層權重
b = np.array([-1.2, 2.4, -2.1])    # 隱藏層偏置
v = np.array([1.2, -0.8, 1.5])     # 輸出層權重
b0 = 0.5                           # 輸出層偏置


def net(x):
    z = w * x[:, None] + b         # 預活化
    h = np.maximum(0.0, z)         # 活化:ReLU 把負的夾成零
    return b0 + h @ v


print("折點:", " ".join(f"{k:.2f}" for k in np.sort(-b / w)))

probes = np.array([0.0, 1.5, 2.7, 4.0])   # 四個線性區各取一點
mask = (w * probes[:, None] + b) > 0      # 誰活躍
slope = mask @ (w * v)          # 斜率=活躍單元的 w·v 之和
print("輸入 / 活躍樣式 / 斜率")
for x0, m, s in zip(probes, mask.astype(int), slope):
    print(f"  {x0:4.1f}   {''.join(map(str, m))}   {s:.4f}")

eps = 1e-5
fd = (net(probes + eps) - net(probes - eps)) / (2 * eps)
print("差分驗證:", np.allclose(slope, fd))
gap = slope[1] - slope[0] - slope[2]
print(f"第二段 −(第一段+第三段)= {gap:.1e}")

xs = np.array([0.0, 0.8, 2.4, 3.0, 5.0])
print("折點處的值:", " ".join(f"{y:.2f}" for y in net(xs)))

實跑輸出:折點是 0.80 2.40 3.00;四個探測點的活躍樣式依序是 010110100101,對應斜率 0.80002.60001.80002.8500;差分驗證回報 True;折點處的函數值是 -1.42 -0.78 3.38 4.46 10.16

三個單元、三個折點、四個線性區。每過一個折點,斜率就跳動一次,跳動量恰好是那個單元的 \(v_d w_d\):過第一個折點時第 1 個單元開始活躍,斜率從 \(0.80\) 跳到 \(2.60\),跳了 \(1.80 = 1.2 \times 1.5\);過第二個折點時第 2 個單元轉為不活躍,斜率掉了 \(0.80\)。折點不是曲線上的裝飾,它就是某個單元開關切換的那一刻。

最後一行輸出值得單獨看。程式印出 第二段 −(第一段+第三段)= -2.2e-16,也就是在浮點誤差內剛好是零:第二段的斜率恰好等於第一段加第三段。這不是巧合。四段斜率全部由三個乘積 \(v_1w_1, v_2w_2, v_3w_3\) 組合而成,三個數字撐不出四個獨立的量,所以四段斜率之間必然有一條線性關係。一般地說,一維輸入配 \(D\) 個單元時有 \(D+1\) 段,但只有 \(D\) 個獨立的自由度——你可以隨意指定其中 \(D\) 段的斜率,剩下那一段就被前面決定了,沒得挑。

現在可以把數量關係說死了。一個隱藏單元最多貢獻一個折點;\(D\) 個單元最多切出 \(D\) 個折點,也就是最多 \(D+1\) 個線性區。 「最多」兩個字不是客氣話,有三個實實在在的原因會讓實際段數變少:

  1. 折點重合。 兩個單元的 \(-b_d/w_d\) 剛好相等時,兩個折點疊在同一個位置,只切出一刀。
  2. 貢獻消失。 \(v_d = 0\) 時第 \(d\) 個單元對輸出完全沒有影響,它的折點在輸出曲線上看不見;\(w_d = 0\) 時那個單元根本不會轉折。
  3. 折點落在定義域外。 你只關心 \(x\) 在某個範圍內的行為,而某個折點落在範圍外面——那一刀確實存在,但你看不到它。

這三件事在訓練過的網路裡都真的會發生,所以「有 \(D\) 個單元就一定有 \(D+1\) 段」是錯的說法。反過來,\(D+1\) 這個上界很有用:它把隱藏單元數變成一個可以直接讀的容量(capacity)指標。單元多,可用的折點就多,能貼合的形狀就複雜——這是本課第一次用一個具體的數字來談「模型有多大」,第 8 章談過擬合時還會回到它。

四、通用逼近定理:它保證什麼,以及它刻意不保證什麼

段數多了為什麼會更準?直覺很短:一條平滑曲線被切成越多段,每一段要負責覆蓋的那一小截就越短,而越短的一截越接近直線。把段數加倍,每段要跨的距離減半,而直線偏離一段弧的程度大致跟距離的平方成正比——所以誤差大約降到四分之一。

這句話可以直接量。下面這段程式拿一段圓弧當目標曲線,把折點等距擺開,只解輸出層的權重(那是一個標準的線性最小平方問題),然後看最大絕對誤差怎麼隨單元數變化:

PYTHON
import numpy as np

# 目標:一段半徑 1 的圓弧,當作「桶身外輪廓」的教學用曲線
lo, hi = -0.8, 0.8
x = np.linspace(lo, hi, 801)
target = np.sqrt(1.0 - x ** 2)


def fit(n_units):
    t = np.linspace(lo, hi, n_units + 1)[:-1]   # 折點等距擺放
    H = np.maximum(0.0, x[:, None] - t)      # 每個單元一條折線
    A = np.concatenate([np.ones((x.size, 1)), H], axis=1)
    coef = np.linalg.lstsq(A, target, rcond=None)[0]
    return float(np.max(np.abs(A @ coef - target)))


prev = None
for n in (2, 4, 8, 16, 32, 64):
    err = fit(n)
    r = "" if prev is None else f"  比值 {err / prev:.3f}"
    print(f"單元 {n:3d} → {n:3d} 段,最大誤差 {err:.6f}{r}")
    prev = err

實跑輸出:單元數 2 / 4 / 8 / 16 / 32 / 64 對應的最大絕對誤差是 0.0904280.0338780.0109140.0031470.0008290.000200,相鄰兩列的比值依序是 0.3750.3220.2880.2630.241。比值一路往 0.25 靠,跟上一段那句「大約降到四分之一」對得上。

這裡有個細節值得順帶說明,正好呼應上一節的「至多」。程式把 \(D\) 個折點等距擺在 \([-0.8,\,0.8]\) 上,其中第一個剛好落在左端點——它的作用是給整段一個基礎斜率,等於一個線性項——所以區間內部只有 \(D-1\) 個折點,切出 \(D\) 段而不是 \(D+1\) 段。少掉的那一段在區間外面。

把這件事推到極限,就是通用逼近定理(universal approximation theorem)。它的粗略陳述是:只要隱藏單元夠多,一個淺層網路可以把任意連續函數逼近到你指定的任何精度。 這句話聽起來很豪邁,但它每一個限定詞都是有意放進去的,缺一個都不成立:

現在講本章最容易被誤讀的一點,請務必讀進去:通用逼近定理是一個存在性命題,不是構造性命題。

存在性命題說的是「有這麼一組參數」。構造性命題說的是「照這個步驟你就能把它做出來」。定理屬於前者。它保證在那個由 \(\boldsymbol\phi\) 張開的巨大空間裡,某個角落確實坐著一組參數能達到你要的精度;它完全沒有提到你要怎麼走到那個角落,甚至沒有保證你走得到。

順著這一點,可以把定理沒有涵蓋的三件事列清楚,這三件事構成了本課接下來一大半的內容:

  1. 參數找不找得到。 訓練是從某個起點出發、一步步往損失變小的方向挪。定理保證終點存在,不保證這條路走得到那裡。第 6、7 章專門處理「怎麼走」以及「為什麼有時候走不動」。
  2. 單元數付不付得起。 定理允許那個數字大到不切實際。第 4 章要說明的正是:同樣的形狀,用多層去做往往省得多——這是「為什麼要深」的核心論證,而它恰恰建立在通用逼近定理的不足之上。
  3. 對沒見過的資料準不準。 定理談的是「逼近某個已知的連續函數」。真實情況是你只有有限筆樣本,把這些點通通穿過去的函數有無窮多個,其中絕大多數在點與點之間的行為荒腔走板。第 8、9 章要處理的就是這一塊。

比喻: 板子越多、箍出來的輪廓越接近正圓——這句話任何一個桶匠都同意,而它正是通用逼近定理在說的事。但請注意它沒說的部分:定理告訴你「存在一種排法能把誤差壓到你指定的程度」,它沒告訴你那是哪一種排法,也沒告訴你手上這批板該怎麼刨才能湊出那個排法。桶匠靠的是幾十年的手感,網路靠的是第 6 章那套一步步下坡的辦法,兩者都可能收不到最好的那個排法。這個比喻的失準之處在於:桶匠可以把板全部拆下來重排,網路的參數卻是連續地一點一點挪的,走過的路徑受起點影響,有些排法從某些起點根本走不到——「重排」這個動作在網路裡沒有對應物。

所以請不要把這條定理讀成「淺層就夠了,不必要深度」。它證明的是表達力的下限,而深度學習真正在乎的是表達力、可訓練性、樣本效率這三件事同時成立。定理只給了第一件。

五、多輸出與多輸入:從一條曲線到一整張曲面

到此為止都是一個輸入、一個輸出。把兩端都放寬,機制不變,但會冒出兩個新的約束。

先看多輸出。 要一次吐出 \(D_o\) 個數,做法是讓所有輸出共用同一組隱藏單元,各自配一套輸出權重與偏置:

$$ y_k \;=\; b_{0,k} \;+\; \sum_{d=1}^{D} v_{k,d}\, h_d, \qquad k = 1, \dots, D_o $$

逐項拆解:\(k\) 是輸出的編號;\(v_{k,d}\) 是第 \(k\) 個輸出給第 \(d\) 個單元的權重;\(b_{0,k}\) 是第 \(k\) 個輸出的偏置。關鍵在 \(h_d\) 沒有下標 \(k\)——每個輸出看到的是同一批活化值。

這帶來一個結構上的約束:所有輸出函數的折點被綁在同一批位置上。 折點位置由 \(-b_d/w_d\) 決定,而那是隱藏層的事,跟 \(k\) 無關。所以無論你有幾個輸出,它們轉折的地方都一模一樣。各輸出之間仍然可以差很多——每一段的斜率可以完全不同,整體高度可以各自平移,某個輸出還能讓某片板往下折——但「在哪裡轉折」這件事沒得商量。

比喻: 一組隱藏單元同時餵給兩個輸出,就像同一批箍好的木板要同時圍出桶身和桶蓋的邊:兩件東西的輪廓可以不同——這一段陡一點、那一段緩一點、整體高一截或矮一截都行——但接縫的位置是同一批板決定的,兩邊只能在相同的位置轉折。這個比喻失準在「同時」兩個字:木桶的板一旦裁好就固定了,而網路的隱藏層是被兩個輸出的誤差一起拉扯著訓練出來的,最後那批折點位置是兩邊妥協的結果,不是先定好再拿去共用。

這個約束的教學意義比它的技術細節重要:隱藏層是一個共用的零件庫。它學到的東西必須同時對所有輸出有用,這既是限制,也正是多任務一起訓練有時反而更好的原因。

再看多輸入。 輸入變成 \(D_i\) 個數,預活化就從一條直線變成

$$ z_d \;=\; \mathbf{w}_d^{\mathsf{T}} \mathbf{x} + b_d \;=\; \sum_{j=1}^{D_i} w_{d,j}\, x_j + b_d $$

逐項拆解:\(\mathbf{x}\) 是輸入向量,\(\mathbf{w}_d\) 是第 \(d\) 個單元的權重向量,上標 \(\mathsf{T}\) 是轉置,\(\mathbf{w}_d^{\mathsf{T}}\mathbf{x}\) 就是兩個向量對應項相乘再全部加起來。這個式子把 \(D_i\) 個輸入做一次加權求和再加個偏置;\(D_i = 2\) 時,把 \(z_d\) 的值畫成高度,你會得到三維空間裡的一張傾斜平面。

ReLU 在這裡做的事是:把「預活化為負」的那半邊輸入空間整個壓成零。 分界線在 \(z_d = 0\) 的那些輸入點上,它們構成輸入空間裡的一張超平面(hyperplane)——\(D_i = 2\) 時是一條直線,\(D_i = 3\) 時是一張平面,更高維想像不出來但式子照樣成立。我們把這張超平面叫第 \(d\) 個單元的活躍邊界:它的一側該單元活躍,另一側不活躍。二維是本節唯一畫得出來的情形,所以先在二維把事情看清楚,再宣告它可以推廣。

\(D\) 個單元就有 \(D\) 張活躍邊界,它們把輸入空間切成一塊塊區域;同一塊裡活躍樣式固定,輸出在那一塊上就是一個仿射函數,圖形是一片平的。整體則是一張連續的分段線性曲面:一塊一塊都是平的,塊與塊在邊界上接得起來,但接縫處會折。

這裡有一個好看的性質:每一塊區域一定是凸的。 所謂凸,白話說就是「這塊區域裡任取兩點,連起來的線段整條都還在這塊區域裡」——沒有凹進去的缺口,也不會中間破洞。理由兩步:第一,每張活躍邊界把空間分成兩個半空間(half-space),而半空間本身是凸的;第二,一塊區域就是「每個單元各站在它那一側」的所有點,也就是 \(D\) 個半空間的交集,而凸集合的交集仍然是凸的。所以區域是凸的不是巧合,是構造出來的必然。這個結論在 \(D_i = 2\) 時是一堆凸多邊形,一般維度下叫凸多面體(convex polytope)。

跑一段程式數數看實際切出幾塊:

PYTHON
import numpy as np
from math import comb

rng = np.random.default_rng(0)

g = np.linspace(-3.0, 3.0, 1200)    # 觀察窗:平面上的方形
P = np.stack(np.meshgrid(g, g), axis=-1).reshape(-1, 2)


def regions(W, b):
    on = (P @ W.T + b) > 0                # 每個格點的活躍狀態
    code = on.astype(np.int64) @ (1 << np.arange(W.shape[0]))
    return len(np.unique(code))


W3 = np.array([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]])
b3 = np.array([0.5, 0.3, -1.4])           # 三條活躍邊界
print("切出", regions(W3, b3), "塊;2^3 =", 2 ** 3)

for n in (2, 3, 5, 8, 12):
    W = rng.normal(0.0, 1.0, (n, 2))
    b = rng.normal(0.0, 1.0, n)
    ub = sum(comb(n, j) for j in range(3))    # 二維下的上界
    print(f"{n:3d} 單元 → {regions(W, b):4d} 塊,上界 {ub:4d}")

Di, D = 40, 200          # 換到高維,只算上界
ub = sum(comb(D, j) for j in range(Di + 1))
print(f"\n{Di} 維輸入、{D} 個單元:區域上界約 {ub:.3e}")
print("同一個網路的參數量:", D * (Di + 1) + (D + 1))

實跑輸出:手工挑的那三條邊界切出 7 塊,而三個單元理論上可以有 8 種活躍樣式——少掉的那一種是「前兩個單元都不活躍、第三個卻活躍」,這在幾何上根本不存在,因為第三條邊界的位置決定了它不可能在那個角落成立。隨機取的參數則得到:2 個單元 3 塊、3 個單元 5 塊、5 個單元 15 塊、8 個單元 19 塊、12 個單元 59 塊,全都低於對應的上界 47163779。原因跟一維那個「至多」一樣,只是多了一條:上界是對整個平面說的,而我們只數了觀察窗裡看得見的塊,交點落在窗外的那些切法不會在窗內生出新的區域。

最後一行是本章的量化重點。維度一升高,區域數就開始失控。最粗的直覺是這樣的:假設有 \(D_i\) 個單元,每個單元的活躍邊界剛好垂直於一個座標軸,那它們就像 \(D_i\) 把刀各切一個維度,把空間切成 \(2^{D_i}\) 個象限型的塊。實務上單元數通常遠多於輸入維度,切法也不會這麼整齊,區域數大致落在 \(2^{D_i}\) 與 \(2^{D}\) 之間。嚴格的上界有一條用二項式係數求和寫成的公式,推導需要一些組合數學,本課只用它算數字、不展開(原書在註記裡給了完整式子與出處,見本章末的原書對照)。

程式算出來的對比是:\(40\) 維輸入配 \(200\) 個隱藏單元,區域數上界約 2.719e+42,而參數量只有 8401 個。十的四十二次方量級的區域,八千出頭的參數。 這不是筆誤——參數量對三個維度各自都只是線性成長,區域數卻是組合式地爆炸。順帶提醒一件事以免誤會:區域多不等於模型好。 區域是表達能力的度量,不是準確度的度量;一個切出天文數字個區域的模型,很可能只是把訓練資料背了下來。這是第 8、9 章的主題,這裡先埋著。

把一般式收攏起來:\(D_i\) 維輸入、\(D\) 個隱藏單元、\(D_o\) 維輸出的淺層網路,寫成矩陣就是

$$ \mathbf{h} = a\bigl[\mathbf{W}\mathbf{x} + \mathbf{b}\bigr], \qquad \mathbf{y} = \mathbf{V}\mathbf{h} + \mathbf{b}_0 $$

逐項拆解:\(\mathbf{W}\) 是 \(D \times D_i\) 的隱藏層權重矩陣,\(\mathbf{b}\) 是長度 \(D\) 的隱藏層偏置向量,\(a[\cdot]\) 對向量的每一項各作用一次;\(\mathbf{V}\) 是 \(D_o \times D\) 的輸出層權重矩陣,\(\mathbf{b}_0\) 是長度 \(D_o\) 的輸出層偏置向量。參數量因此是

$$ D \times (D_i + 1) \;+\; D_o \times (D + 1) $$

逐項拆解:前一項是隱藏層——每個隱藏單元要 \(D_i\) 個權重加 \(1\) 個偏置,這樣的單元有 \(D\) 個;後一項是輸出層——每個輸出要 \(D\) 個權重加 \(1\) 個偏置,這樣的輸出有 \(D_o\) 個。作業二會請你用這條式子算一個具體的網路。

六、把名字收攏,以及零件的其他選擇

本章的機制講完了,剩下一批名字。這些詞在後面十八章會反覆出現,先在這裡對齊。

這種「訊號從輸入一路往輸出走、中間不繞回頭」的結構叫前饋網路(feed-forward network)——把它畫成節點與連線,你會得到一張沒有環路的圖。前一層的每個變數都連到後一層的每個變數時,稱這一層是全連接(fully connected)的;本章的網路正是如此。含有至少一個隱藏層的全連接前饋網路,有一個歷史悠久的名字叫多層感知器(multi-layer perceptron),縮寫 MLP,你在論文裡看到這三個字母時指的就是本章這種東西。至於深度網路(deep network),定義就是隱藏層不只一個。

讀網路示意圖時有兩個約定要先知道,不然會看不懂。第一,偏置通常不畫。 嚴格說每個單元都吃一個恆為 \(1\) 的額外輸入、乘上它的偏置,但把這個節點畫出來會讓圖擠成一團,所以幾乎所有圖都省略它——不是那個網路沒有偏置,是圖沒畫。第二,權重通常不標。 圖上一條線就代表一個權重,數量太多標不下。所以看到一張乾淨的節點—連線圖時,請自己在心裡把偏置和權重補回去。

還有一個詞得說清楚:神經。這個命名來自一段早期的類比——生物神經元接收多個輸入、加總後在超過某個門檻時放電,形式上跟「加權求和之後過一個激活函數」有幾分像。但這個類比很薄弱,別當真:真實神經元的動態、時序、化學傳遞機制沒有一項在這裡出現,而網路的訓練方式在生物上也找不到對應。「神經網路」是一個歷史名稱,不是一個生物學主張。

最後回到激活函數。本課從頭到尾用 ReLU,但它不是唯一選項,也不是最早的選項。早年常用的是邏輯斯提函數(logistic sigmoid)與雙曲正切(hyperbolic tangent),兩者都是平滑的 S 形曲線,把輸入壓進一個有界的範圍。它們有一個共同的毛病叫飽和(saturation):輸入一大,曲線就趴平了,該處的導數趨近於零;而訓練靠的正是導數,導數一沒,那個單元就幾乎收不到更新訊號。ReLU 在正半邊的導數恆為 \(1\),完全沒有這個問題——這是它在實務上勝出的主因,第 7 章談梯度時會把這件事講到底。

ReLU 自己也有一個對稱的毛病,叫死亡 ReLU(dying ReLU):如果某個單元的參數跑到一個位置,使得它對訓練集裡每一筆資料的預活化都是負的,那它的輸出永遠是零,導數也永遠是零,於是它入邊上的參數再也得不到更新——這個單元就此報廢,之後怎麼訓練都救不回來。修補的辦法之一是洩漏 ReLU(leaky ReLU):把負半邊從「一律歸零」改成「乘一個很小的正數」,讓它保留一點點斜率,就不會完全斷訊。另外還有一整族把折點磨圓的平滑版本,各有各的實驗證據,本課不逐一展開。

那本課為什麼固定用 ReLU?除了它簡單、訓練上表現好之外,有一個很教學性的理由:它讓線性區可以被數。 本章從第三小節開始的一切——折點、活躍樣式、區域計數、凸多面體——都建立在「每個單元恰好有一個折點、每一段內部嚴格線性」這件事上。換成平滑的激活函數,這些結論會變成模糊的近似說法,能算的東西少很多。用一個可以數清楚的模型來建立直覺,再把直覺帶去理解那些不好數的模型,是划算的走法。

§03原書對照

原書第 3 章只有十六頁,卻把「一個隱藏層到底能做什麼」交代得相當完整。本課重排了敘事順序,先追一條被夾斷的直線,再談數量與維度。以下按頁指路,標出原書在哪幾頁講得更細。

p.26 先單獨畫出 ReLU 的形狀,緊接著並排三組不同參數值下的輸出曲線,讓你一眼看見折點位置、各段斜率與整體高度其實是三件可以分開調整的事。想確認「換一組參數就是換一個族裡的成員」這句話的具體樣貌,那張並排圖是最短路徑。

最值得翻回去的是 pp.25–28 那組拆解圖。原書把一個三單元網路的計算攤成十個小格:三條原始直線、逐一夾斷、各自加權、最後疊加成形,每一步各佔一格,而且原書把這張圖標註為互動圖。本課的圖例走的是另一條路——把折點位置與各段斜率的來源標註在同一條折線上——兩者構圖不同但互補,建議對著看。p.27 另有一段值得細讀的推導:它逐一指認每個線性區的活躍樣式,說明某一段的斜率為何恰好是特定幾項乘積的和,並點出四段斜率裡只有三段能自由決定。想對照另一種寫法的人,那一頁最省事。

p.29 交代了神經網路示意圖的畫法約定:截距要不要畫成額外節點、哪些標記通常被省略。你日後讀論文遇到的多半是省略版,先看過完整版比較不會誤讀。同一頁接到 p.30 是通用逼近定理的正式敘述,配一張「區段越多、越貼近曲線」的示意。

多輸出與多輸入分佈在 pp.30–33。原書用一張圖說明兩個輸出共用隱藏層時折點為何被綁在同一批位置(p.31),再用一組十格圖展示二維輸入怎麼被三個平面切成凸區塊(p.32),並在 p.33 說明區塊為何必然是凸的、以及維度上升時座標軸對齊的切法會給出多少塊。

真正的定量在 p.34。那裡兩張圖分別把區域數對單元數、區域數對輸入維度各畫了一次,並標出一個極端點:五百個隱藏單元配一百維輸入,可能的線性區數量大到必須用科學記號寫,而該模型的參數量以現代標準看仍屬極小。p.35 用一個三輸入兩輸出的小網路示範參數怎麼數,並分開計算斜率與偏移各佔多少。

pp.35–36 是術語彙整,配一張把各部位名稱標齊的圖;p.36 末尾有一段對「神經」這個詞的保留說明,值得專程翻一次。

註記區的資訊密度最高,也是本課刪得最多的地方。p.37 並排畫出十來種激活函數的形狀,並回顧從人工神經元、感知器到多層架構的歷史脈絡。p.38 講死亡 ReLU 的成因與各種修補版本,給出通用逼近定理的證明歸屬與適用的激活函數類別,並列出線性區數的嚴格上界公式與提出者。p.39 用一小段釐清「線性」與「仿射」的技術差別,說明本書為何沿用寬鬆用法——本課同樣沿用,但沒有解釋來由。

pp.39–40 收了十八道習題,其中幾題附星號、難度明顯高一階;想確認自己是否真的掌握折點位置與區域計數,那幾題比任何自我測驗都準。原書第 3 章對應印刷頁 pp.25–40。

§04作業和解答

作業一:手算折點、活躍樣式與各段斜率

一個一維輸入的淺層網路有兩個隱藏單元,參數為 \(w_1 = 2.0,\ b_1 = -3.0\)、\(w_2 = -1.5,\ b_2 = 1.2\),輸出層 \(v_1 = 0.5,\ v_2 = 2.0,\ b_0 = -1.0\)。請回答:(a)兩個折點各在哪裡;(b)輸入軸被切成幾段,各段的活躍樣式是什麼;(c)各段的斜率是多少;(d)\(x = 0\) 與 \(x = 3\) 時的輸出各是多少;(e)本題有一段的斜率是零,為什麼?

解答 SOLUTION

(a)折點位置是 \(-b_d/w_d\)。第 1 個單元:\(-(-3.0)/2.0 = 1.5\)。第 2 個單元:\(-1.2/(-1.5) = 0.8\)。

(b)兩個折點把輸入軸切成三段:\((-\infty, 0.8)\)、\((0.8, 1.5)\)、\((1.5, +\infty)\)。判斷誰活躍要看預活化的正負號。第 1 個單元 \(w_1 > 0\),所以 \(x > 1.5\) 時才活躍;第 2 個單元 \(w_2 < 0\),所以 \(x < 0.8\) 時活躍。三段的活躍樣式依序是 010010

(c)某段斜率等於該段活躍單元的 \(v_d w_d\) 之和。第一段只有單元 2 活躍:\(2.0 \times (-1.5) = -3.0\)。第二段沒有單元活躍:斜率 \(0\)。第三段只有單元 1 活躍:\(0.5 \times 2.0 = 1.0\)。

(d)\(x = 0\):\(z_1 = -3.0\) 被夾成 \(0\),\(z_2 = 1.2\) 通過,輸出 \(-1.0 + 2.0 \times 1.2 = 1.4\)。\(x = 3\):\(z_1 = 3.0\) 通過,\(z_2 = -3.3\) 被夾成 \(0\),輸出 \(-1.0 + 0.5 \times 3.0 = 0.5\)。(以 numpy 重算核對過:三段斜率為 -3.00.01.0,四個檢查點 \(x = 0,\,0.8,\,1.5,\,3\) 的輸出為 1.4-1.0-1.00.5。)

(e)因為在 \((0.8, 1.5)\) 這一段裡兩個單元同時不活躍:單元 1 還沒開始活躍(要到 \(1.5\) 之後),單元 2 已經停止活躍(在 \(0.8\) 之前)。兩個都被夾成零,輸出就只剩下 \(b_0 = -1.0\) 這個常數。這正好示範了第三小節那個結論:全體不活躍的區域是一條水平線。注意這一段夾在兩個折點之間而不是在遠處,說明「平坦區」不必然出現在定義域邊緣——它取決於各單元的斜的那一半朝哪個方向。

作業二:參數量與區域數的成長速度

考慮一個淺層網路,輸入維度 \(D_i = 6\)、隱藏單元數 \(D = 24\)、輸出維度 \(D_o = 3\)。(a)算出總參數量,並分開寫出隱藏層與輸出層各佔多少;(b)第五小節的程式算上界時用的就是那條二項式係數求和公式,把它寫出來是 \(\sum_{j=0}^{D_i} \binom{D}{j}\)(\(D\) 張超平面在 \(D_i\) 維空間中最多切出幾塊),請用它算出本題的上界;(c)把(b)的結果跟 \(2^{D_i}\) 與 \(2^{D}\) 比一比,說明這個上界落在什麼位置;(d)如果把隱藏單元數加倍到 \(48\),參數量與區域數上界各自變成幾倍?

解答 SOLUTION

(a)隱藏層 \(D \times (D_i + 1) = 24 \times 7 = 168\);輸出層 \(D_o \times (D + 1) = 3 \times 25 = 75\)。總計 \(243\) 個參數。

(b)\(\binom{24}{0} \dots \binom{24}{6}\) 依序是 \(1,\ 24,\ 276,\ 2024,\ 10626,\ 42504,\ 134596\),相加得 \(190051\)。

(c)\(2^{D_i} = 2^6 = 64\),\(2^{D} = 2^{24} = 16777216\)。上界 \(190051\) 確實落在兩者之間,離下界遠得多——這跟第五小節那個「大致介於 \(2^{D_i}\) 與 \(2^{D}\) 之間」的經驗說法一致。

(d)參數量變成 \(48 \times 7 + 3 \times 49 = 336 + 147 = 483\),不到兩倍(因為輸出層的偏置那一項沒有跟著加倍)。區域數上界變成 \(\sum_{j=0}^{6}\binom{48}{j} = 1 + 48 + 1128 + 17296 + 194580 + 1712304 + 12271512 = 14196869\),大約是原來的 \(74.7\) 倍。參數量只多了不到一倍,可切出的區域多了七十幾倍——這正是第五小節那個成長速度落差的小型版本。(三組數字皆以 math.comb 重算核對過。)

作業三:兩組不同的參數,一模一樣的函數

第一小節提過 ReLU 的非負齊次性:對任何 \(\alpha > 0\),\(a[\alpha z] = \alpha\, a[z]\)。(a)利用它證明:把某個隱藏單元的 \(\mathbf{w}_d\) 與 \(b_d\) 同時乘以 \(\alpha\),並把它的輸出權重 \(v_d\) 除以 \(\alpha\),整個網路的輸出完全不變;(b)寫一段程式驗證這件事;(c)這個現象對「訓練找不找得到最佳參數」意味著什麼?

解答 SOLUTION

(a)原本第 \(d\) 個單元對輸出的貢獻是 \(v_d\, a[\mathbf{w}_d^{\mathsf{T}}\mathbf{x} + b_d]\)。做了替換之後,預活化變成 \(\alpha(\mathbf{w}_d^{\mathsf{T}}\mathbf{x} + b_d)\),因為 \(\alpha > 0\),由非負齊次性得 \(a[\alpha z_d] = \alpha\, a[z_d]\);再乘上新的輸出權重 \(v_d/\alpha\),得到 \((v_d/\alpha) \cdot \alpha\, a[z_d] = v_d\, a[z_d]\),跟原本一模一樣。其他單元沒被動到,輸出偏置也沒被動到,所以整個網路的輸出對所有輸入都相同。注意 \(\alpha > 0\) 是必要條件:\(\alpha < 0\) 會把預活化的正負號翻過來,夾斷發生的位置就變了。

(b)用一個五單元、三維輸入的網路驗:

PYTHON
import numpy as np

rng = np.random.default_rng(0)
W = rng.normal(0, 1, (5, 3))       # 隱藏層權重
b = rng.normal(0, 1, 5)            # 隱藏層偏置
v = rng.normal(0, 1, 5)            # 輸出層權重
b0 = 0.7                           # 輸出層偏置
X = rng.normal(0, 1, (4, 3))       # 四筆測試輸入


def net(X, W, b, v, b0):
    return b0 + np.maximum(0.0, X @ W.T + b) @ v


alpha = 3.0                        # 就是題目裡的 α
y1 = net(X, W, b, v, b0)
y2 = net(X, alpha * W, alpha * b, v / alpha, b0)   # 同乘同除
print("原始輸出:", np.round(y1, 6))
print("縮放之後:", np.round(y2, 6))
print("最大差距:", f"{np.max(np.abs(y1 - y2)):.1e}")

實跑輸出:兩組參數算出來的四筆結果都是 2.881277 3.977799 1.784424 1.350948,最大差距 2.2e-16,也就是浮點誤差等級的零。

(c)意味著參數與函數不是一對一的。同一個函數對應到無窮多組參數——上面只用了一個 \(\alpha\),每個單元都可以各取各的,還沒算上把單元編號重新排列(\(D\) 個單元有 \(D!\) 種排法,全都給出同一個函數)。兩個直接的後果。第一,「最佳參數」這句話本身就不精確,該說的是最佳參數的一整個集合;問「訓練有沒有找到那組正確參數」是問錯了問題,該問的是「找到的函數夠不夠好」。第二,這種對稱性會讓損失地景(把損失看成參數的函數所形成的那個高維地形)出現大量互相等價的谷底,而這件事跟「為什麼下坡法在這麼高維的空間裡竟然行得通」有直接關係——那是第 20 章的題目。

§05參考資料