Skip to content

Mạng tích chập (CNN)

This content is not available in your language yet.

Sau bài này, bạn sẽ:

  • Hiểu ảnh được lưu thành ma trận hay tensor nhiều kênh, và một bộ lọc (filter) 3×3 trượt trên ảnh làm mờ, làm nét hay tìm cạnh thế nào.
  • Tính được phép tích chập (convolution) 2D bằng tay và bằng NumPy, biết vì sao trong học sâu nó thực ra là cross-correlation, và tính được kích thước đầu ra theo padding, stride.
  • Đếm được số tham số của một lớp tích chập nhiều kênh, và giải thích vì sao mạng tích chập (Convolutional Neural Network, CNN) cần ít tham số hơn MLP nhờ kết nối cục bộ và chia sẻ trọng số.
  • Nhận ra cấu trúc tích chập → kích hoạt → pooling → … → lớp kết nối đầy đủ (fully connected layer), tính được trường tiếp nhận (receptive field), và đọc được một CNN nhỏ viết bằng PyTorch.

Với máy tính, một ảnh xám là một bảng số. Mỗi ô là một điểm ảnh (pixel), số càng lớn thì càng sáng. Đây là một ảnh xám 8×8, nền tối (0) và một hình vuông sáng (9) ở giữa. Bài này dùng đúng ảnh này từ đầu tới cuối:

X=[0000000000000000009999000099990000999900009999000000000000000000]\mathbf{X} = \begin{bmatrix} 0&0&0&0&0&0&0&0\\ 0&0&0&0&0&0&0&0\\ 0&0&9&9&9&9&0&0\\ 0&0&9&9&9&9&0&0\\ 0&0&9&9&9&9&0&0\\ 0&0&9&9&9&9&0&0\\ 0&0&0&0&0&0&0&0\\ 0&0&0&0&0&0&0&0 \end{bmatrix}

Ảnh màu thì có ba bảng chồng lên nhau, mỗi bảng một kênh (channel): đỏ, lục, lam (RGB). Một ảnh cao HH, rộng WW, có CC kênh là một tensor cỡ H×W×CH \times W \times C, tức một mảng ba chiều. Ảnh chụp 224×224 có 224⋅224⋅3=150 528224 \cdot 224 \cdot 3 = 150\,528 số.

Thử cách đã biết: trải phẳng rồi đưa vào MLP

Phần tiêu đề “Thử cách đã biết: trải phẳng rồi đưa vào MLP”

Bạn đã biết MLP. Cách thẳng nhất là trải phẳng ảnh thành một vectơ dài, rồi đưa vào lớp kết nối đầy đủ. Có ba chỗ không ổn:

  • Quá nhiều tham số. Ảnh 224×224×3 nối tới chỉ 1000 nơ-ron ẩn đã cần 150 528⋅1000≈150150\,528 \cdot 1000 \approx 150 triệu trọng số, cho riêng lớp đầu.
  • Vứt bỏ hàng xóm. Sau khi trải phẳng, hai điểm ảnh cạnh nhau theo chiều dọc nằm cách nhau cả một hàng trong vectơ. MLP không biết điểm nào gần điểm nào; nó phải tự học lại điều đó từ dữ liệu.
  • Học lại cùng một thứ ở mọi chỗ. Một cái cạnh ở góc trái và cùng cái cạnh đó ở góc phải đi qua hai nhóm trọng số khác nhau. Mạng phải học cách nhận ra cạnh riêng cho từng vị trí.

Ý tưởng: một cửa sổ nhỏ trượt khắp ảnh

Phần tiêu đề “Ý tưởng: một cửa sổ nhỏ trượt khắp ảnh”

Người làm xử lý ảnh đã có cách khác từ lâu. Lấy một bảng nhỏ 3×3, gọi là bộ lọc (filter) hay kernel. Đặt nó lên một góc ảnh, nhân từng ô của bảng với điểm ảnh nằm dưới nó, cộng hết lại thành một số. Trượt sang phải một ô, làm lại. Hết hàng thì xuống một ô. Các số thu được xếp thành một ảnh mới.

Các con số trong kernel quyết định ảnh mới trông thế nào:

