Mở đầu: 43,4 lần ít tham số hơn — mà accuracy còn nhỉnh hơn

MLP $784\to128\to10$ ở Bài 10 dùng $101.770$ tham số để đạt $89{,}5\%$ validation accuracy, nhưng overfit rõ rệt (train accuracy $100\%$, cách biệt $10{,}5$ điểm phần trăm). Bài này verify bằng số thật: một CNN nhỏ chỉ $2.346$ tham số — ít hơn $43{,}4$ lần — đạt $90{,}0\%$ validation accuracy trên ĐÚNG bộ dữ liệu đó, với cách biệt train/val chỉ $1{,}94$ điểm phần trăm. Ít tham số hơn, chính xác hơn, overfit ít hơn — không phải may mắn, mà là hệ quả trực tiếp của một ý tưởng kiến trúc: chia sẻ trọng số.

Bài này xây CNN thật vào NeuroJS — conv2d(), maxPool2d(), flatten() — verify từng op bằng gradient checking, và trả lời câu hỏi cốt lõi: MLP đã "đủ dùng" ở Bài 10 rồi, vậy CNN giải quyết vấn đề gì mà MLP không giải quyết được?


📚 Điều kiện tiên quyết
Bắt buộc: Bài 5 (tensor), Bài 7 (autograd), Bài 10 (MLP + softmax-CE trên MNIST — bài này so sánh trực tiếp với số liệu Bài 10). Tài nguyên ngoài: CS231n — Convolutional Neural Networks, PyTorch — nn.Conv2d.

1. Vì sao MLP đuối với ảnh

MLP Bài 10 hoạt động — nhưng nó ẩn giấu $3$ điểm yếu chỉ lộ ra khi ảnh lớn hơn hoặc phức tạp hơn subset MNIST bé nhỏ:

1. Flatten phá cấu trúc không gian. Bài 10 biến ảnh $28\times28$ thành vector $784$ chiều — 2 pixel NẰM CẠNH NHAU trên ảnh (cùng thuộc 1 nét vẽ) có thể rơi vào 2 vị trí CÁCH XA nhau trong vector đó. MLP nhìn $784$ con số độc lập, không hề biết pixel số $i$ và pixel số $i+1$ có "gần nhau" về mặt hình học hay không.

2. Tham số bùng nổ theo kích thước ảnh. Verify bằng số cụ thể:

$$224 \times 224 \times 3 = 150.528 \text{ pixel dau vao}$$

Chỉ cần nối lớp input đó với $1.000$ neuron ẩn (một kích thước khiêm tốn) bằng matmul như Bài 6 đã học:

$$150.528 \times 1.000 = 150.528.000 \text{ tham so — CHI RIENG layer DAU TIEN}$$

Hơn $150$ triệu tham số cho $1$ layer, trước khi mạng học được bất cứ điều gì — và đây mới chỉ là ảnh $224\times224$, kích thước khiêm tốn so với ảnh thật.

3. Không có bất biến tịnh tiến. Đây chính là cạm bẫy domain shift đã verify ở Bài 10 Mục 5: dịch $1$ ảnh MNIST đúng $5$ pixel — không đổi nét vẽ, chỉ đổi vị trí — làm accuracy của MLP sập từ $100\%$ xuống $2\%$. Với MLP, "chữ số $7$ ở góc trái" và "chữ số $7$ y hệt nhưng dịch sang phải $3$ pixel" là $2$ input HOÀN TOÀN KHÁC NHAU về mặt toán học, dù mắt người thấy rõ đó là cùng $1$ chữ số.

Cả $3$ điểm yếu đều bắt nguồn từ cùng $1$ gốc rễ: MLP không biết ảnh là ảnh — nó chỉ thấy $1$ vector số.

2. Phép tích chập: kernel trượt qua ảnh

Ý tưởng cốt lõi: thay vì $1$ neuron nhìn TOÀN BỘ ảnh, dùng $1$ kernel (ma trận nhỏ, ví dụ $3\times3$) trượt qua TỪNG vùng nhỏ của ảnh, tính tích vô hướng tại mỗi vị trí. Kernel giữ nguyên KHÔNG ĐỔI khi trượt — đây chính là "chia sẻ trọng số" sẽ mổ xẻ ở Mục 3. Kích thước output:

