Trong Bài 5 và Bài 6, chúng ta đã huấn luyện thành công mạng MLP đa lớp. Tuy nhiên, khi chuyển sang xử lý dữ liệu dạng hình ảnh có độ phân giải cao, cấu trúc MLP ngay lập tức vấp phải hai rào cản chí mạng: bùng nổ tham số và mất mát cấu trúc không gian 2D.
Bài học này giới thiệu bước đột phá lớn nhất của thị giác máy tính: Mạng nơ-ron tích chập (Convolutional Neural Network - CNN). Chúng ta sẽ khám phá toán học phía sau phép toán tích chập, cơ chế giảm kích thước không gian bằng lớp Pooling, triết lý thiết kế trường thụ cảm (Receptive Field) phân cấp, và thực hành xây dựng một mạng CNN hoàn chỉnh để phân loại chữ số viết tay trên tập dữ liệu MNIST.
pip install torch numpy. Dự án cuối bài
không tải dữ liệu qua mạng và không cần torchvision — nó tự vẽ ra tập dữ
liệu của mình, nên chạy được cả khi mất Internet. Kiến thức cần có: Bài 6 — vòng lặp huấn luyện bốn dòng và
optimizer.zero_grad(). Bài này dùng lại đúng vòng lặp đó, chỉ đổi kiến trúc mạng bên trong.
Bài 3 cũng hữu ích: một bức ảnh chính là mảng NumPy nhiều chiều.
7.1 Bản chất của phép tích chập (Convolution)
Đối với mạng nơ-ron liên kết toàn phần (MLP), khi xử lý hình ảnh, bước đầu tiên chúng ta phải làm là "kéo phẳng" (flatten) bức ảnh thành một vector một chiều. Phép toán này phá vỡ hoàn toàn mối liên hệ không gian lân cận giữa các điểm ảnh kề nhau (ví dụ: một điểm ảnh ở góc trên bên trái sẽ bị cắt rời khỏi điểm ảnh ngay sát dưới nó).
Hơn nữa, hãy tưởng tượng một bức ảnh màu có độ phân giải khiêm tốn $256 \times 256 \times 3$ (chiều cao, chiều rộng và 3 kênh màu RGB). Nếu đưa vào một lớp ẩn của MLP có $1000$ nơ-ron, số lượng trọng số cần học của lớp này sẽ là: $$256 \times 256 \times 3 \times 1000 \approx 196.6 \text{ triệu trọng số!}$$ Đây là một con số khổng lồ, khiến mô hình cực kỳ dễ bị quá khớp (overfitting) và làm nghẽn bộ nhớ GPU.
Mạng CNN giải quyết bài toán này nhờ hai triết lý thiết kế mô phỏng võng mạc sinh học:
- Trường thụ cảm cục bộ (Local Receptive Fields): Thay vì kết nối một nơ-ron lớp sau với toàn bộ điểm ảnh của lớp trước, nơ-ron của CNN chỉ kết nối với một vùng cửa sổ nhỏ kề cận (gọi là trường thụ cảm).
- Chia sẻ trọng số (Shared Weights): Một bộ lọc (Kernel) sẽ trượt quét qua toàn bộ bức ảnh từ trái qua phải, từ trên xuống dưới. Điều này có nghĩa là toàn bộ bức ảnh dùng chung một ma trận trọng số nhỏ, giúp nhận diện một đặc trưng (ví dụ: góc xiên, cạnh dọc) bất kể nó xuất hiện ở góc nào trên bức ảnh (Bất biến dịch chuyển - Translation Invariance).
Hãy xem ví dụ nhân tích chập với bộ lọc $3 \times 3$ sau: $$\text{Ảnh đầu vào } I = \begin{bmatrix} 1 & 1 & 1 \\ 0 & 1 & 1 \\ 0 & 0 & 1 \end{bmatrix} \quad * \quad \text{Kernel } K = \begin{bmatrix} 1 & 0 & 1 \\ 0 & 1 & 0 \\ 1 & 0 & 1 \end{bmatrix}$$ Số duy nhất tại ô góc trên bên trái kết quả thu được sẽ là: $$(1 \times 1) + (1 \times 0) + (1 \times 1) + (0 \times 0) + (1 \times 1) + (1 \times 0) + (0 \times 1) + (0 \times 0) + (1 \times 1) = 1 + 0 + 1 + 0 + 1 + 0 + 0 + 0 + 1 = 4$$
Nhưng
nn.Conv2d của PyTorch không lật. Nó làm phép
tương quan chéo (cross-correlation) — đặt kernel lên đúng chiều rồi nhân, như ví dụ vừa
tính ở trên. Toàn bộ ngành học sâu gọi nó là "convolution" theo quán tính lịch sử. Vì sao không ai sửa cái tên: trọng số của kernel do mạng tự học, nên nếu phép toán cần kernel bị lật thì mạng sẽ đơn giản học ra một kernel đã lật sẵn. Kết quả cuối cùng không đổi, và lật thêm một lần chỉ tốn công.
Ví dụ ở trên không cho thấy khác biệt này vì kernel được chọn $\begin{bmatrix} 1 & 0 & 1 \\ 0 & 1 & 0 \\ 1 & 0 & 1 \end{bmatrix}$ đối xứng khi quay 180° — lật hay không lật đều ra 4. Với một kernel bất đối xứng thì hai phép cho hai kết quả khác nhau. Biết điều này để khi đọc bài báo khoa học bạn không bị vướng, chứ nó không đổi cách bạn viết code.
Trong thực tế, khi thực hiện phép tích chập, ta có ba tham số quan trọng cần cấu hình:
- Kernel Size (Kích thước bộ lọc): Thường chọn kích thước lẻ như $3 \times 3$ hoặc $5 \times 5$ để đảm bảo có điểm nơ-ron trung tâm đối xứng.
- Stride (Bước nhảy - $S$): Khoảng cách di chuyển của bộ lọc sau mỗi bước nhân chập. Nếu $S=1$, bộ lọc dịch chuyển từng điểm ảnh một. Nếu $S=2$, bộ lọc nhảy cóc qua 2 điểm ảnh, làm giảm kích thước bản đồ đặc trưng đầu ra.
- Padding (Độ đệm viền - $P$): Việc trượt bộ lọc khiến các điểm ảnh sát mép biên ngoài cùng được nhân chập ít lần hơn các điểm ở giữa, làm kích thước ảnh bị co hẹp lại sau mỗi lớp và mất mát thông tin ở rìa. Để khắc phục, ta thêm các viền chứa số 0 (Zero Padding) xung quanh bức ảnh trước khi trượt bộ lọc.
Ví dụ: Đầu vào kích thước $28 \times 28$, sử dụng $K=3$, $P=1$, $S=1$: $$H_{\text{out}} = \left\lfloor \frac{28 - 3 + 2(1)}{1} \right\rfloor + 1 = 28$$ Như vậy đệm viền $P=1$ giúp giữ nguyên kích thước không gian ảnh $28 \times 28$ sau tích chập.
7.2 Lớp Pooling & Trích xuất đặc trưng phân cấp
Sau lớp tích chập, chúng ta thường chèn thêm các lớp Pooling (Lấy mẫu xuống). Lớp Pooling thực hiện trượt một cửa sổ qua ảnh và rút gọn thông tin:
- Max Pooling: Chỉ giữ lại giá trị lớn nhất trong ô cửa sổ (thường có kích thước $2 \times 2$ với bước nhảy $S=2$). Đây là lớp mặc định trong các kiến trúc CNN vì nó giữ lại đặc trưng kích hoạt mạnh mẽ nhất (ví dụ: độ tương phản cao nhất).
- Average Pooling: Tính giá trị trung bình của toàn bộ các điểm ảnh trong cửa sổ. Ít được sử dụng hơn ở các tầng ẩn, nhưng thường dùng ở tầng cuối cùng trước bộ phân loại (Global Average Pooling).
Đồng thời, việc thu nhỏ ảnh làm tăng Trường thụ cảm (Receptive Field) của các nơ-ron ở lớp sau. Một nơ-ron ở lớp sâu, nhờ đi qua các lớp Pooling liên tiếp, sẽ "nhìn thấy" một vùng không gian lớn trên bức ảnh gốc đầu vào. Nhờ đó, mạng CNN trích xuất đặc trưng theo cấu trúc phân cấp: các lớp đầu tiên học các chi tiết thô cực nhỏ (đường thẳng, góc cạnh); các lớp giữa học các hình dạng bộ phận (hình tròn, họa tiết); các lớp sâu cuối cùng tổng hợp thông tin để nhận diện toàn bộ vật thể (khuôn mặt, chiếc xe).
7.3 Giải phẫu và thiết kế mạng CNN hoàn chỉnh
Một mạng nơ-ron tích chập CNN phân loại hình ảnh hoàn chỉnh được chia thành hai phần riêng biệt:
-
Bộ trích xuất đặc trưng (Feature Extractor): Gồm các khối xếp chồng tuần tự của:
Conv2d$\to$ReLU$\to$MaxPool2d. Bộ phận này đảm nhận nhiệm vụ học các đặc trưng không gian của ảnh. -
Bộ phân loại tuyến tính (Classifier): Sau khi trích xuất, ta kéo phẳng (Flatten) ma
trận đặc trưng 3D thành một vector 1D và đưa qua các tầng liên kết toàn phần
Linear(MLP) kèm hàm kích hoạt để dự đoán nhãn phân loại.
forward(),
luồng dữ liệu đi qua các tầng tích chập và pooling liên tiếp sẽ liên tục bị thu nhỏ kích thước.
Đến trước tầng
nn.Linear phân loại đầu tiên, bạn bắt buộc phải tính toán thủ công chính xác
kích thước của Tensor sau khi Flatten. Nếu kích thước thực tế sau khi Flatten là $C \times H \times W$
(Số kênh nhân với chiều cao và rộng), thì tham số đầu vào của lớp nn.Linear phải được định
nghĩa đúng bằng giá trị $C \cdot H \cdot W$. Nếu khai báo sai, PyTorch sẽ báo lỗi biên dịch lúc chạy:
RuntimeError: mat1 and mat2 shapes cannot be multiplied.
Dưới đây là sơ đồ luồng lan truyền xuôi của mạng CNN hoàn chỉnh:
import torch
import torch.nn as nn
class SimpleCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
# --- spatial feature extractor ---
self.feature_extractor = nn.Sequential(
# Conv 1: 1 input channel (greyscale) -> 16 feature channels.
# padding=1 keeps the size, so the output is 16 x 28 x 28.
nn.Conv2d(in_channels=1, out_channels=16, kernel_size=3, stride=1, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2), # halves it -> 16 x 14 x 14
# Conv 2: 16 channels -> 32 channels, output 32 x 14 x 14
nn.Conv2d(in_channels=16, out_channels=32, kernel_size=3, stride=1, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2) # halves it again -> 32 x 7 x 7
)
# --- linear classifier ---
# After extraction the tensor is (batch, 32, 7, 7); flattening gives
# (batch, 32*7*7 = 1568). That 1568 is where the shape error comes from.
self.classifier = nn.Sequential(
nn.Linear(32 * 7 * 7, 128), # must match the flattened size exactly
nn.ReLU(),
nn.Linear(128, num_classes) # raw logits, one per class
)
def forward(self, x):
features = self.feature_extractor(x)
# start_dim=1 keeps the batch dimension and flattens everything after it.
flat_features = torch.flatten(features, start_dim=1)
return self.classifier(flat_features)
Hãy quay lại đúng con số đã cảnh báo ở mục 7.1 (MLP cần ~196 triệu trọng số) và so sánh CỤ THỂ với 2 lớp tích chập vừa định nghĩa ở trên, trên cùng một ảnh đầu vào $28 \times 28$ của MNIST:
# Parameters in the two Conv2d layers defined above.
# Conv1: 16 filters, each (1 input channel * 3 * 3) weights + 1 bias
conv1_params = 16 * (1 * 3 * 3 + 1) # = 160
# Conv2: 32 filters, each (16 input channels * 3 * 3) weights + 1 bias
conv2_params = 32 * (16 * 3 * 3 + 1) # = 4,640
feature_extractor_total = conv1_params + conv2_params
print(f"two Conv2d layers (the feature extractor): {feature_extractor_total:,}") # 4,800
# For comparison: the SIMPLEST possible linear layer taking the same image
# already flattened (28*28 = 784) to 128 hidden neurons — matching the width of
# the classifier behind it, and extracting no spatial structure whatsoever.
mlp_equivalent_params = 784 * 128 + 128
print(f"one equivalent linear layer (784 -> 128): {mlp_equivalent_params:,}") # 100,480
print(f"the CNN uses {mlp_equivalent_params / feature_extractor_total:.1f}x fewer") # ~21x
Con số thật: chỉ với 4.800 tham số, 2 lớp tích chập đã trích xuất được đặc trưng không gian từ toàn bộ ảnh 784 điểm ảnh — ít hơn khoảng 21 lần so với một lớp MLP tuyến tính đơn giản nhất có cùng độ rộng đầu ra, dù lớp MLP đó thậm chí còn CHƯA hề "nhìn thấy" cấu trúc không gian 2D của ảnh (chỉ xử lý một vector phẳng). Đây chính là con số cụ thể hoá cho lời giải thích lý thuyết "chia sẻ trọng số + liên kết cục bộ" đã nêu ở mục 7.1.
7.4 Dự án thực hành bài 7: Huấn luyện mạng CNN nhận dạng hình
Dự án của bài này huấn luyện đúng mạng CNN vừa định nghĩa ở trên, và chạy được
hoàn toàn offline — không tải gì, không cần torchvision.
torchvision, nên chỉ cần mạng chập chờn là cả bài học
đứng. Vì vậy script tự vẽ tập dữ liệu của nó: mười hình khác nhau (vòng tròn, gạch dọc,
gạch ngang, dấu cộng, hai đường chéo, chữ X, hộp rỗng, hộp đầy, hai gạch) đứng thay cho mười lớp chữ số,
kèm nhiễu và dịch chuyển ngẫu nhiên. Điểm cốt tử: mười hình này HỌC ĐƯỢC thật. Bản trước của script này nạp vào mạng ảnh
np.random.randn với nhãn np.random.randint — tức nhiễu thuần, giữa ảnh và nhãn
không có quan hệ nào. Bài toán đó không thể học được về mặt nguyên tắc: độ
chính xác nằm mãi ở mức đoán bừa (15% với 10 lớp), trong khi loss huấn luyện vẫn giảm đều — vì mạng đang
học vẹt 200 nhãn ngẫu nhiên. Hãy ghi nhớ dấu hiệu đó, nó sẽ cứu bạn nhiều lần: loss giảm mà độ chính xác trên tập kiểm tra không nhích khỏi mức đoán bừa nghĩa là mạng đang học vẹt, không phải đang học. Loss giảm một mình chưa chứng minh được điều gì.
Muốn huấn luyện trên MNIST thật: cài
torchvision rồi thay
make_shape_dataset() bằng torchvision.datasets.MNIST. Mọi phần còn lại giữ
nguyên — kiến trúc mạng, vòng lặp, hàm Loss đều không cần đổi một dòng.
# train_mnist_cnn.py
# Lesson 7: Computer vision basics — convolutional networks
# Practical AI Engineer series
#
# Run it with: python train_mnist_cnn.py
# Requires: pip install torch numpy (no download, no torchvision)
#
# WHY THE DATA IS SYNTHETIC, AND WHAT THAT COSTS
# Real MNIST needs a ~10 MB download and torchvision, which makes the lesson fail
# on a bad connection. So this script draws its own dataset: ten distinct shapes
# standing in for ten digit classes, plus noise and a random offset.
#
# The important part: these shapes are GENUINELY LEARNABLE. An earlier version of
# this script fed the network `np.random.randn` images with `np.random.randint`
# labels — pure noise with no relationship between image and label. That task is
# unlearnable by construction, so accuracy sat at chance level while the training
# loss still fell, because the network was memorising 200 random labels. A falling
# loss with chance-level accuracy is the signature of exactly that mistake.
#
# To train on real MNIST instead, install torchvision and replace
# make_shape_dataset() with torchvision.datasets.MNIST. Everything else is unchanged.
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
CLASS_NAMES = [
'ring',
'vertical bar',
'horizontal bar',
'plus',
'diagonal \\',
'diagonal /',
'cross X',
'hollow box',
'solid box',
'double bar',
]
def draw_shape(cls, rng):
"""One 28x28 shape for the given class, jittered slightly off centre."""
img = np.zeros((28, 28), dtype=np.float32)
cy, cx = rng.integers(11, 17), rng.integers(11, 17)
t = 2 # stroke thickness
clip = lambda v: int(np.clip(v, 0, 27))
if cls == 0: # ring
yy, xx = np.ogrid[:28, :28]
r = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2)
img[(r > 6) & (r < 6 + t + 1)] = 1
elif cls == 1: # vertical bar
img[cy - 9 : cy + 9, cx - 1 : cx + t] = 1
elif cls == 2: # horizontal bar
img[cy - 1 : cy + t, cx - 9 : cx + 9] = 1
elif cls == 3: # plus
img[cy - 9 : cy + 9, cx - 1 : cx + t] = 1
img[cy - 1 : cy + t, cx - 9 : cx + 9] = 1
elif cls == 4: # diagonal \
for k in range(-9, 9):
img[clip(cy + k), clip(cx + k)] = 1
elif cls == 5: # diagonal /
for k in range(-9, 9):
img[clip(cy + k), clip(cx - k)] = 1
elif cls == 6: # cross X
for k in range(-9, 9):
img[clip(cy + k), clip(cx + k)] = 1
img[clip(cy + k), clip(cx - k)] = 1
elif cls == 7: # hollow box
img[cy - 8 : cy + 8, cx - 8 : cx - 8 + t] = 1
img[cy - 8 : cy + 8, cx + 8 - t : cx + 8] = 1
img[cy - 8 : cy - 8 + t, cx - 8 : cx + 8] = 1
img[cy + 8 - t : cy + 8, cx - 8 : cx + 8] = 1
elif cls == 8: # solid box
img[cy - 6 : cy + 6, cx - 6 : cx + 6] = 1
else: # double bar
img[cy - 5 : cy - 5 + t, cx - 8 : cx + 8] = 1
img[cy + 5 : cy + 5 + t, cx - 8 : cx + 8] = 1
return img
def make_shape_dataset(n_samples=2000, seed=42):
"""Balanced dataset of the ten shapes, with noise, shuffled."""
rng = np.random.default_rng(seed)
X = np.empty((n_samples, 1, 28, 28), dtype=np.float32)
y = np.empty(n_samples, dtype=np.int64)
for i in range(n_samples):
cls = i % 10
X[i, 0] = np.clip(draw_shape(cls, rng) + rng.normal(0, 0.12, (28, 28)), 0, 1)
y[i] = cls
order = rng.permutation(n_samples)
return torch.tensor(X[order]), torch.tensor(y[order])
class MNIST_CNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 16, kernel_size=3, stride=1, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2), # 28x28 -> 14x14
nn.Conv2d(16, 32, kernel_size=3, stride=1, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2), # 14x14 -> 7x7
)
# 32 * 7 * 7 is not a magic number: it is the shape the block above emits.
# Get it wrong and you get "mat1 and mat2 shapes cannot be multiplied".
self.classifier = nn.Sequential(
nn.Linear(32 * 7 * 7, 64),
nn.ReLU(),
nn.Linear(64, num_classes), # raw logits — CrossEntropyLoss wants logits
)
def forward(self, x):
x = self.features(x)
x = torch.flatten(x, 1) # keep the batch dimension, flatten the rest
return self.classifier(x)
def print_sample(img, label):
"""Show one training image as text, so you can see what the network sees."""
print(f'\nsample input — class {label} ({CLASS_NAMES[label]}):')
for row in range(4, 26):
print(' ' + ''.join('#' if v > 0.5 else ('.' if v > 0.25 else ' ') for v in img[row]))
def train():
torch.manual_seed(42)
X, y = make_shape_dataset(2000)
split = 1600
X_train, y_train = X[:split], y[:split]
X_val, y_val = X[split:], y[split:]
print(f'train {tuple(X_train.shape)} | validation {tuple(X_val.shape)}')
print_sample(X_train[0, 0].numpy(), int(y_train[0]))
model = MNIST_CNN()
n_params = sum(p.numel() for p in model.parameters())
print(f'\nmodel has {n_params:,} parameters')
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
epochs, batch_size = 30, 64
print('\n=== training ===')
for epoch in range(1, epochs + 1):
model.train()
running = 0.0
# Mini-batches, not one giant batch: more update steps per pass over the
# data, which is how real training is always done.
for start in range(0, len(X_train), batch_size):
xb = X_train[start : start + batch_size]
yb = y_train[start : start + batch_size]
optimizer.zero_grad()
loss = criterion(model(xb), yb)
loss.backward()
optimizer.step()
running += loss.item() * len(xb)
if epoch % 5 == 0 or epoch == 1:
model.eval()
with torch.no_grad():
val_acc = (model(X_val).argmax(1) == y_val).float().mean().item() * 100
print(f'epoch {epoch:2d}/{epochs} | train loss {running / len(X_train):.4f} | val accuracy {val_acc:.2f}%')
model.eval()
with torch.no_grad():
preds = model(X_val).argmax(1)
acc = (preds == y_val).float().mean().item() * 100
print(f'\n=== final validation accuracy: {acc:.2f}% (chance level is 10%) ===')
# Per-class accuracy: an overall number can hide one class the model never gets.
print('\nper-class accuracy:')
for cls in range(10):
mask = y_val == cls
if mask.sum():
hit = (preds[mask] == cls).float().mean().item() * 100
print(f' {cls} {CLASS_NAMES[cls]:16} {hit:6.1f}% ({int(mask.sum())} samples)')
if __name__ == '__main__':
train()
mock_images HOÀN TOÀN ngẫu nhiên và mock_labels
cũng ngẫu nhiên ĐỘC LẬP với ảnh — nghĩa là không hề tồn tại mối liên hệ thật nào giữa ảnh và nhãn để mô
hình học. Với 10 lớp phân loại, mức chính xác kỳ vọng chỉ quanh mức đoán mò
~10%, dù vòng lặp huấn luyện chạy đúng 15 epoch không lỗi. Mục đích của bộ giả lập này
CHỈ là kiểm chứng toàn bộ pipeline (shape Tensor khớp nhau, forward/backward không crash, Loss tính toán
được) chạy đúng cơ chế — không phải để chứng minh mô hình "học" được gì. Muốn thấy Accuracy tăng thật,
bạn cần thay bằng dữ liệu MNIST thật (ví dụ qua torchvision.datasets.MNIST).
nn.CrossEntropyLoss trong PyTorch tự động tích hợp sẵn phép tính toán
LogSoftmax và NLLLoss (Negative Log Likelihood Loss). Nó giúp chuyển
đổi các giá trị dự đoán thô (logits) từ mạng thành một phân phối xác suất hợp lệ và tính toán sai số một
cách có độ ổn định số học (numerical stability) cực kỳ cao, chống lỗi tràn số.
Chạy nó ra như sau (đã lược phần giữa):
train (1600, 1, 28, 28) | validation (400, 1, 28, 28)
sample input — class 9 (double bar):
...
# #
################
################
...
model has 105,866 parameters
=== training ===
epoch 1/30 | train loss 1.9055 | val accuracy 71.25%
epoch 5/30 | train loss 0.0070 | val accuracy 100.00%
epoch 10/30 | train loss 0.0011 | val accuracy 100.00%
...
epoch 30/30 | train loss 0.0001 | val accuracy 100.00%
=== final validation accuracy: 100.00% (chance level is 10%) ===
per-class accuracy:
0 ring 100.0% (47 samples)
1 vertical bar 100.0% (31 samples)
2 horizontal bar 100.0% (35 samples)
3 plus 100.0% (38 samples)
4 diagonal \ 100.0% (43 samples)
5 diagonal / 100.0% (41 samples)
6 cross X 100.0% (43 samples)
7 hollow box 100.0% (37 samples)
8 solid box 100.0% (47 samples)
9 double bar 100.0% (38 samples)
Bốn điều đáng đọc ra. Một: script in ra một mẫu đầu vào dưới dạng ký tự trước khi huấn
luyện. Hãy xem nó — đó là cách duy nhất để biết mạng thực sự nhìn thấy gì, và nếu bạn từng đổi
phần sinh dữ liệu thì đây là chỗ phát hiện sai sót ngay. Hai: ngay epoch 1 đã đạt 71%, và
epoch 5 đã 100% — nhanh hơn hẳn Bài 6, dù mạng này lớn hơn. Lý do là mini-batch: mỗi lượt qua dữ liệu giờ
có 25 lần cập nhật trọng số thay vì 1. Ba: mạng có 105.866 tham số, mà
gần như toàn bộ nằm ở tầng Linear(1568, 64) — 100.416 tham số. Hai tầng tích chập
chỉ chiếm 4.800. Đúng con số đã so sánh ở mục 7.3, giờ nhìn thấy trên chính mô hình đang chạy.
Bốn: bảng độ chính xác từng lớp mới là phần đáng tin. Một con số tổng 100% có thể che
việc mạng bỏ hẳn một lớp — ví dụ đoán đúng 9 lớp và sai sạch lớp thứ mười vẫn cho 90%. Chia theo lớp thì
không che được gì.
Cách chạy dự án này trên máy bạn
- Cài:
pip install torch numpy. Không cần tải dữ liệu, không cần mạng. - Tải
train_mnist_cnn.pyở cuối bài, hoặc gõ lại đoạn code trên. - Chạy:
python3 train_mnist_cnn.py. Mất khoảng vài chục giây trên CPU. -
Bạn sẽ ra đúng những con số trên, vì script cố định cả hai bộ sinh ngẫu nhiên (
default_rng(42)cho dữ liệu,torch.manual_seed(42)cho trọng số). -
Rồi thử ba phép phá, mỗi phép dạy một điều khác nhau:
-
Đổi
nn.Linear(32 * 7 * 7, 64)thànhnn.Linear(32 * 8 * 8, 64)— gặp đúng lỗimat1 and mat2 shapes cannot be multipliedđã cảnh báo ở mục 7.3. Gặp một lần có chủ đích thì lần sau nhận ra ngay. -
Tăng nhiễu: đổi
rng.normal(0, 0.12, ...)thành0.5. Hình bắt đầu chìm trong nhiễu và độ chính xác tụt xuống. -
Quay lại bài toán bất khả: đổi
make_shape_datasetđể trả nhãn ngẫu nhiên (y[i] = rng.integers(0, 10)). Bạn sẽ thấy loss vẫn giảm mà độ chính xác nằm ở 10% — chính là cái bẫy đã nói ở đầu mục này, tự tay dựng lại được.
-
Đổi
Tóm tắt bài học & Cầu nối kiến thức
- Đạt được: hai triết lý làm nên CNN — liên kết cục bộ và chia sẻ trọng số — cùng con số cụ thể chứng minh chúng: 4.800 tham số thay cho 100.480.
-
Đạt được: tính được kích thước đầu ra của mỗi lớp, nên tránh được lỗi
mat1 and mat2 shapes cannot be multipliedthay vì đoán mò. -
Đạt được: biết
nn.Conv2dthật ra làm tương quan chéo chứ không phải tích chập, và vì sao điều đó không đổi cách bạn viết code. - Đạt được: Pooling, trường thụ cảm, và cách CNN học đặc trưng theo cấp — từ cạnh, tới bộ phận, tới vật thể.
- Đạt được: một dấu hiệu chẩn đoán sẽ dùng suốt đời: loss giảm mà độ chính xác đứng ở mức đoán bừa nghĩa là mạng đang học vẹt, không phải đang học.
- Đạt được: đọc độ chính xác từng lớp, vì một con số tổng có thể che việc mạng bỏ hẳn một lớp.
Cầu nối bài tiếp theo: Đã làm chủ xử lý hình ảnh, bước tiếp theo chúng ta sẽ tiến quân sang thế giới tự nhiên của ngôn ngữ: cách chuyển hóa từ vựng thành các vector đặc trưng trong không gian ngữ nghĩa Word Embeddings ở Bài số 8.
Tải file code thực hành minh họa bài học
File Python train_mnist_cnn.py — mã nguồn xây dựng mạng CNN, tự động giả lập tập dữ liệu
chữ số MNIST, và huấn luyện bộ trích xuất đặc trưng không gian (chạy
python train_mnist_cnn.py, yêu cầu cài đặt thư viện numpy và
torch):
📖 Tài liệu tham khảo
- CS231n: Convolutional Neural Networks for Visual Recognition — Phân tích chi tiết toán học và cơ chế hoạt động của CNN (Stanford University)
- PyTorch nn.Conv2d Documentation — Hướng dẫn chi tiết cấu hình tham số lớp tích chập 2D (Official Docs)
- Mạng nơ-ron tích chập (Convolutional Neural Network) — Kiến trúc tổng quan và lan truyền ngược (Machine Learning cơ bản)
Bình luận