Chào mừng bạn trở lại! Ở Phần 1, chúng ta đã thấy cách mà một mạng nơ-ron có thể cộng hai số bằng cách sử dụng cấu trúc feedforward cơ bản. Ở cuối bài viết, chúng tôi thậm chí đã cho bạn xem một triển khai C++ đầy đủ bao gồm bước đi tới, vòng lặp huấn luyện, tính toán mất mát và cập nhật trọng số — tất cả đều trong mã.

Điều này tự nhiên dẫn đến câu hỏi tiếp theo: làm thế nào mà một mạng nơ-ron học cách điều chỉnh những trọng số đó một cách tự động thông qua quá trình huấn luyện? Đó chính xác là điều mà chúng ta sẽ khám phá ở đây trong Phần 2.

Trước khi đi xa hơn, đây là mã C++ đầy đủ từ Phần 1 mà triển khai một mạng nơ-ron đơn giản để học cách cộng hai số. Nó bao gồm bước đi tới, tính toán mất mát, lan truyền ngược và cập nhật trọng số:

#include <iostream>
#include <cmath>
using namespace std;

// Hàm kích hoạt sigmoid
float sigmoid(float x) {
    return 1.0f / (1.0f + exp(-x));
}

// Đạo hàm của sigmoid (sử dụng trong lan truyền ngược)
float sigmoid_derivative(float y) {
    return y * (1 - y); // nơi y = sigmoid(x)
}

