Ở Bài 5, chúng ta đã tự tay lắp ráp một cấu trúc mạng đa lớp MLP hoàn chỉnh và chạy lan truyền xuôi để xuất ra các dự đoán thô. Tuy nhiên, khi chưa được huấn luyện, các dự đoán này hoàn toàn vô nghĩa. Để mô hình thực sự học được quy luật, ta phải cung cấp cho nó một thước đo sai số và cơ chế tự sửa sai.

Bài học này sẽ hướng dẫn bạn thiết lập Hàm mất mát (Loss Function), tìm hiểu cặn kẽ thuật toán lan truyền ngược Backpropagation dựa trên quy tắc đạo hàm hàm hợp (Chain Rule) và bộ tối ưu hóa thích nghi Adam để hoàn thành một vòng lặp huấn luyện (Training Loop) chuyên nghiệp.

✅ Bạn cần gì trước khi bắt đầu
Thư viện: pip install torch numpy trong môi trường ảo đã tạo ở Bài 1. matplotlib là tuỳ chọn — nếu không có, dự án vẫn chạy và vẫn vẽ đồ thị, chỉ là vẽ bằng ký tự trên terminal thay vì xuất ra file ảnh.

Kiến thức cần có, và nó nằm ở đâu:
  • Bài 2 — đạo hàm là độ dốc, và hàm Loss là con số duy nhất đo mức sai. Bài này dùng lại nguyên hai ý đó, chỉ thay hàm một biến bằng hàm hàng triệu biến.
  • Bài 4autograd và đồ thị tính toán. Bài này gọi loss.backward() và tin rằng nó tính đúng đạo hàm; vì sao nó tính được thì Bài 4 đã giải thích.
  • Bài 5 — cấu trúc MLP và hàm kích hoạt. Mạng dùng ở đây chính là mạng của Bài 5, giờ được đem đi huấn luyện thật.
Nói cách khác: bốn bài trước đã dựng đủ mọi mảnh: bài này là bài lắp chúng lại thành một vòng lặp chạy được.

6.1 Hàm mất mát (Loss Function) — Thước đo độ sai lệch

Nhắc lại từ Bài 2 cho gọn: hàm mất mát nhận vào dự đoán của mô hình ($\hat{y}$, đọc "y mũ") cùng nhãn thực tế ($y$), và trả về một con số — mô hình đang sai bao nhiêu. Càng nhỏ càng tốt.

Điều mới của bài này là: chọn hàm nào. Bài 2 chỉ dùng một hàm bình phương đơn giản vì chỉ cần minh hoạ nguyên lý. Với bài toán thật thì chọn sai hàm Loss khiến mô hình học rất chậm hoặc không học được gì, và mục này chỉ ra vì sao — bằng con số, không bằng lời khuyên.

Có hai hàm chiếm gần như toàn bộ các trường hợp thực tế:

  • MSE Loss (Mean Squared Error): Dùng cho bài toán hồi quy (Regression). Nó đo bình phương khoảng cách trung bình: $$L = \frac{1}{N} \sum_{i=1}^N (y_i - \hat{y}_i)^2$$ Dưới góc nhìn xác suất, tối thiểu hóa MSE tương đương với tối ưu hóa cực đại hóa hợp lý (Maximum Likelihood Estimation - MLE) với giả định rằng sai số của mô hình tuân theo phân phối chuẩn Gaussian có kỳ vọng bằng 0.
  • Cross-Entropy Loss (BCE & Categorical Cross-Entropy): Dùng cho bài toán phân loại (Classification). Hàm này đo khoảng cách giữa hai phân phối xác suất (phân phối thực tế $y$ và dự đoán $\hat{y}$): $$L = -\frac{1}{N} \sum_{i=1}^N \left( y_i \log(\hat{y}_i) + (1 - y_i) \log(1 - \hat{y}_i) \right)$$ Khái niệm này xuất phát từ lý thuyết thông tin của Claude Shannon. Bằng cách tối thiểu hóa entropy chéo, chúng ta đang thực chất giảm thiểu khoảng cách Kullback-Leibler (KL Divergence) giữa phân phối dự đoán của mô hình và phân phối dữ liệu thực tế.
🔢 "Logits" là gì — chữ sắp xuất hiện liên tục
Logits là con số thô mà tầng cuối của mạng xuất ra, TRƯỚC khi bị ép về khoảng xác suất. Chúng có thể là bất cứ giá trị nào: $-8.2$, $0$, $15.7$.