$$OH = \left\lfloor \frac{H + 2P - KH}{S} \right\rfloor + 1$$

Trong đó $H$ = chiều cao ảnh input, $KH$ = chiều cao kernel, $P$ = padding (số pixel $0$ đệm quanh viền ảnh), $S$ = stride (bước nhảy mỗi lần trượt) — công thức tương tự áp cho chiều rộng $OW$.

⚠️ Cạm bẫy: quên padding, ảnh teo dần qua từng layer

Mỗi lớp conv $3\times3$ KHÔNG đệm ($P=0$, $S=1$) làm ảnh nhỏ đi đúng $2$ pixel mỗi cạnh ($OH=H-2$). Verify qua $5$ lớp liên tiếp, ảnh gốc $32\times32$:

Sau layer Không đệm ($P=0$) Có đệm ($P=1$, "same padding")
Input $32\times32$ $32\times32$
Layer 1 $30\times30$ $32\times32$
Layer 3 $26\times26$ $32\times32$
Layer 5 $22\times22$ $32\times32$

Không đệm, ảnh $32\times32$ teo còn $22\times22$ chỉ sau $5$ lớp — tiếp tục xếp sâu hơn (mạng thật thường $50$–$100$+ lớp), ảnh co về $0$ trước khi mạng kịp học xong. $P=1$ với kernel $3\times3$, stride $1$ giữ NGUYÊN kích thước không gian mọi layer (gọi là "same padding") — đây là lựa chọn mặc định trong hầu hết kiến trúc CNN thật.

Kernel là bộ dò đặc trưng — trước khi nói tới việc HỌC kernel, hãy xem kernel THIẾT KẾ SẴN đã làm được gì. Kernel Sobel dò cạnh là ví dụ kinh điển — thử trực tiếp trên $1$ ảnh MNIST thật ở demo dưới đây, chỉnh từng ô kernel bằng tay và xem feature map đổi live:

🔬 Demo — Chỉnh kernel 3×3, xem feature map live trên ảnh MNIST thật

Ảnh gốc (MNIST)

Kernel 3×3 (sửa được)

Feature map (conv2d thật)

Đang tải ảnh…

Bấm Sobel X: mạng lộ ra các cạnh DỌC của nét chữ (đạo hàm ngang); Sobel Y lộ cạnh NGANG. Không cần huấn luyện gì cả — kernel là $9$ con số cố định, và phép tích chập tự nó đã "dò" được cấu trúc hình học. Điều CNN học được qua huấn luyện chính là những con số kernel TỐI ƯU cho bài toán cụ thể, thay vì phải thiết kế tay như Sobel.

3. Chia sẻ trọng số: vì sao tham số giảm hàng chục lần

Với MLP, mỗi neuron ẩn có bộ trọng số RIÊNG cho từng pixel input — $784$ trọng số/neuron, không chia sẻ gì cả. Với conv, MỘT kernel $3\times3$ (chỉ $9$ trọng số + $1$ bias cho $1$ kênh output) được dùng LẶP LẠI ở MỌI vị trí trên ảnh — cùng $1$ bộ dò cạnh áp dụng y hệt dù cạnh đó nằm ở góc trên trái hay giữa ảnh. Đây là bất biến tịnh tiến: kernel dò được đặc trưng ở BẤT KỲ đâu trên ảnh, không cần học riêng cho từng vị trí như MLP.

Đếm tham số thật cho kiến trúc dùng trong demo Mục 5 (conv $1\to4$ kênh $3\times3$ → pool → conv $4\to8$ kênh $3\times3$ → pool → fc $200\to10$), so với MLP $784\to128\to10$ của Bài 10:

Lớp MLP (Bài 10) CNN (bài này)
Lớp 1 $784\times128+128=100.352+128$ conv $4\times1\times3\times3+4=36+4$
Lớp 2 $128\times10+10=1.280+10$ conv $8\times4\times3\times3+8=288+8$
Lớp 3 (fc cuối) $200\times10+10=2.000+10$
Tổng $101.770$ $2.346$

