CORP Evaluation¶
A binned reliability diagram makes the analyst pick the bins, and the picture changes with the choice. The CORP approach of Dimitriadis, Gneiting & Jordan (PNAS 2021) removes the choice: conditional event probabilities are estimated by isotonic regression via PAV, so the algorithm determines the number and position of the flat segments and there is nothing left to tune in your favour.
These numbers are pinned against R’s reliabilitydiag on five datasets
(calibrated, overconfident, squashed, heavily tied, rare-event) to 1e-16 or
better.
Reliability Diagram¶
- class calibre.evaluation.ReliabilityDiagram(x, cep, weight)[source]¶
A fitted CORP reliability diagram.
- Parameters:
x (np.ndarray)
cep (np.ndarray)
weight (np.ndarray)
- x¶
The distinct forecast values, ascending.
- cep¶
PAV-recalibrated conditional event probability at each forecast value.
- weight¶
Number of observations carrying each forecast value.
Notes
Points where the diagram is flat are the CORP bins: the PAV algorithm chose them, so no bin count needs to be supplied and none can be tuned to flatter the forecaster.
- as_function()[source]¶
Return the recalibration map as a callable.
- Returns:
- Piecewise-linear interpolation of
the diagram, matching the paper’s display convention. A single distinct forecast value gives a step function, since there is nothing to interpolate between.
- Return type:
PiecewiseLinear or StepFunction
- plot(**kwargs)[source]¶
Draw this diagram.
Convenience wrapper around
calibre.plots.plot_reliability_diagram(), which documents every keyword. Needs matplotlib:pip install 'calibre[plots]'.- Parameters:
**kwargs (Any) – Passed straight through to
plot_reliability_diagram()–ax,bands,density,style,diagonal,colorandlabel.- Returns:
The axes drawn on.
- Return type:
Examples
>>> import matplotlib >>> matplotlib.use("Agg") >>> import numpy as np >>> from calibre import corp_reliability >>> rng = np.random.default_rng(0) >>> x = rng.uniform(0, 1, 200) >>> y = rng.binomial(1, x).astype(float) >>> ax = corp_reliability(x, y).plot(density="none") >>> ax.get_xlabel() 'forecast probability'
- calibre.corp_reliability(x, y, sample_weight=None)[source]¶
Estimate conditional event probabilities by isotonic regression.
This is the CORP reliability diagram: the PAV-recalibrated forecast probabilities plotted against the original forecast values. Unlike a binned diagram it needs no bin count, because PAV determines the number and position of the flat segments itself.
- Parameters:
- Returns:
The fitted diagram.
- Return type:
Examples
>>> import numpy as np >>> from calibre.evaluation import corp_reliability >>> x = np.array([0.2, 0.4, 0.6, 0.8]) >>> y = np.array([0.0, 1.0, 0.0, 1.0]) >>> diagram = corp_reliability(x, y)
The middle pair violates monotonicity, so PAV pools it to its mean:
>>> diagram.cep array([0. , 0.5, 0.5, 1. ])
See also
score_decomposition : The score decomposition built on this estimate. calibre.CenteredIsotonicCalibrator : Recalibration, rather than diagnosis.
Score Decomposition¶
- calibre.score_decomposition(x, y, score='brier', sample_weight=None)[source]¶
Decompose a mean score into miscalibration, discrimination and uncertainty.
Returns the CORP decomposition
mean_score = MCB - DSC + UNC, where the calibrated forecasts are the PAV-recalibrated probabilities and the reference forecast is the marginal event frequency.Read it as:
MCBis what recalibration would save you,DSCis what your forecasts buy over always predicting the base rate, andUNCis the difficulty of the problem, which no forecaster can change.- Parameters:
- Returns:
mean_score,MCB,DSC,UNC.MCBandDSCare non-negative, guaranteed by the optimality of the PAV solution.
- Return type:
- Raises:
ValueError – If
scoreis not a supported proper scoring rule.
Examples
>>> import numpy as np >>> from calibre.evaluation import score_decomposition >>> rng = np.random.default_rng(0) >>> x = rng.uniform(0, 1, 2000) >>> y = rng.binomial(1, x).astype(float)
These forecasts are calibrated by construction, so miscalibration is small while discrimination is substantial:
>>> d = score_decomposition(x, y) >>> bool(d["MCB"] < 0.01), bool(d["DSC"] > 0.05) (True, True)
The identity holds exactly:
>>> bool(abs(d["mean_score"] - (d["MCB"] - d["DSC"] + d["UNC"])) < 1e-12) True
See also
corp_reliability : The recalibration this decomposition is built on.
Uncertainty Bands¶
Both are resampling-based. The paper’s asymptotic route is not implemented.
- calibre.consistency_bands(x, y, level=0.9, n_resamples=1000, random_state=0)[source]¶
Bands showing how a calibrated forecaster’s diagram would scatter.
Outcomes are redrawn as
y* ~ Bernoulli(x), taking the original forecasts at face value, and the diagram is refit each time. The bands therefore sit around the diagonal and answer: if these forecasts were perfectly calibrated, how far from the diagonal would the estimate wander by chance alone? An observed diagram leaving the band is the analogue of a small p-value.Use
confidence_bands()instead for an interval around the estimate.- Parameters:
x (ndarray) – Forecast probabilities.
y (ndarray) – Binary outcomes in
{0, 1}. Used for the grid and for validation; the bands themselves are generated under the calibration hypothesis and do not depend on the observed outcomes.level (float) – Nominal coverage, default 0.9.
n_resamples (int) – Number of resamples, default 1000.
random_state (int | None) – Seed. Defaults to 0 so results are reproducible.
- Returns:
x(the grid),lowerandupper.- Return type:
- Raises:
ValueError – If
levelis outside(0, 1)orn_resamplesis below 2.
Notes
These are pointwise bands, not a simultaneous envelope. Coverage holds at each forecast value separately. It does not hold at all of them at once, and the difference is not subtle: on perfectly calibrated data the observed diagram leaves a nominal 90% band somewhere on essentially every sample. Measured over 150 replications (
tests/test_monte_carlo.py):npointwise coverage
simultaneous coverage
300
90.1%
1.3%
1200
89.6%
0.0%
4800
89.4%
0.0%
So “my curve stayed inside the band, therefore it is calibrated” is a test with a false-positive rate near one. Read the band at a forecast value you care about, or count excursions and compare that count against the nominal miss rate – do not read it as an envelope.
Resampling only. The paper also derives asymptotic bands from isotonic regression theory (a Chernoff limit for continuous forecasts), which is not implemented here; at large sample sizes this function is the expensive option rather than the unavailable one.
- calibre.confidence_bands(x, y, level=0.9, n_resamples=1000, random_state=0)[source]¶
Bands around the estimated conditional event probabilities.
Outcomes are redrawn from the PAV-recalibrated probabilities rather than the original forecasts, so the bands cluster around the CORP estimate and carry the usual frequentist reading: over repeated experiments, about
levelof such bands contain the true conditional event probability.- Parameters:
- Returns:
x(the grid),lowerandupper.- Return type:
- Raises:
ValueError – If
levelis outside(0, 1)orn_resamplesis below 2.
Notes
Pointwise, not simultaneous, exactly as for
consistency_bands(); see the table there.Coverage of the truth is below nominal on small samples. These bands are centred on the PAV-recalibrated estimate, and isotonic regression is biased at finite sample size, so the band is centred slightly off the true conditional event probability curve. Coverage of that true curve, measured against a known data-generating process over 150 replications at a nominal 90%:
ncoverage of the true curve
300
78.2%
1200
86.9%
4800
90.5%
The shortfall is a property of centring on an isotonic fit, not a defect in the resampling, and it vanishes as the centring bias does. Treat a 90% band on a few hundred observations as closer to an 80% one.
Resampling only; see
consistency_bands().
Usage¶
Decomposing a proper score¶
mean_score = MCB - DSC + UNC holds exactly, and both MCB and DSC are
non-negative by construction:
import numpy as np
from calibre import score_decomposition
rng = np.random.default_rng(0)
scores = rng.uniform(0, 1, 3000)
labels = rng.binomial(1, scores).astype(float)
overconfident = np.clip(1.6 * (scores - 0.5) + 0.5, 0, 1)
for name, x in (("honest", scores), ("overconfident", overconfident)):
d = score_decomposition(x, labels)
print(
f"{name:14s} Brier {d['mean_score']:.4f} = "
f"MCB {d['MCB']:.4f} - DSC {d['DSC']:.4f} + UNC {d['UNC']:.4f}"
)
MCB is what recalibration would save you, DSC is what your scores buy
over always predicting the base rate, and UNC is the difficulty of the
problem, which no forecaster can change. A plain Brier score tells you the model
got worse; this tells you which part you can fix.
Measuring honestly¶
Scoring a calibrator on the data it was fit to does not merely flatter it. For any isotonic-family calibrator it reports perfect calibration by construction, because the calibrator and the diagnostic are the same PAV projection and PAV is idempotent:
import numpy as np
from calibre import IsotonicCalibrator, cross_val_calibrate, score_decomposition
rng = np.random.default_rng(0)
scores = rng.uniform(0, 1, 1500)
labels = rng.binomial(1, scores).astype(float)
in_sample = IsotonicCalibrator().fit(scores, labels).transform(scores)
out_of_fold = cross_val_calibrate(IsotonicCalibrator(), scores, labels, cv=5)
print(f"MCB in-sample {score_decomposition(in_sample, labels)['MCB']:.4f}")
print(f"MCB out-of-fold {score_decomposition(out_of_fold, labels)['MCB']:.4f}")
The in-sample number is zero no matter how badly the model generalises. Use
cross_val_calibrate() for any number you intend to believe.
References¶
Dimitriadis, T., Gneiting, T. & Jordan, A. I. (2021), “Stable reliability diagrams for probabilistic classifiers”, PNAS 118(8).