Toán học đằng sau AI: Bắt đầu đơn giản

Bạn thường viết một chương trình để cộng hai số dưới 100 như thế nào? Dễ dàng, đúng không? Bạn thường code đơn giản kiểu:

#include <iostream>
using namespace std;

int main() {
    int a, b, sum;

    cout << "Nhập số đầu tiên: ";
    cin >> a;

    cout << "Nhập số thứ hai: ";
    cin >> b;

    sum = a + b;

    cout << "Tổng là: " << sum << endl;

    return 0;
}

Nhưng thay vì chỉ giải bài toán một cách thông thường, hãy thử một hướng thú vị hơn: viết một chương trình giúp máy tính tự học cách cộng hai số bằng mạng nơ-ron. Mạng nơ-ron là nền tảng cốt lõi của trí tuệ nhân tạo hiện đại, đứng sau những công nghệ như nhận diện hình ảnh, dịch ngôn ngữ và cả các hệ thống tiên tiến như ChatGPT.

Trong bài viết này, chúng ta sẽ khám phá cách một mạng nơ-ron có thể “học” cách cộng hai số không phải bằng cách tuân theo một quy tắc cố định, mà thông qua quá trình huấn luyện. Bạn sẽ thấy cách mạng nơ-ron nhận diện các mẫu để thực hiện phép cộng. Thậm chí, chúng ta sẽ cùng nhau tìm hiểu những con số thực tế – chính là “ma thuật đằng sau” – giúp điều đó trở thành hiện thực.

Dưới đây là một cái nhìn nhanh về cách cấu trúc bên trong của mạng nơ-ron trông như thế nào sau khi được huấn luyện:

// Trọng số (weight) và độ lệch (bias) của lớp ẩn
double weight1_node1 = 0.658136;
double weight2_node1 = 0.840666;
double bias_node1 = -0.893218;

double weight1_node2 = -0.720667;
double weight2_node2 = -0.369172;
double bias_node2 = 0.036762;

double weight1_node3 = 0.512252;
double weight2_node3 = 0.292342;
double bias_node3 = -0.0745917;

// Trọng số (weight) và độ lệch (bias) của lớp đầu ra
double weight_out_node1 = 1.93108;
double weight_out_node2 = -0.718584;
double weight_out_node3 = 0.589741;
double bias_out = -0.467899;

Nhìn thoáng qua, những giá trị này có thể trông ngẫu nhiên, nhưng chúng đại diện cho một bộ não nhân tạo nhỏ có khả năng học cách cộng hai số.

Chúng ta sẽ sử dụng công thức sau để tính đầu ra. Đừng lo nếu bạn thấy nó hơi phức tạp lúc này—cứ tiếp tục theo dõi là sẽ hiểu:

Ở đây, x1 và x2 là hai số bạn muốn cộng (được chuẩn hóa bằng cách chia cho 100):

double x1 = 0.3; // 30 / 100
double x2 = 0.5; // 50 / 100

// Lớp ẩn
double z1 = x1 * weight1_node1 + x2 * weight2_node1 + bias_node1;
double h1 = sigmoid(z1);

double z2 = x1 * weight1_node2 + x2 * weight2_node2 + bias_node2;
double h2 = sigmoid(z2);
double z3 = x1 * weight1_node3 + x2 * weight2_node3 + bias_node3;
double h3 = sigmoid(z3);

// Lớp đầu ra
double sum = (h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out) * 200.0f;

Hãy cùng phân tích lý do tại sao chúng ta nhân với 200: chúng ta đã chuẩn hóa các đầu vào và đầu ra để nằm trong khoảng từ 0 đến 1 cho việc huấn luyện dễ dàng hơn. Vì tổng lớn nhất của hai số dưới 100 là 200, chúng ta sẽ đưa đầu ra trở lại bằng cách nhân với 200.

Hàm sigmoid:

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

Hãy thử với một số thực tế:

Chúng ta sẽ sử dụng x1 = 30 và x2 = 50. Đầu tiên, chúng ta chuẩn hóa chúng:

x1 = 30 / 100 = 0.3
x2 = 50 / 100 = 0.5

Bây giờ chúng ta thực hiện các phép toán:

z1 = (0.3 × 0.658136) + (0.5 × 0.840666) - 0.893218
   ≈ 0.1974408 + 0.420333 - 0.893218
   ≈ -0.2754442
