iPAS AI 奈米級拆解程式題解說・中級科目三 115-01
Question 49 · PyTorch / torchvision · ResNet

超過 50 層又怕梯度消失:
ResNet 的捷徑,程式裡只有一行 out += identity

第 49 題是 AOI 晶圓瑕疵題組的第 1 題,題目沒有附程式,問的是觀念:網路要很深,又怕梯度消失,該選哪一種架構。答案 ResNet 的關鍵,在 torchvision 的原始碼裡只有一行。這頁先用白話講清楚梯度是什麼、為什麼會「消失」,再逐行拆開 torchvision 真正的殘差區塊程式;最後把 50 層的網路有捷徑、沒捷徑各實際訓練一次,親眼看第 1 層的梯度差了多少,並用 torchvision 的實際模型核對 VGG、GoogLeNet、ResNet、ViT 四個選項。

PyTorch 2.6torchvision 0.21 Bottleneckforwardidentityout += identitydownsample 梯度消失殘差連接零基礎可讀

讀完這頁,你會知道

  1. 梯度是什麼、為什麼要「從最後一層往前傳」,以及梯度消失是怎麼一層一層乘出來的
  2. torchvision 的 Bottleneck 類別:__init__ 掛了哪些零件、forward 每一行做什麼,identity = x 為什麼不是複製
  3. out += identity 為什麼能讓梯度跨層直達(加法的梯度是 1)
  4. 實測:同樣 50 層,有捷徑和沒捷徑,第 1 層的梯度差了約 1019~1020 倍;只加 BatchNorm 為什麼救不了
  5. VGG、GoogLeNet、ResNet、ViT 各自的關鍵設計,四個選項的說法各錯在哪
§0

題目

請依據 AOI 晶圓瑕疵檢測情境作答: 資料極度不平衡,影像品質不一致,且深層 CNN 訓練時 Loss 震盪甚至偶爾出現 NaN。 若團隊需要建立一個超過 50 層的深層神經網路,以捕捉晶圓表面細微的劃傷特徵,但又擔心傳統深層網路常見的梯度消失(Vanishing Gradient)問題,則下列哪一種經典架構最適合優先考慮?

先憑直覺選一個,讀完再回來對照:

先抓題目的三個關鍵字

① 超過 50 層:要的是「很深」的網路,深才能一層一層組合出細微的劃傷特徵。② 梯度消失:網路越深,訓練時的「修正訊號」傳到前面幾層就越小,前面的層幾乎學不到東西(§1 觀念 1、動畫 1)。③ 最適合優先考慮的經典架構:四個都是有名的架構,要找的是專門為了解決深層訓練問題而設計的那一個。

這一題沒有程式,這頁拆的是哪一段?

第 49、50 題是同一個 AOI 晶圓情境:第 50 題附了 PyTorch 的訓練迴圈(處理 Loss 變成 NaN),第 49 題只問架構。答案 ResNet 的「殘差連接」,在 PyTorch 官方的 torchvision 裡就是 Bottleneck 類別的 forward 方法,§3 把那段真正的原始碼(torchvision 0.21)一行一行拆開;第 42 題用的 resnet50(),就是 16 個這種區塊疊起來的。

§1

先補基本功:讀懂這段程式需要的 6 個觀念

這節先用白話講「梯度」,再講這段原始碼會用到的 Python 物件導向語法,例子全部取自 torchvision 的 Bottleneck。中間有兩支動畫:先用「傳話接力」看懂梯度消失和捷徑,最後打開記憶體看 forward 每一行在做什麼;每支動畫前後都有文字說明。

① 梯度觀念 1
修正訊號往回傳
→
② 傳話接力動畫 1
消失與捷徑
→
③ 類別・物件・名牌觀念 2~6
→
④ 記憶體實況動畫 2
forward 逐行

第 3 支動畫(實測梯度+四個選項)放在 §4,和梯度實驗室一起看。

觀念 1|梯度:每個數字「該往哪邊調、調多少」的訊號,而且要從最後一層往前傳

神經網路的每一層都有很多可以調的數字(權重)。訓練一次的流程是:圖片從第 1 層一路算到最後一層得到答案(前向),和正確答案比一比得到誤差(loss),再把「誤差要怎麼改」從最後一層往前傳回每一層(反向傳播)。傳到每個權重身上的那個訊號,就是梯度:它說「把這個數字調大一點或調小一點,loss 會變小」,梯度越大,這次就調越多。

前向第 1 層 → … → 第 50 層 → 答案
→
loss和正確答案比
→
反向第 50 層 → … → 第 1 層
每層拿到自己的梯度
→
更新權重 −= 學習率 × 梯度

關鍵在反向那一步:第 1 層拿到的梯度,是從第 50 層開始,一層一層乘過來的(微積分的連鎖律)。如果每經過一層,訊號都要乘上一個小於 1 的數,乘 49 次以後就幾乎是 0:

每層乘上乘 10 次乘 49 次
0.90.350.0057
0.50.00098(不到千分之一)1.8 × 10-15
0.42(本頁實測的平均)0.00017約 3 × 10-19

