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)$$

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.

Các hàm kích hoạt phi tuyến phổ biến nhất bao gồm:

  • 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   (tốt, gradient truyền qua bình thường)
# x=  2 | f'(x) = 0.10499359
# x=  5 | f'(x) = 0.00664806
# x= 10 | f'(x) = 0.00004540   (gần như bằng 0!)

Với một mạng 10 lớp toàn dùng Sigmoid, nếu mỗi lớp góp phần nhân thêm một hệ số cỡ $0.0001$ vào gradient (do đầu vào rơi vào vùng bão hòa), sau 10 lớp gradient thực tế truyền tới lớp đầu tiên đã bị nhân với $(0.0001)^{10}$ — một con số nhỏ tới mức trọng số của lớp đầu gần như KHÔNG BAO GIỜ được cập nhật. Đây chính là lý do ReLU (đạo hàm luôn 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]})$$

Để lập trình một mạng MLP trong PyTorch, chúng ta xây dựng một class kế thừa từ torch.nn.Module và định nghĩa luồng dữ liệu trong phương thức 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):
        # 1. Bắt buộc gọi super().__init__() để đăng ký tham số với PyTorch
        super(SimpleMLP, self).__init__()
        
        # 2. Sử dụng nn.Sequential để lắp ráp các tầng liên tiếp
        self.network = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),   # Tầng tuyến tính 1
            nn.ReLU(),                          # Hàm kích hoạt phi tuyến ReLU
            nn.Linear(hidden_dim, hidden_dim),  # Tầng tuyến tính 2
            nn.ReLU(),
            nn.Linear(hidden_dim, output_dim),  # Tầng tuyến tính đầu ra
            nn.Sigmoid()                        # Đưa đầu ra về khoảng [0, 1] cho nhãn nhị phân
        )
        
    def forward(self, x):
        # Định nghĩa luồng lan truyền xuôi
        return self.network(x)
⚠️ 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.

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

Trước khi bắt đầu huấn luyện mạng nơ-ron, chúng ta cần gán các giá trị ban đầu cho các ma trận trọng số $W$ và bias $b$.

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 nơ-ron trong 1 lớp ẩn

# ❌ Khởi tạo toàn bộ trọng số bằng CÙNG một hằng số
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("Đầu ra 4 nơ-ron (khởi tạo hằng số):", output)
# tensor([[1.5, 1.5, 1.5, 1.5]], grad_fn=...) — CẢ 4 NƠ-RON RA GIÁ TRỊ GIỐNG HỆT NHAU!
# Vì mọi nơ-ron nhận cùng input, cùng trọng số -> cùng output -> cùng gradient khi backward
# -> mãi mãi cập nhật giống nhau -> 4 nơ-ron chỉ hoạt động như 1 nơ-ron duy nhất.

# ✅ Khởi tạo ngẫu nhiên (ví dụ Kaiming) phá vỡ tính đối xứng này
layer2 = nn.Linear(2, 4)
nn.init.kaiming_normal_(layer2.weight, nonlinearity="relu")
output2 = layer2(x)
print("Đầu ra 4 nơ-ron (khởi tạo Kaiming ngẫu nhiên):", output2)
# tensor([[...]]) — 4 giá trị KHÁC NHAU, mỗi nơ-ron giờ có thể học 1 đặc trưng riêng biệt.

Để tránh việc gradient bị triệt tiêu hoặc bùng nổ trong các mạng nơ-ron sâu, hai phương pháp khởi tạo khoa học thường được áp dụng:

  • 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)

Dự án thực hành của bài học này là tạo một chương trình sinh tập dữ liệu phi tuyến tính mô phỏng hai vòng tròn đồng tâm (vòng tròn ngoài mang nhãn $0$, vòng tròn trong mang nhãn $1$), định nghĩa mạng MLP có 3 lớp bằng PyTorch và thực hiện phép lan truyền xuôi (Forward Pass) ban đầu để trích xuất kết quả dự đoán xác suất thô khi chưa huấn luyện.

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
 
