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

殘差革命:訓練上百層的深度網路

看看為何單純加層反而讓網路「變差」——以及一條優雅的捷徑連線如何打破深度障礙,重塑了之後的每一個架構。

退化問題:越深反而越糟

到了 VGG 與 Inception 的時代(上一篇所談),整個領域有一個乾淨俐落的故事:深度是好的。每多一層,就把較簡單的特徵組合成更豐富的特徵,所以堆更多層應該能讓網路看見更多。很自然地,研究者想堆得更深。然後,真正詭異的事情發生了。超過某個深度後,加層不只是停止幫忙,反而主動讓網路變得更糟——不只在測試資料上,連網路直接在優化的訓練資料上也是如此。一個 56 層的樸素堆疊,其訓練誤差竟然比同樣方式訓練的 20 層網路更高

停下來想想這有多反直覺。假設你最好的 20 層網路已經很不錯。現在你蓋一個 56 層的,要它前 20 層模仿那個 20 層網路,然後讓剩下的 36 層什麼都不做,只把輸入原封不動往下傳(也就是恆等映射,identity mapping)。一個這樣做的網路會與 20 層網路完全一致,絕不會更差。所以一個更深的網路,原則上總能達到至少與較淺網路相同的水準——存在一組權重讓它至少一樣好。可是梯度下降找不到那組權重,連 36 層份的「什麼都不做」都學不會——這告訴我們,問題不在網路的表達能力,而是一個優化問題:正確解存在,但樸素的深層堆疊太難被訓練到那裡。

為什麼「什麼都不做」這麼難學?這與你在先前訓練章節遇過的梯度消失(vanishing gradient)直覺有關。訓練靠的是把梯度反向傳過每一層來調整權重。在一個長長的樸素堆疊裡,這個訊號在回傳途中會被每一層的局部導數反覆相乘;若這些因子大多小於 1,梯度便以幾何方式縮小,最前面的層幾乎動不了。就連要許多層精準地複製其輸入,也是一個很精確的目標,而一個搖搖晃晃、被衰減過的學習訊號很難命中。於是深度製造了一種訓練上的病態,這個領域需要的是結構性的修補,而不只是更大的 GPU。那個修補,就是 殘差網路 以及它的核心積木 殘差連線——也就是本篇接下來要談的。

殘差連線:只學「修正量」

這就是打破深度障礙的想法,而它簡單到幾乎令人不好意思。一般我們要求一個層塊直接從輸入 x 算出某個想要的輸出 H(x)。殘差連線改變了這個問題。我們不再逼層塊產出整個答案,而是讓它只算出答案與輸入之間的,F(x) = H(x) − x,然後在最後把輸入加回去。層塊不再需要學整個映射;它只需要學該對 x 做什麼修改

把它想成修改草稿。從空白頁重寫一篇文章很難;在既有草稿上標幾處修改卻很容易,而如果草稿已經完美,最省事的編輯就是寫上「不用改」交回去。殘差連線 給網路的正是這個選項。如果一個層塊能做的最好的事就是別動它的輸入,它只需要把自己學到的那部分 F 推向零,而捷徑連線(skip connection)會把未經更動的輸入送到輸出。「什麼都不做」不再是一個埋在 36 層相乘底下的脆弱目標;它變成了層塊自然的休息狀態。

一個殘差區塊:輸入 x 分成兩條路徑——卷積路徑 F(x) 與恆等捷徑——在最後的激活函數之前相加。

殘差區塊示意圖。輸入 x 向下經過一個 3x3 卷積、ReLU、再一個 3x3 卷積,產生 F(x)。一條彎曲的箭頭把 x 原封不動繞過這些層。兩者在一個加號處會合,相加後再通過最後一個 ReLU,得到輸出 y。

仔細追蹤圖中的資料流動,因為之後每個架構都重用這個形狀。輸入 x 進來後一分為二。在主路徑上,它經過一個小堆疊——通常是兩個 3×3 卷積、中間夾一個 ReLU——產生學到的殘差 F(x)。在旁路上,捷徑(或稱 shortcut)把 x 原封不動往前帶。兩條路徑在一個加法處會合:我們把 F(x) 與 x 逐元素相加。相加的結果再通過最後一個 ReLU,成為這個區塊的輸出。關鍵在於,這個加法要求 F(x) 與 x 形狀相同(高、寬、通道數都一致),才能逐位置相加——這個限制在形狀改變時我們會再回頭處理。

