Chương 2·Bài 2.3

Logistic regression và softmax

Một bộ lọc thư rác nhận email “Chúc mừng! Bạn đã trúng iPhone, bấm vào đây để nhận”. Nó không cần biết chắc đây là spam. Điều nó cần là một con số kiểu “xác suất spam là 0.97”, để người dùng tự quyết định chặn từ mức nào. Linear regression của bài 2.1 cho ra một số thực bất kỳ, không phải xác suất. Bài này sửa đúng chỗ đó: giữ phần tuyến tính, nhưng ép đầu ra về khoảng (0,1)(0, 1) và học bằng một hàm mất mát hợp với xác suất. Kết quả là logistic regression, mô hình phân loại được dùng nhiều nhất trong thực tế và cũng chính là một neuron của mạng neural ở chương sau.

Dữ liệu: lọc thư rác

Mỗi email được mô tả bằng hai con số: số đường link và số từ quảng cáo như “miễn phí”, “trúng thưởng”, “khuyến mãi”. Nhãn là 11 nếu là spam, 00 nếu là thư thường. Giống bài 2.1, ta tự sinh dữ liệu từ một quy luật đã biết để sau này đối chiếu xem mô hình học được gì. Quy luật dùng hàm sigmoid, sẽ được giải thích ngay ở mục sau; lúc này chỉ cần biết email càng nhiều link và từ quảng cáo thì càng dễ là spam.

Python
import numpy as np

rng = np.random.default_rng(20)
n = 400
links = rng.poisson(2.0, n)                           # số đường link
kw = rng.poisson(1.5, n)                              # số từ quảng cáo
z_true = 1.1 * links + 0.9 * kw - 4.5                 # quy luật thật, mô hình không được biết
y = (rng.random(n) < 1 / (1 + np.exp(-z_true))).astype(float)   # 1 = spam

X = np.column_stack([links, kw]).astype(float)        # (400, 2)
X_tr, X_te, y_tr, y_te = X[:300], X[300:], y[:300], y[300:]
print(X_tr.shape, X_te.shape, " tỉ lệ spam:", y.mean())
print(X[:4], y[:4])

Khoảng một phần ba số email là spam. 300 email đầu dùng để học, 100 email sau để kiểm tra.

Vì sao không dùng linear regression?

Cách nghĩ đầu tiên: nhãn là số 00 và 11, vậy cứ chạy linear regression rồi coi đầu ra là xác suất.

Python
from sklearn.linear_model import LinearRegression

lin = LinearRegression().fit(X_tr, y_tr)
p_lin = lin.predict(X_tr)
print("dự đoán nhỏ nhất:", p_lin.min().round(2), " lớn nhất:", p_lin.max().round(2))
print("số email có 'xác suất' ngoài [0, 1]:", np.sum((p_lin < 0) | (p_lin > 1)))

Vấn đề thứ nhất hiện ngay trong output: một phần đáng kể dự đoán nằm ngoài [0,1][0, 1]. “Xác suất −0.2-0.2” không có nghĩa gì.

Vấn đề thứ hai nghiêm trọng hơn. Để dễ nhìn, tạm chỉ dùng số link. Linear regression cho một đường thẳng; email được coi là spam khi đường thẳng vượt 0.50.5, tức số link vượt một ngưỡng nào đó. Giờ thêm vào tập train 20 email “siêu spam” có từ 15 đến 24 link. Những email này rõ ràng là spam, phân loại chúng chẳng khó gì, nên lẽ ra chúng không được làm thay đổi ranh giới.

Python
def link_threshold(model):
    """Số link tại đó đường thẳng cắt mức 0.5."""
    return (0.5 - model.intercept_) / model.coef_[0]

def accuracy_at(t):
    """Accuracy trên tập test khi coi email có từ t link trở lên là spam."""
    return np.mean((X_te[:, 0] >= t) == y_te)

L_tr = X_tr[:, :1]                                    # chỉ số link, shape (300, 1)
extra = np.repeat(np.arange(15, 25), 2)[:, None]      # 20 email siêu spam
L_more, y_more = np.vstack([L_tr, extra]), np.append(y_tr, np.ones(20))

for name, L, yy in [("300 email gốc    ", L_tr, y_tr), ("thêm 20 siêu spam", L_more, y_more)]:
    t = link_threshold(LinearRegression().fit(L, yy))
    print(f"{name}: ngưỡng = {t:.2f} link   accuracy test = {accuracy_at(t):.2f}")

Ngưỡng nhảy từ khoảng 3 link lên 6 link, và accuracy trên tập test tụt 10 điểm phần trăm. Lý do nằm ở hàm mất mát bình phương: email 24 link có nhãn 11 nhưng đường thẳng dự đoán cho nó một số lớn hơn 11 nhiều, nên bị tính là “sai nặng”. Để giảm cái sai vô lý đó, đường thẳng phải thoải xuống, kéo theo ngưỡng dịch sang phải. Mô hình bị phạt vì quá chắc chắn ở chỗ đúng.

Thêm 20 email siêu spam, ngưỡng của linear regression dịch từ 3.1 lên 6 link.00.510510152025số link3.16.0300 email gốc+ 20 siêu spam
Thêm 20 email siêu spam, ngưỡng của linear regression dịch từ 3.1 lên 6 link.

