Skip to content

API: integrations

Optional extras; each module imports its dependency only when used.

sklearn

scikit-learn adapter: probcal calibrators as sklearn estimators.

Requires the probcal[sklearn] extra; import probcal itself stays numpy-only — this subpackage is imported explicitly by its users.

SklearnCalibrator

SklearnCalibrator(calibrator: BaseCalibrator | None = None, *, input: str = 'probability', positive_column: int = 1)

Bases: ClassifierMixin, TransformerMixin, BaseEstimator

Probability calibration over a single score column, sklearn-style.

Wraps any probcal calibrator as a scikit-learn classifier/transformer whose X is the score itself — shape (n,), (n, 1), or (in probability mode) a two-column predict_proba-style matrix. Use it to end a Pipeline (via :meth:transform) or anywhere an sklearn estimator is expected; the fitted probcal object stays one attribute away (calibrator_) with its full audit surface (interpret(), interval_inverse, to_dict, fingerprint()). The prototype passed as calibrator may also be a :class:~probcal.Chain.

PARAMETER DESCRIPTION
calibrator

Unfitted probcal prototype, cloned via get_params at fit time; None uses BetaCalibrator().

TYPE: BaseCalibrator or None DEFAULT: None

input

Scale of the score column. "probability" requires values in [0, 1] (probcal's forward-entry convention) and additionally accepts a two-column probability matrix; "logit" stays single-column and accepts any reals, mapped through expit exactly first.

TYPE: (probability, logit) DEFAULT: "probability"

positive_column

Which column of a two-column probability matrix holds P(y=1)0 or 1 (default 1, matching predict_proba output). Ignored for single-column input.

TYPE: (int, keyword - only) DEFAULT: 1

ATTRIBUTE DESCRIPTION
calibrator_

The fitted probcal calibrator.

TYPE: BaseCalibrator

classes_

Class labels in numpy.unique order; column 1 of :meth:predict_proba is classes_[1].

TYPE: numpy.ndarray of shape (2,)

n_features_in_

1 or 2, depending on the X shape seen at fit; enforced at predict/transform time.

TYPE: int

Source code in src/probcal/sklearn/_calibrator.py
56
57
58
59
60
61
62
63
64
65
def __init__(
    self,
    calibrator: BaseCalibrator | None = None,
    *,
    input: str = "probability",
    positive_column: int = 1,
) -> None:
    self.calibrator = calibrator
    self.input = input
    self.positive_column = positive_column

fit

fit(X: object, y: object, sample_weight: object = None) -> SklearnCalibrator

Fit the wrapped calibrator on the score column.

PARAMETER DESCRIPTION
X

Scores (probabilities, or logits with input="logit"), or a two-column probability matrix (input="probability" only).

TYPE: array_like of shape (n,), (n, 1), or (n, 2)

y

Binary target; any two label values.

TYPE: array_like of shape (n,)

sample_weight

Positive observation weights.

TYPE: array_like or None DEFAULT: None

RETURNS DESCRIPTION
SklearnCalibrator

The fitted adapter.

RAISES DESCRIPTION
ValueError

If X's column count/input combination is unsupported, input or positive_column is invalid, or y has more than two classes.

Source code in src/probcal/sklearn/_calibrator.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def fit(self, X: object, y: object, sample_weight: object = None) -> "SklearnCalibrator":
    """Fit the wrapped calibrator on the score column.

    Parameters
    ----------
    X : array_like of shape (n,), (n, 1), or (n, 2)
        Scores (probabilities, or logits with ``input="logit"``), or a
        two-column probability matrix (``input="probability"`` only).

    y : array_like of shape (n,)
        Binary target; any two label values.
    sample_weight : array_like or None
        Positive observation weights.

    Returns
    -------
    SklearnCalibrator
        The fitted adapter.

    Raises
    ------
    ValueError
        If ``X``'s column count/``input`` combination is unsupported,
        ``input`` or ``positive_column`` is invalid, or ``y`` has more
        than two classes.
    """
    if self.input not in ("probability", "logit"):
        raise ValueError(f"input must be 'probability' or 'logit', got {self.input!r}")
    if self.positive_column not in (0, 1):
        raise ValueError(f"positive_column must be 0 or 1, got {self.positive_column!r}")
    X_arr, y_arr = validate_X_y(self, X, y, reset=True, allow_1d=True)
    sw = None if sample_weight is None else _check_sample_weight(sample_weight, X_arr)
    check_classification_targets(y_arr)
    self.classes_ = np.unique(y_arr)
    if len(self.classes_) != 2:
        raise ValueError(
            "Only binary classification is supported. Got " f"{len(self.classes_)} classes."
        )
    y_bin = (y_arr == self.classes_[1]).astype(np.float64)
    s = self._scores(X_arr)
    if sw is not None:
        # Zero weight means excluded (sklearn semantics); probcal requires
        # strictly positive weights, so drop those rows here.
        keep = sw > 0.0
        s, y_bin, sw = s[keep], y_bin[keep], sw[keep]
        if np.unique(y_bin).size < 2:
            raise ValueError(
                "Only one class remains after removing zero-weight samples; "
                "both classes are required."
            )
    if s.ndim == 2:
        col = s[:, 1]
        if float(col[y_bin == 1.0].mean()) < float(col[y_bin == 0.0].mean()):
            warnings.warn(
                "the selected positive-probability column has a lower mean among "
                "events than among non-events; if the matrix is ordered the other "
                f"way, positive_column={1 - self.positive_column} is the likely fix",
                UserWarning,
                stacklevel=2,
            )
    proto = self.calibrator if self.calibrator is not None else BetaCalibrator()
    self.calibrator_ = clone(proto)
    self.calibrator_.fit(s, y_bin, sample_weight=sw)
    return self

predict_proba

predict_proba(X: object) -> ndarray

Calibrated (n, 2) probabilities [P(classes_[0]), P(classes_[1])].

Source code in src/probcal/sklearn/_calibrator.py
151
152
153
154
155
156
def predict_proba(self, X: object) -> np.ndarray:
    """Calibrated ``(n, 2)`` probabilities ``[P(classes_[0]), P(classes_[1])]``."""
    check_is_fitted(self, "calibrator_")
    X_arr = validate_X(self, X, allow_1d=True)
    p = self.calibrator_.predict_proba(self._scores(X_arr))
    return np.column_stack([1.0 - p, p])

predict

predict(X: object) -> ndarray

Class labels at the 0.5 calibrated-probability threshold.

Source code in src/probcal/sklearn/_calibrator.py
158
159
160
161
def predict(self, X: object) -> np.ndarray:
    """Class labels at the 0.5 calibrated-probability threshold."""
    proba = self.predict_proba(X)
    return self.classes_[(proba[:, 1] >= 0.5).astype(int)]

transform

transform(X: object) -> ndarray

Calibrated-probability column (n, 1) — lets the adapter end a Pipeline.

Source code in src/probcal/sklearn/_calibrator.py
163
164
165
def transform(self, X: object) -> np.ndarray:
    """Calibrated-probability column ``(n, 1)`` — lets the adapter end a Pipeline."""
    return self.predict_proba(X)[:, [1]]

CalibratedClassifier

CalibratedClassifier(estimator: object = None, *, calibrator: BaseCalibrator | None = None, cv: object = 5, method: str = 'predict_proba', stratify: bool = True, random_state: int | None = None)

Bases: ClassifierMixin, BaseEstimator

Cross-validated probability calibration of a classifier, probcal-style.

The drop-in for sklearn.calibration.CalibratedClassifierCV with 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 (unless cv="prefit"). What probcal adds on top: the fitted calibrator's audit surface (interpret(), bootstrap CIs via probcal.metrics.evaluate), exact inverse maps, JSON serialization, and fingerprints — see guide/sklearn.md.

Also exposes the probcal calibrator protocol (is_monotone_, interval_inverse, point_inverse, affine_logit_coeffs_, fingerprint) by delegation to calibrator_, so a fitted instance can be handed directly to consumers of that protocol (e.g. treecf's Target.calibrated).

PARAMETER DESCRIPTION
estimator

Classifier to calibrate. None resolves to LogisticRegression(max_iter=1000) at fit time (the sklearn precedent for a default-constructible wrapper).

TYPE: object or None DEFAULT: None

calibrator

Unfitted probcal prototype (cloned via get_params); None uses BetaCalibrator().

TYPE: (BaseCalibrator or None, keyword - only) DEFAULT: None

cv

Fold count for the out-of-fold protocol, or "prefit" to score the calibration set with the already-fitted estimator directly.

TYPE: (int or prefit, keyword - only) DEFAULT: 5

method

Score source. "decision_function" margins are mapped through expit before calibration — the calibrator then absorbs any monotone distortion this introduces.

TYPE: (predict_proba, decision_function) DEFAULT: "predict_proba"

stratify

Stratify the folds by class (recommended for rare events).

TYPE: (bool, keyword - only) DEFAULT: True

random_state

Fold-assignment seed (used only when stratify=True).

TYPE: (int or None, keyword - only) DEFAULT: None

ATTRIBUTE DESCRIPTION
estimator_

The deployed classifier (input estimator for cv="prefit", full-data refit otherwise).

TYPE: object

calibrator_

The fitted probcal calibrator (one map, pooled OOF scores).

TYPE: BaseCalibrator

classes_

Class labels; column 1 of :meth:predict_proba is classes_[1].

TYPE: numpy.ndarray of shape (2,)

Source code in src/probcal/sklearn/_classifier.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
def __init__(
    self,
    estimator: object = None,
    *,
    calibrator: BaseCalibrator | None = None,
    cv: object = 5,
    method: str = "predict_proba",
    stratify: bool = True,
    random_state: int | None = None,
) -> None:
    self.estimator = estimator
    self.calibrator = calibrator
    self.cv = cv
    self.method = method
    self.stratify = stratify
    self.random_state = random_state

is_monotone_ property

is_monotone_: bool

Whether the fitted calibration map is non-decreasing (delegated).

affine_logit_coeffs_ property

affine_logit_coeffs_: tuple[float, float] | None

Affine-logit coefficients of the calibration map, if any (delegated).

fit

fit(X: object, y: object, sample_weight: object = None) -> CalibratedClassifier

Fit per the out-of-fold protocol (or score directly when prefit).

PARAMETER DESCRIPTION
X

Features, passed to the wrapped estimator.

TYPE: array_like of shape (n, d)

y

Binary target; any two label values.

TYPE: array_like of shape (n,)

sample_weight

Positive observation weights. Always used for the calibrator stage; also handed to the cross-validated fits and the refit when the estimator can take them. When it cannot, a UserWarning names it and those fits run unweighted.

TYPE: array_like or None DEFAULT: None

RETURNS DESCRIPTION
CalibratedClassifier

The fitted wrapper.

RAISES DESCRIPTION
ValueError

If method is unknown or y has more than two classes.

WARNS DESCRIPTION
UserWarning

If sample_weight is given and the estimator's fit cannot consume it — see guide/sklearn.md.

Source code in src/probcal/sklearn/_classifier.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
def fit(self, X: object, y: object, sample_weight: object = None) -> "CalibratedClassifier":
    """Fit per the out-of-fold protocol (or score directly when prefit).

    Parameters
    ----------
    X : array_like of shape (n, d)
        Features, passed to the wrapped estimator.
    y : array_like of shape (n,)
        Binary target; any two label values.
    sample_weight : array_like or None
        Positive observation weights. Always used for the calibrator
        stage; also handed to the cross-validated fits and the refit
        when the estimator can take them. When it cannot, a
        ``UserWarning`` names it and those fits run unweighted.

    Returns
    -------
    CalibratedClassifier
        The fitted wrapper.

    Raises
    ------
    ValueError
        If ``method`` is unknown or ``y`` has more than two classes.

    Warns
    -----
    UserWarning
        If ``sample_weight`` is given and the estimator's ``fit`` cannot
        consume it — see ``guide/sklearn.md``.
    """
    if self.method not in ("predict_proba", "decision_function"):
        raise ValueError(
            f"method must be 'predict_proba' or 'decision_function', got {self.method!r}"
        )
    X_arr, y_arr = validate_X_y(self, X, y, reset=True)
    sw = None if sample_weight is None else _check_sample_weight(sample_weight, X_arr)
    check_classification_targets(y_arr)
    self.classes_ = np.unique(y_arr)
    if len(self.classes_) != 2:
        raise ValueError(
            "Only binary classification is supported. Got " f"{len(self.classes_)} classes."
        )
    y_bin = (y_arr == self.classes_[1]).astype(np.float64)

    if self.cv == "prefit":
        check_is_fitted(self.estimator)
        self.estimator_ = self.estimator
        oof = self._estimator_scores(self.estimator_, X_arr)
    else:
        base = self.estimator if self.estimator is not None else self._default_estimator()
        n_splits = int(self.cv)  # type: ignore[call-overload]
        if self.stratify:
            splitter: object = StratifiedKFold(
                n_splits=n_splits, shuffle=True, random_state=self.random_state
            )
        else:
            splitter = n_splits
        inner_sw = sw
        if sw is not None and not _accepts_sample_weight(base):
            inner_sw = None
            warnings.warn(
                f"{type(base).__name__}.fit does not accept sample_weight: the "
                "cross-validated fits and the full-data refit are unweighted, "
                "while the calibrator is fitted with the weights. The calibration "
                "map is still weighted, but the scores it calibrates are not.",
                UserWarning,
                stacklevel=2,
            )
        fit_params = {} if inner_sw is None else {"params": {"sample_weight": inner_sw}}
        raw = cross_val_predict(
            clone(base), X_arr, y_arr, cv=splitter, method=self.method, **fit_params
        )
        oof = self._to_scores(np.asarray(raw))
        refit = clone(base)
        if inner_sw is None:
            refit.fit(X_arr, y_arr)
        else:
            refit.fit(X_arr, y_arr, sample_weight=inner_sw)
        self.estimator_ = refit

    if sw is not None:
        # Zero weight means excluded (sklearn semantics); probcal requires
        # strictly positive weights, so drop those rows here.
        keep = sw > 0.0
        oof, y_bin, sw = oof[keep], y_bin[keep], sw[keep]
        if np.unique(y_bin).size < 2:
            raise ValueError(
                "Only one class remains after removing zero-weight samples; "
                "both classes are required."
            )
    proto = self.calibrator if self.calibrator is not None else BetaCalibrator()
    self.calibrator_ = clone(proto)
    self.calibrator_.fit(oof, y_bin, sample_weight=sw)
    return self

predict_proba

predict_proba(X: object) -> ndarray

Calibrated (n, 2) probabilities: estimator scores composed with the calibrator.

Source code in src/probcal/sklearn/_classifier.py
216
217
218
219
220
221
def predict_proba(self, X: object) -> np.ndarray:
    """Calibrated ``(n, 2)`` probabilities: estimator scores composed with the calibrator."""
    check_is_fitted(self, "calibrator_")
    X_arr = validate_X(self, X)
    p = self.calibrator_.predict_proba(self._estimator_scores(self.estimator_, X_arr))
    return np.column_stack([1.0 - p, p])

predict

predict(X: object) -> ndarray

Class labels at the 0.5 calibrated-probability threshold.

Source code in src/probcal/sklearn/_classifier.py
223
224
225
226
def predict(self, X: object) -> np.ndarray:
    """Class labels at the 0.5 calibrated-probability threshold."""
    proba = self.predict_proba(X)
    return self.classes_[(proba[:, 1] >= 0.5).astype(int)]

interval_inverse

interval_inverse(lo: float, hi: float, *, space: str = 'probability', buffer_logit: float = 0.0) -> tuple[float, float]

Preimage of a calibrated interval in the estimator's score space (delegated).

Source code in src/probcal/sklearn/_classifier.py
242
243
244
245
246
247
def interval_inverse(
    self, lo: float, hi: float, *, space: str = "probability", buffer_logit: float = 0.0
) -> tuple[float, float]:
    """Preimage of a calibrated interval in the estimator's score space (delegated)."""
    check_is_fitted(self, "calibrator_")
    return self.calibrator_.interval_inverse(lo, hi, space=space, buffer_logit=buffer_logit)

point_inverse

point_inverse(p: object, *, space: str = 'probability') -> ndarray

Exact preimage of calibrated probabilities (delegated).

Source code in src/probcal/sklearn/_classifier.py
249
250
251
252
def point_inverse(self, p: object, *, space: str = "probability") -> np.ndarray:
    """Exact preimage of calibrated probabilities (delegated)."""
    check_is_fitted(self, "calibrator_")
    return self.calibrator_.point_inverse(p, space=space)

fingerprint

fingerprint() -> str

The fitted calibrator's provenance fingerprint (delegated).

Source code in src/probcal/sklearn/_classifier.py
254
255
256
257
def fingerprint(self) -> str:
    """The fitted calibrator's provenance fingerprint (delegated)."""
    check_is_fitted(self, "calibrator_")
    return self.calibrator_.fingerprint()

to_dict

to_dict() -> dict[str, object]

The fitted calibrator's versioned JSON envelope (delegated).

The estimator itself follows sklearn's pickle conventions and is outside the JSON's scope — persist it with your model artifact.

Source code in src/probcal/sklearn/_classifier.py
259
260
261
262
263
264
265
266
def to_dict(self) -> dict[str, object]:
    """The fitted calibrator's versioned JSON envelope (delegated).

    The estimator itself follows sklearn's pickle conventions and is
    outside the JSON's scope — persist it with your model artifact.
    """
    check_is_fitted(self, "calibrator_")
    return self.calibrator_.to_dict()

to_json

to_json(path: str | PathLike[str] | None = None, *, indent: int = 2) -> str | None

The fitted calibrator's JSON serialization (delegated), never pickle.

Source code in src/probcal/sklearn/_classifier.py
268
269
270
271
272
273
def to_json(
    self, path: "str | os.PathLike[str] | None" = None, *, indent: int = 2
) -> "str | None":
    """The fitted calibrator's JSON serialization (delegated), never pickle."""
    check_is_fitted(self, "calibrator_")
    return self.calibrator_.to_json(path, indent=indent)

interpret

interpret()

The fitted calibrator's plain-language reading (delegated).

Source code in src/probcal/sklearn/_classifier.py
275
276
277
278
def interpret(self):  # noqa: ANN201 - probcal Interpretation
    """The fitted calibrator's plain-language reading (delegated)."""
    check_is_fitted(self, "calibrator_")
    return self.calibrator_.interpret()

SklearnOffset

SklearnOffset(delta: float | None = None, target_mean: float | None = None, *, positive_column: int = 1)

Bases: TransformerMixin, BaseEstimator

A logit offset over a probability column, sklearn-style.

Wraps :class:~probcal.offset.LogitOffset as an sklearn transformer whose X is the probability itself — shape (n,), (n, 1), or a two-column predict_proba-style matrix — so it can end (or sit inside) a Pipeline right after a :class:~probcal.sklearn.SklearnCalibrator step. The offset is deliberately a separate pipeline step rather than a parameter folded into the calibrator: it keeps the central-tendency re-anchoring inspectable and swappable on its own, exactly as :class:~probcal.chain.Chain keeps it a separate stage. No y is consumed (LogitOffset ignores it), so there is no orientation check to run — the column-1 convention for two-column input is documented (guide/sklearn.md), not checked.

PARAMETER DESCRIPTION
delta

Mode A: the log-odds shift to apply directly. Mutually exclusive with target_mean.

TYPE: float or None DEFAULT: None

target_mean

Mode B: the desired post-shift portfolio mean probability, solved by bisection. Mutually exclusive with delta.

TYPE: float or None DEFAULT: None

positive_column

Which column of a two-column probability matrix holds P(y=1)0 or 1 (default 1, matching predict_proba output). Ignored for single-column input.

TYPE: (int, keyword - only) DEFAULT: 1

ATTRIBUTE DESCRIPTION
offset_

The fitted inner offset.

TYPE: LogitOffset

n_features_in_

1 or 2, depending on the X shape seen at fit; enforced at predict/transform time.

TYPE: int

Source code in src/probcal/sklearn/_offset.py
48
49
50
51
52
53
54
55
56
57
def __init__(
    self,
    delta: float | None = None,
    target_mean: float | None = None,
    *,
    positive_column: int = 1,
) -> None:
    self.delta = delta
    self.target_mean = target_mean
    self.positive_column = positive_column

fit

fit(X: object, y: object = None, sample_weight: object = None) -> SklearnOffset

Fit the wrapped offset on the probability column.

PARAMETER DESCRIPTION
X

Probabilities, or a two-column probability matrix.

TYPE: array_like of shape (n,), (n, 1), or (n, 2)

y

Ignored; accepted for pipeline/estimator compatibility.

TYPE: array_like or None DEFAULT: None

sample_weight

Positive observation weights.

TYPE: array_like or None DEFAULT: None

RETURNS DESCRIPTION
SklearnOffset

The fitted adapter.

RAISES DESCRIPTION
ValueError

If X's column count is unsupported or positive_column is invalid.

Source code in src/probcal/sklearn/_offset.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def fit(self, X: object, y: object = None, sample_weight: object = None) -> "SklearnOffset":
    """Fit the wrapped offset on the probability column.

    Parameters
    ----------
    X : array_like of shape (n,), (n, 1), or (n, 2)
        Probabilities, or a two-column probability matrix.
    y : array_like or None
        Ignored; accepted for pipeline/estimator compatibility.
    sample_weight : array_like or None
        Positive observation weights.

    Returns
    -------
    SklearnOffset
        The fitted adapter.

    Raises
    ------
    ValueError
        If ``X``'s column count is unsupported or ``positive_column``
        is invalid.
    """
    if self.positive_column not in (0, 1):
        raise ValueError(f"positive_column must be 0 or 1, got {self.positive_column!r}")
    X_arr = validate_X(self, X, reset=True, allow_1d=True)
    sw = None if sample_weight is None else _check_sample_weight(sample_weight, X_arr)
    p = self._probs(X_arr)
    if sw is not None:
        # Zero weight means excluded (sklearn semantics); probcal requires
        # strictly positive weights, so drop those rows here.
        keep = sw > 0.0
        p, sw = p[keep], sw[keep]
    self.offset_ = LogitOffset(delta=self.delta, target_mean=self.target_mean)
    self.offset_.fit(p, sample_weight=sw, y=y)
    return self

transform

transform(X: object) -> ndarray

Shifted probability column (n, 1) — lets the adapter end a Pipeline.

Source code in src/probcal/sklearn/_offset.py
112
113
114
def transform(self, X: object) -> np.ndarray:
    """Shifted probability column ``(n, 1)`` — lets the adapter end a Pipeline."""
    return self.predict_proba(X)[:, [1]]

predict_proba

predict_proba(X: object) -> ndarray

(n, 2) matrix [1 - p_shifted, p_shifted].

Source code in src/probcal/sklearn/_offset.py
116
117
118
119
120
121
def predict_proba(self, X: object) -> np.ndarray:
    """``(n, 2)`` matrix ``[1 - p_shifted, p_shifted]``."""
    check_is_fitted(self, "offset_")
    X_arr = validate_X(self, X, allow_1d=True)
    p = self.offset_.transform(self._probs(X_arr))
    return np.column_stack([1.0 - p, p])

to_dict

to_dict() -> dict[str, object]

The fitted inner offset's own envelope (loads back as a LogitOffset).

Source code in src/probcal/sklearn/_offset.py
123
124
125
126
def to_dict(self) -> dict[str, object]:
    """The fitted inner offset's own envelope (loads back as a LogitOffset)."""
    check_is_fitted(self, "offset_")
    return self.offset_.to_dict()

optbinning

Calibrated optbinning scorecards.

Requires the probcal[optbinning] extra (optbinning >= 0.21). The scorecard stays the deployed artifact — points are untouched; probcal adds a calibrated PD layer on top plus exact translation between calibrated PD bands and point cut-offs, possible because Scorecard.score is affine in the fitted logistic regression's log-odds unless rounding=True.

CalibratedScorecard

CalibratedScorecard(scorecard: object, calibrator: BaseCalibrator, points_affine_coeffs: tuple[float, float] | None)

An optbinning Scorecard with a probcal calibration layer on top.

Built by :func:calibrate_scorecard. Points (score) are unchanged — the scorecard remains the deployed artifact; predict_proba returns the calibrated PD, and the calibrator protocol (interval_inverse, point_inverse, ...) operates on the scorecard's model-probability scale, so calibrated policies translate to raw probabilities and — via points_affine_coeffs_ — exactly to the points scale.

ATTRIBUTE DESCRIPTION
scorecard_

The wrapped, fitted scorecard.

TYPE: Scorecard

calibrator_

The fitted probcal calibrator over the scorecard's probabilities.

TYPE: BaseCalibrator

points_affine_coeffs_

score = A + B * logit(p_model), recovered from the calibration data and verified to machine precision; None (with a warning at build time) when the relation is not affine (rounding=True).

TYPE: tuple(A, B) or None

Source code in src/probcal/integrations/optbinning.py
56
57
58
59
60
61
62
63
64
def __init__(
    self,
    scorecard: object,
    calibrator: BaseCalibrator,
    points_affine_coeffs: tuple[float, float] | None,
) -> None:
    self.scorecard_ = scorecard
    self.calibrator_ = calibrator
    self.points_affine_coeffs_ = points_affine_coeffs

is_monotone_ property

is_monotone_: bool

Whether the calibration layer preserves the scorecard's ranking.

affine_logit_coeffs_ property

affine_logit_coeffs_: tuple[float, float] | None

The calibration layer's affine-logit coefficients, if any.

predict_proba

predict_proba(X: object) -> ndarray

Calibrated PD for scorecard inputs (1-D, probcal convention).

Source code in src/probcal/integrations/optbinning.py
71
72
73
def predict_proba(self, X: object) -> np.ndarray:
    """Calibrated PD for scorecard inputs (1-D, probcal convention)."""
    return self.calibrator_.predict_proba(self._model_proba(X))

score

score(X: object) -> ndarray

Unchanged scorecard points — the deployed artifact is untouched.

Source code in src/probcal/integrations/optbinning.py
75
76
77
def score(self, X: object) -> np.ndarray:
    """Unchanged scorecard points — the deployed artifact is untouched."""
    return np.asarray(self.scorecard_.score(X))  # type: ignore[attr-defined]

interpret

interpret()

The calibration layer's plain-language reading.

Source code in src/probcal/integrations/optbinning.py
79
80
81
def interpret(self):  # noqa: ANN201 - probcal Interpretation
    """The calibration layer's plain-language reading."""
    return self.calibrator_.interpret()

interval_inverse

interval_inverse(lo: float, hi: float, *, space: str = 'probability', buffer_logit: float = 0.0) -> tuple[float, float]

Preimage of a calibrated PD interval on the model-probability scale.

Source code in src/probcal/integrations/optbinning.py
95
96
97
98
99
def interval_inverse(
    self, lo: float, hi: float, *, space: str = "probability", buffer_logit: float = 0.0
) -> tuple[float, float]:
    """Preimage of a calibrated PD interval on the model-probability scale."""
    return self.calibrator_.interval_inverse(lo, hi, space=space, buffer_logit=buffer_logit)

point_inverse

point_inverse(p: object, *, space: str = 'probability') -> ndarray

Exact preimage of calibrated PDs on the model-probability scale.

Source code in src/probcal/integrations/optbinning.py
101
102
103
def point_inverse(self, p: object, *, space: str = "probability") -> np.ndarray:
    """Exact preimage of calibrated PDs on the model-probability scale."""
    return self.calibrator_.point_inverse(p, space=space)

masterscale

masterscale(bands: object) -> dict[str, tuple[float, float]]

Calibrated PD bands -> scorecard point cut-offs, exactly.

Composes :func:probcal.thresholds.calibrated_bands_to_raw (bands on the calibrated scale to raw log-odds intervals) with the verified affine points map. Point intervals are returned as (lo, hi) with lo <= hi (the affine slope is negative for the usual higher-points-safer scaling), and the cut-offs are checked for monotone ordering across bands.

PARAMETER DESCRIPTION
bands

Calibrated PD bands, e.g. {"A": (0.0, 0.01), "B": (0.01, 0.05)}, or a :class:probcal.Masterscale (its bands are read).

TYPE: dict[str, tuple(lo, hi)] or Masterscale

RETURNS DESCRIPTION
dict[str, tuple(points_lo, points_hi)]

Point cut-offs per band.

RAISES DESCRIPTION
RuntimeError

If the scorecard is not affine in log-odds (rounding=True) — use :meth:interval_inverse on the raw probability instead — or if the resulting cut-offs are not monotone across bands.

Source code in src/probcal/integrations/optbinning.py
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
def masterscale(self, bands: object) -> dict[str, tuple[float, float]]:
    """Calibrated PD bands -> scorecard point cut-offs, exactly.

    Composes :func:`probcal.thresholds.calibrated_bands_to_raw` (bands on
    the calibrated scale to raw log-odds intervals) with the verified
    affine points map. Point intervals are returned as ``(lo, hi)`` with
    ``lo <= hi`` (the affine slope is negative for the usual
    higher-points-safer scaling), and the cut-offs are checked for
    monotone ordering across bands.

    Parameters
    ----------
    bands : dict[str, tuple(lo, hi)] or Masterscale
        Calibrated PD bands, e.g. ``{"A": (0.0, 0.01), "B": (0.01, 0.05)}``,
        or a :class:`probcal.Masterscale` (its ``bands`` are read).

    Returns
    -------
    dict[str, tuple(points_lo, points_hi)]
        Point cut-offs per band.

    Raises
    ------
    RuntimeError
        If the scorecard is not affine in log-odds (``rounding=True``) —
        use :meth:`interval_inverse` on the raw probability instead —
        or if the resulting cut-offs are not monotone across bands.
    """
    if self.points_affine_coeffs_ is None:
        raise RuntimeError(
            "this scorecard is not affine in log-odds (rounding=True); the exact "
            "masterscale is unavailable — use interval_inverse on the raw "
            "probability instead"
        )
    a_pts, b_pts = self.points_affine_coeffs_
    bands_d: dict[str, tuple[float, float]] = (
        bands.bands if hasattr(bands, "bands") else bands  # type: ignore[attr-defined,assignment]
    )
    raw = calibrated_bands_to_raw(self.calibrator_, bands_d, space="logit")
    out: dict[str, tuple[float, float]] = {}
    for name, (z_lo, z_hi) in raw.items():
        p_lo = a_pts + b_pts * z_lo if np.isfinite(z_lo) else np.inf * -np.sign(b_pts)
        p_hi = a_pts + b_pts * z_hi if np.isfinite(z_hi) else np.inf * np.sign(b_pts)
        out[name] = (min(p_lo, p_hi), max(p_lo, p_hi))
    # Bands ordered by rising calibrated PD must map to monotone point
    # ranges (falling when B < 0, i.e. higher points = safer).
    order = sorted(out, key=lambda k: bands_d[k][0])
    cuts = [out[k] for k in order]
    if b_pts < 0:
        mono = all(cuts[i][1] >= cuts[i + 1][1] - 1e-9 for i in range(len(cuts) - 1))
    else:
        mono = all(cuts[i][0] <= cuts[i + 1][0] + 1e-9 for i in range(len(cuts) - 1))
    if not mono:
        raise RuntimeError("masterscale cut-offs are not monotone across bands")
    return out

to_dict

to_dict() -> dict[str, object]

Calibrator envelope plus a fingerprint of the scorecard table.

The scorecard object itself is not serialized (it is optbinning's artifact); rebuild with CalibratedScorecard.from_dict(d, scorecard=...) after loading the scorecard through optbinning's own save/load.

Source code in src/probcal/integrations/optbinning.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def to_dict(self) -> dict[str, object]:
    """Calibrator envelope plus a fingerprint of the scorecard table.

    The scorecard object itself is not serialized (it is optbinning's
    artifact); rebuild with ``CalibratedScorecard.from_dict(d,
    scorecard=...)`` after loading the scorecard through optbinning's
    own ``save``/``load``.
    """
    from .. import __version__

    return {
        "probcal_schema": SCHEMA_VERSION,
        "probcal_version": __version__,
        "class": type(self).__name__,
        "params": {},
        "state": {
            "calibrator": self.calibrator_.to_dict(),
            "points_affine_coeffs": (
                list(self.points_affine_coeffs_)
                if self.points_affine_coeffs_ is not None
                else None
            ),
            "scorecard_fingerprint": self.scorecard_fingerprint(),
        },
        "fit_meta": {},
    }

from_dict classmethod

from_dict(d: dict, scorecard: object) -> CalibratedScorecard

Rebuild around a scorecard loaded through optbinning's own tooling.

RAISES DESCRIPTION
ValueError

If the payload class differs, or the supplied scorecard's table fingerprint does not match the stored one.

Source code in src/probcal/integrations/optbinning.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
@classmethod
def from_dict(cls, d: dict, scorecard: object) -> "CalibratedScorecard":
    """Rebuild around a scorecard loaded through optbinning's own tooling.

    Raises
    ------
    ValueError
        If the payload class differs, or the supplied scorecard's table
        fingerprint does not match the stored one.
    """
    from .._registry import load
    from .._serialize import check_schema

    check_schema(d)
    if d.get("class") != cls.__name__:
        raise ValueError(f"payload was written by {d.get('class')!r}, not {cls.__name__}")
    state = d["state"]
    coeffs = state.get("points_affine_coeffs")
    obj = cls(
        scorecard,
        load(state["calibrator"]),  # type: ignore[arg-type]
        tuple(coeffs) if coeffs is not None else None,  # type: ignore[arg-type]
    )
    stored = state.get("scorecard_fingerprint")
    if stored is not None and obj.scorecard_fingerprint() != stored:
        raise ValueError(
            "the supplied scorecard's table fingerprint does not match the stored "
            "one — this calibration layer was fitted against a different scorecard"
        )
    return obj

scorecard_fingerprint

scorecard_fingerprint() -> str

SHA-256 of the scorecard table (CSV form) — names the deployed artifact.

Source code in src/probcal/integrations/optbinning.py
221
222
223
224
def scorecard_fingerprint(self) -> str:
    """SHA-256 of the scorecard table (CSV form) — names the deployed artifact."""
    table = self.scorecard_.table(style="detailed")  # type: ignore[attr-defined]
    return hashlib.sha256(table.to_csv(index=False).encode("utf-8")).hexdigest()

fingerprint

fingerprint() -> str

SHA-256 over the calibration layer and the scorecard-table fingerprint.

Source code in src/probcal/integrations/optbinning.py
226
227
228
def fingerprint(self) -> str:
    """SHA-256 over the calibration layer and the scorecard-table fingerprint."""
    return fingerprint_of_dict(self.to_dict())

to_json

to_json(path: str | PathLike[str] | None = None, *, indent: int = 2) -> str | None

Serialize the calibration layer (see :meth:to_dict).

Source code in src/probcal/integrations/optbinning.py
230
231
232
233
234
235
236
237
238
239
def to_json(
    self, path: "str | os.PathLike[str] | None" = None, *, indent: int = 2
) -> str | None:
    """Serialize the calibration layer (see :meth:`to_dict`)."""
    text = json.dumps(self.to_dict(), indent=indent)
    if path is None:
        return text
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(text)
    return None

calibrate_scorecard

calibrate_scorecard(scorecard: object, X_cal: object, y_cal: object, *, calibrator: BaseCalibrator | None = None, sample_weight: object = None) -> CalibratedScorecard

Fit a probcal calibration layer on a fitted optbinning scorecard.

PARAMETER DESCRIPTION
scorecard

Fitted scorecard with predict_proba, score, and table.

TYPE: Scorecard

X_cal

Held-out calibration data (never the scorecard's training data — see the data-splitting chapter).

TYPE: array_like

y_cal

Held-out calibration data (never the scorecard's training data — see the data-splitting chapter).

TYPE: array_like

calibrator

Unfitted probcal prototype; None uses BetaCalibrator().

TYPE: (BaseCalibrator or None, keyword - only) DEFAULT: None

sample_weight

Positive observation weights for the calibration fit.

TYPE: (array_like or None, keyword - only) DEFAULT: None

RETURNS DESCRIPTION
CalibratedScorecard

Calibrated PD layer over the unchanged scorecard, with the affine points map recovered and verified (or refused with a warning when rounding=True breaks affinity).

Source code in src/probcal/integrations/optbinning.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
def calibrate_scorecard(
    scorecard: object,
    X_cal: object,
    y_cal: object,
    *,
    calibrator: BaseCalibrator | None = None,
    sample_weight: object = None,
) -> CalibratedScorecard:
    """Fit a probcal calibration layer on a fitted optbinning scorecard.

    Parameters
    ----------
    scorecard : optbinning.Scorecard
        Fitted scorecard with ``predict_proba``, ``score``, and ``table``.
    X_cal, y_cal : array_like
        Held-out calibration data (never the scorecard's training data —
        see the data-splitting chapter).
    calibrator : BaseCalibrator or None, keyword-only
        Unfitted probcal prototype; ``None`` uses ``BetaCalibrator()``.
    sample_weight : array_like or None, keyword-only
        Positive observation weights for the calibration fit.

    Returns
    -------
    CalibratedScorecard
        Calibrated PD layer over the unchanged scorecard, with the affine
        points map recovered and verified (or refused with a warning when
        ``rounding=True`` breaks affinity).
    """
    proto = calibrator if calibrator is not None else BetaCalibrator()
    cal = type(proto)(**proto.get_params())
    p_model = np.asarray(scorecard.predict_proba(X_cal))[:, 1]  # type: ignore[attr-defined]
    cal.fit(p_model, y_cal, sample_weight=sample_weight)

    points = np.asarray(scorecard.score(X_cal), dtype=np.float64)  # type: ignore[attr-defined]
    z = logit(p_model)
    b_pts, a_pts = np.polyfit(z, points, 1)
    resid = float(np.max(np.abs(points - (a_pts + b_pts * z))))
    coeffs: tuple[float, float] | None = (float(a_pts), float(b_pts))
    if resid > _AFFINE_ATOL:
        warnings.warn(
            f"scorecard points are not affine in log-odds (max residual {resid:.3g}, "
            "e.g. rounding=True); masterscale is unavailable — falling back to "
            "interval_inverse on the raw probability",
            UserWarning,
            stacklevel=2,
        )
        coeffs = None
    return CalibratedScorecard(scorecard, cal, coeffs)