Giáo trình giải thuật Deep Learning · cấp độ "dưới đáy"

Lò Luyện Mạng Nơ-ron Từ Số 0

Không có phép màu, không có hộp đen. Chúng ta đi từ một phép nhân và một phép cộng, dựng dần lên autograd, MLP, BatchNorm, WaveNet, rồi tới một GPT hoàn chỉnh — mỗi bước đều có phòng thí nghiệm mô phỏng thuật toán để bạn tự kéo núm, tự phá cho nó hỏng, rồi hiểu tại sao nó hỏng.

12 module nối liền mạch, không nhảy bậc 13 phòng thí nghiệm tương tác (JS thuần, không cài gì) 3 khu 3D: mặt loss, không gian embedding, khối tensor Tinh thần Andrej Karpathy — Zero to Hero

Đã học 0/12 module

Đọc trang này thế nào cho có lãi

Trước khi lao vào công thức, hãy nắm đầu đuôi câu chuyện. Toàn bộ giáo trình chỉ nhằm trả lời một câu hỏi, được hỏi lại mãi ở các mức độ phức tạp tăng dần:

Cho một đống dữ liệu, làm sao tự động tìm ra hàng triệu con số (tham số) để một công thức toán đơn giản dự đoán được dữ liệu đó — và làm sao phóng công thức ấy lên tới quy mô ChatGPT?

Câu trả lời luôn gồm đúng 4 mảnh ghép, lặp lại ở mọi module. Khi bị lạc ở bất kỳ đâu, hãy tự hỏi: mình đang ở mảnh nào?

Toàn bộ Deep Learning = 4 chiếc hộp này, lồng nhau ở quy mô lớn dần ① DỮ LIỆU (x, y) — đầu vào & đáp án ② MÔ HÌNH ŷ = f(x; W) — có tham số ③ HÀM MẤT MÁT L = sai lệch(ŷ, y) → 1 số ④ TỐI ƯU HOÁ W ← W − η·∂L/∂W Vòng lặp huấn luyện: sửa tham số rồi làm lại — hàng vạn tới hàng triệu lần
Bản đồ chủ đạo của cả giáo trình. Module 00–02 dạy trọn 4 hộp ở quy mô một nơ-ron. Module 03–07 thay hộp ② bằng mạng sâu và làm hộp ④ chạy tự động. Module 08–11 phóng hộp ② lên thành Transformer.

Phần 0 · Module 00–02

Nền tảng tuyệt đối

  • Học từ dữ liệu, vòng lặp huấn luyện
  • Nơ-ron, trọng số, bias, hàm kích hoạt
  • Loss, đạo hàm, gradient descent, learning rate

Làm được: huấn luyện một bộ phân loại 2 chiều, giải XOR, và biết vì sao learning rate sai thì mô hình nổ tung.

Phần I · Module 03–04

Autograd & mô hình ngôn ngữ

  • Đồ thị tính toán, chain rule, topological sort
  • Tự viết engine autograd ~60 dòng
  • Bigram: bảng đếm ⟷ mạng nơ-ron tuyến tính

Làm được: một engine đạo hàm tự động, và mô hình sinh tên tiếng Việt đầu tiên.

Phần II · Module 05–07

Mạng sâu, huấn luyện ổn định

  • Embedding, MLP Bengio, mini-batch
  • Khởi tạo Kaiming, bão hoà, BatchNorm
  • Backprop ma trận bằng tay, kiểm tra số

Làm được: chẩn đoán một mạng "không chịu học" chỉ bằng histogram, và tự đạo hàm mọi lớp.

Phần III–IV · Module 08–11

Transformer & thực chiến

  • Phân tầng kiểu WaveNet, reshape tensor
  • Self-attention, causal mask, nanoGPT
  • BPE tokenizer, sampling, scaling, fine-tune

Làm được: đọc hiểu mã nguồn nanoGPT/LLM, biết núm nào ảnh hưởng chất lượng và chi phí.

Quy tắc học của lò này

  • Đừng đọc trôi. Mỗi module có một hộp PHÒNG THÍ NGHIỆM — hãy kéo hết các núm cho tới khi bạn đoán trước được kết quả rồi mới đọc tiếp.
  • Cố ý làm hỏng. Đặt learning rate = 5, đặt gain khởi tạo = 0.1, tắt BatchNorm. Hiểu một thuật toán nghĩa là biết nó chết như thế nào.
  • Gõ lại code, đừng copy. Code Python trong trang đều tự chứa và chạy được ngay; đoạn nào cần PyTorch/NumPy đều ghi rõ ở dòng đầu.
  • Mỗi khái niệm có 3 lớp: trực giác đời thường → công thức toán → code. Nếu tắc ở lớp toán, tra từ điển ký hiệu rồi quay lại.
  • Ôn theo nhịp 1–7: học xong thì đóng đáp án và tự kể lại 3 ý chính; hôm sau kể lại lần nữa không nhìn bài; sau 7 ngày làm lại các câu từng sai. Nút “Đánh dấu đã học” ở mỗi module sẽ lưu tiến độ ngay trên máy này.

★ Bảng cứu nguy: giải mã ký hiệu toán sang ngôn ngữ lập trình

Nếu bạn từng thấy sợ khi nhìn bài báo AI đầy ký tự Hy Lạp, bảng này dành cho bạn. Mọi ký hiệu trong Deep Learning thực chất chỉ là vài hàm lập trình rất quen thuộc. Đánh dấu trang này lại, bạn sẽ quay về đây nhiều lần.

Ký hiệu toánTên gọiDịch sang code PythonTrực giác đời thường
$\frac{\partial L}{\partial w}$ Đạo hàm riêng (gradient) w.grad Chiếc núm vặn độ nhạy: "nếu ta nhích núm $w$ sang phải $+0.001$ thì sai số $L$ tăng hay giảm bao nhiêu?"
$\nabla_W L$ Gradient (véc-tơ đạo hàm) W.grad (cùng shape với W) Cả bảng núm cùng lúc: mỗi ô cho biết núm tương ứng ảnh hưởng lên $L$ ra sao. Luôn cùng kích thước với chính tham số.
$\eta$ (eta), $\alpha$ Learning rate (tốc độ học) lr = 0.01 Độ dài mỗi bước chân khi đi xuống dốc. Quá nhỏ: đi cả ngày không tới. Quá lớn: nhảy vọt qua thung lũng rồi văng ra ngoài.
$\sum_{i=1}^n x_i$ Sigma (tổng) sum(x) Vòng lặp for item in x: total += item
$\prod_{i=1}^n x_i$ Pi (tích) math.prod(x) Vòng lặp nhân dồn for item in x: total *= item
$A \odot B$ Tích Hadamard A * B Nhân từng phần tử tương ứng (element-wise). KHÔNG phải nhân ma trận (A @ B).
$XW$ hoặc $X \cdot W$ Nhân ma trận X @ W Trộn tín hiệu: mỗi hàng kết quả là một tổ hợp có trọng số của các cột đầu vào. Quy tắc shape: $(m,k)@(k,n) \to (m,n)$.
$e^x$ hoặc $\exp(x)$ Hàm mũ Euler ($e \approx 2.718$) math.exp(x) Máy ép số dương: dù $x$ âm tới đâu, $e^{-100}$ vẫn là số dương. Dùng để biến điểm số thành xác suất.
$\log(x)$, $\ln(x)$ Logarit tự nhiên math.log(x) Thước đo độ ngạc nhiên: đoán đúng 100% ($x{=}1$) thì $\log 1 = 0$ (không phạt); đoán 0.1% thì $-\log(0.001)=6.9$ (phạt nặng).
$W^\top$ Ma trận chuyển vị W.T Lật nghiêng bảng tính: dòng thành cột, cột thành dòng.
$\sigma(z)$ Sigmoid 1/(1+exp(-z)) Máy bóp về khoảng (0,1): biến điểm số bất kỳ thành một xác suất "có/không".
$\text{softmax}(z)$ Softmax e_z / e_z.sum() Máy chia bánh 100%: biến mảng điểm lộn xộn $[-3,2,8]$ thành các tỉ lệ có tổng đúng $1.0$.
$\mathbb{E}[X]$ Kỳ vọng / trung bình X.mean() Giá trị trung bình nếu lặp lại thí nghiệm vô số lần.
$\mu, \sigma^2$ Trung bình, phương sai x.mean(), x.var() Tâm của đám số và độ xoè của nó quanh tâm.
$\mathcal{N}(0,1)$ Phân phối chuẩn torch.randn(...) Bốc số ngẫu nhiên hình chuông: hay ra quanh 0, hiếm khi ra ±3.
$\text{fan\_in}$ Fan-in W.shape[0] Số dây cắm vào: có bao nhiêu nơ-ron tầng trước truyền tín hiệu vào một nơ-ron tầng này.
$\theta$ (theta) Tập toàn bộ tham số model.parameters() Cách viết gọn cho "tất cả W và b của mọi tầng gộp lại".
$:=$ hoặc $\leftarrow$ Phép gán = Đây là lệnh gán trong code, không phải phương trình cần giải.
Bàn nháp toán

Đạo hàm không phải phép màu: nhích núm rồi đo loss đổi bao nhiêu

5 phút · không cần nhớ công thức trước

Ví dụ Module 00 bên dưới rút gọn thành $L(w)=\frac{14}{3}(w-2)^2$. Đạo hàm giải tích là $L'(w)=\frac{28}{3}(w-2)$. Nếu đã quên đạo hàm, ta vẫn có thể đo độ dốc bằng hai lần thử:

độ dốc gần đúng = [L(w + h) − L(w − h)] ÷ (2h)

Đây gọi là sai phân hữu hạn. Kéo $w$: hai cách tính phải gần trùng nhau. $h$ là bước thăm dò rất nhỏ, không phải learning rate.

0.50
0.0100
Thanh này chọn h từ 0.0001 đến 0.1 theo thang log.
Loss L(w)—
Đạo hàm đúng—
Đo bằng 2 lần thử—
Đọc dấu của gradient

—

Mẹo nhớ: gradient dương = tăng núm làm loss tăng, nên ta giảm núm; gradient âm = tăng núm làm loss giảm, nên phép “trừ gradient” sẽ tăng núm. Tới $w=2$, độ dốc bằng 0: núm đang ở đáy.

Phần 0 — Nền tảng tuyệt đối · Module 00

00 Học từ dữ liệu là gì? Vòng lặp huấn luyện

Cần biết trước: cộng, nhân, và khái niệm "hàm số" cấp 3 Dựng nên: 4 chiếc hộp của bản đồ chủ đạo Dẫn tới: Module 01 — nơ-ron

Mục tiêu

  • Phân biệt rạch ròi lập trình truyền thống và học từ dữ liệu — vì sao ta cần cái thứ hai.
  • Chạy trọn một ví dụ huấn luyện bằng tay, bằng số cụ thể, không có ma trận, không có thư viện.
  • Thuộc lòng vòng lặp 5 bước mà mọi mô hình từ hồi quy tuyến tính tới GPT-5 đều dùng y hệt.
  • Nắm bộ từ vựng nền: tham số vs siêu tham số, epoch/batch/step, train/val/test, overfitting.

1. Hai kiểu lập trình

Giả sử sếp giao: "viết chương trình nhận ảnh và trả lời đây là chó hay mèo".

Cách truyền thống — bạn tự nghĩ ra luật: nếu tai nhọn và mõm ngắn thì mèo, nếu... Bạn viết luật, máy chạy luật. Vấn đề: bạn không bao giờ viết đủ luật. Con mèo Sphynx không lông. Con chó Chihuahua tai nhọn. Sau 3000 dòng if, độ chính xác vẫn 60%.

Cách học từ dữ liệu — bạn không viết luật. Bạn viết một công thức có lỗ trống, rồi để máy tự điền các lỗ trống ấy sao cho khớp với 10.000 ví dụ có sẵn. Những lỗ trống đó gọi là tham số (parameters, thường ký hiệu $W$, $b$, hoặc gộp lại là $\theta$).

Lập trình truyền thống Dữ liệu Luật (code tay) Máy chạy Đáp án Học từ dữ liệu (đảo ngược!) Dữ liệu Đáp án Máy học Luật (tham số) Đầu ra của quá trình học chính là bộ tham số — file "model.pt" bạn hay tải về chỉ là một đống số như thế.
Học máy là lập trình ngược. Thay vì viết luật để sinh ra đáp án, ta đưa cả dữ liệu lẫn đáp án vào và bắt máy tìm ra luật. Một file checkpoint 7 tỉ tham số thực chất chỉ là 7 tỉ con số đã được dò ra bằng cách đó.

2. Chạy tay một ví dụ trọn vẹn (không ma trận, không thư viện)

Đây là phần quan trọng nhất của module. Ta sẽ làm một mô hình có đúng một tham số để mọi con số đều nhìn thấy được.

Bài toán: dự đoán điểm thi từ số giờ ôn bài. Ta có 3 quan sát:

Giờ ôn $x$Điểm thật $y$Mô hình đoán $\hat{y} = w\cdot x$
12$w$
24$2w$
36$3w$

Bạn nhìn phát ra ngay $w = 2$. Nhưng máy thì không — và cách máy mò ra con số 2 chính là toàn bộ Deep Learning. Ta khởi tạo bừa $w = 0.5$.

Bước ③ — đo sai lệch. Ta cần gom mọi sai lệch về một con số duy nhất để biết "hiện tại tệ cỡ nào". Dùng sai số bình phương trung bình (MSE):

$$L(w) = \frac{1}{n}\sum_{i=1}^{n}\big(w x_i - y_i\big)^2$$

Vì sao bình phương? Hai lý do rất thực dụng: (a) sai lệch âm và dương không triệt tiêu nhau; (b) hàm bình phương trơn nên lấy đạo hàm được — điều kiện sống còn ở bước ④.

Với $w = 0.5$: $L = \frac{1}{3}\big[(0.5-2)^2 + (1-4)^2 + (1.5-6)^2\big] = \frac{1}{3}[2.25 + 9 + 20.25] = 10.5$.

Bước ④ — hỏi đạo hàm nên đi hướng nào. Đạo hàm của $L$ theo $w$:

$$\frac{dL}{dw} = \frac{1}{n}\sum_{i=1}^{n} 2\,(w x_i - y_i)\,x_i$$

Với $w = 0.5$: $\frac{dL}{dw} = \frac{2}{3}\big[(-1.5)(1) + (-3)(2) + (-4.5)(3)\big] = \frac{2}{3}(-21) = -14$.

Đọc con số −14 này bằng tiếng Việt

"Nếu tăng $w$ lên 1 đơn vị, sai số $L$ sẽ giảm khoảng 14." Dấu âm nghĩa là đi theo hướng tăng $w$ thì tốt lên. Vậy quy tắc cập nhật phải là trừ đi gradient:

$$w \leftarrow w - \eta \cdot \frac{dL}{dw}$$

Trừ một số âm = cộng. Chính vì cái dấu trừ này mà thuật toán mang tên gradient descent — đi ngược hướng dốc lên, tức là đi xuống.

Chọn $\eta = 0.1$: $w \leftarrow 0.5 - 0.1 \times (-14) = 1.9$. Chỉ một bước, $w$ đã nhảy từ $0.5$ lên $1.9$ (đáp án đúng là $2.0$). Tính lại: $L(1.9) = 0.0467$. Từ $10.5$ xuống $0.047$ — mô hình vừa học xong.

Bẫy đầu tiên: learning rate

Thử $\eta = 0.5$ ở cùng bước trên: $w \leftarrow 0.5 + 7 = 7.5$. Vọt qua đích. Bước sau gradient sẽ đổi dấu và hất nó về phía âm còn xa hơn — $w$ dao động rồi phóng ra vô cực (NaN). Đây là lỗi số 1 của người mới. Nghịch núm η trong phòng thí nghiệm ngay dưới đây để thấy tận mắt.

Phòng thí nghiệm 00

Vòng lặp huấn luyện quay chậm — một tham số duy nhất

Bấm "1 bước" và quan sát quả bóng đỏ lăn
Dữ liệu & đường dự đoán ŷ = w·x
Mặt cắt hàm mất mát L(w) — quả bóng đang ở đâu?
0.100
Thử 0.005 (bò), 0.10 (nhanh), 0.15 (dao động co dần), 0.25+ (nổ tung).
0.50
Kéo tay để tự đặt điểm xuất phát.
Bước0
Loss L(w)10.50
dL/dw−14.00
Trạng tháiđang học
Diễn giải

Bấm 1 bước để chạy một vòng: dự đoán → đo sai số → tính đạo hàm → sửa w.

Việc cần làm: ① Để η = 0.1, bấm “1 bước” ba lần — quả bóng lăn xuống đáy parabol. ② Đặt lại, kéo η lên 0.15: bóng đổi bên mỗi bước nhưng biên độ co dần. ③ Kéo η lên 0.25: biên độ nở ra và loss sẽ vọt lên. Lý do chính xác: sai lệch $e=w-2$ tuân theo $e_{t+1}=(1-28\eta/3)e_t$. Vì vậy $0<\eta\leq3/28\approx0.107$ hội tụ không đổi bên; $3/28<\eta<3/14\approx0.214$ hội tụ kiểu dao động; $\eta=3/14$ dao động mãi cùng biên độ; lớn hơn nữa thì phân kỳ.

3. Vòng lặp 5 bước — bộ xương của mọi mô hình

Cái bạn vừa làm bằng tay chính là vòng lặp mà PyTorch chạy hàng triệu lần mỗi giờ. Học thuộc nó, vì từ đây tới hết giáo trình ta chỉ thay ruột chứ không đổi xương:

Bộ xương bất biến (pseudo-code)
for buoc in range(so_buoc):
    # 1. LẤY DỮ LIỆU  — một lô (batch) mẫu
    x, y = lay_mot_lo(du_lieu)

    # 2. TRUYỀN XUÔI  — mô hình đoán
    y_du_doan = mo_hinh(x)

    # 3. ĐO SAI SỐ    — gom về đúng MỘT con số
    L = ham_mat_mat(y_du_doan, y)

    # 4. TRUYỀN NGƯỢC — hỏi mọi tham số: "mày ảnh hưởng L thế nào?"
    xoa_gradient_cu()          # optimizer.zero_grad()
    L.backward()               # tính dL/d(tham số) cho TẤT CẢ tham số

    # 5. CẬP NHẬT     — nhích mọi tham số ngược hướng gradient
    for p in tham_so:
        p -= lr * p.grad       # optimizer.step()

Toàn bộ 12 module còn lại chỉ làm ba việc với bộ xương này: (a) làm bước 2 phức tạp hơn (nơ-ron → MLP → Transformer); (b) làm bước 4 tự động và đúng (autograd, Module 03); (c) làm bước 5 thông minh hơn (momentum, Adam, Module 02).

Chạy được ngay — Python thuần, đúng ví dụ vừa tính tay
xs = [1.0, 2.0, 3.0]
ys = [2.0, 4.0, 6.0]
w  = 0.5          # tham số, khởi tạo bừa
lr = 0.1          # siêu tham số, do NGƯỜI chọn

for buoc in range(20):
    # 2. truyền xuôi + 3. đo sai số
    preds = [w * x for x in xs]
    L = sum((p - y) ** 2 for p, y in zip(preds, ys)) / len(xs)

    # 4. truyền ngược (ở đây đạo hàm tính tay được)
    grad = sum(2 * (w * x - y) * x for x, y in zip(xs, ys)) / len(xs)

    # 5. cập nhật
    w = w - lr * grad
    print(f"buoc {buoc:2d} | w = {w:6.3f} | L = {L:9.4f} | grad = {grad:8.3f}")

# buoc  0 | w =  1.900 | L =   10.5000 | grad =  -14.000
# buoc  1 | w =  1.993 | L =    0.0467 | grad =   -0.933
# ...
# buoc 19 | w =  2.000 | L =    0.0000 | grad =   -0.000

4. Bộ từ vựng nền — đọc một lần, dùng cả đời

Thuật ngữNghĩa thật sựTrong ví dụ trên là gì
Feature (đặc trưng, $x$)Thông tin đầu vào mà mô hình được phép nhìn.Số giờ ôn bài.
Label (nhãn, $y$)Đáp án đúng mà ta muốn mô hình bắt chước.Điểm thi thật.
Tham số (parameter)Con số máy tự học. Cái ta muốn tìm.$w$.
Siêu tham số (hyperparameter)Con số người phải chọn trước khi học. Máy không tự tìm được.$\eta = 0.1$, số bước = 20.
Batch (lô)Nhóm mẫu dùng cho một lần cập nhật. Cả tập dữ liệu quá to nên phải cắt lô.Cả 3 mẫu (batch = full dataset).
Step / iterationMột lần chạy trọn 5 bước = một lần tham số được sửa.Một vòng for.
EpochSố lần mô hình đã xem hết toàn bộ dữ liệu một lượt.Ở đây 1 step = 1 epoch.
Train / Val / TestChia dữ liệu 3 phần: học trên train, chỉnh siêu tham số theo val, chỉ đo kết quả cuối cùng một lần trên test.Chưa chia (dữ liệu quá bé).
OverfittingMô hình thuộc lòng dữ liệu train (loss train ↓) nhưng dốt với dữ liệu mới (loss val ↑).Sẽ gặp ở Module 05.
Inference (suy luận)Dùng mô hình đã học xong để đoán — chỉ chạy bước 2, không có bước 4–5.w * 4 = 8.0 cho 4 giờ ôn.

Nó ứng dụng vào đâu trong đời thật

Mô hình một tham số trên trông ngây thơ, nhưng đổi tên biến là ra sản phẩm thật:

  • Dự báo doanh thu: $x$ = chi phí quảng cáo, $y$ = doanh thu. Đúng một dòng công thức, chạy trên Excel cũng được.
  • Chấm điểm tín dụng: đổi MSE thành cross-entropy, đổi $wx$ thành $\sigma(w^\top x + b)$ — bạn có logistic regression, thứ đang chạy thật trong ngân hàng.
  • GPT: $x$ = 2000 token phía trước, $y$ = token kế tiếp, $w$ = 175 tỉ con số. Vòng lặp không đổi một chữ nào. Chỉ khác: hộp ② to hơn, hộp ④ phải tự động, và bước 5 chạy trên 10.000 GPU.

