Danh sách bài viết

Bài 25: F1-Score và trade-off Precision/Recall

F1-Score là harmonic mean của precision và recall: công thức, vì sao harmonic mean phạt extreme, F-beta tổng quát (F0.5, F2), multi-class aggregate (macro / micro / weighted / per-class), Precision-Recall curve, và cách quét threshold tối ưu F1 bằng sklearn.

24/05/2026
13 phút đọc
0 lượt xem
1

Recap — vì sao cần gộp precision và recall

Bài 24 đã định nghĩa precision và recall:

\[ \text{Precision} = \frac{TP}{TP + FP}, \qquad \text{Recall} = \frac{TP}{TP + FN} \]

Hai metric này thường trade-off: đẩy threshold lên thì precision tăng nhưng recall giảm, và ngược lại. Khi cần so sánh nhiều model hoặc tune hyperparameter trong GridSearchCV, một con số duy nhất tiện hơn 2 con số phải cân nhắc đồng thời.

F1-Score là cách gộp precision và recall thành một số duy nhất với một ràng buộc quan trọng: cả hai phải đủ tốt, không cho phép một bên cao bù cho bên kia cực thấp.

2

F1-Score — định nghĩa và công thức

F1-Score là harmonic mean của precision và recall:

\[ F_1 = \frac{2 \cdot \text{Precision} \cdot \text{Recall}}{\text{Precision} + \text{Recall}} = \frac{2 \, TP}{2 \, TP + FP + FN} \]

Dạng phải (qua confusion matrix) tiện khi tính nhanh từ counts mà không qua precision/recall trung gian.

Tính chất:

  • Range \( [0, 1] \). Càng cao càng tốt.
  • \( F_1 = 1 \) khi và chỉ khi precision = recall = 1 (perfect classification, không có FP và không có FN).
  • \( F_1 = 0 \) khi precision = 0 hoặc recall = 0 (nghĩa là \( TP = 0 \)).
  • Đối xứng giữa precision và recall — đổi vai hai bên không thay đổi giá trị F1.
3

Vì sao là harmonic mean, không phải arithmetic mean

Có 3 loại mean phổ biến (cho \( a, b > 0 \)):

\[ \text{AM} = \frac{a + b}{2}, \quad \text{GM} = \sqrt{a \, b}, \quad \text{HM} = \frac{2 \, a \, b}{a + b} \]

Bất đẳng thức quen thuộc: \( \text{HM} \leq \text{GM} \leq \text{AM} \), với dấu bằng chỉ khi \( a = b \). Harmonic mean luôn nhỏ nhất, và đặc biệt nhạy với giá trị nhỏ.

Ví dụ cụ thể — model A có \( P = 1.0, R = 0 \) (predict 1 sample đúng, bỏ qua tất cả positive còn lại):

  • Arithmetic mean: \( (1.0 + 0) / 2 = 0.5 \) — gợi ý model trung bình, sai bản chất vì model thực ra không có giá trị.
  • Harmonic mean (F1): \( 2 \cdot 1.0 \cdot 0 / (1.0 + 0) = 0 \) — phản ánh đúng "model fail".

Tổng quát: harmonic mean phạt extreme. Một bên cực thấp kéo F1 về gần 0, không cho phép bên còn lại bù.

Bảng so sánh — cùng arithmetic mean \( = 0.5 \) nhưng F1 khác nhau:

  • \( P = 0.5, R = 0.5 \Rightarrow F_1 = 0.50 \) (balance).
  • \( P = 0.7, R = 0.3 \Rightarrow F_1 = 0.42 \) (hơi lệch).
  • \( P = 0.9, R = 0.1 \Rightarrow F_1 \approx 0.18 \) (lệch nặng).
  • \( P = 0.99, R = 0.01 \Rightarrow F_1 \approx 0.02 \) (extreme).

Đây là lý do F1 phù hợp với bài toán mà cả hai loại sai (FP và FN) đều phải kiểm soát: model chỉ được công nhận khi cân bằng được hai bên.

4

F-beta Score — tổng quát hoá F1

Khi bài toán ưu tiên một bên (precision hoặc recall) hơn bên kia, dùng F-beta:

\[ F_\beta = (1 + \beta^2) \cdot \frac{\text{Precision} \cdot \text{Recall}}{\beta^2 \cdot \text{Precision} + \text{Recall}} \]

