卷積神經網路 CNN
影像的像素很多,又有「空間結構」:相鄰的像素彼此相關,同一個圖案出現在畫面的哪裡都是同一個東西。卷積神經網路(convolutional neural network,CNN)把這兩個特性直接做進網路結構裡,是深度學習最早大獲成功的架構:1998 年的 LeNet-5 已用來辨識支票上的手寫數字,2012 年的 AlexNet 則在 ImageNet 競賽一舉突破。
為什麼不用全連接網路 Why Not Fully Connected
一張 224×224 的彩色影像有 224 × 224 × 3 = 150,528 個輸入。若第一層有 1000 個神經元並全部連接,光這一層就有約 1.5 億個權值,不但難以訓練,也極易過擬合。更根本的問題是:全連接網路把每個像素位置當成不同的輸入,在左上角學會的貓,到了右下角就要重新學一次。
卷積運算 Convolution
卷積層用一個小的卷積核(kernel,例如 3×3)在輸入上滑動,每個位置做「對應相乘再加總」:
$$y[i, j] = \sum_{m=0}^{k-1}\sum_{n=0}^{k-1} w[m, n]\; x[i\cdot s + m,\ j\cdot s + n] + b$$(深度學習框架實際計算的是互相關,不把卷積核翻轉;因為卷積核本身是學出來的,兩者沒有差別。)卷積層有兩個關鍵性質:
- 局部連接:每個輸出只看輸入的一小塊(感受野)。
- 參數共享:同一組權值用在所有位置,所以參數很少,而且同一個圖案不論出現在哪裡,都會在對應位置產生同樣的反應(平移等變性)。
輸出大小:輸入 \(n \times n\)、卷積核 \(k \times k\)、四周補 \(p\) 圈 0(padding)、每次移動 \(s\) 格(stride)時,
$$n_{\text{out}} = \left\lfloor \frac{n + 2p - k}{s} \right\rfloor + 1$$多通道時(例如彩色影像有 RGB 3 個通道),一個卷積核的大小是 \(k \times k \times C_{\text{in}}\);用 \(C_{\text{out}}\) 個卷積核就得到 \(C_{\text{out}}\) 個輸出通道(特徵圖)。例如 3×3、3 通道輸入、64 個卷積核的卷積層,參數只有 \(3 \cdot 3 \cdot 3 \cdot 64 + 64 = 1792\) 個,和輸入影像多大無關。
下面的程式用公式手算一次,再和 PyTorch 的 conv2d 比對,並檢查輸出大小公式:
# 卷積運算:用公式手算,和 PyTorch 的 conv2d 比對;並檢查輸出大小公式 (n + 2p − k)/s + 1
import torch
import torch.nn.functional as F
x = torch.tensor([[1., 2., 0., 1., 3.],
[0., 1., 3., 2., 1.],
[2., 0., 1., 1., 0.],
[1., 3., 2., 0., 2.],
[0., 1., 1., 2., 1.]])
w = torch.tensor([[1., 0., -1.],
[2., 0., -2.],
[1., 0., -1.]]) # Sobel 核:偵測水平方向的亮度變化(垂直邊緣)
# 公式:y[i, j] = Σ_m Σ_n w[m, n] · x[i + m, j + n](深度學習框架實際算的是互相關,不翻轉卷積核)
y = torch.zeros(3, 3)
for i in range(3):
for j in range(3):
y[i, j] = (w * x[i:i + 3, j:j + 3]).sum()
print("手算:\n", y)
y2 = F.conv2d(x.view(1, 1, 5, 5), w.view(1, 1, 3, 3)).squeeze() # 形狀=(批次, 通道, 高, 寬)
print("conv2d 與手算相同:", torch.equal(y, y2))
for n, k, p, s in [(28, 5, 0, 1), (28, 3, 1, 1), (32, 3, 1, 2), (224, 7, 3, 2)]:
out = F.conv2d(torch.zeros(1, 1, n, n), torch.zeros(1, 1, k, k), padding=p, stride=s).shape[-1]
print(f"n={n:3d} k={k} p={p} s={s} → 輸出 {out:3d},公式 {(n + 2 * p - k) // s + 1:3d}")⬇ 下載 conv_check.py(Python 3+PyTorch)
實際執行的輸出:
手算:
tensor([[-4., -2., 2.],
[-2., 0., 4.],
[-2., 4., 1.]])
conv2d 與手算相同: True
n= 28 k=5 p=0 s=1 → 輸出 24,公式 24
n= 28 k=3 p=1 s=1 → 輸出 28,公式 28
n= 32 k=3 p=1 s=2 → 輸出 16,公式 16
n=224 k=7 p=3 s=2 → 輸出 112,公式 112
互動:卷積核在做什麼 Try It
選一個圖案與卷積核,看黃框滑過輸入、逐格算出輸出。邊緣卷積核在亮暗交界處反應最強;CNN 的第一層學到的卷積核,常常就很像這些邊緣偵測器:
池化與典型架構 Pooling and Architecture
池化(pooling)把每個小區域(例如 2×2)縮成一個值,最常用的是取最大值(max pooling)。它讓特徵圖變小、計算量減少,也讓網路對小幅位移不敏感。典型的 CNN 是「卷積+ReLU → 池化」重複幾次,逐步把影像變成越來越抽象、越來越小的特徵圖,最後接全連接層輸出類別。
堆疊卷積層時,每個輸出看到的輸入範圍(感受野)會擴大:兩層 3×3 卷積的感受野是 5×5,三層是 7×7。所以深層的特徵能涵蓋大範圍的形狀,參數卻比一個 7×7 的大卷積核還少。
範例:平移不變性 Example
產生三種 16×16 的圖形(空心方框、十字、斜線叉,大小與位置隨機,加上雜訊)。訓練時只給畫在左半邊的圖形,測試時拿畫在右半邊的圖形,比較上圖的卷積網路與一個全連接網路:
# 平移不變性:只用畫在「左半邊」的圖形訓練,拿畫在「右半邊」的圖形測試,比較卷積網路與全連接網路
import torch
from torch import nn
def make(n, side, seed):
"""16×16 灰階影像:0=空心方框、1=十字、2=斜線叉,位置限制在左半邊或右半邊,加雜訊"""
g = torch.Generator().manual_seed(seed)
X, y = torch.zeros(n, 1, 16, 16), torch.randint(0, 3, (n,), generator=g)
for i in range(n):
s = int(torch.randint(4, 7, (1,), generator=g)) # 圖形大小 4~6
r = int(torch.randint(0, 16 - s + 1, (1,), generator=g))
c0 = 0 if side == "left" else 8
c = c0 + int(torch.randint(0, 8 - s + 1, (1,), generator=g)) # 只在左(0~7 行)或右(8~15 行)
img = X[i, 0]
if y[i] == 0:
img[r, c:c + s] = img[r + s - 1, c:c + s] = 1
img[r:r + s, c] = img[r:r + s, c + s - 1] = 1
elif y[i] == 1:
img[r + s // 2, c:c + s] = 1
img[r:r + s, c + s // 2] = 1
else:
for k in range(s):
img[r + k, c + k] = img[r + k, c + s - 1 - k] = 1
return X + 0.15 * torch.randn(X.shape, generator=g), y
Xtr, ytr = make(1500, "left", 1)
Xte_l, yte_l = make(500, "left", 2)
Xte_r, yte_r = make(500, "right", 3)
cnn = nn.Sequential(nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 16×16 → 8×8
nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(),
nn.AdaptiveMaxPool2d(1), nn.Flatten(), nn.Linear(16, 3)) # 全域最大池化:不管圖形在哪裡
mlp = nn.Sequential(nn.Flatten(), nn.Linear(256, 32), nn.ReLU(), nn.Linear(32, 3))
for name, net in [("卷積網路 CNN", cnn), ("全連接網路 MLP", mlp)]:
torch.manual_seed(0)
for m in net.modules():
if hasattr(m, "reset_parameters"):
m.reset_parameters()
opt = torch.optim.Adam(net.parameters(), lr=0.01)
for epoch in range(30):
for idx in torch.randperm(len(Xtr)).split(50):
opt.zero_grad()
nn.functional.cross_entropy(net(Xtr[idx]), ytr[idx]).backward()
opt.step()
with torch.no_grad():
acc = lambda X, y: (net(X).argmax(1) == y).float().mean().item()
n_par = sum(p.numel() for p in net.parameters())
print(f"{name}:參數 {n_par:5d} 個,左半邊(看過的位置)準確率 {acc(Xte_l, yte_l):.1%},右半邊(沒看過的位置){acc(Xte_r, yte_r):.1%}")⬇ 下載 shapes_cnn.py(Python 3+PyTorch)
實際執行的輸出:
卷積網路 CNN:參數 1299 個,左半邊(看過的位置)準確率 100.0%,右半邊(沒看過的位置)100.0%
全連接網路 MLP:參數 8323 個,左半邊(看過的位置)準確率 83.6%,右半邊(沒看過的位置)31.0%
全連接網路的參數多了 6 倍,在看過的位置也學得不錯,但到了沒看過的右半邊,準確率就掉到跟亂猜(33%)差不多。卷積網路因為參數共享,加上最後的全域最大池化不管特徵出現在哪裡,在右半邊同樣完全正確。
殘差網路 ResNet
網路疊到幾十層以上時,即使用了 ReLU 與好的初始化,訓練誤差仍會不降反升。2015 年的殘差網路(ResNet)加上「捷徑」,讓每個區塊學習輸入與輸出之間的差(殘差):
因為 \(\partial \mathbf y / \partial \mathbf x = \partial F/\partial \mathbf x + \mathbf I\),梯度至少有一條「乘以 1」的路可以直接傳回前面的層,梯度消失的問題大幅減輕。ResNet 因此能訓練到上百層,殘差連接也成為後來幾乎所有深度網路(包括 Transformer)的標準元件。
在控制上的應用 In Control
CNN 讓攝影機成為控制系統的感測器:從影像估計物體的位置與姿態,用於視覺伺服(visual servoing)、機器人夾取、自動駕駛的車道與障礙物偵測、產線的瑕疵檢測等。這類系統通常是「CNN 負責感知、傳統控制器(如 PID、MPC)負責控制」;也有直接從影像學出控制動作的做法,見後續的強化學習章。