Tự kiểm tra

1 · Vì sao hàm mất mát bắt buộc phải trả về đúng một con số, chứ không phải một danh sách sai lệch?

Vì "đi xuống dốc" chỉ có nghĩa khi có một độ cao để so sánh. Nếu $L$ là một véc-tơ, ta không biết cấu hình nào "thấp hơn" cấu hình nào — không có thứ tự. Gom về một số (thường bằng cách lấy trung bình) tạo ra một bề mặt duy nhất trong không gian tham số để tối ưu hoá. Đây cũng là lý do L.backward() trong PyTorch báo lỗi nếu L không phải scalar.

2 · $w$ là tham số, $\eta$ là siêu tham số. Vì sao không để máy học luôn cả $\eta$ bằng gradient descent?

Trong cách train thông thường, $\eta$ được giữ ngoài đồ thị forward, nên L.backward() chỉ tính gradient cho tham số mô hình; ta chọn $\eta$ bằng validation, scheduler hoặc sweep. Nhưng “không thể học learning rate” là quá mạnh: hypergradient/meta-learning có thể vi phân xuyên qua nhiều bước cập nhật để tối ưu $\eta$. Cách đó tốn bộ nhớ, dễ bất ổn và ít cần cho bài toán thường, nên $\eta$ vẫn được xem là siêu tham số trong giáo trình này.

3 · Ở bước $w = 2.0$ (đáp án đúng), gradient bằng bao nhiêu? Điều gì xảy ra tiếp?

Bằng $0$. Cập nhật trở thành $w \leftarrow 2 - \eta \cdot 0 = 2$: mô hình tự đứng yên. Gradient bằng 0 là tín hiệu "đã tới điểm bằng phẳng". Với parabol thì đó chắc chắn là đáy toàn cục; với mạng nơ-ron thật, điểm bằng phẳng có thể là cực tiểu địa phương hoặc điểm yên ngựa — ta sẽ nhìn tận mắt trong 3D ở Module 02.

Phần 0 — Nền tảng tuyệt đối · Module 01

01 Một nơ-ron: trọng số, bias và hàm kích hoạt

Cần biết trước: Module 00 (vòng lặp 5 bước) Dựng nên: hộp ② — mô hình Dẫn tới: vì sao phải xếp chồng nhiều tầng

Mục tiêu

  • Biết chính xác một nơ-ron tính cái gì: hai phép toán, không hơn.
  • Hiểu ý nghĩa hình học của $w$ và $b$ — nhìn tham số là thấy ngay đường phân chia nằm đâu.
  • Trả lời được câu hỏi kinh điển: bỏ hàm kích hoạt đi thì sao? (đáp án: mạng 100 tầng sụp thành 1 tầng).
  • Tự tay chứng kiến một nơ-ron thất bại trước XOR, và một tầng ẩn cứu nó thế nào.

1. Nơ-ron toán học: chỉ có hai phép toán

Nơ-ron sinh học nhận tín hiệu từ nhiều nhánh, cộng dồn lại, và bắn nếu tổng vượt ngưỡng. Bản sao toán học của nó rút gọn ý tưởng ấy thành hai dòng:

(1) Tổ hợp tuyến tính — cộng dồn có trọng số:

$$z = w_1 x_1 + w_2 x_2 + \dots + w_n x_n + b \;=\; \mathbf{w}^\top \mathbf{x} + b$$

(2) Hàm kích hoạt — bóp méo phi tuyến:

$$a = f(z)$$
x₁ x₂ 1 (hằng số, để nhân với b) w₁ = 1.2 w₂ = −0.7 b = 0.3 Σ cộng dồn z = w·x + b kích hoạt f a = tanh(z) a đầu ra — lại là đầu vào của nơ-ron tầng sau Tham số học được: w₁, w₂, b Không học được: x₁, x₂ (dữ liệu) f (do ta chọn)
Giải phẫu một nơ-ron. Chỉ có hai việc: cộng dồn có trọng số, rồi bóp méo phi tuyến. Mọi thứ hoành tráng phía sau — ResNet, Transformer — đều là hàng triệu bản sao của chiếc hộp này ghép lại.

Trọng số $w$ nghĩa là gì

Độ quan trọng có dấu. $w_1 = 1.2$ nghĩa là "$x_1$ tăng thì tôi phấn khích"; $w_2 = -0.7$ nghĩa là "$x_2$ tăng thì tôi dè chừng"; $w = 0$ nghĩa là "tôi không quan tâm đầu vào này". Trong bộ lọc thư rác, $w$ của từ "trúng thưởng" sẽ lớn và dương.

Bias $b$ nghĩa là gì

Ngưỡng bắn, hay định kiến sẵn có. Nếu $b$ rất âm, nơ-ron cần tổng tín hiệu rất mạnh mới kích hoạt (khó tính). Nếu $b$ dương, nó bắn ngay cả khi đầu vào bằng 0 (dễ tính). Về hình học, $b$ chính là thứ cho phép đường phân chia không đi qua gốc toạ độ — thiếu $b$, mọi đường phân chia bắt buộc phải xuyên qua điểm $(0,0)$, một hạn chế nghiêm trọng.

Hình học: một nơ-ron vẽ một đường thẳng

Tập điểm thoả $w_1x_1 + w_2x_2 + b = 0$ là một đường thẳng (với 2 đầu vào), một mặt phẳng (3 đầu vào), một siêu phẳng (n đầu vào). Nó chia không gian làm đôi: một bên $z > 0$, bên kia $z < 0$.

  • Véc-tơ $\mathbf{w} = (w_1, w_2)$ vuông góc với đường đó và chỉ về phía $z$ dương. Đổi dấu $w$ = lật ngược quyết định.
  • Độ lớn $\|\mathbf{w}\|$ quyết định độ dốc chuyển tiếp: $w$ lớn → ranh giới sắc lẹm như dao; $w$ bé → chuyển tiếp mờ nhoè.
  • $b$ tịnh tiến đường thẳng song song với chính nó. Khoảng cách từ gốc toạ độ tới đường là $|b|/\|\mathbf{w}\|$.

2. Hàm kích hoạt: vì sao không được bỏ

Đây là câu hỏi phỏng vấn kinh điển, và câu trả lời chỉ mất ba dòng đại số. Giả sử ta xếp 2 tầng không có kích hoạt:

$$h = W_1 x + b_1, \qquad y = W_2 h + b_2 = W_2(W_1x + b_1) + b_2 = \underbrace{(W_2W_1)}_{W'}x + \underbrace{(W_2b_1 + b_2)}_{b'}$$

Kết quả vẫn đúng dạng $W'x + b'$ — một tầng tuyến tính. Xếp 100 tầng cũng thế: tích của 100 ma trận vẫn chỉ là một ma trận. Toàn bộ sức mạnh "chiều sâu" bốc hơi. Hàm phi tuyến $f$ chen vào giữa chính là thứ ngăn phép sụp đổ đó, và cho phép mạng bẻ cong không gian.

HàmCông thứcĐạo hàmMiền giá trịNên dùng khi
Sigmoid$\sigma(z)=\dfrac{1}{1+e^{-z}}$$\sigma(1-\sigma)$, tối đa 0.25$(0,1)$Chỉ ở đầu ra bài toán nhị phân (cần xác suất). Tránh ở tầng ẩn: đạo hàm ≤ 0.25 nên 10 tầng là gradient tắt ngóm.
Tanh$\tanh(z)=\dfrac{e^{2z}-1}{e^{2z}+1}$$1-\tanh^2$, tối đa 1$(-1,1)$Tầng ẩn của mạng nhỏ; đối xứng quanh 0 nên hội tụ tốt hơn sigmoid. Dùng suốt Module 03–08.
ReLU$\max(0,z)$1 nếu $z>0$, ngược lại 0$[0,\infty)$Mặc định cho mạng sâu: rẻ, không bão hoà phía dương. Rủi ro "nơ-ron chết" nếu $z$ kẹt âm vĩnh viễn.
GELU$z\cdot\Phi(z)$trơn, gần ReLU$\approx[-0.17,\infty)$Mặc định trong Transformer/GPT — như ReLU nhưng trơn, không có điểm gãy.
Phòng thí nghiệm 01

Sân chơi nơ-ron — vặn tay hoặc để máy tự học

Nền màu = mô hình đang nghĩ gì ở mỗi điểm
Mặt phẳng đầu vào (x₁, x₂) & ranh giới quyết định
Loss theo bước huấn luyện
w₁ 0.00 · w₂ 0.00 · b 0.00
Ba thanh: w₁, w₂, b. Chỉ hoạt động ở chế độ 1 nơ-ron.
0.50
Bước0
Loss (BCE)—
Độ chính xác—
Số tham số—
Diễn giải

Chọn bộ dữ liệu rồi bấm Huấn luyện.

Thí nghiệm bắt buộc: ① Chọn XOR + 1 nơ-ron, bấm Huấn luyện — loss đứng lì quanh 0.69, độ chính xác kẹt ở ~50%. Vặn tay cả ba thanh trượt cũng vô ích: không tồn tại đường thẳng nào chia được XOR. ② Giữ nguyên XOR, đổi sang MLP 2→2: hai nơ-ron ẩn vẽ hai đường thẳng, tầng ra kết hợp chúng và ranh giới cong lên — loss rơi xuống gần 0. Đó là khoảnh khắc "chiều sâu" chứng minh giá trị của nó. ③ Thử xoắn ốc với 2→2 (thất bại) rồi 2→16→16 (thành công): số nơ-ron ẩn quyết định mạng bẻ cong không gian được bao nhiêu lần.

3. Vì sao XOR đánh gục một nơ-ron

XOR là bài toán: đầu ra bằng 1 khi đúng một trong hai đầu vào bằng 1.

x₁x₂y (XOR)
000
011
101
110

Giả sử tồn tại $w_1, w_2, b$ giải được. Điều kiện phân loại đúng đòi hỏi đồng thời:

  • $b < 0$  (từ điểm $(0,0)\to 0$)
  • $w_2 + b > 0$  (từ $(0,1)\to 1$) $\Rightarrow w_2 > -b > 0$
  • $w_1 + b > 0$  (từ $(1,0)\to 1$) $\Rightarrow w_1 > -b > 0$
  • $w_1 + w_2 + b < 0$  (từ $(1,1)\to 0$)

Cộng hai bất đẳng thức giữa: $w_1 + w_2 > -2b$, tức $w_1 + w_2 + b > -b > 0$ — mâu thuẫn trực tiếp với điều kiện cuối. Không có nghiệm. Đây chính là chứng minh của Minsky & Papert năm 1969, thứ đã khiến ngành mạng nơ-ron đóng băng gần hai thập kỷ ("mùa đông AI" lần thứ nhất).

Lối thoát: hai nơ-ron ẩn, mỗi cái vẽ một đường thẳng, rồi nơ-ron ra kết hợp hai kết quả đó. Ví dụ $h_1$ học "ít nhất một cái bật" (OR), $h_2$ học "cả hai cùng bật" (AND), tầng ra tính $h_1 - h_2$ = XOR. Ranh giới cuối cùng không còn là đường thẳng trong không gian gốc nữa.

Cách nghĩ đúng về mạng sâu

Mỗi tầng ẩn không "hiểu" gì cả — nó chỉ đổi hệ toạ độ. Tầng ẩn bẻ cong, gập, kéo giãn không gian đầu vào cho tới khi dữ liệu trở thành tách được bằng một đường thẳng; rồi tầng cuối cùng — vốn chỉ là một nơ-ron tuyến tính tầm thường — vạch đường thẳng đó. Sâu = nhiều lần bẻ cong. Trong phòng thí nghiệm trên, hãy để ý ranh giới cong của MLP: đó là một đường thẳng trong không gian ẩn đã bị bẻ, chiếu ngược về không gian gốc.

Một nơ-ron, viết bằng Python thuần — có cả huấn luyện
import math, random

def sigmoid(z): return 1 / (1 + math.exp(-z))

# Dữ liệu: cổng AND (tách được bằng đường thẳng, khác XOR)
data = [((0,0),0), ((0,1),0), ((1,0),0), ((1,1),1)]

w1, w2, b = random.uniform(-1,1), random.uniform(-1,1), 0.0
lr = 0.5

for buoc in range(3000):
    dw1 = dw2 = db = 0.0
    L = 0.0
    for (x1, x2), y in data:
        z = w1*x1 + w2*x2 + b
        a = sigmoid(z)
        L += -(y*math.log(a + 1e-9) + (1-y)*math.log(1-a + 1e-9))
        # Phép màu của cặp sigmoid + cross-entropy: dL/dz rút gọn thành (a - y)
        dz = (a - y)
        dw1 += dz * x1;  dw2 += dz * x2;  db += dz
    n = len(data)
    w1 -= lr * dw1/n;  w2 -= lr * dw2/n;  b -= lr * db/n
    if buoc % 1000 == 0:
        print(f"buoc {buoc:4d} | L={L/n:.4f} | w=({w1:.2f},{w2:.2f}) b={b:.2f}")

for (x1,x2), y in data:
    print(f"({x1},{x2}) -> {sigmoid(w1*x1+w2*x2+b):.3f}  (thật: {y})")
# Đổi data thành XOR: [((0,0),0),((0,1),1),((1,0),1),((1,1),0)]
# và bạn sẽ thấy L đứng yên ở ~0.693 = log(2) — mô hình bó tay.

Ba cái bẫy quanh hàm kích hoạt

  • Đặt kích hoạt ở tầng cuối rồi lại đưa vào cross-entropy. Trong PyTorch, nn.CrossEntropyLoss và BCEWithLogitsLoss đã bao gồm softmax/sigmoid bên trong. Thêm một lần nữa = bóp hai lần, gradient teo, model học chậm mà không báo lỗi.
  • ReLU chết. Nếu một bước cập nhật quá lớn đẩy $z$ của một nơ-ron xuống âm với mọi mẫu, đạo hàm của nó vĩnh viễn bằng 0 → nơ-ron đó thành cục gạch. Giảm learning rate, hoặc dùng LeakyReLU/GELU.
  • Quên bias. Bỏ $b$ để "cho gọn" là buộc mọi ranh giới đi qua gốc toạ độ. Với dữ liệu chưa chuẩn hoá, đây là án tử.

Nó ứng dụng vào đâu trong đời thật

Một nơ-ron đơn + sigmoid chính là hồi quy logistic — mô hình được triển khai nhiều nhất lịch sử: chấm điểm tín dụng, dự báo bệnh nhân tái nhập viện, lọc spam, dự đoán tỉ lệ click quảng cáo. Ưu điểm chí mạng của nó so với mạng sâu: giải thích được. Hệ số $w_i$ đọc thẳng ra thành "yếu tố này làm tăng log-odds bấy nhiêu", điều mà các cơ quan quản lý tài chính và y tế bắt buộc phải có. Quy tắc nghề: bắt đầu bằng một nơ-ron làm chuẩn (baseline), chỉ dùng mạng sâu khi nó thắng rõ rệt.

Tự kiểm tra

1 · Nhân đôi cả $w$ và $b$ ($w \to 2w$, $b \to 2b$) — ranh giới quyết định có đổi không? Còn dự đoán?

Ranh giới không đổi: $2(w^\top x + b) = 0$ tương đương $w^\top x + b = 0$, vẫn đúng đường thẳng cũ. Nhưng dự đoán thì đổi: mọi $z$ bị nhân đôi, sigmoid trở nên dứt khoát hơn — xác suất bị đẩy về sát 0 hoặc 1. Mô hình "tự tin" gấp bội mà không hề chính xác hơn. Đây đúng là hiện tượng mà weight decay (phạt $\|w\|^2$) sinh ra để kìm lại.

2 · Nếu khởi tạo tất cả trọng số bằng 0 thì chuyện gì xảy ra với một MLP?

Mọi nơ-ron trong cùng một tầng nhận đầu vào giống nhau, cho đầu ra giống nhau, nhận gradient giống nhau, và được cập nhật giống hệt nhau — mãi mãi. Một tầng 100 nơ-ron hành xử đúng như 1 nơ-ron. Hiện tượng này gọi là đối xứng không bị phá vỡ (symmetry breaking failure). Chính vì thế trọng số phải khởi tạo ngẫu nhiên; riêng bias khởi tạo 0 thì vô hại. Module 06 sẽ chỉ ra khởi tạo ngẫu nhiên với biên độ nào mới đúng.

3 · Mạng 2→2→1 dùng ReLU có giải được XOR không, hay bắt buộc phải tanh?

Được. Một nghiệm tay kinh điển: $h_1 = \text{ReLU}(x_1 + x_2)$, $h_2 = \text{ReLU}(x_1 + x_2 - 1)$, đầu ra $y = h_1 - 2h_2$. Kiểm tra: $(0,0)\to 0-0=0$; $(0,1)\to 1-0=1$; $(1,0)\to 1-0=1$; $(1,1)\to 2-2\cdot1=0$. Chuẩn XOR. Điều kiện duy nhất là hàm kích hoạt phải phi tuyến, chứ không phải phải trơn. Bạn có thể kiểm chứng bằng cách đổi kích hoạt trong phòng thí nghiệm ở Module 06.

Phần 0 — Nền tảng tuyệt đối · Module 02

02 Hàm mất mát & Gradient Descent — nhìn bằng 3D

Cần biết trước: Module 00–01 Dựng nên: hộp ③ và ④ Dẫn tới: autograd — cách tính gradient tự động cho triệu tham số

Mục tiêu

  • Chọn đúng hàm mất mát: khi nào MSE, khi nào cross-entropy, và vì sao.
  • Hình dung được bề mặt mất mát (loss landscape) và hiểu "huấn luyện" chính là đi xuống núi trong sương mù.
  • Nhìn tận mắt trong 3D: learning rate quá lớn, thung lũng hẹp, điểm yên ngựa, cực tiểu địa phương.
  • Hiểu momentum và Adam giải quyết chính xác vấn đề gì — không phải "vì nó tốt hơn".

1. Chọn hàm mất mát

Hàm mất mát là lời định nghĩa của bạn về chữ "sai". Đổi loss là đổi mục tiêu, và mô hình sẽ tối ưu đúng cái bạn viết ra — kể cả khi đó không phải cái bạn muốn.

Bài toánHàm mất mátCông thứcGhi nhớ
Hồi quy (dự đoán số)MSE$\frac{1}{n}\sum(\hat{y}-y)^2$Phạt nặng sai lệch lớn (bình phương) → rất nhạy với ngoại lai.
Hồi quy có ngoại laiMAE / Huber$\frac{1}{n}\sum|\hat{y}-y|$Phạt tuyến tính → bền hơn với dữ liệu bẩn.
Phân loại 2 lớpBinary cross-entropy$-[y\log\hat{p} + (1-y)\log(1-\hat{p})]$Dùng kèm sigmoid.
Phân loại nhiều lớp
(gồm cả mô hình ngôn ngữ)
Cross-entropy / NLL$-\log \hat{p}_c$  ($c$ = lớp đúng)Dùng kèm softmax. Đây là loss của mọi LLM.

Cross-entropy: chỉ là "độ ngạc nhiên" trung bình

Với phân loại, mô hình xuất ra một véc-tơ điểm số thô gọi là logits, softmax biến chúng thành xác suất, rồi loss chỉ lấy xác suất mà mô hình gán cho đáp án đúng và log âm nó:

$$L = -\log \hat{p}_c$$

với $\hat{p}_c$ là xác suất mô hình gán cho lớp đúng.

Đọc bảng này là hiểu ngay vì sao nó hoạt động:

$\hat{p}_c$ · lớp đúng$-\log \hat{p}$Diễn giải
1.000.00Chắc chắn và đúng → không phạt.
0.500.69Đoán mò 2 lớp → phạt vừa.
0.102.30Nghi ngờ đáp án đúng → phạt nặng.
0.014.61Gần như loại trừ đáp án đúng → phạt rất nặng.
0.00019.21Tự tin sai bét → phạt khủng khiếp.

Mẹo đọc loss của mô hình ngôn ngữ trong 5 giây

Nếu loss (cross-entropy cơ số $e$) của một mô hình ngôn ngữ là $L$, thì $e^{L}$ là perplexity — có thể hình dung thô như “số nhánh lựa chọn hiệu dụng” ở mỗi token. Ví dụ:

  • Bộ chữ cái 27 ký tự, logits bằng nhau → $L = \log 27 = 3.30$. Đây là mốc khởi tạo đều, không phải luật “loss ≥ 3.3 chắc chắn chưa học”: một mô hình lệch phân phối hoặc gặp tập khó có thể cao hơn dù đã học đôi chút.
  • Mô hình bigram trên tập tên tiếng Anh → $L \approx 2.45$, tức perplexity 11.6: "tôi thu hẹp còn ~12 ký tự khả dĩ". Trên tập tên tiếng Việt không dấu ở Module 04, con số thấp hơn ($\approx 1.9$) vì cấu trúc âm tiết chặt chẽ hơn nhiều.
  • Chỉ so perplexity khi dùng cùng tokenizer, cùng tập đánh giá và cùng cách lấy trung bình. Khác cách cắt token thì con số không còn cùng đơn vị, dù đều ghi là “loss”.

Vì vậy, khi tầng cuối được khởi tạo để logits gần bằng nhau, hãy dùng $\log K$ với $K$ lớp làm sanity check kỳ vọng. Lệch xa là tín hiệu cần kiểm tra khởi tạo, nhãn và masking — chưa phải bằng chứng duy nhất rằng có bug.

Phòng thí nghiệm 02a

Softmax & Cross-entropy — kéo logits, xem hình phạt

Lớp đúng đang là "mèo"
Logits (xám) → Xác suất sau softmax (màu)
2.0
1.0
-1.0
1.00
T < 1: nhọn, quyết đoán. T > 1: bẹt, ngẫu nhiên. Đây chính là núm temperature khi gọi API LLM.
p(mèo)—
Loss = −log p—
dL/dz mèo—
dL/dz chó—
Quan sát

—

