Probability Calibration
A probability model is calibrated when cases assigned probability 0.8 are positive 80% of the time in the population, and the same agreement holds at its other probability levels. A finite sample only estimates this agreement. Calibration differs from ranking: a model can rank cases well while systematically overstating their probabilities. The classification-metrics article uses probabilities to compare decision costs; here we examine whether those probabilities are reliable.
Reading a reliability curve
A reliability curve groups predictions into bins and plots each bin’s mean predicted probability on the horizontal axis and observed positive fraction on the vertical axis. Points above the diagonal indicate under-prediction, and points below indicate over-prediction. For example, a bin averaging 0.25 with 12 positives among 100 cases has an observed frequency of 0.12 and a gap of −0.13. Bin frequencies fluctuate with sample size; a point off the diagonal is an estimate, not proof of a population gap. Report bin counts when assessing how precise those estimates are.
import numpy as np
from sklearn.datasets import make_classification
from sklearn.naive_bayes import GaussianNB
from sklearn.calibration import calibration_curve
from sklearn.model_selection import train_test_split
rng = np.random.default_rng(0)
X0, y = make_classification(n_samples=30000, n_features=8, n_informative=5,
n_redundant=0, class_sep=0.8, random_state=0)
# 20 noisy copies violate naive Bayes conditional independence
X = np.hstack([X0, X0[:, [0]] + rng.normal(0, 0.35, (30000, 20))])
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.5, random_state=0)
p = GaussianNB().fit(Xtr, ytr).predict_proba(Xte)[:, 1]
observed, predicted = calibration_curve(yte, p, n_bins=10, strategy="uniform")
counts = np.bincount(np.searchsorted(np.linspace(0, 1, 11)[1:-1], p), minlength=10)
for count, o, q in zip(counts[counts > 0], observed, predicted):
print(f"n {count:5d} predicted {q:.4f} observed {o:.4f} gap {o - q:+.4f}")
# n 2840 predicted 0.0301 observed 0.0359 gap +0.0059
# n 1100 predicted 0.1472 observed 0.1082 gap -0.0390
# n 894 predicted 0.2499 observed 0.1230 gap -0.1269
# n 827 predicted 0.3493 observed 0.1995 gap -0.1498
# n 896 predicted 0.4515 observed 0.2980 gap -0.1535
# n 1054 predicted 0.5521 observed 0.4858 gap -0.0663
# n 1367 predicted 0.6540 observed 0.6452 gap -0.0088
# n 1962 predicted 0.7518 observed 0.8048 gap +0.0530
# n 2163 predicted 0.8517 observed 0.9011 gap +0.0493
# n 1897 predicted 0.9386 observed 0.9805 gap +0.0419
Gaussian naive Bayes models features as conditionally independent given the class. The 20 noisy copies are dependent on the original feature, so multiplying their likelihood contributions treats related measurements as separate evidence. They are not 21 identical likelihood factors, and this experiment does not isolate duplication as the sole cause of the curve. In the displayed bins, predicted probabilities around 0.15–0.55 exceed observed frequencies by as much as 0.154, while bins above 0.75 underpredict by about 0.04–0.05. A signed average gap could cancel opposing errors; an absolute-gap summary would not cancel them across bins.
Brier, log loss, and what they weigh
| Score | Definition | Behavior |
|---|---|---|
| Brier | \(\frac{1}{n}\sum (p_i-y_i)^2\) | bounded in \([0,1]\); a confident mistake costs at most 1 |
| Log loss | \(-\frac{1}{n}\sum [y_i \log p_i + (1-y_i)\log(1-p_i)]\) | unbounded; a confident mistake can cost arbitrarily much |
Both are strictly proper scoring rules: their population expected loss is uniquely minimized by the true conditional probability. That property specifies a target; finite data, a restricted model, or regularization can still produce miscalibrated fitted probabilities. Brier and log loss assess probability quality, but neither isolates calibration from the model’s ability to distinguish cases. Use reliability diagnostics alongside them, and evaluate decision costs separately when those costs define the application.
Log loss penalizes extreme errors more heavily. For an actual positive, a report of 0.01 gives Brier loss 0.9801 and log loss about 4.605; at 0.000001 these become about 1 and 13.816. Their numerical scales differ, so ratios between the two metrics are not meaningful. Neither percentage alone specifies an application’s cost.
Platt scaling and isotonic regression
A calibrator is another fitted model that maps a base score to a probability. Fit it on predictions for cases excluded from base-model fitting, then evaluate it on separate data. Sigmoid calibration fits \(r(s)=1/(1+\exp[-(as+b)])\); isotonic regression fits a non-decreasing mapping, allowing more flexibility. Sigmoid calibration is strictly increasing when \(a>0\); its fitted slope is not constrained to be positive in this implementation. A decreasing map would reverse the ranking. Isotonic fits pooled levels at observed scores; scikit-learn interpolates between fitted thresholds for new scores.
from sklearn.calibration import CalibratedClassifierCV
from sklearn.ensemble import RandomForestClassifier
from sklearn.frozen import FrozenEstimator # sklearn >= 1.6; replaces cv="prefit"
from sklearn.metrics import brier_score_loss, log_loss, roc_auc_score
Xcal, Xte2, ycal, yte2 = train_test_split(Xte, yte, test_size=0.5, random_state=0)
rf = RandomForestClassifier(n_estimators=300, random_state=0, n_jobs=-1).fit(Xtr, ytr)
p_raw = rf.predict_proba(Xte2)[:, 1]
print(f"{'raw':9s} AUC {roc_auc_score(yte2, p_raw):.6f}"
f" Brier {brier_score_loss(yte2, p_raw):.4f} logloss {log_loss(yte2, p_raw):.4f}")
for method in ("sigmoid", "isotonic"):
cal = CalibratedClassifierCV(FrozenEstimator(rf), method=method).fit(Xcal, ycal)
p = cal.predict_proba(Xte2)[:, 1]
print(f"{method:9s} AUC {roc_auc_score(yte2, p):.6f}"
f" Brier {brier_score_loss(yte2, p):.4f} logloss {log_loss(yte2, p):.4f}")
# raw AUC 0.974098 Brier 0.0690 logloss 0.2500
# sigmoid AUC 0.974098 Brier 0.0590 logloss 0.1996
# isotonic AUC 0.973380 Brier 0.0587 logloss 0.2031
On the separate evaluation subset, sigmoid reduces log loss from 0.2500 to 0.1996, about 20%, and preserves the reported AUC. Its increasing fitted map preserves ranking mathematically, although numerical saturation can create ties. Isotonic has the slightly lower Brier score here, 0.0587 against 0.0590, but higher log loss, 0.2031 against 0.1996. Its AUC falls from 0.974098 to 0.973380 as scores are pooled into ties. Such ties can raise or lower AUC depending on which positive–negative pairs are merged; the decrease is an observation from this run.
The code requires scikit-learn 1.6 or later for FrozenEstimator, which keeps the already-fitted forest fixed while the calibrator is fitted. Here the original split supplies 15,000 base-training rows; the remaining rows are divided into 7,500 calibration and 7,500 evaluation rows. The examples reuse data for demonstration, so they are not a sequence of independent final tests. In an actual development workflow, choose the calibration method without repeatedly consulting the final test set. Passing an unfitted estimator with integer cv supports cross-validated calibration; with ensemble=True, it fits a base-model/calibrator pair on complementary portions in each fold and averages their predictions. The split must still respect groups and time.
ECE is not a single number
Expected calibration error (ECE) here is the sample-weighted mean absolute bin gap: \(\sum_b(n_b/n)|\bar y_b-\bar p_b|\). A bin containing 20% of the cases with a gap of 0.1 contributes 0.02. Uniform bins divide the probability range into equal widths; quantile bins aim for similar case counts. Repeated scores can make quantile edges coincide, leaving fewer nonempty bins than requested.
def ece(y, p, n_bins, strategy="uniform"):
edges = (np.linspace(0, 1, n_bins + 1) if strategy == "uniform"
else np.quantile(p, np.linspace(0, 1, n_bins + 1)))
idx = np.clip(np.searchsorted(edges, p, side="right") - 1, 0, n_bins - 1)
return sum((idx == b).mean() * abs(y[idx == b].mean() - p[idx == b].mean())
for b in range(n_bins) if (idx == b).sum())
p_nb = GaussianNB().fit(Xtr, ytr).predict_proba(Xte)[:, 1]
for nb in (5, 10, 20, 50, 100, 500):
print(f"bins {nb:4d} ECE uniform {ece(yte, p_nb, nb):.4f}"
f" ECE quantile {ece(yte, p_nb, nb, 'q'):.4f}")
# bins 5 ECE uniform 0.0499 ECE quantile 0.0530
# bins 10 ECE uniform 0.0538 ECE quantile 0.0534
# bins 20 ECE uniform 0.0564 ECE quantile 0.0561
# bins 50 ECE uniform 0.0573 ECE quantile 0.0566
# bins 100 ECE uniform 0.0586 ECE quantile 0.0580
# bins 500 ECE uniform 0.0692 ECE quantile 0.0712
On these predictions, uniform-bin ECE rises from 0.0499 with five bins to 0.0692 with 500, so the five-bin figure is about 28% lower. This change does not identify a single cause. Coarse bins can hide variation within a bin, while fine bins have fewer observations and noisier positive fractions. Finite-sample variation and binning can bias ECE estimates; even calibrated probabilities can yield nonzero empirical ECE. The opposing gaps below 0.6 and above 0.75 are mostly in different coarse bins, so that pattern alone does not explain the increase. With bins that are not nested, changing their number can also change their boundaries; ECE need not rise monotonically in general.
Report the bin count, edge strategy, and evaluation sample size with ECE. Inspect the reliability curve and the number of observations supporting each region. Brier and log loss avoid binning, but they measure overall probability quality and therefore answer a different question. A useful evaluation can include both kinds of evidence.
Two models, one dataset, different calibration
The next example fits gradient boosting and a random forest on the same credit-default training data and uses identical probability-band boundaries. The models differ in both ranking and probability estimation, and their bands contain different accounts. The fairlearn dataset fetch needs internet access on its first call.
import numpy as np
from fairlearn.datasets import fetch_credit_card
from sklearn.model_selection import train_test_split
from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier
from sklearn.metrics import roc_auc_score, brier_score_loss
cc = fetch_credit_card(as_frame=True)
X = cc.data.astype(float).to_numpy()
y = cc.target.astype(int).to_numpy()
Xtr, Xte, ytr, yte = train_test_split(
X, y, test_size=0.3, random_state=0, stratify=y)
hgb = HistGradientBoostingClassifier(random_state=0).fit(Xtr, ytr).predict_proba(Xte)[:, 1]
rf = RandomForestClassifier(n_estimators=200, random_state=0,
n_jobs=-1).fit(Xtr, ytr).predict_proba(Xte)[:, 1]
for name, q in (("HistGradientBoosting", hgb), ("RandomForest", rf)):
print(f"{name:22s} AUC {roc_auc_score(yte, q):.4f} Brier {brier_score_loss(yte, q):.4f}")
for lo, hi in [(0.0, 0.1), (0.1, 0.3), (0.3, 0.5), (0.5, 0.7), (0.7, 1.01)]:
k = (q >= lo) & (q < hi)
if k.sum() > 30:
print(f" band {lo:.1f}-{hi:.1f} n {k.sum():5,} predicted {q[k].mean():.4f}"
f" observed {yte[k].mean():.4f} gap {yte[k].mean() - q[k].mean():+.4f}")
# HistGradientBoosting AUC 0.7765 Brier 0.1347
# band 0.0-0.1 n 2,578 predicted 0.0656 observed 0.0671 gap +0.0015
# band 0.1-0.3 n 4,431 predicted 0.1681 observed 0.1690 gap +0.0009
# band 0.3-0.5 n 938 predicted 0.3775 observed 0.3785 gap +0.0009
# band 0.5-0.7 n 587 predicted 0.6113 observed 0.6201 gap +0.0088
# band 0.7-1.0 n 466 predicted 0.7588 observed 0.7511 gap -0.0078
# RandomForest AUC 0.7627 Brier 0.1392
# band 0.0-0.1 n 2,427 predicted 0.0572 observed 0.0746 gap +0.0174
# band 0.1-0.3 n 4,314 predicted 0.1763 observed 0.1627 gap -0.0136
# band 0.3-0.5 n 1,117 predicted 0.3861 observed 0.3491 gap -0.0370
# band 0.5-0.7 n 707 predicted 0.5944 observed 0.5573 gap -0.0371
# band 0.7-1.0 n 435 predicted 0.7907 observed 0.7448 gap -0.0459
The boosted model’s five observed band gaps are small, with maximum absolute gap 0.0088. This is evidence at the chosen bin resolution on one test sample, not calibration everywhere. Broad bands can hide within-band patterns or subgroup differences. Log-loss training targets conditional probabilities, but does not guarantee a calibrated fitted model.
The forest underpredicts in the lowest band by 0.0174 and overpredicts in the other four bands by 0.0136–0.0459. In its top band, the average report is 0.7907 and the observed default fraction is 0.7448. These are empirical default frequencies, not classification accuracies. Whether that band drives a decision depends on the policy and its costs.
The boosted model has better AUC and Brier scores in this run. Their gaps are on different scales and cannot be compared as measures of how much ranking or calibration deteriorated. Similar overall AUCs do not establish similar precision among the top few percent; evaluate that subset directly if it is the operating point. For expected monetary loss, case probabilities must be combined with the relevant loss amounts. Positive and negative probability errors may offset or accumulate depending on their signs and those amounts; these band averages do not determine the portfolio error.
References
- Roelofs et al. (2022). Mitigating Bias in Calibration Error Estimation
- Stephenson, Coelho, and Jolliffe (2008). Two Extra Components in the Brier Score Decomposition
Discover more from Insightful Data Lab
Subscribe to get the latest posts sent to your email.
