ARK · 理解深度學習CHAPTER 10 / 21

CHAPTER 10 / 21 · PART 3 · 結構的力量:四種主力架構

卷積網路:把影像的結構寫進模型

Convolutional networks

用權重共享把「同一個樣式在哪裡都該一樣處理」寫進架構,並分清等變與不變。

§01學習重點

§02課程內容

一、把一張紙攤成一條線之後

到目前為止,你看到的網路都是一層一層的全連接層:這一層的每個單元都吃進上一層的每個單元。要拿它處理一張影像,最直接的做法是把影像攤平成一條向量再送進去。程式上這只是一行,代價卻藏在這一行裡。

先把規模擺出來。假設你手上有一張掃描進電腦的紙,96 列、120 行,每一格記一個 0 到 1 的深淺值。攤平之後是一條 11,520 個數字的向量。假設第一層有 400 個單元,那麼光是第一層的權重矩陣就有 400 × 11,520 = 4,608,000 個數字要學。這組數字全是我為了估規模自己設的,不是任何真實系統的規格;換一張大一點的紙,這個乘積會長得比你預期的快。

參數多只是表面。真正的麻煩有兩個,而且都不是加大記憶體能解決的。

第一,攤平這個動作把「誰跟誰挨著」抹掉了。 原本第 3 列第 7 格的正下方是第 4 列第 7 格,攤平之後它們在向量裡相隔 120 個位置——跟任何一對相隔 120 個位置的數字長得一模一樣。全連接層看到的是一串沒有結構的數字,它不知道哪兩個數字原本擠在一起。可是紙上的樣式恰恰是靠一小塊區域裡的深淺搭配才認得出來的:一道橫槓、一個轉角、一個小圈,全都是「幾格連在一起怎麼變化」的事。這個資訊在攤平那一刻就不見了,網路只能從幾百萬筆資料裡把它重新猜回來。

第二,同一個樣式換一個位置,就得從頭學一次。 假設你已經教會網路「左上角出現一道橫槓」是什麼意思——這件事是由連到左上角那些輸入的權重負責的。現在同一道橫槓出現在右下角,負責的是完全另外一組權重,而那組權重從來沒被教過。網路沒有任何機制可以把左上角學到的東西搬到右下角用。

這兩件事其實是同一件事:全連接層不知道「位置」這回事。 它把輸入向量的每一個位置都當成一個獨立的、彼此無關的欄位。對於一份填在表格裡的資料,這個假設沒問題,欄位順序本來就可以任意換。對於一張紙,這個假設是錯的,而且錯得很徹底。

所以我們要做的事情可以寫成一句話:把「不管出現在紙的哪個角落,同一個局部樣式都該用同樣的方式處理」這個先驗,直接寫進網路的結構裡。 這一章從頭到尾都在做這件事。做完之後你會發現,權重變少只是順帶的結果,重點在別的地方。

二、一枚章蓋滿整張紙:卷積這個運算

先從一維做起。一維的好處是畫得出來也算得動,而二維只是把同一件事多做一個方向。

設輸入是一條長度 \(N\) 的數列 \(x_1, x_2, \dots, x_N\)。取一組固定的權重 \(\omega_1, \dots, \omega_K\),把它們對準輸入的一小段、相乘再相加,得到一個輸出:

$$ z_i \;=\; \sum_{j=1}^{K} \omega_j \, x_{\,i+j-1} \;+\; b $$

逐項拆解:\(K\) 是這組權重有幾個,稱為核尺寸(kernel size);\(\omega_j\) 是第 \(j\) 個權重;\(i\) 是輸出的位置編號,也就是這組權重目前對準輸入的哪一段起點;\(x_{i+j-1}\) 是輸入裡被第 \(j\) 個權重乘到的那一格;\(b\) 是偏置(bias),一個加在最後的常數。這整個運算叫卷積(convolution),那一組被反覆使用的權重合起來記成 \(\boldsymbol\omega\),叫做核(kernel),也有人叫它濾波器(filter)。

這裡有一個符號陷阱要先拆掉。 前面幾章的全連接層,權重矩陣寫成大寫的 \(\boldsymbol\Omega_k\);這一章的卷積核寫成小寫的 \(\boldsymbol\omega\)。同一個希臘字母的大寫與小寫,在本課裡是兩個不同的東西——大寫的是一整個矩陣,每一列每一行各自對應一個單元;小寫的是一小組會被重複用到的數字。看到符號時先看大小寫,不要憑印象讀。

式子裡最關鍵的一件事,容易一眼滑過去:\(\omega_1, \dots, \omega_K\) 不隨 \(i\) 改變。 不管算的是第 1 個輸出還是第 500 個輸出,用的都是同一組數字。這叫權重共享(weight sharing),它就是整章的機制核心。上一節說的那個先驗——「同一個樣式在哪裡都該用同樣的方式處理」——被寫進架構的方式,就是這麼一句「權重不隨位置改變」。

比喻: 把輸入想成一長條紙,把那組權重想成一枚刻好花紋的橡皮章。你沾一次印泥,從紙的最左邊開始蓋,蓋完往右挪一格再蓋一次,一路蓋到最右邊。整張紙上的印痕來自同一枚章——章上刻了幾個刻痕,你就只需要保管幾個數字,跟紙有多長完全無關。反過來說,如果每個位置都另外刻一枚章,那才是全連接層在做的事。這個比喻在兩個地方失準,而且都要記住:第一,真的橡皮章蓋出來只有「有印痕/沒印痕」兩種狀態,卷積的輸出是連續值,而且可以是負的;第二,真的章蓋上去會蓋掉底下的東西,而卷積是把章上的刻紋跟紙上的深淺相乘再相加,是相加不是覆蓋。

