Mạng nơ-ron và CNN hoạt động thế nào: neuron, tích chập, pooling
Hiểu neuron, hàm kích hoạt, tích chập, pooling, feature map, receptive field và tự tính số tham số lớp Conv bằng NumPy, kiểm tra lại với PyTorch.
Học xong bài này, bạn sẽ
- Hiểu neuron, hàm kích hoạt, lớp và linear classifier
- Hiểu tích chập, pooling, feature map, conv 1×1
- Tính số tham số một lớp Conv
Nên học trước: Gradient descent từ đầu với NumPy
Nội dung bài viết
- 1. Một neuron: tổng có trọng số rồi qua hàm kích hoạt
- 2. Hàm kích hoạt: ReLU, sigmoid, softmax
- 3. Lớp (layer) và mạng MLP: chỉ là phép nhân ma trận
- 4. Vì sao MLP không hợp với ảnh
- 5. Tích chập bằng tay
- 6. Stride, padding và công thức kích thước đầu ra
- 7. Nhiều kênh: feature map
- 8. Pooling: giảm kích thước, chịu được xê dịch nhỏ
- 9. Receptive field: một neuron "nhìn thấy" bao nhiêu pixel?
- 10. Đếm số tham số của lớp Conv
- 11. Một CNN nhỏ: theo dõi kích thước qua từng lớp
- 12. Sai lầm thường gặp
- 13. Bài tập
- Tóm tắt
Ở bài "Gradient descent từ đầu với NumPy", mô hình của bạn chỉ có hai tham số w và b. Mô hình phân loại ảnh OK/NG mà bạn sẽ huấn luyện ở cuối chặng có hàng chục nghìn đến hàng chục triệu tham số, nhưng mỗi tham số vẫn học theo đúng vòng lặp đó. Bài này mở "hộp đen" ra: một neuron tính gì, vì sao cần hàm kích hoạt, vì sao mạng nơ-ron thường (MLP) không hợp với ảnh, và CNN (mạng nơ-ron tích chập) giải quyết việc đó bằng tích chập, pooling, feature map như thế nào. Mọi phép tính được làm bằng NumPy để bạn thấy từng con số; PyTorch chỉ xuất hiện để kiểm tra lại kích thước và số tham số. Cú pháp PyTorch sẽ được dạy đầy đủ ở bài sau, "PyTorch cơ bản: tensor, autograd và vòng lặp huấn luyện".
(k·k·Cin + 1)·Cout và kiểm tra lại bằng torch.nn.Conv2d.1. Một neuron: tổng có trọng số rồi qua hàm kích hoạt
Một neuron nhận vector đầu vào x, nhân với vector trọng số w, cộng b (bias), rồi đưa kết quả z qua một hàm phi tuyến f:
z = w1·x1 + w2·x2 + … + wn·xn + b = w·x + b
a = f(z)
Phần w·x + b chính là mô hình hồi quy tuyến tính bạn đã viết ở bài gradient descent. Điểm mới duy nhất là hàm f. Ví dụ một neuron nhận 3 đặc trưng đo từ ảnh một sản phẩm (đã chuẩn hóa về 0–1): độ sáng trung bình, diện tích vết tối lớn nhất, độ tương phản của vết.
import numpy as np
x = np.array([0.80, 0.35, 0.60]) # độ sáng TB, diện tích vết tối, độ tương phản vết
w = np.array([-1.5, 4.0, 2.0]) # trọng số: mô hình học ra (ở đây đặt tay)
b = -1.0 # bias
z = w @ x + b # tổng có trọng số
relu = max(0.0, z)
sigmoid = 1 / (1 + np.exp(-z))
print(f"z = {w[0]}*{x[0]} + {w[1]}*{x[1]} + {w[2]}*{x[2]} + ({b}) = {z:.2f}")
print(f"ReLU(z) = {relu:.2f} | sigmoid(z) = {sigmoid:.4f}")
z = -1.5*0.8 + 4.0*0.35 + 2.0*0.6 + (-1.0) = 0.40
ReLU(z) = 0.40 | sigmoid(z) = 0.5987
Đọc kết quả: trọng số dương lớn (4,0) cho "diện tích vết tối" nghĩa là đặc trưng này đẩy mạnh về phía NG; trọng số âm cho độ sáng nghĩa là sản phẩm càng sáng càng ít nghi ngờ. Sigmoid biến z thành số trong khoảng (0, 1), có thể đọc như "xác suất NG" ≈ 0,60. Trong mạng thật, không ai đặt tay các trọng số này: gradient descent tìm ra chúng từ dữ liệu.
Neuron sinh học và neuron nhân tạo
Tên gọi "neuron" mượn từ sinh học. Một tế bào thần kinh nhận tín hiệu qua các sợi nhánh (dendrite), tổng hợp ở thân tế bào, và khi tổng đủ mạnh thì phát xung theo sợi trục (axon) sang tế bào khác qua khớp nối (synapse). Neuron nhân tạo mô phỏng thô sơ điều đó: đầu vào xᵢ là tín hiệu tới, trọng số wᵢ là "độ mạnh khớp nối", tổng Σ wᵢxᵢ + b là phần tổng hợp ở thân tế bào, hàm kích hoạt f đóng vai "có phát xung hay không, mạnh bao nhiêu". Phép so sánh chỉ để dễ nhớ: neuron thật phức tạp hơn rất nhiều. Cách nhớ chính xác hơn: neuron = linear classifier + hàm kích hoạt (xem mục 3).
2. Hàm kích hoạt: ReLU, sigmoid, softmax
| Hàm | Công thức | Khoảng giá trị | Dùng ở đâu |
|---|---|---|---|
| ReLU | max(0, z) | [0, +∞) | Các lớp ẩn của hầu hết CNN: rẻ, gradient không bị nhỏ dần khi z dương |
| Leaky ReLU | max(εz, z), ε nhỏ (thường 0,01) | (−∞, +∞) | Thay ReLU khi nhiều neuron "chết" (luôn ra 0 vì đầu vào luôn âm): phía âm vẫn có gradient ε. Các biến thể PReLU, ELU, SiLU/Swish ở bài Vanishing gradient. |
| Sigmoid | 1 / (1 + e^(−z)) | (0, 1) | Đầu ra nhị phân (một điểm "xác suất NG"), đầu ra đa nhãn, mặt nạ phân đoạn |
| Tanh | (e^z − e^(−z)) / (e^z + e^(−z)) | (−1, 1) | Ít gặp trong CNN hiện đại |
| Softmax | e^(zi) / Σ e^(zj) | (0, 1), tổng = 1 | Đầu ra nhiều lớp loại trừ nhau: OK / xước / móp / bẩn |
import numpy as np
z = np.array([-3.0, -1.0, 0.0, 1.0, 3.0])
print("z :", z)
print("ReLU :", np.maximum(0, z))
print("sigmoid :", np.round(1 / (1 + np.exp(-z)), 3))
print("tanh :", np.round(np.tanh(z), 3))
def softmax(logits):
e = np.exp(logits - logits.max()) # trừ max để không tràn số, kết quả không đổi
return e / e.sum()
logits = np.array([2.0, 0.5, -1.0]) # điểm thô (logit) cho 3 lớp: OK, xước, móp
p = softmax(logits)
print("softmax :", np.round(p, 3), "tổng =", round(p.sum(), 6))
lon = np.array([1000.0, 999.0]) # logit lớn: tính thẳng e^1000 sẽ tràn số
with np.errstate(over="ignore", invalid="ignore"):
print("tính thẳng:", np.exp(lon) / np.exp(lon).sum())
print("trừ max :", np.round(softmax(lon), 4))
z : [-3. -1. 0. 1. 3.]
ReLU : [0. 0. 0. 1. 3.]
sigmoid : [0.047 0.269 0.5 0.731 0.953]
tanh : [-0.995 -0.762 0. 0.762 0.995]
softmax : [0.786 0.175 0.039] tổng = 1.0
tính thẳng: [nan nan]
trừ max : [0.7311 0.2689]
- Logit là đầu ra thô của lớp cuối, chưa qua softmax/sigmoid. Các hàm loss của PyTorch (
CrossEntropyLoss,BCEWithLogitsLoss) nhận logit và tự tính softmax/sigmoid bên trong theo cách ổn định số học. Đừng tự áp softmax trước khi đưa vàoCrossEntropyLoss. - Softmax chỉ phụ thuộc vào hiệu giữa các logit, nên trừ đi max không làm đổi kết quả nhưng tránh
e^1000 = inf.
Vì sao phải có hàm phi tuyến?
Nếu bỏ hàm kích hoạt, nhiều lớp tuyến tính chồng lên nhau vẫn chỉ là một lớp tuyến tính: W2·(W1·x) = (W2·W1)·x. Mạng sâu 50 lớp khi đó không mạnh hơn hồi quy tuyến tính. Kiểm tra bằng số:
import numpy as np
rng = np.random.default_rng(0)
x = rng.normal(size=3)
W1, W2 = rng.normal(size=(4, 3)), rng.normal(size=(2, 4))
hai_lop = W2 @ (W1 @ x)
mot_lop = (W2 @ W1) @ x # gộp thành một ma trận 2×3
print("không kích hoạt, 2 lớp == 1 lớp:", np.allclose(hai_lop, mot_lop))
co_relu = W2 @ np.maximum(0, W1 @ x)
print("có ReLU ở giữa, 2 lớp == 1 lớp:", np.allclose(co_relu, mot_lop))
không kích hoạt, 2 lớp == 1 lớp: True
có ReLU ở giữa, 2 lớp == 1 lớp: False
ReLU "gập" không gian đầu vào tại những chỗ z = 0; ghép nhiều lớp như vậy, mạng xấp xỉ được những ranh giới OK/NG rất phức tạp.
3. Lớp (layer) và mạng MLP: chỉ là phép nhân ma trận
Đặt nhiều neuron cạnh nhau, cùng nhận một đầu vào, ta có một lớp kết nối đầy đủ (fully connected, trong PyTorch gọi là Linear). Trọng số của cả lớp là ma trận W kích thước (số đầu vào, số neuron). Xử lý cả một batch nhiều mẫu cùng lúc chỉ là một phép nhân ma trận. Mạng gồm nhiều lớp như vậy gọi là MLP (multi-layer perceptron).
import numpy as np
rng = np.random.default_rng(1)
X = np.array([[0.80, 0.35, 0.60], # 4 sản phẩm, mỗi sản phẩm 3 đặc trưng → (4, 3)
[0.90, 0.02, 0.10],
[0.70, 0.50, 0.90],
[0.85, 0.05, 0.20]])
W1, b1 = rng.normal(0, 1, (3, 5)), np.zeros(5) # lớp ẩn: 3 → 5 neuron
W2, b2 = rng.normal(0, 1, (5, 2)), np.zeros(2) # lớp ra: 5 → 2 logit (OK, NG)
H = np.maximum(0, X @ W1 + b1) # (4, 3) @ (3, 5) → (4, 5)
logits = H @ W2 + b2 # (4, 5) @ (5, 2) → (4, 2)
e = np.exp(logits - logits.max(axis=1, keepdims=True))
P = e / e.sum(axis=1, keepdims=True) # softmax theo từng hàng
print("X", X.shape, "→ H", H.shape, "→ logits", logits.shape)
print("P(NG) của 4 sản phẩm (trọng số ngẫu nhiên, chưa học):", np.round(P[:, 1], 3))
so_tham_so = W1.size + b1.size + W2.size + b2.size
print("số tham số:", so_tham_so, "= (3+1)*5 + (5+1)*2 =", (3 + 1) * 5 + (5 + 1) * 2)
X (4, 3) → H (4, 5) → logits (4, 2)
P(NG) của 4 sản phẩm (trọng số ngẫu nhiên, chưa học): [0.067 0.033 0.124 0.044]
số tham số: 32 = (3+1)*5 + (5+1)*2 = 32
Ba điều cần nhớ từ đoạn code ngắn này:
- Đây là forward pass (lan truyền xuôi). Huấn luyện là lặp: forward → tính loss → tính gradient của loss theo mọi phần tử của W1, b1, W2, b2 (backpropagation) → cập nhật như gradient descent. PyTorch làm phần gradient tự động (autograd) ở bài sau.
- Số tham số của một lớp Linear:
(số đầu vào + 1) × số neuron, "+1" là bias. - Trọng số còn ngẫu nhiên nên P(NG) ở trên chưa có ý nghĩa gì. Mạng chỉ "thông minh" sau khi học.
Góc nhìn linear classifier: mỗi lớp đầu ra là một "khuôn mẫu"
Bỏ lớp ẩn đi, chỉ còn một lớp Linear nối thẳng pixel với K lớp đầu ra, ta có linear classifier (bộ phân loại tuyến tính): duỗi ảnh thành vector x (D số), mô hình tính điểm số cho từng lớp f(x) = W·x + b với W kích thước K×D; lớp có điểm cao nhất là dự đoán. Ví dụ đồ chơi ở trang 11 của slide: ảnh 4 pixel, 3 lớp:
import numpy as np
W = np.array([[0.2, -0.5, 0.1, 2.0], # hàng 0: khuôn mẫu "mèo"
[1.5, 1.3, 2.1, 0.0], # hàng 1: khuôn mẫu "chó"
[0.0, 0.25, 0.2, -0.3]]) # hàng 2: khuôn mẫu "tàu"
b = np.array([1.1, 3.2, -1.2])
x = np.array([56, 231, 24, 2]) # ảnh 4 pixel đã duỗi thành vector
diem = W @ x + b
print(diem) # [-96.8 437.9 60.75]
print(["mèo", "chó", "tàu"][diem.argmax()])
[-96.8 437.9 60.75]
chó
(Hình gốc trong slide ghi điểm "tàu" là 61,95 vì quên cộng bias −1,2; con số đúng là 60,75.) Mô hình với bộ trọng số này đoán sai ảnh mèo thành chó; huấn luyện chính là tìm W, b để lớp đúng luôn có điểm cao nhất. Có ba cách hiểu cùng một phép tính:
- Nhân ma trận và nhân từng phần tử: điểm của lớp k là tích vô hướng giữa hàng k của
Wvà ảnh, tức nhân từng cặp phần tử (element-wise) rồi cộng lại. - Khuôn mẫu: mỗi hàng của
Wcó đúng kích thước ảnh. Sắp 784 trọng số của một hàng thành lưới 28×28 rồi vẽ ra (trang 13–16), ta thấy "khuôn" của lớp đó: khuôn số 0 trên MNIST là một vòng sáng có tâm tối; trên CIFAR-10 khuôn "máy bay" ám xanh da trời, khuôn "ngựa" như con ngựa có hai đầu vì dữ liệu có ngựa quay cả trái lẫn phải mà mỗi lớp chỉ có một khuôn. - Hình học: mỗi ảnh là một điểm trong không gian D chiều; mỗi phương trình
wₖ·x + bₖ = 0là một siêu phẳng chia không gian. Bias cho phép siêu phẳng không phải đi qua gốc tọa độ.
Giới hạn của linear classifier chính là lý do cần lớp ẩn và CNN: chỉ một khuôn cho mỗi lớp (không biểu diễn được nhiều biến thể), ranh giới chỉ là mặt phẳng (không tách được dữ liệu kiểu XOR), và nhạy với vị trí (vật dịch vài pixel là lệch khuôn, mục 4).
Định lý xấp xỉ vạn năng và vì sao cần mạng sâu
Định lý xấp xỉ vạn năng (universal approximation theorem, trang 115) nói rằng một MLP chỉ với một lớp ẩn, đủ nhiều neuron và hàm kích hoạt phi tuyến, có thể xấp xỉ mọi hàm liên tục trên một miền giới hạn với sai số nhỏ tùy ý. Nhưng định lý chỉ nói bộ trọng số tốt tồn tại, không nói làm sao tìm ra nó, và số neuron cần có thể lớn khủng khiếp. Trên thực tế, mạng sâu biểu diễn cùng một hàm hiệu quả hơn nhiều nhờ tái sử dụng đặc trưng theo tầng (cạnh → góc, vân → bộ phận → vật), đúng như các feature map ở mục 7 cho thấy.
Đọc sâu hơn: Từ Linear Classifier đến Neural Network (trang 9–23 của slide).
4. Vì sao MLP không hợp với ảnh
Muốn đưa ảnh vào MLP, ta phải "duỗi" ảnh thành một vector dài. Ảnh 224×224 RGB, kích thước đầu vào phổ biến của các mạng phân loại, thành vector 150.528 phần tử. Một lớp ẩn 1000 neuron sẽ cần:
vao = 224 * 224 * 3
an = 1000
tham_so = (vao + 1) * an
print(f"đầu vào: {vao:,} giá trị")
print(f"lớp ẩn {an} neuron: {tham_so:,} tham số ≈ {tham_so * 4 / 1e6:,.0f} MB (float32)")
anh_5mp = 2448 * 2048 # ảnh xám 5 MP nguyên khung của camera công nghiệp
print(f"ảnh xám 5 MP: {(anh_5mp + 1) * an:,} tham số chỉ cho lớp đầu tiên")
đầu vào: 150,528 giá trị
lớp ẩn 1000 neuron: 150,529,000 tham số ≈ 602 MB (float32)
ảnh xám 5 MP: 5,013,505,000 tham số chỉ cho lớp đầu tiên
Kích thước không phải vấn đề duy nhất. Vấn đề sâu hơn là MLP không biết hai pixel cạnh nhau có liên quan và mỗi vị trí trong ảnh có bộ trọng số riêng. Một vết xước học được ở góc trái trên không giúp gì khi vết xước giống hệt xuất hiện ở góc phải dưới: mạng phải thấy vết xước ở mọi vị trí trong dữ liệu huấn luyện. Kiểm tra bằng số: cùng một vết xước, dịch sang phải 6 pixel.
import cv2
import numpy as np
a = np.zeros((32, 32), np.float32)
cv2.line(a, (8, 6), (8, 20), 1.0, 2) # vết xước dọc
b = np.zeros((32, 32), np.float32)
cv2.line(b, (14, 6), (14, 20), 1.0, 2) # cùng vết xước, dịch phải 6 px
va, vb = a.ravel(), b.ravel() # cách MLP nhìn ảnh: một vector dài
cos = va @ vb / (np.linalg.norm(va) * np.linalg.norm(vb))
print("độ giống nhau (cosine) giữa hai vector ảnh:", round(float(cos), 3))
k = np.array([[-1, 2, -1]] * 3, np.float32) # bộ lọc "vạch dọc sáng" 3×3
ra = cv2.filter2D(a, -1, k)
rb = cv2.filter2D(b, -1, k)
print("phản hồi lớn nhất của bộ lọc:", ra.max(), "và", rb.max())
print("vị trí cột phản hồi lớn nhất:", np.unravel_index(ra.argmax(), ra.shape)[1],
"và", np.unravel_index(rb.argmax(), rb.shape)[1])
độ giống nhau (cosine) giữa hai vector ảnh: 0.0
phản hồi lớn nhất của bộ lọc: 3.0 và 3.0
vị trí cột phản hồi lớn nhất: 7 và 13
Với MLP, hai ảnh "không có điểm chung" (cosine 0): mọi pixel sáng nằm ở chỗ khác nhau. Một bộ lọc nhỏ 3×3 trượt khắp ảnh thì cho cùng phản hồi lớn nhất, chỉ dịch vị trí đúng 6 cột. Đó chính là ý tưởng của CNN.
5. Tích chập bằng tay
Lớp tích chập (convolution) dùng một kernel (bộ lọc) nhỏ, ví dụ 3×3, trượt qua ảnh. Tại mỗi vị trí, nhân từng phần tử kernel với vùng ảnh bên dưới rồi cộng lại. Bạn đã gặp phép này ở chặng xử lý ảnh (lọc Gaussian, Sobel); điểm khác duy nhất ở CNN là giá trị trong kernel được học thay vì kỹ sư chọn.
Ví dụ ảnh 5×5 có một cạnh chuyển từ tối sang sáng, kernel Sobel theo phương ngang:
Ảnh I (5×5) Kernel K (3×3)
10 10 50 200 200 -1 0 1
10 10 50 200 200 -2 0 2
10 10 50 200 200 -1 0 1
10 10 50 200 200
10 10 50 200 200
Vị trí (0,0): cửa sổ cột 0..2 = [10 10 50] mỗi hàng
hàng 1: -1·10 + 0·10 + 1·50 = 40
hàng 2: -2·10 + 0·10 + 2·50 = 80
hàng 3: -1·10 + 0·10 + 1·50 = 40 → tổng 160
Vị trí (0,1): cửa sổ cột 1..3 = [10 50 200] → 190·1 + 190·2 + 190·1 = 760
Vị trí (0,2): cửa sổ cột 2..4 = [50 200 200] → 150 + 300 + 150 = 600
import cv2
import numpy as np
I = np.tile(np.array([10, 10, 50, 200, 200], np.float32), (5, 1))
K = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], np.float32)
def tuong_quan_valid(img, k):
"""Trượt kernel k trên img, không đệm biên (valid). Đây là phép lớp Conv2d thực hiện."""
kh, kw = k.shape
H, W = img.shape
out = np.zeros((H - kh + 1, W - kw + 1), np.float32)
for y in range(out.shape[0]):
for x in range(out.shape[1]):
out[y, x] = np.sum(img[y:y + kh, x:x + kw] * k)
return out
tu_viet = tuong_quan_valid(I, K)
print("tự viết (3×3):\n", tu_viet)
opencv = cv2.filter2D(I, -1, K)[1:-1, 1:-1] # filter2D giữ kích thước, cắt bỏ viền để so
print("giống cv2.filter2D:", np.allclose(tu_viet, opencv))
tich_chap_that = tuong_quan_valid(I, K[::-1, ::-1]) # tích chập "toán học": lật kernel 180°
print("tích chập toán học (kernel lật), hàng đầu:", tich_chap_that[0])
tự viết (3×3):
[[160. 760. 600.]
[160. 760. 600.]
[160. 760. 600.]]
giống cv2.filter2D: True
tích chập toán học (kernel lật), hàng đầu: [-160. -760. -600.]
cv2.filter2D và lớp Conv2d của PyTorch đều không lật, tức là tính tương quan chéo (cross-correlation), dù vẫn gọi là "convolution". Với CNN điều này không quan trọng vì kernel được học: mạng sẽ tự học ra kernel đã lật nếu cần. Nó chỉ quan trọng khi bạn chép một bộ lọc cố định từ tài liệu dùng tích chập toán học (ví dụ hàm conv2 của MATLAB): với kernel không đối xứng như Sobel, kết quả bị đổi dấu như dòng cuối ở trên.Kiểm tra nhanh rằng PyTorch cũng không lật kernel (đây là dòng PyTorch đầu tiên của bài, chỉ dùng để đối chiếu):
import numpy as np
import torch
import torch.nn.functional as F
I = np.tile(np.array([10, 10, 50, 200, 200], np.float32), (5, 1))
K = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], np.float32)
x = torch.from_numpy(I)[None, None] # (N=1, C=1, H=5, W=5)
w = torch.from_numpy(K)[None, None] # (Cout=1, Cin=1, 3, 3)
print(F.conv2d(x, w)[0, 0])
tensor([[160., 760., 600.],
[160., 760., 600.],
[160., 760., 600.]])
6. Stride, padding và công thức kích thước đầu ra
- Padding
P: đệm thêmPpixel (thường bằng 0) quanh ảnh để kernel phủ được cả viền. Kernel 3×3 vớiP = 1giữ nguyên kích thước ảnh ("same"). - Stride
S: bước nhảy của kernel.S = 2cho ảnh ra nhỏ một nửa mỗi chiều, cách giảm kích thước phổ biến thay cho pooling.
Với ảnh rộng W, kernel K, padding P, stride S, kích thước đầu ra (phép chia lấy phần nguyên):
O = floor((W + 2P − K) / S) + 1
Tính tay vài trường hợp rồi để PyTorch xác nhận:
import torch
from torch import nn
def kich_thuoc_ra(W, K, S=1, P=0):
return (W + 2 * P - K) // S + 1
cac_truong_hop = [ # (W, K, S, P, ghi chú)
(224, 3, 1, 1, "3×3, same"),
(224, 3, 1, 0, "3×3, không đệm"),
(224, 3, 2, 1, "3×3, stride 2"),
(224, 7, 2, 3, "7×7 stride 2: lớp đầu ResNet"),
(64, 5, 1, 0, "5×5, không đệm"),
(65, 3, 2, 1, "ảnh lẻ, stride 2"),
]
x_cache = {}
for W, K, S, P, ghi_chu in cac_truong_hop:
tinh_tay = kich_thuoc_ra(W, K, S, P)
x = x_cache.setdefault(W, torch.zeros(1, 1, W, W))
pytorch = nn.Conv2d(1, 1, K, stride=S, padding=P)(x).shape[-1]
print(f"W={W:3d} K={K} S={S} P={P}: ({W}+{2 * P}-{K})//{S}+1 = {tinh_tay:3d} | PyTorch: {pytorch:3d} ({ghi_chu})")
W=224 K=3 S=1 P=1: (224+2-3)//1+1 = 224 | PyTorch: 224 (3×3, same)
W=224 K=3 S=1 P=0: (224+0-3)//1+1 = 222 | PyTorch: 222 (3×3, không đệm)
W=224 K=3 S=2 P=1: (224+2-3)//2+1 = 112 | PyTorch: 112 (3×3, stride 2)
W=224 K=7 S=2 P=3: (224+6-7)//2+1 = 112 | PyTorch: 112 (7×7 stride 2: lớp đầu ResNet)
W= 64 K=5 S=1 P=0: (64+0-5)//1+1 = 60 | PyTorch: 60 (5×5, không đệm)
W= 65 K=3 S=2 P=1: (65+2-3)//2+1 = 33 | PyTorch: 33 (ảnh lẻ, stride 2)
Trường hợp ảnh lẻ 65 cho 33: phép chia lấy phần nguyên làm "mất" hoặc "thêm" một pixel. Khi ghép mạng kiểu U-Net (nối đặc trưng encoder với decoder) mà ảnh có kích thước không chia hết cho 2, 4, 8…, các tensor sẽ lệch nhau một pixel và báo lỗi. Đó là lý do người ta chọn ROI kích thước 64, 128, 224, 256, 640.
Công thức đầy đủ trong tài liệu nn.Conv2d còn có dilation D (độ giãn của kernel, mặc định 1): (W + 2P − D·(K − 1) − 1) // S + 1. Với D = 1 nó trở về công thức trên. Kernel 3×3 với D = 2 phủ vùng 5×5 nhưng vẫn chỉ 9 trọng số; tích chập giãn (dilated/atrous) được giải thích ở bài BatchNorm, Dropout và receptive field.
7. Nhiều kênh: feature map
Ảnh màu có 3 kênh. Một kernel của lớp Conv khi đó có kích thước k × k × Cin (ví dụ 3×3×3): nó nhìn đồng thời cả ba kênh trong một vùng 3×3 và cho một số. Lớp có Cout kernel như vậy cho Cout ảnh đầu ra, mỗi ảnh gọi là một feature map (bản đồ đặc trưng): kernel nào "thích" cạnh dọc sẽ cho feature map sáng ở chỗ có cạnh dọc.
đầu ra[co, y, x] = b[co] + Σ_ci Σ_dy Σ_dx w[co, ci, dy, dx] · vào[ci, y+dy, x+dx]
Cài đặt bằng NumPy rồi so với PyTorch, và xem hai feature map trên một tấm kim loại có một vết xước dọc và một vết xước ngang:
import cv2
import numpy as np
import torch
import torch.nn.functional as F
def lop_conv(x, w, b):
"""x: (Cin, H, W); w: (Cout, Cin, k, k); b: (Cout,). Không đệm, stride 1."""
Cout, Cin, k, _ = w.shape
H, W = x.shape[1:]
out = np.zeros((Cout, H - k + 1, W - k + 1), np.float32)
for co in range(Cout):
for y in range(H - k + 1):
for xx in range(W - k + 1):
out[co, y, xx] = np.sum(x[:, y:y + k, xx:xx + k] * w[co]) + b[co]
return out
rng = np.random.default_rng(0)
anh = (120 + rng.normal(0, 4, (48, 48))).astype(np.float32) # tấm kim loại, nhiễu nhẹ
cv2.line(anh, (12, 8), (12, 40), 60.0, 2) # xước dọc (tối)
cv2.line(anh, (22, 34), (42, 34), 60.0, 2) # xước ngang (tối)
x = np.stack([anh, anh * 0.9, anh * 0.8]) / 255 # giả lập 3 kênh màu → (3, 48, 48)
sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], np.float32)
w = np.zeros((2, 3, 3, 3), np.float32)
w[0] = sobel_x / 3 # kernel 0: cạnh dọc, chia đều cho 3 kênh
w[1] = sobel_x.T / 3 # kernel 1: cạnh ngang
b = np.zeros(2, np.float32)
fm = lop_conv(x, w, b)
ref = F.conv2d(torch.from_numpy(x)[None], torch.from_numpy(w), torch.from_numpy(b))[0].numpy()
print("vào", x.shape, "→ feature map", fm.shape, "| khớp PyTorch:", np.allclose(fm, ref, atol=1e-5))
a0, a1 = np.abs(fm[0]), np.abs(fm[1])
print(f"feature map 0 (cạnh dọc) : mạnh nhất ở cột {a0.max(axis=0).argmax()}, max {a0.max():.2f}")
print(f"feature map 1 (cạnh ngang): mạnh nhất ở hàng {a1.max(axis=1).argmax()}, max {a1.max():.2f}")
vis = [cv2.normalize(np.abs(f), None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) for f in fm]
vis = [cv2.resize(v, (184, 184), interpolation=cv2.INTER_NEAREST) for v in vis]
goc = cv2.resize(anh[1:-1, 1:-1].astype(np.uint8), (184, 184), interpolation=cv2.INTER_NEAREST)
cv2.imwrite("feature_maps.png", cv2.hconcat([goc, vis[0], vis[1]]))
print("đã lưu feature_maps.png (ảnh gốc | cạnh dọc | cạnh ngang)")
vào (3, 48, 48) → feature map (2, 46, 46) | khớp PyTorch: True
feature map 0 (cạnh dọc) : mạnh nhất ở cột 9, max 0.91
feature map 1 (cạnh ngang): mạnh nhất ở hàng 35, max 0.94
đã lưu feature_maps.png (ảnh gốc | cạnh dọc | cạnh ngang)
Feature map 0 phản hồi mạnh nhất ở cột 9: mép trái của vết xước dọc (vết vẽ quanh x = 12, dày khoảng 3 pixel; feature map lệch 1 pixel so với ảnh gốc vì không đệm biên). Feature map 1 phản hồi mạnh nhất ở hàng 35, mép dưới của vết xước ngang ở y = 34. Bộ lọc cạnh cho phản hồi ở hai mép của vết (một mép âm, một mép dương, ta lấy trị tuyệt đối). Mở feature_maps.png để thấy: mỗi feature map là "ảnh" của một loại đặc trưng. Lớp Conv đầu tiên của một CNN đã huấn luyện thường học ra những kernel giống bộ lọc cạnh, đốm, màu; các lớp sau kết hợp feature map của lớp trước thành đặc trưng phức tạp hơn: góc, vân, rồi "vết xước", "vết móp".
Tích chập 1×1: trộn kênh tại từng pixel
Kernel 1×1 nghe vô nghĩa với ảnh một kênh, nhưng với khối nhiều kênh nó tính một tổ hợp tuyến tính của tất cả các kênh tại cùng một pixel: giống một lớp Linear nhỏ áp dụng riêng cho từng pixel. Công dụng: đổi số kênh rất rẻ (giảm 256 kênh xuống 64 trước một conv 3×3 đắt đỏ, chính là khối "bottleneck" của ResNet-50), trộn thông tin giữa các kênh (bước "pointwise" của MobileNet), thêm phi tuyến khi đi kèm ReLU.
import torch
import torch.nn as nn
x = torch.rand(8, 64, 32, 32)
conv1x1 = nn.Conv2d(64, 16, kernel_size=1)
print(conv1x1(x).shape) # torch.Size([8, 16, 32, 32])
print(sum(p.numel() for p in conv1x1.parameters())) # (64 + 1) * 16 = 1040
Kích thước không gian giữ nguyên, chỉ số kênh đổi từ 64 thành 16, với 1.040 tham số (một conv 3×3 cùng số kênh cần (9·64 + 1)·16 = 9.232).
8. Pooling: giảm kích thước, chịu được xê dịch nhỏ
Max pooling 2×2, stride 2 chia feature map thành các ô 2×2 không chồng nhau và giữ giá trị lớn nhất mỗi ô. Kích thước giảm một nửa mỗi chiều, không có tham số nào để học. Average pooling lấy trung bình thay vì max. Global average pooling (GAP) lấy trung bình cả feature map, biến (C, H, W) thành vector C số.
import numpy as np
fm = np.array([[1, 3, 0, 2],
[4, 2, 1, 0],
[0, 1, 5, 6],
[2, 0, 7, 1]], np.float32)
H, W = fm.shape
o = fm.reshape(H // 2, 2, W // 2, 2) # tách thành các ô 2×2
print("max pool 2×2:\n", o.max(axis=(1, 3)))
print("avg pool 2×2:\n", o.mean(axis=(1, 3)))
print("global average pooling:", fm.mean())
dich = np.roll(fm, 1, axis=1) # dịch feature map sang phải 1 cột
dich[:, 0] = 0
print("max pool sau khi dịch 1 cột:\n", dich.reshape(2, 2, 2, 2).max(axis=(1, 3)))
max pool 2×2:
[[4. 2.]
[2. 7.]]
avg pool 2×2:
[[2.5 0.75]
[0.75 4.75]]
global average pooling: 2.1875
max pool sau khi dịch 1 cột:
[[4. 3.]
[2. 7.]]
Sau khi dịch 1 cột, ba trong bốn giá trị max pooling không đổi. Pooling giúp mạng chịu được xê dịch nhỏ của chi tiết, nhưng cũng làm mất thông tin vị trí chính xác. Đó là lý do CNN phân loại rất giỏi trả lời "có vết xước không?" nhưng không đo được vết xước dài bao nhiêu micromet; việc đo vẫn là của các công cụ đo lường ở chặng trước.
9. Receptive field: một neuron "nhìn thấy" bao nhiêu pixel?
Receptive field (vùng tiếp nhận) của một điểm trên feature map là vùng ảnh gốc ảnh hưởng tới giá trị của điểm đó. Một lớp Conv 3×3 nhìn 3×3 pixel; hai lớp 3×3 chồng nhau nhìn 5×5; sau mỗi lớp stride 2 hoặc pooling, mỗi bước kernel ở lớp sau tương ứng nhiều pixel hơn ở ảnh gốc. Công thức lặp qua từng lớp, với r là receptive field và j là khoảng cách (tính bằng pixel ảnh gốc) giữa hai điểm liền kề trên feature map:
r_mới = r + (k − 1) · j
j_mới = j · s (bắt đầu r = 1, j = 1)
cac_lop = [("conv3", 3, 1), ("pool2", 2, 2),
("conv3", 3, 1), ("pool2", 2, 2),
("conv3", 3, 1), ("pool2", 2, 2)]
r, j = 1, 1
for ten, k, s in cac_lop:
r = r + (k - 1) * j
j = j * s
print(f"sau {ten}: receptive field {r:2d}×{r:<2d} px, bước {j} px")
# hai lớp 3×3 so với một lớp 5×5 (cùng 64 kênh vào/ra, không tính bias)
print("2 lớp 3×3:", 2 * 3 * 3 * 64 * 64, "tham số | 1 lớp 5×5:", 5 * 5 * 64 * 64, "tham số")
sau conv3: receptive field 3×3 px, bước 1 px
sau pool2: receptive field 4×4 px, bước 2 px
sau conv3: receptive field 8×8 px, bước 2 px
sau pool2: receptive field 10×10 px, bước 4 px
sau conv3: receptive field 18×18 px, bước 4 px
sau pool2: receptive field 22×22 px, bước 8 px
2 lớp 3×3: 73728 tham số | 1 lớp 5×5: 102400 tham số
Hệ quả thực tế:
- Mạng nhỏ 3 tầng ở trên chỉ "nhìn" được vùng 22×22 pixel. Một vết móp rộng 60 pixel thì không neuron nào thấy trọn vẹn ở tầng cuối; mạng phải ghép nhiều mảnh lại ở lớp Linear. Nếu lỗi lớn, cần mạng sâu hơn hoặc thu nhỏ ảnh; nếu lỗi rất nhỏ (vài pixel), thu nhỏ ảnh quá mức sẽ xóa mất lỗi trước khi mạng kịp nhìn.
- Các mạng như ResNet có receptive field lý thuyết hàng trăm pixel nhờ nhiều lớp và stride, nên nhìn được ngữ cảnh rộng.
- Hai lớp 3×3 có cùng receptive field với một lớp 5×5 nhưng ít tham số hơn (73.728 so với 102.400) và có thêm một hàm kích hoạt ở giữa. Đó là lý do hầu hết CNN hiện đại dùng kernel 3×3.
10. Đếm số tham số của lớp Conv
Lớp Conv2d(Cin, Cout, k) có Cout kernel, mỗi kernel k × k × Cin trọng số cộng 1 bias:
số tham số = (k · k · Cin + 1) · Cout
(nếu bias=False: k · k · Cin · Cout)
Ví dụ: Conv2d(3, 16, 3) = (3·3·3 + 1)·16 = 28·16 = 448. Conv2d(64, 128, 3) = (576 + 1)·128 = 73.856. Kiểm tra bằng PyTorch, đếm mọi phần tử trong các tensor tham số:
from torch import nn
def cong_thuc(cin, cout, k, bias=True):
return (k * k * cin + (1 if bias else 0)) * cout
for cin, cout, k, bias in [(3, 16, 3, True), (16, 32, 3, True), (64, 128, 3, True),
(1, 8, 5, True), (64, 64, 3, False), (256, 512, 1, True)]:
lop = nn.Conv2d(cin, cout, k, bias=bias)
dem = sum(p.numel() for p in lop.parameters())
print(f"Conv2d({cin:3d}, {cout:3d}, k={k}, bias={bias!s:5}): công thức {cong_thuc(cin, cout, k, bias):7,} "
f"| PyTorch {dem:7,} | weight {tuple(lop.weight.shape)}")
Conv2d( 3, 16, k=3, bias=True ): công thức 448 | PyTorch 448 | weight (16, 3, 3, 3)
Conv2d( 16, 32, k=3, bias=True ): công thức 4,640 | PyTorch 4,640 | weight (32, 16, 3, 3)
Conv2d( 64, 128, k=3, bias=True ): công thức 73,856 | PyTorch 73,856 | weight (128, 64, 3, 3)
Conv2d( 1, 8, k=5, bias=True ): công thức 208 | PyTorch 208 | weight (8, 1, 5, 5)
Conv2d( 64, 64, k=3, bias=False): công thức 36,864 | PyTorch 36,864 | weight (64, 64, 3, 3)
Conv2d(256, 512, k=1, bias=True ): công thức 131,584 | PyTorch 131,584 | weight (512, 256, 1, 1)
Hai quan sát quan trọng:
- Số tham số của lớp Conv không phụ thuộc kích thước ảnh. Cùng 448 tham số dù ảnh vào 64×64 hay 4000×3000, vì một kernel được dùng chung (weight sharing) ở mọi vị trí. So với 150 triệu tham số của lớp MLP ở mục 4, đây là lý do CNN học được từ vài trăm ảnh.
- Lớp
bias=Falsethường đi kèmBatchNorm2dngay sau đó (BatchNorm đã có tham số dịch riêng nên bias của Conv là thừa). Khi đọc kiến trúc ResNet, bạn sẽ thấy cách viết này.
11. Một CNN nhỏ: theo dõi kích thước qua từng lớp
Ghép mọi thứ thành một CNN phân loại OK/NG cho ROI xám 64×64, đúng kiểu mạng bạn sẽ huấn luyện ở bài PyTorch cơ bản. Đoạn code dưới đây cho một batch ảnh giả đi qua từng lớp và in kích thước, số tham số. Đừng lo về cú pháp nn.Sequential; hãy tập trung vào các con số và tự tính lại chúng bằng các công thức ở trên.
import torch
from torch import nn
torch.manual_seed(0)
mang = nn.Sequential(
nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 64 → 32
nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 32 → 16
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 16 → 8
nn.Flatten(), # 32·8·8 = 2048
nn.Linear(32 * 8 * 8, 2), # 2 logit: OK, NG
)
x = torch.randn(4, 1, 64, 64) # batch 4 ảnh: (N, C, H, W)
print(f"{'đầu vào':22s} {str(tuple(x.shape)):18s}")
for lop in mang:
x = lop(x)
n = sum(p.numel() for p in lop.parameters())
print(f"{lop.__class__.__name__:22s} {str(tuple(x.shape)):18s} {n:6d} tham số")
print("tổng số tham số:", sum(p.numel() for p in mang.parameters()))
đầu vào (4, 1, 64, 64)
Conv2d (4, 8, 64, 64) 80 tham số
ReLU (4, 8, 64, 64) 0 tham số
MaxPool2d (4, 8, 32, 32) 0 tham số
Conv2d (4, 16, 32, 32) 1168 tham số
ReLU (4, 16, 32, 32) 0 tham số
MaxPool2d (4, 16, 16, 16) 0 tham số
Conv2d (4, 32, 16, 16) 4640 tham số
ReLU (4, 32, 16, 16) 0 tham số
MaxPool2d (4, 32, 8, 8) 0 tham số
Flatten (4, 2048) 0 tham số
Linear (4, 2) 4098 tham số
tổng số tham số: 9986
Tự kiểm tra: Conv đầu (3·3·1 + 1)·8 = 80; Conv thứ hai (72 + 1)·16 = 1.168; Conv thứ ba (144 + 1)·32 = 4.640; Linear (2048 + 1)·2 = 4.098. Tổng 9.986 tham số, nhỏ hơn MLP ở mục 4 khoảng 15.000 lần. Chú ý lớp Linear cuối chiếm gần một nửa số tham số và gắn chặt với kích thước ảnh vào: đưa ảnh 96×96 vào mạng này sẽ báo lỗi kích thước ở lớp Linear. Các mạng hiện đại thay Flatten bằng global average pooling để đầu ra luôn là vector C số bất kể kích thước ảnh:
import torch
from torch import nn
torch.manual_seed(0)
mang_gap = nn.Sequential(
nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(),
nn.AdaptiveAvgPool2d(1), nn.Flatten(), # (N, 32, h, w) → (N, 32) với mọi h, w
nn.Linear(32, 2),
)
for kt in (64, 96, 200):
print(f"ảnh {kt}×{kt} → đầu ra {tuple(mang_gap(torch.randn(1, 1, kt, kt)).shape)}")
print("tổng số tham số:", sum(p.numel() for p in mang_gap.parameters()))
ảnh 64×64 → đầu ra (1, 2)
ảnh 96×96 → đầu ra (1, 2)
ảnh 200×200 → đầu ra (1, 2)
tổng số tham số: 5954
Đây chính là cấu trúc của ResNet mà bạn sẽ dùng trong dự án cuối chặng: thân là nhiều khối Conv, cuối là global average pooling và một lớp Linear. Transfer learning chỉ thay lớp Linear cuối đó cho khớp số lớp của bạn.
CNN không chỉ dành cho ảnh
Ý tưởng kết nối cục bộ và dùng chung trọng số áp dụng cho mọi dữ liệu có cấu trúc lân cận. Slide (trang 79) minh họa CNN cho văn bản (Kim, 2014): mỗi từ là một vector, câu thành ma trận; các kernel 1 chiều trượt dọc chuỗi từ để bắt cụm 2–5 từ, rồi max pooling và lớp Linear để phân loại cảm xúc. Trong nhà máy, cùng cách đó dùng cho tín hiệu 1 chiều như rung động, dòng điện động cơ:
import torch
import torch.nn as nn
tin_hieu = torch.rand(4, 1, 1000) # 4 mẫu, 1 kênh, 1000 điểm đo
conv = nn.Conv1d(1, 8, kernel_size=9, padding=4)
print(conv(tin_hieu).shape) # torch.Size([4, 8, 1000])
Đọc sâu hơn: CNN: phép tích chập, padding, stride và pooling (trang 77–90 của slide, kèm ví dụ số tính tay và đính chính).
12. Sai lầm thường gặp
- Nhầm thứ tự trục. OpenCV và NumPy lưu ảnh dạng
(H, W, C); PyTorch dùng(N, C, H, W). Đưa thẳng ảnh OpenCV vào Conv2d sẽ báo lỗi số kênh, hoặc tệ hơn, chạy được nhưng sai (ảnh 3×3 pixel!). Bài "Dataset, DataLoader và augmentation" sẽ chuyển đổi đúng cách. - Áp softmax hai lần: tự thêm softmax ở cuối mạng rồi lại dùng
CrossEntropyLoss. Mạng vẫn học nhưng chậm và kém. Để mạng trả logit; chỉ áp softmax khi suy luận để đọc xác suất. - Tính sai kích thước trước lớp Linear khi đổi kích thước ROI. Dùng công thức ở mục 6, hoặc cho một tensor giả đi qua như mục 11, hoặc dùng global average pooling.
- Thu nhỏ ảnh quá mức để mạng chạy nhanh, làm lỗi nhỏ biến mất. Vết xước rộng 3 pixel trên ảnh 2048 px còn 0,3 pixel khi thu về 224 px. Hãy cắt ROI hoặc chia ảnh thành ô (tile) thay vì thu nhỏ cả khung hình.
- Nghĩ rằng nhiều tham số hơn là tốt hơn. Với vài trăm ảnh công nghiệp, mạng nhỏ hoặc mạng có sẵn đã huấn luyện (transfer learning) thường thắng mạng lớn huấn luyện từ đầu.
13. Bài tập
Bài 1. Feature map kích thước 32×128×128 (C×H×W) đi qua lớp Conv2d(32, 64, kernel_size=5, stride=2, padding=2). Tính kích thước đầu ra và số tham số. Kiểm tra bằng PyTorch.
Xem đáp án
O = (128 + 2·2 − 5) // 2 + 1 = 127 // 2 + 1 = 64, nên đầu ra 64×64×64. Số tham số (5·5·32 + 1)·64 = 801·64 = 51.264.
import torch
from torch import nn
lop = nn.Conv2d(32, 64, kernel_size=5, stride=2, padding=2)
print(tuple(lop(torch.zeros(1, 32, 128, 128)).shape), sum(p.numel() for p in lop.parameters()))
(1, 64, 64, 64) 51264
Bài 2. Một mạng gồm: Conv 3×3 stride 1 → Conv 3×3 stride 2 → Conv 3×3 stride 1 → Conv 3×3 stride 2 → Conv 3×3 stride 1. Tính receptive field sau lớp cuối. Vết lỗi lớn nhất trên ROI có đường kính khoảng 40 pixel: mạng này có đủ "tầm nhìn" không?
Xem đáp án
r, j = 1, 1
for k, s in [(3, 1), (3, 2), (3, 1), (3, 2), (3, 1)]:
r, j = r + (k - 1) * j, j * s
print(f"k={k} s={s}: r={r}, j={j}")
k=3 s=1: r=3, j=1
k=3 s=2: r=5, j=2
k=3 s=1: r=9, j=2
k=3 s=2: r=13, j=4
k=3 s=1: r=21, j=4
Receptive field 21×21 pixel, nhỏ hơn vết lỗi 40 pixel: mỗi neuron cuối chỉ thấy một phần vết lỗi. Mạng vẫn có thể phân loại đúng nhờ lớp cuối tổng hợp nhiều vị trí (global pooling), nhưng sẽ khó phân biệt các lỗi khác nhau ở hình dạng tổng thể. Nên thêm tầng (thêm stride/pooling) hoặc thu nhỏ ROI một cách vừa phải.
Bài 3. Đưa ROI 96×96 vào CNN ở mục 11 (bản dùng Flatten). Sau ba lần pooling, kích thước trước Flatten là bao nhiêu, lớp Linear cần bao nhiêu đầu vào và có bao nhiêu tham số? So sánh với một MLP có một lớp ẩn 256 neuron nhận trực tiếp ảnh 96×96 rồi ra 2 lớp.
Xem đáp án
h = 96 // 2 // 2 // 2
vao_linear = 32 * h * h
cnn = 80 + 1168 + 4640 + (vao_linear + 1) * 2
mlp = (96 * 96 + 1) * 256 + (256 + 1) * 2
print(f"trước Flatten: 32×{h}×{h}, Linear({vao_linear}, 2) có {(vao_linear + 1) * 2} tham số")
print(f"tổng CNN: {cnn:,} | MLP: {mlp:,} | MLP gấp {mlp / cnn:.0f} lần")
trước Flatten: 32×12×12, Linear(4608, 2) có 9218 tham số
tổng CNN: 15,106 | MLP: 2,360,066 | MLP gấp 156 lần
Phải sửa nn.Linear(32 * 8 * 8, 2) thành nn.Linear(32 * 12 * 12, 2). MLP vẫn nhiều tham số hơn khoảng 150 lần mà không có khả năng "nhận ra cùng vết xước ở vị trí khác".
Tóm tắt
- Neuron = tổng có trọng số
w·x + brồi qua hàm kích hoạt; một lớp = phép nhân ma trận; MLP = nhiều lớp chồng lên nhau. - Hàm phi tuyến (ReLU ở lớp ẩn; sigmoid hoặc softmax ở đầu ra) là bắt buộc, nếu không mạng sâu chỉ là một phép tuyến tính. Loss của PyTorch nhận logit.
- MLP trên ảnh vừa bùng nổ tham số (150 triệu cho một lớp với ảnh 224×224×3) vừa không nhận ra cùng chi tiết ở vị trí khác.
- Lớp Conv trượt kernel
k×k×Cintrên ảnh (thực chất là tương quan chéo, không lật kernel), choCoutfeature map. Kích thước ra:floor((W + 2P − K)/S) + 1. - Pooling giảm kích thước và giúp chịu xê dịch nhỏ; receptive field tăng dần qua các lớp, cần đủ lớn so với kích thước lỗi.
- Số tham số Conv =
(k·k·Cin + 1)·Cout, không phụ thuộc kích thước ảnh nhờ dùng chung trọng số; global average pooling giúp mạng nhận mọi kích thước ảnh.
Bài tiếp theo: PyTorch cơ bản: tensor, autograd và vòng lặp huấn luyện, nơi bạn để PyTorch tự tính gradient và huấn luyện chính CNN nhỏ này.


