效率問題:FLOPs、延遲與手機
在這個主題系列到目前為止,準確率一直是王道。從 LeNet 到 AlexNet,接著 VGG 與 Inception,再到一百層深的 ResNet,每一篇都在問同一個問題:怎麼讓網路把圖像認得「更準」?但現實世界還在乎另一個同樣重要的問題:這東西你真的「跑得動」嗎?一個需要一整櫃 GPU 才能運行的網路,對於用臉解鎖手機、或對那種每秒要重畫 30 次的即時相機濾鏡來說,完全派不上用場。在這篇裡,目標從「最準」變成「在一顆小晶片上,能負擔得起的最高準確率」。
要談「負擔得起」,我們需要三個樸實的指標。參數量是學到的權重數目,它決定模型佔多少「記憶體」(一百萬個參數、每個 4 個位元組,大約就是 4 MB)。FLOPs(浮點運算次數,通常以乘加運算來計)衡量一次前向傳遞要花多少純「算術」——這是計算的帳單。延遲則是使用者真正感受到的:在某個特定裝置上,從餵進一張圖像到拿到答案,實際經過的牆鐘時間,以毫秒計。
有了這個框架,本篇接下來就是一趟導覽,看看哪些技巧能讓現代網路住進你的口袋。我們會先從最重要的一招開始——深度可分離卷積,也就是 MobileNet 內部的引擎——接著加上通道洗牌、通道注意力,以及一種把整個網路放大或縮小的巧妙方法。這些招數沒有一個是丟棄你已經學會的卷積觀念;它們只是把這些觀念重新排列得更便宜。
深度可分離卷積:把工作拆開
想想一個普通卷積層到底在做什麼。每一個輸出通道,都是由一個小濾波器在圖像上滑動產生的,而在每一個位置,它會「同時」看「全部」的輸入通道、並橫跨一個小小的空間視窗。所以單一個標準卷積其實是把兩件工作黏在一起做:在「空間」上混合資訊(相鄰的像素),以及在「通道」上混合資訊(不同的特徵圖)。對每一個輸出通道都同時做這兩件事,成本就是這樣堆起來的。
一個卷積濾波器在多通道特徵圖上滑動的示意圖,在一個小空間視窗內涵蓋所有通道。
深度可分離卷積就是把這兩件工作「拆開」,變成依序進行的兩個便宜步驟。第一步,深度卷積:給每一個輸入通道各自一個小空間濾波器,讓它只濾自己——完全不混通道。第二步,逐點卷積:一個 1×1 卷積(正是 VGG/Inception 那篇的招數),它在單一像素位置上橫跨所有通道,把它們混成新的輸出通道——完全不混空間。打個比方:與其讓一個工人去做一份龐大的綜合工作,不如請一位專家負責「空間」、另一位負責「通道」。連續做兩件簡單的工作,結果遠比做一件融合的工作便宜得多。這個分解就是 MobileNet 與 Xception 內部的引擎,後來也用在 EfficientNet 裡。
標準卷積與其深度可分離替代版本的成本,以及由此得到的比值。
我們把每個符號讀一遍。D_K 是核大小(例如 3×3 濾波器就是 3),M 是輸入通道數,N 是輸出通道數,D_F 是特徵圖的寬/高(所以 D_F^2 數的是濾波器必須走訪的空間位置數)。標準成本 D_K^2\cdot M\cdot N\cdot D_F^2 的意思是:對 D_F^2 個位置中的每一個、N 個輸出中的每一個,你都要做一次 D_K^2\times M 的乘加——全部相乘在一起。可分離成本則拆成深度項 D_K^2\cdot M\cdot D_F^2(M 個通道各用自己的 D_K^2 核濾波,沒有 N 這個因子!)加上逐點項 M\cdot N\cdot D_F^2(1×1 的混合,所以沒有 D_K^2 因子)。把可分離成本除以標準成本,D_F^2 約掉,代數就收斂成 \tfrac{1}{N}+\tfrac{1}{D_K^2}。代入一個典型的層,D_K=3、輸出通道很多(比如 N=256):比值是 \tfrac{1}{256}+\tfrac{1}{9}\approx 0.004+0.111\approx 0.115——大約是原本計算量的 九分之一,也就是約 8 到 9 倍的節省。為什麼拆開會贏?直覺是:昂貴的標準卷積要把 D_K^2 空間因子與 N 通道因子「相乘」付出;把空間和通道分開後,你改成把它們「相加」付出,而兩個小數字之和遠小於它們的乘積。
import torch.nn as nn # Standard conv: one big op that mixes space AND channels at once std = nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1) # Depthwise separable = two cheap ops done in sequence: # 1) depthwise: each channel filtered on its own (groups == in_ch) depthwise = nn.Conv2d(in_ch, in_ch, kernel_size=3, padding=1, groups=in_ch) # 2) pointwise: a 1x1 conv that mixes channels (no spatial window) pointwise = nn.Conv2d(in_ch, out_ch, kernel_size=1) separable = nn.Sequential(depthwise, pointwise) # same in/out shape, ~9x cheaper
MobileNet 與 Xception:同一想法的兩種詮釋
深度可分離卷積最美的一點是:同一個建構積木,會隨著拿它的人不同,服務兩個完全不同的志向。MobileNet 拿它來追求又小又快:它把深度可分離積木堆疊起來,做出一個只有同級標準 CNN 一小部分大小的網路,輕到能在手機 CPU 上即時運行。準確率落在同一個級距,成本卻只是零頭。
在這個基底之上,MobileNet 還加了兩個誠實的「旋鈕」,讓你不必重新設計,就能用準確率換速度。寬度乘數把每一層按某個比例變瘦(比如 0.5×),於是每層的通道數減半——特徵變少、計算變少、準確率略降。解析度乘數則餵給網路較小的輸入圖像(例如 160×160 而非 224×224),這會縮小每一張特徵圖,連帶縮小每一層的工作量。這兩個旋鈕都不是魔法;它們是刻意設計、效果可預測的調節器,讓單一架構能橫跨從旗艦到平價的各種手機。
Xception 從相反的方向、追著相反的目標,抵達了同一個運算:它要的是最高準確率,而不是最小尺寸。回想第二篇的 Inception 模組,它把一層的通道分成幾個平行的群組,每組用自己的濾波器處理。Xception 問:如果我們把這種切分推到「極端的極限」——每個通道一個群組,會怎樣?替每一個通道各配一個獨立的空間濾波器,後面接一個 1×1 來重新組合,這正好就是一個深度可分離卷積。所以 Xception(「Extreme Inception」)是 Inception 的極限情形,當作高準確率的主幹來用時,它甚至「贏過」了它所脫胎而來的 Inception。
ShuffleNet:分組卷積加上通道洗牌
對於真正極小的預算——想想便宜的相機晶片或智慧手錶——就連 MobileNet 的 1×1 逐點卷積也會變成最主要的成本(記得,當我們把空間部分弄便宜之後,剩下的就是混通道的那個 1×1)。ShuffleNet 用分組 1×1 卷積來攻擊這最後的成本,借用第三篇 ResNeXt 的「基數(cardinality)」概念:與其用一個 1×1 卷積去混合全部通道,不如把通道分成比如 4 組,讓每組只在自己組內混合。這一下就把 1×1 的成本大致除以組數。
但分組會造成一個實際的問題。如果每一層都把通道固定分在某些組裡、而且各組之間永遠不交談,資訊就會被「孤立」起來:第 1 組算出的特徵永遠只餵給下一層的第 1 組,第 2 組只餵第 2 組,以此類推。網路實際上分裂成好幾個平行的窄網路,彼此從不分享所學——這會嚴重傷害準確率。
解法是出奇簡單的通道洗牌。在一次分組卷積之後,你把通道「重新排列」,讓下一層的每一組,都收到來自先前「所有」組的混合。想像一場派對:大家一開始只在自己那一桌聊天(各組),然後每一輪之間所有人重新換位,讓每張新桌子都坐著來自每張舊桌子各一人——現在點子就傳遍整個房間了。或者想像一副牌發成四疊,再重新發牌,讓每一疊新牌都含有原本四疊各自的牌。洗牌之後,組的界線不再困住資訊,而你付的依然只是便宜的分組卷積成本。
堆疊的特徵圖通道被分成不同顏色的組,然後重新排列,使每個新組都含有每種原始顏色的通道。
Squeeze-and-Excitation:便宜的通道注意力
到目前為止,卷積產生的每一個通道都被當成同等重要。但對某一張特定圖像來說,有些特徵通道遠比其他通道有用——對一隻貓而言「毛皮紋理」通道很重要,「車輪」通道則不然。Squeeze-and-Excitation 區塊(SE)是一個極小、幾乎免費的附加元件,它讓網路能「按重要性、針對每一個輸入,學會重新加權自己的通道」。它是一種早期、輕量的注意力形式。打個比方:一台每個聲道各有一支音量推桿的混音器——SE 把載有有用訊號的通道調大、把載有噪音的通道調小,而且它是從資料中學會這些推桿的位置的。
- 壓縮(SQUEEZE):用全域平均池化(就是第二篇那個池化概念)把每個通道整張 H×W 的特徵圖壓成一個數字。這樣每個通道得到一個精簡的描述值——一個「這個通道整體上發放了多少」的摘要。
- 激發(EXCITE):把這些每通道的數字送進一個極小的兩層瓶頸網路(一個降維的小全連接層、一個 ReLU、再一個把維度還原的層),最後接一個 sigmoid,為每個通道產生一個介於 0 到 1 之間的權重。
- 縮放(SCALE):把每個原始通道的特徵圖,乘上它學到的那個 0 到 1 的權重。重要的通道大致保留它的強度;不重要的則被調向零。
一張多通道特徵圖被池化成每通道一個值,形成一個短描述向量。
先激發再縮放:一個兩層的閘把壓縮後的描述向量變成每通道的權重。
逐一看符號:\mathbf{z} 是壓縮後的描述向量——由步驟 1 的全域平均池化產生,每個通道一個數字,所以它的長度等於通道數 C。W_1 是第一個小全連接層;它把維度「降低」(通常以像 16 這樣的比例,把 C 個數字變成 C/16 個),好讓這個區塊維持便宜。\delta 是夾在中間的 ReLU 非線性。W_2 是第二個全連接層;它把維度「還原」回到 C。\sigma 是 sigmoid,把每個輸出壓進 0 到 1 的範圍,給出重要性權重 \mathbf{s}。最後,s_c 是通道 c 學到的權重,x_c 是通道 c 的原始特徵圖,所以 \tilde{x}_c = s_c\cdot x_c 就是被自己的權重縮放過的那個通道。一個小小的實例:假設經過閘之後,通道 1 拿到 s_1=0.95(幾乎完整保留)、通道 2 拿到 s_2=0.05(幾乎被靜音);這個層就等於「針對這張特定圖像」判定通道 1 有資訊、通道 2 沒有。整個閘只多花約 C^2/16 個參數——相對於那些卷積根本是捨入誤差——這正是為什麼 SE 可以被丟進 ResNet、MobileNet 以及幾乎任何主幹,換來一份近乎免費的準確率提升。
import torch
import torch.nn as nn
class SEBlock(nn.Module):
def __init__(self, channels, r=16):
super().__init__()
self.pool = nn.AdaptiveAvgPool2d(1) # SQUEEZE: H,W -> 1,1
self.fc1 = nn.Linear(channels, channels // r) # W1: reduce dim
self.fc2 = nn.Linear(channels // r, channels) # W2: restore dim
def forward(self, x): # x: (N, C, H, W)
n, c, _, _ = x.shape
z = self.pool(x).view(n, c) # z: per-channel descriptor (N, C)
s = torch.relu(self.fc1(z)) # delta(W1 z)
s = torch.sigmoid(self.fc2(s)) # sigma(W2 ...) -> weights in [0,1]
return x * s.view(n, c, 1, 1) # SCALE: reweight each channelEfficientNet:同時縮放深度、寬度與解析度
現在我們手上有便宜的建構積木了。最後一個問題是:當你有了一個不錯的小網路、又多了一點計算預算,該怎麼把它「養大」?恰好有三個軸。深度 = 更多層(堆更多積木)。寬度 = 每層更多通道(就是 MobileNet 寬度乘數所變瘦的那個軸)。解析度 = 更大的輸入圖像(更多像素、更大的特徵圖)。早期的網路通常只猛催其中一個——VGG 走深、其他的走寬——而每個軸單獨催下去,出乎意料地很快就遇到報酬遞減。
EfficientNet 的洞見是:用一個固定、平衡的比例,把這三個軸「一起」縮放,並由單一個使用者旋鈕來控制。打個比方:如果你想要更大更強壯的身體,你不會只把腿長長——你會讓腿、軀幹、手臂按比例一起長,否則就會失衡而虛弱。EfficientNet 透過一次小規模搜尋,找到了把深度、寬度、解析度一起長大時「該有的比例」,然後對外只暴露一個數字,讓這三者同步成長。
複合縮放:單一旋鈕 φ 以固定的比率 α、β、γ 同時放大深度、寬度與解析度。
把這條規則拆開來看。\phi(phi)是使用者轉動的那個唯一「計算預算」旋鈕:\phi=0 是那個小小的基線網路,每往上一格就花更多計算。\alpha、\beta、\gamma 是各軸的固定成長率,由一次小型網格搜尋一次性找出——\alpha 給深度、\beta 給寬度、\gamma 給解析度,每個都至少是 1,所以網路只會變大。把每個都取 \phi 次方,意思是轉動旋鈕會同時放大三個軸:深度乘 \alpha^\phi、寬度乘 \beta^\phi、輸入解析度乘 \gamma^\phi。而限制式 \alpha\cdot\beta^2\cdot\gamma^2\approx 2 是精巧之處:它把一切釘住,使得「每增加一單位的 \phi,總 FLOPs 大致翻倍」。為什麼 \beta 與 \gamma 要平方、\alpha 卻不用?因為增加通道(寬度)會在「兩個」維度上花計算——更多輸入通道乘以更多輸出通道——而放大圖像(解析度)會在高與寬「兩個」空間維度上花計算;深度只在「一個」維度上加層,所以是一次方。具體例子:已發表的 EfficientNet 找到大約 \alpha\approx1.2,\ \beta\approx1.1,\ \gamma\approx1.15。驗算一下預算:1.2\times1.1^2\times1.15^2 \approx 1.2\times1.21\times1.32 \approx 1.92\approx 2。所以在 \phi=1 時,深度約長 1.2 倍、寬度約 1.1 倍、解析度約 1.15 倍,計算量大致翻倍——這是平衡的一步,而不是偏頗的一步。一句話的直覺:當你負擔得起更多計算時,把它平均地花在三個軸上,而不是全倒進其中一個。
準確率對計算量的曲線:單軸縮放逐漸趨平,而平衡的複合縮放仍持續進步。
最令人滿足的,是這個網路「由什麼組成」。EfficientNet 反覆使用的建構積木叫做 MBConv,它本身就是用本篇的零件組裝起來的:一個反向瓶頸的深度可分離卷積(第 2 節)——與 MobileNet 相同的引擎——裡頭再嵌進一個 Squeeze-and-Excitation 區塊(第 5 節)來重新加權通道。所以複合縮放,放大的是一個每個積木本來就是效率機器的網路。我們遇過的每一招,最終都組裝進同一個家族。