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 +43 -0
- regscan/api.py +53 -0
- regscan/config.py +92 -0
- regscan/families.py +235 -0
- regscan/kernel.py +362 -0
- regscan/kernel_state.py +407 -0
- regscan/py.typed +0 -0
- regscan/registry.py +106 -0
- regscan/result.py +42 -0
- regscan/window.py +85 -0
- regscan-0.1.0.dist-info/METADATA +244 -0
- regscan-0.1.0.dist-info/RECORD +14 -0
- regscan-0.1.0.dist-info/WHEEL +4 -0
- regscan-0.1.0.dist-info/licenses/LICENSE +29 -0
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
|