跳到主要內容

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=02v=02wu,vxi+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
卷積核停在哪裡,下方的算式就展開那九個乘加。注意輸出在直線的左緣是正的、右緣是負的,而在平坦的區域是 0。

這個卷積核算的其實是「右邊減左邊」。在顏色均勻的區域,左右相減等於零;只有在左暗右亮的交界,輸出才會是大的正數。換句話說,它是一個垂直邊緣偵測器,而且不管邊緣在圖的哪個位置,用的都是同樣的九個數字。

寫成程式碼就是幾層迴圈,沒有任何魔法:

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;
  }
}

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

九個數字能做的事情比想像中多。下面的輸入是你剛才畫的數字,請自己改卷積核試試看。

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 的區塊換成其中最大的那個值,長寬各縮一半。它有兩個作用:後面的層要算的像素變成四分之一;而且特徵只要落在那個 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%。 同樣的準確率,如果用全連接網路大概需要十倍以上的參數。

在 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 的空白滑過整張圖,每個位置重跑一次網路(總共 169 次前向傳播),記錄預測類別的機率下降了多少。

fig 05/occlusion sensitivity

網路看到的 28×28

遮住後信心下降最多

預測
原始信心
%
遮擋後最低
%
範例
右圖越亮的位置,被遮住時模型的信心掉得越多。試試 7:通常是橫線和轉角最亮。再試 1 和 7 的差別,模型靠的正是那一橫。

它會在哪裡失敗

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

  • 畫得很小、或只畫在角落其實沒問題,因為前處理會先裁切、縮放,再依質心置中,跟 MNIST 的製作方式一樣。
  • 加上多餘的筆畫(例如在 7 中間加一橫、或在 1 底下加一條底線)很容易騙過它。訓練資料裡幾乎沒有這種寫法。
  • 畫不是數字的東西,它還是會很有信心地回答一個數字。softmax 的輸出加起來一定是 1,模型沒有「我不知道」這個選項。

最後一點在真實系統裡是大問題。在產線或監視畫面上,模型絕大多數時間看到的都是訓練分佈之外的東西,而高信心不等於正確。

接下來

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

這個模型是用梯度下降訓練出來的。下一則筆記換一條完全不同的路:沒有梯度、沒有反向傳播,只靠優勝劣汰,讓 50 隻小鳥自己學會玩 Flappy Bird。而如果你想看「訓練」本身在瀏覽器裡發生,№ 004 會從零訓練一個 Transformer,讓你看著注意力矩陣自己長出來。