Bỏ qua để đến nội dung

Phân loại ảnh với CNN: AlexNet → VGG → ResNet → MobileNet → EfficientNet

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

  • Hiểu vì sao tích chập (convolution) hợp với ảnh: nhìn cục bộ, chia sẻ trọng số, nên ít tham số hơn lớp kết nối đầy đủ (fully connected layer) hàng trăm nghìn lần.
  • Tự tính được kích thước đầu ra ⌊(H+2p−k)/s⌋+1\lfloor (H + 2p - k)/s \rfloor + 1, số tham số của một lớp, và trường tiếp nhận (receptive field) của một chồng lớp.
  • Kể được ý chính của từng mạng trong dòng phân loại ảnh (image classification): AlexNet, VGG, ResNet, MobileNet, EfficientNet, và bài toán mỗi mạng giải.
  • Cài được tích chập 2D bằng NumPy, huấn luyện một CNN nhỏ bằng PyTorch, đếm tham số các mạng có sẵn trong torchvision.

Bạn có một ảnh chữ số viết tay 8×88 \times 8 điểm ảnh và muốn máy nói đó là số mấy. Hay một ảnh chụp 224×224224 \times 224 và muốn máy nói đó là mèo, chó hay ấm trà. Đó là phân loại ảnh: đầu vào là ảnh, đầu ra là một nhãn trong một danh sách lớp cố định.

Bài toán này có một sân thi đấu nổi tiếng: cuộc thi ImageNet Large Scale Visual Recognition Challenge (ILSVRC, Russakovsky và cộng sự, 2015), tổ chức hằng năm từ 2010. Phần phân loại có 1 000 lớp và khoảng 1.2 triệu ảnh huấn luyện. Năm mạng trong tên bài đều là mốc trên con đường này.

Cách đầu tiên ai cũng nghĩ tới: trải ảnh thành một vectơ dài rồi cho vào mạng nơ-ron thường. Ảnh 224×224×3224 \times 224 \times 3 có 150 528 số. Một lớp kết nối đầy đủ ra chỉ 1 000 nơ-ron đã cần khoảng 150 triệu trọng số.

Tệ hơn nữa là chuyện vị trí. Mỗi trọng số gắn với một điểm ảnh cố định. Con mèo ở góc trái và con mèo ở góc phải đi qua hai bộ trọng số khác hẳn nhau. Mạng phải học lại “tai mèo trông thế nào” ở từng chỗ trong ảnh.

  • Cục bộ. Một cạnh, một nét, một góc chỉ cần nhìn vài điểm ảnh sát nhau. Không cần nối một điểm ở góc trên với một điểm ở góc dưới ngay từ lớp đầu.
  • Lặp lại ở mọi nơi. Một cạnh dọc ở bên trái hay bên phải trông như nhau. Vậy dùng cùng một bộ lọc cho mọi vị trí.

Hãy hình dung một chiếc kính lúp nhỏ chỉ biết tìm một kiểu nét. Bạn trượt nó khắp trang giấy và ghi lại chỗ nào nó “sáng lên”. Đó là một kernel. Nhiều kính lúp, mỗi chiếc tìm một kiểu nét, là một lớp tích chập. Xếp chồng nhiều lớp: lớp đầu tìm cạnh, lớp sau ghép cạnh thành nét cong, góc, rồi thành mắt, tai, rồi thành mèo.

Bài toán của bài này là: làm sao dựng một mô hình nhận ảnh làm đầu vào, ít tham số, nhận ra cùng một mẫu ở mọi vị trí, và đủ sâu để ghép mẫu nhỏ thành vật lớn; rồi làm sao cho nó sâu hơn, chính xác hơn mà vẫn rẻ?

Ảnh là X∈RH×W×C\mathbf{X} \in \mathbb{R}^{H \times W \times C}: cao HH, rộng WW điểm ảnh, CC kênh (C=1C = 1 với ảnh xám, C=3C = 3 với ảnh màu). Một CNN là hàm fθf_{\boldsymbol{\theta}} có tham số θ\boldsymbol{\theta}, biến ảnh thành một vectơ logit, mỗi lớp một số. Softmax đổi logit thành xác suất, lớp có xác suất cao nhất là dự đoán y^\hat{y}. Huấn luyện là chọn θ\boldsymbol{\theta} để hàm mất mát cross-entropy L\mathcal{L} trên tập huấn luyện nhỏ nhất.

ILSVRC chấm bằng lỗi top-5 (top-5 error): dự đoán tính là đúng nếu nhãn thật nằm trong 5 lớp có xác suất cao nhất. Lý do: nhiều ảnh có hơn một vật, và 1 000 lớp có những lớp rất gần nhau (nhiều giống chó).

Lấy ảnh một kênh X\mathbf{X} và kernel K\mathbf{K} cỡ k×kk \times k. Giá trị ở ô (i,j)(i, j) của bản đồ đặc trưng F\mathbf{F} là:

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

Đọc bằng lời: đặt góc trên trái của kernel lên ô (i,j)(i, j) của ảnh, nhân từng cặp ô chồng lên nhau, cộng cả k2k^2 tích, cộng thêm hệ số tự do bb. Chỉ số đếm từ 0, giống NumPy, để khớp với ô code bên dưới.

Lấy kernel cạnh dọc

K=[10−110−110−1]\mathbf{K} = \begin{bmatrix} 1 & 0 & -1 \\ 1 & 0 & -1 \\ 1 & 0 & -1 \end{bmatrix}