Hình dung theo lối đời thường: logit là mức độ tự tin thô, còn xác suất là mức tự tin đó sau khi đã quy về thang 0–100%. Ba thí sinh được cho điểm thô $5$, $2$, $-1$ — đó là logits; đổi thành "70%, 25%, 5% khả năng thắng" mới là xác suất.

Hàm đổi logit thành xác suất là Sigmoid (cho hai lớp) hoặc Softmax (cho nhiều lớp). Ký hiệu $z$ trong các công thức dưới đây luôn là logit, còn $\hat{y}$ là xác suất sau khi đã ép.

Vì sao phải phân biệt: PyTorch có hai kiểu hàm Loss — kiểu nhận logits (nn.CrossEntropyLoss, nn.BCEWithLogitsLoss) và kiểu nhận xác suất (nn.BCELoss). Đưa sai loại vào là nguồn của cạm bẫy ở cuối mục này.
🧠 Phân tích toán học: Tại sao không dùng MSE cho bài toán Phân loại?
Nếu ta dùng MSE cho phân loại với hàm kích hoạt Sigmoid ở lớp cuối cùng, đạo hàm của Loss đối với trọng số sẽ chứa nhân tử đạo hàm của Sigmoid: $f'(z) = f(z)(1 - f(z))$. Khi mô hình dự đoán sai trầm trọng (ví dụ nhãn thực tế là 1 nhưng dự đoán gần 0), giá trị $f(z)$ tiến sát về 0, làm cho $f'(z)$ tiến về 0. Điều này làm cho gradient bị triệt tiêu đột ngột (gradient saturation), mạng nơ-ron không thể học được gì khi sai số lớn nhất.

Ngược lại, hàm Cross-Entropy được thiết kế để triệt tiêu nhân tử $f'(z)$ này. Đạo hàm của Cross-Entropy đối với logits đầu ra $z$ chỉ đơn giản là hiệu số tuyến tính: $$\frac{\partial L}{\partial z} = \hat{y} - y$$ Sai số càng lớn thì gradient càng lớn, giúp mô hình sửa sai cực nhanh ở những bước đầu tiên.

Hãy chứng minh bằng con số cụ thể thay vì chỉ tin vào lý thuyết đại số ở trên:

mse_vs_ce_gradient.py
import math

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

# The situation: the true label is 1, but the logit is very negative, so the
# model is predicting almost exactly the WRONG answer.
y_true = 1
z = -8.0                  # raw logit, before Sigmoid
y_pred = sigmoid(z)       # ~0.000335 — confidently wrong

# Gradient of MSE loss w.r.t. z: dL/dz = (y_pred - y_true) * sigmoid'(z)
sigmoid_derivative = y_pred * (1 - y_pred)
grad_mse = (y_pred - y_true) * sigmoid_derivative

# Gradient of cross-entropy w.r.t. z: dL/dz = y_pred - y_true.
# Note what is NOT here: the sigmoid'(z) factor.
grad_ce = y_pred - y_true

print(f"almost entirely wrong: y_pred = {y_pred:.6f} (true label = {y_true})")
print(f"MSE gradient (dL/dz):           {grad_mse:.6f}")
print(f"cross-entropy gradient (dL/dz): {grad_ce:.6f}")
# MSE gradient           -0.000335  -> TINY, the model barely learns
# cross-entropy gradient -0.999665  -> LARGE, the model corrects fast

Với cùng một tình huống dự đoán sai gần như tuyệt đối, gradient của MSE chỉ bằng khoảng 0.03% so với gradient của Cross-Entropy — đây chính xác là con số cụ thể hoá cho hiện tượng "gradient saturation" đã nêu ở trên, và giải thích vì sao gần như KHÔNG có bài toán phân loại thực tế nào còn dùng MSE làm hàm Loss chính.