Tham số \( \beta > 0 \) điều chỉnh trọng số:

  • \( \beta = 1 \) — chính là F1, cân bằng hai bên.
  • \( \beta < 1 \) (vd \( F_{0.5} \)) — ưu tiên precision. Recall bị giảm trọng số, sai FP đắt hơn sai FN.
  • \( \beta > 1 \) (vd \( F_2, F_3 \)) — ưu tiên recall. Sai FN (bỏ sót positive) đắt hơn sai FP.

Cách nhớ ý nghĩa \( \beta \): "recall quan trọng gấp \( \beta \) lần precision" trong nghĩa weighted harmonic mean. \( \beta = 2 \) → recall quan trọng gấp 2; \( \beta = 0.5 \) → precision quan trọng gấp 2.

Use case điển hình:

  • \( F_2 \) — medical screening: bỏ sót bệnh (FN) tốn kém hơn báo nhầm (FP, có thể test lại). Recall quan trọng hơn.
  • \( F_{0.5} \) — spam filter / fraud alert: report FP làm phiền user / chặn email thật quan trọng hơn lọt vài spam. Precision quan trọng hơn.
5

F1 và F-beta trong sklearn

from sklearn.metrics import f1_score, fbeta_score

y_true = [0, 1, 1, 0, 1, 1, 0, 1]
y_pred = [0, 1, 0, 0, 1, 1, 1, 1]

f1   = f1_score(y_true, y_pred)               # beta = 1
f2   = fbeta_score(y_true, y_pred, beta=2)    # ưu tiên recall
f05  = fbeta_score(y_true, y_pred, beta=0.5)  # ưu tiên precision

print(f"F1   = {f1:.4f}")    # 0.8000
print(f"F2   = {f2:.4f}")    # 0.8065
print(f"F0.5 = {f05:.4f}")   # 0.7937

Tham số average mặc định là "binary" — chỉ tính F1 cho class positive (label 1 mặc định). Với multi-class hoặc multi-label phải truyền average rõ ràng (mục 6).

Khi precision + recall = 0 (tức không có TP), sklearn raise UndefinedMetricWarning và trả về 0. Có thể tuỳ chỉnh bằng zero_division=0 hoặc 1 tuỳ ngữ cảnh.

6

Multi-class F1 — macro, micro, weighted, per-class

F1 vốn định nghĩa cho binary. Với multi-class, sklearn tính F1 từng class (one-vs-rest) rồi gộp lại theo một trong các kiểu sau:

  • Macro — trung bình cộng F1 của các class, không weight: \[ F_1^{\text{macro}} = \frac{1}{K} \sum_{k=1}^K F_1^{(k)} \] Mọi class đều quan trọng như nhau. Nhạy với class hiếm — class hiếm performance kém kéo macro xuống.
  • Micro — gộp TP, FP, FN của tất cả class trước, rồi tính F1 một lần: \[ F_1^{\text{micro}} = \frac{2 \, \sum_k TP_k}{2 \, \sum_k TP_k + \sum_k FP_k + \sum_k FN_k} \] Với multi-class single-label (mỗi sample 1 label), \( F_1^{\text{micro}} = \) accuracy. Class lớn chiếm trọng số nhiều.
  • Weighted — trung bình có trọng số theo support (số sample mỗi class): \[ F_1^{\text{weighted}} = \frac{1}{N} \sum_{k=1}^K n_k \cdot F_1^{(k)} \] Reflect tổng thể nhưng vẫn cho phép class hiếm kéo metric xuống một phần.
  • Per-class — trả về vector \( K \) phần tử, F1 của từng class. Quan trọng nhất khi report, không bị che dấu bởi aggregate.
from sklearn.metrics import f1_score, classification_report

f1_macro    = f1_score(y_true, y_pred, average="macro")
f1_micro    = f1_score(y_true, y_pred, average="micro")
f1_weighted = f1_score(y_true, y_pred, average="weighted")
f1_per_cls  = f1_score(y_true, y_pred, average=None)   # array per-class

# Báo cáo gọn nhất:
print(classification_report(y_true, y_pred, digits=4))

classification_report in cả precision, recall, F1, support cho từng class và 3 aggregate (macro, weighted, accuracy) — quy chuẩn nên dùng trong notebook khi đánh giá baseline.

7

Khi nào dùng macro vs weighted vs micro

  • Macro: data imbalanced + muốn mọi class đều phải tốt. Vd phân loại bệnh hiếm — không thể để class hiếm có F1 = 0.1 chỉ vì class phổ biến có F1 = 0.95.
  • Weighted: imbalanced + muốn số đo phản ánh tổng thể trải nghiệm user. Class nhiều sample tự nhiên ảnh hưởng nhiều hơn.
  • Micro: với multi-class single-label, không thêm thông tin so với accuracy — không nên dùng để chẩn đoán imbalance. Với multi-label thì micro lại có nghĩa riêng (gộp toàn bộ TP/FP/FN trên tất cả label).

