Skip to content

infer.calibration

Probability calibration hooks for post-model adjustment, including per-user calibration via CalibratorStore.

Overview

After the model produces raw class probabilities, the calibration layer optionally adjusts them before the reject decision is made:

raw model probs → calibrate → reject decision

Calibration improves the reliability of predicted probabilities without changing the underlying model. Three calibrator implementations are provided, all satisfying the Calibrator protocol:

Class Description
IdentityCalibrator No-op pass-through (default when no calibrator is configured)
TemperatureCalibrator Single-parameter temperature scaling: T > 1 softens, T < 1 sharpens
IsotonicCalibrator Per-class isotonic regression via sklearn.isotonic.IsotonicRegression

All calibrators must preserve input shape and ensure the output sums to 1.0 along the class axis. The concrete calibrator classes and CalibratorStore are implemented as dataclasses; constructor signatures are unchanged.

Calibrator protocol

Any object with a calibrate(core_probs: np.ndarray) -> np.ndarray method satisfies the protocol. The method accepts arrays of shape (n_classes,) (single row) or (n_rows, n_classes) (batch).

CalibratorStore

CalibratorStore manages per-user calibration with a global fallback. It holds a global calibrator (fitted on all users' validation data) and optional per-user calibrators for users that meet the personalization eligibility thresholds (see train calibrate).

At inference time, get_calibrator(user_id) returns the per-user calibrator if one exists, otherwise the global calibrator. calibrate_batch(core_probs, user_ids) applies the correct calibrator row-by-row.

Directory layout

When persisted via save_calibrator_store, the store is written as:

<store_dir>/
    store.json          # metadata (see below)
    global.json         # global calibrator (any type)
    users/
        <user_id>.json  # per-user calibrator (one file per user)

store.json fields

Field Type Description
method str "temperature" or "isotonic"
user_count int Number of per-user calibrators
user_ids list[str] Sorted list of user IDs with per-user calibrators
model_bundle_id str \| null Run directory name of the model this store was fitted against
model_schema_hash str \| null Schema hash of the model bundle (for cross-validation with inference policy)
created_at str \| null ISO-8601 timestamp of store creation

The model_bundle_id, model_schema_hash, and created_at fields were added to enable model-bound calibration. Stores created before this change will have null for these fields; they still load and function normally.

Serialization

Single calibrator

from pathlib import Path
from taskclf.infer.calibration import (
    TemperatureCalibrator, save_calibrator, load_calibrator,
)

cal = TemperatureCalibrator(temperature=1.35)
save_calibrator(cal, Path("artifacts/calibrator.json"))

loaded = load_calibrator(Path("artifacts/calibrator.json"))

Calibrator store

from pathlib import Path
from taskclf.infer.calibration import (
    CalibratorStore, TemperatureCalibrator,
    save_calibrator_store, load_calibrator_store,
)

store = CalibratorStore(
    global_calibrator=TemperatureCalibrator(1.2),
    user_calibrators={"alice": TemperatureCalibrator(1.05)},
    method="temperature",
)
save_calibrator_store(store, Path("artifacts/calibrators"))

loaded_store = load_calibrator_store(Path("artifacts/calibrators"))
print(loaded_store.user_ids)  # ['alice']

Eligibility

Per-user calibration is only fitted when the user meets the eligibility thresholds defined in core.defaults (minimum labeled windows, days, and distinct labels). See the train calibrate CLI command and personalization guide for details.

taskclf.infer.calibration

Probability calibration hooks for post-model adjustment.

Provides a Calibrator protocol and concrete implementations:

  • :class:IdentityCalibrator — no-op pass-through (default).
  • :class:TemperatureCalibrator — single-parameter temperature scaling.
  • :class:IsotonicCalibrator — per-class isotonic regression.

Per-user calibration is managed through :class:CalibratorStore, which maps user_id to a fitted calibrator and falls back to a global calibrator for users without enough labeled data.

Calibrator

Bases: Protocol

Minimal contract for a probability calibrator.

Implementations must:

  • Preserve the shape of core_probs.
  • Ensure the output sums to 1.0 along the class axis.
  • Be deterministic (same input → same output).
Source code in src/taskclf/infer/calibration.py
@runtime_checkable
class Calibrator(Protocol):
    """Minimal contract for a probability calibrator.

    Implementations must:

    * Preserve the shape of *core_probs*.
    * Ensure the output sums to 1.0 along the class axis.
    * Be deterministic (same input → same output).
    """

    def calibrate(self, core_probs: np.ndarray) -> np.ndarray:
        """Adjust a probability vector (or matrix) and return calibrated probabilities.

        Args:
            core_probs: Array of shape ``(n_classes,)`` or ``(n_rows, n_classes)``.

        Returns:
            Calibrated probabilities with the same shape.
        """
        ...  # pragma: no cover

calibrate(core_probs)

Adjust a probability vector (or matrix) and return calibrated probabilities.

Parameters:

Name Type Description Default
core_probs ndarray

Array of shape (n_classes,) or (n_rows, n_classes).

required

Returns:

Type Description
ndarray

Calibrated probabilities with the same shape.

Source code in src/taskclf/infer/calibration.py
def calibrate(self, core_probs: np.ndarray) -> np.ndarray:
    """Adjust a probability vector (or matrix) and return calibrated probabilities.

    Args:
        core_probs: Array of shape ``(n_classes,)`` or ``(n_rows, n_classes)``.

    Returns:
        Calibrated probabilities with the same shape.
    """
    ...  # pragma: no cover

IdentityCalibrator dataclass

No-op calibrator that returns probabilities unchanged.

Source code in src/taskclf/infer/calibration.py
@dataclass(frozen=True, eq=False)
class IdentityCalibrator:
    """No-op calibrator that returns probabilities unchanged."""

    def calibrate(self, core_probs: np.ndarray) -> np.ndarray:
        return core_probs

TemperatureCalibrator dataclass

Scale logits by a learned temperature before softmax.

A temperature > 1 softens the distribution (less confident); a temperature < 1 sharpens it (more confident).

Parameters:

Name Type Description Default
temperature float

Positive scalar. Defaults to 1.0 (identity).

1.0
Source code in src/taskclf/infer/calibration.py
@dataclass(frozen=True, eq=False)
class TemperatureCalibrator:
    """Scale logits by a learned temperature before softmax.

    A temperature > 1 softens the distribution (less confident);
    a temperature < 1 sharpens it (more confident).

    Args:
        temperature: Positive scalar.  Defaults to 1.0 (identity).
    """

    temperature: float = 1.0

    def __post_init__(self) -> None:
        if self.temperature <= 0:
            raise ValueError(f"temperature must be positive, got {self.temperature}")

    def calibrate(self, core_probs: np.ndarray) -> np.ndarray:
        if self.temperature == 1.0:
            return core_probs

        eps = 1e-12
        logits = np.log(np.clip(core_probs, eps, None))
        scaled = logits / self.temperature

        # Numerically stable softmax
        shifted = scaled - scaled.max(axis=-1, keepdims=True)
        exp_vals = np.exp(shifted)
        return exp_vals / exp_vals.sum(axis=-1, keepdims=True)

IsotonicCalibrator dataclass

Per-class isotonic regression calibrator.

Wraps one sklearn.isotonic.IsotonicRegression per class. Each regressor maps the model's raw probability for that class to a calibrated value. After per-class transformation the vector is renormalized to sum to 1.0.

Parameters:

Name Type Description Default
regressors list

List of fitted IsotonicRegression instances, one per class, ordered by label ID.

required
Source code in src/taskclf/infer/calibration.py
@dataclass(frozen=True, eq=False)
class IsotonicCalibrator:
    """Per-class isotonic regression calibrator.

    Wraps one ``sklearn.isotonic.IsotonicRegression`` per class.  Each
    regressor maps the model's raw probability for that class to a
    calibrated value.  After per-class transformation the vector is
    renormalized to sum to 1.0.

    Args:
        regressors: List of fitted ``IsotonicRegression`` instances,
            one per class, ordered by label ID.
    """

    regressors: list

    def __post_init__(self) -> None:
        if not self.regressors:
            raise ValueError("regressors list must not be empty")

    @property
    def n_classes(self) -> int:
        return len(self.regressors)

    def calibrate(self, core_probs: np.ndarray) -> np.ndarray:
        single = core_probs.ndim == 1
        probs = core_probs[np.newaxis, :] if single else core_probs

        calibrated = np.empty_like(probs)
        for c, reg in enumerate(self.regressors):
            calibrated[:, c] = np.clip(reg.predict(probs[:, c]), 1e-12, None)

        row_sums = calibrated.sum(axis=1, keepdims=True)
        calibrated = calibrated / row_sums

        return calibrated[0] if single else calibrated

CalibratorStore dataclass

Per-user calibrator registry with global fallback.

Holds a global calibrator (fitted on all users' validation data) and optional per-user calibrators for users that meet the personalization eligibility thresholds.

Parameters:

Name Type Description Default
global_calibrator Calibrator

Calibrator applied to users without a dedicated calibrator.

required
user_calibrators dict[str, Calibrator]

Mapping from user_id to a fitted calibrator. None or empty dict means all users fall back to the global calibrator.

dict()
method str

Calibration method label ("temperature" or "isotonic").

'temperature'
model_bundle_id str | None

Run directory name of the model bundle this store was fitted against. None for legacy stores.

None
model_schema_hash str | None

Schema hash of the model bundle. Used by :func:~taskclf.core.inference_policy.validate_policy to verify that calibrator and model are compatible.

None
created_at str | None

ISO-8601 timestamp of when the store was created.

None
Source code in src/taskclf/infer/calibration.py
@dataclass(eq=False)
class CalibratorStore:
    """Per-user calibrator registry with global fallback.

    Holds a global calibrator (fitted on all users' validation data)
    and optional per-user calibrators for users that meet the
    personalization eligibility thresholds.

    Args:
        global_calibrator: Calibrator applied to users without a
            dedicated calibrator.
        user_calibrators: Mapping from ``user_id`` to a fitted
            calibrator.  ``None`` or empty dict means all users fall
            back to the global calibrator.
        method: Calibration method label (``"temperature"`` or
            ``"isotonic"``).
        model_bundle_id: Run directory name of the model bundle this
            store was fitted against.  ``None`` for legacy stores.
        model_schema_hash: Schema hash of the model bundle.  Used by
            :func:`~taskclf.core.inference_policy.validate_policy` to
            verify that calibrator and model are compatible.
        created_at: ISO-8601 timestamp of when the store was created.
    """

    global_calibrator: Calibrator
    user_calibrators: dict[str, Calibrator] = field(default_factory=dict)
    method: str = "temperature"
    model_bundle_id: str | None = None
    model_schema_hash: str | None = None
    created_at: str | None = None

    def get_calibrator(self, user_id: str) -> Calibrator:
        """Return the per-user calibrator if available, else the global one."""
        return self.user_calibrators.get(user_id, self.global_calibrator)

    def calibrate_batch(
        self,
        core_probs: np.ndarray,
        user_ids: Sequence[str],
    ) -> np.ndarray:
        """Apply per-user calibration row by row.

        Args:
            core_probs: Probability matrix ``(n_rows, n_classes)``.
            user_ids: Sequence of user_id strings aligned with rows.

        Returns:
            Calibrated probability matrix of the same shape.
        """
        result = np.empty_like(core_probs)
        for i, uid in enumerate(user_ids):
            cal = self.get_calibrator(uid)
            result[i] = cal.calibrate(core_probs[i : i + 1])[0]
        return result

    @property
    def user_ids(self) -> list[str]:
        """Return sorted list of user IDs with per-user calibrators."""
        return sorted(self.user_calibrators)

user_ids property

Return sorted list of user IDs with per-user calibrators.

get_calibrator(user_id)

Return the per-user calibrator if available, else the global one.

Source code in src/taskclf/infer/calibration.py
def get_calibrator(self, user_id: str) -> Calibrator:
    """Return the per-user calibrator if available, else the global one."""
    return self.user_calibrators.get(user_id, self.global_calibrator)

calibrate_batch(core_probs, user_ids)

Apply per-user calibration row by row.

Parameters:

Name Type Description Default
core_probs ndarray

Probability matrix (n_rows, n_classes).

required
user_ids Sequence[str]

Sequence of user_id strings aligned with rows.

required

Returns:

Type Description
ndarray

Calibrated probability matrix of the same shape.

Source code in src/taskclf/infer/calibration.py
def calibrate_batch(
    self,
    core_probs: np.ndarray,
    user_ids: Sequence[str],
) -> np.ndarray:
    """Apply per-user calibration row by row.

    Args:
        core_probs: Probability matrix ``(n_rows, n_classes)``.
        user_ids: Sequence of user_id strings aligned with rows.

    Returns:
        Calibrated probability matrix of the same shape.
    """
    result = np.empty_like(core_probs)
    for i, uid in enumerate(user_ids):
        cal = self.get_calibrator(uid)
        result[i] = cal.calibrate(core_probs[i : i + 1])[0]
    return result

save_calibrator(calibrator, path)

Persist a calibrator to JSON.

Supports :class:IdentityCalibrator, :class:TemperatureCalibrator, and :class:IsotonicCalibrator.

Parameters:

Name Type Description Default
calibrator Calibrator

Calibrator instance to serialize.

required
path Path

Destination file path.

required

Returns:

Type Description
Path

The path that was written.

Source code in src/taskclf/infer/calibration.py
def save_calibrator(calibrator: Calibrator, path: Path) -> Path:
    """Persist a calibrator to JSON.

    Supports :class:`IdentityCalibrator`, :class:`TemperatureCalibrator`,
    and :class:`IsotonicCalibrator`.

    Args:
        calibrator: Calibrator instance to serialize.
        path: Destination file path.

    Returns:
        The *path* that was written.
    """
    if isinstance(calibrator, TemperatureCalibrator):
        data = {"type": "temperature", "temperature": calibrator.temperature}
    elif isinstance(calibrator, IsotonicCalibrator):
        data = _serialize_isotonic(calibrator)
    elif isinstance(calibrator, IdentityCalibrator):
        data = {"type": "identity"}
    else:
        raise TypeError(
            f"Cannot serialize calibrator of type {type(calibrator).__name__}"
        )

    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2))
    return path

