№ 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
這個卷積核算的其實是「右邊減左邊」。在顏色均勻的區域,左右相減等於零;只有在左暗右亮的交界,輸出才會是大的正數。換句話說,它是一個垂直邊緣偵測器,而且不管邊緣在圖的哪個位置,用的都是同樣的九個數字。
寫成程式碼就是幾層迴圈,沒有任何魔法:
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;
}
}換一組數字,就換一種特徵
九個數字能做的事情比想像中多。下面的輸入是你剛才畫的數字,請自己改卷積核試試看。
輸入
輸出■ 負 ■ 正
在傳統電腦視覺裡,這些卷積核是人手設計的:Sobel、Laplacian、Gaussian。CNN 的關鍵一步是不設計了:把九個數字當成參數,隨機初始化,讓梯度下降自己找出對任務最有用的那幾組。
ReLU 與池化
卷積之後通常會接兩個很小的運算。
ReLU 把負數歸零:。沒有它,疊再多層卷積,整體仍然是一個線性運算,等同於一層。ReLU 是讓「深度」真正有意義的那個非線性。
最大池化(max pooling)把每個 2×2 的區塊換成其中最大的那個值,長寬各縮一半。它有兩個作用:後面的層要算的像素變成四分之一;而且特徵只要落在那個 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%。 同樣的準確率,如果用全連接網路大概需要十倍以上的參數。
在 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 的空白滑過整張圖,每個位置重跑一次網路(總共 169 次前向傳播),記錄預測類別的機率下降了多少。
網路看到的 28×28
遮住後信心下降最多
它會在哪裡失敗
多玩一下,你會找到讓它出錯的方法:
- 畫得很小、或只畫在角落其實沒問題,因為前處理會先裁切、縮放,再依質心置中,跟 MNIST 的製作方式一樣。
- 加上多餘的筆畫(例如在 7 中間加一橫、或在 1 底下加一條底線)很容易騙過它。訓練資料裡幾乎沒有這種寫法。
- 畫不是數字的東西,它還是會很有信心地回答一個數字。softmax 的輸出加起來一定是 1,模型沒有「我不知道」這個選項。
最後一點在真實系統裡是大問題。在產線或監視畫面上,模型絕大多數時間看到的都是訓練分佈之外的東西,而高信心不等於正確。
接下來
這個模型有約九千個參數,辨識十個類別。把同樣的積木(卷積、非線性、降採樣)疊深、加寬、再加上殘差連接,就是今天在邊緣裝置上做偵測、姿態估計和分割的骨幹網路。原理沒有變,只是規模不同。
這個模型是用梯度下降訓練出來的。下一則筆記換一條完全不同的路:沒有梯度、沒有反向傳播,只靠優勝劣汰,讓 50 隻小鳥自己學會玩 Flappy Bird。而如果你想看「訓練」本身在瀏覽器裡發生,№ 004 會從零訓練一個 Transformer,讓你看著注意力矩陣自己長出來。