核尺寸通常取奇數,理由很實際:奇數才有一個正中央的位置,可以對稱地圍住它,說「這個輸出對應到輸入的哪一格」時不會左右差半格。取偶數不是不行,只是得額外約定偏哪一邊。

接著是邊界。章挪到紙的最右邊時會伸出紙外,那幾格沒有輸入可以乘。兩種處理方式:

捨棄伸出紙外的位置。 只在章完整落在紙上時才蓋。好處是輸出的每一個數字都由真實輸入算出來,沒有任何假資訊;代價是輸出比輸入短,長度變成 \(N - K + 1\)。疊個幾層,紙就縮掉一截。

把紙的外面補上值再照蓋。 最常見的是補零,這叫填充(padding)。好處是輸出長度保得住;代價是邊界那幾格的輸出裡混進了紙上根本不存在的東西。要提醒的是,補零不是「無害」的——邊界那幾個輸出有一部分是在跟零相乘,等於少吃了幾格真實輸入,統計性質跟中間那些輸出不一樣。這個差異在淺網路裡看不出來,層數一多會累積。除了補零,也有把紙的另一端接過來、或把邊緣鏡射過去的做法,用意都是讓邊界看起來不那麼突兀。

底下這段程式手刻一維卷積,兩種邊界處理各跑一次,順便把「共用一枚章」與「每個位置各刻一枚章」的權重數量擺在一起:

PYTHON
import numpy as np

rng = np.random.default_rng(0)

# 教學用例:一張窄長的印痕紙,24 格,每格一個深淺值
paper = np.round(rng.uniform(0, 1, 24), 2)

# 章上刻好的花紋:5 個數字,蓋到哪裡都是這 5 個
omega = np.array([0.25, -0.60, 1.10, -0.30, 0.15])
bias = 0.05


