9. Evaluation Metrics — Classification

Intuition: Accuracy is not enough. On a dataset with 99% negative samples, a model that always predicts “negative” achieves 99% accuracy and is completely useless. You need metrics that separately measure different types of correctness.


9.1 The Confusion Matrix

The confusion matrix is the foundation of all classification metrics. It breaks down predictions into four categories based on the actual and predicted classes.

Predicted: PositivePredicted: Negative
Actual: PositiveTP (True Positive)FN (False Negative)
Actual: NegativeFP (False Positive)TN (True Negative)
  • TP — Correctly predicted Positive. The model said yes, it was yes.
  • TN — Correctly predicted Negative. The model said no, it was no.
  • FPType I Error (False Alarm). The model said yes, it was actually no. (e.g., flagging a healthy patient as sick)
  • FNType II Error (Miss). The model said no, it was actually yes. (e.g., missing a cancerous tumour)
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
import matplotlib.pyplot as plt
 
cm = confusion_matrix(y_test, y_pred)
print(cm)
 
# Visualise
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['Negative', 'Positive'])
disp.plot(cmap='Blues')
plt.title('Confusion Matrix')
plt.show()
 
# Normalised version (show proportions instead of counts)
cm_normalised = confusion_matrix(y_test, y_pred, normalize='true')
# normalize='true': normalise by row (actual class)
# normalize='pred': normalise by column (predicted class)
# normalize='all':  normalise by total samples

9.2 Accuracy

The proportion of all predictions that are correct.

The imbalance problem: On a dataset with 950 negatives and 50 positives (95% negative), a model that blindly predicts “Negative” for everything achieves:

This model is completely useless — it never predicts the positive class. Accuracy gave you a 95% score and told you nothing.

from sklearn.metrics import accuracy_score
print(f"Accuracy: {accuracy_score(y_test, y_pred):.4f}")

When to use: Only when classes are balanced and misclassification costs are equal. Almost never the right primary metric for real-world problems.


9.3 Precision

“Of all the samples I predicted as Positive, what fraction were actually Positive?”

Precision measures the cost of false positives. A high-precision model rarely sounds a false alarm.

Mnemonic: Precision = how precise your positive calls are. When you predict positive, you’re usually right.

from sklearn.metrics import precision_score
print(f"Precision: {precision_score(y_test, y_pred):.4f}")

Use when FP is costly:

  • Email spam filter — a FP (legitimate email flagged as spam) is very bad; user misses important mail.
  • Legal system — wrongly convicting an innocent person (FP).
  • Search engine — showing irrelevant results (FP) frustrates users.

9.4 Recall (Sensitivity, True Positive Rate)

“Of all the samples that actually are Positive, what fraction did I catch?”

Recall measures the cost of false negatives. A high-recall model misses few positive cases.

Mnemonic: Recall = how many positives you recall (retrieve). Of all the positives out there, how many did you find?

from sklearn.metrics import recall_score
print(f"Recall: {recall_score(y_test, y_pred):.4f}")

Use when FN is costly:

  • Cancer screening — a FN (missed tumour) means the patient goes untreated.
  • Fraud detection — a FN (missed fraud) means financial loss.
  • Earthquake early warning — a FN (missed earthquake) is catastrophic.

9.5 Specificity (True Negative Rate)

“Of all the actual Negatives, what fraction did I correctly identify as Negative?”

Specificity is the complement of the False Positive Rate (FPR). It’s analogous to Recall but for the negative class.

from sklearn.metrics import confusion_matrix
tn, fp, fn, tp = confusion_matrix(y_test, y_pred).ravel()
specificity = tn / (tn + fp)
print(f"Specificity: {specificity:.4f}")

Sensitivity (Recall) vs. Specificity:

  • Sensitivity = catching the sick patients (TP rate for Positives).
  • Specificity = correctly clearing the healthy patients (TN rate for Negatives).
  • In medical testing, you often want both high sensitivity AND high specificity, but there’s a tradeoff.

9.6 F-β Score

The F-score is the harmonic mean of Precision and Recall, with controlling the relative importance.

Why harmonic mean? Unlike the arithmetic mean, the harmonic mean is low when either component is low. A model with Precision = 1.0 and Recall = 0.01 should not score 0.505 — its harmonic mean is just 0.02. The harmonic mean correctly penalises imbalance between P and R.

F1 Score () — Equal weight

from sklearn.metrics import f1_score
print(f"F1: {f1_score(y_test, y_pred):.4f}")