int main() {
    // Khởi tạo trọng số và độ thiên cho 3 nút ẩn
    // Những giá trị này được chọn thủ công để hiển thị rõ hơn quá trình học.
    // Sử dụng cả trọng số dương và âm giúp ngăn chặn đối xứng và cung cấp một điểm khởi đầu đa dạng.
    float weight1_node1 = 0.5f, weight2_node1 = 0.5f, bias_node1 = 0.0f;      // Nút 1 bắt đầu với ảnh hưởng dương từ cả hai đầu vào
    float weight1_node2 = -0.5f, weight2_node2 = -0.5f, bias_node2 = 0.0f;    // Nút 2 bắt đầu với ảnh hưởng âm, khuyến khích sự cân bằng
    float weight1_node3 = 0.25f, weight2_node3 = -0.25f, bias_node3 = 0.0f;   // Nút 3 là hỗn hợp, hữu ích để nắm bắt các tương tác

    // Khởi tạo trọng số và độ thiên cho lớp đầu ra
    float weight_out_node1 = 0.5f, weight_out_node2 = 0.5f, weight_out_node3 = 0.5f, bias_out = 0.0f;

    float learning_rate = 0.01f;
    int epochs = 1000; // Số lần chúng ta lặp qua dữ liệu huấn luyện

    // Vòng lặp huấn luyện
    for (int epoch = 0; epoch < epochs; ++epoch) {
        float total_loss = 0.0f;
        for (int a = 0; a < 100; ++a) {
            for (int b = 0; b < 100; ++b) {
                float x1 = a / 100.0f;
                float x2 = b / 100.0f;
                float target = (a + b) / 200.0f; // Chuẩn hóa tổng mục tiêu về [0, 1]

                // ===== Bước đi tới =====
                float z1 = x1 * weight1_node1 + x2 * weight2_node1 + bias_node1;
                float h1 = sigmoid(z1);

                float z2 = x1 * weight1_node2 + x2 * weight2_node2 + bias_node2;
                float h2 = sigmoid(z2);

                float z3 = x1 * weight1_node3 + x2 * weight2_node3 + bias_node3;
                float h3 = sigmoid(z3);

                float y_pred = h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out;

                float error = target - y_pred;
                total_loss += error * error; // Lỗi bình phương (mất mát)

                // ===== Lan truyền ngược - Lớp đầu ra =====
                float delta_out = error; // Đạo hàm của mất mát theo y_pred
                float grad_out_w1 = delta_out * h1; // Đạo hàm cho trọng số đầu ra 1
                float grad_out_w2 = delta_out * h2; // Đạo hàm cho trọng số đầu ra 2
                float grad_out_w3 = delta_out * h3; // Đạo hàm cho trọng số đầu ra 3
                float grad_out_b = delta_out;       // Đạo hàm cho độ thiên đầu ra

                // ===== Lan truyền ngược - Lớp ẩn =====
                // Tính toán cách mỗi nút ẩn đã góp phần vào lỗi
                float delta_h1 = delta_out * weight_out_node1 * sigmoid_derivative(h1);
                float delta_h2 = delta_out * weight_out_node2 * sigmoid_derivative(h2);
                float delta_h3 = delta_out * weight_out_node3 * sigmoid_derivative(h3);

                float grad_w1_node1 = delta_h1 * x1; // Đạo hàm cho w1 của nút 1
                float grad_w2_node1 = delta_h1 * x2; // Đạo hàm cho w2 của nút 1
                float grad_b_node1 = delta_h1;       // Đạo hàm cho độ thiên của nút 1

                float grad_w1_node2 = delta_h2 * x1;
                float grad_w2_node2 = delta_h2 * x2;
                float grad_b_node2 = delta_h2;

                float grad_w1_node3 = delta_h3 * x1;
                float grad_w2_node3 = delta_h3 * x2;
                float grad_b_node3 = delta_h3;

                // ===== Cập nhật Trọng số Lớp Đầu ra =====
                weight_out_node1 += learning_rate * grad_out_w1;
                weight_out_node2 += learning_rate * grad_out_w2;
                weight_out_node3 += learning_rate * grad_out_w3;
                bias_out += learning_rate * grad_out_b;

                // ===== Cập nhật Trọng số Lớp Ẩn =====
                weight1_node1 += learning_rate * grad_w1_node1;
                weight2_node1 += learning_rate * grad_w2_node1;
                bias_node1 += learning_rate * grad_b_node1;

                weight1_node2 += learning_rate * grad_w1_node2;
                weight2_node2 += learning_rate * grad_w2_node2;
                bias_node2 += learning_rate * grad_b_node2;

                weight1_node3 += learning_rate * grad_w1_node3;
                weight2_node3 += learning_rate * grad_w2_node3;
                bias_node3 += learning_rate * grad_b_node3;
            }
        }
        // Ghi lại mất mát mỗi 100 epochs
        if ((epoch + 1) % 100 == 0 || epoch == 0)
            cout << "[Tóm tắt] Epoch " << epoch + 1 << ": Mất mát = " << total_loss << endl;
    }

    // ===== Kiểm tra mô hình với đầu vào của người dùng =====
    float a, b;
    cout << "\nDự đoán tổng (a + b)\nNhập a: ";
    cin >> a;
    cout << "Nhập b: ";
    cin >> b;

    float x1 = a / 100.0f;
    float x2 = b / 100.0f;

    // Bước đi tới một lần nữa với trọng số đã được huấn luyện
    float h1 = sigmoid(x1 * weight1_node1 + x2 * weight2_node1 + bias_node1);
    float h2 = sigmoid(x1 * weight1_node2 + x2 * weight2_node2 + bias_node2);
    float h3 = sigmoid(x1 * weight1_node3 + x2 * weight2_node3 + bias_node3);
    float y_pred = h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out;
    float predicted_sum = y_pred * 200.0f;

    // Xuất kết quả
    cout << "Tổng dự đoán: " << predicted_sum << "\nTổng thực tế: " << (a + b) << endl;
    return 0;
}

Trong phần này, chúng ta sẽ phân tích cách mà một mạng nơ-ron học: từ việc thiết lập các lớp và hàm kích hoạt đến việc điều chỉnh trọng số thông qua lan truyền ngược. Bạn cũng sẽ học cách chúng tôi huấn luyện mô hình bằng cách sử dụng tốc độ học và epochs — và khám phá xem liệu một mạng có thể vượt qua các tác vụ đơn giản như cộng hay không.

  • Nút và lớp thực sự làm gì trong một mạng nơ-ron.
  • Cách chúng tôi thiết lập trọng số ban đầu và điều chỉnh chúng trong quá trình huấn luyện.
  • Vai trò của tốc độ học và epochs trong việc hình thành quá trình học.
  • Hàm kích hoạt là gì, cách chúng hoạt động và khi nào nên sử dụng sigmoid, tanh hoặc ReLU.
  • Cách mà lan truyền ngược và đạo hàm cho phép mạng học từ những sai lầm.
  • Liệu một mạng nơ-ron có thể vượt qua các tác vụ đơn giản như cộng và học nhiều điều hay không.