load_calibrator(path)

Load a calibrator from a JSON file written by :func:save_calibrator.

Parameters:

Name Type Description Default
path Path

Path to a calibrator JSON file.

required

Returns:

Name Type Description
A Calibrator

class:Calibrator instance.

Raises:

Type Description
ValueError

If the file contains an unknown calibrator type.

Source code in src/taskclf/infer/calibration.py
def load_calibrator(path: Path) -> Calibrator:
    """Load a calibrator from a JSON file written by :func:`save_calibrator`.

    Args:
        path: Path to a calibrator JSON file.

    Returns:
        A :class:`Calibrator` instance.

    Raises:
        ValueError: If the file contains an unknown calibrator type.
    """
    data = json.loads(path.read_text())
    cal_type = data.get("type", "identity")

    if cal_type == "identity":
        return IdentityCalibrator()
    if cal_type == "temperature":
        return TemperatureCalibrator(temperature=data["temperature"])
    if cal_type == "isotonic":
        return _deserialize_isotonic(data)

    raise ValueError(f"Unknown calibrator type: {cal_type!r}")

save_calibrator_store(store, path)

Persist a :class:CalibratorStore to a directory.

Layout::

path/
    store.json          # metadata + global calibrator
    users/
        <user_id>.json  # per-user calibrator