Điều cần thấy: gradient của cross-entropy qua softmax rút gọn thành một công thức đẹp kinh ngạc: $\dfrac{\partial L}{\partial z_i} = p_i - y_i$ (với $y$ là one-hot). Nghĩa là "đẩy xuống mọi lớp sai theo đúng xác suất bạn lỡ gán cho nó, và kéo lên lớp đúng theo đúng phần còn thiếu". Kéo logit "chó" lên cao và để ý gradient của "mèo" tiến về $-1$ — mô hình đang bị la mắng dữ dội nhất. Công thức $p - y$ này sẽ tự tay code lại ở Module 07.

2. Bề mặt mất mát: bức tranh trung tâm của cả ngành

Đây là hình dung quan trọng nhất bạn cần mang theo suốt sự nghiệp:

  • Mỗi bộ tham số có thể có = một điểm trong không gian. Mô hình 2 tham số → mặt phẳng; mô hình 7 tỉ tham số → không gian 7 tỉ chiều.
  • Độ cao tại điểm đó = giá trị loss khi dùng bộ tham số ấy.
  • Tập hợp mọi độ cao tạo thành một địa hình — núi, thung lũng, đèo, cao nguyên.
  • Huấn luyện = thả một người mù xuống địa hình đó. Người ấy không nhìn thấy bản đồ; ở mỗi bước anh ta chỉ sờ được độ dốc ngay dưới chân (gradient) rồi bước một bước dài $\eta$ theo hướng dốc xuống.

Toàn bộ nghệ thuật huấn luyện nằm ở chỗ: bước dài bao nhiêu, có nên giữ đà không, có nên đi mỗi hướng một tốc độ khác nhau không. Hãy xoay quả địa hình dưới đây bằng chuột.

Phòng thí nghiệm 02b · 3D

Đi xuống núi trong sương mù — quỹ đạo tối ưu hoá trên bề mặt loss

Kéo chuột để xoay · lăn chuột để phóng to
Bề mặt L(w₁, w₂) — độ cao là loss
Nhìn từ trên: đường đồng mức
Loss theo bước
0.090
0.00
Mô phỏng việc mỗi bước chỉ nhìn một lô nhỏ dữ liệu → gradient bị lệch ngẫu nhiên.
Bước0
Loss—
‖gradient‖—
Vị trí—
Chẩn đoán

—

Năm thí nghiệm phải làm: ① Bát tròn + SGD, η = 0.05 → đi thẳng về đáy, êm ru. Đây là thế giới trong mơ; mạng nơ-ron thật không bao giờ được như vậy. ② Thung lũng hẹp + SGD (η = 0.09) → quỹ đạo zíc-zắc nảy hai bên vách trong khi tiến dọc thung lũng cực chậm; sau 220 bước bóng vẫn chưa về tới đáy. Nguyên nhân: một hướng dốc gấp 160 lần hướng kia, mà cả hai buộc phải xài chung một $\eta$. Bây giờ đổi sang Momentum — dao động ngang triệt tiêu lẫn nhau, đà dọc cộng dồn: nhanh hơn hàng chục lần. Rồi thử Adam — mỗi trục có bước riêng, gần như đi thẳng. ③ Yên ngựa + SGD, thả gần tâm → bóng gần như đứng im (gradient ≈ 0) rất lâu rồi mới trượt xuống. Trong không gian nhiều chiều, điểm yên ngựa phổ biến hơn cực tiểu địa phương rất nhiều — đây mới là kẻ thù thật của mạng sâu. ④ Nhiều cực tiểu + nhiễu = 0 → mắc kẹt ở hố nông. Kéo nhiễu lên 0.3 → rung lắc giúp bóng thoát hố và tìm hố sâu hơn. Đó chính là lý do người ta dùng mini-batch chứ không dùng full-batch: nhiễu là tính năng, không phải lỗi. ⑤ Bất kỳ địa hình nào, η = 0.4 → bóng văng ra ngoài, loss thành ∞. Bạn vừa tái hiện dòng loss: nan.

3. Ba biến thể của gradient descent — theo lượng dữ liệu mỗi bước

KiểuMỗi bước dùngƯuNhược
Batch GDToàn bộ dữ liệuGradient chính xác, quỹ đạo mượt.Một bước phải quét cả triệu mẫu → chậm khủng khiếp; dễ kẹt ở hố nông vì không có nhiễu.
SGD (thuần)1 mẫuCập nhật cực nhanh, nhiễu mạnh giúp thoát hố.Quỹ đạo run bần bật, không tận dụng được GPU.
Mini-batch ★32 – 512 mẫuLựa chọn thực tế của mọi người. Vừa đủ nhiễu, vừa vắt kiệt được sức song song của GPU.Thêm một siêu tham số phải chỉnh: batch size.

4. Momentum và Adam giải quyết cái gì

Đừng học thuộc công thức trước — hãy nhớ bệnh mà mỗi thuật toán chữa.

Momentum — chữa bệnh zíc-zắc

Bệnh: trong thung lũng hẹp, gradient đổi dấu liên tục theo hướng ngang nhưng luôn cùng dấu theo hướng dọc. SGD phí gần hết công sức để nảy qua nảy lại.

Thuốc: giữ một véc-tơ vận tốc — trung bình trượt của các gradient gần đây. Thành phần ngang đổi dấu nên tự triệt tiêu; thành phần dọc cùng dấu nên cộng dồn thành đà.

$$v \leftarrow \beta v + (1-\beta)\,g, \qquad \theta \leftarrow \theta - \eta\, v \qquad (\beta = 0.9)$$

Trực giác: thay vì một người bước từng bước, hãy tưởng tượng một quả bóng nặng lăn xuống dốc. Nó có quán tính, nên không đổi hướng xoành xoạch và lăn thẳng qua các hố nông.

Adam — chữa bệnh "mỗi hướng cần một bước chân khác nhau"

Bệnh: tham số A nhận gradient cỡ $10^{-5}$, tham số B nhận gradient cỡ $10^{2}$. Một $\eta$ duy nhất thì hoặc A đứng im, hoặc B nổ tung.

Thuốc: theo dõi thêm trung bình bình phương gradient của từng tham số, rồi chia bước đi cho căn của nó — tham số nào hay bị gradient lớn thì bước ngắn lại, ai bị gradient bé thì được bước dài ra.

$$m \leftarrow \beta_1 m + (1-\beta_1) g, \qquad v \leftarrow \beta_2 v + (1-\beta_2) g^2, \qquad \theta \leftarrow \theta - \eta\,\frac{\hat{m}}{\sqrt{\hat{v}} + \epsilon}$$

Adam = Momentum ($m$) + tỉ lệ bước riêng cho từng tham số ($v$) + hiệu chỉnh sai lệch lúc khởi động ($\hat{\cdot}$). Mặc định huyền thoại: $\beta_1{=}0.9$, $\beta_2{=}0.999$, $\epsilon{=}10^{-8}$, và $\eta = 3\times10^{-4}$ — con số mà Karpathy gọi đùa là "learning rate tốt nhất cho Adam, bất kể bài toán gì".

Bốn cái bẫy khiến quá trình huấn luyện chết

  • Loss ra nan: gần như luôn là learning rate quá lớn, hoặc log(0) ở đâu đó. Cách chữa: giảm $\eta$ 10 lần, thêm $\epsilon$ vào trong log, bật gradient clipping.
  • Loss đứng lì ngay từ đầu: kiểm tra loss lúc khởi tạo có bằng $\log K$ (K = số lớp) không. Nếu lệch — bug ở khởi tạo hoặc quên zero_grad().
  • Loss giảm rồi bật tăng vọt: gradient nổ, thường ở mạng sâu/RNN. Dùng clip_grad_norm_(params, 1.0).
  • Quên optimizer.zero_grad(): PyTorch cộng dồn gradient. Không xoá thì bước thứ 100 đang mang tổng gradient của 100 batch → bước đi dài gấp 100 lần dự kiến. Ta sẽ hiểu tận gốc vì sao PyTorch cộng dồn ở Module 03.

Nó ứng dụng vào đâu trong đời thật

Mọi cấu hình huấn luyện thật đều xoay quanh đúng bảng núm bạn vừa nghịch. Một file config nanoGPT thực tế:

learning_rate = 6e-4      # đỉnh của lịch trình
min_lr        = 6e-5      # đáy sau khi cosine decay
warmup_iters  = 2000      # 2000 bước đầu tăng dần từ 0 — tránh nổ lúc khởi động
beta1, beta2  = 0.9, 0.95 # beta2 hạ từ 0.999 xuống 0.95 cho ổn định ở batch lớn
grad_clip     = 1.0       # chặn gradient nổ
batch_size    = 12 * 40   # gradient accumulation để giả lập batch to

Warmup tồn tại vì lúc mới khởi tạo, ước lượng $v$ của Adam còn nhiễu nên bước đi rất bất thường; đi rón rén vài nghìn bước đầu rồi mới tăng tốc. Cosine decay tồn tại vì cuối quá trình ta cần bước ngắn để lắng xuống đáy thay vì nhảy quanh nó. Đây không phải mê tín — bạn vừa nhìn thấy cả hai hiện tượng trong phòng thí nghiệm 3D.

Tự kiểm tra

1 · Vì sao trong không gian nhiều chiều, "điểm yên ngựa" đáng sợ hơn "cực tiểu địa phương"?

Một điểm dừng là cực tiểu địa phương chỉ khi loss cong lên theo tất cả $n$ chiều. Nếu mỗi chiều cong lên/xuống gần như độc lập, xác suất cả $n$ chiều cùng cong lên là cỡ $2^{-n}$ — với $n$ hàng triệu thì bằng 0. Ngược lại, chỉ cần một chiều cong xuống là ta có yên ngựa. Vậy hầu như mọi điểm gradient ≈ 0 trong mạng sâu đều là yên ngựa, và luôn tồn tại lối thoát; vấn đề chỉ là cao nguyên phẳng quanh nó làm ta đi rất chậm — đúng thứ momentum sinh ra để phá.

2 · Nhân đôi batch size thì nên chỉnh learning rate thế nào?

Quy tắc thực nghiệm: batch lớn hơn → ước lượng gradient ít nhiễu hơn → được phép bước dài hơn. Hai quy tắc phổ biến là linear scaling ($\eta \propto B$, dùng trong huấn luyện ImageNet quy mô lớn) và square-root scaling ($\eta \propto \sqrt{B}$, hợp hơn với Adam). Kèm theo đó gần như bắt buộc phải có warmup, vì bước đầu tiên bây giờ rất dài. Lưu ý mặt trái: batch quá lớn làm mất nhiễu — chính thứ giúp thoát hố nông ở thí nghiệm ④.

3 · Adam gần như luôn hội tụ nhanh hơn SGD, vậy vì sao huấn luyện ResNet cho ImageNet người ta vẫn dùng SGD + momentum?

Nhanh hội tụ trên tập train không đồng nghĩa tổng quát hoá tốt trên tập test. Thực nghiệm nhiều năm cho thấy SGD+momentum thường tìm tới các cực tiểu "phẳng" (flat minima) — vùng đáy rộng, ít nhạy với nhiễu tham số — trong khi Adam hay rơi vào đáy hẹp, sắc. Với thị giác máy tính, khoảng cách test accuracy có thể tới 1–2%. Ngược lại, với Transformer/NLP thì Adam (chính xác là AdamW) gần như bắt buộc, vì gradient của embedding thưa và lệch thang đo dữ dội.

Phần I — Autograd & mô hình đầu tiên · Module 03

03 Mạch tính toán & tự viết engine Autograd

Cần biết trước: Module 00–02, đạo hàm cơ bản Dựng nên: hộp ④ chạy tự động Dẫn tới: mọi module còn lại — từ đây ta không tính đạo hàm tay nữa

Mục tiêu

  • Hiểu vì sao không thể tính đạo hàm tay cho mạng thật, và autograd giải bài toán đó thế nào.
  • Nắm chain rule ở mức "nhìn là biết", không cần nhớ công thức.
  • Tự cài đặt lớp Value — một engine đạo hàm tự động hoàn chỉnh, ~70 dòng Python thuần.
  • Dùng chính engine đó dựng một MLP và huấn luyện nó, để thấy micrograd không phải đồ chơi.

1. Vì sao phải có autograd

Ở Module 00, ta đạo hàm tay được vì mô hình chỉ có một tham số. Bây giờ thử tưởng tượng một MLP nhỏ: 3 tầng, mỗi tầng 100 nơ-ron. Đó là ~20.000 tham số, và biểu thức của loss là một hàm hợp lồng nhau hàng nghìn tầng. Đạo hàm tay: bất khả thi. Đạo hàm bằng cách xấp xỉ số $\frac{L(w+h)-L(w)}{h}$: phải chạy forward 20.000 lần cho một bước cập nhật — chậm gấp hàng chục nghìn lần và sai số tích luỹ.

Autograd (backpropagation) làm việc đó với chi phí chỉ khoảng 2 lần forward, cho toàn bộ tham số cùng lúc. Đây là phát kiến kỹ thuật khiến deep learning khả thi về mặt kinh tế.

2. Chain rule, kể bằng bánh răng

Ba bánh răng ăn khớp: A quay 1 vòng làm B quay 3 vòng, B quay 1 vòng làm C quay 2 vòng. Hỏi A quay 1 vòng thì C quay mấy vòng? Ai cũng trả lời được: $3 \times 2 = 6$. Đó chính là chain rule:

$$\frac{\partial C}{\partial A} = \frac{\partial C}{\partial B}\cdot\frac{\partial B}{\partial A}$$

Đạo hàm là "tỉ số truyền động" giữa hai đại lượng. Muốn biết tỉ số truyền từ đầu này sang đầu kia của một dây chuyền dài, chỉ việc nhân dồn các tỉ số dọc đường. Mạng nơ-ron là một dây chuyền như thế, chỉ dài hơn và có nhiều nhánh.

Với các nhánh rẽ, thêm đúng một quy tắc: nếu một giá trị được dùng ở nhiều nơi, gradient từ mọi nơi phải cộng lại. (Bánh răng A truyền lực đồng thời cho hai dây chuyền thì tổng ảnh hưởng là tổng của cả hai.)

3. Ba cổng, ba tính cách

Ở cấp thấp nhất, mạng chỉ gồm vài loại cổng. Mỗi cổng có một hành vi truyền ngược rất dễ nhớ:

CổngForwardBackward — nó làm gì với gradient dội về
Cộng +$c = a + b$Bộ chia đều: chuyển nguyên vẹn gradient cho cả hai đầu vào. $\partial c/\partial a = 1$.
Nhân *$c = a \cdot b$Kẻ đổi chéo: đầu vào này nhận gradient nhân với giá trị của đầu vào kia. $\partial c/\partial a = b$.
Max / ReLU$c=\max(a,b)$Bộ định tuyến: đưa toàn bộ gradient cho kẻ thắng, kẻ thua nhận 0.
Tanh$c=\tanh(a)$Cái van bóp: nhân gradient với $1-c^2$. Khi $|c|\to 1$, van đóng gần kín — đây là gốc rễ của vanishing gradient.
a = 2.0 ∂L/∂a = −4.0 b = −3.0 ∂L/∂b = −4.0 + ∂L/∂q = −4.0 q = a + b = −1.0 c = −4.0 ∂L/∂c = −1.0 × L = q·c = 4.0 ∂L/∂L = 1.0
Mạch $L = (a+b)\cdot c$. Mũi tên đen: forward tính giá trị. Mũi tên đỏ đứt: backward truyền đạo hàm. Cổng nhân đổi chéo ($\partial L/\partial q = c = -4$), cổng cộng chia đều ($\partial L/\partial a = \partial L/\partial q \cdot 1 = -4$).
Phòng thí nghiệm 03

Máy chiếu chậm backpropagation — xem gradient chảy ngược từng nút

Nút đang sáng = nút vừa đẩy gradient về cha
Đồ thị tính toán · số đen = giá trị (data), số đỏ = đạo hàm (grad)
Thứ tự lùi tuân theo topological sort đảo ngược — đúng thuật toán trong code bên dưới.
Nút đã xử lý0
Tổng số nút—
Bước

Forward đã chạy xong, mọi grad đang bằng 0 trừ nút cuối (bằng 1.0). Bấm Lùi 1 nút.

Điều cần thấy: ① Với biểu thức nơ-ron, để ý gradient của $w_1$ bằng $x_1$ nhân gradient dội về — đầu vào lớn thì trọng số của nó nhận gradient lớn, đó là lý do phải chuẩn hoá dữ liệu. ② Với x·x + x, nút x nhận gradient hai lần và chúng cộng dồn thành đúng $2x+1$ — nếu code dùng = thay vì += thì kết quả sai và không có gì báo lỗi. ③ Với 3 tầng tanh, hãy nhìn con số grad teo dần mỗi lần đi qua một cổng tanh: $0.42 \to 0.18 \to 0.07$. Nhân ba lần như vậy là vanishing gradient; nhân 50 lần là mạng sâu ngừng học.

4. Lớp Value: một engine autograd hoàn chỉnh

Chỉ cần ba ý tưởng: (a) mỗi Value nhớ nó sinh ra từ đâu; (b) mỗi phép toán tự đăng ký một closure _backward biết cách chia gradient cho cha nó; (c) hàm backward() sắp xếp tô-pô rồi gọi các closure theo thứ tự ngược.

micrograd.py — Python thuần, không thư viện, copy chạy được ngay
import math

class Value:
    """Một số thực trong đồ thị tính toán, có kèm đạo hàm."""

    def __init__(self, data, _children=(), _op='', label=''):
        self.data = data
        self.grad = 0.0                # dL/d(self) — khởi đầu bằng 0
        self._backward = lambda: None  # cách chia gradient cho các cha
        self._prev = set(_children)    # các nút đã tạo ra nút này
        self._op = _op                 # tên phép toán (để vẽ đồ thị)
        self.label = label

    def __repr__(self):
        return f"Value(data={self.data:.4f}, grad={self.grad:.4f})"

    # ---------- các phép toán cơ bản ----------
    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), '+')
        def _backward():
            self.grad  += 1.0 * out.grad      # cổng cộng: chia đều
            other.grad += 1.0 * out.grad
        out._backward = _backward
        return out

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), '*')
        def _backward():
            self.grad  += other.data * out.grad   # cổng nhân: đổi chéo
            other.grad += self.data  * out.grad
        out._backward = _backward
        return out

    def __pow__(self, k):                  # chỉ hỗ trợ số mũ hằng
        assert isinstance(k, (int, float))
        out = Value(self.data ** k, (self,), f'**{k}')
        def _backward():
            self.grad += k * (self.data ** (k - 1)) * out.grad
        out._backward = _backward
        return out

    def exp(self):
        out = Value(math.exp(self.data), (self,), 'exp')
        def _backward():
            self.grad += out.data * out.grad   # d(e^x)/dx = e^x = chính out
        out._backward = _backward
        return out

    def tanh(self):
        t = math.tanh(self.data)
        out = Value(t, (self,), 'tanh')
        def _backward():
            self.grad += (1.0 - t ** 2) * out.grad
        out._backward = _backward
        return out

    def relu(self):
        out = Value(max(0.0, self.data), (self,), 'relu')
        def _backward():
            self.grad += (1.0 if out.data > 0 else 0.0) * out.grad
        out._backward = _backward
        return out

    def log(self):
        out = Value(math.log(self.data), (self,), 'log')
        def _backward():
            self.grad += (1.0 / self.data) * out.grad
        out._backward = _backward
        return out

    # ---------- đường tắt cho tiện ----------
    def __neg__(self):        return self * -1
    def __sub__(self, o):     return self + (-o)
    def __radd__(self, o):    return self + o
    def __rmul__(self, o):    return self * o
    def __truediv__(self, o): return self * (o ** -1 if isinstance(o, Value) else 1.0 / o)

    # ---------- trái tim của engine ----------
    def backward(self):
        topo, visited = [], set()
        def build(v):
            if v not in visited:
                visited.add(v)
                for child in v._prev:
                    build(child)
                topo.append(v)          # cha luôn được thêm SAU con
        build(self)

        self.grad = 1.0                 # dL/dL = 1, mồi cho cả dây chuyền
        for node in reversed(topo):     # duyệt ngược: cha trước, con sau
            node._backward()


# ===== chạy thử =====
a = Value(2.0, label='a'); b = Value(-3.0, label='b'); c = Value(-4.0, label='c')
L = (a + b) * c
L.backward()
print(L.data, a.grad, b.grad, c.grad)   # 4.0  -4.0  -4.0  -1.0

# kiểm chứng bằng đạo hàm số
h = 1e-6
L2 = ((2.0 + h) + (-3.0)) * (-4.0)
print("xấp xỉ số dL/da =", (L2 - 4.0) / h)   # ≈ -4.0  ✓

Bẫy sống còn: += chứ không phải =

Nếu viết self.grad = ..., code sẽ chạy sai mỗi khi một biến xuất hiện nhiều hơn một lần. Ví dụ $y = x + x$: đạo hàm đúng là $2$, nhưng phép gán đè sẽ ghi $1$ rồi ghi đè lại $1$. Sai âm thầm, không exception, không cảnh báo — loại bug tệ nhất.

Đây cũng chính là lý do PyTorch cộng dồn gradient và bắt bạn gọi optimizer.zero_grad(): engine không thể biết khi nào bạn "xong" một đồ thị, nên nó luôn cộng, và việc xoá là trách nhiệm của bạn. Bây giờ bạn đã hiểu vì sao, thay vì học vẹt.

Vì sao bắt buộc phải topological sort

Một nút $v$ có thể gửi đầu ra cho nhiều nút phía sau. Nếu ta gọi v._backward() khi mới chỉ có một nhánh dội gradient về, thì v.grad còn thiếu, và cái thiếu ấy lan sang toàn bộ tổ tiên của $v$. Topological sort đảm bảo: khi tới lượt $v$, mọi nút phụ thuộc vào $v$ đã xử lý xong, nên v.grad đã đầy đủ. Trong phòng thí nghiệm phía trên, hãy chọn biểu thức x·x + x và để ý nút x luôn là nút cuối cùng được xử lý.

5. Dùng engine đó dựng một mạng thật

Điểm mấu chốt: một khi có Value, bạn không bao giờ phải nghĩ về đạo hàm nữa. Cứ viết forward bằng Python bình thường, engine tự lo phần còn lại.

MLP xây trên micrograd — vẫn Python thuần
import random