⚠️ Cạm bẫy: Áp dụng Softmax hai lần trong PyTorch và lỗi mất ổn định số học
Hàm nn.CrossEntropyLoss trong PyTorch gộp chung cả hai bước LogSoftmaxNLLLoss (Negative Log-Likelihood Loss) làm một. Điều này được thiết kế để giải quyết lỗi tràn số (overflow/underflow) dấu phẩy động bằng cách sử dụng Log-Sum-Exp Trick: $$\log \sum_i e^{z_i} = c + \log \sum_i e^{z_i - c} \quad \text{với } c = \max_i(z_i)$$ Nếu bạn tự chèn thêm một lớp nn.Softmax ở đầu ra mô hình trước khi đưa vào nn.CrossEntropyLoss, chương trình sẽ tính toán Softmax hai lần, làm giảm độ chính xác số học và làm chậm tốc độ hội tụ nghiêm trọng. Lớp cuối cùng của mạng phân loại đa lớp trong PyTorch luôn luôn phải xuất ra logits thô (không có hàm kích hoạt).

6.2 Lan truyền ngược (Backpropagation) & Tối ưu hóa Gradient Descent

Sau khi tính được giá trị Loss ở tầng cuối cùng, làm thế nào để truyền thông tin sai lệch này ngược lại cho các lớp ẩn sâu phía trước để cập nhật trọng số?

Thuật toán Backpropagation (Lan truyền ngược) giải quyết bài toán này bằng cách áp dụng quy tắc đạo hàm hàm hợp (Chain Rule).

⚙️ Đồ thị tính toán động (Dynamic Computation Graph)
PyTorch xây dựng một đồ thị tính toán động (Directed Acyclic Graph - DAG) trong quá trình lan truyền xuôi (Forward Pass). Mỗi nút (Node) trong đồ thị đại diện cho một phép toán, và các cạnh (Edges) đại diện cho các Tensor. Các tham số cần tối ưu được gán cờ requires_grad=True. Khi gọi loss.backward(), engine Autograd của PyTorch sẽ duyệt ngược đồ thị này để tự động tính toán đạo hàm thông qua cơ chế tích lũy gradient.

Trước khi vào công thức, hãy xem hình dạng của việc đang xảy ra. Lan truyền xuôi đi từ trái sang phải để ra dự đoán; lan truyền ngược đi từ phải sang trái để mang thông tin sai lệch về:

LAN TRUYEN XUOI x y-hat L W¹·+b¹ ReLU W²·+b² Sigmoid so voi y LAN TRUYEN NGUOC delta² delta¹ y-hat − y qua (W²)ᵗ sua W² sua W¹ moi delta la "phan loi thuoc ve tang nay"

Đọc sơ đồ theo hai chiều. Chiều xanh: $x$ đi qua nhân ma trận rồi hàm kích hoạt, hai lần, ra $\hat{y}$, rồi so với nhãn thật để có $L$. Chiều đỏ: từ $L$ quay lại, mỗi tầng nhận về phần lỗi thuộc về nó — đó là toàn bộ ý nghĩa của chữ "lan truyền ngược". Hai mũi tên vàng cho thấy mỗi phần lỗi đó dùng để sửa đúng ma trận trọng số nào.

✏️ Ba ký hiệu cần biết trước khi đọc công thức
Công thức dưới đây dùng ba ký hiệu mà không giải thích thì sẽ chặn bạn ngay dòng đầu:
  • $\delta$ (đọc "delta")phần lỗi thuộc về một tầng, chính là các hộp đỏ trong sơ đồ. $\delta^{[2]}$ là lỗi ở tầng cuối, $\delta^{[1]}$ là lỗi đã truyền về tầng ẩn. Nó không phải một khái niệm mới — nó chỉ là tên gọi tắt cho $\frac{\partial L}{\partial z}$, để công thức không dài dòng.
  • $\odot$ — nhân từng phần tử, đúng phép A * B của NumPy ở Bài 3. Không phải nhân ma trận. Chỗ này dùng nó vì ta cần "chặn" lỗi ở đúng những nơ-ron mà ReLU đã tắt.
  • $m$ — số mẫu trong lô đang tính. Phép $\frac{1}{m}$ chỉ là lấy trung bình, để độ lớn gradient không phụ thuộc vào việc bạn đưa vào 10 mẫu hay 10.000 mẫu.
Chỉ số trên trong ngoặc vuông $^{[1]}$, $^{[2]}$ là số thứ tự tầng, không phải số mũ.