Sẵn sàng chưa? Hãy bắt đầu nào!

Nút và Lớp: Thực sự có chuyện gì đang diễn ra?

Một nơ-ron (hoặc nút) là khối xây dựng cơ bản của một mạng nơ-ron. Mỗi nơ-ron nhận các giá trị đầu vào, nhân chúng với các trọng số, cộng một độ thiên, và sau đó truyền kết quả qua một hàm kích hoạt để tạo ra một đầu ra. Nó giống như một quyết định nhỏ mà biến đổi dữ liệu từng bước một.

Một lớp là một tập hợp các nơ-ron hoạt động ở cùng một cấp độ của mạng. Thông tin chảy từ lớp này sang lớp khác. Ba loại lớp chính là:

  • Lớp đầu vào: nhận dữ liệu thô (ví dụ: các số bạn muốn cộng).
  • Các lớp ẩn: thực hiện các biến đổi trên đầu vào, học các mẫu thông qua các kết nối có trọng số.
  • Lớp đầu ra: tạo ra dự đoán hoặc kết quả cuối cùng.

Mỗi nơ-ron trong một lớp được kết nối với các nơ-ron trong lớp tiếp theo, và mỗi kết nối đó có một trọng số mà mạng học và điều chỉnh trong quá trình huấn luyện.

Hãy nghĩ về mỗi nút (nơ-ron) trong một mạng nơ-ron như một quyết định nhỏ. Nó nhận một số đầu vào, nhân chúng với "trọng số" của nó, cộng một "độ thiên", sau đó áp dụng một hàm kích hoạt (như sigmoid, tanh hoặc ReLU).

Nhiều nút = nhiều sức mạnh não:

  • Một số lượng nhỏ nút có thể nhanh chóng giải quyết các vấn đề đơn giản.
  • Nhiều nút có nghĩa là mạng của bạn có thể hiểu các mẫu phức tạp hơn, nhưng quá nhiều có thể làm chậm nó hoặc khiến nó suy nghĩ quá nhiều (quá khớp)!

Nếu chúng ta xếp chồng các lớp? Bạn hoàn toàn có thể xếp chồng nhiều lớp ẩn — đó là một "mạng nơ-ron sâu". Mỗi lớp học một điều gì đó trừu tượng hơn một chút:

  • Lớp đầu tiên học các mẫu đơn giản.
  • Lớp thứ hai kết hợp những mẫu đó thành những ý tưởng phức tạp hơn.
  • Các lớp thứ ba và xa hơn xây dựng sự hiểu biết ngày càng trừu tượng.

Trọng số và Độ thiên trong Mạng Nơ-ron

Trọng số trong một mạng nơ-ron là gì?

Trong một mạng nơ-ron, một trọng số là một giá trị đại diện cho sức mạnh của kết nối giữa hai nơ-ron. Khi dữ liệu đầu vào chảy qua mạng, nó được nhân với những trọng số này. Một trọng số cao hơn có nghĩa là đầu vào có ảnh hưởng mạnh hơn đến đầu ra của nơ-ron tiếp theo. Trong quá trình huấn luyện, những trọng số này được điều chỉnh để giúp mạng đưa ra dự đoán tốt hơn.

Độ thiên trong một mạng nơ-ron là gì?

Một độ thiên là một tham số bổ sung trong một mạng nơ-ron cho phép kích hoạt của một nơ-ron được dịch chuyển. Nó hoạt động như một độ lệch hoặc ngưỡng giúp mô hình phù hợp với dữ liệu tốt hơn bằng cách cho phép nó học các mẫu không đi qua gốc. Giống như trọng số, độ thiên cũng được điều chỉnh trong quá trình huấn luyện để cải thiện dự đoán.

Chúng tôi thiết lập trọng số khởi đầu như thế nào?

Khi chúng tôi bắt đầu huấn luyện, trọng số phải được khởi tạo với một số giá trị. Trong ví dụ mã của chúng tôi, chúng tôi chỉ cần đặt chúng thủ công ban đầu:

float weight1_node1 = 0.5f, weight2_node1 = 0.5f, bias_node1 = 0.0f;