class Neuron:
    def __init__(self, nin):
        self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
        self.b = Value(0.0)
    def __call__(self, x):
        act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
        return act.tanh()
    def parameters(self):
        return self.w + [self.b]

class Layer:
    def __init__(self, nin, nout):
        self.neurons = [Neuron(nin) for _ in range(nout)]
    def __call__(self, x):
        outs = [n(x) for n in self.neurons]
        return outs[0] if len(outs) == 1 else outs
    def parameters(self):
        return [p for n in self.neurons for p in n.parameters()]

class MLP:
    def __init__(self, nin, nouts):
        sizes = [nin] + nouts
        self.layers = [Layer(sizes[i], sizes[i+1]) for i in range(len(nouts))]
    def __call__(self, x):
        for layer in self.layers:
            x = layer(x)
        return x
    def parameters(self):
        return [p for l in self.layers for p in l.parameters()]


# ===== huấn luyện: đúng vòng lặp 5 bước của Module 00 =====
xs = [[2.0, 3.0, -1.0], [3.0, -1.0, 0.5], [0.5, 1.0, 1.0], [1.0, 1.0, -1.0]]
ys = [1.0, -1.0, -1.0, 1.0]
model = MLP(3, [4, 4, 1])           # 3 đầu vào → 4 → 4 → 1;  41 tham số
print("số tham số:", len(model.parameters()))

for buoc in range(100):
    ypred = [model(x) for x in xs]                              # 2. forward
    loss  = sum((yp - yt) ** 2 for yp, yt in zip(ypred, ys))    # 3. loss

    for p in model.parameters():                                # 4a. XOÁ grad cũ
        p.grad = 0.0
    loss.backward()                                             # 4b. backward

    for p in model.parameters():                                # 5. cập nhật
        p.data -= 0.05 * p.grad

    if buoc % 20 == 0:
        print(f"buoc {buoc:3d}  loss = {loss.data:.6f}")

print([f"{yp.data:+.3f}" for yp in [model(x) for x in xs]])
# ['+0.997', '-0.998', '-0.996', '+0.997']  ← khớp gần hoàn hảo với ys

Bốn mươi mốt tham số, một trăm bước, và mạng khớp dữ liệu. So sánh với PyTorch: p.data -= 0.05*p.grad chính là optimizer.step(), p.grad = 0.0 chính là optimizer.zero_grad(), và loss.backward() thì tên còn giống hệt. PyTorch chỉ khác ở chỗ mỗi Value được thay bằng một tensor chứa hàng triệu số và chạy trên GPU — kiến trúc thì y hệt cái bạn vừa viết.

Nó ứng dụng vào đâu trong đời thật

  • Đọc hiểu lỗi PyTorch. Khi gặp RuntimeError: Trying to backward through the graph a second time, bạn hiểu ngay: đồ thị đã bị giải phóng sau lần backward đầu; hoặc bạn quên .detach() một tensor mang theo lịch sử từ vòng lặp trước.
  • Tự viết layer mới. Muốn một phép toán chưa có sẵn? Chỉ cần cài forward và backward — đúng khuôn torch.autograd.Function, mà giờ bạn đã hiểu tận gốc.
  • Tiết kiệm bộ nhớ. Biết rằng mỗi giá trị trung gian phải được giữ lại cho backward, bạn hiểu vì sao torch.no_grad() lúc inference giảm VRAM tới một nửa, và vì sao gradient checkpointing (bỏ giá trị trung gian đi rồi tính lại) đánh đổi thời gian lấy bộ nhớ.

Tự kiểm tra

1 · Vì sao backward() phải khởi động bằng self.grad = 1.0?

Vì $\partial L/\partial L = 1$ theo định nghĩa. Nó là "mồi" cho dây chuyền nhân dồn: mọi gradient khác đều là tích của 1.0 với các đạo hàm cục bộ dọc đường. Nếu mồi bằng 0 thì cả đồ thị nhận gradient 0. Nếu bạn mồi bằng 2.0, mọi gradient nhân đôi — tương đương nhân đôi learning rate.

2 · Đạo hàm cục bộ của cổng tanh khi đầu ra $t = 0.999$ là bao nhiêu? Hậu quả?

$1 - t^2 = 1 - 0.998 = 0.002$. Mọi gradient đi qua cổng này bị nhân với $0.002$, tức gần như bị triệt tiêu. Nơ-ron đó đã bão hoà: nó vẫn xuất giá trị, nhưng không còn học được nữa vì không nhận được tín hiệu sửa lỗi. Với 10 tầng bão hoà nối tiếp, gradient tới tầng đầu là $0.002^{10} \approx 10^{-27}$ — bằng 0 về mặt số học. Module 06 dành trọn để chữa căn bệnh này.

3 · Backprop tốn bộ nhớ ở chỗ nào? Vì sao batch lớn hay bị CUDA out of memory?

Mọi giá trị trung gian của forward phải được giữ nguyên trong RAM/VRAM cho tới khi backward dùng xong, vì đạo hàm cục bộ cần chúng (ví dụ cổng nhân cần other.data). Bộ nhớ activation tỉ lệ thuận với batch_size × số tầng × kích thước tầng — và với Transformer còn thêm ma trận attention cỡ $T^2$ mỗi head. Đó là lý do gấp đôi batch thường gấp đôi VRAM, trong khi số tham số không đổi một byte.

Phần I — Autograd & mô hình đầu tiên · Module 04

04 Bigram: cây cầu từ đếm xác suất sang mạng nơ-ron

Cần biết trước: Module 02 (cross-entropy), Module 03 (autograd) Dựng nên: mô hình ngôn ngữ đầu tiên Dẫn tới: vì sao phải có embedding và ngữ cảnh dài hơn

Mục tiêu

  • Hiểu mô hình ngôn ngữ là gì ở dạng đơn giản nhất có thể: đoán ký tự kế tiếp.
  • Xây mô hình bằng đếm tần suất — không cần học gì cả — rồi đo chất lượng bằng NLL.
  • Xây đúng mô hình đó bằng mạng nơ-ron + gradient descent, và thấy hai cách hội tụ về cùng một kết quả.
  • Hiểu vì sao phiên bản mạng nơ-ron — dù rắc rối hơn — mới là con đường dẫn tới GPT.

1. Mô hình ngôn ngữ, phát biểu cho gọn

Một mô hình ngôn ngữ chỉ làm đúng một việc: cho chuỗi ký tự (hoặc từ) đã có, gán xác suất cho ký tự tiếp theo. ChatGPT làm việc đó với 100.000 token và ngữ cảnh 100.000 vị trí; ta bắt đầu với 27 ký tự và ngữ cảnh dài đúng 1.

Dữ liệu: danh sách tên tiếng Việt không dấu — an, binh, chi, dung, giang, .... Ta thêm ký tự đặc biệt . làm dấu bắt đầu và kết thúc. Vậy tên an sinh ra các cặp huấn luyện:

.a   →  ta thấy '.', mục tiêu đoán 'a'
an   →  ta thấy 'a', mục tiêu đoán 'n'
n.   →  ta thấy 'n', mục tiêu đoán '.' (kết thúc)

2. Cách 1 — chỉ cần đếm

Lập bảng $N$ kích thước $27\times27$: ô $N[i][j]$ đếm số lần ký tự $j$ đi ngay sau ký tự $i$. Xong. Xác suất chỉ là chuẩn hoá theo hàng:

$$P(j \mid i) = \frac{N[i][j] + \alpha}{\sum_k \big(N[i][k] + \alpha\big)}$$

$\alpha$ là làm mượt (smoothing): cộng thêm một lượng nhỏ vào mọi ô để không ô nào có xác suất đúng bằng 0. Vì sao quan trọng đến thế? Vì nếu $P = 0$ mà cặp đó lại xuất hiện trong dữ liệu kiểm tra, loss $= -\log 0 = \infty$. Một cặp chưa từng thấy làm hỏng toàn bộ phép đo.

Phòng thí nghiệm 04

Bảng bigram sống — đếm, sinh tên, rồi để mạng nơ-ron học lại chính bảng đó

Rê chuột lên ô bất kỳ để đọc số liệu
Ma trận bigram: hàng = ký tự hiện tại → cột = ký tự kế tiếp
0.1
α = 0: trung thành dữ liệu nhưng rủi ro vô cực. α lớn: mọi thứ nhoè về phân phối đều.
1.00
Số tên—
Số cặp bigram—
NLL đếm—
NLL mạng NN—
Đoán mò3.296
Tên do mô hình sinh ra

Bấm Sinh 8 tên.

Điều cần thấy: ① Ở chế độ xác suất màu được chuẩn hoá theo từng hàng (ô đậm nhất của mỗi hàng = lựa chọn khả dĩ nhất sau ký tự đó). Hàng . (đầu tiên) cho biết tên tiếng Việt hay bắt đầu bằng chữ gì; cột . cho biết hay kết thúc bằng chữ gì — mô hình đã "học" được quy luật ngữ âm mà không ai lập trình cho nó. Các hàng f j w z phẳng lì vì tiếng Việt không dùng những chữ này — với α > 0 chúng trở thành phân phối đều, đúng những gì làm mượt hứa hẹn. ② Bấm Train mạng nơ-ron: một mạng tuyến tính 27×27 khởi tạo ngẫu nhiên sẽ tụt từ NLL ≈ 3.7 xuống bám sát NLL của bảng đếm. Đổi sang chế độ Chênh lệch — gần như đen kịt, nghĩa là hai phương pháp đi tới cùng một đáp án. ③ Kéo nhiệt độ xuống 0.3: tên sinh ra rất "an toàn", lặp đi lặp lại. Kéo lên 1.8: tên loạn xạ, nhiều chuỗi không đọc được. Đây đúng là núm temperature của mọi API LLM.

3. Cách 2 — đúng mô hình đó, nhưng bằng mạng nơ-ron

Ta cố tình làm phức tạp lên. Vì sao? Sẽ trả lời ở cuối mục. Trước hết, quy trình:

  1. One-hot encoding. Ký tự thứ $i$ được biểu diễn bằng véc-tơ 27 chiều toàn 0, riêng vị trí $i$ bằng 1. Đây là cách nhét một thứ rời rạc (chữ cái) vào một phép toán liên tục (nhân ma trận).
  2. Một tầng tuyến tính duy nhất. Nhân véc-tơ one-hot với ma trận trọng số $W$ kích thước $27\times27$, ra 27 logits.
  3. Softmax. Biến logits thành 27 xác suất có tổng bằng 1.
  4. Cross-entropy + gradient descent. Đúng vòng lặp 5 bước.

Nhận ra điều bất ngờ: one-hot @ W chính là "tra bảng"

Nhân một véc-tơ one-hot với ma trận $W$ không hề tính toán gì cả — nó chỉ lấy ra hàng thứ $i$ của $W$. Toàn bộ 27 phép nhân kia là nhân với 0.

Đây là cách nhìn quyết định cho mọi thứ về sau: lớp Embedding trong PyTorch chỉ là phép tra bảng, và một tầng tuyến tính đứng sau one-hot cũng chỉ là tra bảng. Chúng là một. Khác biệt duy nhất là nn.Embedding cài đặt hiệu quả hơn (đánh chỉ số trực tiếp thay vì nhân ma trận thưa).

Đi xa hơn: nếu đặt $W[i][j] = \log N[i][j]$ thì softmax của hàng $i$ cho ra đúng $P(j|i)$ của bảng đếm. Nghĩa là logits chính là log của số đếm, và bảng đếm là nghiệm tối ưu toàn cục của bài toán này. Gradient descent chỉ đang mò tới đó bằng con đường vòng.

Bigram bằng PyTorch — bản đầy đủ, ~30 dòng
import torch
import torch.nn.functional as F

words = open('ten.txt').read().splitlines()      # mỗi dòng một cái tên
chars = sorted(set(''.join(words)))
stoi  = {c: i + 1 for i, c in enumerate(chars)};  stoi['.'] = 0
itos  = {i: c for c, i in stoi.items()}
V     = len(stoi)

# ---- dựng tập huấn luyện: mọi cặp (ký tự hiện tại, ký tự kế tiếp) ----
xs, ys = [], []
for w in words:
    for c1, c2 in zip('.' + w, w + '.'):
        xs.append(stoi[c1]);  ys.append(stoi[c2])
xs, ys = torch.tensor(xs), torch.tensor(ys)
n = xs.nelement()
print(f"{n} cặp huấn luyện")

# ---- mô hình: đúng MỘT ma trận trọng số ----
g = torch.Generator().manual_seed(2147483647)
W = torch.randn((V, V), generator=g, requires_grad=True)

for k in range(200):
    # 2. FORWARD
    xenc   = F.one_hot(xs, num_classes=V).float()   # (n, V)
    logits = xenc @ W                               # (n, V) — log của số đếm
    # softmax viết tay cho rõ ràng:
    counts = logits.exp()
    probs  = counts / counts.sum(dim=1, keepdim=True)

    # 3. LOSS = trung bình -log(xác suất của ký tự đúng) + regularization
    loss = -probs[torch.arange(n), ys].log().mean() + 0.01 * (W ** 2).mean()

    # 4. BACKWARD
    W.grad = None          # tương đương optimizer.zero_grad()
    loss.backward()

    # 5. CẬP NHẬT
    W.data += -50 * W.grad # lr = 50: hợp lệ vì mô hình cực nông và gradient bé

    if k % 40 == 0:
        print(f"buoc {k:3d}  loss = {loss.item():.4f}")
# buoc   0  loss = 3.7686
# buoc 199  loss = 2.4820      ← khớp NLL của bảng đếm

# ---- sinh tên mới ----
g = torch.Generator().manual_seed(2147483647)
for _ in range(5):
    out, ix = [], 0
    while True:
        logits = (F.one_hot(torch.tensor([ix]), num_classes=V).float() @ W)
        p  = F.softmax(logits, dim=1)
        ix = torch.multinomial(p, num_samples=1, generator=g).item()
        if ix == 0: break
        out.append(itos[ix])
    print(''.join(out))

Bẫy: số hạng 0.01 * (W**2).mean() làm gì ở đó?

Đó là regularization (weight decay / L2). Nó thêm một lực kéo mọi trọng số về 0. Với bigram, tác dụng của nó tương đương chính xác với việc làm mượt $\alpha$ ở bảng đếm: trọng số nhỏ → logits gần nhau → phân phối bẹt hơn → không ô nào có xác suất 0. Hệ số càng lớn thì càng bẹt.

Đây là ví dụ đẹp nhất để hiểu regularization: nó không phải phép thuật, nó là tri thức tiên nghiệm ("đừng quá tự tin về những gì chưa thấy đủ nhiều") được cài vào hàm mất mát.

4. Vậy rốt cuộc vì sao phải dùng mạng nơ-ron?

Với ngữ cảnh 1 ký tự, đếm còn nhanh hơn và cho kết quả y hệt. Câu trả lời nằm ở chuyện mở rộng:

Độ dài ngữ cảnhKích thước bảng đếmVấn đề
1 ký tự (bigram)$27^2 = 729$ ôỔn.
2 ký tự (trigram)$27^3 \approx 20$ nghìnBắt đầu thưa.
5 ký tự$27^6 \approx 387$ triệuHầu hết ô bằng 0 — không đủ dữ liệu để đếm.
10 ký tự$27^{11} \approx 5\times10^{15}$Bất khả thi. Đây là lời nguyền số chiều.

Bảng đếm phình theo hàm mũ của độ dài ngữ cảnh, và tệ hơn: nó không biết khái quát hoá. Nếu đã thấy "nguyễn văn" 1000 lần nhưng chưa thấy "nguyễn vân" lần nào, bảng đếm gán xác suất 0 cho trường hợp thứ hai — dù hai chuỗi gần như đồng nhất.

Mạng nơ-ron thoát cả hai vấn đề: số tham số tăng tuyến tính (thậm chí hằng số) theo ngữ cảnh, và vì các đầu vào tương tự nhau được ánh xạ tới các véc-tơ gần nhau trong không gian, mạng chia sẻ được tri thức giữa những trường hợp giống nhau. Chính ý tưởng đó là nội dung Module 05.

Nó ứng dụng vào đâu trong đời thật

  • Gợi ý gõ phím trên điện thoại nhiều năm chạy bằng n-gram đếm y như thế này (có back-off và Kneser-Ney smoothing tinh vi hơn).
  • Baseline bắt buộc. Trước khi huấn luyện bất kỳ LLM nào, hãy đo perplexity của bigram/trigram trên chính tập dữ liệu đó. Nếu mô hình 100 triệu tham số của bạn không đánh bại được bảng đếm, bạn có bug — chứ không phải cần thêm GPU.
  • Phát hiện bất thường. Bigram trên chuỗi lệnh shell hoặc log hệ thống là bộ dò xâm nhập rẻ tiền mà hiệu quả: chuỗi có NLL cao bất thường thì đáng nghi.
  • Nhiệt độ khi sinh mà bạn vừa nghịch chính là tham số temperature trong mọi API LLM. Giờ bạn biết nó chia logits, chứ không phải "làm mô hình sáng tạo hơn" một cách huyền bí.

Tự kiểm tra

1 · NLL lúc khởi tạo ngẫu nhiên nên bằng bao nhiêu? Nếu đo được 8.2 thì đã sai ở đâu?

Nên bằng $\log 27 = 3.296$ — mô hình chưa biết gì thì rải đều 27 lựa chọn. Nếu đo được 8.2, nghĩa là mô hình đang tự tin sai: logits khởi tạo quá lớn khiến softmax gán xác suất cao cho một lớp ngẫu nhiên nào đó. Cách sửa: khởi tạo W với biên độ nhỏ hơn (nhân với 0.01). Đây là mẹo chẩn đoán số một, và Module 06 sẽ khai thác triệt để nó.

2 · Vì sao learning rate 50 lại chạy được ở đây, trong khi Module 02 nói 0.4 đã nổ?

Vì thang độ của gradient hoàn toàn khác. Ở đây loss lấy trung bình trên ~1500 mẫu, mô hình chỉ có một tầng tuyến tính và không có kích hoạt phi tuyến nào để khuếch đại — gradient trên mỗi phần tử của $W$ rất bé (cỡ $10^{-3}$). Learning rate không có "giá trị đúng phổ quát"; nó luôn phụ thuộc thang đo gradient của chính mô hình đó. Đó cũng là lý do Adam — vốn tự chuẩn hoá theo độ lớn gradient — bớt nhạy cảm với việc chọn $\eta$.

3 · Đâu là giới hạn lý thuyết của mô hình bigram? Loss có thể xuống 0 không?

Không bao giờ. Bigram chỉ nhìn một ký tự phía trước, nên với cùng một ký tự hiện tại, dữ liệu thật chứa nhiều ký tự kế tiếp khác nhau — đó là bất định nội tại (entropy của dữ liệu có điều kiện). Loss tối thiểu đúng bằng entropy có điều kiện ấy — khoảng 1.9 trên tập tên tiếng Việt của phòng thí nghiệm, hay 2.45 trên tập tên tiếng Anh kinh điển. Muốn xuống thấp hơn thì bắt buộc phải nhìn xa hơn. Mọi tiến bộ từ đây tới GPT về bản chất chỉ là các cách khác nhau để nhìn xa hơn mà không nổ tung về chi phí.

Phần II — Mạng sâu & huấn luyện ổn định · Module 05

05 MLP Bengio & không gian Embedding

Cần biết trước: Module 04 (one-hot = tra bảng) Dựng nên: mạng sâu thật đầu tiên + mini-batch Dẫn tới: vấn đề khởi tạo & bão hoà ở Module 06

Mục tiêu

  • Hiểu embedding thực chất là gì và vì sao nó phá được lời nguyền số chiều.
  • Dựng kiến trúc Bengio 2003 — bộ khung mà mọi mô hình ngôn ngữ hiện đại vẫn dùng.
  • Nhìn tận mắt không gian embedding 3D tự tổ chức trong lúc huấn luyện: nguyên âm tụm lại một chỗ.
  • Nắm mini-batch, chia train/val/test, và cách dò learning rate.

1. Ý tưởng cốt lõi: biến ký tự rời rạc thành điểm trong không gian

Vấn đề của bảng đếm là mọi ký tự hoàn toàn xa lạ với nhau: a và e chỉ là chỉ số 1 và 5, không có quan hệ gì. Học được điều gì về a không giúp ích chút nào cho e.

Bengio (2003) đề xuất: gán cho mỗi ký tự một véc-tơ số thực (ví dụ 3 chiều), và để mạng tự học các con số đó cùng lúc với mọi tham số khác. Nếu a và e đóng vai trò giống nhau về mặt ngữ âm, gradient descent sẽ tự đẩy hai véc-tơ của chúng lại gần nhau — vì đặt chúng gần nhau làm giảm loss.

Hệ quả cực mạnh: khi mạng gặp một ngữ cảnh chưa từng thấy nhưng gần một ngữ cảnh đã thấy trong không gian embedding, nó vẫn dự đoán hợp lý. Đây chính là khái quát hoá mà bảng đếm không bao giờ có được.

Ngữ cảnh: 3 ký tự trước đó → đoán ký tự thứ 4 'd' 'u' 'n' chỉ số 4, 21, 14 Bảng tra C (27 × 10) embedding — HỌC ĐƯỢC Ghép nối 3×10 = 30 view(-1, 30) W₁ (30×200) + b₁ tanh tầng ẩn 200 nơ-ron W₂ (200×27) + b₂ 27 logits softmax → 27 xác suất cross entropy Tổng tham số: C (270) + W₁,b₁ (6.200) + W₂,b₂ (5.427) ≈ 11.900 — so với bảng trigram cần 20.000 ô và vẫn thưa rỗng. Điểm mấu chốt: tăng ngữ cảnh từ 3 lên 8 ký tự chỉ làm W₁ to lên tuyến tính, chứ không phình theo hàm mũ.
Kiến trúc Bengio 2003. Bảng tra C là thứ duy nhất mới so với Module 04; phần còn lại đúng là MLP quen thuộc. Kiến trúc này vẫn còn nguyên trong GPT — chỉ khác ở chỗ "ghép nối" được thay bằng self-attention.
Phòng thí nghiệm 05 · 3D

Không gian embedding tự tổ chức — huấn luyện thật, ngay trong trình duyệt

