Source code for torch_uncertainty.post_processing.conformal.aps
from typing import Literal
import torch
import torch.nn.functional as F
from torch import Tensor, nn
from torch.utils.data import DataLoader
from .abstract import Conformal
[docs]
class ConformalClsAPS(Conformal):
def __init__(
self,
alpha: float,
model: nn.Module | None = None,
randomized: bool = True,
ts_init_val: float = 1,
ts_lr: float = 0.1,
ts_max_iter: int = 100,
enable_ts: bool = True,
device: Literal["cpu", "cuda"] | torch.device | None = None,
) -> None:
r"""Conformal classification with Adaptive Prediction Sets (APS;
Romano, Sesia & Candès, NeurIPS 2020).
Uses as non-conformity score the cumulative probability mass needed to reach
the true class once the predictions are sorted by decreasing probability. For
a sample with predicted probabilities :math:`\hat{\mathbf{p}}` whose true class
ranks at position :math:`k` after sorting,
.. math::
s(\mathbf{x}, y) = \sum_{i=1}^{k} \hat{p}_{(i)} - U \cdot \hat{p}_{(k)},
where :math:`U \sim \mathrm{Uniform}(0, 1)` smooths the cumulative score when
:attr:`randomized` is ``True``. The calibrated quantile :math:`\hat{q}` defines
the test-time prediction set
.. math::
\mathcal{C}(\mathbf{x}) = \{ c : s(\mathbf{x}, c) \leq \hat{q} \},
which adapts in size to the difficulty of each example: easy points get tight
sets, ambiguous points get larger ones.
Args:
alpha: Target mis-coverage level :math:`\alpha \in (0, 1)`.
model: Trained classification model. Defaults to ``None``.
randomized: Whether to use randomised tie-breaking in APS. Defaults to ``True``.
ts_init_val: Initial value for the temperature. Defaults to ``1.0``.
ts_lr: Learning rate for the temperature scaling optimizer. Defaults to ``0.1``.
ts_max_iter: Maximum number of iterations for the temperature scaling optimizer.
Defaults to ``100``.
enable_ts: Whether to apply temperature scaling to the logits before
computing the conformal scores. Defaults to ``True``.
device: Device to use. Defaults to ``None``.
Warning:
This implementation only works in the multiclass setting. Raise an issue
if binary support is needed.
Reference:
- `Romano, Y., Sesia, M., & Candès, E. (2020). Classification with Valid
and Adaptive Coverage. NeurIPS 2020 <https://arxiv.org/abs/2006.02544>`_.
Code inspired by TorchCP.
"""
super().__init__(
alpha=alpha,
model=model,
ts_init_val=ts_init_val,
ts_lr=ts_lr,
ts_max_iter=ts_max_iter,
enable_ts=enable_ts,
device=device,
)
self.randomized = randomized
[docs]
def model_forward(self, inputs: Tensor) -> Tensor:
"""Apply the model and return the scores."""
assert self.model is not None
self.model.eval()
return self.model(inputs.to(self.device)).softmax(-1)
def _sort_sum(self, probs: Tensor) -> tuple[Tensor, Tensor, Tensor]:
"""Sort probabilities and compute cumulative sums."""
ordered, indices = torch.sort(probs, dim=-1, descending=True)
cumsum = torch.cumsum(ordered, dim=-1)
return indices, ordered, cumsum
def _calculate_all_labels(self, probs: Tensor) -> Tensor:
"""Calculate APS scores for all labels."""
indices, ordered, cumsum = self._sort_sum(probs)
if self.randomized:
noise = torch.rand(probs.shape, device=probs.device)
else:
noise = torch.zeros_like(probs)
ordered_scores = cumsum - ordered * noise
_, sorted_indices = torch.sort(indices, descending=False, dim=-1)
return ordered_scores.gather(dim=-1, index=sorted_indices)
def _calculate_single_label(self, probs: Tensor, label: Tensor) -> Tensor:
"""Calculate APS score for a single label."""
indices, ordered, cumsum = self._sort_sum(probs)
if self.randomized:
noise = torch.rand(indices.shape[0], device=probs.device)
else:
noise = torch.zeros(indices.shape[0], device=probs.device)
idx = torch.where(indices == label.view(-1, 1))
return cumsum[idx] - noise * ordered[idx]
[docs]
@torch.no_grad()
def fit(self, dataloader: DataLoader) -> None:
"""Calibrate the APS threshold q_hat on a calibration set."""
assert self.model is not None
if self.enable_ts:
self.model.fit(dataloader=dataloader)
aps_scores = []
for inputs, labels in dataloader:
inputs, labels = inputs.to(self.device), labels.to(self.device)
probs = self.model_forward(inputs)
scores = self._calculate_single_label(probs, labels)
aps_scores.append(scores)
self.q_hat = torch.quantile(torch.cat(aps_scores), 1 - self.alpha)
[docs]
@torch.no_grad()
def conformal(self, inputs: Tensor) -> Tensor:
"""Compute the prediction set for each input.
Returns:
A probability vector for each sample. Its support is the conformal
prediction set, with equal probability assigned to every included class.
If thresholding produces an empty prediction set, the class with the
lowest non-conformity score is included before normalization. Consequently,
every returned row is finite and sums to one.
"""
probs = self.model_forward(inputs)
scores = self._calculate_all_labels(probs)
pred_set = scores <= self.quantile
empty = ~pred_set.any(dim=-1)
fallback = F.one_hot(
scores.argmin(dim=-1),
num_classes=scores.shape[-1],
).bool()
pred_set = pred_set | (empty.unsqueeze(-1) & fallback)
pred_set = pred_set.to(dtype=probs.dtype)
return pred_set / pred_set.sum(dim=-1, keepdim=True)