Trong các hệ thống phức tạp hơn, trọng số thường được khởi tạo ngẫu nhiên bằng các giá trị nhỏ (như giữa -0.5 và 0.5). Sự ngẫu nhiên này giúp ngăn chặn đối xứng — nơi tất cả các nơ-ron học cùng một điều — và cho mạng một cơ hội tốt hơn để khám phá các mẫu hữu ích. Các chiến lược khởi tạo phổ biến bao gồm Xavier (Glorot) và He khởi tạo, được thiết kế để duy trì một tín hiệu ổn định khi nó chảy qua mạng. Đối với các thí nghiệm đơn giản như của chúng tôi, các giá trị thủ công hoặc ngẫu nhiên nhỏ là đủ tốt.

Chúng tôi điều chỉnh trọng số như thế nào?

Trong quá trình huấn luyện, đây là cách mà việc điều chỉnh diễn ra:

  1. Mạng đưa ra một dự đoán bằng cách sử dụng trọng số hiện tại.
  2. Nó so sánh dự đoán với câu trả lời đúng (đầu ra mục tiêu).
  3. Nó tính toán độ sai lệch — đây là lỗi hoặc "mất mát".
  4. Nó sử dụng lan truyền ngược để tính toán cách mà mỗi trọng số ảnh hưởng đến lỗi.
  5. Mỗi trọng số được điều chỉnh một chút để giảm lỗi cho lần tiếp theo — bước này được thực hiện bằng cách sử dụng gradient (đạo hàm) và tốc độ học.

Trong mã C++ ví dụ của chúng tôi, quá trình này diễn ra bên trong các vòng lặp huấn luyện lồng nhau. Đây là một tham khảo nhanh:

float error = target - y_pred;
float delta_out = error;
float grad_out_w1 = delta_out * h1;
float grad_out_w2 = delta_out * h2;
float grad_out_w3 = delta_out * h3;
float grad_out_b = delta_out;

float delta_h1 = delta_out * weight_out_node1 * sigmoid_derivative(h1);
float delta_h2 = delta_out * weight_out_node2 * sigmoid_derivative(h2);
float delta_h3 = delta_out * weight_out_node3 * sigmoid_derivative(h3);

float grad_w1_node1 = delta_h1 * x1;
float grad_w2_node1 = delta_h1 * x2;
float grad_b_node1 = delta_h1;

// Cập nhật trọng số
weight_out_node1 += learning_rate * grad_out_w1;
weight_out_node2 += learning_rate * grad_out_w2;
weight_out_node3 += learning_rate * grad_out_w3;
bias_out += learning_rate * grad_out_b;

weight1_node1 += learning_rate * grad_w1_node1;
weight2_node1 += learning_rate * grad_w2_node1;
bias_node1 += learning_rate * grad_b_node1;

Đoạn mã này cho thấy cách chúng tôi tính toán gradient và áp dụng chúng để cập nhật trọng số trong cả lớp đầu ra và lớp ẩn. Mỗi lần lặp lại sử dụng các gradient được tính toán từ đạo hàm của hàm kích hoạt và lỗi để điều chỉnh trọng số theo hướng giảm mất mát. Qua hàng ngàn lần lặp, các trọng số dần trở nên chính xác hơn.

Hàm Kích Hoạt: Đưa tính cách cho các nút của bạn

Hàm kích hoạt quyết định xem một nơ-ron có nên "bắn" hay không. Chúng được áp dụng cho kết quả của tổng có trọng số và độ thiên trong mỗi nút, đưa vào phi tuyến tính cho mạng — nếu không, mạng chỉ có thể học các mối quan hệ tuyến tính (không rất hữu ích cho các vấn đề phức tạp).

Dưới đây là một số hàm kích hoạt phổ biến:

1. Sigmoid

  • Công thức:   1 / (1 + e^(-x))
  • Phạm vi đầu ra: 0 đến 1
  • Trường hợp sử dụng: Tuyệt vời cho phân loại nhị phân hoặc các lớp đầu ra nơi bạn cần các giá trị giữa 0 và 1.
  • Ưu điểm: Đầu ra mượt mà, giới hạn.
  • Nhược điểm: Bão hòa ở các cực (gần 0 hoặc 1), làm chậm quá trình học do gradient biến mất.

