~3 giờ · cần xong bài 1
Đích của bài này:
ekhông phải một hằng số được chọn cho tiện. Nó là số duy nhất làm chod/dx aˣ = aˣ, và chính tính chất đó kéo nó vào mọi phương trình dạng "tốc độ thay đổi tỉ lệ với hiện trạng" — từ đường cong quên của trí nhớ tớisoftmax. Cònloglà cái phanh: nó biến nhân thành cộng, và đó là thứ duy nhất giữ cho số học của bạn không âm thầm tràn về 0.
⚡ 30 phút đầu — làm trước, đọc sau
Đừng đọc hết rồi mới gõ. Mở terminal ngay:
import numpy as np
h = 1e-5
cd = lambda f, x: (f(x + h) - f(x - h)) / (2 * h) # sai phân trung tâm — bài 1
# (1) đạo hàm của e^x, chia cho chính e^x
print([round(cd(np.exp, x) / np.exp(x), 6) for x in [0., 1., 2., 3.]])
# (2) làm y hệt với 2^x
print([round(cd(lambda t: 2.0**t, x) / 2.0**x, 6) for x in [0., 1., 2., 3.]])
# (3) softmax ba logit lớn — CHẠY Y NGUYÊN, đừng sửa gì
z = np.array([1000., 1001., 1002.])
e = np.exp(z); print(e / e.sum())
Ba đích số cụ thể:
(1) [1.0, 1.0, 1.0, 1.0] ← e^x: đạo hàm bằng ĐÚNG chính nó
(2) [0.693147, 0.693147, ...] ← 2^x: lệch đi một hệ số, mãi mãi
(3) [nan nan nan] ← công thức đúng toán, hỏng trên máy
Ra đúng cả ba → đọc tiếp để hiểu vì sao số 0.693147 xuất hiện, và vì sao dòng (3) là
lỗi phổ biến nhất trong toàn bộ code deep learning.
1. Câu hỏi thật: vì sao lại là e, chứ không phải 2 hay 10?
Bạn đã có công cụ từ bài 1: đạo hàm = độ dốc tức thời, xấp xỉ bằng sai phân trung tâm.
Giờ đem nó chĩa vào họ hàm mũ f(x) = aˣ và hỏi một câu duy nhất: đạo hàm của nó là gì?
Chạy sai phân trung tâm tại x = 1, rồi chia cho f(1) để xem "tỉ lệ giữa đạo hàm và chính hàm":
cơ số a |
f'(1) / f(1) (đo bằng số) |
ln a |
|---|---|---|
| 1.5 | 0.4054651081 | 0.4054651081 |
| 2 | 0.6931471806 | 0.6931471806 |
| 2.5 | 0.9162907319 | 0.9162907319 |
| 2.718281828… | 1.0000000000 | 1.0000000000 |
| 3 | 1.0986122887 | 1.0986122887 |
| 10 | 2.3025850932 | 2.3025850930 |
Đọc bảng này chậm. Nó nói:
Với MỌI cơ số
a, đạo hàm củaaˣvẫn là chínhaˣ— chỉ bị nhân thêm một hằng số.d/dx aˣ = (một hằng số phụ thuộc a) · aˣHằng số đó chính là
ln a. Vàelà cơ số duy nhất làm hằng số đó bằng 1.
Hằng số kia không phải phép màu. Viết định nghĩa đạo hàm ra là thấy ngay:
d/dx aˣ = lim(h→0) (a^(x+h) − aˣ) / h
= lim(h→0) (aˣ·a^h − aˣ) / h
= aˣ · lim(h→0) (a^h − 1) / h ← aˣ chui ra ngoài, KHÔNG dính h
└──────── hằng số ────────┘
aˣ tách ra được vì a^(x+h) = aˣ · a^h — đó là toàn bộ trò ảo thuật. Phần còn lại
lim (a^h − 1)/h không dính x, nên nó là một con số, phụ thuộc duy nhất vào a.
Đo con số đó bằng số học:
a = 2: h=1e-1 → 0.7177346254
h=1e-3 → 0.6933874626
h=1e-5 → 0.6931495828 → hội tụ về ln 2 = 0.6931471806
a = 3: h=1e-5 → 1.0986183234 → hội tụ về ln 3 = 1.0986122887
Định nghĩa của e là: cơ số làm giới hạn đó bằng đúng 1. Không hơn.
(Cách xây e mà bạn có thể đã gặp — (1 + 1/n)ⁿ — cho cùng một số:
n=10 → 2.5937424601, n=1000 → 2.7169239322, n=10⁶ → 2.7182804691, và e = 2.718281828459045.
Đó là bài toán lãi kép gộp liên tục. Cùng một hằng số, khác đường vào — không cần nhớ cách này.)
2. e^x là hàm DUY NHẤT có đạo hàm bằng chính nó
Phát biểu chính xác — chú ý chữ "duy nhất" nằm ở đâu:
Nếu
f'(x) = f(x)với mọix, thì bắt buộcf(x) = C·e^xcho một hằng sốCnào đó. Không có nghiệm nào khác.C = f(0).
Kiểm bằng số trước (bài 1, h = 1e-5):
x = −1 f'= 0.3678794412 e^x = 0.3678794412 tỉ số = 1.0000000000
x = 0 f'= 1.0000000000 e^x = 1.0000000000 tỉ số = 1.0000000000
x = 1 f'= 2.7182818285 e^x = 2.7182818285 tỉ số = 1.0000000000
x = 2 f'= 7.3890560991 e^x = 7.3890560989 tỉ số = 1.0000000000
x = 3 f'=20.0855369236 e^x =20.0855369232 tỉ số = 1.0000000000
Đặt g(x) = f(x) · e^(−x). Dùng quy tắc tích (bài 3):
g'(x) = f'(x)·e^(−x) − f(x)·e^(−x) = (f'(x) − f(x))·e^(−x) = 0
Đạo hàm bằng 0 ở mọi nơi ⟹ g là hằng số ⟹ f(x) = C·e^x. Hết.
Điểm cần thấy: chữ "duy nhất" không đến từ trực giác, nó là một định lý. Và nó là lý do ở §3 ta được phép nói "nghiệm là …" chứ không phải "một nghiệm là …".
Vì sao tính chất này lại quan trọng đến vậy. Vì nó biến e^x từ "một hàm" thành
nghiệm của một loại câu hỏi. Bất cứ khi nào bạn viết được một câu dạng:
"tốc độ thay đổi của thứ này tỉ lệ với lượng đang có của chính nó"
thì bạn vừa viết dy/dt = k·y, và §2 nói nghiệm bắt buộc phải là e. Không phải "người ta
hay dùng e" — mà là "không còn hàm nào khác".
3. Ý trung tâm: dR/dt = −R/S ⟹ R(t) = R₀·e^(−t/S)
Đây là phần đáng nhất của bài. Câu chuyện: R(t) là lượng còn nhớ được tại thời điểm t.
Giả thiết duy nhất, và nó rất tự nhiên:
Mỗi đơn vị thời gian, bạn quên mất một tỉ lệ cố định của những gì đang còn nhớ. Nhớ nhiều thì quên nhiều, nhớ ít thì quên ít. Tốc độ quên tỉ lệ với hiện trạng.
Viết thành phương trình — S là "độ bền" (càng lớn quên càng chậm):
dR/dt = −R/S
Giải bằng tay — ba dòng
1) tách biến: dR/R = −dt/S ← dồn R sang trái, t sang phải
2) tích phân 2 vế: ln R = −t/S + C
3) mũ hoá 2 vế: R = e^(−t/S + C) = e^C · e^(−t/S) = R₀ · e^(−t/S)
Ba dòng, hết. R₀ = e^C chính là R(0) — lượng nhớ ban đầu.
Dòng 2 là chỗ duy nhất của cả môn 3 cần tích phân, và nó chỉ cần một sự thật: hàm có đạo hàm bằng
1/Rlàln R. Kiểm bằng số ngay:d/dx ln xtạix = 0.5, 2, 5, 10cho2.0000000003, 0.5, 0.2, 0.1— đúng bằng1/x. Đó là lý do §10 nói bỏ được hai chương tích phân của 3B1B mà không mất gì.
Ví dụ tính tay — S = 10, R₀ = 1
Với S = 10, mũ −t/S tại t = 0, 10, 20, 30 là 0, −1, −2, −3 — số đẹp, làm trên giấy được:
t |
−t/S |
R(t) viết gọn |
giá trị | đọc là |
|---|---|---|---|---|
| 0 | 0 | 1 |
1.0000000000 | còn nguyên |
| 10 | −1 | 1/e |
0.3678794412 | còn ~36.8% |
| 20 | −2 | 1/e² |
0.1353352832 | còn ~13.5% |
| 30 | −3 | 1/e³ |
0.0497870684 | còn ~5.0% |
Chú ý cấu trúc: mỗi S đơn vị thời gian trôi qua, R bị nhân thêm 1/e. Không phải trừ đi
một lượng cố định — nhân với một hệ số cố định. Đó là dấu vân tay của phân rã mũ.
Kiểm nghiệm bằng số rằng nghiệm này thoả đúng phương trình (sai phân trung tâm, bài 1):
t = 0 R'(t) = −0.1000000000 −R/S = −0.1000000000 lệch 2.68e-12
t = 10 R'(t) = −0.0367879441 −R/S = −0.0367879441 lệch 1.15e-12
t = 20 R'(t) = −0.0135335283 −R/S = −0.0135335283 lệch 6.55e-13
t = 30 R'(t) = −0.0049787068 −R/S = −0.0049787068 lệch 6.38e-13
Lệch cỡ 1e-12 = sai số làm tròn máy tính. Nghiệm đúng.
Hai đại lượng phái sinh nên nhớ
nửa đời (còn 50%): t = S·ln 2 = 10 × 0.6931471806 = 6.931471805599453
còn 10%: t = −S·ln 0.1 = 23.025850929940454
còn 1%: t = −S·ln 0.01 = 46.05170185988091
Kiểm: R(6.931471805599453) = 0.5 chính xác. Và để ý — từ 10% xuống 1% mất đúng bằng
khoảng thời gian từ 100% xuống 10%: 46.0517 − 23.0259 = 23.0259. Mỗi bậc giảm 10 lần
tốn đúng cùng một khoảng thời gian. Đó là ý nghĩa thật của "thang log".
Đây là đường cong Ebbinghaus — và là dạng của MỌI phân rã
Với S = 10 ngày:
sau 1 ngày: R = 0.9048374180 sau 7 ngày: R = 0.4965853038
sau 2 ngày: R = 0.8187307531 sau 10 ngày: R = 0.3678794412
sau 3 ngày: R = 0.7408182207 sau 20 ngày: R = 0.1353352832
Ẩn dụ backend — đây là chỗ bạn đã gặp công thức này mà không biết:
| Thứ bạn đã dùng | Phương trình ẩn phía sau |
|---|---|
| Phân rã phóng xạ, "half-life" | dN/dt = −N/S — y hệt |
| EWMA (Prometheus, Grafana, load average) | trọng số mẫu cũ (1−α)^k — chính là e^(−k/S) |
| Leaky bucket thông thường (đối chiếu) | xả với tốc độ cố định khi còn dữ liệu: giảm tuyến tính, không phải phân rã mũ |
| Nguội của cache hit-rate sau khi đổi traffic | tiếp cận trạng thái mới theo hàm mũ |
Kiểm cái EWMA cho chắc — α = 0.2, trọng số mẫu cách đây k bước:
k = 0 : 0.8^k = 1.00000000 e^(−k/S) = 1.00000000
k = 5 : 0.8^k = 0.32768000 e^(−k/S) = 0.32768000
k = 10 : 0.8^k = 0.10737418 e^(−k/S) = 0.10737418
k = 20 : 0.8^k = 0.01152922 e^(−k/S) = 0.01152922
với S = 1/ln(1/0.8) = 4.481420117724549
Khớp từng chữ số. 0.8^k và e^(−k/S) là cùng một hàm viết hai kiểu — vì
0.8^k = e^(k·ln 0.8). Mọi hàm mũ đều là e mũ, chỉ khác hằng số trong mũ.
Đây là câu trả lời cho tự kiểm số 1 của môn 3, và bạn nên nói được nó bằng lời:
exuất hiện không phải vì ai chọn nó, mà vì "tỉ lệ với hiện trạng" ⟹f' = k·f⟹ nghiệm bắt buộc làe. Một giả thiết, một phương trình, một nghiệm.
4. log — hàm ngược, và việc quan trọng duy nhất nó làm
ln x là hàm ngược của e^x: ln(e^x) = x, e^(ln x) = x. Kéo theo ba luật:
ln(a·b) = ln a + ln b ← NHÂN thành CỘNG
ln(a/b) = ln a − ln b ← CHIA thành TRỪ
ln(a^k) = k · ln a ← LUỸ THỪA thành NHÂN
Kiểm với a = 0.001, b = 0.002:
ln(a·b) = −13.122363377404328
ln a + ln b = −13.122363377404328 ✓ khớp tới chữ số cuối
ln(a⁵) = −34.538776394910684
5·ln a = −34.538776394910684 ✓
Đây trông như một trò đại số vặt. Nó không phải.
Vì sao "nhân thành cộng" là chuyện sống chết trên máy tính
float64 biểu diễn được số nhỏ nhất khoảng 2.2250738585072014e-308 (số chuẩn), và
5e-324 (số dưới chuẩn). Nhân nhiều số nhỏ thì bạn đâm vào tường đó rất nhanh.
Chạy thật — nhân 1000 xác suất, mỗi cái 0.01:
np.prod(np.full(1000, 0.01)) → 0.0 ← MẤT TRẮNG
0.01 ** 1000 → 0.0
np.sum(np.log(np.full(1000, 0.01))) → -4605.170185988091
Cộng log ra -4605.17. Nhân trực tiếp ra 0.0. Không phải "ra số nhỏ" — ra đúng số 0,
và từ số 0 thì không cứu được gì: không so sánh được hai giả thuyết, không lấy log lại được
(log 0 = −inf), không lấy gradient được.
Tràn xảy ra sớm hơn bạn tưởng. Tìm mốc chính xác:
số hạng k |
0.01^k |
tổng log |
|---|---|---|
| 100 | 1.000000e-200 | −460.5170 |
| 150 | 1.000000e-300 | −690.7755 |
| 160 | 9.999889e-321 | −736.8272 |
| 161 | 9.881313e-323 | −741.4324 |
| 162 | 0.000000e+00 | −746.0376 |
Chỉ 162 xác suất là đủ. Một câu 162 token, hay 162 điểm dữ liệu trong một likelihood — con số đó nhỏ đến mức lố bịch.
Đây là lý do số học (một trong hai lý do) mà mọi nơi trong ML đều tối ưu
log-likelihood chứ không phải likelihood. Lý do thứ hai thuộc về giải tích và sẽ đến ở bài 3: tích củanthừa số cần quy tắc tích lồngnlớp, còn tổng thì đạo hàm là tổng các đạo hàm — đơn giản hơn hẳn. Hai lý do, hai môn khác nhau, cùng một kết luận.
Đối chiếu thẳng:
| Nhân xác suất | Cộng log | |
|---|---|---|
| Miền giá trị | [0, 1], ép vào sát 0 |
(−∞, 0], trải rộng thoải mái |
1000 số 0.01 |
0.0 — mất trắng |
−4605.17 — vẫn chính xác |
| Phép toán | nhân (dồn sai số nhân tính) | cộng (sai số cộng tính) |
| Đạo hàm | quy tắc tích lồng nhau | tổng các đạo hàm |
| So sánh hai mô hình | 0.0 vs 0.0 — vô nghĩa |
−4605.17 vs −3868.87 — rõ ràng |
(Cột cuối lấy từ số thật: 1000 xác suất ngẫu nhiên trong [0.001, 0.05] cho tổng log
−3868.87, trong khi np.prod của chúng cũng ra 0.0.)
Ẩn dụ backend: log là đổi đơn vị sang thang decibel. Không đổi thông tin, chỉ đổi cái thước — để những thứ chênh nhau 300 bậc độ lớn cùng nằm vừa trên một trục.
5. softmax — nơi e^x và log gặp nhau
Bài toán: mô hình phun ra một vector số thực bất kỳ (gọi là logits), ví dụ z = (0, 1, 2).
Cần biến nó thành phân phối xác suất: mọi phần tử dương, tổng bằng 1.
softmax(z)ᵢ = e^(zᵢ) / Σⱼ e^(zⱼ)
Vì sao e^x mà không phải cách khác? Vì e^x > 0 với mọi x — kể cả e^(−50) = 1.93e-22,
vẫn dương. Nó biến trục số thực (−∞, +∞) thành (0, +∞) một cách trơn tru và đơn điệu.
Chuẩn hoá xong là ra xác suất.
Tính tay softmax([0, 1, 2])
e⁰ = 1.000000 1.000000 / 11.107338 = 0.09003057
e¹ = 2.718282 2.718282 / 11.107338 = 0.24472847
e² = 7.389056 7.389056 / 11.107338 = 0.66524096
tổng = 11.107337927389695 tổng = 1.0 ✓
Ba số 0.09003057 / 0.24472847 / 0.66524096 sẽ quay lại nhiều lần trong bài này. Nhớ mặt chúng.
Tính chất then chốt: cộng hằng số vào mọi logit KHÔNG đổi kết quả
softmax(z + c)ᵢ = e^(zᵢ+c) / Σⱼ e^(zⱼ+c)
= (e^c · e^(zᵢ)) / (e^c · Σⱼ e^(zⱼ)) ← e^c chui ra cả tử lẫn mẫu
= softmax(z)ᵢ
Một dòng, và nó là giấy phép hợp pháp cho toàn bộ thủ thuật ở §6. Kiểm bằng số:
softmax([0, 1, 2]) = [0.09003057 0.24472847 0.66524096]
softmax([1000, 1001, 1002]) = [0.09003057 0.24472847 0.66524096] ← y hệt
Nối ngược môn 2, bài 1 (phép chiếu).
softmaxbất biến khi cộng hằng số nghĩa là: nó hoàn toàn không nhìn thấy thành phần củaztheo hướng vector1 = (1,1,…,1). Kiểm bằng chính công thức chiếu bạn viết ở môn 2:z = (3, 1, −2) proj_1(z) = (0.66666667, 0.66666667, 0.66666667) z − proj_1(z) = (2.33333333, 0.33333333, −2.66666667) softmax(z) = [0.8756006 0.11849965 0.00589975] softmax(z − proj) = [0.8756006 0.11849965 0.00589975] ← BẰNG NHAU softmax(proj_1(z)) = [0.33333333 0.33333333 0.33333333] ← không mang tin gì
softmaxchỉ dùng phần củaztrực giao với1. Giống hệt cáchcosinechỉ dùng hướng và vứt bỏ độ dài (môn 2 bài 1). Cùng một ý: bỏ đi chiều không mang thông tin.
Nếu đổi e^x thành 2^x thì sao?
Đây là tự kiểm số 6 của môn 3. Trả lời bằng số, z = (0, 1, 2):
2^z chuẩn hoá = [0.14285714 0.28571429 0.57142857] = (1/7, 2/7, 4/7)
softmax(z·ln2) = [0.14285714 0.28571429 0.57142857] ← BẰNG NHAU
Không hỏng gì cả — vì
2^x = e^(x·ln 2), đổi cơ số chỉ là chia logits cho một hằng số. Đó chính xác là thứ người ta gọi là temperature. Cái giữ được là tất cả: dương, tổng 1, đơn điệu, khả vi. Cái mất là sự gọn gàng khi lấy đạo hàm — vớie, hệ số là 1; với2, mọi công thức đạo hàm mọc thêm mộtln 2đeo bám vĩnh viễn.
Temperature — chính là chia logits trước khi softmax
softmax(z/T), với z = (1, 2, 3):
T |
phân phối | đọc là |
|---|---|---|
| 0.1 | [2.06106005e-09, 4.53978686e-05, 9.99954600e-01] |
gần như luôn chọn lớp mạnh nhất |
| 0.5 | [0.01587624, 0.11731043, 0.86681333] |
sắc nét |
| 1.0 | [0.09003057, 0.24472847, 0.66524096] |
gốc |
| 2.0 | [0.18632372, 0.30719589, 0.50648039] |
mềm ra |
| 10.0 | [0.30060961, 0.33222499, 0.36716540] |
gần như đều — mất hết ý kiến |
T → 0 thành argmax, T → ∞ thành phân phối đều. Một tham số, cả một dải hành vi.
log ở phía bên kia: cross-entropy
Sau khi có xác suất, hàm mất mát cho nhãn đúng k là −log p_k. Với z = (1,2,3):
| nhãn đúng | p_k |
loss = −log p_k |
|---|---|---|
| 0 | 0.09003057 | 2.40760596 |
| 1 | 0.24472847 | 1.40760596 |
| 2 | 0.66524096 | 0.40760596 |
Đoán đúng thì loss nhỏ, đoán sai thì loss lớn — và lớn theo kiểu log, tức là phạt rất nặng
khi tự tin sai: p = 0.01 cho loss 4.605170, p = 1e-8 cho loss 18.420681.
Và phần thưởng: gradient của cặp softmax + cross-entropy gọn đến mức khó tin —
∂loss/∂z = p − y (y là vector one-hot của nhãn đúng)
Kiểm bằng số (sai phân trung tâm, z = (1,2,3), nhãn đúng = 1):
gradient đo bằng số = [ 0.09003057 −0.75527153 0.66524096]
p − y = [ 0.09003057 −0.75527153 0.66524096] ✓ khớp
(Vì sao lại gọn như vậy — đó là bài 3, chain rule. Ở đây chỉ cần thấy nó đúng bằng số.)
🪤 BẪY — công thức đúng toán học, sai lặng lẽ trên máy
Đây là bẫy quan trọng nhất của cả môn 3. Nó có hai tầng, và tầng thứ hai mới giết người.
Tầng 1 — tràn trên: ồn ào, dễ bắt
z = np.array([1000., 1001., 1002.])
e = np.exp(z) # [inf inf inf] RuntimeWarning: overflow
e / e.sum() # [nan nan nan]
e^1000 vượt giới hạn float64 (~1.8e308). Ra inf, rồi inf/inf = nan. Cái này có warning,
loss thành nan, bạn phát hiện trong 5 phút.
Tầng 2 — tràn dưới: KHÔNG warning, KHÔNG nan, và SAI
Cùng công thức, logits âm lớn:
y = np.array([-745., -746., -747.])
e = np.exp(y) # [5.e-324 0. 0.] — không inf, không nan
e / e.sum() # [1. 0. 0.] ← trông hoàn toàn hợp lệ
Đáp án đúng là [0.66524096, 0.24472847, 0.09003057].
Kết quả
[1, 0, 0]là một phân phối xác suất hợp lệ: mọi phần tử trong[0,1], tổng đúng bằng 1. Không assert nào về "tổng bằng 1" bắt được nó. Không warning nào bật lên. Nó chỉ đơn giản là sai, và mô hình của bạn sẽ học từ số sai đó một cách rất nghiêm túc.
Cùng loại bệnh với logsumexp, ở mức còn tinh vi hơn — sai một chút, hoàn toàn im lặng:
logits ≈ −700 : ngây thơ = −699.5923940355556 đúng = −699.5923940355556 lệch 0.0
logits ≈ −740 : ngây thơ = −739.5958848349227 đúng = −739.5923940355556 lệch 0.0034908
logits ≈ −745 : ngây thơ = −744.4400719213812 đúng = −744.5923940355556 lệch 0.1523221
logits ≈ −750 : ngây thơ = −inf đúng = −749.5923940355556 lệch ∞
Ở −740: một con số hữu hạn, hợp lý, và sai từ chữ số thập phân thứ 3. Không có cách nào
nhìn ra bằng mắt.
Vì sao bug này sống sót qua cả review lẫn test
logits + 1 : [0.09003057 0.24472847 0.66524096] khớp ✓
logits + 10 : [0.09003057 0.24472847 0.66524096] khớp ✓
logits + 100 : [0.09003057 0.24472847 0.66524096] khớp ✓
logits + 700 : [0.09003057 0.24472847 0.66524096] khớp ✓
logits + 710 : [nan nan nan] HỎNG ✗
Code ngây thơ đúng tuyệt đối cho tới ~700, rồi sập một phát. Unit test với logits nhỏ:
xanh hết. Đây là dạng bug chỉ nổ khi mô hình lớn lên, sau khi đã ship.
Cách chữa — trừ max, một dòng
def softmax(z):
e = np.exp(z - z.max()) # DÒNG DUY NHẤT khác nhau
return e / e.sum()
def logsumexp(z):
m = z.max()
return m + np.log(np.exp(z - m).sum())
Vì sao được phép trừ: chính là tính chất bất biến ở §5 — cộng/trừ hằng số không đổi softmax.
Vì sao trừ max chứ không phải số khác: sau khi trừ, số hạng lớn nhất thành e⁰ = 1 (không bao
giờ tràn trên), và mẫu số luôn ≥ 1 (không bao giờ chia cho 0). Với logsumexp, cộng m lại
ở ngoài để trả về đúng giá trị:
logsumexp([1000,1001,1002]) = 1002.4076059644444
= 1000 + logsumexp([0,1,2])
= 1000 + 2.40760596444438 ✓
Bẫy phụ 1 — log(softmax(x)) thay vì log_softmax(x)
from scipy.special import softmax, log_softmax
z = np.array([0., -50., -800.])
np.log(softmax(z)) # [ 0. -50. -inf ] ← softmax đã làm mất số hạng thứ 3
z - logsumexp(z) # [ -1.9e-22 -50. -800. ] ← đúng
softmax ép e^(−800) xuống đúng 0, rồi log 0 = −inf, rồi gradient thành nan. Cách đúng là
không bao giờ đi qua xác suất: log_softmax(z) = z − logsumexp(z), một phép trừ, không mất gì.
Bẫy phụ 2 — nats hay bits? Lệch 1.4427 lần, tuyệt đối im lặng
np.log là ln (cơ số e, đơn vị nats). np.log2 cho bits.
p |
−ln p (nats) |
−log₂ p (bits) |
|---|---|---|
| 0.9 | 0.105361 | 0.152003 |
| 0.5 | 0.693147 | 1.000000 |
| 0.1 | 2.302585 | 3.321928 |
Tỉ số cố định 1 / ln 2 = 1.4426950408889634. Dùng nhầm thì loss của bạn lệch 44% so với con số
in trong paper bạn đang cố tái hiện — và không có gì báo lỗi, vì cả hai đều là số hợp lệ.
Quy tắc rút ra, viết vào nhật ký lỗi:
- Không bao giờ tự viết
np.exp(z)/np.exp(z).sum(). Luôn trừ max.- Không bao giờ
log(softmax(...)). Luônlog_softmax.- Không bao giờ nhân một chuỗi xác suất. Luôn cộng log.
- Ghi rõ đơn vị: nats hay bits.
Cả bốn đều là loại lỗi không crash. Chỉ
assertđối chiếuscipymới bắt được — hệt như bẫy chỉ sốmatmulở môn 2 bài 2.
Code — tự cài, KHÔNG dùng hàm có sẵn
Chạy nguyên khối này. Mọi assert phải xanh.
import numpy as np
from scipy.special import softmax as scipy_softmax, logsumexp as scipy_logsumexp
# ---------- 0. công cụ từ bài 1 ----------
def dao_ham(f, x, h=1e-5):
"""Sai phân trung tâm — đúng như bài 1."""
return (f(x + h) - f(x - h)) / (2 * h)
# ---------- 1. e^x là hàm DUY NHẤT có đạo hàm bằng chính nó ----------
for x in [-1.0, 0.0, 1.0, 2.0, 3.0]:
assert abs(dao_ham(np.exp, x) / np.exp(x) - 1.0) < 1e-8
for x in [0.0, 1.0, 2.0, 3.0, 10.0]: # 2^x thì KHÔNG
assert abs(dao_ham(lambda t: 2.0**t, x) / 2.0**x - np.log(2)) < 1e-7
for a in [1.5, 2.0, 2.5, np.e, 3.0, 10.0]: # tổng quát: hệ số = ln a
r = dao_ham(lambda t, a=a: a**t, 1.0) / a
assert abs(r - np.log(a)) < 1e-7
print("d/dx e^x = 1·e^x ✓ d/dx 2^x = ln2·2^x ≈ 0.693147·2^x ✓")
# ---------- 2. nghiệm của dR/dt = −R/S ----------
S, R0 = 10.0, 1.0
R = lambda t: R0 * np.exp(-t / S)
for t in [0.0, 10.0, 20.0, 30.0]:
assert abs(dao_ham(R, t) - (-R(t) / S)) < 1e-9 # thoả đúng phương trình
assert np.allclose([R(0), R(10), R(20), R(30)],
[1.0, 0.36787944, 0.13533528, 0.04978707], atol=1e-8)
assert abs(R(S * np.log(2)) - 0.5) < 1e-12 # nửa đời = S·ln2
print("R(t)=R0·e^(−t/S) thoả dR/dt=−R/S ✓ nửa đời =", round(S * np.log(2), 6))
# ---------- 3. softmax: ngây thơ vs trừ max ----------
def softmax_ngay_tho(z):
e = np.exp(z)
return e / e.sum()
def softmax(z):
z = np.asarray(z, dtype=float)
e = np.exp(z - z.max()) # DÒNG DUY NHẤT khác nhau
return e / e.sum()
x = np.array([1000.0, 1001.0, 1002.0])
with np.errstate(over="ignore", invalid="ignore"):
assert np.all(np.isnan(softmax_ngay_tho(x))) # tràn trên → nan, ồn ào
assert np.allclose(softmax(x), [0.09003057, 0.24472847, 0.66524096])
assert np.allclose(softmax(x), scipy_softmax(x))
assert np.allclose(softmax(x), softmax([0.0, 1.0, 2.0])) # bất biến khi cộng hằng số
y = np.array([-745.0, -746.0, -747.0]) # BẪY IM LẶNG
with np.errstate(under="ignore", invalid="ignore"):
im_lang = softmax_ngay_tho(y)
assert np.all(np.isfinite(im_lang)) # không nan, không warning
assert np.allclose(im_lang, [1.0, 0.0, 0.0]) # …và sai hoàn toàn
assert np.allclose(softmax(y), [0.66524096, 0.24472847, 0.09003057])
print("softmax ✓ bẫy im lặng tại logits −745 ✓")
# ---------- 4. log-sum-exp ----------
def logsumexp(z):
z = np.asarray(z, dtype=float)
m = z.max()
return m + np.log(np.exp(z - m).sum())
assert abs(logsumexp(x) - 1002.4076059644444) < 1e-9
assert abs(logsumexp(x) - scipy_logsumexp(x)) < 1e-9
assert abs(logsumexp([0.0, 1.0, 2.0]) - 2.40760596444438) < 1e-9
with np.errstate(over="ignore"):
assert np.isinf(np.log(np.exp(x).sum())) # cách ngây thơ: +inf
w = np.array([-740.0, -741.0, -742.0]) # sai IM LẶNG, không inf
with np.errstate(under="ignore"):
lech = abs(np.log(np.exp(w).sum()) - logsumexp(w))
assert 3e-3 < lech < 4e-3
print("logsumexp ✓ lệch im lặng tại −740 =", round(float(lech), 7))
# ---------- 5. log biến NHÂN thành CỘNG ----------
ps = np.full(1000, 0.01)
assert np.prod(ps) == 0.0 # nhân → tràn về 0
tong_log = np.sum(np.log(ps))
assert abs(tong_log - (-4605.170185988091)) < 1e-9
assert round(float(tong_log), 2) == -4605.17
assert 0.01**161 > 0.0 and 0.01**162 == 0.0 # tràn đúng từ mũ 162
print("nhân 1000 số 0.01 →", np.prod(ps), " ; cộng log →", round(float(tong_log), 2))
# ---------- 6. log_softmax — đừng log(softmax(...)) ----------
def log_softmax(z):
z = np.asarray(z, dtype=float)
return z - logsumexp(z)
zz = np.array([0.0, -50.0, -800.0])
with np.errstate(divide="ignore"):
assert np.isneginf(np.log(scipy_softmax(zz))[2]) # ngây thơ: −inf
assert np.all(np.isfinite(log_softmax(zz)))
assert abs(log_softmax(zz)[2] - (-800.0)) < 1e-9
print("log_softmax ✓ cách ngây thơ ra −inf, cách đúng ra −800.0")
# ---------- 7. cross-entropy và gradient của nó ----------
logits = np.array([1.0, 2.0, 3.0])
p = softmax(logits)
nhan = 1 # nhãn đúng là lớp 1
assert abs(-log_softmax(logits)[nhan] - 1.40760596) < 1e-8
y_onehot = np.array([0.0, 1.0, 0.0])
mat = np.eye(3)
grad_so = np.array([
dao_ham(lambda t, i=i: -log_softmax(logits + t * mat[i])[nhan], 0.0, 1e-6)
for i in range(3)
])
assert np.allclose(grad_so, p - y_onehot, atol=1e-6)
print("gradient (softmax+cross-entropy) = p − y ✓ ", np.round(p - y_onehot, 8))
# ---------- 8. nếu softmax dùng 2^x thay e^x ----------
u = np.array([0.0, 1.0, 2.0])
softmax2 = (2.0**u) / np.sum(2.0**u)
assert np.allclose(softmax2, [1/7, 2/7, 4/7])
assert np.allclose(softmax2, softmax(u * np.log(2))) # chỉ là đổi nhiệt độ
print("softmax cơ số 2 =", np.round(softmax2, 8), "= softmax(x·ln2) ✓")
Output đúng phải là:
d/dx e^x = 1·e^x ✓ d/dx 2^x = ln2·2^x ≈ 0.693147·2^x ✓
R(t)=R0·e^(−t/S) thoả dR/dt=−R/S ✓ nửa đời = 6.931472
softmax ✓ bẫy im lặng tại logits −745 ✓
logsumexp ✓ lệch im lặng tại −740 = 0.0034908
nhân 1000 số 0.01 → 0.0 ; cộng log → -4605.17
log_softmax ✓ cách ngây thơ ra −inf, cách đúng ra −800.0
gradient (softmax+cross-entropy) = p − y ✓ [ 0.09003057 -0.75527153 0.66524096]
softmax cơ số 2 = [0.14285714 0.28571429 0.57142857] = softmax(x·ln2) ✓
Khối §3 và §4 là toàn bộ bài học kỹ thuật của bài này, cô lại trong hai dòng
z - z.max(). Nếu chỉ giữ được một thứ từ 3 giờ vừa rồi, giữ hai dòng đó.
Nối vào AI
| Khái niệm trong bài | Chỗ dùng trong AI |
|---|---|
e^x > 0 với mọi x, đơn điệu, trơn |
softmax — biến logits thực bất kỳ thành xác suất |
softmax(z + c) = softmax(z) |
Giấy phép cho trừ max; cũng là lý do logits chỉ xác định "sai khác một hằng số" |
Chia logits cho T |
Temperature sampling — T nhỏ ⇒ quyết đoán, T lớn ⇒ đa dạng |
Đổi cơ số = đổi T |
Trả lời "vì sao phải là e": không bắt buộc về mặt xác suất, bắt buộc về mặt đạo hàm gọn |
−log p |
Cross-entropy loss; phạt rất nặng khi tự tin mà sai |
log(∏) = Σ log |
Log-likelihood thay cho likelihood (môn 4) — không tràn số, đạo hàm là tổng |
| Log-sum-exp trick | Bên trong mọi softmax, log_softmax, cross_entropy của mọi framework |
dR/dt = −R/S ⇒ e^(−t/S) |
Decay bộ nhớ kiểu Ebbinghaus; EWMA; learning-rate decay theo hàm mũ |
ln a là hệ số của d/dx aˣ |
Vì sao đạo hàm với cơ số e sạch, cơ số khác luôn kéo theo một hằng số thừa |
Điểm nối cần thấy rõ: e^x xuất hiện ở hai chỗ trông chẳng liên quan gì nhau —
decay trí nhớ và softmax — vì cùng một lý do. Ở decay, ta cần hàm mà f' ∝ f.
Ở softmax, ta cần hàm dương biến tổng thành tích (e^(a+b) = e^a·e^b, kiểm: e⁵ = 148.4131591
và e²·e³ = 148.4131591). Cả hai tính chất là cùng một tính chất nhìn từ hai phía — và
chỉ e^x có nó.
Hướng tới bài 3: ở §4 bạn thấy nhân 1000 số nhỏ ⟹ tràn về 0. Backprop qua L lớp là
nhân L ma trận Jacobian. Cùng một cấu trúc "nhân lặp", cùng một bệnh — và đó chính là
gradient vanishing/exploding. Bài 3 sẽ nói tại sao.
Tự kiểm — trả lời được mới sang bài sau
- Viết
d/dx aˣcho cơ sốabất kỳ. Hằng số nhân thêm là gì, và nó đến từ đâu trong định nghĩa đạo hàm? (Gợi ý: chỗ nàoaˣchui được ra ngoài giới hạn?) eđược định nghĩa thế nào trong bài này? Nêu định nghĩa dựa trên đạo hàm, không dùng(1+1/n)ⁿ.- Giải
dR/dt = −R/Sbằng tay, ba dòng, không nhìn lại §3. Rồi tínhR(20)vớiS = 10, R₀ = 1và kiểm ra0.1353352832. Vì sao mỗiSđơn vị thời gian thìRbị nhân1/echứ không phải trừ một lượng cố định? - Nêu hai lý do dùng
log-likelihood thay vì likelihood: một lý do về số học, một về giải tích. Cho con số cụ thể cho lý do số học. softmax(z + c) = softmax(z)— chứng minh trong một dòng, rồi giải thích vì sao dòng đó chính là giấy phép cho thủ thuật trừ max.- Vì sao trừ max chứ không phải trừ trung bình, hay trừ phần tử đầu? Chuyện gì được đảm bảo ở tử số, và chuyện gì được đảm bảo ở mẫu số?
softmaxvới logits[−745, −746, −747]tính theo cách ngây thơ ra[1, 0, 0]. Vì sao không assert nào kiểu "tổng bằng 1" hay "không có nan" bắt được lỗi này? Vậy assert đúng phải là gì?- ⭐ Câu khó nhất.
e^xxuất hiện trong decay trí nhớ và trongsoftmax. Chứng tỏ đó là cùng một lý do, không phải trùng hợp. (Gợi ý:f' = fvàe^(a+b) = e^a·e^bliên quan với nhau thế nào? Thử lấy đạo hàm hai vế của đẳng thức thứ hai theoa.)
Câu 8 là ranh giới thật của bài này. Trả lời được nghĩa là bạn không còn coi
elà một hằng số phải nhớ, mà là nghiệm duy nhất của một câu hỏi. Câu 7 là câu trả cổ tức thực dụng nhất — nó là thứ giữ cho code của bạn không sai lặng lẽ, và nó cùng họ với bẫy chỉ sốmatmulở môn 2 bài 2.
Bài tập về nhà trước bài sau
3Blue1Brown — Essence of Calculus:
| Tập | Trạng thái | Vì sao |
|---|---|---|
| 5 — What's so special about Euler's number e? | ⭐ BẮT BUỘC. Xem hai lần. | Chính là §1–§2 của bài này, bằng hình động. Xem xong quay lại đọc lại §1 — bảng ln a sẽ đọc khác hẳn |
| 6 — Implicit differentiation | TUỲ CHỌN | Chỉ xem nếu muốn thấy d/dx ln x = 1/x được suy ra (từ e^(ln x) = x) thay vì được cho. Bỏ qua không mất gì cho bài 3 |
Cách xem tập 5: không ghi chép, không dừng lại giải bài. Giữ sẵn đúng một câu hỏi trong đầu:
vì sao "đạo hàm bằng chính nó" lại tương đương với "biến cộng thành nhân"? Bạn đã có nửa câu
trả lời ở §5 (tính chất e^(a+b) = e^a·e^b) — xem để thấy nốt nửa kia. Đó cũng là câu tự kiểm số 8.
Bốn tập được BỎ QUA của Essence of Calculus — và vì sao:
| Tập | Vì sao bỏ |
|---|---|
| 8 — Integration and the fundamental theorem of calculus | Lộ trình này đi theo hướng đạo hàm (backprop đi ngược từ loss về trọng số). Cả môn 3 chỉ cần một sự thật tích phân: hàm có đạo hàm 1/R là ln R — đã dùng ở §3, dòng 2, và đã kiểm bằng số. Học cả chương để dùng một dòng là lỗ |
| 9 — What does area have to do with slope? | Cùng lý do — đây là phần trực giác cho tập 8 |
| 11 — Taylor series | Chỉ cần biết là nó tồn tại: e^x = Σ xᵏ/k!, và tổng 13 số hạng đầu đã cho 2.718281828286 so với e = 2.718281828459. Chuỗi Taylor là công cụ xấp xỉ; trong lộ trình này ta luôn có np.exp chính xác, nên không cần |
| 12 — The other way to visualize derivatives | Cách nhìn đạo hàm như "hệ số giãn nở của một phép biến đổi 1 chiều" rất đẹp, và nối thẳng với môn 2 bài 2 — nhưng nó không mở khoá thêm gì cho chain rule/backprop. Xem nếu còn thời gian và thấy thích, không xem thì không mất gì |
Chú ý cách bỏ: không phải "khó quá nên bỏ", mà là "đã xác định được cái tối thiểu cần dùng, và lấy đúng nó". Đó là kỹ năng riêng, và nó quan trọng không kém việc học nội dung.
Bài 3 sẽ là: chain rule — trái tim của toàn bộ deep learning. Backpropagation không phải một thuật toán mới; nó là chain rule lặp lại qua các lớp. Chuẩn bị trước một câu hỏi: ở §4 bạn đã thấy nhân 1000 số nhỏ thì tràn về 0 — chuyện gì xảy ra khi nhân 100 ma trận Jacobian?