前面幾層拿到的梯度幾乎是 0,就幾乎不會被更新,整個網路練不起來,這就是梯度消失(vanishing gradient)。反過來,每層乘上大於 1 的數,梯度會越乘越大,叫梯度爆炸(exploding gradient),Loss 會劇烈震盪甚至變成 NaN,那是第 50 題處理的問題。

看動畫 1 之前:這支要回答什麼

觀念 1 講了梯度要從最後一層一層一層傳回來。動畫 1 把它變成 50 個人排成一排傳話:老師把訂正告訴最後一個人,再一個一個往前傳,每傳一次聲音就變小;接著加上 ResNet 的捷徑,看訂正怎麼原封不動地跳過去。中間會插入本頁真的量出來的梯度。

帶著三個問題看:① 為什麼前面幾層「聽不到」訂正?② 捷徑讓每一段的輸出變成什麼?③ 同樣 50 層,加上捷徑後第 1 層的梯度、驗證準確率變成多少?

看的時候注意顏色:紅色的量表是傳到那一層還剩多少訂正,綠色弧線是捷徑。

動畫 1傳話接力:梯度消失,和 ResNet 的捷徑2 分 10 秒・有旁白,也有字幕

傳話、老師、聲音變小是比喻;「每次剩一半」是為了好算的假設(本頁實測沒有捷徑時,平均每層剩約 0.42 倍)。梯度曲線和 0.10、0.52 都是 §4 的實測:同一份 CIFAR-10、同樣 50 個 Linear 層、參數一樣多,只差有沒有捷徑。

看完動畫 1,你應該能回答:為什麼前面幾層「聽不到」訂正?
梯度要從最後一層一層一層傳回來,每經過一層就乘上一個數;如果大多小於 1,乘幾十次就幾乎是 0。實測 50 層沒有捷徑,第 1 層的梯度只有 4.0 × 10-21,等於沒有被更新。
捷徑讓每一段的輸出變成什麼?
輸出 = 這幾層算出的修正 + 原本的輸入(out = f(x) + x)。反向傳播時,訂正可以沿捷徑原封不動地往前送。
同樣 50 層,加上捷徑後差多少?
第 1 層的梯度從 4.0 × 10-21 變成 0.075(和最後一層 0.24 差不到 4 倍);訓練 5 輪的驗證準確率從 0.10 變成 0.52。

觀念 2|class Bottleneck(nn.Module):設計圖,而且繼承了一張更大的設計圖

class 定義一張設計圖(類別)。括號裡的 nn.Module 是它的父類別:PyTorch 裡所有層、所有模型的共同祖先。寫成 class 子(父) 就是繼承:子類別自動擁有父類別的所有方法,只要補上自己不一樣的部分。

從 nn.Module 繼承來的做什麼
.parameters()交出所有要訓練的權重(第 42 題)
物件(x)(__call__)呼叫物件 = 執行它的 forward(x)(觀念 4)
.train()、.eval()、.to('cuda')切換模式、搬到 GPU

Bottleneck 自己只寫了兩件事:__init__(做物件時要掛哪些零件)和 forward(資料進來要怎麼算)。ResNet-50 裡有 16 個 Bottleneck 物件(實測),全部照這一張設計圖做出來,只是每個的通道數不同。

type(model.layer1[1])        # <class 'torchvision.models.resnet.Bottleneck'>
type(model.layer1[1]).__mro__ # Bottleneck → Module → object(繼承的順序)

觀念 3|__init__ 和 self.xxx:做物件時,在它身上掛零件

__init__ 是「做物件時自動執行」的方法。裡面每一句 self.conv1 = …,都是在這一個物件身上掛一個零件(屬性);self 就是「正在被做出來的這一個物件」。以 ResNet-50 的 layer1[1] 為例(實測):

屬性掛的零件(另一個物件)做什麼
self.conv1Conv2d(256, 64, kernel_size=1×1)把 256 個通道壓縮成 64 個(瓶頸的「窄口」)
self.bn1BatchNorm2d(64)把數值拉回穩定範圍
self.conv2Conv2d(64, 64, kernel_size=3×3, padding=1)真正看圖的那一層,長寬不變
self.bn2BatchNorm2d(64)
self.conv3Conv2d(64, 256, kernel_size=1×1)再把通道擴回 256 個(64 × expansion 4)
self.bn3BatchNorm2d(256)
self.reluReLU(inplace=True)負數變 0;原地修改(觀念 5)
self.downsampleNone(這個區塊沒有)形狀要改變的區塊才有;16 個裡有 4 個

「1×1 壓窄 → 3×3 處理 → 1×1 擴回」形狀像瓶頸,所以叫 Bottleneck。這樣 3×3 卷積只要處理 64 個通道,比直接處理 256 個省很多計算。

觀念 4|呼叫物件就是執行 forward:block(x)

ResNet-50 算到這個區塊時,寫的是 out = block(x):把物件當函式呼叫。這是 nn.Module 繼承來的 __call__ 在幫忙:它會做一些準備工作,然後執行 block.forward(x),把結果交回來。所以 forward 從來不用自己呼叫,寫好就好。forward 裡的 self.conv1(x) 也是同一招:呼叫 conv1 這個零件物件,就是執行它的 forward。

def forward(self, x: Tensor) -> Tensor: 裡的 : Tensor 和 -> Tensor 是型別提示:告訴讀程式的人「x 是張量,回傳的也是張量」,Python 執行時不會檢查。

