A model score is not a probability until you calibrate it.
Why a classifier with a usable AUC can still mislead the decision layer that reads its scores, and how expected calibration error and isotonic regression measure and close the gap.
The prototype behind this note, an attrition prediction pipeline, was built in 2024 and 2025 as a master's dissertation, before Kernwise existed. Events arrived on Kafka, XGBoost and LightGBM models scored them, SHAP produced the reasons behind each score, and Pega Customer Decision Hub chose the action. On synthetic data generated for the evaluation, the model reached an AUC of 0.725. Its expected calibration error was 0.115 before isotonic calibration and 0.045 after.
The first number is the one people ask about. In a design where a score is handed to something that acts on it, the second matters at least as much. This note explains why, and shows how little code it takes to measure the problem and correct it. The project is listed on the work page; the code here is written for this note, not taken from the dissertation.
What AUC measures
AUC is the probability that a randomly chosen positive case is scored above a randomly chosen negative one. It is a statement about order and nothing else. Square every score, halve every score, pass them all through any increasing function, and the AUC does not move.
That makes AUC the right metric for a question like "which hundred people should someone look at first". It says nothing about the question a decision layer actually asks: when the model says 0.6, do about 60 percent of those people leave?
Whether a boosted model's scores match observed frequencies depends on how it was trained, and two common choices break the match. Attrition data is imbalanced, and the usual remedy, upweighting the rare class, is aimed at ranking and inflates every probability as a side effect. The XGBoost documentation is direct about it: if you care about predicting the right probability, you cannot rebalance the dataset. The second is overfitting. A model that has partly memorised its training rows is more confident on new rows than it has any right to be.
Where a miscalibrated score does damage
A ranked list for a human reviewer survives miscalibration. The reviewer works down the list and never reads the number as a frequency.
A decision engine does read it that way. Pega Customer Decision Hub prioritises actions by multiplying propensity, context weight, business value and levers. When propensities for competing actions come from different models, an overconfident model wins arbitration it should lose. A rule such as "act above 0.3" selects a different population every time the model is retrained. And an expected-value rule, intervene when the probability of leaving times the cost of losing someone exceeds the cost of the intervention, spends money on people who were never likely to leave.
None of this shows up in AUC. All of it shows up in calibration.
Measuring it
Expected calibration error sorts predictions into bins by predicted probability, compares the average prediction in each bin with the observed rate in that bin, and averages the gaps, weighted by how many predictions each bin holds.
import numpy as np
def expected_calibration_error(y_true, p, n_bins=10):
y_true = np.asarray(y_true, dtype=float)
p = np.asarray(p, dtype=float)
inner_edges = np.linspace(0.0, 1.0, n_bins + 1)[1:-1]
# bin index for each prediction, 0 to n_bins - 1
bins = np.digitize(p, inner_edges, right=True)
ece = 0.0
for b in range(n_bins):
in_bin = bins == b
if in_bin.any():
gap = abs(p[in_bin].mean() - y_true[in_bin].mean())
ece += in_bin.mean() * gap
return eceAn ECE of 0.115 means that, weighted across the score range, the stated probability sits about eleven and a half percentage points away from what actually happens. Two cautions. The number depends on the binning, so report the bin count with it. And a single number hides where the error is: plot predicted against observed for each bin, which scikit-learn's calibration_curve returns directly, and look hardest at the top of the range, because that is where interventions are triggered.
Correcting it with isotonic regression
Isotonic regression learns a step function from raw score to observed frequency under one constraint: it never decreases. It can correct any monotonic distortion, overconfidence at the top and underconfidence at the bottom at the same time, without assuming a shape for either.
It has to be fitted on data the model did not train on. Scores on training rows are more confident than scores on new rows, and a calibrator fitted to them learns the wrong correction. That means three splits: train, calibrate, test.
from sklearn.isotonic import IsotonicRegression
raw_cal = model.predict_proba(X_cal)[:, 1]
calibrator = IsotonicRegression(y_min=0.0, y_max=1.0, out_of_bounds="clip")
calibrator.fit(raw_cal, y_cal)
raw_test = model.predict_proba(X_test)[:, 1]
p_test = calibrator.predict(raw_test)
print(expected_calibration_error(y_test, raw_test)) # before
print(expected_calibration_error(y_test, p_test)) # afterIn a scoring service, out_of_bounds="clip" is not optional. The default is "nan": a raw score outside the range seen during calibration comes back as NaN, and a NaN propensity reaching a decision engine fails somewhere far from its cause. With clipping, an out-of-range score takes the value at the nearest end of the fitted range.
Three more things are worth knowing before choosing isotonic:
- It needs data. scikit-learn's guidance is that isotonic does as well as or better than sigmoid (Platt) scaling once there are more than about 1,000 calibration samples, and is more prone to overfitting below that. With a small calibration set, use sigmoid.
- It introduces ties. A step function maps nearby scores to the same value, so AUC can shift slightly after calibration. If ranking must be preserved exactly, sigmoid is strictly increasing and keeps it.
- In scikit-learn 1.6 and later, wrapping the fitted model in
FrozenEstimatorand passing it toCalibratedClassifierCVwithmethod="isotonic"gives the same result as a single estimator, which helps when the calibrated model has to travel as one object.
Whichever form you use, the calibrator is part of the model. Version them together, retrain them together, and never ship a retrained model with the previous model's calibrator.
Check calibration per group, not only overall
The dissertation generated its synthetic data with demographic stratification so that model quality could be checked per employee band. The standard deviation of AUC across bands was about 0.005, which says ranking quality was even across them.
Ranking per group is half of that check. A model can be well calibrated overall and miscalibrated inside one group, with errors in opposite directions cancelling out in the aggregate. If the decision layer applies one threshold to everyone, calibration within each group is what makes that threshold mean the same thing for each of them. The loop is short:
from sklearn.metrics import roc_auc_score
for band, idx in band_indices.items():
auc = roc_auc_score(y_test[idx], p_test[idx])
ece = expected_calibration_error(y_test[idx], p_test[idx])
print(f"{band:>12} auc={auc:.3f} ece={ece:.3f}")Explanations live on a different scale
SHAP's tree explainer, applied to an XGBoost or LightGBM classifier, explains the raw model output by default, and for binary classification that output is log-odds. For one prediction, the contributions plus a base value add up to that log-odds value. They do not add up to a probability, and certainly not to the calibrated probability, because the isotonic step sits after the model.
The practical rule is to show the calibrated probability as the number and the SHAP contributions as reasons: ranked, with their direction. Because the calibrator never decreases, a feature that pushes the raw score up never pushes the calibrated probability down, so direction survives calibration. Size does not. A label such as "adds 8 points of attrition risk" next to a SHAP value is a conversion the maths does not support.
Before a score leaves the model
- Report ECE, with its bin count, next to AUC.
- Fit the calibrator on a split the model never trained on.
- Clip scores that fall outside the calibration range.
- Version the calibrator with the model it corrects.
- Check calibration per group, not only overall.
- Keep explanations in their own units.
A score that will be multiplied, compared with a threshold or ranked against another model's score is a claim about probability. Calibration is how you make that claim true.