Ở 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ế.
🧠 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))

# 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.

⚠️ 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.

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:

  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. 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ụ.

train_circles.py
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()
💡 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.

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

🔑 Bài học đạt đượ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 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