Hai vấn đề có chung một gốc: đầu ra không bị chặn trong (0,1)(0, 1), và hàm mất mát không hợp với xác suất. Hai mục tiếp theo sửa lần lượt từng cái.

Hỏi nhanh

Thêm vào train 20 email có từ 15 đến 24 link, email nào cũng là spam thật. Vì sao ngưỡng của linear regression lại dịch từ khoảng 3 link lên 6 link?

Sigmoid và log-odds

Giữ phần tuyến tính z=w⊤x+bz = \mathbf{w}^\top\mathbf{x} + b như cũ, rồi ép nó về khoảng (0,1)(0, 1) bằng hàm sigmoid:

p=P(y=1∣x)=σ(z)=11+e−z.p = P(y = 1 \mid \mathbf{x}) = \sigma(z) = \frac{1}{1 + e^{-z}}.
Sigmoid nhận mọi số thực z và trả về một xác suất trong (0, 1).00.51-6-3036zp ≈ 0.05p = 0.5p ≈ 0.95
Sigmoid nhận mọi số thực z và trả về một xác suất trong (0, 1).

Sigmoid nhận mọi số thực và trả về một số trong (0,1)(0, 1). z=0z = 0 cho p=0.5p = 0.5; z=3z = 3 cho p≈0.95p \approx 0.95; z=−3z = -3 cho p≈0.05p \approx 0.05. Đi xa khỏi 0 thì đường cong phẳng dần: tăng zz từ 10 lên 20 gần như không đổi pp. Tên có chữ “hồi quy” vì mô hình hồi quy ra một xác suất, nhưng việc của nó là phân loại: cắt xác suất ở một ngưỡng là có nhãn.

Để hiểu zz là gì, giải ngược phương trình trên theo zz:

z=log⁡p1−p.z = \log \frac{p}{1 - p}.

Tỉ số p1−p\frac{p}{1-p} gọi là odds, cách nói “mấy ăn một” quen thuộc trong cá cược: p=0.75p = 0.75 thì odds là 33, nghĩa là khả năng spam gấp 3 khả năng không spam. zz là log của odds, gọi là log-odds hay logit. Vậy logistic regression là một mô hình tuyến tính, nhưng tuyến tính cho log-odds chứ không cho xác suất. Đi ngược lại cũng dễ: z=ln⁡3z = \ln 3 cho odds 3, tức p=3/4p = 3/4. Muốn lúc đầu mô hình đoán khoảng 0.010.01 cho bài chỉ có 1% mẫu dương thì đặt bias bằng ln⁡(0.01/0.99)≈−4.6\ln(0.01/0.99) \approx -4.6.

Hệ quả trực tiếp: tăng xjx_j thêm một đơn vị thì zz tăng wjw_j, nên odds nhân thêm ewje^{w_j}. Bài báo y khoa hay viết “hút thuốc làm odds mắc bệnh tăng gấp 3” chính là đọc hệ số của logistic regression theo cách này.

Hỏi nhanh

Mô hình cho một email p=0.8p = 0.8. Odds spam của email ấy bằng bao nhiêu?

Ranh giới quyết định

Muốn ra nhãn thì cắt xác suất ở một ngưỡng, mặc định là 0.50.5. Vì σ(z)≥0.5\sigma(z) \ge 0.5 khi và chỉ khi z≥0z \ge 0, ranh giới giữa hai lớp là tập điểm có

w⊤x+b=0.\mathbf{w}^\top\mathbf{x} + b = 0.

Với hai đặc trưng, đó là một đường thẳng; với dd đặc trưng, một siêu phẳng. Sigmoid chỉ quyết định xác suất thay đổi nhanh hay chậm khi đi xuyên qua ranh giới, không làm ranh giới cong đi. Logistic regression vì vậy vẫn là một bộ phân loại tuyến tính. Muốn ranh giới cong thì làm như bài 2.1: thêm đặc trưng x12,x22,x1x2x_1^2, x_2^2, x_1x_2, khi đó ranh giới thẳng trong không gian đặc trưng mới thành đường cong trong mặt phẳng cũ. Chẳng hạn thêm một cột x12+x22x_1^2 + x_2^2 thì ranh giới là đường tròn, hoặc vẫn là đường thẳng nếu hệ số của cột đó bằng 0.

Các mức xác suất là những đường thẳng song song; p = 0.5 là ranh giới.024602468số linktừ quảng cáop = 0.1p = 0.5p = 0.9spamthư thường
Các mức xác suất là những đường thẳng song song; p = 0.5 là ranh giới.

Với một đặc trưng, ranh giới là một điểm: x=−b/wx = -b/w. Nhân cả w\mathbf{w} và bb với một số dương không đổi dấu của zz, nên không đổi nhãn nào; chỉ xác suất dốc hơn hay thoải hơn quanh ranh giới. Khoảng cách từ điểm x\mathbf{x} tới ranh giới là ∣w⊤x+b∣/∥w∥|\mathbf{w}^\top\mathbf{x} + b| / \lVert\mathbf{w}\rVert. Lặp lại thí nghiệm 20 email siêu spam, lần này bằng logistic regression của scikit-learn. Tham số C=np.inf tắt khoản phạt của bài 2.2 để so cho công bằng với linear regression không phạt; mục sau sẽ nói kỹ hơn.

