Skip to main content
Scikit-Learn · lesson 4 of 12
Scikit-Learn intermediate Lesson 4 of 12

Also part of: Machine Learning

Scikit-Learn Model Evaluation

Evaluate scikit-learn models with cross-validation, precision and recall, ROC curves, regression metrics and calibration, with real output for each.

By SkillByExample · Updated

A model is only as good as the way it is measured. Every example here ran with scikit-learn 1.9.1, mostly on the built-in breast cancer dataset (569 tumours, 212 malignant, 357 benign).

Cross-Validation

cross_val_score trains and scores the model once per fold and returns every score.

from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold, cross_val_score

X, y = load_breast_cancer(return_X_y=True)
model = RandomForestClassifier(n_estimators=100, random_state=42)

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X, y, cv=cv)
print("accuracy per fold:", scores.round(4))
print(f"mean {scores.mean():.4f}  std {scores.std():.4f}")
$ python cv.py
accuracy per fold: [0.9649 0.9386 0.9561 0.9474 0.9735]
mean 0.9561  std 0.0123

The five folds range from 0.939 to 0.974, a spread of more than three points from shuffling alone. A single train/test split would have reported one of those numbers as “the” accuracy. The standard deviation tells you how much weight to put on small differences between models.

cross_validate scores several metrics in the same pass and also records timing.

from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import StratifiedKFold, cross_validate

X, y = load_breast_cancer(return_X_y=True)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
res = cross_validate(RandomForestClassifier(n_estimators=100, random_state=42), X, y,
                     cv=cv, scoring=["accuracy", "f1", "roc_auc"])
for metric in ["accuracy", "f1", "roc_auc"]:
    vals = res[f"test_{metric}"]
    print(f"{metric:9s} {vals.mean():.4f} +/- {vals.std():.4f}")
print("keys:", sorted(res))
$ python cv_multi.py
accuracy  0.9561 +/- 0.0123
f1        0.9651 +/- 0.0097
roc_auc   0.9885 +/- 0.0083
keys: ['fit_time', 'score_time', 'test_accuracy', 'test_f1', 'test_roc_auc']

The accuracy row matches cv.py exactly because the folds and seed are the same. f1 and roc_auc come from the same five fits, so trying another metric costs nothing extra. fit_time and score_time are included too, which is useful when comparing slow models.

Precision and Recall, and Which Class They Measure

In this dataset, label 0 is malignant and label 1 is benign. scikit-learn’s binary metrics report label 1 unless told otherwise.

from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import accuracy_score, precision_score, recall_score
from sklearn.model_selection import train_test_split

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)
pred = GradientBoostingClassifier(random_state=42).fit(X_train, y_train).predict(X_test)

print(f"accuracy                        {accuracy_score(y_test, pred):.4f}")
print(f"precision (default: benign)     {precision_score(y_test, pred):.4f}")
print(f"recall    (default: benign)     {recall_score(y_test, pred):.4f}")
print(f"precision (pos_label=0: malignant) {precision_score(y_test, pred, pos_label=0):.4f}")
print(f"recall    (pos_label=0: malignant) {recall_score(y_test, pred, pos_label=0):.4f}")
$ python metrics.py
accuracy                        0.9561
precision (default: benign)     0.9467
recall    (default: benign)     0.9861
precision (pos_label=0: malignant) 0.9744
recall    (pos_label=0: malignant) 0.9048

The default metrics describe benign tumours, the class nobody is worried about missing. Asked about malignant ones, recall drops to 0.905: about 1 malignant tumour in 10 was called benign, a figure that recall_score’s 0.986 default hid. Always check which label is the positive class before reading precision or recall.

The Confusion Matrix

Every binary metric is built from four counts, which confusion_matrix returns directly.

from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import train_test_split

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)
pred = GradientBoostingClassifier(random_state=42).fit(X_train, y_train).predict(X_test)

