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

MÔN 03 / Bài 05

Gradient descent

Nhìn bước đi trong thung lũng để hiểu learning rate, momentum, Adam và tự cài hồi quy tuyến tí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 giờ · cần xong bài 4

Capstone của mạch tối ưu — nhưng CHƯA phải bài cuối môn: còn bài 6 (số học máy tính) phía sau. Bài 4 cho bạn hướng đi (−∇f là hướng dốc nhất). Bài này trả lời câu còn lại: đi bao xa mỗi bước — và vì sao đó là siêu tham số quan trọng nhất trong toàn bộ deep learning.

Kết bài, bạn có một hồi quy tuyến tính tự cài khớp nghiệm đóng tới 8–9 chữ số và biết chính xác ngưỡng learning rate làm nó nổ tung — bằng công thức, không bằng thử sai.

⚡ 30 phút đầu — làm trước, đọc sau

Đừng đọc hết file rồi mới bắt tay. Mở terminal ngay. Hàm đơn giản nhất đời: f(x) = x², f'(x) = 2x.

def gd(x0, lr, steps):
    x = x0
    for _ in range(steps):
        x = x - lr * (2 * x)     # đúng một dòng — đây LÀ gradient descent
    return x

for lr in [0.1, 0.5, 0.9, 1.0, 1.1]:
    print(lr, round(gd(5.0, lr, 10), 4))

Đích — sau 10 bước từ x₀ = 5, phải ra đúng năm số này:

lr=0.1  →   +0.5369
lr=0.5  →    0.0000     ← về đáy sau ĐÚNG 1 bước
lr=0.9  →   +0.5369     ← trùng lr=0.1?! vì sao?
lr=1.0  →   +5.0000     ← KHÔNG hề nhúc nhích sau 10 bước
lr=1.1  →  +30.9587     ← xa hơn lúc bắt đầu 6 lần

Ra đúng năm số → bạn đã có toàn bộ dữ liệu của §2 và §3. Hai câu hỏi mang theo khi đọc tiếp:

  1. Vì sao lr=0.1 và lr=0.9 — khác nhau 9 lần — cho cùng một |x|?
  2. Vì sao ranh giới sống chết nằm đúng ở lr = 1.0, không phải 0.7 hay 2.3?

Cả hai có một câu trả lời chung, và nó viết được trong 5 ký tự.


1. Thuật toán, viết trong một dòng

θ ← θ − lr · ∇f(θ)

Hết. Không có gì hơn. Ba thành phần:

Ký hiệu Là gì Đến từ bài nào
θ tham số hiện tại (một điểm trong không gian tham số) môn 2 bài 1 — vector
∇f(θ) gradient — vector chỉ hướng dốc lên nhanh nhất bài 4
lr learning rate — đi bao xa theo hướng đó bài này

Dấu trừ là vì ∇f chỉ lên dốc, ta muốn xuống dốc.

Ẩn dụ backend: GD là một control loop. Đo sai lệch (∇f), tác động ngược lại (−lr·∇f), lặp. Y hệt PID controller hay autoscaler. Và giống hệt autoscaler: hệ số gain quá lớn thì hệ dao động rồi vỡ, quá nhỏ thì không bao giờ bắt kịp tải. lr chính là gain.

Điều GD KHÔNG làm: nó không nhìn thấy đáy. Nó chỉ biết độ dốc tại chỗ đang đứng. Nó là kẻ đi trong sương mù, mỗi bước sờ xem đất nghiêng phía nào rồi bước theo. Toàn bộ sự tinh tế nằm ở độ dài bước chân.


2. f(x) = x² — toàn bộ câu chuyện learning rate trong một hàm

Ví dụ số đẹp, làm được trên giấy. f(x) = x², f'(x) = 2x, đáy ở x = 0.

Thay vào công thức:

x ← x − lr·(2x) = x·(1 − 2·lr)

Một bước GD trên x² = nhân x với hằng số (1 − 2·lr). Đó là 5 ký tự trả lời cả hai câu hỏi ở phần ⚡.

Gọi hằng số đó là q = 1 − 2·lr. Sau k bước: xₖ = 5·qᵏ. Số phận toàn bộ nằm ở |q|:

lr q = 1−2lr Sau 10 bước từ x₀=5 Chuyện gì xảy ra
0.1 +0.80 +0.5369 co dần, cùng phía, chậm
0.5 0.00 0.0000 ⭐ nhảy thẳng vào đáy sau 1 bước
0.9 −0.80 +0.5369 co dần nhưng đổi phía mỗi bước
1.0 −1.00 +5.0000 dao động ±5 mãi mãi, không hội tụ
1.1 −1.20 +30.9587 PHÂN KỲ — mỗi bước xa hơn 1.2 lần

Quỹ đạo đầy đủ, in ra từ code (đây là số thật, không phải minh hoạ):

lr=0.1   5.0000   4.0000   3.2000   2.5600   2.0480 ...  0.5369
lr=0.5   5.0000   0.0000   0.0000   0.0000   0.0000 ...  0.0000
lr=0.9   5.0000  -4.0000   3.2000  -2.5600   2.0480 ...  0.5369
lr=1.0   5.0000  -5.0000   5.0000  -5.0000   5.0000 ...  5.0000
lr=1.1   5.0000  -6.0000   7.2000  -8.6400  10.3680 ... 30.9587

Chạy tiếp lr=1.1 cho tới bước 30:

bước 10:  x = 30.96      f = 958
bước 20:  x = 191.7      f = 36 744
bước 30:  x = 1186.9     f = 1 408 688

Đây là NaN bạn thấy trong log training, ở dạng chậm. Loss không "hỏng" — nó leo lên theo cấp số nhân cho tới khi float64 tràn.

Vì sao lr=0.1 và lr=0.9 cho cùng kết quả

|q| = 0.8 ở cả hai. lr=0.1 bước ngắn hơn đáy, lr=0.9 bước vọt qua đáy — nhưng vọt qua rồi chỉ còn cách đáy đúng 0.8 lần khoảng cách cũ. Cùng tốc độ co, khác hình dạng đường đi.

Kiểm bằng số bước cần để |x| < 1e−6:

lr 0.01 0.1 0.5 0.9 0.99
số bước 764 70 1 70 764

Bảng này đối xứng quanh lr = 0.5. Không phải trùng hợp: |1 − 2·lr| đối xứng quanh lr = 0.5, nơi nó bằng 0.

Suy ra ngay một điều phản trực giác: lr = 0.01 (rất "an toàn") và lr = 0.99 (sát mép vực) chậm y hệt nhau — cùng 764 bước. Chọn lr bé để "cho chắc" không mua được sự an toàn nào mà bạn không phải trả bằng thời gian. Giữ ý này, §BẪY sẽ quay lại.


3. Ngưỡng learning rate đến từ đâu — và đây chính là môn 2 bài 7

Điều kiện hội tụ: |q| < 1, tức |1 − 2·lr| < 1, tức:

0 < lr < 1

Số 2 trong q = 1 − 2·lr chính là f''(x) = 2. Viết lại tổng quát:

Với hàm bậc hai có độ cong hằng f''>0, hội tụ từ mọi điểm đầu khi và chỉ khi 0 < lr < 2 / f''. Với f = x²: lr < 2/2 = 1. Khớp đúng bảng ở §2.

Đây không phải mẹo cho hàm một chiều. Ở nhiều chiều, f'' trở thành ma trận Hessian H (bài 4), và câu chuyện y hệt — chỉ cần một công cụ của môn 2.

Với hàm bậc hai f(θ) = ½·θᵀHθ, H đối xứng xác định dương, một bước GD là:

θ ← θ − lr·Hθ = (I − lr·H)·θ

Đây là một ma trận nhân lặp lại — đúng bài toán của môn 2 bài 7 (eigenvector) và bài 8 (Markov). Đổi sang cơ sở eigenvector của H (môn 2 bài 6), ma trận (I − lr·H) trở thành đường chéo, và mỗi chiều eigenvector i tiến hoá độc lập:

thành phần thứ i  ←  (1 − lr·λᵢ) · thành phần thứ i

Bài toán nhiều chiều tan rã thành n bài toán một chiều độc lập, mỗi cái đúng như §2. Đó là toàn bộ giá trị của eigenvector, thu hoạch ở đây.

Hội tụ đòi hỏi mọi chiều đều co: |1 − lr·λᵢ| < 1 với mọi i. Chiều nào có λ lớn nhất là chiều dễ nổ nhất, nên:

       2
lr < ─────         λmax = eigenvalue LỚN NHẤT của Hessian
      λmax

Bạn sẽ kiểm công thức này bằng số ba lần trong bài (§4, §6, và phần Code) — mỗi lần đều khớp.


4. Hồi quy tuyến tính bằng GD — và nghiệm đóng làm baseline

4.1. Đạo hàm MSE — tính bằng chain rule, không bằng autograd

Mô hình: ŷ = w·x + b. Loss:

L(w, b) = (1/N) · Σᵢ (w·xᵢ + b − yᵢ)²

Gọi rᵢ = w·xᵢ + b − yᵢ (phần dư). Chain rule (bài 3): lớp ngoài là u² → 2u, lớp trong là rᵢ:

∂L/∂w = (2/N) · Σᵢ rᵢ · xᵢ        (∂rᵢ/∂w = xᵢ)
∂L/∂b = (2/N) · Σᵢ rᵢ             (∂rᵢ/∂b = 1)

Hai dòng này là toàn bộ "backpropagation" của mô hình một lớp. Không có gì bí ẩn.

4.2. Tính tay một bước — ba điểm, số đẹp

Dữ liệu sạch, không nhiễu: (1, 5), (2, 8), (3, 11) — nằm đúng trên y = 3x + 2. Bắt đầu từ w = 0, b = 0, lr = 0.01.

phần dư:   r = (0−5, 0−8, 0−11) = (−5, −8, −11)
loss    :   (25 + 64 + 121)/3 = 210/3 = 70

∂L/∂w = (2/3)·(−5·1 + −8·2 + −11·3) = (2/3)·(−54) = −36
∂L/∂b = (2/3)·(−5  + −8   + −11  )  = (2/3)·(−24) = −16

w ← 0 − 0.01·(−36) = +0.36
b ← 0 − 0.01·(−16) = +0.16
loss mới = 55.3408          ← giảm từ 70 ✓

Làm được trên giấy trong 2 phút. Chạy tiếp cùng lr:

bước 10 100 1 000 5 000 20 000
w 2.24168 3.19708 3.02259 3.00000149 3.00000000
b 1.00230 1.55192 1.94864 1.99999662 2.00000000
loss 6.7e0 2.9e−2 3.8e−4 1.6e−12 7.1e−28

Hội tụ về đúng (3, 2). Và ngưỡng lr cho bộ ba điểm này tính được trước khi chạy:

A = [[1,1],[2,1],[3,1]]      AᵀA = [[14, 6], [6, 3]]
H  = (2/N)·AᵀA = [[9.3333, 4], [4, 2]]
eig(H) = 0.240393  và  11.09294
lr_max = 2/11.09294 = 0.180295

Kiểm bằng số: lr = 0.15 → loss 1.3e−11 sau 300 bước ✓ · lr = 0.19 → loss 3.1e+28 ✗ nổ. Công thức nói ngưỡng ở 0.1803; thực nghiệm đồng ý.

4.3. Chạy thật trên 100 điểm có nhiễu

Sinh dữ liệu, seed cố định để bạn tái tạo đúng từng chữ số:

import numpy as np

rng = np.random.default_rng(42)
x = rng.uniform(-3, 3, 100)
y = 3*x + 2 + rng.normal(0, 0.5, 100)

GD từ w = b = 0, lr = 0.01, 1000 bước:

bước    0  loss= 27.432898  w=0.00000  b=0.00000
bước  100  loss=  0.321112  w=2.98106  b=1.70921
bước  200  loss=  0.241163  w=3.00453  b=1.95544
bước  300  loss=  0.239742  w=3.00614  b=1.98842
bước  400  loss=  0.239716  w=3.00635  b=1.99283
bước  500  loss=  0.239716  w=3.00638  b=1.99342
bước 1000  loss=  0.239716  w=3.00639  b=1.99351

Chú ý thứ tự học: w gần đúng ngay ở bước 100 (2.98), còn b mãi bước 500 mới tới nơi. Không ngẫu nhiên — đó là hai chiều eigenvector với λ khác nhau (§3), và §6 sẽ mổ xẻ.

4.4. Baseline bắt buộc — nghiệm đóng ⭐ QUY TẮC 2

Quy tắc 2: tinh chỉnh baseline mạnh như tinh chỉnh phương pháp của mình. Với hồi quy tuyến tính, baseline không phải "một phương pháp khác" — nó là nghiệm đúng, tính một phát, không lặp. Bỏ qua nó là tự bỏ mất phép đo phân xử duy nhất bạn có.

Bài toán bình phương tối thiểu có nghiệm đóng, và bạn đã có mọi mảnh ghép từ môn 2:

Aθ ≈ y  →  θ* = (AᵀA)⁻¹Aᵀy          ← phương trình chuẩn tắc

A là ma trận 100×2 (cột 1 = x, cột 2 = toàn số 1) — ma trận không vuông, môn 2 bài 5. Không có A⁻¹, nên ta chiếu y lên không gian cột của A (môn 2 bài 1: phép chiếu). (AᵀA)⁻¹Aᵀ chính là giả nghịch đảo, và np.linalg.lstsq tính nó ổn định hơn (qua SVD — môn 2 bài 9).

Đối chiếu:

w b loss
GD tự cài, 1000 bước 3.006386 1.993507 0.23971600
np.linalg.lstsq 3.006386 1.993507 0.23971600
lệch 1.8e−10 3.9e−09 8.3e−17

np.linalg.solve(A.T@A, A.T@y) cũng cho đúng số đó (lệch 4.4e−16).

Ba thứ đọc được từ bảng này mà không có baseline thì không đọc được:

  1. Code GD của bạn đúng — không phải "trông có vẻ hội tụ".
  2. 0.239716 là sàn, không phải thất bại. Loss không về 0 vì dữ liệu có nhiễu σ = 0.5.
  3. Và đây là chỗ hay: loss tại tham số thật (3, 2) là 0.239874, tức CAO HƠN loss tại nghiệm đóng 0.239716. Nghiệm đóng khớp dữ liệu tốt hơn sự thật — vì nó đã ăn một chút nhiễu. Đó là overfitting, đo được, ở mô hình chỉ có 2 tham số. Môn 5 và môn 6 sống trên sự thật này.

4.5. Ngưỡng lr cho bộ dữ liệu này

H = (2/N)·AᵀA = [[5.3493, −0.1594], [−0.1594, 2.0000]]
eig(H) = 1.9924  và  5.3568
lr_max = 2 / 5.3568 = 0.37335

Kiểm:

lr 0.30 0.373 0.374 0.40 0.60
loss sau 500 bước 0.239716 ✓ 3.62 (đang bò) 714.97 ✗ 2e+59 ✗ inf ✗ (tràn ở bước 442)

lr = 0.373 vẫn hội tụ (nó nhỏ hơn 0.37335) nhưng cần 20 000 bước thay vì 500 — hệ số co là |1 − 0.373·5.3568| = 0.998. Sát ngưỡng thì đúng về lý thuyết mà vô dụng trên thực tế.


5. Có nghiệm đóng rồi, vì sao cả ngành vẫn dùng GD?

Câu hỏi đúng phải hỏi. Ba lý do, xếp theo độ nghiêm trọng:

Nghiệm đóng (AᵀA)⁻¹Aᵀy Gradient descent
Tồn tại khi nào chỉ với vài họ mô hình (tuyến tính + MSE) mọi hàm khả vi
Chi phí O(N·d² + d³) — có d³ O(N·d) mỗi bước
Bộ nhớ phải dựng AᵀA cỡ d×d chỉ cần gradient cỡ d
Dữ liệu streaming phải có toàn bộ A nạp từng mini-batch

Với d = 2 như §4, nghiệm đóng thắng tuyệt đối — dùng GD ở đó là ngớ ngẩn, và tôi bắt bạn làm chỉ để có baseline.

Với d = 175·10⁹ (một LLM), d³ là con số không có tên, và AᵀA là ma trận 175 tỷ × 175 tỷ. Mà quan trọng hơn cả: mạng nơ-ron không tuyến tính, không có nghiệm đóng nào để mà tính.

Rút gọn thành một câu: nghiệm đóng là đặc quyền của mô hình tuyến tính; GD là thứ duy nhất còn hoạt động khi bạn rời khỏi đó. Nhưng khi nghiệm đóng có tồn tại, nó là baseline không được phép bỏ qua — vì nó là sự thật, không phải một phương pháp cạnh tranh.


6. Ellipse dẹt — điều kiện xấu, zigzag, và momentum sửa gì

6.1. Hàm và vì sao nó khó

f(x, y) = x² + 10y²        ∇f = (2x, 20y)        H = [[2, 0], [0, 20]]

Eigenvalue 2 và 20. Đường đồng mức là ellipse có tỉ lệ bán trục √10, dẹt theo trục y. Tỉ số độ cong (khác tỉ số bán trục) là

κ = λmax / λmin = 20 / 2 = 10        ← "số điều kiện" (condition number)

κ là toàn bộ độ khó của bài toán. Xuất phát từ (10, 1), chạy tới |∇f| < 1e−3.

6.2. GD bị kẹp giữa hai ràng buộc mâu thuẫn

Mỗi chiều nhân với (1 − lr·λ):

  • chiều dốc (λ=20): cần lr < 2/20 = 0.1, không thì nổ
  • chiều thoải (λ=2): hệ số co = 1 − 2·lr, muốn nhanh thì cần lr lớn

lr bị chiều dốc chặn trên, nhưng tốc độ tổng thể do chiều thoải quyết định. Với lr = 0.09:

chiều dốc  : hệ số = 1 − 20(0.09) = −0.80    ← đổi dấu mỗi bước = ZIGZAG
chiều thoải: hệ số = 1 −  2(0.09) = +0.82    ← bò, mỗi bước chỉ tiến 18%

