Sau khi đã hiểu cách PyTorch quản lý Tensor và tính toán đạo hàm tự động ở Bài 4, chúng ta đã sẵn sàng lắp ráp cấu trúc mô hình học sâu thực thụ đầu tiên: Mạng nơ-ron đa lớp (Multi-Layer Perceptron - MLP).

Bài học này sẽ hướng dẫn bạn hiểu rõ bản chất toán học từ một tế bào nơ-ron đơn lẻ, cách thức lắp ghép chúng thành cấu trúc mạng đa lớp, tầm quan trọng sống còn của các hàm kích hoạt phi tuyến và các phương thức khởi tạo trọng số chuyên nghiệp để mạng bắt đầu học được dữ liệu.

5.1 Tế bào nơ-ron nhân tạo: Từ hàm tuyến tính đến phi tuyến

Một tế bào nơ-ron nhân tạo nhận vào một vector các tín hiệu đầu vào $X = [x_1, x_2, ..., x_n]$, nhân chúng với các trọng số tương ứng $W = [w_1, w_2, ..., w_n]$, cộng thêm sai số chệch (bias) $b$ và đưa toàn bộ tổng này qua một hàm kích hoạt phi tuyến (Activation Function) $f$:

$$a = f\left( \sum_{i=1}^n w_i x_i + b \right) = f(W \cdot X + b)$$

Nếu bạn thấy công thức này quen quen thì đúng vậy: bỏ hàm $f$ đi, nó chính là phương trình hồi quy tuyến tính. Một nơ-ron đơn lẻ như thế có tên riêng — Perceptron — do Frank Rosenblatt đề xuất năm 1958, và trong nhiều năm nó được kỳ vọng sẽ là nền móng của trí tuệ nhân tạo.

🧠 Một Perceptron chỉ vẽ được một đường thẳng — và vì sao điều đó từng làm ngành AI đóng băng
Perceptron phân loại bằng cách hỏi: tổng $W \cdot X + b$ là dương hay âm? Ranh giới giữa hai câu trả lời là tập các điểm mà $W \cdot X + b = 0$ — trong mặt phẳng hai chiều đó là một đường thẳng, và trong không gian $n$ chiều là một siêu phẳng. Nói cách khác, một perceptron chỉ có thể tách được những dữ liệu mà bạn kẻ được một nhát thẳng để chia đôi.

Năm 1969, Marvin Minsky và Seymour Papert chỉ ra rằng ngay cả hàm XOR — chỉ bốn điểm dữ liệu — cũng không có nhát cắt thẳng nào tách được. Cú đòn đó góp phần đẩy nghiên cứu mạng nơ-ron vào giai đoạn ngưng trệ kéo dài, thường được gọi là "mùa đông AI" đầu tiên.

Lời giải hoá ra gồm đúng hai phần, và cả hai đều nằm trong bài học này: xếp chồng nhiều lớp perceptron (thành MLP ở mục 5.2), và chèn một hàm phi tuyến giữa các lớp — vì như phần ngay dưới đây chứng minh, thiếu vế thứ hai thì vế thứ nhất hoàn toàn vô nghĩa.

Tại sao chúng ta bắt buộc phải có hàm kích hoạt phi tuyến $f$?

Trong thực tế, các tập dữ liệu luôn có ranh giới phân tách vô cùng phức tạp (ví dụ như bài toán XOR kinh điển hoặc tập dữ liệu hai vòng tròn đồng tâm). Nếu không sử dụng hàm kích hoạt phi tuyến $f$, thì dù bạn có xếp chồng bao nhiêu lớp tuyến tính chồng lên nhau đi chăng nữa, về mặt toán học chúng vẫn chỉ tương đương với một phép nhân tuyến tính duy nhất:

$$y = W_2 \cdot (W_1 \cdot X + b_1) + b_2 = (W_2 \cdot W_1) \cdot X + (W_2 \cdot b_1 + b_2) = W_{\text{new}} \cdot X + b_{\text{new}}$$

Không có hàm phi tuyến, mạng nơ-ron sẽ hoàn toàn mất đi khả năng xấp xỉ các hàm phi tuyến phức tạp.

Ba hàm kích hoạt bạn sẽ gặp nhiều nhất có hình dạng như sau. Hãy chú ý phần tô đỏ ở hai đầu Sigmoid và Tanh — đó là vùng bão hòa, nơi đồ thị nằm ngang, và một đồ thị nằm ngang thì đạo hàm bằng 0:

Sigmoid → (0, 1) bão hòa bão hòa đạo hàm tối đa 0,25 dùng ở lớp ĐẦU RA nhị phân Tanh → (−1, 1) căn giữa quanh 0 vẫn bão hòa hai đầu ReLU → [0, ∞) chết hẳn đạo hàm = 1 khi x > 0 mặc định cho mọi lớp ẨN

Nhìn hình là thấy ngay đánh đổi. Sigmoid và Tanh "mượt" ở giữa nhưng phẳng lì ở hai đầu — mà phẳng nghĩa là gradient bằng 0, tức không học được nữa. ReLU chỉ là hai đoạn thẳng, thô hơn nhiều, nhưng nửa bên phải có độ dốc đúng bằng 1 ở mọi điểm: gradient đi qua bao nhiêu lớp cũng không bị co lại. Cái giá của nó nằm ở nửa bên trái, phẳng hoàn toàn — chỗ sinh ra vấn đề "Dying ReLU" ngay dưới đây.

Cụ thể ba hàm đó như sau:

  • Sigmoid: Công thức $f(x) = \frac{1}{1 + e^{-x}}$. Hàm này nén toàn bộ giá trị đầu ra vào khoảng $(0, 1)$, rất phù hợp cho bài toán phân loại nhị phân (Binary Classification). Nhược điểm lớn của nó là bị triệt tiêu gradient (khi đầu vào quá lớn hoặc quá nhỏ, đạo hàm tiến sát về 0).
  • Tanh (Hyperbolic Tangent): Công thức $f(x) = \frac{e^x - e^{-x}}{e^x + e^{-x}}$. Hàm nén đầu ra vào khoảng $(-1, 1)$. Nó thường hoạt động tốt hơn Sigmoid ở các lớp ẩn vì giá trị đầu ra được căn giữa ở số 0 (zero-centered), giúp cho việc tối ưu hóa lớp tiếp theo nhanh hơn.
  • ReLU (Rectified Linear Unit): Công thức $f(x) = \max(0, x)$. Hàm này cực kỳ đơn giản (bằng 0 với số âm, giữ nguyên với số dương) nhưng lại là hàm mặc định cho tất cả các lớp ẩn của mạng nơ-ron hiện đại. Nó giải quyết hiện tượng triệt tiêu gradient vì đạo hàm luôn bằng 1 khi đầu vào dương.
    Nhược điểm: Dying ReLU. Nếu đầu vào của một nơ-ron liên tục âm, nơ-ron đó sẽ luôn trả về 0 và gradient qua nó sẽ bằng 0 vĩnh viễn, khiến nơ-ron bị "chết". Giải pháp thay thế là sử dụng Leaky ReLU: $f(x) = \max(\alpha x, x)$ với hệ số dốc nhỏ $\alpha \approx 0.01$.

Con số cụ thể cho thấy Sigmoid "triệt tiêu gradient" nghiêm trọng đến mức nào: đạo hàm của Sigmoid là $f'(x) = f(x)(1 - f(x))$, đạt giá trị lớn nhất đúng $0.25$ tại $x=0$, và co lại cực nhanh khi $|x|$ tăng:

vanishing_gradient_check.py
import math

def sigmoid(x):
    return 1 / (1 + math.exp(-x))

def sigmoid_derivative(x):
    s = sigmoid(x)
    return s * (1 - s)

for x in [0, 2, 5, 10]:
    print(f"x={x:>3} | f'(x) = {sigmoid_derivative(x):.8f}")

# x=  0 | f'(x) = 0.25000000   best case: the gradient passes through at 1/4 strength
# x=  2 | f'(x) = 0.10499359
# x=  5 | f'(x) = 0.00664806
# x= 10 | f'(x) = 0.00004540   effectively zero

# What 10 stacked sigmoid layers do to a gradient:
print(f"best case,  10 layers: {0.25 ** 10:.3e}")   # 9.537e-07
print(f"x=2 inputs, 10 layers: {0.1049 ** 10:.3e}")  # 1.613e-10