觀念 5|名牌、新物件、原地修改:identity = x 不是複製

Python 的變數是名牌,貼在物件上。identity = x 只是在 x 指的那個張量上再貼一張名牌,不會複製出第二份(實測 identity is x 是 True,位址相同)。接下來的程式分成兩種:

做出新物件,名牌移過去

out = self.conv1(x)、out = self.bn1(out):conv、bn 算完都會交回一個新的張量,out 這張名牌改貼到新的那個上面;舊的沒有名牌指著,就被回收。

原地修改,還是同一個物件

out = self.relu(out)(因為 inplace=True)和 out += identity:直接改 out 指的那個張量裡的數字,不做新的(實測位址不變)。

這解釋了一件重要的事:整段 forward 裡,x 指的那個張量從頭到尾沒被改過,所以最後 out += identity 加回去的,正是原本的輸入。

觀念 6|if self.downsample is not None::零件可能不存在

None 是 Python 的「什麼都沒有」。比較是不是 None 要用 is / is not(判斷是不是同一個物件),不用 ==。這一行的意思是:「如果這個區塊有掛 downsample 零件,就用它把 x 變成和 out 一樣的形狀」。ResNet-50 每一組(layer1~layer4)的第 1 個區塊要改變通道數或長寬,所以有 downsample;其餘 12 個區塊是 None,這兩行直接跳過。

model.layer1[0].downsample   # Sequential(Conv2d(64, 256, 1×1), BatchNorm2d(256))
model.layer1[1].downsample   # None

把 6 個觀念串起來:本題的「類別 → 物件 → 方法」路線

① 類別(設計圖)nn.Module(祖先)→ Bottleneck(繼承它)
零件的設計圖:Conv2d、BatchNorm2d、ReLU
→
② 物件resnet50() 做出 16 個 Bottleneck 物件
__init__ 在每個物件身上掛 conv1~bn3、relu、downsample
→
③ 呼叫方法block(x) → forward(x)
identity = x、out += identity

把觀念合起來:forward 執行時,記憶體裡發生什麼

看動畫 2 之前:這支要回答什麼

動畫 1 是比喻;觀念 2~6 講了類別與繼承、__init__ 掛零件、呼叫物件就是執行 forward、名牌與原地修改、None 的判斷。動畫 2 打開記憶體,用 torchvision 真正的 Bottleneck.forward,一行一行看名牌 x、identity、out 各指向哪個張量,最後看加法怎麼把梯度送回去。

帶著三個問題看:① identity = x 之後,記憶體裡有幾個張量?② 哪幾行做出新張量、哪幾行是原地修改?③ 反向傳播時,out += identity 讓 x 拿到多少梯度?

看的時候抓四種樣子:黃色虛線框是設計圖(類別)、綠色格子是物件、藍色格子是張量、紅色名牌是變數名。

動畫 2打開記憶體:Bottleneck 物件和 out += identity2 分 1 秒・有旁白,也有字幕

程式是 torchvision 0.21 的原始碼;位址、形狀取自一次實際執行(ResNet-50 的 layer1[1],輸入 [1, 256, 56, 56]),每次執行位址都不同。「新物件」「同一個物件」是比對 id() 位址的結果;最後的 0.001 和 1 是實測 0.001·a + b 反向傳播後 a、b 的梯度。

看完動畫 2,你應該能回答:identity = x 之後,記憶體裡有幾個張量?
還是一個。identity 只是貼在同一個張量上的第二張名牌(實測 identity is x 是 True)。
哪幾行做出新張量、哪幾行是原地修改?
conv1、bn1、conv2、bn2、conv3、bn3 都會交回新張量,名牌 out 跟著移過去;self.relu(out)(inplace=True)和 out += identity 是原地修改,位址不變。x 從頭到尾沒被改。
反向傳播時,out += identity 讓 x 拿到多少梯度?
加法把梯度原封不動交給兩條路:捷徑那條是完整的 1 倍,卷積那條要再乘上卷積們的導數。就算卷積那條只剩 0.001,x 至少還有捷徑給的那一份。
§2

認識四個選項的架構(數字都是 torchvision 實際數出來的)

VGG(選項 A)
牛津大學 Visual Geometry Group,2014 年;ImageNet 2014 定位賽第 1、分類賽第 2

設計很單純:只用 3×3 卷積,一層接一層疊,中間夾池化。VGG-16 = 13 層卷積+3 層全連接、138,357,544 個參數;VGG-19 = 16+3 = 19 層。每一層只接下一層,沒有任何捷徑。

比喻:一條很長的直線生產線。

GoogLeNet(選項 B)
Google,2014 年 ImageNet 分類賽冠軍;22 層

主角是 Inception 模組同一層裡同時放幾種不同大小的卷積(原論文是 1×1、3×3、5×5)和池化,各自抓不同尺寸的特徵,再把結果接在一起。(torchvision 實測 9 個)。為了讓前面的層也拿得到梯度,它另外在中間接了 2 個輔助分類器(InceptionAux,只在訓練時用)。13,004,888 個參數。

比喻:同時派好幾種放大鏡一起看。

ResNet(選項 C)
微軟研究院何愷明等人,2015 年 ImageNet 分類賽冠軍

