Multiclass: Which Method Do You Actually Need?¶
There is no single best multiclass calibration method. There are two regimes, they have different winners, and picking wrong costs roughly a factor of six.
This notebook shows the two regimes on data where the true probabilities are known — so error is measured, not estimated — then shows how to tell which one you are in from your own data.
import matplotlib.pyplot as plt
import numpy as np
from calibre import (
CenteredIsotonicCalibrator,
TemperatureScaler,
classwise_decomposition,
classwise_ece,
miscalibration_profile,
top_label_ece,
)
rng = np.random.default_rng(20260731)
1. Two kinds of miscalibration¶
A model can be miscalibrated in two structurally different ways.
Globally — every class is distorted the same way, typically overconfidence. One number describes the whole problem.
Per class — some classes are over-predicted, others under. No single number can describe it.
Both look similar in aggregate metrics. They need completely different fixes.
def make(kind, n=6000, J=5, seed=0):
"""True probabilities, labels drawn from them, and a miscalibrated model."""
r = np.random.default_rng(seed)
truth = r.dirichlet(np.ones(J) * 0.7, size=n)
y = np.array([r.choice(J, p=t) for t in truth])
if kind == "global":
skew = truth**2.2 # one exponent for every class
else:
skew = truth ** np.linspace(0.6, 2.4, J) # a different exponent per class
return skew / skew.sum(axis=1, keepdims=True), y, truth
P_global, y_g, truth_g = make("global")
P_perclass, y_p, truth_p = make("perclass")
print("global : same exponent applied to all 5 classes")
print("perclass : exponents 0.6 ... 2.4, one per class")
global : same exponent applied to all 5 classes
perclass : exponents 0.6 ... 2.4, one per class
A note on the first line, because it is easy to get wrong and it invalidates the
whole comparison if you do. Generating miscalibration as truth ** k renormalised is
a temperature distortion — temperature scaling inverts it exactly, by construction. Using
only that generator hands temperature scaling a rigged win. The per-class generator uses
a different exponent per column, which no single temperature can express.
2. The measurement that settles it¶
With the truth in hand we can measure error directly rather than estimating calibration error from bins.
def per_class_calibrate(P_cal, y_cal, P_test):
"""One CenteredIsotonicCalibrator per class, then renormalise rows."""
out = np.zeros_like(P_test)
for j in range(P_cal.shape[1]):
cal = CenteredIsotonicCalibrator().fit(P_cal[:, j], (y_cal == j).astype(float))
out[:, j] = cal.transform(P_test[:, j])
return out / np.clip(out.sum(axis=1, keepdims=True), 1e-12, None)
def compare(P, y, truth, label):
half = len(y) // 2
cal, test = slice(0, half), slice(half, None)
rows = [("uncalibrated", P[test])]
rows.append(
("temperature", TemperatureScaler().fit(P[cal], y[cal]).transform(P[test]))
)
rows.append(("per-class (CIR)", per_class_calibrate(P[cal], y[cal], P[test])))
print(f"\n{label}")
print(f" {'method':18s}{'TRUE error':>12s}{'accuracy':>11s}")
for name, Q in rows:
err = np.abs(Q - truth[test]).mean()
acc = (Q.argmax(1) == y[test]).mean()
print(f" {name:18s}{err:12.5f}{acc:11.4f}")
compare(P_global, y_g, truth_g, "GLOBAL distortion")
compare(P_perclass, y_p, truth_p, "PER-CLASS distortion")
GLOBAL distortion
method TRUE error accuracy
uncalibrated 0.08240 0.4993
temperature 0.00009 0.4993
per-class (CIR) 0.01900 0.4963
PER-CLASS distortion
method TRUE error accuracy
uncalibrated 0.10527 0.4367
temperature 0.08471 0.4367
per-class (CIR) 0.01890 0.4990
Two things to read off that.
The winner flips. Temperature scaling is ~6x better under a global distortion and close to useless under a per-class one. Per-class calibration is the reverse.
Temperature scaling never changes accuracy. It is monotone in the logits, so the predicted class is fixed by construction. Under the per-class distortion, per-class calibration gains accuracy — reordering is exactly what fixes differently-distorted classes, and a one-parameter method cannot do it.
3. Telling which regime you are in¶
You do not have the true probabilities on real data. But you do not need them: the spread of miscalibration across classes distinguishes the regimes.
miscalibration_profile computes per-class MCB — the CORP miscalibration component —
and reports its coefficient of variation.
for label, P, y in (("GLOBAL", P_global, y_g), ("PER-CLASS", P_perclass, y_p)):
prof = miscalibration_profile(P, y)
print(
f"{label:10s} spread={prof['spread']:.2f} "
f"per-class MCB x1000 = {np.round(prof['mcb'] * 1000, 2)}"
)
print(f" {prof['reading']}\n")
GLOBAL spread=0.06 per-class MCB x1000 = [13.58 11.91 11.31 12.51 12.89]
Miscalibration is spread evenly across classes (spread 0.06). A single global correction such as TemperatureScaler is likely to be enough, and it cannot hurt accuracy.
PER-CLASS spread=0.99 per-class MCB x1000 = [55.22 3.31 3.4 12.85 21.87]
Miscalibration is concentrated in classes 0, 4, 3 (spread 0.99). A one-parameter method applies the same correction to every class and cannot express this; per-class calibration is likely to help.
fig, axes = plt.subplots(1, 2, figsize=(11, 3.6), sharey=True)
for ax, (label, P, y) in zip(
axes, (("global", P_global, y_g), ("per-class", P_perclass, y_p))
):
prof = miscalibration_profile(P, y)
ax.bar(np.arange(len(prof["mcb"])), prof["mcb"] * 1000, color="steelblue")
ax.set_title(f"{label} distortion (spread {prof['spread']:.2f})")
ax.set_xlabel("class")
axes[0].set_ylabel("miscalibration (MCB x1000)")
plt.tight_layout()
plt.show()
Flat bars mean one global correction will do. Uneven bars mean the fix differs by class, and no single parameter can express it.
4. Where the miscalibration lives¶
classwise_decomposition gives the full CORP split per class. MCB is what
recalibration would recover, DSC is the discrimination your scores already provide,
UNC is the difficulty of that class.
parts = classwise_decomposition(P_perclass, y_p)
print(f"{'class':>6s}{'Brier':>9s}{'MCB':>9s}{'DSC':>9s}{'UNC':>9s}")
for k, d in enumerate(parts):
print(f"{k:6d}{d['mean_score']:9.4f}{d['MCB']:9.4f}{d['DSC']:9.4f}{d['UNC']:9.4f}")
ok = all(abs(d["mean_score"] - (d["MCB"] - d["DSC"] + d["UNC"])) < 1e-12 for d in parts)
print(f"\nidentity mean_score = MCB - DSC + UNC holds exactly in every class: {ok}")
class Brier MCB DSC UNC
0 0.1814 0.0552 0.0343 0.1604
1 0.1284 0.0033 0.0361 0.1612
2 0.1242 0.0034 0.0379 0.1587
3 0.1368 0.0128 0.0365 0.1604
4 0.1467 0.0219 0.0345 0.1593
identity mean_score = MCB - DSC + UNC holds exactly in every class: True
Two scalar summaries, for when you need one number. classwise_ece averages the
one-vs-rest error over classes; top_label_ece asks only whether the predicted
class’s confidence is right. Both use the bias-aware, tie-safe estimators, so
neither inherits the plugin bias that grows with the bin count.
print(f"{'model':22s}{'classwise ECE':>15s}{'top-label ECE':>15s}")
for name, P, y in (
("global distortion", P_global, y_g),
("per-class distortion", P_perclass, y_p),
):
print(f"{name:22s}{classwise_ece(P, y):15.4f}{top_label_ece(P, y):15.4f}")
model classwise ECE top-label ECE
global distortion 0.1038 0.2032
per-class distortion 0.1137 0.1482
5. What temperature scaling costs you¶
It preserves each row’s class ordering — the predicted class never moves. But it does not preserve the ordering of people within a class, because the softmax denominator makes every calibrated probability depend on the whole row.
If you rank individuals by their probability of a given class — triage, prioritisation, any ranked list — that reordering is real, and no standard calibration metric shows it.
def within_class_inversions(P_before, Q_after):
bad = tot = 0
for j in range(Q_after.shape[1]):
order = np.argsort(P_before[:, j], kind="mergesort")
d = np.diff(Q_after[order, j])
bad += int((d < -1e-9).sum())
tot += d.size
return 100.0 * bad / tot
half = len(y_g) // 2
Q_temp = TemperatureScaler().fit(P_global[:half], y_g[:half]).transform(P_global[half:])
Q_pc = per_class_calibrate(P_global[:half], y_g[:half], P_global[half:])
print(
f"predicted class changed by temperature scaling: "
f"{(Q_temp.argmax(1) != P_global[half:].argmax(1)).sum()} rows"
)
print(
f"within-class pairs inverted, temperature : "
f"{within_class_inversions(P_global[half:], Q_temp):.1f}%"
)
print(
f"within-class pairs inverted, per-class : "
f"{within_class_inversions(P_global[half:], Q_pc):.1f}%"
)
predicted class changed by temperature scaling: 0 rows
within-class pairs inverted, temperature : 49.2%
within-class pairs inverted, per-class : 45.2%
Zero rows change their predicted class, and roughly half of all within-class pairs get reordered. Those two facts are both true at once, and only the first one is usually reported.
Summary¶
Diagnose before choosing.
miscalibration_profiletells you whether your miscalibration is global or per-class. Spread near 0.13 means global; 0.4 and above means per-class.Global →
TemperatureScaler. One parameter, cannot overfit, accuracy exactly preserved, ~6x better than per-class methods in this regime.Per-class → per-class calibration. Temperature scaling barely helps, and cannot change accuracy even when reordering is the fix.
Measure on held-out or out-of-fold predictions. In-sample miscalibration for an isotonic-family calibrator is identically zero — see the binary evaluation notebook.
Know what you traded. Temperature scaling preserves predicted classes and destroys within-class ranking. Both matter, depending on what you do with the output.
Only class-wise calibration is targeted here. Canonical calibration — requiring whole probability vectors to be jointly correct — is infeasible to verify beyond four or five classes.