$101.770 \div 2.346 \approx \mathbf{43{,}4}$ — CNN dùng ít hơn $43$ lần tham số cho cùng bài toán $10$ lớp trên cùng dữ liệu. Trực giác: MLP phải học lại "hình dạng nét bút" TỪ ĐẦU cho MỖI vị trí trên ảnh (784 tổ hợp vị trí độc lập); CNN học đúng $1$ bộ kernel rồi TÁI SỬ DỤNG nó ở mọi vị trí — kiến thức "cạnh là gì" chỉ cần học $1$ lần.

🧠 He init cho conv: "fan-in" không chỉ là số kênh input
Bài 9 định nghĩa He init dùng $\sigma = \sqrt{2/\text{fan\_in}}$ với fan-in là "số input của layer đó" — với lớp Linear thì rõ ràng (số neuron input). Với conv thì fan-in PHẢI tính đủ cả kích thước kernel: $\text{fan\_in} = C_{in} \times KH \times KW$, vì mỗi giá trị output là tổng có trọng số của đúng ngần ấy con số input (không chỉ $C_{in}$ kênh, mà toàn bộ $KH \times KW$ pixel trong vùng kernel quét qua của mỗi kênh). Đã kiểm chứng đúng cách NeuroJS cài đặt trong file tải về:
  • Conv1 ($C_{in}=1$, kernel $3\times3$): fan-in $= 1 \times 3 \times 3 = 9$, $\sigma = \sqrt{2/9} \approx 0{,}4714$.
  • Conv2 ($C_{in}=4$, kernel $3\times3$): fan-in $= 4 \times 3 \times 3 = 36$, $\sigma = \sqrt{2/36} \approx 0{,}2357$.
Nhầm fan-in thành đúng $C_{in}$ (bỏ qua $KH \times KW$) sẽ khiến $\sigma$ bị đánh giá quá cao — với Conv2, nhầm fan-in $=4$ thay vì $36$ cho $\sigma = \sqrt{2/4} \approx 0{,}7071$, gấp $3$ lần giá trị đúng — đúng dạng cạm bẫy $\sigma$ quá lớn gây explode đã verify bằng số ở Bài 9 Mục 4, chỉ khác là xảy ra âm thầm hơn vì ít người nghĩ tới việc kernel cũng phải tính vào fan-in.

4. Pooling & xếp tầng: từ cạnh → góc → hình → chữ số

Max pooling giảm chiều không gian bằng cách giữ lại giá trị LỚN NHẤT trong mỗi ô nhỏ (thường $2\times2$), không học tham số nào — chỉ là phép lọc. Ý nghĩa kép: giảm số phép tính cho các lớp sau, và giữ lại đặc trưng "mạnh nhất" trong vùng đó (nếu cạnh xuất hiện Ở ĐÂU ĐÓ trong ô $2\times2$, max pool vẫn "nhìn thấy" nó dù dịch $1$–$2$ pixel — thêm $1$ lớp bất biến tịnh tiến nhỏ).

Xếp tầng conv → relu → pool lặp lại nhiều lần tạo ra đặc trưng PHÂN CẤP: lớp đầu dò được cạnh/góc đơn giản (như Sobel ở Mục 2); lớp sau ghép các cạnh đó thành hình dạng phức tạp hơn (vòng cung, giao điểm); lớp cuối cùng nhận diện được toàn bộ chữ số. Không ai lập trình tay việc "ghép cạnh thành hình" — nó tự nổi lên từ việc xếp nhiều lớp conv liên tiếp, mỗi lớp nhìn một vùng RỘNG HƠN của ảnh gốc so với lớp trước (gọi là "receptive field" — mở rộng dần theo độ sâu).

Kiến trúc dùng trong demo Mục 5, đọc theo chiều sâu:

Lớp Kích thước output Vai trò
Input $1\times28\times28$ Ảnh xám gốc
Conv1 ($3\times3$, $1\to4$) $4\times26\times26$ Dò $4$ loại cạnh/góc cơ bản
MaxPool ($2\times2$) $4\times13\times13$ Giảm chiều, giữ đặc trưng mạnh nhất
Conv2 ($3\times3$, $4\to8$) $8\times11\times11$ Ghép cạnh thành hình phức tạp hơn
MaxPool ($2\times2$) $8\times5\times5$ Giảm chiều lần $2$
Flatten + FC $200 \to 10$ Tổng hợp toàn bộ đặc trưng thành $10$ lớp
🔢 Receptive field lớn tới đâu sau đúng 2 lớp conv + 2 lớp pool?
"Mỗi lớp nhìn một vùng RỘNG HƠN" nghe trừu tượng — tính chính xác cho kiến trúc demo Mục 5 bằng công thức receptive field chuẩn ($RF_{out} = RF_{in} + (K-1) \times \text{jump}_{in}$, đã kiểm chứng bằng cách truy vết ngược từng chỉ số pixel một cách thủ công, khớp $100\%$ với công thức):
  • Sau Conv1 ($3\times3$): mỗi giá trị output "nhìn thấy" $3\times3 = 9$ pixel gốc.
  • Sau MaxPool1 ($2\times2$): $4\times4 = 16$ pixel gốc.
  • Sau Conv2 ($3\times3$): $8\times8 = 64$ pixel gốc.
  • Sau MaxPool2 ($2\times2$): $10\times10 = 100$ pixel gốc.
Mỗi giá trị trong feature map cuối cùng ($8\times5\times5$) chỉ "nhìn thấy" đúng $10\times10$ pixel trong ảnh gốc $28\times28$ — khoảng $12{,}8\%$ diện tích ảnh, KHÔNG PHẢI toàn bộ chữ số. Đây là lý do CNN thật (VGG, ResNet...) cần xếp RẤT NHIều lớp: receptive field phải đủ lớn để bao trọn toàn bộ vật thể trước khi lớp fully-connected cuối cùng có thể tổng hợp thông tin toàn cục — với kiến trúc nông chỉ $2$ lớp conv của demo này, mỗi neuron vẫn đang nhìn cục bộ, chưa "thấy" hết cả chữ số cùng lúc.

5. Thực hành: train CNN thật, so trực tiếp với MLP Bài 10

conv2d()/maxPool2d()/flatten() giờ đã là autograd op thật trong NeuroJS — gradient checking xác nhận sai lệch dưới $10^{-2}$ so với sai phân hữu hạn (kỷ luật Bài 7). Train kiến trúc Mục 4 trên ĐÚNG subset MNIST của Bài 10 (seed cố định, $8$ epoch, Adam Bài 9):

🧠 Demo — Train CNN thật + so sánh trực tiếp với MLP Bài 10
Chưa train — bấm nút phía trên.

Mỗi ảnh forward/backward riêng qua conv (không gộp batch thành 1 tensor như Bài 10) — chạy nền, không khoá trang, có thể Dừng rồi Train lại bất kỳ lúc nào.

Kết quả (seed cố định, tái lập được): CNN đạt train $91{,}94\%$ / val $90{,}0\%$ — so với MLP Bài 10 train $100\%$ / val $89{,}5\%$:

MLP (Bài 10) CNN (bài này)
Tham số $101.770$ $2.346$ ($43{,}4$ lần ít hơn)
Epoch train $15$ $8$
Train accuracy $100\%$ $91{,}94\%$
Val accuracy $89{,}5\%$ $\mathbf{90{,}0\%}$
Cách biệt train−val $10{,}5$ điểm % (overfit rõ) $\mathbf{1{,}94}$ điểm % (rất nhẹ)
🔍 Vì sao CNN vừa chính xác hơn, vừa overfit ít hơn — cùng lúc

Đây KHÔNG phải trùng hợp. Ít tham số hơn tự nó là một dạng regularization (nối Bài 8: model đơn giản hơn khó "học thuộc lòng" nhiễu của tập train). Nhưng lý do sâu hơn là inductive bias đúng: CNN GIẢ ĐỊNH SẴN rằng đặc trưng hữu ích là cục bộ (local) và có thể tái sử dụng ở mọi vị trí (translation-invariant) — đúng giả định đó khớp với bản chất thật của ảnh chữ số viết tay. MLP phải tự học lại giả định đó từ dữ liệu (nếu học được), tốn tham số và dễ học sai lệch theo nhiễu riêng của tập train. Giả định kiến trúc ĐÚNG còn giá trị hơn nhiều tham số.

