Chào mừng bạn quay trở lại với lộ trình Kỹ Sư AI Thực Chiến. Ở Bài 1, chúng ta đã bắt nhịp thành công sang môi trường lập trình Python. Tuy nhiên, trước khi có thể huấn luyện những mô hình học sâu phức tạp như Multi-Layer Perceptron (MLP), CNN hay Transformer, chúng ta bắt buộc phải hiểu được ngôn ngữ giao tiếp chung của thế giới AI: Đại số Tuyến tính (Linear Algebra)Giải tích (Calculus).

Đối với lập trình viên Web chưa có chuyên môn về toán cao cấp, các công thức toán học học thuật trên trường lớp thường rất khô khan và khó hình dung. Bài học này được thiết kế để thay đổi hoàn toàn điều đó: chúng ta sẽ đi từ bản chất hình học trực quan nhất, sau đó tự tay hiện thực hóa các phép nhân ma trận và vòng lặp tính đạo hàm bằng mã nguồn Python thô để hiểu cặn kẽ cách máy tính "học" dữ liệu.

✅ Bạn cần gì trước khi bắt đầu — và không cần gì
Cần: Python 3.9 trở lên (đã cài ở Bài 1), và biết đọc một vòng lặp for cùng một hàm trong Python. Hết. Mọi đoạn code ở đây chạy bằng Python thuần, không cài thêm thư viện — trừ đúng một đoạn ở mục 2.2 cần NumPy, và đoạn đó bỏ qua được mà không ảnh hưởng gì.

Không cần: bất kỳ kiến thức toán đại học nào. Không cần từng học giải tích, không cần nhớ công thức đạo hàm. Ba khái niệm khó nhất của bài — tuyến tính, đạo hàm, hàm Loss — đều được định nghĩa lại từ đầu ngay tại chỗ chúng xuất hiện.

Còn MLP, CNN, Transformer vừa nhắc ở trên chỉ là tên của những kiến trúc mô hình bạn sẽ gặp từ Bài 5 trở đi. Ở bài này chúng chưa cần thiết — nêu ra chỉ để bạn biết mình đang xây nền cho cái gì.

Một lời khuyên về cách đọc: các hộp có nhãn 🔬 hoặc 🧠 là phần đào sâu, bỏ qua hoàn toàn được ở lượt đọc đầu mà vẫn hiểu và làm được dự án cuối bài.

2.1 Bản chất hình học của Vector & Ma trận

Hãy bắt đầu bằng cách số hóa thế giới thực. Trong khoa học máy tính, mọi loại dữ liệu đầu vào (một bức ảnh, một file âm thanh, một bài viết) đều được biểu diễn dưới dạng mảng số. Trong toán học, ta gọi các mảng số đó là VectorMa trận (Matrix).

  • Vector: Là một mảng số một chiều. Dưới lăng kính lập trình, một vector 3 chiều chỉ đơn giản là một list có 3 phần tử: v = [x_1, x_2, x_3]. Về mặt hình học, một vector đại diện cho một mũi tên chỉ từ gốc tọa độ $O(0, 0, 0)$ đến điểm có tọa độ tương ứng trong không gian.
  • Ma trận: Là một mảng số hai chiều (dạng bảng gồm dòng và cột). Ví dụ, một ma trận kích thước $2 \times 3$ (2 dòng, 3 cột) được biểu diễn trong code Python là một danh sách chứa các danh sách con: A = [[1, 2, 3], [4, 5, 6]].

Số lượng phần tử trong một vector quyết định số chiều (dimensionality) của không gian mà nó sống trong đó. Vector [3, 1] nằm trong mặt phẳng hai chiều nên ta vẽ được nó ra giấy; một vector 768 chiều — kích thước thật của một embedding ở Bài 8 — thì không, nhưng mọi phép toán bên dưới vẫn y nguyên. Đó là lý do đáng bỏ công hiểu thật kỹ trường hợp hai chiều: nó là trường hợp duy nhất bạn nhìn thấy được, và tất cả những trường hợp còn lại chỉ khác nó ở con số.

Bản chất hình học cốt lõi của một ma trận là gì? Đừng chỉ coi ma trận là những ô lưu trữ số tĩnh giống như bảng Excel. Trong học máy, ma trận đại diện cho một phép biến đổi không gian tuyến tính (Linear Transformation). Khi ta nhân một ma trận $A$ với một vector đầu vào $x$, ta đang xoay, kéo dãn hoặc nén vector đó trong không gian để tạo ra một vector đầu ra mới $y = Ax$:

$$y = Ax = \begin{bmatrix} a_{11} & a_{12} \\ a_{21} & a_{22} \end{bmatrix} \begin{bmatrix} x_1 \\ x_2 \end{bmatrix}$$
📐 "Tuyến tính" nghĩa là gì? — chữ xuất hiện nhiều nhất trong bài này
Từ tuyến tính (linear) có mặt ở tên môn học, tên phép biến đổi và tên cả bài học này, nên cần chốt nó lại ngay.

Một phép biến đổi là tuyến tính khi nó giữ đúng hai lời hứa: gấp đôi đầu vào thì đầu ra cũng gấp đôi, và biến đổi tổng hai vector cho ra đúng tổng hai kết quả riêng lẻ. Viết gọn: $A(2x) = 2(Ax)$ và $A(u + v) = Au + Av$.

