§01學習重點
- 說出「更深的網路至少不該比更淺的差」這個推理錯在哪裡,並指出訓練集上的表現為什麼是判斷責任歸屬的關鍵證據
- 把一層普通網路改寫成殘差區塊,並解釋為什麼「什麼都不做」在這個寫法下變成免費的預設值
- 解釋加法在回程上為什麼會產生一條係數恆為 1 的路徑,並親手量出兩種接法的梯度差了幾個數量級
- 算出 \(K\) 個區塊的殘差網路有幾條路徑、各種長度各有幾條,並說出為什麼真正撐起回程的是中短路徑
- 說明殘差網路為什麼把梯度問題從「消失」那一側推到「爆炸」那一側,並自己推出把變異數壓回去該乘多少
- 寫出批次正規化的兩個步驟,說出訓練與推論為什麼必須用不同的統計量,以及搞混會出什麼事
- 指出「批次正規化是一種正則化手段」錯在哪裡,並說明它的正則化效果實際上是從哪個性質長出來的
- 用「換來什麼、多付什麼、綁住什麼」三問評估批次正規化,並說出「綁住批次」為什麼同時是它的麻煩與它的好處
§02課程內容
一、更深卻更差:這個矛盾要往哪裡追
先把一個普通的深度網路擺出來。它把輸入一層一層往前送,第 \(k\) 層做的事情是:
逐項拆解:\(\mathbf{h}_{k-1}\) 是進入這一層的活化值,\(\mathbf{h}_k\) 是離開這一層的活化值;\(\boldsymbol\Omega_k\) 是這一層的權重矩陣,\(\mathbf{b}_k\) 是偏置;方括號裡的東西合起來叫預活化,記作 \(\mathbf{z}_k\);\(a[\cdot]\) 是活化函數,逐個元素作用。有些教材把權重矩陣寫成 \(\mathbf{W}_k\),指的是同一個東西。整個網路的參數全集記作 \(\boldsymbol\phi\),單筆資料的損失記作 \(\ell\),整批的平均損失記作 \(\mathcal{L}\)。
現在來看一個看起來滴水不漏的推理。假設你手上有一個十層的網路,訓練完之後表現不錯。你想更好,於是把它加到二十層。多出來的那十層有沒有可能讓事情變糟?照道理不該。因為只要那十層每一層都學會「輸出等於輸入、什麼都不改」——這種什麼都不改的映射叫恆等映射(identity mapping)——二十層的網路就退化成原本那個十層的網路,表現一模一樣。既然二十層網路的函數族「包含」十層網路能表示的一切,它至少不該更差。
但實測上它就是更差。
這個矛盾值得停下來想清楚,因為它把後面所有的內容都撐起來了。先問:更差是差在哪裡?如果只有測試集上更差、訓練集上反而更好,那是老問題——模型把訓練資料記住了,這在第 8 章與第 9 章談過。真正令人不安的是另一種情況:訓練集上也一樣更差。
訓練集上更差,意味著問題根本還沒走到泛化那一步。模型連手上這批看得見答案的資料都配不好,這不是「學到的東西不能推廣」,而是「壓根沒學到」。責任因此從泛化能力轉移到訓練過程本身。
再往下追一層。一個模型「學不到」有兩種可能:函數族裡根本沒有好的成員(表達力不夠),或者函數族裡有好成員但你找不到它(最佳化找不到)。前面那個推理已經替我們排除了第一種——二十層網路的函數族確實裝得下那個好成員,我們甚至能明確指出它長什麼樣(前十層照抄,後十層全部做恆等映射)。所以剩下的只有第二種:
問題不在表達力,在最佳化。那個好成員在函數族裡,梯度下降走不到它。
為什麼走不到?目前比較被接受的猜想是這樣的:網路一深,損失作為參數的函數就變得極難走。你在某一點量到的梯度,只在那一點附近極小的範圍內說得準;一旦真的照這個方向邁出一步——而實際的更新步長不是無窮小,是一個有限的數——落腳處的坡度方向可能已經跟你出發前量到的那個沒什麼關係了。導數描述的是無窮小的變動,更新用的卻是有限大小的步子,這中間的落差在淺網路上不痛不癢,在深網路上會把每一步都變成一次賭博。
⚠️ 這是猜想,不是定論。它與觀察吻合,也有一些實驗支持,但沒有人證明「深網路的損失一定難走」。本章接下來要講的做法,是在這個猜想成立的前提下設計出來的;它有效這件事本身,反過來也算是這個猜想的一點旁證,但也僅止於旁證。
二、把「什麼都不做」變成免費的預設值
上一節的推理裡藏著一個關鍵字:那十層要學會恆等映射。這件事對第一節那個寫法有多難?
看一眼式子就知道不容易。要讓 \(a[\boldsymbol\Omega_k \mathbf{h}_{k-1} + \mathbf{b}_k]\) 剛好等於 \(\mathbf{h}_{k-1}\),權重矩陣得調到一組很特別的值,偏置得剛好配合,還得跟活化函數的形狀對上。梯度下降是從隨機初始值出發、一小步一小步挪的,要它精準挪到那一組特別的值,跟要它學會任何一個別的複雜函數一樣費力——甚至更費力,因為「什麼都不做」在參數空間裡就是一個孤零零的點。
於是有一個想法:既然「什麼都不做」這麼難學,就別讓它需要學。
做法是換一種寫法。原本每一層算的是「這一層的新表示」,現在改成算「對現有表示的一個修正量」,算完再加回去:
逐項拆解:\(\mathbf{h}_{k-1}\) 是進來的東西,原封不動地出現在等號右邊;\(f_k[\cdot]\) 是這一段裡真正在做運算的部分,通常是一到兩層的線性變換加活化函數,它自己的參數記作 \(\boldsymbol\phi_k\);\(K\) 是這樣的區塊總共有幾個。整條式子就是一句話:輸出等於輸入,再加上一個修正。
這個小改動把難易度整個翻了過來。要這一段做恆等映射,現在只需要 \(f_k\) 的輸出是零。而讓一個線性變換輸出零,只要它的權重是零就行——那是初始化時本來就在附近的地方,不是參數空間裡的一個孤點。「什麼都不做」從一個要努力學的目標,變成了鬆手就會落到的預設值。
兩個名詞要分清楚。整包東西——那條旁路加上末端的相加加上支線上的運算——叫殘差區塊(residual block);而單指那條把 \(\mathbf{h}_{k-1}\) 原封不動送到相加點的旁路,叫殘差連接(residual connection),也常被叫做跳接(skip connection)。整章講的網路,就是把一串殘差區塊接起來,叫殘差網路(residual network)。
這個寫法帶來一個結構上的硬限制,實務上很麻煩:\(f_k\) 的輸出必須跟 \(\mathbf{h}_{k-1}\) 同形狀,否則那個加號沒有定義。維度一樣的時候沒問題,但真實的網路常常要在中途改變表示的形狀——把通道(channel)數變多、把空間解析度降下來。一旦形狀變了,兩邊就加不起來。常見的處理是在跳接上補一個最小的變換,只負責把形狀對齊,不做別的事;或者在形狀不變的區段內才用殘差連接,形狀要變的地方單獨處理。這算不上優雅,但它是這個寫法必須付的帳。
比喻: 想像一段鐵路。原本是單線,所有列車都得依序通過每一個站點,前面那班還在站裡做調車,後面就只能等。現在改成複線:正線變成一條直通軌,車可以一路不停地開過去;旁邊另闢一條待避線,需要加掛、卸貨、換車頭的班次才拐進去做事,做完再併回正線。這裡對應到的機制很直接——直通軌上沒有任何運算,所以「不做事」的成本是零;待避線就是 \(f_k\),進去加工,出來仍然回到同一條主線上。一列什麼都不需要處理的車,走直通軌通過,全程沒有人得先學會「怎樣才叫不處理它」。這個比喻在一個地方明確失準: 鐵路上一列車只能選一條路走,是二選一;殘差區塊裡的兩條路是同時進行、末端相加的。輸入既走了直通軌,也走了待避線,最後把兩份結果加在一起——不是選一個,是兩個都要。後面談變異數的時候,這個差別會直接變成一個要處理的麻煩。
三、回程:一條乘以 1 的路徑,和一整族路徑
前面講的都是前向。但殘差連接真正的威力在回程——也就是第 7 章講過的反向傳播,梯度從損失那一端逐層往前算的那趟。
關鍵在加法。回想微積分:如果 \(u = v + w\),那麼 \(u\) 對 \(v\) 的偏導數是 1,對 \(w\) 的偏導數也是 1。加法不會放大也不會縮小任何一邊的敏感度。落到殘差區塊上,這件事的後果是:
逐項拆解:等號左邊是「\(\mathbf{h}_k\) 對 \(\mathbf{h}_{k-1}\) 的敏感度」,寫成矩陣的形式;右邊第一項 \(\mathbf{I}\) 是單位矩陣,也就是對角線全是 1、其餘全是 0 的那個矩陣,它來自直通軌那一條路——輸入原封不動地出現在輸出裡,敏感度就是 1;右邊第二項是待避線上那段運算貢獻的敏感度。
⚠️ 這裡有一個容易看走眼的符號。第 7 章用過 \(\mathbb{I}[\cdot]\)(黑板粗體的 I,後面接方括號)當指示函數,意思是「括號裡的條件成立就取 1,否則取 0」。本章的 \(\mathbf{I}\) 是粗體羅馬的 I,後面不接括號,指的是單位矩陣。兩個符號長得像,意思完全不同:一個是會回傳 0 或 1 的判斷,一個是一整個矩陣。看到後面有沒有方括號就能分辨。
這條式子接上了第 7 章留下的一個伏筆。那一章講鏈鎖法則時說過:一旦某個中間量同時被送往兩個地方,總導數就得沿每一條路各算一次再相加,並預告了第 11 章會出現這種情形。這裡就是了——\(\mathbf{h}_{k-1}\) 同時走了直通軌與待避線,所以回程上它拿到的是兩份導數的和。
那個 \(\mathbf{I}\) 就是本章第一個教學核心。 純序列網路的回程只有一條路,梯度從最後一層走到第一層,要連續乘上每一層的敏感度矩陣;只要那些矩陣平均而言把東西縮小,連乘幾十次之後就會指數地趨近零——這正是第 7 章講過的梯度消失。殘差網路的回程長得不一樣:每一層的敏感度都是「1 加上其他項」,連乘之後展開來,裡面永遠含有一項是「1 乘 1 乘 1……」,也就是一條係數恆為 1 的直達路徑。不管中間堆了幾個區塊,這條路徑都在,而且不被削弱。
⚠️ 精確一點:跳接讓梯度不容易消失,不是讓梯度「等於 1」,也不是讓梯度「保持不變」。那條係數為 1 的路徑只是總和裡的其中一項,其他項照樣會隨深度變化,總梯度當然還是會變。把話說成「跳接讓梯度不變」就是講錯了。
差多少,量一次就知道。下面這段程式拿同一組權重、同一個起點,跑兩種接法:一種是純序列,一種是加了跳接。權重的變異數刻意設成 \(1.4/D\),比那個能維持尺度的值略低一點——這模擬的是「初始化沒調準」的常見處境,而不是刻意找碴。
import numpy as np
rng = np.random.default_rng(0)
D, K = 64, 50
# 支線權重:變異數刻意設成 1.4/D,略低於能維持尺度的那個值
Om = [rng.normal(0, np.sqrt(1.4 / D), (D, D)) for _ in range(K)]
x = rng.normal(0, 1, D)
def sweep(skip):
h, tape = x.copy(), []
for W in Om: # 前向:記下每一層的開關狀態
z = W @ h
on = (z > 0).astype(float)
tape.append((W, on))
h = h + z * on if skip else z * on
g = np.ones(D) # 從輸出端灌一個單位梯度回去
mag = [np.linalg.norm(g)]
for W, on in reversed(tape): # 回程
side = W.T @ (g * on) # 走待避線的那一份
g = g + side if skip else side # 直通軌把原梯度原封不動帶過來
mag.append(np.linalg.norm(g))
return np.array(mag)
for tag, skip in (("純序列", False), ("加跳接", True)):
m = sweep(skip)
print(tag, " ".join(f"第{k}層 {m[k]:.3e}" for k in (0, 10, 25, 50)))實跑輸出:純序列那條在第 0、10、25、50 層分別是 8.000e+00、3.593e+00、4.890e-01、3.938e-03;加跳接那條是 8.000e+00、8.972e+02、7.602e+05、6.128e+10。兩者從同一個 8.000 出發,走完五十層之後差了十三個數量級。
請注意那個回程更新的寫法:g = g + side。直通軌沒有動任何手腳,它就是把上一步的梯度原封不動地帶過來,再加上待避線那一份。整段程式裡沒有任何一行在「保護」梯度,梯度不衰減完全是加法這個結構自己帶來的。
現在把「一條直達路徑」擴大成「一整族路徑」。 把遞迴定義往回代一層看看:\(\mathbf{h}_2 = \mathbf{h}_1 + f_2[\mathbf{h}_1]\),而 \(\mathbf{h}_1 = \mathbf{h}_0 + f_1[\mathbf{h}_0]\),代進去就是 \(\mathbf{h}_2 = \mathbf{h}_0 + f_1[\mathbf{h}_0] + f_2[\mathbf{h}_0 + f_1[\mathbf{h}_0]]\)。三項:什麼都沒經過的、只經過第一段的、經過第二段(而它的輸入又分成經過與沒經過第一段兩種)的。一直代下去,輸出會變成「輸入,加上一大堆長短不一的子網路」的和。
路徑的數目算起來很單純。每個區塊都給你兩個選擇——走直通軌,或者進待避線——而且各區塊的選擇互不影響。所以 \(K\) 個區塊就有 \(2^K\) 條路徑。再問細一點:經過 \(j\) 條待避線的路徑有幾條?那等於「從 \(K\) 個區塊裡挑 \(j\) 個進待避線」的方法數,也就是二項式係數(binomial coefficient)\(\binom{K}{j}\)。
這兩件事可以直接數出來驗證,順便看一個更有意思的東西:
from itertools import product
from math import comb
K = 6 # 區塊數:每塊可選「走直通軌」或「進待避線」
routes = list(product([0, 1], repeat=K))
print("列舉出的路徑數:", len(routes), "/ 2 的 K 次方:", 2 ** K)
dist = [0] * (K + 1)
for r in routes:
dist[sum(r)] += 1
print("依經過幾條待避線分組:", dist)
print("二項式係數對照: ", [comb(K, j) for j in range(K + 1)])
# 假設每經過一條待避線,回程梯度就縮成原本的 0.2 倍(此值為說明而設)
K2, decay = 50, 0.2
weight = [comb(K2, j) * decay ** j for j in range(K2 + 1)]
total = sum(weight)
share = [w / total for w in weight]
peak = max(range(K2 + 1), key=lambda j: share[j])
print(f"\nK={K2}:條數最多的路徑長度 = {K2 // 2}")
print(f"對回程貢獻最大的路徑長度 = {peak}")
print("累積貢獻:經過 ≤5 條 {:.1%},≤12 條 {:.1%},≥25 條 {:.2e}".format(
sum(share[:6]), sum(share[:13]), sum(share[25:])))實跑輸出:六個區塊列舉出 64 條路徑,等於 \(2^6\);依經過幾條待避線分組是 [1, 6, 15, 20, 15, 6, 1],與二項式係數 [1, 6, 15, 20, 15, 6, 1] 完全一致。
後半段那個假設值得說明。我假設每經過一條待避線,回程的梯度就縮成原本的 0.2 倍——這個數字是為了說明而設的,不是量出來的。在這個假設下,五十個區塊時條數最多的路徑長度是 25,但對回程貢獻最大的路徑長度是 8;經過五條以下待避線的路徑合計貢獻 13.9%,十二條以下合計 93.7%,而經過二十五條以上的那一大群路徑——它們在條數上是多數——合計只貢獻 5.75e-08。
條數的分布和貢獻的分布,峰值落在完全不同的地方。這個落差帶出兩件事。
第一,關於「集成」這個說法。有人把上面那個展開式詮釋成:一個殘差網路其實是一大群深度不一的小網路湊在一起做事,很像集成(ensemble)。這個詮釋抓到了展開式的形狀,用它來建立直覺沒問題,但它是一種詮釋,不是定義,而且有一處要留心:真正的集成,各成員的參數是各自獨立的;這裡所有路徑共用同一批權重,你動其中一個 \(\boldsymbol\Omega\),成千上萬條路徑會同時改變。所以它們不是各自獨立的投票者。
第二,關於「加深」到底加了什麼。直覺上,多疊十個殘差區塊等於把計算鏈拉長十層。但從路徑的角度看,你做的事情是把路徑的家族擴大——新增的路徑裡有很長的,也有很短的,而真正在回程上出力的那批,長度並沒有跟著等比例地變長。所以「殘差網路很深」跟「殘差網路的計算鏈很長」不是同一句話,前者描述架構寫了幾層,後者描述訓練時實際被用起來的是多長的鏈。這個區別在最後一節談「殘差連接為什麼有效」時還會回來。
四、殘差網路自己製造的麻煩:尺度會指數地漲
上一節那張圖裡,加跳接的曲線一路往上衝到 6.13e10。那不是畫錯,那是殘差連接自己帶來的新問題,而且前向與回程都有。這一節先講前向,因為前向比較好推。
先補一塊多數人沒學過的統計事實。你大概知道期望值可以相加:兩個量相加,平均值等於各自平均值之和。變異數就不一定了——它只有在兩個量彼此不相關的時候才可加。寫成式子:若 \(u\) 與 \(v\) 不相關,則 \(\mathrm{Var}[u+v] = \mathrm{Var}[u] + \mathrm{Var}[v]\)。變異數量的是「這個量平常散得多開」,兩個各自亂跳、彼此無關的量疊起來,散開的程度就是兩份加起來。(如果兩者正相關,會比兩份加起來更大;負相關則更小。這一點等一下就會派上用場。)
再回想一次初始化在做什麼。第 7 章講過的初始化——例如把權重的變異數設成「2 除以扇入維度」的那個 He 初始化——它想達成的目標可以用一句話講完:讓活化值的尺度過一層之後大致不變。 太小,訊號逐層縮到零;太大,逐層炸開。整套設計就是在調一個平衡。
現在把殘差連接接上去,看看那個平衡怎麼了。假設 \(\mathbf{h}_{k-1}\) 的變異數是 \(v\)。支線 \(f_k\) 經過妥當的初始化之後,輸出的變異數大約也是 \(v\)——這正是初始化想達成的效果。兩者相加:
每過一個區塊,變異數就翻一倍。過 \(K\) 個區塊,就是 \(2^K\) 倍。這不是慢慢累積,是指數成長。初始化辛苦調出來的那個平衡,被「相加」這個動作一次就打破了——它當初是為「一條鏈」設計的,現在有兩條路匯進同一個點。
量一下。下面這段程式讓同一批輸入走三種網路:完全不加跳接的、加跳接的、加跳接又在支線前面做正規化的(第三種是下一節的主題,先擺進來當對照)。
import numpy as np
rng = np.random.default_rng(0)
B, D, K = 512, 64, 20 # 批次大小、寬度、區塊數
H0 = rng.normal(0, 1, (B, D))
Om = [rng.normal(0, np.sqrt(2.0 / D), (D, D)) for _ in range(K)]
def norm(A, eps=1e-5): # 每個單元各自用這一批的統計量
mu = A.mean(axis=0)
sd = A.std(axis=0)
return (A - mu) / np.sqrt(sd ** 2 + eps)
def grow(mode):
H, out = H0.copy(), [H0.var()]
for W in Om:
inp = norm(H) if mode == "bn" else H
side = np.maximum(inp @ W.T, 0.0) # 待避線:正規化(可選)→ 線性 → 活化
H = side if mode == "plain" else H + side
out.append(H.var())
return out
for mode, tag in (("plain", "無跳接 "), ("skip", "有跳接 "), ("bn", "跳接+正規化")):
v = grow(mode)
print(tag, " ".join(f"第{k}塊 {v[k]:8.2f}" for k in (0, 5, 10, 20)))實跑輸出:無跳接那條在第 0、5、10、20 個區塊分別是 1.00、0.57、0.66、0.23,一直待在 1 附近;有跳接那條是 1.00、44.11、9443.33、328840231.75;跳接加正規化那條是 1.00、4.65、8.67、19.47。
有跳接那條實測的成長率大約是每個區塊 2.67 倍,比「變異數相加」預測的 2 倍還多一點。多出來的零頭從哪來?最順手的嫌疑犯是前面那個但書:\(\mathbf{h}_{k-1}\) 與 \(f_k[\mathbf{h}_{k-1}]\) 並不是不相關的兩個量——後者是前者算出來的,如果兩者正相關,相加後的變異數就會比兩份之和大。這個解釋聽起來剛好補上缺口,但控制變因一驗,它就站不住。把每一塊支線輸出的所有元素整批打亂再加回主幹——數值的分布原封不動,只把它跟輸入的相關性打掉——成長率只從 2.67 掉到約 2.62,幾乎沒動;反過來,保留相關結構、只把支線輸出的變異數重新縮放到與輸入相等,成長率立刻掉回約 2.12。兩個對照擺在一起,答案就攤開了:超額主要不是相關性造成的,是支線輸出的變異數其實大於 \(v\)(等變異數那組實驗裡剩下的一小截,才是相關性的真實貢獻)。為什麼會大於?因為妥當的初始化真正保住的是「每個數平方後取平均」的那種整體大小——統計上叫二階矩,它恆等於變異數加上平均值的平方。輸入的平均值是零的時候,二階矩就是變異數,「輸出的變異數大約也是 \(v\)」才成立;但跳接一路加上去的增量全是非負的,主幹的平均值愈墊愈高,二階矩裡平均值那一份愈長愈大——支線輸出的尺度跟著二階矩走,它的變異數於是被抬到 \(v\) 之上。活化函數確實有份,但它走的是「增量非負、平均值累積」這條通道,不是「兩分支正相關」那條。這也提醒你兩件事:2v 那條式子是估計,不是恆等式;而一個聽起來能解釋數字的機制,跟真的造成那個數字的機制,中間隔著一組對照實驗。
指數成長會撞到一堵很實在的牆:浮點數的表示範圍。一個區塊乘 2.67,一百個區塊就是 2.67 的一百次方,大約 \(4.5\times10^{42}\)——已經超出單精度浮點數(深度學習訓練常用的格式,上限約 \(3.4\times10^{38}\))能表示的範圍。數值一旦溢出,後面全是無效值,訓練不是變慢而是直接壞掉。
回程也一樣。上一節那條衝到 6.13e10 的曲線就是它——每往回走一層,梯度乘上「1 加上其他項」,這個和的期望值大於 1,於是回程的梯度也指數地往上跑。前向的數值與回程的梯度是兩件不同的事(前者是活化值,後者是導數),但在殘差網路裡兩者都會指數變化,讀的時候要分清楚哪句話在講哪一個。
這裡有一個容易被忽略的反轉,值得寫死:
殘差網路沒有消滅梯度爆炸與梯度消失這對問題,它把問題從「消失」那一側推到了「爆炸」那一側。
說「殘差網路解決了梯度問題」是講錯了。它換掉的是問題的方向。而爆炸這一側之所以比消失那一側好對付,是因為爆炸有現成的解法,消失沒有——這就是接下來要處理的事。
最直接的解法:既然每過一個區塊變異數就翻倍,那就在區塊輸出上乘一個固定的係數 \(\lambda\),把它壓回去。要壓多少?自己推一次:乘上 \(\lambda\) 之後變異數變成 \(\lambda^2 \cdot 2v\),我們要它等於 \(v\),所以 \(\lambda^2 = 1/2\),也就是 \(\lambda = 1/\sqrt{2} \approx 0.707\)。(平方是因為變異數的單位是「量的平方」,乘上常數時常數要跟著平方。)
這招管用,而且便宜——沒有多出任何參數。但它只處理了數值尺度。第一節那個損失地形難走的問題,它一點都沒碰。 實務上更常見的做法能同時處理這兩件事,代價是要多付一些東西。
五、批次正規化:它在做什麼,以及它在殘差網路裡的角色
批次正規化(batch normalization)做的事分兩步,順序不能顛倒。
第一步:用當前這一批資料的統計量,把數值拉回標準尺度。 訓練時資料是一批一批餵進去的,這一批記作 \(\mathcal{B}\)。對第 \(j\) 個隱藏單元,把這一批裡所有樣本在該單元上的預活化值收集起來,算它們的平均與變異數:
第二步:用兩個可以學的參數,把它縮放平移回去。
逐項拆解:\(z_{ij}\) 是第 \(i\) 筆資料在第 \(j\) 個單元上的預活化值;\(\mu_j\) 與 \(\sigma_j\) 是這一批在該單元上的平均與標準差;\(\epsilon\) 是一個很小的正數,加在根號裡防止某個單元在這一批剛好完全沒有變化(標準差為零)時除以零;\(\gamma_j\) 是可以學的縮放參數,\(\delta_j\) 是可以學的平移參數。分數那一段把數值拉成平均 0、標準差 1;乘上 \(\gamma_j\) 再加上 \(\delta_j\),就把「該用什麼尺度、該擺在哪裡」交還給訓練決定。
⚠️ 記法提醒:絕大多數文獻把平移參數寫成 \(\beta\),本站改用 \(\delta\),因為 \(\beta\) 在第 6 章已經指給動量的衰減率了。你去讀論文或翻框架文件時看到的 \(\gamma\)/\(\beta\),跟這裡的 \(\gamma\)/\(\delta\) 是同一組東西。縮放參數 \(\gamma\) 與統計量 \(\mu\)、\(\sigma\) 則與領域標準寫法一致。
有幾個細節容易讀漏。每個隱藏單元各自算自己的一組統計量,不是整層共用一組——所以參數量是每個單元兩個。兩步不是彼此的逆運算:第一步壓扁用的是當前這批資料的統計量,第二步拉開用的是訓練出來的參數,兩者來源不同,所以這不是白做工。如果一定要把它讀成一句話:批次正規化不是把資訊丟掉,而是把「尺度」這件事從權重手上拿走,交給兩個專職的參數管。
訓練與推論是兩種模式,這是實務上最常見的 bug 來源。 訓練時你手上有一整批資料,統計量現算就好。推論時常常一次只送一筆進去——一筆資料的標準差是零,這個算法直接垮掉。標準做法是:訓練期間一路把每批的統計量累積成一組移動平均(moving average),推論時改用這組凍結下來的值,不再看當前的輸入。搞混的後果很具體:模型在驗證時表現正常、上線後亂跳,或者反過來,因為兩種模式算出來的東西根本不一樣。
這件事以及它的一個重要後果,可以一次跑出來看:
import numpy as np
rng = np.random.default_rng(0)
D, eps = 4, 1e-5
gamma = np.array([1.0, 1.0, 1.0, 1.0]) # 縮放,初始化為 1
delta = np.array([0.0, 0.0, 0.0, 0.0]) # 平移,初始化為 0
run_mu = np.zeros(D) # 推論用的累積統計量
run_sd = np.ones(D)
def bn_train(A):
global run_mu, run_sd
mu, sd = A.mean(axis=0), A.std(axis=0)
run_mu = 0.9 * run_mu + 0.1 * mu # 訓練期間一路累積起來
run_sd = 0.9 * run_sd + 0.1 * sd
return gamma * (A - mu) / np.sqrt(sd ** 2 + eps) + delta
def bn_infer(A): # 推論:統計量凍結,不看同批的鄰居
return gamma * (A - run_mu) / np.sqrt(run_sd ** 2 + eps) + delta
focus = np.array([2.0, -1.0, 0.5, 3.0]) # 我們要追蹤的那一筆
for step in range(60): # 模擬 60 個訓練批次
bn_train(np.vstack([focus, rng.normal(1.0, 2.0, (31, D))]))
quiet = np.vstack([focus, rng.normal(1.0, 0.3, (7, D))]) # 同批夥伴差異小
noisy = np.vstack([focus, rng.normal(1.0, 6.0, (7, D))]) # 同批夥伴差異大
print("訓練模式・同批安靜:", np.round(bn_train(quiet)[0], 3))
print("訓練模式・同批吵雜:", np.round(bn_train(noisy)[0], 3))
print("推論模式(凍結) :", np.round(bn_infer(focus[None, :])[0], 3))
W = rng.normal(0, 1, (D, D)) # 尺度不變性:權重整體放大 10 倍
base = np.vstack([focus, rng.normal(1.0, 2.0, (15, D))]) @ W.T
print("放大前後的正規化輸出最大差:",
f"{np.abs(bn_train(base) - bn_train(10 * base)).max():.2e}")實跑輸出:同一筆資料 focus,跟差異小的夥伴同批時輸出是 [2.352, -2.435, -1.711, 2.491],跟差異大的夥伴同批時是 [0.706, -0.438, -0.302, 0.885],推論模式下則是 [0.512, -0.891, -0.259, 1.035]。三組數字兩兩不同,而輸入完全一樣。這就是批次正規化最要緊的性質:一筆資料的輸出,取決於跟它同一批的其他資料是誰。 最後一行印出 1.25e-06——把權重整體放大十倍,正規化後的輸出幾乎沒變(差的那一點來自分母裡的 \(\epsilon\),它不隨資料一起放大)。這個性質叫尺度不變性,第六節評估代價時會用到。
現在講它在殘差網路裡的角色,這是本章第二個教學核心。 標準做法是把批次正規化擺在殘差區塊的開頭,也就是待避線的入口,並且把 \(\delta\) 初始化為 0、\(\gamma\) 初始化為 1。
效果是這樣的:每個區塊的支線輸入被拉回單位尺度,於是支線輸出的尺度也被穩住——不管進來的 \(\mathbf{h}_{k-1}\) 已經漲到多大,待避線吐出來的東西大小差不多是固定的。原本每過一個區塊變異數是「翻一倍」,現在變成「加上一個大致固定的量」。指數成長被壓成線性成長。
上一節那組實跑數字正是這件事:跳接加正規化那條是 1.00、4.65、8.67、19.47,二十個區塊之後停在 19.47 左右,平均每個區塊只往上加 0.92。而同樣二十個區塊,沒有正規化的那條已經到了三億二千多萬。線性成長是可以忍受的——一百個區塊也不過漲到一百來倍,浮點數綽綽有餘。
這裡冒出一個副產品,它比上面那件事更有意思。初始化的時候,前面的區塊已經把 \(\mathbf{h}\) 的尺度累積得比較大,而每個區塊的支線輸出大小固定;愈往後的區塊,它加上去的那一份佔整體的比例就愈小。換句話說:剛初始化好的深層殘差網路,後段區塊對輸出的相對貢獻很小,這個網路實際上比它的層數看起來要淺。
但 \(\gamma\) 是可以學的。訓練過程中,如果加深有好處,網路可以自己把後段的縮放參數調上來,讓後面那些區塊的貢獻變重;如果沒好處,就讓它們維持在接近恆等的狀態。於是有效深度(effective depth)不是架構寫死的數字,而是訓練出來的結果。這一句是本章最值得帶走的東西之一,而且請注意它是字面意義、不是修辭:\(\gamma\) 變大,後段區塊的相對貢獻就真的變大。
比喻: 回到那條有待避線的鐵路。現在在每條待避線的入口加一個調度中心:進來的車流不管有多密、多稀,先按當日的實測密度整批重排成標準的間距,再照兩個可調的參數放行——一個管間距要放大或壓縮多少,一個管整體要往前或往後挪多少。這樣一來,不管前面幾站塞成什麼樣,進入這條待避線的車流密度都是可控的,加工完併回主線時也就不會一路愈併愈擠。這對應的正是「支線輸入被拉回標準尺度,於是支線輸出的尺度被穩住」。這個比喻在一個地方明確失準: 真實的調度中心看得到全線的狀況,可以按全局來安排;批次正規化只看得到當前送進來的這一批,看不到整個資料集。這不是實作上的偷懶,是它的定義如此——也正因如此,同一列車在不同的批次裡會被排成不同的間距。這件事就是下一節的主題。
六、換來什麼、多付什麼、綁住什麼
評估批次正規化時,不要列一張優點清單再列一張缺點清單。它最要緊的那個性質同時是麻煩的來源和好處的來源,拆成兩張清單會讓你看不出那是同一件事。改問三個問題。
換來什麼。 除了前面講的數值尺度穩住之外,還有一項實務上最直接的收益:損失作為參數的函數變得比較平順,於是可以用比較大的學習率。同樣的訓練預算,步子邁得大,走得就遠。
多付什麼。 每個隱藏單元多兩個參數,這筆帳很小。比較微妙的是上一節那個尺度不變性帶來的後果:既然把某一層的權重與偏置整體乘上十倍,經過正規化之後輸出不變(我們實測的差距是 1.25e-06,那是 \(\epsilon\) 造成的,不是真的變化),那就表示有一大族長得不一樣的權重會產生一模一樣的網路。參數空間裡多了一整條「怎麼走都不改變任何東西」的方向。這種多餘的自由度是要付代價的——它讓權重的絕對大小不再有意義,也讓一些以權重大小為前提的做法(例如第 9 章的正則化裡那些懲罰權重大小的手法)在這一層失去原本的效果。
綁住什麼。 這一條最要緊:同一批裡的樣本被綁在一起了。 一筆資料的輸出,取決於跟它同批的其他資料是誰——這不是近似,是定義,我們在上一節用三組數字實測過。在此之前,「同樣的輸入必得同樣的輸出」是預設成立的;批次正規化把這個前提拿掉了。
從這一個根因,同時長出兩件方向相反的事。
麻煩的一面:批次太小的時候,統計量本身抖得厲害,正規化反而在製造噪音;跨機器訓練時每台機器只看得到自己那一份資料,統計量要不要湊、怎麼湊,變成一個要處理的工程問題;批次內部差異太大時,算出來的統計量對誰都不準。還有一個連帶後果值得記住:一旦用了批次正規化,批次大小就不再只影響訓練速度,它會影響訓練的結果。
有用的一面:批次是隨機組成的,所以統計量會隨著每次抽到哪些同伴而抖動;同一筆資料在不同批次裡被正規化成不同的值,等於在訓練過程裡注入了雜訊。而在訓練裡注入雜訊,正是第 9 章講過的正則化會做的事之一。所以批次正規化確實帶來了一些正則化效果。
⚠️ 但這裡有一個要正面拆掉的誤解:批次正規化不是一種正則化手段。 它的主要作用是穩住前向的數值與回程的梯度尺度;正則化效果是「統計量會隨批次組成而抖」這個性質的副產品。這個區別不是咬文嚼字——如果你把它當成正則化工具,你就會在模型過擬合時想「多加幾層批次正規化」,那是搞錯了對象;你也會在把批次調大、統計量變穩之後,困惑於「為什麼正則化效果不見了」。第 9 章的那些手法是為了正則化而設計的,批次正規化不是。
同一個加法動作,還能往哪幾個方向變形。 這裡只做概覽,名字給你,細節自己往下追。
*改「怎麼合併」*:不用相加,改成把前後的表示接在一起,這叫串接(concatenation)。好處是資訊完整保留,代價是尺寸會愈疊愈大,所以通常再用一次很細窄的卷積把尺寸壓回來。⚠️ 相加與串接常被混為一談,但它們差很多:相加維持形狀不變,串接會讓形狀愈長愈大。密集連接的網路(DenseNet)走的是這條路。
*改「支線裡放什麼」*:待避線上不必只有一層。一個常見的省參數做法叫瓶頸(bottleneck):先用細窄的卷積把通道數壓下來,在低通道數的狀態下做完主要的運算,再撐回原本的通道數。用少很多的參數,涵蓋同樣大的範圍。
*改「跳多遠」*:跳接不必只跨一個區塊。把它拉長,從網路前段直接接到後段的對稱位置,就得到影像分割那一類的對稱結構(U-Net 是最常被提到的一個)——前段負責看得廣,後段負責還原細節,而細節不必靠中間那個被壓縮過的表示硬記,可以直接從前段拉過來。
順帶一提,換一組軸來取統計量,就得到另一族正規化做法;它們彼此的差別就在「在哪些軸上做平均」。這些主要是後面章節的題材,這裡不展開。至於卷積網路裡批次正規化的統計量要在哪些軸上取:除了批次這一軸之外,還要跨同一個特徵圖上的所有空間位置一起算——因為同一組卷積核在各個位置重複使用,那些位置本來就該共用一組統計量。
最後,誠實地收一個尾:殘差連接為什麼有效,目前沒有定論。
最直覺的解釋是「它讓網路可以更深,而更深就更好」。但至少有兩類證據跟這個單一解釋衝突。第一類:在參數量相當的條件下,比較淺但比較寬的殘差網路,有時候表現勝過比較深比較窄的——如果好處純粹來自深度,這不該發生。第二類:從路徑的角度量下去會發現,很長的那些路徑上梯度其實傳不太動,真正在訓練裡出力的是中短路徑——那麼「加深」實際加到的東西,跟「讓計算鏈變長」並不是同一回事。
也有人認為關鍵在損失地形變得比較好走,或者在於它改變了網路偏好哪一類解、進而影響泛化。這些說法各有實驗支持,但都還沒有到能排除其他解釋的程度。這一章能給你的最誠實的說法是:這個結構確實有效,效果大到改變了整個領域怎麼設計網路;至於為什麼有效,是一個還開著的問題。第 20 章會回頭處理這一類問題。
把整章壓成一句話:殘差連接不是「讓網路變深」的技巧,是把「不變」改成預設值的結構改寫;而批次正規化不是附加的正則化配件,是為了收拾這個改寫在數值尺度上造成的後果。
§03原書對照
原書第 11 章從「把層一個接一個串起來」這個最樸素的寫法談起(p.186),先把一般網路改寫成一層套一層的巢狀函數形式,再一路推到殘差區塊、批次正規化與幾種實務架構。以下按原書的頁次,點出它鋪陳得比較細、值得進階讀者翻回去看的地方。
深度增加卻退步的實證。 原書 pp.187–188 擺出一組影像分類的對照實驗:層數更多的卷積網路在測試集上輸給層數少的,而且訓練集上也一樣輸——這一點被拿來當作「問題出在訓練而不是泛化」的直接證據。同樣這兩頁還放了一組單輸入單輸出網路的梯度量測圖,用自相關函數把淺網路與深網路的梯度隨輸入變化的差別畫出來。想看那個關於損失地形的猜想是怎麼被實證撐起來的,這兩頁的配圖看一次比讀十段文字有效。
展開式與路徑觀點。 原書 pp.189–191 把殘差網路的遞迴定義逐行代換成一條長式子,並用一張圖畫出展開後的樣子,指出輸出等於輸入加上數個較小網路的和;同一組頁面也列出對應的導數展開式,逐項對上每一條路徑。p.191 另有一張圖並排比較殘差區塊裡動作順序的幾種擺法,並說明為什麼整個網路的開頭那一步通常不做成殘差區塊。
變異數與批次正規化。 原書 p.192 交代殘差網路在前向時的變異數會怎麼變化,並提出一個用固定係數縮放的補救辦法;pp.193–194 給出批次正規化的完整式子、可學參數的數量怎麼算、卷積網路的統計量要在哪些軸上取,以及測試階段沒有批次時的處理方式。p.193 那張變異數示意圖把三種情況畫在一起對照,一眼就看得出差別。p.194 隨後逐項列出批次正規化的代價與好處,每一項都各自給了理由。
架構巡覽。 pp.195–198 依序介紹幾個把殘差連接用起來的架構:影像分類用的殘差網路與它省參數的瓶頸版本、把前面各層輸出接在一起而不是相加的做法,以及編碼器與解碼器對稱相連的分割網路與姿態估計網路。每個架構都附了整張結構圖與參數量的討論。
為什麼有效。 原書 p.199 列出兩項與「單純因為變深」相衝突的證據,並給出目前比較被接受的看法;p.201 用兩張損失曲面圖並排說明有跳接與沒跳接的差別,並說明作者為什麼認為這件事跟泛化有關。原書 pp.199–201 另有一段整章總結,把從「加深反而變差」到「批次正規化」這條因果鏈重新串了一次,適合讀完整章之後回頭掃一遍。
章末註記。 原書 pp.202–205 的密度很高:殘差架構兩個版本之間的演變、把殘差網路看成集成的實驗證據、專為殘差架構設計的幾種正則化、批次正規化的多種變體與它們各自在哪些場合失效,以及不靠批次正規化也能訓練很深的替代路線。想順著追論文的人,這幾頁等於一份帶評論的書目。習題排在 pp.205–206,其中一題要求把批次正規化的前向與反向逐步寫成程式。
原書第 11 章對應印刷頁 pp.186–206。
§04作業和解答
作業一:把恆等映射的難度差距講清楚
(a)對第一節那個寫法 \(\mathbf{h}_k = a[\boldsymbol\Omega_k \mathbf{h}_{k-1} + \mathbf{b}_k]\),假設活化函數是 \(a[z] = \max(0, z)\),且輸入 \(\mathbf{h}_{k-1}\) 的每個元素都是正的。要讓這一層做出恆等映射,\(\boldsymbol\Omega_k\) 與 \(\mathbf{b}_k\) 該取什麼值?(b)如果不能假設輸入全為正,(a)的答案還成立嗎?為什麼?(c)對殘差區塊 \(\mathbf{h}_k = \mathbf{h}_{k-1} + f_k[\mathbf{h}_{k-1}, \boldsymbol\phi_k]\),要它做出恆等映射,需要什麼條件?(d)比較(a)(c)兩組條件,說明「難易度被結構翻轉」具體指的是什麼。
解答 SOLUTION
(a)取 \(\boldsymbol\Omega_k = \mathbf{I}\)(單位矩陣)、\(\mathbf{b}_k = \mathbf{0}\)。這時預活化就是 \(\mathbf{h}_{k-1}\) 本身,而它每個元素都是正的,\(\max(0, z)\) 對正數不做任何事,所以輸出等於輸入。
(b)不成立。輸入若有負的元素,那些元素會被 \(\max(0, \cdot)\) 砍成零,輸出就不等於輸入了。要讓這種活化函數在任意輸入下都做出恆等映射,單靠一層做不到——這也順帶說明了為什麼「多疊的層學會什麼都不做」這件事,比第一節那個推理講得輕鬆的時候還要更難一些。
(c)只需要 \(f_k\) 的輸出恆為零。如果 \(f_k\) 的最後一步是一個線性變換,把那個變換的權重與偏置都設成零就夠了;不論輸入是什麼、活化函數是哪一種,輸出都是零,於是 \(\mathbf{h}_k = \mathbf{h}_{k-1}\)。
(d)(a)要求權重矩陣精確地等於一個特定的矩陣,而且這個解還依賴輸入的正負性質;(c)只要求權重是零,這是初始化時本來就在附近的地方,而且對任何輸入、任何活化函數都成立。前者是參數空間裡一個要瞄準的孤點,後者是一個鬆手就會落到的預設值。這就是「難易度被結構翻轉」的具體內容。
作業二:把變異數的成長算清楚,並自己推出補救係數
假設某個殘差網路有 \(K\) 個區塊,輸入的變異數是 1,每個支線的輸出變異數都與該區塊輸入的變異數相等,且支線輸出與區塊輸入不相關。(a)寫出第 \(k\) 個區塊輸出的變異數。(b)假設 \(K = 60\),變異數會成長到多少?用 10 的次方表示,並判斷這個數字對雙精度浮點數(可表示到大約 \(10^{308}\))是不是問題。(c)若在每個區塊輸出上乘一個固定係數 \(\lambda\),要讓變異數維持在 1,\(\lambda\) 該取多少?(d)本章第四小節的實跑輸出顯示,加跳接時實際的成長率約為每區塊 2.67 倍,比理論的 2 倍大。請解釋差距從哪來,並說明這對(c)的答案有什麼影響。
解答 SOLUTION
(a)\(\mathrm{Var}[\mathbf{h}_k] = \mathrm{Var}[\mathbf{h}_{k-1}] + \mathrm{Var}[f_k] = 2\,\mathrm{Var}[\mathbf{h}_{k-1}]\)。從變異數 1 出發遞推,得 \(\mathrm{Var}[\mathbf{h}_k] = 2^k\)。
(b)\(2^{60}\)。取以 10 為底的對數:\(60 \times \log_{10} 2 \approx 60 \times 0.301 = 18.06\),所以大約是 \(10^{18}\)。這個數字本身還在雙精度浮點數的範圍內,不會溢出。但兩件事要留意:一是六十個區塊在現代標準下不算特別深,區塊數再翻幾倍就會出事——\(K = 1024\) 時指數就到 \(10^{308}\) 附近了;二是即使沒有溢出,這麼大的動態範圍會嚴重侵蝕有效位數,數值誤差會先把訓練搞壞。
(c)乘上 \(\lambda\) 之後,區塊輸出的變異數變成 \(\lambda^2 \times 2\,\mathrm{Var}[\mathbf{h}_{k-1}]\)。要它等於 \(\mathrm{Var}[\mathbf{h}_{k-1}]\),需要 \(\lambda^2 \times 2 = 1\),即 \(\lambda = 1/\sqrt{2} \approx 0.707\)。
(d)題目的推導用了兩個假設——支線輸出的變異數與輸入相等、支線輸出與輸入不相關——實際網路裡兩個都只是近似,但出力的不是同一個。把不相關的限制拿掉,一般式是 \(\mathrm{Var}[u+v] = \mathrm{Var}[u] + \mathrm{Var}[v] + 2\,\mathrm{Cov}[u,v]\)。本章第四小節的對照實驗顯示:把支線輸出整批打亂、讓共變異數項歸零,成長率幾乎不動;把支線變異數強制壓回與輸入相等,成長率立刻回到 2 附近。所以差距主要來自「等變異數」這個假設不成立——初始化保住的是二階矩,而非負的增量讓主幹的平均值愈墊愈高,二階矩於是遠大於變異數,支線輸出的變異數跟著超過輸入的變異數。對(c)的影響是:\(1/\sqrt{2}\) 這個值壓不夠。若實測成長率是 \(r\),正確的係數應該是 \(1/\sqrt{r}\);以 \(r = 2.67\) 代入得約 0.612。這也說明了固定係數這個做法的脆弱之處——正確的係數取決於支線的實際結構與活化函數,換一個設計就要重算一次,而批次正規化是就地量、就地調,不需要事先知道這個數字。
作業三:量出「同批夥伴是誰」的影響有多大
改寫本章第五小節的程式,量化批次大小對批次正規化輸出穩定度的影響。(a)固定同一筆資料 focus,讓它分別跟批次大小 4、16、64、256 的隨機夥伴同批,各重複 200 次,記錄每次正規化後第一個維度的輸出,算出這 200 個值的標準差。(b)預測這個標準差隨批次大小怎麼變化,並用(a)的結果檢驗。(c)從(a)(b)的結果解釋兩件事:為什麼小批次時批次正規化的正則化效果比較強,以及為什麼小批次時它比較容易出問題。
解答 SOLUTION
(a)核心是把 focus 固定,只換同批夥伴:
import numpy as np
rng = np.random.default_rng(0)
D, eps = 4, 1e-5
focus = np.array([2.0, -1.0, 0.5, 3.0])
def bn(A):
mu, sd = A.mean(axis=0), A.std(axis=0)
return (A - mu) / np.sqrt(sd ** 2 + eps)
for B in (4, 16, 64, 256):
vals = []
for _ in range(200): # 同一筆資料,換 200 組夥伴
mates = rng.normal(1.0, 2.0, (B - 1, D))
vals.append(bn(np.vstack([focus, mates]))[0, 0])
print(f"批次大小 {B:4d}:標準差 {np.std(vals):.4f}")實跑輸出:批次大小 4、16、64、256 時,focus 第一個維度輸出的標準差分別是 0.6476、0.2709、0.1302、0.0650。
(b)預測是標準差大致與 \(1/\sqrt{|\mathcal{B}|}\) 成反比——因為抖動的來源是拿有限筆樣本去估平均與標準差,而樣本平均的標準誤就是以 \(1/\sqrt{n}\) 的速率縮小。所以批次放大四倍,標準差應該減半。檢驗:從 4 到 16,比值是 0.6476 / 0.2709 = 2.39;從 16 到 64,比值 2.08;從 64 到 256,比值 2.00。後兩組幾乎正中預測,第一組偏高——批次只有 4 的時候,用三筆夥伴去估標準差這件事本身就很不穩,估計量的分布已經明顯偏離常態近似。
(c)第一件事:批次愈小,同一筆資料在不同批次裡被正規化成的值差異愈大,等於訓練時每一步都在對它加一點隨機擾動——這正是雜訊注入式的正則化在做的事,所以效果比較強。第二件事:同樣這個抖動,也意味著小批次時算出來的統計量對真實分布的估計很不可靠;訓練期間累積的移動平均會跟著失準,而推論時用的正是那組值。所以小批次時,訓練與推論兩種模式的落差會被放大。同一個根因——統計量隨批次組成而抖——同時產生了好處與麻煩,這正是第六節不把它們拆成兩張清單的理由。
(本題的所有數值均以 numpy 實跑核對過,np.random.default_rng(0)。)
§05參考資料
- NumPy 官方使用手冊 — 本章四段程式只依賴 numpy,這份手冊是查語法與廣播規則的第一站
- Deep Residual Learning for Image Recognition(arXiv) — 提出殘差區塊的原始論文,想看第二節那個改寫最早是怎麼被論證的就讀它
- Identity Mappings in Deep Residual Networks(arXiv) — 專門討論區塊內部各元件的擺放順序,第二節那個「動作順序會影響什麼」的完整版
- Batch Normalization(arXiv) — 批次正規化的原始論文;注意它的平移參數寫作 β,就是本章的 δ
- Residual Networks Behave Like Ensembles of Relatively Shallow Networks(arXiv) — 第三小節路徑觀點的來源,裡面有實際量測長路徑梯度的實驗
- How Does Batch Normalization Help Optimization?(arXiv) — 對「批次正規化為什麼有效」的通行解釋提出反例,第六節說「沒有定論」指的就是這類爭論
- Dive into Deep Learning:殘差網路一章 — 免費線上教材,附可執行的實作,適合看完本章後對照程式細節
- Understanding Deep Learning(MIT Press) — 本課課綱主題所本的原書出版頁(ISBN 9780262048644,2023-12 出版)
- udlbook 官方網站(作者釋出的 PDF、投影片與習題) — 原書作者維護的免費資源站(udlbook.com 會轉址到此)