~4-5 giờ · cần xong bài 2 · ĐÂY LÀ BÀI QUAN TRỌNG NHẤT CỦA MÔN 3
Backpropagation không phải một thuật toán mới. Nó là chain rule chạy ngược trên đồ thị tính toán.
Trước bài này, "mạng nơ-ron học được" là một câu huyền bí. Sau bài này nó là một vòng lặp nhân số theo thứ tự ngược — bạn tự cài được trong 60 dòng, và bạn biết chính xác chỗ nào nó gãy.
⚡ 30 phút đầu — làm trước, đọc sau
import numpy as np
f = lambda x: (3*x + 1)**2
h = 1e-5
print(f(2.0)) # đích: 49.0
print((f(2.0+h) - f(2.0-h)) / (2*h)) # đích: 42.000000000896875
# tự tính bằng TAY trước khi chạy dòng trên: đặt u = 3x+1, df/dx = ?
print(0.5**20) # đích: 9.5367431640625e-07 <- 20 lớp, mỗi lớp nuốt một nửa
print(1.5**20) # đích: 3325.256730079651 <- 20 lớp, mỗi lớp phóng 1.5 lần
print(1.05**20) # đích: 2.653297705144422 <- và đây là chỗ residual sống
Ra 42 bằng tay trước khi chạy code → bạn đã có chain rule, chỉ chưa gọi tên nó.
Ba con số cuối là toàn bộ lý do mạng sâu khó huấn luyện, và §7-8 chỉ đi giải thích chúng.
1. Chain rule — nhân tỉ giá
Bạn đổi tiền qua hai chặng:
1 EUR = 1.1 USD 1 USD = 25 000 VND
Hỏi: 1 EUR đổi được bao nhiêu VND? Bạn nhân hai tỉ giá: 1.1 × 25 000 = 27 500.
Chain rule đúng là như vậy, không hơn:
dz/dx = dz/dy · dy/dx
Đạo hàm là "tỉ giá tức thời": x nhúc nhích 1 đơn vị thì y nhúc nhích bao nhiêu. Qua nhiều chặng thì nhân các tỉ giá lại. Hết.
Chữ "chain" (chuỗi) là nghĩa đen: x → y → z là một dây chuyền, và bạn nhân dọc theo dây.
Ví dụ tính tay — f(x) = (3x + 1)² tại x = 2
Tách thành hai chặng để nhìn thấy dây chuyền:
chặng 1: u = 3x + 1 du/dx = 3
chặng 2: f = u² df/du = 2u
Tại x = 2:
u = 3(2) + 1 = 7
f = 7² = 49
df/dx = df/du · du/dx = (2·7) · 3 = 14 · 3 = 42
Kiểm bằng sai phân trung tâm (h = 1e-5):
(f(2.00001) − f(1.99999)) / 0.00002 = 42.000000000896875
Khớp tới 9 chữ số. 42 không phải kết quả của một công thức học thuộc — nó là tích của hai tỉ giá.
Sai lầm phổ biến nhất: viết
df/dx = 2(3x+1) = 14và quên nhân3. Bạn đã đi hết chặng 2 mà bỏ quên chặng 1. Chain rule bị bỏ sót im lặng — kết quả vẫn là một con số trông hợp lý.
2. Quy tắc tích — thật ra là "một nút dùng nhiều lần"
Sách dạy: (uv)' = u'v + uv'. Kiểm bằng số cho chắc, h(x) = (2x+1)·x² tại x = 2:
u = 2x+1 = 5 u' = 2
v = x² = 4 v' = 2x = 4
h(2) = 5 × 4 = 20
h'(2) = u'v + uv' = 2(4) + 5(4) = 8 + 20 = 28
sai phân trung tâm: 28.0000000005387 ✓
Nhưng công thức đó che mất ý thật sự. Nhìn nó bằng đồ thị:
Quy tắc tích = quy tắc CỘNG gradient khi một biến đi vào kết quả qua nhiều con đường.
Ví dụ nhỏ nhất, g(x) = x · x tại x = 3:
x đi vào phép nhân qua ĐƯỜNG TRÁI → đóng góp: (giá trị vế phải) = 3
x đi vào phép nhân qua ĐƯỜNG PHẢI → đóng góp: (giá trị vế trái) = 3
tổng: 3 + 3 = 6 = 2x ✓
Kiểm: sai phân trung tâm cho 6.000000000039306.
Ví dụ rõ hơn, L = (a+b)(a+c) tại a=2, b=3, c=4 — ở đây a đi vào cả hai thừa số:
L = 5 × 6 = 30
dL/da = (a+c) + (a+b) = 6 + 5 = 11 ← CỘNG hai đường
dL/db = (a+c) = 6 ← b chỉ có một đường
dL/dc = (a+b) = 5
gradient số: d/da = 11.000000002 d/db = 5.999999999 d/dc = 5.000000002 ✓
Ghi thẳng vào đầu: gradient của một nút = TỔNG đóng góp từ mọi đường đi ra khỏi nó. Đây là lý do trong autograd bạn viết
self.grad += ...chứ không bao giờ viếtself.grad = .... §BẪY-1 cho thấy nhầm dấu này làm sai đúng một nửa mà không crash.
Ẩn dụ backend: một service được gọi từ 2 upstream. Muốn biết nó chịu trách nhiệm bao nhiêu cho độ trễ tổng, bạn cộng phần đóng góp qua cả hai đường gọi, không lấy một đường rồi ghi đè.
3. Đồ thị tính toán — mọi biểu thức đều là một DAG
Ẩn dụ backend: đây chính là DAG của một pipeline. Mỗi nút là một task, cạnh là phụ thuộc. Forward = chạy pipeline. Backward = chạy trace ngược để quy trách nhiệm.
Lấy đúng biểu thức của §1 nhưng viết ra thành các nút, với a = 3, b = 2, c = 1
(chú ý: b đóng vai x, nên L sẽ ra đúng 49 và dL/db sẽ ra đúng 42):
a ─┐
├──[ * ]──> u ──┐
b ─┘ ├──[ + ]──> v ──[ (·)² ]──> L
c ────┘
Forward — chạy xuôi, ghi lại mọi giá trị trung gian (bắt buộc phải ghi, backward cần chúng):
u = a·b = 3 × 2 = 6
v = u + c = 6 + 1 = 7
L = v² = 49
Backward — chạy ngược, mỗi cạnh nhân thêm một đạo hàm cục bộ:
| Bước | Đạo hàm cục bộ | Nhân với gradient đến từ trên | Kết quả |
|---|---|---|---|
| gieo hạt | — | — | dL/dL = 1 |
L = v² |
dL/dv = 2v = 14 |
× 1 |
dL/dv = 14 |
v = u + c |
dv/du = 1 |
× 14 |
dL/du = 14 |
v = u + c |
dv/dc = 1 |
× 14 |
dL/dc = 14 |
u = a·b |
du/da = b = 2 |
× 14 |
dL/da = 28 |
u = a·b |
du/db = a = 3 |
× 14 |
dL/db = 42 |
Kiểm bằng gradient số: [28, 42, 14], lệch tối đa 8.97e-10. ✓
Và dL/db = 42 chính là df/dx = 42 của §1 — cùng một hàm, viết bằng đồ thị thay vì bằng công thức.
Ba điều rút ra, và cả ba đều quan trọng
(a) Mỗi nút chỉ cần biết luật của riêng nó. Nút * biết d(ab)/da = b. Nó không biết
nó nằm trong mạng nào, sau nó là gì. Nó nhận gradient từ trên rơi xuống, nhân với đạo hàm cục bộ,
đẩy tiếp xuống dưới. Đây là toàn bộ backpropagation.
Ẩn dụ backend: mỗi nút là một middleware. Nó không biết toàn bộ request pipeline — nó chỉ biết biến đổi cái nhận được rồi chuyển tiếp. Chain rule là hợp đồng giữa các middleware.
(b) Thứ tự chạy ngược phải là thứ tự topo đảo ngược. Bạn không được tính dL/du trước khi
dL/dv xong, vì dL/du cần dL/dv. Đây đúng là bài toán make / dependency resolution.
(c) Chi phí backward ≈ chi phí forward. Một lượt xuôi, một lượt ngược. So với cách "ngây thơ" —
tính gradient bằng sai phân số cho từng tham số — cần 2N lượt forward:
| Cách | Công việc cho loss vô hướng, mạng 1 triệu tham số |
|---|---|
| Sai phân số | 2 000 000 |
| Backpropagation | 1 forward + 1 backward; backward thường cùng bậc chi phí forward |
| không cần lặp forward riêng cho từng tham số |
Đây là câu trả lời cho "vì sao deep learning khả thi". Không phải GPU. Là chain rule chạy ngược. GPU tăng tốc các phép tính; tỉ lệ thời gian thực tế còn phụ thuộc kiến trúc, bộ nhớ và implementation.
4. Backpropagation — phát biểu đầy đủ trong 5 dòng
1. Forward: chạy xuôi đồ thị, LƯU mọi giá trị trung gian.
2. Đặt grad của nút đầu ra = 1. (dL/dL = 1)
3. Duyệt các nút theo thứ tự topo ĐẢO NGƯỢC.
4. Tại mỗi nút: grad_con += (đạo hàm cục bộ) × grad_của_chính_nó.
5. Hết.
Dòng 4 là chain rule. Dòng 3 là make. Dòng 1 là lý do huấn luyện tốn RAM
(phải giữ mọi activation của lượt xuôi cho tới khi lượt ngược dùng xong).
Nếu bạn chỉ mang một câu ra khỏi môn 3, mang câu này: Backprop = chain rule + thứ tự topo đảo ngược + cộng dồn gradient tại nút phân nhánh. Không có thành phần thứ tư. Không có phép màu.
5. Mạng 2 lớp — tự viết backward bằng tay
Đây là chỗ lý thuyết thành cơ bắp. Mạng:
x ──[ W1 ]──> z1 ──[ relu ]──> a1 ──[ W2 ]──> ŷ ──[ MSE với y ]──> L
Cố ý chọn số nguyên để làm được trên giấy:
x = (1, 2) W1 = [ 1 0 ] W2 = [ 1 0 1 ]
y = (0, 1) [ 0 1 ] [ 0 1 0 ]
[ 1 −1 ]
Forward
z1 = W1·x = ( 1·1+0·2 , 0·1+1·2 , 1·1+(−1)·2 ) = ( 1, 2, −1)
a1 = relu(z1) = ( 1, 2, 0) ← nơ-ron 3 CHẾT
ŷ = W2·a1 = ( 1+0+0 , 0+2+0 ) = ( 1, 2)
e = ŷ − y = (1, 1)
L = mean(e²) = (1 + 1)/2 = 1
Backward — bốn bước, mỗi bước một lần chain rule
Bước 1 — qua MSE. L = (1/m)Σeᵢ² với m = 2:
dL/dŷ = 2·e / m = 2·(1,1)/2 = (1, 1)
Bước 2 — qua W2. ŷ = W2·a1, nên ∂ŷᵢ/∂W2[i,j] = a1[j]:
dL/dW2 = (dL/dŷ) ⊗ a1 (tích ngoài)
= [ 1 ] ⊗ (1, 2, 0) = [ 1 2 0 ]
[ 1 ] [ 1 2 0 ]
Bước 3 — lùi qua W2 về a1. Đây là chỗ ma trận chuyển vị xuất hiện, và §6 giải thích vì sao:
dL/da1 = W2ᵀ · (dL/dŷ) = [ 1 0 ] [ 1 ] ( 1 )
[ 0 1 ] [ 1 ] = ( 1 )
[ 1 0 ] ( 1 )
Bước 4 — qua relu, rồi qua W1. Đạo hàm của relu là 1 nếu đầu vào dương, 0 nếu không:
mask = (z1 > 0) = (1, 1, 0) ← nơ-ron 3 chết → cổng đóng
dL/dz1 = dL/da1 ⊙ mask = (1, 1, 0) ⊙ = nhân từng phần tử
dL/dW1 = (dL/dz1) ⊗ x = [ 1 ] ⊗ (1, 2) = [ 1 2 ]
[ 1 ] [ 1 2 ]
[ 0 ] [ 0 0 ]
Hàng cuối của dL/dW1 bằng 0. Nơ-ron chết không nhận gradient → trọng số của nó không đổi →
nó chết vĩnh viễn với mẫu này. Đó là "dying ReLU", và bạn vừa thấy nó bằng số, không phải bằng lời.
Xác nhận bằng gradient số
lệch |dW1 (tay) − dW1 (số)| = 5.35e-11
lệch |dW2 (tay) − dW2 (số)| = 5.35e-11
Cùng mạng đó với W ngẫu nhiên và batch 5 mẫu (seed = 7, loss = 0.7303448551819083) —
phần 2b của khối code chạy đúng thí nghiệm này:
lệch W1 = 5.34e-11 lệch W2 = 8.72e-11
Quy tắc làm việc suốt đời: mỗi lần bạn tự viết một
backward, việc đầu tiên là so nó với gradient số. Không có ngoại lệ. Backward sai gần như không bao giờ crash — nó chỉ làm mô hình học kém hơn một chút, và bạn sẽ đổ lỗi cho learning rate suốt ba tuần.
6. Chain rule qua L lớp = nhân một chuỗi ma trận Jacobian
Đây là chỗ môn 2 trả cổ tức, và là câu tự kiểm số 2 của README môn 3.
Với hàm nhiều biến, "đạo hàm" là một ma trận: Jacobian J[i,j] = ∂(đầu ra i)/∂(đầu vào j).
Chain rule giữ nguyên hình dạng, chỉ đổi phép nhân số thành phép nhân ma trận:
đầu vào ──[f₁]──[f₂]── ... ──[f_L]──> đầu ra
J_tổng = J_L · J_{L−1} · ... · J_2 · J_1
Bài 2 môn 2: nhân ma trận = ghép các phép biến đổi, đọc từ phải sang trái. Chain rule qua
Llớp là đúng một câu đó, áp cho các phép biến đổi tuyến-tính-hoá tại chỗ. Bạn không học một quy tắc mới; bạn dùng lại quy tắc cũ trên đối tượng mới.
Kiểm bằng chính mạng ở §5
Jacobian của ŷ theo x:
J = W2 · diag(mask) · W1
diag(mask) = diag(1,1,0) là Jacobian của relu — một ma trận đường chéo bật/tắt từng nơ-ron.
Nhân ra:
J = [ 1 0 ] Jacobian tính bằng sai phân số: khớp, lệch 2.88e-11 ✓
[ 0 1 ]
Mạng này tình cờ là phép đồng nhất trên lân cận của x — số liệu do bạn chọn cho đẹp, nhưng
cách tính thì tổng quát.
Vì sao backward có W2ᵀ mà forward có W2
Vì J của lớp a1 ↦ W2·a1 chính là W2, và chain rule dồn gradient theo chiều ngược nên bạn
nhân bên trái bằng chuyển vị:
| Chiều | Phép | Ẩn dụ |
|---|---|---|
| Forward | a1 → W2·a1 |
biến đổi không gian đi tới |
| Backward | g → W2ᵀ·g |
rải ngược trách nhiệm về từng đầu vào |
Nói bằng bài 1 môn 2: hàng j của W2ᵀ là cột j của W2, nên (W2ᵀg)[j] là tích vô hướng
giữa gradient đầu ra và "cột j" — tức là hỏi đầu vào j đã đóng góp bao nhiêu vào mỗi đầu ra,
có trọng số theo mức sai của đầu ra đó. Chuyển vị không phải thủ thuật ký hiệu; nó là câu hỏi ngược.
Kích thước Jacobian quyết định mọi thứ
Nếu tất cả J đều "phóng to" thì tích nổ. Nếu tất cả đều "thu nhỏ" thì tích tắt. Đó là §7.
Đo bằng số, J ngẫu nhiên 4×4 (seed = 3, phần 5b của khối code), nhân với chính nó 20 lần:
| scale | σ_max(J) |
ρ(J) (bán kính phổ) |
‖J²⁰‖ |
ρ²⁰ |
σ_max²⁰ |
|---|---|---|---|---|---|
| 0.5 | 1.1667 | 0.5371 | 7.8276e-06 | 3.9875e-06 | 2.1834e+01 |
| 1.0 | 1.2470 | 1.1512 | 1.8613e+01 | 1.6719e+01 | 8.2624e+01 |
| 1.5 | 2.8731 | 1.4664 | 2.8993e+03 | 2.1148e+03 | 1.4688e+09 |
Đọc bảng này cho kỹ. Ở dòng đầu,
σ_max = 1.17 > 1— nhìn qua tưởng sẽ nổ. Nhưng‖J²⁰‖là7.8e-06, tức tắt ngóm. Cái quyết định là bán kính phổρ(trị tuyệt đối lớn nhất của eigenvalue — bài eigenvector của môn 2), không phảiσ_max.σ_max²⁰chỉ là chặn trên, và ở dòng cuối nó vống lên 1.4688e+09 trong khi thực tế chỉ 2.8993e+03 — lệch 5,7 bậc, tức chặn trên nói quá hơn 500 000 lần. (ρ²⁰là chặn dưới,σ_max²⁰là chặn trên; sự thật nằm giữa.)Đây là lý do "spectral norm < 1 thì an toàn" là lời khuyên đúng nhưng quá chặt, và "eigenvalue quanh 1" chỉ mô tả một phần của bài toán lặp cùng một ma trận. Với chuỗi Jacobian khác nhau giữa các lớp, không được dùng phổ của từng ma trận như một bảo đảm cho tích.
6b. ⭐ Backprop chỉ là một LỰA CHỌN THỨ TỰ nhân ma trận
(bổ sung 19/8 — nguồn: MML tr.161-162, §5.6.2)
§6 vừa cho bạn J_tổng = J_L · J_{L−1} · ... · J_1. Có một chi tiết trong dòng đó chưa được nói ra,
và nó là chi tiết quan trọng nhất:
Nhân ma trận có tính kết hợp. Cái tích đó không quy định phải nhân theo thứ tự nào.
Với dy/dx = (dy/db)(db/da)(da/dx), bạn được phép:
TRÁI → PHẢI: ((dy/db · db/da) · da/dx) ← xuất phát từ ĐẦU RA = reverse mode
PHẢI → TRÁI: (dy/db · (db/da · da/dx)) ← xuất phát từ ĐẦU VÀO = forward mode
MML nói thẳng: "the forward and reverse mode differ in the order of multiplication".
Backpropagation CHÍNH LÀ reverse-mode automatic differentiation. Nó không phải phát minh của deep learning, không phải một thuật toán riêng cho mạng nơ-ron. Nó là một lựa chọn thứ tự nhân ma trận — thứ tự nào rẻ hơn thì chọn.
Cả hai thứ tự cho cùng một ma trận kết quả. Chỉ khác giá.
Đếm phép nhân, đừng tin cảm giác
Chuỗi 1000 → 100 → 10 → 1. Các Jacobian: J₁ là 100×1000, J₂ là 10×100, J₃ là 1×10.
Nhân ma trận (m×k)·(k×n) tốn m·k·n phép nhân vô hướng.
def dem_phep_nhan(dims):
"""dims = [d_vào, ..., d_ra]. Tầng i có Jacobian (dims[i+1] × dims[i])."""
J = [(dims[i + 1], dims[i]) for i in range(len(dims) - 1)]
acc, rev = J[-1], 0 # reverse: gộp từ phía ĐẦU RA
for Ji in reversed(J[:-1]):
rev += acc[0] * acc[1] * Ji[1]
acc = (acc[0], Ji[1])
acc, fwd = J[0], 0 # forward: gộp từ phía ĐẦU VÀO
for Ji in J[1:]:
fwd += Ji[0] * Ji[1] * acc[1]
acc = (Ji[0], acc[1])
return rev, fwd
print(" hình dạng | reverse | forward | ai thắng")
for dims in [[1000, 100, 10, 1], [1, 10, 100, 1000], [1000, 1000, 1000, 1]]:
r, f = dem_phep_nhan(dims)
print(f" {str(dims):>22} | {r:>11,} | {f:>11,} | "
f"{'reverse' if r < f else 'forward'} nhanh {max(r,f)/min(r,f):.1f} lần")
assert dem_phep_nhan([1000, 100, 10, 1]) == (101_000, 1_010_000)
assert dem_phep_nhan([1, 10, 100, 1000]) == (1_010_000, 101_000) # ĐẢO NGƯỢC hoàn toàn
| hình dạng | reverse | forward | ai thắng |
|---|---|---|---|
1000 → 100 → 10 → 1 |
101.000 | 1.010.000 | reverse, 10 lần |
1 → 10 → 100 → 1000 |
1.010.000 | 101.000 | forward, 10 lần |
1000 → 1000 → 1000 → 1 |
2.000.000 | 1.001.000.000 | reverse, 500,5 lần |
Và kiểm rằng hai thứ tự thật sự cho cùng kết quả, không phải hai phép tính khác nhau:
import numpy as np
def kiem(dims, seed=0):
rg = np.random.default_rng(seed)
J = [rg.normal(size=(dims[i + 1], dims[i])) for i in range(len(dims) - 1)]
rev = J[-1]
for Ji in reversed(J[:-1]):
rev = rev @ Ji # gộp từ đầu ra
fwd = J[0]
for Ji in J[1:]:
fwd = Ji @ fwd # gộp từ đầu vào
return np.max(np.abs(rev - fwd))
assert kiem([1000, 100, 10, 1]) < 1e-10 # lệch 1.14e-13 — chỉ là làm tròn
assert kiem([1, 10, 100, 1000]) < 1e-10 # lệch 3.20e-14
Kết luận đúng là gì
Không phải "reverse mode luôn tốt hơn". Dòng thứ hai của bảng là phản ví dụ sạch sẽ: đảo hình dạng thì lợi thế đảo theo, cùng đúng hệ số 10.
Chiều nào rẻ hơn phụ thuộc HÌNH DẠNG bài toán, không phụ thuộc thuật toán. Quy tắc gọn: gộp từ phía hẹp — phía có ít chiều hơn — để ma trận trung gian luôn nhỏ.
Mạng nơ-ron chọn reverse vì hình dạng của nó cực kỳ lệch: hàng triệu tham số vào, một số vô hướng (loss) ra. Cột "reverse" của dòng 3 cho thấy độ lệch càng lớn thì chênh lệch càng khủng khiếp —
500 lầnchỉ với 3 tầng.
Vì sao khó tự nghĩ ra: vì backprop luôn được dạy như một thuật toán có tên, kèm sơ đồ mũi tên đi ngược — và một thứ có tên riêng thì não xếp nó vào ô "kiến thức mới", không xếp vào ô "hệ quả của thứ đã biết". Tính kết hợp của phép nhân ma trận thì bạn đã có từ môn 2 bài 2; không ai nói với bạn rằng chỉ cần dùng nó theo chiều ngược là ra backprop. Thiếu mắt xích đó, câu hỏi "còn thứ tự nào khác không?" không bao giờ được đặt ra.
Chỗ forward mode thắng có tồn tại trong thực tế: tính đạo hàm của nhiều đầu ra theo một
tham số vô hướng (phân tích độ nhạy, jvp trong JAX), hoặc tích Jacobian–vector trong các phương
pháp bậc hai. Đó là lý do JAX cung cấp cả jacrev lẫn jacfwd chứ không chỉ một.
🪤 BẪY: cái giá của reverse mode không nằm ở số phép nhân — nó nằm ở BỘ NHỚ. Muốn nhân từ
phía đầu ra, bạn phải có mọi Jᵢ, tức phải giữ mọi activation của lượt xuôi cho tới khi lượt
ngược dùng tới (đúng dòng 1 của §4). Forward mode thì không cần giữ gì — nó tính cùng lúc với
lượt xuôi. Reverse đổi RAM lấy FLOPs. Đó là lý do gradient checkpointing tồn tại: nó trả lại
một phần FLOPs để mua lại RAM, và bạn chỉ hiểu được đánh đổi đó sau khi thấy nó là một lựa chọn
thứ tự nhân.
6c. ⭐ Vì sao KHÔNG viết gradient ra dạng tường minh
(bổ sung 19/8 — nguồn: MML tr.159)
Có một cách "hiển nhiên" để tính gradient mà không ai dùng: áp quy tắc dây chuyền trên giấy, viết biểu thức đạo hàm ra dạng tường minh, rồi cài công thức đó. MML dành nguyên một đoạn để nói vì sao không: biểu thức đó phình to đến mức tính gradient tốn hơn tính chính hàm số.
Ba thứ hay bị gộp làm một — chúng là ba thứ khác nhau:
| làm gì | giá | |
|---|---|---|
| Đạo hàm ký hiệu (symbolic) | biến biểu thức f thành biểu thức f' |
biểu thức phình theo cấp số nhân |
| Sai phân hữu hạn (numeric) | (f(x+h) − f(x−h))/2h |
2N lượt forward + sai số (§BẪY 4) |
| Autodiff (cái backprop dùng) | tính giá trị f'(x) bằng cách lưu giá trị trung gian |
~2 lượt, chính xác tới máy |
Autodiff không phải sai phân (nó chính xác, không có h), và cũng không phải đạo hàm ký hiệu
(nó không bao giờ dựng ra biểu thức f', chỉ dựng ra số). Đây là chỗ hầu như ai cũng gộp nhầm ít
nhất một cặp.
Đo sự phình to
(sympy không có trong venv này, nên dưới đây là một bộ đạo hàm ký hiệu 20 dòng tự viết — đủ để
đếm số nút, mà đếm nút mới là thứ cần.)
MOT_NGOI = ("sin", "cos", "exp") # hàm một biến
def dsize(e): # số nút nếu viết ra thành CÂY (như biểu thức trên giấy)
if e[0] in ("x", "num"):
return 1
if e[0] in MOT_NGOI:
return 1 + dsize(e[1])
return 1 + dsize(e[1]) + dsize(e[2])
def dag_size(e, seen=None): # số nút nếu DÙNG LẠI nút chung (như autodiff)
seen = set() if seen is None else seen
if id(e) in seen:
return 0
seen.add(id(e))
if e[0] in ("x", "num"):
return 1
if e[0] in MOT_NGOI:
return 1 + dag_size(e[1], seen)
return 1 + dag_size(e[1], seen) + dag_size(e[2], seen)
def diff(e): # quy tắc dây chuyền + quy tắc tích, KHÔNG rút gọn
t = e[0]
if t == "x": return ("num", 1)
if t == "num": return ("num", 0)
if t == "sin": return ("*", ("cos", e[1]), diff(e[1]))
if t == "exp": return ("*", ("exp", e[1]), diff(e[1]))
if t == "*": return ("+", ("*", diff(e[1]), e[2]), ("*", e[1], diff(e[2])))
def lam_ham(k): # e ← sin(e) · exp(e) — e xuất hiện HAI lần mỗi tầng
e = ("x",)
for _ in range(k):
e = ("*", ("sin", e), ("exp", e))
return e
print(" tầng | f (DAG) | f (cây) | f' (cây) | f'/f_DAG")
for k in range(1, 8):
e = lam_ham(k)
print(f" {k:>3} | {dag_size(e):>7} | {dsize(e):>7} | {dsize(diff(e)):>8} | "
f"{dsize(diff(e))/dag_size(e):>7.1f}x")
assert dag_size(lam_ham(7)) == 22 and dsize(diff(lam_ham(7))) == 6915
assert dsize(diff(lam_ham(7))) / dsize(diff(lam_ham(6))) > 2.3 # nhân >2.3 mỗi tầng
| tầng | f (DAG) |
f (cây) |
f' (cây) |
f'/f_DAG |
|---|---|---|---|---|
| 1 | 4 | 5 | 15 | 3,8x |
| 2 | 7 | 13 | 59 | 8,4x |
| 3 | 10 | 29 | 179 | 17,9x |
| 4 | 13 | 61 | 483 | 37,2x |
| 5 | 16 | 125 | 1219 | 76,2x |
| 6 | 19 | 253 | 2947 | 155,1x |
| 7 | 22 | 509 | 6915 | 314,3x |
Đọc cột đầu và cột cuối cạnh nhau. Hàm ở tầng 7 có 22 nút nếu bạn dùng lại các nút chung — tức
chi phí tính f(x) là 22 phép. Biểu thức đạo hàm tường minh của nó có 6915 nút: tính gradient
tốn 314 lần chi phí tính chính hàm số. Mỗi tầng thêm vào nhân số nút của f' lên hơn 2,3 lần,
trong khi f chỉ tăng thêm 3 nút.
Nguồn gốc của sự phình to nằm ở đúng một chỗ: quy tắc tích. diff(a*b) sinh ra diff(a) và
diff(b) — hai nhánh, mỗi nhánh lại nhân đôi ở tầng dưới. Đó là §2 của bài này ("một nút dùng nhiều
lần") nhìn từ phía chi phí: nút dùng lại k lần thì đạo hàm ký hiệu chép lại nó k lần, còn
autograd chỉ cộng dồn vào cùng một .grad.
Vì thế framework LƯU BIẾN TRUNG GIAN thay vì làm đạo hàm ký hiệu. Bảng trên là toàn bộ lý do dòng 1 của §4 ("Forward: LƯU mọi giá trị trung gian") tồn tại. Nó không phải một chi tiết cài đặt tiện tay — nó là thứ đổi
314xthành~2x.
Vì sao khó tự nghĩ ra
Vì trên giấy, với f(x) = (3x+1)², đạo hàm tường minh ngắn hơn hàm gốc. Toàn bộ kinh nghiệm
phổ thông nói "đạo hàm là phép làm đơn giản đi". Sự phình to chỉ lộ ra khi có nút dùng lại và
độ sâu — hai thứ mà bài tập trên giấy không bao giờ có, còn mạng nơ-ron thì có cả hai ở mức tối đa.
🪤 BẪY: đừng đọc bảng này thành "đạo hàm ký hiệu vô dụng" — quy tắc 4,
kết quả âm phải khai rõ phạm vi. Đạo hàm ký hiệu vẫn là công cụ đúng khi bạn cần một công thức để
đọc và phân tích (như ∂L/∂w = (2/N)Σrᵢxᵢ ở bài 5 §4.1) chứ không
phải một con số để chạy. Phạm vi của kết luận là: đừng dùng nó làm cơ chế tính gradient trong vòng
lặp huấn luyện.
7. Vanishing / exploding gradient — chỉ là phép nhân lặp
Chain rule qua L lớp = nhân L số (hoặc L ma trận). Phép nhân lặp có đúng ba số phận:
nhân 20 số 0.5 = 9.5367431640625e-07 ← TẮT
nhân 20 số 1.0 = 1.0 ← ổn định (dải dao cạo)
nhân 20 số 1.5 = 3325.256730079651 ← NỔ
Nhìn theo độ sâu:
| tỉ số mỗi lớp | r²⁰ |
r⁵⁰ |
r¹⁰⁰ |
|---|---|---|---|
| 0.5 | 9.5367e-07 | 8.8818e-16 | 7.8886e-31 |
| 0.9 | 1.2158e-01 | 5.1538e-03 | 2.6561e-05 |
| 1.0 | 1.0 | 1.0 | 1.0 |
| 1.1 | 6.7275e+00 | 1.1739e+02 | 1.3781e+04 |
| 1.5 | 3.3253e+03 | 6.3762e+08 | 4.0656e+17 |
| 2.0 | 1.0486e+06 | 1.1259e+15 | 1.2677e+30 |
Với float32 (số dương nhỏ nhất chuẩn hoá là 1.1754944e-38), 0.5^L chết theo hai nấc:
L |
0.5^L trong float32 |
Trạng thái |
|---|---|---|
| 126 | 1.175494e-38 | số chuẩn hoá cuối cùng |
| 127 | 5.877472e-39 | rơi xuống subnormal — vẫn khác 0, nhưng mất dần chữ số có nghĩa |
| 149 | 1.401298e-45 | subnormal nhỏ nhất |
| 150 | 0.0 | bằng đúng 0 — lớp đó ngừng học hoàn toàn |
Nấc thứ nhất (L = 127) là chỗ độ chính xác bắt đầu rữa ra; nấc thứ hai (L = 150) là chỗ gradient
biến mất theo nghĩa đen. Cả hai đều im lặng — không exception, không warning.
Hàm kích hoạt tự nó đã kéo tỉ số xuống dưới 1
Đây là chỗ vanishing đến miễn phí, không cần trọng số xấu:
z |
tanh(z) |
tanh'(z) = 1 − tanh² |
(tanh')²⁰ |
|---|---|---|---|
| 0.0 | 0.000000 | 1.000000 | 1.0 |
| 1.0 | 0.761594 | 0.419974 | 2.914e-08 |
| 2.0 | 0.964028 | 0.070651 | 9.602e-24 |
| 3.0 | 0.995055 | 0.009866 | 7.636e-41 |
tanh' đạt cực đại đúng bằng 1 tại z = 0, và nhỏ hơn 1 ở mọi nơi khác. Nghĩa là:
Một mạng
tanhsâu luôn có xu hướng tắt gradient — trường hợp tốt nhất là hoà vốn. Vớisigmoidcòn tệ hơn:sigmoid'(0) = 0.25, tức trần là0.25²⁰ = 9.09e-13. Đây là lý do lịch sử vì sao mạng sâu "không huấn luyện được" trước khi ReLU xuất hiện.
relu' chỉ nhận giá trị 0 hoặc 1 — trên nhánh còn sống nó không làm nhỏ gradient chút nào.
Đó là nửa lý do ReLU thắng. (Nửa còn lại là nó rẻ.)
Đo trên mạng 20 lớp thật
20 lớp sigmoid, chiều 8, trọng số khởi tạo N(0,1)/√d:
| Lớp | ‖grad‖ plain |
|---|---|
| 20 (gần loss) | 6.9973e-01 |
| 15 | 3.6210e-04 |
| 10 | 1.5939e-07 |
| 5 | 1.0312e-10 |
| 1 (xa loss nhất) | 2.6205e-13 |
Tỉ số lớp 1 / lớp 20 = 3.7450e-13. Lớp đầu nhận tín hiệu yếu hơn 2670 tỉ lần lớp cuối
(1 / 3.7450e-13 = 2.6702e+12).
Ẩn dụ backend: log đi qua 20 tầng proxy, mỗi tầng lấy mẫu 25%. Đến tầng cuối thì bạn không còn tín hiệu để debug nữa — không phải vì tín hiệu sai, mà vì nó bị pha loãng đến mức bằng nhiễu.
8. Residual x + f(x) — vì sao một dấu cộng chữa được
Đạo hàm của khối residual, tính bằng 3 giây:
h(x) = x + f(x) → h'(x) = 1 + f'(x)
Số hạng 1 đó là một đường cao tốc gradient chạy thẳng từ đầu ra về đầu vào, không qua trạm thu phí nào.
Ẩn dụ backend: bạn đặt một bypass route song song với chuỗi 20 middleware. Dù mọi middleware có nuốt tín hiệu đến đâu, đường bypass vẫn chuyển nguyên vẹn.
Nhưng phải làm cho ĐÚNG — chỗ này rất dễ nói bừa
So sánh sai lầm hay gặp: "plain nhân 0.5 thì tắt, residual nhân 1.5 thì ổn". Không.
1.5²⁰ = 3325, đó là nổ, không phải chữa. Bảng thật, cùng một nhánh f với cùng f':
f' mỗi lớp |
plain: (f')²⁰ |
residual: (1+f')²⁰ |
|---|---|---|
| 0.5 | 9.5367e-07 | 3325.256730 ← nổ |
| 0.2 | 1.0486e-14 | 38.337600 |
| 0.1 | 1.0000e-20 | 6.727500 |
| 0.05 | 9.5367e-27 | 2.653298 ← đúng vùng cần |
| 0.01 | 1.0000e-40 | 1.220190 |
Ý thật nằm ở chỗ "cửa sổ sống sót DỊCH đi", không phải "rộng ra"
Định nghĩa "sống sót" = tích 20 lớp nằm trong [0.01, 100]. Tính chính xác:
| Kiến trúc | f' phải nằm trong |
Bề rộng cửa sổ |
|---|---|---|
| Plain | [0.794328, 1.258925] |
0.464597 |
| Residual | [−0.205672, 0.258925] |
0.464597 |
Bề rộng y hệt nhau — tỉ lệ đúng bằng 1.0. Residual không làm cửa sổ rộng hơn một chút nào. Nó dịch cửa sổ từ "quanh 1" về "quanh 0". Và đó mới là toàn bộ vấn đề:
Mạng plain đòi nhánh
fphải có đạo hàm ≈ 1 — một điều kiện phải canh cực chuẩn, và mọi hàm kích hoạt bão hoà đều kéo bạn ra khỏi nó. Mạng residual đòi nhánhfcó đạo hàm ≈ 0 — mà đó chính là chỗ khởi tạo trọng số nhỏ rơi vào miễn phí, không phải cố gắng gì.Residual không thêm sức mạnh. Nó đổi bài toán "canh chuẩn giá trị 1" thành "để yên gần 0".
Kiểm bằng thí nghiệm ngẫu nhiên — f'ᵢ ~ N(0, 0.1), 20 lớp, lặp 10 000 lần:
| trung vị tích | tỉ lệ tích nằm trong [0.5, 2.0] |
|
|---|---|---|
Plain ∏f'ᵢ |
3.925e-26 | 0.0000 |
Residual ∏(1+f'ᵢ) |
0.905469 | 0.8636 |
Plain: không một lần nào trong 10 000 lần sống sót. Residual: 86.36% sống sót, trung vị 0.905 —
gần như không suy hao.
Vì sao có số hạng 1: khai triển tích
Với f' = (0.1, −0.2, 0.05):
∏(1 + f'ᵢ) = 1 + Σf'ᵢ + Σ(cặp) + (tích ba)
= 1 + (−0.05) + (−0.025) + (−0.001)
= 0.924 ✓ khớp phép nhân trực tiếp 0.9240000000000002
Số hạng 1 là đường đi qua toàn bộ các nhánh identity, bỏ qua mọi f. Nó luôn ở đó, bất kể
f tệ đến đâu. Các số hạng còn lại là gradient đi qua 1, 2, 3... khối f. Mạng residual L lớp
không phải một đường sâu L — nó là 2^L đường có độ sâu khác nhau cộng lại, và đường ngắn nhất
có độ sâu 0.
Đo trên mạng 20 lớp thật (cùng cấu hình §7)
| Lớp | plain ‖grad‖ |
residual ‖grad‖ |
|---|---|---|
| 20 | 6.9973e-01 | 2.8448e+00 |
| 10 | 1.5939e-07 | 2.9060e+00 |
| 1 | 2.6205e-13 | 3.5462e+00 |
Và để chắc chắn không phải do chọn seed đẹp — quét 200 seed:
trung vị ‖g₁‖/‖g₂₀‖ |
biên xấu nhất | |
|---|---|---|
| Plain | 2.641e-13 | tốt nhất cũng chỉ 3.371e-12 |
| Residual | 1.1511 | tệ nhất vẫn là 0.7275 |
Hai phân bố không chạm nhau, cách nhau 11 bậc độ lớn. Ở độ sâu 50 (cũng quét 200 seed), plain
rơi xuống trung vị 3.145e-33 trong khi residual vẫn giữ 1.2299.
🪤 BẪY
Cả năm bẫy dưới đây đều thuộc loại code chạy, không crash, số sai lặng lẽ.
BẪY 1 — = thay vì += khi một nút được dùng lại
Trong autograd, nếu bạn viết:
self.grad = other.data * out.grad # SAI
self.grad += other.data * out.grad # ĐÚNG
Với biểu thức thường thì hai dòng cho kết quả giống hệt nhau — vì mỗi nút chỉ có một đường ra.
Bug chỉ lộ khi có nút phân nhánh. g(x) = x * x tại x = 3:
đúng: grad = 6.0 (= 3 + 3, cộng hai đường)
sai: grad = 3.0 (đường sau ghi đè đường trước)
Không exception, không NaN, không cảnh báo. Chỉ là gradient bằng đúng một nửa, và mô hình học chậm hơn — bạn sẽ đi chỉnh learning rate.
BẪY 2 — quên chia cho số phần tử trong MSE (bẫy độc nhất trong bài)
d_yhat = 2 * err # thiếu / m
Gradient ra lớn gấp đúng m lần. Kiểm bằng số: tỉ số dW2_sai / dW2_đúng = [2.0] với m = 2;
với batch 5 mẫu × 2 đầu ra thì tỉ số là [10.0] = N × D_out.
Vì sao đây là bẫy tệ nhất: hướng của gradient vẫn hoàn toàn đúng, chỉ độ lớn sai một hằng số. Nên loss vẫn giảm, mô hình vẫn hội tụ, biểu đồ vẫn đẹp. Bạn không có tín hiệu nào để nghi ngờ.
Nó chỉ nổ ra khi bạn đổi batch size — learning rate hiệu dụng đột nhiên đổi theo, và mô hình đang chạy tốt bỗng phân kỳ. Bạn sẽ đi tìm bug ở data loader.
Cách chặn duy nhất: so với gradient số ngay khi vừa viết xong
backward.
BẪY 3 — quên mask của ReLU, hoặc áp mask nhầm chỗ
d_z1 = d_a1 * mask # ĐÚNG: mask áp SAU khi lùi qua W2, TRƯỚC khi lùi qua W1
d_z1 = d_a1 # SAI: lệch 2.0 ở ví dụ §5
Ở ví dụ §5, dL/dW1 đúng là [[1,2],[1,2],[0,0]] còn bản thiếu mask ra [[1,2],[1,2],[1,2]]:
2/6 ô sai — đúng hàng của nơ-ron đã chết, lệch tối đa 2.0. Trên mạng batch ở phần 2b
(seed 7) có 4/15 ô của z1 âm — 26.7%, và gradient sai ở đúng những ô đó.
Vẫn không crash — shape hoàn toàn hợp lệ.
BẪY 4 — gradient check tự nó sai
Hai lỗi con, và cả hai đều làm bạn tin nhầm:
(a) h càng nhỏ càng chính xác — SAI. Sai số có đáy rồi đi lên (do triệt tiêu chữ số).
Đo trên hàm §2 g(x) = (2x+1)x² tại x = 2 (đáp án đúng 28), sai phân trung tâm:
h |
sai số tuyệt đối |
|---|---|
| 1e-02 | 2.000e-04 |
| 1e-04 | 2.001e-08 |
| 1e-06 | 3.611e-10 ← đáy |
| 1e-08 | 1.702e-07 |
| 1e-12 | 2.489e-03 |
| 1e-14 | 2.441e-01 |
Hai nhánh của chữ V: đi từ 1e-02 xuống 1e-06 là sai số cắt cụt giảm theo h²
(giảm 100 lần mỗi khi h giảm 10 lần); từ 1e-06 trở xuống là sai số làm tròn tăng theo 1/h.
h = 1e-14 tệ hơn đáy hơn 8 bậc.
Dùng h quanh 1e-5 … 1e-6. Đặt h = 1e-12 thì gradient check của bạn báo sai trong khi
code đúng — bạn sẽ đi sửa code không có bug.
Chuyện thứ hai, trên hàm §1 f(x) = (3x+1)² tại x = 2 và cùng một h = 1e-5:
sai phân tiến (f(x+h)−f(x))/h cho sai số 9.0e-05, sai phân trung tâm cho 8.97e-10.
Chênh 5 bậc, cùng số lần gọi hàm ×2. Luôn dùng trung tâm.
(b) So sai số TUYỆT ĐỐI thay vì TƯƠNG ĐỐI. Hai gradient 1.234e-8 và 1.230e-8:
lệch tuyệt đối = 4.0e-11 → qua ngưỡng 1e-6 dễ dàng
lệch tương đối = 1.62e-3 → thật ra sai 0.16%
Với mạng sâu, gradient ở lớp đầu thường cỡ 1e-13 (xem §7) — mọi thứ đều "qua" ngưỡng tuyệt đối.
BẪY 5 (bonus) — quên xoá gradient giữa hai bước
Vì gradient cộng dồn (+=), nếu không đặt lại về 0 trước mỗi lượt backward mới, gradient của
bước trước cộng vào bước sau. Mô hình vẫn chạy, chỉ là bạn đang dùng một thứ trung bình trượt
kỳ quặc thay vì gradient. Đây là lý do mọi framework có zero_grad() — và là lỗi số 1 của người mới.
Code — tự cài, KHÔNG dùng hàm có sẵn
Chạy được nguyên khối. Mọi khẳng định trong bài đều có assert tương ứng.
import numpy as np
# ============================================================
# PHẦN 0 — công cụ: gradient số, dùng làm trọng tài cho mọi thứ dưới
# ============================================================
def grad_so(fn, params, h=1e-6):
"""Đạo hàm số bằng sai phân trung tâm. Chậm, nhưng KHÔNG BAO GIỜ sai logic."""
params = np.asarray(params, dtype=float)
g = np.zeros_like(params)
it = np.nditer(params, flags=["multi_index"])
while not it.finished:
i = it.multi_index
p1 = params.copy(); p1[i] += h
p2 = params.copy(); p2[i] -= h
g[i] = (fn(p1) - fn(p2)) / (2 * h)
it.iternext()
return g
def lech_tuong_doi(a, b):
a, b = np.asarray(a, float), np.asarray(b, float)
return np.max(np.abs(a - b) / np.maximum(np.abs(a) + np.abs(b), 1e-12))
# ============================================================
# PHẦN 1 — MINI-AUTOGRAD (capstone của bài)
# ============================================================
class Value:
"""Một nút trong đồ thị tính toán: giữ giá trị, gradient, và cách chạy ngược."""
def __init__(self, data, _children=(), _op=""):
self.data = float(data)
self.grad = 0.0
self._backward = lambda: None # mặc định: nút lá, không làm gì
self._prev = set(_children)
self._op = _op
def __repr__(self):
return f"Value({self.data:.6f}, grad={self.grad:.6f})"
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
# d(a+b)/da = 1, d(a+b)/db = 1 -> cộng tiếp vào (+=, KHÔNG phải =)
self.grad += 1.0 * out.grad
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():
# quy tắc tích: d(a*b)/da = b, d(a*b)/db = a
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def tanh(self):
t = np.tanh(self.data)
out = Value(t, (self,), "tanh")
def _backward():
self.grad += (1 - t * t) * out.grad
out._backward = _backward
return out
__radd__ = __add__
__rmul__ = __mul__
def backward(self):
# sắp xếp topo: cha luôn đứng SAU con -> chạy ngược thì cha xong trước
topo, seen = [], set()
def build(n):
if n in seen:
return
seen.add(n)
for ch in n._prev:
build(ch)
topo.append(n)
build(self)
for n in topo:
n.grad = 0.0
self.grad = 1.0 # gieo hạt: dL/dL = 1
for n in reversed(topo):
n._backward()
# --- kiểm 1: đồ thị 3 nút của §3, kết quả tính tay được ---
a, b, c = Value(3.0), Value(2.0), Value(1.0)
L = (a * b + c) * (a * b + c)
L.backward()
assert L.data == 49.0
assert (a.grad, b.grad, c.grad) == (28.0, 42.0, 14.0)
print(f"kiểm 1: L={L.data} da={a.grad} db={b.grad} dc={c.grad} (tính tay: 28/42/14) OK")
# --- kiểm 2: nút dùng LẠI (x xuất hiện 2 lần) + tanh ---
x, w = Value(2.0), Value(-1.5)
o = (x * w + x).tanh()
o.backward()
f2 = lambda p: float(np.tanh(p[0] * p[1] + p[0]))
assert lech_tuong_doi([x.grad, w.grad], grad_so(f2, [2.0, -1.5])) < 1e-7
print(f"kiểm 2: o={o.data:.10f} dx={x.grad:.10f} dw={w.grad:.10f} khớp grad số OK")
# --- kiểm 3: biểu thức lớn hơn, chỉ tin gradient số ---
p = [Value(0.5), Value(-1.2), Value(2.0), Value(0.3)]
out = ((p[0] * p[1] + p[2]).tanh() * p[3] + p[0] * p[2]).tanh()
out.backward()
f3 = lambda q: float(np.tanh(np.tanh(q[0] * q[1] + q[2]) * q[3] + q[0] * q[2]))
mine = np.array([q.grad for q in p])
assert lech_tuong_doi(mine, grad_so(f3, [0.5, -1.2, 2.0, 0.3])) < 1e-7
print(f"kiểm 3: out={out.data:.10f} grad={np.round(mine, 6)} khớp grad số OK")
# --- kiểm 4: BẪY 1 — '=' thay '+=' -> sai LẶNG LẼ đúng một nửa ---
class ValueSai(Value):
def __mul__(self, other):
other = other if isinstance(other, ValueSai) else ValueSai(other)
out = ValueSai(self.data * other.data, (self, other), "*")
def _backward():
self.grad = other.data * out.grad # '=' — BUG
other.grad = self.data * out.grad # '=' — BUG
out._backward = _backward
return out
s = ValueSai(3.0)
y = s * s
y.backward()
assert y.data == 9.0 and s.grad == 3.0 # đúng phải là 6.0
print(f"kiểm 4 (BẪY): g(x)=x*x tại x=3 -> grad={s.grad}, đúng là 6.0. Không crash. OK")
# ============================================================
# PHẦN 2 — MẠNG 2 LỚP, BACKWARD TỰ VIẾT (numpy thuần)
# x -> W1 -> relu -> W2 -> MSE
# ============================================================
xv = np.array([1.0, 2.0])
W1 = np.array([[1.0, 0.0],
[0.0, 1.0],
[1.0, -1.0]])
W2 = np.array([[1.0, 0.0, 1.0],
[0.0, 1.0, 0.0]])
yv = np.array([0.0, 1.0])
# forward — LƯU mọi giá trị trung gian, backward sẽ cần
z1 = W1 @ xv
a1 = np.maximum(z1, 0.0)
yhat = W2 @ a1
err = yhat - yv
loss = np.mean(err ** 2)
assert np.allclose(z1, [1, 2, -1]) and np.allclose(a1, [1, 2, 0])
assert np.allclose(yhat, [1, 2]) and loss == 1.0
# backward — bốn bước, mỗi bước là MỘT lần áp chain rule
m = yv.size
d_yhat = 2 * err / m # dL/dŷ
dW2 = np.outer(d_yhat, a1) # dL/dW2
d_a1 = W2.T @ d_yhat # dL/da1 <- chuỗi Jacobian: chính là W2 chuyển vị
mask = (z1 > 0).astype(float) # đạo hàm relu: 1 nếu sống, 0 nếu chết
d_z1 = d_a1 * mask # dL/dz1
dW1 = np.outer(d_z1, xv) # dL/dW1
assert np.allclose(dW2, [[1, 2, 0], [1, 2, 0]])
assert np.allclose(d_a1, [1, 1, 1]) and np.allclose(mask, [1, 1, 0])
assert np.allclose(dW1, [[1, 2], [1, 2], [0, 0]])
# đối chiếu với gradient số — ĐÂY mới là bằng chứng
lossW1 = lambda M: float(np.mean((W2 @ np.maximum(M @ xv, 0) - yv) ** 2))
lossW2 = lambda M: float(np.mean((M @ np.maximum(W1 @ xv, 0) - yv) ** 2))
assert np.max(np.abs(grad_so(lossW1, W1) - dW1)) < 1e-6
assert np.max(np.abs(grad_so(lossW2, W2) - dW2)) < 1e-6
print("phần 2: mạng 2 lớp, backward tay khớp gradient số (<1e-6) OK")
# --- BẪY 2: quên chia cho số phần tử trong MSE ---
dW2_sai = np.outer(2 * err, a1) # thiếu / m
ti_so = np.unique(np.round(dW2_sai[dW2 != 0] / dW2[dW2 != 0], 9))
assert ti_so.tolist() == [float(m)]
print(f" BẪY 2: quên chia m -> gradient lớn gấp đúng {m}x, HƯỚNG vẫn đúng "
f"-> loss vẫn giảm, không ai phát hiện")
# --- BẪY 3: quên nhân mask relu ---
dW1_sai = np.outer(d_a1, xv) # thiếu * mask
assert not np.allclose(dW1_sai, dW1)
assert int(np.sum(dW1_sai != dW1)) == 2 # ĐÚNG 2/6 ô sai, không phải 4/6
print(f" BẪY 3: quên mask relu -> lệch {np.max(np.abs(dW1_sai - dW1))}, "
f"sai {int(np.sum(dW1_sai != dW1))}/{dW1.size} ô — đúng hàng nơ-ron đã chết")
# ============================================================
# PHẦN 2b — CÙNG MẠNG ĐÓ, W NGẪU NHIÊN, BATCH 5 MẪU
# (số của §5 và của BẪY 2 / BẪY 3 lấy từ đây)
# ============================================================
rng_b = np.random.default_rng(7)
Xb = rng_b.normal(size=(5, 2)) # 5 mẫu × 2 đầu vào
Yb = rng_b.normal(size=(5, 2)) # 5 mẫu × 2 đầu ra
W1b = rng_b.normal(size=(3, 2)) / np.sqrt(2)
W2b = rng_b.normal(size=(2, 3)) / np.sqrt(3)
Zb = Xb @ W1b.T # forward
Ab = np.maximum(Zb, 0.0)
Pb = Ab @ W2b.T
Eb = Pb - Yb
mb = Eb.size # 5 mẫu × 2 đầu ra = 10
lossb = np.mean(Eb ** 2)
d_Pb = 2 * Eb / mb # backward — y hệt 4 bước ở §5
dW2b = d_Pb.T @ Ab
d_Ab = d_Pb @ W2b
d_Zb = d_Ab * (Zb > 0)
dW1b = d_Zb.T @ Xb
lbW1 = lambda M: float(np.mean((np.maximum(Xb @ M.T, 0) @ W2b.T - Yb) ** 2))
lbW2 = lambda M: float(np.mean((np.maximum(Xb @ W1b.T, 0) @ M.T - Yb) ** 2))
e1b = np.max(np.abs(grad_so(lbW1, W1b) - dW1b))
e2b = np.max(np.abs(grad_so(lbW2, W2b) - dW2b))
assert mb == 10 and e1b < 1e-8 and e2b < 1e-8
assert np.sum(Zb <= 0) == 4 and Zb.size == 15 # 4/15 ô relu chết = 26.7%
print(f"phần 2b: batch 5 mẫu — loss={lossb!r}")
print(f" lệch W1={e1b:.2e} lệch W2={e2b:.2e} ô relu chết {int(np.sum(Zb <= 0))}/{Zb.size}"
f" = {np.mean(Zb <= 0):.1%}")
# BẪY 2 trên batch: tỉ số sai = N × D_out, KHÔNG phải N
dW2b_sai = (2 * Eb).T @ Ab # thiếu / m
ti_so_b = np.unique(np.round(dW2b_sai[dW2b != 0] / dW2b[dW2b != 0], 9))
assert ti_so_b.tolist() == [10.0] == [float(5 * 2)]
print(f" BẪY 2 trên batch: tỉ số = {ti_so_b.tolist()} = N × D_out = 5 × 2")
# ============================================================
# PHẦN 2c — BẪY 4a: h NHỎ HƠN KHÔNG PHẢI LÚC NÀO CŨNG CHÍNH XÁC HƠN
# ============================================================
gx = lambda t: (2 * t + 1) * t ** 2 # hàm §2, g'(2) = 28
sai = {h: abs((gx(2.0 + h) - gx(2.0 - h)) / (2 * h) - 28.0)
for h in (1e-2, 1e-4, 1e-6, 1e-8, 1e-12, 1e-14)}
assert min(sai, key=sai.get) == 1e-6 # ĐÁY ở 1e-6, KHÔNG ở h nhỏ nhất
assert sai[1e-14] > sai[1e-6] * 1e8 # h nhỏ nhất là h TỆ NHẤT
print("phần 2c: BẪY 4a — " + " ".join(f"{h:.0e}→{e:.3e}" for h, e in sai.items()))
f1x = lambda t: (3 * t + 1) ** 2 # hàm §1, f'(2) = 42
fwd = abs((f1x(2.0 + 1e-5) - f1x(2.0)) / 1e-5 - 42.0)
ctr = abs((f1x(2.0 + 1e-5) - f1x(2.0 - 1e-5)) / 2e-5 - 42.0)
assert fwd > 1e4 * ctr # trung tâm thắng 5 bậc ở cùng h
print(f" cùng h=1e-5: tiến sai {fwd:.1e} trung tâm sai {ctr:.2e}")
# ============================================================
# PHẦN 3 — VANISHING / EXPLODING = NHÂN L SỐ
# ============================================================
assert abs(0.5 ** 20 - 9.5367431640625e-07) < 1e-18
assert abs(1.5 ** 20 - 3325.256730079651) < 1e-9
assert abs(0.05 ** 20 - 9.5367431640625e-27) < 1e-38
assert abs(1.05 ** 20 - 2.653297705144422) < 1e-9
print(f"phần 3: 0.5^20 = {0.5**20:.6e} 1.5^20 = {1.5**20:.6f}")
# float32 chết theo HAI nấc: subnormal ở L=127, bằng đúng 0 ở L=150
tiny32 = float(np.finfo(np.float32).tiny) # 1.1754944e-38
pw = lambda L: float(np.float32(0.5) ** L)
assert pw(126) >= tiny32 # còn chuẩn hoá
assert 0.0 < pw(127) < tiny32 # subnormal — KHÁC 0
assert pw(149) > 0.0 and pw(150) == 0.0 # bằng đúng 0 từ L=150
print(f" float32: 0.5^126={pw(126):.6e} 0.5^127={pw(127):.6e} (subnormal) 0.5^150={pw(150)}")
print(" f' | plain (f')^20 | residual (1+f')^20")
for fp in [0.5, 0.2, 0.1, 0.05]:
print(f" {fp:<5} | {fp**20:.4e} | {(1+fp)**20:.6f}")
# thí nghiệm ngẫu nhiên: f'_i ~ N(0, 0.1), 20 lớp, 10 000 lần
rng = np.random.default_rng(1)
fps = rng.normal(0.0, 0.1, size=(10000, 20))
plain = np.prod(fps, axis=1)
resid = np.prod(1 + fps, axis=1)
assert np.median(np.abs(plain)) < 1e-20
assert 0.9 < np.median(resid) < 1.0
assert np.mean((np.abs(plain) > 0.5) & (np.abs(plain) < 2)) == 0.0
print(f" 20 lớp ngẫu nhiên: plain trung vị |tích| = {np.median(np.abs(plain)):.3e}"
f" residual trung vị = {np.median(resid):.6f}")
print(f" tỉ lệ tích nằm trong [0.5, 2.0]: plain "
f"{np.mean((np.abs(plain)>0.5)&(np.abs(plain)<2)):.4f}"
f" residual {np.mean((resid>0.5)&(resid<2)):.4f}")
# ============================================================
# PHẦN 4 — MẠNG 20 LỚP THẬT: PLAIN vs RESIDUAL
# ============================================================
def chuoi_grad(depth, d, residual, seed, scale=1.0):
"""Trả về ‖grad‖ tại từng lớp, từ lớp 1 đến lớp cuối."""
rng = np.random.default_rng(seed)
Ws = [rng.normal(size=(d, d)) * scale / np.sqrt(d) for _ in range(depth)]
h, zs = rng.normal(size=d), []
for W in Ws: # forward
z = W @ h
zs.append(z)
s = 1 / (1 + np.exp(-z))
h = h + s if residual else s
g, norms = np.ones(d), [] # backward
for i in reversed(range(depth)):
s = 1 / (1 + np.exp(-zs[i]))
g_nhanh = Ws[i].T @ (g * s * (1 - s))
g = g_nhanh + g if residual else g_nhanh # '+ g' CHÍNH LÀ số hạng "1" của (1+f')
norms.append(np.linalg.norm(g))
return np.array(norms[::-1])
pl, rs = chuoi_grad(20, 8, False, 42), chuoi_grad(20, 8, True, 42)
print("phần 4: 20 lớp sigmoid")
print(f" plain lớp20={pl[-1]:.4e} lớp10={pl[9]:.4e} lớp1={pl[0]:.4e}"
f" tỉ số = {pl[0]/pl[-1]:.4e}")
print(f" residual lớp20={rs[-1]:.4e} lớp10={rs[9]:.4e} lớp1={rs[0]:.4e}"
f" tỉ số = {rs[0]/rs[-1]:.4e}")
assert pl[0] / pl[-1] < 1e-10
assert 0.5 < rs[0] / rs[-1] < 5
# không ăn gian bằng cách chọn seed đẹp: quét 200 seed
tp = [chuoi_grad(20, 8, False, s)[0] / chuoi_grad(20, 8, False, s)[-1] for s in range(200)]
tr = [chuoi_grad(20, 8, True, s)[0] / chuoi_grad(20, 8, True, s)[-1] for s in range(200)]
assert np.max(tp) < 1e-11 and np.min(tr) > 0.5 # hai phân bố KHÔNG chạm nhau
print(f" 200 seed: plain trung vị {np.median(tp):.3e} (max {np.max(tp):.3e})"
f" | residual trung vị {np.median(tr):.4f} (min {np.min(tr):.4f})")
# sâu hơn nữa: 50 lớp, vẫn 200 seed — khoảng cách chỉ nới rộng
tp50 = [chuoi_grad(50, 8, False, s)[0] / chuoi_grad(50, 8, False, s)[-1] for s in range(200)]
tr50 = [chuoi_grad(50, 8, True, s)[0] / chuoi_grad(50, 8, True, s)[-1] for s in range(200)]
assert np.median(tp50) < 1e-30 and np.median(tr50) > 1.0
print(f" độ sâu 50: plain trung vị {np.median(tp50):.3e}"
f" | residual trung vị {np.median(tr50):.4f}")
# ============================================================
# PHẦN 5 — CHUỖI JACOBIAN CHÍNH LÀ NHÂN MA TRẬN (nối môn 2)
# ============================================================
J = W2 @ np.diag(mask) @ W1 # dŷ/dx của mạng ở phần 2
assert np.allclose(J, np.eye(2))
Jso = np.stack([(W2 @ np.maximum(W1 @ (xv + e * 1e-6), 0)
- W2 @ np.maximum(W1 @ (xv - e * 1e-6), 0)) / 2e-6
for e in np.eye(2)], axis=1)
assert np.max(np.abs(J - Jso)) < 1e-6
print(f"phần 5: J = W2 @ diag(mask) @ W1 =\n{J}\n khớp Jacobian số OK")
# ============================================================
# PHẦN 5b — BÁN KÍNH PHỔ ρ QUYẾT ĐỊNH, KHÔNG PHẢI σ_max (bảng §6)
# ============================================================
rng_j = np.random.default_rng(3)
print(" scale | σ_max | ρ | ‖J²⁰‖ | ρ²⁰ | σ_max²⁰")
for sc in (0.5, 1.0, 1.5):
Jr = rng_j.normal(size=(4, 4)) * sc / np.sqrt(4)
smax = np.linalg.svd(Jr, compute_uv=False)[0]
rho = np.max(np.abs(np.linalg.eigvals(Jr)))
n20 = np.linalg.norm(np.linalg.matrix_power(Jr, 20), 2)
assert rho ** 20 <= n20 <= smax ** 20 # ρ²⁰ là SÀN, σ_max²⁰ là TRẦN
print(f" {sc:<4} | {smax:6.4f} | {rho:6.4f} | {n20:.4e} | {rho**20:.4e} | {smax**20:.4e}")
# dòng scale=0.5: σ_max > 1 mà tích vẫn TẮT -> σ_max không phải thứ quyết định
Jr05 = np.random.default_rng(3).normal(size=(4, 4)) * 0.5 / np.sqrt(4)
assert np.linalg.svd(Jr05, compute_uv=False)[0] > 1.0
assert np.linalg.norm(np.linalg.matrix_power(Jr05, 20), 2) < 1e-5
print("\nTẤT CẢ ASSERT ĐÃ QUA.")
Kết quả phải ra:
kiểm 1: L=49.0 da=28.0 db=42.0 dc=14.0 (tính tay: 28/42/14) OK
kiểm 2: o=-0.7615941560 dx=-0.2099871708 dw=0.8399486832 khớp grad số OK
kiểm 3: out=0.8526021343 grad=[0.52489 0.008854 0.154242 0.241763] khớp grad số OK
kiểm 4 (BẪY): g(x)=x*x tại x=3 -> grad=3.0, đúng là 6.0. Không crash. OK
phần 2: mạng 2 lớp, backward tay khớp gradient số (<1e-6) OK
BẪY 2: quên chia m -> gradient lớn gấp đúng 2x, HƯỚNG vẫn đúng -> loss vẫn giảm, không ai phát hiện
BẪY 3: quên mask relu -> lệch 2.0, sai ở hàng của nơ-ron đã chết
phần 3: 0.5^20 = 9.536743e-07 1.5^20 = 3325.256730
20 lớp ngẫu nhiên: plain trung vị |tích| = 3.925e-26 residual trung vị = 0.905469
tỉ lệ tích nằm trong [0.5, 2.0]: plain 0.0000 residual 0.8636
phần 4: 20 lớp sigmoid
plain lớp20=6.9973e-01 lớp10=1.5939e-07 lớp1=2.6205e-13 tỉ số = 3.7450e-13
residual lớp20=2.8448e+00 lớp10=2.9060e+00 lớp1=3.5462e+00 tỉ số = 1.2466e+00
200 seed: plain trung vị 2.641e-13 (max 3.371e-12) | residual trung vị 1.1511 (min 0.7275)
Bài tập bắt buộc, không được bỏ: thêm
__pow__,relu(),__sub__,__truediv__vàoValue. Với mỗi phép, viết_backwardrồi lập tức kiểm bằnggrad_so. Nếu bạn làm được cả bốn mà không nhìn lại bài này, bạn đã có backpropagation trong tay vĩnh viễn.
Nối vào AI
| Khái niệm ở bài này | Chỗ dùng trong AI |
|---|---|
| Chain rule | Backpropagation — mọi mạng nơ-ron từng được huấn luyện |
| Đồ thị tính toán | Cái mà PyTorch/JAX dựng ra khi bạn viết biểu thức; .backward() chỉ duyệt ngược nó |
| Cộng dồn gradient tại nút phân nhánh | Weight tying (embedding dùng chung ở đầu vào và đầu ra); multi-head attention gộp về cùng một X |
Wᵀ trong backward |
Mọi lớp Linear — forward nhân W, backward nhân Wᵀ |
| Chuỗi Jacobian | Vì sao mạng sâu khó; vì sao cần chuẩn hoá khởi tạo (Xavier/He) |
| Vanishing gradient | Lý do LSTM có cổng, lý do ReLU thay sigmoid, lý do RNN không nhớ xa |
| Exploding gradient | Lý do có gradient clipping trong mọi vòng huấn luyện LLM |
x + f(x) |
ResNet; và mọi khối Transformer là x + Attention(x) rồi x + FFN(x) |
| Lưu giá trị trung gian ở forward | Vì sao huấn luyện tốn RAM gấp nhiều lần suy luận; vì sao có gradient checkpointing |
| Đạo hàm relu = mask 0/1 | Dying ReLU → lý do LeakyReLU / GELU tồn tại |
Chú ý điểm nối xuyên môn: môn 2 bài 2 dạy nhân ma trận = ghép phép biến đổi.
Bài này dạy chain rule = ghép đạo hàm. Chúng là cùng một cấu trúc: forward ghép
W_L ⋯ W_2 W_1, backward ghép W_1ᵀ W_2ᵀ ⋯ W_Lᵀ. Backward là forward chạy ngược qua chuyển vị.
Tự kiểm — trả lời được mới sang bài sau
- Vì sao backprop chỉ tốn ~2 lượt qua mạng, trong khi tính gradient bằng sai phân số cần
2Nlượt? Điều gì làm chain rule "tái sử dụng" được công đã tính? - Trong autograd, vì sao bắt buộc là
grad += ...chứ không phảigrad = ...? Cho một biểu thức cụ thể mà hai cách ra khác nhau, và tính bằng tay cả hai kết quả. - Forward nhân
W, backward nhânWᵀ. Giải thích vì sao là chuyển vị — bằng ý nghĩa, không bằng "vì chiều ma trận phải khớp". - ⭐ Chain rule qua
Llớp = nhânLma trận Jacobian. Từ đó suy ra vì sao gradient vanishing/exploding xảy ra, và vì saox + f(x)chữa được. (Đây là câu tự kiểm số 2 của cả môn 3.) - Residual không làm cửa sổ
f'"sống sót" rộng hơn — số liệu ở §8 cho thấy bề rộng y hệt (0.464597). Vậy chính xác thì nó thay đổi cái gì, và vì sao thay đổi đó lại là thứ dễ đạt được? tanh'đạt cực đại đúng bằng1tạiz = 0. Hệ quả gì cho một mạngtanhsâu 50 lớp? Và vì saorelukhông có vấn đề đó trên nhánh còn sống?- Bạn viết
backwardcho một lớp mới, gradient check bằng sai số tuyệt đối ngưỡng1e-6và nó qua. Vì sao điều đó chưa chứng minh được gì? Sửa lại quy trình kiểm cho đúng. - Bạn quên chia cho batch size trong MSE. Loss vẫn giảm, mô hình vẫn hội tụ. Bằng cách nào bạn phát hiện ra bug này, nếu không chạy gradient check?
Câu 4 là câu sâu nhất và là ranh giới thật. Trả lời được nghĩa là bạn hiểu backprop ở mức có thể sửa nó, không chỉ chạy nó. Câu 5 là câu bẫy nhất — hầu hết giải thích trên mạng nói sai chỗ này. Số liệu §8 là bằng chứng. Câu 8 là câu thực chiến nhất: nó hỏi bạn về loại bug không bao giờ tự lộ diện.
Bài tập về nhà trước bài 4
3Blue1Brown — Essence of Calculus:
| Tập | Vì sao |
|---|---|
| 4 — Visualizing the chain rule and product rule | ⭐ Chính là §1-2 của bài này bằng hình động. Xem hai lần |
3Blue1Brown — Neural Networks (series riêng, cùng tác giả):
| Tập | Vì sao |
|---|---|
| 1 — But what is a neural network? (27 phút) | ⚠️ XEM TẬP NÀY TRƯỚC. Tập 3-4 nói về backprop trong một mạng nơ-ron — không xem tập 1 thì bạn đang xem cách sửa một cỗ máy mà chưa biết cỗ máy đó là gì. Ý cần lấy: một "lớp" chỉ là W·x cộng một hàm phi tuyến — đúng thứ bạn đã chứng minh ở môn 2 bài 2 §4 |
| 3 — Backpropagation, intuitively | Trực giác "mỗi mẫu dữ liệu bỏ phiếu cho hướng chỉnh trọng số". Xem trước tập 4 |
| 4 — Backpropagation calculus | ⭐ Chính là §3-5 của bài này, viết bằng ký hiệu. Xem sau khi đã tự chạy mini-autograd — nó sẽ đọc như một bài ôn tập chứ không phải bài mới |
Thứ tự quan trọng: chạy code trước, xem video sau. Bạn đã có
Valuechạy được rồi; khi 3B1B vẽ ra sơ đồ mũi tên ngược, bạn sẽ nhận ra đó chính làfor n in reversed(topo)của mình. Người xem video trước rồi mới code thì phải học hai lần.
Những tập BỎ QUA ở môn 3, và vì sao:
| Tập Calculus | Bỏ vì |
|---|---|
| 8, 9 — tích phân & định lý cơ bản | Toàn bộ lộ trình này chỉ đi xuôi theo đạo hàm: gradient → gradient descent → backprop. Không có chỗ nào cần tính tích phân. (Tích phân sẽ cần nếu bạn đi sang mô hình sinh liên tục hoặc suy diễn Bayes — không phải bây giờ.) |
| 11 — Taylor series | Chỉ cần biết là nó tồn tại: "mọi hàm trơn xấp xỉ được bằng đa thức quanh một điểm". Đó là nền của Hessian và Newton method ở cuối môn 3, nhưng ở mức dùng kết quả, không cần dẫn dắt |
| 12 — cách nhìn khác về đạo hàm | Đẹp, không cần. Xem nếu thừa thời gian |
Bài 4 sẽ là: đạo hàm riêng và gradient — trả lời câu "khi hàm có 1 triệu đầu vào thì
'độ dốc' nghĩa là gì", và vì sao hướng dốc nhất đúng bằng −∇f. Bài này bạn đã tính
dL/dW1, dL/dW2 — bài 4 sẽ cho chúng một cái tên và một ý nghĩa hình học.
Sau đó bài 5 ghép tất cả lại thành gradient descent, và bạn có vòng lặp huấn luyện hoàn chỉnh.
(Backprop đầy đủ cho mạng nhiều lớp, batch, và các phép toán tensor — sẽ quay lại ở môn 7,
nơi bạn mở rộng chính Value của bài này thành một micrograd hoàn chỉnh.)