Beginner · Lesson 6 of 10

Evaluation that does not lie

Why accuracy misleads on imbalanced data, what precision, recall, F1 and ROC-AUC measure, how stratified cross-validation and a dummy baseline keep you honest, and how a bootstrap puts an interval on a score

Beginner40 minUpdated 13 Nov 2026

You will learn

  • Read a confusion matrix and compute precision, recall and F1 from it by hand
  • Explain why accuracy is the wrong headline number when one class is rare, and what ROC-AUC measures instead
  • Run stratified k-fold cross-validation and compare every model to a dummy baseline
  • Put a bootstrap confidence interval on a test-set score and say whether two models are distinguishable

Before this: trees and gradient boosting

Every lesson so far has ended with a number: an accuracy, a mean squared error, a score on a held-out split. This lesson is about whether that number can be trusted. It is for anyone who has shown a 96 % accuracy to a colleague and been asked “96 % compared to what”. By the end you will have a short checklist that catches the common ways an evaluation flatters a model, and you will have run every item on it in scikit-learn.

Lesson 02 dealt with the first and worst failure, leakage between training and test data. This lesson assumes your split is clean and asks the next questions: is the metric the right one, is the baseline fair, is the estimate stable, and how wide is the error bar.

Accuracy on a rare class

Start with a dataset where one class is rare. Fraud, equipment faults, rare diseases and spam at a well-run company all look like this. The example below is synthetic, with about 4 % positives, so that the numbers are reproducible and the point is not lost in data cleaning.

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score

X, y = make_classification(
    n_samples=4000, n_features=20, n_informative=6, n_redundant=2,
    weights=[0.96, 0.04], flip_y=0.01, class_sep=0.9, random_state=0,
)
print("positives:", int(y.sum()), "of", len(y), f"({100 * y.mean():.1f} %)")

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

dummy = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
model = LogisticRegression(max_iter=2000).fit(X_train, y_train)

print(f"dummy accuracy:  {accuracy_score(y_test, dummy.predict(X_test)):.3f}")
print(f"logreg accuracy: {accuracy_score(y_test, model.predict(X_test)):.3f}")
positives: 177 of 4000 (4.4 %)
dummy accuracy:  0.956
logreg accuracy: 0.965

The dummy classifier never looks at the features. It answers “negative” every time, and it scores 95.6 % because 95.6 % of the test rows are negative. The logistic regression, which did look at the features, scores 96.5 %. Reported alone, 96.5 % sounds like a finished product. Next to the baseline it is a gain of less than one point, and you cannot tell from these two numbers whether the model finds any of the rare cases at all.

Two habits follow. The first is to always fit a DummyClassifier (or DummyRegressor) and report it alongside the model. The second is to stop using accuracy as the headline when the classes are unbalanced. The stratify=y argument to train_test_split matters here too: it keeps the class ratio the same in both halves, so a lucky split cannot hide or exaggerate the rare class.

The confusion matrix and what it yields

For a two-class problem every prediction lands in one of four cells. A true positive (TP) is a positive the model caught. A false positive (FP) is a negative the model flagged. A false negative (FN) is a positive the model missed. A true negative (TN) is a negative correctly left alone. Accuracy is (TP + TN) divided by everything, which is why a huge TN count swamps it.

Three metrics look only at the cells that involve positives.

Metric Formula Question it answers
Precision TP / (TP + FP) When the model says positive, how often is it right
Recall TP / (TP + FN) Of the real positives, how many did the model find
F1 2 x precision x recall / (precision + recall) One number that is low if either of the above is low

F1 is the harmonic mean of precision and recall. The harmonic mean punishes imbalance: a model with precision 1.0 and recall 0.1 has an arithmetic mean of 0.55 but an F1 of 0.18. That is the behaviour you want from a summary score, because a fraud detector that catches one case in ten is not half good.

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.dummy import DummyClassifier
from sklearn.metrics import (confusion_matrix, precision_score, recall_score,
                             f1_score, roc_auc_score)

X, y = make_classification(
    n_samples=4000, n_features=20, n_informative=6, n_redundant=2,
    weights=[0.96, 0.04], flip_y=0.01, class_sep=0.9, random_state=0,
)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=0
)
dummy = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
model = LogisticRegression(max_iter=2000).fit(X_train, y_train)

for name, clf in [("dummy", dummy), ("logreg", model)]:
    pred = clf.predict(X_test)
    prob = clf.predict_proba(X_test)[:, 1]
    tn, fp, fn, tp = confusion_matrix(y_test, pred).ravel()
    print(f"{name:7s} TP={tp:3d} FP={fp:3d} FN={fn:3d} TN={tn:4d}")
    print(f"        precision={precision_score(y_test, pred, zero_division=0):.3f}"
          f" recall={recall_score(y_test, pred):.3f}"
          f" F1={f1_score(y_test, pred):.3f}"
          f" ROC-AUC={roc_auc_score(y_test, prob):.3f}")
dummy   TP=  0 FP=  0 FN= 53 TN=1147
        precision=0.000 recall=0.000 F1=0.000 ROC-AUC=0.500