Nó lấy tổng cột trái trừ tổng cột phải của cửa sổ. Vùng phẳng (hai cột bằng nhau) cho 0. Chỗ tối bên trái, sáng bên phải cho số âm; chỗ sáng bên trái, tối bên phải cho số dương. Vậy bản đồ đặc trưng “sáng lên” ở các cạnh dọc, và dấu cho biết chiều của cạnh.

Trong toán, phép trên là tương quan chéo (cross-correlation): tích chập “đúng nghĩa” lật kernel trước khi trượt. Thư viện học sâu không lật nhưng vẫn gọi là tích chập; vì kernel được học, điều này không ảnh hưởng gì.

Ảnh màu có Cin=3C_{\text{in}} = 3 kênh. Khi đó mỗi kernel có cỡ k×k×Cink \times k \times C_{\text{in}}: nó phủ cùng lúc cả ba kênh, và tổng ở công thức trên chạy thêm qua các kênh. Mỗi kernel vẫn cho một bản đồ đặc trưng. Một lớp có CoutC_{\text{out}} kernel cho CoutC_{\text{out}} bản đồ, tức đầu ra có CoutC_{\text{out}} kênh.

Đếm tham số: mỗi kernel có k2Cink^2 C_{\text{in}} trọng số và một hệ số tự do, có CoutC_{\text{out}} kernel, nên

soˆˊ tham soˆˊ=k2 Cin Cout+Cout\text{số tham số} = k^2\, C_{\text{in}}\, C_{\text{out}} + C_{\text{out}}

Con số này không phụ thuộc cỡ ảnh H×WH \times W: cùng các kernel đó được trượt trên ảnh to hay nhỏ.

So với lớp kết nối đầy đủ: biến ảnh 32×32×332 \times 32 \times 3 thành 16 bản đồ cùng cỡ 32×3232 \times 32. Kết nối đầy đủ nối mỗi số ra với mọi số vào, cần (32⋅32⋅3)⋅(32⋅32⋅16)=50 331 648(32 \cdot 32 \cdot 3) \cdot (32 \cdot 32 \cdot 16) = 50\,331\,648 trọng số. Tích chập 3×33 \times 3 chỉ cần 32⋅3⋅16=4323^2 \cdot 3 \cdot 16 = 432 trọng số (ô code thứ hai in hai số này), ít hơn khoảng 116 nghìn lần. Hai quan sát ở phần Trực giác chính là hai nguồn tiết kiệm: cục bộ (mỗi số ra chỉ nhìn 27 số vào thay vì 3 072) và chia sẻ trọng số (weight sharing: mọi vị trí dùng chung một kernel).

Chia sẻ trọng số còn cho một tính chất nữa: dịch ảnh đi bao nhiêu thì bản đồ đặc trưng dịch đi bấy nhiêu. Mẫu nào cũng được nhận ra dù nằm ở đâu.

Hai tham số nữa điều khiển việc trượt. Padding (đệm) pp: thêm pp hàng và cột số 0 quanh ảnh. Stride (bước nhảy) ss: mỗi lần kernel dời đi ss ô thay vì 1.

Theo một chiều, ảnh sau khi đệm dài H+2pH + 2p. Mép trái của cửa sổ đứng được ở các vị trí 0,s,2s,…0, s, 2s, \dots, miễn là cửa sổ còn nằm trọn trong ảnh, tức mép trái không vượt quá H+2p−kH + 2p - k. Số vị trí như vậy là số bước ss nằm vừa trong đoạn đó, cộng thêm vị trí 0:

H′=⌊H+2p−ks⌋+1H' = \left\lfloor \frac{H + 2p - k}{s} \right\rfloor + 1

Dấu ⌊⋅⌋\lfloor \cdot \rfloor (làm tròn xuống) có vì bước cuối có thể bị hụt: phần thừa ở mép bị bỏ qua. Chiều rộng W′W' tính y như vậy. Với ảnh 8×88 \times 8 và k=3k = 3:

Padding ppStride ssCỡ đầu ra
016×66 \times 6
118×88 \times 8 (giữ nguyên cỡ)
023×33 \times 3
124×44 \times 4

Pooling dùng đúng công thức này: max pooling 2×22 \times 2 với s=2s = 2, p=0p = 0 cho ⌊(H−2)/2⌋+1=H/2\lfloor (H - 2)/2 \rfloor + 1 = H/2 khi HH chẵn. VGG dùng k=3k = 3, p=1p = 1, s=1s = 1 cho mọi lớp tích chập để giữ nguyên cỡ, rồi để pooling lo việc thu nhỏ: 224→112→56→28→14→7224 \to 112 \to 56 \to 28 \to 14 \to 7.

Một ô của bản đồ đặc trưng “thấy” bao nhiêu điểm ảnh gốc? Vùng đó gọi là trường tiếp nhận. Một lớp 3×33 \times 3: mỗi ô thấy 3×33 \times 3 điểm ảnh. Xếp thêm một lớp 3×33 \times 3 nữa: mỗi ô của lớp thứ hai nhìn 3×33 \times 3 ô của lớp thứ nhất, các ô này chồng lên nhau và mỗi ô nhìn 3×33 \times 3 điểm ảnh, nên tổng cộng thấy 5×55 \times 5. Mỗi lớp k×kk \times k, stride 1, nới trường tiếp nhận thêm k−1k - 1:

rL=1+L (k−1)r_L = 1 + L\,(k - 1)

Có stride thì mỗi bước sau nới rộng hơn, vì một ô ở lớp sau ứng với nhiều điểm ảnh hơn. Với lớp thứ ll có kernel klk_l và stride sls_l:

rL=1+∑l=1L(kl−1)∏m=1l−1smr_L = 1 + \sum_{l=1}^{L} (k_l - 1) \prod_{m=1}^{l-1} s_m

Tích các stride đứng trước cho biết một bước ở lớp ll dài bao nhiêu điểm ảnh gốc. Ví dụ CNN nhỏ ở ô PyTorch: tích chập 3×33 \times 3 (trường tiếp nhận 3), max pooling 2×22 \times 2 stride 2 (thêm 1⋅11 \cdot 1, thành 4), tích chập 3×33 \times 3 sau một stride 2 (thêm 2⋅22 \cdot 2, thành 8), pooling nữa (thêm 1⋅21 \cdot 2, thành 10). Mỗi ô của bản đồ cuối thấy vùng 10×1010 \times 10, rộng hơn cả ảnh 8×88 \times 8 (phần thừa rơi vào vùng đệm). Đó là cách mạng sâu “nhìn” được cả vật lớn dù mỗi kernel chỉ nhỏ 3×33 \times 3.

AlexNet (Krizhevsky, Sutskever, Hinton, 2012) có 5 lớp tích chập và 3 lớp kết nối đầy đủ, khoảng 60 triệu tham số. Lớp đầu dùng 96 kernel 11×11×311 \times 11 \times 3 với stride 4 trên ảnh 224×224224 \times 224. Ở ILSVRC-2012, một biến thể của nó đạt lỗi top-5 15.3%, trong khi đội về nhì đạt 26.2%.

Theo bài báo, khác biệt đến từ ReLU thay cho tanh (học nhanh hơn nhiều lần), dropout và tăng cường dữ liệu (data augmentation: cắt ngẫu nhiên, lật ngang) để bớt overfit, và hai GPU GTX 580 chạy 5 tới 6 ngày. Phần lớn tham số nằm ở các lớp kết nối đầy đủ cuối, đúng như phép đếm ở trên dự đoán: bản torchvision có khoảng 2.5 triệu tham số ở phần tích chập và 58.6 triệu ở phần kết nối đầy đủ.

VGG (Simonyan và Zisserman, 2014, đăng ở ICLR 2015) hỏi: nếu chỉ dùng kernel nhỏ nhất có trái, phải, trên, dưới, tức 3×33 \times 3, rồi xếp thật nhiều lớp thì sao? Mạng có 16 tới 19 lớp có trọng số, mọi tích chập 3×33 \times 3, stride 1, padding 1, thêm 5 lớp max pooling. VGG về nhất phần định vị và nhì phần phân loại ở ILSVRC 2014.

Vì sao ba lớp 3×33 \times 3 tốt hơn một lớp 7×77 \times 7? Theo công thức trường tiếp nhận, ba lớp 3×33 \times 3 cũng thấy 1+3⋅2=71 + 3 \cdot 2 = 7 điểm mỗi chiều. Nhưng với CC kênh vào và ra:

3⋅(32C2)=27C2so với72C2=49C23 \cdot (3^2 C^2) = 27 C^2 \quad \text{so với} \quad 7^2 C^2 = 49 C^2

Lớp 7×77 \times 7 tốn hơn 81%. Thêm nữa, ba lớp có ba ReLU xen giữa, nên hàm mà chồng lớp biểu diễn được “uốn” nhiều hơn một lớp. Cái giá: VGG-16 có khoảng 138 triệu tham số (phần lớn, lại, ở các lớp kết nối đầy đủ) và tốn khoảng 15.3 tỉ phép nhân-cộng mỗi ảnh.

Nếu sâu hơn là tốt hơn, cứ xếp thêm lớp? He và cộng sự (2016) thấy điều ngược lại. Trên CIFAR-10, mạng thường 56 lớp có lỗi trên chính tập huấn luyện cao hơn mạng 20 lớp. Đây không phải overfitting (overfitting thì lỗi huấn luyện thấp, lỗi kiểm tra cao). Tác giả gọi đó là vấn đề suy giảm (degradation).

Lập luận của họ: lấy mạng nông đã học xong, thêm các lớp làm ánh xạ đồng nhất (identity mapping, ra đúng bằng vào). Mạng sâu này cho đúng kết quả của mạng nông. Vậy mạng sâu hơn có ít nhất một nghiệm không tệ hơn. Thế mà gradient descent không tìm ra: học đúng ánh xạ đồng nhất qua vài lớp phi tuyến hóa ra khó.

Cách sửa: gọi H(x)H(\mathbf{x}) là hàm mà vài lớp liên tiếp cần học. Thay vì học thẳng HH, cho các lớp học phần dư F(x)=H(x)−xF(\mathbf{x}) = H(\mathbf{x}) - \mathbf{x}, rồi cộng lại đầu vào qua một kết nối tắt (residual connection, shortcut):

y=F(x)+x\mathbf{y} = F(\mathbf{x}) + \mathbf{x}