Hai dòng cuối mới là chỗ đáng sợ. Chú ý rằng 0,25 là giá trị lớn nhất mà đạo hàm Sigmoid có thể đạt được — nó chỉ xảy ra đúng tại $x = 0$. Nghĩa là ngay cả trong trường hợp tốt nhất có thể, mỗi lớp Sigmoid vẫn cắt gradient xuống còn một phần tư. Qua 10 lớp, gradient tới được lớp đầu tiên chỉ còn $0{,}25^{10} \approx 9{,}5 \times 10^{-7}$ — nhỏ hơn ban đầu khoảng một triệu lần. Và đó là kịch bản lạc quan; nếu đầu vào lệch khỏi 0 chỉ một chút, ví dụ $x = 2$, con số tụt xuống $1{,}6 \times 10^{-10}$.

Với learning rate cỡ $10^{-3}$, một gradient nhỏ như vậy làm trọng số lớp đầu thay đổi ở chữ số thập phân thứ mười — tức là không thay đổi gì trong độ chính xác của số thực 32-bit. Lớp đầu tiên của mạng đứng im trong khi các lớp cuối vẫn học bình thường. Đây chính là hiện tượng triệt tiêu gradient (vanishing gradient), và là lý do ReLU — đạo hàm luôn đúng bằng 1 với đầu vào dương, không co lại theo độ sâu — trở thành lựa chọn mặc định cho mọi lớp ẩn của mạng sâu hiện đại.

5.2 Lắp ráp mạng MLP (Multi-Layer Perceptron) với PyTorch

Mạng Multi-Layer Perceptron (MLP) là cấu trúc xếp chồng nhiều lớp nơ-ron liên kết toàn phần (Fully Connected hay Linear Layer). Mỗi nơ-ron của lớp này sẽ kết nối với tất cả các nơ-ron của lớp kế tiếp. Phép toán lan truyền xuôi ở mỗi lớp tương đương với phép nhân ma trận: $$Z^{[l]} = W^{[l]} A^{[l-1]} + b^{[l]}$$ $$A^{[l]} = f(Z^{[l]})$$

Ở Bài 4, ta tự tay khai báo từng trọng số bằng torch.randn((), requires_grad=True) — bốn tham số thì làm vậy còn được. Mạng dưới đây có 105 tham số, và một mạng thật có hàng triệu. Đây là lúc dùng tới torch.nn.

torch.nn (viết tắt của neural networks) là gói dựng sẵn của PyTorch chứa các thành phần lắp ghép mạng. Ba thứ ta dùng ngay bây giờ:

  • nn.Linear(in, out) — một tầng liên kết toàn phần. Gọi nn.Linear(2, 8) nghĩa là "nhận vào 2 số, trả ra 8 số", và PyTorch tự tạo ma trận trọng số $8 \times 2$ cùng vector bias 8 phần tử, đã bật sẵn requires_grad=True. Bạn không phải khai báo trọng số nào cả.
  • nn.ReLU(), nn.Sigmoid() — các hàm kích hoạt ở mục 5.1, đóng gói thành tầng để xếp chồng cho tiện. Chúng không có tham số học được.
  • nn.Sequential(...) — một hộp chứa các tầng chạy nối tiếp: đầu ra của tầng này là đầu vào của tầng kế. Nếu bạn quen Express thì nó giống hệt một chuỗi middleware.

Còn nn.Module là lớp cha mà mọi mô hình PyTorch phải kế thừa. Vì sao lại cần cả một class thay vì một hàm? Vì mô hình không chỉ là phép tính — nó còn phải mang theo trạng thái: toàn bộ trọng số. Kế thừa nn.Module cho bạn miễn phí những thứ đó: model.parameters() để liệt kê mọi trọng số cần học (Bài 6 sẽ đưa thẳng danh sách này cho optimizer), model.to(device) để chuyển cả mạng sang GPU trong một dòng, và model.state_dict() để lưu/nạp mô hình.

Việc của bạn chỉ còn hai bước: khai báo các tầng trong __init__, và mô tả dữ liệu chảy qua chúng theo thứ tự nào trong forward():

mlp_structure.py
import torch
import torch.nn as nn

class SimpleMLP(nn.Module):
    def __init__(self, input_dim=2, hidden_dim=8, output_dim=1):
        # Must come first — it sets up the bookkeeping nn.Module needs.
        super(SimpleMLP, self).__init__()

        # Layers run top to bottom: output of one feeds into the next.
        self.network = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),   # 2 -> 8
            nn.ReLU(),                          # non-linearity, no parameters
            nn.Linear(hidden_dim, hidden_dim),  # 8 -> 8
            nn.ReLU(),
            nn.Linear(hidden_dim, output_dim),  # 8 -> 1
            nn.Sigmoid()                        # squash to [0, 1] for a probability
        )

    def forward(self, x):
        # You never call forward() yourself — write model(x) and PyTorch calls it.
        return self.network(x)