Python
from sklearn.linear_model import LogisticRegression

for name, L, yy in [("300 email gốc    ", L_tr, y_tr), ("thêm 20 siêu spam", L_more, y_more)]:
    m = LogisticRegression(C=np.inf).fit(L, yy)
    t = -m.intercept_[0] / m.coef_[0, 0]
    print(f"{name}: ngưỡng = {t:.2f} link   accuracy test = {accuracy_at(t):.2f}")

Ngưỡng không nhúc nhích, ít nhất đến hai chữ số thập phân. Với email 24 link, mô hình đã cho pp rất gần 11; đúng nhãn và đúng tự tin thì gần như không bị phạt, nên những email này không kéo ranh giới đi đâu cả. Lý do nằm ở hàm mất mát của mục tiếp theo.

Hỏi nhanh

Logistic regression một feature có w=2w = 2, b=−6b = -6. Ranh giới quyết định nằm ở đâu?

Hàm mất mát: cross-entropy

Mô hình gán xác suất pip_i cho nhãn 11 và 1−pi1 - p_i cho nhãn 00. Một cách tự nhiên để chấm điểm là hỏi: mô hình gán bao nhiêu xác suất cho đúng cái nhãn đã xảy ra? Với một mẫu, con số đó là pip_i nếu yi=1y_i = 1 và 1−pi1 - p_i nếu yi=0y_i = 0, viết gọn là piyi(1−pi)1−yip_i^{y_i}(1-p_i)^{1-y_i}. Coi các email độc lập, xác suất cho cả tập dữ liệu là tích của chúng. Chọn tham số làm tích này lớn nhất gọi là maximum likelihood.

Tích của hàng trăm số nhỏ hơn 1 rất khó làm việc, nên ta lấy log để biến tích thành tổng, đổi dấu để thành bài toán cực tiểu, rồi chia cho nn. Kết quả là binary cross-entropy, còn gọi là log loss:

J(w,b)=−1n∑i=1n[ yilog⁡pi+(1−yi)log⁡(1−pi) ].J(\mathbf{w}, b) = -\frac{1}{n}\sum_{i=1}^{n}\Big[\,y_i \log p_i + (1 - y_i)\log(1 - p_i)\,\Big].

Trong mỗi số hạng chỉ một trong hai phần còn lại: với email spam là −log⁡pi-\log p_i, với thư thường là −log⁡(1−pi)-\log(1 - p_i). Nhìn vào một email spam thật: đoán p=0.99p = 0.99 mất 0.010.01; đoán p=0.5p = 0.5 mất 0.690.69; đoán p=0.01p = 0.01 mất 4.64.6. Khi p→0p \to 0, loss tăng vô hạn. Cross-entropy phạt rất nặng sự tự tin sai, và gần như không phạt sự tự tin đúng. Đây chính là lý do 20 email siêu spam không kéo được ranh giới. Với thư thường mà mô hình đoán p=0.9p = 0.9, loss là −log⁡0.1≈2.30-\log 0.1 \approx 2.30. Nhưng mọi email vẫn góp một khoản dương, dù nhỏ, nên ranh giới phụ thuộc vào tất cả các điểm chứ không chỉ những điểm gần nó.

Tại sao không giữ hàm bình phương (p−y)2(p - y)^2 cho quen? Hãy nhìn gradient theo zz. Với một email spam mà mô hình đang đoán p=0.01p = 0.01, tức sai rất nặng:

  • Bình phương: ∂∂z(p−1)2=2(p−1) p(1−p)≈−0.0196\frac{\partial}{\partial z}(p - 1)^2 = 2(p - 1)\,p(1 - p) \approx -0.0196.
  • Cross-entropy: ∂∂z(−log⁡p)=p−1=−0.99\frac{\partial}{\partial z}\big(-\log p\big) = p - 1 = -0.99.

Gradient của hàm bình phương nhỏ hơn 50 lần, đúng ở chỗ mô hình cần sửa nhiều nhất. Thừa số p(1−p)p(1-p) đến từ đạo hàm của sigmoid và gần bằng 0 ở hai đầu, nơi đường cong phẳng. Cross-entropy có log⁡\log triệt tiêu đúng thừa số đó. Thêm nữa, hàm bình phương ghép với sigmoid không còn lồi, còn cross-entropy thì lồi: không có cực tiểu địa phương nào để mắc kẹt.

Spam bị đoán p nhỏ: cross-entropy cho gradient lớn, bình phương gần 0.024600.51p đoán cho email spamloss00.5100.51p đoán cho email spam|gradient theo z|cross-entropybình phươngcross-entropybình phương
Spam bị đoán p nhỏ: cross-entropy cho gradient lớn, bình phương gần 0.

Hỏi nhanh

Với p=σ(z)p = \sigma(z), nếu dùng bình phương sai số thay cho cross-entropy, thì khi mô hình rất tự tin mà sai, chuyện gì xảy ra?

Gradient và tự cài bằng NumPy

