Source code for driftwatch.conformal.mondrian
"""Class-conditional (Mondrian) conformal p-values and credibility.
For a class ``c`` the calibration set keeps the nonconformity scores ``s_i`` of the labelled
calibration samples whose true class is ``c``. A new sample ``x`` with nonconformity ``s_c(x)``
for class ``c`` gets
p_c(x) = (1 + #{i : y_i = c, s_i >= s_c(x)}) / (n_c + 1)
(build guide section 5, PROTOCOL.md section 4). The comparison is inclusive: a tie counts as
"at least as nonconforming". A class with no calibration sample has ``n_c = 0`` and gets
``p = 1``: nothing in the calibration set says the sample is unusual. The credibility of a
sample is the p-value of its predicted class; a low credibility says the sample looks unlike
what the model knows for that class.
Validity (``P(p <= eps) <= eps`` for a sample exchangeable with the calibration samples of its
class) holds only when the calibration samples are a fair draw from the current data. That is
why calibration labels come only from the reserved random slice (``recalibrate.py``).
Pure numpy; no model code and no labels of the evaluated slot.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
[docs]
@dataclass(frozen=True)
class ClassCalibration:
"""Sorted nonconformity scores of the calibration samples, one array per class."""
scores: tuple[np.ndarray, ...] # index = class; each sorted ascending
@property
def n_classes(self) -> int:
return len(self.scores)
def counts(self) -> tuple[int, ...]:
return tuple(int(len(s)) for s in self.scores)
[docs]
@classmethod
def from_scores(
cls, own_scores: np.ndarray, labels: np.ndarray, n_classes: int = 2
) -> ClassCalibration:
"""``own_scores[i]`` is the nonconformity of calibration sample ``i`` for its own label."""
s = np.asarray(own_scores, dtype=np.float64)
y = np.asarray(labels)
if s.ndim != 1 or s.shape != y.shape:
raise ValueError("scores and labels must be one-dimensional and the same length")
if len(y) and (y.min() < 0 or y.max() >= n_classes):
raise ValueError(f"labels must lie in 0..{n_classes - 1}")
if not np.isfinite(s).all():
raise ValueError("calibration scores must be finite")
return cls(tuple(np.sort(s[y == c]) for c in range(n_classes)))
[docs]
def pvalues(calibration: ClassCalibration, test_scores: np.ndarray) -> np.ndarray:
"""Mondrian p-values, shape ``(n, n_classes)``.
``test_scores[:, c]`` is the nonconformity ``s_c(x)`` of each test sample for class ``c``.
"""
t = np.asarray(test_scores, dtype=np.float64)
if t.ndim != 2 or t.shape[1] != calibration.n_classes:
raise ValueError(f"test scores must have shape (n, {calibration.n_classes})")
if not np.isfinite(t).all():
raise ValueError("test scores must be finite")
out = np.empty_like(t)
for c, cal in enumerate(calibration.scores):
n_c = len(cal)
# #{s_i >= s} = n_c - #{s_i < s}: searchsorted 'left' counts the strictly smaller ones
at_least = n_c - np.searchsorted(cal, t[:, c], side="left")
out[:, c] = (1.0 + at_least) / (n_c + 1.0)
return out
[docs]
def credibility(p: np.ndarray, predicted: np.ndarray) -> np.ndarray:
"""The p-value of the predicted class, per sample."""
p = np.asarray(p)
pred = np.asarray(predicted, dtype=np.int64)
if p.ndim != 2 or pred.shape != (p.shape[0],):
raise ValueError("p must be (n, classes) and predicted must be (n,)")
if len(pred) and (pred.min() < 0 or pred.max() >= p.shape[1]):
raise ValueError("predicted class outside the p-value columns")
return p[np.arange(len(pred)), pred]