model = SimpleMLP()
print(sum(p.numel() for p in model.parameters()))  # 105

Con số 105 ở dòng cuối cộng lại như sau: tầng 1 có $8 \times 2 = 16$ trọng số và 8 bias; tầng 2 có $8 \times 8 = 64$ trọng số và 8 bias; tầng ra có $1 \times 8 = 8$ trọng số và 1 bias. Tổng cộng $16 + 8 + 64 + 8 + 8 + 1 = 105$ con số, và tất cả đều do PyTorch tự tạo, tự bật theo dõi đạo hàm, và sẵn sàng cho vòng huấn luyện ở Bài 6. Hãy so với Bài 4, nơi bạn phải viết tay từng dòng requires_grad=True cho đúng bốn tham số.

Một chi tiết dễ vấp: bạn định nghĩa forward() nhưng không bao giờ gọi model.forward(x) — bạn viết model(x). nn.Module cài sẵn __call__ để gọi forward() giùm bạn, kèm theo các hook nội bộ. Gọi thẳng forward() vẫn ra kết quả đúng nhưng bỏ qua các hook đó, nên quy ước trong toàn bộ hệ sinh thái PyTorch là dùng model(x).

Đây là hình dạng của mạng vừa khai báo, kèm số chiều dữ liệu tại mỗi chặng:

SimpleMLP — 2 → 8 → 8 → 1, tổng 105 tham số đầu vào 2 số toạ độ x, y Linear(2,8) + ReLU 8 nơ-ron · 24 tham số Linear(8,8) + ReLU 8 nơ-ron · 72 tham số Linear(8,1) + Sigmoid 1 số xác suất ∈ [0, 1] 9 tham số "Liên kết toàn phần" nghĩa là MỌI nơ-ron của tầng trước nối tới MỌI nơ-ron của tầng sau — hình chỉ vẽ bớt cho dễ nhìn.
⚠️ Cạm bẫy: xếp hai nn.Linear liền nhau, quên hàm kích hoạt
Đây là lỗi phổ biến nhất khi mới viết mạng, và nó không báo lỗi gì cả — code chạy trơn tru, mạng huấn luyện được, chỉ là kết quả mãi không khá lên.

nn.Sequential(nn.Linear(2, 8), nn.Linear(8, 8), nn.Linear(8, 1)) trông như một mạng ba tầng, nhưng theo đúng phép nhân ma trận ở đầu mục 5.1, ba phép biến đổi tuyến tính liên tiếp gộp lại vẫn chỉ là một phép biến đổi tuyến tính. Bạn trả chi phí tính toán cho ba tầng và nhận về sức mạnh biểu diễn của một tầng — tức là vẫn chỉ vẽ được một đường thẳng, y hệt Perceptron 1958.

Cách kiểm tra nhanh: đọc print(model) và đếm — giữa hai Linear bất kỳ phải có một hàm kích hoạt. Nếu thấy hai Linear nằm cạnh nhau, bạn vừa tìm ra lý do mạng không học được.
⚠️ Cạm bẫy: Quên gọi super().__init__()
Nếu bạn định nghĩa một class kế thừa từ nn.Module mà quên gọi super().__init__() ở dòng đầu tiên của hàm khởi tạo, PyTorch sẽ báo lỗi AttributeError: cannot assign module before Module.__init__ is called. Lỗi này xảy ra vì PyTorch cần lớp cha thiết lập các cấu trúc quản lý trọng số nội bộ trước khi đăng ký các tầng nn.Linear.
💡 Chọn bao nhiêu tầng, mỗi tầng bao nhiêu nơ-ron?
Câu hỏi này chưa có công thức, và bất kỳ ai đưa cho bạn một công thức chính xác đều đang bịa. Nhưng có vài mốc thực dụng đáng biết.

Số chiều đầu vào và đầu ra thì bài toán quyết định, không phải bạn: ở đây là 2 (toạ độ x, y) và 1 (xác suất một lớp). Chỉ có các tầng ẩn ở giữa là tự do.

