Ở 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.
6.1 Hàm mất mát (Loss Function) — Thước đo độ sai lệch
Hàm mất mát (Loss Function hoặc Cost Function) là công thức toán học đo đạc độ sai lệch giữa dự đoán của mô hình ($\hat{y}$) so với nhãn thực tế ($y$). Giá trị Loss càng nhỏ nghĩa là mô hình dự đoán càng chính xác.
Tùy vào bài toán học máy mà ta lựa chọn các hàm Loss khác nhau:
- 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ế.
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))
# Tình huống: nhãn thực tế y=1, nhưng logit z rất âm -> mô hình dự đoán RẤT SAI (gần 0)
y_true = 1
z = -8.0 # logit thô trước Sigmoid
y_pred = sigmoid(z) # ~ 0.000335 -- gần như chắc chắn dự đoán SAI (nhãn thật là 1)
# Gradient của MSE Loss theo 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 của Cross-Entropy Loss theo z: dL/dz = y_pred - y_true (không có nhân tử sigmoid')
grad_ce = y_pred - y_true
print(f"Dự đoán sai gần như hoàn toàn: y_pred = {y_pred:.6f} (nhãn thật = {y_true})")
print(f"Gradient MSE (dL/dz): {grad_mse:.6f}")
print(f"Gradient Cross-Entropy (dL/dz): {grad_ce:.6f}")
# Gradient MSE ~ -0.000335 -- CỰC NHỎ, mô hình gần như không học được gì
# Gradient CE ~ -0.999665 -- LỚN, mô hình sửa sai rất nhanh dù đang sai nặng nhất
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.
Hãy đi qua ví dụ toán học cụ thể của một mạng MLP 2 tầng (Input $X$, Lớp ẩn $Z^{[1]}$ kích hoạt bằng ReLU, Lớp đầu ra $Z^{[2]}$ kích hoạt bằng Sigmoid) để tính đạo hàm ngược:
- 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. Cập nhật moment bậc 1 (trung bình trượt của gradient)
m = beta1 * m + (1 - beta1) * grad
# 2. Cập nhật moment bậc 2 (trung bình trượt của gradient bình phương)
v = beta2 * v + (1 - beta2) * (grad ** 2)
# 3. Hiệu chỉnh chệch (có thể tắt để so sánh) — bù lại việc m, v khởi tạo từ 0
if correct_bias:
m_hat, v_hat = m / (1 - beta1 ** t), v / (1 - beta2 ** t)
else:
m_hat, v_hat = m, v
# 4. Cập nhật trọng số
theta_new = theta - lr * m_hat / (v_hat ** 0.5 + eps)
return theta_new, m, v
# So sánh CÓ và KHÔNG hiệu chỉnh chệch, cùng gradient không đổi = 0.5, cùng 4 bước
for label, correct in [("CÓ hiệu chỉnh chệch", True), ("KHÔNG hiệu chỉnh chệch", False)]:
print(f"--- {label} ---")
theta, m, v = 1.0, 0.0, 0.0
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"Bước {t}: theta = {theta:.6f}")
# CÓ hiệu chỉnh: mỗi bước giảm ĐỀU 0.001000 — bước đi ổn định ngay từ bước đầu tiên.
# KHÔNG hiệu chỉnh: bước 1 đã giảm 0.003162 (gấp ~3 lần bước "đúng"), rồi các bước
# sau còn LỚN HƠN NỮA (0.00425 -> 0.00495 -> 0.00544...) — vì thiếu hiệu chỉnh, tỉ lệ
# m/sqrt(v) bị lệch do beta2=0.999 tích luỹ v chậm hơn hẳn beta1=0.9 tích luỹ m.
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ụ.
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
# Sinh tập dữ liệu hai vòng tròn đồng tâm tương tự Bài 5
def generate_concentric_circles(n_samples=1200, noise=0.05, factor=0.5):
np.random.seed(42)
n_samples_out = n_samples // 2
n_samples_in = n_samples - n_samples_out
# Vòng tròn ngoài
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)
# Vòng tròn trong
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))
# Trộn ngẫu nhiên
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(SimpleMLP, self).__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()
)
self._initialize_weights()
def _initialize_weights(self):
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_sampled):
print("\n=== Đồ thị biểu diễn sai số (Loss reduction) ===")
max_loss = max(losses)
for i, loss in enumerate(losses):
epoch = epochs_sampled[i]
# Quy đổi độ dài cột ASCII từ 0-40 ký tự
bar_len = int((loss / max_loss) * 40) if max_loss > 0 else 0
bar = "█" * bar_len
print(f"Epoch {epoch:4d} | Loss: {loss:.4f} | {bar}")
def train():
# 1. Sinh dữ liệu
X_np, y_np = generate_concentric_circles(n_samples=1000)
# Phân chia tập Train / Test (80 / 20)
split = 800
X_train, X_test = X_np[:split], X_np[split:]
y_train, y_test = y_np[:split], y_np[split:]
# Đưa về PyTorch Tensor
X_train_t = torch.tensor(X_train, dtype=torch.float32)
y_train_t = torch.tensor(y_train, dtype=torch.float32).unsqueeze(1) # Chuyển sang kích thước (800, 1)
X_test_t = torch.tensor(X_test, dtype=torch.float32)
y_test_t = torch.tensor(y_test, dtype=torch.float32).unsqueeze(1)
# 2. Định nghĩa mô hình, hàm Loss và Bộ tối ưu
model = SimpleMLP()
# Hàm Binary Cross Entropy cho bài toán phân loại nhị phân
criterion = nn.BCELoss()
# Bộ tối ưu Adam cải tiến tự động điều chỉnh tốc độ học
optimizer = optim.Adam(model.parameters(), lr=0.01)
epochs = 500
sampled_losses = []
sampled_epochs = []
print("=== Bắt đầu huấn luyện mạng MLP trên dữ liệu Vòng Tròn Đồng Tâm ===")
for epoch in range(1, epochs + 1):
model.train()
# Lan truyền xuôi (Forward Pass)
predictions = model(X_train_t)
# Tính toán sai số (Loss)
loss = criterion(predictions, y_train_t)
# Giải phóng bộ đệm gradient cũ
optimizer.zero_grad()
# Lan truyền ngược (Backward Pass) để tính đạo hàm
loss.backward()
# Cập nhật trọng số
optimizer.step()
# Lưu lại mẫu loss để vẽ đồ thị
if epoch == 1 or epoch % 50 == 0:
sampled_losses.append(loss.item())
sampled_epochs.append(epoch)
# Tính toán độ chính xác (Accuracy) trên tập train
train_preds = (predictions >= 0.5).float()
accuracy = (train_preds == y_train_t).float().mean().item() * 100
print(f"Epoch {epoch:4d}/500 | Loss: {loss.item():.4f} | Accuracy: {accuracy:.2f}%")
# Đánh giá trên tập test
model.eval()
with torch.no_grad():
test_preds = model(X_test_t)
test_loss = criterion(test_preds, y_test_t)
test_preds_binary = (test_preds >= 0.5).float()
test_accuracy = (test_preds_binary == y_test_t).float().mean().item() * 100
print(f"\n=== Kết quả sau huấn luyện ===")
print(f"Loss trên tập Test: {test_loss.item():.4f}")
print(f"Độ chính xác (Accuracy) trên tập Test: {test_accuracy:.2f}%")
# Vẽ đồ thị ASCII Loss
draw_ascii_loss_chart(sampled_losses, sampled_epochs)
# Thử nghiệm xuất đồ thị ảnh nếu có matplotlib (không bắt buộc)
try:
import matplotlib.pyplot as plt
plt.figure(figsize=(8, 5))
plt.plot(sampled_epochs, sampled_losses, marker='o', color='gold', label='Loss')
plt.title('Đồ thị suy giảm Loss - Mạng MLP phân loại Vòng Tròn Đồng Tâm')
plt.xlabel('Epoch')
plt.ylabel('Loss Value')
plt.grid(True)
plt.legend()
plt.savefig('loss_chart.png')
print("\n[LƯU Ý] Đã vẽ và lưu đồ thị chất lượng cao thành công vào file 'loss_chart.png'!")
except ImportError:
pass
if __name__ == "__main__":
train()
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.
Tóm tắt bài học & Cầu nối kiến thức
- Đạt được: Tự lập trình vòng lặp huấn luyện (Training Loop), tính toán sai số bằng Loss Function.
- Đạt được: Làm chủ thuật toán lan truyền ngược Backpropagation để tự động cập nhật trọng số cho mạng nơ-ron.
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