Hình dung theo lối đời thường: tuyến tính là phép biến đổi không bẻ cong. Nó kéo dãn, bóp lại, xoay hoặc lật cả mặt phẳng, nhưng đường thẳng vẫn là đường thẳng, và gốc toạ độ vẫn nằm ở gốc toạ độ.

Ví dụ CÓ tuyến tính: "quy đổi mọi giá sang một loại tiền khác" — nhân tất cả với một tỉ giá. Hàng gấp ba lần tiền thì kết quả cũng gấp ba lần, và tổng hoá đơn tính theo cách nào cũng như nhau.

Ví dụ KHÔNG tuyến tính: "thuế thu nhập theo bậc". Thu nhập gấp đôi thì thuế hơn gấp đôi, vì phần trên bị đánh bậc cao hơn. Lời hứa thứ nhất vỡ ngay.

Điều này dẫn tới một hệ quả quan trọng, và nó là lý do tồn tại của Bài 5: xếp nhiều phép biến đổi tuyến tính lên nhau vẫn chỉ ra một phép tuyến tính. Nghĩa là một mạng nơ-ron toàn ma trận, dù có trăm tầng, cũng không mạnh hơn một tầng. Muốn học được những mối quan hệ bị bẻ cong như thuế bậc thang, mạng phải chèn thêm một hàm phi tuyến giữa các tầng — đó chính là hàm kích hoạt (activation) bạn sẽ gặp ở Bài 5.

Dễ thấy nhất là xem một phép biến đổi cụ thể làm gì với một vector cụ thể. Lấy $A = \begin{bmatrix} 2 & 0 \\ 0 & 0.5 \end{bmatrix}$ và $x = [2, 2]$ — ma trận này kéo dãn trục ngang gấp đôi và bóp trục dọc còn một nửa:

Trước: x = [2, 2] 2 4 2 1 nhan A Sau: y = Ax = [4, 1] 2 4 2 1 keo ngang, bop doc

Mũi tên vàng nét liền bên trái là $x$; mũi tên xanh bên phải là $y$; mũi tên vàng nét đứt bên phải là vị trí cũ của $x$ để bạn so sánh. Ma trận không hề "chứa" dữ liệu nào — nó làm một việc: đẩy mọi vector trong mặt phẳng sang chỗ mới. Nhân với vector khác thì vector đó cũng bị kéo ngang bóp dọc đúng theo tỉ lệ ấy.

Đây chính là nguyên lý của một tầng nơ-ron nhân tạo: nhận vector đầu vào $x$, đẩy nó qua một phép biến đổi để những thứ ban đầu chồng chéo lên nhau trở nên tách bạch, dễ phân loại hơn. Trong ngữ cảnh mạng nơ-ron, ma trận biến đổi đó thường được ký hiệu là $W$ và gọi là trọng số (weights) — cùng một thứ với $A$ ở trên, chỉ khác tên gọi. Điểm cốt tử: các con số trong $W$ không do con người đặt ra, mà do máy tự tìm lấy. Toàn bộ nửa sau của bài học này nói về cách nó tìm.

Tích vô hướng (dot product) — phép toán bạn cần trước khi làm gì khác

Trước khi sang phép nhân ma trận, phải nắm một phép toán nhỏ hơn nhiều. Nắm nó rồi thì phép nhân ma trận không còn gì phải học thuộc, vì nó chỉ là phép toán này lặp lại nhiều lần.

Tích vô hướng của hai vector là: nhân từng cặp phần tử cùng vị trí, rồi cộng tất cả lại. Kết quả là một con số duy nhất — không phải một vector. Đó là lý do nó tên là "vô hướng" (scalar): đầu vào là hai mũi tên có hướng, đầu ra là một con số trơ.

$$A \cdot B = \sum_{i=1}^n a_i b_i$$

Hình dung theo lối đời thường: bạn có giỏ hàng [2, 1, 3] — 2 chiếc bút, 1 quyển vở, 3 cái bìa — và bảng giá [5, 20, 8]. Tổng tiền là $2 \times 5 + 1 \times 20 + 3 \times 8 = 54$. Bạn vừa tính một tích vô hướng. Nó có mặt ở khắp nơi trong AI vì nó chính là phép "cân nhắc nhiều yếu tố rồi chốt một con số": mỗi đầu vào nhân với mức quan trọng của nó, rồi cộng dồn.

Hai ví dụ để thấy đúng ranh giới của nó.

  • Dùng được: hai người cùng đánh giá 3 bộ phim theo thang điểm, an = [5, 1, 4]binh = [4, 2, 5]. Tích vô hướng bằng 42 — cao, vì cả hai đều cho điểm lớn ở cùng những phim. So với chi = [1, 5, 1] thì tích vô hướng với An chỉ còn 14. Con số ấy chính là một thước đo "hai người này giống nhau tới đâu", và đó đúng là cách các hệ thống gợi ý hoạt động.
  • Không dùng được: [1, 2][1, 2, 3]. Không có cặp nào ghép với số 3, nên phép toán không tồn tại — chứ không phải bằng 0. Hai vector bắt buộc phải cùng số chiều. Hãy nhớ điều kiện này: nó chính là gốc rễ của lỗi ghép chiều mà bạn sẽ gặp ở mục sau, và của lỗi phổ biến nhất khi mới viết code AI.

Viết ra bằng Python thuần thì nó ngắn đúng như mô tả — một dòng cho cả phép toán:

dot_product.py
def dot(a, b):
    # Two vectors must have the same length, or there is nothing to pair up.
    if len(a) != len(b):
        raise ValueError(f"length mismatch: {len(a)} vs {len(b)}")
    # zip() walks both lists in step, giving (a[0], b[0]), (a[1], b[1]), ...
    return sum(x * y for x, y in zip(a, b))