Kéo để xoay khối 3D
Embedding 3 chiều của 27 ký tự — xanh: nguyên âm · đỏ: dấu kết thúc
Loss train (đậm) & val (nhạt)
64
0.10
Bước0
Loss train—
Loss val—
Tham số—
Tên sinh ra sau huấn luyện

Bấm Huấn luyện — quá trình chạy khoảng 3–6 giây, embedding sẽ chuyển động trực tiếp.

Điều cần thấy: ① Lúc khởi tạo, 27 điểm nằm rải ngẫu nhiên như đám mây bụi. Trong lúc train, chúng tự tách thành cụm: các nguyên âm a e i o u y trôi về gần nhau vì chúng có thể thay thế cho nhau trong nhiều ngữ cảnh; dấu . bay ra một góc riêng vì vai trò của nó độc nhất. Không ai lập trình điều này — nó rơi ra từ việc tối ưu loss. ② Đây chính xác là hiện tượng đứng sau word2vec và mọi véc-tơ ngữ nghĩa hiện đại. ③ So sánh loss train và val: kéo tầng ẩn lên 128 với ngữ cảnh 3, train lâu — loss train tiếp tục giảm trong khi loss val chạm đáy rồi đi lên. Bạn vừa nhìn thấy overfitting xảy ra trực tiếp.

2. Mã nguồn hoàn chỉnh (PyTorch)

MLP Bengio với mini-batch và chia train/val/test
import torch, torch.nn.functional as F, random

# ---------- dữ liệu ----------
block_size = 3          # nhìn lại bao nhiêu ký tự

def build_dataset(words):
    X, Y = [], []
    for w in words:
        context = [0] * block_size          # '...' phần đệm đầu chuỗi
        for ch in w + '.':
            ix = stoi[ch]
            X.append(context); Y.append(ix)
            context = context[1:] + [ix]    # trượt cửa sổ sang phải
    return torch.tensor(X), torch.tensor(Y)

random.seed(42); random.shuffle(words)
n1, n2 = int(0.8 * len(words)), int(0.9 * len(words))
Xtr,  Ytr  = build_dataset(words[:n1])     # 80% học
Xdev, Ydev = build_dataset(words[n1:n2])   # 10% chỉnh siêu tham số
Xte,  Yte  = build_dataset(words[n2:])     #  10% đo lần cuối, đúng MỘT lần

# ---------- tham số ----------
g = torch.Generator().manual_seed(2147483647)
n_emb, n_hid = 10, 200
C  = torch.randn((27, n_emb),               generator=g)
W1 = torch.randn((n_emb * block_size, n_hid), generator=g) * 0.2
b1 = torch.zeros(n_hid)
W2 = torch.randn((n_hid, 27),               generator=g) * 0.01   # ← nhỏ có chủ đích
b2 = torch.zeros(27)
parameters = [C, W1, b1, W2, b2]
for p in parameters: p.requires_grad = True
print(sum(p.nelement() for p in parameters), "tham số")

# ---------- vòng lặp huấn luyện ----------
for i in range(200_000):
    ix = torch.randint(0, Xtr.shape[0], (32,))        # 1. lấy lô 32 mẫu

    emb = C[Xtr[ix]]                                  # (32, 3, 10) — TRA BẢNG
    h   = torch.tanh(emb.view(-1, n_emb * block_size) @ W1 + b1)   # (32, 200)
    logits = h @ W2 + b2                              # (32, 27)
    loss   = F.cross_entropy(logits, Ytr[ix])         # gộp softmax + NLL

    for p in parameters: p.grad = None                # 4a
    loss.backward()                                   # 4b

    lr = 0.1 if i < 100_000 else 0.01                 # 5. hạ lr nửa sau
    for p in parameters: p.data += -lr * p.grad

@torch.no_grad()
def split_loss(X, Y):
    emb = C[X]
    h   = torch.tanh(emb.view(-1, n_emb * block_size) @ W1 + b1)
    return F.cross_entropy(h @ W2 + b2, Y).item()

print("train", split_loss(Xtr, Ytr))    # ≈ 2.12
print("val  ", split_loss(Xdev, Ydev))  # ≈ 2.16   ← so với bigram 2.48

Bốn chi tiết dễ làm sai trong đoạn code trên

  • emb.view(-1, 30) so với emb.view(32, 30). Dùng -1 để PyTorch tự suy ra chiều đầu — lô cuối cùng có thể không đủ 32 mẫu. Và phải là .view/.reshape chứ không phải .permute: ta muốn ghép nối 3 embedding thành một véc-tơ 30 chiều, thứ tự phải giữ nguyên.
  • W2 * 0.01. Cố ý khởi tạo tầng cuối rất nhỏ để logits ban đầu gần như bằng nhau → loss khởi đầu ≈ $\log 27 = 3.3$ thay vì 27. Không có mẹo này, vài nghìn bước đầu bị lãng phí chỉ để bóp các logits vô lý về lại gần 0 (Karpathy gọi đây là "hockey stick loss curve").
  • Dùng F.cross_entropy thay vì tự viết softmax rồi log. Bản của PyTorch trừ đi max của logits trước khi exp để tránh tràn số, và có công thức backward rút gọn. Tự viết thì exp(90) ra inf và loss thành nan.
  • Không bao giờ nhìn tập test cho tới khi xong hết. Mỗi lần bạn chỉnh siêu tham số dựa trên test, bạn đang rò rỉ thông tin và tự lừa mình. Đó là việc của tập val.

3. Cách dò learning rate như dân chuyên nghiệp

Đừng đoán mò. Chạy vài trăm bước với $\eta$ tăng dần theo hàm mũ, vẽ loss theo $\eta$, rồi chọn giá trị nằm ở giữa đoạn dốc xuống mạnh nhất — thường là khoảng 1/3 của giá trị làm loss bắt đầu nổ.

lre  = torch.linspace(-3, 0, 1000)     # số mũ từ -3 tới 0
lrs  = 10 ** lre                       # tức lr chạy từ 0.001 tới 1.0
losses = []
for i in range(1000):
    ...  # forward + backward như thường
    for p in parameters: p.data += -lrs[i] * p.grad
    losses.append(loss.item())
plt.plot(lre, losses)   # tìm đáy của chữ U → đó là vùng lr tốt

Nó ứng dụng vào đâu trong đời thật

  • Hệ gợi ý. Thay ký tự bằng ID sản phẩm/bài hát; embedding học được sẽ đặt các món hay được mua cùng nhau gần nhau. Đây là xương sống của gợi ý ở Spotify, Shopee, TikTok.
  • Tìm kiếm ngữ nghĩa & RAG. Embed câu hỏi và tài liệu vào cùng một không gian, rồi lấy hàng xóm gần nhất bằng cosine. Vector database (Pinecone, Qdrant, pgvector) tồn tại chỉ để làm nhanh phép "hàng xóm gần nhất" trên không gian bạn vừa nhìn thấy trong 3D.
  • Dữ liệu phân loại trong bảng. Cột "mã tỉnh thành" có 63 giá trị: one-hot cho ra 63 cột thưa; embedding 8 chiều vừa gọn hơn vừa học được rằng các tỉnh lân cận thì hành xử giống nhau.
  • Chẩn đoán mô hình. Chiếu embedding về 2–3 chiều (PCA/t-SNE/UMAP) là cách nhanh nhất để phát hiện mô hình đang gộp nhầm hai lớp lẽ ra phải tách.

Tự kiểm tra

1 · Tăng số chiều embedding từ 2 lên 10 thì được gì, mất gì?

Được: nhiều "trục ngữ nghĩa" hơn để mô tả mỗi ký tự (nguyên âm/phụ âm, hay đứng đầu/cuối, ...). Với 2 chiều, mọi thông tin bị ép vào một mặt phẳng và các ký tự khác vai trò buộc phải chen chúc. Mất: thêm tham số nên dễ overfit hơn với dữ liệu nhỏ, và khó trực quan hoá. Với bộ chữ cái 27 ký tự, 10 chiều là quá đủ; với từ vựng 50.000 token thì cần 768 chiều trở lên (GPT-2 dùng 768, GPT-3 dùng 12.288).

2 · Vì sao lô 32 mẫu ngẫu nhiên lại hiệu quả hơn dùng cả tập dữ liệu mỗi bước?

Gradient trên 32 mẫu là một ước lượng nhiễu của gradient thật, nhưng rẻ hơn hàng nghìn lần. Thà đi 1000 bước hơi lệch hướng còn hơn 1 bước hoàn hảo: bạn tới đích nhanh hơn nhiều. Thêm nữa, chính cái nhiễu ấy giúp thoát cực tiểu nông — đúng thí nghiệm ④ ở phòng lab 3D Module 02. Đây là lý do "stochastic" trong SGD là một tính năng chứ không phải sự đánh đổi miễn cưỡng.

3 · Loss train 2.1 còn loss val 2.6. Chẩn đoán và kê đơn?

Overfitting: mô hình đang thuộc lòng tập train. Đơn thuốc, theo thứ tự ưu tiên: (1) thêm dữ liệu — luôn hiệu quả nhất; (2) giảm dung lượng mô hình (ít nơ-ron ẩn hơn, embedding nhỏ hơn); (3) regularization: weight decay, dropout; (4) early stopping — dừng ngay khi loss val chạm đáy. Ngược lại, nếu train ≈ val và cả hai đều cao thì là underfitting: mô hình quá nhỏ hoặc chưa train đủ lâu — hãy làm điều ngược lại.

Phần II — Mạng sâu & huấn luyện ổn định · Module 06

06 Khởi tạo trọng số, bão hoà & Batch Normalization

Cần biết trước: Module 03 (cổng tanh làm teo gradient), Module 05 Dựng nên: kỹ năng chẩn đoán mạng không chịu học Dẫn tới: LayerNorm trong Transformer

Mục tiêu

  • Hiểu vì sao một hằng số nhân sai lúc khởi tạo có thể giết chết cả mạng 50 tầng.
  • Suy ra công thức khởi tạo Kaiming từ đầu, thay vì học thuộc sqrt(2/fan_in).
  • Đọc được histogram activation — công cụ chẩn đoán nhanh nhất mà một kỹ sư ML có.
  • Hiểu BatchNorm làm gì, vì sao nó có hai chế độ train/eval, và vì sao Transformer lại chọn LayerNorm.

1. Vì sao khởi tạo lại quan trọng đến thế

Xét một tầng $y = Wx$ với fan-in $= n$. Nếu các thành phần độc lập và có trung bình 0:

$$\operatorname{Var}(y) = n \cdot \operatorname{Var}(w) \cdot \operatorname{Var}(x)$$

Đọc dòng này rất kỹ, vì mọi thứ còn lại chỉ là hệ quả. Đặt $\operatorname{Var}(x) = 1$ và hỏi: tín hiệu ra có giữ được độ xoè bằng tín hiệu vào không?

  • Nếu $n\operatorname{Var}(w) > 1$ → phương sai nhân lên mỗi tầng. Qua 10 tầng, hệ số $1.5^{10} \approx 58$: mọi $z$ đều rất lớn, tanh bão hoà hết ở $\pm 1$, đạo hàm $1-t^2 \approx 0$ → không học được gì.
  • Nếu $n\operatorname{Var}(w) < 1$ → phương sai teo dần. Qua 10 tầng, $0.5^{10} \approx 0.001$: mọi activation dồn về 0, mạng mất hết khả năng biểu diễn, gradient cũng teo theo.
  • Chỉ khi $n\operatorname{Var}(w) = 1$, tức $\boxed{\operatorname{Var}(w) = 1/n}$, tín hiệu mới đi xuyên qua mạng mà giữ nguyên độ xoè.

Từ đó ra đúng các công thức bạn thấy trong tài liệu, chỉ khác nhau ở hằng số bù cho hàm kích hoạt:

TênĐộ lệch chuẩn của $w$Dùng choLý do hằng số
Xavier / Glorot$\sqrt{1/n_{\text{in}}}$ (hoặc $\sqrt{2/(n_{in}+n_{out})}$)tanh, sigmoidtanh gần như tuyến tính quanh 0, không đổi thang đo.
Kaiming / He$\sqrt{2/n_{\text{in}}}$ReLUReLU vứt bỏ một nửa tín hiệu (phần âm) nên phương sai giảm còn 1/2 → phải nhân bù $\sqrt{2}$.
Kaiming cho tanh$\dfrac{5/3}{\sqrt{n_{\text{in}}}}$tanhgain 5/3 bù lại phần tanh bóp tín hiệu.
Chuẩn cho Transformer$0.02$ cố định, chia thêm $\sqrt{2L}$ ở tầng residualGPT-2/nanoGPTGiữ phương sai ổn định khi cộng dồn $L$ nhánh residual.
Phòng thí nghiệm 06

Máy chụp X-quang mạng sâu — histogram activation & gradient theo từng tầng

Kéo "gain" và nhìn mạng sống hay chết
Phân phối giá trị activation ở mỗi tầng (forward)
Phân phối gradient ở mỗi tầng (backward)
1.00
w ~ N(0, gain²/fan_in). Thử 0.4 (teo), 1.67 (=5/3, chuẩn cho tanh), 3.5 (bão hoà).
6
Std tầng cuối—
% bão hoà (|a|>0.97)—
Grad tầng 1 / tầng cuối—
Chẩn đoán

—

Ba bệnh án cần nhận mặt: ① gain = 0.4, tanh, 8 tầng → histogram co lại thành cây kim nhọn quanh 0. Mọi nơ-ron xuất giá trị gần như nhau: mạng 8 tầng đang hoạt động như 0 tầng. Gradient cũng teo theo cấp số nhân. ② gain = 3.5 → histogram phình ra hai mép −1 và +1: bão hoà. Chỉ số "% bão hoà" vượt 40%, và tỉ số gradient đầu/cuối vọt lên trên 3 — gradient nở ra khi đi ngược, mô hình sẽ nổ. ③ gain = 1.67 (đúng 5/3) → histogram giữ nguyên hình chuông đẹp, độ rộng gần như không đổi qua cả 8 tầng. Đó là dấu hiệu của một mạng khoẻ. ④ Bây giờ bật BatchNorm rồi kéo gain lung tung từ 0.1 tới 4 — histogram gần như không đổi. Đó chính là điều BatchNorm đem lại: nó làm việc chọn khởi tạo bớt sinh tử.

2. Mẹo "logits ban đầu phải nhỏ"

Một dạng khác của cùng vấn đề, xảy ra ở tầng cuối. Nếu $W_2$ khởi tạo bình thường, logits ban đầu sẽ rải rác trong khoảng $\pm 5$; softmax biến chúng thành những xác suất rất lệch — mô hình tự tin một cách ngẫu nhiên. Loss khởi đầu có thể là 27 thay vì 3.3.

Hậu quả không phải là sai kết quả cuối cùng, mà là lãng phí: vài nghìn bước đầu tiên chỉ dùng để bóp các logits vô nghĩa về gần 0, tạo ra đường loss hình gậy hockey. Cách chữa nằm gọn trong một dòng:

W2 = torch.randn((n_hid, vocab)) * 0.01   # gần như bằng 0
b2 = torch.zeros(vocab)                   # bias tầng cuối luôn khởi tạo 0

# kiểm tra: loss bước 0 phải xấp xỉ log(vocab)
# log(27) = 3.296 ✓

3. Batch Normalization

Ý tưởng táo bạo của Ioffe & Szegedy (2015): thay vì cầu nguyện cho khởi tạo đúng, hãy ép phân phối về chuẩn ngay trong lúc chạy — biến nó thành một phần của mạng.

$$\hat{x} = \frac{x - \mu_{\mathcal{B}}}{\sqrt{\sigma^2_{\mathcal{B}} + \epsilon}}, \qquad y = \gamma \hat{x} + \beta$$

Trong đó $\mu_\mathcal{B}, \sigma^2_\mathcal{B}$ là trung bình và phương sai tính trên chiều batch, còn $\gamma$ (gain) và $\beta$ (bias) là hai tham số học được.

Vì sao lại cần $\gamma$ và $\beta$ nếu vừa mới chuẩn hoá xong?

Vì ép cứng về $\mathcal{N}(0,1)$ là một hạn chế: đôi khi mạng muốn tầng đó bão hoà (để hoạt động như một cổng bật/tắt), hoặc muốn dịch tâm đi. $\gamma,\beta$ trả lại quyền tự do: mạng có thể học $\gamma = \sigma, \beta = \mu$ để khôi phục nguyên trạng nếu nó muốn. Ta chuẩn hoá để khởi đầu ở trạng thái khoẻ mạnh, chứ không phải để trói buộc vĩnh viễn.

Hai chế độ — nguồn gốc của vô số bug

Lúc huấn luyện (train)Lúc suy luận (eval)
Dùng thống kê nào$\mu,\sigma$ của chính lô hiện tại$\mu,\sigma$ trung bình trượt tích luỹ suốt quá trình train
Vì sao khác nhauNhiễu từ lô khác nhau đóng vai regularizer nhẹSuy luận phải tất định: một mẫu vào thì kết quả không được phụ thuộc những mẫu đứng cùng lô với nó
Trong PyTorchmodel.train()model.eval() — quên dòng này là accuracy tụt thảm hại
class BatchNorm1d:
    def __init__(self, dim, eps=1e-5, momentum=0.1):
        self.eps, self.momentum, self.training = eps, momentum, True
        self.gamma = torch.ones(dim)          # tham số học được
        self.beta  = torch.zeros(dim)
        self.running_mean = torch.zeros(dim)  # bộ đệm, KHÔNG học bằng gradient
        self.running_var  = torch.ones(dim)

    def __call__(self, x):
        if self.training:
            xmean = x.mean(0, keepdim=True)
            xvar  = x.var(0, keepdim=True, unbiased=True)
            with torch.no_grad():             # cập nhật trung bình trượt
                self.running_mean = (1-self.momentum)*self.running_mean + self.momentum*xmean
                self.running_var  = (1-self.momentum)*self.running_var  + self.momentum*xvar
        else:
            xmean, xvar = self.running_mean, self.running_var
        xhat = (x - xmean) / torch.sqrt(xvar + self.eps)
        return self.gamma * xhat + self.beta

    def parameters(self):
        return [self.gamma, self.beta]        # running_* KHÔNG nằm ở đây

Bốn cái bẫy kinh điển của BatchNorm

  • Quên model.eval(). Bug phổ biến nhất trong toàn bộ deep learning ứng dụng. Mô hình bỗng dưng tệ hẳn khi đưa lên production — chỉ vì nó đang dùng thống kê của lô hiện tại thay vì running stats. Cùng lỗi này cũng vô hiệu hoá Dropout.
  • Bias thừa trước BatchNorm. Nếu tầng trước là x @ W + b rồi tới BatchNorm, thì $b$ hoàn toàn vô dụng — bước trừ trung bình xoá sạch nó, và gradient của nó luôn xấp xỉ 0. Hãy đặt bias=False. (Vai trò dịch chuyển đã do $\beta$ đảm nhiệm.)
  • Batch quá nhỏ. Với batch size 2–4, ước lượng $\mu,\sigma$ nhiễu kinh khủng và BatchNorm phản tác dụng. Khi đó dùng GroupNorm hoặc LayerNorm.
  • Ràng buộc chéo giữa các mẫu. BatchNorm khiến dự đoán cho mẫu A phụ thuộc vào những mẫu tình cờ nằm cùng lô — về mặt toán học thì kỳ quặc, và là cơn ác mộng khi debug. Chính lý do này khiến giới NLP bỏ hẳn nó.

4. LayerNorm — lựa chọn của Transformer

LayerNorm làm đúng phép chuẩn hoá ấy nhưng theo hướng vuông góc: thay vì lấy thống kê dọc theo chiều batch, nó lấy dọc theo chiều đặc trưng của từng mẫu riêng lẻ.

BatchNorm — chuẩn hoá theo cột LayerNorm — chuẩn hoá theo hàng → chiều đặc trưng chiều batch μ, σ tính trên cả cột ⇒ các mẫu ràng buộc nhau → chiều đặc trưng μ, σ tính trong từng hàng ⇒ mỗi mẫu độc lập hoàn toàn
Cùng một phép toán, khác trục. LayerNorm không phụ thuộc batch nên chạy được với batch size 1, không cần running stats, không có chế độ train/eval khác nhau, và xử lý được chuỗi dài ngắn khác nhau. Đó là lý do mọi Transformer đều dùng nó.

Nó ứng dụng vào đâu trong đời thật

Đây là module có tỉ lệ "ứng dụng ngay hôm nay" cao nhất. Khi một mô hình không chịu học, hãy chạy đúng checklist này theo thứ tự:

  1. Loss ở bước 0 có bằng $\log K$ (K = số lớp) không? Nếu không → sai khởi tạo tầng cuối.
  2. Vẽ histogram activation từng tầng. Nhọn ở 0 → gain quá nhỏ. Hai cột ở ±1 → gain quá lớn.
  3. Vẽ tỉ số grad.std() / param.std() cho từng tầng. Con số khoẻ mạnh là khoảng $10^{-3}$. Nhỏ hơn nhiều → tầng đó đứng im; lớn hơn nhiều → tầng đó đang bị đá văng.
  4. Thử overfit đúng 1 lô. Nếu mạng không đưa loss của 32 mẫu về gần 0 được, bạn có bug — không phải thiếu dữ liệu.
  5. Chỉ khi cả 4 bước trên sạch sẽ mới bắt đầu chỉnh siêu tham số.

Tự kiểm tra

1 · Vì sao Kaiming nhân $\sqrt{2}$ cho ReLU mà không phải cho tanh?

ReLU đặt toàn bộ phần âm về 0. Với đầu vào đối xứng quanh 0, khoảng một nửa số đầu ra thành 0, làm phương sai giảm đúng một nửa. Nhân trọng số với $\sqrt{2}$ khiến phương sai tăng gấp đôi, bù lại vừa khít. Tanh thì gần như tuyến tính quanh 0 (đạo hàm 1) nên không mất mát ở vùng nhỏ — chỉ cần gain 1 về lý thuyết, còn 5/3 là con số thực nghiệm bù cho phần bị bóp ở đuôi.

2 · Ngày nay nhiều mạng hiện đại bỏ BatchNorm. Nhờ đâu mà bỏ được?

