Segmented calibration: shrunken per-segment offsets¶
A single calibration map is often right on average but wrong for a specific slice of the
portfolio (a product line, a vintage, a geography) because that slice's true residual
miscalibration differs from the pooled average the map was fit to correct. Fitting one offset
per segment from scratch overfits: a 20-obligor segment's offset MLE has a huge standard
error and mostly reflects sampling noise, not a real segment effect. Ignoring segments
entirely (complete pooling) throws away real heterogeneity when it exists. SegmentedCalibrator
sits between the two: one shared base map fit on all the data, plus a shrunken per-segment
logit offset that interpolates between "trust this segment's own data" and "trust the pooled
average", governed by how much genuine between-segment heterogeneity the data supports.
Design rationale¶
Shrinking a per-segment offset toward the shared base map, rather than shrinking a full
per-segment refit toward a shared model, keeps four properties for free. The result stays
monotone: a level shift on the logit scale cannot un-sort scores that base_ already
sorted. It stays invertible: inverting a scalar shift through base_'s own exact inverse
is closed-form for any base_ (see Protocol notes below), whereas inverting a shrunk
nonlinear per-segment map has no general closed form. It stays serializable: the
per-segment state is six numbers (delta_hat, se, delta_tilde, n, events, shrink)
next to the one shared base_, not a second full calibrator's worth of parameters per
segment. And it stays auditable per segment: interpret() reads off, for any segment,
exactly how much of its own signal survived shrinkage versus how much fell back to the shared
map. A full per-segment refit (an independent BetaCalibrator, say, fit per segment, with
its parameters shrunk toward the pooled fit) does not shrink naturally to a well-defined
"no effect" point in parameter space, does not preserve monotonicity or exact invertibility
under shrinkage, and loses the one-number-per-segment audit trail; that design is out of
scope here.
The model¶
SegmentedCalibrator fits a shared base calibrator (default BetaCalibrator()) on the full
dataset, exactly as if segments did not exist:
For each segment \( g \), it then fits the offset-only logistic MLE
(probcal.offset.estimate_offset) of the segment's residual log-odds shift against \( p_0 \):
\( \hat\delta_g \) is the natural, unbiased read of "how far off is base for segment \( g \)
specifically", but for a small segment its standard error is large, and using it directly
(no pooling) would put wide, noisy jumps into predictions for exactly the customers with the
least data behind them.
Empirical-Bayes shrinkage (DerSimonian-Laird)¶
Treat the segment-level MLEs \( \hat\delta_g \) as noisy measurements of true, unknown per-segment effects drawn from a common population with variance \( \tau^2 \): the classic random-effects setup, here across segments instead of across independent studies (DerSimonian & Laird, 1986). \( \tau^2 \) is estimated by their method-of-moments estimator, restricted to the segments with a finite standard error (a single-class segment has no MLE, see below, and contributes nothing to \( \tau^2 \)):
with \( G \) the number of segments with a finite standard error; \( \tau^2 = 0 \) outright when \( G < 2 \) (nothing to estimate heterogeneity from). Each segment's shrunk offset is then the classic empirical-Bayes (precision-weighted) combination of its own estimate and the population value 0 (the base map is already the pooled central estimate, so the population mean of the residual offsets is 0 by construction):
A small, noisy segment (large \( \widehat{\mathrm{se}}_g \)) has \( \text{shrink}_g \) near 0 and is pulled almost entirely back to the base map; a large, precise segment (small \( \widehat{\mathrm{se}}_g \)) keeps most of its own estimate. When \( \tau^2 = 0 \) (no detected heterogeneity beyond sampling noise), every segment shrinks fully to 0: complete pooling, recovered exactly. Prediction applies the shrunk offset on the logit scale:
A segment with only one outcome class has no offset MLE (estimate_offset raises
ValueError, since the score equation has no interior root); SegmentedCalibrator records
it as \( \hat\delta_g = 0 \), \( \widehat{\mathrm{se}}_g = \infty \), which shrinks fully
(\( \tau^2 / (\tau^2 + \infty) = 0 \)). That is the honest reading, since an
infinite-variance estimate carries zero weight in the pooling.
Unseen segments and the Chain limitation¶
fit and predict_proba add a keyword-only segments argument on top of the base
calibrator signature: segments=None at fit time collapses to one segment "__all__"
(so the zero-argument protocol call SegmentedCalibrator().fit(s, y) still works), and
segments=None at predict time returns the plain base map (delta=0, no segment-specific
adjustment) rather than raising, since there is no segment information to look up with.
A label present in segments at predict time but never seen at fit time is handled by the
constructor's unseen policy: "global" (default) applies delta=0; "raise" raises
ValueError, for deployments where an unrecognized segment must not silently fall back.
Labels are compared as strings (_coerce_segments calls .astype(str)): fitting with
integer labels 0, 1 stores them as "0", "1", but predicting with float labels 0.0,
1.0 looks up "0.0", "1.0". That is a silent mismatch, since every row then looks
"unseen" and, under the default unseen="global", falls back to the base map without
raising. Pass segments with the same representation at fit and predict time (cast to
str yourself if the label type is not guaranteed to match). As a backstop, predict_proba and the
segment= inverse paths raise a UserWarning whenever every row of one call is unseen and
unseen="global", the exact failure mode this int/float mismatch produces, and it names the
fitted segments_ so the mismatch is easy to spot; a partial overlap (some rows match,
others are genuinely new segments) stays silent, since that is a legitimate use case.
probcal.chain.Chain has no segments= slot: every stage's predict_proba is called with
no extra arguments. Chain([seg, ...]) therefore always predicts through seg's global map
(segments=None, delta=0); the per-segment shift is never applied inside a Chain. Use
SegmentedCalibrator directly, passing segments=, whenever the per-segment offset must
apply; Chain([seg, offset]) remains useful for composing seg's global map with a
portfolio-wide offset (e.g. monitor.moc_offset_from_counts), same as any other calibrator.
Protocol notes¶
is_monotone_ is base_.is_monotone_, since segmentation adds a level shift per segment,
which does not change monotonicity in the raw score. affine_logit_coeffs_ (the whole-calibrator
property external tooling, e.g. attribution repair, reads) is (a, b + delta_tilde) only when
exactly one segment was fitted and base_ is itself affine on the logit scale; with more than
one segment there is no single affine map for the whole object (each segment has its own
intercept shift), so it is None. This does not affect SegmentedCalibrator's own
interval_inverse/point_inverse, which always invert through base_ directly (composed
with the requested segment's delta_tilde via Chain([base_, LogitOffset(delta=delta_tilde_g)]),
or base_ alone when segment=None or the shrunk offset is exactly 0), and so work for any
number of segments as long as base_ itself has an exact inverse.
Example¶
Three segments of very different size and true miscalibration, micro (n=30), mid
(n=300) and large (n=3000), with base_ fit on the pooled data:
import numpy as np
from probcal import SegmentedCalibrator
from probcal._math import expit, logit
rng = np.random.default_rng(42)
sizes = {"micro": 30, "mid": 300, "large": 3000}
true_deltas = {"micro": -0.6, "mid": 0.12, "large": 0.5}
s_parts, y_parts, seg_parts = [], [], []
for name, n in sizes.items():
s_g = expit(rng.normal(-1.0, 1.0, n))
p_true = expit(logit(s_g) + true_deltas[name])
y_g = (rng.random(n) < p_true).astype(float)
s_parts.append(s_g)
y_parts.append(y_g)
seg_parts.append(np.full(n, name))
scores = np.concatenate(s_parts)
y = np.concatenate(y_parts)
segments = np.concatenate(seg_parts)
cal = SegmentedCalibrator().fit(scores, y, segments=segments)
print(cal.interpret())
p_global = cal.predict_proba(scores) # base map only (delta=0)
p_segmented = cal.predict_proba(scores, segments=segments) # per-segment shrunk offset applied
Interpretation[SegmentedCalibrator]
parameter value
----------- ---------
tau2 0.195006
delta.large 0.0598435
delta.micro -0.51923
delta.mid -0.484786
- tau2 = 0.1950: between-segment heterogeneity variance (DerSimonian-Laird method of moments on the per-segment offset MLEs); tau2 = 0 means complete pooling (every delta_tilde = 0)
- segment 'large': n=3000, events=1259.0, delta_hat=+0.0603, se=0.0402, delta_tilde=+0.0598, shrink=0.992
- segment 'micro': n=30, events=6.0, delta_hat=-1.1095, se=0.4708, delta_tilde=-0.5192, shrink=0.468
- segment 'mid': n=300, events=90.0, delta_hat=-0.5307, se=0.1359, delta_tilde=-0.4848, shrink=0.913
- unseen segments at predict/inverse time use delta=0 (unseen='global')
large is precise (se=0.04) and keeps 99.2% of its own offset MLE. micro is noisy
(se=0.47, only 30 observations) and is pulled halfway back toward the population: its raw
MLE overshoots the true -0.6 at -1.11, but delta_tilde=-0.52 is much closer.
Recovery simulation¶
docs/scripts/segmented_sim.py::recovery(runs, n_per_segment, true_deltas, seed) draws six
segments with true residual offsets spread -0.6 .. +0.6 and sizes 30 .. 3000, fits a
SegmentedCalibrator, and compares the mean squared error (across segments and runs) of
three estimators of the true per-segment offset against no pooling (delta_hat_), complete
pooling (one offset MLE fit on the pooled data, ignoring segment identity), and the shipped
empirical-Bayes shrinkage (delta_tilde_); a second, homogeneous scenario (every segment's
true delta is 0, n=3000) checks that shrinkage degrades gracefully to complete pooling as
the true spread shrinks to 0. tests/test_segmented_sim.py (pytest.mark.slow) enforces the
same gates at a reduced run count in CI.
| scenario | runs | MSE no-pooling | MSE complete-pooling | MSE / stat EB |
|---|---|---|---|---|
| heterogeneous (spread -0.6..+0.6) | 2000 | 0.2354 | 0.1680 | 0.1242 |
| homogeneous (all true delta = 0, n=3000) | 2000 | - | - | max|mean delta_tilde| = 0.0003 |
Empirical Bayes beats both no pooling and complete pooling on the heterogeneous scenario (it never has to choose between "trust this tiny segment" and "ignore segments"; it blends the two per segment, weighted by how much of a segment's disagreement with its peers survives sampling noise), and collapses to complete pooling (shrunk offsets near 0) once there is no real heterogeneity left to detect.