Đạo hàm của sigmoid có dạng đẹp: σ′(z)=σ(z)(1−σ(z))=p(1−p)\sigma'(z) = \sigma(z)\big(1 - \sigma(z)\big) = p(1 - p). Với một mẫu, loss là ℓ=−[ylog⁡p+(1−y)log⁡(1−p)]\ell = -\big[y\log p + (1-y)\log(1-p)\big]. Theo quy tắc chuỗi:

∂ℓ∂z=(−yp+1−y1−p)⏟∂ℓ/∂p⋅p(1−p)⏟∂p/∂z=−y(1−p)+(1−y)p=p−y.\frac{\partial \ell}{\partial z} = \underbrace{\left(-\frac{y}{p} + \frac{1-y}{1-p}\right)}_{\partial \ell / \partial p} \cdot \underbrace{p(1-p)}_{\partial p / \partial z} = -y(1-p) + (1-y)p = p - y.

Mọi phân số triệt tiêu, chỉ còn lại “xác suất đoán trừ nhãn thật”. Vì z=w⊤x+bz = \mathbf{w}^\top\mathbf{x} + b, nhân thêm x\mathbf{x} để có đạo hàm theo w\mathbf{w}, rồi lấy trung bình trên cả tập:

∇wJ=1nX⊤(p−y),∂J∂b=1n∑i(pi−yi).\nabla_{\mathbf{w}} J = \frac{1}{n} X^\top(\mathbf{p} - \mathbf{y}), \qquad \frac{\partial J}{\partial b} = \frac{1}{n}\sum_{i}(p_i - y_i).

So với linear regression ở bài 2.1, công thức giống hệt, chỉ thay y^\hat{\mathbf{y}} bằng p\mathbf{p} và không có hệ số 2. Sự trùng hợp này không phải tình cờ: cả hai thuộc cùng một họ mô hình (generalized linear model), và ở đó gradient luôn có dạng “dự đoán trừ thực tế, nhân với đặc trưng”.

Thử với một mẫu x=2x = 2, nhãn y=1y = 1, mô hình w=1w = 1, b=−1b = -1. Khi đó z=1⋅2−1=1z = 1 \cdot 2 - 1 = 1, p=σ(1)=0.731p = \sigma(1) = 0.731, loss =−log⁡0.731=0.313= -\log 0.731 = 0.313. Gradient theo ww là (p−y) x=(0.731−1)⋅2=−0.538(p - y)\,x = (0.731 - 1)\cdot 2 = -0.538. Gradient âm nghĩa là tăng ww sẽ giảm loss: một bước với learning rate 0.50.5 cho w=1+0.5⋅0.538=1.269w = 1 + 0.5 \cdot 0.538 = 1.269, và mô hình tự tin hơn vào nhãn đúng.

Bảng shape cho 300 email train với 2 đặc trưng:

Đại lượng Biểu thức Shape
Đặc trưng X (300,2)(300, 2)
Tham số w, b (2,)(2,), số
Điểm tuyến tính z = X @ w + b (300,)(300,)
Xác suất p = sigmoid(z) (300,)(300,)
Sai lệch p - y (300,)(300,)
Gradient theo w X.T @ (p - y) / n (2,)(2,)

Cài đặt theo đúng cách của bài 2.1, gộp (w,b)(\mathbf{w}, b) vào một vector theta:

Python
def sigmoid(z):
    # sigmoid(z) = exp(-log(1 + exp(-z))), tính ổn định
    return np.exp(-np.logaddexp(0., -z))

def loss(theta, X, y):
    logits = X @ theta[:-1] + theta[-1]
    # cross-entropy tính thẳng từ logits: không tràn số, không cần kẹp
    return np.mean(np.logaddexp(0., logits) - y * logits)

def grad(theta, X, y):
    r = sigmoid(X @ theta[:-1] + theta[-1]) - y       # p − y, shape (n,)
    return np.append(X.T @ r / len(y), r.mean())

Loss được tính thẳng từ logits bằng logaddexp, nên không tràn số và không cần kẹp xác suất. Nếu kẹp xác suất ngay trong loss dùng để học, gradient p−yp - y sẽ không còn khớp ở vùng bị kẹp. Trước khi huấn luyện, kiểm gradient bằng sai phân trung tâm như bài 2.1, bước rẻ nhất để bắt lỗi dấu hoặc lỗi chia nn:

Python
def numerical_grad(f, theta, h=1e-5):
    g = np.zeros_like(theta)
    for j in range(len(theta)):
        e = np.zeros_like(theta)
        e[j] = h
        g[j] = (f(theta + e) - f(theta - e)) / (2 * h)
    return g

def rel_error(a, b):
    return np.linalg.norm(a - b) / (np.linalg.norm(a) + np.linalg.norm(b))

theta0 = np.random.default_rng(1).normal(size=3)
num = numerical_grad(lambda t: loss(t, X_tr, y_tr), theta0)
print(f"sai số tương đối: {rel_error(grad(theta0, X_tr, y_tr), num):.1e}")

Sai số cỡ 10−1210^{-12}: gradient đúng. Giờ chạy gradient descent. Hai đặc trưng đều là số đếm nhỏ, cùng thang đo, nên ở đây không cần chuẩn hóa.