KernelGiá trịTác dụng
Làm mờ trung bìnhchín ô đều bằng 1/91/9Mỗi điểm thành trung bình của 9 điểm quanh nó, nên chi tiết nhòe đi
Làm néttâm 55, bốn ô trên dưới trái phải −1-1, góc 00Điểm khác hàng xóm thì càng bị đẩy khác hơn, nên đường viền rõ hơn
Sobel dọccột trái −1,−2,−1-1, -2, -1; cột giữa 00; cột phải 1,2,11, 2, 1Đo “bên phải sáng hơn bên trái bao nhiêu”, nên chỉ sáng lên ở cạnh dọc

Ý hay nằm ở hai chỗ. Kernel chỉ nhìn một vùng nhỏ, nên nó tôn trọng hàng xóm. Và cùng một kernel dùng cho mọi vị trí, nên 9 số đủ để tìm cạnh dọc ở bất cứ đâu trong ảnh.

Bài toán của bài này là: thay vì tự tay thiết kế kernel như Sobel, để mạng nơ-ron tự học các kernel từ dữ liệu, xếp nhiều lớp như vậy chồng lên nhau, và hiểu mỗi lớp nhận gì, trả gì, tốn bao nhiêu tham số.

Gọi X\mathbf{X} là ảnh xám và K\mathbf{K} là kernel cỡ k×kk \times k. Ký hiệu Xi,jX_{i,j} là điểm ảnh ở hàng ii, cột jj, và Ku,vK_{u,v} là ô ở hàng uu, cột vv của kernel, đều đếm từ 0 như trong NumPy. Đặt góc trên trái của kernel tại vị trí (i,j)(i, j), nhân từng cặp ô rồi cộng:

Zi,j=∑u=0k−1∑v=0k−1Xi+u, j+v Ku,v+bZ_{i,j} = \sum_{u=0}^{k-1}\sum_{v=0}^{k-1} X_{i+u,\, j+v}\, K_{u,v} + b

Ở đây bb là hệ số tự do (bias), một số chung cho cả kernel; các bộ lọc xử lý ảnh ở trên có b=0b = 0. Bảng kết quả Z\mathbf{Z} gọi là bản đồ đặc trưng (feature map): nó cho biết ở mỗi vị trí, vùng ảnh giống mẫu hình mà kernel tìm tới đâu.

Tính tay một ô. Áp Sobel dọc lên ảnh ở trên, tại (i,j)=(2,0)(i, j) = (2, 0). Cửa sổ là hàng 2 tới 4, cột 0 tới 2 của X\mathbf{X}:

[009009009]⊙[−101−202−101]=[0090018009]\begin{bmatrix} 0&0&9\\ 0&0&9\\ 0&0&9 \end{bmatrix} \odot \begin{bmatrix} -1&0&1\\ -2&0&2\\ -1&0&1 \end{bmatrix} = \begin{bmatrix} 0&0&9\\ 0&0&18\\ 0&0&9 \end{bmatrix}

Phép ⊙\odot nhân từng ô với ô cùng chỗ. Cộng chín số lại được Z2,0=9+18+9=36Z_{2,0} = 9 + 18 + 9 = 36. Cột phải sáng, cột trái tối: đây là cạnh trái của hình vuông. Dời cửa sổ tới (2,2)(2, 2), cả cửa sổ toàn số 9, cột trái và cột phải triệt tiêu nhau, nên Z2,2=0Z_{2,2} = 0: giữa hình vuông không có cạnh. Ở cạnh phải, tối nằm bên phải, nên giá trị âm, Z2,5=−36Z_{2,5} = -36.

Ảnh 8×8, kernel 3×3: góc trên trái của kernel chỉ đặt được ở 6 vị trí mỗi chiều để kernel không lòi ra ngoài, nên Z\mathbf{Z} cỡ 6×6.

Padding (đệm) pp: thêm pp vòng số 0 quanh ảnh trước khi trượt. Không có padding thì mỗi lớp làm ảnh co lại k−1k - 1 điểm mỗi chiều, và các điểm ảnh ở mép được kernel ghé qua ít lần hơn điểm ở giữa. Với k=3k = 3, p=1p = 1 giữ nguyên kích thước.

Stride (bước trượt) ss: mỗi lần kernel nhảy ss ô thay vì 1. Stride 2 làm đầu ra nhỏ đi khoảng một nửa mỗi chiều.