logreg  TP= 13 FP=  2 FN= 40 TN=1145
        precision=0.867 recall=0.245 F1=0.382 ROC-AUC=0.862

Now the picture is honest. The dummy finds nothing, so its precision, recall and F1 are all zero. The logistic regression is precise (13 of its 15 alarms were real) but it misses 40 of the 53 positives. Whether that is acceptable depends entirely on the cost of a miss versus the cost of a false alarm, which is a question about the application, not about the model. Check the numbers by hand once: 13 / (13 + 2) is 0.867 and 13 / (13 + 40) is 0.245.

The threshold is a choice, and ROC-AUC ignores it

predict on a scikit-learn classifier returns the positive class when the predicted probability is at least 0.5. Nothing about the problem says 0.5 is right. Sweeping the threshold shows the whole trade-off.

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_score, recall_score

X, y = make_classification(
    n_samples=4000, n_features=20, n_informative=6, n_redundant=2,
    weights=[0.96, 0.04], flip_y=0.01, class_sep=0.9, random_state=0,
)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=0
)
model = LogisticRegression(max_iter=2000).fit(X_train, y_train)
prob = model.predict_proba(X_test)[:, 1]

print("threshold  precision  recall")
for t in [0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8]:
    pred = (prob >= t).astype(int)
    p = precision_score(y_test, pred, zero_division=0)
    r = recall_score(y_test, pred)
    print(f"{t:9.2f}  {p:9.3f}  {r:6.3f}")
00.20.40.60.810.20.40.60.8precisionrecallscorethreshold
Precision and recall of the logistic regression on the test split as the decision threshold moves. Numbers from the block above.

At a threshold of 0.05 the model catches 74 % of the positives at the price of being wrong on 82 % of its alarms. At 0.7 every alarm is correct and 87 % of the positives slip through. The model did not change. Only the rule for turning a probability into a decision changed, and that rule belongs to whoever owns the cost of each kind of mistake.

ROC-AUC sidesteps the threshold. It takes the ranking the model produces and asks: if you pick one random positive and one random negative, how often does the model score the positive higher. A perfect ranker scores 1.0, a coin toss scores 0.5, and the dummy scored exactly 0.5 above because it gives every row the same probability. The logistic regression’s 0.862 says its ranking is useful even though its default-threshold recall is poor. ROC-AUC is the right comparison between models when the threshold has not yet been chosen. It is the wrong number to show a stakeholder who wants to know how many fraud cases will be caught, because it does not say.

One split is one sample

The numbers above come from a single 70/30 split chosen by random_state=0. A different seed gives a different test set and a different F1. With only 53 positives in the test set, moving three of them from found to missed changes recall by about 6 points. Cross-validation turns one noisy estimate into several.

Stratified k-fold splits the data into k parts, each with the same class ratio as the whole, then trains k times, holding out a different part each time. Every row is in a test set exactly once. The spread across folds is a first, rough measure of how much the score depends on which rows you happened to test on.

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.dummy import DummyClassifier

X, y = make_classification(
    n_samples=4000, n_features=20, n_informative=6, n_redundant=2,
    weights=[0.96, 0.04], flip_y=0.01, class_sep=0.9, random_state=0,
)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
models = {
    "dummy (stratified)": DummyClassifier(strategy="stratified", random_state=0),
    "logreg": LogisticRegression(max_iter=2000),
    "hist gradient boosting": HistGradientBoostingClassifier(random_state=0),
}
for name, clf in models.items():
    scores = cross_val_score(clf, X, y, cv=cv, scoring="f1")
    print(f"{name:23s} F1 per fold: {np.round(scores, 3)}"
          f"  mean {scores.mean():.3f} +/- {scores.std():.3f}")
dummy (stratified)      F1 per fold: [0.098 0.073 0.171 0.048 0.048]  mean 0.088 +/- 0.045
logreg                  F1 per fold: [0.326 0.435 0.286 0.255 0.34 ]  mean 0.328 +/- 0.061
hist gradient boosting  F1 per fold: [0.56  0.549 0.571 0.655 0.44 ]  mean 0.555 +/- 0.069
00.20.40.60.81dummylogregboostingmean F1
Mean F1 across five stratified folds. Numbers from the block above.

Three things are visible that the single split hid. The logistic regression’s F1 ranges from 0.26 to 0.44 depending on the fold, so the 0.382 from the earlier split was a middling draw, not a property of the model. The gradient boosting model from lesson 05 is clearly better, by more than the fold-to-fold spread. And a dummy that guesses positive at the base rate scores a non-zero F1 of about 0.09, which is the floor any real model has to clear.

This dummy uses strategy="stratified" rather than most_frequent because most_frequent has an F1 of exactly zero, which is a less useful floor. Pick the baseline that is hardest to beat with no information.

Cross-validation estimates how well the procedure generalises. It does not give you a final model, because you trained five of them. The usual practice is to use cross-validation to choose between models and settings, then retrain the winner on all the training data and report its score once on a test set that was never touched. The evaluation metrics reference collects the scikit-learn names for every scoring string used here.