F2 Score () — Recall matters twice as much

Use when missing a positive is worse than a false alarm (medical, fraud, safety).

F0.5 Score () — Precision matters twice as much

Use when false alarms are worse than misses (spam filter, recommendation system).

from sklearn.metrics import fbeta_score
f2  = fbeta_score(y_test, y_pred, beta=2)
f05 = fbeta_score(y_test, y_pred, beta=0.5)

9.7 Multiclass Averaging

For problems with classes, metrics like Precision, Recall, and F1 are computed per class and then aggregated:

AveragingHowWhen to use
MacroUnweighted mean across all classes: All classes are equally important, even rare ones
MicroPool all TP, FP, FN globally, then compute: Class imbalance exists; overall correctness matters
WeightedWeighted mean by class support (number of true instances): Class imbalance; want to account for class frequency
from sklearn.metrics import precision_score, recall_score, f1_score
 
print(f"Macro  F1: {f1_score(y_test, y_pred, average='macro'):.4f}")
print(f"Micro  F1: {f1_score(y_test, y_pred, average='micro'):.4f}")
print(f"Weighted F1: {f1_score(y_test, y_pred, average='weighted'):.4f}")
 
# Full report — the best way to see all metrics at once
from sklearn.metrics import classification_report
print(classification_report(y_test, y_pred, target_names=['Class 0', 'Class 1', 'Class 2']))

9.8 Matthews Correlation Coefficient (MCC)

A single metric that captures all four cells of the confusion matrix, symmetric with respect to both classes. Particularly useful for binary classification with imbalanced classes.

  • : Perfect prediction.
  • : No better than random.
  • : Perfect inverse prediction.
from sklearn.metrics import matthews_corrcoef
print(f"MCC: {matthews_corrcoef(y_test, y_pred):.4f}")

Why MCC > Accuracy/F1 for imbalanced data: MCC uses all four cells of the confusion matrix. It will be high only if the model performs well on both classes — you can’t fool it by always predicting the majority class.


9.9 Cohen’s Kappa

Measures agreement between predictions and ground truth, corrected for chance agreement:

where = observed accuracy, = expected accuracy a random classifier would achieve.

Interpretation
Less agreement than chance
Slight
Fair
Moderate
Substantial
Almost perfect
from sklearn.metrics import cohen_kappa_score
print(f"Cohen's κ: {cohen_kappa_score(y_test, y_pred):.4f}")

9.10 ROC-AUC Curve

What it is

The Receiver Operating Characteristic (ROC) curve plots the True Positive Rate (Recall/Sensitivity) against the False Positive Rate (1 - Specificity) at every possible classification threshold .

At : every sample predicted Positive → TPR = 1, FPR = 1 (top-right corner).
At : no sample predicted Positive → TPR = 0, FPR = 0 (bottom-left corner).
As decreases from 1 to 0, the operating point traces a curve from bottom-left to top-right.

AUC — Area Under the Curve

AUC is the area under the ROC curve.

AUCInterpretation
1.0Perfect model — can perfectly rank all positives above all negatives
0.5Random classifier — the diagonal line
< 0.5Model is inversely predicting (flip all predictions to get a model with AUC > 0.5)

Probabilistic interpretation: AUC = the probability that a randomly selected positive sample gets a higher model score than a randomly selected negative sample. An AUC of 0.85 means: if you pick a random positive and a random negative, there’s an 85% chance the model ranks the positive higher.

Key property: threshold-independent. AUC evaluates the model’s ranking ability across all thresholds, not at any specific threshold. This makes it useful for comparing models independent of the operating point.

from sklearn.metrics import roc_auc_score, roc_curve
import matplotlib.pyplot as plt
 
# Requires probability scores, not hard predictions
y_proba = model.predict_proba(X_test)[:, 1]   # probability of positive class
 
fpr, tpr, thresholds = roc_curve(y_test, y_proba)
auc = roc_auc_score(y_test, y_proba)
 
plt.figure(figsize=(7, 6))
plt.plot(fpr, tpr, lw=2, label=f'ROC curve (AUC = {auc:.3f})')
plt.plot([0, 1], [0, 1], 'k--', lw=1, label='Random (AUC = 0.5)')
plt.fill_between(fpr, tpr, alpha=0.1)
plt.xlabel('False Positive Rate (1 - Specificity)')
plt.ylabel('True Positive Rate (Recall)')
plt.title('ROC Curve')
plt.legend(loc='lower right')
plt.grid(True, alpha=0.3)
plt.show()

Multiclass ROC-AUC

