Source code for torch_uncertainty.metrics.regression.quantile_calibration

import warnings
from typing import Literal

import matplotlib.pyplot as plt
import torch
from torch import Tensor
from torch.distributions import Distribution, Independent
from torchmetrics import Metric
from torchmetrics.utilities.plot import _PLOT_OUT_TYPE


[docs] class QuantileCalibrationError(Metric): is_differentiable = False higher_is_better = False full_state_update = False covered: Tensor total: Tensor unsupported: Tensor def __init__( self, num_bins: int = 15, norm: Literal["l1", "l2", "max"] = "l1", ignore_index: int | None = None, validate_args: bool = True, **kwargs, ) -> None: r"""Quantile Calibration Error for regression tasks. For each confidence level :math:`\alpha \in (0, 1)`, a well-calibrated probabilistic regressor should ensure that a fraction :math:`\alpha` of the ground-truth targets lies inside the centered :math:`\alpha`-credible interval of the predicted distribution :math:`p_\theta(\cdot \mid x)`. Concretely, let .. math:: \hat{c}(\alpha) = \frac{1}{N} \sum_{i=1}^{N} \mathbf{1}\!\left[ y_i \in \left[ F^{-1}_{\theta, x_i}\!\left(\tfrac{1 - \alpha}{2}\right), F^{-1}_{\theta, x_i}\!\left(\tfrac{1 + \alpha}{2}\right) \right] \right]. The metric evaluates this coverage on :attr:`num_bins` confidence levels :math:`\alpha_k` regularly spaced between ``0.05`` and ``0.95``. It returns .. math:: \operatorname{QCE}_{L_1} = \frac{1}{K}\sum_{k=1}^{K} \left|\hat{c}(\alpha_k)-\alpha_k\right|, with analogous root-mean-square and maximum variants for ``norm="l2"`` and ``norm="max"``. For :class:`~torch.distributions.Independent` distributions, calibration is evaluated marginally: every scalar event component contributes one coverage observation. Args: num_bins: Number of confidence levels. Defaults to ``15``. norm: Norm used to aggregate the calibration gaps. One of ``"l1"``, ``"l2"``, or ``"max"``. Defaults to ``"l1"``. ignore_index: Optional target value to ignore. Defaults to ``None``. validate_args: Whether to validate input shapes. Defaults to ``True``. kwargs: Additional keyword arguments, see `Advanced metric settings <https://torchmetrics.readthedocs.io/en/stable/pages/overview.html#metric-kwargs>`_. """ super().__init__(**kwargs) if num_bins < 1: raise ValueError(f"Expected `num_bins` to be at least 1, but got {num_bins}.") if norm not in ("l1", "l2", "max"): raise ValueError(f"Expected `norm` to be one of ('l1', 'l2', 'max'), but got {norm}.") self.n_bins = num_bins self.norm = norm self.ignore_index = ignore_index self.validate_args = validate_args self.register_buffer("conf_intervals", torch.linspace(0.05, 0.95, num_bins)) self.add_state( "covered", default=torch.zeros(num_bins, dtype=torch.long), dist_reduce_fx="sum", ) self.add_state("total", default=torch.tensor(0, dtype=torch.long), dist_reduce_fx="sum") self.add_state( "unsupported", default=torch.tensor(0, dtype=torch.long), dist_reduce_fx="sum", )
[docs] def update( # pyrefly: ignore[bad-override] self, dist: Distribution, target: Tensor, ignore_mask: Tensor | None = None, ) -> None: """Update the metric with predictive distributions and targets. Args: dist: Predicted distribution. It must implement ``icdf``. target: Ground-truth values, with one value per predictive distribution. ignore_mask: Boolean mask of targets to ignore. A mask over only the batch dimensions is expanded over trailing event dimensions. Defaults to ``None``. """ expected_shape = dist.batch_shape + dist.event_shape if self.validate_args and target.shape != expected_shape: raise ValueError( "Expected `target` to have shape equal to the distribution batch and " f"event shapes ({expected_shape}), but got {target.shape}." ) marginal_dist = dist.base_dist if isinstance(dist, Independent) else dist levels = self.conf_intervals.to(target.device) try: intervals = [ ( marginal_dist.icdf((1 - level) / 2), marginal_dist.icdf((1 + level) / 2), ) for level in levels ] except NotImplementedError: warnings.warn( "The distribution does not support the `icdf()` method. " "This metric will therefore return `nan`. Please use a " "distribution that implements `icdf()`.", UserWarning, stacklevel=2, ) self.unsupported += 1 return valid = torch.ones_like(target, dtype=torch.bool) if self.ignore_index is not None: valid &= target != self.ignore_index if ignore_mask is not None: ignore_mask = ignore_mask.bool() while ignore_mask.ndim < target.ndim: ignore_mask = ignore_mask.unsqueeze(-1) try: ignore_mask = torch.broadcast_to(ignore_mask, target.shape) except RuntimeError as err: raise ValueError( f"Expected `ignore_mask` to be broadcastable to {target.shape}, " f"but got {ignore_mask.shape}." ) from err valid &= ~ignore_mask inside = torch.stack( [(target >= lower) & (target <= upper) & valid for lower, upper in intervals] ) self.covered += inside.reshape(self.n_bins, -1).sum(dim=1) self.total += valid.sum()
[docs] def compute(self) -> Tensor: """Compute the Quantile Calibration Error.""" if self.unsupported > 0 or self.total == 0: return torch.tensor(torch.nan, device=self.covered.device) empirical_coverage = self.covered / self.total calibration_gap = (empirical_coverage - self.conf_intervals).abs() if self.norm == "l1": return calibration_gap.mean() if self.norm == "l2": return calibration_gap.square().mean().sqrt() return calibration_gap.max()
[docs] def plot(self) -> _PLOT_OUT_TYPE: # pyrefly: ignore[bad-override] """Plot empirical coverage against nominal coverage.""" if self.unsupported > 0: raise NotImplementedError( "The distribution does not support the `icdf()` method. " "Please use a distribution that implements `icdf()`." ) if self.total == 0: raise RuntimeError("QuantileCalibrationError requires at least one valid target.") nominal = self.conf_intervals.detach().cpu() * 100 empirical = (self.covered / self.total).detach().cpu() * 100 fig, ax = plt.subplots() ax.plot(nominal, empirical, marker="o", label="Model") ax.plot([0, 100], [0, 100], linestyle="--", color="black", label="Ideal") ax.set_xlabel("Nominal Coverage (%)") ax.set_ylabel("Empirical Coverage (%)") ax.set_xlim(0, 100) ax.set_ylim(0, 100) ax.legend() return fig, ax