Nếu ánh xạ đồng nhất là tốt nhất, các lớp chỉ cần đẩy trọng số về gần 0 để F(x)≈0F(\mathbf{x}) \approx \mathbf{0}, việc dễ hơn nhiều. Thêm khối không còn làm mạng tệ đi. Đường tắt đồng nhất không thêm tham số nào và gần như không tốn phép tính (chỉ một phép cộng). Khi số kênh hay cỡ bản đồ đổi, đường tắt dùng một tích chập 1×11 \times 1 để khớp chiều.

Hình dưới vẽ một khối dư. Hãy nhìn đường tắt vòng qua hai lớp tích chập và gặp lại nhánh chính ở phép cộng.

Một cách nhìn bổ sung, từ bài báo tiếp theo của cùng nhóm (He và cộng sự, 2016, “Identity Mappings”): đạo hàm của x\mathbf{x} theo chính nó là ma trận đơn vị, nên gradient có một đường đi thẳng qua các phép cộng từ lớp cuối về lớp đầu.

Mạng 50 lớp trở lên dùng khối “nút cổ chai” (bottleneck) 1×1→3×3→1×11 \times 1 \to 3 \times 3 \to 1 \times 1: hạ số kênh, tích chập, rồi nâng lại, cho rẻ. ResNet-152 có 152 lớp, sâu gấp 8 lần VGG nhưng chỉ tốn 11.3 tỉ phép nhân-cộng, ít hơn VGG-16. Một tổ hợp (ensemble) các ResNet đạt lỗi top-5 3.57% và đứng nhất phần phân loại ILSVRC 2015.

Đến đây mạng đã chính xác, nhưng quá nặng cho điện thoại. MobileNet (Howard và cộng sự, 2017) thay mỗi lớp tích chập thường bằng tích chập tách theo chiều sâu (depthwise separable convolution), gồm hai bước:

  1. Depthwise: mỗi kênh vào có một kernel k×kk \times k riêng, chỉ lọc trong kênh đó. Lọc không gian, không trộn kênh.
  2. Pointwise: một tích chập 1×11 \times 1 trộn CinC_{\text{in}} kênh thành CoutC_{\text{out}} kênh. Trộn kênh, không nhìn hàng xóm.

Tích chập thường làm cả hai việc trong một bước. Đếm phép nhân-cộng trên bản đồ đầu ra cỡ H′×W′H' \times W': mỗi số ra của tích chập thường cần k2Cink^2 C_{\text{in}} phép, có H′W′CoutH'W'C_{\text{out}} số ra; còn hai bước tách thì lần lượt cần k2k^2 phép cho mỗi số trong H′W′CinH'W'C_{\text{in}} số, và CinC_{\text{in}} phép cho mỗi số trong H′W′CoutH'W'C_{\text{out}} số:

thường=k2 Cin Cout H′W′taˊch=k2 Cin H′W′+Cin Cout H′W′\begin{aligned} \text{thường} &= k^2\, C_{\text{in}}\, C_{\text{out}}\, H'W' \\ \text{tách} &= k^2\, C_{\text{in}}\, H'W' + C_{\text{in}}\, C_{\text{out}}\, H'W' \end{aligned}

Chia dòng dưới cho dòng trên, CinH′W′C_{\text{in}} H'W' triệt tiêu, còn lại:

taˊchthường=k2+Coutk2 Cout=1Cout+1k2\frac{\text{tách}}{\text{thường}} = \frac{k^2 + C_{\text{out}}}{k^2\, C_{\text{out}}} = \frac{1}{C_{\text{out}}} + \frac{1}{k^2}

Với k=3k = 3 và CoutC_{\text{out}} cỡ hàng trăm, số hạng 1/Cout1/C_{\text{out}} rất nhỏ, tỉ lệ gần 1/91/9: ít hơn 8 tới 9 lần. Ví dụ trong bài báo, cũng là ô code thứ hai: bản đồ 14×1414 \times 14, 512 kênh vào và ra, tích chập thường cần 462.4 triệu phép nhân-cộng, bản tách chỉ 52.3 triệu. Số tham số giảm cùng tỉ lệ (bỏ H′W′H'W' ở cả hai dòng). Bản MobileNet đầy đủ có 4.2 triệu tham số, 569 triệu phép nhân-cộng, độ chính xác top-1 70.6% trên ImageNet. Hai siêu tham số, hệ số độ rộng α\alpha (nhân số kênh) và hệ số độ phân giải ρ\rho (nhân cỡ ảnh), cho bạn chọn mạng nhỏ hơn nữa.

Các bản sau, MobileNetV2 (Sandler và cộng sự, 2018) và MobileNetV3 (Howard và cộng sự, 2019), thêm kết nối tắt và tìm kiến trúc tự động.

EfficientNet (2019): co giãn cả ba chiều cùng lúc

Phần tiêu đề “EfficientNet (2019): co giãn cả ba chiều cùng lúc”

Có thêm tài nguyên tính toán thì làm mạng to hơn theo chiều nào? Sâu hơn (thêm lớp, độ sâu dd), rộng hơn (thêm kênh, độ rộng ww), hay cho ảnh to hơn (độ phân giải rr)? Tan và Le (2019) nhận xét: số phép tính tỉ lệ với dd, nhưng với w2w^2 và r2r^2. Gấp đôi số kênh thì cả CinC_{\text{in}} lẫn CoutC_{\text{out}} gấp đôi; gấp đôi cạnh ảnh thì H′W′H'W' gấp 4.

