tscp 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. tscp/__init__.py +45 -0
  2. tscp/data/__init__.py +19 -0
  3. tscp/data/archive.py +488 -0
  4. tscp/data/availability.py +55 -0
  5. tscp/data/io.py +109 -0
  6. tscp/data/splits.py +169 -0
  7. tscp/datasets/__init__.py +6 -0
  8. tscp/datasets/synthetic.py +177 -0
  9. tscp/features/__init__.py +22 -0
  10. tscp/features/base.py +64 -0
  11. tscp/features/calendar.py +105 -0
  12. tscp/features/combine.py +69 -0
  13. tscp/features/context.py +42 -0
  14. tscp/features/history.py +58 -0
  15. tscp/features/phase.py +41 -0
  16. tscp/imports.py +32 -0
  17. tscp/methods/__init__.py +48 -0
  18. tscp/methods/aci.py +44 -0
  19. tscp/methods/base.py +228 -0
  20. tscp/methods/compose.py +90 -0
  21. tscp/methods/engine.py +128 -0
  22. tscp/methods/io.py +106 -0
  23. tscp/methods/nexcp.py +42 -0
  24. tscp/methods/split.py +30 -0
  25. tscp/metrics/__init__.py +30 -0
  26. tscp/metrics/conditional.py +66 -0
  27. tscp/metrics/interval.py +75 -0
  28. tscp/metrics/report.py +135 -0
  29. tscp/nn/__init__.py +13 -0
  30. tscp/nn/data.py +85 -0
  31. tscp/nn/losses.py +63 -0
  32. tscp/nn/training.py +125 -0
  33. tscp/online/__init__.py +6 -0
  34. tscp/online/aci.py +53 -0
  35. tscp/online/base.py +67 -0
  36. tscp/py.typed +0 -0
  37. tscp/quantile/__init__.py +16 -0
  38. tscp/quantile/base.py +73 -0
  39. tscp/quantile/distribution.py +170 -0
  40. tscp/quantile/weighted.py +103 -0
  41. tscp/regions/__init__.py +15 -0
  42. tscp/regions/base.py +139 -0
  43. tscp/regions/box.py +82 -0
  44. tscp/regions/interval.py +62 -0
  45. tscp/registry.py +179 -0
  46. tscp/scores/__init__.py +9 -0
  47. tscp/scores/absolute.py +22 -0
  48. tscp/scores/base.py +87 -0
  49. tscp/scores/cqr.py +71 -0
  50. tscp/scores/normalized.py +74 -0
  51. tscp/scores/signed.py +46 -0
  52. tscp/sources/__init__.py +7 -0
  53. tscp/sources/base.py +32 -0
  54. tscp/sources/expanding.py +25 -0
  55. tscp/sources/holdout.py +18 -0
  56. tscp/testing/__init__.py +40 -0
  57. tscp/testing/archives.py +80 -0
  58. tscp/testing/checks.py +832 -0
  59. tscp/testing/components.py +28 -0
  60. tscp/typing.py +24 -0
  61. tscp/utils/__init__.py +14 -0
  62. tscp/utils/component.py +76 -0
  63. tscp/utils/config.py +135 -0
  64. tscp/utils/hashing.py +35 -0
  65. tscp/weights/__init__.py +7 -0
  66. tscp/weights/base.py +22 -0
  67. tscp/weights/exponential.py +37 -0
  68. tscp/weights/uniform.py +23 -0
  69. tscp-0.1.0.dev0.dist-info/METADATA +130 -0
  70. tscp-0.1.0.dev0.dist-info/RECORD +72 -0
  71. tscp-0.1.0.dev0.dist-info/WHEEL +4 -0
  72. tscp-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