basket, price = [2, 1, 3], [5, 20, 8]
print("total bill:", dot(basket, price))          # 54

an, binh, chi = [5, 1, 4], [4, 2, 5], [1, 5, 1]
print("an . binh =", dot(an, binh))               # 42 -> similar taste
print("an . chi  =", dot(an, chi))                # 14 -> different taste

print(dot([1, 2], [1, 2, 3]))                     # ValueError: length mismatch: 2 vs 3

Dòng cuối cố tình cho nổ lỗi. Chạy nó một lần đi — thấy được thông báo lỗi khi hai vector lệch chiều lúc bạn đang chủ động gây ra nó sẽ dễ chịu hơn nhiều so với lúc gặp lại nó trong một mô hình 20 tầng.

🧠 Góc nhìn hình học: vì sao tích vô hướng đo được độ tương đồng
Ngoài định nghĩa đại số ở trên, tích vô hướng còn có một định nghĩa hình học tương đương: $$A \cdot B = \|A\| \|B\| \cos(\theta)$$ Trong đó $\|A\|$ là độ dài của vector, còn $\theta$ là góc giữa hai vector.

Công thức này giải thích vì sao ví dụ đánh giá phim ở trên lại chạy được. Vì độ dài luôn dương, dấu và độ lớn của tích vô hướng do $\cos(\theta)$ quyết định — tức là do hướng:
  • Hai vector cùng hướng ($\theta = 0^\circ$): $\cos(0) = 1$, tích vô hướng đạt giá trị lớn nhất.
  • Hai vector vuông góc ($\theta = 90^\circ$): $\cos(90^\circ) = 0$, tích vô hướng bằng 0 — hai vector hoàn toàn không liên quan.
  • Hai vector ngược hướng ($\theta = 180^\circ$): tích vô hướng âm, càng ngược càng âm sâu.
Có một hệ quả thực tế cần biết trước: vì công thức có $\|A\| \|B\|$, một vector dài sẽ cho tích vô hướng lớn dù hướng chẳng giống mấy. Muốn so sánh riêng hướng mà bỏ qua độ dài, người ta chia cho hai độ dài đó, được thước đo Cosine Similarity — thứ bạn sẽ dùng để tìm văn bản gần nghĩa ở Bài 8 và Bài 15.

Đây cũng là nền móng của cơ chế Attention (Chú ý) trong các mô hình Transformer như GPT hay BERT (Bài 10): để biết một từ trong câu liên quan mật thiết tới từ nào khác, mô hình tính tích vô hướng giữa các vector biểu diễn của chúng, đúng phép toán một dòng bạn vừa viết.

2.2 Giải mã phép nhân ma trận (Matrix Multiplication)

Có tích vô hướng trong tay rồi, phép nhân ma trận không còn là quy tắc phải học thuộc nữa. Nó chỉ là: lấy tích vô hướng của mỗi dòng bên trái với mỗi cột bên phải. Ô ở dòng $i$ cột $j$ của kết quả chính là tích vô hướng của dòng $i$ ma trận trái và cột $j$ ma trận phải — không hơn.

Và vì tích vô hướng đòi hai vector cùng số chiều, điều kiện ghép chiều của phép nhân ma trận suy ra được ngay, không cần nhớ: số cột của ma trận bên trái bắt buộc phải bằng số dòng của ma trận bên phải — vì một dòng bên trái có bao nhiêu phần tử thì một cột bên phải phải có đúng bấy nhiêu để ghép cặp.

Nếu ma trận $A$ có kích thước $m \times n$ và ma trận $B$ có kích thước $n \times p$, ma trận kết quả $C = A \cdot B$ sẽ có kích thước $m \times p$. Mỗi phần tử $C_{ij}$ được tính bằng công thức: $$C_{ij} = \sum_{k=1}^n A_{ik} B_{kj}$$

Nhìn kỹ dấu tổng đó: nó chính là công thức tích vô hướng bạn vừa viết ở trên, chỉ đổi tên biến. Nếu công thức này trông đáng ngại, hãy đọc nó thành một câu: "ô này bằng tích vô hướng của dòng $i$ với cột $j$".

Viết ra bằng Python thuần thì cần ba vòng lặp for lồng nhau: một chạy qua các dòng, một chạy qua các cột, và một — vòng trong cùng — chính là phép tích vô hướng đang được thực hiện tay:

matrix_multiply.py
def naive_matrix_multiply(A, B):
    m, n = len(A), len(A[0])      # rows and columns of the left matrix
    n_B, p = len(B), len(B[0])    # rows and columns of the right matrix

    # Columns of A must match rows of B, or the row/column pairs cannot be zipped.
    if n != n_B:
        raise ValueError(f"cannot multiply: A is {m}x{n} but B is {n_B}x{p}")

    # Start with a result matrix full of zeros, then accumulate into it.
    C = [[0.0 for _ in range(p)] for _ in range(m)]

    for i in range(m):                          # walk the rows of A
        for j in range(p):                      # walk the columns of B
            for k in range(n):                  # ← this innermost loop IS the dot product
                C[i][j] += A[i][k] * B[k][j]
    return C

A = [[1, 2], [3, 4]]    # 2x2
B = [[5, 6], [7, 8]]    # 2x2
print("naive matrix product:", naive_matrix_multiply(A, B))
# Output: [[19.0, 22.0], [43.0, 50.0]]