Với ảnh vuông cạnh nn (ở đây nn là số điểm ảnh mỗi cạnh, không phải số đặc trưng), ảnh sau khi đệm có cạnh n+2pn + 2p. Kernel cạnh kk đặt ở vị trí đầu tiên chiếm từ ô 0 tới ô k−1k - 1, nên phần còn lại để trượt dài n+2p−kn + 2p - k ô. Mỗi bước nhảy ss ô, nên có ⌊(n+2p−k)/s⌋\lfloor (n + 2p - k)/s \rfloor bước trọn vẹn, cộng thêm vị trí xuất phát:

nout=⌊n+2p−ks⌋+1n_{\text{out}} = \left\lfloor \frac{n + 2p - k}{s} \right\rfloor + 1

Dấu ⌊⋅⌋\lfloor\cdot\rfloor là làm tròn xuống: bước cuối mà kernel lòi ra ngoài thì bỏ. Vài ví dụ với ảnh 8×8 của bài:

Kernel, padding, stridePhép tínhCạnh đầu ra
k=3k = 3, p=0p = 0, s=1s = 1 (“valid”)⌊5/1⌋+1\lfloor 5/1 \rfloor + 16
k=3k = 3, p=1p = 1, s=1s = 1 (“same”)⌊7/1⌋+1\lfloor 7/1 \rfloor + 18
k=3k = 3, p=0p = 0, s=2s = 2⌊5/2⌋+1\lfloor 5/2 \rfloor + 13

Gọi số kênh vào là CinC_{\text{in}} và số kênh ra là CoutC_{\text{out}}, đúng như hai tham số in_channels, out_channels của nn.Conv2d trong PyTorch. Ảnh màu có Cin=3C_{\text{in}} = 3. Khi đó kernel không phải 3×3 mà là k×k×Cink \times k \times C_{\text{in}}: nó có một lát 3×3 riêng cho mỗi kênh. Ở mỗi vị trí, nhân từng ô trên cả ba kênh rồi cộng tất cả thành một số, cộng thêm bb. Vậy một kernel, dù ảnh vào có bao nhiêu kênh, cho ra một bản đồ đặc trưng.

Một lớp tích chập thường có nhiều kernel, mỗi kernel tìm một mẫu hình riêng (cạnh dọc, cạnh ngang, một vệt màu…). CoutC_{\text{out}} kernel cho ra CoutC_{\text{out}} bản đồ, xếp chồng thành một tensor nout×nout×Coutn_{\text{out}} \times n_{\text{out}} \times C_{\text{out}}. Tensor này lại là đầu vào có CoutC_{\text{out}} kênh cho lớp tích chập kế tiếp.

Đếm tham số. Mỗi kernel có k2Cink^2 C_{\text{in}} trọng số và 1 hệ số tự do. Cả lớp:

soˆˊ tham soˆˊ=(k2Cin+1)Cout\text{số tham số} = \left(k^2 C_{\text{in}} + 1\right) C_{\text{out}}

Không có nn trong công thức: số tham số không phụ thuộc kích thước ảnh. Ví dụ, ảnh xám (Cin=1C_{\text{in}} = 1), 8 kernel 3×3: (9+1)⋅8=80(9 + 1) \cdot 8 = 80 tham số, dù ảnh là 28×28 hay 1000×1000.

Lớp pooling (gộp) làm bản đồ đặc trưng nhỏ lại. Phổ biến nhất là max-pooling 2×2, stride 2: chia bản đồ thành các ô 2×2 không chồng nhau, mỗi ô giữ lại số lớn nhất. Còn có average pooling, giữ trung bình. Pooling không có tham số và làm riêng trên từng kênh.

Lấy bản đồ Sobel dọc 6×6 của ảnh, qua ReLU (số âm thành 0), rồi max-pooling 2×2. Kết quả 3×3 (ô code đầu tiên tính lại):

[270036002700]\begin{bmatrix} 27&0&0\\ 36&0&0\\ 27&0&0 \end{bmatrix}

Bản đồ còn một phần tư số ô, nhưng thông tin chính vẫn còn: “có cạnh dọc kiểu tối sang sáng ở cột bên trái”. Pooling có ba tác dụng: giảm số phép tính ở các lớp sau, mở rộng vùng ảnh mà mỗi giá trị phía sau “thấy”, và làm kết quả ít nhạy với xê dịch nhỏ, như mục sau.