Quỹ đạo thật, 8 bước đầu — nhìn cột y đổi dấu liên tục trong khi x nhích rất chậm:

bước 0:  x=10.00000  y=+1.000000   f=110.00
bước 1:  x= 8.20000  y=-0.800000   f= 73.64
bước 2:  x= 6.72400  y=+0.640000   f= 49.31
bước 3:  x= 5.51368  y=-0.512000   f= 33.02
bước 4:  x= 4.52122  y=+0.409600   f= 22.12
bước 5:  x= 3.70740  y=-0.327680   f= 14.82
bước 6:  x= 3.04007  y=+0.262144   f=  9.93
bước 7:  x= 2.49285  y=-0.209715   f=  6.65
bước 8:  x= 2.04414  y=+0.167772   f=  4.46

Đó là zigzag: GD nảy qua nảy lại ngang lòng thung lũng, tiến dọc thung lũng rất ít. Trong toàn bộ 51 bước, y đổi dấu 50 lần.

Tốc độ tốt nhất mà GD đạt được, tính bằng công thức: lr* = 2/(λmax+λmin) = 2/22 = 0.0909, cho hệ số co

(κ − 1)/(κ + 1) = 9/11 = 0.8182

Quét lr từ 0.001 đến 0.1 bằng máy: tốt nhất là 51 bước — đúng như dự đoán. Baseline đã được tinh chỉnh (Quy tắc 2), không phải chạy mặc định một lần.

6.3. Momentum — thêm quán tính

v ← β·v + ∇f(θ)
θ ← θ − lr·v

v là trung bình trượt có trọng số mũ của các gradient gần đây (chính hàm e^(−t/S) của bài 2, ở dạng rời rạc). Cơ chế:

  • Chiều zigzag: gradient đổi dấu mỗi bước → các số hạng trong v triệt tiêu nhau → bước nhỏ lại
  • Chiều thoải: gradient luôn cùng dấu → các số hạng cộng dồn → bước to lên, tối đa gấp 1/(1−β) lần

Momentum không đi đường khác — nó làm nhiễu tự triệt tiêu và tín hiệu tự khuếch đại. Ẩn dụ backend: đó chính xác là exponential moving average bạn đặt trên một metric nhiễu trước khi cho nó điều khiển autoscaler. Cùng một ý tưởng, cùng một tham số β.

Tham số tối ưu lý thuyết (heavy ball, Polyak):

lr = 4 / (√λmax + √λmin)² = 0.115443
β  = ((√λmax − √λmin)/(√λmax + √λmin))² = 0.269874

6.4. Bảng kết quả — số bước tới |∇f| < 1e−3

Thuật toán lr β số bước Ghi chú
GD, lr đoán bừa 0.02 — 243 không tinh chỉnh
GD, lr đã quét 0.09 — 51 ⭐ baseline thật
GD 0.10 — ∞ lr = 2/λmax đúng chằn → dao động mãi
Momentum 0.09 0.5 30 cùng lr với baseline
Momentum 0.09 0.9 133 ⚠️ tệ hơn cả GD
Momentum, tham số lý thuyết 0.1154 0.2699 21 ⭐ nhanh gấp 2.4 lần
RMSProp mọi lr đã thử — không đạt xem §7.2
Adam 0.66 — 107 tốt nhất sau khi quét lr

Ba điều đọc được, và điều thứ ba quan trọng nhất:

  1. GD chưa tinh chỉnh (243) so với momentum tinh chỉnh (21) là so sánh gian lận — chênh 11.6 lần. So đúng phải là 51 với 21, tức 2.4 lần. Đây là Quy tắc 2 ở dạng nhỏ nhất bạn sờ được.
  2. β = 0.9 làm mọi thứ TỆ HƠN (133 bước). "Momentum tốt" không có nghĩa "β càng lớn càng tốt" — quán tính quá lớn thì vọt qua đáy rồi phải quay lại.
  3. Adam thua cả GD trần trên bài này. 107 so với 51. §7 giải thích vì sao — và vì sao điều đó không mâu thuẫn với việc cả ngành dùng Adam.

6.5. Momentum thay đổi cái gì — phát biểu chính xác

Không phải "đi thẳng hơn" một cách mơ hồ. Nó đổi tốc độ hội tụ:

Hệ số co mỗi bước Với κ = 10
GD với lr tối ưu (κ−1)/(κ+1) 0.8182
Momentum với (lr, β) tối ưu (√κ−1)/(√κ+1) 0.5195

κ → √κ. Đó là toàn bộ đóng góp của momentum, và nó là một định lý, không phải mẹo. Với κ = 10 thì √κ = 3.16 — bài toán "dễ đi" gấp 3 lần. Với κ = 10 000 (chuyện thường ở mạng nơ-ron thật), √κ = 100 — khác biệt hai bậc độ lớn.

Kiểm bằng số: tỉ lệ xₖ₊₁/xₖ của momentum trong quỹ đạo thật hội tụ về 0.5195; của GD là hằng số 0.82. Khớp công thức tới 4 chữ số.


6b. ⭐ Khi −lr·∇f chưa đủ: Newton, Gauss–Newton, Levenberg–Marquardt (bổ sung từ VMLS — 2026-10)

(nguồn: VMLS tr.387-393, §18.2-18.3 — ví dụ tanh tr.390, chỉnh λ tr.392)

GD tuyến tính hoá loss rồi đi một đoạn lr bạn phải tự đoán (§2-§3). Khi loss là tổng bình phương phần dư ‖f(x)‖², sách tuyến tính hoá phần dư f(x) ≈ f(xₖ) + J(x − xₖ) (J = Jacobian, môn 3 bài 3) rồi giải bình phương tối thiểu (đúng bài toán môn 2 bài 5 §4b) để ra bước kế:

Newton       (f(x)=0, m=n) :  x ← x − f(x)/f'(x)
Gauss–Newton (min ‖f‖²)    :  x ← x − (JᵀJ)⁻¹ Jᵀ f              ← phần dư Ax−b có J=A: 1 bước = nghiệm đóng §4.4
Levenberg–Marquardt (LM)   :  x ← x − (JᵀJ + λI)⁻¹ Jᵀ f      ← ridge (môn 6 bài 4) đặt lên BƯỚC ĐI
import numpy as np
from scipy.optimize import brentq
f, df = np.tanh, lambda x: 1 - np.tanh(x)**2          # nghiệm duy nhất: x = 0
def newton(x, k):
    out = [x]
    for _ in range(k): x = x - f(x)/df(x); out.append(x)
    return np.array(out)
def lm(x, lam=1.0, k=12):                              # min f² :  x ← x − f'f/(λ + f'²)
    for _ in range(k):
        xn = x - df(x)*f(x)/(lam + df(x)**2)
        x, lam = (xn, .8*lam) if f(xn)**2 < f(x)**2 else (x, 2*lam)   # tốt: tin hơn · hỏng: vứt, siết lại
    return x
xs = brentq(lambda x: np.sinh(2*x) - 4*x, .5, 2)       # ngưỡng sống/chết — TÍNH ra, không thử sai
with np.errstate(all="ignore"):
    for x0 in (.95, 1.15): print(x0, "→", *[f"{v:.4g}" for v in newton(x0, 4)])
    print(f"ngưỡng = {xs:.6f}")      # in ra: 0.95 → … 4.3e-07 (về 0) · 1.15 → 1.15 -1.318 2.156 -16.5 5.361e+13 · 1.088659
    assert abs(newton(xs-.01, 60)[-1]) < 1e-9 and abs(newton(xs+.01, 6)[-1]) > 1e3
assert abs(lm(1.15)) < 1e-6                            # LM sống sót chỗ Newton chết
R = np.random.default_rng(0); J, r = R.normal(size=(30, 4)), R.normal(size=30)
step = lambda lam: -np.linalg.solve(J.T@J + lam*np.eye(4), J.T@r)
assert np.allclose(step(1e-9), -np.linalg.lstsq(J, r, rcond=None)[0], atol=1e-6)   # λ→0: Gauss–Newton
assert np.allclose(step(1e9), -J.T@r/1e9, rtol=1e-6)                                # λ→∞: GD, lr = 1/(2λ)
A, b = R.normal(size=(50, 3)), R.normal(size=50)       # phần dư TUYẾN TÍNH Ax − b có J = A
assert np.allclose(np.linalg.solve(A.T@A, A.T@b), np.linalg.lstsq(A, b, rcond=None)[0])  # 1 bước từ x=0
  1. λ là núm xoay: λ → 0 ra Gauss–Newton, λ → ∞ ra GD với lr = 1/(2λ). Tức λ đóng vai 1/(2·lr), và LM tự chỉnh nó: bước làm f² giảm thì nhận và hạ λ; bước làm tăng thì vứt và nâng λ. Không bao giờ nhận bước làm tệ đi.
  2. Newton nổ khi rời vùng mà tiếp tuyến còn đúng: từ 0.95 về 0, từ 1.15 văng tới 5.4e13. Ranh giới là nghiệm của sinh(2x) = 4x, 1.088659, nơi Newton nhảy x → −x mãi. Cùng bài học §3.

