Một bộ lọc nhỏ trượt qua ảnh và đọc từng vùng bằng đúng một bộ trọng số. Các ví dụ dưới đây giữ ảnh ở dạng bảng số, để ta nhìn thấy từng ô, rồi đối chiếu với tensor mà CNN thật sự dùng.
Ví dụ 1: Một bộ lọc đọc bốn cửa sổ trên ảnh 3×3
Ảnh xám ở đây chỉ chứa vài mức sáng nhỏ. Kernel 2×2 nhân ô trên trái với 1, ô dưới phải với 2, và bỏ qua hai ô còn lại. Trượt kernel qua ảnh 3×3, ta có bốn cửa sổ. Ta tính tay kết quả của từng cửa sổ, rồi so với PyTorch. Phép tính ở đây là cross-correlation, không lật kernel, đúng với cách PyTorch làm.
import numpy as np
import torch
import torch.nn.functional as F
torch.set_num_threads(1)
image = np.array([[1, 2, 0], [0, 1, 3], [2, 1, 0]], dtype=np.float32)
kernel = np.array([[1, 0], [0, 2]], dtype=np.float32)
manual = np.empty((2, 2), dtype=np.float32)
for row in range(2):
for col in range(2):
window = image[row:row + 2, col:col + 2]
manual[row, col] = (window * kernel).sum()
# Hai trục đầu là batch và kênh, không phải kích thước cửa sổ.
x = torch.from_numpy(image)[None, None]
weight = torch.from_numpy(kernel)[None, None]
actual = F.conv2d(x, weight)
assert np.array_equal(manual, np.array([[3, 8], [2, 1]]))
assert np.array_equal(manual, actual[0, 0].numpy())
print("Tính tay:", manual.astype(int).tolist())
print("PyTorch:", actual[0, 0].int().tolist())
print("Shape:", tuple(actual.shape))
Tính tay: [[3, 8], [2, 1]]
PyTorch: [[3, 8], [2, 1]]
Shape: (1, 1, 2, 2)
Cửa sổ đầu là [[1, 2], [0, 1]], cho . Cửa sổ kế bên là [[2, 0], [1, 3]], cho . Cùng một kernel được dùng lại ở mọi vị trí. Đó là lý do CNN cần ít trọng số hơn nhiều so với một lớp nối đầy đủ.
PyTorch yêu cầu ảnh có bốn trục: batch, kênh, cao, rộng. Ta thêm hai trục đầu bằng [None, None]. Đầu ra có shape (1, 1, 2, 2): một ảnh, một kênh, và bốn vị trí mà kernel đặt vừa.
Ví dụ 2: Thêm viền số 0, rồi bước cách hai ô
Ảnh 3×3 chứa các số từ 1 tới 9. Kernel 3×3 toàn số 1, nên mỗi vị trí cho tổng của một vùng 3×3. Padding 1 thêm một viền số 0 quanh ảnh, để kernel đọc được cả vùng sát mép. Stride 2 cho kernel bước cách hai ô, nên chỉ còn bốn vị trí. Ta tính bằng NumPy trước, để kiểm cả giá trị ở mép lẫn công thức kích thước đầu ra.
import numpy as np
import torch
import torch.nn.functional as F
torch.set_num_threads(1)
image = np.arange(1, 10, dtype=np.float32).reshape(3, 3)
padding, stride, size = 1, 2, 3
padded = np.pad(image, padding)
out_size = (image.shape[0] + 2 * padding - size) // stride + 1
manual = np.zeros((out_size, out_size), dtype=np.float32)
for row in range(out_size):
for col in range(out_size):
top, left = row * stride, col * stride
manual[row, col] = padded[top:top + size, left:left + size].sum()
y = F.conv2d(torch.from_numpy(image)[None, None],
torch.ones(1, 1, size, size), padding=padding, stride=stride)
assert np.array_equal(manual, np.array([[12, 16], [24, 28]]))
assert np.array_equal(y[0, 0].numpy(), manual)
print("Ảnh:", image.astype(int).tolist())
print("Padding 1, stride 2:", y[0, 0].int().tolist())
print("Chiều đầu ra:", out_size)
Ảnh: [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
Padding 1, stride 2: [[12, 16], [24, 28]]
Chiều đầu ra: 2
Kích thước đầu ra theo công thức . Ô đầu tiên bằng 12, tức chỉ cộng . Năm ô còn lại của cửa sổ rơi vào viền 0. Stride 2 làm giảm số vị trí được đọc, chứ không chia giá trị cho 2. Mỗi ô đầu ra vẫn là tổng đầy đủ của một cửa sổ.
Ví dụ 3: Một điểm sáng dịch qua ranh giới pooling
Hai hàng tín hiệu, mỗi hàng có một điểm sáng bằng 4, chỉ lệch nhau một pixel. Ta so max pooling với average pooling để xem thông tin nào còn giữ lại. Sau đó ta tính vùng nhìn, tức kích thước vùng trên ảnh gốc ảnh hưởng tới một ô đầu ra, qua chuỗi conv, pool, conv.
import torch
import torch.nn.functional as F
torch.set_num_threads(1)
signals = torch.tensor([[[[0., 4., 0., 0.]]],
[[[0., 0., 4., 0.]]]])
maximum = F.max_pool2d(signals, kernel_size=(1, 2))
average = F.avg_pool2d(signals, kernel_size=(1, 2))
print("Max:", maximum[:, 0, 0].tolist())
print("Average:", average[:, 0, 0].tolist())
# jump là khoảng cách trên ảnh gốc giữa hai ô kề nhau ở đầu ra.
field, jump = 1, 1
for name, kernel_size, stride in [("conv", 3, 1), ("pool", 2, 2), ("conv", 3, 1)]:
field += (kernel_size - 1) * jump
jump *= stride
print(name, "vùng nhìn/jump:", field, jump)
assert maximum[0, 0, 0].tolist() == [4.0, 0.0]
assert maximum[1, 0, 0].tolist() == [0.0, 4.0]
assert (field, jump) == (8, 2)
Max: [[4.0, 0.0], [0.0, 4.0]]
Average: [[2.0, 0.0], [0.0, 2.0]]
conv vùng nhìn/jump: 3 1
pool vùng nhìn/jump: 4 2
conv vùng nhìn/jump: 8 2
Max pooling giữ được độ sáng 4, nhưng điểm sáng nhảy sang ô đầu ra khác chỉ vì nó dịch qua ranh giới giữa hai cửa sổ. Pooling giúp model chịu được những dịch chuyển nhỏ bên trong một cửa sổ, chứ không chịu được mọi phép dịch. Average pooling cho 2, vì lấy trung bình của 0 và 4.
Phần sau tính vùng nhìn từng bước. Conv 3×3 đầu tiên cho mỗi ô nhìn 3 pixel. Pool 2×2 với stride 2 mở rộng lên 4 pixel, đồng thời làm các ô đầu ra cách nhau 2 pixel trên ảnh gốc. Vì vậy conv cuối mở rộng thêm pixel, chứ không phải 2, và vùng nhìn thành 8. Càng xếp nhiều lớp, mỗi ô càng nhìn được vùng rộng hơn trên ảnh gốc.