Một CNN điển hình xếp các khối như sau, đọc từ ảnh tới kết quả:

  1. Tích chập → kích hoạt (thường là ReLU) → pooling, lặp lại vài lần. Mỗi lần, ảnh nhỏ đi về chiều cao và chiều rộng, còn số kênh thường tăng lên (ví dụ 8, rồi 16, rồi 32).
  2. Trải phẳng (flatten) tensor cuối thành vectơ.
  3. Một hay vài lớp kết nối đầy đủ, lớp cuối có số đầu ra bằng số lớp, rồi softmax và cross-entropy như MLP.

Các lớp đầu học các mẫu hình đơn giản như cạnh và vệt màu. Các lớp sau ghép chúng thành mẫu hình phức tạp hơn: góc, nét cong, rồi các bộ phận. Cả mạng học bằng lan truyền ngược như MLP; trọng số của các kernel chỉ là thêm tham số để cập nhật.

So một lớp tích chập với một lớp kết nối đầy đủ có cùng đầu vào và đầu ra:

  • Kết nối cục bộ (local connectivity). Mỗi giá trị đầu ra chỉ nối với k×kk \times k điểm ảnh xung quanh, không phải cả ảnh. Điều này mã hóa sẵn một giả định đúng với ảnh: điểm gần nhau liên quan với nhau nhiều hơn.
  • Chia sẻ trọng số (weight sharing). Cùng một kernel dùng ở mọi vị trí. Mẫu hình hữu ích ở góc này cũng hữu ích ở góc kia, nên học một lần là đủ.
  • Ít tham số hơn hẳn. Hai điều trên cộng lại: lớp tích chập 1 → 8 kênh, kernel 3×3, trên ảnh 28×28 cho 26×26×8 giá trị ra với 80 tham số. Một lớp kết nối đầy đủ cho cùng số giá trị ra cần 4 245 280 tham số (ô code thứ hai).
  • Đẳng biến với phép dịch (translation equivariance). Dịch vật trong ảnh sang phải 1 điểm ảnh thì bản đồ đặc trưng cũng dịch sang phải đúng 1 ô, các giá trị giữ nguyên, vì cùng một kernel quét mọi chỗ. Pooling biến điều này thành bất biến gần đúng (approximate invariance): kết quả ít đổi khi vật xê dịch nhỏ. Chỉ là gần đúng: với ảnh của bài, dịch hình vuông sang phải 1 điểm ảnh làm bản đồ 3×3 sau pooling ở trên đổi từ một cột khác 0 thành hai cột. Còn nếu lấy số lớn nhất của cả bản đồ (pooling toàn cục), kết quả là 36 ở cả hai trường hợp.

Đổi lại, CNN giả định ảnh có cấu trúc cục bộ và lặp lại. Với dữ liệu dạng bảng, nơi thứ tự các cột không có nghĩa, giả định đó sai và CNN không có lợi thế.

Trường tiếp nhận (receptive field) của một giá trị trong bản đồ đặc trưng là vùng ảnh vào có ảnh hưởng tới nó. Sau một lớp 3×3, mỗi giá trị nhìn 3×3 điểm ảnh. Thêm một lớp 3×3 nữa: mỗi giá trị nhìn 3×3 giá trị của lớp trước, mỗi giá trị đó lại nhìn 3×3 điểm ảnh, chồng lên nhau thành 5×5.

Tổng quát, đi từ ảnh vào, gọi rr là cạnh trường tiếp nhận và Δ\Delta là khoảng cách (tính bằng điểm ảnh của ảnh vào) giữa hai giá trị kề nhau ở lớp hiện tại. Lúc đầu r=1r = 1, Δ=1\Delta = 1. Mỗi lớp có kernel kk và stride ss cập nhật:

r←r+(k−1) Δ,Δ←Δ⋅sr \leftarrow r + (k - 1)\, \Delta, \qquad \Delta \leftarrow \Delta \cdot s

Cập nhật đầu đúng vì kernel thêm k−1k - 1 giá trị mỗi chiều, mỗi giá trị cách nhau Δ\Delta điểm ảnh. Cập nhật thứ hai đúng vì stride ss bỏ qua s−1s - 1 giá trị trong mỗi ss giá trị.

  • Ba lớp 3×3 stride 1: r=3,5,7r = 3, 5, 7. Trường tiếp nhận tăng đều, mỗi lớp thêm 2.
  • CNN của ô code thứ hai (tích chập 3×3, pooling 2×2 stride 2, tích chập 3×3, pooling 2×2 stride 2): r=3r = 3, rồi 44 (Δ\Delta thành 2), rồi 4+2⋅2=84 + 2 \cdot 2 = 8, rồi 8+1⋅2=108 + 1 \cdot 2 = 10. Mỗi giá trị cuối cùng tóm tắt một vùng 10×10 của ảnh 28×28.

