Ở 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.
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 4 —
autogradvà đồ thị tính toán. Bài này gọiloss.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.
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ế.
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.
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:
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.
nn.CrossEntropyLoss trong PyTorch gộp chung cả hai bước LogSoftmax và
NLLLoss (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).
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ề:
Đọ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.
- $\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 * Bcủ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.
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:
- 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}$$
- 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}$$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:
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):
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
--- 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
# 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):
=== 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 đượ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.
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
-
Cài thư viện:
pip install torch numpy(thêmmatplotlibnếu muốn file ảnh). - Tải
train_circles.pyở cuối bài, hoặc gõ lại đoạn code trên. - Chạy:
python3 train_circles.py. Lần đầuimport torchhơi lâu, đừng lo. -
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. -
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
- Đạ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 numpy và
torch):
Bình luận