\mathbf{y} = \mathcal{F}(\mathbf{x}, \{W_i\}) + \mathbf{x}

殘差區塊的定義式。

讓我們為每個符號命名。x 是區塊的輸入,也就是從前一個區塊送來的特徵圖(一疊通道)。𝓕(x, {Wᵢ}) 是學到的殘差:主路徑所計算的函數,它是幾層卷積加 ReLU,其可調權重寫成 {Wᵢ}(那些層裡所有權重矩陣 Wᵢ 的集合)。+ x 是恆等捷徑,字面上就是把原始輸入加回來。y 是流向下一個區塊的輸出。現在,直接從式子讀出本篇的重點:如果學習把 𝓕 裡的每個權重都推向零,那麼 𝓕(x,{Wᵢ}) = 0、y = 0 + x = x。區塊自動變成恆等映射。具體點,設 x 是單一數字 x = 3。樸素區塊得學到一組輸出剛好是 3 的權重才能保住它。殘差區塊卻只需要 𝓕 = 0,就免費得到 y = 0 + 3 = 3。因為恆等如今是廉價的預設值,從結構上說,多堆殘差區塊永遠不會讓訓練變更糟——這正是樸素深層堆疊所缺的保證。

為何有效:梯度高速公路與批次正規化

我們已看過前向傳遞的故事(恆等很廉價)。殘差網路之所以真的能被訓練,更深的理由藏在反向傳遞——也就是梯度流動的地方。把它想成一條多線道的路。在樸素深層網路裡,每個梯度都得爬著回穿過每一層的收費站,在每一站被乘上一個(通常會縮小的)因子——這就是梯度消失問題。捷徑連線加了一條快速車道:一條直達路線,讓梯度反向流過整個區塊,而不被它的卷積過濾。即使卷積車道塞車、幾乎傳不出訊號,快速車道仍能讓一個強健的梯度抵達前面的層。

\frac{\partial \mathcal{L}}{\partial \mathbf{x}} = \frac{\partial \mathcal{L}}{\partial \mathbf{y}}\left(1 + \frac{\partial \mathcal{F}}{\partial \mathbf{x}}\right)

反向流過一個殘差區塊的梯度。

這是本篇最重要的式子,我們逐項拆解。L 是損失(loss),一個衡量網路目前錯得多離譜的單一數字,訓練就是要把它最小化。∂L/∂x 是我們要的東西:若我們輕推區塊的輸入 x,損失會如何變化——也就是必須繼續往前傳給較早層的梯度。∂L/∂y 是上游梯度,從上方各層已經傳到的訊號。∂𝓕/∂x 是殘差路徑自己的梯度,描述學到的 F 如何回應 x 的變化。對 y = 𝓕(x) + x 套用連鎖律(chain rule),那個「+ x」項的導數貢獻出乾淨的 1,而 𝓕 項貢獻 ∂𝓕/∂x,合起來得到因子 (1 + ∂𝓕/∂x)。為什麼重要:即使卷積條件很差、使得 ∂𝓕/∂x 縮向 0,括號也只是 (1 + 0) = 1,上游梯度 ∂L/∂y 幾乎毫無折損地通過。這個「+1」就是用微積分寫出來的快速車道——一條無法消失的梯度路徑。用數字看:若一個 50 層的樸素網路把約 0.5 的因子乘 50 次,訊號會衰減到 0.5⁵⁰ ≈ 10⁻¹⁵(沒了)。換成殘差區塊,每個因子大約是 (1 + 小量),所以不論你堆多深,乘積都維持在 1 附近。

快速車道是工程上的一半。另一半是批次正規化(batch normalization,batch norm),ResNet 大量倚賴它——每個卷積後面都接一層 BN。回想它做什麼:對每個通道,它取當前小批次(mini-batch)裡的激活值,減掉它們的平均、除以它們的標準差,讓它們呈現整齊的、大致零均值、單位變異數的分布,再讓網路用兩個可學參數重新縮放與平移。效果是讓每一層的輸入都維持在良好狀態——隨訓練進行,既不爆成巨值,也不塌成微量。這份穩定正是讓你能安心調高學習率、真的訓練 100 層以上的關鍵。殘差連線與批次正規化是搭檔:捷徑讓梯度跨越深度仍活著,批次正規化讓每條路徑上的激活在數值上保持清醒,使那個梯度仍有意義。