Pooling và stride làm Δ\Delta tăng, nên các lớp sau mở rộng trường tiếp nhận nhanh hơn. Nhờ vậy mạng đủ sâu có thể nhìn cả vật, dù mỗi kernel chỉ 3×3.

  • Bất biến chỉ gần đúng. Đẳng biến với phép dịch đúng cho tích chập stride 1, nhưng stride, pooling và mép ảnh làm nó lệch, như ví dụ dịch 1 điểm ảnh ở trên. CNN cũng không tự bất biến với phép xoay hay đổi tỉ lệ; người ta bù bằng tăng cường dữ liệu (xoay, lật, cắt ảnh khi huấn luyện), một chủ đề của phần Thị giác máy tính.
  • Chỉ nhìn cục bộ ở mỗi lớp. Muốn liên hệ hai vùng xa nhau, mạng cần đủ nhiều lớp. Kiến trúc nào xếp lớp thế nào cho hiệu quả là chủ đề của bài Các kiến trúc CNN hiện đại.

Hình dưới có ảnh xám 8×8 của bài, kernel 3×3 và bản đồ đặc trưng 6×6. Năm nút chọn kernel: Đồng nhất, Làm mờ, Làm nét, Sobel dọc, Sobel ngang. Bấm vào một ô của bản đồ đặc trưng (hoặc dùng phím mũi tên, hay hai nút lùi, tới) để dời cửa sổ. Khung 3×3 trên ảnh cho thấy cửa sổ đang nằm đâu, bảng bên cạnh kernel ghi chín phép nhân từng ô và tổng của chúng. Hàng và cột đếm từ 0. Hãy thử:

  1. Lúc mở, hình chọn Sobel dọc và cửa sổ ở hàng 1, cột 1. Cột phải của kernel gặp hai số 9, nên tổng là 2⋅9+1⋅9=272 \cdot 9 + 1 \cdot 9 = 27. Bấm ô hàng 2, cột 0: cột phải của cửa sổ là ba số 9, phép tính ra 9+18+9=369 + 18 + 9 = 36, giá trị lớn nhất của bản đồ. Bấm ô hàng 2, cột 2: cửa sổ toàn số 9, tổng là 0. Bấm ô hàng 2, cột 5: −36-36, cạnh phải có dấu ngược lại.
  2. Sobel ngang: hai hàng trên của bản đồ dương (lớn nhất 36), hai hàng dưới âm, hai hàng giữa bằng 0. Kernel này tìm cạnh ngang.
  3. Đồng nhất: kernel chỉ có số 1 ở tâm, nên mỗi ô ra đúng bằng điểm ảnh ở tâm cửa sổ. Bản đồ là phần 6×6 ở giữa ảnh: hình vuông 9 trên nền 0, mất một vòng mép.
  4. Làm mờ rồi Làm nét: làm mờ cho 9.00 ở bốn ô giữa và nhỏ dần ra mép, tới 1.00 ở bốn góc. Làm nét đẩy góc hình vuông lên 27, giữa hình vuông giữ 9, và tạo viền −9-9 ngay bên ngoài.

Hai ô đầu chạy trên trang, chỉ cần NumPy hoặc Python thuần, và chạy độc lập với nhau. Ô thứ ba huấn luyện CNN bằng PyTorch nên chạy trên máy bạn.

Ô thứ nhất viết hàm conv2d bằng hai vòng lặp, đúng như công thức ở mục Lý thuyết, áp Sobel dọc lên ảnh 8×8, rồi qua ReLU và max-pooling 2×2.

Tích chập 2D, Sobel và max-pooling bằng NumPy