# Giả lập hoặc tự tạo hàm sinh dữ liệu hai vòng tròn đồng tâm (tương đương make_circles của scikit-learn)
# Việc này giúp mã nguồn chạy được ngay mà không bắt buộc cài đặt scikit-learn.
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
    
    # Sinh vòng tròn ngoài (bán kính 1.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)
    
    # Sinh vòng tròn trong (bán kính factor = 0.5)
    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)
    
    # Ghép hai cụm dữ liệu
    X = np.vstack((X_out, X_in))
    y = np.concatenate((y_out_label, y_in_label))
    
    # Trộn ngẫu nhiên dữ liệu
    indices = np.arange(n_samples)
    np.random.shuffle(indices)
    return X[indices], y[indices]
 
# Định nghĩa lớp mạng MLP (Multi-Layer Perceptron) tùy biến kế thừa từ nn.Module
class SimpleMLP(nn.Module):
    def __init__(self, input_dim=2, hidden_dim=8, output_dim=1):
        # 1. BẮT BUỘC gọi super().__init__() để đăng ký các tham số với PyTorch
        super(SimpleMLP, self).__init__()
        
        # 2. Định nghĩa các tầng liên kết toàn phần (Fully Connected / Linear Layers)
        # Mạng gồm 3 tầng tuyến tính có các hàm kích hoạt phi tuyến xen kẽ
        self.network = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),   # Tầng ẩn 1
            nn.ReLU(),                          # Hàm kích hoạt phi tuyến ReLU
            nn.Linear(hidden_dim, hidden_dim),  # Tầng ẩn 2
            nn.ReLU(),
            nn.Linear(hidden_dim, output_dim),  # Tầng đầu ra
            nn.Sigmoid()                        # Đưa đầu ra về khoảng [0, 1] để phân loại nhị phân
        )
        
        # 3. Minh họa cách khởi tạo trọng số Kaiming (He) Initialization
        self._initialize_weights()
 
    def _initialize_weights(self):
        for m in self.modules():
            if isinstance(m, nn.Linear):
                # Khởi tạo trọng số Kaiming cho tầng tuyến tính có ReLU
                nn.init.kaiming_normal_(m.weight, nonlinearity='relu')
                if m.bias is not None:
                    # Khởi tạo bias bằng 0
                    nn.init.constant_(m.bias, 0.0)
 
    def forward(self, x):
        # Định nghĩa luồng lan truyền xuôi (Forward pass) của dữ liệu qua mạng
        return self.network(x)
 
if __name__ == "__main__":
    print("=== Khởi tạo tập dữ liệu hai vòng tròn đồng tâm ===")
    X_np, y_np = generate_concentric_circles(n_samples=10, noise=0.05, factor=0.5)
    print(f"Kích thước X: {X_np.shape} (10 mẫu, mỗi mẫu là tọa độ x, y 2D)")
    print(f"Nhãn y: {y_np} (0 đại diện cho vòng ngoài, 1 đại diện cho vòng trong)\n")
    
    # Chuyển đổi dữ liệu NumPy sang PyTorch Tensor
    X_tensor = torch.tensor(X_np, dtype=torch.float32)
    
    print("=== Khởi tạo mô hình mạng MLP ===")
    model = SimpleMLP(input_dim=2, hidden_dim=8, output_dim=1)
    print(model)
    
    print("\n=== Thực hiện Lan truyền xuôi (Forward Pass) ban đầu ===")
    # Đưa mô hình về chế độ đánh giá (không tính gradient cho bước này)
    model.eval()
    with torch.no_grad():
        predictions = model(X_tensor)
        
    print("Kết quả dự đoán xác suất thô chưa huấn luyện:")
    for i in range(len(X_np)):
        print(f"Tọa độ: [{X_np[i][0]:.3f}, {X_np[i][1]:.3f}] | Dự đoán xác suất lớp 1: {predictions[i].item():.4f} (Nhãn thực tế: {int(y_np[i])})")
        
    print("\nNhận xét: Vì mạng chưa được huấn luyện, các dự đoán xác suất ngẫu nhiên và chưa khớp với nhãn thực tế.")
💡 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 sẽ vô hiệu hóa các lớp đặc biệt như Dropout hay Batch Normalization (nếu có) để đảm bảo kết quả dự đoán của mô hình nhất quán và không bị nhiễu động trong quá trình chạy suy luận.

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

🔑 Bài học đạt được:
  • Đạt được: Nắm vững cấu trúc mạng Perceptron và mạng truyền thẳng đa lớp MLP.
  • Đạt được: Hiểu rõ vai trò của các hàm kích hoạt phi tuyến (ReLU, Sigmoid) giúp mạng nơ-ron học các biên phân loại phi tuyến phức tạp.

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