Dùng thật — sigmoid + MSE của môn 6 bài 1 §5 (gradient bão hoà nhỏ hơn cross-entropy 1491 lần). Số bước tới accuracy ≥ 95%, từ khởi tạo bão hoà và sai hướng (−8, −8, 0):

sig = lambda z: 1/(1 + np.exp(-z))
def chay(seed, kieu, ts, n=200):
    g = np.random.default_rng(seed)
    X = np.column_stack([np.vstack([g.normal(-1.5, 1, (n//2, 2)), g.normal(1.5, 1, (n//2, 2))]), np.ones(n)])
    y = np.r_[np.zeros(n//2), np.ones(n//2)]
    res = lambda th: sig(X@th) - y                              # phần dư r = σ(z) − y
    jac = lambda th: (sig(X@th)*(1 - sig(X@th)))[:, None]*X
    th, lam, t = np.array([-8., -8., 0.]), 1.0, 0
    while (((X@th) > 0) == (y > .5)).mean() < .95 and t < 400_000:
        J, r = jac(th), res(th)
        if kieu == "gd": th = th - ts*2*J.T@r/n
        else:
            new = th - np.linalg.solve(J.T@J + lam*np.eye(3), J.T@r)
            th, lam = (new, .8*lam) if (res(new)**2).sum() < (r**2).sum() else (th, 2*lam)
        t += 1
    return t
KQ = {ten: [chay(s, k, ts) for s in range(8)] for ten, k, ts in
      [("GD lr=0.1", "gd", .1), ("GD lr=200", "gd", 200), ("LM", "lm", None)]}
for ten, b in KQ.items(): print(f"{ten:<10} 8 seed: {min(b):>6} … {max(b):>6}")      # 16807…257144 · 9…25 · 14…18
assert max(KQ["LM"]) < 30 and min(KQ["GD lr=0.1"]) > 100*max(KQ["LM"]) and max(KQ["GD lr=200"]) < 30

lr = 0.1 — con số "an toàn" — cần 17 nghìn tới 257 nghìn bước; lr = 200 chỉ 9-25; LM không dò gì (λ khởi điểm 1) mất 14-18. LM không thắng GD đã chỉnh đúng — nó thay việc dò lr bằng phép thử nhận/vứt: Bẫy 1 ("cứ để lr nhỏ cho an toàn") ở dạng đo được. Nghi phạm là gradient bão hoà bé tí; số liệu khớp, nhưng tôi không tách giả thuyết đó bằng thí nghiệm riêng.

Phạm vi: mỗi bước LM giải hệ p × p (O(p³)) nên không dùng được cho p hàng tỉ — lý do §5, §7 (SGD, Adam) tồn tại; cần loss dạng tổng bình phương phần dư (cross-entropy thì không); đo trên 3 tham số, 200 mẫu, 8 seed.


7. SGD → momentum → RMSProp → Adam: mỗi cái THÊM gì

7.1. Bảng — đọc theo cột "thêm gì"

Optimizer Công thức cập nhật THÊM gì VÌ SAO
SGD θ ← θ − lr·g — mốc gốc. Một lr duy nhất cho mọi tham số
+ Momentum v ← βv + gθ ← θ − lr·v quán tính — trung bình trượt của gradient zigzag đổi dấu thì triệt tiêu, hướng ổn định thì cộng dồn. Đổi κ → √κ
+ RMSProp s ← ρs + (1−ρ)g²θ ← θ − lr·g/√s chia theo căn phương sai gradient, riêng từng chiều chiều nào gradient thường xuyên to thì tự động bước ngắn lại. Một lr cho mỗi tham số, tự sinh ra
= Adam m ← β₁m + (1−β₁)gs ← β₂s + (1−β₂)g²θ ← θ − lr·m̂/√ŝ cả hai, cộng hiệu chỉnh chệch m̂ = m/(1−β₁ᵗ) quán tính lo hướng, chia √s lo độ dài bước. Hiệu chỉnh chệch vì m, s khởi tạo bằng 0 nên vài bước đầu bị kéo về 0

Chỉ có hai ý tưởng trong cả bảng, và Adam là phép cộng của chúng:

Adam  =  làm mượt HƯỚNG (momentum)  +  chuẩn hoá ĐỘ DÀI BƯỚC theo từng chiều (RMSProp)

7.2. RMSProp làm gì, thấy bằng số

Tại p = (10, 0.1), gradient = (20, 2) — chênh nhau 10 lần.

GD      lr=0.09 → bước = (1.8, 0.18)          ← chiều nào gradient to, bước to
RMSProp lr=0.1  → bước = (0.31623, 0.31623)   ← HAI CHIỀU BƯỚC BẰNG NHAU

s = 0.1·g² = (40, 0.4), √s = (6.32, 0.632), g/√s = (3.162, 3.162) — độ lớn gradient bị chia hết, chỉ còn dấu và tỉ lệ tương đối gần đây. Đó là ý nghĩa của "tự điều chỉnh lr từng chiều".

Và đó cũng là điểm yếu của nó. Vì độ dài bước ≈ lr bất kể ở gần hay xa đáy, RMSProp không dừng lại được — nó quay vòng quanh đáy ở bán kính ~lr:

lr 0.5 0.1 0.01
điểm dừng lại quanh (0.25, 0.25) (0.05, 0.05) (0.005, 0.005)
‖∇f‖ sàn 5.03 1.005 0.1005

Sàn tỉ lệ tuyến tính với lr. Muốn |∇f| < 1e−3 phải hạ lr xuống ~1e−4, mà khi đó nó bò không tới nơi. Đó là lý do bảng §6.4 ghi "không đạt".

Đây là lý do LỊCH SỬ thật của learning-rate schedule — giảm lr dần trong lúc train. Không phải mẹo kinh nghiệm: bán kính quỹ đạo cuối tỉ lệ với lr, nên muốn vào sâu hơn thì bắt buộc phải hạ lr. Và nhớ lại Quy tắc 1: chính lựa chọn schedule này là thứ làm Kaplan et al. ra kết luận sai và khiến cả ngành đốt 2 năm.

7.3. Vì sao Adam thua GD ở §6.4 mà cả ngành vẫn dùng Adam

Bài x² + 10y² là bài toán mơ: bậc hai chính xác, gradient sạch không nhiễu, 2 chiều, Hessian hằng số, κ = 10, và ta được phép quét lr cho GD. Trong điều kiện đó GD tinh chỉnh tất nhiên thắng.

Điều kiện thật của một mạng nơ-ron:

Ellipse §6 Mạng nơ-ron thật
gradient chính xác nhiễu (mini-batch)
số chiều 2 10⁶ – 10¹¹
Hessian hằng số đổi mỗi bước
κ 10 10³ – 10⁶, khác nhau theo lớp
quét lr vài giây vài ngày GPU cho một điểm

Giá trị thật của Adam không phải "hội tụ nhanh nhất" — mà là "chạy được với lr mặc định trên hàng triệu tham số có thang đo khác nhau, mà không ai phải quét gì". Nó bán sự bền vững, không bán tốc độ.

Đây là Quy tắc 3 ở dạng cụ thể: kỹ thuật hoạt động ≠ lời giải thích bạn được dạy đúng. Câu chuyện "Adam nhanh hơn" sai; câu chuyện "Adam đỡ phải chỉnh lr" đúng. Chứng minh hội tụ gốc của Adam sai suốt 4 năm mà không ai phát hiện — vì nó chạy tốt.


7b. ⭐ SGD hoạt động chỉ vì gradient cần KHÔNG CHỆCH — không cần đúng

(bổ sung 19/8 — nguồn: MML tr.232, §7.1.3)

Bảng §7.1 mở đầu bằng "SGD" mà chưa nói chữ S (stochastic) đến từ đâu. Toàn bộ bài này tới giờ dùng gradient trên cả 100 điểm. Thực tế thì mỗi bước chỉ lấy vài chục điểm. Câu hỏi đúng phải hỏi: vì sao được phép làm thế?

Câu trả lời của MML ngắn đến mức dễ đọc lướt qua:

"we only require that the gradient is an unbiased estimate of the true gradient"

Không chệch (unbiased) nghĩa là gradient mini-batch đúng trong kỳ vọng, không cần từng bước bằng gradient toàn bộ. Đây không phải điều kiện duy nhất để bảo đảm hội tụ: các kết quả hội tụ còn cần giả thiết về hàm mục tiêu, kiểm soát phương sai và lựa chọn learning rate.

Và không chệch ở đây không phải "gần đúng lắm" — nó là một đẳng thức. Kiểm bằng cách duyệt hết mọi mini-batch khả dĩ thay vì lấy mẫu:

import itertools
import numpy as np

rng = np.random.default_rng(0)
N = 10
X = rng.uniform(-3, 3, N)
Y = 3 * X + 2 + rng.normal(0, 1.0, N)
w, b = 1.0, 0.5                                   # một điểm bất kỳ trong không gian tham số

def g(idx):
    r = w * X[idx] + b - Y[idx]
    return np.array([2 * np.mean(r * X[idx]), 2 * np.mean(r)])

g_full = g(np.arange(N))
print("g_full =", np.round(g_full, 6))            # [-16.756178  -3.310179]

for B in [1, 2, 3, 5]:
    lo = list(itertools.combinations(range(N), B))     # DUYỆT HẾT, không lấy mẫu
    tb = np.mean([g(list(t)) for t in lo], axis=0)
    print(f"  B={B}  {len(lo):>3} mini-batch  →  trung bình {np.round(tb,6)}"
          f"   lệch {np.max(np.abs(tb - g_full)):.1e}")
    assert np.max(np.abs(tb - g_full)) < 1e-12    # ĐẲNG THỨC, không phải xấp xỉ
g_full = [-16.756178  -3.310179]
  B=1   10 mini-batch  →  trung bình [-16.756178  -3.310179]   lệch 0.0e+00
  B=2   45 mini-batch  →  trung bình [-16.756178  -3.310179]   lệch 3.6e-15
  B=3  120 mini-batch  →  trung bình [-16.756178  -3.310179]   lệch 7.1e-15
  B=5  252 mini-batch  →  trung bình [-16.756178  -3.310179]   lệch 4.4e-16

Trung bình gradient trên mọi mini-batch kích thước B bằng đúng g_full, với mọi B. Lệch 1e-15 là dấu phẩy động, không phải xấp xỉ thống kê. Lý do thì tầm thường một khi nhìn ra: g_full là trung bình của N gradient từng điểm, và trung bình của một mẫu con lấy đều là ước lượng không chệch của trung bình toàn bộ. Hết.

Ý sâu hơn, và nó lật ngược cách bạn đang nghĩ

g_full không phải "gradient thật". Nó cũng chỉ là một ước lượng — ước lượng thực nghiệm, với N mẫu, của kỳ vọng trên phân phối sinh ra dữ liệu. Sinh dữ liệu từ một mô hình đã biết thì tính được gradient thật và đo được g_full lệch bao nhiêu:

g_that = np.array([2 * (w - 3.0) * 3.0, 2 * (b - 2.0)])   # E[x]=0, E[x²]=3 với x ~ U(−3,3)
print("g_thật (trên phân phối) =", g_that)                 # [-12.  -3.]

for n in [10, 100, 1000, 10000, 100000]:
    rg = np.random.default_rng(7)
    Xs = rg.uniform(-3, 3, n)
    Ys = 3 * Xs + 2 + rg.normal(0, 1.0, n)
    r = w * Xs + b - Ys
    gf = np.array([2 * np.mean(r * Xs), 2 * np.mean(r)])
    print(f"  N={n:>6}  g_full = {np.round(gf,4)}   lệch tương đối "
          f"{np.linalg.norm(gf-g_that)/np.linalg.norm(g_that):.2%}")

assert np.linalg.norm(g_full - g_that) > 1.0      # g_full của N=10 lệch RẤT xa
N g_full lệch so với gradient thật
10 [−15.88, −4.03] 32,43 %
100 [−11.52, −2.36] 6,47 %
1 000 [−11.77, −2.85] 2,21 %
10 000 [−12.14, −3.02] 1,18 %
100 000 [−12.01, −3.02] 0,17 %

g_thật = (−12, −3). Với N = 100 — kích thước tập dữ liệu của §4.3 — g_full đã lệch 6,5 %.

Có ba đại lượng, không phải hai:

gradient mini-batch  →  ước lượng không chệch của  →  g_full  →  ước lượng không chệch của  →  gradient thật
       (B mẫu)                                        (N mẫu)                                    (∞ mẫu)

g_full chỉ là một chặng giữa, không phải sự thật. Nó "chính xác" so với tập dữ liệu của bạn và sai so với thứ bạn thật sự muốn tối ưu. Cả hai mũi tên là cùng một loại ước lượng.

Vì sao khó tự nghĩ ra: vì g_full có tên gọi nghe như sự thật — "full batch", "gradient đầy đủ", "gradient chính xác". Từ ngữ tự nó đóng câu hỏi lại. Chỉ khi bạn sinh dữ liệu từ một mô hình đã biết — thứ không làm được với dữ liệu thật — thì mới có cách đo g_full sai bao nhiêu, và mới thấy nó cũng chỉ là một mẫu như mọi mẫu khác.

Sai lầm mà nó ngăn được

Tăng batch size để "gradient chính xác hơn". Bảng trên cho thấy chuyện gì thực sự xảy ra: bạn tốn compute để đi từ ước lượng B mẫu tới ước lượng N mẫu — mà cái đích đó bản thân nó đã lệch 6,5 % so với thứ đáng quan tâm. Bạn đang trả tiền để hội tụ chính xác hơn về một chỗ sai.

MML nói thẳng: học máy "không nhất thiết cần một ước lượng chính xác của cực tiểu hàm mục tiêu" — vì mục tiêu cuối là generalization, không phải cực tiểu của loss trên tập train. Bạn đã có bằng chứng số cho câu đó ngay trong bài này, ở §4.4: loss tại nghiệm đóng (0.239716) thấp hơn loss tại tham số thật (3, 2) (0.239874). Cực tiểu chính xác của loss thực nghiệm không phải chỗ bạn muốn tới. Đi tới đó chính xác hơn là đi sai chính xác hơn.

(Từ vựng "ước lượng không chệch" sẽ được định nghĩa đàng hoàng ở môn 4 bài 1; ở đây chỉ cần đúng một tính chất: kỳ vọng bằng đại lượng cần ước lượng.)

🪤 BẪY: không chệch không kéo theo "không chệch sau khi đi qua một hàm phi tuyến". Gradient mini-batch không chệch, nhưng bước cập nhật của Adam là m̂/√ŝ — một hàm phi tuyến của gradient, nên bước đi của Adam là CHỆCH dù gradient thì không. Đây không phải chuyện lý thuyết suông: nó đúng là loại chỗ mà chứng minh hội tụ của Adam đã sai suốt 4 năm (§7.3). Với SGD trần thì lập luận không chệch áp thẳng được; với optimizer thích ứng thì không.


7c. ⭐ Nhiễu là TÍNH NĂNG, không phải lỗi — nhưng chỉ trong một cửa sổ

(bổ sung 19/8 — nguồn: MML tr.232)

§7b nói nhiễu mini-batch không phá hội tụ. MML còn đi thêm một bước: mini-batch nhỏ cho nhiễu, và nhiễu giúp thoát khỏi cực tiểu địa phương xấu. Toàn bộ bài này chạy trên hàm lồi (x², x²+10y²) nên chưa có chỗ nào cho thấy điều đó. Dựng một hàm có nhiều đáy:

f_i(x) = 0.05x² − 0.1·cᵢ·x − 0.35·cos(3x)        cᵢ ~ N(0, 60), trung bình 0
f(x)   = trung bình các fᵢ = 0.05x² − 0.35·cos(3x)        ← parabol + sóng

Một parabol thoải cộng một sóng cosin biên độ vừa: cực tiểu toàn cục ở x = 0, và một dãy cực tiểu địa phương cách nhau ≈ 2.03. Mỗi fᵢ khác nhau ở độ dốc tuyến tính, nên gradient mini-batch có nhiễu thật (không phải nhiễu bịa thêm vào), và độ lớn nhiễu tỉ lệ 1/√B — đúng như SGD thật.

import numpy as np

N, A = 256, 0.35
rg0 = np.random.default_rng(0)
c = rg0.normal(0, 60.0, N); c -= c.mean()

f  = lambda t: 0.05 * t ** 2 - A * np.cos(3 * t)
g1 = lambda t, idx: 0.1 * t - 0.1 * np.mean(c[idx]) + 3 * A * np.sin(3 * t)

xs = np.linspace(-7, 7, 2801); fv = f(xs)
for i in range(1, len(xs) - 1):
    if fv[i] < fv[i - 1] and fv[i] < fv[i + 1]:
        print(f"   cực tiểu tại x = {xs[i]:+.3f}   f = {fv[i]:+.4f}")

def chay(B, seed, x0=4.2, lr=0.05, steps=600):
    r = np.random.default_rng(seed)
    t = x0                                        # xuất phát từ MỘT CỰC TIỂU XẤU
    for _ in range(steps):
        idx = r.integers(0, N, B) if B < N else np.arange(N)
        t = t - lr * g1(t, idx)
    return t

print("   B  | nhiễu gradient | thoát về lưu vực toàn cục | vừa thoát vừa LẮNG (|x|<0.2)")
for B in [256, 64, 16, 8, 4, 2, 1]:
    nhieu = 0.0 if B == N else 0.1 * c.std() / np.sqrt(B)
    kq = np.array([chay(B, s) for s in range(500)])       # 500 seed mỗi dòng
    print(f" {B:>4} | {nhieu:>14.3f} | {np.mean(np.abs(kq) < 1.0):>25.3f} |"
          f" {np.mean(np.abs(kq) < 0.2):>28.3f}")

assert np.mean([abs(chay(256, s)) < 1.0 for s in range(50)]) == 0.0    # full-batch: KHÔNG BAO GIỜ
assert np.mean([abs(chay(4, s)) < 1.0 for s in range(500)]) > 0.3      # B=4: một phần ba thoát

Bảy cực tiểu địa phương trong [−7, 7], ở 0, ±2.03, ±4.055, ±6.075, với f = −0.35 / −0.1374 / +0.5 / +1.5614. Xuất phát ở x₀ = 4.2 — ngay cạnh cực tiểu xấu +4.055. Chạy 500 seed cho mỗi mức nhiễu:

B nhiễu gradient thoát về lưu vực toàn cục vừa thoát vừa lắng (|x| < 0.2)
256 (full-batch) 0.000 0.000 0.000
64 0.759 0.000 0.000
16 1.518 0.000 0.000
8 2.146 0.026 0.016
4 3.035 0.332 0.144 ⭐
2 4.293 0.352 0.122
1 6.071 0.270 0.068

Full-batch GD không thoát một lần nào trong 500 lần — nó tất định, gradient tại 4.055 bằng 0, nó ngồi đó vĩnh viễn. Đây là chỗ "gradient chính xác hơn" thua thẳng gradient nhiễu.

Vì sao khó tự nghĩ ra: mọi thứ khác trong kỹ thuật đều dạy rằng nhiễu là thứ cần loại bỏ — lọc nó, lấy trung bình nó, tăng mẫu để giảm nó. Ở đây nhiễu là thứ duy nhất làm được việc, và thuật toán "chính xác hơn" là thuật toán thua. Không ai tự nghĩ ra một ngoại lệ ngược hẳn với phản xạ của cả nghề, trừ khi có một bảng số bắt phải nhìn.

🪤 BẪY — nhiễu lớn hơn không phải tốt hơn: có một CỬA SỔ

Đọc cột cuối, không phải cột giữa. Cột "thoát về lưu vực" gần như bão hoà từ B = 4 trở xuống, nhưng cột "vừa thoát vừa lắng" lên đỉnh ở B = 4 rồi tụt: 0.144 → 0.122 → 0.068.

Vì sao: với B = 1 nhiễu đủ lớn để nhảy ra khỏi đáy xấu — và cũng đủ lớn để nhảy ra khỏi đáy tốt. Nó không định cư ở đâu cả. Hai cột đo hai chuyện khác nhau, và chỉ cột thứ hai mới là thứ bạn muốn.

nhiễu quá nhỏ  (B ≥ 16)  →  KẸT ở đáy xấu, 0% thoát
nhiễu vừa      (B = 4)   →  thoát được VÀ lắng lại  ⭐
nhiễu quá lớn  (B = 1)   →  thoát được nhưng KHÔNG LẮNG, cứ nhảy loanh quanh

Và đây chính là lý do learning rate phải GIẢM DẦN. Bạn cần nhiễu lớn lúc đầu (để thoát) và nhỏ về sau (để lắng) — một giá trị cố định không làm được cả hai. Đó là cùng một kết luận đã rút ra ở §7.2 bằng một đường hoàn toàn khác: bán kính quỹ đạo cuối của RMSProp tỉ lệ tuyến tính với lr, nên muốn vào sâu hơn thì bắt buộc phải hạ lr.

Hai lập luận độc lập, một kết luận. §7.2 nói phải hạ lr để lắng; mục này nói phải giữ lr cao lúc đầu để thoát. Ghép lại ra đúng hình dạng của mọi schedule đang dùng thật: warmup → giữ cao → decay. Không phải mẹo kinh nghiệm.

Và nhớ lại quy tắc 1: chính lựa chọn schedule này là chi tiết thiết lập đã làm Kaplan et al. kết luận sai. Bây giờ bạn hiểu vì sao nó không phải một chi tiết nhỏ — nó điều khiển trực tiếp thứ vừa đo được ở bảng trên.

🪤 BẪY thứ hai, nhỏ hơn nhưng dễ vấp: đừng đọc bảng này thành "batch nhỏ luôn tốt hơn" — quy tắc 4. Phạm vi của kết luận là: hàm mục tiêu nhiều cực tiểu, và mức nhiễu nằm cùng thang với chiều cao rào cản. Trên bài lồi x² + 10y² ở §6, thêm nhiễu chỉ làm mọi thứ tệ đi — không có đáy xấu nào để mà thoát. Đó đúng là bài tập số 2 ở cuối bài, và giờ bạn biết trước nên trông đợi điều gì.


🪤 BẪY

Bẫy 1 — "cứ để lr nhỏ cho an toàn". SAI, và nó khiến bạn kết luận sai về MÔ HÌNH.

lr là siêu tham số quan trọng nhất, và trực giác "nhỏ thì an toàn" là bản năng sai nguy hiểm nhất trong bài này.

Cùng bộ dữ liệu §4.3, cùng 1000 bước, chỉ đổi lr:

lr w (đúng: 3.006) b (đúng: 1.994) loss Bạn sẽ kết luận gì
1e−5 0.15353 0.03485 24.9537 "model không học được gì cả"
1e−4 1.22340 0.32786 11.0435 "loss có giảm nhưng kẹt, chắc thiếu capacity"
1e−3 2.97911 1.70408 0.3242 "gần được rồi, cần thêm layer?"
0.01 3.00639 1.99351 0.239716 ✅ đúng

Cả bốn dòng chạy CÙNG một model, CÙNG một dữ liệu, CÙNG số bước, và KHÔNG có lỗi nào. Chỉ khác một con số.

Ba dòng đầu không crash, không cảnh báo, loss vẫn giảm đơn điệu — mọi dấu hiệu đều nói "đang train bình thường". Đây đúng là dạng "code chạy nhưng số sai lặng lẽ".

Và tổn thất thật không phải mấy bước lãng phí — là kết luận sai về mô hình. Bạn sẽ đi thêm layer, đổi kiến trúc, thu thêm dữ liệu, viết vào báo cáo rằng "phương pháp X không hoạt động" — trong khi chỉ có một số sai. Đó là Quy tắc 1 (lỗi giết bạn là chi tiết thiết lập) chồng lên Quy tắc 4 (kết luận âm sai phạm vi).

Cách chặn duy nhất: trước khi kết luận bất cứ điều gì về mô hình, quét lr theo thang log — 1e−5, 1e−4, 1e−3, 1e−2, 1e−1 — và nhìn cả năm đường loss. Rẻ hơn mọi thứ khác bạn định làm.

Nhắc lại số ở §2 cho khỏi quên: lr = 0.01 và lr = 0.99 trên f = x² chậm y hệt nhau (764 bước). "Nhỏ" không phải là "an toàn" — nó là cùng mức tồi ở đầu bên kia.

Bẫy 2 — quét tham số bằng máy và tìm ra một "cải tiến" không tồn tại

Trong lúc quét (lr, β) cho momentum ở §6, máy tìm ra một cấu hình đè bẹp tham số lý thuyết:

lr = 0.113,  β = 0.32   →   10 bước      (lý thuyết: 21 bước)

Nó còn cho đúng 10 bước từ 5 điểm xuất phát khác nhau. Trông y như một phát hiện.

Bây giờ đổi một chi tiết thiết lập — ngưỡng dừng — và giữ nguyên mọi thứ khác:

ngưỡng ‖∇f‖ < 1e−3 1e−4 1e−6 1e−8
GD lr=0.09 51 62 85 108
Momentum lý thuyết 21 25 32 40
"kỷ lục" lr=.113, β=.32 10 24 31 40

Lợi thế bốc hơi. Quỹ đạo đó vô tình đi ngang qua sát gốc toạ độ ở đúng bước 10 rồi bay ra xa lại (|∇f| ở bước 10 là 4.8e−4, ở bước 11 vọt lên 6.0e−2). Ta bắt được nó đúng lúc nó đi qua — một cộng hưởng, không phải hội tụ nhanh.

Đây chính là câu hỏi số 3 trong "ba câu hỏi trước khi tin bất kỳ con số nào": "nếu đổi một chi tiết thiết lập, kết luận có đổi không?" Nếu tôi chỉ chạy ở tol = 1e−3 và không hỏi câu đó, tôi đã viết một dòng sai vào bài học này — và bạn đã tin nó, vì nó có số liệu, có 5 điểm xuất phát, và tái tạo được.

Tái tạo được ≠ đúng. Một kết quả sai vẫn tái tạo được hoàn hảo.

Bẫy 3 — gradient tự viết sai dấu hoặc sai hệ số

Quên số 2 trong ∂L/∂w = (2/N)Σrᵢxᵢ thì code vẫn chạy, vẫn hội tụ về đúng chỗ — chỉ là lr hiệu dụng bị chia đôi. Bạn sẽ không bao giờ biết. Nhưng viết r = y − ŷ thay vì ŷ − y thì dấu lật, GD leo dốc, loss tăng dần rồi nổ — và bạn sẽ đi tìm bug ở lr.

Cách chặn: gradient check bằng sai phân số học, 3 dòng, chạy một lần trước khi train:

num = (L(w + 1e-6, b) - L(w - 1e-6, b)) / (2e-6)
assert abs(grad_w - num) < 1e-5

Có trong phần Code bên dưới. Đây là phiên bản GD của assert np.allclose(matmul(A,B), A@B) ở môn 2 bài 2 — và đáng ghi vào nhật ký lỗi ngay lần đầu nó bắt được cái gì.


Code — tự cài, KHÔNG dùng hàm có sẵn

Chạy được ngay, mọi assert phải xanh. Nếu một cái đỏ, đó là bài học — tìm ra nguyên nhân thật rồi ghi vào nhật ký lỗi.

import numpy as np

# ══ PHẦN 1 — GD một chiều trên f(x)=x², f'(x)=2x ═══════════════════════
def gd_1d(x0, lr, steps):
    x, traj = x0, [x0]
    for _ in range(steps):
        x = x - lr * (2 * x)          # x ← x(1 − 2·lr)
        traj.append(x)
    return traj

for lr in [0.1, 0.5, 0.9, 1.0, 1.1]:
    t = gd_1d(5.0, lr, 10)
    print(f"lr={lr:<4} hệ số={1-2*lr:+.2f}  x₁₀={t[-1]:+10.4f}")

assert abs(gd_1d(5.0, 0.1, 10)[-1] - 5 * 0.8**10)  < 1e-12
assert abs(gd_1d(5.0, 0.5, 1)[-1])                 < 1e-15   # 1 bước là xong
assert abs(gd_1d(5.0, 0.9, 10)[-1] - 5 * 0.8**10)  < 1e-12   # |q| bằng lr=0.1
assert abs(abs(gd_1d(5.0, 1.0, 10)[-1]) - 5.0)     < 1e-12   # dao động mãi
assert abs(gd_1d(5.0, 1.1, 10)[-1]) > 30                     # phân kỳ
print("ngưỡng 2/f'' = 1.0 được xác nhận bằng số ✓\n")

# ══ PHẦN 2 — hồi quy tuyến tính tự cài ════════════════════════════════
rng = np.random.default_rng(42)
N = 100
x = rng.uniform(-3, 3, N)
y = 3 * x + 2 + rng.normal(0, 0.5, N)

def mse(w, b):
    return float(np.mean((w * x + b - y) ** 2))

def grad_mse(w, b):
    r = w * x + b - y                          # phần dư
    return 2 * np.mean(r * x), 2 * np.mean(r)  # chain rule, KHÔNG autograd

# gradient check — Quy tắc 1, chặn bẫy 3
eps = 1e-6
gw, gb = grad_mse(1.0, 0.5)
num_w = (mse(1.0 + eps, 0.5) - mse(1.0 - eps, 0.5)) / (2 * eps)
num_b = (mse(1.0, 0.5 + eps) - mse(1.0, 0.5 - eps)) / (2 * eps)
assert abs(gw - num_w) < 1e-5 and abs(gb - num_b) < 1e-5
print(f"gradient check: giải tích ({gw:.6f}, {gb:.6f}) = sai phân ({num_w:.6f}, {num_b:.6f}) ✓")

w = b = 0.0
lr = 0.01
history = []
for step in range(1001):
    if step % 100 == 0:
        print(f"  bước {step:>4}  loss={mse(w,b):>10.6f}  w={w:.5f}  b={b:.5f}")
    history.append(mse(w, b))
    gw, gb = grad_mse(w, b)
    w -= lr * gw
    b -= lr * gb

# BASELINE BẮT BUỘC — Quy tắc 2
A = np.column_stack([x, np.ones(N)])
w_star, b_star = np.linalg.lstsq(A, y, rcond=None)[0]
print(f"GD    : w={w:.6f}  b={b:.6f}  loss={mse(w,b):.8f}")
print(f"lstsq : w={w_star:.6f}  b={b_star:.6f}  loss={mse(w_star,b_star):.8f}")
assert max(abs(w - w_star), abs(b - b_star)) < 1e-6
assert all(history[i+1] <= history[i] + 1e-12 for i in range(len(history)-1))  # giảm đơn điệu
assert np.allclose([w_star, b_star], np.linalg.solve(A.T @ A, A.T @ y))       # = phương trình chuẩn tắc
print("GD khớp nghiệm đóng, loss giảm đơn điệu ✓\n")

# ngưỡng lr = 2/λmax(H), H = Hessian của MSE
H = 2 * (A.T @ A) / N
lam = np.linalg.eigvalsh(H)
print(f"eig(Hessian) = {np.round(lam,4)}   ngưỡng lr = 2/λmax = {2/lam.max():.5f}")
def train(lr_, steps=500):
    w_, b_ = 0.0, 0.0
    for _ in range(steps):
        gw_, gb_ = grad_mse(w_, b_)
        w_ -= lr_ * gw_; b_ -= lr_ * gb_
    return mse(w_, b_)
assert train(0.30) < 0.25          # dưới ngưỡng → hội tụ
assert train(0.40) > 1e3           # trên ngưỡng → nổ
print(f"lr=0.30 → loss {train(0.30):.6f} | lr=0.40 → loss {train(0.40):.3g} ✓\n")

# ══ PHẦN 3 — ellipse dẹt f(x,y)=x²+10y² ═══════════════════════════════
def run(lr, beta=0.0, tol=1e-3, maxit=100_000):
    """beta=0 → GD thường; beta>0 → momentum. Trả về số bước, hoặc None nếu hỏng."""
    p, v = np.array([10.0, 1.0]), np.zeros(2)
    for k in range(maxit):
        g = np.array([2 * p[0], 20 * p[1]])
        if np.linalg.norm(g) < tol:
            return k
        v = beta * v + g
        p = p - lr * v
        if not np.isfinite(p).all() or np.linalg.norm(p) > 1e12:
            return None
    return None

kappa   = 20 / 2
lr_hb   = 4 / (np.sqrt(20) + np.sqrt(2)) ** 2
beta_hb = ((np.sqrt(20) - np.sqrt(2)) / (np.sqrt(20) + np.sqrt(2))) ** 2
for name, l_, b_ in [("GD  lr=0.02 (đoán bừa)", 0.02, 0.0),
                     ("GD  lr=0.09 (đã quét)",  0.09, 0.0),
                     ("Momentum β=0.5",         0.09, 0.5),
                     ("Momentum β=0.9",         0.09, 0.9),
                     ("Momentum lý thuyết",     lr_hb, beta_hb)]:
    print(f"  {name:<24} lr={l_:.4f} β={b_:.4f} → {run(l_, b_)} bước")

assert run(0.02) == 243 and run(0.09) == 51
assert run(0.09, 0.5) == 30
assert run(0.09, 0.9) == 133           # β quá lớn TỆ HƠN GD trần
assert run(lr_hb, beta_hb) == 21
assert run(0.10) is None               # lr = 2/λmax đúng chằn → dao động mãi
assert abs((kappa - 1) / (kappa + 1) - 0.81818) < 1e-4
assert abs((np.sqrt(kappa) - 1) / (np.sqrt(kappa) + 1) - np.sqrt(beta_hb)) < 1e-12
print("momentum đổi tốc độ (κ−1)/(κ+1)=0.8182 → (√κ−1)/(√κ+1)=0.5195 ✓\n")

# ══ PHẦN 4 — BẪY 2: một "kỷ lục" giả do cộng hưởng ════════════════════
def run_tol(lr, beta, tol):
    p, v = np.array([10.0, 1.0]), np.zeros(2)
    for k in range(100_000):
        g = np.array([2 * p[0], 20 * p[1]])
        if np.linalg.norm(g) < tol:
            return k
        v = beta * v + g
        p = p - lr * v
    return None

for tol in [1e-3, 1e-4, 1e-6, 1e-8]:
    print(f"  tol={tol:<7g} GD={run_tol(0.09,0,tol):>4}"
          f"  MOM_lý_thuyết={run_tol(lr_hb,beta_hb,tol):>4}"
          f"  MOM_quét_máy={run_tol(0.113,0.32,tol):>4}")
assert run_tol(0.113, 0.32, 1e-3) == 10    # "kỷ lục" chỉ tồn tại ở tol=1e-3
assert run_tol(0.113, 0.32, 1e-8) == 40    # siết tol → lợi thế biến mất
print("cấu hình 10 bước là CỘNG HƯỞNG may rủi, không phải cải tiến ✓")

Kết quả phải khớp mọi bảng trong bài. Đặc biệt chú ý: cả bốn assert ở phần 3 đều so với số nguyên chính xác, không phải khoảng. GD trên hàm tất định là hoàn toàn xác định — cùng seed, cùng tham số, cùng số bước, không sai lệch một đơn vị. Nếu bạn ra số khác, có bug thật.


Nối vào AI

Khái niệm bài này Chỗ dùng trong AI
θ ← θ − lr·∇f Mọi vòng lặp training từng tồn tại. Không có ngoại lệ
lr < 2/λmax Vì sao có lr giới hạn; vì sao mô hình lớn cần lr nhỏ hơn (Hessian "sắc" hơn)
Hessian & eigenvalue (môn 2 bài 7) Đo được độ khó κ của bề mặt loss; nền của optimizer bậc hai
Số điều kiện κ Vì sao chuẩn hoá đầu vào và BatchNorm/LayerNorm giúp: chúng kéo κ xuống
Zigzag & momentum torch.optim.SGD(momentum=0.9) — mặc định của gần như mọi bài báo thị giác
Chia theo √s (RMSProp) Vì sao Adam sống sót khi các lớp có thang gradient khác nhau 1000 lần
Sàn hội tụ tỉ lệ lr Learning-rate schedule (cosine, warmup + decay) — §7.2, và Quy tắc 1
Nghiệm đóng làm baseline Quy tắc 2 — trước attention hãy đo average-pooling; trước Adam hãy đo SGD
Gradient check bằng sai phân Cách kiểm backprop tự cài; chính là torch.autograd.gradcheck
MSE và nhiễu Gauss Loss không tự nhiên mà có — nó là −log likelihood. Môn 4

Điểm nối đáng dừng lại: chuỗi bài 3 (chain rule) → bài 4 (gradient) → bài 5 (đi theo gradient) chính là backpropagation + optimizer — tức là toàn bộ vòng lặp học của mọi mạng nơ-ron. Môn 7 chỉ thêm .backward() để bạn khỏi phải viết grad_mse bằng tay. Cơ chế thì bạn vừa cài xong.


Tự kiểm — trả lời được mới sang bài 6

  1. Vì sao trên f(x)=x², lr = 0.1 và lr = 0.9 cho cùng |x| sau 10 bước? Trả lời bằng q = 1 − 2·lr, và nói rõ hai đường đi khác nhau thế nào dù cùng đích.

  2. Suy ra lr < 2/λmax từ đầu, không nhìn §3: viết một bước GD trên f = ½θᵀHθ thành phép nhân ma trận, đổi sang cơ sở eigenvector, rồi áp điều kiện co. Chỗ nào bài này mượn môn 2 bài 6 và bài 7?

  3. Hồi quy tuyến tính có nghiệm đóng. Nêu ba lý do cả ngành vẫn dùng GD, xếp theo mức nghiêm trọng — và nói rõ lý do nào vẫn đúng kể cả khi máy tính nhanh vô hạn.

  4. Ở §4.4, loss tại nghiệm đóng (0.239716) thấp hơn loss tại tham số thật (3, 2) (0.239874). Giải thích. Điều đó nói gì về việc dùng loss trên cùng tập dữ liệu đã fit để đánh giá mô hình?

  5. GD trên x² + 10y² bị kẹp giữa hai ràng buộc mâu thuẫn về lr. Nêu cả hai, và giải thích vì sao chính κ = λmax/λmin — chứ không phải λmax hay λmin riêng lẻ — mới là thứ quyết định độ khó.

  6. Momentum đổi tốc độ từ (κ−1)/(κ+1) thành (√κ−1)/(√κ+1). Cơ chế nào tạo ra √? Và vì sao β = 0.9 lại làm bài §6 tệ hơn GD trần (133 so với 51)?

  7. RMSProp không bao giờ đạt |∇f| < 1e−3 trên ellipse, và bán kính quỹ đạo cuối tỉ lệ tuyến tính với lr. Từ tính chất này hãy tự suy ra vì sao learning-rate schedule là bắt buộc — không được nói "vì thực nghiệm cho thấy vậy".

  8. ⭐ Câu khó nhất. Adam thua GD tinh chỉnh trên bài §6 (107 so với 51), nhưng cả ngành dùng Adam. Nêu bốn điều kiện của bài §6 không đúng với mạng nơ-ron thật, và từ đó phát biểu chính xác Adam bán cái gì. Sau đó thiết kế một thí nghiệm phân biệt được hai lời giải thích "Adam hội tụ nhanh hơn" và "Adam đỡ phải chỉnh lr".

Câu 8 là ranh giới thật của bài này, và nó là Quy tắc 3 ở dạng sờ được: bạn phải phân biệt kỹ thuật hoạt động với lời giải thích của bạn về vì sao nó hoạt động. Không thiết kế nổi thí nghiệm ở vế sau thì bạn đang tin một câu chuyện, không phải một cơ chế.

Câu 2 là câu trả cổ tức xa nhất — nó biến eigenvector từ "một chương của môn 2" thành công cụ chẩn đoán bạn dùng suốt phần đời còn lại của lộ trình.


Nhìn lại môn 3

Sáu bài, mỗi bài đóng góp đúng một thứ, và chúng ghép thành một chuỗi không thừa mảnh nào:

Bài Đóng góp một câu
1 Đạo hàm = tốc độ thay đổi tức thời — rate() của Prometheus, không phải counter cộng dồn
2 e^x là hàm duy nhất mà đạo hàm bằng chính nó → nên nó xuất hiện ở mọi thứ dạng "tốc độ thay đổi tỉ lệ với hiện trạng": softmax, decay trí nhớ, và cả β·v của momentum ở bài này
3 Chain rule — công cụ duy nhất để tính đạo hàm qua nhiều lớp. Backpropagation không phải thuật toán mới, nó là chain rule lặp lại
4 Gradient = vector các đạo hàm riêng, chỉ hướng dốc nhất. Cho bạn biết đi hướng nào
5 Gradient descent — cho bạn biết đi bao xa, và vì sao đó là chỗ mọi thứ hỏng
6 Số học máy tính — float không phải số thực; underflow/overflow/catastrophic cancellation là lớp lỗi không có exception, giết nhiều giờ debug nhất (học sau bài này — đừng bỏ qua)

Ghép lại: bài 3 tính được gradient, bài 4 giải nghĩa nó, bài 5 dùng nó. Đó là trọn vẹn vòng lặp học của mọi mạng nơ-ron — và bạn vừa tự cài từ số 0, đối chiếu với nghiệm đóng, không dùng một dòng autograd nào.

Còn thiếu gì để đi tiếp: cả môn 3 giả định loss là một hàm cho sẵn. MSE từ đâu ra? Vì sao bình phương chứ không phải trị tuyệt đối? Vì sao phân loại dùng cross-entropy chứ không dùng MSE?

Manh mối đã nằm ngay trong bài này: dữ liệu §4 được sinh với nhiễu Gauss σ = 0.5, và loss cuối cùng 0.2397 chính bằng phương sai nhiễu của mẫu (0.239874). Không phải trùng hợp.

Môn 4 (xác suất) sẽ cho biết: mọi hàm loss đều là −log của một giả thiết xác suất — MSE là nhiễu Gauss, cross-entropy là phân phối Categorical — và "tối ưu hoá" thật ra là "ước lượng hợp lý cực đại". Lúc đó e^x của bài 2 và gradient descent của bài 5 sẽ gặp nhau ở đúng một chỗ.


Bài tập về nhà trước bài 6

Xem 3Blue1Brown — Neural Networks:

Tập Vì sao
2 — Gradient descent, how neural networks learn ⭐ Chính là bài này, bằng hình động. Xem sau khi đã tự cài xong §Code — thứ tự này quan trọng: có số của mình rồi mới xem hình thì hình mới dán vào được

Xem với một câu hỏi trong đầu: video vẽ một quả bóng lăn xuống thung lũng. Chỗ nào trong ẩn dụ đó tương ứng với lr, và vì sao ẩn dụ "lăn" thật ra mô tả momentum chứ không phải GD trần?

Xem lại nếu quên: Essence of Calculus ch.4 (chain rule) và Neural Networks ch.3-4 (backprop) — nền của bài 3, và là thứ môn 7 sẽ dựng tiếp lên.

BỎ QUA có chủ đích, và đây là lý do — Quy tắc 4, ghi kèm phạm vi:

Bỏ Vì sao bỏ Điều kiện lật lại
Calculus ch.8, 9 (tích phân, diện tích ↔ độ dốc) Lộ trình này không tính tích phân bằng tay ở đâu cả. Training là tổng rời rạc, không phải tích phân Cần khi học mô hình sinh liên tục (diffusion, normalizing flow) hoặc suy luận Bayes biến phân — môn 4 sẽ báo trước
Calculus ch.11 (chuỗi Taylor) Chỉ cần biết là nó tồn tại: xấp xỉ bậc hai f(θ+d) ≈ f(θ) + ∇fᵀd + ½dᵀHd là thứ đẻ ra công thức lr < 2/λmax ở §3. Bạn đã dùng kết quả, không cần dựng lại Cần khi học optimizer bậc hai (Newton, L-BFGS, K-FAC)
Calculus ch.12 (cách nhìn khác về đạo hàm) Góc nhìn bổ sung đẹp, không mở khoá gì mới trong lộ trình Xem cho vui khi rảnh

Bài tập cuối cùng của môn 3 — làm rồi mới sang môn 4:

  1. Đổi f(x,y) = x² + 10y² thành x² + 100y² (κ = 100). Tự tính lại lr_max, lr tối ưu cho GD, và (lr, β) tối ưu cho momentum bằng ba công thức ở §6 — rồi mới chạy để kiểm. Số bước phải khớp dự đoán tỉ số √κ.
  2. Thêm nhiễu vào gradient: g + rng.normal(0, 0.5, 2). Chạy lại bảng §6.4. Bây giờ momentum và Adam có còn thua GD nữa không? Đây là điều kiện thứ nhất trong bốn điều kiện của câu tự kiểm số 8 — và là cách rẻ nhất để tự kiểm chứng lời giải thích của bạn thay vì tin nó.

Môn 4 sẽ là: xác suất — nơi bạn khám phá ra rằng MSE không phải một lựa chọn thiết kế, mà là hệ quả bắt buộc của giả thiết nhiễu Gauss. Và 0.2397 ở §4 sẽ có tên gọi.

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ị.