Kiểm lại ô đầu tiên bằng tay để tin vào đoạn code: dòng 1 của $A$ là [1, 2], cột 1 của $B$ là [5, 7], tích vô hướng bằng $1 \times 5 + 2 \times 7 = 19$ — đúng bằng số ở góc trên bên trái. Ba vòng lặp đó không làm gì khác ngoài lặp lại phép tính này cho cả 4 ô.

Một chi tiết nhỏ dễ làm bạn bối rối khi chạy thử: kết quả in ra là 19.0 chứ không phải 19, dù đầu vào toàn số nguyên. Nguyên nhân nằm ở dòng khởi tạo 0.0 — cộng số nguyên vào một số thực thì Python trả về số thực. Đổi thành 0 là ra số nguyên. Không sai gì cả, nhưng đây là kiểu chênh lệch khiến người mới tưởng mình gõ hỏng code, nên biết trước thì đỡ mất thời gian.

🧠 Tại sao NumPy nhân ma trận nhanh hơn vòng lặp Python hàng trăm lần?
Thuật toán 3 vòng lặp lồng nhau ở trên có độ phức tạp thời gian là $\mathcal{O}(N^3)$. Khi xử lý các ma trận lớn trong Deep Learning (ví dụ kích thước $1000 \times 1000$), CPU sẽ phải thực hiện $10^9$ phép toán. Chạy bằng vòng lặp Python thô sẽ cực kỳ chậm vì Python là ngôn ngữ động, tốn thời gian kiểm tra kiểu dữ liệu của từng phần tử ở mỗi bước lặp.

Các thư viện tính toán khoa học như NumPy hay PyTorch không sử dụng vòng lặp Python. Chúng đẩy tính toán xuống các thư viện mã máy C/Fortran được tối ưu cực hạn (như BLAS - Basic Linear Algebra Subprograms, LAPACK). Các thư viện này sử dụng thuật toán tối ưu hóa bộ nhớ đệm Cache Locality (chia ma trận lớn thành các khối nhỏ vừa với cache L1/L2 của CPU để tránh trễ truy xuất RAM) và thuật toán phân rã phức tạp như Strassen ($\mathcal{O}(N^{2.807})$) để tăng tốc độ tính toán gấp hàng trăm, hàng ngàn lần.
⚠️ Cạm bẫy: dấu * không phải phép nhân ma trận
Trong NumPy và PyTorch, hai dấu trông na ná nhau làm hai việc hoàn toàn khác:
  • A * Bnhân từng phần tử (element-wise) — lấy từng ô nhân với ô cùng toạ độ. Hai ma trận phải cùng kích thước, và kết quả cũng cùng kích thước đó.
  • A @ B mới là phép nhân ma trận dòng-nhân-cột chuẩn toán học — thứ vừa viết tay ở trên.
Vì sao cạm bẫy này đắt: với hai ma trận vuông cùng kích thước, cả hai phép đều chạy trót lọt và trả về ma trận cùng shape. Không có lỗi nào để bạn lần theo — chỉ có một mô hình học mãi không khá lên.

Lưu ý cho lúc này: @ chỉ hoạt động với mảng NumPy hoặc tensor PyTorch. Gõ nó với list Python thuần thì nhận TypeError: unsupported operand type(s) for @: 'list' and 'list' — đó là lý do mục trên phải viết tay ba vòng lặp thay vì gõ A @ B.

Trên thực tế, ghép chiều sai là lỗi phổ biến bậc nhất khi mới viết code AI, và NumPy báo lỗi bằng một thông báo khá rối. Biết cách đọc thông báo đó có giá trị hơn nhiều so với học thuộc quy tắc, nên ta hãy cố tình gây ra nó một lần.

📦 Riêng đoạn dưới đây cần cài NumPy
Mọi đoạn code khác trong bài này chạy bằng Python thuần, không cần cài gì. Đoạn này là ngoại lệ duy nhất, vì thông báo lỗi ta muốn xem là do NumPy sinh ra. Cài trong môi trường ảo đã tạo ở Bài 1: pip install numpy.

Nếu bạn muốn để dành, bỏ qua hoàn toàn đoạn này cũng được — NumPy là nội dung chính của Bài 3, ở đây nó chỉ đóng vai người sinh ra thông báo lỗi. Nhưng đọc qua thì đáng, vì bạn sẽ gặp lại đúng thông báo này rất nhiều lần.
shape_mismatch_debug.py
import numpy as np

A = np.array([[1, 2, 3], [4, 5, 6]])   # shape (2, 3)
B = np.array([[1, 2, 3], [4, 5, 6]])   # shape (2, 3) — same shape as A, which does NOT fit

# Columns of A = 3, rows of B = 2. 3 != 2, so the row/column pairs cannot be zipped.
try:
    C = A @ B
except ValueError as e:
    print("error:", e)

# The fix used most often: .T transposes B, turning (2,3) into (3,2).
C = A @ B.T   # (2,3) @ (3,2) -> (2,2), valid
print("after transposing B:\n", C)

# Best habit of all: print both shapes BEFORE the line that fails.
print(A.shape, B.shape)   # (2, 3) (2, 3)

Thông báo lỗi in ra nguyên văn như sau (NumPy 2.4) — dài, nhưng chỉ có ba chỗ cần đọc:

Terminal
error: matmul: Input operand 1 has a mismatch in its core dimension 0,
with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 2 is different from 3)