An error bar from the bootstrap

The fold standard deviation is a quick look at variability but it is not a confidence interval, and it mixes two sources of noise (which rows were tested on and which rows were trained on). For the question “how uncertain is this test-set score”, the bootstrap is the standard tool and it needs no distributional assumptions.

The idea: the test set is a sample from the population you care about. Resample it with replacement, the same size, many times. Each resample is a plausible alternative test set. Compute the metric on each, and the spread of those values is the uncertainty in the metric. The predictions are fixed throughout, so no retraining is needed and a thousand resamples take a few seconds.

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import f1_score

X, y = make_classification(
    n_samples=4000, n_features=20, n_informative=6, n_redundant=2,
    weights=[0.96, 0.04], flip_y=0.01, class_sep=0.9, random_state=0,
)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=0
)
preds = {
    "logreg": LogisticRegression(max_iter=2000).fit(X_train, y_train).predict(X_test),
    "boosting": HistGradientBoostingClassifier(random_state=0).fit(X_train, y_train).predict(X_test),
}

rng = np.random.default_rng(0)
n = len(y_test)
for name, pred in preds.items():
    boot = []
    for _ in range(1000):
        idx = rng.integers(0, n, size=n)   # resample the test set with replacement
        boot.append(f1_score(y_test[idx], pred[idx]))
    lo, hi = np.percentile(boot, [2.5, 97.5])
    print(f"{name:9s} F1 = {f1_score(y_test, pred):.3f}  95 % interval [{lo:.3f}, {hi:.3f}]")

diff = []
for _ in range(1000):
    idx = rng.integers(0, n, size=n)
    diff.append(f1_score(y_test[idx], preds["boosting"][idx]) - f1_score(y_test[idx], preds["logreg"][idx]))
lo, hi = np.percentile(diff, [2.5, 97.5])
print(f"boosting minus logreg: {np.mean(diff):+.3f}  95 % interval [{lo:+.3f}, {hi:+.3f}]")
logreg    F1 = 0.382  95 % interval [0.214, 0.521]
boosting  F1 = 0.507  95 % interval [0.357, 0.641]
boosting minus logreg: +0.126  95 % interval [-0.010, +0.259]

The intervals are wide, about 0.3 of F1 either way, because 53 positives is a small number to judge a rare-class detector on. The last line is the one to learn from. Boosting beats logistic regression by 0.126 on this test set, but the interval on that difference reaches slightly below zero. On this evidence alone you could not promise a colleague that boosting is better. The cross-validation above, which used all 177 positives across its folds, was more convincing, and the two methods together give a fair account: boosting is probably better, and a bigger test set is needed before the margin can be quoted.

Note that the difference was bootstrapped on paired resamples (the same idx for both models). Bootstrapping each model separately and comparing the intervals would be wrong, because the two scores rise and fall together when the same hard rows are drawn.

A checklist

Before a score goes into a report, a slide or a pull request:

  1. The test rows were never used for fitting, scaling, feature selection or choosing hyperparameters (lesson 02).
  2. A dummy baseline is reported next to the model, on the same split and metric.
  3. The metric matches the problem. Accuracy only when classes are balanced and mistakes cost the same. Precision and recall together, or F1, when a class is rare. ROC-AUC for ranking quality before the threshold is fixed.
  4. The threshold was chosen on the validation data for the cost structure at hand, not left at 0.5 by default.
  5. Stratified cross-validation was used to pick between candidates, and the final number comes from one untouched test set.
  6. The number has an interval, from a bootstrap, and claimed differences between models were bootstrapped on paired resamples.

Large language model benchmarks break these rules constantly, with test sets that leaked into training corpora and single-run scores without intervals. The evaluation and benchmarks hub covers that side.

Exercise

  1. Change weights=[0.96, 0.04] to [0.5, 0.5] in the first block and rerun the first two blocks. What happens to the gap between dummy accuracy and model accuracy, and to the gap between accuracy and F1. Explain in one sentence why accuracy becomes a reasonable headline in this case.
  2. Using the threshold sweep, find the threshold at which recall first reaches 0.5 and report the precision there. Then write down a scenario in which you would choose it, and one in which you would refuse it.
  3. Raise n_samples to 20000 and rerun the bootstrap block. Report the new interval on the difference between the two models and say whether it now excludes zero. How many positives were in the test set this time.

Summary

  • Accuracy is dominated by the majority class. On a 4 % positive problem a model that finds nothing scores 96 %, so always report a dummy baseline next to it.
  • Precision (how often an alarm is right) and recall (how many positives were found) are a pair. F1 is their harmonic mean and punishes whichever is weak.
  • The 0.5 threshold is a default, not a decision. ROC-AUC measures ranking quality independent of it, and is the comparison to use before the threshold is chosen.
  • Stratified k-fold cross-validation shows how much a score depends on the split and is the tool for choosing between models. The final score comes from one untouched test set.
  • A bootstrap over the test set gives an interval on any metric, and paired resamples give an interval on the difference between two models.

Further reading