Nhờ ba thứ cộng lại: (a) kết nối residual ($x + f(x)$) tạo đường cao tốc cho gradient, giải quyết phần lớn vấn đề mà BatchNorm sinh ra để chữa; (b) khởi tạo cẩn thận theo độ sâu (chia $\sqrt{2L}$ như GPT-2); (c) LayerNorm/RMSNorm làm cùng việc mà không ràng buộc các mẫu với nhau. Bài học chung: BatchNorm là một miếng vá tài tình cho một vấn đề mà kiến trúc tốt hơn có thể tránh ngay từ đầu.

3 · Bạn thấy loss train dao động mạnh giữa các lô dù learning rate nhỏ. Nghi ngờ điều gì?

Vài khả năng, kiểm tra theo thứ tự: (a) batch quá nhỏ → ước lượng gradient nhiễu; (b) dữ liệu chưa xáo trộn → các lô liên tiếp toàn cùng một lớp; (c) BatchNorm với batch nhỏ → thống kê chuẩn hoá tự nó đã run rẩy; (d) vài mẫu ngoại lai có loss khổng lồ chi phối trung bình. Cách chẩn đoán nhanh: vẽ histogram loss trên từng mẫu thay vì chỉ nhìn giá trị trung bình.

Phần II — Mạng sâu & huấn luyện ổn định · Module 07

07 Backprop ma trận bằng tay

Cần biết trước: Module 03 (chain rule), Module 05 (shape của MLP) Dựng nên: khả năng tự viết layer mới Dẫn tới: hiểu chi phí tính toán của Transformer

Mục tiêu

  • Thuộc ba công thức vàng của backprop ma trận và biết cách suy ra chúng chỉ bằng phép ghép shape.
  • Xử lý đúng gradient của broadcasting (bias) — nguồn bug số một khi tự viết layer.
  • Tự dẫn ra $\partial L/\partial \text{logits} = p - y$, công thức nằm ở tim mọi mô hình phân loại.
  • Biết cách kiểm chứng gradient tay bằng autograd và bằng sai phân số.

1. Đã có autograd rồi, học cái này làm gì?

Câu hỏi chính đáng. Ba lý do rất thực tế:

  1. Debug. Khi loss ra nan hoặc một tầng đứng im, bạn phải biết gradient nên trông như thế nào để nhận ra nó đang sai.
  2. Viết layer/kernel mới. Mọi torch.autograd.Function, mọi kernel Triton/CUDA đều buộc bạn cung cấp backward bằng tay.
  3. Hiểu chi phí. Nhìn công thức là biết backward tốn khoảng gấp đôi forward, và biết tensor trung gian nào bắt buộc phải giữ lại trong VRAM.

2. Ba công thức vàng

Cho $C = A \cdot B$ với $A:(m,k)$, $B:(k,n)$, $C:(m,n)$. Gọi $\bar{X} \equiv \partial L/\partial X$ (luôn cùng shape với $X$ — nhớ kỹ điều này, nó là chìa khoá). Khi đó:

$$\boxed{\;\bar{A} = \bar{C}\,B^\top \quad (m,n)(n,k) \to (m,k)\;} \qquad \boxed{\;\bar{B} = A^\top \bar{C} \quad (k,m)(m,n) \to (k,n)\;}$$

Mẹo không cần học thuộc: ghép shape như xếp hình

Bạn biết chắc $\bar{A}$ phải có shape $(m,k)$. Trong tay bạn có $\bar{C}:(m,n)$ và $B:(k,n)$. Chỉ có đúng một cách nhân hai thứ đó để ra $(m,k)$: phải chuyển vị $B$ thành $(n,k)$ rồi đặt sau $\bar{C}$. Xong.

Quy trình 3 bước áp dụng được cho mọi phép toán ma trận: ① viết shape của mọi thứ ra giấy; ② nhớ rằng gradient luôn cùng shape với biến của nó; ③ chỉ có một cách ghép hợp lệ — đó là đáp án. Trong 95% trường hợp, mẹo này thay thế hoàn toàn việc đạo hàm.

Công thức vàng thứ ba: broadcasting thì backward là tổng

Với $Z = XW + b$, trong đó $b$ có shape $(n,)$ nhưng được phát tán (broadcast) ra cho cả $m$ hàng. Một giá trị dùng lại ở nhiều nơi thì gradient phải cộng dồn từ mọi nơi (đúng quy tắc += ở Module 03):

$$\bar{b} = \sum_{i=1}^{m} \bar{Z}_{i,:} \qquad \texttt{dZ.sum(0)}$$

Quy tắc tổng quát rất gọn: chiều nào bị broadcast lúc forward thì bị sum lúc backward. Chiều nào bị sum lúc forward thì bị broadcast lúc backward. Chúng là hai phép toán chuyển vị của nhau.

Phòng thí nghiệm 07

Thám tử shape — nhìn gradient chảy ngược qua phép nhân ma trận

Rê chuột lên một ô của dA để xem nó gom dữ liệu từ đâu
Forward: C = A @ B + b · Backward: dA, dB, db
4
5
3
Shape dA—
Shape dB—
Sai lệch tối đa—
Công thức đang áp dụng

—

Điều cần thấy: ① Ô dA[i][j] được tính bằng tích vô hướng của hàng $i$ trong dC với hàng $j$ trong B — rê chuột lên nó, hai vùng nguồn sẽ sáng lên. ② Kéo thanh n: shape của dA không đổi (luôn bằng shape của A) dù dC thay đổi — đó là bất biến giúp bạn không bao giờ nhớ sai công thức. ③ Bấm Kiểm chứng bằng sai phân số: chương trình tính lại mọi gradient bằng $\frac{L(x+h)-L(x-h)}{2h}$ rồi so với công thức. Sai lệch cỡ $10^{-9}$ nghĩa là công thức đúng. Đây là kỹ thuật gradcheck mà bạn nên chạy mỗi khi tự viết một layer mới.

3. Dẫn ra $\partial L/\partial \text{logits} = p - y$

Đây là công thức đáng để tự tay dẫn một lần trong đời. Cho logits $z$, xác suất $p_i = \dfrac{e^{z_i}}{\sum_j e^{z_j}}$, và loss $L = -\log p_c$ với $c$ là lớp đúng.

Trường hợp $i = c$:

$$\frac{\partial L}{\partial z_c} = -\frac{\partial \log p_c}{\partial z_c} = -\left(1 - p_c\right) = p_c - 1$$

Trường hợp $i \ne c$:

$$\frac{\partial L}{\partial z_i} = p_i$$

Gộp lại bằng ký hiệu one-hot $y$:

$$\frac{\partial L}{\partial z} = p - y$$

Đọc ra tiếng Việt: "đẩy mọi lớp sai xuống đúng bằng xác suất bạn lỡ gán cho nó; kéo lớp đúng lên đúng bằng phần xác suất còn thiếu." Khi mô hình đã đúng và tự tin ($p_c \to 1$), gradient tiến về 0 và nó ngừng bị chỉnh — hoàn toàn hợp lý. Bạn đã nhìn thấy chính hai con số này chạy trong phòng thí nghiệm 02a.

Vì sao PyTorch gộp softmax vào trong cross_entropy

Nếu tách rời, bạn phải tính $e^{z}$ với $z$ có thể lên tới 90 → tràn số thành inf, rồi inf/inf = nan. Bản gộp dùng log-sum-exp trick: trừ $\max(z)$ trước khi mũ, kết quả không đổi về mặt toán nhưng an toàn về mặt số. Đồng thời nó dùng thẳng công thức rút gọn $p - y$ thay vì đi qua đồ thị softmax rồi log — nhanh hơn và ổn định hơn. Bài học: khi thư viện gộp hai phép toán lại, thường là vì lý do ổn định số học, không phải vì tiện tay.

4. Bảng tra cứu gradient các lớp thường gặp

ForwardBackwardGhi nhớ
$Z = XW + b$$\bar{X}=\bar{Z}W^\top$, $\bar{W}=X^\top\bar{Z}$, $\bar{b}=\bar{Z}.\text{sum}(0)$Ghép shape.
$A = \tanh(Z)$$\bar{Z} = (1 - A^2)\odot\bar{A}$Dùng lại đầu ra $A$, không cần $Z$ → tiết kiệm bộ nhớ.
$A = \text{ReLU}(Z)$$\bar{Z} = \bar{A}\odot[Z>0]$Chỉ cần lưu mặt nạ bit.
$L = \text{CE}(\text{softmax}(Z), y)$$\bar{Z} = (p - y)/m$Chia $m$ vì loss lấy trung bình trên lô.
$E = C[\text{idx}]$ (embedding)$\bar{C}[\text{idx}] \mathrel{+}= \bar{E}$ (scatter-add)Phải cộng dồn vì một chỉ số xuất hiện nhiều lần trong lô.
$Y = X.\text{view}(...)$$\bar{X} = \bar{Y}.\text{view}(X.\text{shape})$Reshape chỉ là đổi cách đọc bộ nhớ → backward là reshape ngược.
$Y = X_1 + X_2$ (residual)$\bar{X_1} = \bar{X_2} = \bar{Y}$Đường cao tốc gradient — lý do ResNet huấn luyện được 1000 tầng.
Backprop tay cho một MLP 2 tầng, đối chiếu với autograd
import torch, torch.nn.functional as F

def cmp(ten, dt, t):
    """So sánh gradient tay với gradient của PyTorch."""
    ex   = torch.all(dt == t.grad).item()
    maxd = (dt - t.grad).abs().max().item()
    print(f"{ten:12s} | khớp tuyệt đối: {str(ex):5s} | sai lệch tối đa: {maxd:.3e}")

m, k, h, V = 32, 30, 64, 27
X  = torch.randn(m, k)
W1 = torch.randn(k, h) * 0.2; b1 = torch.randn(h) * 0.1
W2 = torch.randn(h, V) * 0.1; b2 = torch.randn(V) * 0.1
Y  = torch.randint(0, V, (m,))
for t in [W1, b1, W2, b2]: t.requires_grad = True

# ---------- FORWARD (giữ lại mọi giá trị trung gian) ----------
Z1     = X @ W1 + b1
A1     = torch.tanh(Z1)
logits = A1 @ W2 + b2
loss   = F.cross_entropy(logits, Y)

for t in [W1, b1, W2, b2]: t.retain_grad()
loss.backward()

# ---------- BACKWARD BẰNG TAY ----------
dlogits = F.softmax(logits, dim=1)          # p
dlogits[range(m), Y] -= 1                   # p - y
dlogits /= m                                # loss lấy trung bình trên lô

dW2 = A1.T @ dlogits                        # (h,m)@(m,V) -> (h,V) ✓
db2 = dlogits.sum(0)                        # broadcast -> sum
dA1 = dlogits @ W2.T                        # (m,V)@(V,h) -> (m,h) ✓
dZ1 = (1 - A1 ** 2) * dA1                   # đạo hàm tanh
dW1 = X.T @ dZ1                             # (k,m)@(m,h) -> (k,h) ✓
db1 = dZ1.sum(0)

cmp('W2', dW2, W2);  cmp('b2', db2, b2)
cmp('W1', dW1, W1);  cmp('b1', db1, b1)
# W2  | khớp tuyệt đối: False | sai lệch tối đa: 2.794e-09
# ...  ← sai lệch cỡ 1e-9 là do dấu phẩy động, KHÔNG phải lỗi công thức

Nó ứng dụng vào đâu trong đời thật

  • Tối ưu hiệu năng. Nhìn dW1 = X.T @ dZ1 là biết ngay: backward của một tầng linear tốn hai phép nhân ma trận (một cho $\bar{X}$, một cho $\bar{W}$) so với một ở forward. Đó là nguồn gốc của quy tắc "backward ≈ 2× forward", và của công thức ước lượng FLOPs huấn luyện $\approx 6ND$ (6 = 2 forward + 4 backward, $N$ tham số, $D$ token).
  • Fine-tuning tiết kiệm. Khi đóng băng một tầng, ta vẫn phải tính $\bar{X}$ để gradient chảy tiếp về các tầng dưới, nhưng bỏ được $\bar{W}$. Nếu đóng băng toàn bộ phần dưới thì bỏ được cả hai — đó là lý do fine-tune vài tầng cuối nhanh hơn hẳn.
  • Bug embedding. Dùng = thay vì += trong scatter-add của embedding là bug thật, từng xuất hiện trong nhiều codebase: khi một token lặp lại trong cùng lô, gradient bị ghi đè và tầng embedding học sai một cách âm thầm.

Tự kiểm tra

1 · $A:(32, 100)$, $W:(100, 50)$. Shape của $\bar{W}$ là gì và tại sao?

$(100, 50)$ — luôn bằng shape của chính $W$. Kiểm tra bằng công thức: $\bar{W} = A^\top \bar{Z}$ với $A^\top:(100,32)$ và $\bar{Z}:(32,50)$, cho ra $(100,50)$ ✓. Lưu ý chiều batch 32 đã "bị nuốt" trong phép nhân — đúng như trực giác: gradient của trọng số là tổng đóng góp của cả 32 mẫu, chứ không phải 32 gradient riêng lẻ.

2 · Vì sao phải chia $\bar{Z}$ cho $m$ ở dòng dlogits /= m?

Vì loss được định nghĩa là trung bình trên lô: $L = \frac{1}{m}\sum_i L_i$. Do đó $\partial L/\partial L_i = 1/m$, và hệ số này nhân vào toàn bộ dây chuyền phía sau. Nếu quên, gradient lớn gấp $m$ lần dự kiến — với $m=32$ thì learning rate hiệu dụng của bạn đang gấp 32 lần con số bạn đặt, và mô hình sẽ nổ. Nếu dùng reduction='sum' thì không chia, nhưng khi đó phải giảm learning rate tương ứng.

3 · Vì sao backward của kết nối residual $y = x + f(x)$ lại cứu được mạng rất sâu?

Vì cổng cộng chia đều gradient: $\bar{x} = \bar{y} + \bar{y}\cdot f'(x)$. Số hạng $\bar{y}$ đi thẳng, không nhân với bất kỳ đạo hàm nào. Dù nhánh $f$ có bão hoà và trả về gradient ≈ 0, tầng dưới vẫn nhận đủ $\bar{y}$. Nói cách khác, residual tạo một đường cao tốc mà tích các đạo hàm nhỏ không thể chặn — đó là lý do ResNet-152 và GPT với 100 tầng huấn luyện được, trong khi mạng phẳng 30 tầng thì không.

Phần III — Kiến trúc hiện đại · Module 08

08 Mạng phân tầng & WaveNet — nghệ thuật reshape tensor

Cần biết trước: Module 05 (flatten của Bengio) Dựng nên: tư duy phân cấp & kỹ năng đọc shape Dẫn tới: Transformer — nơi cây cố định được thay bằng liên kết học được

Mục tiêu

  • Nhận ra điểm nghẽn ép phẳng trong kiến trúc Bengio và vì sao nó chặn đường mở rộng ngữ cảnh.
  • Hiểu trường tiếp nhận (receptive field) và vì sao cấu trúc cây cho nó tăng theo hàm mũ.
  • Thành thạo phép reshape tensor $(B,T,C) \to (B,T/2,2C)$ — nhìn bằng khối 3D thay vì bằng chỉ số.
  • Đọc hiểu kiến trúc kiểu Sequential phân tầng, tiền thân trực tiếp của các khối trong Transformer.

1. Điểm nghẽn ép phẳng

Ở Module 05, ta ghép 3 embedding thành một véc-tơ 30 chiều rồi nhồi thẳng vào một tầng ẩn. Nếu muốn ngữ cảnh 8 ký tự thay vì 3, ma trận $W_1$ phình từ $(30, 200)$ lên $(80, 200)$ — vẫn chịu được. Nhưng vấn đề sâu hơn nằm ở chỗ khác:

Tầng đầu tiên phải trộn toàn bộ 8 ký tự cùng một lúc, trong một phép nhân duy nhất, rồi ép kết quả qua một hàm tanh. Toàn bộ cấu trúc thời gian bị nghiền nát ngay lập tức.

Điều đó lãng phí về mặt thống kê: quan hệ giữa hai ký tự liền kề mạnh hơn và dễ học hơn nhiều so với quan hệ giữa hai ký tự cách nhau 7 vị trí, nhưng kiến trúc phẳng đối xử với chúng như nhau. Cách sửa của WaveNet (DeepMind, 2016) rất tự nhiên: gộp dần từng cặp, theo tầng.

Ngữ cảnh 8 ký tự · mỗi tầng gộp từng cặp · chỉ cần log₂8 = 3 tầng để nhìn hết t₁ t₂ t₃ t₄ t₅ t₆ t₇ t₈ (B, 8, 10) h₁ h₂ h₃ h₄ (B, 4, 68) g₁ g₂ (B, 2, 68) đầu ra (B, 1, 68) ← nhìn thấy cả 8 ký tự, sau đúng 3 lần gộp
Cây gộp nhị phân. Mỗi tầng nhân đôi trường tiếp nhận. Ngữ cảnh 8 cần 3 tầng, ngữ cảnh 1024 chỉ cần 10 tầng. So với việc nhồi phẳng, mô hình được học quan hệ gần trước, quan hệ xa sau — đúng thứ tự mà cấu trúc ngôn ngữ gợi ý.
Phòng thí nghiệm 08 · 3D

Khối tensor (B, T, C) và phép gộp — nhìn reshape bằng mắt

Kéo để xoay · bấm "Gộp một tầng" để xem T co lại và C dày lên
Tensor 3 chiều: batch × thời gian × kênh
Trường tiếp nhận — rê chuột lên một nút để xem nó "thấy" những ký tự nào
4
Shape hiện tại—
Tầng đã gộp0
Trường tiếp nhận1
Phép biến đổi

—

Điều cần thấy: ① Phép gộp không tạo và không xoá một con số nào — nó chỉ đọc lại cùng vùng bộ nhớ theo cách nhóm khác. Tổng số ô luôn bằng $B\times T\times C$. ② Chiều thời gian ngắn đi một nửa trong khi chiều kênh dày gấp đôi: thông tin của hai bước thời gian được xếp cạnh nhau thành một véc-tơ dài hơn, để tầng tuyến tính kế tiếp trộn chúng. ③ Ở biểu đồ cây bên dưới, rê chuột lên một nút ở tầng 3 và để ý nó phủ tới 8 ký tự — trường tiếp nhận tăng theo $2^{L}$ với $L$ là số tầng.

2. Phép reshape, viết bằng code

Toàn bộ ý tưởng gói gọn trong một lớp 5 dòng:

class FlattenConsecutive:
    """Gộp n bước thời gian liền kề thành một véc-tơ dày hơn."""
    def __init__(self, n):
        self.n = n
    def __call__(self, x):
        B, T, C = x.shape
        x = x.view(B, T // self.n, C * self.n)   # (B, T, C) -> (B, T/n, C*n)
        if x.shape[1] == 1:
            x = x.squeeze(1)                     # bỏ chiều thời gian khi chỉ còn 1
        self.out = x
        return x
    def parameters(self):
        return []                                # không có tham số nào!


model = Sequential([
    Embedding(vocab_size, n_emb),
    FlattenConsecutive(2), Linear(n_emb * 2, n_hid, bias=False), BatchNorm1d(n_hid), Tanh(),
    FlattenConsecutive(2), Linear(n_hid * 2, n_hid, bias=False), BatchNorm1d(n_hid), Tanh(),
    FlattenConsecutive(2), Linear(n_hid * 2, n_hid, bias=False), BatchNorm1d(n_hid), Tanh(),
    Linear(n_hid, vocab_size),
])
# Ngữ cảnh 8 ký tự, ~22.000 tham số, val loss ≈ 1.99
# so với MLP phẳng cùng số tham số: ≈ 2.09.  Cùng ngân sách, kiến trúc tốt hơn thắng.

Bẫy chết người: view gộp đúng cặp, nhưng BatchNorm thì không

Khi tensor có 3 chiều $(B, T, C)$, x.mean(0) chỉ lấy trung bình theo chiều batch và cho ra shape $(T, C)$ — nghĩa là mỗi vị trí thời gian có thống kê riêng. Đó gần như luôn là điều bạn không muốn: bạn muốn thống kê gộp cả $B$ lẫn $T$, tức x.mean((0,1)), shape $(C,)$.

Karpathy gặp đúng bug này trong video WaveNet: running_mean có shape $(1, 7, 68)$ thay vì $(68,)$ — code vẫn chạy nhờ broadcasting, chỉ là học kém đi một cách âm thầm. Bài học: in shape của mọi tensor trung gian ra ít nhất một lần. Một dòng print(x.shape) rẻ hơn ba ngày debug.

Phân biệt view, reshape, permute, transpose

PhépCó đổi thứ tự dữ liệu trong bộ nhớ?Khi nào dùng
viewKhông — chỉ đổi cách diễn giải cùng vùng nhớ. Yêu cầu tensor liền mạch (contiguous).Gộp/tách các chiều liền kề, như trường hợp trên. Rẻ nhất, không tốn gì.
reshapeKhông, nếu được; nếu không thì tự sao chép.An toàn hơn view, nhưng có thể âm thầm tốn bộ nhớ.
permute / transposeCó — hoán đổi vai trò các trục, làm tensor không còn liền mạch.Khi cần đổi thứ tự trục, ví dụ $(B,T,C) \to (B,C,T)$ cho Conv1d. Sau đó thường phải gọi .contiguous().

Quy tắc vàng: nếu bạn định "gộp hai chiều cạnh nhau" thì dùng view; nếu định "đổi chỗ hai chiều" thì dùng permute. Nhầm hai cái này tạo ra loại bug tệ nhất: code chạy, shape đúng, nhưng dữ liệu bị xáo trộn và mô hình chỉ học kém hơn một chút — không đủ để bạn nghi ngờ.

Nó ứng dụng vào đâu trong đời thật

  • WaveNet gốc sinh âm thanh thô 16.000 mẫu/giây; ngữ cảnh phải hàng nghìn bước, nên cây phân tầng (ở đó là dilated causal convolution) là bắt buộc.
  • Chuỗi thời gian dài — dữ liệu cảm biến IoT, ECG, log hệ thống: cây gộp cho $O(\log T)$ tầng thay vì $O(T)$ bước tuần tự của RNN, và song song hoá được hoàn toàn.
  • Patch trong Vision Transformer dùng đúng phép reshape này ở dạng 2D: ảnh $(B, 3, 224, 224)$ được cắt thành các mảnh 16×16 rồi ép phẳng thành $(B, 196, 768)$. Cùng một dòng view.
  • Nhận ra giới hạn của nó để hiểu Transformer: cây này có cấu trúc cố định — t₁ luôn ghép với t₂, dù trong câu cụ thể ấy chúng chẳng liên quan gì tới nhau. Self-attention thay cấu trúc cứng đó bằng liên kết do dữ liệu quyết định. Đó chính là nội dung Module 09.

Tự kiểm tra

1 · Ngữ cảnh 1024 ký tự cần bao nhiêu tầng gộp đôi? Còn MLP phẳng thì tham số tầng đầu là bao nhiêu?

$\log_2 1024 = 10$ tầng. Với MLP phẳng, embedding 10 chiều nhân 1024 vị trí = véc-tơ 10.240 chiều; nối vào tầng ẩn 200 nơ-ron là $10.240 \times 200 \approx 2$ triệu tham số chỉ riêng tầng đầu, và tất cả bị trộn trong một cú. Mạng phân tầng đạt cùng trường tiếp nhận với vài chục nghìn tham số được tái sử dụng qua các tầng.

2 · Vì sao Linear trong đoạn code trên đặt bias=False?

Vì ngay sau nó là BatchNorm1d, mà bước đầu tiên của BatchNorm là trừ trung bình — thao tác này xoá sạch mọi hằng số cộng vào. Bias trở nên vô dụng, chỉ tốn tham số và tốn bộ nhớ gradient. Vai trò dịch chuyển đã được $\beta$ của BatchNorm đảm nhiệm. Đây là quy ước chuẩn trong mọi kiến trúc có normalization ngay sau linear/conv.

3 · Cây gộp cố định có nhược điểm gì mà attention khắc phục?

Cấu trúc gộp không phụ thuộc nội dung: vị trí 1 luôn ghép với vị trí 2, bất kể chúng có liên quan hay không. Trong câu "con mèo mà tôi thấy hôm qua đã ngủ", chủ ngữ "mèo" và động từ "ngủ" cách nhau rất xa nhưng phụ thuộc chặt chẽ; cây cố định buộc thông tin phải đi qua nhiều tầng trung gian, mỗi tầng làm nhoè thêm. Self-attention cho phép hai vị trí bất kỳ nối trực tiếp với nhau trong một bước, với cường độ do chính nội dung quyết định. Cái giá phải trả: chi phí $O(T^2)$ thay vì $O(T\log T)$.

Phần III — Kiến trúc hiện đại · Module 09

09 Self-Attention & tự dựng Transformer (nanoGPT)

Cần biết trước: Module 05–08 (embedding, softmax, reshape, residual) Dựng nên: kiến trúc đứng sau toàn bộ LLM hiện đại Dẫn tới: tokenizer và triển khai thực tế

Mục tiêu

  • Hiểu self-attention như một hệ thống tra cứu mềm: query, key, value là ba vai trò, không phải ba phép thuật.
  • Theo dõi từng bước biến đổi ma trận, từ $X$ tới đầu ra, bằng bản đồ nhiệt tương tác.
  • Biết chính xác causal mask làm gì và vì sao thiếu nó thì mô hình gian lận.
  • Lắp ráp một khối Transformer đầy đủ và hiểu vai trò của từng bộ phận.

1. Trực giác: một buổi mai mối trong lớp học

Mỗi token trong câu phát ra ba thứ khác nhau, sinh từ chính véc-tơ của nó qua ba ma trận trọng số học được:

Vai tròSinh ra bởiNghĩa đời thường
Query $q = xW_Q$token đang cần thông tin"Tôi là một động từ, tôi đang tìm chủ ngữ của mình ở đâu?"
Key $k = xW_K$token quảng cáo bản thân"Tôi là một danh từ số ít, đứng đầu câu — đây là tấm biển của tôi."
Value $v = xW_V$nội dung thực sự được chuyển đi"Nếu bạn chọn tôi, đây là thứ tôi gửi cho bạn."

Mỗi query so khớp với mọi key bằng tích vô hướng — hai véc-tơ càng cùng hướng thì điểm càng cao. Điểm số được softmax thành trọng số có tổng bằng 1, rồi đầu ra là trung bình có trọng số của các value. Nói ngắn: mỗi token tự chọn xem nên nghe ai, và nghe bao nhiêu.

Điểm mấu chốt phân biệt nó với Module 08: các trọng số này không cố định. Chúng được tính lại cho từng câu, từ chính nội dung câu đó. Cây gộp là hệ thống dây điện hàn cứng; attention là tổng đài tự động định tuyến.

$$\text{Attention}(Q,K,V) = \operatorname{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}} + M\right)V$$
Phòng thí nghiệm 09

Máy chiếu chậm Self-Attention — sáu bước từ X tới đầu ra

Câu mẫu: "con mèo đen đang ngủ say"
Bản đồ nhiệt các ma trận trung gian
ngủ
Hàng tương ứng sẽ được tô sáng ở mọi ma trận.
√d = 2.00
Đặt về 0.25 để thấy softmax biến thành hàm "chọn một, bỏ hết".
Entropy hàng—
Chú ý mạnh nhất—
Bước

—

Điều cần thấy: ① Ở bước ⑥, hàng của token "ngủ" dồn phần lớn trọng số vào "mèo" — động từ tự tìm về chủ ngữ của nó, dù giữa chúng có hai từ chen vào. Embedding trong thí nghiệm này được đặt sẵn để hiện tượng hiển thị rõ; trong mô hình thật, chính gradient descent tìm ra các véc-tơ như vậy. ② Tắt causal mask: hàng của "con" (token đầu) bỗng nhận được thông tin từ "say" (token cuối). Mô hình sẽ đạt loss cực thấp khi huấn luyện và hoàn toàn vô dụng khi sinh văn bản — vì lúc sinh thật không có tương lai để nhìn. Đây là bug im lặng kinh điển. ③ Kéo hệ số chia xuống 0.25: phân phối chú ý sụp thành gần như one-hot, entropy tụt về 0. Đó chính là lý do phải chia $\sqrt{d_k}$.

2. Ba chi tiết ai cũng hỏi

Vì sao chia $\sqrt{d_k}$?

Nếu các thành phần của $q,k$ độc lập với phương sai 1, thì tích vô hướng $q\cdot k$ của $d_k$ số hạng có phương sai $d_k$, tức độ lệch chuẩn $\sqrt{d_k}$. Với $d_k = 64$, điểm số dao động cỡ ±8; softmax của những con số đó gần như one-hot. Hậu quả: gradient qua softmax teo về 0 (softmax bão hoà y hệt sigmoid), và attention mất khả năng học phân bổ mềm. Chia cho $\sqrt{d_k}$ kéo phương sai về 1 và giữ softmax ở vùng "mềm". Chuẩn hoá thang đo, đúng bài học của Module 06.

Causal mask thực chất làm gì?

Nó cộng $-\infty$ (thực tế là -1e9) vào mọi ô nằm phía trên đường chéo trước khi softmax. Sau softmax, $e^{-\infty} = 0$, nên các vị trí tương lai nhận trọng số đúng bằng 0.

tril = torch.tril(torch.ones(T, T))
wei  = wei.masked_fill(tril == 0, float('-inf'))
wei  = F.softmax(wei, dim=-1)

Nhờ mask, một lần forward trên câu dài $T$ tạo ra $T$ bài toán dự đoán độc lập cùng lúc (vị trí 1 đoán 2, vị trí 2 đoán 3, ...). Đây là lý do huấn luyện LLM hiệu quả đến vậy: mỗi token vừa là đầu vào vừa là nhãn.

Multi-head để làm gì?

Một head chỉ học được một kiểu quan hệ. Ngôn ngữ có nhiều kiểu cùng lúc: chủ ngữ–động từ, tính từ–danh từ, đại từ–tiền ngữ, dấu câu. Multi-head chạy $h$ attention song song trên các không gian con $d/h$ chiều rồi ghép kết quả. Chi phí gần như không đổi (tổng số chiều giữ nguyên) nhưng mô hình có $h$ "kênh quan hệ" riêng biệt.

3. Khối Transformer đầy đủ

import torch, torch.nn as nn, torch.nn.functional as F

class Head(nn.Module):
    """Một đầu self-attention có che nhân quả."""
    def __init__(self, n_embd, head_size, block_size, dropout=0.1):
        super().__init__()
        self.key   = nn.Linear(n_embd, head_size, bias=False)
        self.query = nn.Linear(n_embd, head_size, bias=False)
        self.value = nn.Linear(n_embd, head_size, bias=False)
        self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        B, T, C = x.shape
        k = self.key(x)      # (B, T, hs)
        q = self.query(x)    # (B, T, hs)
        v = self.value(x)    # (B, T, hs)

        wei = q @ k.transpose(-2, -1) * k.shape[-1] ** -0.5   # (B, T, T)
        wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf'))
        wei = F.softmax(wei, dim=-1)
        wei = self.dropout(wei)
        return wei @ v                                        # (B, T, hs)


class MultiHeadAttention(nn.Module):
    def __init__(self, n_head, n_embd, block_size):
        super().__init__()
        hs = n_embd // n_head
        self.heads = nn.ModuleList([Head(n_embd, hs, block_size) for _ in range(n_head)])
        self.proj  = nn.Linear(n_embd, n_embd)     # trộn lại sau khi ghép
    def forward(self, x):
        out = torch.cat([h(x) for h in self.heads], dim=-1)
        return self.proj(out)


class FeedForward(nn.Module):
    """Mỗi token tự suy nghĩ một mình, không nhìn ai."""
    def __init__(self, n_embd):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_embd, 4 * n_embd),   # phình ra 4 lần — tỉ lệ chuẩn
            nn.GELU(),
            nn.Linear(4 * n_embd, n_embd),
            nn.Dropout(0.1),
        )
    def forward(self, x):
        return self.net(x)


class Block(nn.Module):
    """Giao tiếp (attention) rồi suy nghĩ (feed-forward)."""
    def __init__(self, n_embd, n_head, block_size):
        super().__init__()
        self.ln1 = nn.LayerNorm(n_embd)
        self.sa  = MultiHeadAttention(n_head, n_embd, block_size)
        self.ln2 = nn.LayerNorm(n_embd)
        self.ffwd = FeedForward(n_embd)
    def forward(self, x):
        x = x + self.sa(self.ln1(x))     # residual + pre-norm
        x = x + self.ffwd(self.ln2(x))
        return x


class GPT(nn.Module):
    def __init__(self, vocab, n_embd=384, n_head=6, n_layer=6, block_size=256):
        super().__init__()
        self.block_size = block_size
        self.tok_emb = nn.Embedding(vocab, n_embd)
        self.pos_emb = nn.Embedding(block_size, n_embd)   # attention mù vị trí!
        self.blocks  = nn.Sequential(*[Block(n_embd, n_head, block_size) for _ in range(n_layer)])
        self.ln_f    = nn.LayerNorm(n_embd)
        self.head    = nn.Linear(n_embd, vocab)

    def forward(self, idx, targets=None):
        B, T = idx.shape
        x = self.tok_emb(idx) + self.pos_emb(torch.arange(T, device=idx.device))
        x = self.ln_f(self.blocks(x))
        logits = self.head(x)                                  # (B, T, vocab)
        loss = None
        if targets is not None:
            loss = F.cross_entropy(logits.view(B*T, -1), targets.view(B*T))
        return logits, loss

    @torch.no_grad()
    def generate(self, idx, max_new_tokens, temperature=1.0, top_k=None):
        for _ in range(max_new_tokens):
            idx_cond = idx[:, -self.block_size:]        # cắt cho vừa cửa sổ
            logits, _ = self(idx_cond)
            logits = logits[:, -1, :] / temperature     # chỉ lấy bước cuối
            if top_k is not None:
                v, _ = torch.topk(logits, top_k)
                logits[logits < v[:, [-1]]] = -float('inf')
            probs = F.softmax(logits, dim=-1)
            idx = torch.cat([idx, torch.multinomial(probs, 1)], dim=1)
        return idx

Đọc kiến trúc theo nhịp "giao tiếp — suy nghĩ"

Mỗi Block có đúng hai pha, lặp lại $L$ lần:

  • Attention = giao tiếp. Các token trao đổi thông tin với nhau. Đây là phép toán duy nhất trong cả Transformer mà các vị trí "nhìn thấy" nhau.
  • Feed-forward = suy nghĩ. Mỗi token xử lý riêng thông tin vừa thu được, độc lập hoàn toàn với các token khác. Thú vị là khoảng 2/3 tham số của một LLM nằm ở đây, chứ không phải ở attention.

Cộng thêm residual (đường cao tốc gradient, Module 07) và LayerNorm (giữ thang đo ổn định, Module 06). Bốn thành phần đó, lặp 6 lần cho nanoGPT hay 96 lần cho GPT-3. Không có gì khác.

Năm cái bẫy khi tự cài Transformer

  • Quên positional embedding. Không có vị trí, self-attention là tương đương theo hoán vị (permutation-equivariant): đổi chỗ token thì các đầu ra tương ứng cũng đổi chỗ, chứ mạng không biết token nào đứng trước. Nếu sau đó gộp cả câu bằng tổng/trung bình, biểu diễn gộp mới trở thành bất biến và hai thứ tự có thể không phân biệt được.
  • Sai chiều softmax. Phải là dim=-1 để mỗi hàng có tổng bằng 1 (mỗi query phân bổ 100% sự chú ý của mình). Dùng dim=-2 vẫn chạy, vẫn ra số, và mô hình học kém đi mà không báo gì.
  • Mask sai chiều tam giác. tril (tam giác dưới) mới là đúng; triu khiến mỗi token chỉ nhìn thấy tương lai.
  • Quên cắt ngữ cảnh lúc sinh. Nếu idx dài hơn block_size, tra pos_emb sẽ vượt biên và crash — hoặc tệ hơn, âm thầm dùng nhầm chỉ số.
  • Trộn lẫn post-norm và pre-norm. Bài báo gốc 2017 và nhiều model encoder–decoder như Marian dùng post-norm; nhiều LLM mới dùng pre-norm (x + sa(ln(x))) vì thường ổn định hơn khi chồng sâu. Cả hai đều hợp lệ nếu code/config khớp checkpoint. Pre-norm không xoá nhu cầu warmup; warmup vẫn rất phổ biến trong huấn luyện Transformer.

4. Cái giá: $O(T^2)$

Ma trận attention có kích thước $T \times T$ cho mỗi head, mỗi tầng, mỗi mẫu. Nhân đôi độ dài ngữ cảnh làm chi phí tính toán và bộ nhớ tăng gấp bốn.

Ngữ cảnh TSố ô của ma trận attentionGhi chú
25665 nghìnnanoGPT chạy được trên laptop.
2.0484,2 triệuGPT-3.
128.00016,4 tỉBất khả thi nếu làm ngây thơ — bắt buộc phải có FlashAttention, sliding window, hoặc attention thưa.

Đó là lý do "cửa sổ ngữ cảnh dài" là một hạng mục nghiên cứu riêng, và vì sao API tính tiền theo token đầu vào chứ không chỉ token đầu ra.

Nó ứng dụng vào đâu trong đời thật

  • Đọc hiểu bản đồ chú ý là công cụ diễn giải mô hình: chiếu ma trận $A$ ra để xem mô hình "nhìn" vào đâu khi trả lời — hữu ích khi debug prompt hoặc kiểm tra thiên lệch.
  • KV cache. Khi sinh văn bản, $k$ và $v$ của các token cũ không đổi, nên được lưu lại thay vì tính lại. Đây là tối ưu quan trọng nhất khi phục vụ LLM, và nó chiếm phần lớn VRAM lúc inference — bây giờ bạn biết chính xác nó cache cái gì.
  • Ngoài văn bản. Vision Transformer (mảnh ảnh là token), AlphaFold (axit amin là token), Whisper (khung âm thanh là token), mô hình protein và mô hình chuỗi ADN — tất cả dùng đúng khối bạn vừa đọc.
  • Chọn mô hình. Hiểu $O(T^2)$ giúp bạn ước lượng chi phí trước khi gọi API: nhồi 100 nghìn token ngữ cảnh cho mỗi câu hỏi đắt hơn rất nhiều so với dùng RAG lấy ra 2 nghìn token liên quan.

Tự kiểm tra

1 · Nếu bỏ hoàn toàn tầng feed-forward, chỉ giữ attention, mô hình yếu đi ở điểm nào?

Attention về bản chất là phép trung bình có trọng số của các value — một thao tác gần như tuyến tính (phần phi tuyến duy nhất là softmax tạo ra trọng số). Không có FFN, mô hình có thể trộn thông tin giữa các vị trí nhưng gần như không biến đổi thông tin tại mỗi vị trí. FFN với GELU là nơi diễn ra phần lớn phép tính phi tuyến, và cũng là nơi nhiều nghiên cứu cho rằng "tri thức sự kiện" được lưu (dạng bộ nhớ khoá–giá trị). Bỏ nó đi, mô hình sụt chất lượng nghiêm trọng dù vẫn chạy.

2 · Vì sao positional embedding cần thiết, trong khi CNN và RNN thì không?

RNN xử lý tuần tự nên thứ tự nằm sẵn trong trạng thái; CNN dùng cửa sổ theo vị trí tương đối nên có cấu trúc cục bộ. Self-attention không vị trí xử lý một tập token: hoán vị đầu vào sẽ hoán vị đầu ra theo cùng cách, nhưng không cho mạng biết trước/sau. Ta phải bơm vị trí tường minh: embedding học được (GPT-2), sin/cos cố định (bài báo gốc), hoặc mã hoá tương đối/xoay như RoPE.

3 · Tại một vị trí, tổng trọng số chú ý luôn bằng 1. Điều đó ràng buộc gì lên mô hình?

Đó là bài toán ngân sách: chú ý nhiều hơn vào token này bắt buộc phải giảm chú ý vào token khác — attention chỉ phân phối lại, không tạo thêm. Hệ quả thực tế: khi ngữ cảnh rất dài, mỗi token nhận trung bình $1/T$ sự chú ý và tín hiệu bị loãng, góp phần vào hiện tượng "lạc giữa đống thông tin" (lost in the middle). Đây cũng là lý do một số biến thể thêm token "chìm" (attention sink) để mô hình có chỗ đổ phần chú ý thừa.

Phần III — Kiến trúc hiện đại · Module 10

10 Tokenizer: thuật toán Byte-Pair Encoding

Cần biết trước: Module 04 (từ vựng, chỉ số) Dựng nên: lớp vào/ra của mọi LLM Dẫn tới: hiểu vì sao LLM đếm chữ sai và tiếng Việt tốn tiền hơn

Mục tiêu

  • Hiểu vì sao mô hình không đọc chữ mà đọc token, và sự đánh đổi khi chọn kích thước token.
  • Chạy tay thuật toán BPE và tự cài nó — chỉ ~40 dòng.
  • Giải thích được hàng loạt hành vi kỳ quặc của LLM đều bắt nguồn từ tokenization.
  • Ước lượng chi phí API cho văn bản tiếng Việt.

1. Ba lựa chọn, hai cực đoan

MứcTừ vựngĐộ dài chuỗiVấn đề
Ký tự~100Rất dàiNgữ cảnh $O(T^2)$ nổ tung; mô hình phải học lại chính tả từ đầu.
TừVô hạnNgắnTừ chưa gặp (OOV) thành <unk>; lỗi chính tả thành từ mới; embedding table khổng lồ.
Subword (BPE) ★32k – 200kVừaĐúng điểm cân bằng. Từ thường gặp là một token; từ hiếm tự động vỡ thành các mảnh có nghĩa.

2. Thuật toán BPE, bốn bước lặp lại

  1. Khởi đầu: mỗi byte là một token (256 token gốc — nhờ đó không bao giờ có OOV, kể cả emoji hay tiếng Nhật).
  2. Đếm mọi cặp token liền kề trong toàn bộ kho văn bản.
  3. Tìm cặp xuất hiện nhiều nhất, gộp nó thành một token mới, ghi lại luật gộp.
  4. Lặp lại cho tới khi đạt kích thước từ vựng mong muốn (ví dụ 50.257 với GPT-2).
Phòng thí nghiệm 10

BPE chạy chậm — xem từ vựng tự hình thành

Sửa văn bản tuỳ ý rồi bấm gộp
Văn bản sau khi token hoá
Các luật gộp đã học (theo thứ tự)
Mỗi lần gộp tạo ra đúng một token mới trong từ vựng.
Số token—
Cỡ từ vựng—
Tỉ lệ nén—
Số lần gộp0
Các cặp đang thắng cuộc

—

Điều cần thấy: ① Vài lần gộp đầu luôn chộp lấy các cặp cực phổ biến — với văn bản mẫu, "co" rồi "con" rồi "con " (kèm dấu cách!) xuất hiện gần như ngay lập tức. Thuật toán tự phát hiện ra từ mà không ai dạy nó khái niệm "từ". ② Tỉ lệ nén tăng nhanh lúc đầu rồi chững lại — quy luật lợi ích giảm dần, và đó là lý do từ vựng 50k chứ không phải 500k. ③ Chú ý dấu cách được gộp vào đầu token (" con" chứ không phải "con"). Đây là quy ước của GPT và là nguồn gốc của vô số hành vi kỳ lạ mà ta bàn ngay dưới.

BPE tối giản — huấn luyện, mã hoá, giải mã
def get_stats(ids):
    """Đếm tần suất mọi cặp liền kề."""
    counts = {}
    for pair in zip(ids, ids[1:]):
        counts[pair] = counts.get(pair, 0) + 1
    return counts

def merge(ids, pair, idx):
    """Thay mọi lần xuất hiện của `pair` bằng token mới `idx`."""
    new_ids, i = [], 0
    while i < len(ids):
        if i < len(ids) - 1 and ids[i] == pair[0] and ids[i+1] == pair[1]:
            new_ids.append(idx); i += 2
        else:
            new_ids.append(ids[i]); i += 1
    return new_ids