2. Tanh

  • Công thức(e^x - e^(-x)) / (e^x + e^(-x))
  • Phạm vi đầu ra: -1 đến 1
  • Trường hợp sử dụng: Khi bạn muốn đầu ra trung tâm bằng 0.
  • Ưu điểm: Gradient mạnh hơn so với sigmoid; học nhanh hơn trong các lớp ẩn.
  • Nhược điểm: Vẫn bị ảnh hưởng bởi gradient biến mất ở các cực.

3. ReLU (Rectified Linear Unit)

  • Công thức:   max(0, x)
  • Phạm vi đầu ra: 0 đến vô cực.
  • Trường hợp sử dụng: Mặc định cho hầu hết các lớp ẩn trong học sâu.
  • Ưu điểm: Tính toán nhanh, giúp với kích hoạt thưa.
  • Nhược điểm: Có thể chết nếu đầu vào luôn âm ("vấn đề ReLU chết").

Khi nào nên sử dụng hàm kích hoạt nào?

Hàm kích hoạtPhạm vi đầu raTốt choLoại lớp phổ biếnGhi chú
Sigmoid0 đến 1Đầu ra nhị phânLớp đầu raCó thể gây ra gradient biến mất
Tanh-1 đến 1Các lớp ẩn trung tâm bằng 0Lớp ẩnGradient mạnh hơn so với sigmoid
ReLU0 đến ∞Hầu hết các mạng sâu và CNNLớp ẩnNhanh, hiệu quả, nhưng có thể "chết" trên các giá trị âm

Trong mã ví dụ của chúng tôi, chúng tôi đã sử dụng sigmoid cho các lớp ẩn. Nó hoạt động cho các bản demo nhỏ, nhưng trong các mạng lớn hơn, ReLU thường được ưu tiên để có hiệu suất tốt hơn và huấn luyện nhanh hơn.

Đạo hàm và Lan truyền ngược: Cách mà mạng học từ những sai lầm

Hãy tưởng tượng bạn bị bịt mắt, đứng trên một ngọn đồi, và cần tìm đường xuống. Bạn sẽ cảm nhận xung quanh bằng chân để cảm nhận hướng nào dốc xuống, rồi di chuyển theo hướng đó.

Đó là những gì mà các mạng nơ-ron làm bằng cách sử dụng đạo hàm — chúng kiểm tra những điều chỉnh nhỏ nào làm cho dự đoán chính xác hơn.

Sau khi đưa ra một dự đoán, mạng tính toán độ sai lệch của nó (chúng tôi gọi điều này là "mất mát"). Sau đó, nó sử dụng một cái gì đó gọi là lan truyền ngược để truy vết lỗi đó ngược qua mạng, tìm ra chính xác cách mà mỗi trọng số đã góp phần vào sai lầm.

Dưới đây là vòng lặp lan truyền ngược đơn giản hóa:

  1. Bước đi tới: Đoán câu trả lời.
  2. Tính toán Mất mát: Đoán đó sai bao xa?
  3. Bước lùi: Tìm hiểu cách mà mỗi trọng số ảnh hưởng đến lỗi đó.
  4. Cập nhật Trọng số: Điều chỉnh nhẹ các trọng số để giảm thiểu sai lầm trong tương lai.

Quá trình này lặp lại hàng ngàn lần, dần dần làm cho mạng trở nên thông minh hơn.

Hãy cùng xem một triển khai C++ đơn giản hóa của ý tưởng này:

float sigmoid(float x) {
    return 1.0f / (1.0f + exp(-x));
}

float sigmoid_derivative(float y) {
    return y * (1 - y); // y = sigmoid(x)
}

Đây là hàm kích hoạt của chúng tôi và đạo hàm của nó. Chúng được sử dụng trong cả bước đi tới và bước lùi.

Vòng lặp huấn luyện sử dụng hai đầu vào (a và b), chuẩn hóa chúng giữa 0 và 1, và huấn luyện mạng để dự đoán tổng của chúng. Chúng tôi sử dụng 3 nút ẩn và 1 nút đầu ra. Đây là một phần quan trọng của vòng lặp:

float error = target - y_pred;
total_loss += error * error;

float delta_out = error;
...
float delta_h1 = delta_out * weight_out_node1 * sigmoid_derivative(h1);