Giờ là cùng nội dung đó viết bằng công thức, cho mạng MLP 2 tầng (đầu vào $X$, lớp ẩn $Z^{[1]}$ kích hoạt ReLU, lớp ra $Z^{[2]}$ kích hoạt Sigmoid). Bạn không cần tự dẫn lại các công thức này — PyTorch làm hộ. Đọc chúng để tin rằng bên dưới loss.backward() không có phép màu nào:

  1. Lan truyền xuôi (Forward): $$Z^{[1]} = W^{[1]} X + b^{[1]}$$ $$A^{[1]} = \text{ReLU}(Z^{[1]})$$ $$Z^{[2]} = W^{[2]} A^{[1]} + b^{[2]}$$ $$A^{[2]} = \sigma(Z^{[2]}) = \hat{Y}$$
  2. Lan truyền ngược (Backward): Sai số ở lớp cuối cùng: $$\delta^{[2]} = A^{[2]} - Y$$ Đạo hàm đối với trọng số và bias lớp 2: $$\frac{\partial L}{\partial W^{[2]}} = \frac{1}{m} \delta^{[2]} (A^{[1]})^T$$ $$\frac{\partial L}{\partial b^{[2]}} = \frac{1}{m} \sum_{\text{mẫu}} \delta^{[2]}$$ Lan truyền lỗi ngược về lớp ẩn: $$\delta^{[1]} = \left( (W^{[2]})^T \delta^{[2]} \right) \odot \sigma_{\text{ReLU}}'(Z^{[1]})$$ Đạo hàm đối với trọng số và bias lớp 1: $$\frac{\partial L}{\partial W^{[1]}} = \frac{1}{m} \delta^{[1]} X^T$$ $$\frac{\partial L}{\partial b^{[1]}} = \frac{1}{m} \sum_{\text{mẫu}} \delta^{[1]}$$

Khi đã tính được gradient ($\nabla_W L$), bộ tối ưu hóa (Optimizer) sẽ thực hiện cập nhật các trọng số theo hướng ngược chiều gradient để giảm sai số dần dần:

$$W \leftarrow W - \alpha \cdot \frac{\partial L}{\partial W}$$
⚠️ Tại sao bắt buộc phải gọi optimizer.zero_grad()?
Trong PyTorch, khi ta gọi loss.backward(), gradient mới tính được sẽ cộng dồn (accumulate) vào thuộc tính .grad có sẵn của các Tensor chứ không bị ghi đè. Thiết kế này nhằm hỗ trợ việc huấn luyện các mô hình lớn (như Transformer/RNN) khi bộ nhớ GPU không đủ chứa một Batch lớn: ta có thể chia Batch thành nhiều Sub-batches, chạy backward cộng dồn gradient qua nhiều bước rồi mới cập nhật trọng số một lần (Gradient Accumulation).

Tuy nhiên, đối với vòng lặp huấn luyện thông thường, việc quên gọi optimizer.zero_grad() trước mỗi bước backward sẽ khiến gradient của các bước trước cộng dồn vào bước sau, hướng cập nhật trọng số bị sai lệch hoàn toàn và hàm Loss sẽ bùng nổ (phân kỳ).

6.3 Các bộ tối ưu cải tiến: Adam & Tốc độ học (Learning Rate)

Thuật toán Stochastic Gradient Descent (SGD) cơ bản cập nhật mọi trọng số với cùng một tốc độ học $\alpha$ cố định. Cách này rất dễ khiến mô hình bị kẹt ở các điểm cực tiểu cục bộ hoặc điểm yên ngựa (Saddle Points - nơi gradient bằng 0 nhưng không phải cực trị).

Các cải tiến từ SGD đến Adam:

  • Momentum (Động lượng): Mô phỏng một quả cầu vật lý lăn xuống dốc. Nó cộng dồn một phần gradient của các bước trước vào bước hiện tại, giúp mô hình vượt qua các vùng phẳng hoặc thung lũng hẹp rất nhanh.
  • RMSprop: Tự động điều chỉnh tốc độ học bằng cách chia gradient cho căn bậc hai của trung bình trượt bình phương gradient. Trọng số nào biến động quá mạnh sẽ bị giảm tốc độ cập nhật, trọng số nào đi chậm sẽ được tăng tốc.
  • Adam (Adaptive Moment Estimation): Là sự kết hợp hoàn hảo giữa Momentum và RMSprop, tính toán cả moment bậc 1 (động lượng) và moment bậc 2 (phương sai thích nghi) của gradient:
🧮 Thuật toán toán học của Adam Optimizer
Tại mỗi bước lặp $t$, với gradient $g_t$:
1. Cập nhật moment bậc 1 (Momentum): $$m_t = \beta_1 m_{t-1} + (1 - \beta_1) g_t$$ 2. Cập nhật moment bậc 2 (RMSprop): $$v_t = \beta_2 v_{t-1} + (1 - \beta_2) g_t^2$$ 3. Hiệu chỉnh chệch (Bias Correction) để tránh giá trị bị kéo về 0 ở các bước đầu tiên: $$\hat{m}_t = \frac{m_t}{1 - \beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1 - \beta_2^t}$$ 4. Cập nhật trọng số: $$\theta_t = \theta_{t-1} - \frac{\eta}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t$$ Trong đó, các giá trị siêu tham số mặc định được chứng minh thực nghiệm tối ưu cho hầu hết mọi kiến trúc: $\beta_1 = 0.9$, $\beta_2 = 0.999$, $\epsilon = 10^{-8}$.

Để thấy rõ 4 bước công thức trên hoạt động cụ thể ra sao, đây là cách tự viết Adam bằng Python thuần (chỉ để MINH HỌA cơ chế — torch.optim.Adam đã làm việc này hiệu quả hơn nhiều):

adam_from_scratch.py
def adam_step(theta, grad, m, v, t, lr=0.001, beta1=0.9, beta2=0.999, eps=1e-8, correct_bias=True):
    # 1. First moment: a running average of the gradient (this is momentum).
    m = beta1 * m + (1 - beta1) * grad
    # 2. Second moment: a running average of the SQUARED gradient (this is RMSprop).
    v = beta2 * v + (1 - beta2) * (grad ** 2)
    # 3. Bias correction. m and v both start at 0, so early on they are far too
    #    small; dividing by (1 - beta**t) compensates. Switchable here so we can
    #    measure what happens without it.
    if correct_bias:
        m_hat, v_hat = m / (1 - beta1 ** t), v / (1 - beta2 ** t)
    else:
        m_hat, v_hat = m, v
    # 4. The update itself.
    theta_new = theta - lr * m_hat / (v_hat ** 0.5 + eps)
    return theta_new, m, v

# Same constant gradient of 0.5, same 4 steps, with and without bias correction.
for label, correct in [("with bias correction", True), ("without bias correction", False)]:
    print(f"--- {label} ---")
    theta, m, v = 1.0, 0.0, 0.0
    previous = theta
    for t in range(1, 5):
        theta, m, v = adam_step(theta, grad=0.5, m=m, v=v, t=t, correct_bias=correct)
        print(f"step {t}: theta = {theta:.6f}  (moved {previous - theta:.6f})")
        previous = theta
Terminal
--- with bias correction ---
step 1: theta = 0.999000  (moved 0.001000)
step 2: theta = 0.998000  (moved 0.001000)
step 3: theta = 0.997000  (moved 0.001000)
step 4: theta = 0.996000  (moved 0.001000)
--- without bias correction ---
step 1: theta = 0.996838  (moved 0.003162)
step 2: theta = 0.992588  (moved 0.004250)
step 3: theta = 0.987638  (moved 0.004950)
step 4: theta = 0.982196  (moved 0.005442)

Con số thật (chạy thử ở trên) cho thấy: bỏ bước hiệu chỉnh chệch không làm bước cập nhật đầu tiên "rón rén" như trực giác đơn giản hay nói — với $\beta_1=0.9$ và $\beta_2=0.999$ (giá trị mặc định thực tế), nó lại khiến bước đi LỚN HƠN NHIỀU so với thiết kế ngay từ bước 1 (gấp ~3 lần), rồi tiếp tục phình to hơn nữa qua từng bước thay vì ổn định — đây là lý do bias correction luôn được bật mặc định trong mọi triển khai Adam thật (kể cả torch.optim.Adam), không phải một chi tiết tuỳ chọn có thể bỏ qua.

6.4 Vòng lặp huấn luyện (Training Loop) chuyên sâu

Dưới đây là mã nguồn Python thực thi đầy đủ vòng lặp huấn luyện mạng MLP trên tập dữ liệu hai vòng tròn đồng tâm phi tuyến. Mã nguồn được thiết kế chạy trực tiếp tuần tự 500 epochs, sử dụng nn.BCELoss và bộ tối ưu Adam, tự động vẽ biểu đồ ASCII Loss trên terminal để người học quan sát trực quan tốc độ hội tụ.