每 2~3 層包成一個區塊,區塊的輸出 = 這幾層算出的修正 + 原本的輸入(殘差連接)。ResNet-50:16 個 Bottleneck、25,557,032 個參數;ResNet-152:50 個 Bottleneck、60,192,808 個參數,152 層也練得動。

比喻:每一段生產線旁邊,都有一條直通的輸送帶。

ViT(選項 D)
Google,2020 年;Vision Transformer

把圖片切成 16×16 的小方塊,像一串字一樣交給 Transformer2017 年為了翻譯提出的架構,核心是「注意力」:每一個小方塊都去看其他所有方塊,決定要參考誰。。ViT-B/16:12 層、86,567,656 個參數。torchvision 的實作開頭仍有一個 Conv2d 負責切方塊,而且每一層都有殘差連接(原始碼 x = x + input)。

比喻:把拼圖拆開,每一片都去問其他片。

四種架構比一比

年份深度參數(torchvision)有殘差連接嗎怎麼處理深層訓練
VGG-16 / 19201416 / 19 層138.4M / 143.7M沒有沒有專門設計;論文用「先訓練淺的,再拿來初始化深的」
GoogLeNet201422 層13.0M沒有2 個輔助分類器在中間補梯度
ResNet-50 / 152201550 / 152 層25.6M / 60.2M有(每個區塊)殘差連接:梯度沿捷徑直達
ViT-B/16202012 層 Transformer86.6M有(每層兩條)殘差連接+LayerNorm;需要大量資料

為什麼捷徑能救梯度:加法的梯度是 1

沒有捷徑時,一個區塊是 輸出 = f(x),梯度往回傳要乘上 f 的導數;有捷徑時是 輸出 = f(x) + x,往回傳時,加法會把梯度原封不動同時送給兩條路:

從後面傳來的梯度 g
→
⊕ 加法兩條路都拿到 g
→
走 f 那條g × f 的導數(可能很小)
+
走捷徑那條g × 1(不變)

實測一個最簡單的例子:out = 0.001·a + b,反向傳播後 a 的梯度是 0.001、b 的梯度是 1.0。所以就算 f 那條路的梯度很小,x 至少還能從捷徑拿到完整的 g,乘 16 次、50 次也不會因為「乘很多個小數」而消失。這就是選項 C 說的「使梯度能跨層傳遞」。

BatchNorm 能不能代替捷徑?實測:不行

BatchNorm 會把每一層的數值拉回穩定範圍,是訓練深網路的重要工具(ResNet 本身也有用)。但只加 BatchNorm、不加捷徑的 50 層網路,一開始第 1 層的梯度反而暴增到 4,064(最後一層約 0.59),訓練 5 輪驗證準確率只有 0.21;同樣 50 層加上捷徑是 0.52(§4 實驗室可以切換看)。深的網路沒有捷徑,梯度不是太小就是太大,很難剛好。

§3

逐行解剖(torchvision 0.21 的 Bottleneck 原始碼,節錄)

左邊是 torchvision 真正的原始碼(只省略 __init__ 的內容,§1 觀念 3 已經列出它掛的零件),點任一行可以跳到解說;右邊往下捲時,左邊會自動標出目前講到哪一行。手機上會改成上下排列。

resnet.py(節錄)24 行

定義一張叫 Bottleneck 的設計圖;它是 nn.Module 的子類別,所以天生就有 parameters()、可以被呼叫等所有 PyTorch 層的能力。

逐字拆解

class
關鍵字「定義一個類別(設計圖)」。
Bottleneck
類別名稱大寫駝峰,照 PEP 8 慣例是類別。意思是「瓶頸」:1×1 壓窄、3×3 處理、1×1 擴回。
(nn.Module)
父類別繼承 PyTorch 所有層的共同祖先。
:
冒號下面縮排的內容都屬於這個類別。

這一行執行時發生什麼事

Python 讀到 class 時,只是把設計圖做好,還沒有任何物件。物件是在 resnet50() 裡,_make_layer 照著它做出來的,一共 16 個。

執行後多了: Bottleneck → 一個類別物件(設計圖) (物件要等 resnet50() 呼叫時才做:16 個)

為什麼叫「殘差」

原論文的想法:與其要這幾層直接學出「整個輸出」,不如讓它們只學「輸出和輸入差多少」,也就是殘差(residual)。輸出 = 輸入 + 殘差。如果什麼都不用改,殘差學成 0 就好,這比要一疊層剛好學成「原封不動」容易得多。

新手常踩的坑

  • 自己寫層時忘了在 __init__ 第一行呼叫 super().__init__():一掛零件就出現 AttributeError: cannot assign module before Module.__init__() call(實測),因為 nn.Module 用來記錄零件的機制還沒準備好。torchvision 的 Bottleneck 也有這一行(觀念 3 的程式沒列出來)。

在設計圖上記一個所有 Bottleneck 物件共用的數字:最後一層 1×1 卷積要把通道擴成 planes 的 4 倍。

寫在 class 底下、方法外面的變數是類別屬性:所有物件共用同一份,用 Bottleneck.expansion 或 self.expansion 都拿得到(實測是 4)。: int 是型別提示。layer1 的 planes 是 64,所以 conv3 輸出 64 × 4 = 256 個通道。

