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/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"]
|
tscp/metrics/__init__.py
ADDED
|
@@ -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"]
|
tscp/metrics/interval.py
ADDED
|
@@ -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"]
|