Họ thấy tăng riêng một chiều thì lợi ích chóng bão hòa, và đề xuất co giãn kết hợp (compound scaling) bằng một hệ số ϕ\phi duy nhất:

d=αϕ,w=βϕ,r=γϕ,α⋅β2⋅γ2≈2d = \alpha^{\phi}, \quad w = \beta^{\phi}, \quad r = \gamma^{\phi}, \qquad \alpha \cdot \beta^2 \cdot \gamma^2 \approx 2

Ràng buộc αβ2γ2≈2\alpha \beta^2 \gamma^2 \approx 2 làm số phép tính, tỉ lệ với d⋅w2⋅r2=(αβ2γ2)ϕd \cdot w^2 \cdot r^2 = (\alpha \beta^2 \gamma^2)^{\phi}, tăng khoảng 2ϕ2^{\phi}: mỗi bậc ϕ\phi gấp đôi chi phí. Các hằng số tìm bằng dò lưới nhỏ trên mạng gốc: α=1.2\alpha = 1.2, β=1.1\beta = 1.1, γ=1.15\gamma = 1.15 (tích αβ2γ2≈1.92\alpha\beta^2\gamma^2 \approx 1.92).

Mạng gốc EfficientNet-B0 được tìm bằng tìm kiếm kiến trúc tự động, dùng khối của MobileNetV2 (MBConv) thêm squeeze-and-excitation. B0 có 5.3 triệu tham số, 0.39 tỉ phép tính, top-1 77.1%. Tăng ϕ\phi cho B1 tới B7; theo bài báo, B7 đạt top-1 84.3%, nhỏ hơn 8.4 lần và nhanh hơn 6.1 lần so với mạng tích chập tốt nhất trước đó.

Bạn hiếm khi huấn luyện các mạng này từ đầu. Học chuyển giao (transfer learning) lấy một mạng đã học trên ImageNet, thay lớp phân loại cuối bằng lớp mới có số lớp của bài toán của bạn. Có hai cách hay dùng (theo hướng dẫn transfer learning của PyTorch): đóng băng (frozen) mọi lớp tích chập, chỉ học lớp cuối; hoặc fine-tune cả mạng với tốc độ học η\eta nhỏ. Lý do nó hiệu quả: các lớp đầu học cạnh, màu, kết cấu, những thứ chung cho hầu hết ảnh.

  • Tích chập nhìn cục bộ. Quan hệ giữa hai vùng xa nhau chỉ có ở lớp sâu. Bài Phân loại ảnh với Transformer cho mọi patch nhìn nhau ngay từ lớp đầu.
  • Đếm tham số không phải đo tốc độ. MobileNet ít phép tính, nhưng tốc độ thật còn tùy phần cứng và thư viện.
  • Bài chỉ phân loại cả ảnh. Gán nhãn cho từng điểm ảnh thuộc bài Semantic Segmentation; làm giàu dữ liệu ảnh thuộc bài Image Augmentation.

Minh họa dưới đây cho bạn tự trượt một kernel 3×33 \times 3 trên một chữ số 8×88 \times 8. Bên trái là ảnh với khung cửa sổ, bên phải là bản đồ đặc trưng 6×66 \times 6 (p=0p = 0, s=1s = 1). Bên dưới là dòng phép tính: chín tích cộng lại thành đúng số ở ô đang sáng. Hãy thử:

  1. Chọn kernel đồng nhất (chỉ ô giữa bằng 1): mỗi ô của bản đồ đặc trưng bằng đúng điểm ảnh ở giữa cửa sổ, nên bản đồ là ảnh gốc bị cắt mất viền.
  2. Chọn kernel cạnh dọc, đặt cửa sổ ở vùng nền trống: chín tích đều bằng 0, ô ra bằng 0.
  3. Dời cửa sổ sang trái và phải một nét dọc của chữ số: bên này nét ô ra âm, bên kia dương, giữa nét gần 0.
  4. Đổi sang kernel cạnh ngang ở cùng vị trí: nét dọc gần như biến mất, các nét ngang hiện lên.

Bảng dưới đặt năm mạng cạnh nhau. Cột “Tham số” là số đếm thật bằng torchvision ở ô PyTorch thứ hai (bản ImageNet, 1 000 lớp). AlexNet trong torchvision theo bản “One weird trick” (Krizhevsky, 2014), hơi khác bản 2012. MobileNet bản đầu không có trong torchvision nên bảng ghi số của bài báo.

MạngNămÝ chínhTham số
AlexNet2012Mạng sâu trên GPU, ReLU, dropout, tăng cường dữ liệu61.1M
VGG-162014Chỉ kernel 3×33 \times 3 xếp chồng, 16 lớp có trọng số138.4M
ResNet-18 / ResNet-502015Khối dư F(x)+xF(\mathbf{x}) + \mathbf{x}, huấn luyện được mạng hàng trăm lớp11.7M / 25.6M
MobileNet2017Tích chập tách theo chiều sâu, ít hơn 8 tới 9 lần phép tính4.2M (bài báo)
MobileNetV2 / V3-Small2018 / 2019Thêm kết nối tắt, tìm kiến trúc tự động3.5M / 2.5M
EfficientNet-B02019Co giãn kết hợp độ sâu, độ rộng, độ phân giải5.3M

