regscan 0.1.0__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.
regscan/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ """Regression-based scan statistics for interval anomalies in 1-D signals.
2
+
3
+ Notation: ``n`` is the signal length, ``w`` the window width (kernel
4
+ bandwidth for ``F_KR``), ``r = 3w`` the longest interval considered, and ``d``
5
+ the polynomial degree for ``F_d``. A candidate interval is ``[a, b]``,
6
+ inclusive.
7
+
8
+ Reference: Rakib et al., "Efficient Regression Models for Scan Statistics",
9
+ arXiv:2608.22201 (2026). https://arxiv.org/abs/2608.22201
10
+
11
+ Quick start
12
+ -----------
13
+ >>> import numpy as np, regscan
14
+ >>> x = np.random.default_rng(0).normal(0, 1, 400)
15
+ >>> x[150:180] -= 4.0
16
+ >>> res = regscan.scan(x, method="poly_deg1")
17
+ >>> res.a, res.b, round(res.score, 2) # doctest: +SKIP
18
+ (150, 179, 0.72)
19
+ """
20
+
21
+ from .api import scan, scan_many
22
+ from .config import ScanConfig
23
+ from .families import deramp, epidemic_score
24
+ from .registry import available, describe
25
+ from .result import ScanResult
26
+ from .window import default_width, range_cap, sr_factor, window_params
27
+
28
+ __version__ = "0.1.0"
29
+ __all__ = [
30
+ "ScanConfig",
31
+ "ScanResult",
32
+ "__version__",
33
+ "available",
34
+ "default_width",
35
+ "deramp",
36
+ "describe",
37
+ "epidemic_score",
38
+ "range_cap",
39
+ "scan",
40
+ "scan_many",
41
+ "sr_factor",
42
+ "window_params",
43
+ ]
regscan/api.py ADDED
@@ -0,0 +1,53 @@
1
+ """Public entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+
7
+ import numpy as np
8
+
9
+ from .config import ScanConfig
10
+ from .registry import get
11
+ from .result import ScanResult
12
+ from .window import default_width
13
+
14
+
15
+ def scan(x, *, method: str = "nwkr_gaussian", w: int | None = None,
16
+ r: int | None = None, config: ScanConfig | None = None) -> ScanResult:
17
+ """Scan one signal for the interval that best explains it.
18
+
19
+ Parameters
20
+ ----------
21
+ x: the signal.
22
+ method: a name from :func:`regscan.available`.
23
+ w: window width -- the kernel bandwidth for ``F_KR``, and the scale of
24
+ structure any family's fit can follow. Defaults to ``max(3, n // 16)``.
25
+ Pass it explicitly when you know the scale of the feature you are
26
+ after; the default is a fallback, not a recommendation.
27
+ r: the longest interval the scan will consider, in samples. Defaults to
28
+ ``3 * w``. An anomaly wider than *r* cannot be returned, so raise it
29
+ when looking for broad features; lower it to cut cost, which falls
30
+ linearly in *r*.
31
+ config: a :class:`ScanConfig`; defaults are used when omitted.
32
+ """
33
+ cfg = config or ScanConfig()
34
+ x = np.asarray(x, dtype=float)
35
+ if x.ndim != 1:
36
+ raise ValueError(f"x must be 1-D, got shape {x.shape}")
37
+ if not np.isfinite(x).any():
38
+ raise ValueError("x is entirely non-finite")
39
+ if w is None:
40
+ w = default_width(x.size)
41
+ elif int(w) < 1:
42
+ raise ValueError(f"w must be >= 1, got {w}")
43
+ if r is not None and int(r) < 1:
44
+ raise ValueError(f"r must be >= 1, got {r}")
45
+ fn = get(method)
46
+ score, (a, b) = fn(x, w, r, cfg=cfg)
47
+ return ScanResult(float(score), int(a), int(b), method, int(x.size))
48
+
49
+
50
+ def scan_many(signals: Iterable[Sequence[float]], *,
51
+ method: str = "nwkr_gaussian", **kw) -> list[ScanResult]:
52
+ """Scan each signal in turn. Signals may differ in length."""
53
+ return [scan(s, method=method, **kw) for s in signals]
regscan/config.py ADDED
@@ -0,0 +1,92 @@
1
+ """Scan configuration.
2
+
3
+ Kernel choice and scan bounds are values, not module-level globals. A global
4
+ mutated by a setter is fine in a script and wrong in a library: two callers in
5
+ one process, or two methods scanned in sequence inside one worker, silently
6
+ share state and contaminate each other's results.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, replace
12
+ from typing import Literal
13
+
14
+ KernelKind = Literal["gaussian", "laplace"]
15
+
16
+ #: Kernels are truncated at ``TRUNCATION * r`` where r is the bandwidth, so a
17
+ #: Gaussian is evaluated over roughly +-3 sigma.
18
+ TRUNCATION = 3
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ScanConfig:
23
+ """Immutable parameters for one scan.
24
+
25
+ Parameters
26
+ ----------
27
+ kernel:
28
+ ``"gaussian"`` uses ``exp(-(i-j)^2 / w^2)``, ``"laplace"`` uses
29
+ ``exp(-|i-j| / w)``. Only used by the kernel family.
30
+ buffer:
31
+ Channels excluded at each end of the signal. Candidate intervals are
32
+ never placed there. ``0`` means the whole signal is searchable.
33
+
34
+ Note this *excises* the samples rather than merely forbidding an
35
+ interval from starting there: the fit, ``SR_A`` and the outside
36
+ residuals are all computed on the trimmed signal.
37
+
38
+ A non-zero buffer suppresses detections at the ends of the signal, so
39
+ when comparing families give them all the same value -- a buffer on
40
+ one and not another is not a like-for-like comparison.
41
+ min_width, max_width:
42
+ Bounds on the candidate interval length, as a fraction of the signal.
43
+ Intervals approaching half the signal stop discriminating (inside and
44
+ outside become comparable), so capping is usually wise.
45
+ truncation:
46
+ Kernel support in units of bandwidth: weights beyond ``truncation * w``
47
+ are treated as zero. At 3 a Gaussian has decayed to e^-9 there, so the
48
+ discarded tail is far below numerical noise.
49
+ super_resolution:
50
+ ``1`` (the default) scans every sample. A larger integer block-means
51
+ the signal by that factor, scans the shorter version, then searches
52
+ the original samples around the winning blocks to recover the
53
+ endpoints. ``"auto"`` picks the factor from the signal length via
54
+ :func:`regscan.sr_factor`.
55
+
56
+ This trades exactness for speed, and the saving is large: a factor of
57
+ 4 ran roughly 39x faster on a 1600-sample signal and returned the same
58
+ interval. But the coarse pass locates each endpoint only to within a
59
+ block, and a wrong block is a wrong answer, so the default is exact.
60
+ Compare the two on your own data before enabling it.
61
+ sr_base:
62
+ Signal length below which ``"auto"`` chooses a factor of 1.
63
+ sr_cap:
64
+ Upper bound on the automatically chosen factor.
65
+ """
66
+
67
+ kernel: KernelKind = "gaussian"
68
+ buffer: int = 0
69
+ min_width: float = 0.0
70
+ max_width: float = 1.0
71
+ truncation: int = TRUNCATION
72
+ super_resolution: int | str = 1
73
+ sr_base: int = 450
74
+ sr_cap: int | None = None
75
+
76
+ def __post_init__(self) -> None:
77
+ if self.kernel not in ("gaussian", "laplace"):
78
+ raise ValueError(f"kernel must be gaussian or laplace, got {self.kernel!r}")
79
+ if self.buffer < 0:
80
+ raise ValueError("buffer must be >= 0")
81
+ if not 0.0 <= self.min_width <= self.max_width <= 1.0:
82
+ raise ValueError("need 0 <= min_width <= max_width <= 1")
83
+ if self.truncation < 1:
84
+ raise ValueError("truncation must be >= 1")
85
+ if self.super_resolution != "auto" and (
86
+ not isinstance(self.super_resolution, int) or self.super_resolution < 1
87
+ ):
88
+ raise ValueError('super_resolution must be a positive int or "auto"')
89
+
90
+ def evolve(self, **kw) -> ScanConfig:
91
+ """Return a copy with some fields changed."""
92
+ return replace(self, **kw)
regscan/families.py ADDED
@@ -0,0 +1,235 @@
1
+ """Function families and the scan statistic itself.
2
+
3
+ For an interval ``I = [a, b]`` and a family ``F``::
4
+
5
+ S(I) = 1 - (SR_I + SR_O) / SR_A
6
+
7
+ where SR_A, SR_I, SR_O are sums of squared residuals from fitting F to the
8
+ whole signal, to the inside of I, and to the outside. S is 0 when splitting
9
+ buys nothing and approaches 1 when the split explains the signal entirely.
10
+
11
+ Because every family divides by its *own* SR_A, scores are comparable across
12
+ intervals within a family but **not** across families: the same interval
13
+ scores differently under a constant fit than a kernel fit, since the kernel's
14
+ SR_A is already smaller. Compare families by rank and interval agreement.
15
+
16
+ This module holds ``F_0`` (constant) and ``F_d`` (polynomial), which are cheap
17
+ enough to implement directly with prefix sums. ``F_KR`` lives in
18
+ :mod:`regscan.kernel`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import numpy as np
24
+
25
+ from .config import ScanConfig
26
+
27
+
28
+ def _sse_constant(ps1: np.ndarray, ps2: np.ndarray, lo: int, hi: int) -> float:
29
+ """SSE of a constant fit to x[lo:hi] via prefix sums, in O(1)."""
30
+ k = hi - lo
31
+ if k <= 0:
32
+ return 0.0
33
+ s1 = ps1[hi] - ps1[lo]
34
+ s2 = ps2[hi] - ps2[lo]
35
+ return max(0.0, float(s2 - s1 * s1 / k))
36
+
37
+
38
+ def _sse_outside_constant(ps1, ps2, n, a, b) -> float:
39
+ k = n - (b - a)
40
+ if k <= 0:
41
+ return 0.0
42
+ s1 = (ps1[a] - ps1[0]) + (ps1[n] - ps1[b])
43
+ s2 = (ps2[a] - ps2[0]) + (ps2[n] - ps2[b])
44
+ return max(0.0, float(s2 - s1 * s1 / k))
45
+
46
+
47
+ class _PolyMoments:
48
+ """Precomputed moment prefix sums for degree-*d* least squares.
49
+
50
+ Fitting a degree-d polynomial to a contiguous range needs only the moment
51
+ sums over that range::
52
+
53
+ M[k, l] = sum t^(k+l) v[k] = sum t^k y sy2 = sum y^2
54
+
55
+ Prefix sums over ``t^p`` for p up to 2d and over ``t^p y`` for p up to d
56
+ make every one of those an O(1) lookup, so a fit costs O(d^2) to assemble
57
+ plus O(d^3) to solve -- independent of how many samples the range holds.
58
+ Without this the cost is O(m) per fit and the scan degrades to O(n r m).
59
+
60
+ Moments are additive over disjoint ranges, so the outside of an interval
61
+ (two flanks) costs the same as the inside.
62
+
63
+ ``t`` is normalised to [0, 1). Raw indices would put ``t^(2d)`` at 1920^6
64
+ for a 1920-sample signal at d=3, which is representable but badly
65
+ conditioned; normalising keeps every moment O(1) in magnitude. A small
66
+ ridge on the diagonal covers the remaining ill-conditioning of the
67
+ Hilbert-like moment matrix.
68
+ """
69
+
70
+ __slots__ = ("_sy2", "_tp", "_tpy", "degree", "n", "reg")
71
+
72
+ def __init__(self, x: np.ndarray, degree: int, reg: float = 1e-10):
73
+ y = np.asarray(x, dtype=float)
74
+ n = y.size
75
+ self.degree = int(degree)
76
+ self.reg = float(reg)
77
+ self.n = n
78
+ t = np.arange(n, dtype=float) / max(n, 1)
79
+
80
+ powers = [np.ones(n)]
81
+ for _ in range(2 * self.degree):
82
+ powers.append(powers[-1] * t)
83
+ self._tp = [np.concatenate([[0.0], np.cumsum(p)]) for p in powers]
84
+ self._tpy = [np.concatenate([[0.0], np.cumsum(powers[k] * y)])
85
+ for k in range(self.degree + 1)]
86
+ self._sy2 = np.concatenate([[0.0], np.cumsum(y * y)])
87
+
88
+ def _blocks(self, ranges):
89
+ d = self.degree
90
+ m = np.empty((d + 1, d + 1))
91
+ v = np.empty(d + 1)
92
+ sy2 = 0.0
93
+ count = 0
94
+ raw_t = [0.0] * (2 * d + 1)
95
+ raw_ty = [0.0] * (d + 1)
96
+ for lo, hi in ranges: # hi exclusive
97
+ if lo >= hi:
98
+ continue
99
+ count += hi - lo
100
+ for p in range(2 * d + 1):
101
+ raw_t[p] += self._tp[p][hi] - self._tp[p][lo]
102
+ for k in range(d + 1):
103
+ raw_ty[k] += self._tpy[k][hi] - self._tpy[k][lo]
104
+ sy2 += self._sy2[hi] - self._sy2[lo]
105
+ for k in range(d + 1):
106
+ v[k] = raw_ty[k]
107
+ for l in range(d + 1):
108
+ m[k, l] = raw_t[k + l]
109
+ return m, v, sy2, count
110
+
111
+ def sse(self, ranges) -> float:
112
+ """SSE of the least-squares fit over the union of *ranges*."""
113
+ d = self.degree
114
+ m, v, sy2, count = self._blocks(ranges)
115
+ if count <= d + 1:
116
+ return 0.0
117
+ m.flat[:: d + 2] += self.reg
118
+ try:
119
+ alpha = np.linalg.solve(m, v)
120
+ except np.linalg.LinAlgError:
121
+ return float(sy2 - (v[0] ** 2 / count if count else 0.0))
122
+ # At the least-squares solution M a = v, so a^T M a = a . v and
123
+ # SSE = sum y^2 - 2 a.v + a^T M a collapses to sum y^2 - a.v.
124
+ return max(0.0, float(sy2 - alpha @ v))
125
+
126
+
127
+ def scan_constant(x: np.ndarray, w: int, r: int | None = None,
128
+ cfg: ScanConfig | None = None):
129
+ """Scan with ``F_0``.
130
+
131
+ *w* is unused by the constant model itself; it only sets the default range
132
+ cap ``r = 3w``, the longest interval considered. O(n r) with prefix sums.
133
+ """
134
+ cfg = cfg or ScanConfig()
135
+ x = np.asarray(x, dtype=float)
136
+ n = x.size
137
+ if n < 4:
138
+ return 0.0, (0, 0)
139
+
140
+ ps1 = np.concatenate([[0.0], np.cumsum(x)])
141
+ ps2 = np.concatenate([[0.0], np.cumsum(x * x)])
142
+ sra = _sse_constant(ps1, ps2, 0, n)
143
+ if sra <= 1e-18:
144
+ return 0.0, (0, 0)
145
+
146
+ r = 3 * int(w) if r is None else int(r)
147
+ lo_bound = cfg.buffer
148
+ hi_bound = n - cfg.buffer
149
+ w_min = max(1, int(cfg.min_width * n))
150
+ w_max = min(r, int(cfg.max_width * n), hi_bound - lo_bound)
151
+
152
+ best, best_ab = -np.inf, (0, 0)
153
+ for a in range(lo_bound, hi_bound - w_min + 1):
154
+ b_hi = min(a + w_max, hi_bound)
155
+ for b in range(a + w_min, b_hi + 1):
156
+ s = 1.0 - (
157
+ _sse_constant(ps1, ps2, a, b)
158
+ + _sse_outside_constant(ps1, ps2, n, a, b)
159
+ ) / sra
160
+ if s > best:
161
+ best, best_ab = s, (a, b - 1)
162
+ return (float(best), best_ab) if np.isfinite(best) else (0.0, (0, 0))
163
+
164
+
165
+ def scan_poly(x, w: int, r: int | None = None, degree: int = 1,
166
+ cfg: ScanConfig | None = None, reg: float = 1e-10):
167
+ """Scan with ``F_d``, a degree-*d* polynomial fitted inside and outside.
168
+
169
+ *w* sets the default range cap ``r = 3w``. O(n r d^3) using
170
+ :class:`_PolyMoments`; the per-interval cost does not depend on the
171
+ interval length.
172
+ """
173
+ cfg = cfg or ScanConfig()
174
+ y = np.asarray(x, dtype=float)
175
+ n = y.size
176
+ if n < 2 * (degree + 2):
177
+ return 0.0, (0, 0)
178
+
179
+ mom = _PolyMoments(y, degree, reg)
180
+ sra = mom.sse([(0, n)])
181
+ if sra <= 1e-18:
182
+ return 0.0, (0, 0)
183
+
184
+ r = 3 * int(w) if r is None else int(r)
185
+ lo_b, hi_b = cfg.buffer, n - cfg.buffer
186
+ w_min = max(degree + 2, int(cfg.min_width * n))
187
+ w_max = min(r, int(cfg.max_width * n) or n, hi_b - lo_b)
188
+
189
+ best, best_ab = -np.inf, (0, 0)
190
+ for a in range(lo_b, hi_b - w_min + 1):
191
+ for b in range(a + w_min, min(a + w_max, hi_b) + 1):
192
+ if n - (b - a) <= degree + 1:
193
+ continue
194
+ s = 1.0 - (
195
+ mom.sse([(a, b)]) + mom.sse([(0, a), (b, n)])
196
+ ) / sra
197
+ if s > best:
198
+ best, best_ab = s, (a, b - 1)
199
+ return (float(best), best_ab) if np.isfinite(best) else (0.0, (0, 0))
200
+
201
+
202
+ def deramp(x: np.ndarray) -> np.ndarray:
203
+ """Subtract a global degree-1 fit.
204
+
205
+ Not equivalent to ``F_1``: that fits a separate line inside and outside
206
+ the candidate interval, whereas this fits one line over the whole signal,
207
+ so a real step tilts that line and is partly absorbed before ``F_0`` sees
208
+ it. Cheaper, strictly weaker.
209
+ """
210
+ x = np.asarray(x, dtype=float)
211
+ t = np.arange(x.size, dtype=float)
212
+ good = np.isfinite(x)
213
+ if good.sum() <= 2:
214
+ return x
215
+ return x - np.polyval(np.polyfit(t[good], x[good], 1), t)
216
+
217
+
218
+ def epidemic_score(x: np.ndarray, a: int, b: int) -> float:
219
+ """Score an interval under the constant family.
220
+
221
+ Used to put change-point baselines (which return boundaries, not scores)
222
+ onto the same [0, 1] scale as ``F_0``, so their outputs are comparable.
223
+ """
224
+ x = np.asarray(x, dtype=float)
225
+ n = x.size
226
+ if n == 0 or a > b:
227
+ return float("-inf")
228
+ sra = float(((x - x.mean()) ** 2).sum())
229
+ if sra < 1e-18:
230
+ return 0.0
231
+ ins = x[a : b + 1]
232
+ out = np.concatenate([x[:a], x[b + 1 :]])
233
+ sse_in = float(((ins - ins.mean()) ** 2).sum()) if ins.size else 0.0
234
+ sse_out = float(((out - out.mean()) ** 2).sum()) if out.size else 0.0
235
+ return 1.0 - (sse_in + sse_out) / sra