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.
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$$
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 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(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:
# 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ế.
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).")
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ố.
Tóm tắt bài học & Cầu nối kiến thứ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 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