scikit-learn adapter¶
probcal meets sklearn at three depths, from lightest to heaviest:
- Bare duck. Scores
(n,),(n, 1), or apredict_probamatrix(n, 2). On sklearn ≥ 1.6, a plain probcal calibrator satisfies sklearn's estimator protocol directly, with no import fromprobcal.sklearnat all. SklearnCalibrator. Scores(n,),(n, 1), or apredict_probamatrix(n, 2). You need the(n, 2)/classes_probability-matrix convention on the way out too: pipelines,VotingClassifier, stacking.CalibratedClassifier. Feature-levelX, whatever shape the wrapped estimator's ownfit/predict_probaexpects. You want the whole out-of-fold calibration protocol as one auditable object.
probcal.sklearn (extra: pip install "probcal[sklearn]", scikit-learn ≥ 1.4; the
support matrix lists both floors) holds tiers 2 and 3. Neither is imported by import probcal, so the core
stays numpy-only regardless of which tier you use.
Design position. The core is duck-typed to sklearn's semantic contract
by design: fit/predict_proba, get_params/set_params,
__sklearn_is_fitted__, __sklearn_tags__. That is genuinely enough for
clone, check_is_fitted, get_tags, and CV loops with a custom scorer:
contexts that only ever call methods, never inspect array shape. What duck
typing cannot do is dissolve sklearn's shape conventions: a classifier's
predict_proba returns (n, 2) and carries classes_, and a probcal
calibrator's predict_proba returns (n,) because there is only one class's
probability to report. SklearnCalibrator and CalibratedClassifier exist
to translate that convention where something in the ecosystem
(Pipeline, VotingClassifier, GridSearchCV scoring on
"neg_log_loss") actually requires it. Bare duckness targets
sklearn ≥ 1.6 (the versions that define the
__sklearn_is_fitted__/__sklearn_tags__ protocol); on older sklearn, use
the adapter. Both floors and what checks them: the
support matrix.
Tier 1: bare duck (sklearn ≥ 1.6)¶
No probcal.sklearn import. A bare calibrator clones, reports its tags,
passes check_is_fitted, and scores under cross_val_score with a scorer
that calls its native 1-D predict_proba:
import numpy as np
from sklearn.base import clone
from sklearn.metrics import log_loss
from sklearn.model_selection import cross_val_score
from sklearn.utils import get_tags
from sklearn.utils.validation import check_is_fitted
from probcal import BetaCalibrator # no probcal.sklearn import
rng = np.random.default_rng(0)
s = rng.uniform(0.05, 0.95, 300)
y = rng.binomial(1, s).astype(float)
cal = BetaCalibrator()
assert get_tags(cal).requires_fit # true of every probcal calibrator
cal.fit(s, y)
check_is_fitted(cal) # would raise NotFittedError had fit not run
unfitted_copy = clone(cal) # same params, fitted_ is False
def neg_log_loss(est: BetaCalibrator, X: np.ndarray, y_true: np.ndarray) -> float:
p = est.predict_proba(X) # 1-D, not the (n, 2) adapter convention
return -log_loss(y_true, p, labels=[0.0, 1.0])
scores = cross_val_score(BetaCalibrator(), s.reshape(-1, 1), y, cv=3, scoring=neg_log_loss)
s.reshape(-1, 1) works because the core accepts a single-column (n, 1)
score array everywhere it accepts (n,), ravelling it before any check.
sklearn's CV plumbing always hands an estimator a 2-D X, and this leniency
is what lets a bare calibrator sit directly in cross_val_score with no
reshaping on the calibrator's side.
This is genuinely useful whenever the surrounding code only calls methods
and never asks for a (n, 2) matrix or classes_: a custom CV loop, a
hyperparameter search with a hand-written scorer, anything that treats the
calibrator as "an object with fit/predict_proba" rather than
"a classifier".
Tier 2, SklearnCalibrator: ending a pipeline with a calibrated column¶
SklearnCalibrator is score-level: its X is the score itself. In
probability mode (input="probability", the default) that is one column
((n,) or (n, 1)) or a two-column predict_proba-style matrix (n, 2);
input="logit" stays single-column, because a logit matrix has no
sum-to-one rule to validate an orientation against. More columns than that
raise. Use it wherever sklearn expects an estimator, or let transform end
a Pipeline: transform returns the calibrated-probability column,
(n, 1), where predict_proba returns the full (n, 2) matrix. A
FunctionTransformer ahead of it selects the score column out of a wider
feature table:
import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer
from probcal import BetaCalibrator
from probcal.sklearn import SklearnCalibrator
# X's column 0 is the model's score; the rest are context columns the
# calibrator does not use.
rng = np.random.default_rng(1)
s = rng.uniform(0.05, 0.95, 300)
y = rng.binomial(1, s).astype(float)
X = np.column_stack([s, rng.normal(size=300)])
pipe = Pipeline(
[
("select_score", FunctionTransformer(lambda a: a[:, [0]])),
("cal", SklearnCalibrator(BetaCalibrator())),
]
).fit(X, y)
pipe.predict_proba(X) # (n, 2)
pipe.transform(X) # (n, 1) calibrated column
pipe.named_steps["cal"].calibrator_ is the full probcal audit surface
(interpret(), interval_inverse, to_json()), one attribute away from
the sklearn object.
A two-column matrix follows the predict_proba convention: positive_column
(default 1) says which column holds P(y=1). SklearnCalibrator never
guesses a flipped matrix and corrects it for you — at fit time it checks
whether the chosen column's mean is actually higher among events than among
non-events and, if it is not, warns once (UserWarning) naming the likely
fix, without changing which column it calibrates:
from probcal.sklearn import SklearnCalibrator
cal = SklearnCalibrator().fit(proba_matrix, y) # positive_column=1 (default)
cal.predict_proba(proba_matrix) # (n, 2) in, (n, 2) out
# A reversed matrix needs positive_column=0 to match:
cal_reversed = SklearnCalibrator(positive_column=0).fit(reversed_matrix, y)
Score columns on the margin scale are declared, not guessed:
SklearnCalibrator(input="logit") maps them through expit exactly first.
Because it is an ordinary sklearn classifier over its one score column, it
composes into ensembles that mix estimator types over the same X, each
branch selecting what it needs. Here a VotingClassifier pairs the
calibrated score against a feature-based classifier:
import numpy as np
from sklearn.ensemble import VotingClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import FunctionTransformer
from probcal.sklearn import SklearnCalibrator
rng = np.random.default_rng(2)
s = rng.uniform(0.05, 0.95, 300)
y = rng.binomial(1, s).astype(float)
X = np.column_stack([s, rng.normal(size=(300, 3))])
score_branch = Pipeline(
[
("select_score", FunctionTransformer(lambda a: a[:, [0]])),
("cal", SklearnCalibrator()),
]
)
vote = VotingClassifier(
[("calibrated_score", score_branch), ("lr", LogisticRegression())],
voting="soft",
).fit(X, y)
vote.predict_proba(X)
Tier 3, CalibratedClassifier: cross-validated calibration as one object¶
CalibratedClassifier is the drop-in for
sklearn.calibration.CalibratedClassifierCV(ensemble=False): out-of-fold
scores via cross_val_predict, one probcal calibrator fitted on the pooled
OOF scores, and the estimator refit on all data. What it adds is the whole
probcal calibrator protocol on the result (interpret(), to_json(),
interval_inverse) instead of an opaque fitted map:
from sklearn.linear_model import LogisticRegression
from probcal.sklearn import CalibratedClassifier
clf = CalibratedClassifier(LogisticRegression(), cv=5, random_state=0).fit(X_train, y_train)
clf.predict_proba(X_new) # (n, 2)
# probcal calibrator protocol, delegated to calibrator_:
clf.interpret()
clf.to_json()
clf.interval_inverse(0.0, 0.02, space="logit")
cv="prefit" scores the calibration set with the already-fitted estimator;
method="decision_function" maps margins through expit before calibration
(the calibrator absorbs the monotone distortion this introduces). The
probcal calibrator protocol is delegated straight through: hand a fitted
clf to treecf's Target.calibrated the same way you would a bare
calibrator. With cv="prefit", an estimator that carries classes_ must
have the same classes as y; a mismatch raises ValueError naming both.
Folds and data. cv takes what CalibratedClassifierCV takes: an
integer ≥ 2, any sklearn splitter (GroupKFold, TimeSeriesSplit, ...) or
iterable of (train, test) index pairs, or "prefit". stratify applies
only to an integer cv: True (the default) uses
StratifiedKFold(cv, shuffle=True, random_state=random_state), False uses
KFold(cv, shuffle=True, random_state=random_state). X reaches the wrapped estimator untouched, as in
CalibratedClassifierCV: DataFrames with string columns feeding a
ColumnTransformer, and sparse matrices, work, and n_features_in_ and
feature_names_in_ are copied from the fitted estimator.
Against CalibratedModel(flow="cv"). The numpy-only deployment wrapper
runs the same out-of-fold protocol without sklearn. Its folds are always
class-stratified and drawn with numpy's default_rng(random_state), so the
same seed assigns different folds than StratifiedKFold, and it alone
offers ensemble=True (one calibrator per fold). Use CalibratedClassifier
inside sklearn pipelines and searches, CalibratedModel for a deployment
object with no sklearn dependency.
Grid search over the calibration map¶
The probcal calibrator is a nested estimator, so its parameters are
grid-searchable. calibrator=BetaCalibrator() (or any instance) must be
given explicitly, since the default calibrator=None has no variant to
set:
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV
from probcal import BetaCalibrator
from probcal.sklearn import CalibratedClassifier
gs = GridSearchCV(
CalibratedClassifier(LogisticRegression(max_iter=200), calibrator=BetaCalibrator()),
{"calibrator__variant": ["a", "ab", "abm"]},
scoring="neg_log_loss",
cv=5,
).fit(X, y)
Stacking an offset¶
A calibrator often needs a level correction on top of it: production
drifted, or a CalibrationMonitor.apply_recommendation call produced a
fresh delta. Two patterns compose that into an sklearn pipeline, and both
end in the same LogitOffset object the rest of the package already
understands.
One auditable object. Give SklearnCalibrator a Chain prototype
instead of a bare calibrator. Chain.fit refits both stages in sequence —
the calibrator first, then the offset on the calibrator's own output — so
the whole map stays behind one attribute, calibrator_:
from sklearn.pipeline import Pipeline
from probcal import BetaCalibrator, Chain, LogitOffset
from probcal.sklearn import SklearnCalibrator
est = SklearnCalibrator(calibrator=Chain([BetaCalibrator(), LogitOffset(target_mean=0.2)]))
pipe = Pipeline([("cal", est)]).fit(s.reshape(-1, 1), y)
pipe.predict_proba(s.reshape(-1, 1)) # (n, 2), through calibrator then offset
fitted_chain = pipe.named_steps["cal"].calibrator_
fitted_chain.interpret() # both stages, concatenated
Reach for this when nothing downstream needs the offset to have a lifecycle
of its own: one fit call, one fingerprint, one thing to serialize.
A separate, swappable step. When the offset does have its own
governance lifecycle — a monitor alarms, apply_recommendation returns a
new delta, and only the offset should change — keep SklearnCalibrator
and SklearnOffset as two pipeline steps and replace the second one:
from sklearn.pipeline import Pipeline
from probcal.sklearn import SklearnCalibrator, SklearnOffset
pipe = Pipeline([("cal", SklearnCalibrator()), ("off", SklearnOffset(target_mean=0.2))]).fit(
s.reshape(-1, 1), y
)
fp_before = pipe.named_steps["cal"].calibrator_.fingerprint()
# Governance produced a new delta (e.g. from mon.apply_recommendation()).
# Swap only the offset step and refit it alone, on the calibrator's output:
new_delta = 0.05
pipe.set_params(off=SklearnOffset(delta=new_delta))
p_cal = pipe.named_steps["cal"].transform(s.reshape(-1, 1))
pipe.named_steps["off"].fit(p_cal)
set_params plus refitting that one step replaces the offset artifact
without touching the calibrator: its fingerprint is unchanged, because
nothing there called its fit again.
Both routes end in a LogitOffset object underneath — Chain.offsets_[-1]
in the first case, SklearnOffset.offset_ in the second — so either one
composes, interprets, and serializes exactly like any other LogitOffset:
interpret(), to_dict(), the
audit trail.
SklearnOffset's column-1 convention for two-column input mirrors
SklearnCalibrator's (positive_column says which column holds P(y=1)),
but it is documented, not checked: SklearnOffset.fit takes no y, so
there is nothing to compare a guessed orientation against. That is
acceptable because an offset step sits downstream of a checked calibrator
step — by the time a probability reaches the offset, its column convention
was already validated once, by whatever produced it.
The prefit recipe: calibrating an already-fitted model¶
Sometimes the model is already trained and you only have a held-out
calibration set. On sklearn ≥ 1.6, sklearn.frozen.FrozenEstimator wraps a
fitted estimator so clone-based tools (CalibratedClassifierCV, a CV
search) leave it untouched instead of refitting it:
from sklearn.calibration import CalibratedClassifierCV
from sklearn.frozen import FrozenEstimator
from sklearn.linear_model import LogisticRegression
model = LogisticRegression().fit(X_train, y_train)
cccv = CalibratedClassifierCV(FrozenEstimator(model)).fit(X_calib, y_calib)
cccv.predict_proba(X_new)
That is sklearn's own meta-estimator route, and it is a fine default. The
primitives-first alternative composes FrozenEstimator with the bare
SklearnCalibrator: the same idea with no meta-estimator in between, in
three lines:
from sklearn.frozen import FrozenEstimator
from probcal.sklearn import SklearnCalibrator
frozen = FrozenEstimator(model) # a fitted estimator that clone()/fit() cannot touch
cal = SklearnCalibrator().fit(frozen.predict_proba(X_calib)[:, [1]], y_calib)
cal.predict_proba(frozen.predict_proba(X_new)[:, [1]])
Both recipes are alternatives to CalibratedClassifier(cv="prefit"), not
replacements for it: some teams prefer composing sklearn primitives to
adopting a probcal meta-estimator, and both routes above serve that
preference. Reach for probcal's own meta-estimator (CalibratedClassifier,
any cv, including "prefit") when you want interpret(), JSON
serialization and a fingerprint, or the exact inverse maps
(interval_inverse/point_inverse). A FrozenEstimator composition still
gets you the fitted SklearnCalibrator.calibrator_'s full audit surface via
that one attribute, so the difference is packaging (one call vs. three
lines), not capability.
Sample weights and metadata routing¶
Both estimators declare sample_weight in fit, so the weights always reach
the probcal calibrator. What depends on enable_metadata_routing is how they
reach the wrapped classifier.
Weights must be non-negative: a negative sample_weight raises
ValueError in all three adapters. Zero weights mean "excluded", as in
sklearn: those rows are dropped before the probcal calibrator is fitted
(probcal's own calibrators require strictly positive weights).
Routing off (sklearn's default). Weights are handed down directly:
CalibratedClassifier forwards them to cross_val_predict's fold fits and to
the full-data refit whenever the base estimator's fit takes a
sample_weight argument.
Routing on. Every consumer must ask for the metadata, ours included:
import sklearn
from sklearn.pipeline import Pipeline
from probcal.sklearn import SklearnCalibrator
with sklearn.config_context(enable_metadata_routing=True):
pipe = Pipeline([("cal", SklearnCalibrator().set_fit_request(sample_weight=True))])
pipe.fit(scores.reshape(-1, 1), y, sample_weight=w)
Inside a search, the base estimator declares its own request and the wrapper
declares one per method the search calls. Again, calibrator=BetaCalibrator()
is required since the grid searches calibrator__variant:
import sklearn
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV
from probcal import BetaCalibrator
from probcal.sklearn import CalibratedClassifier
with sklearn.config_context(enable_metadata_routing=True):
clf = CalibratedClassifier(
LogisticRegression().set_fit_request(sample_weight=True),
calibrator=BetaCalibrator(),
cv=3,
)
gs = GridSearchCV(
clf.set_fit_request(sample_weight=True).set_score_request(sample_weight=True),
{"calibrator__variant": ["ab", "abm"]},
cv=3,
).fit(X, y, sample_weight=w)
An undeclared request raises sklearn's UnsetMetadataPassedError; the adapter
does not soften that into a silent drop.
Base estimators that cannot take weights. If the base estimator's fit has
no sample_weight parameter (KNeighborsClassifier, for instance),
CalibratedClassifier.fit warns once (UserWarning, naming the class), runs
the fold fits and the refit unweighted, and still fits the calibrator with the
weights: the calibration map is weighted, the scores it calibrates are not.
Under routing, a router base (a Pipeline, say) counts as weight-capable: it
takes weights through **params, and sklearn's routing has the final word.
This behaviour is pinned by tests/test_sklearn_routing.py, which CI runs
at 1.4.2 (sklearn-min) and at whatever resolves as latest (currently
1.9.0); a local run additionally confirmed it identical on 1.6.1, the
first version with the dunder-protocol tags this guide's bare-duck tier
depends on.
Against CalibratedClassifierCV¶
CalibratedClassifierCV(ensemble=False) |
probcal.sklearn.CalibratedClassifier |
|
|---|---|---|
| OOF protocol, one map, full refit | yes | yes (verified equivalent) |
| Calibration methods | sigmoid, isotonic | all 12 probcal calibrators + CalibratorSelector |
| Parameter interpretation | no | interpret() on the fitted map |
| Metric CIs | no | probcal.metrics.evaluate bootstrap |
| Exact inverse maps (policy → raw threshold) | no | interval_inverse / point_inverse, delegated |
| Serialization | pickle | versioned JSON + fingerprint() (and pickle) |
| Multiclass | yes | binary only, by design |
| Prefit route | CalibratedClassifierCV(FrozenEstimator(m)) |
cv="prefit", or FrozenEstimator(m) + bare SklearnCalibrator |
Estimator-check compliance¶
Both estimators run sklearn.utils.estimator_checks.parametrize_with_checks
in CI on the pinned minimum (1.4) and the latest release.
CalibratedClassifier passes the full corpus. On sklearn ≥ 1.6 that includes
check_sample_weight_equivalence_on_dense_data, which hands in explicit
splits and so runs live; sklearn < 1.6's check_sample_weights_invariance
is declared inapplicable, since weight ≡ duplication cannot hold
through an integer-cv split whose fold assignment depends on n (sklearn's
own CV wrappers share this). The declarations live in the data-only module
probcal.sklearn._xfail. SklearnCalibrator's one-column contract is
inapplicable to the generic multi-feature checks. Those are declared through
sklearn's expected_failed_checks, each entry naming the data that check
generates and the part of the score-level contract it violates, the same
domain-restriction sklearn special-cases its own IsotonicRegression for; the
remaining convention checks run live. The inapplicable checks that do have a
score-level analogue are re-implemented on valid probability data in
tests/test_sklearn_mirror_checks.py; see API stability for the full
account.