Quy tắc kinh nghiệm: với imbalanced data, luôn xem per-class F1 trước, rồi mới chọn macro hoặc weighted để báo cáo aggregate. Đừng chỉ in một con số micro/accuracy và kết luận model "tốt".

Ví dụ minh hoạ chênh lệch — dataset 3 class với phân bố 900/90/10 sample, model dự đoán đúng class lớn nhưng tệ với class hiếm:

  • F1 per-class: [0.97, 0.60, 0.10].
  • F1 macro = (0.97 + 0.60 + 0.10) / 3 = 0.557.
  • F1 weighted ≈ (900·0.97 + 90·0.60 + 10·0.10) / 1000 = 0.928.
  • Hai con số chênh nhau gần 0.4 — tuỳ business mà chọn cách report.
8

Precision-Recall Curve và Average Precision

F1 phụ thuộc threshold — model classifier như Logistic Regression cho ra probability, threshold mặc định 0.5 chỉ là một lựa chọn. Precision-Recall (PR) Curve visualize toàn bộ trade-off khi quét threshold từ 0 đến 1:

  • Trục X: recall, trục Y: precision.
  • Mỗi điểm trên đường cong = (recall, precision) ở một threshold.
  • Threshold cao → ít prediction positive → precision cao, recall thấp (góc trái trên).
  • Threshold thấp → nhiều prediction positive → recall cao, precision thấp (góc phải dưới).
  • Model tốt: đường cong nằm sát góc phải-trên (cả precision và recall đều cao).
from sklearn.metrics import precision_recall_curve, average_precision_score

# y_score: probability của class positive, lấy từ predict_proba(X)[:, 1]
precision, recall, thresholds = precision_recall_curve(y_true, y_score)

# Mảng precision, recall có n+1 phần tử; thresholds có n phần tử
# (điểm cuối là precision=1, recall=0 cho threshold = +inf — không có threshold tương ứng)

ap = average_precision_score(y_true, y_score)
print(f"Average Precision = {ap:.4f}")

Average Precision (AP) là diện tích dưới PR curve — tổng kết toàn bộ curve thành một con số, threshold-independent. AP cao = PR curve sát góc phải-trên.

AP là alternative của ROC-AUC dành cho imbalanced data: PR curve không bị "kéo đẹp" bởi True Negative nhiều, trong khi ROC bị (Bài 26 sẽ phân tích).

9

F1 vs ROC-AUC — chọn metric nào

Bài 26 sẽ trình bày kỹ ROC-AUC. Tóm lược tiêu chí chọn:

  • F1 (hoặc F-beta): cần một con số ở threshold cố định (vd 0.5). Khi deploy production và đã chốt threshold, F1 phản ánh đúng performance thật.
  • ROC-AUC: cần metric threshold-independent — đánh giá khả năng rank positive trên negative của model. Tốt để so sánh model trước khi chốt threshold.
  • Imbalanced data: ưu tiên F1 (hoặc Average Precision) hơn ROC-AUC. ROC-AUC bị "kéo đẹp" do nhiều True Negative — model dở vẫn ra AUC cao. PR curve / F1 không có hiệu ứng đó vì không dùng TN.

Quy tắc thực tiễn: với fraud detection, hiếm bệnh, anomaly — báo cáo PR-AUC và F1 thay vì ROC-AUC.

10

Tối ưu threshold để maximize F1

Threshold mặc định 0.5 không phải lúc nào cũng tối ưu — đặc biệt khi data imbalanced. Quy trình tìm threshold maximize F1:

  1. Lấy y_score = model.predict_proba(X_val)[:, 1].
  2. Gọi precision_recall_curve để được mảng precision, recall, thresholds.
  3. Tính F1 cho mỗi cặp (precision, recall).
  4. Chọn threshold tương ứng với F1 lớn nhất.
import numpy as np
from sklearn.metrics import precision_recall_curve

precision, recall, thresholds = precision_recall_curve(y_val, y_score)

# Bỏ phần tử cuối (precision=1, recall=0 — không có threshold tương ứng)
p, r = precision[:-1], recall[:-1]

# Tính F1 từng điểm; tránh chia 0
f1 = np.where((p + r) > 0, 2 * p * r / (p + r), 0)

best_idx = f1.argmax()
best_threshold = thresholds[best_idx]
best_f1 = f1[best_idx]