Cột năm là năm bài báo đầu tiên lên arXiv (ResNet: tháng 12/2015, đăng ở CVPR 2016). Hãy nhìn cột cuối: sau VGG, mạng chính xác hơn nhưng nhỏ đi, vì các ý tưởng về sau chủ yếu tiết kiệm tham số và phép tính.

Code trong bài chạy thử lần cuối tháng 11/2026: hai ô NumPy với Python 3.14, NumPy 2.4, SciPy 1.18 và scikit-learn 1.8 (bản có trong Pyodide); hai ô PyTorch với Python 3.14, PyTorch 2.15 và torchvision 0.29 trên CPU. Hai ô NumPy chạy ngay trên trang, hai ô PyTorch chạy trên máy bạn. Các ô độc lập, không dùng biến của nhau. Ô đầu lần đầu chạy lâu hơn, vì trình duyệt phải tải thêm SciPy và scikit-learn.

Ô dưới lấy ảnh chữ số 1 đầu tiên trong load_digits của scikit-learn (ảnh 8×88 \times 8, giá trị 0 tới 16, chia 16 để về đoạn [0,1][0, 1]), trượt kernel cạnh dọc bằng hai vòng lặp đúng như công thức, rồi so với scipy.signal.correlate2d. Cuối cùng in cỡ đầu ra theo công thức cho vài cặp (p,s)(p, s).

Tích chập 2D trên một chữ số 8×8

import numpy as np
from scipy.signal import correlate2d, convolve2d
from sklearn.datasets import load_digits
# Ảnh chữ số 8x8 đầu tiên có nhãn 1, đưa giá trị về đoạn [0, 1]
digits = load_digits()
idx = int(np.flatnonzero(digits.target == 1)[0])
X = digits.images[idx] / 16.0
# Kernel cạnh dọc: cột trái dương, cột phải âm
K = np.array([[1.0, 0.0, -1.0],
[1.0, 0.0, -1.0],
[1.0, 0.0, -1.0]])
def out_size(n, k, p, s):
# Công thức kích thước đầu ra theo một chiều
return (n + 2 * p - k) // s + 1
def conv2d(X, K):
# Tích chập viết tay: padding 0, stride 1
k = K.shape[0]
H = out_size(X.shape[0], k, 0, 1)
W = out_size(X.shape[1], k, 0, 1)
out = np.zeros((H, W))
for i in range(H):
for j in range(W):
# Tổng tích từng ô của cửa sổ k x k với kernel
out[i, j] = np.sum(X[i:i + k, j:j + k] * K)
return out
F = conv2d(X, K)
print("label:", digits.target[idx], "image:", X.shape)
print(np.round(X, 1))
print("feature map:", F.shape)
print(np.round(F, 2))
print("same as scipy correlate2d:",
np.allclose(F, correlate2d(X, K, mode="valid")))
print("same as convolve2d (flipped):",
np.allclose(F, convolve2d(X, K, mode="valid")))
for p, s in [(0, 1), (1, 1), (0, 2), (1, 2)]:
print(f"k=3 p={p} s={s}: {out_size(8, 3, p, s)}x{out_size(8, 3, p, s)}")

Lần chạy đầu cần tải Python, khoảng 10 MB.

Đọc kết quả:

  • Ảnh và feature map: (6, 6): nét dọc của chữ số 1 nằm ở cột 3 và 4 (đếm từ 0); bản đồ có cỡ ⌊(8+0−3)/1⌋+1=6\lfloor (8 + 0 - 3)/1 \rfloor + 1 = 6.
  • Các cột của bản đồ: cột 1 và 2 âm (khoảng −1.8-1.8 tới −3-3): cửa sổ có nền tối bên trái, nét sáng bên phải. Cột 3 và 4 dương (tới 3.003.00): mép phải của nét. Kernel tìm ra hai cạnh của nét dọc, và dấu cho biết cạnh nào.
  • same as scipy correlate2d: True: vòng lặp tay trùng với tương quan chéo của SciPy.
  • same as convolve2d (flipped): False: convolve2d lật kernel trước khi trượt (tích chập đúng nghĩa toán), nên ra kết quả khác. Với kernel này, lật trái phải chỉ đổi dấu, nên bản đồ ra đổi dấu toàn bộ.
  • Bốn dòng cuối: khớp bảng kích thước đầu ra ở mục Lý thuyết.

Ô thứ hai chỉ dùng Python thuần. Nó làm lại ba phép đếm của mục Lý thuyết: lớp kết nối đầy đủ so với tích chập, tích chập thường so với tách theo chiều sâu (lớp 14×14×51214 \times 14 \times 512 của MobileNet), và ba lớp 3×33 \times 3 so với một lớp 7×77 \times 7 kèm trường tiếp nhận. Các phép đếm bỏ qua hệ số tự do cho gọn.

Đếm tham số: kết nối đầy đủ, tích chập, depthwise separable

