Mở đầu: thuật toán khiến deep learning khả thi
Bài 6 kết thúc bằng một lời hứa: "đây là lần cuối bạn phải tự tay đạo hàm". Lời hứa đó không phải nói cho vui — nó dựa trên một thuật toán cụ thể, backpropagation, thứ biến deep learning từ một ý tưởng lý thuyết đẹp đẽ nhưng vô dụng thành thứ chạy được trên phần cứng thật. Không có nó, huấn luyện một mạng vài triệu tham số sẽ tốn thời gian tính bằng năm thay vì mili giây mỗi bước — bạn sẽ thấy con số đó cụ thể ngay Mục 1.
Bài này xây autograd — cỗ máy tự động tính đạo hàm — thẳng vào NeuroJS. Sau bài này, mọi
phép toán (+, *, matmul, relu, sigmoid)
tự nhớ cách lan truyền gradient ngược qua chính nó; bạn không còn phải viết backward tay như
Bài 6 nữa, dù kiến trúc mạng có phức tạp tới đâu. Đây là bước nhảy
quan trọng nhất biến NeuroJS từ "thư viện tensor" thành "thư viện học sâu" thật sự — mọi bài từ Bài 9 trở
đi (optimizer, CNN, attention) đều đứng trên vai autograd của bài này.
1. Bài toán: vì sao không thể tính đạo hàm bằng tay ở quy mô thật
Mạng XOR của Bài 6 có $9$ tham số — đủ nhỏ để tự đạo hàm tay một lần. Nhưng thử tưởng tượng cách "ngây thơ" để tính gradient mà không cần biết công thức đạo hàm nào cả: sai phân hữu hạn (finite difference) — nhích từng tham số lên một chút, chạy lại forward, xem loss đổi bao nhiêu:
$$\frac{\partial L}{\partial w_i} \approx \frac{L(w_i+\epsilon) - L(w_i-\epsilon)}{2\epsilon}$$
Cách này ĐÚNG về mặt toán học (Mục 4 dùng chính nó để kiểm tra autograd), nhưng để tính gradient cho MỘT tham số cần 2 lần forward pass (một lần $+\epsilon$, một lần $-\epsilon$). Muốn gradient cho TẤT CẢ tham số, phải lặp lại cho từng cái một:
| Số tham số | Số forward pass cần (sai phân hữu hạn) | Số forward+backward cần (backprop) |
|---|---|---|
| $9$ (mạng XOR Bài 6) | $18$ lần | $1$ forward + $1$ backward |
| $1{,}000{,}000$ (mạng nhỏ thực tế) | $2{,}000{,}000$ lần | $1$ forward + $1$ backward |
Giả sử một lần forward mất khoảng $1$ mili giây (hợp lý cho mạng cỡ vừa) — mạng $9$ tham số vẫn ổn ($18$ ms), nhưng mạng $1$ triệu tham số cần $2$ triệu lần forward CHO MỖI BƯỚC cập nhật — xấp xỉ $33$ phút, trong khi một bước huấn luyện thật cần hàng nghìn tới hàng triệu bước. Hoàn toàn bất khả thi. Trong khi đó, backprop tính gradient cho TẤT CẢ tham số cùng lúc, chỉ với đúng $1$ lượt forward và $1$ lượt ngược — chi phí gần như không đổi dù mạng có $9$ hay $1$ triệu tham số. Đây là lý do backprop tồn tại: chain rule được tổ chức lại để không phải lặp lại công việc cho từng tham số riêng lẻ.
2. Computation graph: mỗi phép toán là 1 nút
Backprop hoạt động trên một cấu trúc gọi là computation graph: mỗi phép toán (cộng, nhân, matmul...) là một nút, các nút nối với nhau theo đúng thứ tự phép toán được thực hiện ở forward pass. Hai bước:
- Forward: chạy phép tính như bình thường, nhưng LƯU lại giá trị trung gian ở mỗi nút.
- Backward: đi ngược đúng thứ tự đã tạo ra các nút (thứ tự topo), tại mỗi nút áp dụng công thức lõi:
$$\text{gradient tai nut} = \text{local gradient} \times \text{upstream gradient}$$
Trong đó "local gradient" là đạo hàm của đúng phép toán tạo ra nút đó (vd $c=ab$ thì local gradient theo $a$ là $b$), còn "upstream gradient" là gradient đã tích luỹ từ MỌI nút phía sau dùng tới nút này. Tính tay ví dụ nhỏ nhất có thể còn minh hoạ được điểm quan trọng nhất (một biến dùng lại ở 2 nhánh): $a=2$, $b=3$, $c=a \cdot b$, $d = c + a$ (dùng lại $a$!), $L = d^2$:
| Bước ngược | Công thức | Giá trị |
|---|---|---|
| $\partial L/\partial L$ (seed) | — | $1$ |
| $\partial L/\partial d$ | $2d \times 1$ | $2 \times 8 = 16$ |
| $\partial L/\partial c$ | $1 \times \partial L/\partial d$ ($d=c+a$ nên $\partial d/\partial c=1$) | $16$ |
| $\partial L/\partial a$ (nhánh qua $d$) | $1 \times \partial L/\partial d$ | $16$ |
| $\partial L/\partial a$ (nhánh qua $c$) | $b \times \partial L/\partial c$ ($c=ab$ nên $\partial c/\partial a=b$) | $3 \times 16 = 48$ |
| $\partial L/\partial a$ (TỔNG 2 nhánh) | $48+16$ | $64$ |
| $\partial L/\partial b$ | $a \times \partial L/\partial c$ | $2 \times 16 = 32$ |
Vì $a$ được dùng ở CẢ 2 nhánh ($c=ab$ và $d=c+a$), gradient của nó là tổng đóng góp từ cả hai nhánh ($48+16=64$), không phải chỉ một trong hai. Đây chính là điểm sẽ trở thành cạm bẫy lớn nhất Mục 3. Demo Mục 5 cho bạn bấm từng bước ngược này trên đúng sơ đồ SVG, xem giá trị hiện ra tại mỗi nút.
3. Xây autograd thật vào NeuroJS
Theo đúng kiểu
micrograd
của Andrej Karpathy: mỗi Tensor giờ mang thêm $3$ trường — .grad (gradient tích
luỹ), ._backward (closure "1 bước lùi" riêng của phép toán tạo ra nó), và
._prev (các tensor input, để dò thứ tự topo bằng
DFS):
// Moi Tensor gio mang: .grad (Float32Array), ._backward (closure), ._prev (mang input)
function mul(a, b) {
const out = /* ...tinh gia tri forward nhu Bai 5... */;
out._prev = [a, b];
out._backward = () => {
a._ensureGrad(); b._ensureGrad();
a.grad += out.grad * b.data; // local gradient cua phep nhan: d(ab)/da = b
b.grad += out.grad * a.data; // d(ab)/db = a
};
return out;
}
// backward(): duyet TOPO (DFS) roi lui NGUOC dung thu tu do
Tensor.prototype.backward = function () {
const topo = [], visited = new Set();
const build = (t) => { if (visited.has(t)) return; visited.add(t);
for (const p of t._prev) build(p); topo.push(t); };
build(this);
this.grad = 1; // seed: chi goi tren scalar (loss)
for (let i = topo.length - 1; i >= 0; i--) topo[i]._backward();
};
Cài đủ $5$ phép NeuroJS cần cho Bài 6 trở đi: add,
mul (cả hai đã hỗ trợ broadcasting từ Bài 5 — backward phải
"un-broadcast" đúng ngược lại, cộng dồn theo đúng chiều đã bị nhân bản ở forward),
matmul (backward chuẩn: $dA = d_{out} \cdot B^T$, $dB = A^T \cdot d_{out}$),
relu, sigmoid (dùng đúng công thức đạo hàm
Bài 6 đã lập bảng, giờ chạy tự động).
// batch (4,3) + bias (3,) -> out (4,3): forward NHAN BAN bias ra 4 dong (Bai 5).
// Backward phai lam NGUOC LAI: CONG DON gradient cua 4 dong do ve lai dung 1
// dong (3,) cua bias - khong lam vay, shape gradient se khong khop input.
function unbroadcast(gradOut, outShape, targetShape) {
const result = new Float32Array(targetShape.reduce((a, b) => a * b, 1));
// ... duyet outShape, cong don tung phan tu ve dung vi tri da bi "nhan ban" ...
return result;
}
// vd: dOut shape (4,3) -> unbroadcast ve bias shape (3,) = CONG 4 dong lai voi nhau
Vì Mục 2 vừa chứng minh một node dùng ở 2 nhánh phải CỘNG gradient từ cả hai —
_backward của mọi phép toán đều dùng +=, không phải =. Hệ quả
tất yếu: nếu bạn gọi backward() ở vòng lặp huấn luyện MỚI mà quên xoá gradient của vòng
CŨ, gradient mới sẽ cộng dồn lên gradient cũ — sai hoàn toàn. Kiểm chứng bằng số cụ thể: tham số
$w=3$, vòng $1$ dùng dữ liệu $x=2$ ($L=(wx)^2$, $\partial L/\partial w = 2wx^2 = 24$), vòng $2$ dùng
$x=5$ ($\partial L/\partial w$ riêng lẻ $=2wx^2=150$):
| Cách làm | $w.grad$ sau vòng 2 |
|---|---|
QUÊN gọi zeroGrad() trước vòng 2 |
$174$ ($=24+150$) — SAI, nhiễm gradient vòng cũ |
CÓ gọi zeroGrad() trước vòng 2 |
$150$ — đúng, chỉ phản ánh dữ liệu vòng 2 |
Không có lỗi nào được ném ra — chương trình chạy trơn tru, loss vẫn là một con số hợp lệ, model vẫn
"học" được thứ gì đó, chỉ là học SAI (giống hệt tinh thần cạm bẫy broadcasting của
Bài 5: sai âm thầm, không sai ồn ào). Đây chính xác là lý do mọi vòng
lặp huấn luyện PyTorch đều bắt đầu bằng optimizer.zero_grad() — không phải nghi thức thừa
thãi, mà là bước bắt buộc để tránh đúng lỗi này.
Nhưng cùng cơ chế cộng dồn này KHÔNG PHẢI luôn là lỗi — nó là nền tảng của một kỹ thuật thật gọi là
gradient accumulation (dùng khi batch mong muốn quá lớn để vừa VRAM một lần): chia
batch lớn thành các batch nhỏ, tính loss mỗi batch nhỏ CHIA CHO số bước tích luỹ rồi gọi
backward() liên tiếp KHÔNG gọi zero_grad() giữa chừng, chỉ
optimizer.step() một lần sau cùng. Kiểm chứng bằng số cụ thể: $4$ điểm $x=(1,2,3,4)$,
$y=(2,4,7,9)$, $w=1$. Gradient MSE tính trên nguyên batch $4$ điểm $= -18{,}5$. Chia thành 2 batch nhỏ
$(1,2)$ và $(3,4)$, tính gradient trung bình riêng từng batch rồi CHIA MỖI CÁI CHO $2$ (số bước tích
luỹ) trước khi cộng dồn: kết quả cũng là $-18{,}5$ — khớp tuyệt đối với gradient full-batch. Đây là
bằng chứng số học cho thấy += không phải "tính năng nguy hiểm cần tránh" mà là công cụ mô
phỏng đúng một batch lớn từ nhiều batch nhỏ hơn — miễn là chia đúng tỉ lệ trước khi cộng dồn.
4. Gradient checking: tự kiểm tra autograd bằng chính công thức Mục 1
Câu hỏi tự nhiên: làm sao biết autograd viết ở Mục 3 tính ĐÚNG? Trả lời bằng đúng công cụ tưởng đã "quá chậm để dùng" ở Mục 1 — sai phân hữu hạn — nhưng giờ dùng để KIỂM TRA (chỉ vài tham số, không phải để huấn luyện thật) thay vì để tính gradient sản xuất. Chọn $\epsilon$ sai thì phép kiểm tra vô nghĩa: quá lớn thì công thức xấp xỉ (bỏ qua đạo hàm bậc cao) sai; quá nhỏ thì phép trừ 2 số gần bằng nhau bị lỗi làm tròn dấu phẩy động nuốt mất tín hiệu thật. Quét $\epsilon$ trên một hàm có độ cong thật ($L=\sigma(ab+a)^3$):
// Sai phan huu han TRUNG TAM (central difference) - chinh xac hon 1 phia
function centralDiff(f, x, eps) {
return (f(x + eps) - f(x - eps)) / (2 * eps);
}
// L(a) = sigmoid(a*b + a)^3 voi b=-1.3 co dinh - ham co DO CONG that (khac
// vi du 3-phep-toan Muc 2, chi la da thuc bac thap nen khong lo ro 2 phia sai so)
function L(a) { const c = a * (-1.3) + a; const s = 1/(1+Math.exp(-c)); return s*s*s; }
centralDiff(L, 0.7, 1e-5); // ~sai so 8e-13 so voi dao ham giai tich - xem bang duoi
| $\epsilon$ | Sai số so với đạo hàm giải tích |
|---|---|
| $1$ (quá lớn) | $3{,}06 \times 10^{-4}$ |
| $0{,}01$ | $3{,}17 \times 10^{-8}$ |
| $10^{-4}$ | $3{,}15 \times 10^{-12}$ |
| $10^{-5}$ (tốt nhất) | $8{,}08 \times 10^{-13}$ |
| $10^{-8}$ | $9{,}54 \times 10^{-10}$ |
| $10^{-10}$ (quá nhỏ) | $7{,}19 \times 10^{-8}$ |
Sai số giảm dần khi $\epsilon$ nhỏ lại, chạm đáy quanh $10^{-5}$, rồi tăng trở lại khi $\epsilon$ tiếp tục nhỏ hơn nữa — một hình chữ U rõ ràng. Vùng đáy $10^{-4}$–$10^{-6}$ là "vùng vàng": đủ nhỏ để công thức xấp xỉ chính xác, đủ lớn để không bị lỗi làm tròn nuốt mất.
Float32Array (32-bit) để khớp hành vi thư viện thật (Bài 5). Nếu nhiễu $\epsilon$ trực tiếp lên giá trị đã lưu ở đó, "vùng vàng" bị lệch hẳn: đo thực tế trên
chính engine này, $\epsilon=10^{-5}$ (vùng vàng lý thuyết) cho sai số tới $8{,}7 \times 10^{-2}$ — hoàn
toàn vô dụng, vì độ chính xác 32-bit (~7 chữ số có nghĩa) không đủ phân biệt nổi một nhiễu nhỏ cỡ đó
cộng vào giá trị đang lưu. Vì vậy file tải về tính vế "sai phân hữu hạn tham chiếu" bằng một hàm
JavaScript thuần túy ở double precision (64-bit, không đụng Float32Array), rồi mới so với
gradient autograd (chạy trên Tensor 32-bit thật) — đúng cách torch.autograd.gradcheck của
PyTorch làm (ép kiểu double trước khi kiểm tra), không phải mẹo riêng của bài này.
5. Nếm trước: vanishing & exploding gradient
Backprop nhân liên tiếp nhiều "local gradient" qua từng lớp (công thức Mục 2). Chuyện gì xảy ra khi xếp rất nhiều lớp? Tính bằng autograd thật, không đoán: xích $N$ lớp sigmoid liên tiếp (đạo hàm sigmoid tối đa chỉ $0{,}25$, tại $z=0$), và xích $N$ lần nhân với hằng số $2$:
| Số lớp $N$ | Gradient qua $N$ lớp sigmoid (tiêu biến) | Gradient qua $N$ lần nhân $2$ (bùng nổ) |
|---|---|---|
| $1$ | $0{,}25$ | $2$ |
| $10$ | $3{,}9 \times 10^{-7}$ | $1{,}024 \times 10^3$ |
| $20$ | $1{,}3 \times 10^{-13}$ | $1{,}049 \times 10^6$ |
| $50$ | $4{,}5 \times 10^{-33}$ (thực tế $=0$ do giới hạn số dấu phẩy động) | (tăng tiếp theo luỹ thừa $2$) |
Chỉ $20$ lớp sigmoid đã đủ làm gradient tiêu biến về $10^{-13}$ — với mạng sâu thật ($50$–$100$ lớp), gradient ở những lớp đầu tiên thực chất bằng $0$, model không học được gì ở đó nữa. Ngược lại, nếu mỗi lớp khuếch đại tín hiệu lên (như nhân hằng số $>1$), gradient bùng nổ theo cấp số nhân, làm bước cập nhật GD trở nên vô nghĩa (quá lớn, số học tràn). Đây là hai vấn đề cốt lõi ngăn cản mạng thật sự sâu trước khi có các kỹ thuật khắc phục — chi tiết đầy đủ và lời giải (LSTM, residual connection, chuẩn hoá gradient) dành cho Bài 13 khi cần xử lý chuỗi dài thật.
6. Thực hành: computation graph — bấm Backward xem gradient chảy ngược
Đúng ví dụ 3 phép toán của Mục 2 ($a=2, b=3, c=ab, d=c+a, L=d^2$), chạy bằng autograd thật của
ai-neuro.js. Bấm Bước ngược liên tiếp để tô sáng từng nút theo ĐÚNG thứ tự
backward() thật sự xử lý, xem giá trị gradient hiện ra:
Để ý nút a: nó nhận gradient ở 2 bước khác nhau (qua nhánh $d$ rồi qua nhánh $c$) và CỘNG DỒN thành $64$ — đúng cạm bẫy Mục 3, nhìn thấy tận mắt trên sơ đồ.
Đối chiếu công nghiệp: 3 dòng "thần chú" PyTorch mà giờ bạn đã hiểu rõ cơ chế bên trong:
# Doi chieu 1-1 voi vi du 3 phep toan Muc 2 - cai dat: pytorch.org/get-started/locally
import torch
a = torch.tensor(2.0, requires_grad=True) # requires_grad=True: bao PyTorch GHI LAI computation graph
b = torch.tensor(3.0, requires_grad=True)
c = a * b # moi phep toan tu dong them 1 nut vao graph (giong ._prev cua Muc 3)
d = c + a # a duoc DUNG LAI o day - PyTorch tu biet phai cong don gradient
L = d ** 2
L.backward() # DUNG 1 lan goi: tu dong duyet topo NGUOC, chay het moi ._backward()
print(a.grad) # 64.0 - khop chinh xac tay tinh Muc 2
print(b.grad) # 32.0
# optimizer.zero_grad() trong vong lap that: xoa .grad truoc MOI buoc moi,
# tranh dung cam bay Muc 3 (cong don gradient tu vong lap truoc)
Tóm lược
- ✅ Sai phân hữu hạn chết ở quy mô thật: $O(N)$ lần forward cho $N$ tham số — mạng $1$ triệu tham số cần $2$ triệu lần forward MỖI bước, ~$33$ phút so với vài mili giây của backprop.
- ✅ Computation graph + công thức lõi: gradient tại nút = local gradient × upstream gradient, đi ngược đúng thứ tự topo — verified tay trên ví dụ $3$ phép toán ($a.grad=64=48+16$).
-
✅ Autograd thật đã có trong NeuroJS: mỗi Tensor mang
.grad,._backward,._prev; cài đủadd/mul/matmul/relu/sigmoid— không cần viết backward tay nữa từ đây trở đi. -
✅ Gradient CỘNG DỒN là bắt buộc (một node dùng ở 2 nhánh) nhưng cũng là cạm bẫy số 1:
quên
zeroGrad()làm gradient nhiễm vòng lặp cũ (verified: $174$ thay vì $150$). - ✅ Gradient checking dùng chính sai phân hữu hạn để KIỂM TRA (không phải để huấn luyện) — vùng vàng $\epsilon \in [10^{-4}, 10^{-6}]$, và phải tính ở double precision, không phải trên buffer float32 của Tensor.
- ✅ Vanishing/exploding: $20$ lớp sigmoid đủ làm gradient tiêu biến về $10^{-13}$; nhân hằng số $>1$ liên tiếp làm gradient bùng nổ theo cấp số nhân — chi tiết + lời giải ở Bài 13.
Tải file code thực hành minh họa bài học
ai-neuro.js — NeuroJS giờ đã có autograd đầy đủ (Tensor mang
.grad/._backward/._prev,
add/mul/matmul/relu/sigmoid, gradient checking). Self-test gồm $43$ check: $22$ regression
từ Bài 5 (không đổi hành vi) cộng $21$ check mới của bài này (chạy
node ai-neuro.js):
Bình luận