Skip to content
Phạm Khánh Minh Mẫn / syllabus

MÔN 03 / Bài 03

Chain rule và backpropagation

Nhân độ nhạy dọc mỗi đường, cộng tại chỗ phân nhánh; tự lần ngược gradient qua một mạng nhỏ.

PDF ghi tay

Bản cô đọng: hình trước, công thức sau. Nền trắng, chữ đen.

Trong bài này ↓

Chữ gạch chấm là tham chiếu tới môn hoặc tài liệu chưa được đưa lên website.

~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) = 14 và quên nhân 3. 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ết self.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 L lớ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ần chỉ 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 314x thà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 tanh sâu luôn có xu hướng tắt gradient — trường hợp tốt nhất là hoà vốn. Với sigmoid cò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 f phả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ánh f có đạ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ào Value. Với mỗi phép, viết _backward rồi lập tức kiểm bằng grad_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

  1. 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 2N lượt? Điều gì làm chain rule "tái sử dụng" được công đã tính?
  2. Trong autograd, vì sao bắt buộc là grad += ... chứ không phải grad = ...? 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ả.
  3. Forward nhân W, backward nhân Wᵀ. 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".
  4. ⭐ Chain rule qua L lớp = nhân L ma trận Jacobian. Từ đó suy ra vì sao gradient vanishing/exploding xảy ra, và vì sao x + f(x) chữa được. (Đây là câu tự kiểm số 2 của cả môn 3.)
  5. 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?
  6. tanh' đạt cực đại đúng bằng 1 tại z = 0. Hệ quả gì cho một mạng tanh sâu 50 lớp? Và vì sao relu không có vấn đề đó trên nhánh còn sống?
  7. Bạn viết backward cho một lớp mới, gradient check bằng sai số tuyệt đối ngưỡng 1e-6 và nó qua. Vì sao điều đó chưa chứng minh được gì? Sửa lại quy trình kiểm cho đúng.
  8. 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ó Value chạ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.)

Vị trí đọc được lưu trên trình duyệt này khi cho phép lưu trữ; không đồng bộ giữa các thiết bị.