print(f"Best threshold = {best_threshold:.4f}, F1 = {best_f1:.4f}")

# Áp dụng threshold mới khi predict
y_pred_tuned = (model.predict_proba(X_test)[:, 1] >= best_threshold).astype(int)

Hai lưu ý:

  • Tune threshold trên validation set, không trên test set — nếu không sẽ leak. Test set chỉ để báo cáo cuối.
  • Threshold tối ưu F1 không nhất thiết là tối ưu business — F1 cân bằng FP và FN, business có thể đặt cost khác nhau. Khi cost rõ ràng, dùng F-beta hoặc tính trực tiếp expected cost cho mỗi threshold.
11

Cảnh báo phổ biến với F1

  • F1 không "fix" class imbalance hoàn toàn. Với binary, F1 mặc định chỉ tính cho class positive (label 1) — vẫn cần xem support, accuracy, và F1 của class negative riêng nếu cần.
  • F1 macro và weighted có thể chênh nhiều với imbalanced data (xem ví dụ mục 7). Báo cáo cả hai, đừng chỉ chọn con số đẹp hơn.
  • F1 không phân biệt FP và FN. Nếu bài toán có cost không đối xứng (vd FN đắt gấp 10 FP), F1 không phản ánh — phải dùng F-beta phù hợp, hoặc tính expected cost trực tiếp.
  • F1 không phải là probability. F1 = 0.85 không nghĩa "model đúng 85%". Để truyền đạt cho non-technical stakeholder, nên kèm precision, recall, và ví dụ confusion matrix.
  • Đừng tối ưu F1 mà bỏ qua context business. F1 là proxy — một model F1 = 0.78 có thể tốt hơn model F1 = 0.82 nếu nó đúng ở các case quan trọng hơn (vd loại bệnh nguy hiểm hơn).
12

Industry-specific — chọn beta theo domain

  • Information Retrieval / Search: F1 standard. Cân bằng giữa lấy đúng (precision) và lấy đủ (recall).
  • Medical screening (cancer, sepsis): F2 hoặc cao hơn. Bỏ sót bệnh = hậu quả nghiêm trọng, false positive chỉ tốn thêm xét nghiệm xác minh.
  • Email spam filter / SMS filter: F0.5. Spam lọt vào inbox phiền nhưng có thể xoá; email thật bị filter mất là thiệt hại lớn hơn.
  • Fraud / payment risk: F1 hoặc F0.5 tuỳ chính sách. Block giao dịch thật quá nhiều mất khách; bỏ sót gian lận mất tiền.
  • NLP — NER, intent classification, NLU: F1 micro hoặc macro tuỳ task. Báo cáo conference NLP gần như mặc định F1.
  • Object detection: dùng mAP (mean Average Precision) — biến thể của AP cho multi-class với bounding box; F1 không trực tiếp áp dụng được.

Quy tắc: trước khi chốt metric, viết ra cost (hoặc impact) của FP và FN trong domain — chọn beta để phản ánh tỉ lệ cost đó.

13

Code Python — breast cancer và iris

Phần 1 — Breast cancer (binary): train Logistic Regression, compute F1, F2, F0.5.

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score, fbeta_score, classification_report

X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test  = scaler.transform(X_test)

model = LogisticRegression(max_iter=2000).fit(X_train, y_train)
y_pred = model.predict(X_test)

f1   = f1_score(y_test, y_pred)
f2   = fbeta_score(y_test, y_pred, beta=2)
f05  = fbeta_score(y_test, y_pred, beta=0.5)
print(f"F1   = {f1:.4f}")    # ~ 0.9846
print(f"F2   = {f2:.4f}")    # ~ 0.9846  (precision ~= recall nên gần nhau)
print(f"F0.5 = {f05:.4f}")   # ~ 0.9846
print(classification_report(y_test, y_pred, digits=4))

Phần 2 — Iris (multi-class): F1 macro, weighted, per-class.

from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score

X, y = load_iris(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=0, stratify=y
)

model = LogisticRegression(max_iter=2000).fit(X_train, y_train)
y_pred = model.predict(X_test)

print(f"F1 macro    = {f1_score(y_test, y_pred, average='macro'):.4f}")
print(f"F1 weighted = {f1_score(y_test, y_pred, average='weighted'):.4f}")
print(f"F1 micro    = {f1_score(y_test, y_pred, average='micro'):.4f}")
print(f"F1 per-class = {f1_score(y_test, y_pred, average=None)}")
# F1 macro    ~ 0.9778
# F1 weighted ~ 0.9778
# F1 micro    ~ 0.9778   (iris balanced -> macro = weighted = micro)
# F1 per-class ~ [1.0, 0.9474, 0.9863]

