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

ScoreDefinitionBehavior
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


Discover more from Insightful Data Lab

Subscribe to get the latest posts sent to your email.

Similar Posts

Questions, corrections, or additional insights?

This site uses Akismet to reduce spam. Learn how your comment data is processed.