# OVR (One-vs-Rest): compute AUC for each class vs. all others, then average
auc_ovr = roc_auc_score(y_test, y_proba_all_classes, multi_class='ovr', average='macro')
 
# OVO (One-vs-One): compute AUC for each pair of classes, then average
auc_ovo = roc_auc_score(y_test, y_proba_all_classes, multi_class='ovo', average='macro')

9.11 Precision-Recall Curve & Average Precision

When to prefer PR curve over ROC

On highly imbalanced datasets, the ROC curve can be misleadingly optimistic. Why? The FPR term has TN in the denominator — when the majority class is Negative, TN is huge, so FPR stays small even with many FPs. The ROC curve doesn’t “see” those false positives clearly.

The PR curve focuses entirely on the positive class — it uses TP, FP, and FN, completely ignoring TN. It is more discriminating on imbalanced data.

  • High recall, low precision: Model casts a wide net — catches most positives, but many false alarms.
  • High precision, low recall: Model is conservative — only predicts positive when very confident, but misses many.

Average Precision (AP)

The area under the PR curve, approximated as a weighted mean of Precisions at each recall level where recall increases:

from sklearn.metrics import precision_recall_curve, average_precision_score
import matplotlib.pyplot as plt
 
y_proba = model.predict_proba(X_test)[:, 1]
 
precision, recall, thresholds = precision_recall_curve(y_test, y_proba)
ap = average_precision_score(y_test, y_proba)
 
plt.figure(figsize=(7, 6))
plt.plot(recall, precision, lw=2, label=f'PR curve (AP = {ap:.3f})')
plt.axhline(y=y_test.mean(), color='k', linestyle='--', label=f'Baseline (prevalence = {y_test.mean():.3f})')
plt.xlabel('Recall')
plt.ylabel('Precision')
plt.title('Precision-Recall Curve')
plt.legend()
plt.grid(True, alpha=0.3)

NOTE

The random classifier baseline for a PR curve is the prevalence (positive rate), not 0.5. If positives make up 5% of the data, a random classifier has AP ≈ 0.05, not 0.5. A good model’s AP should be significantly above the prevalence line.


9.12 Choosing & Tuning the Decision Threshold

Most classifiers output a probability score (via predict_proba), not a hard label. The default threshold is 0.5 — predict Positive if score > 0.5. This is often not the best threshold.

Tuning the threshold for a specific goal

import numpy as np
from sklearn.metrics import precision_score, recall_score, f1_score
 
y_proba = model.predict_proba(X_test)[:, 1]
thresholds = np.linspace(0, 1, 101)
 
results = []
for t in thresholds:
    y_pred_t = (y_proba >= t).astype(int)
    if y_pred_t.sum() == 0:   # no positive predictions
        continue
    results.append({
        'threshold': t,
        'precision': precision_score(y_test, y_pred_t, zero_division=0),
        'recall':    recall_score(y_test, y_pred_t, zero_division=0),
        'f1':        f1_score(y_test, y_pred_t, zero_division=0)
    })
 
df_thresh = pd.DataFrame(results)
best_f1_thresh = df_thresh.loc[df_thresh['f1'].idxmax(), 'threshold']
print(f"Threshold maximising F1: {best_f1_thresh:.2f}")
 
# Apply the chosen threshold
y_pred_final = (y_proba >= best_f1_thresh).astype(int)

Finding the optimal threshold from the ROC curve

The point on the ROC curve closest to the top-left corner — perfect TPR with no FPR — often gives a good operating threshold:

fpr, tpr, thresholds = roc_curve(y_test, y_proba)
optimal_idx = np.argmax(tpr - fpr)   # Youden's J statistic
optimal_threshold = thresholds[optimal_idx]
print(f"Optimal ROC threshold (Youden's J): {optimal_threshold:.3f}")

9.13 Metrics Quick-Reference

MetricFormulaBest for
AccuracyBalanced classes, equal misclassification costs
PrecisionFP is costly (spam, false alarms)
RecallFN is costly (cancer, fraud, safety)
SpecificityMedical testing (true negative rate)
F1Imbalanced classes; balanced P/R tradeoff
F2 F-scoreRecall matters more than Precision
F0.5 F-scorePrecision matters more than Recall
MCCAll 4 cellsBinary, highly imbalanced data
Cohen’s κChance-corrected accuracyAgreement tasks, multiclass
ROC-AUCArea under ROCRanking ability, threshold-independent
Average PrecisionArea under PRImbalanced binary classification