Giải mã ba chỗ đó:

  • "operand 1" — NumPy đếm từ 0, nên operand 0 là $A$ và operand 1 là $B$. Thủ phạm được nêu tên là ma trận bên phải.
  • "core dimension 0" — chiều thứ 0 của $B$, tức là số dòng của nó.
  • "size 2 is different from 3" — $B$ có 2 dòng, nhưng cần 3 để khớp với 3 cột của $A$. Đúng quy tắc "cột trái = dòng phải".

Phần gufunc signature (n?,k),(k,m?)->(n?,m?) ở giữa chính là quy tắc ghép chiều được viết dưới dạng ký hiệu: chữ k xuất hiện ở cả hai vế đầu vào, nghĩa là cột của cái thứ nhất và dòng của cái thứ hai buộc phải là cùng một con số. Đọc được dòng này thì bạn không cần lời giải thích nào nữa.

Mẹo thực chiến rút gọn tất cả những điều trên: mỗi lần gặp ValueError về matmul, in .shape của cả hai mảng ở dòng ngay trước dòng gây lỗi. Gần như lần nào bạn cũng thấy ngay chiều nào đang lệch, nhanh hơn đọc thông báo.

2.3 Đạo hàm (Derivatives) — La bàn chỉ hướng giảm sai số

Đến đây ta đã có công cụ để biểu diễn một mạng nơ-ron: ma trận và phép nhân ma trận. Nhưng như đã nói ở mục 2.1, các con số trong ma trận trọng số $W$ không do con người đặt — máy phải tự tìm. Nửa còn lại của bài học trả lời câu hỏi đó, và công cụ là đạo hàm.

Về mặt hình học, đạo hàm của hàm số $f(x)$ tại một điểm là độ dốc (hệ số góc) của đường tiếp tuyến với đồ thị tại điểm đó. Nó cho biết hàm số đang tăng hay giảm, và nhanh tới mức nào, nếu ta dịch $x$ đi một lượng cực nhỏ.

Hình dung theo lối đời thường: bạn đứng trên một sườn đồi trong sương mù dày. Bạn không thấy thung lũng ở đâu, nhưng bạn cảm nhận được mặt đất dưới chân đang nghiêng về phía nào và dốc bao nhiêu. Đạo hàm chính là cảm giác đó, viết thành một con số. Và đó cũng đúng là toàn bộ thông tin mà thuật toán huấn luyện có — nó không bao giờ thấy được đáp án, chỉ cảm được độ nghiêng tại chỗ mình đang đứng.

Hai ví dụ để thấy con số đó nói gì. Một: nếu $f$ là vị trí của xe theo thời gian thì đạo hàm của nó là vận tốc — đạo hàm lớn nghĩa là đang đi nhanh. Hai: ở đúng đáy thung lũng, mặt đất phẳng, đạo hàm bằng 0. Số 0 đó là tín hiệu "đã tới đích, dừng lại" — và bạn sẽ thấy thuật toán ở mục 2.4 tự dừng đúng theo tín hiệu này.

Công thức giới hạn vi phân lý thuyết của đạo hàm:

$$f'(x) = \lim_{h \to 0} \frac{f(x + h) - f(x)}{h}$$

Đọc công thức này bằng lời cho dễ vào: nhích $x$ sang phải một đoạn $h$ rất nhỏ, xem $f$ thay đổi bao nhiêu, rồi chia cho $h$ — tức là "thay đổi theo chiều dọc chia cho thay đổi theo chiều ngang", đúng nghĩa độ dốc. Ký hiệu $\lim_{h \to 0}$ chỉ nói thêm rằng $h$ phải nhỏ vô cùng.

Máy tính không tính được giới hạn vô cùng nhỏ, nhưng nó không cần: chỉ cần chọn $h$ đủ nhỏ rồi tính đúng biểu thức đó. Cách làm này gọi là sai phân hữu hạn (finite difference), và $h$ thường lấy $10^{-5}$:

derivative.py
def f(x):
    return x**2

def numerical_derivative(func, x, h=1e-5):
    # The limit formula, written out literally. No calculus rules needed.
    return (func(x + h) - func(x)) / h

slope = numerical_derivative(f, 3)
print(f"approximate derivative at x=3: {slope:.5f}")
# approximate derivative at x=3: 6.00001
# By hand, the exact derivative of x**2 is 2x, so at x=3 the true answer is 6.

Kết quả in ra là 6.00001, không phải 6. Phần lệch 0.00001 đó không phải bug — nó chính là $h$ còn sót lại, vì ta dùng một đoạn nhỏ hữu hạn thay cho "nhỏ vô cùng". Đây là điểm đáng ghi nhớ: cách tính đạo hàm này luôn sai một chút, và nếu chọn $h$ quá nhỏ để giảm sai số thì lại gặp vấn đề ngược — hai số gần nhau trừ nhau làm mất chữ số có nghĩa của số thực. Đó là lý do các framework thật không dùng cách này mà dùng autograd, cho đạo hàm chính xác tuyệt đối; bạn sẽ gặp nó ở Bài 4. Ở bài này ta chấp nhận sai số nhỏ để thấy rõ nguyên lý bằng code trần.

🎯 "Hàm Loss" là gì? — thứ mà toàn bộ việc huấn luyện nhắm vào
Từ này sắp xuất hiện liên tục, ở đây và ở mọi bài còn lại của series, nên phải chốt trước.

