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/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
+ ]