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/testing/checks.py
ADDED
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
"""Checks that methods and components keep tscp's contracts.
|
|
2
|
+
|
|
3
|
+
Each check takes a factory, a callable that returns a fresh instance, and raises an
|
|
4
|
+
AssertionError that names the property that failed. The checks need no test framework::
|
|
5
|
+
|
|
6
|
+
from tscp.testing import check_method
|
|
7
|
+
|
|
8
|
+
check_method(lambda: MyMethod(alpha=0.1))
|
|
9
|
+
|
|
10
|
+
tscp runs its own presets and components through these checks, and a component written
|
|
11
|
+
elsewhere can be checked the same way.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import tempfile
|
|
17
|
+
from collections.abc import Callable, Sequence
|
|
18
|
+
from dataclasses import replace
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import torch
|
|
22
|
+
from torch import Tensor
|
|
23
|
+
|
|
24
|
+
from tscp.data import ResidualArchive
|
|
25
|
+
from tscp.features import FeatureMap
|
|
26
|
+
from tscp.methods import ConformalPredictor, load_method, save_method
|
|
27
|
+
from tscp.metrics import Report, evaluate
|
|
28
|
+
from tscp.online import OnlineUpdate
|
|
29
|
+
from tscp.quantile import Pool, QuantileModel, ScoreDistribution
|
|
30
|
+
from tscp.regions import RESIDUAL_DIMS, Region, restore_event_dims
|
|
31
|
+
from tscp.scores import AbsoluteScore, Score, SignedScore
|
|
32
|
+
from tscp.sources import HoldoutSource
|
|
33
|
+
from tscp.testing.archives import synthetic_archive
|
|
34
|
+
from tscp.utils import Component, ConfigMixin, from_config
|
|
35
|
+
from tscp.weights import Weights
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check(condition, prop: str, message: str) -> None:
|
|
39
|
+
if not bool(condition):
|
|
40
|
+
raise AssertionError(f"{prop}: {message}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _devices(devices: Sequence[str] | None) -> list[str]:
|
|
44
|
+
if devices is not None:
|
|
45
|
+
return list(devices)
|
|
46
|
+
return ["cpu"] + (["cuda"] if torch.cuda.is_available() else [])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def assert_regions_equal(
|
|
50
|
+
first: Region, second: Region, atol: float = 0.0, rtol: float = 0.0
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Assert that two batches of regions are the same, up to ``atol + rtol * |value|``."""
|
|
53
|
+
if type(first) is not type(second):
|
|
54
|
+
raise AssertionError(
|
|
55
|
+
f"regions differ in type: {type(first).__name__} and {type(second).__name__}"
|
|
56
|
+
)
|
|
57
|
+
if first.event_dims != second.event_dims:
|
|
58
|
+
raise AssertionError(
|
|
59
|
+
f"regions cover other dimensions jointly: {first.event_dims} and {second.event_dims}"
|
|
60
|
+
)
|
|
61
|
+
tensors, others = first.tensors(), second.tensors()
|
|
62
|
+
if tensors.keys() != others.keys():
|
|
63
|
+
raise AssertionError(f"regions have other tensors: {sorted(tensors)}, {sorted(others)}")
|
|
64
|
+
for name, value in tensors.items():
|
|
65
|
+
other = others[name].to(value.device)
|
|
66
|
+
if value.shape != other.shape:
|
|
67
|
+
raise AssertionError(
|
|
68
|
+
f"tensor {name!r} has shapes {tuple(value.shape)} and {tuple(other.shape)}"
|
|
69
|
+
)
|
|
70
|
+
if atol == 0 and rtol == 0:
|
|
71
|
+
same = torch.equal(value, other)
|
|
72
|
+
else:
|
|
73
|
+
same = torch.allclose(value, other, rtol=rtol, atol=atol)
|
|
74
|
+
if not same:
|
|
75
|
+
finite = torch.isfinite(value) & torch.isfinite(other)
|
|
76
|
+
gap = (value - other)[finite].abs().max().item() if finite.any() else 0.0
|
|
77
|
+
raise AssertionError(
|
|
78
|
+
f"tensor {name!r} differs: largest finite difference {gap:.3g}, "
|
|
79
|
+
"or a difference in where it is infinite"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _same_regions(
|
|
84
|
+
first: Region, second: Region, prop: str, message: str, atol=0.0, rtol=0.0
|
|
85
|
+
) -> None:
|
|
86
|
+
try:
|
|
87
|
+
assert_regions_equal(first, second, atol, rtol)
|
|
88
|
+
except AssertionError as error:
|
|
89
|
+
raise AssertionError(f"{prop}: {message} ({error})") from None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _run(method: ConformalPredictor, archive: ResidualArchive) -> Region:
|
|
93
|
+
return method.fit(archive).calibrate(archive).predict(archive)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _fitted(component: Component, archive: ResidualArchive) -> Component:
|
|
97
|
+
"""Fit ``component`` the way the composer does, if it is trainable."""
|
|
98
|
+
if component.is_trainable:
|
|
99
|
+
plan = archive.splits
|
|
100
|
+
validation = archive.split("validation") if "validation" in plan else None
|
|
101
|
+
component.fit(archive.split("fit"), validation)
|
|
102
|
+
return component
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _scramble(
|
|
106
|
+
archive: ResidualArchive, targets: Tensor, rows: Tensor, seed: int
|
|
107
|
+
) -> ResidualArchive:
|
|
108
|
+
"""Replace the residuals at ``targets`` and every other field at ``rows`` with noise.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
archive: A whole archive.
|
|
112
|
+
targets: ``[T, H]`` bool, the residuals to change. Unobserved ones stay 0.
|
|
113
|
+
rows: ``[T]`` bool, the rows whose forecasts, outputs and context change. Outputs
|
|
114
|
+
are rescaled by positive factors, so positive outputs stay positive.
|
|
115
|
+
seed: Seed of the noise.
|
|
116
|
+
"""
|
|
117
|
+
generator = torch.Generator().manual_seed(seed)
|
|
118
|
+
|
|
119
|
+
def noise(like: Tensor) -> Tensor:
|
|
120
|
+
return torch.randn(like.shape, generator=generator).to(like.device, like.dtype)
|
|
121
|
+
|
|
122
|
+
def at(values: Tensor) -> Tensor:
|
|
123
|
+
return rows.reshape(-1, *([1] * (values.ndim - 1)))
|
|
124
|
+
|
|
125
|
+
changed = targets[..., None].expand_as(archive.residuals)
|
|
126
|
+
if archive.observed is not None:
|
|
127
|
+
changed = changed & archive.observed
|
|
128
|
+
fields = {"residuals": torch.where(changed, 10 * noise(archive.residuals), archive.residuals)}
|
|
129
|
+
if archive.forecasts is not None:
|
|
130
|
+
forecasts = archive.forecasts
|
|
131
|
+
fields["forecasts"] = torch.where(at(forecasts), forecasts + noise(forecasts), forecasts)
|
|
132
|
+
outputs = {}
|
|
133
|
+
for name, value in archive.outputs.items():
|
|
134
|
+
if value.is_floating_point():
|
|
135
|
+
value = torch.where(at(value), value * (1.5 + noise(value).tanh()), value)
|
|
136
|
+
outputs[name] = value
|
|
137
|
+
fields["outputs"] = outputs
|
|
138
|
+
if archive.context is not None and archive.context.is_floating_point():
|
|
139
|
+
context = archive.context
|
|
140
|
+
fields["context"] = torch.where(at(context), context + noise(context), context)
|
|
141
|
+
return replace(archive, **fields)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _default_archives() -> tuple[ResidualArchive, ...]:
|
|
145
|
+
return (
|
|
146
|
+
synthetic_archive(horizon=1, num_origins=240, seed=1),
|
|
147
|
+
synthetic_archive(horizon=3, num_series=2, num_origins=240, missing=0.05, seed=2),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# -- methods --------------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def check_method(
|
|
155
|
+
factory: Callable[[], ConformalPredictor],
|
|
156
|
+
*,
|
|
157
|
+
archives: Sequence[ResidualArchive] | None = None,
|
|
158
|
+
devices: Sequence[str] | None = None,
|
|
159
|
+
atol: float = 1e-6,
|
|
160
|
+
rtol: float = 1e-5,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Check that a method keeps tscp's contracts.
|
|
163
|
+
|
|
164
|
+
On each archive:
|
|
165
|
+
|
|
166
|
+
- **runs:** fit, calibrate and predict give one region per test origin, which
|
|
167
|
+
:func:`~tscp.metrics.evaluate` accepts;
|
|
168
|
+
- **determinism:** two fresh instances give the same regions;
|
|
169
|
+
- **causality:** changing what is not yet known at a test origin (residuals whose
|
|
170
|
+
target time is later, and the forecasts, outputs and context of later rows) leaves
|
|
171
|
+
the regions up to that origin unchanged;
|
|
172
|
+
- **split discipline:** fitting on a copy whose rows after the fitting splits are
|
|
173
|
+
scrambled, then calibrating and predicting on the original, gives the same regions;
|
|
174
|
+
- **config:** the config survives a JSON round trip and rebuilds the same method;
|
|
175
|
+
- **save and load:** a saved, loaded and recalibrated method gives the same regions;
|
|
176
|
+
- **devices:** the regions on every device agree with the CPU's, within float32
|
|
177
|
+
rounding (``atol`` and ``rtol``): devices reduce in other orders.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
factory: Returns a fresh, unfitted method.
|
|
181
|
+
archives: Archives to check on. By default, two synthetic archives: one step, and
|
|
182
|
+
three steps over two series with missing targets.
|
|
183
|
+
devices: Devices to check; CPU, and CUDA when it is available, by default.
|
|
184
|
+
atol: Absolute tolerance for comparing regions across devices.
|
|
185
|
+
rtol: Relative tolerance for comparing regions across devices.
|
|
186
|
+
"""
|
|
187
|
+
for archive in archives if archives is not None else _default_archives():
|
|
188
|
+
_check_method_on(factory, archive, _devices(devices), atol, rtol)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _check_method_on(factory, archive: ResidualArchive, devices: list[str], atol, rtol):
|
|
192
|
+
method = factory()
|
|
193
|
+
_check(
|
|
194
|
+
isinstance(method, ConfigMixin),
|
|
195
|
+
"config",
|
|
196
|
+
"a method must record its config: subclass ConformalPredictor or Component",
|
|
197
|
+
)
|
|
198
|
+
region = _run(method, archive)
|
|
199
|
+
test = archive.split("test")
|
|
200
|
+
report = evaluate(region, test, getattr(method, "alpha", 0.1))
|
|
201
|
+
_check(isinstance(report, Report), "runs", "evaluate() must accept the regions")
|
|
202
|
+
|
|
203
|
+
_same_regions(_run(factory(), archive), region, "determinism", "fresh instances disagree")
|
|
204
|
+
|
|
205
|
+
for position in sorted({len(test) // 4, (3 * len(test)) // 4}):
|
|
206
|
+
origin = int(test.origins[position])
|
|
207
|
+
later = _scramble(
|
|
208
|
+
archive, archive.target_times > origin, archive.origins > origin, seed=position
|
|
209
|
+
)
|
|
210
|
+
_same_regions(
|
|
211
|
+
_run(factory(), later)[: position + 1],
|
|
212
|
+
region[: position + 1],
|
|
213
|
+
"causality",
|
|
214
|
+
f"regions up to test origin {origin} change with information that comes later",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
plan = archive.splits
|
|
218
|
+
fitting = [plan[name] for name in ("fit", "validation") if name in plan]
|
|
219
|
+
if fitting:
|
|
220
|
+
last = max(int(rows[-1]) for rows in fitting)
|
|
221
|
+
after = torch.arange(len(archive), device=archive.device) > last
|
|
222
|
+
scrambled = _scramble(archive, after[:, None].expand(-1, archive.horizon), after, seed=11)
|
|
223
|
+
other = factory().fit(scrambled).calibrate(archive).predict(archive)
|
|
224
|
+
_same_regions(
|
|
225
|
+
other, region, "split discipline", "fitting reads rows after the fitting splits"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
config = json.loads(json.dumps(method.to_config()))
|
|
229
|
+
rebuilt = from_config(config)
|
|
230
|
+
_check(
|
|
231
|
+
type(rebuilt) is type(method) and rebuilt.to_config() == method.to_config(),
|
|
232
|
+
"config",
|
|
233
|
+
"the config does not rebuild the same method",
|
|
234
|
+
)
|
|
235
|
+
_same_regions(_run(rebuilt, archive), region, "config", "a rebuilt method disagrees")
|
|
236
|
+
|
|
237
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
238
|
+
path = Path(directory) / "method.npz"
|
|
239
|
+
save_method(method, path)
|
|
240
|
+
loaded = load_method(path, archive)
|
|
241
|
+
_same_regions(
|
|
242
|
+
loaded.calibrate(archive).predict(archive),
|
|
243
|
+
region,
|
|
244
|
+
"save and load",
|
|
245
|
+
"a saved, loaded and recalibrated method gives other regions",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
for device in devices:
|
|
249
|
+
if device == "cpu":
|
|
250
|
+
continue
|
|
251
|
+
moved = _run(factory().to(device), archive.to(device)).to("cpu")
|
|
252
|
+
_same_regions(moved, region, f"device {device}", "regions disagree with CPU", atol, rtol)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# -- scores ---------------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def check_score(
|
|
259
|
+
factory: Callable[[], Score],
|
|
260
|
+
*,
|
|
261
|
+
archive: ResidualArchive | None = None,
|
|
262
|
+
alpha: float = 0.1,
|
|
263
|
+
devices: Sequence[str] | None = None,
|
|
264
|
+
atol: float = 1e-6,
|
|
265
|
+
rtol: float = 1e-5,
|
|
266
|
+
) -> None:
|
|
267
|
+
"""Check that a score keeps the score contract.
|
|
268
|
+
|
|
269
|
+
- **shape:** scores are floating, of shape ``[T, *kept]`` with ``kept`` from
|
|
270
|
+
:meth:`~tscp.scores.Score.kept_shape`;
|
|
271
|
+
- **levels:** each lies in ``[0, alpha]``, and the channels of an element share
|
|
272
|
+
``alpha``: their levels sum to at most ``alpha``;
|
|
273
|
+
- **validity:** a score is never valid where a residual it combines is unobserved,
|
|
274
|
+
and valid scores are finite;
|
|
275
|
+
- **sublevel set:** for random thresholds ``q``, ``region(q)`` contains exactly the
|
|
276
|
+
residuals whose scores are at most ``q`` in every channel;
|
|
277
|
+
- **no residuals:** a region does not change when the query residuals do;
|
|
278
|
+
- **config** and **devices**, as in :func:`check_method`.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
factory: Returns a fresh score; a trainable one is fitted on the fit split.
|
|
282
|
+
archive: Archive to check on; by default a synthetic archive over three steps and
|
|
283
|
+
two series, with forecasts, outputs and missing targets.
|
|
284
|
+
alpha: Miscoverage level for :meth:`~tscp.scores.Score.levels`.
|
|
285
|
+
devices: Devices to check; CPU, and CUDA when it is available, by default.
|
|
286
|
+
atol: Absolute tolerance for comparing scores across devices.
|
|
287
|
+
rtol: Relative tolerance for comparing scores across devices.
|
|
288
|
+
"""
|
|
289
|
+
if archive is None:
|
|
290
|
+
archive = synthetic_archive(horizon=3, num_series=2, num_origins=240, missing=0.05, seed=3)
|
|
291
|
+
score = _fitted(factory(), archive)
|
|
292
|
+
calibration, query = archive.split("calibration"), archive.split("test")
|
|
293
|
+
kept = score.kept_shape(archive.horizon, archive.num_series)
|
|
294
|
+
|
|
295
|
+
values = score(calibration)
|
|
296
|
+
expected_shape = (len(calibration), *kept)
|
|
297
|
+
_check(values.is_floating_point(), "shape", "scores must be floating")
|
|
298
|
+
_check(
|
|
299
|
+
tuple(values.shape) == expected_shape,
|
|
300
|
+
"shape",
|
|
301
|
+
f"scores have shape {tuple(values.shape)}, not {expected_shape}",
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
levels = score.levels(alpha, kept)
|
|
305
|
+
_check(
|
|
306
|
+
levels.dtype == torch.float64 and tuple(levels.shape) == kept,
|
|
307
|
+
"levels",
|
|
308
|
+
f"levels must be float64 of shape {kept}",
|
|
309
|
+
)
|
|
310
|
+
_check(
|
|
311
|
+
((levels >= 0) & (levels <= alpha + 1e-12)).all(),
|
|
312
|
+
"levels",
|
|
313
|
+
"each level must lie in [0, alpha]",
|
|
314
|
+
)
|
|
315
|
+
if score.channels:
|
|
316
|
+
_check(
|
|
317
|
+
(levels.sum(dim=-1) <= alpha + 1e-12).all(),
|
|
318
|
+
"levels",
|
|
319
|
+
"the channels of an element must share alpha, but their levels sum to more",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
valid = score.valid(calibration)
|
|
323
|
+
implied = Score.valid(score, calibration) # every residual the score combines observed
|
|
324
|
+
if valid is not None:
|
|
325
|
+
_check(
|
|
326
|
+
valid.dtype == torch.bool and valid.shape == values.shape,
|
|
327
|
+
"validity",
|
|
328
|
+
"valid() must return a bool mask shaped like the scores, or None",
|
|
329
|
+
)
|
|
330
|
+
if implied is not None:
|
|
331
|
+
_check(
|
|
332
|
+
not (valid & ~implied).any(),
|
|
333
|
+
"validity",
|
|
334
|
+
"a score is valid where a residual it combines is unobserved",
|
|
335
|
+
)
|
|
336
|
+
defined = valid if valid is not None else torch.ones_like(values, dtype=torch.bool)
|
|
337
|
+
_check(torch.isfinite(values[defined]).all(), "validity", "valid scores must be finite")
|
|
338
|
+
|
|
339
|
+
query_values = score(query)
|
|
340
|
+
generator = torch.Generator().manual_seed(0)
|
|
341
|
+
finite = query_values[torch.isfinite(query_values)]
|
|
342
|
+
spread = float(finite.std()) if finite.numel() > 1 else 1.0
|
|
343
|
+
jitter = torch.randn(query_values.shape, generator=generator).to(query_values)
|
|
344
|
+
thresholds = query_values + max(spread, 1e-3) * jitter
|
|
345
|
+
region = score.region(thresholds, query)
|
|
346
|
+
expected = query_values <= thresholds
|
|
347
|
+
query_valid = score.valid(query)
|
|
348
|
+
if score.channels:
|
|
349
|
+
expected = expected.all(dim=-1)
|
|
350
|
+
if query_valid is not None:
|
|
351
|
+
query_valid = query_valid.all(dim=-1)
|
|
352
|
+
_check(
|
|
353
|
+
tuple(region.batch_shape) == tuple(expected.shape),
|
|
354
|
+
"sublevel set",
|
|
355
|
+
f"regions have batch shape {tuple(region.batch_shape)}, not {tuple(expected.shape)}",
|
|
356
|
+
)
|
|
357
|
+
agree = region.contains(query.residuals) == expected
|
|
358
|
+
if query_valid is not None:
|
|
359
|
+
agree = agree | ~query_valid
|
|
360
|
+
_check(
|
|
361
|
+
agree.all(),
|
|
362
|
+
"sublevel set",
|
|
363
|
+
f"region(q) must contain exactly the residuals whose scores are at most q; "
|
|
364
|
+
f"{int((~agree).sum())} of {agree.numel()} disagree",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
blind = replace(query, residuals=_scramble_residuals(query, seed=1))
|
|
368
|
+
_same_regions(
|
|
369
|
+
score.region(thresholds, blind),
|
|
370
|
+
region,
|
|
371
|
+
"no residuals",
|
|
372
|
+
"a region must not read the residuals of its query rows",
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
rebuilt = _fitted(from_config(json.loads(json.dumps(score.to_config()))), archive)
|
|
376
|
+
_check(type(rebuilt) is type(score), "config", "the config does not rebuild the score")
|
|
377
|
+
_check(
|
|
378
|
+
torch.equal(rebuilt(calibration), values),
|
|
379
|
+
"config",
|
|
380
|
+
"a score rebuilt from its config gives other scores",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
for device in _devices(devices):
|
|
384
|
+
if device == "cpu":
|
|
385
|
+
continue
|
|
386
|
+
moved = _fitted(factory().to(device), archive.to(device))
|
|
387
|
+
on_device = moved(calibration.to(device)).to("cpu")
|
|
388
|
+
_check(
|
|
389
|
+
torch.allclose(on_device[defined], values[defined], rtol=rtol, atol=atol),
|
|
390
|
+
f"device {device}",
|
|
391
|
+
"scores disagree with CPU",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _scramble_residuals(rows: ResidualArchive, seed: int) -> Tensor:
|
|
396
|
+
generator = torch.Generator().manual_seed(seed)
|
|
397
|
+
noise = torch.randn(rows.residuals.shape, generator=generator).to(rows.residuals)
|
|
398
|
+
if rows.observed is None:
|
|
399
|
+
return noise
|
|
400
|
+
return noise * rows.observed
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# -- weights --------------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def check_weights(
|
|
407
|
+
factory: Callable[[], Weights],
|
|
408
|
+
*,
|
|
409
|
+
archive: ResidualArchive | None = None,
|
|
410
|
+
) -> None:
|
|
411
|
+
"""Check that a weight scheme keeps the weights contract.
|
|
412
|
+
|
|
413
|
+
- **shape:** weights are floating, ``[Q, P]`` or ``[Q, P, ...]`` for ``Q`` query rows and
|
|
414
|
+
``P`` pool rows;
|
|
415
|
+
- **values:** finite and nonnegative;
|
|
416
|
+
- **causality:** changing what is not yet known at a query leaves its weights unchanged;
|
|
417
|
+
- **config:** a weight scheme rebuilt from its config gives the same weights.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
factory: Returns fresh weights; trainable ones are fitted on the fit split.
|
|
421
|
+
archive: Archive to check on; a synthetic one by default.
|
|
422
|
+
"""
|
|
423
|
+
if archive is None:
|
|
424
|
+
archive = synthetic_archive(horizon=2, num_series=2, num_origins=240, seed=4)
|
|
425
|
+
|
|
426
|
+
def weights_of(component, source):
|
|
427
|
+
return component(source.split("calibration"), source.split("test"))
|
|
428
|
+
|
|
429
|
+
weights = _fitted(factory(), archive)
|
|
430
|
+
values = weights_of(weights, archive)
|
|
431
|
+
pool, query = archive.split("calibration"), archive.split("test")
|
|
432
|
+
_check(
|
|
433
|
+
values.is_floating_point() and values.ndim >= 2,
|
|
434
|
+
"shape",
|
|
435
|
+
"weights must be a floating tensor [Q, P] or [Q, P, ...]",
|
|
436
|
+
)
|
|
437
|
+
_check(
|
|
438
|
+
tuple(values.shape[:2]) == (len(query), len(pool)),
|
|
439
|
+
"shape",
|
|
440
|
+
f"weights start with shape {tuple(values.shape[:2])}, not {(len(query), len(pool))}",
|
|
441
|
+
)
|
|
442
|
+
_check(
|
|
443
|
+
(torch.isfinite(values) & (values >= 0)).all(),
|
|
444
|
+
"values",
|
|
445
|
+
"weights must be finite and nonnegative",
|
|
446
|
+
)
|
|
447
|
+
position = len(query) // 2
|
|
448
|
+
origin = int(query.origins[position])
|
|
449
|
+
later = _scramble(archive, archive.target_times > origin, archive.origins > origin, seed=5)
|
|
450
|
+
changed = weights_of(_fitted(factory(), later), later)
|
|
451
|
+
_check(
|
|
452
|
+
torch.equal(changed[: position + 1], values[: position + 1]),
|
|
453
|
+
"causality",
|
|
454
|
+
f"weights of queries up to origin {origin} change with information that comes later",
|
|
455
|
+
)
|
|
456
|
+
rebuilt = _fitted(from_config(json.loads(json.dumps(weights.to_config()))), archive)
|
|
457
|
+
_check(
|
|
458
|
+
torch.equal(weights_of(rebuilt, archive), values),
|
|
459
|
+
"config",
|
|
460
|
+
"weights rebuilt from their config differ",
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# -- quantile models ------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def check_quantile_model(
|
|
468
|
+
factory: Callable[[], QuantileModel],
|
|
469
|
+
*,
|
|
470
|
+
archive: ResidualArchive | None = None,
|
|
471
|
+
num_queries: int = 8,
|
|
472
|
+
) -> None:
|
|
473
|
+
"""Check that a quantile model keeps the quantile-model contract.
|
|
474
|
+
|
|
475
|
+
With equal and with random weights:
|
|
476
|
+
|
|
477
|
+
- **distribution:** the model returns a :class:`~tscp.quantile.ScoreDistribution` of
|
|
478
|
+
batch shape ``[Q, *kept]``, or ``[*kept]`` when shared by every query;
|
|
479
|
+
- **levels:** several levels at once equal one level at a time;
|
|
480
|
+
- **monotone:** thresholds never increase with ``alpha``;
|
|
481
|
+
- **conventions:** ``alpha <= 0`` gives ``+inf`` and ``alpha >= 1`` gives ``-inf``, which
|
|
482
|
+
the long-run guarantee of the ACI family relies on;
|
|
483
|
+
- **level:** ``level(s)`` is where the threshold stops covering ``s``;
|
|
484
|
+
- **select:** ``select(i)`` is the distribution of query ``i``.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
factory: Returns a fresh quantile model; a trainable one is fitted on the fit split.
|
|
488
|
+
archive: Archive to check on; a synthetic one by default.
|
|
489
|
+
num_queries: Number of query rows.
|
|
490
|
+
"""
|
|
491
|
+
if archive is None:
|
|
492
|
+
archive = synthetic_archive(horizon=2, num_series=2, num_origins=240, seed=6)
|
|
493
|
+
model = _fitted(factory(), archive)
|
|
494
|
+
rows = archive.split("calibration")
|
|
495
|
+
query = archive.split("test").select(torch.arange(num_queries, device=archive.device))
|
|
496
|
+
scores = AbsoluteScore()(rows)
|
|
497
|
+
pool = Pool(scores, rows)
|
|
498
|
+
kept = tuple(scores.shape[1:])
|
|
499
|
+
generator = torch.Generator().manual_seed(0)
|
|
500
|
+
random_weights = 0.1 + torch.rand((num_queries, len(pool)), generator=generator)
|
|
501
|
+
alphas = torch.tensor([0.02, 0.1, 0.3, 0.6], dtype=torch.float64)
|
|
502
|
+
for label, weights in (("equal weights", None), ("random weights", random_weights.double())):
|
|
503
|
+
distribution = model(pool, weights, query)
|
|
504
|
+
_check(
|
|
505
|
+
isinstance(distribution, ScoreDistribution),
|
|
506
|
+
"distribution",
|
|
507
|
+
f"with {label}, the model must return a ScoreDistribution",
|
|
508
|
+
)
|
|
509
|
+
batch = tuple(distribution.batch_shape)
|
|
510
|
+
_check(
|
|
511
|
+
batch in (kept, (num_queries, *kept)),
|
|
512
|
+
"distribution",
|
|
513
|
+
f"with {label}, batch shape {batch} is neither {kept} nor {(num_queries, *kept)}",
|
|
514
|
+
)
|
|
515
|
+
grid = alphas.reshape(-1, *([1] * len(batch)))
|
|
516
|
+
together = distribution.threshold(grid)
|
|
517
|
+
_check(
|
|
518
|
+
tuple(together.shape) == (len(alphas), *batch),
|
|
519
|
+
"levels",
|
|
520
|
+
f"with {label}, thresholds at {len(alphas)} levels have shape {tuple(together.shape)}",
|
|
521
|
+
)
|
|
522
|
+
for index, alpha in enumerate(alphas.tolist()):
|
|
523
|
+
_check(
|
|
524
|
+
torch.equal(together[index], distribution.threshold(alpha)),
|
|
525
|
+
"levels",
|
|
526
|
+
f"with {label}, several levels at once disagree with one level at a time",
|
|
527
|
+
)
|
|
528
|
+
_check(
|
|
529
|
+
(together[:-1] >= together[1:]).all(),
|
|
530
|
+
"monotone",
|
|
531
|
+
f"with {label}, thresholds must not increase with alpha",
|
|
532
|
+
)
|
|
533
|
+
_check(
|
|
534
|
+
torch.isposinf(distribution.threshold(0.0)).all(),
|
|
535
|
+
"conventions",
|
|
536
|
+
f"with {label}, alpha <= 0 must give +inf (an unbounded region)",
|
|
537
|
+
)
|
|
538
|
+
_check(
|
|
539
|
+
torch.isneginf(distribution.threshold(1.0)).all(),
|
|
540
|
+
"conventions",
|
|
541
|
+
f"with {label}, alpha >= 1 must give -inf (an empty region)",
|
|
542
|
+
)
|
|
543
|
+
for row in torch.randperm(len(pool), generator=generator)[:5].tolist():
|
|
544
|
+
probe = scores[row].double()
|
|
545
|
+
level = distribution.level(probe)
|
|
546
|
+
_check(
|
|
547
|
+
((level > 0) & (level <= 1)).all(),
|
|
548
|
+
"level",
|
|
549
|
+
f"with {label}, levels must lie in (0, 1]",
|
|
550
|
+
)
|
|
551
|
+
below, above = level - 1e-6, level + 1e-6
|
|
552
|
+
covered = distribution.threshold(below.clamp(min=1e-9)) >= probe
|
|
553
|
+
missed = distribution.threshold(above.clamp(max=1 - 1e-9)) < probe
|
|
554
|
+
_check(
|
|
555
|
+
(covered | (below <= 0)).all() and (missed | (above >= 1)).all(),
|
|
556
|
+
"level",
|
|
557
|
+
f"with {label}, level(s) must be where the threshold stops covering s",
|
|
558
|
+
)
|
|
559
|
+
if len(batch) == len(kept) + 1:
|
|
560
|
+
for index in (0, num_queries - 1):
|
|
561
|
+
one = distribution.select(index)
|
|
562
|
+
_check(
|
|
563
|
+
tuple(one.batch_shape) == kept
|
|
564
|
+
and torch.equal(one.threshold(0.1), distribution.threshold(0.1)[index]),
|
|
565
|
+
"select",
|
|
566
|
+
f"with {label}, select({index}) must be the distribution of query {index}",
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# -- online updates -------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def check_online_update(
|
|
574
|
+
factory: Callable[[], OnlineUpdate],
|
|
575
|
+
*,
|
|
576
|
+
archives: Sequence[ResidualArchive] | None = None,
|
|
577
|
+
devices: Sequence[str] | None = None,
|
|
578
|
+
atol: float = 1e-6,
|
|
579
|
+
rtol: float = 1e-5,
|
|
580
|
+
) -> None:
|
|
581
|
+
"""Check that an online update keeps the online-update contract.
|
|
582
|
+
|
|
583
|
+
- **method contracts:** :func:`check_method` passes for the update inside a
|
|
584
|
+
:class:`~tscp.methods.ConformalPredictor`, with an absolute score and with a signed
|
|
585
|
+
score whose two channels have their own levels;
|
|
586
|
+
- **reset:** predicting twice with the same instance gives the same regions;
|
|
587
|
+
- **response:** a miss changes later thresholds, and never earlier ones.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
factory: Returns a fresh online update.
|
|
591
|
+
archives: Archives for :func:`check_method`.
|
|
592
|
+
devices: Devices for :func:`check_method`.
|
|
593
|
+
atol: Absolute tolerance for comparing regions across devices.
|
|
594
|
+
rtol: Relative tolerance for comparing regions across devices.
|
|
595
|
+
"""
|
|
596
|
+
|
|
597
|
+
def absolute():
|
|
598
|
+
return ConformalPredictor(AbsoluteScore(), source=HoldoutSource(), update=factory())
|
|
599
|
+
|
|
600
|
+
def signed():
|
|
601
|
+
return ConformalPredictor(SignedScore(), source=HoldoutSource(), update=factory())
|
|
602
|
+
|
|
603
|
+
for method in (absolute, signed):
|
|
604
|
+
check_method(method, archives=archives, devices=devices, atol=atol, rtol=rtol)
|
|
605
|
+
|
|
606
|
+
archive = synthetic_archive(horizon=2, num_origins=240, seed=7)
|
|
607
|
+
method = absolute()
|
|
608
|
+
first = _run(method, archive)
|
|
609
|
+
_same_regions(method.predict(archive), first, "reset", "reset() must clear the state")
|
|
610
|
+
|
|
611
|
+
test_rows = archive.splits["test"]
|
|
612
|
+
spike = len(test_rows) // 3
|
|
613
|
+
residuals = archive.residuals.clone()
|
|
614
|
+
residuals[test_rows[spike]] = 1e6
|
|
615
|
+
spiked = _run(absolute(), replace(archive, residuals=residuals))
|
|
616
|
+
differs = torch.zeros(len(test_rows), dtype=torch.bool)
|
|
617
|
+
for name, value in first.tensors().items():
|
|
618
|
+
differs |= (value != spiked.tensors()[name]).reshape(len(test_rows), -1).any(dim=1)
|
|
619
|
+
_check(
|
|
620
|
+
not differs[: spike + 1].any(),
|
|
621
|
+
"causality",
|
|
622
|
+
"a miss changes thresholds before its outcome is known",
|
|
623
|
+
)
|
|
624
|
+
_check(
|
|
625
|
+
differs[spike + 1 :].any(),
|
|
626
|
+
"response",
|
|
627
|
+
"a miss never changes a later threshold; an online update must react to its outcomes",
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
# -- regions --------------------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def check_region(
|
|
635
|
+
factory: Callable[[], Region],
|
|
636
|
+
*,
|
|
637
|
+
residual_shape: tuple[int, int, int] = (6, 3, 2),
|
|
638
|
+
enclosing: tuple[Tensor, Tensor] | None = None,
|
|
639
|
+
samples: int = 4000,
|
|
640
|
+
devices: Sequence[str] | None = None,
|
|
641
|
+
) -> None:
|
|
642
|
+
"""Check that a region type keeps the region contract.
|
|
643
|
+
|
|
644
|
+
- **shapes:** the batch shape is ``[Q, *kept]`` for residuals of ``residual_shape``,
|
|
645
|
+
:attr:`~tscp.regions.Region.dimension` is the number of coordinates covered jointly,
|
|
646
|
+
and :meth:`~tscp.regions.Region.contains` and
|
|
647
|
+
:meth:`~tscp.regions.Region.log_volume` answer per region;
|
|
648
|
+
- **shift:** :meth:`~tscp.regions.Region.to_target` keeps membership and volume;
|
|
649
|
+
- **indexing:** selecting regions selects their tensors along the batch dimensions;
|
|
650
|
+
- **devices:** the regions move to every device and back unchanged;
|
|
651
|
+
- **volume:** with ``enclosing`` bounds, a Monte Carlo estimate of each bounded region's
|
|
652
|
+
volume agrees with ``log_volume`` within five standard errors.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
factory: Returns the regions to check.
|
|
656
|
+
residual_shape: ``(Q, H, N)`` of the residuals the regions are for.
|
|
657
|
+
enclosing: ``(lower, upper)`` of boxes that contain the regions, each
|
|
658
|
+
``[*batch, *event]``, for the volume check.
|
|
659
|
+
samples: Monte Carlo samples per region.
|
|
660
|
+
devices: Devices to check; CPU, and CUDA when it is available, by default.
|
|
661
|
+
"""
|
|
662
|
+
region = factory()
|
|
663
|
+
num_queries, horizon, num_series = residual_shape
|
|
664
|
+
sizes = dict(zip(RESIDUAL_DIMS, (horizon, num_series), strict=True))
|
|
665
|
+
kept = [sizes[name] for name in RESIDUAL_DIMS if name not in region.event_dims]
|
|
666
|
+
_check(
|
|
667
|
+
tuple(region.batch_shape) == (num_queries, *kept),
|
|
668
|
+
"shapes",
|
|
669
|
+
f"batch shape {tuple(region.batch_shape)} is not {(num_queries, *kept)}",
|
|
670
|
+
)
|
|
671
|
+
joint = math.prod(sizes[name] for name in region.event_dims)
|
|
672
|
+
_check(region.dimension == joint, "shapes", f"dimension {region.dimension} is not {joint}")
|
|
673
|
+
generator = torch.Generator().manual_seed(0)
|
|
674
|
+
points = torch.randn(residual_shape, generator=generator)
|
|
675
|
+
covered = region.contains(points)
|
|
676
|
+
_check(
|
|
677
|
+
covered.dtype == torch.bool and covered.shape == region.batch_shape,
|
|
678
|
+
"shapes",
|
|
679
|
+
"contains() must return a bool tensor of the batch shape",
|
|
680
|
+
)
|
|
681
|
+
log_volume = region.log_volume()
|
|
682
|
+
_check(
|
|
683
|
+
log_volume.shape == region.batch_shape and not log_volume.isnan().any(),
|
|
684
|
+
"shapes",
|
|
685
|
+
"log_volume() must return a tensor of the batch shape without NaN",
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
forecasts = 10 * torch.randn(residual_shape, generator=generator)
|
|
689
|
+
shifted = region.to_target(forecasts)
|
|
690
|
+
_check(
|
|
691
|
+
type(shifted) is type(region) and shifted.event_dims == region.event_dims,
|
|
692
|
+
"shift",
|
|
693
|
+
"to_target() must return a region of the same type",
|
|
694
|
+
)
|
|
695
|
+
_check(
|
|
696
|
+
torch.equal(shifted.contains(points + forecasts), covered),
|
|
697
|
+
"shift",
|
|
698
|
+
"a shifted region must cover the shifted points",
|
|
699
|
+
)
|
|
700
|
+
finite = torch.isfinite(log_volume)
|
|
701
|
+
_check(
|
|
702
|
+
torch.allclose(shifted.log_volume()[finite], log_volume[finite], atol=1e-4)
|
|
703
|
+
and torch.equal(shifted.log_volume()[~finite], log_volume[~finite]),
|
|
704
|
+
"shift",
|
|
705
|
+
"shifting must not change the volume",
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
tensors = region.tensors()
|
|
709
|
+
mask = torch.arange(num_queries) % 2 == 0
|
|
710
|
+
for index in (1, slice(1, 3), mask):
|
|
711
|
+
selected = region[index]
|
|
712
|
+
_check(
|
|
713
|
+
type(selected) is type(region)
|
|
714
|
+
and all(torch.equal(v, tensors[k][index]) for k, v in selected.tensors().items()),
|
|
715
|
+
"indexing",
|
|
716
|
+
f"region[{index!r}] must select the tensors along the batch dimensions",
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
for device in _devices(devices):
|
|
720
|
+
back = region.to(device).to("cpu")
|
|
721
|
+
_check(
|
|
722
|
+
all(torch.equal(v, tensors[k]) for k, v in back.tensors().items()),
|
|
723
|
+
f"device {device}",
|
|
724
|
+
"moving the regions changed them",
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
if enclosing is not None:
|
|
728
|
+
lower, upper = enclosing
|
|
729
|
+
uniform = torch.rand(samples, *lower.shape, generator=generator).to(lower)
|
|
730
|
+
drawn = restore_event_dims(lower + uniform * (upper - lower), region.event_dims)
|
|
731
|
+
inside = torch.stack([region.contains(point) for point in drawn]).double().mean(dim=0)
|
|
732
|
+
width = (upper - lower).reshape(*region.batch_shape, -1).double()
|
|
733
|
+
enclosing_volume = width.log().sum(dim=-1).exp()
|
|
734
|
+
estimate = inside * enclosing_volume
|
|
735
|
+
error = (inside * (1 - inside) / samples).sqrt() * enclosing_volume
|
|
736
|
+
exact = log_volume.double().exp()
|
|
737
|
+
_check(
|
|
738
|
+
((estimate - exact).abs() <= 5 * error + 1e-9)[finite].all(),
|
|
739
|
+
"volume",
|
|
740
|
+
"Monte Carlo volumes disagree with log_volume()",
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# -- feature maps ---------------------------------------------------------------------------
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def check_feature_map(
|
|
748
|
+
factory: Callable[[], FeatureMap],
|
|
749
|
+
*,
|
|
750
|
+
archive: ResidualArchive | None = None,
|
|
751
|
+
devices: Sequence[str] | None = None,
|
|
752
|
+
atol: float = 1e-6,
|
|
753
|
+
rtol: float = 1e-5,
|
|
754
|
+
) -> None:
|
|
755
|
+
"""Check that a feature map keeps the feature-map contract.
|
|
756
|
+
|
|
757
|
+
- **shape:** features are floating, ``[T, *element, F]`` with element ``()``, ``(N,)``
|
|
758
|
+
or ``(H, N)``;
|
|
759
|
+
- **values:** finite, with what is unknown filled in and marked invalid;
|
|
760
|
+
- **validity:** ``valid`` is None or a bool mask ``[T, *element]``;
|
|
761
|
+
- **causality:** changing what is not yet known at an origin leaves the features of the
|
|
762
|
+
rows up to that origin unchanged;
|
|
763
|
+
- **config** and **devices**, as in :func:`check_method`.
|
|
764
|
+
|
|
765
|
+
Args:
|
|
766
|
+
factory: Returns a fresh feature map; a trainable one is fitted on the fit split.
|
|
767
|
+
archive: Archive to check on; by default a synthetic archive over three steps and
|
|
768
|
+
two series, with context, timestamps and missing targets.
|
|
769
|
+
devices: Devices to check; CPU, and CUDA when it is available, by default.
|
|
770
|
+
atol: Absolute tolerance for comparing features across devices.
|
|
771
|
+
rtol: Relative tolerance for comparing features across devices.
|
|
772
|
+
"""
|
|
773
|
+
if archive is None:
|
|
774
|
+
archive = synthetic_archive(horizon=3, num_series=2, num_origins=240, missing=0.05, seed=8)
|
|
775
|
+
feature_map = _fitted(factory(), archive)
|
|
776
|
+
rows = archive.split("test")
|
|
777
|
+
values = feature_map(rows)
|
|
778
|
+
allowed = [(), (archive.num_series,), (archive.horizon, archive.num_series)]
|
|
779
|
+
element = tuple(values.shape[1:-1])
|
|
780
|
+
_check(
|
|
781
|
+
values.is_floating_point() and values.ndim >= 2 and values.shape[0] == len(rows),
|
|
782
|
+
"shape",
|
|
783
|
+
f"features must be a floating tensor [T, *element, F], not {tuple(values.shape)}",
|
|
784
|
+
)
|
|
785
|
+
_check(element in allowed, "shape", f"element {element} is none of {allowed}")
|
|
786
|
+
_check(
|
|
787
|
+
torch.isfinite(values).all(),
|
|
788
|
+
"values",
|
|
789
|
+
"features must be finite: fill what is unknown and mark it invalid",
|
|
790
|
+
)
|
|
791
|
+
valid = feature_map.valid(rows)
|
|
792
|
+
if valid is not None:
|
|
793
|
+
_check(
|
|
794
|
+
valid.dtype == torch.bool and tuple(valid.shape) == (len(rows), *element),
|
|
795
|
+
"validity",
|
|
796
|
+
f"valid() must return None or a bool mask of shape {(len(rows), *element)}",
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
position = len(rows) // 2
|
|
800
|
+
origin = int(rows.origins[position])
|
|
801
|
+
later = _scramble(archive, archive.target_times > origin, archive.origins > origin, seed=9)
|
|
802
|
+
changed = _fitted(factory(), later)(later.split("test"))
|
|
803
|
+
_check(
|
|
804
|
+
torch.equal(changed[: position + 1], values[: position + 1]),
|
|
805
|
+
"causality",
|
|
806
|
+
f"features of rows up to origin {origin} change with information that comes later",
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
rebuilt = _fitted(from_config(json.loads(json.dumps(feature_map.to_config()))), archive)
|
|
810
|
+
_check(torch.equal(rebuilt(rows), values), "config", "a rebuilt feature map differs")
|
|
811
|
+
|
|
812
|
+
for device in _devices(devices):
|
|
813
|
+
if device == "cpu":
|
|
814
|
+
continue
|
|
815
|
+
moved = _fitted(factory().to(device), archive.to(device))(rows.to(device)).to("cpu")
|
|
816
|
+
_check(
|
|
817
|
+
torch.allclose(moved, values, rtol=rtol, atol=atol),
|
|
818
|
+
f"device {device}",
|
|
819
|
+
"features disagree with CPU",
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
__all__ = [
|
|
824
|
+
"assert_regions_equal",
|
|
825
|
+
"check_feature_map",
|
|
826
|
+
"check_method",
|
|
827
|
+
"check_online_update",
|
|
828
|
+
"check_quantile_model",
|
|
829
|
+
"check_region",
|
|
830
|
+
"check_score",
|
|
831
|
+
"check_weights",
|
|
832
|
+
]
|