跳到主要內容

№ 001互動

從零開始的 CNN:在瀏覽器裡看見卷積神經網路怎麼「看」

不用任何機器學習函式庫,只用 TypeScript 寫出一個能辨識手寫數字的卷積神經網路,然後把它每一層的輸出攤開來看。

發布
更新
閱讀時間
6 分鐘

卷積神經網路(CNN)的教學,通常從一張方塊圖開始:幾個方塊、幾個箭頭,最後吐出答案。最有趣的部分反而被藏了起來:方塊「裡面」到底發生了什麼?

這篇反過來做。下面這個辨識器已經在你的瀏覽器裡跑起來了,背後沒有伺服器,也沒有 TensorFlow.js 或 ONNX Runtime,整個前向傳播是兩百多行 TypeScript。 先畫一個數字,接著我們一層一層把它拆開。

fig 01/draw → predict

網路看到的 28×28

預測
–
信心
–%
推論時間
–ms
  1. 00.0
  2. 10.0
  3. 20.0
  4. 30.0
  5. 40.0
  6. 50.0
  7. 60.0
  8. 70.0
  9. 80.0
  10. 90.0
在左邊畫一個 0–9 的數字。中間是網路真正收到的 28×28 影像,右邊是十個類別的機率。不想畫的話,按下方的範例數字。

影像只是一格一格的數字

對模型來說,一張灰階影像就是一個二維陣列,每一格是 0 到 1 之間的數字:0 是白紙,1 是墨水。上面中間那張小圖就是它,共 28×28=78428 \times 28 = 784 個數字。

最直覺的做法,是把這 784 個數字攤平,全部接進一層全連接層。這行得通,但很浪費:全連接層不知道哪兩個像素相鄰。同一個「7」往右移兩格,對它來說就是一組全新的輸入,得重新學一次。

CNN 從兩個對影像幾乎永遠成立的假設出發:

  • 局部性:有意義的特徵(邊緣、轉角、筆畫端點)只牽涉一小塊相鄰的像素。
  • 平移等變性:垂直邊緣不管出現在左上角還是右下角,都是同一種東西,該用同一組參數偵測。卷積保證的是「輸入右移一格,輸出也右移一格」;答案本身不隨位置改變(平移不變性),則要靠後面的池化,而且只是近似。

把這兩個假設直接寫進模型的結構,就得到卷積。

卷積:一個小窗口滑過整張圖

拿一個小小的權重矩陣,稱為卷積核(kernel),通常是 3×3。把它疊在影像左上角,九個位置兩兩相乘再全部加起來,得到一個數字;往右滑一格,再算一次。滑完整張圖,這些數字排起來就是一張新的圖,叫做特徵圖(feature map)。

寫成式子:

yi,j=b+∑u=02∑v=02wu,v xi+u, j+vy_{i,j} = b + \sum_{u=0}^{2} \sum_{v=0}^{2} w_{u,v} \, x_{i+u,\, j+v}

下面這張圖把過程放慢。輸入是一條亮的直線(最下面兩列往左歪了一格),卷積核左欄是 −1、右欄是 +1。按「單步」,看每個輸出格子怎麼算出來。

fig 02/conv2d / step

輸入 6×6

-101-101-101

卷積核

輸出 4×4

0·(-1) + 0·(0) + 1·(1) + 0·(-1) + 0·(0) + 1·(1) + 0·(-1) + 0·(0) + 1·(1) = 3

位置 (0, 0) · 1/16
卷積核停在哪裡,下方的算式就展開那九個乘加。注意輸出在直線的左緣是正的、右緣是負的。

這個卷積核算的是「右邊減左邊」。顏色均勻的地方相減是零,只有左暗右亮的交界才得到大的正數。它是一個垂直邊緣偵測器,而且不管邊緣在哪裡,用的都是同樣九個數字。

寫成程式就是四層迴圈。下面是單通道的簡化版;lib/ml/ops.ts 裡的版本多了批次和通道的迴圈,最裡面兩層一模一樣:

簡化自 lib/ml/ops.ts
for (let oy = 0; oy < oH; oy++) {
  for (let ox = 0; ox < oW; ox++) {
    let sum = bias;
    for (let ky = 0; ky < kH; ky++) {
      for (let kx = 0; kx < kW; kx++) {
        const iy = oy * stride + ky - padding;
        const ix = ox * stride + kx - padding;
        if (iy < 0 || iy >= h || ix < 0 || ix >= w) continue; // zero padding
        sum += x[iy * w + ix] * kernel[ky * kW + kx];
      }
    }
    out[oy * oW + ox] = sum;
  }
}

換一組數字,就換一種特徵

九個數字能做的事比想像中多。下面的輸入是你剛才畫的數字(沒畫就是範例的 7),自己改卷積核試試。

fig 03/kernel playground

輸入

卷積核 3×3

輸出■ 負 ■ 正

青色是正的輸出,粉紅色是負的。試試「垂直邊緣」和「水平邊緣」:同一個數字,不同的卷積核會點亮不同的筆畫。

傳統電腦視覺的卷積核是人設計的:Sobel、Laplacian、Gaussian。CNN 的關鍵一步是不設計了:把這九個數字當成參數,隨機初始化,讓梯度下降自己找出對任務最有用的幾組。

