Source code for mixle.ppl.conformal
"""Split (inductive) conformal prediction — distribution-free, finite-sample valid
prediction intervals and label sets around any already-fitted model.
Conformal prediction turns point predictions into calibrated sets using a held-out
*calibration* split, with a coverage guarantee that holds for any model and any data
distribution as long as the calibration and test points are exchangeable: a set built at
level ``alpha`` covers the truth with probability at least ``1 - alpha``. A wrong model
only makes the sets wider, never breaks the guarantee.
The machinery is a nonconformity score plus one order statistic. For regression the score
is the absolute residual ``|y - yhat|`` and the calibrated interval is
``predict(x) +/- qhat``; for classification the score is ``1 - p(true class | x)`` and the
label set is ``{y : 1 - p(y | x) <= tau}``. Both reduce to the conformal quantile
``qhat`` / ``tau`` — the ``ceil((n + 1)(1 - alpha))`` smallest calibration score, the
``+1`` being the finite-sample correction.
:class:`ConformalRegressor` wraps a fitted :class:`~mixle.ppl.regression.RegressionResult`
(anything exposing ``predict(given)``); :class:`ConformalClassifier` wraps a matrix of
per-class probabilities (e.g. the posterior of a mixle generative classifier). The
:func:`conformal` helper is the one-liner entry point for the regression case.
"""
from __future__ import annotations
from typing import Any
import numpy as np
[docs]
def conformal_quantile(scores: Any, alpha: float) -> float:
"""The level-``alpha`` conformal quantile of calibration ``scores``.
Returns the ``ceil((n + 1)(1 - alpha))`` smallest score (the finite-sample-corrected
empirical ``1 - alpha`` quantile). When ``alpha`` is too small for the calibration
size — ``(n + 1)(1 - alpha) > n`` — no finite threshold gives the requested coverage
and ``inf`` is returned, the honest "the set is everything" answer.
"""
s = np.sort(np.asarray(scores, dtype=float))
n = s.size
if n == 0:
raise ValueError("conformal calibration needs at least one score.")
k = int(np.ceil((n + 1) * (1.0 - alpha)))
if k > n:
return float("inf")
return float(s[k - 1])
[docs]
class ConformalRegressor:
"""Split-conformal prediction intervals around a fitted regression ``result``.
Calibrates the absolute-residual nonconformity score on held-out ``(given, y_cal)`` and
produces symmetric intervals ``predict(x) +/- qhat`` with marginal coverage at least
``1 - alpha``. ``result`` is any object with a ``predict(given)`` method returning the
fitted mean (a :class:`~mixle.ppl.regression.RegressionResult`, a location-scale result,
or a GP regressor).
"""
def __init__(self, result: Any, y_cal: Any, *, given: dict, alpha: float = 0.1) -> None:
self.result = result
self.alpha = float(alpha)
yhat = np.asarray(result.predict(given), dtype=float).reshape(-1)
y = np.asarray(y_cal, dtype=float).reshape(-1)
if yhat.shape != y.shape:
raise ValueError(f"calibration predictions {yhat.shape} and targets {y.shape} disagree.")
self.scores = np.abs(y - yhat)
self.qhat = conformal_quantile(self.scores, self.alpha) # interval half-width
[docs]
def interval(self, given: dict) -> tuple[np.ndarray, np.ndarray]:
"""Return ``(lower, upper)`` arrays of the conformal interval at covariates ``given``."""
center = np.asarray(self.result.predict(given), dtype=float).reshape(-1)
return center - self.qhat, center + self.qhat
[docs]
def covers(self, y: Any, *, given: dict) -> np.ndarray:
"""Boolean array: does the interval at ``given`` contain each observed ``y``."""
lo, hi = self.interval(given)
y = np.asarray(y, dtype=float).reshape(-1)
return (y >= lo) & (y <= hi)
[docs]
class ConformalClassifier:
"""Split-conformal label sets from per-class probabilities.
``proba_cal`` is an ``(n, K)`` matrix of calibration probabilities ``p(y | x)`` (any
proper classifier — a mixle generative classifier's class posterior, a softmax,
...) and ``y_cal`` the integer labels. The nonconformity score is ``1 - p(true)`` and
the calibrated set keeps every label whose score is within the conformal quantile, so it
covers the true label with probability at least ``1 - alpha`` and grows from one label
(confident) to several (hedging) as the model is unsure.
"""
def __init__(self, proba_cal: Any, y_cal: Any, *, alpha: float = 0.1) -> None:
proba = np.asarray(proba_cal, dtype=float)
y = np.asarray(y_cal, dtype=int).reshape(-1)
if proba.ndim != 2 or proba.shape[0] != y.shape[0]:
raise ValueError("proba_cal must be (n_calibration, n_classes) aligned with y_cal.")
self.alpha = float(alpha)
self.scores = 1.0 - proba[np.arange(y.size), y]
self.tau = conformal_quantile(self.scores, self.alpha)
[docs]
def predict_set(self, proba: Any) -> np.ndarray:
"""Boolean ``(n, K)`` label-inclusion matrix at probabilities ``proba``."""
return (1.0 - np.asarray(proba, dtype=float)) <= self.tau
[docs]
def covers(self, proba: Any, y: Any) -> np.ndarray:
"""Boolean array: is each true label ``y`` in the predicted set."""
sets = self.predict_set(proba)
y = np.asarray(y, dtype=int).reshape(-1)
return sets[np.arange(y.size), y]
[docs]
def set_sizes(self, proba: Any) -> np.ndarray:
"""Number of labels in the predicted set for each row of ``proba``."""
return self.predict_set(proba).sum(axis=1)
[docs]
class ConformalQuantileRegressor:
"""Conformalized quantile regression (Romano, Patterson, Candes 2019).
Combines two fitted quantile regressions (a lower and an upper conditional quantile) with a
split-conformal calibration so the band has exact marginal coverage *and* the adaptive,
heteroscedastic width of quantile regression — wide where the data is noisy, narrow where it is
tight, unlike the constant-width absolute-residual band of :class:`ConformalRegressor`.
The nonconformity score is the signed distance outside the predicted band,
``E_i = max(qlo(x_i) - y_i, y_i - qhi(x_i))`` (negative when ``y_i`` is comfortably inside), and
the calibrated band is ``[qlo(x) - qhat, qhi(x) + qhat]`` with ``qhat`` the conformal quantile of
the calibration scores. ``lo`` and ``hi`` are fitted quantile-regression results (from
``...fit(..., quantile=tau)``), typically at ``tau = alpha/2`` and ``1 - alpha/2``.
"""
def __init__(self, lo: Any, hi: Any, y_cal: Any, *, given: dict, alpha: float = 0.1) -> None:
self.lo = lo
self.hi = hi
self.alpha = float(alpha)
y = np.asarray(y_cal, dtype=float).reshape(-1)
qlo = np.asarray(lo.predict(given), dtype=float).reshape(-1)
qhi = np.asarray(hi.predict(given), dtype=float).reshape(-1)
self.scores = np.maximum(qlo - y, y - qhi) # CQR nonconformity (negative when inside the band)
self.qhat = conformal_quantile(self.scores, self.alpha)
[docs]
def interval(self, given: dict) -> tuple[np.ndarray, np.ndarray]:
"""Return ``(lower, upper)`` arrays of the calibrated adaptive band at covariates ``given``."""
qlo = np.asarray(self.lo.predict(given), dtype=float).reshape(-1)
qhi = np.asarray(self.hi.predict(given), dtype=float).reshape(-1)
return qlo - self.qhat, qhi + self.qhat
[docs]
def covers(self, y: Any, *, given: dict) -> np.ndarray:
"""Boolean array: does the adaptive band at ``given`` contain each observed ``y``."""
lo, hi = self.interval(given)
y = np.asarray(y, dtype=float).reshape(-1)
return (y >= lo) & (y <= hi)
[docs]
class ConformalStructure:
"""Split-conformal credible sets over combinatorial structures (rankings, matchings, spanning
trees, permutations, ...) from a fitted mixle distribution's exact log-density.
The nonconformity of a structure ``s`` is ``-log p(s)``: the lower its model probability, the
more surprising it is. Calibrating on held-out true structures yields a log-probability
threshold, and the conformal set is ``{s : log p(s) >= threshold}`` — it contains the true
structure with probability at least ``1 - alpha`` whenever the calibration and test structures
are exchangeable (for example iid draws), with no assumption on the model being correct.
``dist`` is any structure distribution exposing ``log_density`` (``PlackettLuceDistribution``,
``MallowsDistribution``, ``MatchingDistribution``, ``SpanningTreeDistribution``, ...);
``calibration`` is a sequence of observed structures. Membership is always available; listing or
counting the set additionally needs the distribution's exact ``enumerator()``.
"""
def __init__(self, dist: Any, calibration: Any, *, alpha: float = 0.1) -> None:
self.dist = dist
self.alpha = float(alpha)
self.scores = np.array([-float(dist.log_density(s)) for s in calibration], dtype=float)
self.qhat = conformal_quantile(self.scores, self.alpha) # largest admitted nonconformity
@property
def log_prob_threshold(self) -> float:
"""Structures with ``log p(s)`` at or above this value are in the conformal set."""
return -self.qhat
[docs]
def contains(self, structure: Any) -> bool:
"""Is ``structure`` in the conformal set (its log-probability above the threshold)."""
return bool(-float(self.dist.log_density(structure)) <= self.qhat)
[docs]
def covers(self, structures: Any) -> np.ndarray:
"""Boolean array: membership of each structure (use on held-out truths to check coverage)."""
return np.array([self.contains(s) for s in structures], dtype=bool)
[docs]
def members(self) -> list:
"""List the structures in the conformal set, highest-probability first.
Requires the distribution's exact ``enumerator()`` (raises ``EnumerationError`` otherwise).
The enumerator yields structures in descending log-probability, so the scan stops at the
threshold.
"""
out = []
for structure, log_p in self.dist.enumerator():
if log_p < self.log_prob_threshold:
break
out.append(structure)
return out
[docs]
def size(self) -> int:
"""Number of structures in the conformal set (needs the exact ``enumerator()``)."""
return len(self.members())
[docs]
class ConformalLinkPredictor:
"""Split-conformal candidate-neighbor sets for a random-graph model from its edge-probability
matrix ``P`` (``P[i, j] = p(edge i--j)``, e.g. ``X @ X.T`` from a fitted RDPG, or an
Erdos-Renyi / stochastic-block-model edge probability).
The nonconformity of a present edge ``(i, j)`` is ``1 - P[i, j]``. Calibrating on held-out true
edges gives a threshold; the predicted neighbor set of a node keeps every candidate ``j`` with
``1 - P[i, j] <= tau``, so it contains a true neighbor with probability at least ``1 - alpha``
over exchangeable held-out edges (a random split of the observed edges).
"""
def __init__(self, edge_prob: Any, cal_edges: Any, *, alpha: float = 0.1) -> None:
self.P = np.asarray(edge_prob, dtype=float)
if self.P.ndim != 2 or self.P.shape[0] != self.P.shape[1]:
raise ValueError("edge_prob must be a square (n_nodes, n_nodes) probability matrix.")
self.alpha = float(alpha)
scores = np.array([1.0 - self.P[int(i), int(j)] for i, j in cal_edges], dtype=float)
self.tau = conformal_quantile(scores, self.alpha)
[docs]
def neighbor_set(self, i: int, candidates: Any = None) -> np.ndarray:
"""Candidate nodes ``j`` in node ``i``'s conformal neighbor set."""
row = self.P[int(i)]
cand = np.arange(row.size) if candidates is None else np.asarray(candidates, dtype=int)
return cand[(1.0 - row[cand]) <= self.tau]
[docs]
def covers(self, edges: Any) -> np.ndarray:
"""Boolean array: is each held-out true edge's endpoint in the predicted neighbor set."""
return np.array([(1.0 - self.P[int(i), int(j)]) <= self.tau for i, j in edges], dtype=bool)
[docs]
def set_sizes(self, nodes: Any = None) -> np.ndarray:
"""Neighbor-set size per node (defaults to all nodes)."""
nodes = range(self.P.shape[0]) if nodes is None else nodes
return np.array([self.neighbor_set(i).size for i in nodes], dtype=int)
[docs]
class ConformalKnowledgeGraph:
"""Split-conformal completion sets for a knowledge-graph model (any-slot UQ).
Calibrating on held-out true triples turns the model's completion posterior into a *set* of
candidate fillers that contains the true one with probability at least ``1 - alpha`` over
exchangeable held-out triples. ``slot`` selects which slot is predicted -- ``'tail'`` for
``(h, r, ?)``, ``'head'`` for ``(?, r, t)``, ``'relation'`` for ``(h, ?, t)`` -- using the model's
``tail_log_posterior`` / ``head_log_posterior`` / ``relation_log_posterior``. The nonconformity of
a triple is ``1 - p(true filler)``, so a confident model gives small completion sets and a recommended
completion carries a coverage guarantee.
"""
def __init__(self, kg: Any, calibration: Any, *, slot: str = "tail", alpha: float = 0.1) -> None:
if slot not in ("tail", "head", "relation"):
raise ValueError("slot must be 'tail', 'head', or 'relation'.")
self.kg = kg
self.slot = slot
self.alpha = float(alpha)
scores = [1.0 - float(np.exp(self._posterior(h, r, t)[self._truth(h, r, t)])) for h, r, t in calibration]
self.tau = conformal_quantile(scores, self.alpha)
def _posterior(self, h: int, r: int, t: int) -> np.ndarray:
if self.slot == "tail":
return self.kg.tail_log_posterior(int(h), int(r))
if self.slot == "head":
return self.kg.head_log_posterior(int(r), int(t))
return self.kg.relation_log_posterior(int(h), int(t))
def _truth(self, h: int, r: int, t: int) -> int:
return int({"tail": t, "head": h, "relation": r}[self.slot])
[docs]
def completion_set(self, h: int | None = None, r: int | None = None, t: int | None = None) -> np.ndarray:
"""Candidate fillers in the conformal set for the missing slot of a query."""
p = np.exp(self.kg.complete(h=h, r=r, t=t))
return np.flatnonzero((1.0 - p) <= self.tau)
[docs]
def covers(self, triples: Any) -> np.ndarray:
"""Boolean array: is each held-out true triple's filler in the completion set."""
return np.array(
[(1.0 - np.exp(self._posterior(h, r, t)[self._truth(h, r, t)])) <= self.tau for h, r, t in triples],
dtype=bool,
)
[docs]
def set_sizes(self, triples: Any) -> np.ndarray:
"""Completion-set size for each query (the slot of each triple is treated as missing)."""
out = []
for h, r, t in triples:
q = {"tail": (h, r, None), "head": (None, r, t), "relation": (h, None, t)}[self.slot]
out.append(self.completion_set(*q).size)
return np.array(out, dtype=int)
[docs]
def conformal(result: Any, y_cal: Any, *, given: dict, alpha: float = 0.1) -> ConformalRegressor:
"""Split-conformal calibration of a fitted regression ``result`` into prediction intervals.
Mirrors ``fit``'s convention (labels positional, ``given=`` keyword), a one-liner over
:class:`ConformalRegressor`::
m = Normal(free * Field("x") + free, free).fit(y_tr, given={"x": x_tr})
cp = conformal(m.result, y_cal, given={"x": x_cal}, alpha=0.1)
lo, hi = cp.interval({"x": x_te})
cp.covers(y_te, given={"x": x_te}).mean() # ~ 0.9
"""
return ConformalRegressor(result, y_cal, given=given, alpha=alpha)