Python
theta = np.zeros(3)
history = []
for epoch in range(5000):
    history.append(loss(theta, X_tr, y_tr))
    theta -= 0.5 * grad(theta, X_tr, y_tr)

print("w =", theta[:2].round(3), "  b =", theta[2].round(3))
print("loss ban đầu:", round(history[0], 4), "  sau 5000 vòng:", round(history[-1], 4))

Loss ban đầu là 0.6931=log⁡20.6931 = \log 2, không phải ngẫu nhiên: với theta bằng 0, mọi email có p=0.5p = 0.5 và mỗi email mất −log⁡0.5-\log 0.5. Đây là một mốc nên nhớ để kiểm code: bài hai lớp khởi tạo bằng 0 thì loss ban đầu phải là log⁡2\log 2 (dùng class weight thì mốc này đổi).

Gradient descent của bài trên 300 email: ranh giới p = 0.5 (nét liền) và hai mức 0.1, 0.9 (nét đứt) dịch dần về chỗ tách spam.

Tham số học được nằm gần quy luật thật (1.1, 0.9, −4.5)(1.1,\ 0.9,\ -4.5); phần lệch là do chỉ có 300 email. Đối chiếu với scikit-learn:

Python
model = LogisticRegression(C=np.inf).fit(X_tr, y_tr)
print("scikit-learn: w =", model.coef_[0].round(3), "  b =", model.intercept_.round(3))
print("trùng gradient descent:", np.allclose(theta, np.append(model.coef_[0], model.intercept_), atol=1e-3))

Hai cách cho cùng một nghiệm. scikit-learn không dùng gradient descent mà dùng L-BFGS, một phương pháp dùng thêm thông tin độ cong nên hội tụ trong vài chục bước thay vì vài nghìn. Khác với linear regression, ở đây không có nghiệm đóng: đặt gradient bằng 0 cho một hệ phương trình chứa sigmoid, không giải ra được bằng biến đổi đại số.

Hỏi nhanh

Khởi tạo mọi tham số bằng 0 cho một bài phân loại hai lớp. Cross-entropy lúc đầu bằng bao nhiêu?

Đọc hệ số qua odds

Hệ số của logistic regression không đọc được như linear regression, kiểu “thêm một link thì xác suất tăng bao nhiêu”. Cách đọc đúng là qua odds, như mục trước đã nói.

Python
w = model.coef_[0]
print("thêm 1 link: odds nhân", np.exp(w[0]).round(2), "  thêm 1 từ quảng cáo: odds nhân", np.exp(w[1]).round(2))

emails = np.array([[1, 1], [2, 1], [7, 1], [8, 1]])     # (số link, số từ quảng cáo)
print("P(spam):", model.predict_proba(emails)[:, 1].round(3))

Mỗi link thêm vào nhân odds spam lên khoảng 3 lần, bất kể email đang ở đâu. Nhưng xác suất thì không tăng đều. Đi từ 1 lên 2 link, xác suất tăng khoảng 0.11; đi từ 7 lên 8 link, khi email đã gần như chắc là spam, xác suất chỉ nhích thêm khoảng 0.01 vì không thể vượt quá 1. Odds nhân đều, xác suất bão hòa. Muốn so độ quan trọng giữa các đặc trưng có đơn vị khác nhau thì chuẩn hóa trước, giống bài 2.2.

Mỗi link nhân odds lên 3 lần, nhưng xác suất tăng chậm dần khi gần 1.00.51012345678910số link (1 từ quảng cáo)+0.11+0.01mỗi link: odds × 3.08
Mỗi link nhân odds lên 3 lần, nhưng xác suất tăng chậm dần khi gần 1.

Hỏi nhanh

Một email đang có p=0.5p = 0.5. Hệ số của “số link” là w=ln⁡3w = \ln 3. Thêm một link thì pp thành bao nhiêu?

Ngưỡng quyết định

Ngưỡng 0.50.5 hợp lý khi hai loại sai có giá như nhau. Với bộ lọc thư rác thì không: để lọt một email spam chỉ hơi phiền, còn chặn nhầm một email thật (thư mời nhập học, kết quả thi) có thể rất đắt. Xem ngưỡng thay đổi điều gì trên 100 email test:

Python
p_te = model.predict_proba(X_te)[:, 1]
print("predict trùng p > 0.5:", np.array_equal(model.predict(X_te), (p_te > 0.5).astype(float)))
for t in [0.3, 0.5, 0.9]:
    block = p_te >= t
    print(f"ngưỡng {t}: chặn đúng {np.sum(block & (y_te == 1)):>2} spam, "
          f"chặn nhầm {np.sum(block & (y_te == 0)):>2} thư thật, lọt {np.sum(~block & (y_te == 1)):>2} spam")
print("accuracy:", model.score(X_te, y_te), "  luôn đoán 'không spam':", np.mean(y_te == 0))

Dòng đầu xác nhận predict chính là cắt p>0.5p > 0.5 (đúng bằng 0.50.5 thì scikit-learn chọn lớp 0). Hạ ngưỡng thì chặn được nhiều spam hơn nhưng chặn nhầm nhiều thư thật hơn; nâng ngưỡng thì ngược lại. Không có ngưỡng nào tốt nhất cho mọi bài: ngưỡng là quyết định nghiệp vụ, chọn theo cái giá của từng loại sai. Mô hình chỉ cung cấp xác suất. Bài 1.5 có các thước đo cho đúng sự đánh đổi này (precision, recall, đường ROC).