def stamp(x, w, b, pad=False):
    k = w.size
    if pad:                                   # 紙邊外面當成空白照蓋
        x = np.concatenate([np.zeros(k // 2), x, np.zeros(k // 2)])
    n = x.size - k + 1                        # 章能完整落下的位置數
    return np.array([x[i:i + k] @ w + b for i in range(n)])


drop = stamp(paper, omega, bias)
padded = stamp(paper, omega, bias, pad=True)

print("紙有幾格:", paper.size)
print("只在章完整落在紙上時才蓋:", drop.size, "個輸出")
print("紙邊外面補空白照蓋   :", padded.size, "個輸出")
print("前五個輸出:", np.round(drop[:5], 3))
print("共用一枚章要保管的數字:", omega.size + 1)
print("每個位置各刻一枚章:", drop.size * (omega.size + 1))

實跑輸出:紙有 24 格,捨棄伸出紙外的位置得到 20 個輸出,補零照蓋得到 24 個輸出,前五個輸出是 0.208 0.009 0.758 0.496 0.24;共用一枚章只要保管 6 個數字(五個刻痕加一個偏置),每個位置各刻一枚章則要 120 個。二十倍的差距只是在一條 24 格的紙上;紙越長,這個比值越大,而共用那一邊的數字完全不動

看到這個數字,很容易得出一個結論:卷積的用意是省參數。這個結論是反的。 省參數是結果,不是目的。目的是把「每個位置該用同樣方式處理」這件事變成硬性約束——網路連「在不同位置用不同權重」這個選項都沒有,所以它不可能學出一個只在左上角有效的樣式偵測器。少掉的那些參數,正是被這條約束刪掉的、我們認為不該存在的自由度。

最後補齊層的結構。一個卷積層(convolutional layer)跟前面幾章的層長得一樣:先做一個線性運算,再加偏置,再過激活函數。差別只在那個線性運算從「一個滿的矩陣乘法」換成了「一枚章到處蓋」。所以你在第 7 章學到的預活化 \(\mathbf{z}\)、活化 \(\mathbf{h}\)、參數全集 \(\boldsymbol\phi\) 這一套記法,在這裡照樣適用,不必重新建立。

三、等變不是不變:本章的分水嶺

這一節是全章最容易被講糊的地方。幾乎每個初學者都會在這裡形成一個錯誤印象,而且這個錯誤印象聽起來很順:「卷積網路的好處是對平移免疫,圖案挪到哪裡都認得出來。」這句話把兩個不同的性質混成了一個。

把兩個性質分開寫。設 \(T[\cdot]\) 是「整張紙往右挪一格」這個動作,\(f[\cdot]\) 是你套在紙上的運算:

$$ \text{不變:}\quad f\bigl[T[x]\bigr] = f[x] \qquad\qquad \text{等變:}\quad f\bigl[T[x]\bigr] = T\bigl[f[x]\bigr] $$

逐項拆解:左邊那條說的是,你先挪紙再算,跟根本沒挪就算,得到的結果一模一樣——輸出對這個動作沒反應。右邊那條說的是,你先挪紙再算,等於先算完再把結果照樣挪一格——輸出有反應,而且反應的方式跟輸入被動的方式同一種

這兩者不是程度差別,是兩件不同的事。而卷積是右邊那一種。 這很好理解:既然每個位置用的是同一枚章,紙往右挪一格,每一段輸入都往右挪了一格,於是每一個輸出也往右挪一格。輸出整體跟著搬家,沒有一個數字留在原地。這叫平移等變性(translation equivariance)。

那「圖案挪到哪裡都認得出來」那個性質呢?那是平移不變性(translation invariance),它不是卷積帶來的。它是另外一個運算帶來的,而且只帶來一部分。

先把那個運算擺出來。池化(pooling)是把輸出切成一段一段的小區塊,每個區塊只留一個數字:留最大值的叫最大池化(max pooling),留平均值的叫平均池化(average pooling)。留下來的數字比原本少,所以池化同時也是一種把尺寸變小的做法,稱為下採樣(downsampling)。

最大池化為什麼帶來部分不變性,用一句話就講得完:紙挪一格,每個小區塊裡的內容換了一點,但區塊裡最大的那個數字往往還是同一個。 只要那個最大值沒有被擠出區塊,池化的輸出就一個字都不會變。

這件事口說無憑,直接量給你看。底下這段程式做四千次獨立試驗:每次造一條紙,把它整體往右挪一格,然後比三件事——卷積輸出有沒有跟著整體挪(等變),卷積輸出有多少格完全沒動(不變),最大池化輸出有多少格完全沒動(不變):

PYTHON
import numpy as np

rng = np.random.default_rng(0)
omega = np.array([0.25, -0.60, 1.10, -0.30, 0.15])


def stamp(x, w):                       # 補空白,輸出與輸入等長
    p = w.size // 2
    xp = np.concatenate([np.zeros(p), x, np.zeros(p)])
    return np.array([xp[i:i + w.size] @ w for i in range(x.size)])


def deepest(v, box=3):                 # 每 box 格只留印得最深的一點
    n = v.size // box
    return v[:n * box].reshape(n, box).max(axis=1)


L, trials = 30, 4000
equiv_trials = conv_fixed = conv_all = pool_fixed = pool_all = 0

for _ in range(trials):
    x = rng.uniform(0, 1, L)
    xs = np.concatenate([[0.0], x[:-1]])          # 整張紙往右挪一格
    c, cs = stamp(x, omega), stamp(xs, omega)
    c_shift = np.concatenate([[0.0], c[:-1]])     # 把原來的印痕也挪一格
    equiv_trials += int(np.allclose(cs[1:28], c_shift[1:28]))
    conv_fixed += int(np.sum(np.isclose(cs, c)))
    conv_all += c.size
    p, ps = deepest(c), deepest(cs)
    pool_fixed += int(np.sum(np.isclose(ps, p)))
    pool_all += p.size

print(f"「挪一格後印痕跟著挪一格」成立:{equiv_trials}/{trials} 次")
print("卷積輸出逐格完全不變的比例:", round(conv_fixed / conv_all, 4))
print("最大池化輸出逐格完全不變的比例:", round(pool_fixed / pool_all, 4))

實跑輸出:等變關係成立 4000/4000 次;卷積輸出逐格完全不變的比例是 0.0;最大池化輸出逐格完全不變的比例是 0.5181

三個數字放在一起,這一節的結論就從「我說」變成「你看」了。卷積對平移的反應是滿分的等變——四千次試驗沒有一次例外;同時它的不變性是,一格都沒有留在原地。而最大池化把不變的比例從零拉到約 52%。所以「卷積讓網路對平移免疫」這句話錯在哪裡,現在有一個很具體的答案:免疫的那一半是池化做的,而且它只做到一半。

那 52% 也提醒我們,不變性是部分的、近似的,不是保證。三個常見的漏洞:最大值被擠出區塊時,輸出就變了;區塊大小除不盡輸出長度時,尾巴那一段的切法會跟著位移改變;而真實世界的位移常常不是整整一格,落在格與格之間時,連「同一個最大值」都不成立。任何宣稱「卷積網路完全不受平移影響」的說法,都可以用這三點反駁。

比喻: 兩個工序,兩種要求。第一個工序是校對:你要把一整張紙上有幾處花紋、各在哪裡,一格不差地記下來。這時你希望紙挪一格,記下來的位置也跟著挪一格——你要的是等變。第二個工序是驗收:你只要回答「這一整張紙蓋的是不是同一款花紋」,那麼紙貼歪一格根本不該影響答案——你要的是不變。同一批印痕,兩個工序要的性質相反。這個比喻在一處失準:真人做驗收時,紙歪半格、歪三格都一樣看得出來,而池化只能吸收掉小幅度的位移,位移一大就失效——它是靠「最大值恰好還在同一個區塊裡」撐著的,不是真的看懂了花紋。

任務決定你要哪一種,這是本節最實用的一條。整張紙給一個答案的任務(這是不是同一款花紋)要的是不變性,所以架構裡會一路把尺寸縮小、把位置資訊丟掉;每個位置各給一個答案的任務(紙上每一格分別屬於哪一款花紋)要的是等變性,所以架構裡不能把位置資訊丟光,丟了就答不出來。

最後釘一句常被略過的話:平移就只是平移。 卷積的等變性是對「挪位置」這一個動作成立的,它不會順便送你對旋轉、對縮放、對鏡射的等變性。把紙轉四十五度,同一枚章蓋出來的印痕跟原本的印痕轉四十五度不一樣。要處理旋轉或縮放,得另外設計,那超出本課的範圍。

四、看多寬、看多遠:三個旋鈕與感受野

上一節的章一次只看五格。真實的樣式常常橫跨更寬的範圍,怎麼辦?有三個可以分開調的旋鈕,它們解決的是同一個取捨,但代價各不相同。

旋鈕一:核尺寸。 直接把章刻大一點,一次看更寬。代價很直接——章面每加大一格,要保管的數字就多一個;二維的話多一整排。

旋鈕二:膨脹。 刻痕的數量不變,但把它們攤開,中間空出來的格子跳過不看。這叫膨脹(dilation),也譯作擴張。它的定義是學生最常記反的地方,所以寫清楚:膨脹率等於相鄰兩個刻痕之間的間隔格數——貼著排(沒有空格)是膨脹率 1,中間空一格是膨脹率 2,空兩格是膨脹率 3。也就是「空了幾格再加一」。一枚 \(K\) 個刻痕、膨脹率 \(d\) 的章,實際橫跨的寬度是 \((K-1)d + 1\) 格,而要保管的數字仍然只有 \(K\) 個。用同樣少的權重覆蓋更寬的範圍,這就是它的用途。

旋鈕三:步幅。 前面每挪一格就蓋一次,其實不必。隔幾格才蓋一次,這個間距叫步幅(stride)。步幅大於 1 時,輸出的數量直接變少——步幅 2 大約砍半。所以步幅不只是「跳著看」,它順手做了一次下採樣。

把三者合起來,捨棄伸出紙外的位置時,輸出長度是

$$ N_{\text{out}} \;=\; \left\lfloor \frac{N - \bigl[(K-1)d + 1\bigr]}{s} \right\rfloor + 1 $$

逐項拆解:\(N\) 是輸入長度;中括號裡是這枚章實際橫跨的寬度;相減得到章的起點還能放在幾格範圍內;除以步幅 \(s\) 是因為起點每次跳 \(s\) 格;那個 \(\lfloor\cdot\rfloor\) 是向下取整,因為除不盡時最後那一段放不下一整枚章;最後加 1 是把第一個起點算回來。這條式子值得你自己代幾個數字驗一遍,因為算輸出尺寸算錯是寫卷積網路最常見的錯誤。

這三個旋鈕真正在調的是三件可以分開的事:一次看多寬(章面加膨脹)、要保管幾個數字(刻痕的數量)、輸出有多密(步幅)。把它們當成同一個旋鈕,是初學時最容易犯的簡化。

現在講看多遠,這是另一回事。

一個輸出單元只看得到輸入的一小段,但把好幾層疊起來之後,第二層的一個輸出看到的是第一層的好幾個輸出,而第一層的每個輸出又各自看了輸入的一小段。往回追到底,一個單元最終吃到了原始輸入上的哪一段範圍,這段範圍就叫它的感受野(receptive field)。 就地定義清楚:它量的是「這個單元的值會被輸入的哪些格子影響」,單位是原始輸入的格數。

感受野怎麼長大?每多疊一層,感受野在原有基礎上加上 \((K-1)\) 格——多出來的那 \(K-1\) 格,來自這一層的章往左右各多伸出去的部分。所以三層 5 格的章疊起來,感受野是 \(5 \to 5+4=9 \to 9+4=13\)。如果某一層用了膨脹或步幅,成長會更快:膨脹率 \(d\) 讓這一層貢獻 \((K-1)d\) 格,而步幅則會讓後面每一層的貢獻都乘上這個步幅。

這些都是算出來的。下面用擾動法把它量回來:把輸入的某一格加上一個擾動,看第 \(k\) 輪的某個輸出動不動;會動的那些輸入格,就是這個輸出的感受野。

PYTHON
import numpy as np

rng = np.random.default_rng(0)


def stamp(x, w, dilation=1):
    span = (w.size - 1) * dilation + 1          # 章攤開後橫跨幾格
    off = np.arange(w.size) * dilation
    return np.array([x[i + off] @ w for i in range(x.size - span + 1)])


L = 61
layers = [rng.normal(0, 1, 5) for _ in range(3)]   # 三輪,各一枚 5 刻痕的章


def run(x, dil):
    trace = []
    h = x
    for w, d in zip(layers, dil):
        h = stamp(h, w, d)
        trace.append(h)
    return trace


def measure(dil):
    base = rng.uniform(0, 1, L)
    ref = run(base, dil)
    widths = []
    for k in range(len(layers)):
        mid = ref[k].size // 2
        touched = []
        for j in range(L):
            x = base.copy()
            x[j] += 1.0
            if not np.isclose(run(x, dil)[k][mid], ref[k][mid]):
                touched.append(j)
        widths.append(max(touched) - min(touched) + 1)
    return widths


print("每輪都用貼著蓋的章:", measure([1, 1, 1]))
print("第二輪換成刻痕攤開一倍的章:", measure([1, 2, 1]))

實跑輸出:三輪都用貼著蓋的章時,感受野依序是 [5, 9, 13];把第二輪換成膨脹率 2 的章之後,變成 [5, 13, 17]。第一組跟前面的手算完全對得上。第二組也對得上:第二輪的章橫跨 \((5-1)\times 2 + 1 = 9\) 格,貢獻 \(9-1=8\) 格,所以 \(5+8=13\);第三輪回到貼著蓋,再加 4 格得 17。同樣三層、同樣的權重數量,感受野從 13 變成 17——這就是膨脹在買的東西。

兩個常見誤解在這裡可以一次拆掉。第一,核尺寸不等於感受野。 核尺寸是這一層的章面多大,感受野是回溯到原始輸入的總範圍;上面那組數字裡,核尺寸一直是 5,感受野卻是 5、9、13。第二,最後一層的感受野不保證覆蓋整張紙。 上面那個三層網路的感受野只有 13 格,而紙有 61 格——最後一輪的每一個輸出,其實只看過紙上不到四分之一的範圍。要它看得到全紙,你得繼續疊、或者把步幅與膨脹加上去。所以深度在卷積網路裡有一個非常具體的意義:層數換的是視野。

五、一枚章不夠用:通道

到目前為止全篇只有一枚章。這不夠,理由有兩層。

第一層是加權和本身在抹平:把五格的深淺乘一乘加起來變成一個數字,原本那五格的差異就壓縮掉了。第二層是激活函數又把一部分直接抹掉——如果用的是第 3 章那個把負值送成零的激活函數,凡是這枚章「沒偵測到」的地方,輸出全部塌成零,那一整片的資訊就此消失。用一枚章掃完一張紙,你留下的是「這張紙在哪些位置像這一款花紋」,其餘什麼都沒留。

解法很直接:平行蓋好幾輪,每一輪換一枚刻紋不同的章。 每一枚章掃完整張紙,各自產生一整組輸出,這一整組就叫一個通道(channel),也叫特徵圖(feature map)——這是同一個東西的兩種叫法,看到哪一個都是指同一件事。有幾枚章就有幾個通道。

輸入也可以本來就有多個通道。這時候關鍵的一步來了,而且是最常被漏掉的一步:一枚章要同時吃進所有輸入通道。 也就是說,輸出通道上某個位置的值,是把「每一個輸入通道 × 章面上每一個位置」全部加權求和的結果,而不是每個輸入通道各算各的:

$$ z^{(o)}_{i} \;=\; \sum_{c=1}^{C_{\text{in}}} \sum_{j=1}^{K} \omega^{(o,c)}_{j}\, x^{(c)}_{\,i+j-1} \;+\; b^{(o)} $$

逐項拆解:上標 \((o)\) 是輸出通道的編號,\((c)\) 是輸入通道的編號;\(C_{\text{in}}\) 是輸入有幾個通道;內層的 \(j\) 在數章面上的位置,外層的 \(c\) 在數輸入的第幾個通道;\(\omega^{(o,c)}_j\) 是「第 \(o\) 枚章、對付第 \(c\) 個輸入通道、章面第 \(j\) 個刻痕」的那個權重;\(b^{(o)}\) 是第 \(o\) 個輸出通道的偏置,一個輸出通道只有一個。兩層求和都跑完才得到一個數字——漏掉外層那一圈,參數量就會算錯,這是本章最常見的計算錯誤。

由此可以直接數出一個卷積層要保管多少數字:三個數量相乘,再加上每個輸出通道各一個偏置。三個數量分別是這一輪要蓋幾枚章每枚章要同時吃進幾疊紙每枚章面上有幾個刻痕。二維的話,章面上的刻痕數就是章的高乘以章的寬。

PYTHON
import numpy as np

rng = np.random.default_rng(0)

Cin, H, W = 3, 20, 26        # 進來三疊印痕紙,每疊 20×26 格
Cout, K = 7, 5               # 這一輪要蓋 7 枚章,每枚章面 5×5

sheets = rng.uniform(0, 1, (Cin, H, W))
omega = rng.normal(0, 0.2, (Cout, Cin, K, K))
bias = rng.normal(0, 0.05, Cout)

Ho, Wo = H - K + 1, W - K + 1
out = np.empty((Cout, Ho, Wo))
for o in range(Cout):
    for i in range(Ho):
        for j in range(Wo):
            out[o, i, j] = np.sum(sheets[:, i:i + K, j:j + K] * omega[o]) + bias[o]

# 把第一疊塗掉,看輸出動不動——驗證每個輸出都吃進了所有輸入疊
blank = sheets.copy()
blank[0] = 0.0
out0 = np.empty(Ho * Wo)
for i in range(Ho):
    for j in range(Wo):
        out0[i * Wo + j] = np.sum(blank[:, i:i + K, j:j + K] * omega[0]) + bias[0]

n_conv = omega.size + bias.size
n_dense = (Cin * H * W) * (Cout * Ho * Wo) + Cout * Ho * Wo

print("輸出形狀:", out.shape)
print("塗掉一疊之後,第 1 疊輸出仍不變的格數:",
      int(np.sum(np.isclose(out0, out[0].ravel()))), "/", Ho * Wo)
print("卷積層的參數量:", n_conv)
print("同樣輸入輸出的全連接層:", n_dense)
print("倍數:", round(n_dense / n_conv))

實跑輸出:輸出形狀是 (7, 16, 22);把第一疊紙整個塗白之後,第一個輸出通道上仍然不變的格數是 0352——每一格都動了,這就是「每個輸出都吃進所有輸入通道」的直接證據。參數量方面,這個卷積層是 532 個(7 × 3 × 25 個權重加 7 個偏置),而要用全連接層做出同樣的輸入輸出形狀,需要 3846304 個,相差 7230 倍。

有一個維度值得單獨拿出來說:通道方向沒有「鄰近」這個概念。 第 1 個通道跟第 2 個通道,並不比第 1 個通道跟第 5 個通道「更接近」——通道的編號是任意的,換個順序不影響任何事。這正是空間方向與通道方向被差別對待的原因:空間方向有鄰近關係,所以我們讓權重沿著它共享;通道方向沒有,所以在通道之間就老老實實做全連接式的混合。整章的先驗只加在有鄰近關係的那個方向上。

把這個想法推到極端就得到一個便宜好用的東西:讓章面只覆蓋單一位置。這時候空間方向什麼都沒做,但通道方向仍然是完整的線性混合——等於在每個位置各跑一次同一個小型全連接層。它的用途是把通道數調成你要的數字而空間尺寸完全不動,是接合兩段結構時的標準零件。覺得它「什麼都沒做」是誤會,它做的事全發生在通道方向。

比喻: 一枚章只回答一個問題:「這裡像不像這一款花紋?」要判斷一張紙上發生了什麼,你需要一整套章——一枚問橫槓、一枚問轉角、一枚問小圈。每一枚都掃完整張紙,各自交回一疊印痕紙。下一輪的章不能只挑其中一疊看,它得把所有疊在同一個位置上的印痕一起吃進去,才知道「這裡同時有橫槓也有轉角」。這個比喻在一處失準:真實工坊裡的章是師傅按用途刻的,你可以說出每一枚在問什麼;網路裡的章是訓練出來的,除了第一輪還能勉強看出它在偵測什麼,越後面的章對應到什麼樣式,往往說不清楚。所以「通道就是顏色」這個說法只在最前面成立——之後每個通道代表的是網路自己長出來的某種東西。

六、架構本身就是一種知識

現在把整章收攏。有一個說法可以把卷積層跟前面幾章接起來:卷積層是全連接層的一個特例。

怎麼個特例法?拿一個全連接層的權重矩陣,做兩件事:第一,把大多數位置釘成零——只留下「這個輸出對應的那一小段輸入」那幾格;第二,把剩下的非零位置強迫彼此相等——不同列的同一個相對位置,必須是同一個數字。做完這兩件事,這個矩陣乘法算出來的東西,跟一枚章到處蓋算出來的完全一樣。

PYTHON
import numpy as np

rng = np.random.default_rng(0)

L, K = 12, 5
omega = np.array([0.25, -0.60, 1.10, -0.30, 0.15])
x = np.round(rng.uniform(0, 1, L), 2)

n_out = L - K + 1
Omega = np.zeros((n_out, L))          # 一個全連接層的權重矩陣
for i in range(n_out):
    Omega[i, i:i + K] = omega         # 每一列擺同一組刻紋,只是位置不同

by_matrix = Omega @ x
by_stamp = np.array([x[i:i + K] @ omega for i in range(n_out)])

print("矩陣乘法與逐格蓋印逐項相等:", bool(np.allclose(by_matrix, by_stamp)))
print("權重矩陣的格子總數:", Omega.size)
print("其中不是零的:", int(np.count_nonzero(Omega)))
print("互不相同的非零值:", int(np.unique(np.round(Omega[Omega != 0], 8)).size))
print("最大絕對差:", float(np.max(np.abs(by_matrix - by_stamp))))

實跑輸出:兩種算法逐項相等(True,最大絕對差 1.1102230246251565e-16,那是浮點數的捨入誤差,不是真的差異);權重矩陣有 96 個格子,其中不是零的只有 40 個,而這 40 個裡互不相同的值只有 5 個。96 個位置,5 個自由的數字。 這就是「結構受限」與「參數變少」是同一件事兩面的具體樣子——你不是先決定要省參數然後剛好得到卷積,你是先決定「位置不該影響處理方式」,省參數是這個決定的帳單。

有了這個視角,就可以講架構的形狀了。一個典型的卷積網路長成這樣:空間尺寸一路變小,通道數一路變多。 這個此消彼長不是巧合。尺寸變小是為了讓感受野追得上——上一節算過,貼著蓋的章一層只增加 4 格視野,靠疊層追一張大紙太慢,所以中途要用池化或大於 1 的步幅把尺寸砍下來,剩下的每一格就自動代表原紙上更大的一塊。而通道數變多,是因為位置變少之後,得靠「每個位置記更多種東西」把資訊補回來。

尺寸怎麼變小,前面已經出現三種:最大池化、平均池化、以及步幅大於 1 的卷積順帶完成的隔點取樣。它們的效果都是變小,機制卻不同——池化不帶任何參數、是固定的規則,步幅則是在一個有權重的運算裡順便跳著算。把兩者混為一談,是初學時常見的誤解之一。附帶一提,池化是逐通道各做各的:空間尺寸依區塊大小縮小(最常見的設定是每 2×2 一個區塊,寬高各減半),通道數完全不動。

尾端怎麼收,取決於你要什麼形狀的答案。整張紙一個答案的任務,把最後那一疊攤平接上全連接層,讓它把散在各處的資訊整合成一個輸出。紙上要框出幾個位置的任務,輸出的是若干組座標與類別。每一格各給一個答案的任務,輸出跟輸入一樣大,所以縮小之後還得再放大回去——把尺寸放大的做法包括在已知的點之間補值、把每個值複製成一小塊,以及讓每個輸入貢獻到好幾個輸出的轉置卷積(transposed convolution)。這裡要提醒一句:轉置卷積不是卷積的逆運算,它只是把那個權重矩陣轉置過來用,還原不回原本的值。這一整段前後對稱的結構通常叫編碼段與解碼段。

最後回到本章的主旨。先處理一個你可能已經憋了一陣子的問題:剛才那筆 96 對 5 的帳,怎麼看都像卷積吃了大虧——九成以上的自由被沒收,一個被綁住手腳的模型,憑什麼反而學得更好?

要回答它,別盯著留下的 5 個數字看,去驗一驗被沒收的自由原本能買到什麼。回看那兩個動作:釘成零沒收的,是「這一格輸出想參考整張紙就參考整張紙」的自由——這一刀的代價,第四小節已經算過:一層看不遠沒關係,視野會隨著疊層一段一段接出去,不必一次到位。真正的主角是強迫相等那一刀,它沒收的是「同一個花紋,出現在紙的這一頭用一套刻紋去認、出現在另一頭就換一套」的自由。對認花紋這件事,這種自由不是本事,是包袱:握著它的網路,得先從資料裡學會「這份自由不該用」,而這本身就很耗資料;資料不夠時,它多半學成反面——拿這份自由去死記訓練集裡各個位置的巧合。

所以那次沒收不是節儉,是表態。第一節立的那個先驗,到這裡可以把話說滿:花紋是什麼、該怎麼處理,不隨它出現的位置改變——違反這一條的處理方式,共享刻紋的章根本做不出來,而這片被劃掉的範圍,正是 96 到 5 之間消失的那些自由。這種在見到任何資料之前就已經定好的傾向,叫歸納偏好(inductive bias)。第 4 章談深度時已經用過這個詞,這裡是同一個概念換一個來源:那裡的偏好來自複合,這裡的偏好來自共享。

再把同一件事翻成第 9 章的語言。正則化與架構約束做的是同一類事:縮小模型最後能落腳的範圍。差別在力道,也在下手處。懲罰項是抬價——不想要的解還在候選之列,只是變貴了,證據夠強時照樣選得回來;架構約束是除名——那些解根本不在候選之列,出價再高也輪不到。本小節開頭立過一個等式:卷積層=加了兩道約束的全連接層。推到這裡可以立第二個:架構約束=力道開到頂的正則化,只是它動手的地方不是損失函數,是接線。

還剩一個最順口的解釋要擋掉:「參數從 96 掉到 5,模型小了,自然不容易過擬合。」這句話在第 8 章就出過事——雙下降曲線上,容量越過臨界點之後,測試誤差反而再降一次,「越大越危險、越小越安全」的單調直覺在那裡就斷了。所以重點從來不是刪掉幾格,而是刪掉的是不是恰好該刪的那幾格——卷積贏在刪得準,不在刪得多。

所以整章的結論只有一句:架構本身就是一種知識。 你對資料結構的理解,可以不用寫成規則、不用寫成損失函數,而是直接寫進網路的連法裡。第一節那個「全連接層不知道位置這回事」的問題,到這裡閉環了。

最後留一個懸念。既然層數換的是視野,那疊得越深不是越好嗎?方向上是對的,但實務上疊到一定程度之後,訓練本身會開始出狀況——那已經是第 11 章要處理的題目了。

§03原書對照

原書第 10 章從「把影像直接餵給全連接網路會遇到什麼」開場。p.161 用一整段把難處攤開,並就地估算了一個小網路的權重規模,那筆帳怎麼算的、量級落在哪,翻那一頁看得最清楚。

不變性與等變性的形式定義寫在 p.162,兩條式子並排,旁邊配一組對照圖:同一張風景照左右平移後仍要被歸到同一類,而逐像素標註的彩色疊圖則要跟著平移。這一頁是整章動機的樞紐,值得逐字讀。

一維卷積的圖解集中在 pp.163–164,核如何在位置之間移動、邊界要補值還是乾脆捨棄伸出範圍的輸出,各畫了一格。p.163 底下還有一條腳註,指出機器學習裡沿用的這個運算,嚴格講與數學上同名的那個運算差在權重有沒有翻轉,而領域裡就這麼將錯就錯地用下來了——這件事很少被提起,值得看一眼。p.164 另有一張把步幅、核尺寸與膨脹三者並置的圖,圖說順帶交代了膨脹這個名稱的法文語源;p.165 則說明膨脹率該怎麼數。p.166 有一張圖把全連接層與卷積層的權重矩陣並排,用顏色標出哪些權重是零、哪些是重複的同一個值——想確認「卷積層是全連接層的特例」這句話到底長什麼樣,那張圖最直接。

通道的參數計數寫在 p.167,含輸入通道數、輸出通道數與核內元素數三者如何相乘。感受野逐層擴大的四格示意在 p.168。pp.167 與 169–170 記錄了一組一維資料上的對照實驗:兩個網路的層數與隱藏單元數相同,訓練設定、參數量與測試誤差都列了出來,p.170 接著用歸納偏好解釋為什麼差距不是參數多寡造成的。想看一次完整的實驗記錄,那三頁最齊全。

二維卷積與彩色影像的三通道處理在 pp.170–172。縮小與放大表示尺寸的各種做法各有一組圖,落在 pp.172–173;轉置卷積與縮小用的權重矩陣互為轉置的關係在 pp.173–174;只改通道數不動空間的那種卷積在 pp.174–175。

pp.174–179 是三個電腦視覺應用的實例:影像分類的兩個經典網路逐層規格與錯誤率、物件偵測的網格與方框編碼、語意分割的編碼解碼結構,每一個都附架構圖與原始論文出處。原書全章最後一段(p.180)交代了一個實驗觀察:網路加深到某個程度之後就變得難訓練,那正是下一章要處理的問題。

pp.180–184 的註記區另有大量延伸——p.181 回溯了卷積網路的早期系譜,包括一個辨識小尺寸灰階手寫數字、以今天眼光看已明顯是現代架構雛形的系統;其餘還有競賽成績隨年份演進的散點圖、好幾種為了省參數而設計的卷積變體、池化的各種推廣,以及把隱藏單元究竟學到什麼視覺化出來的幾條路線。另有兩處值得單獨點名,都在 p.183:一是把不變與等變推廣到旋轉、鏡射與縮放的那一串工作,二是丟棄法用在卷積層為什麼效果打折、以及因此長出來的兩種替代做法。習題在 pp.184–185,其中幾題要求手繪權重矩陣,畫過之後對稀疏與共享的理解會紮實很多。原書第 10 章對應印刷頁 pp.161–185。

§04作業和解答

作業一:把輸出尺寸與參數量算對

一輪蓋印的設定如下:輸入是 3 疊紙,每疊 33 列 41 行;這一輪要蓋 9 枚章,每枚章面 7×7,刻痕貼著排,只在章完整落在紙上時才蓋,蓋印間距(步幅)為 2。請回答:(a)輸出是幾疊、每疊幾列幾行;(b)這一層要保管多少個數字;(c)若改成刻痕間各空兩格的章(其餘不變、步幅改回 1),這枚章實際橫跨幾格?要讓輸出尺寸與輸入一樣,每邊得補幾格空白?(d)第(c)小題的參數量跟(b)比是多少。

解答 SOLUTION

(a)輸出通道數就是章的枚數,9 疊。每疊的列數用第四小節那條式子算:刻痕貼著排時膨脹率是 1,章橫跨 \((7-1)\times 1 + 1 = 7\) 格,所以列數是 \(\lfloor (33-7)/2 \rfloor + 1 = 13 + 1 = 14\),行數是 \(\lfloor (41-7)/2 \rfloor + 1 = 17 + 1 = 18\)。輸出是 9 疊 14×18。

(b)三個數量相乘再加偏置:9 枚章 × 3 疊輸入 × 每枚章面 49 個刻痕 = 1,323,加上 9 個偏置,共 1,332 個。注意「3 疊輸入」這一項不能漏——漏掉的話會算成 9 × 49 + 9 = 450,那正是第五小節說的那個最常見的錯誤。

(c)刻痕間各空兩格,代表相鄰刻痕的間隔是 3 格,也就是膨脹率 3。實際橫跨 \((7-1)\times 3 + 1 = 19\) 格。要讓步幅 1 的輸出跟輸入一樣大,兩邊合計要補 \(19-1 = 18\) 格,所以每邊補 9 格。(以程式代入驗算:補完之後列數 \(33 + 18 - 19 + 1 = 33\)、行數 \(41 + 18 - 19 + 1 = 41\),確實與輸入相同。)

(d)完全一樣,還是 1,332 個。 膨脹只改變刻痕之間的間隔,不改變刻痕的數量。這一題的用意就是把這件事釘住:膨脹買到的是更寬的視野,付的不是權重,付的是「中間那些格子沒被看到」。

作業二:把平均池化拿來當對照組

第三小節量到最大池化讓約 52% 的輸出格完全不變。請把那段程式的池化方式改成平均池化(每個區塊取平均而不是取最大),其餘完全不動,重新量一次。(a)先預測結果,並寫出你的理由;(b)跑出數字驗證;(c)由此說明「下採樣」與「不變性」是不是同一件事。

解答 SOLUTION

(a)預測是趨近於零。理由:最大池化只回報區塊裡最大的那一個數字,區塊裡其他數字怎麼變它都不理,所以只要最大值那一格沒被擠出區塊,輸出就完全不動。平均池化不一樣,它把區塊裡每一個數字都算進去,只要有任何一格換了值,平均就跟著換。紙整體挪一格之後,每個區塊裡至少有一格內容不同,所以平均幾乎不可能剛好相同。

(b)把 deepest 換成取 mean(axis=1),其餘不動,實跑結果:最大池化 0.5181、平均池化 0.0。四千次試驗、四萬個池化輸出格,平均池化沒有任何一格完全不變。

(c)不是同一件事。 兩種池化把尺寸縮小的效果一模一樣(都是每三格變一格),但帶來的不變性差了 52 個百分點。所以「縮小」本身不會產生不變性,產生不變性的是「最大值這個統計量對區塊內部的重排不敏感」這個性質。順帶一提,這也解釋了為什麼想要平移穩健度時,最大池化比平均池化更常被選用——雖然平均池化在別的方面有它自己的好處。

作業三:三層不同設定的感受野

一個三輪的堆疊:第一輪章面 5、步幅 1;第二輪章面 3、步幅 2;第三輪章面 3、步幅 1。刻痕都貼著排。(a)手算每一輪之後的感受野寬度;(b)說明步幅為什麼會讓後面每一輪的貢獻都放大;(c)改寫第四小節的量測程式,把步幅加進去,驗證你的答案。

解答 SOLUTION

(a)用兩個量往前推:感受野寬度 \(r\),以及「輸出往前挪一格,對應到輸入挪幾格」這個跨距 \(t\)。起點 \(r_0 = 1\)、\(t_0 = 1\)。每一輪更新為 \(r_k = r_{k-1} + (K_k - 1)\,t_{k-1}\)、\(t_k = t_{k-1} \times s_k\)。

  • 第一輪:\(r_1 = 1 + 4 \times 1 = 5\),\(t_1 = 1\)
  • 第二輪:\(r_2 = 5 + 2 \times 1 = 7\),\(t_2 = 2\)
  • 第三輪:\(r_3 = 7 + 2 \times 2 = 11\),\(t_3 = 2\)

(b)因為步幅改變的是「這一輪的輸出彼此相隔多遠」。第二輪步幅 2 之後,它的相鄰兩個輸出在原輸入上其實相隔 2 格。第三輪的章雖然只跨自己輸入的 3 格,但那 3 格在原輸入上已經攤開成 5 格的跨度,所以它多帶進來的範圍是 \(2 \times 2 = 4\) 格而不是 2 格。步幅的影響是乘進跨距裡、對後面每一輪持續生效的,這跟膨脹只影響當輪不一樣。

(c)把 stamp 改成接受 stride 參數、輸出的起點每次跳 stride 格,其餘的擾動量測邏輯不動(輸入長度取 81、三輪的章分別是 5、3、3,步幅 1、2、1)。實跑輸出的三個寬度依序是 5711,與(a)逐項相符。

這一題要帶走的結論是:算感受野不能只把核尺寸減一加起來,得同時追蹤跨距。忘了追蹤跨距,是設計較深的卷積網路時最容易低估視野的原因。

§05參考資料