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/scores/base.py ADDED
@@ -0,0 +1,87 @@
1
+ """The score interface (axis 2)."""
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+ from tscp.data import ResidualArchive
7
+ from tscp.regions import RESIDUAL_DIMS, Region
8
+ from tscp.utils import Component
9
+
10
+
11
+ class Score(Component):
12
+ """How an error becomes a number, and the region a threshold on it defines.
13
+
14
+ A score maps the rows of an archive to ``[T, *kept]``. The kept dimensions are the
15
+ dimensions of ``[H, N]`` it does not reduce, followed by one dimension for its
16
+ :attr:`channels` if it has any. A per-step score keeps step and series and yields an
17
+ interval per element. A score over the whole horizon reduces ``"step"`` and yields one
18
+ joint region per series; a score across series reduces ``"series"``. Weights, quantile
19
+ models and online updates treat each kept element as its own problem.
20
+
21
+ A score sees the whole rows: residuals, forecasts, forecaster outputs and context,
22
+ and, through the rows' timeline, the history before them. The region of a threshold
23
+ ``q`` is ``{r : score(r) <= q}``, so a target is missed exactly when its score exceeds
24
+ the threshold.
25
+
26
+ Channels split one element into several scores that are thresholded separately, such
27
+ as the lower and upper side of an interval. :meth:`levels` then shares the miscoverage
28
+ ``alpha`` among them so that the combined region still misses with probability at
29
+ most ``alpha``.
30
+ """
31
+
32
+ #: Dimensions of ``[H, N]`` this score reduces into one number.
33
+ reduce_dims: tuple[str, ...] = ()
34
+ #: Names of the channels, each thresholded separately; empty for one score per element.
35
+ channels: tuple[str, ...] = ()
36
+
37
+ def forward(self, rows: ResidualArchive) -> Tensor:
38
+ """Return scores of shape ``[T, *kept]`` for the ``T`` rows of ``rows``."""
39
+ raise NotImplementedError
40
+
41
+ def region(self, threshold: Tensor, rows: ResidualArchive) -> Region:
42
+ """Return the region ``{r : score(r) <= threshold}`` for each query row.
43
+
44
+ Args:
45
+ threshold: ``[Q, *kept]`` thresholds, one per query row and kept element.
46
+ rows: The ``Q`` query rows. Their forecasts, outputs and context are known at
47
+ their origins, so a region may use them; their residuals are not, and a
48
+ region must never read them.
49
+ """
50
+ raise NotImplementedError
51
+
52
+ def kept_shape(self, horizon: int, num_series: int) -> tuple[int, ...]:
53
+ """Return the sizes of the kept dimensions for an archive with ``H`` and ``N``."""
54
+ unknown = set(self.reduce_dims) - set(RESIDUAL_DIMS)
55
+ if unknown:
56
+ raise ValueError(f"reduce_dims may only name {RESIDUAL_DIMS}, not {sorted(unknown)}")
57
+ sizes = {"step": horizon, "series": num_series}
58
+ kept = tuple(size for name, size in sizes.items() if name not in self.reduce_dims)
59
+ return (*kept, len(self.channels)) if self.channels else kept
60
+
61
+ def levels(self, alpha: float, kept: tuple[int, ...]) -> Tensor:
62
+ """Return the miscoverage level of each kept element, ``[*kept]`` in float64.
63
+
64
+ By default every element gets ``alpha``. A score with channels overrides this to
65
+ share ``alpha`` among them.
66
+ """
67
+ return torch.full(kept, float(alpha), dtype=torch.float64)
68
+
69
+ def valid(self, rows: ResidualArchive) -> Tensor | None:
70
+ """Return which scores are defined, ``[T, *kept]`` bool, or None if all are.
71
+
72
+ A score is defined where every residual it combines is observed: its own element
73
+ for a per-step score, and every step or series it reduces otherwise. Channels
74
+ share the mask of their element.
75
+ """
76
+ observed = rows.observed
77
+ if observed is None or bool(observed.all()):
78
+ return None
79
+ reduced = tuple(1 + RESIDUAL_DIMS.index(name) for name in self.reduce_dims)
80
+ if reduced:
81
+ observed = observed.all(dim=reduced)
82
+ if self.channels:
83
+ observed = observed.unsqueeze(-1).expand(*observed.shape, len(self.channels))
84
+ return observed
85
+
86
+
87
+ __all__ = ["Score"]
tscp/scores/cqr.py ADDED
@@ -0,0 +1,71 @@
1
+ """Conformalized quantile regression."""
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+ from tscp.data import ResidualArchive
7
+ from tscp.regions import Interval
8
+ from tscp.registry import register
9
+ from tscp.scores.base import Score
10
+
11
+
12
+ @register("score", "cqr")
13
+ class CQRScore(Score):
14
+ """Conformalized quantile regression (Romano, Patterson & Candès, NeurIPS 2019).
15
+
16
+ The forecaster supplies a lower and an upper quantile of each target, stored as
17
+ outputs in target units, as the forecaster produced them. In residual coordinates
18
+ they are ``low = q_lower - f`` and ``high = q_upper - f`` for the point forecast
19
+ ``f``, so the archive needs its ``forecasts``. The score ``max(low - r, r - high)`` is
20
+ how far the target falls outside the forecaster's band (negative inside it), and a
21
+ threshold ``q`` widens the band by ``q`` on both sides.
22
+
23
+ With ``asymmetric=True`` the two sides are channels at ``alpha / 2`` each, scored
24
+ ``low - r`` and ``r - high``, so each side gets its own correction (Romano et al.,
25
+ Theorem 2).
26
+
27
+ Args:
28
+ lower: Name of the output with the lower quantiles, ``[T, H, N]``.
29
+ upper: Name of the output with the upper quantiles, ``[T, H, N]``.
30
+ asymmetric: Correct each side separately.
31
+ """
32
+
33
+ def __init__(self, lower: str = "lower", upper: str = "upper", asymmetric: bool = False):
34
+ super().__init__()
35
+ self.lower = lower
36
+ self.upper = upper
37
+ self.asymmetric = bool(asymmetric)
38
+ self.channels = ("lower", "upper") if self.asymmetric else ()
39
+
40
+ def _band(self, rows: ResidualArchive) -> tuple[Tensor, Tensor]:
41
+ """The forecaster's band in residual coordinates: ``(low, high)``, each ``[T, H, N]``."""
42
+ if rows.forecasts is None:
43
+ raise ValueError("CQRScore needs the archive's forecasts")
44
+ missing = [name for name in (self.lower, self.upper) if name not in rows.outputs]
45
+ if missing:
46
+ raise KeyError(f"the archive has no outputs {missing}")
47
+ bounds = (rows.outputs[self.lower], rows.outputs[self.upper])
48
+ if any(bound.shape != rows.residuals.shape for bound in bounds):
49
+ raise ValueError("quantile outputs must have shape [T, H, N]")
50
+ return bounds[0] - rows.forecasts, bounds[1] - rows.forecasts
51
+
52
+ def forward(self, rows: ResidualArchive) -> Tensor:
53
+ low, high = self._band(rows)
54
+ below, above = low - rows.residuals, rows.residuals - high
55
+ if self.asymmetric:
56
+ return torch.stack([below, above], dim=-1)
57
+ return torch.maximum(below, above)
58
+
59
+ def region(self, threshold: Tensor, rows: ResidualArchive) -> Interval:
60
+ low, high = self._band(rows)
61
+ if self.asymmetric:
62
+ return Interval(low - threshold[..., 0], high + threshold[..., 1])
63
+ return Interval(low - threshold, high + threshold)
64
+
65
+ def levels(self, alpha: float, kept: tuple[int, ...]) -> Tensor:
66
+ if self.asymmetric:
67
+ return torch.full(kept, float(alpha) / 2, dtype=torch.float64)
68
+ return super().levels(alpha, kept)
69
+
70
+
71
+ __all__ = ["CQRScore"]
@@ -0,0 +1,74 @@
1
+ """Residuals divided by their expected size."""
2
+
3
+ from typing import Self
4
+
5
+ import torch
6
+ from torch import Tensor
7
+
8
+ from tscp.data import ResidualArchive
9
+ from tscp.regions import Interval
10
+ from tscp.registry import register
11
+ from tscp.scores.base import Score
12
+
13
+
14
+ @register("score", "normalized")
15
+ class NormalizedScore(Score):
16
+ """``|r| / scale``: each error in units of its expected size.
17
+
18
+ The scale comes either from the forecaster, as an output with a predicted spread per
19
+ target (``scale="name"``), or from the fit split (``scale=None``): the root mean
20
+ square of the observed residuals of each step and series. A threshold ``q`` gives the
21
+ interval ``[-q * scale, q * scale]``, so regions widen where errors are expected to be
22
+ large.
23
+
24
+ Args:
25
+ scale: Name of a forecaster output of shape ``[T, H, N]`` with positive scales,
26
+ or None to fit one scale per step and series on the fit split.
27
+ """
28
+
29
+ def __init__(self, scale: str | None = None):
30
+ super().__init__()
31
+ self.scale = scale
32
+ self.register_buffer("fitted_scale", None)
33
+
34
+ @property
35
+ def is_trainable(self) -> bool:
36
+ return self.scale is None
37
+
38
+ def fit(self, train: ResidualArchive, validation: ResidualArchive | None = None) -> Self:
39
+ residuals = train.residuals.to(torch.float64)
40
+ observed = train.observed
41
+ if observed is None:
42
+ observed = torch.ones_like(train.residuals, dtype=torch.bool)
43
+ count = observed.sum(dim=0)
44
+ if (count == 0).any():
45
+ raise ValueError("every step and series needs an observed residual in the fit split")
46
+ scale = ((residuals.square() * observed).sum(dim=0) / count).sqrt()
47
+ if (scale == 0).any():
48
+ raise ValueError("a fitted scale is zero: the fit split has only zero residuals")
49
+ self.fitted_scale = scale.to(train.residuals.dtype)
50
+ return self
51
+
52
+ def _scale(self, rows: ResidualArchive) -> Tensor:
53
+ if self.scale is None:
54
+ if self.fitted_scale is None:
55
+ raise RuntimeError("fit() must run before a fitted scale is used")
56
+ return self.fitted_scale # [H, N], broadcast over rows
57
+ if self.scale not in rows.outputs:
58
+ raise KeyError(f"the archive has no output {self.scale!r}")
59
+ scale = rows.outputs[self.scale]
60
+ if scale.shape != rows.residuals.shape:
61
+ raise ValueError(f"output {self.scale!r} must have shape [T, H, N]")
62
+ if not (scale > 0).all():
63
+ raise ValueError(f"output {self.scale!r} must be positive")
64
+ return scale
65
+
66
+ def forward(self, rows: ResidualArchive) -> Tensor:
67
+ return rows.residuals.abs() / self._scale(rows)
68
+
69
+ def region(self, threshold: Tensor, rows: ResidualArchive) -> Interval:
70
+ width = threshold * self._scale(rows)
71
+ return Interval(-width, width)
72
+
73
+
74
+ __all__ = ["NormalizedScore"]
tscp/scores/signed.py ADDED
@@ -0,0 +1,46 @@
1
+ """Signed residuals: one score per side of the interval."""
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+ from tscp.data import ResidualArchive
7
+ from tscp.regions import Interval
8
+ from tscp.registry import register
9
+ from tscp.scores.base import Score
10
+
11
+
12
+ @register("score", "signed")
13
+ class SignedScore(Score):
14
+ """Two one-sided scores per element, for intervals that need not be symmetric.
15
+
16
+ Channel ``lower`` scores ``-r`` and channel ``upper`` scores ``r``; thresholds
17
+ ``q_lower`` and ``q_upper`` give the interval ``[-q_lower, q_upper]``. The
18
+ miscoverage is shared between the sides, ``lower_share * alpha`` below and the rest
19
+ above, so by a union bound the interval misses with probability at most ``alpha``.
20
+ Skewed errors get a narrower side where they are rare.
21
+
22
+ Args:
23
+ lower_share: Fraction of ``alpha`` spent on the lower side, in (0, 1).
24
+ """
25
+
26
+ channels = ("lower", "upper")
27
+
28
+ def __init__(self, lower_share: float = 0.5):
29
+ super().__init__()
30
+ if not 0 < lower_share < 1:
31
+ raise ValueError("lower_share must lie in (0, 1)")
32
+ self.lower_share = float(lower_share)
33
+
34
+ def forward(self, rows: ResidualArchive) -> Tensor:
35
+ residuals = rows.residuals
36
+ return torch.stack([-residuals, residuals], dim=-1)
37
+
38
+ def region(self, threshold: Tensor, rows: ResidualArchive) -> Interval:
39
+ return Interval(-threshold[..., 0], threshold[..., 1])
40
+
41
+ def levels(self, alpha: float, kept: tuple[int, ...]) -> Tensor:
42
+ shares = torch.tensor([self.lower_share, 1 - self.lower_share], dtype=torch.float64)
43
+ return (float(alpha) * shares).expand(kept).clone()
44
+
45
+
46
+ __all__ = ["SignedScore"]
@@ -0,0 +1,7 @@
1
+ """Axis 1, residual source: which past errors calibrate."""
2
+
3
+ from .base import Source, calibration_rows
4
+ from .expanding import ExpandingSource
5
+ from .holdout import HoldoutSource
6
+
7
+ __all__ = ["ExpandingSource", "HoldoutSource", "Source", "calibration_rows"]
tscp/sources/base.py ADDED
@@ -0,0 +1,32 @@
1
+ """The residual-source interface (axis 1)."""
2
+
3
+ from torch import Tensor
4
+
5
+ from tscp.data import ResidualArchive
6
+ from tscp.utils import Component
7
+
8
+
9
+ class Source(Component):
10
+ """Which past residuals calibrate.
11
+
12
+ :meth:`rows` names the archive rows scored at calibration time. An expanding source
13
+ also adds each test residual to the pool once its target time has passed (see
14
+ :mod:`tscp.data.availability`); a static source keeps the pool fixed.
15
+ """
16
+
17
+ #: Whether test residuals join the pool once they are known.
18
+ expanding: bool = False
19
+
20
+ def rows(self, archive: ResidualArchive) -> Tensor:
21
+ """Return the archive rows scored at calibration time."""
22
+ raise NotImplementedError
23
+
24
+
25
+ def calibration_rows(archive: ResidualArchive) -> Tensor:
26
+ """Return the rows of the calibration split, with a clear error if there is none."""
27
+ if archive.splits is None or "calibration" not in archive.splits:
28
+ raise ValueError("this source needs an archive with a 'calibration' split")
29
+ return archive.splits["calibration"]
30
+
31
+
32
+ __all__ = ["Source", "calibration_rows"]
@@ -0,0 +1,25 @@
1
+ """The calibration split plus test residuals as they become known."""
2
+
3
+ from torch import Tensor
4
+
5
+ from tscp.data import ResidualArchive
6
+ from tscp.registry import register
7
+ from tscp.sources.base import Source, calibration_rows
8
+
9
+
10
+ @register("source", "expanding")
11
+ class ExpandingSource(Source):
12
+ """The calibration split, plus each test residual once its target time has passed.
13
+
14
+ The pool at test origin ``t`` holds every calibration residual and every test
15
+ residual of (origin ``s``, step ``h``) with ``s + h <= t``. The ``fit`` split never
16
+ joins the pool: components were fitted on it.
17
+ """
18
+
19
+ expanding = True
20
+
21
+ def rows(self, archive: ResidualArchive) -> Tensor:
22
+ return calibration_rows(archive)
23
+
24
+
25
+ __all__ = ["ExpandingSource"]
@@ -0,0 +1,18 @@
1
+ """The calibration split as a fixed pool."""
2
+
3
+ from torch import Tensor
4
+
5
+ from tscp.data import ResidualArchive
6
+ from tscp.registry import register
7
+ from tscp.sources.base import Source, calibration_rows
8
+
9
+
10
+ @register("source", "holdout")
11
+ class HoldoutSource(Source):
12
+ """The calibration split, fixed for the whole test period (split conformal)."""
13
+
14
+ def rows(self, archive: ResidualArchive) -> Tensor:
15
+ return calibration_rows(archive)
16
+
17
+
18
+ __all__ = ["HoldoutSource"]
@@ -0,0 +1,40 @@
1
+ """Checks and fixtures for anyone who builds on tscp's interfaces.
2
+
3
+ The checks verify that a method or component keeps tscp's contracts: causality, split
4
+ discipline, config round trips, devices, and each axis's own rules. tscp checks its own
5
+ presets and components with them; a method or component written elsewhere can be checked
6
+ the same way, in any test framework::
7
+
8
+ from tscp.testing import check_score
9
+
10
+ check_score(lambda: MyScore(width=2.0))
11
+ """
12
+
13
+ from .archives import HOUR, MONDAY, synthetic_archive, with_missing_targets
14
+ from .checks import (
15
+ assert_regions_equal,
16
+ check_feature_map,
17
+ check_method,
18
+ check_online_update,
19
+ check_quantile_model,
20
+ check_region,
21
+ check_score,
22
+ check_weights,
23
+ )
24
+ from .components import MaxAbsScore
25
+
26
+ __all__ = [
27
+ "HOUR",
28
+ "MONDAY",
29
+ "MaxAbsScore",
30
+ "assert_regions_equal",
31
+ "check_feature_map",
32
+ "check_method",
33
+ "check_online_update",
34
+ "check_quantile_model",
35
+ "check_region",
36
+ "check_score",
37
+ "check_weights",
38
+ "synthetic_archive",
39
+ "with_missing_targets",
40
+ ]
@@ -0,0 +1,80 @@
1
+ """Small synthetic archives for checks and tests."""
2
+
3
+ from dataclasses import replace
4
+
5
+ import torch
6
+
7
+ from tscp.data import ResidualArchive
8
+ from tscp.datasets import variance_shift
9
+
10
+ MONDAY = 1_767_571_200_000_000_000
11
+ """2026-01-05 00:00 UTC, a Monday, in nanoseconds: the first origin of synthetic archives."""
12
+ HOUR = 3_600_000_000_000
13
+ """One hour in nanoseconds: the base step of synthetic archives."""
14
+
15
+
16
+ def with_missing_targets(
17
+ archive: ResidualArchive, fraction: float, seed: int = 0
18
+ ) -> ResidualArchive:
19
+ """Return ``archive`` with a random ``fraction`` of its targets marked unobserved.
20
+
21
+ Unobserved residuals become exactly 0, as the archive requires. Targets that were
22
+ already unobserved stay so.
23
+ """
24
+ if not 0 <= fraction < 1:
25
+ raise ValueError("fraction must lie in [0, 1)")
26
+ generator = torch.Generator().manual_seed(seed)
27
+ observed = (torch.rand(archive.residuals.shape, generator=generator) >= fraction).to(
28
+ archive.device
29
+ )
30
+ if archive.observed is not None:
31
+ observed = observed & archive.observed
32
+ return replace(archive, residuals=archive.residuals * observed, observed=observed)
33
+
34
+
35
+ def synthetic_archive(
36
+ *,
37
+ horizon: int = 2,
38
+ num_series: int = 1,
39
+ num_origins: int = 300,
40
+ missing: float = 0.0,
41
+ seed: int = 0,
42
+ ) -> ResidualArchive:
43
+ """A small archive whose error scale jumps during the test split, with forecaster outputs.
44
+
45
+ The residuals come from :func:`~tscp.datasets.variance_shift`, with its default splits.
46
+ The archive also holds what tscp's own components read: the outputs ``scale``
47
+ (positive) and a band ``lower`` and ``upper`` one unit around the forecasts; two
48
+ random context columns, ``load`` and ``temperature``; and timestamps, one hour apart
49
+ from :data:`MONDAY`. Every built-in score and feature map can run on it, and with
50
+ ``missing > 0`` so can the code paths for unobserved targets.
51
+
52
+ Args:
53
+ horizon: Number of steps ``H``.
54
+ num_series: Number of series ``N``.
55
+ num_origins: Number of forecast origins ``T``.
56
+ missing: Fraction of targets to mark unobserved.
57
+ seed: Seed of every random draw.
58
+ """
59
+ archive = variance_shift(
60
+ horizon=horizon, num_origins=num_origins, num_series=num_series, seed=seed
61
+ )
62
+ generator = torch.Generator().manual_seed(seed + 1)
63
+ outputs = {
64
+ "scale": 0.5 + torch.rand(archive.residuals.shape, generator=generator),
65
+ "lower": archive.forecasts - 1.0,
66
+ "upper": archive.forecasts + 1.0,
67
+ }
68
+ archive = replace(
69
+ archive,
70
+ outputs=outputs,
71
+ context=torch.randn(num_origins, 2, generator=generator),
72
+ context_names=("load", "temperature"),
73
+ timestamps=MONDAY + HOUR * archive.origins,
74
+ )
75
+ if missing:
76
+ archive = with_missing_targets(archive, missing, seed=seed + 2)
77
+ return archive
78
+
79
+
80
+ __all__ = ["HOUR", "MONDAY", "synthetic_archive", "with_missing_targets"]