Multiclass Calibration¶
There is no single best multiclass calibration method. There are two regimes with different winners, and picking wrong costs roughly a factor of six. Measured against known true probabilities over 12 seeds on 5 classes:
miscalibration |
uncalibrated |
temperature |
per-class (CIR) |
|---|---|---|---|
global |
0.0821 |
0.0025 |
0.0165 |
class-dependent |
0.1043 |
0.0849 |
0.0173 |
class-dependent + shift |
0.0373 |
0.0276 |
0.0176 |
The winner took 12/12 seeds in every row. So measure before you choose.
Scope is deliberate: only class-wise calibration is targeted. Canonical calibration is infeasible to verify beyond four or five classes.
The Diagnostic¶
- calibre.miscalibration_profile(P, y)[source]¶
Report how miscalibration is distributed across classes.
This is the diagnostic worth running before choosing a multiclass method. If every class is miscalibrated by about the same amount, a single global correction such as temperature scaling can capture it. If the miscalibration is concentrated in particular classes, no one-parameter method can express the fix and per-class calibration is needed.
- Parameters:
- Returns:
mcb(per-class miscalibration),spread(coefficient ofvariation of
mcb),worst_classes(indices ordered by descendingmcb), andreading(a plain-language interpretation).
- Return type:
Notes
Calibrated on synthetic data where the true regime is known,
spreadis about 0.13 when the distortion is global and 0.38-0.92 when it is class-dependent. The 0.25 threshold used forreadingsits between those, but it is a rule of thumb from one study design, not a test with a calibrated false-positive rate. Treat a borderline value as “try both”.Measure this on out-of-fold predictions. Miscalibration estimated on the data a calibrator was fit to is not merely optimistic; for an isotonic-family calibrator it is identically zero.
Examples
>>> import numpy as np >>> from calibre.multiclass import miscalibration_profile >>> rng = np.random.default_rng(0) >>> truth = rng.dirichlet(np.ones(4) * 0.7, size=3000) >>> y = np.array([rng.choice(4, p=t) for t in truth])
Distort each class differently – no single temperature can undo this:
>>> skewed = truth ** np.array([0.6, 1.2, 1.8, 2.4]) >>> P = skewed / skewed.sum(axis=1, keepdims=True) >>> profile = miscalibration_profile(P, y) >>> bool(profile["spread"] > 0.25) True >>> "per-class" in profile["reading"] True
See also
TemperatureScaler : The method to reach for when the spread is small.
Class-wise Evaluation¶
- calibre.classwise_decomposition(P, y, score='brier')[source]¶
Decompose the score of each class one-vs-rest.
Runs the CORP decomposition (
calibre.evaluation.score_decomposition()) separately on each column, treating classkagainst all others. Each entry therefore carries the same guarantees as the binary case: the identitymean_score = MCB - DSC + UNCis exact, andMCBandDSCare non-negative.- Parameters:
- Returns:
- One decomposition per class, in class order. Each has
mean_score,MCB,DSC,UNC.
- Return type:
Notes
This is class-wise calibration, the standard relaxation of the multiclass problem. It says nothing about whether whole probability vectors are jointly calibrated.
Examples
>>> import numpy as np >>> from calibre.multiclass import classwise_decomposition >>> rng = np.random.default_rng(0) >>> truth = rng.dirichlet(np.ones(3), size=1500) >>> y = np.array([rng.choice(3, p=t) for t in truth]) >>> parts = classwise_decomposition(truth, y) >>> len(parts) 3
The identity holds for every class:
>>> all( ... abs(d["mean_score"] - (d["MCB"] - d["DSC"] + d["UNC"])) < 1e-12 ... for d in parts ... ) True
See also
miscalibration_profile : Reads these to say which calibration method to use.
- calibre.classwise_ece(P, y, n_bins=15, estimator='debiased')[source]¶
Average one-vs-rest calibration error across classes.
- Parameters:
P (ndarray) – Predicted probabilities, shape
(n_samples, n_classes).y (ndarray) – Integer class labels.
n_bins (int) – Bins per class, used by the
"debiased"estimator.estimator (str) –
"debiased"(default) subtracts the per-bin Bernoulli variance;"sweep"chooses the bin count by monotonicity instead.
- Returns:
Mean per-class calibration error.
- Return type:
- Raises:
ValueError – If
estimatoris unknown.
Notes
Built on the bias-aware estimators in
calibre.metrics, so the plugin bias that grows with the bin count is corrected, and no bin edge ever splits a group of tied predictions.Examples
>>> import numpy as np >>> from calibre.multiclass import classwise_ece >>> rng = np.random.default_rng(0) >>> truth = rng.dirichlet(np.ones(3), size=2000) >>> y = np.array([rng.choice(3, p=t) for t in truth]) >>> bool(classwise_ece(truth, y) < 0.05) True
- calibre.top_label_ece(P, y, n_bins=15, estimator='debiased')[source]¶
Calibration error of the predicted class’s confidence.
Asks whether, among the cases predicted with confidence
c, the model is right aboutcof the time. This is the weakest and most commonly reported notion of multiclass calibration; a model can score perfectly here while being badly miscalibrated on every non-predicted class.- Parameters:
- Returns:
Calibration error of the top-label confidence.
- Return type:
- Raises:
ValueError – If
estimatoris unknown.
Examples
>>> import numpy as np >>> from calibre.multiclass import top_label_ece >>> rng = np.random.default_rng(0) >>> truth = rng.dirichlet(np.ones(3), size=2000) >>> y = np.array([rng.choice(3, p=t) for t in truth]) >>> bool(top_label_ece(truth, y) < 0.05) True
See also
classwise_ece : The stronger notion, averaging over every class.
- calibre.classwise_reliability(P, y)[source]¶
Build a CORP reliability diagram for each class, one-vs-rest.
- Parameters:
- Returns:
- One diagram per class, in class order. No
bin count to choose.
- Return type:
Examples
>>> import numpy as np >>> from calibre.multiclass import classwise_reliability >>> rng = np.random.default_rng(0) >>> truth = rng.dirichlet(np.ones(3), size=1000) >>> y = np.array([rng.choice(3, p=t) for t in truth]) >>> diagrams = classwise_reliability(truth, y) >>> len(diagrams) 3 >>> all(np.all(np.diff(d.cep) >= -1e-12) for d in diagrams) True
Calibrators¶
- class calibre.TemperatureScaler(max_log_temperature=3.0)[source]¶
Divide the logits by one fitted constant.
The strongest method available when miscalibration is global: on synthetic data with a single global distortion it beat per-class calibration by a factor of six, winning all 12 seeds. It has one parameter, so it cannot overfit a calibration set, and it is monotone in the logits, so the predicted class never changes – accuracy is exactly preserved.
That guarantee is also its ceiling. Because the same temperature is applied to every class, it cannot correct a distortion that differs by class; in that regime it barely improved on doing nothing. Run
miscalibration_profile()first.- Parameters:
max_log_temperature (float) – The search runs over
log(T)in[-b, b]. Widen only if the fitted temperature lands on a bound.
- temperature_¶
Fitted temperature. Above 1 softens the predictions, below 1 sharpens.
- n_features_in_¶
Number of classes seen during fit.
Notes
Temperature scaling preserves the class ordering within each row, but not the ordering of people within a class: the softmax denominator makes each calibrated probability depend on the whole row. Measured at 49.6% of adjacent within-class pairs inverted – worse than one-vs-rest calibration followed by normalisation. If you rank people by their probability of a given class, that reordering is real and no standard calibration metric reveals it.
Examples
>>> import numpy as np >>> from calibre.multiclass import TemperatureScaler >>> rng = np.random.default_rng(0) >>> truth = rng.dirichlet(np.ones(4) * 0.7, size=2000) >>> y = np.array([rng.choice(4, p=t) for t in truth])
An overconfident model, sharpened globally:
>>> sharp = truth ** 2.2 >>> P = sharp / sharp.sum(axis=1, keepdims=True) >>> scaler = TemperatureScaler().fit(P, y)
It recovers a temperature above 1, softening the predictions back:
>>> bool(scaler.temperature_ > 1.5) True
And the predicted class is untouched, by construction:
>>> Q = scaler.transform(P) >>> bool(np.all(Q.argmax(axis=1) == P.argmax(axis=1))) True
See also
miscalibration_profile : Tells you whether this method suits your data.
- fit(P, y)[source]¶
Fit the temperature by minimising negative log-likelihood.
- Parameters:
- Returns:
self.
- Return type:
- Raises:
ValueError – If
max_log_temperatureis not positive, or the inputs are malformed.
- transform(P)[source]¶
Apply the fitted temperature.
- Parameters:
P (ndarray) – Predicted probabilities.
- Returns:
- Calibrated probabilities, rows summing to 1. Every row’s
argmax is unchanged.
- Return type:
ndarray
- Raises:
AttributeError – If called before
fit().
Usage¶
Choosing a method¶
import numpy as np
from calibre import miscalibration_profile
rng = np.random.default_rng(0)
truth = rng.dirichlet(np.ones(5) * 0.7, size=4000)
labels = np.array([rng.choice(5, p=t) for t in truth])
# Each class distorted by a different exponent.
skewed = truth ** np.linspace(0.6, 2.4, 5)
scores = skewed / skewed.sum(axis=1, keepdims=True)
profile = miscalibration_profile(scores, labels)
print(f"spread {profile['spread']:.2f}")
print(profile["reading"])
A spread near 0.13 means the miscalibration is even across classes and
TemperatureScaler will likely capture it. A spread of 0.4 and
above means it is concentrated in particular classes, and a one-parameter method
applied to every class cannot express that fix.
What temperature scaling costs¶
TemperatureScaler never changes the predicted class, so
accuracy is exactly preserved — this is asserted on every row in the test suite.
But it does reorder cases within a class, at 49.6% of adjacent pairs in our
measurements. No standard metric reveals this. If you rank individuals by their
probability of a given class, that reordering is real.