tscp 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. tscp/__init__.py +45 -0
  2. tscp/data/__init__.py +19 -0
  3. tscp/data/archive.py +488 -0
  4. tscp/data/availability.py +55 -0
  5. tscp/data/io.py +109 -0
  6. tscp/data/splits.py +169 -0
  7. tscp/datasets/__init__.py +6 -0
  8. tscp/datasets/synthetic.py +177 -0
  9. tscp/features/__init__.py +22 -0
  10. tscp/features/base.py +64 -0
  11. tscp/features/calendar.py +105 -0
  12. tscp/features/combine.py +69 -0
  13. tscp/features/context.py +42 -0
  14. tscp/features/history.py +58 -0
  15. tscp/features/phase.py +41 -0
  16. tscp/imports.py +32 -0
  17. tscp/methods/__init__.py +48 -0
  18. tscp/methods/aci.py +44 -0
  19. tscp/methods/base.py +228 -0
  20. tscp/methods/compose.py +90 -0
  21. tscp/methods/engine.py +128 -0
  22. tscp/methods/io.py +106 -0
  23. tscp/methods/nexcp.py +42 -0
  24. tscp/methods/split.py +30 -0
  25. tscp/metrics/__init__.py +30 -0
  26. tscp/metrics/conditional.py +66 -0
  27. tscp/metrics/interval.py +75 -0
  28. tscp/metrics/report.py +135 -0
  29. tscp/nn/__init__.py +13 -0
  30. tscp/nn/data.py +85 -0
  31. tscp/nn/losses.py +63 -0
  32. tscp/nn/training.py +125 -0
  33. tscp/online/__init__.py +6 -0
  34. tscp/online/aci.py +53 -0
  35. tscp/online/base.py +67 -0
  36. tscp/py.typed +0 -0
  37. tscp/quantile/__init__.py +16 -0
  38. tscp/quantile/base.py +73 -0
  39. tscp/quantile/distribution.py +170 -0
  40. tscp/quantile/weighted.py +103 -0
  41. tscp/regions/__init__.py +15 -0
  42. tscp/regions/base.py +139 -0
  43. tscp/regions/box.py +82 -0
  44. tscp/regions/interval.py +62 -0
  45. tscp/registry.py +179 -0
  46. tscp/scores/__init__.py +9 -0
  47. tscp/scores/absolute.py +22 -0
  48. tscp/scores/base.py +87 -0
  49. tscp/scores/cqr.py +71 -0
  50. tscp/scores/normalized.py +74 -0
  51. tscp/scores/signed.py +46 -0
  52. tscp/sources/__init__.py +7 -0
  53. tscp/sources/base.py +32 -0
  54. tscp/sources/expanding.py +25 -0
  55. tscp/sources/holdout.py +18 -0
  56. tscp/testing/__init__.py +40 -0
  57. tscp/testing/archives.py +80 -0
  58. tscp/testing/checks.py +832 -0
  59. tscp/testing/components.py +28 -0
  60. tscp/typing.py +24 -0
  61. tscp/utils/__init__.py +14 -0
  62. tscp/utils/component.py +76 -0
  63. tscp/utils/config.py +135 -0
  64. tscp/utils/hashing.py +35 -0
  65. tscp/weights/__init__.py +7 -0
  66. tscp/weights/base.py +22 -0
  67. tscp/weights/exponential.py +37 -0
  68. tscp/weights/uniform.py +23 -0
  69. tscp-0.1.0.dev0.dist-info/METADATA +130 -0
  70. tscp-0.1.0.dev0.dist-info/RECORD +72 -0
  71. tscp-0.1.0.dev0.dist-info/WHEEL +4 -0
  72. tscp-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
tscp/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,6 @@
1
+ """Datasets as residual archives."""
2
+
3
+ from . import synthetic
4
+ from .synthetic import context_scale, gaussian_control, variance_shift
5
+
6
+ __all__ = ["context_scale", "gaussian_control", "synthetic", "variance_shift"]
@@ -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"]