除了 MLP 以外,卷積神經網路(Convolutional Neural Network, CNN)也是一種經常被使用的神經網路。它的概念比較像是用濾波器模擬人眼的視野,經由一層一層的處理,來萃取從局部的邊緣、角點,到整體的物體、場景等特徵。因此,convolution 本身的操作,是由一塊小的矩陣(濾波器 filter,或者稱為卷積核 kernel)對一個大的矩陣(例如影像)進行滑動,每次將對應位置的元素相乘後再相加。以下圖為例,大矩陣的尺寸是 5 * 5,kernel 的尺寸是 2 * 2,每次移動一格後,會得到 (5-2+1) * (5-2+1) = 4 * 4 的輸出。當然,你也可以不要每次只移動一格,而是一次移動兩格、三格,甚至更多,這個就是 PyTorch 或其他常見工具裡的 stride 參數,而預設值為 1,代表一次移動一格;而如果希望輸出的矩陣大小與輸入相同,也可以事先在輸入矩陣的四周補零(或其他數值,如果你使用的工具允許),我們稱之為 padding。若需要知道考慮 stride 大於 1、輸入有 padding,或甚至 dilation 等情況後的輸出大小該如何計算,可以參考 PyTorch 等工具的官方文件

而在 CNN 中,一個 convolution layer 會由多個 kernels 所組成,因此輸出會是多個矩陣疊在一起,疊加的維度稱為 channel。若仍以影像為例,假設輸入是一張 100 * 100 * 3 的 RGB 影像(3 個 channels),並且使用 5 個 4 * 4 * 3 的 kernels(kernel 的 channel 數必須跟輸入影像一樣)時,輸出會是一個 97 * 97 * 5 的特徵圖;輸出的每個 channel 都是由對應的一個 kernel 跟輸入影像做完整的 convolution 運算得到的。

上面圖片當中的下方的數字,用於影像時是依次代表訓練階段一次放幾張進去(batch size)、影像高度、影像寬度,以及該影像的 channel 數量;而用於 kernel 時則是 kernel 高度、kernel 寬度、輸入影像的 channel 數量,以及輸出影像的 channel 數量。需要注意的是,各家的深度學習工具為了效能等方面的考量,預設不一定是使用前述的維度順序;在目前較常見的工具中,主要是以 TensorFlow 會使用前述的順序(稱為 NHWC 或 channels-last),而其他工具例如 PyTorch,預設的影像維度順序是 batch size、該影像的 channel 數量、影像高度,以及影像寬度(稱為 NCHW 或 channels-first);預設的 kernel 維度順序則是輸出影像的 channel 數量、輸入影像的 channel 數量、kernel 高度,以及 kernel 寬度。

為了減少運算量和增強模型對微小位移的不變性等緣故,通常還會在卷積層之間加入池化(pooling)的運算,也就是在一小塊區域裡,只保留一個代表性的數值。比較常見的方法是取平均值或最大值來作為代表,分別稱為 average pooling 和 max pooling(具體的函式名稱,在各家工具當中可能有所不同)。以 max pooling 為例,若只考慮一個 channel,影像大小 4 * 4,以及 kernel(或稱 pooling window)大小 2 * 2 時,操作方式的示意如下:

由上圖可見,在 pooling 運算中,pooling window 每次移動的長度,預設會等於 window 的大小,即 stride = window size(這點與 convolution kernel 的預設行為不同,請留意)。而當輸入大小不是 window size 的整數倍,而其他參數仍使用預設時,則多數工具的行為是將剩下的部分捨去,例如 7 * 7 大小的輸入,遇到 3 * 3 大小的 pooling window,則輸出大小會是 floor(7 / 3) * floor(7 / 3) = 2 * 2。若需要知道考慮 stride 不等於 window size、輸入有 padding,或甚至 dilation 等情況後的輸出大小,可以參考 PyTorch 等工具的官方文件

以下是用 PyTorch 的 CNN 相關函式庫,進行數字辨識的範例:

import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')

train_set = datasets.MNIST(root='./data', train=True,  download=True, transform=transforms.ToTensor())
test_set = datasets.MNIST(root='./data', train=False, download=True, transform=transforms.ToTensor())
print('Data shapes:', train_set.data.shape, test_set.data.shape)

train_loader = DataLoader(train_set, batch_size=128, shuffle=True)
test_loader = DataLoader(test_set, batch_size=128)

model = nn.Sequential(
	nn.Conv2d(1, 16, kernel_size=3, padding=1),
	nn.ReLU(),
	nn.MaxPool2d(2),
	nn.Conv2d(16, 32, kernel_size=3, padding=1),
	nn.ReLU(),
	nn.MaxPool2d(2),
	nn.Flatten(),
	nn.Linear(32 * 7 * 7, 256),
	nn.ReLU(),
	nn.Linear(256, 10)
).to(device)
optim = torch.optim.Adam(model.parameters())
criterion = nn.CrossEntropyLoss()

model.train()
for i in range(10):
	print(f'Epoch {i+1}/10')
	for X_batch, y_batch in train_loader:
		loss = criterion(model(X_batch.to(device)), y_batch.to(device))
		optim.zero_grad()
		loss.backward()
		optim.step()

model.eval()
correct = sum(
	(model(X.to(device)).argmax(dim=1) == y.to(device)).sum().item()
	for X, y in test_loader
)
print('Accuracy: {:.2f}%'.format(100 * correct / len(test_set)))

在上述範例中,

使用 CNN 的另一個經典架構,是 Fully Convolutional Network(全卷積網路),它的優點是可處理任意寬高大小的輸入。若要嘗試,只需要把前一個範例的模型定義,更換為以下內容:

model = nn.Sequential(
	nn.Conv2d(1, 16, kernel_size=3, padding=1),
	nn.ReLU(),
	nn.MaxPool2d(2),
	nn.Conv2d(16, 32, kernel_size=3, padding=1),
	nn.ReLU(),
	nn.MaxPool2d(2),
	nn.Conv2d(32, 256, kernel_size=1),
	nn.ReLU(),
	nn.Conv2d(256, 10, kernel_size=1),
	nn.AdaptiveAvgPool2d(1),
	nn.Flatten()
).to(device)

在上述範例中,我們主要是以 kernel size 為 1 * 1 的卷積層,來代替原本的 nn.Linear,並在最後用 nn.AdaptiveAvgPool2d(1) 將寬和高都壓成 1,以及用 nn.Flatten() 來移除這兩個維度。