import numpy as np
# Ảnh xám 8x8: một hình vuông sáng (9) trên nền tối (0)
IMAGE = np.array([
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 9, 9, 9, 9, 0, 0],
[0, 0, 9, 9, 9, 9, 0, 0],
[0, 0, 9, 9, 9, 9, 0, 0],
[0, 0, 9, 9, 9, 9, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
])
# Sobel dọc: phát hiện cạnh dọc (sáng dần từ trái sang phải)
SOBEL_V = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]])
def conv2d(x, kernel, stride=1):
"""Tích chập 'valid', không lật kernel (cross-correlation)."""
k = kernel.shape[0]
n_out = (x.shape[0] - k) // stride + 1
out = np.zeros((n_out, n_out))
for i in range(n_out):
for j in range(n_out):
r, c = i * stride, j * stride
window = x[r:r + k, c:c + k] # cửa sổ k x k
out[i, j] = np.sum(window * kernel) # nhân từng ô rồi cộng
return out
fmap = conv2d(IMAGE, SOBEL_V)
print("feature map", fmap.shape)
print(fmap.astype(int))
# Stride 2: cửa sổ nhảy 2 ô, đầu ra chỉ còn (8 - 3) // 2 + 1 = 3
print("stride 2:", conv2d(IMAGE, SOBEL_V, stride=2).shape)
# Kích hoạt ReLU rồi max-pooling 2x2, bước 2
relu = np.maximum(fmap, 0)
pooled = relu.reshape(3, 2, 3, 2).max(axis=(1, 3))
print("after ReLU + 2x2 max-pool", pooled.shape)
print(pooled.astype(int))

The first run downloads Python, about 10 MB.

Bản đồ đặc trưng 6×6 có hai cột bên trái dương, đọc từ trên xuống là 9, 27, 36, 36, 27, 9 (cạnh trái, tối sang sáng), và hai cột bên phải là các số đối −9,−27,−36,−36,−27,−9-9, -27, -36, -36, -27, -9 (cạnh phải, sáng sang tối). Hai cột giữa bằng 0, vì cửa sổ nằm trọn trong vùng đồng màu nên hai cột của kernel triệt tiêu nhau. Số giảm dần ở hàng trên và hàng dưới vì cửa sổ chỉ phủ một phần hình vuông: ở hàng 2 và 3 của bản đồ, cả ba hàng của cửa sổ chạm hình vuông, nên tổng là 9⋅(1+2+1)=369 \cdot (1 + 2 + 1) = 36; ở hàng 1, chỉ hai hàng dưới của cửa sổ chạm, nên còn 9⋅(2+1)=279 \cdot (2 + 1) = 27; ở hàng 0, chỉ hàng dưới cùng chạm, còn 9⋅1=99 \cdot 1 = 9. Hàng 4 và 5 đối xứng như vậy. Với stride 2, cạnh đầu ra là ⌊(8−3)/2⌋+1=3\lfloor (8 - 3)/2 \rfloor + 1 = 3, khớp công thức. ReLU bỏ hết số âm, max-pooling 2×2 giữ lại 27, 36, 27 ở cột trái, đúng ma trận 3×3 ở mục Pooling.

Dòng pooled = relu.reshape(3, 2, 3, 2).max(axis=(1, 3)) là cách NumPy chia bản đồ 6×6 thành 3×3 khối 2×2 rồi lấy số lớn nhất của từng khối, không cần vòng lặp. Thử đổi dòng fmap = conv2d(IMAGE, SOBEL_V) thành conv2d(IMAGE, SOBEL_V.T) (Sobel ngang): bản đồ dương ở hai hàng trên, âm ở hai hàng dưới, và sau pooling các số 27, 36, 27 nằm ở hàng trên thay vì cột trái.

Ô thứ hai đếm tham số cho ảnh xám 28×28 cỡ chữ số viết tay, so một MLP với một CNN nhỏ. Nó dùng đúng hai công thức của bài: kích thước đầu ra và số tham số.

Đếm tham số: MLP và CNN

# Đếm tham số: MLP và CNN cho cùng một ảnh xám 28x28, 10 lớp
def out_size(n, k, p=0, s=1):
"""Kích thước đầu ra: floor((n + 2p - k) / s) + 1."""
return (n + 2 * p - k) // s + 1
def dense(n_in, n_out):
return n_in * n_out + n_out # trọng số + hệ số tự do
def conv(c_in, c_out, k):
return k * k * c_in * c_out + c_out # mỗi kernel k x k x c_in, 1 bias
# MLP: trải phẳng 784 điểm ảnh -> 128 -> 10
mlp = dense(28 * 28, 128) + dense(128, 10)
print(f"MLP params: {mlp:,}")
# CNN: conv 3x3 (1->8) -> pool 2 -> conv 3x3 (8->16) -> pool 2 -> dense
n = 28
total = 0
for c_in, c_out in [(1, 8), (8, 16)]:
total += conv(c_in, c_out, 3)
n = out_size(n, k=3) # tích chập "valid"
print(f"conv {c_in}->{c_out}: {n}x{n}x{c_out}", end="")
n = out_size(n, k=2, s=2) # max-pooling 2x2, bước 2
print(f" -> pool: {n}x{n}x{c_out}")
total += dense(n * n * 16, 10)
print(f"CNN params: {total:,}")
# Chỉ riêng lớp đầu: kết nối đầy đủ cần bao nhiêu tham số
# để ra cùng số giá trị như conv 1->8 (26x26x8)?
print(f"dense 784 -> 26*26*8: {dense(784, 26 * 26 * 8):,}")
print(f"conv 1->8, 3x3: {conv(1, 8, 3):,}")