Bắt đầu nhỏ rồi tăng dần. Một hoặc hai tầng ẩn giải quyết được phần lớn bài toán dạng bảng; thêm tầng chỉ khi mạng hiện tại đã học hết mức mà vẫn chưa đủ tốt. Mạng lớn hơn không chỉ chậm hơn — nó còn dễ học thuộc lòng dữ liệu huấn luyện thay vì học quy luật (overfitting, sẽ gặp ở Bài 6).

Ở bài này, 8 nơ-ron mỗi tầng là con số cố ý chọn nhỏ để mạng đủ đơn giản mà vẫn tách được hai vòng tròn. Cách duy nhất để biết con số nào đúng cho bài toán của bạn là thử và đo — đúng tinh thần "đo trước rồi mới kết luận" xuyên suốt series này.

5.3 Khởi tạo trọng số (Weight Initialization)

Mạng đã có hình dạng, nhưng 105 con số bên trong nó phải bắt đầu từ đâu đó. Câu hỏi "gán giá trị ban đầu bằng bao nhiêu" nghe như chi tiết vụn vặt — thực ra chọn sai là mạng không bao giờ học được, và phần này chứng minh điều đó bằng code chứ không chỉ bằng lời.

Nếu chúng ta khởi tạo toàn bộ trọng số bằng số 0 (hoặc bất kỳ hằng số nào giống nhau), thì ở bước lan truyền xuôi, tất cả các nơ-ron trong cùng một lớp ẩn sẽ nhận được giá trị đầu ra giống hệt nhau. Điều này dẫn đến việc khi lan truyền ngược, các gradient của chúng cũng bằng nhau và cập nhật giống hệt nhau. Mạng đa lớp lúc này bị suy thoái thành một nơ-ron đơn lẻ (mất đi tính phá vỡ đối xứng - Symmetry Breaking).

Hãy CHỨNG MINH bằng code, thay vì chỉ tin vào lý thuyết ở trên:

symmetry_breaking_proof.py
import torch
import torch.nn as nn

torch.manual_seed(0)
layer = nn.Linear(2, 4)  # 4 neurons in one hidden layer

# Initialise every weight to the SAME constant.
nn.init.constant_(layer.weight, 0.5)
nn.init.constant_(layer.bias, 0.0)

x = torch.tensor([[1.0, 2.0]])
output = layer(x)
print("4 neurons, constant init:", output)
# tensor([[1.5, 1.5, 1.5, 1.5]]) — ALL FOUR NEURONS PRODUCE THE SAME VALUE.
# Same input, same weights -> same output -> same gradient on the backward pass
# -> identical updates forever -> 4 neurons behaving as 1.

# Random init (Kaiming here) breaks that symmetry.
layer2 = nn.Linear(2, 4)
nn.init.kaiming_normal_(layer2.weight, nonlinearity="relu")
output2 = layer2(x)
print("4 neurons, Kaiming init:", output2)
# tensor([[1.6014, -0.2386, 0.0344, -3.3811]]) — four different values, so each
# neuron can now learn a different feature.

Kết quả in ra nói hết: dòng đầu là tensor([[1.5, 1.5, 1.5, 1.5]]) — bốn nơ-ron cho ra bốn giá trị y hệt nhau. Vì chúng giống nhau ở lượt đi xuôi, gradient của chúng ở lượt đi ngược cũng sẽ giống nhau, nên bước cập nhật cũng giống nhau, và chúng sẽ còn giống nhau mãi mãi. Một lớp 4 nơ-ron khởi tạo kiểu này thực chất chỉ là một nơ-ron được sao chép bốn lần — bạn trả tiền cho 4 nơ-ron nhưng chỉ nhận về sức mạnh của 1.

Dòng thứ hai với khởi tạo Kaiming cho ra [1.6014, -0.2386, 0.0344, -3.3811]: bốn giá trị khác hẳn nhau. Từ vòng huấn luyện đầu tiên, mỗi nơ-ron đã đi một hướng riêng và có thể học một đặc trưng riêng. Đó là toàn bộ ý nghĩa của cụm từ "phá vỡ đối xứng".

