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ố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.

✅ Bạn cần gì trước khi bắt đầu
Thư viện: 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).
🧠 Phép toán Tích chập 2D (2D Convolution) hoạt động thế nào?
Về mặt toán học rời rạc, phép tích chập giữa một bức ảnh đầu vào $I$ và một bộ lọc (Kernel) $K$ kích thước $k \times k$ tại tọa độ $(i, j)$ được định nghĩa như sau: $$S(i, j) = (I * K)(i, j) = \sum_{m} \sum_{n} I(i-m, j-n) K(m, n)$$ Trực quan hơn, chúng ta đặt bộ lọc $K$ (là một ma trận trọng số nhỏ, ví dụ kích thước $3 \times 3$) lên trên góc của ảnh đầu vào $I$, nhân từng phần tử trùng khít tọa độ với nhau rồi cộng dồn lại để được một số duy nhất ghi vào ma trận kết quả (được gọi là Feature Map - bản đồ đặc trưng).

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$$
🔎 Chi tiết mà hầu hết tài liệu bỏ qua: PyTorch không làm "tích chập"
Công thức $\sum_m \sum_n I(i-m, j-n) K(m,n)$ ở trên là tích chập theo đúng định nghĩa toán học, và để ý dấu trừ: nó lật kernel 180° trước khi nhân.

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.
📐 Công thức tính chiều không gian đầu ra (Output Shape)
Nếu bức ảnh đầu vào có kích thước chiều cao $H_{\text{in}}$ (hoặc chiều rộng $W_{\text{in}}$), kích thước bộ lọc là $K$, đệm viền là $P$, và bước nhảy là $S$. Kích thước chiều cao (hoặc rộng) đầu ra $H_{\text{out}}$ của lớp tích chập được tính bằng công thức: $$H_{\text{out}} = \left\lfloor \frac{H_{\text{in}} - K + 2P}{S} \right\rfloor + 1$$ Trong đó $\lfloor \dots \rfloor$ là phép toán lấy phần nguyên (Floor function).

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.
⚠️ Cạm bẫy: Chọn kích thước Kernel quá lớn
Một lỗi thiết kế thường gặp là chọn kích thước bộ lọc quá lớn (ví dụ $11 \times 11$ hoặc $7 \times 7$) ở các tầng sâu của mạng. Việc này làm tăng số lượng tham số tính toán một cách không cần thiết và làm mờ các thông tin chi tiết mịn của ảnh. Triết lý thiết kế hiện đại (như VGG hoặc ResNet) khuyến khích xếp chồng nhiều lớp tích chập nhỏ kích thước $3 \times 3$ liên tiếp nhau để thay thế cho một bộ lọc lớn, giúp mạng sâu hơn, phi tuyến tốt hơn mà lại có ít tham số hơn.

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).
🧠 Cơ chế tăng Trường thụ cảm (Receptive Field) phân cấp
Lớp Max Pooling kích thước $2 \times 2$, bước nhảy $S=2$ làm giảm một nửa kích thước chiều cao và chiều rộng của Feature Map, giúp giảm thiểu số lượng tính toán cho các tầng sau.

Đồ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:

  1. 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.
  2. 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.
⚠️ Cạm bẫy: Lỗi khớp chiều khi chuyển tiếp sang tầng Linear (mat1 and mat2 shapes mismatch)
Đây là lỗi gây ức chế nhất đối với các kỹ sư AI mới bắt đầu. Khi bạn viết hàm 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:

cnn_architecture.py
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:

count_parameters.py
# 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.

⚠️ Dữ liệu ở đây là tổng hợp, và đây là lý do bạn cần biết chính xác
MNIST thật cần tải khoảng 10 MB kèm 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
# 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()
⚠️ Vì sao đừng ngạc nhiên nếu Accuracy chỉ quanh 10% khi chạy thử code này
Bộ dữ liệu giả lập ở trên sinh 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).
💡 Tại sao sử dụng CrossEntropyLoss cho phân loại nhiều lớp?
Hàm loss nn.CrossEntropyLoss trong PyTorch tự động tích hợp sẵn phép tính toán LogSoftmaxNLLLoss (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):

Terminal
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

  1. Cài: pip install torch numpy. Không cần tải dữ liệu, không cần mạng.
  2. Tải train_mnist_cnn.py ở cuối bài, hoặc gõ lại đoạn code trên.
  3. Chạy: python3 train_mnist_cnn.py. Mất khoảng vài chục giây trên CPU.
  4. 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ố).
  5. 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ành nn.Linear(32 * 8 * 8, 64) — gặp đúng lỗi mat1 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ành 0.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.

Tóm tắt bài học & Cầu nối kiến thức

🔑 Bài học đạt đượ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 multiplied thay vì đoán mò.
  • Đạt được: biết nn.Conv2d thậ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 numpytorch):

Tải về train_mnist_cnn.py

📖 Tài liệu tham khảo

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

Bài 6: Huấn luyện mạng: Loss & Backpropagation Bài 8: Xử lý Văn bản & Word Embeddings Quay lại Lộ trình Kỹ Sư AI Thực Chiến

Bình luận