Parameters:

Name Type Description Default
store CalibratorStore

Store to serialize.

required
path Path

Target directory (created if needed).

required

Returns:

Type Description
Path

The directory path.

Source code in src/taskclf/infer/calibration.py
def save_calibrator_store(store: CalibratorStore, path: Path) -> Path:
    """Persist a :class:`CalibratorStore` to a directory.

    Layout::

        path/
            store.json          # metadata + global calibrator
            users/
                <user_id>.json  # per-user calibrator

    Args:
        store: Store to serialize.
        path: Target directory (created if needed).

    Returns:
        The directory *path*.
    """
    path.mkdir(parents=True, exist_ok=True)

    global_path = path / "global.json"
    save_calibrator(store.global_calibrator, global_path)

    meta: dict[str, object] = {
        "method": store.method,
        "user_count": len(store.user_calibrators),
        "user_ids": sorted(store.user_calibrators),
    }
    if store.model_bundle_id is not None:
        meta["model_bundle_id"] = store.model_bundle_id
    if store.model_schema_hash is not None:
        meta["model_schema_hash"] = store.model_schema_hash
    if store.created_at is not None:
        meta["created_at"] = store.created_at
    (path / "store.json").write_text(json.dumps(meta, indent=2))

    if store.user_calibrators:
        users_dir = path / "users"
        users_dir.mkdir(exist_ok=True)
        for uid, cal in store.user_calibrators.items():
            save_calibrator(cal, users_dir / f"{uid}.json")

    return path

load_calibrator_store(path)

Load a :class:CalibratorStore from a directory.

Parameters:

Name Type Description Default
path Path

Directory previously written by :func:save_calibrator_store.

required

Returns:

Type Description
CalibratorStore

A populated CalibratorStore.

Source code in src/taskclf/infer/calibration.py
def load_calibrator_store(path: Path) -> CalibratorStore:
    """Load a :class:`CalibratorStore` from a directory.

    Args:
        path: Directory previously written by :func:`save_calibrator_store`.

    Returns:
        A populated ``CalibratorStore``.
    """
    meta = json.loads((path / "store.json").read_text())
    global_cal = load_calibrator(path / "global.json")

    user_cals: dict[str, Calibrator] = {}
    users_dir = path / "users"
    for uid in meta.get("user_ids", []):
        user_path = users_dir / f"{uid}.json"
        if user_path.exists():
            user_cals[uid] = load_calibrator(user_path)

    return CalibratorStore(
        global_calibrator=global_cal,
        user_calibrators=user_cals,
        method=meta.get("method", "temperature"),
        model_bundle_id=meta.get("model_bundle_id"),
        model_schema_hash=meta.get("model_schema_hash"),
        created_at=meta.get("created_at"),
    )