# Đếm tham số và phép nhân-cộng (bỏ qua bias cho gọn)
def dense(h, w, c_in, c_out):
# Lớp kết nối đầy đủ: mỗi điểm ra nối với mọi điểm vào
return (h * w * c_in) * (h * w * c_out)
def conv(k, c_in, c_out):
# Tích chập thường: c_out kernel, mỗi kernel k x k x c_in
return k * k * c_in * c_out
def separable(k, c_in, c_out):
# Depthwise (một kernel k x k mỗi kênh) + pointwise 1x1
return k * k * c_in + c_in * c_out
# 1) Ảnh 32x32x3 ra 16 bản đồ đặc trưng cùng cỡ
print(f"dense 32x32x3 -> 32x32x16: {dense(32, 32, 3, 16):,}")
print(f"conv 3x3, 3 -> 16: {conv(3, 3, 16):,}")
# 2) Một lớp của MobileNet: bản đồ 14x14, 512 kênh vào và ra
hw = 14 * 14
std, sep = conv(3, 512, 512), separable(3, 512, 512)
print(f"standard: {std:,} params, {std * hw / 1e6:.1f}M mult-adds")
print(f"separable: {sep:,} params, {sep * hw / 1e6:.1f}M mult-adds")
print(f"ratio: {sep / std:.4f} vs 1/N + 1/k^2 = {1 / 512 + 1 / 9:.4f}")
# 3) Ba lớp 3x3 xếp chồng so với một lớp 7x7, C kênh vào và ra
C = 64
print(f"three 3x3: {3 * conv(3, C, C):,} one 7x7: {conv(7, C, C):,}")
rf = 1
for layer in range(1, 4):
rf += 3 - 1 # mỗi lớp 3x3, stride 1, nới trường nhìn thêm 2
print(f"after {layer} conv 3x3: receptive field {rf}x{rf}")

Lần chạy đầu cần tải Python, khoảng 10 MB.

Đọc kết quả:

  • 50 331 648 so với 432: cùng biến ảnh 32×32×332 \times 32 \times 3 thành 16 kênh 32×3232 \times 32, tích chập ít hơn khoảng 116 nghìn lần.
  • 462.4M và 52.3M phép nhân-cộng: trùng với bảng 3 trong bài báo MobileNet. Tỉ lệ 0.1131 khớp đúng 1/512+1/91/512 + 1/9, tức ít hơn khoảng 8.8 lần.
  • 110 592 so với 200 704: với C=64C = 64, đúng 27C227C^2 và 49C249C^2 của VGG. Ba dòng cuối cho trường tiếp nhận 3,5,73, 5, 7: ba lớp 3×33 \times 3 thấy bằng một lớp 7×77 \times 7.

File dưới huấn luyện một CNN hai khối tích chập trên 1 797 ảnh chữ số của load_digits, chia 80% để học và 20% để kiểm tra. Mỗi khối là tích chập 3×33 \times 3 (padding 1, giữ cỡ), ReLU và max pooling 2×22 \times 2: 8×8→4×4→2×28 \times 8 \to 4 \times 4 \to 2 \times 2. Cuối cùng là một lớp kết nối đầy đủ ra 10 logit. Không cần tải dữ liệu: bộ ảnh đi kèm scikit-learn.

Chạy trên máy bạn

CNN hai lớp tích chập trên load_digits

Cần cài
pip install torch scikit-learn
Phần cứng
CPU là đủ, không cần GPU. Dưới một phút trên máy phát triển; nạp PyTorch có thể mất thêm vài chục giây
import torch
from torch import nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
torch.manual_seed(0)
# 1 797 ảnh chữ số 8x8, giá trị đưa về [0, 1], thêm trục kênh
digits = load_digits()
X = torch.tensor(digits.images / 16.0, dtype=torch.float32).unsqueeze(1)
y = torch.tensor(digits.target)
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.2, random_state=0, stratify=y)
# Hai khối tích chập: 8x8 -> 4x4 -> 2x2, rồi một lớp phân loại
model = nn.Sequential(
nn.Conv2d(1, 8, kernel_size=3, padding=1), nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(8, 16, kernel_size=3, padding=1), nn.ReLU(),
nn.MaxPool2d(2),
nn.Flatten(),
nn.Linear(16 * 2 * 2, 10),
)
n_params = sum(p.numel() for p in model.parameters())
print("input:", tuple(X_tr.shape), "params:", n_params)
print("feature maps:", tuple(model[:6](X_tr[:1]).shape))
opt = torch.optim.Adam(model.parameters(), lr=0.005)
loss_fn = nn.CrossEntropyLoss()
for epoch in range(1, 31):
model.train()
perm = torch.randperm(len(X_tr))
total = 0.0
for start in range(0, len(X_tr), 64):
batch = perm[start:start + 64]
loss = loss_fn(model(X_tr[batch]), y_tr[batch])
opt.zero_grad()
loss.backward() # lan truyền ngược tới từng ô của kernel
opt.step()
total += loss.item() * len(batch)
if epoch % 10 == 0:
model.eval()
with torch.no_grad():
acc = (model(X_te).argmax(1) == y_te).float().mean()
print(f"epoch {epoch} train loss {total / len(X_tr):.3f} "
f"test acc {acc.item():.3f}")

Kết quả mẫu

Lấy từ một lần chạy thật. Câu trả lời của mô hình mỗi lần một khác.

input: (1437, 1, 8, 8) params: 1898
feature maps: (1, 16, 2, 2)
epoch 10 train loss 0.104 test acc 0.961
epoch 20 train loss 0.037 test acc 0.961
epoch 30 train loss 0.014 test acc 0.975