批次正規化把每個通道的激活值在小批次上標準化,再用可學參數重新縮放,讓訊號在每個深度都保持良好狀態。

示意圖:一個通道的一批激活值被置中為零均值、縮放為單位變異數,再經過可學的縮放與平移參數,產生正規化後的輸出。

ResNet 內部:基本區塊、瓶頸區塊與深度

現在來看具體架構。ResNet 以一個依深度命名的家族形式推出:ResNet-18、-34、-50、-101、-152,其中數字大致是堆了多少個有權重的層(卷積層加上最後的全連接層)。它們共用同一副骨架——一個 7×7 卷積加池化的「莖部」(stem),接著四個由殘差區塊組成的階段(stage),然後是全域平均池化與一個分類器——差別在於每個階段放多少個區塊,以及關鍵的——它們用兩種區塊設計中的哪一種。

左:用於 ResNet-18/34 的基本區塊(兩個 3×3 卷積)。右:用於 ResNet-50/101/152 的瓶頸區塊(1×1 降維、3×3、1×1 升維)。

兩種殘差區塊設計的並排比較。基本區塊有兩個堆疊的 3x3 卷積加一條捷徑。瓶頸區塊有一個降低通道數的 1x1 卷積、一個在縮減寬度下運作的 3x3 卷積、以及一個把通道數升回去的 1x1 卷積,同樣有一條捷徑。

基本區塊就是我們在第 2 節畫的那個:兩個 3×3 卷積(各自後接批次正規化與 ReLU)加上捷徑。它用在較淺的 ResNet-18 與 -34。但兩個對著比如 256 通道運作的 3×3 卷積很昂貴:一個從 256 通道到 256 通道的 3×3 卷積,每個輸出位置要花 3·3·256·256 ≈ 59 萬次乘加。堆它 50 次以上,帳單就爆了。所以更深的版本改用瓶頸區塊(bottleneck block),它重用了上一篇的 1×1 技巧。

瓶頸區塊有三個卷積:一個降維的 1×1(比如 256 → 64),一個在便宜的縮減寬度下做空間運算的 3×3(64 → 64),以及一個升維的 1×1(64 → 256),好讓捷徑相加仍對得上。名字來自它的形狀:寬、中間捏窄、再變寬。為什麼這麼省:昂貴的 3×3 現在在 64 通道而非 256 通道上運作,而兩個 1×1 卷積很便宜,因為 1×1 沒有空間範圍。我們算算看。降維花 1·1·256·64 ≈ 1.6 萬,3×3 花 3·3·64·64 ≈ 3.7 萬,升維花 1·1·64·256 ≈ 1.6 萬,每個位置合計約 7 萬次乘加,對比全寬下單一 3×3 的 59 萬。瓶頸用一小部分的算力買到了深度——這正是 ResNet-50/101/152 仍負擔得起的原因。

還有兩個讓階段彼此銜接的管線零件。第一,降採樣(downsampling):階段之間,空間解析度應減半(讓後面的層看到較粗、較抽象的格點),而通道數加倍。ResNet 用帶步幅的卷積(strided convolution)做這件事——一個一次跨兩個像素而非一個的卷積,於是一張 56×56 的圖在單一層內就變成 28×28,而不必另外用一個池化步驟。第二,投影捷徑(projection shortcut)。記得捷徑相加需要兩條路徑形狀相符。在階段交界處,主路徑同時改變了空間尺寸與通道數,於是恆等的 x 不再與 F(x) 相符。解法是在捷徑路徑上放一個同樣步幅的 1×1 卷積——剛好足夠的運算來重塑 x(把它在空間上降採樣並調整通道),讓加法重新對齊。在階段內部,形狀不變的地方,捷徑就維持為樸素、免費的恆等映射。

# One ResNet bottleneck block (PyTorch-style pseudocode)
# in_ch=256 -> mid=64 -> out_ch=256, with an optional downsampling skip