Nhưng "ngẫu nhiên" thôi chưa đủ — ngẫu nhiên ở thang đo nào mới là vấn đề. Trọng số ban đầu quá lớn thì tín hiệu phình to dần qua từng lớp (bùng nổ gradient); quá nhỏ thì tín hiệu teo dần rồi biến mất (chính là triệt tiêu gradient ở mục 5.1, lần này do khởi tạo chứ không do hàm kích hoạt). Hai công thức dưới đây tồn tại để chọn đúng thang đo đó:

  • Xavier (Glorot) Initialization: Khởi tạo trọng số tuân theo phân phối chuẩn có phương sai tỉ lệ nghịch với số lượng nơ-ron đầu vào và đầu ra: $$\text{Var}(W) = \frac{2}{n_{\text{in}} + n_{\text{out}}}$$ Phương pháp này hoạt động cực kỳ hiệu quả khi đi kèm các hàm kích hoạt tuyến tính hoặc dạng cong như Tanh và Sigmoid, giúp giữ cho phương sai của dữ liệu và gradient không thay đổi khi đi qua các lớp.
  • He (Kaiming) Initialization: Khởi tạo trọng số có phương sai được điều chỉnh riêng biệt cho hàm kích hoạt ReLU: $$\text{Var}(W) = \frac{2}{n_{\text{in}}}$$ Tại sao lại có hệ số 2 thay vì hệ số 1? Bởi vì hàm ReLU triệt tiêu hoàn toàn phần âm (bằng 0), làm phương sai của dữ liệu đi qua giảm đi một nửa. Việc nhân thêm hệ số 2 giúp bù đắp lại phương sai bị hao hụt này.

5.4 Dự án thực hành bài 5: Bộ phân loại dữ liệu phi tuyến tính (Hai vòng tròn đồng tâm)

Giờ ta ghép cả ba mục lại thành một chương trình chạy được. Dự án sinh tập dữ liệu hai vòng tròn đồng tâm — vòng ngoài nhãn $0$, vòng trong nhãn $1$ — dựng mạng MLP ba tầng bằng PyTorch, khởi tạo trọng số theo Kaiming, rồi chạy một lượt lan truyền xuôi để xem mạng dự đoán gì khi chưa được huấn luyện.

Vì sao lại chọn hai vòng tròn đồng tâm? Vì đây đúng là loại dữ liệu mà một Perceptron đơn lẻ ở mục 5.1 bó tay: không có đường thẳng nào tách được vòng trong khỏi vòng ngoài. Nếu mạng MLP làm được, đó là bằng chứng trực tiếp cho mọi thứ bài học này vừa trình bày. Còn ở bài này ta mới dựng mạng — việc huấn luyện nó để thực sự phân loại đúng là nội dung Bài 6.

Dưới đây là mã nguồn Python chi tiết của dự án:

classify_circles.py
import torch
import torch.nn as nn
import numpy as np


# Two concentric circles: the classic dataset that a single straight line cannot
# separate. Written by hand rather than imported from scikit-learn so the file
# runs with nothing but torch and numpy installed.
def generate_concentric_circles(n_samples=1000, noise=0.05, factor=0.5):
    n_samples_out = n_samples // 2
    n_samples_in = n_samples - n_samples_out

    # Outer ring, radius 1.0, label 0.
    theta_out = np.linspace(0, 2 * np.pi, n_samples_out)
    x_out = np.cos(theta_out) + np.random.normal(0, noise, n_samples_out)
    y_out = np.sin(theta_out) + np.random.normal(0, noise, n_samples_out)
    X_out = np.vstack((x_out, y_out)).T
    y_out_label = np.zeros(n_samples_out)

    # Inner ring, radius `factor`, label 1.
    theta_in = np.linspace(0, 2 * np.pi, n_samples_in)
    x_in = factor * np.cos(theta_in) + np.random.normal(0, noise, n_samples_in)
    y_in = factor * np.sin(theta_in) + np.random.normal(0, noise, n_samples_in)
    X_in = np.vstack((x_in, y_in)).T
    y_in_label = np.ones(n_samples_in)

    X = np.vstack((X_out, X_in))
    y = np.concatenate((y_out_label, y_in_label))

    # Shuffle, so the two classes are not handed to the model in blocks.
    indices = np.arange(n_samples)
    np.random.shuffle(indices)
    return X[indices], y[indices]