組區塊數planes區塊輸出通道(× 4)
layer1364256
layer24128512
layer362561,024
layer435122,048(第 42 題 fc 的 2048 就是這裡來的)

3 + 4 + 6 + 3 = 16 個區塊,每個 3 層卷積共 48 層,加上最前面 1 層卷積和最後 1 層 fc,就是「50」。

定義這個區塊收到一批特徵圖 x 時,要怎麼一步一步算出輸出。

def forward
定義方法nn.Module 規定的名字,呼叫物件時就會執行它(觀念 4)。
self
參數這個區塊物件自己,靠它拿到 self.conv1 這些零件。
x: Tensor
參數+型別提示輸入的張量。layer1[1] 收到的形狀是 [1, 256, 56, 56](實測:1 張圖、256 個通道、56×56)。
-> Tensor
回傳型別提示回傳也是張量,而且形狀和輸入一樣(實測 [1, 256, 56, 56])。

先把輸入記下來:在 x 指的張量上再貼一張叫 identity 的名牌。這一份原本的輸入,就是等一下捷徑要加回去的東西。

這一行執行時發生什麼事

不複製任何數字,只多一張名牌(實測 identity is x 是 True,兩者位址都是同一個)。複製一份 [1, 256, 56, 56] 的張量要 80 萬個數字,不複製又快又省記憶體;而且後面的程式都不會改到 x,所以安全。

執行後: x ─┐ ├─→ Tensor 物件 [1, 256, 56, 56](同一個) identity ─┘

新手常踩的坑

  • 以為 identity = x 是備份:如果後面有程式原地改了 x(例如 x += 1),identity 也會跟著變,因為它們是同一個物件。真的要備份得用 x.clone()。

先用 1×1 卷積把 256 個通道壓縮成 64 個,再標準化、把負數變 0。

self.conv1(x)
呼叫零件Conv2d(256→64, 1×1):每個位置把 256 個數字組合成 64 個。輸出 [1, 64, 56, 56],新物件。
self.bn1(out)
呼叫零件BatchNorm2d(64):每個通道拉回平均 0、變異數 1 附近。新物件。
self.relu(out)
呼叫零件ReLU(inplace=True):負數變 0,原地改,還是 bn1 做出的那一個(實測位址不變)。

注意第 7 行吃的是 x,第 8、9 行吃的是 out:名牌 out 一路往後移,x 留在原地沒被碰到。

在 64 個通道上做 3×3 卷積,看每個位置周圍 3×3 的範圍;長寬維持 56×56。

Conv2d(64, 64, kernel_size=3, padding=1):補一圈邊,所以長寬不變。如果是每組的第一個區塊(例如 layer2[0]),這裡的 stride 是 2,長寬會減半;torchvision 把減半放在 3×3 這層,原論文放在第一個 1×1,這個版本又叫 ResNet V1.5(原始碼註解有寫)。

用 1×1 卷積把 64 個通道擴回 256 個,再標準化;這裡先不經過 ReLU。

為什麼沒有 ReLU?因為下一步要和 identity 相加:先加,最後再一起過 ReLU(第 22 行)。如果在加之前就把負數砍掉,這幾層就只能「加正的修正」,不能把某些值調小。

此時: out → Tensor [1, 256, 56, 56](bn3 做出的新物件) identity → Tensor [1, 256, 56, 56](原本的 x,沒被改過)

如果這個區塊有 downsample 零件,就用它把原本的輸入 x 轉成和 out 一樣的形狀,identity 改指那個新張量。

加法兩邊的形狀必須一模一樣。每一組的第 1 個區塊會改變通道數(或長寬),例如 layer1[0]:輸入 x 是 [1, 64, 56, 56],out 是 [1, 256, 56, 56],直接相加會出錯。它的 downsample 是 Sequential(Conv2d(64, 256, 1×1), BatchNorm2d(256)),算出來的新張量才是 [1, 256, 56, 56](實測)。本頁拆的 layer1[1] 是 None,這兩行跳過。

把原本的輸入,一格一格加進 out。這一行就是 ResNet 的「捷徑」:區塊的輸出 = 三層卷積算出的修正 + 原本的輸入。

逐字拆解

out
變數bn3 的輸出,[1, 256, 56, 56]。
+=
原地加法把右邊的值加進左邊那個物件,不做新張量(實測位址不變)。
identity
變數原本的輸入(或 downsample 後的版本)。

這一行執行時發生什麼事

256 × 56 × 56 = 802,816 個位置,每個位置做一次「out 的數字 + identity 對應位置的數字」(實測結果和 bn3 的輸出 + x 完全相同)。

反向傳播時,這一行為什麼這麼重要

加法的梯度是 1:從後面傳來的梯度,會原封不動同時交給「三層卷積那條路」和「identity 那條路」。卷積那條路可能讓梯度變小,但 identity 這條直接連到 x,梯度一點都不少。16 個區塊每個都有這條路,第 1 層就能拿到夠大的梯度(§4 實測第 1 層 0.075,沒有捷徑時是 4.0 × 10-21)。