h1 = sigmoid(z1) ≈ 0.431844

z2 = (0.3 × -0.720667) + (0.5 × -0.369172) + 0.036762
   ≈ -0.2162001 - 0.184586 + 0.036762
   ≈ -0.3640241
h2 = sigmoid(z2) ≈ 0.409872

z3 = (0.3 × 0.512252) + (0.5 × 0.292342) - 0.0745917
   ≈ 0.1536756 + 0.146171 - 0.0745917
   ≈ 0.2252549
h3 = sigmoid(z3) ≈ 0.556078

// Lớp đầu ra cuối cùng
output = (h1 × 1.93108) + (h2 × -0.718584) + (h3 × 0.589741) - 0.467899
       ≈ (0.834389) + (-0.294320) + (0.328013) - 0.467899
       ≈ 0.400183

Tổng dự đoán = 0.400183 × 200 ≈ 80.0366
// Tại sao nhân với 200? Bởi vì trong quá trình huấn luyện, chúng ta đã chuẩn hóa tất cả các đầu vào và đầu ra để giữ trong khoảng từ 0 đến 1 cho sự ổn định học tập tốt hơn. Vì tổng lớn nhất của hai đầu vào là 100 + 100 = 200, chúng ta đưa đầu ra của mạng trở lại bằng cách nhân với 200.

Bùm! Kết quả là khoảng 80, đó là tổng đúng của 30 và 50.

Nếu bạn không tin rằng mạng nhỏ này thực sự có thể cộng, hãy thử nghiệm lại với các số khác: 20 và 30.

Chúng ta sẽ chuẩn hóa các đầu vào trước:

x1 = 20 / 100 = 0.2
x2 = 30 / 100 = 0.3

Sau đó là các phép tính:

z1 = (0.2 × 0.658136) + (0.3 × 0.840666) - 0.893218
   ≈ 0.1316272 + 0.2521998 - 0.893218
   ≈ -0.509391
h1 = sigmoid(z1) ≈ 0.375241

z2 = (0.2 × -0.720667) + (0.3 × -0.369172) + 0.036762
   ≈ -0.1441334 - 0.1107516 + 0.036762
   ≈ -0.218123
h2 = sigmoid(z2) ≈ 0.445714

z3 = (0.2 × 0.512252) + (0.3 × 0.292342) - 0.0745917
   ≈ 0.1024504 + 0.0877026 - 0.0745917
   ≈ 0.1155613
h3 = sigmoid(z3) ≈ 0.528860

// Lớp đầu ra cuối cùng
output = (h1 × 1.93108) + (h2 × -0.718584) + (h3 × 0.589741) - 0.467899
       ≈ (0.724688) + (-0.320095) + (0.311644) - 0.467899
       ≈ 0.248338

Tổng dự đoán = 0.248338 × 200 ≈ 49.6676

Nó dự đoán khoảng 49.67. Gần sát với tổng thực tế: 50!

Thật tuyệt, phải không?

Vậy... Mạng nơ-ron thực sự là gì?

Một mạng nơ-ron giống như một phiên bản toán học của bộ não bạn. Nó bao gồm các đơn vị gọi là nơ-ron xử lý đầu vào bằng cách nhân chúng với trọng số (weight), cộng một độ lệch (bias), và sau đó áp dụng một hàm kích hoạt (activation). Một hàm kích hoạt phổ biến là sigmoid:

sigmoid(x) = 1 / (1 + e^(-x))

Điều này nén bất kỳ đầu vào nào thành một giá trị giữa 0 và 1, cho phép mạng nắm bắt các mẫu phức tạp.

Đây là đồ thị của hàm sigmoid, cho thấy rõ cách mà các đầu vào được chuyển đổi thành các giá trị giữa 0 và 1:

Cũng có những hàm kích hoạt khác, như ReLU (Rectified Linear Unit), tanh, và softmax, mỗi hàm có các trường hợp sử dụng và tính chất riêng. Nhưng sigmoid là một hàm tuyệt vời để bắt đầu vì nó đơn giản —hoàn hảo cho các mạng nơ-ron nhỏ như của chúng ta.

Cài đặt: Dạy mạng của chúng ta cộng

Đối với thí nghiệm này, chúng tôi đã tạo ra một mạng nhỏ với:

  • 2 nút đầu vào (cho hai số cần cộng)
  • 3 nút ẩn (thực hiện tính toán phức tạp bằng hàm sigmoid)
  • 1 nút đầu ra (cho chúng ta tổng)