Đây là nơi chúng tôi tính toán lỗi, và sử dụng đạo hàm để đẩy các điều chỉnh trở lại qua mạng — lan truyền ngược đang hoạt động.

Tốc độ Học và Epochs

Như bạn có thể đã nhận thấy trong mã, chúng tôi sử dụng hai siêu tham số quan trọng để kiểm soát quá trình huấn luyện: tốc độ_học và epochs.

Tốc độ học là gì?

Tốc độ học xác định kích thước mỗi bước khi cập nhật trọng số. Một tốc độ học nhỏ (như 0.01) có nghĩa là mạng thực hiện các điều chỉnh nhỏ mỗi lần, điều này an toàn hơn nhưng chậm hơn. Một tốc độ học lớn có thể làm mọi thứ nhanh hơn, nhưng nếu quá lớn, mạng có thể vượt qua và không học đúng cách.

Trong mã của chúng tôi, chúng tôi sử dụng:

float learning_rate = 0.01f;

Điều này mang lại cho chúng tôi sự cân bằng giữa tốc độ và ổn định.

Epoch là gì?

Một epoch là một lượt hoàn chỉnh qua toàn bộ tập dữ liệu huấn luyện. Trong ví dụ của chúng tôi, cho mỗi epoch, chúng tôi huấn luyện mạng bằng mọi kết hợp của a và b từ 0 đến 99. Sau đó chúng tôi làm lại cho epoch tiếp theo.

Chúng tôi huấn luyện qua nhiều epochs để cho phép mạng tinh chỉnh trọng số của nó nhiều lần.

  • Nếu bạn tăng số lượng epochs, mạng sẽ có nhiều cơ hội hơn để cải thiện, có thể giảm thiểu mất mát hơn nữa. Tuy nhiên, quá nhiều epochs có thể dẫn đến quá khớp, nơi mô hình trở nên quá phù hợp với dữ liệu huấn luyện và hoạt động kém trên các đầu vào mới.
  • Nếu bạn giảm số lượng epochs, quá trình huấn luyện sẽ nhanh hơn, nhưng mạng có thể không học đủ và có thể hoạt động kém.

Trong mã, chúng tôi sử dụng:

int epochs = 1000;

Điều này thường là một khởi đầu tốt cho các vấn đề đơn giản như học cộng, nhưng bạn có thể điều chỉnh dựa trên tốc độ giảm mất mát.

Liệu một mạng nơ-ron có thể học nhiều điều không?

Hoàn toàn có thể. Các mạng nơ-ron không bị giới hạn trong việc học chỉ một loại tác vụ. Thực tế, cùng một kiến trúc mạng có thể được huấn luyện để thực hiện nhiều việc khác nhau — chẳng hạn như nhận diện chữ số viết tay, dịch ngôn ngữ, hoặc thậm chí tạo nhạc — tùy thuộc vào dữ liệu mà nó được cung cấp và cách nó được huấn luyện.

Trong ví dụ của chúng tôi, mạng đã học để cộng hai số, nhưng với dữ liệu huấn luyện khác và một lớp đầu ra được sửa đổi, nó cũng có thể học để:

  • Trừ các số
  • Phân loại hình ảnh
  • Dự đoán các giá trị tương lai trong chuỗi thời gian

Càng nhiều tác vụ bạn muốn mạng xử lý — hoặc càng phức tạp tác vụ — bạn có thể cần nhiều nơ-ron và lớp hơn. Một vấn đề đơn giản như cộng có thể chỉ cần một vài nút, trong khi các tác vụ như nhận diện hình ảnh hoặc dịch ngôn ngữ có thể yêu cầu các mạng sâu và rộng hơn. Chỉ cần nhớ: sự phức tạp hơn không phải lúc nào cũng tốt hơn — thêm quá nhiều nút có thể dẫn đến quá khớp, nơi mà mạng ghi nhớ thay vì học để tổng quát.

Đây là phiên bản nâng cao của mạng nơ-ron của chúng tôi nơi chúng tôi huấn luyện nó để cả cộng và trừ các số đầu vào bằng cách sử dụng một mạng chia sẻ duy nhất:


#include <iostream>
#include <cmath>
using namespace std;

