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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The conformal quantile of (weighted) scores."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
from torch import Tensor
|
|
7
|
+
|
|
8
|
+
from tscp.data import ResidualArchive
|
|
9
|
+
from tscp.quantile.base import Pool, QuantileModel
|
|
10
|
+
from tscp.quantile.distribution import EmpiricalDistribution
|
|
11
|
+
from tscp.registry import register
|
|
12
|
+
from tscp.typing import Alpha
|
|
13
|
+
|
|
14
|
+
_MAX_ELEMENTS = 2**23
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def uniform_quantile(scores: Tensor, alpha: Alpha, test_weight: float = 1.0) -> Tensor:
|
|
18
|
+
"""Conformal quantile of equally weighted scores.
|
|
19
|
+
|
|
20
|
+
The threshold is the ``ceil((1 - alpha) * (n + w))``-th smallest of the ``n`` pool
|
|
21
|
+
scores, where ``w`` is the test point's weight (1 for split conformal); ``+inf`` when
|
|
22
|
+
that rank exceeds ``n`` or ``alpha <= 0``, and ``-inf`` when ``alpha >= 1``.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
scores: ``[P, *kept]`` pool scores.
|
|
26
|
+
alpha: Miscoverage level; a float, or a tensor that broadcasts against ``kept``
|
|
27
|
+
from the right (for example ``[*kept]`` or ``[Q, *kept]``).
|
|
28
|
+
test_weight: Weight of the test point, relative to 1 per pool score.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Thresholds of shape ``broadcast(alpha.shape, kept)``, in the dtype of ``scores``.
|
|
32
|
+
"""
|
|
33
|
+
ordered = torch.sort(scores, dim=0).values
|
|
34
|
+
return EmpiricalDistribution(ordered, None, test_weight).threshold(alpha)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def weighted_quantile(
|
|
38
|
+
scores: Tensor, weights: Tensor, alpha: Alpha, test_weight: float = 1.0
|
|
39
|
+
) -> Tensor:
|
|
40
|
+
"""Conformal quantile of weighted scores.
|
|
41
|
+
|
|
42
|
+
For each query the threshold is the smallest pool score ``s`` with
|
|
43
|
+
``sum(w_i for s_i <= s) / (sum(w_i) + test_weight) >= 1 - alpha``: the
|
|
44
|
+
``(1 - alpha)``-quantile of the weighted pool with the test point's weight at
|
|
45
|
+
``+inf`` (Tibshirani et al., NeurIPS 2019; Barber et al., Annals of Statistics 2023).
|
|
46
|
+
It is ``+inf`` when the pool never reaches ``1 - alpha`` or ``alpha <= 0``, and
|
|
47
|
+
``-inf`` when ``alpha >= 1``. With all weights 1 it equals :func:`uniform_quantile`.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
scores: ``[P, *kept]`` pool scores.
|
|
51
|
+
weights: Nonnegative weights ``[Q, P]``, or broadcastable to ``[Q, P, *kept]``.
|
|
52
|
+
alpha: Miscoverage level; a float, or a tensor broadcastable to ``[Q, *kept]``.
|
|
53
|
+
test_weight: Weight of the test point.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Thresholds ``[Q, *kept]``, in the dtype of ``scores``.
|
|
57
|
+
"""
|
|
58
|
+
num_scores, kept = scores.shape[0], tuple(scores.shape[1:])
|
|
59
|
+
if weights.ndim < 2 or weights.shape[1] != num_scores:
|
|
60
|
+
raise ValueError("weights must have shape [Q, P] or [Q, P, *kept] with P pool scores")
|
|
61
|
+
num_queries = weights.shape[0]
|
|
62
|
+
alpha = torch.as_tensor(alpha, dtype=torch.float64, device=scores.device)
|
|
63
|
+
alpha = alpha.broadcast_to((num_queries, *kept))
|
|
64
|
+
ordered, order = torch.sort(scores, dim=0)
|
|
65
|
+
out = torch.empty((num_queries, *kept), dtype=scores.dtype, device=scores.device)
|
|
66
|
+
chunk = max(1, _MAX_ELEMENTS // max(1, num_scores * math.prod(kept)))
|
|
67
|
+
for start in range(0, num_queries, chunk):
|
|
68
|
+
stop = min(start + chunk, num_queries)
|
|
69
|
+
part = EmpiricalDistribution.weighted(ordered, order, weights[start:stop], test_weight)
|
|
70
|
+
out[start:stop] = part.threshold(alpha[start:stop])
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@register("quantile", "weighted")
|
|
75
|
+
class WeightedQuantile(QuantileModel):
|
|
76
|
+
"""Empirical conformal quantile of (weighted) scores, with the test point at ``+inf``.
|
|
77
|
+
|
|
78
|
+
Returns an :class:`~tscp.quantile.EmpiricalDistribution`: one shared by every query
|
|
79
|
+
when ``weights`` is None, one per query otherwise. Either way the pool is sorted once.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
test_weight: Weight of the test point relative to the pool weights; 1 for split
|
|
83
|
+
conformal and NexCP.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, test_weight: float = 1.0):
|
|
87
|
+
super().__init__()
|
|
88
|
+
if not test_weight >= 0:
|
|
89
|
+
raise ValueError("test_weight must be nonnegative")
|
|
90
|
+
self.test_weight = float(test_weight)
|
|
91
|
+
|
|
92
|
+
def forward(
|
|
93
|
+
self, pool: Pool, weights: Tensor | None, query: ResidualArchive
|
|
94
|
+
) -> EmpiricalDistribution:
|
|
95
|
+
ordered, order = pool.sorted()
|
|
96
|
+
if weights is None:
|
|
97
|
+
return EmpiricalDistribution(ordered, None, self.test_weight)
|
|
98
|
+
if (weights < 0).any() or weights.isnan().any():
|
|
99
|
+
raise ValueError("weights must be nonnegative numbers")
|
|
100
|
+
return EmpiricalDistribution.weighted(ordered, order, weights, self.test_weight)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
__all__ = ["WeightedQuantile", "uniform_quantile", "weighted_quantile"]
|
tscp/regions/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Axis 6, output: what the user gets, with a membership test and a size."""
|
|
2
|
+
|
|
3
|
+
from .base import RESIDUAL_DIMS, Region, check_event_dims, move_event_dims, restore_event_dims
|
|
4
|
+
from .box import Box
|
|
5
|
+
from .interval import Interval
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"RESIDUAL_DIMS",
|
|
9
|
+
"Box",
|
|
10
|
+
"Interval",
|
|
11
|
+
"Region",
|
|
12
|
+
"check_event_dims",
|
|
13
|
+
"move_event_dims",
|
|
14
|
+
"restore_event_dims",
|
|
15
|
+
]
|
tscp/regions/base.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""The region interface (axis 6, output)."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
from torch import Tensor
|
|
7
|
+
|
|
8
|
+
RESIDUAL_DIMS = ("step", "series")
|
|
9
|
+
"""Names of the dimensions of ``[H, N]``, in the order residuals store them."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check_event_dims(event_dims) -> tuple[str, ...]:
|
|
13
|
+
"""Return ``event_dims`` as a tuple in ``[H, N]`` order, checking the names."""
|
|
14
|
+
event_dims = tuple(event_dims)
|
|
15
|
+
unknown = sorted(set(event_dims) - set(RESIDUAL_DIMS))
|
|
16
|
+
if unknown or len(set(event_dims)) != len(event_dims):
|
|
17
|
+
raise ValueError(f"event_dims must name distinct dimensions of {RESIDUAL_DIMS}")
|
|
18
|
+
return tuple(name for name in RESIDUAL_DIMS if name in event_dims)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def move_event_dims(values: Tensor, event_dims: tuple[str, ...]) -> Tensor:
|
|
22
|
+
"""Reorder ``[..., H, N]`` values to ``[..., *kept, *event]``.
|
|
23
|
+
|
|
24
|
+
The dimensions named in ``event_dims`` move to the end and the others stay in front,
|
|
25
|
+
each group in ``[H, N]`` order. This is how a region that covers some dimensions
|
|
26
|
+
jointly lines residuals or forecasts up with its bounds.
|
|
27
|
+
"""
|
|
28
|
+
if values.ndim < 2:
|
|
29
|
+
raise ValueError("expected values of shape [..., H, N]")
|
|
30
|
+
lead = values.ndim - 2
|
|
31
|
+
kept = [lead + i for i, name in enumerate(RESIDUAL_DIMS) if name not in event_dims]
|
|
32
|
+
event = [lead + i for i, name in enumerate(RESIDUAL_DIMS) if name in event_dims]
|
|
33
|
+
return values.permute(*range(lead), *kept, *event)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def restore_event_dims(values: Tensor, event_dims: tuple[str, ...]) -> Tensor:
|
|
37
|
+
"""Invert :func:`move_event_dims`: reorder ``[..., *kept, *event]`` back to ``[..., H, N]``."""
|
|
38
|
+
if values.ndim < 2:
|
|
39
|
+
raise ValueError("expected values of shape [..., *kept, *event] with two residual dims")
|
|
40
|
+
order = [name for name in RESIDUAL_DIMS if name not in event_dims]
|
|
41
|
+
order += [name for name in RESIDUAL_DIMS if name in event_dims]
|
|
42
|
+
lead = values.ndim - 2
|
|
43
|
+
return values.permute(*range(lead), *(lead + order.index(name) for name in RESIDUAL_DIMS))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Region:
|
|
47
|
+
"""A batch of prediction regions, one per query origin and kept element.
|
|
48
|
+
|
|
49
|
+
Regions live in residual coordinates: a target ``y`` with point forecast ``f`` is
|
|
50
|
+
covered when ``y - f`` lies in the region. Coverage and volume do not change under
|
|
51
|
+
this shift, so evaluation never needs the forecasts; :meth:`to_target` maps a region
|
|
52
|
+
back to target units.
|
|
53
|
+
|
|
54
|
+
A region covers the dimensions of ``[H, N]`` in :attr:`event_dims` jointly and makes
|
|
55
|
+
one region per element of the others. :meth:`contains` always takes the residuals of
|
|
56
|
+
the query rows, ``[Q, H, N]``, and returns one answer per region, of shape
|
|
57
|
+
:attr:`batch_shape`.
|
|
58
|
+
|
|
59
|
+
A subclass stores its regions in tensors whose leading dimensions are the batch
|
|
60
|
+
dimensions, returns them from :meth:`tensors`, and can be rebuilt from them by its
|
|
61
|
+
constructor; indexing and device moves then work without more code. A subclass with
|
|
62
|
+
settings that are not tensors overrides :meth:`_rebuild`.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
#: Dimensions of ``[H, N]`` that each region covers jointly.
|
|
66
|
+
event_dims: tuple[str, ...] = ()
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def batch_shape(self) -> torch.Size:
|
|
70
|
+
"""``[Q, *kept]``: one region per query origin and element not covered jointly."""
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def dimension(self) -> int:
|
|
75
|
+
"""Number of coordinates each region covers jointly (1 for an interval)."""
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
|
|
78
|
+
def tensors(self) -> dict[str, Tensor]:
|
|
79
|
+
"""The tensors that define the regions, by constructor argument name."""
|
|
80
|
+
raise NotImplementedError
|
|
81
|
+
|
|
82
|
+
def contains(self, residuals: Tensor) -> Tensor:
|
|
83
|
+
"""Return a bool tensor of shape :attr:`batch_shape`, True where covered.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
residuals: ``[Q, H, N]`` residuals (or any points in the region's coordinates).
|
|
87
|
+
"""
|
|
88
|
+
raise NotImplementedError
|
|
89
|
+
|
|
90
|
+
def log_volume(self) -> Tensor:
|
|
91
|
+
"""Return the log-volume of each region.
|
|
92
|
+
|
|
93
|
+
``+inf`` means unbounded; ``-inf`` means empty or of zero volume.
|
|
94
|
+
"""
|
|
95
|
+
raise NotImplementedError
|
|
96
|
+
|
|
97
|
+
def to_target(self, forecasts: Tensor) -> "Region":
|
|
98
|
+
"""Return the regions shifted by the ``[Q, H, N]`` point forecasts, in target units."""
|
|
99
|
+
raise NotImplementedError
|
|
100
|
+
|
|
101
|
+
def _rebuild(self, tensors: dict[str, Tensor]) -> "Region":
|
|
102
|
+
"""Return a region of the same type and settings, made of other tensors."""
|
|
103
|
+
return type(self)(**tensors)
|
|
104
|
+
|
|
105
|
+
def __getitem__(self, index) -> "Region":
|
|
106
|
+
"""Select regions along the batch dimensions, as ``region[3]`` or ``region[mask]``."""
|
|
107
|
+
items = index if isinstance(index, tuple) else (index,)
|
|
108
|
+
if any(item is Ellipsis for item in items):
|
|
109
|
+
raise IndexError("regions do not support Ellipsis in indices")
|
|
110
|
+
if len(items) > len(self.batch_shape):
|
|
111
|
+
raise IndexError("too many indices: regions are indexed along batch dimensions")
|
|
112
|
+
return self._rebuild({name: value[index] for name, value in self.tensors().items()})
|
|
113
|
+
|
|
114
|
+
def to(self, device: torch.device | str) -> "Region":
|
|
115
|
+
"""Return the regions on ``device``."""
|
|
116
|
+
return self._rebuild({name: value.to(device) for name, value in self.tensors().items()})
|
|
117
|
+
|
|
118
|
+
def __len__(self) -> int:
|
|
119
|
+
return self.batch_shape[0]
|
|
120
|
+
|
|
121
|
+
def __repr__(self) -> str:
|
|
122
|
+
parts = [f"batch_shape={tuple(self.batch_shape)}"]
|
|
123
|
+
if self.event_dims:
|
|
124
|
+
parts.append(f"event_dims={self.event_dims}")
|
|
125
|
+
return f"{type(self).__name__}({', '.join(parts)})"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def event_size(shape: torch.Size, event_ndim: int) -> int:
|
|
129
|
+
"""Number of coordinates in the last ``event_ndim`` dimensions of ``shape``."""
|
|
130
|
+
return math.prod(shape[len(shape) - event_ndim :]) if event_ndim else 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
__all__ = [
|
|
134
|
+
"RESIDUAL_DIMS",
|
|
135
|
+
"Region",
|
|
136
|
+
"check_event_dims",
|
|
137
|
+
"move_event_dims",
|
|
138
|
+
"restore_event_dims",
|
|
139
|
+
]
|
tscp/regions/box.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Boxes: an interval per coordinate, covered jointly."""
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
from torch import Tensor
|
|
5
|
+
|
|
6
|
+
from tscp.regions.base import Region, check_event_dims, event_size, move_event_dims
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Box(Region):
|
|
10
|
+
"""Axis-aligned boxes over the dimensions in ``event_dims``.
|
|
11
|
+
|
|
12
|
+
A box covers a point when every coordinate lies within its bounds. Bounds have shape
|
|
13
|
+
``[*batch, *event]``: the batch dimensions (the query origin, then whichever of step
|
|
14
|
+
and series is not covered jointly), then the dimensions in ``event_dims``, both in
|
|
15
|
+
``[H, N]`` order. For example, a box per series over the whole horizon has bounds
|
|
16
|
+
``[Q, N, H]`` and ``event_dims=("step",)``.
|
|
17
|
+
|
|
18
|
+
The log-volume is the sum of the coordinates' log-widths. A box with an empty or
|
|
19
|
+
zero-width coordinate has log-volume ``-inf``; otherwise any infinite coordinate makes
|
|
20
|
+
it ``+inf``.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
lower: Lower bounds ``[*batch, *event]``, in residual coordinates.
|
|
24
|
+
upper: Upper bounds, the same shape as ``lower``.
|
|
25
|
+
event_dims: The dimensions of ``[H, N]`` each box covers jointly.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, lower: Tensor, upper: Tensor, event_dims=("step",)):
|
|
29
|
+
event_dims = check_event_dims(event_dims)
|
|
30
|
+
if not event_dims:
|
|
31
|
+
raise ValueError("a box covers at least one dimension jointly; use an Interval")
|
|
32
|
+
lower = torch.as_tensor(lower)
|
|
33
|
+
upper = torch.as_tensor(upper, device=lower.device)
|
|
34
|
+
if lower.shape != upper.shape:
|
|
35
|
+
raise ValueError("lower and upper must have the same shape")
|
|
36
|
+
if not (lower.is_floating_point() and upper.is_floating_point()):
|
|
37
|
+
raise ValueError("box bounds must be floating tensors")
|
|
38
|
+
if lower.ndim < len(event_dims):
|
|
39
|
+
raise ValueError("bounds must end with one dimension per entry of event_dims")
|
|
40
|
+
self.lower = lower
|
|
41
|
+
self.upper = upper
|
|
42
|
+
self.event_dims = event_dims
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def batch_shape(self) -> torch.Size:
|
|
46
|
+
return self.lower.shape[: self.lower.ndim - len(self.event_dims)]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def dimension(self) -> int:
|
|
50
|
+
return event_size(self.lower.shape, len(self.event_dims))
|
|
51
|
+
|
|
52
|
+
def tensors(self) -> dict[str, Tensor]:
|
|
53
|
+
return {"lower": self.lower, "upper": self.upper}
|
|
54
|
+
|
|
55
|
+
def _rebuild(self, tensors: dict[str, Tensor]) -> "Box":
|
|
56
|
+
return Box(tensors["lower"], tensors["upper"], self.event_dims)
|
|
57
|
+
|
|
58
|
+
def _flat(self, values: Tensor) -> Tensor:
|
|
59
|
+
"""``[*batch, *event]`` values with the event dimensions flattened into one."""
|
|
60
|
+
return values.reshape(*self.batch_shape, self.dimension)
|
|
61
|
+
|
|
62
|
+
def is_empty(self) -> Tensor:
|
|
63
|
+
"""True where some coordinate has ``lower > upper``."""
|
|
64
|
+
return self._flat(self.upper < self.lower).any(dim=-1)
|
|
65
|
+
|
|
66
|
+
def contains(self, residuals: Tensor) -> Tensor:
|
|
67
|
+
points = move_event_dims(residuals, self.event_dims)
|
|
68
|
+
inside = (self.lower <= points) & (points <= self.upper)
|
|
69
|
+
return self._flat(inside).all(dim=-1)
|
|
70
|
+
|
|
71
|
+
def log_volume(self) -> Tensor:
|
|
72
|
+
width = self._flat(self.upper - self.lower)
|
|
73
|
+
log_width = torch.log(width.clamp(min=0)) # -inf for empty or zero-width coordinates
|
|
74
|
+
degenerate = torch.isneginf(log_width).any(dim=-1)
|
|
75
|
+
return torch.where(degenerate, -torch.inf, log_width.sum(dim=-1))
|
|
76
|
+
|
|
77
|
+
def to_target(self, forecasts: Tensor) -> "Box":
|
|
78
|
+
shift = move_event_dims(forecasts, self.event_dims)
|
|
79
|
+
return Box(self.lower + shift, self.upper + shift, self.event_dims)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["Box"]
|
tscp/regions/interval.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Intervals, one per element."""
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
from torch import Tensor
|
|
5
|
+
|
|
6
|
+
from tscp.regions.base import Region
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Interval(Region):
|
|
10
|
+
"""Closed intervals ``[lower, upper]``, one per element of the batch.
|
|
11
|
+
|
|
12
|
+
Bounds may be infinite: ``(-inf, inf)`` is the whole line, and ``lower > upper``
|
|
13
|
+
(for instance ``(inf, -inf)``) is empty.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
lower: Lower bounds, in residual coordinates.
|
|
17
|
+
upper: Upper bounds, the same shape as ``lower``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
event_dims = ()
|
|
21
|
+
|
|
22
|
+
def __init__(self, lower: Tensor, upper: Tensor):
|
|
23
|
+
lower = torch.as_tensor(lower)
|
|
24
|
+
upper = torch.as_tensor(upper, device=lower.device)
|
|
25
|
+
if lower.shape != upper.shape:
|
|
26
|
+
raise ValueError("lower and upper must have the same shape")
|
|
27
|
+
if not (lower.is_floating_point() and upper.is_floating_point()):
|
|
28
|
+
raise ValueError("interval bounds must be floating tensors")
|
|
29
|
+
self.lower = lower
|
|
30
|
+
self.upper = upper
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def batch_shape(self) -> torch.Size:
|
|
34
|
+
return self.lower.shape
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def dimension(self) -> int:
|
|
38
|
+
return 1
|
|
39
|
+
|
|
40
|
+
def tensors(self) -> dict[str, Tensor]:
|
|
41
|
+
return {"lower": self.lower, "upper": self.upper}
|
|
42
|
+
|
|
43
|
+
def is_empty(self) -> Tensor:
|
|
44
|
+
"""True where ``lower > upper``."""
|
|
45
|
+
return self.upper < self.lower
|
|
46
|
+
|
|
47
|
+
def width(self) -> Tensor:
|
|
48
|
+
"""``upper - lower``, with empty intervals at 0."""
|
|
49
|
+
return torch.where(self.is_empty(), 0.0, self.upper - self.lower)
|
|
50
|
+
|
|
51
|
+
def contains(self, residuals: Tensor) -> Tensor:
|
|
52
|
+
return (self.lower <= residuals) & (residuals <= self.upper)
|
|
53
|
+
|
|
54
|
+
def log_volume(self) -> Tensor:
|
|
55
|
+
width = self.upper - self.lower
|
|
56
|
+
return torch.where(self.is_empty(), -torch.inf, torch.log(width.clamp(min=0)))
|
|
57
|
+
|
|
58
|
+
def to_target(self, forecasts: Tensor) -> "Interval":
|
|
59
|
+
return Interval(self.lower + forecasts, self.upper + forecasts)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = ["Interval"]
|
tscp/registry.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Short names for components and methods, and plugins that add more.
|
|
2
|
+
|
|
3
|
+
Every built-in component and preset is registered under a kind and a short name, so that
|
|
4
|
+
a config or a spec can name it::
|
|
5
|
+
|
|
6
|
+
from tscp import registry
|
|
7
|
+
|
|
8
|
+
weights = registry.build("weights", "exponential", decay=0.99)
|
|
9
|
+
|
|
10
|
+
A package that adds components registers them the same way, with :func:`register`, and
|
|
11
|
+
declares an entry point in the group ``tscp.plugins`` whose value is the module that does
|
|
12
|
+
the registering. tscp imports those modules when a name or a class is not found, so a
|
|
13
|
+
spec or config that names a plugin's component works once the plugin is installed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import importlib
|
|
17
|
+
import importlib.metadata
|
|
18
|
+
import inspect
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from tscp.utils.config import qualified_name
|
|
23
|
+
|
|
24
|
+
KINDS = ("score", "source", "weights", "quantile", "update", "features", "method")
|
|
25
|
+
"""Kinds of registered classes: the component axes, feature maps, and methods."""
|
|
26
|
+
|
|
27
|
+
ENTRY_POINT_GROUP = "tscp.plugins"
|
|
28
|
+
"""The entry-point group of plugins: each entry point names a module to import."""
|
|
29
|
+
|
|
30
|
+
_ENTRIES: dict[str, dict[str, type]] = {kind: {} for kind in KINDS}
|
|
31
|
+
_BUILTINS = (
|
|
32
|
+
"tscp.scores",
|
|
33
|
+
"tscp.sources",
|
|
34
|
+
"tscp.weights",
|
|
35
|
+
"tscp.quantile",
|
|
36
|
+
"tscp.online",
|
|
37
|
+
"tscp.features",
|
|
38
|
+
"tscp.methods",
|
|
39
|
+
)
|
|
40
|
+
_LOADED_PLUGINS: set[str] = set()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _check_kind(kind: str) -> None:
|
|
44
|
+
if kind not in KINDS:
|
|
45
|
+
raise ValueError(f"unknown kind {kind!r}; the kinds are {KINDS}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def register(kind: str, name: str, *, replace: bool = False) -> Callable[[type], type]:
|
|
49
|
+
"""Class decorator that makes a class buildable as ``build(kind, name, ...)``.
|
|
50
|
+
|
|
51
|
+
Names are case-insensitive. Registering a name that another class holds raises a
|
|
52
|
+
ValueError, so two packages never silently override each other or tscp's own
|
|
53
|
+
components; pass ``replace=True`` to take a name on purpose. Registering the same
|
|
54
|
+
class again, as when its module is reloaded, does nothing.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
kind: One of :data:`KINDS`.
|
|
58
|
+
name: The short name.
|
|
59
|
+
replace: Take the name even if another class holds it.
|
|
60
|
+
"""
|
|
61
|
+
_check_kind(kind)
|
|
62
|
+
key = name.lower()
|
|
63
|
+
|
|
64
|
+
def decorator(cls: type) -> type:
|
|
65
|
+
current = _ENTRIES[kind].get(key)
|
|
66
|
+
same = current is not None and qualified_name(current) == qualified_name(cls)
|
|
67
|
+
if current is not None and not same and not replace:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"{kind} {key!r} is already registered to {qualified_name(current)}; "
|
|
70
|
+
"pass replace=True to take the name"
|
|
71
|
+
)
|
|
72
|
+
_ENTRIES[kind][key] = cls
|
|
73
|
+
return cls
|
|
74
|
+
|
|
75
|
+
return decorator
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def entries(kind: str) -> dict[str, type]:
|
|
79
|
+
"""The live name-to-class table of a kind.
|
|
80
|
+
|
|
81
|
+
It fills as modules register; unlike :func:`lookup`, reading it imports nothing.
|
|
82
|
+
"""
|
|
83
|
+
_check_kind(kind)
|
|
84
|
+
return _ENTRIES[kind]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_builtins() -> None:
|
|
88
|
+
for module in _BUILTINS:
|
|
89
|
+
importlib.import_module(module)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_plugins() -> list[str]:
|
|
93
|
+
"""Import the module of every installed plugin not imported yet.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The names of the entry points imported by this call.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
ImportError: If a plugin's module fails to import; the message names the plugin.
|
|
100
|
+
"""
|
|
101
|
+
loaded = []
|
|
102
|
+
for entry_point in importlib.metadata.entry_points(group=ENTRY_POINT_GROUP):
|
|
103
|
+
if entry_point.name in _LOADED_PLUGINS:
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
entry_point.load()
|
|
107
|
+
except Exception as error:
|
|
108
|
+
raise ImportError(
|
|
109
|
+
f"the tscp plugin {entry_point.name!r} ({entry_point.value}) failed to load: "
|
|
110
|
+
f"{error}"
|
|
111
|
+
) from error
|
|
112
|
+
_LOADED_PLUGINS.add(entry_point.name)
|
|
113
|
+
loaded.append(entry_point.name)
|
|
114
|
+
return loaded
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def lookup(kind: str, name: str) -> type:
|
|
118
|
+
"""Return the class registered as ``name`` for ``kind``.
|
|
119
|
+
|
|
120
|
+
The built-in components are imported first; plugins are imported if the name is
|
|
121
|
+
still unknown.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ValueError: If no built-in or installed plugin registers the name.
|
|
125
|
+
"""
|
|
126
|
+
_check_kind(kind)
|
|
127
|
+
_load_builtins()
|
|
128
|
+
key = name.lower()
|
|
129
|
+
if key not in _ENTRIES[kind]:
|
|
130
|
+
load_plugins()
|
|
131
|
+
if key not in _ENTRIES[kind]:
|
|
132
|
+
raise ValueError(f"unknown {kind} {name!r}; registered: {sorted(_ENTRIES[kind])}")
|
|
133
|
+
return _ENTRIES[kind][key]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build(kind: str, name: str, *, ignore_unknown: bool = False, **kwargs: Any) -> Any:
|
|
137
|
+
"""Build the class registered as ``name`` for ``kind`` with keyword arguments.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
kind: One of :data:`KINDS`.
|
|
141
|
+
name: A registered name, in any case.
|
|
142
|
+
ignore_unknown: Drop keyword arguments the class does not take, which helps when
|
|
143
|
+
one config sweeps several classes. By default they raise.
|
|
144
|
+
**kwargs: Arguments of the class.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: If the name is not registered.
|
|
148
|
+
TypeError: If an argument is unknown and ``ignore_unknown`` is False.
|
|
149
|
+
"""
|
|
150
|
+
cls = lookup(kind, name)
|
|
151
|
+
parameters = inspect.signature(cls.__init__).parameters
|
|
152
|
+
takes_any = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values())
|
|
153
|
+
accepted = [p for p in parameters if p != "self"]
|
|
154
|
+
unknown = [] if takes_any else sorted(set(kwargs) - set(accepted))
|
|
155
|
+
if unknown:
|
|
156
|
+
if not ignore_unknown:
|
|
157
|
+
raise TypeError(f"{cls.__name__} does not take {unknown}; it takes {accepted}")
|
|
158
|
+
kwargs = {key: value for key, value in kwargs.items() if key in accepted}
|
|
159
|
+
return cls(**kwargs)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def names(kind: str) -> list[str]:
|
|
163
|
+
"""The registered names of a kind, built-ins and installed plugins included."""
|
|
164
|
+
_check_kind(kind)
|
|
165
|
+
_load_builtins()
|
|
166
|
+
load_plugins()
|
|
167
|
+
return sorted(_ENTRIES[kind])
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
__all__ = [
|
|
171
|
+
"ENTRY_POINT_GROUP",
|
|
172
|
+
"KINDS",
|
|
173
|
+
"build",
|
|
174
|
+
"entries",
|
|
175
|
+
"load_plugins",
|
|
176
|
+
"lookup",
|
|
177
|
+
"names",
|
|
178
|
+
"register",
|
|
179
|
+
]
|
tscp/scores/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Axis 2, score: how an error becomes a number."""
|
|
2
|
+
|
|
3
|
+
from .absolute import AbsoluteScore
|
|
4
|
+
from .base import Score
|
|
5
|
+
from .cqr import CQRScore
|
|
6
|
+
from .normalized import NormalizedScore
|
|
7
|
+
from .signed import SignedScore
|
|
8
|
+
|
|
9
|
+
__all__ = ["AbsoluteScore", "CQRScore", "NormalizedScore", "Score", "SignedScore"]
|
tscp/scores/absolute.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The absolute residual."""
|
|
2
|
+
|
|
3
|
+
from torch import Tensor
|
|
4
|
+
|
|
5
|
+
from tscp.data import ResidualArchive
|
|
6
|
+
from tscp.regions import Interval
|
|
7
|
+
from tscp.registry import register
|
|
8
|
+
from tscp.scores.base import Score
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@register("score", "absolute")
|
|
12
|
+
class AbsoluteScore(Score):
|
|
13
|
+
"""``|r|``, per element; a threshold ``q`` gives the symmetric interval ``[-q, q]``."""
|
|
14
|
+
|
|
15
|
+
def forward(self, rows: ResidualArchive) -> Tensor:
|
|
16
|
+
return rows.residuals.abs()
|
|
17
|
+
|
|
18
|
+
def region(self, threshold: Tensor, rows: ResidualArchive) -> Interval:
|
|
19
|
+
return Interval(-threshold, threshold)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["AbsoluteScore"]
|