Choosing a binary threshold on held-out data

A classifier’s default 0.5 cutoff is rarely the one that maximizes F1 when positives are rare. optimize_thresholds finds the F1-maximizing cutoff exactly, but only data it did not see can say how well that cutoff does: tuning on the test set and scoring on it reports that set’s own maximum.

Three splits: fit the model, choose the cutoff, measure

import matplotlib.pyplot as plt
import numpy as np
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score
from sklearn.model_selection import train_test_split

from optimal_cutoffs import optimize_thresholds

X, y = make_classification(
    n_samples=6000, weights=[0.9], class_sep=0.8, flip_y=0.02, random_state=0
)
X_train, X_rest, y_train, y_rest = train_test_split(
    X, y, test_size=0.5, stratify=y, random_state=0
)
X_val, X_test, y_val, y_test = train_test_split(
    X_rest, y_rest, test_size=0.5, stratify=y_rest, random_state=0
)
model = LogisticRegression().fit(X_train, y_train)
p_val = model.predict_proba(X_val)[:, 1]
p_test = model.predict_proba(X_test)[:, 1]

tuned = optimize_thresholds(y_val, p_val, metric="f1")
peeked = optimize_thresholds(y_test, p_test, metric="f1")

print(f"positives: {y_test.mean():.1%} of the test set")
print(f"{'cutoff':<28} {'value':>6} {'test F1':>8}")
for label, cutoff in [
    ("default", 0.5),
    ("tuned on validation", tuned.threshold),
    ("tuned on test (optimistic)", peeked.threshold),
]:
    print(f"{label:<28} {cutoff:6.3f} {f1_score(y_test, p_test > cutoff):8.3f}")
positives: 10.7% of the test set
cutoff                        value  test F1
default                       0.500    0.424
tuned on validation           0.239    0.529
tuned on test (optimistic)    0.206    0.549

F1 against the cutoff

The validation and test curves peak in different places; the gap between the optimistic row above and the validation-tuned row is how much tuning on the test set would overstate F1.

grid = np.linspace(0.01, 0.99, 197)
fig, ax = plt.subplots(figsize=(7, 3.5))
ax.plot(grid, [f1_score(y_val, p_val > t) for t in grid], label="validation")
ax.plot(grid, [f1_score(y_test, p_test > t) for t in grid], label="test")
ax.axvline(0.5, color="gray", linestyle=":", label="default 0.5")
ax.axvline(tuned.threshold, color="black", linestyle="--", label="tuned")
ax.set_xlabel("cutoff")
ax.set_ylabel("F1")
ax.legend()
fig.tight_layout()
plt.show()
plot binary threshold

Costs instead of F1

When a missed positive costs ten times a false alarm and the probabilities are calibrated, the cost-minimizing cutoff is known in closed form: 1 / (1 + 10). mode="bayes" returns it without looking at labels.

bayes = optimize_thresholds(
    None, p_test, mode="bayes", utility={"tp": 0, "tn": 0, "fp": -1, "fn": -10}
)


def mean_cost(cutoff):
    pred = p_test > cutoff
    return ((pred & (y_test == 0)).sum() + 10 * (~pred & (y_test == 1)).sum()) / len(
        y_test
    )


print(f"Bayes cutoff {bayes.threshold:.4f} (1/11 = {1 / 11:.4f})")
print(f"mean cost per case: {mean_cost(0.5):.3f} at 0.5, ", end="")
print(f"{mean_cost(bayes.threshold):.3f} at the Bayes cutoff")
Bayes cutoff 0.0909 (1/11 = 0.0909)
mean cost per case: 0.751 at 0.5, 0.328 at the Bayes cutoff

Total running time of the script: (0 minutes 0.476 seconds)

Gallery generated by Sphinx-Gallery