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/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""tscp: conformal prediction for time series, in PyTorch.
|
|
2
|
+
|
|
3
|
+
Subpackages load on first access, so ``import tscp`` stays light::
|
|
4
|
+
|
|
5
|
+
import tscp
|
|
6
|
+
|
|
7
|
+
archive = tscp.datasets.synthetic.gaussian_control(horizon=1)
|
|
8
|
+
method = tscp.methods.get_method("split", alpha=0.1)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0.dev0"
|
|
14
|
+
|
|
15
|
+
_SUBPACKAGES = (
|
|
16
|
+
"data",
|
|
17
|
+
"datasets",
|
|
18
|
+
"features",
|
|
19
|
+
"methods",
|
|
20
|
+
"metrics",
|
|
21
|
+
"nn",
|
|
22
|
+
"online",
|
|
23
|
+
"quantile",
|
|
24
|
+
"regions",
|
|
25
|
+
"registry",
|
|
26
|
+
"scores",
|
|
27
|
+
"sources",
|
|
28
|
+
"testing",
|
|
29
|
+
"utils",
|
|
30
|
+
"weights",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = ["__version__", *_SUBPACKAGES]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def __getattr__(name: str):
|
|
37
|
+
if name in _SUBPACKAGES:
|
|
38
|
+
module = importlib.import_module(f"{__name__}.{name}")
|
|
39
|
+
globals()[name] = module
|
|
40
|
+
return module
|
|
41
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def __dir__() -> list[str]:
|
|
45
|
+
return sorted(__all__)
|
tscp/data/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The data contract: residual archives, split plans and the availability rule."""
|
|
2
|
+
|
|
3
|
+
from .archive import Graph, ResidualArchive
|
|
4
|
+
from .availability import availability_mask, known_counts, target_times
|
|
5
|
+
from .io import load_archive, save_archive
|
|
6
|
+
from .splits import SPLIT_NAMES, SplitPlan, chronological_split
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"SPLIT_NAMES",
|
|
10
|
+
"Graph",
|
|
11
|
+
"ResidualArchive",
|
|
12
|
+
"SplitPlan",
|
|
13
|
+
"availability_mask",
|
|
14
|
+
"chronological_split",
|
|
15
|
+
"known_counts",
|
|
16
|
+
"load_archive",
|
|
17
|
+
"save_archive",
|
|
18
|
+
"target_times",
|
|
19
|
+
]
|
tscp/data/archive.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""The residual archive: the input of every method."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field, replace
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import torch
|
|
9
|
+
from torch import Tensor
|
|
10
|
+
|
|
11
|
+
from tscp.data.availability import known_counts, target_times
|
|
12
|
+
from tscp.data.splits import SplitPlan, chronological_split
|
|
13
|
+
from tscp.utils.hashing import array_fingerprint
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _int_vector(values, name: str, size: int, device: torch.device) -> Tensor:
|
|
17
|
+
vector = torch.as_tensor(values, device=device)
|
|
18
|
+
if vector.ndim != 1 or vector.numel() != size:
|
|
19
|
+
raise ValueError(f"{name} must be a vector of length {size}")
|
|
20
|
+
if vector.dtype.is_floating_point or vector.dtype == torch.bool:
|
|
21
|
+
raise ValueError(f"{name} must hold integers")
|
|
22
|
+
return vector.to(torch.int64)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _as_cube(values, name: str) -> Tensor:
|
|
26
|
+
"""``[T]``, ``[T, H]`` or ``[T, H, N]`` values as a floating ``[T, H, N]`` tensor."""
|
|
27
|
+
values = torch.as_tensor(values)
|
|
28
|
+
if not values.is_floating_point():
|
|
29
|
+
values = values.to(torch.get_default_dtype())
|
|
30
|
+
if values.ndim == 1:
|
|
31
|
+
return values[:, None, None]
|
|
32
|
+
if values.ndim == 2:
|
|
33
|
+
return values[:, :, None]
|
|
34
|
+
if values.ndim == 3:
|
|
35
|
+
return values
|
|
36
|
+
raise ValueError(f"{name} must have shape [T], [T, H] or [T, H, N]")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, eq=False)
|
|
40
|
+
class Graph:
|
|
41
|
+
"""A graph over the series of an archive, in PyG's edge-list convention.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
edge_index: ``[2, E]`` source and target series of each edge.
|
|
45
|
+
num_nodes: Number of series; must equal the archive's ``N``.
|
|
46
|
+
edge_weight: Optional ``[E]`` edge weights.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
edge_index: Tensor
|
|
50
|
+
num_nodes: int
|
|
51
|
+
edge_weight: Tensor | None = None
|
|
52
|
+
|
|
53
|
+
def __post_init__(self):
|
|
54
|
+
edge_index = torch.as_tensor(self.edge_index)
|
|
55
|
+
if edge_index.ndim != 2 or edge_index.shape[0] != 2 or edge_index.dtype.is_floating_point:
|
|
56
|
+
raise ValueError("edge_index must be an integer tensor of shape [2, E]")
|
|
57
|
+
edge_index = edge_index.to(torch.int64)
|
|
58
|
+
if edge_index.numel() and (edge_index.min() < 0 or edge_index.max() >= self.num_nodes):
|
|
59
|
+
raise ValueError("edge_index refers to a node outside [0, num_nodes)")
|
|
60
|
+
object.__setattr__(self, "edge_index", edge_index)
|
|
61
|
+
if self.edge_weight is not None:
|
|
62
|
+
weight = torch.as_tensor(self.edge_weight, device=edge_index.device)
|
|
63
|
+
if weight.shape != (edge_index.shape[1],):
|
|
64
|
+
raise ValueError("edge_weight must have one entry per edge")
|
|
65
|
+
object.__setattr__(self, "edge_weight", weight)
|
|
66
|
+
|
|
67
|
+
def to(self, device: torch.device | str) -> "Graph":
|
|
68
|
+
weight = None if self.edge_weight is None else self.edge_weight.to(device)
|
|
69
|
+
return Graph(self.edge_index.to(device), self.num_nodes, weight)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, eq=False, repr=False)
|
|
73
|
+
class ResidualArchive:
|
|
74
|
+
"""Residuals of one forecaster on one dataset, with what methods need beside them.
|
|
75
|
+
|
|
76
|
+
Residuals are truth minus forecast, in the target's original units. tscp never
|
|
77
|
+
standardizes them; a component that rescales fits its scaling on the ``fit`` split.
|
|
78
|
+
Rows are forecast origins. The archive holds the whole timeline and a
|
|
79
|
+
:class:`~tscp.data.SplitPlan`, so the same archive can be split again.
|
|
80
|
+
|
|
81
|
+
A row subset, made by :meth:`select` or :meth:`split`, remembers the archive its rows
|
|
82
|
+
came from as its ``timeline``, with their positions in it as ``rows``. Components read
|
|
83
|
+
the history before a row from there, through :meth:`known_counts`, so nothing they
|
|
84
|
+
compute depends on a residual before its target time.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
residuals: ``[T, H, N]`` truth minus forecast.
|
|
88
|
+
origins: ``[T]`` forecast origins, in base time steps, strictly increasing.
|
|
89
|
+
steps: ``[H]`` horizon steps, in base time steps, positive and strictly
|
|
90
|
+
increasing. The target of (origin ``s``, step ``h``) is at time ``s + h``.
|
|
91
|
+
splits: Split plan over the rows, or None for a row subset.
|
|
92
|
+
forecasts: ``[T, H, N]`` point forecasts, to map regions back to target units.
|
|
93
|
+
outputs: Other forecaster outputs, each ``[T, H, N, ...]``, such as ``"scale"``.
|
|
94
|
+
context: ``[T, C]`` or ``[T, N, C]`` covariates known at each origin.
|
|
95
|
+
context_names: Names of the ``C`` context columns.
|
|
96
|
+
observed: ``[T, H, N]`` bool; False marks a missing target, whose residual must be
|
|
97
|
+
stored as exactly 0.
|
|
98
|
+
graph: Graph over the ``N`` series.
|
|
99
|
+
series: Names of the ``N`` series.
|
|
100
|
+
timestamps: ``[T]`` int64 times of the origins, in nanoseconds since the Unix epoch
|
|
101
|
+
(UTC), strictly increasing. Optional; calendar features read them.
|
|
102
|
+
provenance: JSON-serializable metadata: dataset, forecaster, horizon, protocol.
|
|
103
|
+
timeline: For a row subset, the whole archive its rows were taken from, or the part
|
|
104
|
+
of it up to where the subset's holder may look. Set by :meth:`select` and
|
|
105
|
+
:meth:`split`; never part of the fingerprint.
|
|
106
|
+
rows: For a row subset, the positions of its rows in ``timeline``.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
residuals: Tensor
|
|
110
|
+
origins: Tensor
|
|
111
|
+
steps: Tensor
|
|
112
|
+
splits: SplitPlan | None = None
|
|
113
|
+
forecasts: Tensor | None = None
|
|
114
|
+
outputs: Mapping[str, Tensor] = field(default_factory=dict)
|
|
115
|
+
context: Tensor | None = None
|
|
116
|
+
context_names: tuple[str, ...] = ()
|
|
117
|
+
observed: Tensor | None = None
|
|
118
|
+
graph: Graph | None = None
|
|
119
|
+
series: tuple[str, ...] = ()
|
|
120
|
+
timestamps: Tensor | None = None
|
|
121
|
+
provenance: Mapping[str, Any] = field(default_factory=dict)
|
|
122
|
+
timeline: "ResidualArchive | None" = None
|
|
123
|
+
rows: Tensor | None = None
|
|
124
|
+
|
|
125
|
+
def __post_init__(self):
|
|
126
|
+
assign = object.__setattr__
|
|
127
|
+
residuals = torch.as_tensor(self.residuals)
|
|
128
|
+
if residuals.ndim != 3 or not residuals.is_floating_point():
|
|
129
|
+
raise ValueError("residuals must be a floating tensor of shape [T, H, N]")
|
|
130
|
+
num_origins, horizon, num_series = residuals.shape
|
|
131
|
+
if 0 in residuals.shape:
|
|
132
|
+
raise ValueError("residuals must have at least one origin, step and series")
|
|
133
|
+
device = residuals.device
|
|
134
|
+
assign(self, "residuals", residuals)
|
|
135
|
+
|
|
136
|
+
origins = _int_vector(self.origins, "origins", num_origins, device)
|
|
137
|
+
if (origins.diff() <= 0).any():
|
|
138
|
+
raise ValueError("origins must strictly increase")
|
|
139
|
+
assign(self, "origins", origins)
|
|
140
|
+
steps = _int_vector(self.steps, "steps", horizon, device)
|
|
141
|
+
if steps[0] < 1 or (steps.diff() <= 0).any():
|
|
142
|
+
raise ValueError("steps must be positive and strictly increase")
|
|
143
|
+
assign(self, "steps", steps)
|
|
144
|
+
|
|
145
|
+
if self.forecasts is not None:
|
|
146
|
+
forecasts = torch.as_tensor(self.forecasts, device=device)
|
|
147
|
+
if forecasts.shape != residuals.shape or not forecasts.is_floating_point():
|
|
148
|
+
raise ValueError("forecasts must be a floating tensor shaped like residuals")
|
|
149
|
+
assign(self, "forecasts", forecasts)
|
|
150
|
+
|
|
151
|
+
outputs = {}
|
|
152
|
+
for name, value in self.outputs.items():
|
|
153
|
+
if not name.isidentifier():
|
|
154
|
+
raise ValueError(f"output name {name!r} must be a Python identifier")
|
|
155
|
+
value = torch.as_tensor(value, device=device)
|
|
156
|
+
if tuple(value.shape[:3]) != tuple(residuals.shape):
|
|
157
|
+
raise ValueError(f"output {name!r} must have shape [T, H, N, ...]")
|
|
158
|
+
outputs[name] = value
|
|
159
|
+
assign(self, "outputs", outputs)
|
|
160
|
+
|
|
161
|
+
context_names = tuple(self.context_names)
|
|
162
|
+
if self.context is not None:
|
|
163
|
+
context = torch.as_tensor(self.context, device=device)
|
|
164
|
+
leading = {2: (num_origins,), 3: (num_origins, num_series)}.get(context.ndim)
|
|
165
|
+
if leading is None or tuple(context.shape[:-1]) != leading:
|
|
166
|
+
raise ValueError("context must have shape [T, C] or [T, N, C]")
|
|
167
|
+
if context_names and len(context_names) != context.shape[-1]:
|
|
168
|
+
raise ValueError("context_names must name every context column")
|
|
169
|
+
assign(self, "context", context)
|
|
170
|
+
elif context_names:
|
|
171
|
+
raise ValueError("context_names given without context")
|
|
172
|
+
assign(self, "context_names", context_names)
|
|
173
|
+
|
|
174
|
+
valid = None
|
|
175
|
+
if self.observed is not None:
|
|
176
|
+
observed = torch.as_tensor(self.observed, device=device)
|
|
177
|
+
if observed.dtype != torch.bool or observed.shape != residuals.shape:
|
|
178
|
+
raise ValueError("observed must be a bool tensor shaped like residuals")
|
|
179
|
+
if (residuals[~observed] != 0).any():
|
|
180
|
+
raise ValueError("unobserved residuals must be stored as exactly 0")
|
|
181
|
+
assign(self, "observed", observed)
|
|
182
|
+
valid = observed
|
|
183
|
+
finite = torch.isfinite(residuals) if valid is None else torch.isfinite(residuals) | ~valid
|
|
184
|
+
if not finite.all():
|
|
185
|
+
raise ValueError("residuals must be finite where observed")
|
|
186
|
+
|
|
187
|
+
series = tuple(self.series)
|
|
188
|
+
if series and len(series) != num_series:
|
|
189
|
+
raise ValueError("series must name every series")
|
|
190
|
+
assign(self, "series", series)
|
|
191
|
+
|
|
192
|
+
if self.timestamps is not None:
|
|
193
|
+
timestamps = _int_vector(self.timestamps, "timestamps", num_origins, device)
|
|
194
|
+
if (timestamps.diff() <= 0).any():
|
|
195
|
+
raise ValueError("timestamps must strictly increase")
|
|
196
|
+
assign(self, "timestamps", timestamps)
|
|
197
|
+
|
|
198
|
+
if self.graph is not None:
|
|
199
|
+
if self.graph.num_nodes != num_series:
|
|
200
|
+
raise ValueError("graph.num_nodes must equal the number of series")
|
|
201
|
+
assign(self, "graph", self.graph.to(device))
|
|
202
|
+
|
|
203
|
+
if self.splits is not None:
|
|
204
|
+
splits = self.splits if isinstance(self.splits, SplitPlan) else SplitPlan(self.splits)
|
|
205
|
+
splits = splits.to(device)
|
|
206
|
+
splits.validate(origins, steps)
|
|
207
|
+
assign(self, "splits", splits)
|
|
208
|
+
assign(self, "provenance", dict(self.provenance))
|
|
209
|
+
|
|
210
|
+
if (self.timeline is None) != (self.rows is None):
|
|
211
|
+
raise ValueError("timeline and rows must be given together")
|
|
212
|
+
if self.timeline is not None:
|
|
213
|
+
timeline = self.timeline
|
|
214
|
+
if timeline.timeline is not None:
|
|
215
|
+
raise ValueError("a timeline must be a whole archive, not a row subset")
|
|
216
|
+
if timeline.device != device:
|
|
217
|
+
raise ValueError("a row subset must be on the device of its timeline")
|
|
218
|
+
rows = _int_vector(self.rows, "rows", num_origins, device)
|
|
219
|
+
if rows[0] < 0 or rows[-1] >= len(timeline) or (rows.diff() <= 0).any():
|
|
220
|
+
raise ValueError("rows must be strictly increasing positions in the timeline")
|
|
221
|
+
same = (
|
|
222
|
+
torch.equal(timeline.origins[rows], origins)
|
|
223
|
+
and torch.equal(timeline.steps, steps)
|
|
224
|
+
and timeline.num_series == num_series
|
|
225
|
+
)
|
|
226
|
+
if not same:
|
|
227
|
+
raise ValueError("rows must match the origins, steps and series of the timeline")
|
|
228
|
+
assign(self, "rows", rows)
|
|
229
|
+
|
|
230
|
+
# -- shape and time ------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def __len__(self) -> int:
|
|
233
|
+
return self.residuals.shape[0]
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def num_origins(self) -> int:
|
|
237
|
+
"""``T``, the number of forecast origins (rows)."""
|
|
238
|
+
return self.residuals.shape[0]
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def horizon(self) -> int:
|
|
242
|
+
"""``H``, the number of horizon steps."""
|
|
243
|
+
return self.residuals.shape[1]
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def num_series(self) -> int:
|
|
247
|
+
"""``N``, the number of series."""
|
|
248
|
+
return self.residuals.shape[2]
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def device(self) -> torch.device:
|
|
252
|
+
return self.residuals.device
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def target_times(self) -> Tensor:
|
|
256
|
+
"""``[T, H]`` target time of each (origin, step)."""
|
|
257
|
+
return target_times(self.origins, self.steps)
|
|
258
|
+
|
|
259
|
+
@classmethod
|
|
260
|
+
def from_forecasts(
|
|
261
|
+
cls,
|
|
262
|
+
truth,
|
|
263
|
+
forecasts,
|
|
264
|
+
*,
|
|
265
|
+
origins=None,
|
|
266
|
+
steps=None,
|
|
267
|
+
sizes: Mapping[str, int | float | None] | None = None,
|
|
268
|
+
splits: SplitPlan | Mapping[str, Tensor] | None = None,
|
|
269
|
+
outputs: Mapping[str, Tensor] | None = None,
|
|
270
|
+
context=None,
|
|
271
|
+
context_names: tuple[str, ...] = (),
|
|
272
|
+
timestamps=None,
|
|
273
|
+
series: tuple[str, ...] = (),
|
|
274
|
+
provenance: Mapping[str, Any] | None = None,
|
|
275
|
+
) -> "ResidualArchive":
|
|
276
|
+
"""Build an archive from a forecaster's backtest: its forecasts and the truth.
|
|
277
|
+
|
|
278
|
+
The residual is ``truth - forecast``. A NaN in ``truth`` marks a target that was
|
|
279
|
+
not observed; its residual is stored as 0 and its forecast may be NaN too.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
truth: ``[T, H, N]`` targets, or ``[T, H]`` for one series, or ``[T]`` for one
|
|
283
|
+
step and one series.
|
|
284
|
+
forecasts: Point forecasts of the same shape, finite wherever the truth is
|
|
285
|
+
observed.
|
|
286
|
+
origins: ``[T]`` forecast origins in base time steps; ``0 .. T - 1`` by default.
|
|
287
|
+
steps: ``[H]`` horizon steps; ``1 .. H`` by default.
|
|
288
|
+
sizes: Split sizes for :func:`~tscp.data.chronological_split`.
|
|
289
|
+
splits: An explicit split plan instead of ``sizes``.
|
|
290
|
+
outputs: Other forecaster outputs, each ``[T, H, N, ...]``.
|
|
291
|
+
context: ``[T, C]`` or ``[T, N, C]`` covariates known at each origin.
|
|
292
|
+
context_names: Names of the context columns.
|
|
293
|
+
timestamps: ``[T]`` origin times, as int64 nanoseconds since the epoch or as a
|
|
294
|
+
``numpy.datetime64`` array in any unit.
|
|
295
|
+
series: Names of the series.
|
|
296
|
+
provenance: JSON-serializable metadata.
|
|
297
|
+
|
|
298
|
+
Raises:
|
|
299
|
+
ValueError: If shapes disagree, a forecast is missing where the truth is
|
|
300
|
+
observed, or both ``sizes`` and ``splits`` are given.
|
|
301
|
+
"""
|
|
302
|
+
truth, forecasts = _as_cube(truth, "truth"), _as_cube(forecasts, "forecasts")
|
|
303
|
+
if truth.shape != forecasts.shape:
|
|
304
|
+
raise ValueError("truth and forecasts must have the same shape")
|
|
305
|
+
forecasts = forecasts.to(truth.dtype)
|
|
306
|
+
observed = ~torch.isnan(truth)
|
|
307
|
+
if not torch.isfinite(forecasts[observed]).all():
|
|
308
|
+
raise ValueError("forecasts must be finite wherever the truth is observed")
|
|
309
|
+
residuals = torch.where(observed, truth - forecasts, torch.zeros((), dtype=truth.dtype))
|
|
310
|
+
num_origins, horizon, _ = truth.shape
|
|
311
|
+
origins = torch.arange(num_origins) if origins is None else origins
|
|
312
|
+
steps = torch.arange(1, horizon + 1) if steps is None else steps
|
|
313
|
+
if sizes is not None and splits is not None:
|
|
314
|
+
raise ValueError("give sizes or splits, not both")
|
|
315
|
+
if sizes is not None:
|
|
316
|
+
splits = chronological_split(torch.as_tensor(origins), torch.as_tensor(steps), sizes)
|
|
317
|
+
if isinstance(timestamps, np.ndarray) and timestamps.dtype.kind == "M":
|
|
318
|
+
timestamps = timestamps.astype("datetime64[ns]").astype(np.int64)
|
|
319
|
+
return cls(
|
|
320
|
+
residuals=residuals,
|
|
321
|
+
origins=origins,
|
|
322
|
+
steps=steps,
|
|
323
|
+
splits=splits,
|
|
324
|
+
forecasts=forecasts,
|
|
325
|
+
outputs=dict(outputs or {}),
|
|
326
|
+
context=context,
|
|
327
|
+
context_names=tuple(context_names),
|
|
328
|
+
observed=None if bool(observed.all()) else observed,
|
|
329
|
+
timestamps=timestamps,
|
|
330
|
+
series=tuple(series),
|
|
331
|
+
provenance=dict(provenance or {}),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def targets(self) -> Tensor:
|
|
335
|
+
"""``[T, H, N]`` targets: forecasts plus residuals, NaN where unobserved.
|
|
336
|
+
|
|
337
|
+
This inverts :meth:`from_forecasts` up to floating-point rounding.
|
|
338
|
+
|
|
339
|
+
Raises:
|
|
340
|
+
ValueError: If the archive has no forecasts.
|
|
341
|
+
"""
|
|
342
|
+
if self.forecasts is None:
|
|
343
|
+
raise ValueError("targets() needs the archive's forecasts")
|
|
344
|
+
values = self.forecasts + self.residuals
|
|
345
|
+
if self.observed is None:
|
|
346
|
+
return values
|
|
347
|
+
return torch.where(self.observed, values, torch.full((), torch.nan, dtype=values.dtype))
|
|
348
|
+
|
|
349
|
+
def known_counts(self, origins: Tensor) -> Tensor:
|
|
350
|
+
"""``[Q, H]``: how many leading rows have a known residual at each origin, per step.
|
|
351
|
+
|
|
352
|
+
At ``origins[q]`` the residuals of step ``h`` in rows ``0 .. counts[q, h] - 1`` are
|
|
353
|
+
known, and no others (see :func:`~tscp.data.known_counts`). Unobserved targets
|
|
354
|
+
among them are marked by :attr:`observed`.
|
|
355
|
+
"""
|
|
356
|
+
origins = torch.as_tensor(origins, device=self.device)
|
|
357
|
+
return known_counts(self.origins, self.steps, origins)
|
|
358
|
+
|
|
359
|
+
# -- rows ----------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
def select(self, rows: Tensor) -> "ResidualArchive":
|
|
362
|
+
"""Return the archive restricted to ``rows`` (strictly increasing), without splits.
|
|
363
|
+
|
|
364
|
+
The result remembers this archive's timeline (this archive itself, unless it is a
|
|
365
|
+
row subset already), with the positions of the selected rows in it.
|
|
366
|
+
"""
|
|
367
|
+
rows = torch.as_tensor(rows, device=self.device)
|
|
368
|
+
if rows.ndim != 1 or rows.dtype.is_floating_point or rows.dtype == torch.bool:
|
|
369
|
+
raise ValueError("rows must be a vector of integer indices")
|
|
370
|
+
rows = rows.to(torch.int64)
|
|
371
|
+
if rows.numel() == 0:
|
|
372
|
+
raise ValueError("cannot select zero rows")
|
|
373
|
+
if rows[0] < 0 or rows[-1] >= len(self) or (rows.diff() <= 0).any():
|
|
374
|
+
raise ValueError("rows must be strictly increasing indices into the archive")
|
|
375
|
+
|
|
376
|
+
if self.timeline is None:
|
|
377
|
+
timeline, positions = self, rows
|
|
378
|
+
else:
|
|
379
|
+
timeline, positions = self.timeline, self.rows[rows]
|
|
380
|
+
return self._take(rows, timeline=timeline, rows=positions)
|
|
381
|
+
|
|
382
|
+
def head(self, num_rows: int) -> "ResidualArchive":
|
|
383
|
+
"""Return the first ``num_rows`` rows as a whole archive, without splits."""
|
|
384
|
+
if not 1 <= num_rows <= len(self):
|
|
385
|
+
raise ValueError(f"num_rows must lie between 1 and {len(self)}")
|
|
386
|
+
return self._take(slice(0, num_rows), timeline=None, rows=None)
|
|
387
|
+
|
|
388
|
+
def _take(self, index, **fields) -> "ResidualArchive":
|
|
389
|
+
def take(value):
|
|
390
|
+
return None if value is None else value[index]
|
|
391
|
+
|
|
392
|
+
return replace(
|
|
393
|
+
self,
|
|
394
|
+
residuals=self.residuals[index],
|
|
395
|
+
origins=self.origins[index],
|
|
396
|
+
splits=None,
|
|
397
|
+
forecasts=take(self.forecasts),
|
|
398
|
+
outputs={name: value[index] for name, value in self.outputs.items()},
|
|
399
|
+
context=take(self.context),
|
|
400
|
+
observed=take(self.observed),
|
|
401
|
+
timestamps=take(self.timestamps),
|
|
402
|
+
**fields,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def split(self, name: str) -> "ResidualArchive":
|
|
406
|
+
"""Return the rows of one split, such as ``archive.split("calibration")``.
|
|
407
|
+
|
|
408
|
+
The timeline of the result ends with the split's last row: it holds the history
|
|
409
|
+
before and within the split, and nothing after it.
|
|
410
|
+
"""
|
|
411
|
+
if self.splits is None:
|
|
412
|
+
raise KeyError("this archive has no split plan; it is already a row subset")
|
|
413
|
+
if name not in self.splits:
|
|
414
|
+
raise KeyError(f"no {name!r} split; this archive has {self.splits.names}")
|
|
415
|
+
rows = self.splits[name]
|
|
416
|
+
return self.head(int(rows[-1]) + 1).select(rows)
|
|
417
|
+
|
|
418
|
+
def to(self, device: torch.device | str) -> "ResidualArchive":
|
|
419
|
+
def move(value):
|
|
420
|
+
return None if value is None else value.to(device)
|
|
421
|
+
|
|
422
|
+
return replace(
|
|
423
|
+
self,
|
|
424
|
+
residuals=self.residuals.to(device),
|
|
425
|
+
origins=self.origins.to(device),
|
|
426
|
+
steps=self.steps.to(device),
|
|
427
|
+
splits=None if self.splits is None else self.splits.to(device),
|
|
428
|
+
forecasts=move(self.forecasts),
|
|
429
|
+
outputs={name: value.to(device) for name, value in self.outputs.items()},
|
|
430
|
+
context=move(self.context),
|
|
431
|
+
observed=move(self.observed),
|
|
432
|
+
timestamps=move(self.timestamps),
|
|
433
|
+
graph=None if self.graph is None else self.graph.to(device),
|
|
434
|
+
timeline=None if self.timeline is None else self.timeline.to(device),
|
|
435
|
+
rows=move(self.rows),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# -- identity ------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
def arrays(self) -> dict[str, Tensor]:
|
|
441
|
+
"""Every tensor of the archive under a flat name, as stored in ``.npz`` files."""
|
|
442
|
+
arrays = {"residuals": self.residuals, "origins": self.origins, "steps": self.steps}
|
|
443
|
+
if self.splits is not None:
|
|
444
|
+
arrays.update({f"split.{name}": rows for name, rows in self.splits.indices.items()})
|
|
445
|
+
if self.forecasts is not None:
|
|
446
|
+
arrays["forecasts"] = self.forecasts
|
|
447
|
+
arrays.update({f"output.{name}": value for name, value in self.outputs.items()})
|
|
448
|
+
if self.context is not None:
|
|
449
|
+
arrays["context"] = self.context
|
|
450
|
+
if self.observed is not None:
|
|
451
|
+
arrays["observed"] = self.observed
|
|
452
|
+
if self.timestamps is not None:
|
|
453
|
+
arrays["timestamps"] = self.timestamps
|
|
454
|
+
if self.graph is not None:
|
|
455
|
+
arrays["graph.edge_index"] = self.graph.edge_index
|
|
456
|
+
if self.graph.edge_weight is not None:
|
|
457
|
+
arrays["graph.edge_weight"] = self.graph.edge_weight
|
|
458
|
+
return arrays
|
|
459
|
+
|
|
460
|
+
def fingerprint(self) -> str:
|
|
461
|
+
"""SHA-256 of every array, names and splits included; metadata is not hashed."""
|
|
462
|
+
return array_fingerprint(self.arrays())
|
|
463
|
+
|
|
464
|
+
def __repr__(self) -> str:
|
|
465
|
+
parts = [f"T={self.num_origins}", f"H={self.horizon}", f"N={self.num_series}"]
|
|
466
|
+
if self.splits is not None:
|
|
467
|
+
sizes = ", ".join(f"{name}={size}" for name, size in self.splits.sizes().items())
|
|
468
|
+
parts.append(f"splits=({sizes})")
|
|
469
|
+
extras = [
|
|
470
|
+
name
|
|
471
|
+
for name, value in (
|
|
472
|
+
("forecasts", self.forecasts),
|
|
473
|
+
("context", self.context),
|
|
474
|
+
("observed", self.observed),
|
|
475
|
+
("timestamps", self.timestamps),
|
|
476
|
+
("graph", self.graph),
|
|
477
|
+
)
|
|
478
|
+
if value is not None
|
|
479
|
+
]
|
|
480
|
+
extras += [f"output.{name}" for name in self.outputs]
|
|
481
|
+
if extras:
|
|
482
|
+
parts.append(f"with=({', '.join(extras)})")
|
|
483
|
+
if self.timeline is not None:
|
|
484
|
+
parts.append(f"rows of a timeline with T={self.timeline.num_origins}")
|
|
485
|
+
return f"ResidualArchive({', '.join(parts)})"
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
__all__ = ["Graph", "ResidualArchive"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""When a residual becomes known.
|
|
2
|
+
|
|
3
|
+
The residual of (origin ``s``, step ``h``) is known once its target time ``s + h`` has
|
|
4
|
+
passed, so at forecast origin ``t`` it is usable only if ``s + h <= t``. Every pool that
|
|
5
|
+
grows during the test period, every feature built from past residuals and every online
|
|
6
|
+
update goes through this rule, so nothing sees a residual before its target time.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import torch
|
|
10
|
+
from torch import Tensor
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def target_times(origins: Tensor, steps: Tensor) -> Tensor:
|
|
14
|
+
"""Return the ``[T, H]`` target time of each (origin, step): ``origin + step``."""
|
|
15
|
+
return origins[:, None] + steps[None, :]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def availability_mask(pool_origins: Tensor, query_origins: Tensor, offsets: Tensor) -> Tensor:
|
|
19
|
+
"""Return which pool residuals are known at each query origin.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
pool_origins: ``[P]`` origins of the pool rows.
|
|
23
|
+
query_origins: ``[Q]`` origins at which the pool is used.
|
|
24
|
+
offsets: ``[G]`` steps after which each group of residuals is known: the horizon
|
|
25
|
+
steps for per-step scores, or the last step for a score over the whole horizon.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
A ``[Q, P, G]`` bool mask, True where ``pool_origin + offset <= query_origin``.
|
|
29
|
+
"""
|
|
30
|
+
return (pool_origins[None, :, None] + offsets[None, None, :]) <= query_origins[:, None, None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def known_counts(origins: Tensor, steps: Tensor, query_origins: Tensor) -> Tensor:
|
|
34
|
+
"""Return how many leading rows have a known residual at each query origin, per step.
|
|
35
|
+
|
|
36
|
+
Rows are in time order, so the residuals of step ``h`` known at origin ``t`` are always
|
|
37
|
+
a prefix of the rows: those with ``origin + h <= t``. This is the rule of
|
|
38
|
+
:func:`availability_mask` in a form whose size does not grow with the pool, for
|
|
39
|
+
components that read the history of an archive.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
origins: ``[T]`` origins of the rows, strictly increasing.
|
|
43
|
+
steps: ``[H]`` horizon steps.
|
|
44
|
+
query_origins: ``[Q]`` origins at which the history is read.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
``[Q, H]`` counts: at query ``q``, the residuals of step ``h`` in rows
|
|
48
|
+
``0 .. counts[q, h] - 1`` are known, and no others.
|
|
49
|
+
"""
|
|
50
|
+
times = (origins[None, :] + steps[:, None]).contiguous()
|
|
51
|
+
queries = query_origins[None, :].expand(steps.numel(), -1).contiguous()
|
|
52
|
+
return torch.searchsorted(times, queries, right=True).T
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = ["availability_mask", "known_counts", "target_times"]
|