Hàm Loss (hàm mất mát, hay hàm sai số) là một hàm nhận vào các trọng số của mô hình và trả về đúng một con số: mô hình đang sai bao nhiêu. Càng thấp càng tốt; 0 là hoàn hảo. Nó ngược với điểm thi — hãy coi nó như số câu làm sai: mục tiêu là kéo về 0.

Vì sao phải nén tất cả về một con số? Vì đạo hàm chỉ có nghĩa với một con số. Không gộp được "sai" thành một đại lượng duy nhất thì không có độ dốc nào để đi theo, và không có gì để tối ưu.

Ví dụ dùng được: dự đoán giá nhà. Mô hình đoán 3 căn là 2.0 / 3.0 / 5.0 tỉ, giá thật là 2.2 / 2.5 / 5.1. Lấy bình phương từng chênh lệch rồi tính trung bình: $((-0.2)^2 + 0.5^2 + (-0.1)^2)/3 = 0.1$. Đó là một hàm Loss hoàn chỉnh, tên gọi MSE (Mean Squared Error). Sửa trọng số cho khớp hơn thì con số này giảm — mượt và liên tục, nên có đạo hàm để đi theo.

Ví dụ KHÔNG dùng được: "tỉ lệ đoán đúng 90%". Nghe hợp lý nhưng không dùng làm Loss để huấn luyện được, vì nó nhảy bậc: sửa trọng số một lượng bé xíu thì số câu đoán đúng vẫn nguyên như thế, tỉ lệ không đổi, đạo hàm bằng 0 — la bàn chỉ vào khoảng không. Phải đến khi một dự đoán lật hẳn sang phía bên kia thì nó mới nhảy một bậc. Đây chính là lý do các mô hình phân loại được huấn luyện bằng Cross-Entropy (mượt) rồi mới đo bằng accuracy. Phân biệt cho rõ: hàm để huấn luyện phải mượt, còn thước đo để báo cáo cho người thì không cần.

Trong bài này, hàm $f(x) = x^2 - 4x + 4$ ở mục 2.4 đóng vai hàm Loss, và $x$ đóng vai trọng số duy nhất. Mô hình thật có hàng triệu trọng số, nhưng cơ chế thì đúng y như thế.

Với một hàm Loss thật, phụ thuộc vào hàng triệu trọng số $W$ chứ không phải một biến $x$, ta lấy đạo hàm theo từng trọng số một, coi tất cả những cái còn lại như hằng số. Đạo hàm kiểu đó gọi là đạo hàm riêng (partial derivative), ký hiệu $\frac{\partial f}{\partial w_i}$. Ý nghĩa thực tế của nó rất cụ thể: "nếu chỉ nhích riêng trọng số này, sai số thay đổi thế nào?"

Tập hợp tất cả các đạo hàm riêng này thành một vector được gọi là Gradient Vector, ký hiệu là $\nabla f$ (đọc là nabla f): $$\nabla f = \left[ \frac{\partial f}{\partial w_1}, \frac{\partial f}{\partial w_2}, \dots, \frac{\partial f}{\partial w_n} \right]$$

Về mặt hình học, Gradient luôn chỉ hướng dốc lên nhanh nhất của hàm số tại điểm đó. Do đó, để tìm cực tiểu của hàm sai số (đáy thung lũng), chúng ta phải đi theo hướng ngược lại với chiều của vector Gradient, tức là hướng $-\nabla f$. Đây chính là triết lý cốt lõi của thuật toán tối ưu hóa huyền thoại Gradient Descent (Cực tiểu hóa độ dốc).

2.4 Dự án thực hành bài 2: Tự viết thuật toán Gradient Descent

Giờ ghép tất cả lại thành một thuật toán chạy được. Ta sẽ tự viết Gradient Descent để tìm cực tiểu của hàm: $$f(x) = x^2 - 4x + 4$$ Hàm này viết lại được thành $f(x) = (x-2)^2$, nên ta đã biết đáp án: cực tiểu bằng 0, tại $x = 2$.

Biết trước đáp án chính là chủ đích. Thuật toán sẽ không được cho biết con số đó — nó chỉ được dùng đạo hàm, đúng như cảm giác chân trong sương mù ở mục trước. Có đáp án trong tay, ta kiểm được nó có thật sự đi tới đích không, thay vì phải tin.

🥣 "Hàm lồi" — vì sao ta chọn một hàm dễ để bắt đầu
$f(x) = (x-2)^2$ là một hàm lồi (convex): đồ thị của nó có hình cái bát, chỉ có một đáy duy nhất. Đặc điểm này quan trọng vì nó cho một bảo đảm mạnh — cứ đi xuống dốc, từ bất kỳ điểm xuất phát nào, là chắc chắn tới đúng đáy đó. Không có chỗ nào để mắc kẹt.

Hàm Loss của mạng nơ-ron thật thì không lồi: nó giống một dãy núi lởm chởm với vô số hố lớn nhỏ. Đi xuống dốc có thể rơi vào một hố cạn (cực tiểu địa phương) rồi dừng ở đó, dù nơi khác còn sâu hơn nhiều. Nghịch lý dễ chịu là trong thực tế điều đó thường không tệ như nghe: với mô hình rất nhiều chiều, phần lớn các hố cạn ấy đủ tốt để dùng.

Nói cách khác: thuật toán bạn viết dưới đây đúng là thuật toán mà mô hình thật dùng. Chỉ có địa hình là khác.