Dời ngưỡng là đổi số thư thật bị chặn nhầm lấy số spam lọt qua.00.250.50.751p dự đoánspamthư thật0.30.50.9
Dời ngưỡng là đổi số thư thật bị chặn nhầm lấy số spam lọt qua.

Dòng cuối là một lời nhắc khác. Một “mô hình” luôn đoán “không spam” đã được accuracy khoảng 0.670.67, vì hai phần ba số email là thư thường. Accuracy của mô hình thật phải được so với mốc đó, không phải với 00. Dữ liệu càng lệch thì mốc này càng cao: nếu chỉ 1% email là spam, đoán bừa “không spam” đã có accuracy 0.990.99.

Khi đề bài chấm bằng log loss, cái cần nộp là xác suất. Mốc tương ứng là đoán cho mọi email cùng một xác suất bằng tỉ lệ spam của tập train:

Python
from sklearn.metrics import log_loss

base = np.full(len(y_te), y_tr.mean())
print("log loss mốc:", round(log_loss(y_te, base), 3), "  mô hình:", round(log_loss(y_te, p_te), 3))

Hỏi nhanh

Với bộ lọc thư rác, chặn nhầm một thư thật tốn kém hơn nhiều so với để lọt một thư spam. Ta nên chỉnh ngưỡng thế nào?

Nhiều lớp: softmax regression

Nhận dạng chữ số viết tay có 10 lớp, từ 0 đến 9. Mở rộng tự nhiên: mỗi lớp kk có vector trọng số wk\mathbf{w}_k và bias bkb_k riêng, cho một điểm zk=wk⊤x+bkz_k = \mathbf{w}_k^\top\mathbf{x} + b_k (gọi chung là logits). Hàm softmax biến KK điểm thành một phân phối xác suất:

pk=ezk∑j=1Kezj.p_k = \frac{e^{z_k}}{\sum_{j=1}^{K} e^{z_j}}.

Mọi pkp_k dương và cộng lại bằng 1. Lớp có điểm cao nhất có xác suất cao nhất, nhưng các lớp khác không bị đẩy về đúng 0, nên “max” mềm hơn, đúng như tên gọi. Với K=2K = 2, p1=11+e−(z1−z2)=σ(z1−z2)p_1 = \frac{1}{1 + e^{-(z_1 - z_2)}} = \sigma(z_1 - z_2): softmax hai lớp chính là logistic regression trên hiệu hai điểm. Softmax luôn chia hết xác suất cho các lớp đã biết: đưa ảnh một chiếc ô tô vào mô hình chó, mèo, chim thì nó vẫn chọn một trong ba, có khi rất chắc.

Hàm mất mát vẫn là cross-entropy: ℓ=−log⁡py\ell = -\log p_{y}, trừ log xác suất của lớp đúng. Gradient theo logits cũng giữ dạng quen thuộc:

∂ℓ∂z=p−ey,\frac{\partial \ell}{\partial \mathbf{z}} = \mathbf{p} - \mathbf{e}_y,

trong đó ey\mathbf{e}_y là vector one-hot của nhãn thật. Mô hình đẩy xác suất của lớp đúng lên và của các lớp khác xuống, đúng bằng lượng đang chênh. Mỗi thành phần của p−ey\mathbf{p} - \mathbf{e}_y nằm trong [−1,1][-1, 1]: âm ở lớp đúng, dương ở các lớp khác.

Đại lượng Biểu thức Shape (1300 ảnh, 64 điểm ảnh, 10 lớp)
Ảnh X (1300,64)(1300, 64)
Trọng số W, b (64,10)(64, 10), (10,)(10,)
Logits Z = X @ W + b (1300,10)(1300, 10)
Xác suất P = softmax(Z) (1300,10)(1300, 10), mỗi hàng cộng bằng 1
Nhãn one-hot Y (1300,10)(1300, 10)
Gradient theo W X.T @ (P - Y) / n (64,10)(64, 10)

Cài softmax có một cái bẫy. e1000e^{1000} vượt quá số lớn nhất mà kiểu float64 biểu diễn được, nên thành inf, và inf / inf là nan. Cách chữa dựa trên một nhận xét: cộng cùng một hằng số cc vào mọi zkz_k không đổi softmax, vì ece^{c} xuất hiện ở cả tử lẫn mẫu và triệt tiêu. Chọn c=−max⁡kzkc = -\max_k z_k thì số mũ lớn nhất là 00, không bao giờ tràn. Cùng lý do, nhân mọi logit với một số dương giữ nguyên thứ tự nên giữ nguyên lớp dự đoán, chỉ làm phân phối nhọn hơn hay bẹt hơn.

Python
def softmax_naive(z):
    e = np.exp(z)
    return e / e.sum()

def softmax(Z):
    Z = Z - Z.max(axis=-1, keepdims=True)             # trừ max từng hàng, kết quả không đổi
    E = np.exp(Z)
    return E / E.sum(axis=-1, keepdims=True)

