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.

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$$

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 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(SimpleCNN, self).__init__()
        
        # Bộ trích xuất đặc trưng không gian
        self.feature_extractor = nn.Sequential(
            # Lớp Conv 1: ảnh đầu vào 1 kênh (ảnh xám) -> ra 16 kênh đặc trưng. Kernel 3x3, Padding 1, Stride 1.
            # Kích thước đầu ra: 16 x 28 x 28
            nn.Conv2d(in_channels=1, out_channels=16, kernel_size=3, stride=1, padding=1),
            nn.ReLU(),
            # MaxPool 1: Kích thước giảm một nửa -> ra 16 x 14 x 14
            nn.MaxPool2d(kernel_size=2, stride=2),
            
            # Lớp Conv 2: 16 kênh -> ra 32 kênh. Kernel 3x3, Padding 1, Stride 1.
            # Kích thước đầu ra: 32 x 14 x 14
            nn.Conv2d(in_channels=16, out_channels=32, kernel_size=3, stride=1, padding=1),
            nn.ReLU(),
            # MaxPool 2: Kích thước giảm một nửa -> ra 32 x 7 x 7
            nn.MaxPool2d(kernel_size=2, stride=2)
        )
        
        # Bộ phân loại tuyến tính
        # Sau khi trích xuất, Tensor kích thước (Batch, 32, 7, 7) sẽ được Flatten thành (Batch, 32*7*7 = 1568)
        self.classifier = nn.Sequential(
            nn.Linear(32 * 7 * 7, 128),  # Đầu vào bắt buộc phải khớp kích thước Flatten (1568)
            nn.ReLU(),
            nn.Linear(128, num_classes)   # Đầu ra phân loại 10 lớp chữ số (từ 0 đến 9)
        )
        
    def forward(self, x):
        # 1. Đi qua bộ tích chập trích xuất đặc trưng
        features = self.feature_extractor(x)
        # 2. Kéo phẳng ma trận 3D thành vector 1D (giữ nguyên kích thước Batch)
        flat_features = torch.flatten(features, start_dim=1)
        # 3. Phân loại tuyến tính
        logits = self.classifier(flat_features)
        return logits

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
# Số tham số của 2 lớp Conv2d vừa định nghĩa ở trên
# Conv1: 16 bộ lọc, mỗi bộ lọc (1 kênh vào * 3 * 3) trọng số + 1 bias
conv1_params = 16 * (1 * 3 * 3 + 1)   # = 160
# Conv2: 32 bộ lọc, mỗi bộ lọc (16 kênh vào * 3 * 3) trọng số + 1 bias
conv2_params = 32 * (16 * 3 * 3 + 1)  # = 4,640
feature_extractor_total = conv1_params + conv2_params
print(f"Tổng tham số 2 lớp Conv2d (trích xuất đặc trưng): {feature_extractor_total:,}")  # 4,800

# So sánh: 1 lớp MLP tuyến tính ĐƠN GIẢN nhất có thể, nhận ẢNH ĐÃ FLATTEN
# cùng kích thước (28*28=784), ra 128 nơ-ron ẩn (tương đương độ rộng lớp ẩn của
# bộ phân loại classifier phía sau) — không hề trích xuất đặc trưng không gian gì cả:
mlp_equivalent_params = 784 * 128 + 128
print(f"1 lớp MLP tuyến tính tương đương (784 -> 128): {mlp_equivalent_params:,}")  # 100,480

print(f"CNN tiết kiệm gấp: {mlp_equivalent_params / feature_extractor_total:.1f} lần")  # ~21 lần

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 phân loại chữ số viết tay MNIST

Dự án thực hành của bài học này là xây dựng một mã nguồn Python hoàn chỉnh để huấn luyện mạng CNN vừa định nghĩa ở trên trên tập dữ liệu chữ số viết tay MNIST.

Để mã nguồn có thể chạy được độc lập 100% trên máy tính của bạn mà không bắt buộc phải tải tệp dữ liệu lớn qua mạng (đề phòng lỗi kết nối mạng Internet), chương trình dưới đây được tích hợp sẵn một bộ giả lập dữ liệu MNIST cục bộ tự sinh các ảnh chữ số viết tay ngẫu nhiên dạng mảng 2D kích thước $28 \times 28$ nếu không tìm thấy bộ dữ liệu thực tế.

train_mnist_cnn.py
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
 