def train_bpe(text, vocab_size):
    ids = list(text.encode('utf-8'))      # 256 token byte làm nền
    merges = {}                           # (int, int) -> int
    for i in range(vocab_size - 256):
        stats = get_stats(ids)
        if not stats: break
        pair = max(stats, key=stats.get)  # cặp phổ biến nhất
        idx  = 256 + i
        ids  = merge(ids, pair, idx)
        merges[pair] = idx
        print(f"gộp {pair} -> {idx}  (xuất hiện {stats[pair]} lần)")
    return ids, merges

def encode(text, merges):
    ids = list(text.encode('utf-8'))
    while len(ids) >= 2:
        stats = get_stats(ids)
        # luôn áp dụng luật gộp có chỉ số NHỎ NHẤT trước — đúng thứ tự lúc train
        pair = min(stats, key=lambda p: merges.get(p, float('inf')))
        if pair not in merges: break
        ids = merge(ids, pair, merges[pair])
    return ids

def decode(ids, merges):
    vocab = {i: bytes([i]) for i in range(256)}
    for (p0, p1), idx in merges.items():          # dict giữ thứ tự chèn
        vocab[idx] = vocab[p0] + vocab[p1]
    return b''.join(vocab[i] for i in ids).decode('utf-8', errors='replace')

3. Vì sao rất nhiều hành vi kỳ quặc của LLM bắt nguồn từ đây

Hiện tượngNguyên nhân gốc
Đếm sai số chữ cái trong một từMô hình nhìn thấy token straw + berry, chứ không thấy 10 ký tự riêng lẻ. Hỏi nó đếm chữ r chẳng khác gì hỏi bạn đếm số nét trong một chữ Hán bạn chỉ nhớ mặt chữ.
Số học kém1234 có thể tách thành 123+4, còn 1235 thành 1+235. Cấu trúc hàng đơn vị/chục/trăm bị phá vỡ tuỳ tiện. (LLaMA sửa bằng cách tách mỗi chữ số thành một token.)
Nhạy cảm với dấu cách thừa"hello" và " hello" là hai token khác nhau với hai embedding khác nhau. Prompt kết thúc bằng dấu cách có thể cho kết quả tệ hơn hẳn.
Tiếng Việt tốn token gấp 2–3 lần tiếng AnhKho huấn luyện tokenizer chủ yếu là tiếng Anh, nên tiếng Việt có dấu bị vỡ thành nhiều mảnh byte. "Xin chào các bạn" ≈ 4 token tiếng Anh nhưng ≈ 9–12 token với tokenizer GPT. Bạn trả tiền theo token — cùng nội dung, bản tiếng Việt đắt gấp đôi ba lần.
Mã Python từng bị xử lý tệGPT-2 mã hoá mỗi dấu cách thụt đầu dòng thành một token riêng → 4 mức thụt = 16 token lãng phí. GPT-4 gộp các chuỗi dấu cách lại, và chất lượng viết code cải thiện rõ rệt chỉ nhờ thay đổi này.
Token "ma" gây lỗi lạNhững chuỗi như SolidGoldMagikarp có trong kho huấn luyện tokenizer nhưng gần như vắng bóng trong kho huấn luyện mô hình → embedding của chúng chưa bao giờ được cập nhật, giữ nguyên giá trị ngẫu nhiên lúc khởi tạo → mô hình phản ứng hoàn toàn thất thường.

Nó ứng dụng vào đâu trong đời thật

  • Tính tiền API. Ước lượng nhanh: tiếng Anh ≈ 4 ký tự/token; tiếng Việt có dấu ≈ 1,5–2 ký tự/token. Một tài liệu 10.000 ký tự tiếng Việt tốn khoảng 5.000–6.000 token, không phải 2.500. Hãy đo bằng tiktoken trước khi báo giá cho khách.
  • Chọn mô hình cho tiếng Việt. Các mô hình có tokenizer huấn luyện kèm tiếng Việt (Gemma, Qwen, PhoGPT, các bản Llama fine-tune tiếng Việt) cho tỉ lệ nén tốt hơn hẳn — vừa rẻ hơn vừa dùng được ngữ cảnh dài hơn với cùng số token.
  • Chuẩn hoá trước khi đưa vào mô hình. Thống nhất Unicode về NFC, bỏ dấu cách thừa, chuẩn hoá xuống dòng. Cùng một câu ở hai dạng Unicode khác nhau cho ra hai chuỗi token khác nhau.
  • Tokenizer riêng cho miền hẹp. Nếu bạn xử lý log y tế, mã chứng khoán hay công thức hoá học, huấn luyện BPE riêng trên dữ liệu đó có thể giảm 30–50% số token — giảm thẳng chi phí và tăng ngữ cảnh hữu dụng.

Tự kiểm tra

1 · Vì sao BPE bắt đầu từ byte chứ không phải từ ký tự Unicode?

Unicode có hơn 150.000 điểm mã — quá lớn để làm từ vựng gốc, và luôn có nguy cơ gặp ký tự chưa từng thấy. Byte thì chỉ có đúng 256 giá trị và mọi văn bản UTF-8 đều là một chuỗi byte, nên tokenizer byte-level không bao giờ gặp OOV: emoji, chữ Hán, tiếng Việt có dấu, thậm chí dữ liệu nhị phân đều mã hoá được. Cái giá: một ký tự tiếng Việt có dấu chiếm 2–3 byte, nên nếu tokenizer chưa học các luật gộp cho tiếng Việt, chuỗi token sẽ dài ra đáng kể.

2 · Vì sao khi encode phải áp dụng luật gộp theo đúng thứ tự học, chứ không gộp cặp phổ biến nhất trong câu hiện tại?

Vì mã hoá phải tất định và nhất quán với lúc huấn luyện. Nếu áp dụng thứ tự khác, cùng một chuỗi có thể ra hai kết quả token khác nhau, và mô hình sẽ nhìn thấy các mẫu nó chưa từng được huấn luyện. Thứ tự học đóng vai trò như thứ tự ưu tiên: luật học sớm (cặp phổ biến hơn) luôn được áp dụng trước. Đó là ý nghĩa của min(stats, key=lambda p: merges.get(p, inf)) trong đoạn code.

3 · Tăng từ vựng từ 32k lên 128k thì được gì, mất gì?

Được: chuỗi ngắn hơn (nén tốt hơn) → tiết kiệm chi phí attention $O(T^2)$, ngữ cảnh hữu dụng dài hơn, và các ngôn ngữ ngoài tiếng Anh được đối xử công bằng hơn. Mất: bảng embedding và tầng logits cuối phình to — với $d=4096$, thêm 96k token là thêm ~800 triệu tham số chỉ cho hai bảng tra; softmax trên 128k lớp cũng đắt hơn; và các token hiếm nhận được rất ít lần cập nhật gradient (nguy cơ token "ma"). Xu hướng hiện tại nghiêng về từ vựng lớn hơn (Llama 3 dùng 128k) vì chi phí ngữ cảnh dài lớn hơn chi phí embedding.

Phần IV — Ra thực chiến · Module 11

11 Từ nanoGPT tới sản phẩm thật

Cần biết trước: toàn bộ Module 00–10 Dựng nên: bản đồ nối kiến thức với công việc thật Dẫn tới: dự án của chính bạn

Mục tiêu

  • Nối những gì đã học với quy trình tạo ra một LLM thương mại: pretrain → SFT → căn chỉnh.
  • Ước lượng được chi phí huấn luyện và bộ nhớ suy luận bằng vài phép nhân đơn giản.
  • Nắm các núm sinh văn bản (temperature, top-k, top-p) ở mức biết chúng làm gì với phân phối.
  • Có checklist debug và lộ trình dự án để tự đi tiếp.

1. Ba giai đoạn tạo ra một trợ lý AI

① TIỀN HUẤN LUYỆN Đoán token kế tiếp trên ~15 nghìn tỉ token Hàng nghìn GPU · vài tháng · triệu đô → mô hình nền (base model) ② TINH CHỈNH CÓ GIÁM SÁT 10k–100k cặp (câu hỏi → câu trả lời mẫu) Vài GPU · vài giờ tới vài ngày → mô hình biết trò chuyện ③ CĂN CHỈNH (RLHF/DPO) Học từ so sánh: người chấm A hay B tốt hơn Tối ưu theo sở thích, không theo nhãn cứng → hữu ích, trung thực, an toàn Toàn bộ Module 00–10 nằm gọn trong giai đoạn ①. Hai giai đoạn sau dùng đúng vòng lặp 5 bước, chỉ đổi dữ liệu và hàm mất mát. Điều đáng nhớ: 99% năng lực của mô hình đến từ giai đoạn ①. Giai đoạn ②③ chủ yếu dạy nó cách cư xử, chứ không dạy thêm tri thức. Hệ quả thực tế: nếu mô hình nền không biết về miền của bạn, fine-tune sẽ không nhồi được tri thức vào — hãy dùng RAG.
Ba giai đoạn. Cùng một vòng lặp huấn luyện của Module 00, chỉ khác dữ liệu và mục tiêu. Nhận ra điều đó là bạn đã đọc hiểu được hầu hết các bài báo về LLM.

2. Số học của việc scale — ba công thức bỏ túi

Ba phép nhân đủ để ước lượng mọi thứ

  • Chi phí huấn luyện: $\text{FLOPs} \approx 6 \times N \times D$  ($N$ = số tham số, $D$ = số token). Hệ số 6 = 2 cho forward + 4 cho backward, đúng như Module 07 đã chỉ ra.
  • Tỉ lệ tối ưu (Chinchilla): với ngân sách cho trước, nên dùng $D \approx 20 \times N$ token. Mô hình 7 tỉ tham số nên ăn khoảng 140 tỉ token. (Thực tế ngày nay người ta huấn luyện quá mức đó rất nhiều, vì chi phí suy luận về sau quan trọng hơn.)
  • VRAM lúc suy luận: $\approx N \times$ số byte mỗi tham số, cộng thêm KV cache. FP16 = 2 byte, INT8 = 1 byte, INT4 = 0,5 byte.
Mô hìnhTham sốToken huấn luyệnVRAM để chạy (FP16 / INT4)Chạy được ở đâu
nanoGPT (bài này)10 triệu1 triệu20 MBLaptop, vài phút.
GPT-2 small124 triệu10 tỉ250 MB / 62 MBGPU rẻ tiền.
Llama-3 8B8 tỉ15 nghìn tỉ16 GB / 4,5 GBRTX 3090/4090 hoặc Mac 16GB.
Llama-3 70B70 tỉ15 nghìn tỉ140 GB / 35 GB2×A100, hoặc 2×3090 ở INT4.
GPT-4 lớp~1,8 nghìn tỉ (MoE)~13 nghìn tỉhàng TBCụm máy chủ.
Phòng thí nghiệm 11

Ba núm sinh văn bản — temperature, top-k, top-p

Cùng một phân phối, ba cách cắt gọt khác nhau
Xác suất token kế tiếp · xám = bị loại bỏ · màu = còn được chọn
1.00
Chia logits. Thấp = bám đỉnh, an toàn, lặp lại. Cao = táo bạo, dễ lảm nhảm.
tắt
Chỉ giữ k token có xác suất cao nhất. 0 = tắt.
1.00
Giữ nhóm token nhỏ nhất có tổng xác suất ≥ p. Tự co giãn theo độ chắc chắn của mô hình.
Token còn lại—
Entropy—
P(đỉnh)—
Kết quả bốc mẫu

—

Cách chọn trong thực tế: việc trích xuất dữ liệu, phân loại, trả lời có đáp án đúng → temperature = 0 (chọn luôn token cao nhất, kết quả tất định, dễ kiểm thử). Viết sáng tạo → temperature 0.8–1.0 + top_p 0.9. Sinh mã nguồn → temperature 0.2. Đừng dùng đồng thời top-k và top-p ở mức chặt — chúng chồng lấn và bạn sẽ không biết núm nào đang gây ra hành vi gì. Điểm mấu chốt: cả ba núm này chỉ can thiệp lúc sinh, chúng không thay đổi một tham số nào của mô hình.

3. Tuỳ biến mô hình: chọn đúng công cụ

CáchChi phíDùng khiKhông dùng khi
Prompt engineeringGần như 0Luôn thử đầu tiên. Đổi hành vi, định dạng đầu ra, giọng điệu.Cần thay đổi sâu hoặc prompt dài quá tốn kém.
RAG (truy hồi + sinh)ThấpThêm tri thức: tài liệu nội bộ, dữ liệu mới, thông tin phải trích dẫn nguồn.Cần đổi phong cách hay kỹ năng chứ không phải kiến thức.
LoRA / QLoRATrung bình
(1 GPU, vài giờ)
Đổi phong cách, định dạng, học một kỹ năng hẹp. Chỉ huấn luyện ~0,1% tham số.Nhồi tri thức mới — nó không hiệu quả cho việc đó.
Fine-tune toàn bộCaoChuyên biệt hoá sâu, có ≥ 100k mẫu chất lượng, và đã thử ba cách trên.Hầu hết các trường hợp. Rủi ro "quên thảm hoạ" mọi năng lực khác.

Checklist debug — dán lên màn hình

  1. Loss ở bước 0 có bằng $\log K$ (K = số lớp)? Nếu không → khởi tạo tầng cuối sai (Module 06).
  2. Thử overfit đúng 1 lô nhỏ. Không đưa được về loss ≈ 0 → có bug, không phải thiếu dữ liệu.
  3. Đã gọi zero_grad() chưa? Gradient cộng dồn âm thầm (Module 03).
  4. Histogram activation từng tầng khoẻ không? Nhọn ở 0 hay dựng cột ở ±1 → sai khởi tạo (Module 06).
  5. Tỉ số grad.std()/param.std() ≈ $10^{-3}$? Lệch xa → tầng đó chết hoặc đang nổ (Module 06).
  6. Đã model.eval() khi đánh giá chưa? BatchNorm và Dropout đổi hành vi (Module 06).
  7. Shape có đúng như bạn nghĩ không? print(x.shape) ở mọi tầng, ít nhất một lần (Module 08).
  8. Dữ liệu đã xáo trộn chưa? Nhãn có lệch không? Rất nhiều "vấn đề mô hình" thật ra là vấn đề dữ liệu.
  9. Loss val có tăng lên không? Overfitting → thêm dữ liệu, giảm mô hình, early stopping (Module 05).
  10. So với baseline ngu nhất (bigram / đoán lớp đa số) có thắng không? Nếu không, đừng thêm GPU (Module 04).

4. Đi tiếp: bốn dự án theo thứ tự tăng dần

Cuối tuần này

Sinh tên tiếng Việt

  • Thu 5.000 tên thật
  • Bigram → MLP → so sánh loss
  • Vẽ embedding bằng matplotlib

Học được: toàn bộ Module 04–05 bằng tay bạn.

Một tuần

nanoGPT trên văn bản Việt

  • Truyện Kiều hoặc kho tin tức
  • 6 tầng, 6 head, ngữ cảnh 256
  • Train trên Colab/Kaggle miễn phí

Học được: Module 09–10 và cảm giác thật về chi phí GPU.

Hai tuần

Fine-tune LoRA

  • Lấy một mô hình 7B mở
  • QLoRA trên 1 GPU 24GB
  • Đo trước/sau bằng bộ đánh giá tự viết

Học được: quy trình thật, và giới hạn thật của fine-tune.

Một tháng

Đưa lên production

  • vLLM hoặc llama.cpp
  • KV cache, batching, lượng tử hoá
  • Theo dõi độ trễ & chi phí/1k token

Học được: phần mà các khoá học hay bỏ qua nhất.

Nguồn học tiếp, xếp theo thứ tự nên đọc

  • Andrej Karpathy — Neural Networks: Zero to Hero (YouTube). Nguồn cảm hứng của trang này; hãy code song song với video.
  • nanoGPT (github.com/karpathy/nanoGPT) — 300 dòng, đọc hết được trong một buổi tối. Bạn đã đủ nền để hiểu từng dòng.
  • The Illustrated Transformer — Jay Alammar, để củng cố phần hình dung.
  • Attention Is All You Need (2017) — giờ bạn đọc được bài báo gốc mà không cần hướng dẫn.
  • A Recipe for Training Neural Networks — Karpathy, blog. Đọc lại mỗi khi bị kẹt.

Tự kiểm tra

1 · Temperature, top-k và top-p có làm mô hình “học thêm” hay thay đổi checkpoint không?

Không. Chúng chỉ đổi cách chọn token lúc sinh từ cùng một phân phối logits; toàn bộ tham số giữ nguyên. Muốn đổi kiến thức hoặc hành vi nền phải tác động ở prompt/RAG/fine-tune. Vì vậy đừng dùng sampling để che một lỗi kiến thức rồi kết luận model đã được sửa.

2 · Cần trả lời từ tài liệu nội bộ vừa cập nhật; nên chọn prompt, RAG, LoRA hay fine-tune toàn bộ?

Ưu tiên RAG: tri thức mới nằm trong kho ngoài, có thể cập nhật và trích nguồn mà không sửa weights. Prompt dùng để quy định nhiệm vụ/định dạng; LoRA hợp với phong cách hoặc kỹ năng hẹp; fine-tune toàn bộ chỉ đáng cân nhắc khi có nhiều dữ liệu chất lượng và cần thay đổi sâu mà các cách rẻ hơn đã thất bại.

3 · Khi tăng context từ 4k lên 8k, hai khoản chi phí nào tăng khác nhau?

KV cache tăng gần tuyến tính theo số token. Attention ở pha xử lý cả prompt có ma trận $T\times T$, nên phần đó có thể tăng gần bình phương (tuỳ kernel/kiến trúc). GQA/MQA giảm số đầu K/V nên giảm cache, nhưng không tự xoá mọi chi phí context dài.

Phụ lục

☰ Sổ tay thuật ngữ Anh – Việt

Tra nhanh khi đọc tài liệu tiếng Anh. Số trong ngoặc là module giải thích kỹ.

Activation — giá trị đầu ra của một nơ-ron sau hàm kích hoạt. (01)
Attention — cơ chế cho mỗi vị trí tự chọn nghe những vị trí nào. (09)
Autograd — engine tự động tính đạo hàm bằng đồ thị tính toán. (03)
Backpropagation — lan truyền ngược; áp chain rule từ loss về mọi tham số. (03)
Batch — lô mẫu dùng cho một lần cập nhật tham số. (00)
BatchNorm — chuẩn hoá theo chiều batch để ổn định phân phối. (06)
Bias — hằng số cộng thêm; quyết định ngưỡng kích hoạt. (01)
BPE — thuật toán gộp cặp byte phổ biến nhất để tạo từ vựng. (10)
Causal mask — che tương lai để mô hình chỉ nhìn về quá khứ. (09)
Chain rule — quy tắc dây chuyền; nhân dồn các tỉ số đạo hàm. (03)
Checkpoint — file lưu toàn bộ tham số của mô hình tại một thời điểm.
Contiguous — tensor có dữ liệu nằm liền mạch trong bộ nhớ. (08)
Cross-entropy — hàm mất mát cho phân loại; là $-\log$ xác suất lớp đúng. (02)
Dropout — tắt ngẫu nhiên một tỉ lệ nơ-ron lúc train để chống overfit.
Embedding — véc-tơ số thực học được, đại diện cho một token. (05)
Epoch — một lượt duyệt hết toàn bộ tập huấn luyện. (00)
Fan-in / fan-out — số kết nối vào / ra của một nơ-ron. (06)
Feed-forward (FFN) — khối MLP trong mỗi tầng Transformer. (09)
Fine-tuning — huấn luyện tiếp một mô hình đã có trên dữ liệu mới. (11)
Gradient — véc-tơ đạo hàm riêng; hướng tăng nhanh nhất của loss. (02)
Gradient clipping — chặn độ dài gradient để tránh nổ. (02)
Hyperparameter — siêu tham số, do người chọn chứ máy không học. (00)
Inference — chạy mô hình để dự đoán, không cập nhật tham số. (00)
Kaiming init — khởi tạo $\sqrt{2/n_{in}}$, chuẩn cho ReLU. (06)
KV cache — lưu key/value cũ để không tính lại khi sinh token. (09)
LayerNorm — chuẩn hoá theo chiều đặc trưng của từng mẫu. (06)
Learning rate — độ dài mỗi bước cập nhật. (00, 02)
Logits — điểm số thô trước softmax. (02)
LoRA — fine-tune bằng ma trận hạng thấp, chỉ đụng ~0,1% tham số. (11)
Loss / cost function — hàm mất mát, thước đo mức sai. (00, 02)
Mini-batch — lô nhỏ 32–512 mẫu; lựa chọn thực tế. (02)
Momentum — giữ đà từ các gradient trước để chống zíc-zắc. (02)
MLP — mạng nơ-ron nhiều tầng kết nối đầy đủ. (05)
Overfitting — thuộc lòng train, dốt với dữ liệu mới. (05)
Perplexity — $e^{\text{loss}}$; “số nhánh lựa chọn hiệu dụng”, chỉ so trực tiếp khi cùng tokenizer và tập đánh giá. (02)
Positional encoding — thông tin vị trí bơm vào attention. (09)
Receptive field — trường tiếp nhận; số đầu vào mà một nơ-ron nhìn thấy. (08)
Regularization — các kỹ thuật kìm mô hình để nó tổng quát hoá tốt hơn. (04)
Residual connection — $x + f(x)$; đường cao tốc cho gradient. (07)
Saturation — bão hoà; nơ-ron kẹt ở đầu mút, gradient ≈ 0. (03, 06)
Softmax — biến điểm số thành phân phối xác suất. (02)
Temperature — hệ số chia logits khi sinh; điều tiết độ táo bạo. (11)
Tensor — mảng nhiều chiều; đơn vị dữ liệu cơ bản. (08)
Token — đơn vị nhỏ nhất mô hình đọc được. (10)
Topological sort — sắp xếp tô-pô, thứ tự duyệt đúng của backward. (03)
Vanishing gradient — gradient teo dần khi đi qua nhiều tầng. (03, 06)
Warmup — tăng dần learning rate ở giai đoạn đầu. (02)
Weight decay — phạt trọng số lớn; một dạng regularization. (04)