Ta cho điểm xuất phát ở thật xa, $x_{\text{init}} = 10.0$, rồi bắt vòng lặp tự "bò" về theo công thức cập nhật: $$x_{\text{new}} = x_{\text{old}} - \eta \cdot f'(x_{\text{old}})$$ Trong đó $\eta$ (đọc là "eta") là hệ số học (learning rate), quyết định mỗi bước chân dài bao nhiêu.

Điều đáng dừng lại một giây là dấu trừ. Đạo hàm chỉ hướng dốc lên; ta muốn đi xuống, nên phải trừ đi. Đó là toàn bộ nội dung của chữ "descent" trong tên thuật toán, và nó cũng là chỗ dễ nhầm nhất khi mới học — bảng lần tay ở phía dưới sẽ cho thấy dấu trừ này tự động chạy đúng cả khi $x$ ở bên trái đáy.

Xem đường đi của nó trên đồ thị trước khi đọc code — bốn bước đầu, với $\eta = 0.1$:

x f(x) = (x-2)² dich: x = 2 x=10 8.4 7.12 6.096 … 62 buoc nua tiep tuyen tai x=10: doc len

Chú ý các bước đầu dài, rồi ngắn dần khi tới gần đáy. Không ai lập trình cho nó chậm lại: gần đáy thì độ dốc nhỏ, mà bước chân tỉ lệ với độ dốc, nên nó tự phanh. Đây là một tính chất rất đẹp của thuật toán — đi nhanh ở nơi còn xa, đi cẩn thận ở nơi đã gần.

Còn hai từ trong đoạn code sắp tới cần biết trước. Epoch là một vòng lặp cập nhật — ở bài này là một bước chân xuống dốc (với dữ liệu thật, một epoch nghĩa là mô hình đã học qua trọn bộ dữ liệu một lượt). Siêu tham số (hyperparameter) là những giá trị ta chọn và thuật toán không bao giờ tự sửa — như learning_rate hay epochs. Phân biệt cho rõ với tham số (parameter), tức các trọng số mà thuật toán tự tìm; ở đây tham số duy nhất là x. Chọn siêu tham số là việc của bạn, tìm tham số là việc của máy.

gradient_descent.py
def f(x):
    """The loss function we are minimising."""
    return x**2 - 4 * x + 4

def numerical_derivative(func, x, h=1e-5):
    """Approximate the derivative with a finite difference."""
    return (func(x + h) - func(x)) / h

# Hyperparameters — values WE choose, which the algorithm never changes.
x = 10.0             # starting guess, deliberately far from the answer
learning_rate = 0.1  # how big a step to take each iteration
epochs = 100         # maximum number of steps
tolerance = 1e-6     # stop once x barely moves any more

for epoch in range(1, epochs + 1):
    grad = numerical_derivative(f, x)

    # Step AGAINST the slope. This one line does all the learning.
    x_new = x - learning_rate * grad

    # Converged: x stopped moving, so more iterations would change nothing.
    if abs(x_new - x) < tolerance:
        print(f"Converged early at iteration {epoch}. x = {x_new:.6f}")
        x = x_new
        break

    x = x_new
    print(f"Iteration {epoch:02d}: x = {x:.6f} | loss = {f(x):.6f}")

print(f"\nResult: x = {x:.6f} | minimum value: f(x) = {f(x):.6f}")

Chạy nó, bạn sẽ thấy đúng như thế này (đã lược bỏ phần giữa):

Terminal
Iteration 01: x = 8.399999 | loss = 40.959987
Iteration 02: x = 7.119998 | loss = 26.214382
Iteration 03: x = 6.095998 | loss = 16.777196
...
Iteration 65: x = 1.999999 | loss = 0.000000
Converged early at iteration 66. x = 1.999998

Result: x = 1.999998 | minimum value: f(x) = 0.000000

Ba điều cần đọc ra từ kết quả này. Một: thuật toán tới được $x = 1.999998$ — sai so với đáp án thật đúng $2$ khoảng hai phần triệu, và nó chưa từng được cho biết con số 2. Hai: nó dừng ở vòng 66 chứ không chạy hết 100, vì nhánh tolerance đã bắt được lúc $x$ gần như không nhích nữa. Ba: con số epochs = 100 không phải chọn bừa — với $\eta = 0.1$ thì đúng vòng 66 mới đủ nhỏ để dừng; nếu đặt epochs = 50 thì vòng lặp hết trước khi hội tụ và bạn sẽ không bao giờ thấy dòng "Converged early" đó.

💡 Hệ số học: con số quan trọng nhất, và cả bốn kiểu sai
Hệ số học $\eta$ quyết định độ dài mỗi bước chân. Các con số dưới đây lấy từ việc chạy thật đoạn code trên với hàm $f(x) = (x-2)^2$ và điểm xuất phát $x = 10$ — bạn tự đổi một dòng là kiểm lại được hết:
  • $\eta = 0.1$ (như trong bài): hội tụ ở vòng 66, tới $x = 1.999998$. Ổn.
  • $\eta = 0.5$: hội tụ ở vòng 2. Nhanh hơn hẳn — nên đừng nghĩ "nhỏ là an toàn, lớn là nguy hiểm". Với hàm này, 0.5 mới là cỡ bước hợp nhất.
  • $\eta = 0.0001$ (quá nhỏ): phải tới vòng 36.887 mới dừng, và — đây là chỗ bất ngờ — nó dừng ở $x = 2.004993$, tức còn lệch 0.005 so với đáp án. Bước chân quá ngắn khiến điều kiện "$x$ gần như không nhích" thoả mãn trước khi thật sự tới đáy. Thuật toán báo hội tụ, mà chưa tới đích. Kiểu sai này khó phát hiện hơn nhiều so với phân kỳ.
  • $\eta = 1.0$ (đúng ngưỡng tới hạn): $x$ nhảy qua nhảy lại giữa $10$ và $-6$, mãi mãi. Không hội tụ, cũng không nổ. Loss không hề giảm.
  • $\eta = 1.5$ (quá lớn): phân kỳ. $x$ đi $10 \to -14 \to 34 \to -62 \to 130$, mỗi bước vọt qua đáy và xa hơn lần trước; tới vòng 40 thì $x$ đã cỡ $2.4 \times 10^{11}$. Trong huấn luyện thật, đây là lúc bạn thấy loss thành NaN.