ReLU 與池化

卷積之後,通常接兩個很小的運算。

ReLU 把負數歸零:ReLU(x)=max⁡(0,x)\mathrm{ReLU}(x) = \max(0, x)。只有卷積疊卷積的話,疊幾層都還是線性運算,可以合併成一個大一點的卷積,深度等於白疊。ReLU 就是讓深度有意義的那個非線性。

最大池化(max pooling)把每個 2×2 區塊換成其中最大的值,長寬各減半。好處有兩個:後面的層只剩四分之一的像素要算;特徵落在區塊內哪一格,輸出都一樣,模型對小幅位移就沒那麼敏感。

完整的網路

這篇的模型只有兩個卷積區塊和一層全連接層:

層輸出形狀參數數量
輸入1 × 28 × 280
conv1 (3×3, 8 個卷積核) → ReLU8 × 28 × 2880
maxpool 2×28 × 14 × 140
conv2 (3×3, 16 個卷積核) → ReLU16 × 14 × 141,168
maxpool 2×216 × 7 × 70
flatten → dense107,850
softmax100

總共 9,098 個參數,權重檔約 66 KB,在 MNIST 測試集上的準確率是 98.6%。

參數這麼少,是卷積的功勞。我在同一份 MNIST 上訓練了只有一層隱藏層的全連接網路做比較:

模型參數數量測試集準確率
這篇的 CNN9,09898.6%
全連接,參數差不多8,75593.4–93.8%
全連接,參數約 10 倍91,43597.8–98.0%
全連接,參數約 45 倍407,05098.2–98.3%

在 TypeScript 裡,模型就是一個陣列:

lib/ml/sequential.ts
export const MNIST_CNN: LayerSpec[] = [
  { type: "conv2d", name: "conv1", inC: 1, outC: 8, kernel: 3, padding: 1 },
  { type: "relu", name: "relu1" },
  { type: "maxpool", name: "pool1", size: 2 },
  { type: "conv2d", name: "conv2", inC: 8, outC: 16, kernel: 3, padding: 1 },
  { type: "relu", name: "relu2" },
  { type: "maxpool", name: "pool2", size: 2 },
  { type: "flatten", name: "flatten" },
  { type: "dense", name: "fc", inF: 784, outF: 10 },
  { type: "softmax", name: "softmax" },
];

Sequential.forward() 和一般推論函式庫有一個刻意的不同:它回傳每一層的輸出,而不只是最後的答案。後面幾張圖全靠它。

網路看到了什麼

這是你畫的數字通過網路時,每一層的特徵圖,越亮代表那個位置反應越強。

fig 04/feature maps

conv1 → relu8 × 28×28

maxpool8 × 14×14

conv2 → relu16 × 14×14

maxpool16 × 7×7

第一列的 8 張圖是 conv1 學到的 8 個卷積核各自的反應。有些只對某個方向的筆畫亮起來,這些邊緣偵測器沒有人教,是訓練出來的。越往下解析度越低,內容也越抽象。

換幾個數字畫畫看:

  1. conv1 的特徵圖還看得出原本的數字,只是各自強調了不同方向的筆畫。
  2. 到了 conv2,單張特徵圖已經很難解讀,它們編碼的是「某種筆畫組合出現在某處」。
  3. 最後的 16 × 7 × 7 = 784 個數字,就是全連接層用來投票的全部依據。

哪些像素真的重要

機率高,不代表模型「懂」了。想知道它靠影像的哪個部分做判斷,最直接的方法是:遮住那裡,看信心掉多少。

下面這張圖用一塊 4×4 的空白滑過整張圖,每次移 2 格,共 13 × 13 = 169 個位置。每個位置重跑一次網路(遮到的全是空白就跳過),記下預測類別的機率掉了多少。

fig 05/occlusion sensitivity

網路看到的 28×28

遮住後信心下降最多

預測
–
原始信心
–%
遮擋後最低
–%
右圖越亮的位置,被遮住時信心掉得越多。試試 7:通常是橫線和轉角最亮。再比較 1 和 7:兩者差在那一橫,圖上最亮的通常也就是它。

它會在哪裡失敗

多玩一下,你會找到讓它出錯的方法:

  • 多加一筆,例如在 7 中間加一橫、在 1 底下加一條底線,很容易騙過它,因為訓練資料裡幾乎沒有這種寫法。
  • 畫不是數字的東西,它照樣很有信心地回答一個數字。softmax 的輸出加起來一定是 1,模型沒有「我不知道」這個選項。在產線或監視畫面上,模型遲早會看到訓練資料以外的東西,這時高信心不等於正確。

(畫得很小、或只畫在角落倒是沒問題:前處理會先裁切、縮放,再依質心置中,和 MNIST 的製作方式一樣。)

接下來

這個模型只有九千多個參數、分十個類別。把同樣的積木(卷積、非線性、降採樣)疊深、加寬、再加上殘差連接,就是今天在邊緣裝置上做物件偵測、姿態估計和分割的骨幹網路。原理沒變,變的只是規模。

這個模型是先訓練好才搬進來的。想親眼看「訓練」在瀏覽器裡發生,№ 004 會從零訓練一個 Transformer,讓你看著注意力矩陣自己長出來。