z = np.array([2.0, 1.0, 0.0])
print(softmax(z).round(3))
print(softmax_naive(z + 1000), softmax(z + 1000).round(3))

Dòng đầu tính tay được: trừ max được (0,−1,−2)(0, -1, -2), lấy mũ được (1, 0.368, 0.135)(1,\ 0.368,\ 0.135), tổng là 1.5031.503, chia ra được (0.665, 0.245, 0.090)(0.665,\ 0.245,\ 0.090). Lớp đầu hơn lớp thứ hai 1 đơn vị logit, và xác suất gấp e≈2.7e \approx 2.7 lần. Nếu nhãn đúng là lớp thứ hai thì loss là −log⁡0.245=1.41-\log 0.245 = 1.41, và gradient theo logits là p−e2=(0.665, −0.755, 0.090)\mathbf{p} - \mathbf{e}_2 = (0.665,\ -0.755,\ 0.090): kéo lớp đầu xuống, đẩy lớp thứ hai lên.

Softmax của (2, 1, 0): trừ max, lấy mũ, chia cho tổng.z210e^(z − max)10.3680.135p0.6650.2450.090exp÷ 1.503tổng = 1
Softmax của (2, 1, 0): trừ max, lấy mũ, chia cho tổng.

Khi cần log⁡pk\log p_k (để tính loss) thì đừng tính softmax rồi mới lấy log, vì pkp_k rất nhỏ có thể làm tròn thành 0. Dùng thẳng log-sum-exp với cùng mẹo trừ max:

log⁡pk=zk−m−log⁡∑jezj−m,m=max⁡jzj.\log p_k = z_k - m - \log\sum_j e^{z_j - m}, \qquad m = \max_j z_j.

Thử trên bộ chữ số viết tay của scikit-learn: 1797 ảnh 8×88 \times 8, mỗi điểm ảnh có giá trị từ 0 đến 16.

Python
from sklearn.datasets import load_digits

D, t = load_digits(return_X_y=True)
D = D / 16                                            # điểm ảnh về [0, 1]
idx = np.random.default_rng(0).permutation(len(t))
D, t = D[idx], t[idx]
D_tr, D_te, t_tr, t_te = D[:1300], D[1300:], t[:1300], t[1300:]
print(D_tr.shape, D_te.shape)

Huấn luyện bằng gradient descent. Để so được với LogisticRegression() mặc định (C=1), ta thêm đúng khoản phạt L2 mà nó dùng: hàm mất mát của scikit-learn là C∑iℓi+12∥W∥2C\sum_i \ell_i + \tfrac12\lVert W\rVert^2, chia cho CnCn thì thành loss trung bình cộng 12Cn∥W∥2\tfrac{1}{2Cn}\lVert W\rVert^2.

Python
n, K = len(t_tr), 10
Y = np.eye(K)[t_tr]                                   # one-hot, (1300, 10)
lam = 1 / (1.0 * n)                                   # khớp C = 1
W, b = np.zeros((D_tr.shape[1], K)), np.zeros(K)
for epoch in range(5000):
    G = (softmax(D_tr @ W + b) - Y) / n               # (1300, 10)
    W -= 2.0 * (D_tr.T @ G + lam * W)
    b -= 2.0 * G.sum(axis=0)

pred = np.argmax(D_te @ W + b, axis=1)
print("tự cài:       accuracy test =", round(np.mean(pred == t_te), 3))

sk = LogisticRegression(C=1.0, max_iter=1000).fit(D_tr, t_tr)
print("scikit-learn: accuracy test =", round(sk.score(D_te, t_te), 3))
print("nhãn trùng nhau:", np.mean(sk.predict(D_te) == pred),
      "  xác suất lệch lớn nhất:", np.abs(sk.predict_proba(D_te) - softmax(D_te @ W + b)).max().round(3))

Một mô hình tuyến tính đơn giản đã nhận đúng khoảng 98% chữ số. Hai cài đặt cho cùng nhãn trên mọi ảnh test; xác suất còn lệch nhau một chút vì gradient descent sau 5000 vòng vẫn chưa hội tụ hẳn, trong khi L-BFGS của scikit-learn đã tới nơi.

Mỗi cột của W có 64 số, xếp lại thành ảnh 8×88 \times 8 thì đọc được mô hình đang “nhìn” gì. Điểm ảnh có trọng số dương là nơi nét mực làm tăng điểm của lớp đó, trọng số âm là nơi nét mực làm giảm điểm. Hình dưới không giống những chữ số mẫu: mô hình không học “chữ 0 trông thế nào” mà học “chỗ nào có mực thì phân biệt được 0 với chín lớp còn lại”.

Trên: ảnh trung bình mỗi chữ số. Dưới: 64 trọng số của lớp đó.trung bìnhtrọng số0123456789xanh: nét mực ở đây tăng điểm lớp · cam: giảm điểm
Trên: ảnh trung bình mỗi chữ số. Dưới: 64 trọng số của lớp đó.

Hỏi nhanh

Cộng thêm 100100 vào mọi logit z1,…,zKz_1, \dots, z_K. Xác suất softmax thay đổi thế nào?

