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/data/io.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Self-contained ``.npz`` residual archives.
|
|
2
|
+
|
|
3
|
+
A file holds every array of a :class:`~tscp.data.ResidualArchive` plus a JSON header with
|
|
4
|
+
the format name, schema version, names, provenance and the archive fingerprint. Files are
|
|
5
|
+
written without pickle, are never overwritten, and are checked against their fingerprint
|
|
6
|
+
when loaded.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import torch
|
|
14
|
+
|
|
15
|
+
from tscp import __version__
|
|
16
|
+
from tscp.data.archive import Graph, ResidualArchive
|
|
17
|
+
from tscp.data.splits import SplitPlan
|
|
18
|
+
|
|
19
|
+
FORMAT = "tscp.residual_archive"
|
|
20
|
+
SCHEMA_VERSION = 2
|
|
21
|
+
"""The schema version :func:`save_archive` writes. Version 2 added ``timestamps``."""
|
|
22
|
+
READABLE_VERSIONS = (1, 2)
|
|
23
|
+
"""Schema versions :func:`load_archive` reads."""
|
|
24
|
+
|
|
25
|
+
_PLAIN = ("residuals", "origins", "steps", "forecasts", "context", "observed", "timestamps")
|
|
26
|
+
_PREFIXES = ("split.", "output.", "graph.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def save_archive(archive: ResidualArchive, path: str | Path) -> None:
|
|
30
|
+
"""Write ``archive`` to a new ``.npz`` file.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
FileExistsError: If ``path`` exists; archives are never overwritten.
|
|
34
|
+
ValueError: If the provenance is not JSON-serializable without NaN.
|
|
35
|
+
"""
|
|
36
|
+
arrays = {name: value.detach().cpu().numpy() for name, value in archive.arrays().items()}
|
|
37
|
+
header = {
|
|
38
|
+
"format": FORMAT,
|
|
39
|
+
"schema_version": SCHEMA_VERSION,
|
|
40
|
+
"tscp_version": __version__,
|
|
41
|
+
"fingerprint": archive.fingerprint(),
|
|
42
|
+
"series": list(archive.series),
|
|
43
|
+
"context_names": list(archive.context_names),
|
|
44
|
+
"graph_num_nodes": None if archive.graph is None else archive.graph.num_nodes,
|
|
45
|
+
"provenance": dict(archive.provenance),
|
|
46
|
+
}
|
|
47
|
+
try:
|
|
48
|
+
text = json.dumps(header, sort_keys=True, allow_nan=False)
|
|
49
|
+
except (TypeError, ValueError) as error:
|
|
50
|
+
raise ValueError(f"provenance must be JSON-serializable without NaN: {error}") from error
|
|
51
|
+
arrays["header"] = np.asarray(text)
|
|
52
|
+
with Path(path).open("xb") as handle:
|
|
53
|
+
np.savez_compressed(handle, **arrays)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_archive(path: str | Path) -> ResidualArchive:
|
|
57
|
+
"""Read an archive written by :func:`save_archive`, checking its fingerprint.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ValueError: If the file is not a tscp archive, has another schema version, holds
|
|
61
|
+
unexpected arrays, or its arrays do not match the fingerprint in its header.
|
|
62
|
+
"""
|
|
63
|
+
with np.load(path, allow_pickle=False) as data:
|
|
64
|
+
if "header" not in data.files:
|
|
65
|
+
raise ValueError(f"{path} is not a tscp residual archive: it has no header")
|
|
66
|
+
header = json.loads(str(data["header"].item()))
|
|
67
|
+
if header.get("format") != FORMAT:
|
|
68
|
+
raise ValueError(f"{path} is not a tscp residual archive")
|
|
69
|
+
if header.get("schema_version") not in READABLE_VERSIONS:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"{path} has schema version {header.get('schema_version')}; "
|
|
72
|
+
f"this tscp reads versions {READABLE_VERSIONS}"
|
|
73
|
+
)
|
|
74
|
+
names = [name for name in data.files if name != "header"]
|
|
75
|
+
unexpected = [n for n in names if n not in _PLAIN and not n.startswith(_PREFIXES)]
|
|
76
|
+
if unexpected:
|
|
77
|
+
raise ValueError(f"{path} holds unexpected arrays: {unexpected}")
|
|
78
|
+
arrays = {name: torch.from_numpy(data[name]) for name in names}
|
|
79
|
+
|
|
80
|
+
splits = {n.removeprefix("split."): v for n, v in arrays.items() if n.startswith("split.")}
|
|
81
|
+
outputs = {n.removeprefix("output."): v for n, v in arrays.items() if n.startswith("output.")}
|
|
82
|
+
graph = None
|
|
83
|
+
if "graph.edge_index" in arrays:
|
|
84
|
+
graph = Graph(
|
|
85
|
+
edge_index=arrays["graph.edge_index"],
|
|
86
|
+
num_nodes=header["graph_num_nodes"],
|
|
87
|
+
edge_weight=arrays.get("graph.edge_weight"),
|
|
88
|
+
)
|
|
89
|
+
archive = ResidualArchive(
|
|
90
|
+
residuals=arrays["residuals"],
|
|
91
|
+
origins=arrays["origins"],
|
|
92
|
+
steps=arrays["steps"],
|
|
93
|
+
splits=SplitPlan(splits) if splits else None,
|
|
94
|
+
forecasts=arrays.get("forecasts"),
|
|
95
|
+
outputs=outputs,
|
|
96
|
+
context=arrays.get("context"),
|
|
97
|
+
context_names=tuple(header["context_names"]),
|
|
98
|
+
observed=arrays.get("observed"),
|
|
99
|
+
timestamps=arrays.get("timestamps"),
|
|
100
|
+
graph=graph,
|
|
101
|
+
series=tuple(header["series"]),
|
|
102
|
+
provenance=header["provenance"],
|
|
103
|
+
)
|
|
104
|
+
if archive.fingerprint() != header["fingerprint"]:
|
|
105
|
+
raise ValueError(f"{path}: the arrays do not match the fingerprint in the header")
|
|
106
|
+
return archive
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = ["FORMAT", "READABLE_VERSIONS", "SCHEMA_VERSION", "load_archive", "save_archive"]
|
tscp/data/splits.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Chronological split plans with no shared target times."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Iterator, Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
from torch import Tensor
|
|
9
|
+
|
|
10
|
+
SPLIT_NAMES = ("fit", "validation", "calibration", "test")
|
|
11
|
+
"""Split names in chronological order.
|
|
12
|
+
|
|
13
|
+
``fit`` (and the optional ``validation``) are for trainable components; ``calibration``
|
|
14
|
+
supplies the conformal scores; ``test`` is where regions are made and evaluated.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _rows(values, name: str) -> Tensor:
|
|
19
|
+
rows = torch.as_tensor(values)
|
|
20
|
+
if rows.ndim != 1 or rows.dtype.is_floating_point or rows.dtype == torch.bool:
|
|
21
|
+
raise ValueError(f"split {name!r} must be a vector of integer row indices")
|
|
22
|
+
rows = rows.to(torch.int64)
|
|
23
|
+
if rows.numel() == 0:
|
|
24
|
+
raise ValueError(f"split {name!r} is empty")
|
|
25
|
+
if rows[0] < 0 or (rows.diff() <= 0).any():
|
|
26
|
+
raise ValueError(f"split {name!r} rows must be nonnegative and strictly increasing")
|
|
27
|
+
return rows
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, eq=False)
|
|
31
|
+
class SplitPlan:
|
|
32
|
+
"""Named sets of archive rows, in time order, that share no target time.
|
|
33
|
+
|
|
34
|
+
Rows are positions in the archive, not origins. :meth:`validate` checks the plan against
|
|
35
|
+
an archive's timeline; :class:`~tscp.data.ResidualArchive` does so on construction.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
indices: Row indices per split name; names come from :data:`SPLIT_NAMES`.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
indices: Mapping[str, Tensor]
|
|
42
|
+
|
|
43
|
+
def __post_init__(self):
|
|
44
|
+
unknown = sorted(set(self.indices) - set(SPLIT_NAMES))
|
|
45
|
+
if unknown:
|
|
46
|
+
raise ValueError(f"unknown split names {unknown}; use names from {SPLIT_NAMES}")
|
|
47
|
+
ordered = {
|
|
48
|
+
name: _rows(self.indices[name], name) for name in SPLIT_NAMES if name in self.indices
|
|
49
|
+
}
|
|
50
|
+
object.__setattr__(self, "indices", ordered)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def names(self) -> tuple[str, ...]:
|
|
54
|
+
"""Split names present, in time order."""
|
|
55
|
+
return tuple(self.indices)
|
|
56
|
+
|
|
57
|
+
def sizes(self) -> dict[str, int]:
|
|
58
|
+
"""Number of rows per split."""
|
|
59
|
+
return {name: rows.numel() for name, rows in self.indices.items()}
|
|
60
|
+
|
|
61
|
+
def __getitem__(self, name: str) -> Tensor:
|
|
62
|
+
return self.indices[name]
|
|
63
|
+
|
|
64
|
+
def __contains__(self, name: object) -> bool:
|
|
65
|
+
return name in self.indices
|
|
66
|
+
|
|
67
|
+
def __iter__(self) -> Iterator[str]:
|
|
68
|
+
return iter(self.indices)
|
|
69
|
+
|
|
70
|
+
def to(self, device: torch.device | str) -> "SplitPlan":
|
|
71
|
+
return SplitPlan({name: rows.to(device) for name, rows in self.indices.items()})
|
|
72
|
+
|
|
73
|
+
def validate(self, origins: Tensor, steps: Tensor) -> None:
|
|
74
|
+
"""Check that the splits fit the timeline, keep time order and share no target time.
|
|
75
|
+
|
|
76
|
+
The target of (origin ``s``, step ``h``) is at time ``s + h``. Consecutive splits
|
|
77
|
+
must not share a target time, so the first target of a split must come after the
|
|
78
|
+
last target of the split before it. For steps ``1..H`` this means the first origin
|
|
79
|
+
of a split is at least ``H`` base steps after the last origin of the previous one.
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
ValueError: If a split runs past the archive, overlaps or precedes the split
|
|
83
|
+
before it, or shares a target time with it.
|
|
84
|
+
"""
|
|
85
|
+
num_rows = origins.numel()
|
|
86
|
+
previous: tuple[str, Tensor] | None = None
|
|
87
|
+
for name, rows in self.indices.items():
|
|
88
|
+
if rows[-1] >= num_rows:
|
|
89
|
+
raise ValueError(f"split {name!r} refers to row {int(rows[-1])}, past the archive")
|
|
90
|
+
if previous is not None:
|
|
91
|
+
previous_name, previous_rows = previous
|
|
92
|
+
last_origin = int(origins[previous_rows[-1]])
|
|
93
|
+
first_origin = int(origins[rows[0]])
|
|
94
|
+
if first_origin <= last_origin:
|
|
95
|
+
message = f"split {name!r} must start after split {previous_name!r} ends"
|
|
96
|
+
raise ValueError(message)
|
|
97
|
+
last_target = last_origin + int(steps[-1])
|
|
98
|
+
first_target = first_origin + int(steps[0])
|
|
99
|
+
if first_target <= last_target:
|
|
100
|
+
needed = last_target - int(steps[0]) + 1
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"splits {previous_name!r} and {name!r} share target times: "
|
|
103
|
+
f"{previous_name!r} has targets up to time {last_target}, so "
|
|
104
|
+
f"{name!r} must start at origin {needed} or later, not {first_origin}"
|
|
105
|
+
)
|
|
106
|
+
previous = (name, rows)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def chronological_split(
|
|
110
|
+
origins: Tensor, steps: Tensor, sizes: Mapping[str, int | float | None]
|
|
111
|
+
) -> SplitPlan:
|
|
112
|
+
"""Cut consecutive splits in time order, skipping the origins needed between them.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
origins: ``[T]`` forecast origins, strictly increasing.
|
|
116
|
+
steps: ``[H]`` horizon steps, strictly increasing.
|
|
117
|
+
sizes: Rows per split, keyed by names from :data:`SPLIT_NAMES` (any order; splits
|
|
118
|
+
are laid out in time order). An ``int`` is a row count, a ``float`` in (0, 1) a
|
|
119
|
+
fraction of all ``T`` rows (rounded down), and ``None``, allowed only for the
|
|
120
|
+
last split, takes every remaining row.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
A validated plan. Between two splits it skips the fewest rows that keep their
|
|
124
|
+
target times apart.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
ValueError: If a size is invalid or the archive has too few origins.
|
|
128
|
+
"""
|
|
129
|
+
origins = torch.as_tensor(origins, dtype=torch.int64)
|
|
130
|
+
steps = torch.as_tensor(steps, dtype=torch.int64)
|
|
131
|
+
unknown = sorted(set(sizes) - set(SPLIT_NAMES))
|
|
132
|
+
if unknown:
|
|
133
|
+
raise ValueError(f"unknown split names {unknown}; use names from {SPLIT_NAMES}")
|
|
134
|
+
names = [name for name in SPLIT_NAMES if name in sizes]
|
|
135
|
+
if not names:
|
|
136
|
+
raise ValueError("sizes names no split")
|
|
137
|
+
num_rows = origins.numel()
|
|
138
|
+
indices: dict[str, Tensor] = {}
|
|
139
|
+
start = 0
|
|
140
|
+
for position, name in enumerate(names):
|
|
141
|
+
if indices:
|
|
142
|
+
last_target = origins[next(reversed(indices.values()))[-1]] + steps[-1]
|
|
143
|
+
# First row whose earliest target comes after the previous split's last target.
|
|
144
|
+
start = int(torch.searchsorted(origins, last_target - steps[0], right=True))
|
|
145
|
+
size = sizes[name]
|
|
146
|
+
if size is None:
|
|
147
|
+
if position != len(names) - 1:
|
|
148
|
+
raise ValueError(f"only the last split may take the remaining rows, not {name!r}")
|
|
149
|
+
count = num_rows - start
|
|
150
|
+
elif isinstance(size, bool) or not isinstance(size, int | float):
|
|
151
|
+
raise ValueError(f"size of split {name!r} must be an int, a float or None")
|
|
152
|
+
elif isinstance(size, float):
|
|
153
|
+
if not 0 < size < 1:
|
|
154
|
+
raise ValueError(f"fraction for split {name!r} must lie in (0, 1)")
|
|
155
|
+
count = math.floor(size * num_rows)
|
|
156
|
+
else:
|
|
157
|
+
count = size
|
|
158
|
+
if count < 1 or start + count > num_rows:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"not enough origins for split {name!r}: it needs {count} rows from row {start}, "
|
|
161
|
+
f"and the archive has {num_rows} rows"
|
|
162
|
+
)
|
|
163
|
+
indices[name] = torch.arange(start, start + count)
|
|
164
|
+
plan = SplitPlan(indices)
|
|
165
|
+
plan.validate(origins, steps)
|
|
166
|
+
return plan
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
__all__ = ["SPLIT_NAMES", "SplitPlan", "chronological_split"]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Synthetic residual archives.
|
|
2
|
+
|
|
3
|
+
Both generators simulate a Gaussian random walk forecast by its last value, so the
|
|
4
|
+
residual of (origin ``s``, step ``h``) is the sum of the next ``h`` innovations. Residuals
|
|
5
|
+
over overlapping targets are correlated, as real multi-step errors are.
|
|
6
|
+
|
|
7
|
+
Gaussian data are controls, for checking validity, not benchmarks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
|
|
13
|
+
import torch
|
|
14
|
+
from torch import Tensor
|
|
15
|
+
|
|
16
|
+
from tscp.data import ResidualArchive, chronological_split
|
|
17
|
+
|
|
18
|
+
DEFAULT_SIZES = {"fit": 0.2, "calibration": 0.3, "test": None}
|
|
19
|
+
"""Default split sizes: 20% fit, 30% calibration, the rest test."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _random_walk_archive(
|
|
23
|
+
scales: Tensor,
|
|
24
|
+
*,
|
|
25
|
+
horizon: int,
|
|
26
|
+
num_series: int,
|
|
27
|
+
sizes: Mapping[str, int | float | None] | None,
|
|
28
|
+
seed: int,
|
|
29
|
+
provenance: dict,
|
|
30
|
+
) -> ResidualArchive:
|
|
31
|
+
if horizon < 1:
|
|
32
|
+
raise ValueError("horizon must be at least 1")
|
|
33
|
+
num_origins = scales.numel() - horizon
|
|
34
|
+
generator = torch.Generator().manual_seed(seed)
|
|
35
|
+
innovations = torch.randn(scales.numel(), num_series, generator=generator)
|
|
36
|
+
path = torch.cumsum(innovations * scales[:, None], dim=0)
|
|
37
|
+
steps = torch.arange(1, horizon + 1)
|
|
38
|
+
targets = torch.stack([path[step : num_origins + step] for step in steps.tolist()], dim=1)
|
|
39
|
+
forecasts = path[:num_origins, None, :].expand(-1, horizon, -1).clone()
|
|
40
|
+
origins = torch.arange(num_origins)
|
|
41
|
+
return ResidualArchive(
|
|
42
|
+
residuals=targets - forecasts,
|
|
43
|
+
origins=origins,
|
|
44
|
+
steps=steps,
|
|
45
|
+
splits=chronological_split(origins, steps, sizes or DEFAULT_SIZES),
|
|
46
|
+
forecasts=forecasts,
|
|
47
|
+
provenance={**provenance, "horizon": horizon, "seed": seed},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def gaussian_control(
|
|
52
|
+
*,
|
|
53
|
+
horizon: int,
|
|
54
|
+
num_origins: int = 2000,
|
|
55
|
+
num_series: int = 1,
|
|
56
|
+
scale: float = 1.0,
|
|
57
|
+
sizes: Mapping[str, int | float | None] | None = None,
|
|
58
|
+
seed: int = 0,
|
|
59
|
+
) -> ResidualArchive:
|
|
60
|
+
"""A random walk with i.i.d. Gaussian innovations: an exchangeable control.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
horizon: Number of steps ``H``; take it from the experiment config.
|
|
64
|
+
num_origins: Number of forecast origins ``T``.
|
|
65
|
+
num_series: Number of independent series ``N``.
|
|
66
|
+
scale: Innovation standard deviation.
|
|
67
|
+
sizes: Split sizes for :func:`~tscp.data.chronological_split`;
|
|
68
|
+
:data:`DEFAULT_SIZES` if None.
|
|
69
|
+
seed: Seed of the generator.
|
|
70
|
+
"""
|
|
71
|
+
scales = torch.full((num_origins + horizon,), float(scale))
|
|
72
|
+
provenance = {"dataset": "gaussian_control", "role": "control", "scale": scale}
|
|
73
|
+
return _random_walk_archive(
|
|
74
|
+
scales,
|
|
75
|
+
horizon=horizon,
|
|
76
|
+
num_series=num_series,
|
|
77
|
+
sizes=sizes,
|
|
78
|
+
seed=seed,
|
|
79
|
+
provenance={**provenance, "forecaster": "last value"},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def variance_shift(
|
|
84
|
+
*,
|
|
85
|
+
horizon: int,
|
|
86
|
+
num_origins: int = 2000,
|
|
87
|
+
num_series: int = 1,
|
|
88
|
+
shift_at: float = 0.75,
|
|
89
|
+
scale_before: float = 1.0,
|
|
90
|
+
scale_after: float = 3.0,
|
|
91
|
+
sizes: Mapping[str, int | float | None] | None = None,
|
|
92
|
+
seed: int = 0,
|
|
93
|
+
) -> ResidualArchive:
|
|
94
|
+
"""A Gaussian random walk whose innovation scale jumps once: not exchangeable.
|
|
95
|
+
|
|
96
|
+
With the default splits the jump falls inside the test period, so methods that
|
|
97
|
+
calibrate once under-cover after it, and adaptive ones should recover.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
horizon: Number of steps ``H``; take it from the experiment config.
|
|
101
|
+
num_origins: Number of forecast origins ``T``.
|
|
102
|
+
num_series: Number of independent series ``N``.
|
|
103
|
+
shift_at: Fraction of the timeline after which the scale changes.
|
|
104
|
+
scale_before: Innovation standard deviation before the jump.
|
|
105
|
+
scale_after: Innovation standard deviation after the jump.
|
|
106
|
+
sizes: Split sizes; :data:`DEFAULT_SIZES` if None.
|
|
107
|
+
seed: Seed of the generator.
|
|
108
|
+
"""
|
|
109
|
+
if not 0 < shift_at < 1:
|
|
110
|
+
raise ValueError("shift_at must lie in (0, 1)")
|
|
111
|
+
scales = torch.full((num_origins + horizon,), float(scale_before))
|
|
112
|
+
scales[int(shift_at * num_origins) :] = float(scale_after)
|
|
113
|
+
provenance = {
|
|
114
|
+
"dataset": "variance_shift",
|
|
115
|
+
"role": "shift",
|
|
116
|
+
"shift_at": shift_at,
|
|
117
|
+
"scale_before": scale_before,
|
|
118
|
+
"scale_after": scale_after,
|
|
119
|
+
"forecaster": "last value",
|
|
120
|
+
}
|
|
121
|
+
return _random_walk_archive(
|
|
122
|
+
scales,
|
|
123
|
+
horizon=horizon,
|
|
124
|
+
num_series=num_series,
|
|
125
|
+
sizes=sizes,
|
|
126
|
+
seed=seed,
|
|
127
|
+
provenance=provenance,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def context_scale(
|
|
132
|
+
*,
|
|
133
|
+
horizon: int,
|
|
134
|
+
num_origins: int = 2000,
|
|
135
|
+
num_series: int = 1,
|
|
136
|
+
strength: float = 1.0,
|
|
137
|
+
sizes: Mapping[str, int | float | None] | None = None,
|
|
138
|
+
seed: int = 0,
|
|
139
|
+
) -> ResidualArchive:
|
|
140
|
+
"""A Gaussian random walk whose next innovation scale is set by a known covariate.
|
|
141
|
+
|
|
142
|
+
Each origin ``t`` has a covariate ``x_t``, a standard normal draw stored as the
|
|
143
|
+
context column ``x``, and the innovation that follows it has standard deviation
|
|
144
|
+
``exp(strength * x_t)``. The one-step residual of origin ``t`` therefore has a known
|
|
145
|
+
conditional scale, and a score that learns the scale from the context gives narrower
|
|
146
|
+
intervals than one that ignores it. Gaussian, so a control, not a benchmark.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
horizon: Number of steps ``H``; take it from the experiment config.
|
|
150
|
+
num_origins: Number of forecast origins ``T``.
|
|
151
|
+
num_series: Number of independent series ``N``.
|
|
152
|
+
strength: How strongly the covariate scales the innovations.
|
|
153
|
+
sizes: Split sizes; :data:`DEFAULT_SIZES` if None.
|
|
154
|
+
seed: Seed of the generator.
|
|
155
|
+
"""
|
|
156
|
+
generator = torch.Generator().manual_seed(seed + 1)
|
|
157
|
+
covariate = torch.randn(num_origins + horizon, generator=generator)
|
|
158
|
+
scales = torch.ones(num_origins + horizon)
|
|
159
|
+
scales[1:] = torch.exp(strength * covariate[:-1]) # x_t scales the innovation at t + 1
|
|
160
|
+
provenance = {
|
|
161
|
+
"dataset": "context_scale",
|
|
162
|
+
"role": "control",
|
|
163
|
+
"strength": strength,
|
|
164
|
+
"forecaster": "last value",
|
|
165
|
+
}
|
|
166
|
+
archive = _random_walk_archive(
|
|
167
|
+
scales,
|
|
168
|
+
horizon=horizon,
|
|
169
|
+
num_series=num_series,
|
|
170
|
+
sizes=sizes,
|
|
171
|
+
seed=seed,
|
|
172
|
+
provenance=provenance,
|
|
173
|
+
)
|
|
174
|
+
return replace(archive, context=covariate[:num_origins, None], context_names=("x",))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = ["DEFAULT_SIZES", "context_scale", "gaussian_control", "variance_shift"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Feature maps from what is known at each origin to vectors."""
|
|
2
|
+
|
|
3
|
+
from .base import ENCODINGS, FeatureMap, encode_cycles, timeline_of
|
|
4
|
+
from .calendar import FIELDS, Calendar, calendar_fields
|
|
5
|
+
from .combine import Concat
|
|
6
|
+
from .context import ContextColumns
|
|
7
|
+
from .history import ResidualHistory
|
|
8
|
+
from .phase import Phase
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ENCODINGS",
|
|
12
|
+
"FIELDS",
|
|
13
|
+
"Calendar",
|
|
14
|
+
"Concat",
|
|
15
|
+
"ContextColumns",
|
|
16
|
+
"FeatureMap",
|
|
17
|
+
"Phase",
|
|
18
|
+
"ResidualHistory",
|
|
19
|
+
"calendar_fields",
|
|
20
|
+
"encode_cycles",
|
|
21
|
+
"timeline_of",
|
|
22
|
+
]
|
tscp/features/base.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""The feature-map interface."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
from torch import Tensor
|
|
7
|
+
|
|
8
|
+
from tscp.data import ResidualArchive
|
|
9
|
+
from tscp.utils import Component
|
|
10
|
+
|
|
11
|
+
ENCODINGS = ("integer", "cyclic")
|
|
12
|
+
"""How cyclic quantities such as the hour of day become features."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def timeline_of(rows: ResidualArchive) -> ResidualArchive:
|
|
16
|
+
"""The archive ``rows`` were taken from; a whole archive is its own timeline."""
|
|
17
|
+
return rows.timeline if rows.timeline is not None else rows
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def encode_cycles(values: Tensor, periods: Tensor, encoding: str) -> Tensor:
|
|
21
|
+
"""Encode ``[T, K]`` integer positions in cycles of the given ``[K]`` periods.
|
|
22
|
+
|
|
23
|
+
``"integer"`` keeps the positions (``K`` features). ``"cyclic"`` gives the sine and
|
|
24
|
+
cosine of each position's angle in its cycle (``2 K`` features: all sines, then all
|
|
25
|
+
cosines), so that the end of a cycle lies next to its start.
|
|
26
|
+
"""
|
|
27
|
+
if encoding not in ENCODINGS:
|
|
28
|
+
raise ValueError(f"encoding must be one of {ENCODINGS}, not {encoding!r}")
|
|
29
|
+
dtype = torch.get_default_dtype()
|
|
30
|
+
if encoding == "integer":
|
|
31
|
+
return values.to(dtype)
|
|
32
|
+
angles = 2 * math.pi * values.to(torch.float64) / periods.to(values.device, torch.float64)
|
|
33
|
+
return torch.cat([angles.sin(), angles.cos()], dim=-1).to(dtype)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FeatureMap(Component):
|
|
37
|
+
"""Turns what is known at each origin into features.
|
|
38
|
+
|
|
39
|
+
:meth:`forward` returns ``[T, *element, F]`` for ``T`` rows: one vector of ``F``
|
|
40
|
+
features per row (element ``()``), per series (``(N,)``) or per step and series
|
|
41
|
+
(``(H, N)``). Weights compare these vectors, quantile models regress on them, and
|
|
42
|
+
Mondrian weights group by them.
|
|
43
|
+
|
|
44
|
+
The features of a row may use only what is known at its origin: its own forecasts,
|
|
45
|
+
outputs, context and timestamp, and the residuals of its timeline whose target time
|
|
46
|
+
has passed, which :meth:`~tscp.data.ResidualArchive.known_counts` finds. A row
|
|
47
|
+
subset's ``timeline`` holds that history; a whole archive is its own timeline
|
|
48
|
+
(:func:`timeline_of`).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def forward(self, rows: ResidualArchive) -> Tensor:
|
|
52
|
+
"""Return ``[T, *element, F]`` features of the ``T`` rows."""
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
|
|
55
|
+
def valid(self, rows: ResidualArchive) -> Tensor | None:
|
|
56
|
+
"""Return ``[T, *element]`` bool, False where a row's features are incomplete.
|
|
57
|
+
|
|
58
|
+
Incomplete features, such as a residual history before enough of it is known, are
|
|
59
|
+
0. None means every row's features are complete.
|
|
60
|
+
"""
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = ["ENCODINGS", "FeatureMap", "encode_cycles", "timeline_of"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Calendar fields of the origins' timestamps."""
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
from torch import Tensor
|
|
5
|
+
|
|
6
|
+
from tscp.data import ResidualArchive
|
|
7
|
+
from tscp.features.base import ENCODINGS, FeatureMap, encode_cycles
|
|
8
|
+
from tscp.registry import register
|
|
9
|
+
|
|
10
|
+
FIELDS = {"minute": 60, "hour": 24, "dayofweek": 7, "day": 31, "month": 12, "dayofyear": 366}
|
|
11
|
+
"""Calendar fields and the length of their cycle, for the cyclic encoding."""
|
|
12
|
+
|
|
13
|
+
_FROM_ONE = ("day", "month", "dayofyear") # fields that count from 1
|
|
14
|
+
_NS_PER_SECOND = 1_000_000_000
|
|
15
|
+
_SECONDS_PER_DAY = 86_400
|
|
16
|
+
_MONTH_STARTS = (0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334) # in a common year
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _floordiv(a: Tensor, b: int) -> Tensor:
|
|
20
|
+
return torch.div(a, b, rounding_mode="floor")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def calendar_fields(timestamps: Tensor) -> dict[str, Tensor]:
|
|
24
|
+
"""Calendar fields of int64 nanosecond timestamps, in UTC, by integer arithmetic.
|
|
25
|
+
|
|
26
|
+
Dates follow the proleptic Gregorian calendar, by H. Hinnant's days-to-civil
|
|
27
|
+
algorithm, so they agree with Python's :mod:`datetime` before and after 1970.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
``minute`` (0-59), ``hour`` (0-23), ``dayofweek`` (0 is Monday), ``day`` (1-31),
|
|
31
|
+
``month`` (1-12) and ``dayofyear`` (1-366), each an int64 tensor shaped like
|
|
32
|
+
``timestamps``.
|
|
33
|
+
"""
|
|
34
|
+
seconds = _floordiv(timestamps, _NS_PER_SECOND)
|
|
35
|
+
days = _floordiv(seconds, _SECONDS_PER_DAY)
|
|
36
|
+
second_of_day = seconds - days * _SECONDS_PER_DAY
|
|
37
|
+
shifted = days + 719_468 # days since 0000-03-01
|
|
38
|
+
era = _floordiv(shifted, 146_097)
|
|
39
|
+
day_of_era = shifted - era * 146_097
|
|
40
|
+
year_of_era = _floordiv(
|
|
41
|
+
day_of_era
|
|
42
|
+
- _floordiv(day_of_era, 1_460)
|
|
43
|
+
+ _floordiv(day_of_era, 36_524)
|
|
44
|
+
- _floordiv(day_of_era, 146_096),
|
|
45
|
+
365,
|
|
46
|
+
)
|
|
47
|
+
day_from_march = day_of_era - (
|
|
48
|
+
365 * year_of_era + _floordiv(year_of_era, 4) - _floordiv(year_of_era, 100)
|
|
49
|
+
)
|
|
50
|
+
month_from_march = _floordiv(5 * day_from_march + 2, 153)
|
|
51
|
+
day = day_from_march - _floordiv(153 * month_from_march + 2, 5) + 1
|
|
52
|
+
month = torch.where(month_from_march < 10, month_from_march + 3, month_from_march - 9)
|
|
53
|
+
year = year_of_era + era * 400 + (month <= 2).to(torch.int64)
|
|
54
|
+
leap = ((year % 4 == 0) & (year % 100 != 0)) | (year % 400 == 0)
|
|
55
|
+
starts = torch.tensor(_MONTH_STARTS, device=timestamps.device)
|
|
56
|
+
dayofyear = starts[month - 1] + day + ((month > 2) & leap).to(torch.int64)
|
|
57
|
+
return {
|
|
58
|
+
"minute": _floordiv(second_of_day, 60) % 60,
|
|
59
|
+
"hour": _floordiv(second_of_day, 3_600),
|
|
60
|
+
"dayofweek": (days + 3) % 7, # 1970-01-01 was a Thursday
|
|
61
|
+
"day": day,
|
|
62
|
+
"month": month,
|
|
63
|
+
"dayofyear": dayofyear,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@register("features", "calendar")
|
|
68
|
+
class Calendar(FeatureMap):
|
|
69
|
+
"""Calendar fields of each origin's timestamp: ``[T, F]``.
|
|
70
|
+
|
|
71
|
+
Fields come from the archive's ``timestamps``, in UTC; pass local times as if they
|
|
72
|
+
were UTC to get local fields. ``offset`` shifts the times first, for example by the
|
|
73
|
+
forecast step to read the fields of the targets. The ``"integer"`` encoding gives one
|
|
74
|
+
feature per field (see :func:`calendar_fields` for the ranges); ``"cyclic"`` gives the
|
|
75
|
+
sine and cosine of each field in its cycle (see :data:`FIELDS`).
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
fields: Names from :data:`FIELDS`.
|
|
79
|
+
encoding: ``"integer"`` or ``"cyclic"``.
|
|
80
|
+
offset: Nanoseconds added to each timestamp.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, fields=("hour",), encoding: str = "integer", offset: int = 0):
|
|
84
|
+
super().__init__()
|
|
85
|
+
fields = list(fields)
|
|
86
|
+
unknown = sorted(set(fields) - set(FIELDS))
|
|
87
|
+
if not fields or unknown:
|
|
88
|
+
raise ValueError(f"fields must name some of {sorted(FIELDS)}, not {unknown}")
|
|
89
|
+
if encoding not in ENCODINGS:
|
|
90
|
+
raise ValueError(f"encoding must be one of {ENCODINGS}, not {encoding!r}")
|
|
91
|
+
self.fields = fields
|
|
92
|
+
self.encoding = encoding
|
|
93
|
+
self.offset = int(offset)
|
|
94
|
+
|
|
95
|
+
def forward(self, rows: ResidualArchive) -> Tensor:
|
|
96
|
+
if rows.timestamps is None:
|
|
97
|
+
raise ValueError("Calendar needs an archive with timestamps")
|
|
98
|
+
values = calendar_fields(rows.timestamps + self.offset)
|
|
99
|
+
shift = int(self.encoding == "cyclic") # cycles start at 0
|
|
100
|
+
columns = [values[f] - (shift if f in _FROM_ONE else 0) for f in self.fields]
|
|
101
|
+
periods = torch.tensor([FIELDS[field] for field in self.fields])
|
|
102
|
+
return encode_cycles(torch.stack(columns, dim=-1), periods, self.encoding)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = ["FIELDS", "Calendar", "calendar_fields"]
|