class Bottleneck:
    def __init__(self, in_ch, mid, out_ch, stride=1):
        # 1x1 reduce: shrink channels (cheap) before the costly 3x3
        self.reduce = Conv1x1(in_ch, mid)
        self.bn1    = BatchNorm(mid)
        # 3x3 spatial conv at the narrow width; stride halves H,W when >1
        self.conv   = Conv3x3(mid, mid, stride=stride)
        self.bn2    = BatchNorm(mid)
        # 1x1 expand: restore channels so the skip-add lines up
        self.expand = Conv1x1(mid, out_ch)
        self.bn3    = BatchNorm(out_ch)
        # projection shortcut ONLY when shape changes (stride>1 or ch differ)
        self.proj = None
        if stride != 1 or in_ch != out_ch:
            self.proj = Conv1x1(in_ch, out_ch, stride=stride)

    def forward(self, x):
        identity = x if self.proj is None else self.proj(x)  # reshape skip if needed
        out = relu(self.bn1(self.reduce(x)))
        out = relu(self.bn2(self.conv(out)))
        out = self.bn3(self.expand(out))   # NOTE: no ReLU yet
        out = out + identity               # the residual addition
        return relu(out)                   # final activation AFTER the add
一個 ResNet-50 的瓶頸區塊。ReLU 在捷徑相加之後才出現,而投影捷徑只在區塊改變形狀時才啟用。

ResNeXt:基數,第三個調節桿

ResNet 行得通之後,把網路做大的明顯做法就是更(更多區塊)或更(每層更多通道)。ResNeXt 提出了第三個獨立的調節桿,並把它命名為基數(cardinality):一個區塊內平行變換分支的數量。一個 ResNeXt 區塊不是用單一瓶頸做 256 → 64 → 64 → 256,而是並排跑比如 32 條較小的分支,每條做 256 → 4 → 4 → 256,再把它們的輸出加總。基數就是你有多少條這樣的分支。

如果這聞起來像上一篇的 Inception 模組,你的直覺沒錯——兩者都遵循分裂—變換—合併(split-transform-merge)的範式:把訊號分成平行路徑、各自變換、再合併。但有一個鮮明、刻意的差異。Inception 的分支全都不同(這裡一個 1×1、那裡一個 3×3、別處一個 5×5),是手工設計又繁瑣的。ResNeXt 的分支全都是同一個便宜的拓樸,只是重複而已。這份一致性正是它的賣點:只有一種分支設計要調,而要放大這個區塊只需改一個整數(基數),而非重新設計一群形狀各異的路徑。更好推理,也更好擴展。

\mathbf{y} = \mathbf{x} + \sum_{i=1}^{C} \mathcal{T}_i(\mathbf{x})

ResNeXt 核心的聚合變換。

我們為符號命名,並看看它如何推廣了殘差區塊。x 一樣是區塊輸入,y 是它的輸出。C 是基數,也就是平行分支的數量。每個 𝓣ᵢ(x) 是一條變換分支——實務上對每個 i 都是同一個小瓶頸形狀,但各有自己獨立的權重,因此不同分支學到不同的專家。從 i = 1 到 C 的 Σ 表示我們把全部 C 條分支的輸出逐元素相加(合併),而 + x 是我們熟悉的殘差捷徑。檢查一下:令 C = 1、只有一條分支,公式就塌成 y = x + 𝓣₁(x),正是第 2 節那個普通的殘差區塊。所以 ResNeXt 是嚴格的推廣:把基數旋鈕從 1 轉到 32,就加進了平行的專家,而不動到深度或每條分支的設計。

基數的運作:C 條平行分支各自變換同一個輸入,它們的特徵圖在殘差相加前先被加總。

ResNeXt 區塊示意圖:輸入扇出到 C 條相同的瓶頸分支,每條產生自己的特徵圖;這些特徵圖被加總後,再經由捷徑連線與輸入相加。