新手常踩的坑

  • 形狀對不上就相加:例如把 [1, 64, 56, 56] 加到 [1, 256, 56, 56],PyTorch 會報 RuntimeError: The size of tensor a (256) must match the size of tensor b (64) at non-singleton dimension 1(實測)。這正是第 18、19 行 downsample 存在的原因。
  • 寫成 out = out + identity 也對,差別只是會做一個新張量,多用一點記憶體。

加完之後,再把負數變 0。又是原地修改,out 還是同一個物件。

順序是「先加、再 ReLU」:這是原論文的做法。後來也有「先 ReLU 再加」的改良版(pre-activation ResNet),torchvision 的 resnet50 用的是原本這一種。

把算好的張量交回去;外面的 out = block(x) 拿到它,再交給下一個 Bottleneck。

形狀和輸入一樣是 [1, 256, 56, 56](實測),所以同一組裡的區塊可以一直接下去。整個 ResNet-50 就是 16 次「算修正、加回輸入」。

§4

互動實驗:梯度實驗室(實測:50 層有沒有捷徑)

上半部是算術:自己調「層數」和「每層乘上多少」,感受連乘有多可怕。下半部是本機真的跑出來的結果(PyTorch 2.6、CPU):同一份 CIFAR-10(攤平成 3,072 個數字)、同樣 50 個 Linear 層、寬度 128、參數一樣多(1,220,234 個),只差有沒有捷徑;另外兩組對照是「只加 BatchNorm」和「只有 10 層」。

① 連乘計算機
梯度從最後一層傳到第 1 層,大約要乘上「層數 − 1」次。拖動看看。
傳到第 1 層剩下:—
② 實測:每一層的梯度(還沒訓練,同一批 256 張圖)
勾選要比較的網路。縱軸是對數刻度:每往上一格,大 100 倍。
看實測要注意的三件事
  • 沒有捷徑:從第 50 層往前,梯度平均每層只剩約 0.42 倍,第 1 層是 4.0 × 10-21;訓練 5 輪,驗證準確率一直是 0.10(10 類亂猜)。換成 Sigmoid,第 1 層的梯度小到 float32 存不下,直接變成 0。
  • 有捷徑:每一層的梯度都在 0.075~0.24 之間,第 1 層和最後一層差不到 4 倍;5 輪後驗證 0.52。
  • 對照組:只加 BatchNorm,梯度在前面反而暴增到 4,064,5 輪只到 0.21;只有 10 層、沒有捷徑,第 1 層梯度 5.2 × 10-5 還練得動,5 輪到 0.46。問題的根源是「深」,捷徑是讓深網路練得動的關鍵。

動畫 3:實測梯度,和四個選項

看動畫 3 之前:這支要回答什麼

上面的實驗室可以自己勾選;動畫 3 把五組實測依序講一遍(沒有捷徑、Sigmoid、有捷徑、只加 BatchNorm、只有 10 層),再用 torchvision 的實際模型,一個一個核對 VGG、GoogLeNet、ResNet、ViT 四個選項的說法,最後回到 AOI 題組,接上第 50 題。

帶著三個問題看:① 只加 BatchNorm 為什麼救不了?② GoogLeNet 幫梯度靠的是什麼?③ ViT 真的不需要殘差連接嗎?

動畫 3實測梯度與四個選項2 分 11 秒・有旁白,也有字幕

梯度與準確率是本機實際訓練的結果(PyTorch 2.6、CPU、CIFAR-10 攤平後的 50 層全連接網路);層數、參數數量、Inception 模組數、輔助分類器、ViT 的殘差連接,都是在 torchvision 0.21 建出模型(weights=None)實際數、實際讀原始碼得到的。torchvision 的 Inception 實作把原論文的 5×5 換成了 3×3(原始碼註解註明是已知的 bug),動畫依原論文寫 5×5。

看完動畫 3,你應該能回答:只加 BatchNorm 為什麼救不了?
沒有捷徑時,梯度仍然要一層一層乘過去;實測 50 層加 BatchNorm,第 1 層的梯度反而暴增到 4,064,5 輪驗證只有 0.21。梯度不是太小就是太大,很難剛好。
GoogLeNet 幫梯度靠的是什麼?
在網路中間另外接的 2 個輔助分類器(InceptionAux,只在訓練時使用),讓前面的層也直接拿到梯度。Inception 模組是用來同時抓不同尺寸的特徵,所以選項 B 的理由不對。
ViT 真的不需要殘差連接嗎?
需要。torchvision 的 ViT 每一層 Transformer 都有兩條殘差連接(x = x + input、return x + y);而且資料不夠多時表現不如 CNN。選項 D「不會出現任何深層模型的訓練問題」太絕對。
§5

回到題目:逐項對答案

(A) VGG:透過重複堆疊 3×3 卷積層與池化層來維持梯度穩定 錯

VGG 的特色是只用 3×3 卷積、一層層疊上去,但疊卷積本身不會讓梯度穩定。VGG 最深也只有 19 層(torchvision 實測 VGG-19:16 層卷積+3 層全連接),中間沒有任何捷徑;同樣是「一層只接下一層」的 50 層網路,本頁實測第 1 層的梯度只剩 4.0 × 10-21。

(B) GoogLeNet:透過 Inception 模塊並行使用不同大小的卷積核,以避免深層網路的梯度問題 錯