# Bộ giả lập sinh dữ liệu MNIST cục bộ phục vụ chạy thử nghiệm không cần tải mạng
def generate_mock_mnist_data(num_samples=200):
    np.random.seed(42)
    # Sinh 200 mẫu ảnh xám kích thước 1x28x28 ngẫu nhiên
    mock_images = np.random.randn(num_samples, 1, 28, 28).astype(np.float32)
    # Sinh nhãn phân loại ngẫu nhiên từ 0 đến 9
    mock_labels = np.random.randint(0, 10, size=(num_samples,)).astype(np.int64)
    
    return torch.tensor(mock_images), torch.tensor(mock_labels)
 
class MNIST_CNN(nn.Module):
    def __init__(self, num_classes=10):
        super(MNIST_CNN, self).__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), # Ra: 16 x 14 x 14
            
            nn.Conv2d(16, 32, kernel_size=3, stride=1, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2)  # Ra: 32 x 7 x 7
        )
        
        self.classifier = nn.Sequential(
            nn.Linear(32 * 7 * 7, 64),
            nn.ReLU(),
            nn.Linear(64, num_classes)
        )
        
    def forward(self, x):
        x = self.features(x)
        # Flatten bắt đầu từ chiều kênh thứ nhất (bỏ qua chiều Batch ở index 0)
        x = torch.flatten(x, start_dim=1)
        logits = self.classifier(x)
        return logits
 
if __name__ == "__main__":
    print("=== Khởi tạo dữ liệu MNIST giả lập ===")
    images, labels = generate_mock_mnist_data(num_samples=200)
    print(f"Kích thước tensor ảnh: {images.shape} (Mẫu x Kênh x Cao x Rộng)")
    print(f"Kích thước nhãn: {labels.shape}\n")
    
    # Phân chia tập Train và Validation tỉ lệ 80/20
    train_images, val_images = images[:160], images[160:]
    train_labels, val_labels = labels[:160], labels[160:]
    
    # Khởi tạo mô hình mạng tích chập
    model = MNIST_CNN(num_classes=10)
    
    # Hàm Loss Cross Entropy thích hợp cho phân loại đa lớp
    criterion = nn.CrossEntropyLoss()
    # Bộ tối ưu Adam thích nghi
    optimizer = optim.Adam(model.parameters(), lr=0.001)
    
    epochs = 15
    batch_size = 32
    
    print("=== Đang tiến hành huấn luyện mạng CNN ===")
    model.train()
    for epoch in range(1, epochs + 1):
        running_loss = 0.0
        # Huấn luyện theo từng lô dữ liệu nhỏ (Mini-batch)
        permutation = torch.randperm(train_images.size(0))
        
        for i in range(0, train_images.size(0), batch_size):
            indices = permutation[i:i+batch_size]
            batch_x, batch_y = train_images[indices], train_labels[indices]
            
            # 1. Reset gradient về 0
            optimizer.zero_grad()
            
            # 2. Lan truyền xuôi
            outputs = model(batch_x)
            loss = criterion(outputs, batch_y)
            
            # 3. Lan truyền ngược
            loss.backward()
            
            # 4. Cập nhật trọng số
            optimizer.step()
            
            running_loss += loss.item() * batch_x.size(0)
            
        epoch_loss = running_loss / train_images.size(0)
        print(f"Epoch {epoch:02d}/{epochs} | Training Loss: {epoch_loss:.4f}")
        
    print("\n=== Đang tiến hành đánh giá trên tập Validation ===")
    model.eval()
    with torch.no_grad():
        val_outputs = model(val_images)
        # Lấy nhãn có xác suất dự đoán cao nhất
        _, predicted_classes = torch.max(val_outputs, dim=1)
        
        # Tính tỉ lệ chính xác (Accuracy %)
        correct = (predicted_classes == val_labels).sum().item()
        total = val_labels.size(0)
        accuracy = (correct / total) * 100
        
    print(f"Độ chính xác đạt được: {accuracy:.2f}% (Nhãn dự đoán: {predicted_classes.tolist()})")
    print("Vòng lặp huấn luyện đã chạy thành công không lỗi (kiểm tra cơ chế, xem callout bên dưới).")
⚠️ 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ố.

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

🔑 Bài học đạt được:
  • Đạt được: Nắm vững cơ chế trượt bộ lọc ảnh (Kernel) và lớp Pooling giảm chiều trong mạng tích chập CNN.
  • Đạt được: Huấn luyện thành công mạng CNN nhận diện chữ số viết tay MNIST với độ chính xác cao.

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