Rút ra: hệ số học không có giá trị "đúng" phổ quát, và cả hai đầu đều có cái giá của nó. Đây là siêu tham số bạn sẽ dò nhiều nhất trong suốt phần còn lại của series.

Cuối cùng, để thấy thật rõ quan hệ giữa dấu của đạo hàm và hướng $x$ dịch chuyển — điều dễ nhầm nhất khi mới học — hãy lần tay ba vòng lặp đầu với $x_{\text{init}} = 10$, $\eta = 0.1$:

Vòng lặp $x$ hiện tại $f'(x) = 2x - 4$ Dấu đạo hàm $x_{\text{new}} = x - \eta \cdot f'(x)$
1 10.0 16 Dương (+) → đồ thị đang đi LÊN 10 − 0.1×16 = 8.4 (giảm)
2 8.4 12.8 Dương (+) → vẫn đang đi LÊN 8.4 − 0.1×12.8 = 7.12 (giảm)
3 7.12 10.24 Dương (+) → vẫn đang đi LÊN 7.12 − 0.1×10.24 = 6.096 (giảm)

Quy tắc bất biến xuyên suốt: đạo hàm dương → $x$ phải GIẢM để đi xuống dốc (vì trừ đi 1 số dương); ngược lại nếu đạo hàm ÂM (đang ở phía bên trái điểm cực tiểu $x=2$), phép trừ số âm sẽ làm $x$ TĂNG lên — thuật toán luôn tự động "bò" đúng hướng về điểm cực tiểu $x=2$ mà không cần biết trước đáp án, chỉ dựa vào dấu của đạo hàm tại từng bước.

Một chi tiết nhỏ để bạn không hoang mang khi đối chiếu: bảng trên dùng đạo hàm chính xác $f'(x) = 2x - 4$, nên nó cho $16$ và $8.4$ tròn trịa. Đoạn code thì dùng sai phân hữu hạn nên in ra $16.000010$ và $8.399999$. Chênh lệch đó chính là $h$ đã nói ở mục 2.3 — bảng tính bằng toán, code tính bằng phép đo. Cả hai đều đúng, chỉ khác độ chính xác.

Cách chạy dự án này trên máy bạn

  1. Tải file gradient_descent.py ở cuối bài, hoặc tự gõ lại đoạn code trên vào một file mới.
  2. Mở terminal tại thư mục chứa file. Không cần môi trường ảo, không cần cài gì — script này chỉ dùng Python thuần.
  3. Chạy: python3 gradient_descent.py (trên Windows là python gradient_descent.py).
  4. Đối chiếu dòng cuối với kết quả ở trên. Sau đó làm phần đáng giá nhất: sửa learning_rate thành 0.5, rồi 1.0, rồi 1.5, chạy lại mỗi lần và xem thuật toán lần lượt hội tụ nhanh, dao động, rồi nổ. Đọc về bốn kiểu hành vi ấy thì dễ quên; thấy chúng hiện ra trên terminal của mình thì khó quên hơn nhiều.

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

🔑 Bài học đạt được:
  • Đạt được: hiểu vector là mũi tên, ma trận là một phép biến đổi không gian chứ không phải bảng số, và "tuyến tính" nghĩa là không bẻ cong.
  • Đạt được: tích vô hướng — và nhờ nó, phép nhân ma trận cùng điều kiện ghép chiều trở thành thứ suy ra được, không phải học thuộc.
  • Đạt được: đạo hàm là độ dốc cảm được dưới chân, hàm Loss là con số duy nhất đo mức sai, và Gradient Descent là vòng lặp ghép hai thứ đó lại để máy tự tìm trọng số.
  • Đạt được: đọc được thông báo lỗi ghép chiều của NumPy, và biết hệ số học sai thì hỏng theo bốn kiểu khác nhau — trong đó kiểu êm ái nhất (quá nhỏ) lại khó phát hiện nhất.

Cầu nối bài tiếp theo: Để tăng hiệu năng tính toán ma trận lên hàng triệu phép tính mỗi giây mà không bị nghẽn CPU, chúng ta cần học cách song song hóa dữ liệu với hai thư viện cốt lõi NumPy và Pandas ở Bài số 3.

Tải file code thực hành minh họa bài học

File Python gradient_descent.py — mã nguồn thực hành chạy vòng lặp tìm cực tiểu của hàm số bằng Gradient Descent thô tự tính đạo hàm (chạy python gradient_descent.py, không cần cài đặt thêm thư viện ngoài):

Tải về gradient_descent.py

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 1: Chuyển dịch tư duy lập trình: Từ JS sang Python cho AI Bài 3: Làm việc với Dữ liệu lớn: NumPy & Pandas chuyên sâu Quay lại Lộ trình Kỹ Sư AI Thực Chiến

Bình luận