train_circles.py
# train_circles.py
# Lesson 6: Training a network — Loss & Backpropagation
# Practical AI Engineer series
#
# Run it with:  python train_circles.py
# Requires:     pip install torch numpy      (matplotlib optional)
#
# A complete training loop on the two-concentric-circles dataset from Lesson 5.
# The four lines that do the actual learning are marked STEP 1..4 below; every
# training loop you will ever write is those same four lines in that same order.

import math

import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim


def generate_concentric_circles(n_samples=1200, noise=0.05, factor=0.5):
    """Two concentric rings: outer labelled 0, inner labelled 1.

    Not linearly separable — no straight line splits them — which is exactly why
    the network needs its non-linear activations from Lesson 5.
    """
    np.random.seed(42)
    n_out = n_samples // 2
    n_in = n_samples - n_out

    theta_out = np.linspace(0, 2 * np.pi, n_out)
    X_out = np.vstack(
        (
            np.cos(theta_out) + np.random.normal(0, noise, n_out),
            np.sin(theta_out) + np.random.normal(0, noise, n_out),
        )
    ).T

    theta_in = np.linspace(0, 2 * np.pi, n_in)
    X_in = np.vstack(
        (
            factor * np.cos(theta_in) + np.random.normal(0, noise, n_in),
            factor * np.sin(theta_in) + np.random.normal(0, noise, n_in),
        )
    ).T

    X = np.vstack((X_out, X_in))
    y = np.concatenate((np.zeros(n_out), np.ones(n_in)))

    # Shuffle, so the train/test split below does not put every inner-ring point
    # in the test set.
    idx = np.arange(n_samples)
    np.random.shuffle(idx)
    return X[idx], y[idx]


class SimpleMLP(nn.Module):
    def __init__(self, input_dim=2, hidden_dim=8, output_dim=1):
        super().__init__()
        self.network = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, output_dim),
            nn.Sigmoid(),  # squashes the output into 0..1 so BCELoss can read it
        )
        self._initialise_weights()

    def _initialise_weights(self):
        # Kaiming init, for the symmetry-breaking reason covered in Lesson 5.
        for m in self.network:
            if isinstance(m, nn.Linear):
                nn.init.kaiming_normal_(m.weight, nonlinearity='relu')
                if m.bias is not None:
                    nn.init.constant_(m.bias, 0.0)

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


def draw_ascii_loss_chart(losses, epochs):
    """Bar chart of the loss curve, on a LOGARITHMIC scale.

    A linear scale is useless here: the loss falls from 0.69 to 0.0003, so after
    the first two rows every bar rounds down to zero characters and the chart
    goes blank exactly where the interesting part is. Log scale keeps the whole
    decay visible.
    """
    print('\n=== Loss curve (log scale — each bar is an order of magnitude) ===')
    lo, hi = math.log10(min(losses)), math.log10(max(losses))
    span = hi - lo or 1.0
    for epoch, loss in zip(epochs, losses):
        frac = (math.log10(loss) - lo) / span
        bar = '█' * max(1, int(frac * 40))
        print(f'Epoch {epoch:4d} | loss {loss:.4f} | {bar}')