cm = confusion_matrix(y_test, pred)
print(cm)
tn, fp, fn, tp = cm.ravel()
print(f"tn={tn} fp={fp} fn={fn} tp={tp}   (positive = label 1 = benign)")
$ python confusion.py
[[38  4]
 [ 1 71]]
tn=38 fp=4 fn=1 tp=71   (positive = label 1 = benign)

Rows are the true labels and columns the predictions, in label order (0 = malignant, 1 = benign). The 4 in the top-right are malignant tumours predicted benign, the costly mistake here. Because benign is label 1, scikit-learn names them “false positives”, a reminder that “positive” just means label 1.

ROC Curves and Choosing an Operating Point

roc_curve lists, for every threshold, the true-positive and false-positive rates.

import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score, roc_curve
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

X, y = load_breast_cancer(return_X_y=True)
y = 1 - y                                   # make malignant the positive class
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=0, stratify=y)
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
prob = model.fit(X_train, y_train).predict_proba(X_test)[:, 1]

fpr, tpr, thr = roc_curve(y_test, prob)
print(f"ROC AUC {roc_auc_score(y_test, prob):.4f},  {len(thr)} thresholds")
i = np.argmax(tpr >= 0.99)                 # first threshold catching 99% of malignant
print(f"to catch 99% of malignant: threshold {thr[i]:.3f}, false-positive rate {fpr[i]:.3f}")
$ python roc.py
ROC AUC 0.9917,  14 thresholds
to catch 99% of malignant: threshold 0.032, false-positive rate 0.224

Flipping the labels makes malignant the positive class, so the rates now describe cancers. Catching 99% of them requires dropping the threshold to about 0.03, which also flags about 22% of benign tumours. ROC AUC summarises all thresholds in one number, but a deployment has to pick one point on the curve, and this is how to read that trade-off.

Regression Metrics

Different regression metrics weigh errors differently, so report more than one.

from sklearn.datasets import fetch_california_housing
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.metrics import (mean_absolute_error, mean_absolute_percentage_error,
                             r2_score, root_mean_squared_error)
from sklearn.model_selection import train_test_split

