"""Compose Dempster–Hill (DH) transport readouts with a score map."""
from collections.abc import Callable
from dataclasses import dataclass, replace
from functools import cached_property
import numpy as np
from yemale import ot
from yemale._array import batch, readonly, rows
[docs]
@dataclass(frozen=True)
class ScoreMap:
r"""A score S_x and its optional inverse and outcome derivative.
Callbacks receive the fixed model prediction at x. Write p, d, and s for
the prediction, outcome, and score widths, and q for the number of outcomes.
Args:
forward: ``forward(predictions, outcomes)`` returns scores ``(q, s)``
or ``(q,)`` for scalar scores.
Outcomes have shape ``(q, d)``; predictions have shape ``(q, p)``
or ``(1, p)`` when one prediction accompanies many outcomes.
inverse: Optional ``inverse(predictions, scores)`` returning outcomes
``(q, d)``. Used to convert score samples into outcome samples.
It must invert the score on the chosen law's support.
jacobian: Optional ``jacobian(predictions, outcomes)`` returning D_y S_x
with shape ``(s, d)`` or ``(q, s, d)``. Required for
``potential_gradient`` and density. Density requires s = d and
mutually inverse, differentiable score maps with nonsingular
Jacobian at every queried outcome. For a restricted inverse branch,
evaluate density on that branch's domain.
"""
forward: Callable
inverse: Callable | None = None
jacobian: Callable | None = None
def __repr__(self):
if self is residual:
return "residual"
callbacks = (
("forward", self.forward),
("inverse", self.inverse),
("jacobian", self.jacobian),
)
arguments = ", ".join(
f"{name}={getattr(callback, '__name__', type(callback).__name__)}"
for name, callback in callbacks
if callback is not None
)
return f"ScoreMap({arguments})"
def _residual(predictions, outcomes):
if predictions.shape[-1] != outcomes.shape[-1]:
raise ValueError("residual scores need predictions and outcomes of equal width")
return np.subtract(outcomes, predictions, dtype=np.float64)
def _residual_inverse(predictions, scores):
return predictions + scores
def _residual_jacobian(predictions, outcomes):
return np.eye(outcomes.shape[-1])
residual = ScoreMap(_residual, _residual_inverse, _residual_jacobian)
def _points(value, dimension, name):
"""Keep a point or row batch; scalar-coordinate vectors may denote a batch."""
value = np.asarray(value)
if value.ndim == 0:
value = value.reshape(1)
if dimension == 1 and value.ndim == 1 and value.size != 1:
value = value[:, None]
if value.ndim not in (1, 2) or value.shape[-1] != dimension:
raise ValueError(
f"{name} must have shape ({dimension},) or (q, {dimension}); "
f"got {value.shape}"
)
return value
[docs]
@dataclass(frozen=True, eq=False, repr=False)
class CPD:
"""Conformal predictive distribution (CPD) and readouts at given predictions.
Create with ``cp.predict(prediction)``. Queries pair prediction and outcome
rows, or use one prediction for many outcomes. ``cpd[i]`` selects a batch
member and shares the fitted transport. Geometric queries keep the outcome row axis:
for q outcomes, s score coordinates, and d outcome coordinates, ``transform``
and ``sign`` return ``(q, s)``, ``rank`` and ``potential`` return ``(q,)``,
and ``potential_gradient`` returns ``(q, d)``. A single outcome has q = 1.
Sampling and summaries need an inverse score and either a candidate outcome
or an explicit score law. Their shapes are documented on each method.
"""
_cp: Conformalizer
_prediction: np.ndarray
_score_law: ot.Law | None = None
def __repr__(self):
size = 1 if self._prediction.ndim == 1 else len(self._prediction)
return f"CPD(predictions={size}, law={self._score_law is not None})"
@property
def transport(self):
"""The shared DH transport on calibration scores."""
return self._cp.transport
[docs]
def __getitem__(self, index):
"""Select predictions from a batch, sharing its transport and score law."""
if self._prediction.ndim == 1:
raise TypeError("this CPD contains one prediction, not a batch")
if isinstance(index, tuple):
raise IndexError("index prediction rows, not their coordinates")
prediction = self._prediction[index]
if prediction.ndim not in (1, 2):
raise IndexError("index prediction rows, not their coordinates")
return replace(self, _prediction=prediction)
def _scores(self, outcomes):
outcomes = np.atleast_2d(
_points(outcomes, self._cp._outcome_dimension, "outcomes")
)
predictions = np.atleast_2d(self._prediction)
if len(predictions) not in (1, len(outcomes)):
raise ValueError("pair outcome rows with predictions, or select one cpd[i]")
scores, _ = rows(
self._cp.score.forward(predictions, outcomes),
self.transport._target.shape[1],
"scores",
)
if len(scores) != len(outcomes):
raise ValueError("score must return one score per outcome")
return scores
def _score_jacobian(self, outcomes):
jacobian = np.asarray(
self._cp.score.jacobian(np.atleast_2d(self._prediction), outcomes)
)
shape = (self.transport._target.shape[1], outcomes.shape[1])
if jacobian.shape not in (shape, (len(outcomes), *shape)):
raise ValueError(
f"score Jacobian must have shape {shape} or "
f"{(len(outcomes), *shape)}; got {jacobian.shape}"
)
return jacobian
[docs]
def rank(self, outcomes):
r"""Return one center-outward rank per outcome, on the reference scale.
.. math:: \operatorname{Rank}_x(y)=\operatorname{Rank}(S_x(y)).
"""
return self.transport.rank(self._scores(outcomes))
[docs]
def sign(self, outcomes):
r"""Return each outcome's reference-space direction; zero maps give zero.
.. math:: \operatorname{Sign}_x(y)=\operatorname{Sign}(S_x(y)).
"""
return self.transport.sign(self._scores(outcomes))
[docs]
def potential(self, outcomes):
r"""Evaluate the potential through the score, one value per outcome.
.. math:: y\mapsto\Phi(S_x(y)).
"""
return self.transport.potential(self._scores(outcomes))
[docs]
def potential_gradient(self, outcomes):
r"""Return the potential gradient in outcome coordinates.
.. math:: H_x(y)=DS_x(y)^\top T(S_x(y)).
This equals the gradient of ``potential`` where the score and DH
potential are differentiable. At ties, it uses the DH-selected target.
Requires ``ScoreMap.jacobian``; returns one vector per outcome.
"""
score = self._cp.score
if score.jacobian is None:
raise TypeError("potential_gradient requires ScoreMap.jacobian")
outcomes = np.atleast_2d(
_points(outcomes, self._cp._outcome_dimension, "outcomes")
)
transformed = self.transform(outcomes)
jacobian = self._score_jacobian(outcomes)
return np.einsum("...ij,...i->...j", jacobian, transformed)
[docs]
def region(self, coverage=None, *, reference_set=None, randomized=True, rng=None):
r"""Return an outcome region by composing a DH region with S_x.
For a hard reference set B:
.. math:: \mathcal R_B(x)=\{y:T(S_x(y))\in B\}.
Supply ``coverage`` (default 0.9) or ``reference_set``, not both.
Reference sets are predicates on target coordinates, as in ``T.region``.
With ``coverage``, randomization is on by default: draw once inside each
reference cell and include its source cell if the draw lies inside the
reference ball. This selection stays fixed across membership calls.
Use ``rng`` to reproduce it, or ``randomized=False`` to select by cell
centres instead, with whole-shell rounding as in ``T.quantile_region``.
"""
if reference_set is not None:
if coverage is not None or rng is not None or randomized is not True:
raise ValueError(
"reference_set replaces coverage and randomization options"
)
region = self.transport.region(reference_set)
else:
region = self.transport.quantile_region(
0.9 if coverage is None else coverage, randomized=randomized, rng=rng
)
return Region(self, region)
[docs]
def reference_distribution(self, outcome):
r"""Return K_x(y, .) = K(S_x(y), .) for one candidate outcome.
The returned Law draws reference points, not future outcomes.
"""
return self.transport.reference_distribution(self._scores(outcome))
[docs]
def log_density(self, outcomes, *, temperature=None):
"""Return the forward smooth log-density readout, one value per outcome.
Requires ``ScoreMap.jacobian`` and equal score/outcome dimensions.
No candidate or inverse score is needed. ``temperature`` is passed
to ``transport.smooth``; prediction batches use paired outcomes.
"""
score = self._cp.score
if score.jacobian is None:
raise TypeError("density requires ScoreMap.jacobian")
outcomes = np.atleast_2d(
_points(outcomes, self._cp._outcome_dimension, "outcomes")
)
scores = self._scores(outcomes)
if scores.shape[1] != outcomes.shape[1]:
raise ValueError("density requires equal score and outcome dimensions")
logdet = np.linalg.slogdet(self._score_jacobian(outcomes))[1]
return self.transport.smooth(temperature).log_density(scores) + logdet
[docs]
def density(self, outcomes, *, temperature=None):
r"""Evaluate the forward smooth density through the score.
.. math:: p_{\tau,x}(y)=p_\tau(S_x(y))|\det D_yS_x(y)|.
Here p_tau is ``transport.smooth(temperature).density``. Evaluate on
a domain where the score is one-to-one and differentiable. Return
one value per outcome, as in ``log_density``. This readout need not
integrate to one or equal the PDF of the chosen sampling law.
"""
return np.exp(self.log_density(outcomes, temperature=temperature))
@cached_property
def _law(self):
if self._score_law is None:
raise TypeError(
"supply candidate=... or law=... with cp.predict(prediction, ...)"
)
score = self._cp.score
if score.inverse is None:
raise TypeError("distribution readouts require ScoreMap.inverse")
if self._cp._outcome_dimension != self._score_law.dimension:
raise ValueError(
"Law composition requires equal score and outcome dimensions"
)
prediction = np.atleast_2d(self._prediction)
# Sampling uses Y = S_x^{-1}(Z). Density uses the opposite direction:
# p_Y(y) = p_Z(S_x(y)) * |det D_y S_x(y)|.
backward = None
if score.jacobian is not None:
def backward(outcomes):
return (
self._scores(outcomes),
np.linalg.slogdet(self._score_jacobian(outcomes))[1],
)
return self._score_law._map(
lambda scores: score.inverse(prediction, scores), backward
)
@cached_property
def _laws(self):
return tuple(self[i]._law for i in range(len(self._prediction)))
def _readout(self, method, *args, **kwargs):
"""Apply each prediction's law separately, then stack on the prediction axis."""
if self._prediction.ndim == 1:
return getattr(self._law, method)(*args, **kwargs)
if not len(self._prediction):
raise ValueError("distribution readouts need at least one prediction")
return np.stack([getattr(law, method)(*args, **kwargs) for law in self._laws])
[docs]
def sample(self, size=1, *, rng=None):
"""Draw outcomes: ``(size, d)`` or ``(batch, size, d)`` for a CPD batch."""
# Create one stream so batch members advance it instead of reseeding.
return self._readout("sample", size, rng=np.random.default_rng(rng))
[docs]
def expect(self, function, *, n_integration_points=64, rng=None):
r"""Compute E_Pi_x[f(Y)] = E_Pi[f(S_x^{-1}(Z))] through the chosen Law.
``function`` receives ``(q, d)`` outcomes. Numerical integration and its
controls are those of :meth:`yemale.ot.Law.expect`.
"""
# None requests fixed integration points; random batches share one stream.
generator = None if rng is None else np.random.default_rng(rng)
return self._readout(
"expect", function, n_integration_points=n_integration_points, rng=generator
)
[docs]
def mean(self, *, n_integration_points=64):
"""Return the outcome mean, shape ``(d,)`` or ``(batch, d)``."""
return self._readout("mean", n_integration_points=n_integration_points)
[docs]
def cov(self, *, n_integration_points=64):
"""Return outcome covariance, shape ``(d, d)`` or ``(batch, d, d)``."""
return self._readout("cov", n_integration_points=n_integration_points)
[docs]
def moment(self, powers, *, n_integration_points=64):
"""Return E[prod(Y**powers)] using the chosen Law's moment method."""
return self._readout(
"moment", powers, n_integration_points=n_integration_points
)
[docs]
def entropy(self, *, n_integration_points=64):
"""Return outcome differential entropy in nats; requires density."""
return self._readout("entropy", n_integration_points=n_integration_points)
[docs]
def logpdf(self, outcomes):
"""Return outcome log density for one prediction; index a CPD batch first.
A point ``(d,)`` returns a scalar; a batch ``(*batch_shape, d)`` returns
``batch_shape``. For d = 1, a scalar or one-element vector is a point,
and a longer vector is a batch. Density does not keep a singleton
outcome axis unless it is present in the input batch shape.
"""
if self._prediction.ndim != 1:
raise ValueError("select one cpd[i] before evaluating its density")
return self._law.logpdf(outcomes)
[docs]
def pdf(self, outcomes):
"""Return outcome density for one prediction; requires the score Jacobian.
Input and output shapes follow ``logpdf``; index a CPD batch first.
"""
if self._prediction.ndim != 1:
raise ValueError("select one cpd[i] before evaluating its density")
return self._law.pdf(outcomes)
[docs]
def density_region(self, mass, *, n_integration_points=256):
"""Return a density-mass region of one completed outcome law, not coverage."""
if self._prediction.ndim != 1:
raise ValueError("select one cpd[i] before constructing its density region")
return self._law.density_region(mass, n_integration_points=n_integration_points)
[docs]
@dataclass(frozen=True, eq=False, repr=False)
class Region:
"""Candidate outcomes whose scores belong to a shared DH region.
Create with ``cpd.region(coverage)`` or ``model.predict_region(inputs, coverage)``.
"""
_cpd: CPD
_region: ot.Region
def __repr__(self):
return f"Region(coverage={self.coverage:.6g})"
@property
def coverage(self):
"""Marginal coverage reported by the underlying DH region."""
return self._region.coverage
[docs]
def contains(self, outcomes):
"""Return one Boolean per outcome, pairing rows for a prediction batch."""
return self._region.contains(self._cpd._scores(outcomes))
[docs]
def select(self, candidates):
"""Filter candidates for one region, preserving their values and order.
For a batch, select one region with ``regions[i]`` first.
"""
if len(np.atleast_2d(self._cpd._prediction)) != 1:
raise ValueError("select one region first: regions[i].select(candidates)")
candidates = np.asarray(candidates)
accepted = self.contains(candidates)
return candidates[accepted]
[docs]
def __getitem__(self, index):
"""Select predictions, retaining the same DH region and randomization."""
return replace(self, _cpd=self._cpd[index])