def train():
    # Seed BOTH generators. numpy seeds the data; torch seeds the initial weights.
    # Without the torch seed the loss numbers differ on every run, which makes the
    # output in the lesson impossible to compare against.
    torch.manual_seed(42)

    X_np, y_np = generate_concentric_circles(n_samples=1000)

    # Hold back 20% the model never trains on, so the final number means something.
    split = 800
    to_t = lambda a: torch.tensor(a, dtype=torch.float32)
    X_train, X_test = to_t(X_np[:split]), to_t(X_np[split:])
    # unsqueeze(1): (800,) -> (800, 1), the shape BCELoss expects
    y_train = to_t(y_np[:split]).unsqueeze(1)
    y_test = to_t(y_np[split:]).unsqueeze(1)

    model = SimpleMLP()
    criterion = nn.BCELoss()  # binary cross-entropy, for a two-class problem
    optimizer = optim.Adam(model.parameters(), lr=0.01)

    epochs = 500
    sampled_losses, sampled_epochs = [], []

    print('=== Training an MLP on the concentric-circles data ===')
    for epoch in range(1, epochs + 1):
        model.train()

        predictions = model(X_train)  # STEP 1: forward pass
        loss = criterion(predictions, y_train)  # STEP 2: how wrong are we
        optimizer.zero_grad()  # STEP 3a: clear last round's gradients
        loss.backward()  # STEP 3b: backpropagate
        optimizer.step()  # STEP 4: nudge every weight

        if epoch == 1 or epoch % 50 == 0:
            sampled_losses.append(loss.item())
            sampled_epochs.append(epoch)
            hits = ((predictions >= 0.5).float() == y_train).float().mean().item() * 100
            print(f'Epoch {epoch:4d}/{epochs} | loss {loss.item():.4f} | train accuracy {hits:.2f}%')

    # eval() + no_grad(): stop tracking gradients, and switch layers that behave
    # differently at inference time. Neither matters for this small model, but
    # leaving them out of a real model is a bug that is hard to spot.
    model.eval()
    with torch.no_grad():
        test_preds = model(X_test)
        test_loss = criterion(test_preds, y_test).item()
        test_acc = ((test_preds >= 0.5).float() == y_test).float().mean().item() * 100

    print('\n=== After training ===')
    print(f'test loss     {test_loss:.4f}')
    print(f'test accuracy {test_acc:.2f}%')

    draw_ascii_loss_chart(sampled_losses, sampled_epochs)

    try:
        import matplotlib.pyplot as plt

        plt.figure(figsize=(8, 5))
        plt.plot(sampled_epochs, sampled_losses, marker='o', color='gold', label='loss')
        plt.yscale('log')  # same reason as the ASCII chart above
        plt.title('Loss decay — MLP classifying concentric circles')
        plt.xlabel('epoch')
        plt.ylabel('loss (log scale)')
        plt.grid(True)
        plt.legend()
        plt.savefig('loss_chart.png')
        print("\n[note] also saved a high-resolution chart to 'loss_chart.png'")
    except ImportError:
        pass  # matplotlib is optional; the ASCII chart above is enough


if __name__ == '__main__':
    train()

Chạy nó ra đúng như sau (đã lược phần giữa):

Terminal
=== Training an MLP on the concentric-circles data ===
Epoch    1/500 | loss 0.7111 | train accuracy 49.75%
Epoch   50/500 | loss 0.4149 | train accuracy 85.50%
Epoch  100/500 | loss 0.0194 | train accuracy 100.00%
...
Epoch  500/500 | loss 0.0004 | train accuracy 100.00%

=== After training ===
test loss     0.0008
test accuracy 100.00%

=== Loss curve (log scale — each bar is an order of magnitude) ===
Epoch    1 | loss 0.7111 | ████████████████████████████████████████
Epoch   50 | loss 0.4149 | █████████████████████████████████████
Epoch  100 | loss 0.0194 | ████████████████████
Epoch  150 | loss 0.0049 | ████████████
Epoch  200 | loss 0.0026 | █████████
Epoch  250 | loss 0.0017 | ███████
Epoch  300 | loss 0.0012 | █████
Epoch  350 | loss 0.0009 | ███
Epoch  400 | loss 0.0007 | ██
Epoch  450 | loss 0.0005 | █
Epoch  500 | loss 0.0004 | █

Bốn điều đáng đọc ra từ đây. Một: ở vòng 1, độ chính xác là 49,75% — đúng bằng mức đoán bừa giữa hai lớp. Mạng lúc đó chưa biết gì cả, và đó là điểm xuất phát đúng. Hai: đến vòng 100 thì đã 100%. Vậy 400 vòng còn lại làm gì? Nó không tăng độ chính xác nữa, mà tăng độ tự tin: loss vẫn tiếp tục giảm từ 0,0194 xuống 0,0004. Phân biệt hai thứ này là một trong những điều dễ nhầm nhất — accuracy đếm số câu đúng, loss đo mức chắc chắn. Ba: loss trên tập test (0,0008) xấp xỉ loss trên tập train, nghĩa là mô hình không học vẹt 800 điểm mà thật sự nắm được quy luật. Bốn: đồ thị dùng thang log, nên mỗi khoảng bằng nhau là một lần giảm mười lần. Nếu vẽ thang thường, từ vòng 100 trở đi mọi cột đều dài 0 ký tự và đồ thị trắng trơn đúng ở chỗ thú vị nhất.