X, y = fetch_california_housing(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
pred = HistGradientBoostingRegressor(random_state=0).fit(X_train, y_train).predict(X_test)

print(f"MAE   {mean_absolute_error(y_test, pred):.4f}")
print(f"RMSE  {root_mean_squared_error(y_test, pred):.4f}")
print(f"MAPE  {mean_absolute_percentage_error(y_test, pred):.2%}")
print(f"R2    {r2_score(y_test, pred):.4f}")
cheap = y_test < 0.6
print(f"MAPE on the cheapest districts only: {mean_absolute_percentage_error(y_test[cheap], pred[cheap]):.2%}")
$ python regression_metrics.py
MAE   0.3095
RMSE  0.4621
MAPE  17.96%
R2    0.8370
MAPE on the cheapest districts only: 62.97%

MAE is the average miss in the target’s units ($31,000 here), and RMSE is larger because it squares errors and so weighs big misses more. MAPE says 18% overall, but on the cheapest districts it jumps to 63%: dividing by a small true value inflates percentage errors. Prefer MAE or RMSE unless every target is comfortably away from zero.

Imbalanced Classes

With 1% positives, the default settings catch few of them. Class weights and a lower threshold both shift the balance toward recall.

from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import precision_score, recall_score
from sklearn.model_selection import train_test_split

X, y = make_classification(n_samples=10_000, weights=[0.99, 0.01], random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)
print("positives in test set:", y_test.sum())

for cw in [None, "balanced"]:
    prob = RandomForestClassifier(class_weight=cw, random_state=42).fit(
        X_train, y_train).predict_proba(X_test)[:, 1]
    for t in [0.5, 0.2]:
        pred = prob >= t
        print(f"class_weight={str(cw):8s} threshold {t}: recall {recall_score(y_test, pred):.2f}"
              f"  precision {precision_score(y_test, pred, zero_division=0):.2f}")
$ python imbalanced.py
positives in test set: 29
class_weight=None     threshold 0.5: recall 0.14  precision 1.00
class_weight=None     threshold 0.2: recall 0.31  precision 0.53
class_weight=balanced threshold 0.5: recall 0.14  precision 0.80
class_weight=balanced threshold 0.2: recall 0.38  precision 0.22

With only 29 positives, the default forest at 0.5 catches 4 of them (recall 0.14), though everything it flags is real. Lowering the threshold to 0.2 roughly doubles recall at the cost of precision. class_weight="balanced" barely moves recall here (0.14, unchanged): a random forest still grows each tree to pure leaves. The threshold is the stronger lever here, and it can be chosen after training.

Calibration

A model can rank examples well while its probability values are off. GaussianNB tends to push probabilities toward 0 and 1 more than the data justifies.

from sklearn.calibration import CalibratedClassifierCV
from sklearn.datasets import make_classification
from sklearn.metrics import brier_score_loss
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import GaussianNB

X, y = make_classification(n_samples=3000, n_features=20, n_informative=15,
                           n_redundant=5, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

uncal_prob = GaussianNB().fit(X_train, y_train).predict_proba(X_test)[:, 1]
cal = CalibratedClassifierCV(GaussianNB(), method="isotonic", cv=5).fit(X_train, y_train)
cal_prob = cal.predict_proba(X_test)[:, 1]

print(f"Uncalibrated Brier score: {brier_score_loss(y_test, uncal_prob):.4f}")
print(f"Calibrated Brier score:   {brier_score_loss(y_test, cal_prob):.4f}")
$ python calibration_brier.py
Uncalibrated Brier score: 0.1456
Calibrated Brier score:   0.1410

The Brier score is the mean squared error between the predicted probability and the actual 0 or 1, so lower is better. Calibration lowers it from 0.1456 to 0.1410. CalibratedClassifierCV fits GaussianNB on cross-validation folds and learns the correction on held-out predictions, so the calibration step never sees a prediction on the base model’s own training rows.

The Brier score doesn’t show where the miscalibration is. A reliability table bins the predictions and compares each bin’s average prediction with its actual positive rate.

from sklearn.calibration import CalibratedClassifierCV, calibration_curve
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import GaussianNB

X, y = make_classification(n_samples=3000, n_features=20, n_informative=15,
                           n_redundant=5, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

models = {"Uncalibrated": GaussianNB(),
          "Calibrated": CalibratedClassifierCV(GaussianNB(), method="isotonic", cv=5)}
for label, model in models.items():
    prob = model.fit(X_train, y_train).predict_proba(X_test)[:, 1]
    frac_pos, mean_pred = calibration_curve(y_test, prob, n_bins=5, strategy="quantile")
    print(f"{label}:")
    for pred, actual in zip(mean_pred, frac_pos):
        print(f"  predicted {pred:.2f}  ->  actually positive {actual:.2f}")
$ python calibration_curve.py
Uncalibrated:
  predicted 0.01  ->  actually positive 0.11
  predicted 0.10  ->  actually positive 0.23
  predicted 0.41  ->  actually positive 0.45
  predicted 0.80  ->  actually positive 0.77
  predicted 0.98  ->  actually positive 0.99
Calibrated:
  predicted 0.10  ->  actually positive 0.12
  predicted 0.24  ->  actually positive 0.23
  predicted 0.39  ->  actually positive 0.45
  predicted 0.75  ->  actually positive 0.78
  predicted 0.98  ->  actually positive 1.00

The uncalibrated model’s lowest bin is the clearest tell: it predicts 0.01 on average, but 11% of those examples are actually positive, so it is overconfident at the low end. After calibration the predicted and actual columns track each other closely in every bin, which is the same improvement the Brier score summarised as one number.

Practice

1. Swap method="isotonic" for method="sigmoid" and compare the Brier scores.
from sklearn.calibration import CalibratedClassifierCV
from sklearn.datasets import make_classification
from sklearn.metrics import brier_score_loss
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import GaussianNB

X, y = make_classification(n_samples=3000, n_features=20, n_informative=15,
                           n_redundant=5, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
for method in ["sigmoid", "isotonic"]:
    cal = CalibratedClassifierCV(GaussianNB(), method=method, cv=5).fit(X_train, y_train)
    print(f"{method:8s} Brier {brier_score_loss(y_test, cal.predict_proba(X_test)[:, 1]):.4f}")
$ python p1_sigmoid.py
sigmoid  Brier 0.1424
isotonic Brier 0.1410

Sigmoid calibration also improves on the uncalibrated 0.1456, but isotonic edges it out. Isotonic regression is more flexible, and with 2100 training rows it has enough data not to overfit; on small datasets sigmoid is usually the safer choice.

2. Compute the Brier score of a model that always predicts the base rate, as a baseline.
import numpy as np
from sklearn.datasets import make_classification
from sklearn.metrics import brier_score_loss
from sklearn.model_selection import train_test_split

X, y = make_classification(n_samples=3000, n_features=20, n_informative=15,
                           n_redundant=5, random_state=42)
_, _, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
base = np.full(len(y_test), y_train.mean())
print(f"base rate {y_train.mean():.3f}  Brier {brier_score_loss(y_test, base):.4f}")
$ python p2_baseline.py
base rate 0.498  Brier 0.2500

Always predicting the 0.498 base rate scores 0.25, the Brier score of knowing nothing on a balanced problem. Both GaussianNB versions, at about 0.14, carry real information; a model scoring near 0.25 would not.

3. On the 1%-positive data, compare ROC AUC with average precision.
from sklearn.datasets import make_classification
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import average_precision_score, roc_auc_score
from sklearn.model_selection import train_test_split

X, y = make_classification(n_samples=10_000, weights=[0.99, 0.01], random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)
prob = RandomForestClassifier(random_state=42).fit(X_train, y_train).predict_proba(X_test)[:, 1]
print(f"ROC AUC           {roc_auc_score(y_test, prob):.4f}")
print(f"average precision {average_precision_score(y_test, prob):.4f}")
print(f"positive rate     {y_test.mean():.4f}")
$ python p3_ap_vs_auc.py
ROC AUC           0.8287
average precision 0.3465
positive rate     0.0145

ROC AUC looks respectable at 0.83, but average precision is 0.35. AP’s baseline is the positive rate (0.0145), so 0.35 is far better than chance, yet it shows that most high-scoring rows are still false alarms. When positives are rare, report average precision alongside ROC AUC.

Frequently Asked Questions

Why is accuracy a bad metric for imbalanced datasets?
When 99% of rows belong to one class, a model that always predicts that class scores 99% accuracy while catching none of the rare cases. Precision, recall, F1 and ROC AUC computed for the rare class expose the problem, and a DummyClassifier baseline shows what accuracy means for your class balance.
What is the difference between cross-validation and a single train/test split?
A single split gives one score that depends on which rows happened to land in the test set. K-fold cross-validation trains and scores k times on different partitions and reports the mean and spread, which is a more reliable estimate and shows how much the score varies.
Which class do precision and recall refer to in scikit-learn?
For binary problems, precision_score, recall_score and f1_score report the class given by pos_label, which defaults to 1. If your class of interest is labelled 0, as malignant tumours are in load_breast_cancer, pass pos_label=0 or you will be measuring the wrong class.
What does it mean for a classifier's probabilities to be calibrated?
A calibrated classifier's probabilities match observed frequencies: among all predictions of about 0.8, roughly 80% turn out positive. A model can rank examples well, with a high ROC AUC, and still be badly calibrated. CalibratedClassifierCV fixes this by fitting a sigmoid or isotonic mapping on cross-validated predictions.