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
@@ -0,0 +1,103 @@
1
+ """The conformal quantile of (weighted) scores."""
2
+
3
+ import math
4
+
5
+ import torch
6
+ from torch import Tensor
7
+
8
+ from tscp.data import ResidualArchive
9
+ from tscp.quantile.base import Pool, QuantileModel
10
+ from tscp.quantile.distribution import EmpiricalDistribution
11
+ from tscp.registry import register
12
+ from tscp.typing import Alpha
13
+
14
+ _MAX_ELEMENTS = 2**23
15
+
16
+
17
+ def uniform_quantile(scores: Tensor, alpha: Alpha, test_weight: float = 1.0) -> Tensor:
18
+ """Conformal quantile of equally weighted scores.
19
+
20
+ The threshold is the ``ceil((1 - alpha) * (n + w))``-th smallest of the ``n`` pool
21
+ scores, where ``w`` is the test point's weight (1 for split conformal); ``+inf`` when
22
+ that rank exceeds ``n`` or ``alpha <= 0``, and ``-inf`` when ``alpha >= 1``.
23
+
24
+ Args:
25
+ scores: ``[P, *kept]`` pool scores.
26
+ alpha: Miscoverage level; a float, or a tensor that broadcasts against ``kept``
27
+ from the right (for example ``[*kept]`` or ``[Q, *kept]``).
28
+ test_weight: Weight of the test point, relative to 1 per pool score.
29
+
30
+ Returns:
31
+ Thresholds of shape ``broadcast(alpha.shape, kept)``, in the dtype of ``scores``.
32
+ """
33
+ ordered = torch.sort(scores, dim=0).values
34
+ return EmpiricalDistribution(ordered, None, test_weight).threshold(alpha)
35
+
36
+
37
+ def weighted_quantile(
38
+ scores: Tensor, weights: Tensor, alpha: Alpha, test_weight: float = 1.0
39
+ ) -> Tensor:
40
+ """Conformal quantile of weighted scores.
41
+
42
+ For each query the threshold is the smallest pool score ``s`` with
43
+ ``sum(w_i for s_i <= s) / (sum(w_i) + test_weight) >= 1 - alpha``: the
44
+ ``(1 - alpha)``-quantile of the weighted pool with the test point's weight at
45
+ ``+inf`` (Tibshirani et al., NeurIPS 2019; Barber et al., Annals of Statistics 2023).
46
+ It is ``+inf`` when the pool never reaches ``1 - alpha`` or ``alpha <= 0``, and
47
+ ``-inf`` when ``alpha >= 1``. With all weights 1 it equals :func:`uniform_quantile`.
48
+
49
+ Args:
50
+ scores: ``[P, *kept]`` pool scores.
51
+ weights: Nonnegative weights ``[Q, P]``, or broadcastable to ``[Q, P, *kept]``.
52
+ alpha: Miscoverage level; a float, or a tensor broadcastable to ``[Q, *kept]``.
53
+ test_weight: Weight of the test point.
54
+
55
+ Returns:
56
+ Thresholds ``[Q, *kept]``, in the dtype of ``scores``.
57
+ """
58
+ num_scores, kept = scores.shape[0], tuple(scores.shape[1:])
59
+ if weights.ndim < 2 or weights.shape[1] != num_scores:
60
+ raise ValueError("weights must have shape [Q, P] or [Q, P, *kept] with P pool scores")
61
+ num_queries = weights.shape[0]
62
+ alpha = torch.as_tensor(alpha, dtype=torch.float64, device=scores.device)
63
+ alpha = alpha.broadcast_to((num_queries, *kept))
64
+ ordered, order = torch.sort(scores, dim=0)
65
+ out = torch.empty((num_queries, *kept), dtype=scores.dtype, device=scores.device)
66
+ chunk = max(1, _MAX_ELEMENTS // max(1, num_scores * math.prod(kept)))
67
+ for start in range(0, num_queries, chunk):
68
+ stop = min(start + chunk, num_queries)
69
+ part = EmpiricalDistribution.weighted(ordered, order, weights[start:stop], test_weight)
70
+ out[start:stop] = part.threshold(alpha[start:stop])
71
+ return out
72
+
73
+
74
+ @register("quantile", "weighted")
75
+ class WeightedQuantile(QuantileModel):
76
+ """Empirical conformal quantile of (weighted) scores, with the test point at ``+inf``.
77
+
78
+ Returns an :class:`~tscp.quantile.EmpiricalDistribution`: one shared by every query
79
+ when ``weights`` is None, one per query otherwise. Either way the pool is sorted once.
80
+
81
+ Args:
82
+ test_weight: Weight of the test point relative to the pool weights; 1 for split
83
+ conformal and NexCP.
84
+ """
85
+
86
+ def __init__(self, test_weight: float = 1.0):
87
+ super().__init__()
88
+ if not test_weight >= 0:
89
+ raise ValueError("test_weight must be nonnegative")
90
+ self.test_weight = float(test_weight)
91
+
92
+ def forward(
93
+ self, pool: Pool, weights: Tensor | None, query: ResidualArchive
94
+ ) -> EmpiricalDistribution:
95
+ ordered, order = pool.sorted()
96
+ if weights is None:
97
+ return EmpiricalDistribution(ordered, None, self.test_weight)
98
+ if (weights < 0).any() or weights.isnan().any():
99
+ raise ValueError("weights must be nonnegative numbers")
100
+ return EmpiricalDistribution.weighted(ordered, order, weights, self.test_weight)
101
+
102
+
103
+ __all__ = ["WeightedQuantile", "uniform_quantile", "weighted_quantile"]
@@ -0,0 +1,15 @@
1
+ """Axis 6, output: what the user gets, with a membership test and a size."""
2
+
3
+ from .base import RESIDUAL_DIMS, Region, check_event_dims, move_event_dims, restore_event_dims
4
+ from .box import Box
5
+ from .interval import Interval
6
+
7
+ __all__ = [
8
+ "RESIDUAL_DIMS",
9
+ "Box",
10
+ "Interval",
11
+ "Region",
12
+ "check_event_dims",
13
+ "move_event_dims",
14
+ "restore_event_dims",
15
+ ]
tscp/regions/base.py ADDED
@@ -0,0 +1,139 @@
1
+ """The region interface (axis 6, output)."""
2
+
3
+ import math
4
+
5
+ import torch
6
+ from torch import Tensor
7
+
8
+ RESIDUAL_DIMS = ("step", "series")
9
+ """Names of the dimensions of ``[H, N]``, in the order residuals store them."""
10
+
11
+
12
+ def check_event_dims(event_dims) -> tuple[str, ...]:
13
+ """Return ``event_dims`` as a tuple in ``[H, N]`` order, checking the names."""
14
+ event_dims = tuple(event_dims)
15
+ unknown = sorted(set(event_dims) - set(RESIDUAL_DIMS))
16
+ if unknown or len(set(event_dims)) != len(event_dims):
17
+ raise ValueError(f"event_dims must name distinct dimensions of {RESIDUAL_DIMS}")
18
+ return tuple(name for name in RESIDUAL_DIMS if name in event_dims)
19
+
20
+
21
+ def move_event_dims(values: Tensor, event_dims: tuple[str, ...]) -> Tensor:
22
+ """Reorder ``[..., H, N]`` values to ``[..., *kept, *event]``.
23
+
24
+ The dimensions named in ``event_dims`` move to the end and the others stay in front,
25
+ each group in ``[H, N]`` order. This is how a region that covers some dimensions
26
+ jointly lines residuals or forecasts up with its bounds.
27
+ """
28
+ if values.ndim < 2:
29
+ raise ValueError("expected values of shape [..., H, N]")
30
+ lead = values.ndim - 2
31
+ kept = [lead + i for i, name in enumerate(RESIDUAL_DIMS) if name not in event_dims]
32
+ event = [lead + i for i, name in enumerate(RESIDUAL_DIMS) if name in event_dims]
33
+ return values.permute(*range(lead), *kept, *event)
34
+
35
+
36
+ def restore_event_dims(values: Tensor, event_dims: tuple[str, ...]) -> Tensor:
37
+ """Invert :func:`move_event_dims`: reorder ``[..., *kept, *event]`` back to ``[..., H, N]``."""
38
+ if values.ndim < 2:
39
+ raise ValueError("expected values of shape [..., *kept, *event] with two residual dims")
40
+ order = [name for name in RESIDUAL_DIMS if name not in event_dims]
41
+ order += [name for name in RESIDUAL_DIMS if name in event_dims]
42
+ lead = values.ndim - 2
43
+ return values.permute(*range(lead), *(lead + order.index(name) for name in RESIDUAL_DIMS))
44
+
45
+
46
+ class Region:
47
+ """A batch of prediction regions, one per query origin and kept element.
48
+
49
+ Regions live in residual coordinates: a target ``y`` with point forecast ``f`` is
50
+ covered when ``y - f`` lies in the region. Coverage and volume do not change under
51
+ this shift, so evaluation never needs the forecasts; :meth:`to_target` maps a region
52
+ back to target units.
53
+
54
+ A region covers the dimensions of ``[H, N]`` in :attr:`event_dims` jointly and makes
55
+ one region per element of the others. :meth:`contains` always takes the residuals of
56
+ the query rows, ``[Q, H, N]``, and returns one answer per region, of shape
57
+ :attr:`batch_shape`.
58
+
59
+ A subclass stores its regions in tensors whose leading dimensions are the batch
60
+ dimensions, returns them from :meth:`tensors`, and can be rebuilt from them by its
61
+ constructor; indexing and device moves then work without more code. A subclass with
62
+ settings that are not tensors overrides :meth:`_rebuild`.
63
+ """
64
+
65
+ #: Dimensions of ``[H, N]`` that each region covers jointly.
66
+ event_dims: tuple[str, ...] = ()
67
+
68
+ @property
69
+ def batch_shape(self) -> torch.Size:
70
+ """``[Q, *kept]``: one region per query origin and element not covered jointly."""
71
+ raise NotImplementedError
72
+
73
+ @property
74
+ def dimension(self) -> int:
75
+ """Number of coordinates each region covers jointly (1 for an interval)."""
76
+ raise NotImplementedError
77
+
78
+ def tensors(self) -> dict[str, Tensor]:
79
+ """The tensors that define the regions, by constructor argument name."""
80
+ raise NotImplementedError
81
+
82
+ def contains(self, residuals: Tensor) -> Tensor:
83
+ """Return a bool tensor of shape :attr:`batch_shape`, True where covered.
84
+
85
+ Args:
86
+ residuals: ``[Q, H, N]`` residuals (or any points in the region's coordinates).
87
+ """
88
+ raise NotImplementedError
89
+
90
+ def log_volume(self) -> Tensor:
91
+ """Return the log-volume of each region.
92
+
93
+ ``+inf`` means unbounded; ``-inf`` means empty or of zero volume.
94
+ """
95
+ raise NotImplementedError
96
+
97
+ def to_target(self, forecasts: Tensor) -> "Region":
98
+ """Return the regions shifted by the ``[Q, H, N]`` point forecasts, in target units."""
99
+ raise NotImplementedError
100
+
101
+ def _rebuild(self, tensors: dict[str, Tensor]) -> "Region":
102
+ """Return a region of the same type and settings, made of other tensors."""
103
+ return type(self)(**tensors)
104
+
105
+ def __getitem__(self, index) -> "Region":
106
+ """Select regions along the batch dimensions, as ``region[3]`` or ``region[mask]``."""
107
+ items = index if isinstance(index, tuple) else (index,)
108
+ if any(item is Ellipsis for item in items):
109
+ raise IndexError("regions do not support Ellipsis in indices")
110
+ if len(items) > len(self.batch_shape):
111
+ raise IndexError("too many indices: regions are indexed along batch dimensions")
112
+ return self._rebuild({name: value[index] for name, value in self.tensors().items()})
113
+
114
+ def to(self, device: torch.device | str) -> "Region":
115
+ """Return the regions on ``device``."""
116
+ return self._rebuild({name: value.to(device) for name, value in self.tensors().items()})
117
+
118
+ def __len__(self) -> int:
119
+ return self.batch_shape[0]
120
+
121
+ def __repr__(self) -> str:
122
+ parts = [f"batch_shape={tuple(self.batch_shape)}"]
123
+ if self.event_dims:
124
+ parts.append(f"event_dims={self.event_dims}")
125
+ return f"{type(self).__name__}({', '.join(parts)})"
126
+
127
+
128
+ def event_size(shape: torch.Size, event_ndim: int) -> int:
129
+ """Number of coordinates in the last ``event_ndim`` dimensions of ``shape``."""
130
+ return math.prod(shape[len(shape) - event_ndim :]) if event_ndim else 1
131
+
132
+
133
+ __all__ = [
134
+ "RESIDUAL_DIMS",
135
+ "Region",
136
+ "check_event_dims",
137
+ "move_event_dims",
138
+ "restore_event_dims",
139
+ ]
tscp/regions/box.py ADDED
@@ -0,0 +1,82 @@
1
+ """Boxes: an interval per coordinate, covered jointly."""
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+ from tscp.regions.base import Region, check_event_dims, event_size, move_event_dims
7
+
8
+
9
+ class Box(Region):
10
+ """Axis-aligned boxes over the dimensions in ``event_dims``.
11
+
12
+ A box covers a point when every coordinate lies within its bounds. Bounds have shape
13
+ ``[*batch, *event]``: the batch dimensions (the query origin, then whichever of step
14
+ and series is not covered jointly), then the dimensions in ``event_dims``, both in
15
+ ``[H, N]`` order. For example, a box per series over the whole horizon has bounds
16
+ ``[Q, N, H]`` and ``event_dims=("step",)``.
17
+
18
+ The log-volume is the sum of the coordinates' log-widths. A box with an empty or
19
+ zero-width coordinate has log-volume ``-inf``; otherwise any infinite coordinate makes
20
+ it ``+inf``.
21
+
22
+ Args:
23
+ lower: Lower bounds ``[*batch, *event]``, in residual coordinates.
24
+ upper: Upper bounds, the same shape as ``lower``.
25
+ event_dims: The dimensions of ``[H, N]`` each box covers jointly.
26
+ """
27
+
28
+ def __init__(self, lower: Tensor, upper: Tensor, event_dims=("step",)):
29
+ event_dims = check_event_dims(event_dims)
30
+ if not event_dims:
31
+ raise ValueError("a box covers at least one dimension jointly; use an Interval")
32
+ lower = torch.as_tensor(lower)
33
+ upper = torch.as_tensor(upper, device=lower.device)
34
+ if lower.shape != upper.shape:
35
+ raise ValueError("lower and upper must have the same shape")
36
+ if not (lower.is_floating_point() and upper.is_floating_point()):
37
+ raise ValueError("box bounds must be floating tensors")
38
+ if lower.ndim < len(event_dims):
39
+ raise ValueError("bounds must end with one dimension per entry of event_dims")
40
+ self.lower = lower
41
+ self.upper = upper
42
+ self.event_dims = event_dims
43
+
44
+ @property
45
+ def batch_shape(self) -> torch.Size:
46
+ return self.lower.shape[: self.lower.ndim - len(self.event_dims)]
47
+
48
+ @property
49
+ def dimension(self) -> int:
50
+ return event_size(self.lower.shape, len(self.event_dims))
51
+
52
+ def tensors(self) -> dict[str, Tensor]:
53
+ return {"lower": self.lower, "upper": self.upper}
54
+
55
+ def _rebuild(self, tensors: dict[str, Tensor]) -> "Box":
56
+ return Box(tensors["lower"], tensors["upper"], self.event_dims)
57
+
58
+ def _flat(self, values: Tensor) -> Tensor:
59
+ """``[*batch, *event]`` values with the event dimensions flattened into one."""
60
+ return values.reshape(*self.batch_shape, self.dimension)
61
+
62
+ def is_empty(self) -> Tensor:
63
+ """True where some coordinate has ``lower > upper``."""
64
+ return self._flat(self.upper < self.lower).any(dim=-1)
65
+
66
+ def contains(self, residuals: Tensor) -> Tensor:
67
+ points = move_event_dims(residuals, self.event_dims)
68
+ inside = (self.lower <= points) & (points <= self.upper)
69
+ return self._flat(inside).all(dim=-1)
70
+
71
+ def log_volume(self) -> Tensor:
72
+ width = self._flat(self.upper - self.lower)
73
+ log_width = torch.log(width.clamp(min=0)) # -inf for empty or zero-width coordinates
74
+ degenerate = torch.isneginf(log_width).any(dim=-1)
75
+ return torch.where(degenerate, -torch.inf, log_width.sum(dim=-1))
76
+
77
+ def to_target(self, forecasts: Tensor) -> "Box":
78
+ shift = move_event_dims(forecasts, self.event_dims)
79
+ return Box(self.lower + shift, self.upper + shift, self.event_dims)
80
+
81
+
82
+ __all__ = ["Box"]
@@ -0,0 +1,62 @@
1
+ """Intervals, one per element."""
2
+
3
+ import torch
4
+ from torch import Tensor
5
+
6
+ from tscp.regions.base import Region
7
+
8
+
9
+ class Interval(Region):
10
+ """Closed intervals ``[lower, upper]``, one per element of the batch.
11
+
12
+ Bounds may be infinite: ``(-inf, inf)`` is the whole line, and ``lower > upper``
13
+ (for instance ``(inf, -inf)``) is empty.
14
+
15
+ Args:
16
+ lower: Lower bounds, in residual coordinates.
17
+ upper: Upper bounds, the same shape as ``lower``.
18
+ """
19
+
20
+ event_dims = ()
21
+
22
+ def __init__(self, lower: Tensor, upper: Tensor):
23
+ lower = torch.as_tensor(lower)
24
+ upper = torch.as_tensor(upper, device=lower.device)
25
+ if lower.shape != upper.shape:
26
+ raise ValueError("lower and upper must have the same shape")
27
+ if not (lower.is_floating_point() and upper.is_floating_point()):
28
+ raise ValueError("interval bounds must be floating tensors")
29
+ self.lower = lower
30
+ self.upper = upper
31
+
32
+ @property
33
+ def batch_shape(self) -> torch.Size:
34
+ return self.lower.shape
35
+
36
+ @property
37
+ def dimension(self) -> int:
38
+ return 1
39
+
40
+ def tensors(self) -> dict[str, Tensor]:
41
+ return {"lower": self.lower, "upper": self.upper}
42
+
43
+ def is_empty(self) -> Tensor:
44
+ """True where ``lower > upper``."""
45
+ return self.upper < self.lower
46
+
47
+ def width(self) -> Tensor:
48
+ """``upper - lower``, with empty intervals at 0."""
49
+ return torch.where(self.is_empty(), 0.0, self.upper - self.lower)
50
+
51
+ def contains(self, residuals: Tensor) -> Tensor:
52
+ return (self.lower <= residuals) & (residuals <= self.upper)
53
+
54
+ def log_volume(self) -> Tensor:
55
+ width = self.upper - self.lower
56
+ return torch.where(self.is_empty(), -torch.inf, torch.log(width.clamp(min=0)))
57
+
58
+ def to_target(self, forecasts: Tensor) -> "Interval":
59
+ return Interval(self.lower + forecasts, self.upper + forecasts)
60
+
61
+
62
+ __all__ = ["Interval"]
tscp/registry.py ADDED
@@ -0,0 +1,179 @@
1
+ """Short names for components and methods, and plugins that add more.
2
+
3
+ Every built-in component and preset is registered under a kind and a short name, so that
4
+ a config or a spec can name it::
5
+
6
+ from tscp import registry
7
+
8
+ weights = registry.build("weights", "exponential", decay=0.99)
9
+
10
+ A package that adds components registers them the same way, with :func:`register`, and
11
+ declares an entry point in the group ``tscp.plugins`` whose value is the module that does
12
+ the registering. tscp imports those modules when a name or a class is not found, so a
13
+ spec or config that names a plugin's component works once the plugin is installed.
14
+ """
15
+
16
+ import importlib
17
+ import importlib.metadata
18
+ import inspect
19
+ from collections.abc import Callable
20
+ from typing import Any
21
+
22
+ from tscp.utils.config import qualified_name
23
+
24
+ KINDS = ("score", "source", "weights", "quantile", "update", "features", "method")
25
+ """Kinds of registered classes: the component axes, feature maps, and methods."""
26
+
27
+ ENTRY_POINT_GROUP = "tscp.plugins"
28
+ """The entry-point group of plugins: each entry point names a module to import."""
29
+
30
+ _ENTRIES: dict[str, dict[str, type]] = {kind: {} for kind in KINDS}
31
+ _BUILTINS = (
32
+ "tscp.scores",
33
+ "tscp.sources",
34
+ "tscp.weights",
35
+ "tscp.quantile",
36
+ "tscp.online",
37
+ "tscp.features",
38
+ "tscp.methods",
39
+ )
40
+ _LOADED_PLUGINS: set[str] = set()
41
+
42
+
43
+ def _check_kind(kind: str) -> None:
44
+ if kind not in KINDS:
45
+ raise ValueError(f"unknown kind {kind!r}; the kinds are {KINDS}")
46
+
47
+
48
+ def register(kind: str, name: str, *, replace: bool = False) -> Callable[[type], type]:
49
+ """Class decorator that makes a class buildable as ``build(kind, name, ...)``.
50
+
51
+ Names are case-insensitive. Registering a name that another class holds raises a
52
+ ValueError, so two packages never silently override each other or tscp's own
53
+ components; pass ``replace=True`` to take a name on purpose. Registering the same
54
+ class again, as when its module is reloaded, does nothing.
55
+
56
+ Args:
57
+ kind: One of :data:`KINDS`.
58
+ name: The short name.
59
+ replace: Take the name even if another class holds it.
60
+ """
61
+ _check_kind(kind)
62
+ key = name.lower()
63
+
64
+ def decorator(cls: type) -> type:
65
+ current = _ENTRIES[kind].get(key)
66
+ same = current is not None and qualified_name(current) == qualified_name(cls)
67
+ if current is not None and not same and not replace:
68
+ raise ValueError(
69
+ f"{kind} {key!r} is already registered to {qualified_name(current)}; "
70
+ "pass replace=True to take the name"
71
+ )
72
+ _ENTRIES[kind][key] = cls
73
+ return cls
74
+
75
+ return decorator
76
+
77
+
78
+ def entries(kind: str) -> dict[str, type]:
79
+ """The live name-to-class table of a kind.
80
+
81
+ It fills as modules register; unlike :func:`lookup`, reading it imports nothing.
82
+ """
83
+ _check_kind(kind)
84
+ return _ENTRIES[kind]
85
+
86
+
87
+ def _load_builtins() -> None:
88
+ for module in _BUILTINS:
89
+ importlib.import_module(module)
90
+
91
+
92
+ def load_plugins() -> list[str]:
93
+ """Import the module of every installed plugin not imported yet.
94
+
95
+ Returns:
96
+ The names of the entry points imported by this call.
97
+
98
+ Raises:
99
+ ImportError: If a plugin's module fails to import; the message names the plugin.
100
+ """
101
+ loaded = []
102
+ for entry_point in importlib.metadata.entry_points(group=ENTRY_POINT_GROUP):
103
+ if entry_point.name in _LOADED_PLUGINS:
104
+ continue
105
+ try:
106
+ entry_point.load()
107
+ except Exception as error:
108
+ raise ImportError(
109
+ f"the tscp plugin {entry_point.name!r} ({entry_point.value}) failed to load: "
110
+ f"{error}"
111
+ ) from error
112
+ _LOADED_PLUGINS.add(entry_point.name)
113
+ loaded.append(entry_point.name)
114
+ return loaded
115
+
116
+
117
+ def lookup(kind: str, name: str) -> type:
118
+ """Return the class registered as ``name`` for ``kind``.
119
+
120
+ The built-in components are imported first; plugins are imported if the name is
121
+ still unknown.
122
+
123
+ Raises:
124
+ ValueError: If no built-in or installed plugin registers the name.
125
+ """
126
+ _check_kind(kind)
127
+ _load_builtins()
128
+ key = name.lower()
129
+ if key not in _ENTRIES[kind]:
130
+ load_plugins()
131
+ if key not in _ENTRIES[kind]:
132
+ raise ValueError(f"unknown {kind} {name!r}; registered: {sorted(_ENTRIES[kind])}")
133
+ return _ENTRIES[kind][key]
134
+
135
+
136
+ def build(kind: str, name: str, *, ignore_unknown: bool = False, **kwargs: Any) -> Any:
137
+ """Build the class registered as ``name`` for ``kind`` with keyword arguments.
138
+
139
+ Args:
140
+ kind: One of :data:`KINDS`.
141
+ name: A registered name, in any case.
142
+ ignore_unknown: Drop keyword arguments the class does not take, which helps when
143
+ one config sweeps several classes. By default they raise.
144
+ **kwargs: Arguments of the class.
145
+
146
+ Raises:
147
+ ValueError: If the name is not registered.
148
+ TypeError: If an argument is unknown and ``ignore_unknown`` is False.
149
+ """
150
+ cls = lookup(kind, name)
151
+ parameters = inspect.signature(cls.__init__).parameters
152
+ takes_any = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values())
153
+ accepted = [p for p in parameters if p != "self"]
154
+ unknown = [] if takes_any else sorted(set(kwargs) - set(accepted))
155
+ if unknown:
156
+ if not ignore_unknown:
157
+ raise TypeError(f"{cls.__name__} does not take {unknown}; it takes {accepted}")
158
+ kwargs = {key: value for key, value in kwargs.items() if key in accepted}
159
+ return cls(**kwargs)
160
+
161
+
162
+ def names(kind: str) -> list[str]:
163
+ """The registered names of a kind, built-ins and installed plugins included."""
164
+ _check_kind(kind)
165
+ _load_builtins()
166
+ load_plugins()
167
+ return sorted(_ENTRIES[kind])
168
+
169
+
170
+ __all__ = [
171
+ "ENTRY_POINT_GROUP",
172
+ "KINDS",
173
+ "build",
174
+ "entries",
175
+ "load_plugins",
176
+ "lookup",
177
+ "names",
178
+ "register",
179
+ ]
@@ -0,0 +1,9 @@
1
+ """Axis 2, score: how an error becomes a number."""
2
+
3
+ from .absolute import AbsoluteScore
4
+ from .base import Score
5
+ from .cqr import CQRScore
6
+ from .normalized import NormalizedScore
7
+ from .signed import SignedScore
8
+
9
+ __all__ = ["AbsoluteScore", "CQRScore", "NormalizedScore", "Score", "SignedScore"]
@@ -0,0 +1,22 @@
1
+ """The absolute residual."""
2
+
3
+ from torch import Tensor
4
+
5
+ from tscp.data import ResidualArchive
6
+ from tscp.regions import Interval
7
+ from tscp.registry import register
8
+ from tscp.scores.base import Score
9
+
10
+
11
+ @register("score", "absolute")
12
+ class AbsoluteScore(Score):
13
+ """``|r|``, per element; a threshold ``q`` gives the symmetric interval ``[-q, q]``."""
14
+
15
+ def forward(self, rows: ResidualArchive) -> Tensor:
16
+ return rows.residuals.abs()
17
+
18
+ def region(self, threshold: Tensor, rows: ResidualArchive) -> Interval:
19
+ return Interval(-threshold, threshold)
20
+
21
+
22
+ __all__ = ["AbsoluteScore"]