Skip to content

Calibration

Post-hoc calibration inserts a monotone map g between the model's probability output and the number a decision actually uses: p' = g(predict_proba(x)). Cutoffs, rating grades, and recourse policies are then stated on the calibrated scale — and that changes how targets must be built, and nothing else.

The trap

Target.probability inverts the model's own sigmoid link, not g. Once calibration is deployed, it silently targets the uncalibrated probability. Concretely: suppose an isotonic g maps model-p 5% to calibrated 2%. The intended policy "calibrated PD ≤ 2%" is satisfied by any point with model-p ≤ 5%; but Target.probability(op="<=", value=0.02) demands model-p ≤ 2% — a materially harder, possibly unattainable, target. Nothing errors; the counterfactuals are just wrong for the policy.

Why only target construction changes

For a monotone g, the preimage identity holds:

{x : g(f(x)) ∈ [lo, hi]}  =  {x : f(x) ∈ [g⁻¹(lo), g⁻¹(hi)]}

Calibration never changes counterfactual geometry — only the interval the search must reach. The engine, constraints, pruning, verification, and the Rust core all consume a raw interval exactly as before. Target.calibrated therefore does one thing: it holds the calibrator and, when the model link is known, inverts [lo, hi] through the calibrator's generalized inverse into raw-margin bounds.

The calibrator protocol

treecf imports no calibration library. Any object with these two members works (e.g. a probcal calibrator):

class SupportsIntervalInverse(Protocol):
    is_monotone_: bool
    def interval_inverse(
        self, lo: float, hi: float, *, space: str = "probability", buffer_logit: float = 0.0
    ) -> tuple[float, float]: ...

space="logit" (which treecf always uses) returns generalized-inverse bounds on the logit of the model probability — for a SIGMOID-link ensemble that is exactly the raw margin. lo=0.0/hi=1.0 map to -inf/+inf. Non-monotone calibrators are rejected at construction: the preimage of an interval under a non-monotone map need not be an interval.

Usage

target = treecf.Target.calibrated(cal, op="<=", value=0.02)   # calibrated PD ≤ 2%
result = explainer.explain(x, target=target)

Masterscales defined on calibrated PD invert per band:

target = treecf.Target.bands(
    {"A": (0.0, 0.005), "B": (0.005, 0.02), "C": (0.02, 0.10)},
    space="calibrated",
    calibrator=cal,
)

Robustness to recalibration drift

Calibrators get refitted — quarterly central-tendency updates are routine in credit risk. A counterfactual computed today can be invalidated by tomorrow's recalibration. buffer_logit=m shrinks the calibrated interval by m log-odds before inversion, so the produced counterfactual survives any future drift of magnitude ≤ m:

target = treecf.Target.calibrated(cal, op="<=", value=0.02, buffer_logit=0.1)

The trade is explicit: robustness paid in recourse difficulty. Two further practical notes: the calibrator is held by reference (refitting it between target construction and explain changes the inversion — reconstruct the target after a refit), and step-shaped calibrators (isotonic, binning) make counterfactuals near a block edge fragile — prefer a continuous calibrator or a buffer when recourse is downstream.

explain_batch resolves the target's raw interval exactly once per call (once per band for a bands ladder) and threads it through every row — the calibrator's interval_inverse is never re-invoked per row.

Calibrator provenance (0.2.4)

Certificates for calibrated targets record which calibrator produced the stored raw interval, without embedding it:

"calibrator": {
  "embedded": false,
  "fingerprint": "9f2a…",
  "type": "IsotonicCalibrator",
  "buffer_logit": 0.1
}

The fingerprint comes from the calibrator's own fingerprint() method when it has one (probcal calibrators do); null otherwise. What it proves: a later auditor holding the same calibrator object can tie the certificate to it. What it does not prove: that the interval is correct — for that, pass the calibrator back:

report = explainer.check_certificate(cert, calibrator=cal)
report["calibrator_match"]   # fingerprints agree AND re-inverting the stored
                             # calibrated bounds reproduces the stored raw interval

Without calibrator=, check_certificate behaves exactly as in 0.2.3 (no calibrator_match key). Batch records carry the same fingerprint per row (BatchRecord.calibrator_fingerprint), so each JSON line stays self-contained. Remember what the engine actually consumed: the raw interval, not the calibrator — provenance ties the interval to its source, verification always re-runs on the stored interval.

The calibrated read-out

Results for calibrated targets carry score_calibrated — the calibrator's probability at the counterfactual (and, on certificates, at the factual):

res = explainer.explain(x, target=treecf.Target.calibrated(cal, op="<=", value=0.02))
res.score_raw          # what the engine optimized and verified against
res.score_calibrated   # g(sigmoid(score_raw)) — presentational

It is presentational only: the search satisfied the raw interval produced by interval_inverse, and that remains the verified claim. The read-out needs the calibrator to expose predict_proba; calibrators without it (the protocol requires only interval_inverse) leave the field None, as do raw and probability targets.

Worked examples with probcal

probcal implements the calibrator protocol end to end; its treecf guide walks through the pairing from the other side. The trio that covers most policies:

import probcal, treecf

cal = probcal.BetaCalibrator().fit(scores, y)
exp = treecf.Explainer(model=model, background=X)

# 1. Threshold policy on the calibrated scale
res = exp.explain(x, target=treecf.Target.calibrated(cal, op="<=", value=0.02))

# 2. Masterscale bands, each band inverted through the same calibrator
bands = treecf.Target.bands(
    {"A": (0.0, 0.005), "B": (0.005, 0.02), "C": (0.02, 0.10)},
    space="calibrated", calibrator=cal,
)

# 3. Drift-robust recourse after a macro adjustment
chain = probcal.Chain([cal, probcal.LogitOffset(delta=0.3).fit(cal.predict_proba(scores))])
res = exp.explain(x, target=treecf.Target.calibrated(chain, op="<=", value=0.02,
                                                     buffer_logit=0.2))