№ 001互動
從零開始的 CNN:在瀏覽器裡看見卷積神經網路怎麼「看」
不用任何機器學習函式庫,只用 TypeScript 寫出一個能辨識手寫數字的卷積神經網路,然後把它每一層的輸出攤開來看。
- 發布
- 更新
- 閱讀時間
- 6 分鐘
卷積神經網路(CNN)的教學,通常從一張方塊圖開始:幾個方塊、幾個箭頭,最後吐出答案。最有趣的部分反而被藏了起來:方塊「裡面」到底發生了什麼?
這篇反過來做。下面這個辨識器已經在你的瀏覽器裡跑起來了,背後沒有伺服器,也沒有 TensorFlow.js 或 ONNX Runtime,整個前向傳播是兩百多行 TypeScript。 先畫一個數字,接著我們一層一層把它拆開。
網路看到的 28×28
- 00.0
- 10.0
- 20.0
- 30.0
- 40.0
- 50.0
- 60.0
- 70.0
- 80.0
- 90.0
影像只是一格一格的數字
對模型來說,一張灰階影像就是一個二維陣列,每一格是 0 到 1 之間的數字:0 是白紙,1 是墨水。上面中間那張小圖就是它,共 個數字。
最直覺的做法,是把這 784 個數字攤平,全部接進一層全連接層。這行得通,但很浪費:全連接層不知道哪兩個像素相鄰。同一個「7」往右移兩格,對它來說就是一組全新的輸入,得重新學一次。
CNN 從兩個對影像幾乎永遠成立的假設出發:
- 局部性:有意義的特徵(邊緣、轉角、筆畫端點)只牽涉一小塊相鄰的像素。
- 平移等變性:垂直邊緣不管出現在左上角還是右下角,都是同一種東西,該用同一組參數偵測。卷積保證的是「輸入右移一格,輸出也右移一格」;答案本身不隨位置改變(平移不變性),則要靠後面的池化,而且只是近似。
把這兩個假設直接寫進模型的結構,就得到卷積。
卷積:一個小窗口滑過整張圖
拿一個小小的權重矩陣,稱為卷積核(kernel),通常是 3×3。把它疊在影像左上角,九個位置兩兩相乘再全部加起來,得到一個數字;往右滑一格,再算一次。滑完整張圖,這些數字排起來就是一張新的圖,叫做特徵圖(feature map)。
寫成式子:
下面這張圖把過程放慢。輸入是一條亮的直線(最下面兩列往左歪了一格),卷積核左欄是 −1、右欄是 +1。按「單步」,看每個輸出格子怎麼算出來。
輸入 6×6
卷積核
輸出 4×4
0·(-1) + 0·(0) + 1·(1) + 0·(-1) + 0·(0) + 1·(1) + 0·(-1) + 0·(0) + 1·(1) = 3
這個卷積核算的是「右邊減左邊」。顏色均勻的地方相減是零,只有左暗右亮的交界才得到大的正數。它是一個垂直邊緣偵測器,而且不管邊緣在哪裡,用的都是同樣九個數字。
寫成程式就是四層迴圈。下面是單通道的簡化版;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),自己改卷積核試試。
輸入
輸出■ 負 ■ 正
傳統電腦視覺的卷積核是人設計的:Sobel、Laplacian、Gaussian。CNN 的關鍵一步是不設計了:把這九個數字當成參數,隨機初始化,讓梯度下降自己找出對任務最有用的幾組。
ReLU 與池化
卷積之後,通常接兩個很小的運算。
ReLU 把負數歸零:。只有卷積疊卷積的話,疊幾層都還是線性運算,可以合併成一個大一點的卷積,深度等於白疊。ReLU 就是讓深度有意義的那個非線性。
最大池化(max pooling)把每個 2×2 區塊換成其中最大的值,長寬各減半。好處有兩個:後面的層只剩四分之一的像素要算;特徵落在區塊內哪一格,輸出都一樣,模型對小幅位移就沒那麼敏感。
完整的網路
這篇的模型只有兩個卷積區塊和一層全連接層:
| 層 | 輸出形狀 | 參數數量 |
|---|---|---|
| 輸入 | 1 × 28 × 28 | 0 |
| conv1 (3×3, 8 個卷積核) → ReLU | 8 × 28 × 28 | 80 |
| maxpool 2×2 | 8 × 14 × 14 | 0 |
| conv2 (3×3, 16 個卷積核) → ReLU | 16 × 14 × 14 | 1,168 |
| maxpool 2×2 | 16 × 7 × 7 | 0 |
| flatten → dense | 10 | 7,850 |
| softmax | 10 | 0 |
總共 9,098 個參數,權重檔約 66 KB,在 MNIST 測試集上的準確率是 98.6%。
參數這麼少,是卷積的功勞。我在同一份 MNIST 上訓練了只有一層隱藏層的全連接網路做比較:
| 模型 | 參數數量 | 測試集準確率 |
|---|---|---|
| 這篇的 CNN | 9,098 | 98.6% |
| 全連接,參數差不多 | 8,755 | 93.4–93.8% |
| 全連接,參數約 10 倍 | 91,435 | 97.8–98.0% |
| 全連接,參數約 45 倍 | 407,050 | 98.2–98.3% |
在 TypeScript 裡,模型就是一個陣列:
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() 和一般推論函式庫有一個刻意的不同:它回傳每一層的輸出,而不只是最後的答案。後面幾張圖全靠它。
網路看到了什麼
這是你畫的數字通過網路時,每一層的特徵圖,越亮代表那個位置反應越強。
conv1 → relu8 × 28×28
maxpool8 × 14×14
conv2 → relu16 × 14×14
maxpool16 × 7×7
換幾個數字畫畫看:
- conv1 的特徵圖還看得出原本的數字,只是各自強調了不同方向的筆畫。
- 到了 conv2,單張特徵圖已經很難解讀,它們編碼的是「某種筆畫組合出現在某處」。
- 最後的 16 × 7 × 7 = 784 個數字,就是全連接層用來投票的全部依據。
哪些像素真的重要
機率高,不代表模型「懂」了。想知道它靠影像的哪個部分做判斷,最直接的方法是:遮住那裡,看信心掉多少。
下面這張圖用一塊 4×4 的空白滑過整張圖,每次移 2 格,共 13 × 13 = 169 個位置。每個位置重跑一次網路(遮到的全是空白就跳過),記下預測類別的機率掉了多少。
網路看到的 28×28
遮住後信心下降最多
它會在哪裡失敗
多玩一下,你會找到讓它出錯的方法:
- 多加一筆,例如在 7 中間加一橫、在 1 底下加一條底線,很容易騙過它,因為訓練資料裡幾乎沒有這種寫法。
- 畫不是數字的東西,它照樣很有信心地回答一個數字。softmax 的輸出加起來一定是 1,模型沒有「我不知道」這個選項。在產線或監視畫面上,模型遲早會看到訓練資料以外的東西,這時高信心不等於正確。
(畫得很小、或只畫在角落倒是沒問題:前處理會先裁切、縮放,再依質心置中,和 MNIST 的製作方式一樣。)
接下來
這個模型只有九千多個參數、分十個類別。把同樣的積木(卷積、非線性、降採樣)疊深、加寬、再加上殘差連接,就是今天在邊緣裝置上做物件偵測、姿態估計和分割的骨幹網路。原理沒變,變的只是規模。
這個模型是先訓練好才搬進來的。想親眼看「訓練」在瀏覽器裡發生,№ 004 會從零訓練一個 Transformer,讓你看著注意力矩陣自己長出來。