class SimpleMLP(nn.Module):
    def __init__(self, input_dim=2, hidden_dim=8, output_dim=1):
        # super().__init__() must come first: it sets up the bookkeeping that
        # lets PyTorch find and track every layer you assign below.
        super(SimpleMLP, self).__init__()

        # Three linear layers with a non-linearity between each pair. Remove the
        # ReLUs and the whole stack collapses into a single linear layer.
        self.network = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),  # hidden layer 1
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),  # hidden layer 2
            nn.ReLU(),
            nn.Linear(hidden_dim, output_dim),  # output layer
            nn.Sigmoid(),  # squash to [0, 1] so the output reads as a probability
        )

        self._initialize_weights()

    def _initialize_weights(self):
        for m in self.modules():
            if isinstance(m, nn.Linear):
                # Kaiming is the right choice here because the hidden layers use
                # ReLU. Xavier would under-scale the variance for that activation.
                nn.init.kaiming_normal_(m.weight, nonlinearity="relu")
                if m.bias is not None:
                    # Bias can safely start at 0 — the random weights already
                    # break the symmetry between neurons.
                    nn.init.constant_(m.bias, 0.0)

    def forward(self, x):
        return self.network(x)


if __name__ == "__main__":
    print("=== Generating the two-circles dataset ===")
    X_np, y_np = generate_concentric_circles(n_samples=10, noise=0.05, factor=0.5)
    print(f"Shape of X: {X_np.shape} (10 samples, each an (x, y) coordinate)")
    print(f"Labels y: {y_np} (0 = outer ring, 1 = inner ring)\n")

    X_tensor = torch.tensor(X_np, dtype=torch.float32)

    print("=== Building the MLP ===")
    model = SimpleMLP(input_dim=2, hidden_dim=8, output_dim=1)
    print(model)
    n_params = sum(p.numel() for p in model.parameters())
    print(f"Trainable parameters: {n_params}")

    print("\n=== Forward pass on the untrained network ===")
    # eval() switches off training-only layers. This model has none, but making
    # it a habit costs nothing and prevents a whole class of bug later.
    model.eval()
    with torch.no_grad():
        predictions = model(X_tensor)

    print("Raw probabilities from the untrained model:")
    for i in range(len(X_np)):
        print(
            f"Point: [{X_np[i][0]:6.3f}, {X_np[i][1]:6.3f}] | "
            f"P(class 1) = {predictions[i].item():.4f} | true label: {int(y_np[i])}"
        )

    print("\nThe network has not been trained, so these probabilities carry no")
    print("information yet — they hover around 0.5 regardless of the true label.")
💡 block model.eval() dùng để làm gì?
Trong PyTorch, khi gọi model.eval(), mô hình sẽ được chuyển sang chế độ đánh giá. Việc này vô hiệu hóa các tầng có hành vi khác nhau giữa lúc huấn luyện và lúc dự đoán — cụ thể là Dropout và Batch Normalization.

Bạn chưa gặp hai tầng đó và mạng trong bài này cũng không có cái nào, nên gọi model.eval() ở đây không đổi kết quả gì cả. Vẫn nên tập thói quen viết nó, vì khi mạng của bạn có Dropout (sẽ gặp từ Bài 7 trở đi) mà quên gọi, mô hình sẽ tắt ngẫu nhiên một phần nơ-ron ngay trong lúc dự đoán — cùng một đầu vào cho ra hai kết quả khác nhau, và đó là loại bug rất khó lần ra.

Lưu ý model.eval()torch.no_grad()hai việc khác nhau: eval() đổi hành vi của các tầng, còn no_grad() tắt việc dựng đồ thị tính toán (Bài 4). Khi chạy dự đoán bạn thường cần cả hai.

Chạy thử và đọc kết quả

Chạy python classify_circles.py, phần cuối kết quả sẽ như thế này:

Terminal
=== Building the MLP ===
SimpleMLP(
  (network): Sequential(
    (0): Linear(in_features=2, out_features=8, bias=True)
    (1): ReLU()
    (2): Linear(in_features=8, out_features=8, bias=True)
    (3): ReLU()
    (4): Linear(in_features=8, out_features=1, bias=True)
    (5): Sigmoid()
  )
)
Trainable parameters: 105