Đọc kết quả:

  • params: 1898: lớp tích chập đầu 32⋅1⋅8+8=803^2 \cdot 1 \cdot 8 + 8 = 80, lớp thứ hai 32⋅8⋅16+16=1 1683^2 \cdot 8 \cdot 16 + 16 = 1\,168, lớp cuối 64⋅10+10=65064 \cdot 10 + 10 = 650. Tổng 1 898, đúng công thức đếm.
  • feature maps: (1, 16, 2, 2): sau hai lần pooling, mỗi ảnh còn 16 bản đồ 2×22 \times 2, tức 64 số vào lớp cuối. Mỗi ô của bản đồ này có trường tiếp nhận 10×1010 \times 10, bao trọn ảnh.
  • test acc 0.975: sau 30 epoch, mạng chưa tới 2 000 tham số đoán đúng 97.5% ảnh chưa thấy. Số này có thể lệch chút ít trên máy hoặc bản PyTorch khác.

Ở ô NumPy bạn tự đặt kernel cạnh dọc; ở đây loss.backward() tính gradient của L\mathcal{L} theo từng ô của mọi kernel, và mạng tự học ra kernel của mình.

File cuối dựng bảy mạng của bảng ở mục Minh họa với weights=None: chỉ dựng kiến trúc với trọng số ngẫu nhiên, không tải gì từ mạng Internet. Nếu muốn trọng số đã học trên ImageNet để làm transfer learning, bạn đổi weights=None thành weights="DEFAULT", và torchvision sẽ tải tệp trọng số về.

Chạy trên máy bạn

Số tham số của bảy CNN trong torchvision

Cần cài
pip install torch torchvision
Phần cứng
CPU là đủ, cần khoảng 1 GB RAM trống cho VGG-16. Vài giây tới vài phút, phần lớn là dựng VGG-16
from torchvision import models
# weights=None: chỉ dựng kiến trúc, trọng số ngẫu nhiên, không tải gì
names = ["alexnet", "vgg16", "resnet18", "resnet50", "mobilenet_v2",
"mobilenet_v3_small", "efficientnet_b0"]
for name in names:
model = models.get_model(name, weights=None)
n = sum(p.numel() for p in model.parameters())
print(f"{name:20s} {n / 1e6:6.1f}M params")

Kết quả mẫu

Lấy từ một lần chạy thật.

alexnet 61.1M params
vgg16 138.4M params
resnet18 11.7M params
resnet50 25.6M params
mobilenet_v2 3.5M params
mobilenet_v3_small 2.5M params
efficientnet_b0 5.3M params

VGG-16 nặng gấp hơn 10 lần ResNet-18 dù nông hơn, vì ba lớp kết nối đầy đủ cuối của nó chiếm khoảng 124 triệu tham số. ResNet bỏ chúng, thay bằng phép lấy trung bình toàn cục (global average pooling) trên mỗi bản đồ đặc trưng rồi một lớp kết nối đầy đủ duy nhất. EfficientNet-B0 ra 5.3 triệu, khớp số trong bài báo.

Câu 1/5 Ảnh xám 28×2828 \times 28 đi qua một lớp tích chập kernel 5×55 \times 5, padding 0, stride 1, rồi max pooling 2×22 \times 2 stride 2. Bản đồ đặc trưng cuối có cỡ bao nhiêu?
Câu 2/5 Một lớp tích chập 3×33 \times 3 có 256 kênh vào và 256 kênh ra. Thay nó bằng tích chập tách theo chiều sâu (depthwise separable) cùng cỡ đầu ra thì số phép nhân-cộng còn khoảng bao nhiêu phần?
Câu 3/5 Vì sao thêm kết nối tắt (đầu ra =F(x)+x= F(\mathbf{x}) + \mathbf{x}) giúp huấn luyện được mạng rất sâu như ResNet-152?
Câu 4/5 Bạn gấp đôi số kênh của mọi lớp tích chập trong một CNN, giữ nguyên số lớp và cỡ ảnh. Số phép tính của các lớp tích chập tăng khoảng bao nhiêu?
Câu 5/5 Trong ảnh, con mèo dịch sang trái 4 điểm ảnh. Bản đồ đặc trưng ra khỏi một lớp tích chập (stride 1, cùng kernel) thay đổi thế nào?

Ý chính:

  • Tích chập trượt một kernel nhỏ trên ảnh: nhìn cục bộ và dùng chung trọng số, nên một lớp chỉ có k2CinCout+Coutk^2 C_{\text{in}} C_{\text{out}} + C_{\text{out}} tham số, không phụ thuộc cỡ ảnh, và mẫu được nhận ra ở mọi vị trí.
  • Cỡ đầu ra mỗi chiều là ⌊(H+2p−k)/s⌋+1\lfloor (H + 2p - k)/s \rfloor + 1. Xếp chồng lớp nhỏ thì trường tiếp nhận lớn dần, rL=1+L(k−1)r_L = 1 + L(k - 1) khi stride bằng 1.
  • AlexNet (2012) đưa CNN sâu lên GPU; VGG (2014) xếp chồng kernel 3×33 \times 3; ResNet (2015–2016) cho khối học phần dư y=F(x)+x\mathbf{y} = F(\mathbf{x}) + \mathbf{x} nên huấn luyện được mạng rất sâu.
  • MobileNet (2017) tách tích chập thành depthwise và pointwise, chi phí còn khoảng 1/Cout+1/k21/C_{\text{out}} + 1/k^2; EfficientNet (2019) co giãn độ sâu, độ rộng, độ phân giải cùng lúc theo một hệ số ϕ\phi.

Đọc thêm:

Nội dung theo giấy phép CC BY-NC-SA 4.0. Mã nguồn theo giấy phép MIT.Giới thiệu