// Hàm kích hoạt sigmoid (nén đầu vào vào phạm vi [0, 1])
float sigmoid(float x) {
    return 1.0f / (1.0f + exp(-x));
}

// Đạo hàm của sigmoid (sử dụng để tính toán gradient trong lan truyền ngược)
float sigmoid_derivative(float y) {
    return y * (1 - y); // y = sigmoid(x)
}

int main() {
    // === Trọng số và Độ thiên Lớp Ẩn (3 nơ-ron ẩn, 2 đầu vào mỗi nơ-ron) ===
    float w1_n1 = 0.5f, w2_n1 = 0.5f, b1 = 0.0f;
    float w1_n2 = -0.5f, w2_n2 = -0.5f, b2 = 0.0f;
    float w1_n3 = 0.25f, w2_n3 = -0.25f, b3 = 0.0f;

    // === Trọng số và Độ thiên Lớp Đầu ra (2 nơ-ron đầu ra: tổng và trừ) ===
    float out_w1_sum = 0.5f, out_w2_sum = 0.5f, out_w3_sum = 0.5f, out_b_sum = 0.0f;
    float out_w1_sub = -0.5f, out_w2_sub = -0.5f, out_w3_sub = 0.5f, out_b_sub = 0.0f;

    float learning_rate = 0.01f;
    int epochs = 1000;

    // === Vòng lặp Huấn luyện ===
    for (int epoch = 0; epoch < epochs; ++epoch) {
        float total_loss = 0.0f;

        for (int a = 0; a < 100; ++a) {
            for (int b = 0; b < 100; ++b) {
                // === Chuẩn hóa các giá trị đầu vào ===
                float x1 = a / 100.0f;
                float x2 = b / 100.0f;

                // === Chuẩn hóa các mục tiêu ===
                float target_sum = (a + b) / 200.0f;          // Tổng nằm trong khoảng từ 0 đến 198 → [0, ~1]
                float target_sub = (a - b + 100.0f) / 200.0f; // Trừ nằm trong khoảng từ -99 đến +99 → [0, ~1]

                // === Bước đi tới (Đầu vào → Lớp Ẩn) ===
                float z1 = x1 * w1_n1 + x2 * w2_n1 + b1;
                float h1 = sigmoid(z1);

                float z2 = x1 * w1_n2 + x2 * w2_n2 + b2;
                float h2 = sigmoid(z2);

                float z3 = x1 * w1_n3 + x2 * w2_n3 + b3;
                float h3 = sigmoid(z3);

                // === Bước đi tới (Lớp Ẩn → Lớp Đầu ra) ===
                float y_pred_sum = h1 * out_w1_sum + h2 * out_w2_sum + h3 * out_w3_sum + out_b_sum;
                float y_pred_sub = h1 * out_w1_sub + h2 * out_w2_sub + h3 * out_w3_sub + out_b_sub;

                // === Tính toán Mất mát (Lỗi Bình phương) ===
                float error_sum = target_sum - y_pred_sum;
                float error_sub = target_sub - y_pred_sub;
                total_loss += error_sum * error_sum + error_sub * error_sub;

                // === Lan truyền ngược - Lớp Đầu ra (Gradient cho mỗi nơ-ron đầu ra) ===
                float delta_out_sum = error_sum;
                float grad_out_w1_sum = delta_out_sum * h1;
                float grad_out_w2_sum = delta_out_sum * h2;
                float grad_out_w3_sum = delta_out_sum * h3;
                float grad_out_b_sum = delta_out_sum;

                float delta_out_sub = error_sub;
                float grad_out_w1_sub = delta_out_sub * h1;
                float grad_out_w2_sub = delta_out_sub * h2;
                float grad_out_w3_sub = delta_out_sub * h3;
                float grad_out_b_sub = delta_out_sub;

                // === Lan truyền ngược - Lớp Ẩn ===
                float delta_h1 = (delta_out_sum * out_w1_sum + delta_out_sub * out_w1_sub) * sigmoid_derivative(h1);
                float delta_h2 = (delta_out_sum * out_w2_sum + delta_out_sub * out_w2_sub) * sigmoid_derivative(h2);
                float delta_h3 = (delta_out_sum * out_w3_sum + delta_out_sub * out_w3_sub) * sigmoid_derivative(h3);

                float grad_w1_n1 = delta_h1 * x1;
                float grad_w2_n1 = delta_h1 * x2;
                float grad_b1 = delta_h1;

                float grad_w1_n2 = delta_h2 * x1;
                float grad_w2_n2 = delta_h2 * x2;
                float grad_b2 = delta_h2;

                float grad_w1_n3 = delta_h3 * x1;
                float grad_w2_n3 = delta_h3 * x2;
                float grad_b3 = delta_h3;

                // === Cập nhật Trọng số Đầu ra ===
                out_w1_sum += learning_rate * grad_out_w1_sum;
                out_w2_sum += learning_rate * grad_out_w2_sum;
                out_w3_sum += learning_rate * grad_out_w3_sum;
                out_b_sum += learning_rate * grad_out_b_sum;

                out_w1_sub += learning_rate * grad_out_w1_sub;
                out_w2_sub += learning_rate * grad_out_w2_sub;
                out_w3_sub += learning_rate * grad_out_w3_sub;
                out_b_sub += learning_rate * grad_out_b_sub;

                // === Cập nhật Trọng số Lớp Ẩn ===
                w1_n1 += learning_rate * grad_w1_n1;
                w2_n1 += learning_rate * grad_w2_n1;
                b1 += learning_rate * grad_b1;

                w1_n2 += learning_rate * grad_w1_n2;
                w2_n2 += learning_rate * grad_w2_n2;
                b2 += learning_rate * grad_b2;

                w1_n3 += learning_rate * grad_w1_n3;
                w2_n3 += learning_rate * grad_w2_n3;
                b3 += learning_rate * grad_b3;
            }
        }

        // === In Mất mát Mỗi 100 Epoch ===
        if ((epoch + 1) % 100 == 0 || epoch == 0)
            cout << "[Epoch " << epoch + 1 << "] Mất mát: " << total_loss << endl;
    }

    // === Giai đoạn Kiểm tra ===
    float a, b;
    cout << "\nDự đoán tổng\nNhập a: ";
    cin >> a;
    cout << "Nhập b: ";
    cin >> b;

    float x1 = a / 100.0f;
    float x2 = b / 100.0f;

    // Bước đi tới một lần nữa cho đầu vào của người dùng
    float h1 = sigmoid(x1 * w1_n1 + x2 * w2_n1 + b1);
    float h2 = sigmoid(x1 * w1_n2 + x2 * w2_n2 + b2);
    float h3 = sigmoid(x1 * w1_n3 + x2 * w2_n3 + b3);

    float y_sum = h1 * out_w1_sum + h2 * out_w2_sum + h3 * out_w3_sum + out_b_sum;
    float y_sub = h1 * out_w1_sub + h2 * out_w2_sub + h3 * out_w3_sub + out_b_sub;

    // Đảo ngược đầu ra
    float predicted_sum = y_sum * 200.0f;
    float predicted_sub = y_sub * 200.0f - 100.0f;

    cout << "Tổng dự đoán:        " << predicted_sum << endl;
    cout << "Tổng thực tế:           " << (a + b) << endl;
    cout << "Hiệu dự đoán: " << predicted_sub << endl;
    cout << "Hiệu thực tế:    " << (a - b) << endl;

    return 0;
}

Kết thúc

Đó là rất nhiều thông tin — nhưng thật tuyệt phải không? Chúng ta không chỉ nói lý thuyết; chúng ta đã xem một mạng nơ-ron học cách làm một điều gì đó thực tế: cộng và trừ các số. Không có quy tắc nào được mã hóa cứng — chỉ có trọng số tự điều chỉnh thông qua quá trình huấn luyện. Trên đường đi, chúng ta đã tìm hiểu về tốc độ học, hàm kích hoạt, và phép màu lan truyền ngược kết nối tất cả lại với nhau.

Trong phần tiếp theo, chúng ta sẽ lùi lại một bước từ mã và khám phá câu chuyện đứng sau tất cả điều này. Mạng nơ-ron bắt nguồn từ đâu? Tại sao AI lại bùng nổ chỉ trong vài năm qua? Điều gì đã thay đổi? Chúng ta sẽ khám phá cách mà tất cả bắt đầu — và tại sao nó chỉ mới bắt đầu.