=== Forward pass on the untrained network ===
Raw probabilities from the untrained model:
Point: [-0.008, -1.007] | P(class 1) = 0.5014 | true label: 0
Point: [ 0.488,  0.098] | P(class 1) = 0.5190 | true label: 1
Point: [-0.075,  0.432] | P(class 1) = 0.5355 | true label: 1
Point: [ 0.543, -0.078] | P(class 1) = 0.5087 | true label: 1
Point: [ 0.980, -0.013] | P(class 1) = 0.5239 | true label: 0
Point: [-1.033,  0.026] | P(class 1) = 0.4318 | true label: 0
Point: [ 0.027,  1.010] | P(class 1) = 0.5977 | true label: 0
Point: [ 0.074, -0.512] | P(class 1) = 0.5050 | true label: 1
Point: [-0.482, -0.061] | P(class 1) = 0.4659 | true label: 1
Point: [ 0.919, -0.052] | P(class 1) = 0.5199 | true label: 0

Hai điều đáng đọc ở đây. Thứ nhất, print(model) in ra đúng cấu trúc bạn vừa khai báo, kèm số chiều thật của từng tầng: 2 → 8, 8 → 8, 8 → 1. Đây là cách kiểm tra nhanh nhất xem mạng có đúng hình dạng mình nghĩ không — và với mạng phức tạp hơn, nó thường phát hiện ra lỗi khai báo số chiều trước cả khi bạn chạy được một vòng huấn luyện.

Thứ hai, toàn bộ xác suất dự đoán đều nằm quanh 0,5 (thấp nhất 0,4318, cao nhất 0,5977), và chúng không hề tương quan với nhãn thật: điểm nhãn 0 lẫn điểm nhãn 1 đều cho ra những con số như nhau. Đó chính xác là điều phải xảy ra. Trọng số đang là số ngẫu nhiên Kaiming, chưa hề thấy dữ liệu lần nào, nên mạng không biết gì hơn một cú tung đồng xu. Nếu bạn thấy mạng chưa huấn luyện mà đã dự đoán đúng, thì gần như chắc chắn có lỗi rò rỉ dữ liệu ở đâu đó.

Nói cách khác: bài học này đã dựng xong bộ khung và xác nhận nó chạy được, nhưng chưa dạy nó điều gì. Việc biến những con số quanh 0,5 kia thành các dự đoán đúng cần thêm hai thứ mà Bài 6 sẽ cung cấp — một hàm đo sai số, và một vòng lặp cập nhật trọng số theo gradient.

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

🔑 Bài học đạt được:
  • Đạt được: Hiểu Perceptron là gì, vì sao một mình nó chỉ vẽ được một đường thẳng, và vì sao giới hạn đó từng làm ngành AI đóng băng cho tới khi có MLP.
  • Đạt được: Hiểu rõ vai trò sống còn của hàm kích hoạt phi tuyến — không có nó thì xếp bao nhiêu lớp cũng chỉ tương đương một lớp — cùng đánh đổi giữa Sigmoid, Tanh và ReLU, và con số cho thấy 10 lớp Sigmoid làm gradient co lại một triệu lần trong trường hợp tốt nhất.
  • Đạt được: Lắp ráp mạng MLP bằng torch.nn — biết nn.Linear, nn.Sequentialnn.Module mỗi thứ lo phần gì, vì sao gọi model(x) chứ không phải model.forward(x), và đếm được 105 tham số của mạng đến từ đâu.
  • Đạt được: Biết vì sao không được khởi tạo trọng số bằng hằng số (4 nơ-ron suy thoái thành 1), và chọn giữa Xavier và Kaiming theo hàm kích hoạt đang dùng.

Cầu nối bài tiếp theo: Mô hình mạng MLP đã thiết kế xong nhưng chưa thể tự học. Ở Bài số 6, chúng ta sẽ viết vòng lặp huấn luyện (Training Loop) sử dụng thuật toán lan truyền ngược Backpropagation để cập nhật trọng số cho mạng.

Tải file code thực hành minh họa bài học

File Python classify_circles.py — mã nguồn khởi tạo mạng MLP 3 lớp ẩn sử dụng PyTorch và chạy lan truyền xuôi trích xuất xác suất dự đoán ban đầu trên tập dữ liệu vòng tròn đồng tâm (chạy python classify_circles.py, yêu cầu cài đặt thư viện numpytorch):

Tải về classify_circles.py

📖 Tài liệu tham khảo

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

Bài 4: PyTorch Cơ Bản: Tensor & Autograd chuyên sâu Bài 6: Huấn luyện mạng: Loss & Backpropagation Quay lại Lộ trình Kỹ Sư AI Thực Chiến

Bình luận