The first run downloads Python, about 10 MB.

MLP 784 → 128 → 10 có 101 770 tham số, gần hết nằm ở lớp đầu (784⋅128+128=100 480784 \cdot 128 + 128 = 100\,480). CNN có 5 258 tham số, ít hơn khoảng 19 lần. Đường đi của kích thước cho thấy làm tròn xuống: 28 → 26 → 13 → 11 → 5, vì pooling 2×2 trên cạnh 11 chỉ có ⌊9/2⌋+1=5\lfloor 9/2 \rfloor + 1 = 5 vị trí, cột cuối bị bỏ. Phần lớn tham số của CNN (4 010) nằm ở lớp kết nối đầy đủ cuối, lớp tích chập thứ hai chỉ có (9⋅8+1)⋅16=1 168(9 \cdot 8 + 1) \cdot 16 = 1\,168.

Hai dòng cuối là so sánh rõ nhất: lớp tích chập 1 → 8 kênh cho ra 26×26×8 = 5 408 giá trị với 80 tham số; một lớp kết nối đầy đủ ra cùng số giá trị cần 4 245 280 tham số, gấp hơn 50 nghìn lần.

Ô thứ ba huấn luyện một CNN nhỏ trên bộ chữ số viết tay load_digits của scikit-learn (1 797 ảnh 8×8, một kênh, có sẵn khi cài, không cần tải). Nó so với một MLP cùng số epoch, rồi thử cả hai trên ảnh kiểm tra bị dịch sang phải 1 điểm ảnh.

Run on your machine

CNN và MLP trên chữ số 8×8

Install
pip install torch scikit-learn
Hardware
CPU là đủ. Khoảng 40 giây tới 2 phút tùy máy
import torch
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from torch import nn
torch.manual_seed(0)
# 1797 ảnh chữ số 8x8, giá trị 0..16; chia về 0..1
X, y = load_digits(return_X_y=True)
X = torch.tensor(X / 16.0, dtype=torch.float32).reshape(-1, 1, 8, 8)
y = torch.tensor(y)
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.3, random_state=0, stratify=y)
cnn = nn.Sequential( # ảnh vào: 1 x 8 x 8
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(),
nn.MaxPool2d(2), # 16 x 4 x 4
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(),
nn.MaxPool2d(2), # 32 x 2 x 2
nn.Flatten(), nn.Linear(32 * 2 * 2, 10))
mlp = nn.Sequential( # trải phẳng 64 điểm ảnh
nn.Flatten(), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, 10))
def accuracy(model, X, y):
with torch.no_grad():
return (model(X).argmax(1) == y).float().mean().item()
def train(model, epochs=15):
opt = torch.optim.Adam(model.parameters(), lr=0.01)
loss_fn = nn.CrossEntropyLoss()
for _ in range(epochs):
perm = torch.randperm(len(X_tr))
for i in range(0, len(X_tr), 64): # mini-batch 64 ảnh
idx = perm[i:i + 64]
opt.zero_grad()
loss_fn(model(X_tr[idx]), y_tr[idx]).backward()
opt.step()
# Ảnh kiểm tra dịch sang phải 1 điểm ảnh (cột trái thành 0)
X_shift = torch.roll(X_te, shifts=1, dims=3)
X_shift[..., 0] = 0
for name, model in [("CNN", cnn), ("MLP", mlp)]:
train(model)
n_par = sum(p.numel() for p in model.parameters())
print(f"{name}: {n_par:5d} params "
f"test acc {accuracy(model, X_te, y_te):.3f} "
f"shifted {accuracy(model, X_shift, y_te):.3f}")

Sample output

From one real run.

