:空間與通道解耦的輕量化設(shè)計(jì))
做深度學(xué)習(xí)做到今天我見過太多人把 1×1 卷積當(dāng)“降維工具”把 3×3 卷積當(dāng)“特征提取工具”各自用得很溜卻從沒想過它們之間其實(shí)藏著一套組合哲學(xué)。我最初也是這樣——直到有一次要把一個(gè)基準(zhǔn)模型壓縮到能跑在端側(cè)設(shè)備上才把“1×1 3×3”的搭配從“碰巧好用”變成了“刻意設(shè)計(jì)”。這篇文章我想把這兩步的關(guān)系徹底講透它們各自在干什么、為什么組合起來這么強(qiáng)、以及最常見的三種“兩步走”套路分別省在哪、又該怎么在代碼里落地。不管你是剛接觸卷積神經(jīng)網(wǎng)絡(luò)的新手還是已經(jīng)在調(diào)模型但始終對“瓶頸結(jié)構(gòu)”“深度可分離卷積”半懂不懂的從業(yè)者這套思路都值得花十分鐘重新捋一遍??炊竽銜?huì)發(fā)現(xiàn)很多號稱精巧的網(wǎng)絡(luò)結(jié)構(gòu)本質(zhì)上都是在反復(fù)使用同一個(gè)哲學(xué)把空間維度的處理和通道維度的處理解耦開分兩步走。這個(gè)認(rèn)知會(huì)直接影響你設(shè)計(jì)網(wǎng)絡(luò)時(shí)的判斷力。1. 兩種卷積各自的底層邏輯先別混著用1.1 1×1卷積不是“小卷積”它是通道維上的全連接很多初學(xué)者第一眼看到 1×1 卷積會(huì)覺得它“什么都沒做”——畢竟卷積核在空間上只有一個(gè)像素對單個(gè)位置來說它沒有聚合任何鄰域信息。但問題恰恰出在這里1×1 卷積根本不需要空間信息它專管通道之間的線性組合。拿具體計(jì)算來說輸入形狀是 H×W×C_in經(jīng)過一個(gè) 1×1 卷積輸出 H×W×C_out。在空間上每個(gè)點(diǎn) (i, j)它做的事情是把一個(gè)長度為 C_in 的向量通過一個(gè) C_in×C_out 的權(quán)重矩陣映射成另一個(gè)長度為 C_out 的向量。這不就是作用在每個(gè)空間位置上的全連接嗎所以 1×1 卷積有三個(gè)核心作用通道信息混合把不同通道在同一空間位置上的特征重新線性組合讓信息在通道維度上“流動(dòng)”起來。改變通道數(shù)C_in 和 C_out 可以自由設(shè)置想壓縮就壓縮想擴(kuò)張就擴(kuò)張。這是控制模型計(jì)算量的最關(guān)鍵開關(guān)。廉價(jià)的計(jì)算代價(jià)它完全不涉及空間鄰域FLOPs 只有標(biāo)準(zhǔn)卷積的平方反比級別。后面我會(huì)寫具體算式。如果你把網(wǎng)絡(luò)想象成一條流水線1×1 卷積就像流水線上給每件產(chǎn)品“貼標(biāo)簽、重新裝箱”的工位——不改變物品本身的空間位置但徹底重排了它的屬性組合。1.2 3×3卷積才是真正的空間特征提取器3×3 卷積就完全不同了。它的每個(gè)輸出位置會(huì)看輸入特征圖上周圍 3×3 鄰域共 9 個(gè)像素在空間上產(chǎn)生局部感受野。圖像中的邊緣、角點(diǎn)、紋理等最基本的結(jié)構(gòu)都是靠這種局部鄰域聚合才能被捕捉到。那為什么偏偏是 3×3 這么流行而不是 5×5 或者 7×7最經(jīng)典的解釋來自 VGG 系列的經(jīng)驗(yàn)兩個(gè)串聯(lián)的 3×3 卷積感受野等效于一個(gè) 5×5 卷積三個(gè)串聯(lián)的 3×3 卷積感受野等效于一個(gè) 7×7 卷積。但參數(shù)量和計(jì)算量卻少得多。推一下單層 7×7 卷積的參數(shù)量是 49×C×C而三個(gè) 3×3 卷積是 3×9×C×C27×C×C直接省了接近一半。更重要的是兩層 3×3 之間夾著非線性激活比單層 7×7 多引入兩次非線性變換特征表達(dá)能力反而更強(qiáng)。關(guān)于“為什么主流網(wǎng)絡(luò)都在用 3×3 堆疊而不是大卷積核”這是核心答案。1.3 它們不在同一個(gè)維度上干活這才是能組合的前提這句話聽起來像廢話但它是“兩步走”哲學(xué)的全部基礎(chǔ)1×1 卷積只動(dòng)通道維3×3 卷積主要?jiǎng)涌臻g維同時(shí)也在混合通道。一個(gè)管“有哪些信息”一個(gè)管“周圍發(fā)生了什么”。如果你只用 1×1 卷積堆網(wǎng)絡(luò)永遠(yuǎn)無法在空間上感知鄰域結(jié)構(gòu)如果你只用 3×3 卷積堆網(wǎng)絡(luò)通道之間的信息融合效率會(huì)很低而且計(jì)算量爆炸。兩種卷積各自都“缺一半”。把它們按順序組合就是在兩個(gè)維度上分別完成自己的職責(zé)——先整理通道再提取空間或者先提取空間再融合通道。這兩步的順序、次數(shù)、通道比例演化出了過去十年里大量經(jīng)典結(jié)構(gòu)設(shè)計(jì)。2. “兩步走”的三種經(jīng)典走法本質(zhì)是同一種哲學(xué)2.1 先降、再提、后升瓶頸結(jié)構(gòu)BottleneckResNet 系列里最經(jīng)典的瓶頸模塊走法是這樣的先用一個(gè) 1×1 卷積把通道從 256 壓到 64再用一個(gè) 3×3 卷積在低通道數(shù)下做空間特征提取最后用一個(gè) 1×1 卷積把通道恢復(fù)到 256。整個(gè)過程是典型的“降維-卷積-升維”。這一步每一步都有明確動(dòng)機(jī)。直接拿 256 通道做 3×3 卷積計(jì)算量非常大先降維到 64讓 3×3 卷積作用在窄通道上計(jì)算量一下子降到一個(gè)零頭空間特征的提取并沒有損失太多——因?yàn)樾畔⒃诮稻S后被壓縮到了更緊湊的表示里。最后再升維是為了讓輸出通道數(shù)和輸入對齊便于做殘差連接。這一個(gè)模塊內(nèi)部的“兩步走”還不止一次1×1 降維是第一步調(diào)整3×3 提取是第二步空間操作最后的 1×1 升維算是第三步收尾。在 ResNet-50 及更深版本里這種瓶頸結(jié)構(gòu)基本取代了原來的兩層 3×3 結(jié)構(gòu)原因只有一個(gè)同樣表達(dá)能力下FLOPs 和參數(shù)量都大幅下降。2.2 先空間、后通道深度可分離卷積Depthwise Separable ConvolutionMobileNet 系列大量使用的深度可分離卷積是另一種兩步走。它把標(biāo)準(zhǔn)卷積拆成兩個(gè)獨(dú)立操作第一步是深度卷積每個(gè)輸入通道單獨(dú)用一個(gè) 3×3 卷積核去卷通道之間互不干擾第二步是逐點(diǎn)卷積用一個(gè) 1×1 卷積把上一步各通道的結(jié)果按位置重新混合。對比一下標(biāo)準(zhǔn)卷積標(biāo)準(zhǔn) 3×3 卷積同時(shí)完成“空間聚合”和“通道混合”兩步攪在一起計(jì)算量是毫不留情的 C_in×C_out×9。深度可分離卷積把兩者拆開先做空間聚合每個(gè)通道獨(dú)立再做通道混合1×1 卷積計(jì)算量變成 C_in×9 C_in×C_out。這一步拆解能省多少我下一節(jié)給具體數(shù)字。這里先說哲學(xué)層面標(biāo)準(zhǔn)卷積逼著網(wǎng)絡(luò)在同一個(gè)操作里同時(shí)考慮“周圍是什么”和“各通道怎么組合”這兩個(gè)任務(wù)其實(shí)是可以被解耦的。深度可分離卷積的假設(shè)就是——先用 3×3 只提取空間結(jié)構(gòu)再用 1×1 自由融合通道信息效果不會(huì)比同步做差多少但計(jì)算量小了一個(gè)數(shù)量級。2.3 兩步走也能只走通道SE 模塊的啟發(fā)SENet 提出的 SESqueeze-and-Excitation模塊表面上看和卷積無關(guān)實(shí)際上也在用兩步走哲學(xué)第一步是全局平均池化把每個(gè)通道的空間信息壓縮成一個(gè)標(biāo)量第二步是兩個(gè) 1×1 卷積實(shí)現(xiàn)上通常用全連接先降維再升維對每個(gè)通道算出一個(gè)權(quán)重。最后把權(quán)重乘回原特征圖。這一步“壓縮-激勵(lì)”的過程本質(zhì)上就是對通道維度做一次“注意力重標(biāo)定”——先全局看一眼每個(gè)通道整體有多重要再用 1×1 做非線性變換重新分配通道權(quán)重。它和瓶頸結(jié)構(gòu)有個(gè)共同點(diǎn)中間都故意插入了一個(gè)維度瓶頸降維再升維目的是讓網(wǎng)絡(luò)在低維空間學(xué)會(huì)更高效的通道關(guān)系順帶減少計(jì)算量。SE 模塊的流行告訴我一件事1×1 卷積不只是配合 3×3 用的“計(jì)算量調(diào)節(jié)器”它本身就是一種通道維度的建模工具。只要你能把問題拆成“空間上怎么辦”和“通道上怎么辦”兩步這兩步走的思想就可以在不同層級上反復(fù)套用。3. 算一筆賬“兩步走”到底省在哪數(shù)字說了算3.1 誰在消耗算力公式先擺出來標(biāo)準(zhǔn)卷積的 FLOPs乘加次數(shù)通常約等于實(shí)際計(jì)算量的一半可以寫成輸出尺寸 H×W×C_out 再接上遍歷整個(gè)特征圖的所有位置FLOPs H × W × C_out × C_in × k × k這個(gè)公式里最值得注意的變量是 C_in × k×k輸出通道越多、卷積核越大計(jì)算量呈平方級上漲。這也是為什么 7×7 卷積極度昂貴、深網(wǎng)絡(luò)里普遍用 3×3 的原因。為了對比我取一個(gè)非常常見的中間層配置輸入特征圖 28×28×256輸出也是 28×28×256。在這個(gè)配置上分別算四種做法的 FLOPs單層 7×7 卷積784 × 65536 × 49 ≈ 25.2 億三個(gè)串聯(lián) 3×3 卷積每層通道不變3 × 784 × 65536 × 9 ≈ 13.9 億瓶頸結(jié)構(gòu)1×1 降到 640.13 億 3×3 64 通道0.29 億 1×1 升回 2560.13 億≈ 0.55 億深度可分離卷積3×3 深度卷積0.018 億 1×1 逐點(diǎn)卷積0.51 億≈ 0.53 億3.2 對比表格感受一下數(shù)量級差異結(jié)構(gòu)方案FLOPs28×28×256→28×28×256參數(shù)量核心特點(diǎn)單層 7×7約 25.2 億3,211,264感受野大但計(jì)算量驚人三個(gè) 3×3約 13.9 億1,767,168等效感受野 7×7省 45%瓶頸結(jié)構(gòu)降到64約 0.55 億69,632計(jì)算量降到單層 7×7 的約 2%深度可分離約 0.53 億67,840空間與通道解耦極輕量看到瓶頸結(jié)構(gòu)和深度可分離卷積的數(shù)字你可能和我第一次算出來時(shí)一樣震驚同樣做一次 28×28×256 的特征變換兩步走的方案消耗的計(jì)算量只有直接大卷積核的百分之二左右。3.3 省的是什么不省的是什么這里有一個(gè)容易混淆的點(diǎn)。FLOPs 降下去了參數(shù)量也降下去了但“省”的方式不同。瓶頸結(jié)構(gòu)省的大頭是 3×3 卷積作用在高通道數(shù)上的開銷深度可分離卷積省的大頭是 3×3 卷積同時(shí)對通道做混合的冗余計(jì)算。但要注意FLOPs 低不代表實(shí)際推理一定快。在 GPU 上小卷積核的并行度可能受內(nèi)存帶寬限制在 CPU 或移動(dòng)端則友好得多。所以動(dòng)手選方案時(shí)一定要結(jié)合自己的硬件環(huán)境做實(shí)測不能只看 FLOPs 紙面數(shù)字。這一點(diǎn)我在第五節(jié)再展開。4. 用代碼把“兩步走”寫出來直接能落地的模塊4.1 標(biāo)準(zhǔn)瓶頸模塊的實(shí)現(xiàn)先說最常用的瓶頸結(jié)構(gòu)。以下 PyTorch 代碼我做過多次微調(diào)后固定下來的一版帶殘差連接和可選下采樣import torch import torch.nn as nn class Bottleneck(nn.Module): def __init__(self, in_channels, out_channels, stride1, reduction4): super().__init__() hidden out_channels // reduction self.conv1 nn.Conv2d(in_channels, hidden, 1, biasFalse) self.bn1 nn.BatchNorm2d(hidden) self.conv2 nn.Conv2d(hidden, hidden, 3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(hidden) self.conv3 nn.Conv2d(hidden, out_channels, 1, biasFalse) self.bn3 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.downsample None if stride ! 1 or in_channels ! out_channels: self.downsample nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, stridestride, biasFalse), nn.BatchNorm2d(out_channels), ) def forward(self, x): identity x out self.relu(self.bn1(self.conv1(x))) out self.relu(self.bn2(self.conv2(out))) out self.bn3(self.conv3(out)) if self.downsample is not None: identity self.downsample(identity) out identity return self.relu(out)幾個(gè)值得注意的細(xì)節(jié)biasFalse卷積后面緊跟 BatchNormbias 會(huì)被 BN 抵消掉留著反而浪費(fèi)參數(shù)。reduction4隱藏通道數(shù) 輸出通道數(shù) / 4這是最常用的壓縮比例。后面我會(huì)講什么時(shí)候該調(diào)它。downsample輸入輸出通道不一致或 stride2 時(shí)需要對殘差路徑做投影這里用 1×1 卷積完成其實(shí)就是一次“1×1 步走”來對齊維度。4.2 深度可分離卷積模塊的實(shí)現(xiàn)下面這段代碼實(shí)現(xiàn)的是深度卷積 逐點(diǎn)卷積的完整結(jié)構(gòu)class DepthwiseSeparableConv(nn.Module): def __init__(self, in_channels, out_channels, kernel_size3, stride1): super().__init__() self.depthwise nn.Conv2d( in_channels, in_channels, kernel_size, stride, paddingkernel_size // 2, groupsin_channels, biasFalse ) self.bn1 nn.BatchNorm2d(in_channels) self.pointwise nn.Conv2d(in_channels, out_channels, 1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.relu(self.bn1(self.depthwise(x))) x self.relu(self.bn2(self.pointwise(x))) return x這里最關(guān)鍵的是 depthwise 卷積里的 groupsin_channels。這個(gè)參數(shù)讓每個(gè)輸入通道只被自己的卷積核卷積完全不會(huì)跨通道混合。很多新手在這里習(xí)慣性不寫 groups以為自己在做深度卷積實(shí)際上 channels 之間悄悄混在一起了和標(biāo)準(zhǔn)卷積區(qū)別只在參數(shù)少了點(diǎn)計(jì)算量的節(jié)省就完全沒有了。我習(xí)慣把深度可分離卷積層寫成“一個(gè)深度卷積 BN ReLU 一個(gè)逐點(diǎn)卷積 BN ReLU”激活函數(shù)放在每個(gè)步驟之后而不是只在最后放一個(gè)。實(shí)測這樣訓(xùn)練更容易收斂精度也略高。4.3 選擇瓶頸還是深度可分離我的經(jīng)驗(yàn)如果是用于 CPU/移動(dòng)端推理深度可分離卷積通常是更穩(wěn)的選擇因?yàn)樗總€(gè)操作都很簡單對低并行度的硬件更友好。如果在 GPU 上做大規(guī)模訓(xùn)練瓶頸結(jié)構(gòu)往往效果更穩(wěn)定精度更好調(diào)。很多輕量級網(wǎng)絡(luò)比如 MobileNet 系列的變體在 GPU 上實(shí)測并沒有想象中那么快核心原因就是深度卷積在 GPU 上很難把算力吃滿。如果你剛起步我建議先在同一個(gè)數(shù)據(jù)集上同時(shí)實(shí)現(xiàn)兩種模塊對比參數(shù)量和精度。直接在現(xiàn)有網(wǎng)絡(luò)里把標(biāo)準(zhǔn)卷積替換成其中任意一種觀察變化這比看十篇論文都有用。4.4 順帶附一個(gè) SE 模塊實(shí)現(xiàn)SE 模塊的實(shí)現(xiàn)也很短幾乎必背class SqueezeExcitation(nn.Module): def __init__(self, channels, reduction16): super().__init__() self.squeeze nn.AdaptiveAvgPool2d(1) self.fc nn.Sequential( nn.Linear(channels, channels // reduction, biasFalse), nn.ReLU(inplaceTrue), nn.Linear(channels // reduction, channels, biasFalse), nn.Sigmoid(), ) def forward(self, x): b, c, _, _ x.size() y self.squeeze(x).view(b, c) y self.fc(y).view(b, c, 1, 1) return x * y它和卷積模塊配套使用時(shí)一般放在殘差分支的最后、相加之前對通道做加權(quán)。如果你把 SE 想象成一個(gè)只走“通道維”的兩步走模塊就很容易理解它和卷積模塊為什么能無縫配合。5. 常見誤區(qū)與調(diào)參心得踩過的坑都在這5.1 1×1卷積不只是“降維工具”這是我見過最多的誤解。1×1 卷積確實(shí)常被用來降維但它的本質(zhì)是通道混合。如果你只把它當(dāng)前往計(jì)算量削減器你會(huì)忽略它在特征融合中的巨大作用。比如在深淺層特征融合的很多結(jié)構(gòu)里1×1 卷積就是用來把不同來源的通道對齊并混合而未必降維。帶著這個(gè)認(rèn)知去看網(wǎng)絡(luò)結(jié)構(gòu)你會(huì)少很多困惑。5.2 深度可分離卷積不是萬能靈藥FLOPs 降了 80% 不意味著推理時(shí)間降 80%。至少有兩個(gè)隱藏成本深度卷積內(nèi)部必須逐通道串行/并行分通道做GPU 利用率偏低逐點(diǎn)卷積雖然計(jì)算量小但要讀取整張中間特征圖內(nèi)存訪問壓力大。所以如果你是 GPU 服務(wù)器部署深度可分離卷積的優(yōu)勢會(huì)被削弱很多。我自己踩過的坑是把一個(gè)標(biāo)準(zhǔn)卷積網(wǎng)改成深度可分離后GPU 訓(xùn)練速度反而慢了 15%而參數(shù)量確實(shí)降了很多。最終方案是部分層保持標(biāo)準(zhǔn)卷積只在計(jì)算熱點(diǎn)層使用深度可分離。5.3 BN 和 ReLU 的位置不能亂放瓶頸模塊里我強(qiáng)調(diào)過一個(gè)順序conv1 → BN → ReLU → conv2 → BN → ReLU → conv3 → BN → 與恒等分支相加 → ReLU。最后一個(gè) ReLU 放在相加之后是因?yàn)闅埐罘种У暮愕扔成湫枰案蓛簟钡貍鬟f到下一層不能在相加前就被激活截?cái)?。很多人圖省事把 ReLU 全堆在卷積后面結(jié)果殘差梯度流被嚴(yán)重阻礙訓(xùn)練變慢甚至不收斂。如果你在訓(xùn)練時(shí)發(fā)現(xiàn) loss 下不去優(yōu)先查一下激活函數(shù)相對殘差連接的位置。5.4 降維比例reduction不是固定 1/4瓶頸結(jié)構(gòu)里 reduction4 是最常見選擇但絕不是唯一選擇。我曾經(jīng)在某個(gè)分類任務(wù)上把 reduction 從 4 調(diào)到 8參數(shù)量繼續(xù)下降精度掉了 0.3 個(gè)百分點(diǎn)調(diào)到 2精度漲了一點(diǎn)參數(shù)量卻翻了一倍。這個(gè)比例本質(zhì)上是“信息瓶頸”的寬度調(diào)得越小中間層能表達(dá)的信息越少但計(jì)算量也越小。我的經(jīng)驗(yàn)是如果任務(wù)比較簡單或者精度壓力大用 2 或 4如果就是要極輕量模型可以直接上 8 甚至 16然后配合更寬的網(wǎng)絡(luò)整體調(diào)參。5.5 瓶頸結(jié)構(gòu)的瓶頸到底在哪一層設(shè)計(jì)瓶頸模塊時(shí)最容易忽略的是中間 3×3 卷積的輸入通道已經(jīng)被壓縮到 hidden因此它的感受野操作只能建立在壓縮后的特征上。如果壓縮得太狠空間信息的豐富度會(huì)被嚴(yán)重削弱。這也是為什么 MobileNet 那類結(jié)構(gòu)寧可逐步降低通道也不做一次性極窄瓶頸的原因。你在設(shè)計(jì)網(wǎng)絡(luò)時(shí)每一層的壓縮比例要跟“這個(gè)位置到底需要多豐富的空間特征”匹配而不是統(tǒng)一套一個(gè)參數(shù)。5.6 初始化對兩步走結(jié)構(gòu)的影響不可忽略BN 后接 1×1 卷積時(shí)輸出方差很大程度上由后續(xù)權(quán)重尺度決定。我遇到過深度可分離網(wǎng)絡(luò)在訓(xùn)練初期 loss 掉不下去的情況排查后發(fā)現(xiàn)是深度卷積那層初始化方差過大導(dǎo)致經(jīng)過 BN 后輸出震蕩。后來在所有深度卷積層把 weight 初始化標(biāo)準(zhǔn)差調(diào)小到原來的 0.5 倍問題就消失了。如果你復(fù)現(xiàn)輕量網(wǎng)絡(luò)發(fā)現(xiàn)收斂異常先檢查初始化分布再檢查學(xué)習(xí)率不要一上來就怪?jǐn)?shù)據(jù)集。小結(jié)與個(gè)人體會(huì)把 1×1 和 3×3 放在一起看最深的體會(huì)是現(xiàn)代卷積網(wǎng)絡(luò)在設(shè)計(jì)上一直在做“拆解”和“解耦”——把空間特征提取和通道信息融合拆成兩步在各自維度上做更高效的操作。瓶頸結(jié)構(gòu)是先降再提后升深度可分離卷積是先空間后通道SE 模塊則是純通道維的兩步走。套路不同哲學(xué)相通。我自己在一次輕量模型改造里用瓶頸替換了三個(gè)標(biāo)準(zhǔn)卷積層參數(shù)量從 9.6M 掉到 2.1M精度只損失了 0.4 個(gè)百分點(diǎn)推理速度提升了接近一倍。踩過的坑也不少ReLU 位置、groups 設(shè)置、初始化尺度都是靠打印中間張量和逐層對比才定位出來的。如果你也想動(dòng)手改一版自己的輕量網(wǎng)絡(luò)我建議從一個(gè)小模型開始把標(biāo)準(zhǔn)卷積替換成瓶頸或深度可分離用同樣的訓(xùn)練配置跑一個(gè)對比實(shí)驗(yàn)。不用追求一步到位先跑通一個(gè)最小的“兩步走”再逐步鋪開這比啃任何結(jié)構(gòu)分析的論文都更實(shí)在。