Skip to content

Getting started

Installation

pip install probcal            # runtime dependency: numpy only
pip install "probcal[viz]"     # + matplotlib for probcal.plots

To work on the package itself:

git clone https://github.com/wlazlod/probcal && cd probcal
uv sync --extra dev            # tests, lint, type-check

probcal requires Python ≥ 3.11. The core import path touches nothing beyond numpy and the standard library: scipy, scikit-learn, pandas, and matplotlib are never imported by probcal itself (matplotlib only inside the optional probcal.plots).

Score-level quickstart

Calibrators work directly on scores; no model object is required. The example uses the built-in synthetic PD portfolio (3% event rate, asymmetric tail distortion), fitted on one draw and measured on another: a calibrator scored on its own fitting rows reports slope 1 and intercept 0 by construction, which is no evidence at all.

from probcal import BetaCalibrator, make_pd_portfolio
from probcal.metrics import calibration_guardrails

cal_set = make_pd_portfolio(n=8000, random_state=42)   # calibration set
test = make_pd_portfolio(n=8000, random_state=1)       # held-out set, same distortion

g_before = calibration_guardrails(test.y, test.scores)
print(f"before: slope={g_before.slope:.3f}  intercept={g_before.intercept:+.3f}  ok={g_before.all_ok}")

cal = BetaCalibrator().fit(cal_set.scores, cal_set.y)
p = cal.predict_proba(test.scores)                    # never the rows it was fitted on

g_after = calibration_guardrails(test.y, p)
print(f"after:  slope={g_after.slope:.3f}  intercept={g_after.intercept:+.3f}  ok={g_after.all_ok}")
print()
print(cal.interpret())

Output:

before: slope=0.901  intercept=-0.743  ok=False
after:  slope=0.922  intercept=+0.021  ok=True

Interpretation[BetaCalibrator]
parameter  value
---------  --------
a          0.875054
b          1.58922
c          -1.15227
- a = 0.875: sensitivity near s -> 0; a < 1 raises the smallest probabilities (model was overconfident in the low tail), a > 1 deepens them
- b = 1.589: sensitivity near s -> 1; the mirrored reading for the high tail
- c = -1.152: base-rate shift of -1.152 log-odds, odds factor 0.316
- identity map corresponds to (a, b, c) = (1, 1, 0)
- a != b (gap -0.714): asymmetric tail distortion that no symmetric (Platt/temperature) map could express

Every calibrator follows the same contract: fit(s, y, sample_weight=None), predict_proba(s), interpret(). Inputs are probabilities in (0, 1); if your model emits raw logits, convert first with the exported probcal.expit.

Model-level wrapper

CalibratedModel wraps any object with predict_proba(X) or decision_function(X):

import numpy as np
from probcal import CalibratedModel, PlattCalibrator


class ScoreModel:
    """Stand-in for a trained classifier: reads the score off column 0."""

    def fit(self, X, y):
        return self

    def predict_proba(self, X):
        s = np.asarray(X)[:, 0]
        return np.column_stack([1.0 - s, s])

    def get_params(self):
        return {}


model = ScoreModel()
X_cal, X_new = cal_set.scores.reshape(-1, 1), test.scores.reshape(-1, 1)  # from above
X_train, y_train = X_cal, cal_set.y

# Prefit flow: the model is already trained, a separate calibration set exists.
wrapped = CalibratedModel(model, PlattCalibrator(), flow="prefit").fit(X_cal, cal_set.y)
p = wrapped.predict_proba(X_new)

# CV flow: no calibration set to spare — clone/retrain per fold, pool out-of-fold scores.
wrapped = CalibratedModel(model, PlattCalibrator(), flow="cv", cv=5).fit(X_train, y_train)

# Central-tendency re-anchoring, kept as a separate auditable stage:
wrapped.offset_to(target_mean=0.031)
print(wrapped.offsets_[0].interpret())

Automatic selection

from probcal import CalibratorSelector

s_cal, y_cal, s_new = cal_set.scores, cal_set.y, test.scores   # from the quickstart
sel = CalibratorSelector().fit(s_cal, y_cal)   # nested CV, log-loss criterion
print(sel.report_)                             # ranked table with guardrail flags
p = sel.predict_proba(s_new)                   # the refitted winner

The selector never scores a candidate on the data it was fitted on. See Automatic selection for the protocol and Data splitting for why the nesting is mandatory.

Where to go next

  • The Concepts chapters are the package's theoretical foundation: method derivations, metric pathologies, and the selection rules.
  • The tutorial notebook walks a full PD calibration cycle: diagnose, select, fit, re-anchor, backtest, and translate cutoffs back to raw scores.
  • The FAQ covers the inverse-map protocol and interop with counterfactual engines.