實際的回報:在大致固定的算力與參數預算下,提高基數通常比把同樣的資源花在額外的寬度或深度上更能提升準確率。許多平行的專家勝過一個臃腫的通才。這裡還有一個與下一篇相關的乾淨實作細節。讓 C 條相同的分支各自只碰一片通道,正好就是一個分組卷積(grouped convolution)——把單一卷積層拆成 C 個彼此獨立、不跨組混合通道的群組。所以 ResNeXt 骨子裡就是一個瓶頸,其中間的 3×3 是一個有 C 組的分組卷積。記住分組卷積:第 4 篇講求效率的架構——尤其是 ShuffleNet——會直接建立在它之上,把視覺模型擠進手機裡。

DenseNet:把每一層都接起來

ResNet 問的是:如果一層只學對它輸入的修正會怎樣?DenseNet 把連接的想法推到極致,問了另一個問題:如果每一層都能看見它之前所有層的輸出呢?在一個密集區塊(dense block)裡,第 1 層餵給第 2、3、4… 層;第 2 層餵給第 3、4… 層;依此類推。每一層收到的輸入,是這個區塊裡前面每一層輸出的疊加。ResNet 加的是一條快速車道,DenseNet 則接出一張密密麻麻的捷徑網,讓資訊與梯度抵達各處。

\mathbf{x}_\ell = H_\ell\!\left([\mathbf{x}_0, \mathbf{x}_1, \ldots, \mathbf{x}_{\ell-1}]\right)

密集連接:第 ℓ 層看見所有較早特徵圖的串接。

逐個符號看。x₀ 是密集區塊的輸入,而 x₁, …, x_{ℓ−1} 是第 1 到第 ℓ−1 層產生的特徵圖。方括號 [x₀, x₁, …, x_{ℓ−1}] 表示串接(concatenation):把那些特徵圖沿通道軸並排成一個高高的疊堆(這一步不是相加)。H_ℓ 是第 ℓ 層的變換,在 DenseNet 中是批次正規化、再 ReLU、再卷積的組合。x_ℓ 是第 ℓ 層輸出的東西,它接著也加入那個越來越大的串接,供更後面的層讀取。直接對比 ResNet 的 y = 𝓕(x) + x:那裡較早的訊號被進新訊號裡,作為獨立個體就消失了;這裡它被起來、原樣保留,所以每個更後面的層都還能回頭重用原始的早期特徵。重點全在於這種保留,而非融合。

因為每一層的輸出都被保留並重用,各層不必重新推導已經存在的特徵,所以每一層都可以刻意做得很,只貢獻少量的新通道。那個數量就是 DenseNet 的招牌超參數——成長率(growth rate)k(常見 k = 12 或 32):每一層恰好往疊堆裡加 k 張新特徵圖。經過 ℓ 層後,區塊裡有 x₀ 的通道數加上 ℓ·k,呈線性增長。具體來說,以 64 通道輸入、k = 32 為例,連續各層的輸入是 64、然後 96、然後 128、然後 160 通道——每一層加上它的 32,再把整堆往下傳。因為 k 很小,總參數量出奇地低——這就是為什麼 DenseNet 能用明顯更少的參數達到與 ResNet 相當的準確率。

在密集區塊內,每一層讀取所有較早特徵圖的串接,並附上 k 張新的,使通道疊堆逐層增長。

密集區塊示意圖:每個先前層的特徵圖被串接後餵入下一層,下一層再把少量 k 張新特徵圖附加到不斷增長的疊堆上。

盤點一下取捨。優點:梯度流動極好,因為每一層都透過串接擁有一條幾乎直達損失的路徑(是第 3 節梯度高速公路的更密版本);參數使用有效率,得益於薄薄的成長率層;特徵重用達到極致,因為沒有資訊被覆蓋。代價:串接很吃記憶體。相加(ResNet)讓通道數保持平穩,但疊加(DenseNet)讓激活越堆越高,而且所有那些中間特徵圖都得留在記憶體裡供反向傳遞使用——這就是 DenseNet 在有限硬體上訓練起來可能較棘手的實際原因。實務上,DenseNet 在密集區塊之間插入轉換層(transition layer,一個 1×1 卷積加池化)來壓縮通道疊堆並把空間尺寸減半,讓增長受控。於是,一場革命長出兩種哲學:ResNet 加上修正,DenseNet 保留並重用——而幾乎每個現代視覺骨幹網路,都是這兩者其中之一、或兩者兼具的後裔。