⚠️ Bài toán này DỄ, và biết điều đó là quan trọng
Đạt 100% cả train lẫn test nghe rất đẹp, nhưng đừng rút ra kết luận sai. Hai vòng tròn ở đây cách nhau rõ rệt (bán kính 0,5 và 1,0, nhiễu chỉ 0,05), nên chúng gần như không chồng lấn. Bất kỳ mạng nào có một hàm phi tuyến cũng tách được.

Bài toán được chọn dễ có chủ đích: mục tiêu của bài là vòng lặp huấn luyện có chạy đúng hay không, và một bài toán dễ cho câu trả lời rõ ràng — nếu loss không giảm thì chắc chắn code sai, không phải "dữ liệu khó".

Muốn thấy nó khó lên, đổi noise=0.05 thành 0.2 rồi chạy lại: hai vòng tròn bắt đầu lẫn vào nhau, và độ chính xác sẽ không còn 100%. Đó mới là hình dạng của mọi bài toán thật, và là lý do các bài sau phải nói tới overfitting.
💡 Phân biệt train() và eval() của mô hình
Trong vòng lặp huấn luyện, việc gọi model.train() đưa mô hình vào trạng thái học tập (cho phép cập nhật các giá trị thống kê của batchnorm, dropout). Khi kết thúc huấn luyện, ta gọi model.eval() và bọc phép tính trong block with torch.no_grad(): để chạy kiểm tra sai số trên tập Test mà không làm ảnh hưởng đến các trọng số đã học.

Cách chạy dự án này trên máy bạn

  1. Cài thư viện: pip install torch numpy (thêm matplotlib nếu muốn file ảnh).
  2. Tải train_circles.py ở cuối bài, hoặc gõ lại đoạn code trên.
  3. Chạy: python3 train_circles.py. Lần đầu import torch hơi lâu, đừng lo.
  4. Bạn sẽ ra đúng những con số ở trên, vì script cố định cả hai bộ sinh số ngẫu nhiên — np.random.seed(42) cho dữ liệu và torch.manual_seed(42) cho trọng số ban đầu.
  5. Rồi thử phá nó, đây là phần học được nhiều nhất: xoá dòng optimizer.zero_grad() và chạy lại. Gradient của các vòng trước sẽ cộng dồn vào vòng sau, và bạn sẽ thấy loss cư xử hoàn toàn khác. Gặp lỗi này một lần có chủ đích thì lần sau gặp nó trong một mô hình thật bạn sẽ nhận ra ngay.

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

🔑 Bài học đạt được:
  • Đạt được: chọn hàm Loss có căn cứ, không theo cảm tính — và biết bằng con số vì sao MSE làm mô hình phân loại gần như ngừng học khi nó sai nặng nhất.
  • Đạt được: đọc được ba ký hiệu $\delta$, $\odot$, $m$, nên công thức lan truyền ngược không còn là một khối chữ lạ.
  • Đạt được: hình dung lan truyền ngược như dòng chảy hai chiều — xuôi ra dự đoán, ngược mang phần lỗi về cho từng tầng.
  • Đạt được: bốn dòng làm nên mọi vòng lặp huấn luyện, và lý do zero_grad() bắt buộc phải có.
  • Đạt được: phân biệt accuracy và loss — vì sao mô hình đã đúng 100% mà loss vẫn còn giảm được 50 lần nữa.
  • Đạt được: Adam, và một chi tiết ngược trực giác: bỏ bias correction không làm bước đi rón rén hơn mà làm nó lớn gấp ba ngay từ bước đầu.

Cầu nối bài tiếp theo: Mạng MLP xử lý dữ liệu phẳng rất tốt, nhưng với dữ liệu không gian dạng hình ảnh, ta cần một cấu trúc mạng tối ưu hơn là Mạng tích chập CNN ở Bài số 7.

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

File Python train_circles.py — mã nguồn viết vòng lặp huấn luyện mạng MLP trên dữ liệu hai vòng tròn đồng tâm phi tuyến, hiển thị quá trình suy giảm sai số bằng biểu đồ ASCII (chạy python train_circles.py, yêu cầu cài đặt thư viện numpytorch):

Tải về train_circles.py

📖 Tài liệu tham khảo

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

Bài 5: Mạng Nơ-ron Đơn Giản (Perceptron & MLP) Bài 7: Thị giác Máy tính: Mạng tích chập CNN chuyên sâu Quay lại Lộ trình Kỹ Sư AI Thực Chiến

Bình luận