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.
Đọ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?
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án | Tên gọi | Dịch sang code Python | Trự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. |
Đạ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ướcVí 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.
—
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$).
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$ |
|---|---|---|
| 1 | 2 | $w$ |
| 2 | 4 | $2w$ |
| 3 | 6 | $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.
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ănBấ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:
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).
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 / iteration | Một lần chạy trọn 5 bước = một lần tham số được sửa. | Một vòng for. |
| Epoch | Số 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 / Test | Chia 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é). |
| Overfitting | Mô 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)$$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àm | Công thức | Đạo hàm | Miề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. |
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ểmChọ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) |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
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.
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.CrossEntropyLossvà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án | Hàm mất mát | Công thức | Ghi 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 lai | MAE / 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ớp | Binary 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.00 | 0.00 | Chắc chắn và đúng → không phạt. |
| 0.50 | 0.69 | Đoán mò 2 lớp → phạt vừa. |
| 0.10 | 2.30 | Nghi ngờ đáp án đúng → phạt nặng. |
| 0.01 | 4.61 | Gần như loại trừ đáp án đúng → phạt rất nặng. |
| 0.0001 | 9.21 | Tự 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.
Softmax & Cross-entropy — kéo logits, xem hình phạt
Lớp đúng đang là "mèo"temperature khi gọi API LLM.
—
Đ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.
Đ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—
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ểu | Mỗi bước dùng | Ưu | Nhược |
|---|---|---|---|
| Batch GD | Toàn bộ dữ liệu | Gradient 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ẫu | Cậ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ẫu | Lự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ặclog(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ổng | Forward | Backward — 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. |
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ề chaForward đã 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.
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.
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
forwardvàbackward— đúng khuôntorch.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.
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ệuBấ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:
- 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).
- 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.
- Softmax. Biến logits thành 27 xác suất có tổng bằng 1.
- 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.
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ảnh | Kích thước bảng đếm | Vấn đề |
|---|---|---|
| 1 ký tự (bigram) | $27^2 = 729$ ô | Ổn. |
| 2 ký tự (trigram) | $27^3 \approx 20$ nghìn | Bắt đầu thưa. |
| 5 ký tự | $27^6 \approx 387$ triệu | Hầ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ố
temperaturetrong 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.
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 3DBấ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)
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ớiemb.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/.reshapechứ 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_entropythay vì tự viết softmax rồi log. Bản của PyTorch trừ đi max của logits trước khiexpđể tránh tràn số, và có công thức backward rút gọn. Tự viết thìexp(90)rainfvà loss thànhnan. - 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 cho | Lý do hằng số |
|---|---|---|---|
| Xavier / Glorot | $\sqrt{1/n_{\text{in}}}$ (hoặc $\sqrt{2/(n_{in}+n_{out})}$) | tanh, sigmoid | tanh gần như tuyến tính quanh 0, không đổi thang đo. |
| Kaiming / He | $\sqrt{2/n_{\text{in}}}$ | ReLU | ReLU 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}}}}$ | tanh | gain 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 residual | GPT-2/nanoGPT | Giữ phương sai ổn định khi cộng dồn $L$ nhánh residual. |
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—
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 nhau | Nhiễ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 PyTorch | model.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 + brồ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 đặtbias=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ẻ.
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ự:
- 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.
- Vẽ histogram activation từng tầng. Nhọn ở 0 → gain quá nhỏ. Hai cột ở ±1 → gain quá lớn.
- 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. - 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.
- 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ế:
- Debug. Khi loss ra
nanhoặ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. - Viết layer/kernel mới. Mọi
torch.autograd.Function, mọi kernel Triton/CUDA đều buộc bạn cung cấpbackwardbằng tay. - 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):
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.
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—
Đ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
| Forward | Backward | Ghi 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. |
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 @ dZ1là 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
Sequentialphâ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.
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—
Đ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ép | Có đổi thứ tự dữ liệu trong bộ nhớ? | Khi nào dùng |
|---|---|---|
view | Khô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ì. |
reshape | Khô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 / transpose | Có — 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ởi | Nghĩ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$$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"—
Đ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ùngdim=-2vẫ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;triukhiến mỗi token chỉ nhìn thấy tương lai. - Quên cắt ngữ cảnh lúc sinh. Nếu
idxdài hơnblock_size, trapos_embsẽ 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 T | Số ô của ma trận attention | Ghi chú |
|---|---|---|
| 256 | 65 nghìn | nanoGPT chạy được trên laptop. |
| 2.048 | 4,2 triệu | GPT-3. |
| 128.000 | 16,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ức | Từ vựng | Độ dài chuỗi | Vấn đề |
|---|---|---|---|
| Ký tự | ~100 | Rất dài | Ngữ cảnh $O(T^2)$ nổ tung; mô hình phải học lại chính tả từ đầu. |
| Từ | Vô hạn | Ngắn | Từ 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 – 200k | Vừ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
- 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).
- Đếm mọi cặp token liền kề trong toàn bộ kho văn bản.
- 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.
- 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).
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—
Đ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.
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ượng | Nguyê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ém | 1234 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 Anh | Kho 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
tiktokentrướ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
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ình | Tham số | Token huấn luyện | VRAM để chạy (FP16 / INT4) | Chạy được ở đâu |
|---|---|---|---|---|
| nanoGPT (bài này) | 10 triệu | 1 triệu | 20 MB | Laptop, vài phút. |
| GPT-2 small | 124 triệu | 10 tỉ | 250 MB / 62 MB | GPU rẻ tiền. |
| Llama-3 8B | 8 tỉ | 15 nghìn tỉ | 16 GB / 4,5 GB | RTX 3090/4090 hoặc Mac 16GB. |
| Llama-3 70B | 70 tỉ | 15 nghìn tỉ | 140 GB / 35 GB | 2×A100, hoặc 2×3090 ở INT4. |
| GPT-4 lớp | ~1,8 nghìn tỉ (MoE) | ~13 nghìn tỉ | hàng TB | Cụm máy chủ. |
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—
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ách | Chi phí | Dùng khi | Không dùng khi |
|---|---|---|---|
| Prompt engineering | Gần như 0 | Luô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ấp | Thê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 / QLoRA | Trung 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ộ | Cao | Chuyê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
- 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).
- Thử overfit đúng 1 lô nhỏ. Không đưa được về loss ≈ 0 → có bug, không phải thiếu dữ liệu.
- Đã gọi
zero_grad()chưa? Gradient cộng dồn âm thầm (Module 03). - 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).
- Tỉ số
grad.std()/param.std()≈ $10^{-3}$? Lệch xa → tầng đó chết hoặc đang nổ (Module 06). - Đã
model.eval()khi đánh giá chưa? BatchNorm và Dropout đổi hành vi (Module 06). - 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). - 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.
- Loss val có tăng lên không? Overfitting → thêm dữ liệu, giảm mô hình, early stopping (Module 05).
- 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ỹ.