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/methods/engine.py ADDED
@@ -0,0 +1,128 @@
1
+ """The online event loop.
2
+
3
+ An online method answers queries in time order and learns from outcomes as they become
4
+ known. :class:`OnlineEngine` turns one outcome into the release mask an update observes;
5
+ :func:`replay` runs it over the test queries of an archive, releasing each outcome once
6
+ its target time has passed, by the rule of :mod:`tscp.data.availability`. The engine
7
+ itself does not depend on archives: code that answers live queries can drive it with
8
+ outcomes as they arrive.
9
+ """
10
+
11
+ from collections.abc import Iterable
12
+
13
+ import torch
14
+ from torch import Tensor
15
+
16
+ from tscp.online import OnlineUpdate
17
+ from tscp.quantile import ScoreDistribution
18
+
19
+
20
+ def group_selectors(
21
+ kept: tuple[int, ...], step_kept: bool, groups: int, device: torch.device
22
+ ) -> Tensor:
23
+ """``[G, *kept]`` bool: the kept elements whose outcome arrives with each group.
24
+
25
+ With steps kept, group ``g`` is horizon step ``g`` (the first kept dimension); with
26
+ a score over the horizon there is one group, released after the last step.
27
+ """
28
+ if not step_kept:
29
+ return torch.ones((1, *kept), dtype=torch.bool, device=device)
30
+ selectors = torch.zeros((groups, *kept), dtype=torch.bool, device=device)
31
+ for group in range(groups):
32
+ selectors[group, group] = True
33
+ return selectors
34
+
35
+
36
+ class OnlineEngine:
37
+ """Feeds an online update its queries and outcomes.
38
+
39
+ Args:
40
+ update: The update to drive; :meth:`OnlineUpdate.reset` is called here.
41
+ levels: ``[*kept]`` target miscoverage of each kept element.
42
+ step_kept: Whether the score keeps the step dimension, so that each step's
43
+ outcome arrives separately.
44
+ num_groups: Number of release groups: the number of steps if they are kept, else 1.
45
+ """
46
+
47
+ def __init__(self, update: OnlineUpdate, levels: Tensor, step_kept: bool, num_groups: int):
48
+ self.update = update
49
+ self.selectors = group_selectors(tuple(levels.shape), step_kept, num_groups, levels.device)
50
+ update.reset(levels, levels.device)
51
+
52
+ def threshold(self, t: int, distribution: ScoreDistribution) -> Tensor:
53
+ """The threshold of query ``t``."""
54
+ return self.update.threshold(t, distribution)
55
+
56
+ def release(
57
+ self,
58
+ t: int,
59
+ group: int,
60
+ scores: Tensor,
61
+ thresholds: Tensor,
62
+ valid: Tensor | None,
63
+ levels: Tensor | None,
64
+ ) -> None:
65
+ """Pass the outcome of release group ``group`` of query ``t`` to the update."""
66
+ released = self.selectors[group]
67
+ if valid is not None:
68
+ released = released & valid
69
+ self.update.observe(t, scores, thresholds, released, levels)
70
+
71
+
72
+ def replay(
73
+ engine: OnlineEngine,
74
+ origins: Tensor,
75
+ offsets: Tensor,
76
+ chunks: Iterable[tuple[int, int, ScoreDistribution]],
77
+ scores: Tensor,
78
+ valid: Tensor | None,
79
+ out: Tensor,
80
+ ) -> Tensor:
81
+ """Answer every query in time order, releasing each outcome once its target time passes.
82
+
83
+ At each query origin, every outcome whose target time has come is released first;
84
+ then the query is answered. Outcomes that would arrive at the same origin as a query
85
+ are known before it is answered.
86
+
87
+ Args:
88
+ engine: The engine to drive.
89
+ origins: ``[Q]`` query origins.
90
+ offsets: ``[G]`` steps after which each release group becomes known.
91
+ chunks: ``(start, stop, distribution)`` for consecutive chunks of queries, in order;
92
+ ``distribution`` covers queries ``start .. stop - 1``.
93
+ scores: ``[Q, *kept]`` scores of the queries' residuals, released once known.
94
+ valid: ``[Q, *kept]`` bool, or None if every score is defined.
95
+ out: ``[Q, *kept]`` tensor that receives the thresholds.
96
+
97
+ Returns:
98
+ ``out``.
99
+ """
100
+ origin_list, offset_list = origins.tolist(), offsets.tolist()
101
+ levels = None
102
+ if engine.update.needs_levels:
103
+ levels = torch.empty(out.shape, dtype=torch.float64, device=out.device)
104
+ pending = [0] * len(offset_list) # first query whose outcome in group g is unreleased
105
+ for start, stop, distribution in chunks:
106
+ for u in range(start, stop):
107
+ for group, offset in enumerate(offset_list):
108
+ while pending[group] < u and origin_list[pending[group]] + offset <= origin_list[u]:
109
+ t = pending[group]
110
+ engine.release(
111
+ t,
112
+ group,
113
+ scores[t],
114
+ out[t],
115
+ None if valid is None else valid[t],
116
+ None if levels is None else levels[t],
117
+ )
118
+ pending[group] += 1
119
+ current = distribution.select(u - start)
120
+ out[u] = engine.threshold(u, current)
121
+ if levels is not None:
122
+ # Stored now, while the distribution is at hand; the update sees it only
123
+ # when the outcome is released.
124
+ levels[u] = current.level(scores[u])
125
+ return out
126
+
127
+
128
+ __all__ = ["OnlineEngine", "group_selectors", "replay"]
tscp/methods/io.py ADDED
@@ -0,0 +1,106 @@
1
+ """Fitted methods in self-contained ``.npz`` files.
2
+
3
+ A file holds a method's config and its fitted state, ``state_dict()``, with a JSON header
4
+ that records the format, the schema version, the tscp version, the config and the
5
+ fingerprints of the state and of the archive the method was fitted on. Files are written
6
+ without pickle and never overwritten; loading checks the state against its fingerprint.
7
+
8
+ Calibration state is not saved, because calibration is cheap: load, then ``calibrate``.
9
+ """
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ import torch
16
+
17
+ from tscp import __version__
18
+ from tscp.data import ResidualArchive
19
+ from tscp.utils import Component, array_fingerprint, from_config
20
+
21
+ FORMAT = "tscp.method"
22
+ SCHEMA_VERSION = 1
23
+ _PREFIX = "state."
24
+
25
+
26
+ def save_method(method: Component, path: str | Path) -> None:
27
+ """Write a fitted method, or any fitted component, to a new ``.npz`` file.
28
+
29
+ Raises:
30
+ FileExistsError: If ``path`` exists; files are never overwritten.
31
+ ValueError: If the config cannot be written as JSON.
32
+ """
33
+ state = {name: value.detach().cpu() for name, value in method.state_dict().items()}
34
+ arrays = {_PREFIX + name: value.numpy() for name, value in state.items()}
35
+ header = {
36
+ "format": FORMAT,
37
+ "schema_version": SCHEMA_VERSION,
38
+ "tscp_version": __version__,
39
+ "config": method.to_config(),
40
+ "state_fingerprint": array_fingerprint(state),
41
+ "fit_archive": getattr(method, "fit_fingerprint", None),
42
+ }
43
+ try:
44
+ text = json.dumps(header, sort_keys=True, allow_nan=False)
45
+ except (TypeError, ValueError) as error:
46
+ raise ValueError(f"the method's config cannot be written as JSON: {error}") from error
47
+ arrays["header"] = np.asarray(text)
48
+ with Path(path).open("xb") as handle:
49
+ np.savez_compressed(handle, **arrays)
50
+
51
+
52
+ def load_method(path: str | Path, archive: ResidualArchive | None = None) -> Component:
53
+ """Read a method written by :func:`save_method`, fitted and ready to calibrate.
54
+
55
+ The method is rebuilt from its config and its fitted state is loaded. A component
56
+ whose fitted state lives outside torch sets ``refit_on_load``; it is fitted again on
57
+ ``archive.split("fit")``, so ``archive`` must then be the archive the method was
58
+ fitted on, which its fingerprint checks.
59
+
60
+ Raises:
61
+ ValueError: If the file is not a tscp method, has another schema version, holds
62
+ state that does not match its fingerprint, or needs an archive to refit that is
63
+ missing or is not the one the method was fitted on.
64
+ """
65
+ with np.load(path, allow_pickle=False) as data:
66
+ if "header" not in data.files:
67
+ raise ValueError(f"{path} is not a tscp method: it has no header")
68
+ header = json.loads(str(data["header"].item()))
69
+ if header.get("format") != FORMAT:
70
+ raise ValueError(f"{path} is not a tscp method")
71
+ if header.get("schema_version") != SCHEMA_VERSION:
72
+ raise ValueError(
73
+ f"{path} has schema version {header.get('schema_version')}; "
74
+ f"this tscp reads version {SCHEMA_VERSION}"
75
+ )
76
+ unexpected = [
77
+ name for name in data.files if name != "header" and not name.startswith(_PREFIX)
78
+ ]
79
+ if unexpected:
80
+ raise ValueError(f"{path} holds unexpected arrays: {unexpected}")
81
+ state = {
82
+ name.removeprefix(_PREFIX): torch.from_numpy(data[name])
83
+ for name in data.files
84
+ if name.startswith(_PREFIX)
85
+ }
86
+ if array_fingerprint(state) != header["state_fingerprint"]:
87
+ raise ValueError(f"{path}: the fitted state does not match the fingerprint in the header")
88
+ method = from_config(header["config"])
89
+ method.load_state_dict(state)
90
+ refit = [m for m in method.modules() if isinstance(m, Component) and m.refit_on_load]
91
+ if refit:
92
+ if archive is None:
93
+ raise ValueError(
94
+ "this method refits components on load: pass the archive it was fitted on"
95
+ )
96
+ if archive.fingerprint() != header["fit_archive"]:
97
+ raise ValueError("the archive is not the one the method was fitted on")
98
+ plan = archive.splits
99
+ validation = archive.split("validation") if "validation" in plan else None
100
+ for component in refit:
101
+ component.fit(archive.split("fit"), validation)
102
+ method.fit_fingerprint = header["fit_archive"]
103
+ return method
104
+
105
+
106
+ __all__ = ["FORMAT", "SCHEMA_VERSION", "load_method", "save_method"]
tscp/methods/nexcp.py ADDED
@@ -0,0 +1,42 @@
1
+ """Nonexchangeable conformal prediction."""
2
+
3
+ from tscp.methods.base import ConformalPredictor
4
+ from tscp.registry import register
5
+ from tscp.scores import AbsoluteScore, Score
6
+ from tscp.sources import ExpandingSource, Source
7
+ from tscp.weights import ExponentialWeights
8
+
9
+
10
+ @register("method", "nexcp")
11
+ class NexCP(ConformalPredictor):
12
+ """Nonexchangeable conformal prediction (Barber, Candès, Ramdas & Tibshirani, 2023).
13
+
14
+ Weighs each past residual by ``decay ** age``, so recent errors count more, and adds
15
+ each test residual to the pool once it is known. The coverage loss under
16
+ nonexchangeability is bounded by the weighted total-variation distance terms of the
17
+ paper. With ``decay=1`` it is split conformal over the same pool.
18
+
19
+ Args:
20
+ decay: Weight ratio per base time step, in (0, 1].
21
+ score: Defaults to :class:`~tscp.scores.AbsoluteScore`.
22
+ source: Defaults to :class:`~tscp.sources.ExpandingSource`;
23
+ :class:`~tscp.sources.HoldoutSource` keeps the calibration split fixed.
24
+ alpha: Target miscoverage level.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ decay: float = 0.99,
30
+ score: Score | None = None,
31
+ source: Source | None = None,
32
+ alpha: float = 0.1,
33
+ ):
34
+ super().__init__(
35
+ score=score if score is not None else AbsoluteScore(),
36
+ source=source if source is not None else ExpandingSource(),
37
+ weights=ExponentialWeights(decay),
38
+ alpha=alpha,
39
+ )
40
+
41
+
42
+ __all__ = ["NexCP"]
tscp/methods/split.py ADDED
@@ -0,0 +1,30 @@
1
+ """Split conformal prediction."""
2
+
3
+ from tscp.methods.base import ConformalPredictor
4
+ from tscp.registry import register
5
+ from tscp.scores import AbsoluteScore, Score
6
+ from tscp.sources import HoldoutSource
7
+
8
+
9
+ @register("method", "split")
10
+ class SplitCP(ConformalPredictor):
11
+ """Split conformal prediction (Vovk et al., 2005; Lei et al., JASA 2018).
12
+
13
+ Scores the calibration split, weighs every score equally and takes the
14
+ ``ceil((1 - alpha)(n + 1))``-th smallest as the threshold. Coverage is at least
15
+ ``1 - alpha`` in finite samples when calibration and test residuals are exchangeable.
16
+
17
+ Args:
18
+ score: Defaults to :class:`~tscp.scores.AbsoluteScore`.
19
+ alpha: Target miscoverage level.
20
+ """
21
+
22
+ def __init__(self, score: Score | None = None, alpha: float = 0.1):
23
+ super().__init__(
24
+ score=score if score is not None else AbsoluteScore(),
25
+ source=HoldoutSource(),
26
+ alpha=alpha,
27
+ )
28
+
29
+
30
+ __all__ = ["SplitCP"]
@@ -0,0 +1,30 @@
1
+ """Metrics by output type, plus the default report.
2
+
3
+ Region size is always per-context log-volume, summarized per dimension; the mean of raw
4
+ volumes is never reported, because the widest regions dominate it.
5
+ """
6
+
7
+ from .conditional import grouped_coverage, rolling_coverage, worst_window_coverage
8
+ from .interval import (
9
+ coverage,
10
+ coverage_gap,
11
+ log_width,
12
+ mean_width,
13
+ observed_regions,
14
+ winkler_score,
15
+ )
16
+ from .report import Report, evaluate
17
+
18
+ __all__ = [
19
+ "Report",
20
+ "coverage",
21
+ "coverage_gap",
22
+ "evaluate",
23
+ "grouped_coverage",
24
+ "log_width",
25
+ "mean_width",
26
+ "observed_regions",
27
+ "rolling_coverage",
28
+ "winkler_score",
29
+ "worst_window_coverage",
30
+ ]
@@ -0,0 +1,66 @@
1
+ """Coverage by group and over rolling windows."""
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+
7
+ def _hits_and_counts(covered: Tensor, observed: Tensor | None) -> tuple[Tensor, Tensor]:
8
+ counts = torch.ones_like(covered, dtype=torch.float64)
9
+ if observed is not None:
10
+ counts = observed.to(torch.float64)
11
+ return covered.to(torch.float64) * counts, counts
12
+
13
+
14
+ def rolling_coverage(covered: Tensor, window: int, observed: Tensor | None = None) -> Tensor:
15
+ """Coverage over each run of ``window`` consecutive query origins, per element.
16
+
17
+ Args:
18
+ covered: ``[Q, ...]`` bool, as returned by ``region.contains``.
19
+ window: Number of consecutive origins per window.
20
+ observed: Optional mask shaped like ``covered``; unobserved targets are skipped.
21
+
22
+ Returns:
23
+ ``[Q - window + 1, ...]``; NaN for a window without observed targets.
24
+ """
25
+ if not 1 <= window <= covered.shape[0]:
26
+ raise ValueError("window must lie between 1 and the number of origins")
27
+ hits, counts = _hits_and_counts(covered, observed)
28
+ zero = torch.zeros_like(hits[:1])
29
+ hits = torch.cat([zero, hits.cumsum(0)])
30
+ counts = torch.cat([zero, counts.cumsum(0)])
31
+ return (hits[window:] - hits[:-window]) / (counts[window:] - counts[:-window])
32
+
33
+
34
+ def worst_window_coverage(covered: Tensor, window: int, observed: Tensor | None = None) -> Tensor:
35
+ """Lowest rolling coverage over every window and element."""
36
+ rolling = rolling_coverage(covered, window, observed)
37
+ return torch.where(rolling.isnan(), torch.inf, rolling).min()
38
+
39
+
40
+ def grouped_coverage(
41
+ covered: Tensor, groups: Tensor, observed: Tensor | None = None
42
+ ) -> tuple[Tensor, Tensor]:
43
+ """Coverage within each group of query origins, such as the hour of day.
44
+
45
+ Args:
46
+ covered: ``[Q, ...]`` bool.
47
+ groups: ``[Q]`` integer group label of each origin.
48
+ observed: Optional mask shaped like ``covered``.
49
+
50
+ Returns:
51
+ The sorted group labels and the coverage in each.
52
+ """
53
+ if groups.shape != covered.shape[:1]:
54
+ raise ValueError("groups must hold one label per query origin")
55
+ hits, counts = _hits_and_counts(covered, observed)
56
+ hits = hits.reshape(covered.shape[0], -1).sum(1)
57
+ counts = counts.reshape(covered.shape[0], -1).sum(1)
58
+ labels, inverse = torch.unique(groups, return_inverse=True)
59
+ group_hits = torch.zeros(len(labels), dtype=torch.float64, device=covered.device)
60
+ group_counts = torch.zeros_like(group_hits)
61
+ group_hits.index_add_(0, inverse, hits)
62
+ group_counts.index_add_(0, inverse, counts)
63
+ return labels, group_hits / group_counts
64
+
65
+
66
+ __all__ = ["grouped_coverage", "rolling_coverage", "worst_window_coverage"]
@@ -0,0 +1,75 @@
1
+ """Coverage and size of regions; interval-specific scores."""
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+ from tscp.regions import Interval, Region, move_event_dims
7
+
8
+
9
+ def _mean(values: Tensor, observed: Tensor | None) -> Tensor:
10
+ values = values.to(torch.float64)
11
+ if observed is None:
12
+ return values.mean()
13
+ return values[observed].mean()
14
+
15
+
16
+ def observed_regions(region: Region, observed: Tensor | None) -> Tensor | None:
17
+ """Return which regions have all their targets observed, or None if every target is.
18
+
19
+ Args:
20
+ region: The regions.
21
+ observed: ``[Q, H, N]`` mask of observed targets (or the region's batch shape for a
22
+ region per element), or None.
23
+
24
+ Returns:
25
+ A bool tensor of the region's batch shape, or None.
26
+ """
27
+ if observed is None or bool(observed.all()):
28
+ return None
29
+ if not region.event_dims:
30
+ return observed
31
+ moved = move_event_dims(observed, region.event_dims)
32
+ return moved.reshape(*region.batch_shape, -1).all(dim=-1)
33
+
34
+
35
+ def coverage(region: Region, residuals: Tensor, observed: Tensor | None = None) -> Tensor:
36
+ """Fraction of regions that cover their targets, among those with every target observed."""
37
+ return _mean(region.contains(residuals), observed_regions(region, observed))
38
+
39
+
40
+ def coverage_gap(
41
+ region: Region, residuals: Tensor, alpha: float, observed: Tensor | None = None
42
+ ) -> Tensor:
43
+ """Coverage minus its target ``1 - alpha``; negative means under-coverage."""
44
+ return coverage(region, residuals, observed) - (1 - alpha)
45
+
46
+
47
+ def log_width(interval: Interval) -> Tensor:
48
+ """Log-width of each interval: its log-volume in one dimension."""
49
+ return interval.log_volume()
50
+
51
+
52
+ def mean_width(interval: Interval, observed: Tensor | None = None) -> Tensor:
53
+ """Arithmetic mean width.
54
+
55
+ Only for comparing with published one-step tables, which report it. Report size as
56
+ log-width otherwise: a mean of raw widths is dominated by the widest.
57
+ """
58
+ return _mean(interval.width(), observed)
59
+
60
+
61
+ def winkler_score(interval: Interval, residuals: Tensor, alpha: float) -> Tensor:
62
+ """Winkler (interval) score of each interval: width plus ``2/alpha`` times the miss."""
63
+ below = (interval.lower - residuals).clamp(min=0)
64
+ above = (residuals - interval.upper).clamp(min=0)
65
+ return (interval.upper - interval.lower) + (2 / alpha) * (below + above)
66
+
67
+
68
+ __all__ = [
69
+ "coverage",
70
+ "coverage_gap",
71
+ "log_width",
72
+ "mean_width",
73
+ "observed_regions",
74
+ "winkler_score",
75
+ ]
tscp/metrics/report.py ADDED
@@ -0,0 +1,135 @@
1
+ """The default evaluation report."""
2
+
3
+ import dataclasses
4
+ import math
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import torch
9
+ from torch import Tensor
10
+
11
+ from tscp.data import ResidualArchive
12
+ from tscp.metrics.conditional import grouped_coverage, worst_window_coverage
13
+ from tscp.metrics.interval import observed_regions, winkler_score
14
+ from tscp.regions import RESIDUAL_DIMS, Interval, Region
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Report:
19
+ """What :func:`evaluate` measures.
20
+
21
+ Attributes:
22
+ alpha: The target miscoverage level.
23
+ num_regions: Regions evaluated: those whose targets are all observed.
24
+ coverage: Fraction of evaluated regions that cover their targets.
25
+ coverage_gap: ``coverage - (1 - alpha)``; negative means under-coverage.
26
+ log_volume_per_dim: Mean log-volume per dimension (for an interval, the
27
+ log-width), over bounded, nonempty regions only.
28
+ infinite_fraction: Fraction of evaluated regions that are unbounded.
29
+ empty_fraction: Fraction of evaluated regions that are empty or of zero volume.
30
+ winkler: Mean Winkler score of bounded, nonempty intervals; None for other regions.
31
+ coverage_by_step: Coverage at each horizon step, for regions made per step.
32
+ log_width_by_step: Mean log-width at each horizon step, for intervals made per step.
33
+ joint_coverage: Fraction of origins (and series, unless covered jointly) whose
34
+ targets are covered at every step of the horizon.
35
+ worst_window_coverage: Lowest coverage over windows of consecutive origins, if a
36
+ window was given.
37
+ grouped_coverage: Coverage per group label, if groups were given.
38
+ """
39
+
40
+ alpha: float
41
+ num_regions: int
42
+ coverage: float
43
+ coverage_gap: float
44
+ log_volume_per_dim: float
45
+ infinite_fraction: float
46
+ empty_fraction: float
47
+ winkler: float | None = None
48
+ coverage_by_step: tuple[float, ...] | None = None
49
+ log_width_by_step: tuple[float, ...] | None = None
50
+ joint_coverage: float | None = None
51
+ worst_window_coverage: float | None = None
52
+ grouped_coverage: dict[int, float] | None = None
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ """The report as a JSON-serializable dict."""
56
+ return dataclasses.asdict(self)
57
+
58
+
59
+ def _mean(values: Tensor, mask: Tensor) -> float:
60
+ return float(values[mask].to(torch.float64).mean()) if mask.any() else math.nan
61
+
62
+
63
+ def evaluate(
64
+ region: Region,
65
+ archive: ResidualArchive,
66
+ alpha: float,
67
+ *,
68
+ window: int | None = None,
69
+ groups: Tensor | None = None,
70
+ ) -> Report:
71
+ """Score regions against the residuals they were made for.
72
+
73
+ Works for any region type. A region that covers some dimensions jointly counts as
74
+ covering only if all its targets lie inside, and it is evaluated only if all its
75
+ targets are observed. Size is the per-context log-volume divided by the region's
76
+ dimension, averaged over bounded, nonempty regions only; unbounded and empty regions
77
+ are counted, never averaged in.
78
+
79
+ Args:
80
+ region: Regions from ``predict``, one per row of ``archive`` and element not
81
+ covered jointly.
82
+ archive: The rows the regions were made for, usually ``archive.split("test")``.
83
+ alpha: The target miscoverage level.
84
+ window: If given, also report the worst coverage over windows of this many origins.
85
+ groups: If given, ``[Q]`` labels; also report coverage per label.
86
+ """
87
+ residuals = archive.residuals
88
+ sizes = dict(zip(RESIDUAL_DIMS, residuals.shape[1:], strict=True))
89
+ kept = [name for name in RESIDUAL_DIMS if name not in region.event_dims]
90
+ expected = (len(archive), *(sizes[name] for name in kept))
91
+ if tuple(region.batch_shape) != expected:
92
+ raise ValueError(
93
+ f"regions must have batch shape {expected}: one per row and element not covered "
94
+ f"jointly, not {tuple(region.batch_shape)}"
95
+ )
96
+ covered = region.contains(residuals)
97
+ valid = observed_regions(region, archive.observed)
98
+ if valid is None:
99
+ valid = torch.ones_like(covered)
100
+ log_volume = region.log_volume()
101
+ proper = valid & torch.isfinite(log_volume)
102
+ coverage = _mean(covered, valid)
103
+ fields: dict[str, Any] = {
104
+ "alpha": float(alpha),
105
+ "num_regions": int(valid.sum()),
106
+ "coverage": coverage,
107
+ "coverage_gap": coverage - (1 - alpha),
108
+ "log_volume_per_dim": _mean(log_volume / region.dimension, proper),
109
+ "infinite_fraction": _mean(torch.isposinf(log_volume), valid),
110
+ "empty_fraction": _mean(torch.isneginf(log_volume), valid),
111
+ }
112
+ if isinstance(region, Interval):
113
+ fields["winkler"] = _mean(winkler_score(region, residuals, alpha), proper)
114
+ if "step" in kept:
115
+ step = 1 # the step dimension of the batch
116
+ fields["coverage_by_step"] = tuple(
117
+ _mean(covered.select(step, h), valid.select(step, h)) for h in range(sizes["step"])
118
+ )
119
+ if isinstance(region, Interval):
120
+ fields["log_width_by_step"] = tuple(
121
+ _mean(log_volume.select(step, h), proper.select(step, h))
122
+ for h in range(sizes["step"])
123
+ )
124
+ fields["joint_coverage"] = _mean(covered.all(dim=step), valid.all(dim=step))
125
+ else:
126
+ fields["joint_coverage"] = coverage
127
+ if window is not None:
128
+ fields["worst_window_coverage"] = float(worst_window_coverage(covered, window, valid))
129
+ if groups is not None:
130
+ labels, values = grouped_coverage(covered, groups, valid)
131
+ fields["grouped_coverage"] = dict(zip(labels.tolist(), values.tolist(), strict=True))
132
+ return Report(**fields)
133
+
134
+
135
+ __all__ = ["Report", "evaluate"]
tscp/nn/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Helpers for learned components: losses, row batches and a reproducible training loop.
2
+
3
+ A learned component is a :class:`~tscp.utils.Component` with torch parameters, fitted in
4
+ its ``fit(train, validation)``: it batches the fit split with :class:`RowBatches` and
5
+ fits itself with :func:`train`, which stops early on the validation split. Its fitted
6
+ parameters live in ``state_dict()``, so a saved method keeps them.
7
+ """
8
+
9
+ from .data import RowBatches
10
+ from .losses import interval_score, pinball_loss
11
+ from .training import TrainingResult, seeded, train
12
+
13
+ __all__ = ["RowBatches", "TrainingResult", "interval_score", "pinball_loss", "seeded", "train"]