tscp/nn/data.py ADDED
@@ -0,0 +1,85 @@
1
+ """Batches of archive rows for training learned components."""
2
+
3
+ import math
4
+ from collections.abc import Iterator
5
+
6
+ import torch
7
+ from torch import Tensor
8
+
9
+ from tscp.data import ResidualArchive
10
+ from tscp.features import FeatureMap
11
+
12
+
13
+ class RowBatches:
14
+ """Batches of the rows of an archive, with their features, for :func:`tscp.nn.train`.
15
+
16
+ Each pass yields dicts with:
17
+
18
+ - ``"index"``: ``[B]`` positions of the batch's rows within ``rows``;
19
+ - ``"residuals"``: ``[B, H, N]``;
20
+ - ``"observed"``: ``[B, H, N]`` bool, all True when the archive has no mask;
21
+ - ``"features"`` and ``"features_valid"``: ``[B, *element, F]`` and ``[B, *element]``,
22
+ when a feature map is given.
23
+
24
+ Features are computed once, when the batches are made, causally from the rows'
25
+ timeline. With ``shuffle`` the order changes on every pass, drawn from a generator
26
+ seeded with ``seed``, so fresh batches with the same seed give the same passes.
27
+
28
+ Args:
29
+ rows: The rows, such as ``train`` in a component's ``fit``.
30
+ features: Optional feature map.
31
+ batch_size: Rows per batch.
32
+ shuffle: Shuffle the rows on every pass.
33
+ seed: Seed of the shuffles.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ rows: ResidualArchive,
39
+ features: FeatureMap | None = None,
40
+ *,
41
+ batch_size: int = 256,
42
+ shuffle: bool = False,
43
+ seed: int = 0,
44
+ ):
45
+ if batch_size < 1:
46
+ raise ValueError("batch_size must be positive")
47
+ self.rows = rows
48
+ self.batch_size = int(batch_size)
49
+ self.shuffle = bool(shuffle)
50
+ self._generator = torch.Generator().manual_seed(seed)
51
+ self._observed = rows.observed
52
+ if self._observed is None:
53
+ self._observed = torch.ones_like(rows.residuals, dtype=torch.bool)
54
+ self.features: Tensor | None = None
55
+ self.features_valid: Tensor | None = None
56
+ if features is not None:
57
+ self.features = features(rows)
58
+ valid = features.valid(rows)
59
+ if valid is None:
60
+ valid = torch.ones(self.features.shape[:-1], dtype=torch.bool, device=rows.device)
61
+ self.features_valid = valid
62
+
63
+ def __len__(self) -> int:
64
+ return math.ceil(len(self.rows) / self.batch_size)
65
+
66
+ def __iter__(self) -> Iterator[dict[str, Tensor]]:
67
+ count = len(self.rows)
68
+ if self.shuffle:
69
+ order = torch.randperm(count, generator=self._generator).to(self.rows.device)
70
+ else:
71
+ order = torch.arange(count, device=self.rows.device)
72
+ for start in range(0, count, self.batch_size):
73
+ index = order[start : start + self.batch_size]
74
+ batch = {
75
+ "index": index,
76
+ "residuals": self.rows.residuals[index],
77
+ "observed": self._observed[index],
78
+ }
79
+ if self.features is not None:
80
+ batch["features"] = self.features[index]
81
+ batch["features_valid"] = self.features_valid[index]
82
+ yield batch
83
+
84
+
85
+ __all__ = ["RowBatches"]
tscp/nn/losses.py ADDED
@@ -0,0 +1,63 @@
1
+ """Losses for training learned components."""
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+ from tscp.metrics import winkler_score
7
+ from tscp.regions import Interval
8
+
9
+
10
+ def _mean(values: Tensor, weights: Tensor | None) -> Tensor:
11
+ if weights is None:
12
+ return values.mean()
13
+ weights = weights.to(values.dtype).expand_as(values)
14
+ return (values * weights).sum() / weights.sum().clamp(min=torch.finfo(values.dtype).tiny)
15
+
16
+
17
+ def pinball_loss(
18
+ quantiles: Tensor,
19
+ targets: Tensor,
20
+ level: float | Tensor,
21
+ weights: Tensor | None = None,
22
+ ) -> Tensor:
23
+ """Mean pinball loss of predicted quantiles, minimized by the true ``level``-quantile.
24
+
25
+ ``level`` is the probability of the quantile, such as 0.9 for the 90% quantile, not a
26
+ miscoverage level. With ``e = target - quantile`` the loss is ``level * e`` when
27
+ ``e >= 0`` and ``(level - 1) * e`` otherwise.
28
+
29
+ Args:
30
+ quantiles: Predicted quantiles.
31
+ targets: Observed values, broadcastable against ``quantiles``.
32
+ level: Probability in (0, 1), a float or a broadcastable tensor.
33
+ weights: Optional nonnegative weights, such as an observed mask; the loss is then
34
+ their weighted mean, and 0 if every weight is 0.
35
+ """
36
+ error = targets - quantiles
37
+ return _mean(torch.maximum(level * error, (level - 1) * error), weights)
38
+
39
+
40
+ def interval_score(
41
+ lower: Tensor,
42
+ upper: Tensor,
43
+ targets: Tensor,
44
+ alpha: float,
45
+ weights: Tensor | None = None,
46
+ ) -> Tensor:
47
+ """Mean interval (Winkler) score: the width plus ``2 / alpha`` times the distance missed.
48
+
49
+ It is minimized by the central ``1 - alpha`` interval of the targets' distribution,
50
+ and it is :func:`tscp.metrics.winkler_score` averaged, so it trains for what evaluation
51
+ reports.
52
+
53
+ Args:
54
+ lower: Lower bounds.
55
+ upper: Upper bounds, broadcastable against ``lower``.
56
+ targets: Observed values, broadcastable against the bounds.
57
+ alpha: Target miscoverage level.
58
+ weights: Optional nonnegative weights; see :func:`pinball_loss`.
59
+ """
60
+ return _mean(winkler_score(Interval(lower, upper), targets, alpha), weights)
61
+
62
+
63
+ __all__ = ["interval_score", "pinball_loss"]
tscp/nn/training.py ADDED
@@ -0,0 +1,125 @@
1
+ """A small, reproducible training loop for learned components."""
2
+
3
+ import contextlib
4
+ import math
5
+ from collections.abc import Callable, Iterable, Iterator
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import torch
10
+ from torch import Tensor
11
+
12
+
13
+ @contextlib.contextmanager
14
+ def seeded(seed: int, device: torch.device | str | None = None) -> Iterator[None]:
15
+ """Run a block with the random state seeded, and restore the caller's afterwards.
16
+
17
+ Building a network inside ``seeded(seed)`` makes its initial weights the same on every
18
+ run, without changing the random numbers the rest of the program draws.
19
+
20
+ Args:
21
+ seed: The seed.
22
+ device: A CUDA device whose generator to fork and seed as well.
23
+ """
24
+ devices = [device] if device is not None and torch.device(device).type == "cuda" else []
25
+ with torch.random.fork_rng(devices=devices):
26
+ torch.manual_seed(seed)
27
+ yield
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TrainingResult:
32
+ """What :func:`train` did.
33
+
34
+ Attributes:
35
+ losses: Mean training loss of each epoch.
36
+ validation_losses: Validation loss after each epoch; empty without validation.
37
+ best_epoch: The epoch whose state the module was left in, with validation.
38
+ """
39
+
40
+ losses: tuple[float, ...]
41
+ validation_losses: tuple[float, ...]
42
+ best_epoch: int | None
43
+
44
+
45
+ def train(
46
+ module: torch.nn.Module,
47
+ loss: Callable[[torch.nn.Module, Any], Tensor],
48
+ batches: Iterable[Any],
49
+ *,
50
+ validation: Iterable[Any] | None = None,
51
+ epochs: int = 100,
52
+ optimizer: Callable[[list[torch.nn.Parameter]], torch.optim.Optimizer] | None = None,
53
+ patience: int | None = None,
54
+ seed: int = 0,
55
+ ) -> TrainingResult:
56
+ """Fit ``module`` by minimizing ``loss(module, batch)`` over the batches.
57
+
58
+ Training is reproducible without touching the caller's random state: it runs inside
59
+ :func:`seeded`, so dropout and shuffles draw the same numbers on every run.
60
+
61
+ With ``validation``, the mean loss over its batches is computed after every epoch, in
62
+ eval mode and without gradients. The module ends in the state of the epoch with the
63
+ lowest validation loss, and with ``patience``, training stops after that many epochs
64
+ without improvement.
65
+
66
+ Args:
67
+ module: The module to train; its parameters with ``requires_grad`` are updated.
68
+ loss: Maps the module and a batch to a scalar loss.
69
+ batches: Training batches; iterated once per epoch, so it must be re-iterable,
70
+ like :class:`tscp.nn.RowBatches`.
71
+ validation: Validation batches, also re-iterable.
72
+ epochs: Most passes over the training batches.
73
+ optimizer: Builds the optimizer from the parameters; Adam with learning rate
74
+ ``1e-3`` by default.
75
+ patience: Epochs without a better validation loss before stopping.
76
+ seed: Seed of the random state during training.
77
+ """
78
+ if epochs < 1:
79
+ raise ValueError("epochs must be positive")
80
+ if patience is not None and patience < 1:
81
+ raise ValueError("patience must be positive")
82
+ parameters = [parameter for parameter in module.parameters() if parameter.requires_grad]
83
+ if not parameters:
84
+ raise ValueError("the module has no parameters to train")
85
+ build = optimizer or (lambda params: torch.optim.Adam(params, lr=1e-3))
86
+ losses: list[float] = []
87
+ validation_losses: list[float] = []
88
+ best, best_epoch, best_state, waited = math.inf, None, None, 0
89
+ with seeded(seed, parameters[0].device):
90
+ step = build(parameters)
91
+ for epoch in range(epochs):
92
+ module.train()
93
+ total, count = 0.0, 0
94
+ for batch in batches:
95
+ step.zero_grad()
96
+ value = loss(module, batch)
97
+ value.backward()
98
+ step.step()
99
+ total, count = total + float(value.detach()), count + 1
100
+ if count == 0:
101
+ raise ValueError("the training batches are empty")
102
+ losses.append(total / count)
103
+ if validation is None:
104
+ continue
105
+ module.eval()
106
+ with torch.no_grad():
107
+ values = [float(loss(module, batch)) for batch in validation]
108
+ if not values:
109
+ raise ValueError("the validation batches are empty")
110
+ current = sum(values) / len(values)
111
+ validation_losses.append(current)
112
+ if current < best:
113
+ best, best_epoch, waited = current, epoch, 0
114
+ best_state = {k: v.detach().clone() for k, v in module.state_dict().items()}
115
+ else:
116
+ waited += 1
117
+ if patience is not None and waited >= patience:
118
+ break
119
+ if best_state is not None:
120
+ module.load_state_dict(best_state)
121
+ module.eval()
122
+ return TrainingResult(tuple(losses), tuple(validation_losses), best_epoch)
123
+
124
+
125
+ __all__ = ["TrainingResult", "seeded", "train"]
@@ -0,0 +1,6 @@
1
+ """Axis 5, online update: how the threshold reacts to its own misses."""
2
+
3
+ from .aci import ACIUpdate
4
+ from .base import OnlineUpdate
5
+
6
+ __all__ = ["ACIUpdate", "OnlineUpdate"]
tscp/online/aci.py ADDED
@@ -0,0 +1,53 @@
1
+ """Adaptive conformal inference."""
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+ from tscp.online.base import OnlineUpdate
7
+ from tscp.quantile import ScoreDistribution
8
+ from tscp.registry import register
9
+
10
+
11
+ @register("update", "aci")
12
+ class ACIUpdate(OnlineUpdate):
13
+ """Adaptive conformal inference (Gibbs & Candès, NeurIPS 2021).
14
+
15
+ Keeps a level ``alpha_t`` per kept element and moves it after each released outcome:
16
+ ``alpha_{t+1} = alpha_t + gamma * (alpha - err_t)``, where ``err_t`` is 1 when the
17
+ target fell outside its region. A level at or below 0 gives an unbounded region and
18
+ one at or above 1 an empty region, so over any sequence of ``T`` outcomes the miss
19
+ rate stays within ``(max(alpha_1, 1 - alpha_1) + gamma) / (gamma * T)`` of ``alpha``.
20
+
21
+ Args:
22
+ gamma: Step size, positive.
23
+ """
24
+
25
+ def __init__(self, gamma: float = 0.005):
26
+ super().__init__()
27
+ if not gamma > 0:
28
+ raise ValueError("gamma must be positive")
29
+ self.gamma = float(gamma)
30
+ self.register_buffer("target", None, persistent=False)
31
+ self.register_buffer("level", None, persistent=False)
32
+
33
+ def reset(self, alpha: Tensor, device: torch.device) -> None:
34
+ self.target = torch.as_tensor(alpha, dtype=torch.float64, device=device).clone()
35
+ self.level = self.target.clone()
36
+
37
+ def threshold(self, t: int, distribution: ScoreDistribution) -> Tensor:
38
+ return distribution.threshold(self.level)
39
+
40
+ def observe(
41
+ self,
42
+ t: int,
43
+ scores: Tensor,
44
+ thresholds: Tensor,
45
+ released: Tensor,
46
+ levels: Tensor | None = None,
47
+ ) -> None:
48
+ miss = (scores > thresholds).to(torch.float64)
49
+ stepped = self.level + self.gamma * (self.target - miss)
50
+ self.level = torch.where(released, stepped, self.level)
51
+
52
+
53
+ __all__ = ["ACIUpdate"]
tscp/online/base.py ADDED
@@ -0,0 +1,67 @@
1
+ """The online-update interface (axis 5)."""
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+ from tscp.quantile import ScoreDistribution
7
+ from tscp.utils import Component
8
+
9
+
10
+ class OnlineUpdate(Component):
11
+ """How the threshold reacts to its own misses.
12
+
13
+ The composer answers the test queries in time order, numbered ``t = 0, 1, ...``.
14
+ Before answering a query it passes every outcome that has become known
15
+ (:meth:`observe`); then it asks for the query's threshold (:meth:`threshold`),
16
+ handing over the conformal distribution of its score. Updates that move a level (the
17
+ ACI family) read the distribution's threshold at their level, several levels at once
18
+ if they run experts; updates that move the threshold itself (conformal PID, SAOCP)
19
+ may adjust or ignore it.
20
+
21
+ Outcomes arrive late: the outcome of step ``h`` of a query is known ``h`` base steps
22
+ after its origin, so later queries are often answered before earlier outcomes
23
+ arrive. An update that keeps state per query, such as expert thresholds, keys it by
24
+ ``t``.
25
+ """
26
+
27
+ #: Whether :meth:`observe` needs the realized level of each outcome (DtACI does).
28
+ needs_levels: bool = False
29
+
30
+ def reset(self, alpha: Tensor, device: torch.device) -> None:
31
+ """Start a new pass with target miscoverage ``alpha``, ``[*kept]`` in float64."""
32
+ raise NotImplementedError
33
+
34
+ def threshold(self, t: int, distribution: ScoreDistribution) -> Tensor:
35
+ """Return the threshold ``[*kept]`` for query ``t``.
36
+
37
+ Args:
38
+ t: Position of the query, counting from 0 in time order.
39
+ distribution: The conformal distribution of query ``t``'s score, of batch
40
+ shape ``[*kept]``.
41
+ """
42
+ raise NotImplementedError
43
+
44
+ def observe(
45
+ self,
46
+ t: int,
47
+ scores: Tensor,
48
+ thresholds: Tensor,
49
+ released: Tensor,
50
+ levels: Tensor | None = None,
51
+ ) -> None:
52
+ """Take an outcome of query ``t``.
53
+
54
+ Args:
55
+ t: Position of the query the outcome belongs to.
56
+ scores: ``[*kept]`` scores of its residuals.
57
+ thresholds: ``[*kept]`` thresholds it was answered with.
58
+ released: ``[*kept]`` bool, True for the elements whose outcome is known now.
59
+ Per-step outcomes arrive one step at a time; missing targets never arrive.
60
+ levels: ``[*kept]`` realized levels, ``distribution.level(scores)`` under the
61
+ distribution query ``t`` was answered with, if :attr:`needs_levels`;
62
+ otherwise None.
63
+ """
64
+ raise NotImplementedError
65
+
66
+
67
+ __all__ = ["OnlineUpdate"]
tscp/py.typed ADDED
File without changes
@@ -0,0 +1,16 @@
1
+ """Axis 4, quantile model: how weighted scores become a threshold."""
2
+
3
+ from .base import Pool, QuantileModel
4
+ from .distribution import TOLERANCE, EmpiricalDistribution, ScoreDistribution
5
+ from .weighted import WeightedQuantile, uniform_quantile, weighted_quantile
6
+
7
+ __all__ = [
8
+ "TOLERANCE",
9
+ "EmpiricalDistribution",
10
+ "Pool",
11
+ "QuantileModel",
12
+ "ScoreDistribution",
13
+ "WeightedQuantile",
14
+ "uniform_quantile",
15
+ "weighted_quantile",
16
+ ]
tscp/quantile/base.py ADDED
@@ -0,0 +1,73 @@
1
+ """The quantile-model interface (axis 4)."""
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+ from tscp.data import ResidualArchive
7
+ from tscp.quantile.distribution import ScoreDistribution
8
+ from tscp.utils import Component
9
+
10
+
11
+ class Pool:
12
+ """The scores a predictor calibrates with, with the rows they come from.
13
+
14
+ The composer builds one per call to ``predict``. It sorts the scores once, however
15
+ many queries read them.
16
+
17
+ Args:
18
+ scores: ``[P, *kept]`` pool scores.
19
+ rows: The ``P`` pool rows, a row subset of the archive, for quantile models and
20
+ weights that use features of the rows.
21
+ valid: ``[P, *kept]`` bool, False where a score is undefined; None if all are.
22
+ """
23
+
24
+ def __init__(self, scores: Tensor, rows: ResidualArchive, valid: Tensor | None = None):
25
+ if scores.shape[0] != len(rows):
26
+ raise ValueError("the pool needs one row per score")
27
+ if valid is not None and valid.shape != scores.shape:
28
+ raise ValueError("valid must have the shape of the scores")
29
+ self.scores = scores
30
+ self.rows = rows
31
+ self.valid = valid
32
+ self._sorted: tuple[Tensor, Tensor] | None = None
33
+
34
+ def __len__(self) -> int:
35
+ return self.scores.shape[0]
36
+
37
+ @property
38
+ def kept(self) -> tuple[int, ...]:
39
+ """Sizes of the kept dimensions."""
40
+ return tuple(self.scores.shape[1:])
41
+
42
+ def sorted(self) -> tuple[Tensor, Tensor]:
43
+ """The scores sorted along the pool dimension, and the indices that sort them."""
44
+ if self._sorted is None:
45
+ values, indices = torch.sort(self.scores, dim=0)
46
+ self._sorted = (values, indices)
47
+ return self._sorted
48
+
49
+
50
+ class QuantileModel(Component):
51
+ """How weighted scores become a threshold.
52
+
53
+ :meth:`forward` takes the calibration pool, the weights of its scores for each query
54
+ and the query rows, and returns a :class:`~tscp.quantile.ScoreDistribution` from which
55
+ the composer and online updates read thresholds at any level.
56
+
57
+ ``weights=None`` means every pool score counts the same for every query; the composer
58
+ passes None only when every pool score is defined and available. Otherwise weights
59
+ are ``[Q, P]`` or broadcastable to ``[Q, P, *kept]``, nonnegative, and already zero for
60
+ scores that are undefined or not yet known at the query.
61
+
62
+ A model that regresses scores on features of the rows, such as quantile regression on
63
+ recent residuals, reads them from ``pool.rows`` and ``query`` and their timelines.
64
+ """
65
+
66
+ def forward(
67
+ self, pool: Pool, weights: Tensor | None, query: ResidualArchive
68
+ ) -> ScoreDistribution:
69
+ """Return the distribution of the score at each of the ``Q`` query rows."""
70
+ raise NotImplementedError
71
+
72
+
73
+ __all__ = ["Pool", "QuantileModel"]
@@ -0,0 +1,170 @@
1
+ """Conformal distributions of scores: a threshold at any level, and the level of any score."""
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+ from tscp.typing import Alpha
7
+
8
+ TOLERANCE = 1e-9
9
+ """Levels within this of a cumulative weight count as reached, so float rounding in
10
+ ``1 - alpha`` or in cumulative sums cannot shift a threshold by one score."""
11
+
12
+
13
+ class ScoreDistribution:
14
+ """The conformal distribution of a score, for a batch of queries and kept elements.
15
+
16
+ Quantile models (axis 4) return one. :meth:`threshold` gives the threshold at any
17
+ miscoverage level, several levels at once if asked; :meth:`level` gives the largest
18
+ level at which a score is still covered, its conformal p-value. Online updates use
19
+ both: the ACI family moves the level it asks a threshold for, and DtACI weighs its
20
+ experts by the level of each realized score.
21
+
22
+ The batch shape is ``[Q, *kept]`` for one distribution per query, or ``[*kept]`` when
23
+ every query shares one.
24
+ """
25
+
26
+ @property
27
+ def batch_shape(self) -> torch.Size:
28
+ raise NotImplementedError
29
+
30
+ def threshold(self, alpha: Alpha) -> Tensor:
31
+ """Return the threshold at miscoverage ``alpha``.
32
+
33
+ ``alpha`` broadcasts against :attr:`batch_shape` from the right and may add leading
34
+ dimensions to ask for several levels at once: ``alpha`` of shape ``[L, *batch]``
35
+ gives thresholds ``[L, *batch]``. A level at or below 0 gives ``+inf`` (an
36
+ unbounded region), and one at or above 1 gives ``-inf`` (an empty region).
37
+ """
38
+ raise NotImplementedError
39
+
40
+ def level(self, scores: Tensor) -> Tensor:
41
+ """Return the largest miscoverage level whose threshold still covers ``scores``.
42
+
43
+ ``scores`` broadcast against :attr:`batch_shape`. The result, in float64, is the
44
+ conformal p-value of each score (up to the rounding tolerance of
45
+ :meth:`threshold`): 1 below every pool score, and the test point's share of the
46
+ weight above them all.
47
+ """
48
+ raise NotImplementedError
49
+
50
+ def select(self, index: int) -> "ScoreDistribution":
51
+ """Return the distribution of one query, of batch shape ``[*kept]``."""
52
+ raise NotImplementedError
53
+
54
+
55
+ class EmpiricalDistribution(ScoreDistribution):
56
+ """The weighted empirical distribution of pool scores, with the test point at ``+inf``.
57
+
58
+ With weights ``w_i`` on pool scores ``s_i`` and the test point's weight ``w``, the
59
+ threshold at ``alpha`` is the smallest pool score ``s`` with
60
+ ``sum(w_i for s_i <= s) / (sum(w_i) + w) >= 1 - alpha`` (Tibshirani et al., NeurIPS
61
+ 2019; Barber et al., Annals of Statistics 2023), or ``+inf`` when the pool never
62
+ reaches ``1 - alpha``. With equal weights it is the ``ceil((1 - alpha)(n + w))``-th
63
+ smallest of the ``n`` pool scores.
64
+
65
+ Args:
66
+ ordered: ``[P, *kept]`` pool scores, sorted along the pool dimension.
67
+ cumulative: None for equal weights. Otherwise the cumulative weights of the sorted
68
+ scores divided by the total weight plus ``test_weight``: ``[Q, P, *kept]``, or
69
+ ``[P, *kept]`` for one query.
70
+ test_weight: Weight of the test point; with equal weights, relative to 1 per score.
71
+ """
72
+
73
+ def __init__(self, ordered: Tensor, cumulative: Tensor | None = None, test_weight=1.0):
74
+ self.ordered = ordered
75
+ self.cumulative = cumulative
76
+ self.test_weight = float(test_weight)
77
+
78
+ @classmethod
79
+ def weighted(
80
+ cls, ordered: Tensor, order: Tensor, weights: Tensor, test_weight: float = 1.0
81
+ ) -> "EmpiricalDistribution":
82
+ """Build a distribution per query from sorted scores and unsorted weights.
83
+
84
+ Args:
85
+ ordered: ``[P, *kept]`` pool scores, sorted along the pool dimension.
86
+ order: ``[P, *kept]`` indices that sort the scores, as ``torch.sort`` returns.
87
+ weights: Nonnegative weights ``[Q, P]``, or broadcastable to ``[Q, P, *kept]``.
88
+ test_weight: Weight of the test point.
89
+ """
90
+ num_scores, kept = ordered.shape[0], tuple(ordered.shape[1:])
91
+ if weights.ndim < 2 or weights.shape[1] != num_scores:
92
+ raise ValueError("weights must have shape [Q, P] or [Q, P, *kept] with P pool scores")
93
+ weights = weights.reshape(*weights.shape, *([1] * (2 + len(kept) - weights.ndim)))
94
+ full = (weights.shape[0], num_scores, *kept)
95
+ part = weights.to(torch.float64).broadcast_to(full)
96
+ part = torch.gather(part, 1, order.unsqueeze(0).expand(full))
97
+ total = part.sum(dim=1, keepdim=True) + test_weight
98
+ return cls(ordered, torch.cumsum(part, dim=1) / total, test_weight)
99
+
100
+ @property
101
+ def _pool_dim(self) -> int:
102
+ """Position of the pool dimension, counted from the right."""
103
+ return -self.ordered.ndim
104
+
105
+ @property
106
+ def batch_shape(self) -> torch.Size:
107
+ if self.cumulative is None:
108
+ return self.ordered.shape[1:]
109
+ shape = list(self.cumulative.shape)
110
+ del shape[self._pool_dim]
111
+ return torch.Size(shape)
112
+
113
+ def threshold(self, alpha: Alpha) -> Tensor:
114
+ level = 1 - torch.as_tensor(alpha, dtype=torch.float64, device=self.ordered.device)
115
+ if self.cumulative is None:
116
+ return self._uniform_threshold(level)
117
+ shape = torch.broadcast_shapes(level.shape, self.batch_shape)
118
+ level = level.broadcast_to(shape)
119
+ pool_dim, num_scores = self._pool_dim, self.ordered.shape[0]
120
+ # Cumulative weights never decrease, so the first index that reaches the level is
121
+ # the number of indices that do not; it equals P when the level is never reached.
122
+ unreached = self.cumulative < (level - TOLERANCE).unsqueeze(pool_dim)
123
+ first = unreached.sum(dim=pool_dim)
124
+ index = first.clamp(max=num_scores - 1).unsqueeze(pool_dim)
125
+ values = torch.gather(self.ordered.expand(unreached.shape), pool_dim, index)
126
+ values = torch.where(first >= num_scores, torch.inf, values.squeeze(pool_dim).double())
127
+ return _extremes(values, level).to(self.ordered.dtype)
128
+
129
+ def _uniform_threshold(self, level: Tensor) -> Tensor:
130
+ num_scores, kept = self.ordered.shape[0], tuple(self.ordered.shape[1:])
131
+ shape = torch.broadcast_shapes(level.shape, kept)
132
+ level = level.broadcast_to(shape)
133
+ rank = torch.ceil((level - TOLERANCE) * (num_scores + self.test_weight))
134
+ index = (rank - 1).clamp(0, num_scores - 1).to(torch.int64).reshape(-1, *kept)
135
+ source = self.ordered.unsqueeze(0).expand(index.shape[0], num_scores, *kept)
136
+ values = torch.gather(source, 1, index.unsqueeze(1)).squeeze(1).reshape(shape)
137
+ values = torch.where(rank > num_scores, torch.inf, values.to(torch.float64))
138
+ return _extremes(values, level).to(self.ordered.dtype)
139
+
140
+ def level(self, scores: Tensor) -> Tensor:
141
+ scores = torch.as_tensor(scores, device=self.ordered.device)
142
+ pool_dim, num_scores = self._pool_dim, self.ordered.shape[0]
143
+ shape = torch.broadcast_shapes(scores.shape, self.batch_shape)
144
+ scores = scores.broadcast_to(shape)
145
+ below = (self.ordered < scores.unsqueeze(pool_dim)).sum(dim=pool_dim)
146
+ if self.cumulative is None:
147
+ return 1 - below.to(torch.float64) / (num_scores + self.test_weight)
148
+ full = list(shape)
149
+ full.insert(len(full) + pool_dim + 1, num_scores)
150
+ index = (below - 1).clamp(min=0).unsqueeze(pool_dim)
151
+ mass = torch.gather(self.cumulative.expand(full), pool_dim, index).squeeze(pool_dim)
152
+ return 1 - torch.where(below > 0, mass, 0.0)
153
+
154
+ def select(self, index: int) -> "EmpiricalDistribution":
155
+ if self.cumulative is None or self.cumulative.ndim == self.ordered.ndim:
156
+ return self
157
+ return EmpiricalDistribution(self.ordered, self.cumulative[index], self.test_weight)
158
+
159
+
160
+ def _extremes(values: Tensor, level: Tensor) -> Tensor:
161
+ """Apply the conventions at the ends: ``alpha <= 0`` covers all, ``alpha >= 1`` nothing.
162
+
163
+ Without them, a test weight of 0 would give the largest pool score at ``alpha = 0``,
164
+ and the long-run guarantee of the ACI family relies on an unbounded region there.
165
+ """
166
+ values = torch.where(level >= 1, torch.inf, values)
167
+ return torch.where(level <= 0, -torch.inf, values)
168
+
169
+
170
+ __all__ = ["TOLERANCE", "EmpiricalDistribution", "ScoreDistribution"]