Confusion matrix CNN cũng đổi khác MLP: chữ số $6$ vẫn hoàn hảo ($35/35$, giống hệt MLP Bài 10 — nét vòng+móc dễ nhận diện bất kể kiến trúc) — nhưng cặp nhầm lẫn nhiều nhất giờ là $7$ và $9$ (nhầm LẪN NHAU — $7\to9$ đúng $3$ lần VÀ $9\to7$ đúng $3$ lần, một cặp nhầm lẫn ĐỐI XỨNG), khác hẳn cặp $2\to4$/$2\to3$ nổi bật ở MLP. Bấm Train ở demo trên để xem bảng đầy đủ tự render.

Đối chiếu công nghiệp: PyTorch nn.Conv2d/nn.MaxPool2d đúng 1-1 với Mục 2-4:

cnn_mnist.py (chạy ngoài browser — cần cài PyTorch)
# Doi chieu 1-1 voi kien truc Muc 4-5 - cai dat: pytorch.org/get-started/locally
import torch.nn as nn

model = nn.Sequential(
    nn.Conv2d(1, 4, kernel_size=3),   # Muc 5: conv2d(1->4,3x3), khong padding
    nn.ReLU(),
    nn.MaxPool2d(2),                   # Muc 4: pool 2x2
    nn.Conv2d(4, 8, kernel_size=3),   # conv2d(4->8,3x3)
    nn.ReLU(),
    nn.MaxPool2d(2),
    nn.Flatten(),                      # Muc 5: flatten() - (8,5,5) -> (200,)
    nn.Linear(200, 10),
)
loss_fn = nn.CrossEntropyLoss()  # gop softmax+CE, giong Bai 10 Muc 3
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)  # giong Bai 9

Tóm lược

  • MLP đuối với ảnh vì 3 lý do: flatten phá cấu trúc 2D, tham số bùng nổ (verified: $150.528.000$ tham số cho $1$ layer ảnh $224\times224$), không bất biến tịnh tiến (nối domain shift Bài 10: dịch $5$px sập accuracy $100\%\to2\%$).
  • Tích chập: kernel trượt qua ảnh, công thức output-size $OH=\lfloor(H+2P-KH)/S\rfloor+1$; quên padding làm ảnh teo dần (verified: $5$ lớp $3\times3$ không đệm teo $32\to22$px); kernel là bộ dò đặc trưng — demo Sobel thật lộ cạnh mà KHÔNG cần huấn luyện gì.
  • Chia sẻ trọng số giảm tham số $43{,}4$ lần (verified: CNN $2.346$ so với MLP $101.770$) — cùng $1$ kernel tái sử dụng ở mọi vị trí thay vì học riêng cho từng vị trí.
  • Pooling + xếp tầng tạo đặc trưng phân cấp cạnh → hình → chữ số; max pool giảm chiều không học tham số, giữ đặc trưng mạnh nhất.
  • Verified trên chính subset MNIST của Bài 10: CNN ít tham số hơn $43{,}4$ lần vẫn đạt val accuracy CAO HƠN ($90{,}0\%$ so với $89{,}5\%$) với overfit ít hơn hẳn ($1{,}94$ so với $10{,}5$ điểm phần trăm) — inductive bias đúng giá trị hơn số lượng tham số.

Tải file code thực hành minh họa bài học

File JavaScript train CNN thật trên ĐÚNG subset MNIST của Bài 10, verify param count + accuracy + overfit gap bằng số đo được (chạy node cnn_demo.js, mất khoảng $12$ giây):

Tải về cnn_demo.js

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 10: Dự án 1: Nhận dạng chữ số MNIST Bài 12: Embedding & word2vec Quay lại Lộ trình Series Trí Tuệ Nhân Tạo

Bình luận