Dưới đây là sơ đồ cho thấy cấu trúc của mạng nơ-ron mà chúng tôi đang sử dụng để cộng hai số. Bạn có thể thấy cách mà mỗi nút đầu vào kết nối với tất cả các nút lớp ẩn, cách mà trọng số (weight) và độ lệch (bias) được áp dụng, và cách mọi thứ đi về đầu ra cuối cùng:

Và đúng vậy, chúng tôi đã chuẩn hóa mọi thứ. Vì các số của chúng tôi dưới 100, chúng tôi chia các đầu vào cho 100 và đưa đầu ra trở lại bằng cách nhân với 200 ở cuối.

Đây là mã mà chúng tôi viết để cộng hai số bằng cách sử dụng mạng nơ-ron:

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

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

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

int main() {
    float weight1_node1 = 0.5f, weight2_node1 = 0.5f, bias_node1 = 0.0f;
    float weight1_node2 = -0.5f, weight2_node2 = -0.5f, bias_node2 = 0.0f;
    float weight1_node3 = 0.25f, weight2_node3 = -0.25f, bias_node3 = 0.0f;

    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;

    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 mục tiêu về khoảng 0-1

                // Phép tính tiến cho lớp ẩn
                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);

                // Lớp đầu ra với kích hoạt tuyến tính
                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;

                // Phép tính lùi (lớp đầu ra - tuyến tính)
                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;

                // Phép tính lùi (lớp ẩn)
                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;

                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ố
                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;

                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;
            }
        }

        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
    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;

    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;

    cout << "w1_node1: " << weight1_node1 << ", w2_node1: " << weight2_node1
                             << ", w1_node2: " << weight1_node2 << ", w2_node2: " << weight2_node2
                             << ", w1_node3: " << weight1_node3 << ", w2_node3: " << weight2_node3
                             << ", out_w1: " << weight_out_node1 << ", out_w2: " << weight_out_node2
                             << ", out_w3: " << weight_out_node3 << ", bias_out: " << bias_out
                             << ", bias_node1: " << bias_node1 << ", bias_node2: " << bias_node2 << ", bias_node3: " << bias_node3 << endl;

    cout << "Tổng dự đoán: " << predicted_sum << "\nTổng thực tế: " << (a + b) << endl;

    return 0;
}

Vậy... Nó đã học như thế nào?

Đây mới là phần thú vị: chúng ta không hề lập trình sẵn quy tắc cộng. Thay vào đó, chúng ta chỉ đưa cho mạng một loạt các cặp số và kết quả tổng của chúng. Sau đó, mạng sẽ tự điều chỉnh các trọng số và độ lệch qua từng lần học, để dần dần đưa ra dự đoán chính xác hơn.

Nó giống như quá trình thử và sai, nhưng thông minh hơn — nhờ một chút hỗ trợ từ phép tính vi phân.

Nó thực sự đã học cách cộng chưa?

À, không hẳn giống như cách con người chúng ta cộng. Mạng nơ-ron không thực sự “hiểu” cộng nghĩa là gì. Nhưng nó đã học được cách ước lượng tổng một cách rất tốt. Nó phát hiện ra các mẫu trong các cặp số và đưa ra dự đoán thường khá chính xác.

Nó giống như đang học qua cảm nhận các mẫu hơn là tuân theo quy tắc cụ thể. Nếu bạn thấy hứng thú, hãy thử thay đổi các số đầu vào và quan sát kết quả. Càng thử nghiệm, bạn sẽ càng cảm nhận được cách mà “bộ não nhỏ” này tư duy.

Tổng kết

Việc dạy một mạng nơ-ron cộng hai số thoạt nghe có vẻ ngớ ngẩn, nhưng thực ra lại là cách tuyệt vời để khám phá bên trong “tâm trí” của trí tuệ nhân tạo. Đằng sau tất cả những ồn ào về AI, mọi thứ chỉ xoay quanh các trọng số (weight), độ lệch (bias) và một chút về toán học.

Ở phần tiếp theo, chúng ta sẽ đi sâu hơn vào cách mạng nơ-ron thực sự hoạt động: tìm hiểu về các lớp (layers), hàm kích hoạt (activation functions), đạo hàm (derivatives), và những gì thực sự diễn ra trong quá trình huấn luyện mạng.