MODELCATALOG

Is it any good — and by whose definition.

sklearn.metrics

metrics

Is it any good — and by whose definition.

1 Get data Something to practise on
2 Split & tune An honest test set
3 Prepare Into numbers a model accepts
4 Compose One object, no leakage
5 Model The algorithm itself
6 Unsupervised No labels available
7 Measure Is it any good, and why
8 Utilities Glue and extension

What it is for

The largest subpackage, and the one where choosing wrongly does the most damage. Accuracy on imbalanced data and ROC AUC on rare positives are both routinely reported and both routinely misleading.

Classification

classification_report common Precision, recall and F1 per class. Start here
confusion_matrix common The raw counts. Read this before believing any single number
accuracy_score trap Misleading on imbalanced data
balanced_accuracy_score Accuracy corrected for imbalance
f1_score, precision_score, recall_score Set average='macro' to weight classes equally
roc_auc_score trap Ranking quality. Flattering when positives are rare
average_precision_score bank PR AUC. Use this instead for rare positives
precision_recall_curve, roc_curve The full trade-off, for choosing an operating threshold
log_loss Penalises confident wrong answers
brier_score_loss Calibration quality
matthews_corrcoef Robust single number for imbalanced binary
cohen_kappa_score bank Agreement above chance — use it on your annotators, not just your model

Regression

mean_absolute_error Interpretable in the target's own units
mean_squared_error Pass squared=False for RMSE
mean_absolute_percentage_error Scale-free, but explodes near zero
r2_score Proportion of variance explained
mean_pinball_loss Quantile forecasts

Clustering

silhouette_score, davies_bouldin_score No labels needed
adjusted_rand_score, normalized_mutual_info_score Labels available for comparison

Plots and extension

ConfusionMatrixDisplay, RocCurveDisplay, PrecisionRecallDisplay Each has .from_estimator() and .from_predictions()
sklearn.metrics.pairwise cosine_similarity, euclidean_distances
make_scorer common Wrap any function so GridSearchCV can optimise it

Worked examples 5

Runnable, with the arguments that change the outcome called out. Output shapes are shown as comments where they help.

sklearn.metrics

from sklearn.metrics import (classification_report, confusion_matrix,
                             average_precision_score, precision_recall_curve)
import numpy as np

print(classification_report(y_test, y_pred, digits=3))
print(confusion_matrix(y_test, y_pred))

# For rare positives, PR AUC - not ROC AUC
p = model.predict_proba(X_test)[:, 1]
print("PR AUC:", average_precision_score(y_test, p))

# Choose the operating point deliberately: what recall can the
# review team actually absorb?
prec, rec, thr = precision_recall_curve(y_test, p)
for target in (0.5, 0.7, 0.9):
    i = np.argmin(np.abs(rec - target))
    print(f"recall {rec[i]:.2f} -> precision {prec[i]:.2f} @ {thr[i]:.4f}")

Read the confusion matrix before any single number

Every aggregate metric compresses this table. Look at the table first, then decide which compression is right.

from sklearn.metrics import (classification_report, confusion_matrix,
                             ConfusionMatrixDisplay)

print(classification_report(y_test, y_pred, digits=3,
                            target_names=["fees", "fraud", "service", "tech"]))

#               precision    recall  f1-score   support
#         fees      0.881     0.912     0.896      1204
#        fraud      0.774     0.612     0.684       312   <- recall problem
#      service      0.856     0.889     0.872       998
#         tech      0.802     0.771     0.786       486
#
#     accuracy                          0.851      3000
#    macro avg      0.828     0.796     0.809      3000   <- classes equal
# weighted avg      0.848     0.851     0.848      3000   <- by frequency

print(confusion_matrix(y_test, y_pred))

# macro vs weighted matters: weighted hides poor performance on small
# classes, and 'fraud' is usually the small class you cannot afford
# to miss.

Choosing the operating threshold deliberately

predict() uses 0.5, which is almost never the right cut for imbalanced problems. Pick the point your operation can absorb.

from sklearn.metrics import precision_recall_curve, average_precision_score
import numpy as np

p = model.predict_proba(X_test)[:, 1]
print("PR AUC:", round(average_precision_score(y_test, p), 4))

prec, rec, thr = precision_recall_curve(y_test, p)

print(f"{'recall':>8} {'precision':>10} {'threshold':>10} {'alerts/day':>11}")
for target in (0.90, 0.80, 0.70, 0.50):
    i = np.argmin(np.abs(rec[:-1] - target))
    alerts = (p >= thr[i]).sum() / DAYS_IN_TEST
    print(f"{rec[i]:8.2f} {prec[i]:10.2f} {thr[i]:10.4f} {alerts:11.0f}")

#   recall  precision  threshold  alerts/day
#     0.90       0.08     0.0142         840   <- team cannot work 840
#     0.80       0.19     0.0631         310
#     0.70       0.34     0.1487         152   <- feasible
#     0.50       0.61     0.4012          61
#
# The threshold is a capacity decision, not a modelling one. Bring this
# table to the operations team rather than picking a number yourself.

Why ROC AUC misleads on rare positives

Both metrics on the same predictions. The gap is the argument.

from sklearn.metrics import roc_auc_score, average_precision_score
import numpy as np

# 0.5% positive rate
print("positive rate:", y_test.mean())          # 0.005

print("ROC AUC:", round(roc_auc_score(y_test, p), 4))          # 0.9412
print("PR AUC: ", round(average_precision_score(y_test, p), 4)) # 0.3140

# ROC AUC of 0.94 sounds excellent. But the baseline for PR AUC is the
# positive rate itself - 0.005 - so 0.314 means the model is 60x better
# than random, while precision at usable recall is still around 0.3.
#
# ROC AUC stays high because the enormous true-negative count dominates
# the false-positive rate denominator. It is not wrong, it is answering
# a question you did not ask.

A custom metric for GridSearchCV to optimise

When the business cost of the two error types differs, encode that and search against it rather than against F1.

from sklearn.metrics import make_scorer, confusion_matrix

COST_FN = 500.0     # missing a fraudulent transaction
COST_FP = 12.0      # an analyst reviewing a clean one

def expected_cost(y_true, y_pred):
    tn, fp, fn, tp = confusion_matrix(y_true, y_pred).ravel()
    return fp * COST_FP + fn * COST_FN

# greater_is_better=False makes the searcher minimise it
cost_scorer = make_scorer(expected_cost, greater_is_better=False)

search = RandomizedSearchCV(pipe, params, scoring=cost_scorer,
                            cv=5, n_iter=30, n_jobs=-1)
search.fit(X_train, y_train)
print("best expected cost per fold:", -search.best_score_)

# This is usually a more honest objective than F1, which weights the
# two error types equally for no reason connected to your business.