Iris balanced (50/50/50 sample mỗi class) nên 3 aggregate trùng nhau — đây là tình huống lý tưởng, không phản ánh được khi nào chọn macro vs weighted. Hiệu ứng khác nhau chỉ rõ với imbalanced data (mục 7).

Phần 3 — Quét threshold trên breast cancer maximize F1:

import numpy as np
from sklearn.metrics import precision_recall_curve

# Dùng validation split riêng để tránh leak vào test
X_tr, X_val, y_tr, y_val = train_test_split(
    X_train, y_train, test_size=0.25, random_state=0, stratify=y_train
)
model = LogisticRegression(max_iter=2000).fit(X_tr, y_tr)
y_score_val = model.predict_proba(X_val)[:, 1]

precision, recall, thresholds = precision_recall_curve(y_val, y_score_val)
p, r = precision[:-1], recall[:-1]
f1_arr = np.where((p + r) > 0, 2 * p * r / (p + r), 0)
best_idx = f1_arr.argmax()
best_threshold = thresholds[best_idx]
print(f"Best threshold = {best_threshold:.4f}, val F1 = {f1_arr[best_idx]:.4f}")

# Áp dụng trên test set, so với threshold mặc định 0.5
y_score_test = model.predict_proba(X_test)[:, 1]
y_pred_default = (y_score_test >= 0.5).astype(int)
y_pred_tuned   = (y_score_test >= best_threshold).astype(int)
print(f"Test F1 (threshold 0.5)         = {f1_score(y_test, y_pred_default):.4f}")
print(f"Test F1 (threshold {best_threshold:.3f}) = {f1_score(y_test, y_pred_tuned):.4f}")

Phần 4 — Vẽ Precision-Recall curve (mô tả): dùng matplotlib.pyplot.plot(recall, precision), set xlabel("Recall"), ylabel("Precision"), thêm baseline horizontal ở mức positive_rate = y_test.mean() (no-skill classifier). Sklearn cũng có helper PrecisionRecallDisplay.from_estimator(model, X_test, y_test) để vẽ trực tiếp.

14

Bài tập thực hành

Bài 1 — Tính bằng tay. Cho \( P = 0.8, R = 0.6 \). Tính:

  • F1.
  • F2 (ưu tiên recall).
  • F0.5 (ưu tiên precision).

Đáp án mong đợi: \( F_1 = 0.6857 \), \( F_2 = 0.6383 \), \( F_{0.5} = 0.7407 \). Verify bằng fbeta_score trên một cặp y_true, y_pred tự dựng cho ra precision/recall đúng như đề.

Bài 2 — Iris multi-class. Lặp lại Phần 2 mục 13 nhưng dùng RandomForestClassifier(n_estimators=20, random_state=0). In F1 macro, weighted, per-class. So sánh với Logistic Regression — model nào có F1 macro cao hơn?

Bài 3 — Threshold tối ưu trên breast cancer. Tái hiện Phần 3 mục 13. Yêu cầu thêm:

  • In ra precision và recall tại threshold tối ưu.
  • Vẽ PR curve và đánh dấu điểm threshold tối ưu (chấm tròn).
  • So sánh với threshold tối ưu cho F2 (recall heavy) — threshold đó có khác không?

Bài 4 — Imbalanced data. Sinh dữ liệu bằng make_classification(n_samples=2000, weights=[0.95, 0.05], random_state=0) — class positive chỉ 5%. Train Logistic Regression. So sánh:

  • Accuracy.
  • F1 binary (default).
  • F1 macro.
  • F1 weighted.
  • Average Precision.

Quan sát: accuracy có thể cao bất thường (vd 0.95+) chỉ vì luôn predict negative. F1 binary có thấp như mong đợi không? Nhận xét.

Bài 5 — F-beta cho spam filter giả lập. Cho ma trận nhầm lẫn dạng dict: {"TP": 80, "FP": 5, "FN": 20, "TN": 895}. Tính precision, recall, F1, F0.5, F2 từ counts. Argument bằng lập luận: spam filter này nên báo metric nào?

15

Bài tiếp theo

Bài 26: ROC Curve và AUC — metric threshold-independent cho binary classification: định nghĩa TPR/FPR, đường ROC, diện tích AUC, so sánh với PR-AUC khi data imbalanced, và cách dùng roc_curve / roc_auc_score trong sklearn.