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.
- tscp/__init__.py +45 -0
- tscp/data/__init__.py +19 -0
- tscp/data/archive.py +488 -0
- tscp/data/availability.py +55 -0
- tscp/data/io.py +109 -0
- tscp/data/splits.py +169 -0
- tscp/datasets/__init__.py +6 -0
- tscp/datasets/synthetic.py +177 -0
- tscp/features/__init__.py +22 -0
- tscp/features/base.py +64 -0
- tscp/features/calendar.py +105 -0
- tscp/features/combine.py +69 -0
- tscp/features/context.py +42 -0
- tscp/features/history.py +58 -0
- tscp/features/phase.py +41 -0
- tscp/imports.py +32 -0
- tscp/methods/__init__.py +48 -0
- tscp/methods/aci.py +44 -0
- tscp/methods/base.py +228 -0
- tscp/methods/compose.py +90 -0
- tscp/methods/engine.py +128 -0
- tscp/methods/io.py +106 -0
- tscp/methods/nexcp.py +42 -0
- tscp/methods/split.py +30 -0
- tscp/metrics/__init__.py +30 -0
- tscp/metrics/conditional.py +66 -0
- tscp/metrics/interval.py +75 -0
- tscp/metrics/report.py +135 -0
- tscp/nn/__init__.py +13 -0
- tscp/nn/data.py +85 -0
- tscp/nn/losses.py +63 -0
- tscp/nn/training.py +125 -0
- tscp/online/__init__.py +6 -0
- tscp/online/aci.py +53 -0
- tscp/online/base.py +67 -0
- tscp/py.typed +0 -0
- tscp/quantile/__init__.py +16 -0
- tscp/quantile/base.py +73 -0
- tscp/quantile/distribution.py +170 -0
- tscp/quantile/weighted.py +103 -0
- tscp/regions/__init__.py +15 -0
- tscp/regions/base.py +139 -0
- tscp/regions/box.py +82 -0
- tscp/regions/interval.py +62 -0
- tscp/registry.py +179 -0
- tscp/scores/__init__.py +9 -0
- tscp/scores/absolute.py +22 -0
- tscp/scores/base.py +87 -0
- tscp/scores/cqr.py +71 -0
- tscp/scores/normalized.py +74 -0
- tscp/scores/signed.py +46 -0
- tscp/sources/__init__.py +7 -0
- tscp/sources/base.py +32 -0
- tscp/sources/expanding.py +25 -0
- tscp/sources/holdout.py +18 -0
- tscp/testing/__init__.py +40 -0
- tscp/testing/archives.py +80 -0
- tscp/testing/checks.py +832 -0
- tscp/testing/components.py +28 -0
- tscp/typing.py +24 -0
- tscp/utils/__init__.py +14 -0
- tscp/utils/component.py +76 -0
- tscp/utils/config.py +135 -0
- tscp/utils/hashing.py +35 -0
- tscp/weights/__init__.py +7 -0
- tscp/weights/base.py +22 -0
- tscp/weights/exponential.py +37 -0
- tscp/weights/uniform.py +23 -0
- tscp-0.1.0.dev0.dist-info/METADATA +130 -0
- tscp-0.1.0.dev0.dist-info/RECORD +72 -0
- tscp-0.1.0.dev0.dist-info/WHEEL +4 -0
- 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"]
|
tscp/online/__init__.py
ADDED
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"]
|