CNN: 6090 params test acc 0.976 shifted 0.650
MLP: 4810 params test acc 0.957 shifted 0.461

Đọc kết quả:

  • Số tham số gần bằng nhau. CNN có 6 090 tham số: 160 ở lớp tích chập đầu ((9+1)⋅16(9 + 1) \cdot 16), 4 640 ở lớp thứ hai ((9⋅16+1)⋅32(9 \cdot 16 + 1) \cdot 32), 1 290 ở lớp kết nối đầy đủ cuối. MLP có 4 810. Hai lớp tích chập dùng padding=1 nên giữ nguyên cạnh, chỉ pooling làm ảnh nhỏ đi: 8 → 4 → 2.
  • CNN đúng hơn trên ảnh kiểm tra: 0.976 so với 0.957, sau cùng 15 epoch với cùng bộ tối ưu Adam. Ảnh 8×8 rất nhỏ nên MLP vẫn làm khá tốt; khoảng cách thường rộng ra khi ảnh lớn hơn.
  • Dịch ảnh 1 điểm ảnh, cả hai đều tụt mạnh, nhưng CNN ít hơn: 0.650 so với 0.461. Các lớp tích chập đẳng biến với phép dịch, nhưng lớp kết nối đầy đủ ở cuối vẫn gắn mỗi trọng số với một vị trí, và trên ảnh 8×8, 1 điểm ảnh là một phần tám bề ngang. Đây là con số cụ thể cho chữ “gần đúng” ở mục Lý thuyết. Cách bù thường dùng là huấn luyện với ảnh đã dịch, xoay ngẫu nhiên (bài Data augmentation trong CV).

Seed cố định, nên ba lần chạy trên máy phát triển cho cùng kết quả; trên máy khác hay bản PyTorch khác, số có thể lệch đôi chút.

Question 1 of 5 Ảnh vào 32×3232 \times 32, kernel 5×55 \times 5, padding p=2p = 2, stride s=2s = 2. Bản đồ đặc trưng ra có kích thước bao nhiêu?
Question 2 of 5 Ảnh màu RGB đi vào một lớp tích chập có 16 kernel 3×33 \times 3. Lớp này có bao nhiêu tham số, tính cả hệ số tự do?
Question 3 of 5 Xếp chồng ba lớp tích chập 3×33 \times 3, stride 1, không có pooling. Mỗi giá trị ở đầu ra lớp thứ ba phụ thuộc vào một vùng bao nhiêu điểm ảnh của ảnh vào?
Question 4 of 5 Một lớp tích chập (stride 1, có padding giữ kích thước). Bạn dịch vật trong ảnh sang phải 2 điểm ảnh, vật vẫn nằm gọn trong ảnh. Bản đồ đặc trưng của lớp này thay đổi thế nào?
Question 5 of 5 Bạn lấy kernel đã học từ một lớp nn.Conv2d của PyTorch, rồi áp lên ảnh bằng scipy.signal.convolve2d. Kết quả khác hẳn đầu ra của lớp đó trong PyTorch. Nguyên nhân hợp lý nhất?

Ý chính:

  • Ảnh là tensor H×W×CH \times W \times C. Một lớp tích chập trượt các kernel nhỏ k×k×Cink \times k \times C_{\text{in}} trên ảnh, nhân từng ô rồi cộng; trong học sâu phép này không lật kernel, nên thực chất là cross-correlation.
  • Cạnh đầu ra là ⌊(n+2p−k)/s⌋+1\lfloor (n + 2p - k)/s \rfloor + 1. Số tham số là (k2Cin+1)Cout(k^2 C_{\text{in}} + 1) C_{\text{out}}, không phụ thuộc kích thước ảnh. Pooling thu nhỏ bản đồ đặc trưng, không có tham số.
  • CNN xếp các khối tích chập → ReLU → pooling, rồi trải phẳng và đưa vào lớp kết nối đầy đủ. Kết nối cục bộ và chia sẻ trọng số làm nó ít tham số hơn MLP rất nhiều, và đẳng biến với phép dịch; pooling thêm bất biến gần đúng.
  • Trường tiếp nhận lớn dần theo độ sâu: mỗi lớp thêm (k−1) Δ(k - 1)\,\Delta, và pooling, stride làm nó tăng nhanh hơn.

Đọc thêm:

Content licensed under CC BY-NC-SA 4.0. Code under the MIT license.About