Inception 模組的目的是同時抓不同尺寸的特徵,不是處理梯度。GoogLeNet(22 層)為了讓前面的層拿到梯度,另外加了 2 個輔助分類器(torchvision 實測 aux1、aux2,只在訓練時使用);它沒有殘差連接,也不是「超過 50 層又怕梯度消失」時的典型答案。

(C) ResNet:透過殘差連接,使梯度能跨層傳遞並降低深層訓練困難 正解

殘差連接就是 out += identity:反向傳播時,加法把梯度原封不動送回輸入,不會因為連乘而消失。ResNet-50 由 16 個這種區塊組成,ResNet-152 有 50 個區塊也練得動。本頁實測同樣 50 層,加上捷徑後第 1 層的梯度從 4.0 × 10-21 變成 0.075,驗證準確率從 0.10 變成 0.52。

(D) ViT:完全捨棄卷積,因此不會出現任何深層模型的訓練問題 錯

兩個地方都說錯了。第一,「不用卷積」和「沒有訓練問題」沒有因果關係:ViT 每一層的 Transformer 區塊一樣靠殘差連接才練得動(torchvision 原始碼 x = x + input、return x + y),而且資料不夠多時表現不如 CNN。第二,torchvision 的 ViT 開頭仍用一個 Conv2d 把圖切成 16×16 的方塊。「不會出現任何」這種絕對的說法,在考題裡通常是錯的訊號。

記一張表就夠:四個選項的關鍵設計

選項關鍵設計解決了什麼殘差連接
(A) VGG只用 3×3 卷積,一層層疊結構簡單、好理解沒有
(B) GoogLeNetInception:多種大小的卷積並排同時抓多尺寸特徵、參數少沒有(靠輔助分類器補梯度)
(C) ResNet輸出 = 修正 + 原本的輸入深層網路的梯度消失/退化有
(D) ViT圖切小方塊+Transformer 注意力看全圖的長距離關係有(每層兩條)
§6

動手跑跑看:數一數 ResNet-50 的區塊,再自己做兩個 50 層網路比梯度

下面的程式分兩段:先用 torchvision 數一數 ResNet-50 由幾個 Bottleneck 物件組成;再自己寫兩個 50 層的網路,零件一模一樣、只差 forward 裡有沒有 out += identity,比較第 1 層和第 50 層的梯度。不用下載任何資料(用亂數當假圖片),也不下載預訓練權重,複製到 Google Colab 或裝好 PyTorch 的電腦就能執行,幾秒鐘跑完。

import torch
import torch.nn as nn
from torchvision.models import resnet50
from torchvision.models.resnet import Bottleneck

torch.manual_seed(0)

# 1. 看 torchvision 的 ResNet-50:由幾個 Bottleneck 物件組成?
model = resnet50(weights=None)            # 只建結構,不下載權重
blocks = [m for m in model.modules() if isinstance(m, Bottleneck)]
print('Bottleneck 物件:', len(blocks), '個')
print('有 downsample 的:', sum(b.downsample is not None for b in blocks), '個')
print('layer1[0].downsample =', model.layer1[0].downsample)
print('layer1[1].downsample =', model.layer1[1].downsample)

# 2. 自己做兩個 50 層的網路:零件一樣,只差有沒有捷徑
W, DEPTH = 128, 50

class Plain(nn.Module):
    def __init__(self):
        super().__init__()
        self.inp = nn.Linear(3072, W)
        self.layers = nn.ModuleList([nn.Linear(W, W) for _ in range(DEPTH)])
        self.out = nn.Linear(W, 10)

    def forward(self, x):
        x = torch.relu(self.inp(x))
        for fc in self.layers:
            x = torch.relu(fc(x))
        return self.out(x)

class Residual(Plain):                    # 繼承 Plain:零件一模一樣,只改 forward
    def forward(self, x):
        x = torch.relu(self.inp(x))
        for i in range(0, DEPTH, 2):
            identity = x
            out = torch.relu(self.layers[i](x))
            out = self.layers[i + 1](out)
            out += identity                # ← 捷徑:把輸入加回來
            x = torch.relu(out)
        return self.out(x)

# 3. 同一批假資料,比較第 1 層和第 50 層的梯度
x = torch.randn(256, 3072)                # 256 張「假圖片」(3072 = 32×32×3)
y = torch.randint(0, 10, (256,))          # 256 個假答案(0~9)
for Net in (Plain, Residual):
    torch.manual_seed(0)                  # 兩個網路的初始權重一模一樣
    net = Net()
    loss = nn.CrossEntropyLoss()(net(x), y)
    loss.backward()
    g1 = net.layers[0].weight.grad.norm().item()
    g50 = net.layers[-1].weight.grad.norm().item()
    n = sum(p.numel() for p in net.parameters())
    print(f'{Net.__name__:8} 參數 {n:,}  第 1 層梯度 {g1:.3e}  第 50 層梯度 {g50:.3e}')
實際跑出來的結果

在 torch 2.6.0/torchvision 0.21.0(CPU)實際執行:

Bottleneck 物件: 16 個
有 downsample 的: 4 個
layer1[0].downsample = Sequential(
  (0): Conv2d(64, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
  (1): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
)
layer1[1].downsample = None
Plain    參數 1,220,234  第 1 層梯度 3.689e-21  第 50 層梯度 9.572e-03
Residual 參數 1,220,234  第 1 層梯度 6.657e-02  第 50 層梯度 1.946e-01
  • 16 個 Bottleneck、4 個有 downsample:每一組的第 1 個區塊要改變形狀,所以掛了 downsample;layer1[1] 是 None。
  • 參數一模一樣(1,220,234 個):Residual 繼承 Plain,__init__ 完全沿用,只改寫了 forward。差別只有那一行 out += identity。
  • 第 1 層的梯度差了約 1019 倍:沒有捷徑 3.689 × 10-21,有捷徑 6.657 × 10-2。這次用的是亂數假資料,數字和 §4 用 CIFAR-10 量的(4.0 × 10-21 對 0.075)略有不同,但差距的數量級一樣。
新出現的語法是什麼白話
from torchvision.models.resnet import Bottleneckimport直接把 Bottleneck 這張設計圖拿出來,方便用 isinstance 比對
model.modules()nn.Module 的方法交出模型裡所有零件(一層一層往裡找),回傳產生器
[m for m in … if isinstance(m, Bottleneck)]串列推導式只留下「是照 Bottleneck 設計圖做的」那些物件
sum(b.downsample is not None for b in blocks)內建函式True 算 1、False 算 0,加起來就是「有幾個」
nn.ModuleList([...])類別 → 物件裝零件的清單;用它裝,PyTorch 才找得到裡面的參數(一般 list 不行)
class Residual(Plain):繼承沿用 Plain 的一切,只改寫 forward
for i in range(0, DEPTH, 2)for 迴圈i = 0, 2, 4, …, 48:兩層一組
net.layers[0].weight.grad.norm().item()屬性鏈+方法第 1 層的權重 → 它的梯度 → 梯度的大小(長度)→ 轉成 Python 的 float
f'{g1:.3e}'f-string 格式用科學記號、小數 3 位顯示,例如 3.689e-21 = 3.689 × 10-21
§7

一頁總結+自我檢測

這題要帶走的 6 件事
  1. 梯度是「每個權重該怎麼調」的訊號,從最後一層往前傳;第 1 層的梯度是一層一層乘出來的,乘很多個小於 1 的數就會消失,大於 1 就會爆炸。
  2. 殘差連接:區塊輸出 = 修正 + 原本的輸入。在 torchvision 裡就是 Bottleneck.forward 的 out += identity。
  3. 加法的梯度是 1:梯度沿捷徑原封不動回到輸入,所以 50 層、152 層都練得動(ResNet,2015 年 ImageNet 冠軍)。
  4. identity = x 是同一個張量的第二張名牌,不是複本;conv、bn 做新張量,relu(inplace=True) 和 += 原地修改。形狀不同時靠 downsample(ResNet-50 的 16 個區塊裡有 4 個)。
  5. 實測:同樣 50 層、參數一樣多,沒有捷徑第 1 層梯度 4.0 × 10-21、驗證 0.10;有捷徑 0.075、驗證 0.52。只加 BatchNorm 救不了。
  6. VGG 只是疊 3×3 卷積;GoogLeNet 的 Inception 是抓多尺寸特徵(補梯度的是輔助分類器);ViT 一樣靠殘差連接。答案 (C) ResNet。
每層梯度乘上 0.9,乘 49 次剩多少?乘上 1.1 呢?
0.949 ≈ 0.0057(消失);1.149 ≈ 107(爆炸)。乘越多次,只要不是剛好 1,就會往 0 或往無限大跑。§4 的連乘計算機可以自己試。
把第 21 行刪掉(不加 identity),這個區塊還能跑嗎?會怎樣?
能跑,形狀也對,只是變成普通的三層卷積,沒有捷徑了。16 個區塊都刪掉,就是一個 48 層卷積、沒有捷徑的網路,會遇到本頁實測的梯度消失問題。
identity = x 換成 identity = x.clone(),結果會不同嗎?
數值完全相同,因為 forward 裡沒有任何程式原地修改 x。差別只是多複製了一份張量,浪費記憶體和時間。
layer2[0] 的 downsample 為什麼必須存在?
layer2 的第一個區塊要把通道從 256 變 512、長寬從 56 變 28。out 變成 [1, 512, 28, 28],原本的 x 還是 [1, 256, 56, 56],形狀不同不能相加;downsample(1×1 卷積、stride 2+BN)把 x 轉成 [1, 512, 28, 28] 才加得起來。
只要加 BatchNorm,就不需要殘差連接了嗎?
不行。本頁實測 50 層只加 BatchNorm,第 1 層的梯度反而暴增到 4,064,5 輪驗證只有 0.21;同樣 50 層加上捷徑是 0.52。ResNet 是兩個都用:每個區塊裡有 BN,區塊之間有捷徑。
題組的第 50 題說「Loss 震盪甚至出現 NaN」,和本題的梯度消失是同一件事嗎?
方向相反。梯度消失是梯度太小、前面的層學不動(本題,用殘差連接處理);Loss 震盪、出現 NaN 通常是梯度太大(梯度爆炸),第 50 題用梯度裁剪 clip_grad_norm_ 把它限制住。