標準卷積的成本問題
在第 2 篇我們數過卷積層的參數量:一疊濾波器需要 K × K × C_in × C_out 個可學習的數值,其中 K 是卷積核的邊長,C_in 是輸入特徵通道的數量,C_out 是你想要的輸出通道數量。參數告訴你權重佔多少記憶體,但它不會告訴你跑這層需要多少運算量,而運算量才決定模型能不能在手機上順跑。
關鍵洞察是:那 K × K × C_in × C_out 次乘加並不是只做一次——它在每一個輸出位置都重複一遍。濾波器滑過整張特徵圖,每停一格就重算一次完整的內積。所以真正的計算成本是把參數量再乘上輸出位置的數量 H_out × W_out。
一個標準卷積層的計算成本。
逐項來讀。H_out · W_out 是輸出特徵圖的像素數量——每一格都是濾波器要重做一次工作的地方。K · K 是單一通道、單一核視窗內的乘加次數。出現 C_in 是因為每個輸出值要把所有輸入通道的貢獻加總,所以那個 K · K 視窗要對每個輸入通道各做一遍。出現 C_out 是因為你學的不是一個濾波器,而是 C_out 個,各自產生一個輸出通道。把這四組相乘,就得到這層在一張影像上所做的乘加總次數。本篇的每個技巧,都是在攻擊其中一個或多個因子。
來代入數字。假設網路中段有一層維持 56 × 56 的特徵圖,用 3 × 3 的核,把 256 個輸入通道映射到 256 個輸出通道。那就是 56 · 56 · 3 · 3 · 256 · 256 ≈ 18.5 億次乘加——而這只是一層、一張影像。真實網路會疊上數十層這樣的層,影片應用每秒還得做 30 次。在電力預算極小的手機上,標準卷積很快就成了瓶頸。
所以本篇的目標可以直白地說:在保留大部分建模能力的同時,大幅削減乘加次數。我們用三個工具來達成,接下來逐一介紹——1 × 1 卷積(便宜地混合通道)、膨脹卷積(免費地看更廣)、以及深度可分離卷積(把昂貴的卷積拆成兩個便宜的)。它們都不是投機取巧;每一個都是針對「你到底需要上面哪個因子」的乾淨想法。
1×1 卷積:混合通道
第一個工具聽起來小到幾乎沒意義:1x1 卷積,又叫逐點卷積(pointwise)。它的核只有一個像素寬、一個像素高——K = 1。乍看毫無用處,因為根本沒有鄰域可以滑窗。但把空間的部分拿掉之後,剩下的東西看看是什麼:深度。
用 1 × 1 的核,完全沒有空間混合——每個輸出像素只取決於那一個輸入像素上那一疊通道值,絕不碰鄰居。但它徹底混合通道:每個輸出通道都是該位置上所有 C_in 個輸入通道的加權和。換句話說,1 × 1 卷積是一個沿著深度軸運作的小型線性層,在每個像素獨立施作。它重塑通道維度,卻完全不動空間網格。
一個網格,每格都裝著一疊垂直排列的彩色通道值,箭頭顯示某個像素上的那一疊被重新加權成一疊較短的輸出。
這為什麼這麼有用?最主要的用途是便宜地改變通道數量。想像一張有 256 個通道的特徵圖,你想用昂貴的 3 × 3 卷積做空間濾波。直接在 256 通道上跑 3 × 3 卷積很貴(回想成本公式裡 C_in 和 C_out 都在)。換個做法:先用 1 × 1 卷積把 256 → 64 壓縮,在較瘦的 64 通道張量上做昂貴的空間運算,再用另一個 1 × 1 卷積把 64 → 256 擴張回去。這種「壓縮再擴張」的模式叫做瓶頸(bottleneck),在現代網路裡到處都是。
把壓縮算清楚。一張 56 × 56 × 256 的特徵圖,經過產生 64 個輸出通道的 1 × 1 卷積:得到 56 × 56 × 64。成本是 H · W · K · K · C_in · C_out = 56 · 56 · 1 · 1 · 256 · 64 ≈ 5,100 萬次乘加。對比第 1 節那層 3 × 3、256→256 的約 18.5 億:1 × 1 卷積便宜了 30 倍以上,而且它還把下一層要處理的通道數減成 1/4。1 × 1 卷積也帶來真實的建模能力——接上一個非線性後,它讓網路學到有用的跨通道互動(哪個通道該強化或抵銷哪個通道)。
更深一層的看法是:1 × 1 卷積是一個沿深度方向、類似多層感知器的微型線性映射,而同一個權重矩陣會在每一個像素重複使用。這又是權重共享——正是第 2 篇讓普通卷積層變得高效的那個原則,如今純粹沿著通道軸施展。一小組權重,在整張空間網格上反覆播放。
# A 1x1 conv is literally a per-pixel matrix multiply across channels.
# x: input feature map, shape (H, W, C_in)
# W: weight matrix, shape (C_in, C_out) <- ONE matrix, shared over all pixels
# y: output feature map, shape (H, W, C_out)
for i in range(H):
for j in range(W):
# take the channel vector at pixel (i, j): length C_in
# multiply by the shared weight matrix -> length C_out
y[i, j] = x[i, j] @ W # no neighbours touched: pure channel mixing膨脹卷積:視野更廣、成本不變
第 3 篇我們認識了感受野:後段某個神經元真正能看到原圖多大一塊。我們靠疊層、靠池化/跨步來把它養大。但池化和跨步換取視野的代價是丟掉解析度——特徵圖會縮小。對於需要在每一個像素都給標籤的任務(例如語意分割),這種細節流失很傷。膨脹卷積(又叫 atrous,法文「帶洞」)讓你拿到視野,卻不必付這個代價。
想法美在簡單:保留同樣的 K × K 卷積核,但把各個權重點(tap)拉開,在它們之間插入空隙,空隙大小由膨脹率 d 控制。d = 1 時就是各權重點緊鄰的普通卷積。d = 2 時,每兩個權重點之間跳過一個輸入像素,於是核能伸到更廣的範圍,卻仍只用它那 K × K 個權重。參數量一個都沒加,特徵圖也不縮小——跨步依舊是 1。
膨脹核的有效覆蓋邊長。
逐個符號拆解。K 是單邊真正的權重點數量——你真的會去學、會去付成本的權重。d 是膨脹率,即相鄰權重點之間插入的空隙。K_eff 是核現在所跨越區域的邊長。為什麼是這個公式?一排 K 個權重點之間有 (K − 1) 個間隙。膨脹把每個間隙撐大 (d − 1) 個額外的空格。於是覆蓋邊長從 K 長到 K + (K − 1)(d − 1),即使真正起作用的還是只有 K 個權重——其餘的跨幅都是不貢獻任何東西的洞。
用例子驗算。取 K = 3、d = 2:K_eff = 3 + (3 − 1)(2 − 1) = 3 + 2 = 5。所以膨脹率 2 的 3 × 3 核跨越輸入的 5 × 5 區域——感受野從 3×3 跳到 5×5——卻仍恰好握有 3 × 3 = 9 個權重,每通道每位置仍只花 9 次乘加,跟先前一樣。你用 3 × 3 的價錢買到了 5 × 5 的視野。
一個網格,九個核權重點隔一格擺放一個,覆蓋 5×5 區域,而它們之間的格子保持空白。
直覺畫面:把手指張開,同一隻手就能用同樣多的指尖摸到更大一片區域。你沒有增加手指(權重),只是把它們伸得更開。把幾層膨脹卷積疊起來、讓 d 逐步變大(例如 1、2、4),感受野就指數成長,而特徵圖維持滿解析度——這正是膨脹成為語意分割等密集預測網路主力的原因。
深度可分離卷積
現在進入核心。標準卷積一次做兩件事:它在空間上(跨 K × K 鄰域)混合資訊,同時跨通道(對 C_in 加總)混合,兩者融合在單一運算裡。深度可分離卷積問:如果我們把這兩件事拆成各自獨立、便宜得多的步驟,會怎樣?結果是真的可以——而且能用零頭的成本逼近一個完整卷積。這個分解就是 MobileNet 與 Xception 內部的引擎。
- 深度卷積(depthwise):對每個輸入通道各施作一個 K × K 的空間核,彼此獨立。第 1 通道的濾波器只碰第 1 通道,第 2 通道的只碰第 2 通道,以此類推。這完成了空間濾波,但完全不做跨通道混合——通道之間保持分離。輸出仍是 C_in 個通道。
- 逐點卷積(pointwise):施作一個 1 × 1 卷積,把那 C_in 個通道混合成 C_out 個輸出通道。這正是第 2 節那個通道混合工具,而且完全不做空間運算(K = 1)。
合起來,深度卷積(管空間)+ 逐點卷積(管通道)重現了標準卷積原本一起做的事。接著看回報,以每個輸出位置的 FLOPs 計。標準卷積每位置花 K · K · C_in · C_out 次乘加。深度步驟花 K · K · C_in(每通道一個 K×K 濾波器,沒有 C_out 因子,因為它不改變通道數)。逐點步驟花 1 · 1 · C_in · C_out = C_in · C_out。所以可分離版本每位置花 K · K · C_in + C_in · C_out——是兩個小項的相加,而非四個因子的相乘。
深度可分離成本除以標準成本。
我們把這個除法的每一步走一遍,讓加速一目了然。分子是可分離成本:深度的 K·K·C_in 加逐點的 C_in·C_out。分母是完整卷積成本 K·K·C_in·C_out。把分數拆成兩塊。第一塊 (K·K·C_in)/(K·K·C_in·C_out),上下約掉 K·K·C_in,剩下 1/C_out。第二塊 (C_in·C_out)/(K·K·C_in·C_out),約掉 C_in·C_out,剩下 1/(K·K)。相加:ratio = 1/C_out + 1/(K·K)。兩個乾淨的倒數項。
接著解讀這兩項的意義。當 C_out 很大時(256、512……),1/C_out 微乎其微、基本消失——所以節省由第二項 1/(K·K) 主導。代入常見的 K = 3:1/(K·K) = 1/9。若 C_out = 256,則 ratio = 1/256 + 1/9 ≈ 0.0039 + 0.111 ≈ 0.115,約為 1/8.7。所以在 K = 3 時,深度可分離區塊大致做同樣的事,卻少了 8–9 倍的乘加。(K = 5 時主導項是 1/25,優勢擴大到約 20 倍。)正是這個分解的想法,讓準確的視覺模型能在手機上即時運行。
# Standard conv vs depthwise-separable conv (PyTorch-style), same in/out shapes.
import torch.nn as nn
# (a) Standard 3x3 conv: spatial + channel mixing fused. Expensive.
std = nn.Conv2d(in_channels=256, out_channels=256, kernel_size=3, padding=1)
# (b) Depthwise-separable: two cheap steps that together do the same job.
dw = nn.Conv2d(256, 256, kernel_size=3, padding=1,
groups=256) # groups=C_in => one K x K filter PER channel
pw = nn.Conv2d(256, 256, kernel_size=1) # 1x1 => mix channels, no spatial work
sep = nn.Sequential(dw, pw)
# FLOPs per output position:
# standard : 3*3*256*256 = 589,824
# separable: 3*3*256 + 256*256 = 2,304 + 65,536 = 67,840
# ratio : 67,840 / 589,824 ~= 0.115 (about 8.7x cheaper)把零件組起來:高效架構
這三個工具不是對手——它們組合成一個可重複使用的建構區塊。驅動高效行動網路骨幹的模式是反向殘差瓶頸(inverted-residual bottleneck):先用 1 × 1 卷積把通道擴張,接著用深度卷積在那個寬張量上做空間濾波,最後用 1 × 1 卷積投影壓回去。這就是第 2 節的瓶頸想法,只是裡外翻轉——先擴張、在寬處濾波、再壓縮。
- 擴張:用一個 1 × 1 1x1 卷積把通道數抬高(例如 24 → 144),給下一步一個更豐富的空間來運作。其後接一個非線性。
- 空間濾波:用深度卷積(深度可分離卷積的空間那一半)獨立處理那 144 個通道——因為此處沒有跨通道加總,所以便宜。對於需要上下文的任務,可選擇在這裡用膨脹卷積來加寬感受野。
- 投影:第二個 1 × 1 卷積壓回去(144 → 24)。當輸入與輸出形狀相符時,直接跨過整個區塊加上一條殘差捷徑(這也是它叫「殘差」的原因)。
一條水平管線,堆疊著多個高效區塊,每個都標出擴張、深度、投影三個子步驟,末端接上分類頭。
用這些區塊做設計,本質上是在平衡部署所在意的三件事:準確度(預測有多好)、延遲(一張影像跑多快)、以及記憶體(啟用值與權重需要多少 RAM)。簡單指引:想改變通道數或加上便宜的跨通道推理時,就用 1 × 1 卷積;需要更大感受野卻必須維持滿空間解析度(分割、偵測)時,就用膨脹;當普通 3 × 3 卷積是 FLOP 熱點、而你願意拿一點點準確度換大幅加速時,就用深度可分離。
你現在握有一套工具,能打造視野廣、運算卻輕的網路。但有個我們一直繞過的陷阱:把數十個這種區塊疊成一個非常深的網路,它不會自己訓練好。梯度可能消失、啟用值可能漂移,而天真的深層堆疊甚至可能變得更糟。第 5 篇——也是壓軸——正是處理這件事:如何真正訓練深層 CNN,以及讓這些高聳的高效堆疊得以學習的正規化、殘差連接與初始化技巧。