Khi đi thi

  • Đề chấm log loss thì nộp predict_proba, kiểm đúng thứ tự cột lớp.
  • Đề chấm F1 hay có lớp hiếm thì chọn ngưỡng trên validation, đừng để mặc định 0.5.
  • Tự cài loss thì tính từ logits bằng logaddexp hoặc mẹo trừ max, không lấy log của softmax.

Tóm tắt

  • Linear regression trên nhãn 0/1 cho “xác suất” ngoài [0,1][0, 1] và bị những điểm dễ phân loại kéo lệch ranh giới.
  • Logistic regression là mô hình tuyến tính cho log-odds: p=σ(w⊤x+b)p = \sigma(\mathbf{w}^\top\mathbf{x} + b). Mỗi đơn vị của xjx_j nhân odds lên ewje^{w_j}. Ranh giới quyết định vẫn là đường thẳng.
  • Học bằng cực tiểu cross-entropy, sinh ra từ maximum likelihood. Hàm lồi, gradient 1nX⊤(p−y)\frac1n X^\top(\mathbf{p} - \mathbf{y}) cùng dạng với linear regression; không có nghiệm đóng.
  • Ngưỡng 0.50.5 chỉ là mặc định; chọn ngưỡng theo cái giá của từng loại sai. So accuracy với mốc đoán lớp đông nhất.
  • Softmax mở rộng ra KK lớp với gradient p−ey\mathbf{p} - \mathbf{e}_y; khi cài phải trừ max và kẹp xác suất.

Kiểm tra cuối bài

Mười câu dưới đây bao quát cả bài. Làm hết rồi xem điểm; câu nào sai thì đọc lại mục tương ứng.

Câu 1

Bài phân loại hai lớp có ba feature: tuổi từ 18 đến 80, thu nhập từ 20 đến 500 triệu, và điểm tín dụng từ 300 đến 850. Logistic regression không chuẩn hóa đạt accuracy 72%, thêm StandardScaler thì đạt 84%. Vì sao?

Đề thiNOAI 2026 Singapore · Vòng sơ loại · Câu 305

Câu 2

Hệ số của một feature là wj=0.7w_j = 0.7. Tăng feature ấy thêm 1 đơn vị, giữ nguyên các feature khác. Điều gì thay đổi?

Câu 3

Một email spam thật, y=1y = 1, được mô hình đoán p=0.1p = 0.1. Cross-entropy của mẫu này bằng bao nhiêu?

Câu 4

Mô hình lọc spam cho ra logit zz, qua sigmoid thành pp. Ta nên huấn luyện bằng loss nào, và vì sao?

Đề thiNOAI 2026 Singapore · Vòng sơ loại · Câu 273

Câu 5

Logistic regression hai lớp dùng trực tiếp hai feature x1x_1, x2x_2, vector trọng số khác 0, ngưỡng 0.5. Trong mặt phẳng feature, ranh giới quyết định có dạng gì?

Câu 6

Softmax hai lớp với logit (3,1)(3, 1). Xác suất của lớp thứ nhất bằng bao nhiêu?

Câu 7

np.exp(np.array([1000., 1001.])) cho ra [inf inf], khiến softmax thành nan. Sửa thế nào cho đúng?

Câu 8

Validation có 1000 email, chỉ 50 email là spam. Mô hình A đoán tất cả là không spam. Mô hình B có TP = 30, FP = 40, FN = 20, TN = 910. Nếu chọn theo F1 của lớp spam, mô hình nào thắng?

Phỏng theoUSA-NA-AIO 2026 · Vòng 1 · Part 1.4

Câu 9

Bài hai lớp yêu cầu nộp xác suất của lớp spam để chấm log loss. model.classes_ là ["spam", "valid"]. Trong predict_proba(X_test), ta lấy cột nào?

Câu 10

Feature nhị phân x1x_1 rất hiếm: trong train, email nào có x1=1x_1 = 1 cũng là spam. Logistic regression không có regularization. Trọng số tối ưu w^1\hat w_1 sẽ ra sao?

Phỏng theoCMU 10-601 · Exam 2 Practice · Q2.3(c)

Đọc thêm

  • James, Witten, Hastie, Tibshirani — An Introduction to Statistical Learning, chương 4. Logistic regression, cách đọc odds và so sánh với các bộ phân loại khác, có lab Python.
  • Jurafsky, Martin — Speech and Language Processing, chương Logistic Regression. Trình bày từ bài toán phân loại văn bản, suy gradient từng bước, rất hợp để đọc sau bài này.
  • Ng, Ma — CS229 Lecture Notes, phần Classification và Generalized Linear Models. Giải thích vì sao gradient của linear và logistic regression có cùng dạng.
  • Zhang và cộng sự — Dive into Deep Learning, mục Softmax Regression. Softmax nhìn từ phía mạng neural, có phần log-sum-exp; cầu nối sang chương 3.
  • Murphy — Probabilistic Machine Learning: An Introduction, chương 10. Logistic regression đầy đủ: maximum likelihood, phạt L2, các thuật toán tối ưu bậc hai.
  • Tài liệu chính thức: scikit-learn — Logistic regression, các tham số C, class_weight và lựa chọn solver.