forecastscope 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.
- forecastscope/__init__.py +5 -0
- forecastscope/api.py +72 -0
- forecastscope/combinations/__init__.py +1 -0
- forecastscope/combinations/context_memory.py +127 -0
- forecastscope/combinations/validated.py +144 -0
- forecastscope/diagnostics/__init__.py +236 -0
- forecastscope/diagnostics/autocorrelation.py +217 -0
- forecastscope/diagnostics/causality.py +183 -0
- forecastscope/diagnostics/changepoints.py +172 -0
- forecastscope/diagnostics/distribution.py +180 -0
- forecastscope/diagnostics/filters.py +201 -0
- forecastscope/diagnostics/fractal.py +206 -0
- forecastscope/diagnostics/nonlinear.py +267 -0
- forecastscope/diagnostics/seasonality.py +218 -0
- forecastscope/diagnostics/sequence.py +130 -0
- forecastscope/diagnostics/stationarity.py +233 -0
- forecastscope/diagnostics/volatility.py +187 -0
- forecastscope/forecasting.py +263 -0
- forecastscope/types.py +12 -0
- forecastscope-0.1.0.dist-info/METADATA +85 -0
- forecastscope-0.1.0.dist-info/RECORD +24 -0
- forecastscope-0.1.0.dist-info/WHEEL +5 -0
- forecastscope-0.1.0.dist-info/licenses/LICENSE +21 -0
- forecastscope-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Nonlinear dynamics and chaos: phase-space embedding, correlation dimension, Lyapunov, RQA, 0-1, surrogates.
|
|
2
|
+
|
|
3
|
+
Is the series the output of a low-dimensional deterministic (possibly chaotic) system, or is it stochastic?
|
|
4
|
+
This distinction bounds forecastability: a chaotic system is deterministic yet has a finite prediction
|
|
5
|
+
horizon (errors grow like e^{lambda t}), while a stochastic one has no such structure to exploit. The tools
|
|
6
|
+
here reconstruct the state space (Takens), measure its dimension (Grassberger-Procaccia) and its sensitivity
|
|
7
|
+
to initial conditions (largest Lyapunov exponent), visualize recurrences (RQA), and - crucially - guard every
|
|
8
|
+
"it's chaotic" claim behind SURROGATE testing, because colored noise can mimic a low correlation dimension.
|
|
9
|
+
|
|
10
|
+
Methods / references (verified 2026-07-04; dossier research-nonlinear-dynamics-2026-07-04.md):
|
|
11
|
+
* Takens time-delay embedding - Takens 1981, LNM 898:366-381, DOI 10.1007/BFb0091924.
|
|
12
|
+
* Correlation dimension D2 (Grassberger-Procaccia) - 1983, Physica D 9:189-208,
|
|
13
|
+
DOI 10.1016/0167-2789(83)90298-1. ``nolds.corr_dim``.
|
|
14
|
+
* Largest Lyapunov exponent - Rosenstein et al. 1993, Physica D 65:117-134,
|
|
15
|
+
DOI 10.1016/0167-2789(93)90009-P. ``nolds.lyap_r``.
|
|
16
|
+
* Recurrence plots + RQA - Eckmann, Kamphorst & Ruelle 1987, EPL 4(9):973-977,
|
|
17
|
+
DOI 10.1209/0295-5075/4/9/004; Marwan et al. 2007, Phys. Rep. 438:237-329,
|
|
18
|
+
DOI 10.1016/j.physrep.2006.11.001. RQA implemented directly (RR/DET/LAM/L_max).
|
|
19
|
+
* IAAFT surrogates - Schreiber & Schmitz 1996, PRL 77:635-638, DOI 10.1103/PhysRevLett.77.635; the
|
|
20
|
+
surrogate-data method: Theiler et al. 1992, Physica D 58:77-94, DOI 10.1016/0167-2789(92)90102-S.
|
|
21
|
+
|
|
22
|
+
HONESTY GATE: Osborne & Provenzale 1989 showed colored noise yields a finite, saturating correlation
|
|
23
|
+
dimension - so a low D2 alone does NOT prove chaos. The unit requires the surrogate test (a nonlinear
|
|
24
|
+
statistic must differ from phase-randomized surrogates) before any determinism/chaos claim.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import warnings
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _clean(x) -> np.ndarray:
|
|
35
|
+
a = np.asarray(x, dtype=float).ravel()
|
|
36
|
+
return a[np.isfinite(a)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def time_delay_embed(x, dim: int = 3, lag: int = 1) -> np.ndarray:
|
|
40
|
+
"""Takens embedding: rows are state vectors [x_t, x_{t+lag}, ..., x_{t+(dim-1)lag}]."""
|
|
41
|
+
a = _clean(x)
|
|
42
|
+
n = len(a) - (dim - 1) * lag
|
|
43
|
+
if n <= 0:
|
|
44
|
+
raise ValueError("series too short for this embedding dim/lag")
|
|
45
|
+
return np.column_stack([a[i * lag: i * lag + n] for i in range(dim)])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ChaosMeasures:
|
|
50
|
+
"""Correlation dimension + largest Lyapunov exponent, with a (surrogate-gated) chaos verdict."""
|
|
51
|
+
|
|
52
|
+
corr_dim: float
|
|
53
|
+
lyap_r: float
|
|
54
|
+
emb_dim: int
|
|
55
|
+
lag: int
|
|
56
|
+
likely_chaotic: bool # lyap_r > 0 AND the surrogate test rejects linearity (set by caller)
|
|
57
|
+
reference: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _seeded_slope(debug, seed: int) -> float:
|
|
61
|
+
"""Fit nolds' exposed log-data with its robust model and an isolated seed."""
|
|
62
|
+
from sklearn.linear_model import LinearRegression, RANSACRegressor
|
|
63
|
+
from sklearn.preprocessing import PolynomialFeatures
|
|
64
|
+
|
|
65
|
+
x, y, _ = debug
|
|
66
|
+
x, y = np.asarray(x), np.asarray(y)
|
|
67
|
+
keep = np.isfinite(x) & np.isfinite(y)
|
|
68
|
+
x, y = x[keep], y[keep]
|
|
69
|
+
if len(x) < 2:
|
|
70
|
+
return float("nan")
|
|
71
|
+
model = RANSACRegressor(LinearRegression(fit_intercept=False), random_state=seed)
|
|
72
|
+
try:
|
|
73
|
+
model.fit(PolynomialFeatures(1).fit_transform(x.reshape(-1, 1)), y)
|
|
74
|
+
return float(model.estimator_.coef_[-1])
|
|
75
|
+
except ValueError:
|
|
76
|
+
return float(np.polyfit(x, y, 1)[0])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def chaos_measures(x, emb_dim: int = 4, lag: int = 1, seed: int = 0) -> ChaosMeasures:
|
|
80
|
+
"""Grassberger-Procaccia correlation dimension + Rosenstein largest Lyapunov exponent (nolds)."""
|
|
81
|
+
import nolds
|
|
82
|
+
|
|
83
|
+
a = _clean(x)
|
|
84
|
+
if not isinstance(seed, int) or not 0 <= seed < 2**32:
|
|
85
|
+
raise ValueError("seed must be a nonnegative 32-bit integer")
|
|
86
|
+
_, d2_debug = nolds.corr_dim(a, emb_dim=emb_dim, lag=lag, fit="poly", debug_data=True)
|
|
87
|
+
d2 = _seeded_slope(d2_debug, seed)
|
|
88
|
+
# Rosenstein largest Lyapunov: positive => sensitive dependence (a chaos signature, not proof)
|
|
89
|
+
try:
|
|
90
|
+
_, lyap_debug = nolds.lyap_r(a, emb_dim=emb_dim, lag=lag, min_tsep=max(5, len(a) // 200),
|
|
91
|
+
fit="poly", debug_data=True)
|
|
92
|
+
lyap = _seeded_slope(lyap_debug, (seed + 1) % 2**32)
|
|
93
|
+
except Exception: # noqa: BLE001
|
|
94
|
+
lyap = float("nan")
|
|
95
|
+
return ChaosMeasures(
|
|
96
|
+
corr_dim=d2, lyap_r=lyap, emb_dim=emb_dim, lag=lag,
|
|
97
|
+
likely_chaotic=False, # only the surrogate-gated report sets this True
|
|
98
|
+
reference="Grassberger & Procaccia 1983, DOI 10.1016/0167-2789(83)90298-1; Rosenstein et al. 1993, DOI 10.1016/0167-2789(93)90009-P",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class RQA:
|
|
104
|
+
"""Recurrence quantification: recurrence rate, determinism, laminarity, longest diagonal line."""
|
|
105
|
+
|
|
106
|
+
recurrence_rate: float # RR: density of recurrence points
|
|
107
|
+
determinism: float # DET: fraction of recurrence points on diagonal lines (predictability)
|
|
108
|
+
laminarity: float # LAM: fraction on vertical lines (intermittency / laminar states)
|
|
109
|
+
l_max: int # longest diagonal line (inversely related to the largest Lyapunov)
|
|
110
|
+
threshold: float
|
|
111
|
+
reference: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def recurrence_quantification(x, emb_dim: int = 3, lag: int = 1, rr_target: float = 0.1,
|
|
115
|
+
l_min: int = 2, max_n: int = 800) -> RQA:
|
|
116
|
+
"""Build the recurrence matrix at a threshold giving ~rr_target density, then quantify it (RQA).
|
|
117
|
+
|
|
118
|
+
Following Marwan et al. 2007: recurrence R(i,j) = 1 iff ||v_i - v_j|| < eps, with eps chosen to hit a
|
|
119
|
+
target recurrence rate. DET = fraction of recurrence points forming diagonal lines of length >= l_min
|
|
120
|
+
(determinism); LAM = fraction forming vertical lines (laminarity); L_max = longest diagonal.
|
|
121
|
+
"""
|
|
122
|
+
v = time_delay_embed(x, dim=emb_dim, lag=lag)
|
|
123
|
+
n = v.shape[0]
|
|
124
|
+
if n > max_n: # truncate (not subsample) to keep diagonals intact:
|
|
125
|
+
v = v[:max_n] # decimating would break the temporal continuity RQA needs
|
|
126
|
+
n = v.shape[0]
|
|
127
|
+
# pairwise Euclidean distances
|
|
128
|
+
diff = v[:, None, :] - v[None, :, :]
|
|
129
|
+
dist = np.sqrt((diff ** 2).sum(axis=2))
|
|
130
|
+
eps = float(np.quantile(dist, rr_target)) # threshold hitting ~rr_target density
|
|
131
|
+
rec = (dist < eps).astype(np.int8)
|
|
132
|
+
np.fill_diagonal(rec, 0) # exclude the trivial main diagonal (LOI)
|
|
133
|
+
rr = float(rec.sum() / (n * (n - 1)))
|
|
134
|
+
# diagonal-line lengths (determinism) and vertical-line lengths (laminarity)
|
|
135
|
+
diag_lengths = _line_lengths_diagonal(rec)
|
|
136
|
+
vert_lengths = _line_lengths_vertical(rec)
|
|
137
|
+
total_points = int(rec.sum())
|
|
138
|
+
det = float(sum(l for l in diag_lengths if l >= l_min) / total_points) if total_points else 0.0
|
|
139
|
+
lam = float(sum(l for l in vert_lengths if l >= l_min) / total_points) if total_points else 0.0
|
|
140
|
+
l_max = int(max(diag_lengths)) if diag_lengths else 0
|
|
141
|
+
return RQA(
|
|
142
|
+
recurrence_rate=rr, determinism=det, laminarity=lam, l_max=l_max, threshold=eps,
|
|
143
|
+
reference="Eckmann, Kamphorst & Ruelle 1987, DOI 10.1209/0295-5075/4/9/004; Marwan et al. 2007, DOI 10.1016/j.physrep.2006.11.001",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _line_lengths_diagonal(rec: np.ndarray) -> list[int]:
|
|
148
|
+
"""Lengths of consecutive-1 runs along all off-main diagonals (both triangles) of the recurrence matrix.
|
|
149
|
+
|
|
150
|
+
The matrix is symmetric, so we count both the positive and negative offsets: this matches the denominator
|
|
151
|
+
(the full recurrence-point count ``rec.sum()``) so DET is the true fraction of points on diagonal lines.
|
|
152
|
+
"""
|
|
153
|
+
n = rec.shape[0]
|
|
154
|
+
lengths: list[int] = []
|
|
155
|
+
for k in range(1, n):
|
|
156
|
+
lengths.extend(_runs(np.diagonal(rec, offset=k))) # upper triangle
|
|
157
|
+
lengths.extend(_runs(np.diagonal(rec, offset=-k))) # lower triangle
|
|
158
|
+
return lengths
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _line_lengths_vertical(rec: np.ndarray) -> list[int]:
|
|
162
|
+
"""Lengths of consecutive-1 runs down each column (vertical lines)."""
|
|
163
|
+
lengths: list[int] = []
|
|
164
|
+
for col in rec.T:
|
|
165
|
+
lengths.extend(_runs(col))
|
|
166
|
+
return lengths
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _runs(v: np.ndarray) -> list[int]:
|
|
170
|
+
"""Lengths of maximal runs of 1s in a 0/1 vector."""
|
|
171
|
+
out: list[int] = []
|
|
172
|
+
run = 0
|
|
173
|
+
for val in v:
|
|
174
|
+
if val:
|
|
175
|
+
run += 1
|
|
176
|
+
elif run:
|
|
177
|
+
out.append(run)
|
|
178
|
+
run = 0
|
|
179
|
+
if run:
|
|
180
|
+
out.append(run)
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def iaaft_surrogate(x, n_iter: int = 100, seed: int = 0) -> np.ndarray:
|
|
185
|
+
"""One IAAFT surrogate: same amplitude distribution AND power spectrum, but linearized dynamics.
|
|
186
|
+
|
|
187
|
+
Schreiber & Schmitz 1996. If a nonlinear statistic on the data differs from an ensemble of these
|
|
188
|
+
surrogates, the difference is genuine nonlinear structure, not an artifact of the spectrum/distribution.
|
|
189
|
+
"""
|
|
190
|
+
a = _clean(x)
|
|
191
|
+
rng = np.random.default_rng(seed)
|
|
192
|
+
sorted_a = np.sort(a)
|
|
193
|
+
amp = np.abs(np.fft.rfft(a))
|
|
194
|
+
s = rng.permutation(a)
|
|
195
|
+
for _ in range(n_iter):
|
|
196
|
+
# match the power spectrum
|
|
197
|
+
S = np.fft.rfft(s)
|
|
198
|
+
S = amp * np.exp(1j * np.angle(S))
|
|
199
|
+
s = np.fft.irfft(S, n=len(a))
|
|
200
|
+
# match the amplitude distribution (rank-order remap)
|
|
201
|
+
ranks = np.argsort(np.argsort(s))
|
|
202
|
+
s = sorted_a[ranks]
|
|
203
|
+
return s
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def zero_one_test(x, n_c: int = 100, seed: int = 0) -> float:
|
|
207
|
+
"""Gottwald-Melbourne 0-1 test for chaos: K near 1 => chaotic, near 0 => regular (Gottwald & Melbourne 2004).
|
|
208
|
+
|
|
209
|
+
For random phases c, drive p_c(n) = sum x_j cos(j c), q_c(n) = sum x_j sin(j c); the mean-square
|
|
210
|
+
displacement M_c(n) grows linearly (K~1) for chaos and stays bounded (K~0) for regular dynamics. K is the
|
|
211
|
+
median asymptotic growth rate over the random phases.
|
|
212
|
+
"""
|
|
213
|
+
a = _clean(x)
|
|
214
|
+
n = len(a)
|
|
215
|
+
rng = np.random.default_rng(seed)
|
|
216
|
+
a = a - a.mean()
|
|
217
|
+
ncut = n // 10
|
|
218
|
+
ks = []
|
|
219
|
+
for _ in range(n_c):
|
|
220
|
+
c = rng.uniform(np.pi / 5, 4 * np.pi / 5)
|
|
221
|
+
j = np.arange(1, n + 1)
|
|
222
|
+
p = np.cumsum(a * np.cos(j * c))
|
|
223
|
+
q = np.cumsum(a * np.sin(j * c))
|
|
224
|
+
M = np.array([np.mean((p[:n - k] - p[k:]) ** 2 + (q[:n - k] - q[k:]) ** 2) for k in range(1, ncut)])
|
|
225
|
+
t = np.arange(1, ncut)
|
|
226
|
+
# correlation of M with linear growth -> K
|
|
227
|
+
if np.std(M) > 0:
|
|
228
|
+
ks.append(float(np.corrcoef(t, M)[0, 1]))
|
|
229
|
+
return float(np.median(ks)) if ks else float("nan")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def nonlinear_report(x, emb_dim: int = 4, lag: int = 1, n_surrogates: int = 19, seed: int = 0) -> dict:
|
|
233
|
+
"""Full nonlinear-dynamics panel WITH the surrogate gate: chaos claims require surrogate rejection."""
|
|
234
|
+
a = _clean(x)
|
|
235
|
+
cm = chaos_measures(a, emb_dim=emb_dim, lag=lag, seed=seed)
|
|
236
|
+
rqa = recurrence_quantification(a, emb_dim=emb_dim, lag=lag)
|
|
237
|
+
k01 = zero_one_test(a, seed=seed)
|
|
238
|
+
# surrogate test: compare the data's correlation dimension to an ensemble of IAAFT surrogates.
|
|
239
|
+
surr_d2 = []
|
|
240
|
+
for i in range(n_surrogates):
|
|
241
|
+
try:
|
|
242
|
+
surrogate_seed = (seed + i + 2) % 2**32
|
|
243
|
+
s = iaaft_surrogate(a, seed=surrogate_seed)
|
|
244
|
+
surr_d2.append(chaos_measures(s, emb_dim=emb_dim, lag=lag, seed=surrogate_seed).corr_dim)
|
|
245
|
+
except Exception: # noqa: BLE001
|
|
246
|
+
warnings.warn("Surrogate diagnostic unavailable", RuntimeWarning, stacklevel=2)
|
|
247
|
+
nonlinear_detected = None
|
|
248
|
+
if surr_d2:
|
|
249
|
+
mu, sd = float(np.mean(surr_d2)), float(np.std(surr_d2))
|
|
250
|
+
# data D2 significantly LOWER than surrogates => genuine (nonlinear) low-dim structure
|
|
251
|
+
nonlinear_detected = bool(sd > 0 and cm.corr_dim < mu - 2 * sd)
|
|
252
|
+
likely_chaotic = bool((cm.lyap_r > 0.01) and (k01 > 0.5) and (nonlinear_detected is True))
|
|
253
|
+
return {
|
|
254
|
+
"n": len(a),
|
|
255
|
+
"seed": seed,
|
|
256
|
+
"fit": "RANSAC with explicit random_state; OLS on no consensus",
|
|
257
|
+
"correlation_dimension": cm.corr_dim,
|
|
258
|
+
"largest_lyapunov": cm.lyap_r,
|
|
259
|
+
"zero_one_K": k01,
|
|
260
|
+
"rqa": {"recurrence_rate": rqa.recurrence_rate, "determinism": rqa.determinism,
|
|
261
|
+
"laminarity": rqa.laminarity, "l_max": rqa.l_max, "reference": rqa.reference},
|
|
262
|
+
"surrogate_test": {"n_surrogates": len(surr_d2), "nonlinear_detected": nonlinear_detected,
|
|
263
|
+
"reference": "Schreiber & Schmitz 1996, DOI 10.1103/PhysRevLett.77.635 (IAAFT); Theiler et al. 1992, DOI 10.1016/0167-2789(92)90102-S"},
|
|
264
|
+
"likely_chaotic": likely_chaotic,
|
|
265
|
+
"verdict_note": "chaos claimed only when Lyapunov>0 AND 0-1 K>0.5 AND surrogate rejects linearity",
|
|
266
|
+
"reference": cm.reference,
|
|
267
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Seasonality analysis: periodogram/Welch dominant period, seasonal strength, MSTL, seasonal subseries.
|
|
2
|
+
|
|
3
|
+
Seasonality is a pattern repeating at a fixed period m (a day, a week, a year). This module answers the four
|
|
4
|
+
questions a forecaster asks before choosing a seasonal model: (1) is there a dominant period at all, and what
|
|
5
|
+
is it? (2) how strong is the seasonality (and is it worth modelling)? (3) are there MULTIPLE seasonal periods
|
|
6
|
+
(e.g. daily + weekly)? (4) what does the per-phase seasonal profile look like? Each answer is delegated to
|
|
7
|
+
its authoritative implementation and wrapped in one stable, NaN-safe API that carries the primary reference.
|
|
8
|
+
|
|
9
|
+
Methods / references (verified 2026-07-04):
|
|
10
|
+
* Periodogram (Schuster 1898) / Welch (1967) - the squared magnitude of the FFT; the tallest non-DC peak
|
|
11
|
+
gives the dominant period. ``scipy.signal.periodogram`` / ``scipy.signal.welch``.
|
|
12
|
+
* Seasonal strength Fs = max(0, 1 - Var(R)/Var(S+R)) from an STL decomposition - Wang, Smith & Hyndman
|
|
13
|
+
2006, DOI 10.1007/s10618-005-0039-x; strength form per FPP3 sec. 4.3 (the F_S >= 0.64 threshold drives
|
|
14
|
+
``nsdiffs``). Re-implemented here so seasonality does not import stationarity.
|
|
15
|
+
* MSTL - multiple-seasonal STL. Bandara, Hyndman & Bergmeir 2021, arXiv:2107.13462. ``statsmodels.MSTL``.
|
|
16
|
+
* Seasonal subseries - the per-phase means + the per-phase series; the standard "month plot" (Cleveland &
|
|
17
|
+
Terpenning 1982, JASA 77(377), DOI 10.1080/01621459.1982.10477766).
|
|
18
|
+
|
|
19
|
+
NOTE: ``scipy.signal.cwt`` is DEPRECATED (1.12) and REMOVED (1.15); wavelet/scalogram seasonality uses
|
|
20
|
+
PyWavelets elsewhere. Here we stay on FFT-based spectral estimation, which scipy keeps.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _clean(x) -> np.ndarray:
|
|
30
|
+
a = np.asarray(x, dtype=float).ravel()
|
|
31
|
+
return a[np.isfinite(a)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Spectrum:
|
|
36
|
+
"""A spectral density estimate (periodogram or Welch) over positive frequencies, with the dominant peak."""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
freqs: np.ndarray
|
|
40
|
+
power: np.ndarray
|
|
41
|
+
dominant_period: float | None
|
|
42
|
+
dominant_power: float
|
|
43
|
+
reference: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def periodogram(x, fs: float = 1.0) -> Spectrum:
|
|
47
|
+
"""Schuster periodogram; the dominant period is 1 / (the tallest non-DC frequency)."""
|
|
48
|
+
from scipy.signal import periodogram as _periodogram
|
|
49
|
+
|
|
50
|
+
a = _clean(x)
|
|
51
|
+
f, pxx = _periodogram(a, fs=fs)
|
|
52
|
+
if len(f) > 1:
|
|
53
|
+
idx = 1 + int(np.argmax(pxx[1:])) # skip the DC (zero-frequency) bin
|
|
54
|
+
dom_freq = float(f[idx])
|
|
55
|
+
dom_power = float(pxx[idx])
|
|
56
|
+
dom_period = (1.0 / dom_freq) if dom_freq > 0 else None
|
|
57
|
+
else:
|
|
58
|
+
dom_period, dom_power = None, 0.0
|
|
59
|
+
return Spectrum(
|
|
60
|
+
name="Periodogram", freqs=f, power=pxx, dominant_period=dom_period, dominant_power=dom_power,
|
|
61
|
+
reference="Schuster 1898, Terrestrial Magnetism 3(1):13-41; scipy.signal.periodogram",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def welch(x, fs: float = 1.0, nperseg: int | None = None) -> Spectrum:
|
|
66
|
+
"""Welch's averaged periodogram (lower variance, coarser resolution) and its dominant period."""
|
|
67
|
+
from scipy.signal import welch as _welch
|
|
68
|
+
|
|
69
|
+
a = _clean(x)
|
|
70
|
+
nperseg = min(nperseg or 256, len(a))
|
|
71
|
+
f, pxx = _welch(a, fs=fs, nperseg=nperseg)
|
|
72
|
+
if len(f) > 1:
|
|
73
|
+
idx = 1 + int(np.argmax(pxx[1:]))
|
|
74
|
+
dom_freq = float(f[idx])
|
|
75
|
+
dom_power = float(pxx[idx])
|
|
76
|
+
dom_period = (1.0 / dom_freq) if dom_freq > 0 else None
|
|
77
|
+
else:
|
|
78
|
+
dom_period, dom_power = None, 0.0
|
|
79
|
+
return Spectrum(
|
|
80
|
+
name="Welch", freqs=f, power=pxx, dominant_period=dom_period, dominant_power=dom_power,
|
|
81
|
+
reference="Welch 1967, IEEE Trans. Audio Electroacoust. 15(2):70-73, DOI 10.1109/TAU.1967.1161901",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def seasonal_strength(x, period: int) -> float:
|
|
86
|
+
"""F_S = max(0, 1 - Var(remainder)/Var(seasonal+remainder)) from a robust STL fit. 0 if too short."""
|
|
87
|
+
from statsmodels.tsa.seasonal import STL
|
|
88
|
+
|
|
89
|
+
a = _clean(x)
|
|
90
|
+
if period < 2 or len(a) < 2 * period:
|
|
91
|
+
return 0.0
|
|
92
|
+
res = STL(a, period=period, robust=True).fit()
|
|
93
|
+
var_r = float(np.var(res.resid))
|
|
94
|
+
var_sr = float(np.var(res.seasonal + res.resid))
|
|
95
|
+
if var_sr <= 0:
|
|
96
|
+
return 0.0
|
|
97
|
+
return float(max(0.0, 1.0 - var_r / var_sr))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Decomp:
|
|
102
|
+
"""An additive decomposition into trend + seasonal + remainder, with the trend/seasonal strength scores."""
|
|
103
|
+
|
|
104
|
+
trend: np.ndarray
|
|
105
|
+
seasonal: np.ndarray
|
|
106
|
+
remainder: np.ndarray
|
|
107
|
+
strength_trend: float
|
|
108
|
+
strength_seasonal: float
|
|
109
|
+
periods: tuple[int, ...]
|
|
110
|
+
reference: str
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _strengths(trend, seasonal, remainder) -> tuple[float, float]:
|
|
114
|
+
var_r = float(np.var(remainder)) or 1e-12
|
|
115
|
+
var_tr = float(np.var(trend + remainder)) or 1e-12
|
|
116
|
+
var_sr = float(np.var(seasonal + remainder)) or 1e-12
|
|
117
|
+
f_t = max(0.0, 1.0 - var_r / var_tr)
|
|
118
|
+
f_s = max(0.0, 1.0 - var_r / var_sr)
|
|
119
|
+
return f_t, f_s
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def stl_decompose(x, period: int) -> Decomp:
|
|
123
|
+
"""STL (Cleveland et al. 1990) additive decomposition at a single ``period``."""
|
|
124
|
+
from statsmodels.tsa.seasonal import STL
|
|
125
|
+
|
|
126
|
+
a = _clean(x)
|
|
127
|
+
if period < 2 or len(a) < 2 * period:
|
|
128
|
+
raise ValueError(f"STL needs len >= 2*period; got len={len(a)}, period={period}")
|
|
129
|
+
res = STL(a, period=period, robust=True).fit()
|
|
130
|
+
f_t, f_s = _strengths(res.trend, res.seasonal, res.resid)
|
|
131
|
+
return Decomp(
|
|
132
|
+
trend=np.asarray(res.trend, dtype=float), seasonal=np.asarray(res.seasonal, dtype=float),
|
|
133
|
+
remainder=np.asarray(res.resid, dtype=float), strength_trend=f_t, strength_seasonal=f_s,
|
|
134
|
+
periods=(int(period),),
|
|
135
|
+
reference="Cleveland, Cleveland, McRae & Terpenning 1990, J. Official Statistics 6(1):3-73 (STL)",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def mstl_decompose(x, periods: list[int]) -> Decomp:
|
|
140
|
+
"""MSTL: multiple-seasonal STL (daily + weekly + ...) additive decomposition."""
|
|
141
|
+
from statsmodels.tsa.seasonal import MSTL
|
|
142
|
+
|
|
143
|
+
a = _clean(x)
|
|
144
|
+
periods = [int(p) for p in periods if int(p) >= 2]
|
|
145
|
+
if not periods:
|
|
146
|
+
raise ValueError("MSTL needs at least one period >= 2")
|
|
147
|
+
if len(a) < 2 * max(periods):
|
|
148
|
+
raise ValueError(f"MSTL needs len >= 2*max(periods); got len={len(a)}, max period={max(periods)}")
|
|
149
|
+
res = MSTL(a, periods=periods).fit()
|
|
150
|
+
seasonal_2d = np.asarray(res.seasonal, dtype=float)
|
|
151
|
+
# MSTL returns one seasonal column per period; sum them into a single additive seasonal component.
|
|
152
|
+
seasonal = seasonal_2d.sum(axis=1) if seasonal_2d.ndim == 2 else seasonal_2d
|
|
153
|
+
trend = np.asarray(res.trend, dtype=float)
|
|
154
|
+
remainder = np.asarray(res.resid, dtype=float)
|
|
155
|
+
f_t, f_s = _strengths(trend, seasonal, remainder)
|
|
156
|
+
return Decomp(
|
|
157
|
+
trend=trend, seasonal=seasonal, remainder=remainder, strength_trend=f_t, strength_seasonal=f_s,
|
|
158
|
+
periods=tuple(periods),
|
|
159
|
+
reference="Bandara, Hyndman & Bergmeir 2021, MSTL, arXiv:2107.13462; statsmodels.tsa.seasonal.MSTL",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class SeasonalSubseries:
|
|
165
|
+
"""The per-phase (per-season) means + the per-phase series, for the standard month/subseries plot."""
|
|
166
|
+
|
|
167
|
+
period: int
|
|
168
|
+
means: np.ndarray # length = period: the mean of each phase
|
|
169
|
+
profiles: list[np.ndarray] # one array per phase, each the values that landed in that phase
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def seasonal_subseries(x, period: int) -> SeasonalSubseries:
|
|
173
|
+
"""Split the series into cycles of length ``period``; return the per-phase mean and per-phase values."""
|
|
174
|
+
a = _clean(x)
|
|
175
|
+
if period < 2:
|
|
176
|
+
raise ValueError("period must be >= 2")
|
|
177
|
+
n_full = (len(a) // period) * period
|
|
178
|
+
if n_full == 0:
|
|
179
|
+
raise ValueError("series shorter than one period")
|
|
180
|
+
trimmed = a[:n_full]
|
|
181
|
+
cycles = trimmed.reshape(-1, period) # rows = cycles, cols = phases
|
|
182
|
+
means = cycles.mean(axis=0)
|
|
183
|
+
profiles = [cycles[:, j] for j in range(period)]
|
|
184
|
+
return SeasonalSubseries(period=int(period), means=means, profiles=profiles)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def seasonality_report(x, fs: float = 1.0, candidate_periods: list[int] | None = None) -> dict:
|
|
188
|
+
"""Run the full seasonality panel: periodogram + Welch + per-candidate strength + (optional) MSTL."""
|
|
189
|
+
a = _clean(x)
|
|
190
|
+
pg = periodogram(a, fs=fs)
|
|
191
|
+
wl = welch(a, fs=fs)
|
|
192
|
+
candidates = candidate_periods or []
|
|
193
|
+
strengths = {int(p): float(seasonal_strength(a, int(p))) for p in candidates if int(p) >= 2}
|
|
194
|
+
out = {
|
|
195
|
+
"n": len(a),
|
|
196
|
+
"periodogram": {
|
|
197
|
+
"dominant_period": pg.dominant_period, "dominant_power": pg.dominant_power,
|
|
198
|
+
"freqs": pg.freqs.tolist(), "power": pg.power.tolist(), "reference": pg.reference,
|
|
199
|
+
},
|
|
200
|
+
"welch": {
|
|
201
|
+
"dominant_period": wl.dominant_period, "dominant_power": wl.dominant_power,
|
|
202
|
+
"reference": wl.reference,
|
|
203
|
+
},
|
|
204
|
+
"seasonal_strength_by_candidate_period": strengths,
|
|
205
|
+
}
|
|
206
|
+
# If the periodogram found a dominant period, also report STL strength at that period.
|
|
207
|
+
if pg.dominant_period is not None and pg.dominant_period >= 2:
|
|
208
|
+
p_int = round(pg.dominant_period)
|
|
209
|
+
if p_int >= 2 and len(a) >= 2 * p_int:
|
|
210
|
+
out["stl_at_dominant"] = _decomp_dict(stl_decompose(a, p_int))
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _decomp_dict(d: Decomp) -> dict:
|
|
215
|
+
return {
|
|
216
|
+
"strength_trend": d.strength_trend, "strength_seasonal": d.strength_seasonal,
|
|
217
|
+
"periods": list(d.periods), "reference": d.reference,
|
|
218
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Retrospective sequence diagnostics on original, contiguous observations.
|
|
2
|
+
|
|
3
|
+
Matrix Profile/FLUSS: Yeh et al. 2016, DOI 10.1109/ICDM.2016.0179;
|
|
4
|
+
Gharghabi et al. 2017, DOI 10.1109/ICDM.2017.21 (STUMPY implementation).
|
|
5
|
+
Multiscale entropy: Costa et al. 2002, DOI 10.1103/PhysRevLett.89.068102.
|
|
6
|
+
Lempel-Ziv: Lempel & Ziv 1976, DOI 10.1109/TIT.1976.1055501.
|
|
7
|
+
Time reversal: the third-order lag statistic documented by tsfresh.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def finite_sequence(x) -> np.ndarray:
|
|
17
|
+
a = np.asarray(x, dtype=float)
|
|
18
|
+
if a.ndim != 1 or len(a) < 3 or not np.isfinite(a).all():
|
|
19
|
+
raise ValueError("A contiguous finite one-dimensional sequence of at least three observations is required")
|
|
20
|
+
return a
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def matrix_profile(x, window: int = 32, regimes: int = 3) -> dict:
|
|
24
|
+
"""Exact z-normalized self join; candidates are descriptive, never causal alarms."""
|
|
25
|
+
import stumpy
|
|
26
|
+
|
|
27
|
+
a = finite_sequence(x)
|
|
28
|
+
if not isinstance(window, int) or window < 3 or len(a) < 2 * window + 1:
|
|
29
|
+
raise ValueError("Matrix Profile requires an integer window >=3 and n >= 2*window+1")
|
|
30
|
+
if not isinstance(regimes, int) or regimes < 2:
|
|
31
|
+
raise ValueError("FLUSS requires at least two candidate regimes")
|
|
32
|
+
if np.std(a) == 0:
|
|
33
|
+
return {"available": False, "reason": "constant sequence has no discriminating shape profile",
|
|
34
|
+
"n": len(a), "window": window}
|
|
35
|
+
mp = stumpy.stump(a, m=window)
|
|
36
|
+
distances = np.asarray(mp.P_, dtype=float)
|
|
37
|
+
neighbors = np.asarray(mp.I_, dtype=int)
|
|
38
|
+
motifs = []
|
|
39
|
+
for i in np.argsort(distances, kind="stable"):
|
|
40
|
+
j = int(neighbors[i])
|
|
41
|
+
if np.isfinite(distances[i]) and j >= 0 and abs(int(i)-j) >= window:
|
|
42
|
+
motifs.append({"start": int(i), "match": j, "distance": float(distances[i])})
|
|
43
|
+
break
|
|
44
|
+
discords = []
|
|
45
|
+
for i in np.argsort(-distances, kind="stable"):
|
|
46
|
+
if np.isfinite(distances[i]) and all(abs(int(i)-d['start']) >= window for d in discords):
|
|
47
|
+
discords.append({"start": int(i), "distance": float(distances[i])})
|
|
48
|
+
if len(discords) == 3:
|
|
49
|
+
break
|
|
50
|
+
cac, boundaries = stumpy.fluss(neighbors, L=window, n_regimes=regimes, excl_factor=1)
|
|
51
|
+
return {"available": True, "n": len(a), "window": window,
|
|
52
|
+
"exclusion_zone": math.ceil(window / stumpy.config.STUMPY_EXCL_ZONE_DENOM),
|
|
53
|
+
"profile": [float(v) if np.isfinite(v) else None for v in distances],
|
|
54
|
+
"nearest_neighbor": neighbors.tolist(), "motifs": motifs, "discords": discords,
|
|
55
|
+
"corrected_arc_curve": np.asarray(cac, dtype=float).tolist(),
|
|
56
|
+
"regime_boundaries": sorted(int(b) for b in boundaries if 0 < int(b) < len(distances)),
|
|
57
|
+
"regimes_requested": regimes, "normalization": "per-window population-standard-deviation z scores",
|
|
58
|
+
"note": "Retrospective shape candidates, not anomaly probabilities or proven regime changes."}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def multiscale_entropy(x, scales=tuple(range(1, 11)), order: int = 2, tolerance_ratio: float = .15) -> dict:
|
|
62
|
+
"""Costa coarse-graining, with original-series tolerance and no self matches.
|
|
63
|
+
|
|
64
|
+
Both m and m+1 templates use the same n-m start positions. Matching uses <= r
|
|
65
|
+
Chebyshev distance; finite-sample no-match estimates remain unavailable.
|
|
66
|
+
"""
|
|
67
|
+
from scipy.spatial import cKDTree
|
|
68
|
+
|
|
69
|
+
a = finite_sequence(x)
|
|
70
|
+
if order < 1 or not isinstance(order, int) or not 0 < tolerance_ratio < 1:
|
|
71
|
+
raise ValueError("Invalid entropy order or tolerance ratio")
|
|
72
|
+
scales = tuple(scales)
|
|
73
|
+
if not scales or any(not isinstance(s, int) or s < 1 for s in scales) or len(set(scales)) != len(scales):
|
|
74
|
+
raise ValueError("Scales must be distinct positive integers")
|
|
75
|
+
tolerance = float(tolerance_ratio * np.std(a))
|
|
76
|
+
rows = []
|
|
77
|
+
for scale in scales:
|
|
78
|
+
coarse = a[:len(a)//scale*scale].reshape(-1, scale).mean(axis=1)
|
|
79
|
+
row = {"scale": scale, "n": len(coarse), "tolerance": tolerance, "entropy": None,
|
|
80
|
+
"matching_m": 0, "matching_m_plus_one": 0}
|
|
81
|
+
if len(coarse) < max(20, order + 3):
|
|
82
|
+
row["reason"] = "fewer than twenty coarse-grained observations"
|
|
83
|
+
else:
|
|
84
|
+
templates = np.lib.stride_tricks.sliding_window_view(coarse, order + 1)
|
|
85
|
+
counts = []
|
|
86
|
+
for dimension in (order, order + 1):
|
|
87
|
+
tree = cKDTree(templates[:, :dimension])
|
|
88
|
+
counts.append(int((tree.count_neighbors(tree, tolerance, p=np.inf) - len(templates)) // 2))
|
|
89
|
+
b, matches = counts
|
|
90
|
+
row.update(matching_m=b, matching_m_plus_one=matches)
|
|
91
|
+
if matches and b:
|
|
92
|
+
row["entropy"] = float(-np.log(matches / b))
|
|
93
|
+
else:
|
|
94
|
+
row["reason"] = "no matching templates; finite sample entropy is unavailable"
|
|
95
|
+
rows.append(row)
|
|
96
|
+
return {"order": order, "tolerance_ratio": tolerance_ratio, "tolerance_basis": "original population standard deviation",
|
|
97
|
+
"scales": rows}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def time_reversal(x, lag: int = 1) -> dict:
|
|
101
|
+
"""E[x[t+2l]^2*x[t+l] - x[t+l]*x[t]^2], raw and standardized."""
|
|
102
|
+
a = finite_sequence(x)
|
|
103
|
+
if not isinstance(lag, int) or lag < 1 or len(a) <= 2 * lag:
|
|
104
|
+
raise ValueError("Time-reversal lag requires n > 2*lag")
|
|
105
|
+
|
|
106
|
+
def statistic(values):
|
|
107
|
+
return float(np.mean(values[lag:-lag] * (values[2*lag:]**2 - values[:-2*lag]**2)))
|
|
108
|
+
|
|
109
|
+
sd = np.std(a)
|
|
110
|
+
return {"lag": lag, "raw": statistic(a), "standardized": statistic((a-np.mean(a))/sd) if sd else 0.0,
|
|
111
|
+
"note": "A descriptive third-order statistic; no significance or irreversibility verdict is inferred."}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def sequence_report(x, seasonality: int = 1, window: int | None = None) -> dict:
|
|
115
|
+
"""One JSON-ready contract for offline case diagnostics and the browser consumer."""
|
|
116
|
+
import antropy
|
|
117
|
+
|
|
118
|
+
a = finite_sequence(x)
|
|
119
|
+
chosen = window if window is not None else min(max(8, 2*seasonality), 64, len(a)//3)
|
|
120
|
+
profile = matrix_profile(a, window=chosen) if chosen >= 3 and len(a) >= 2*chosen+1 else {
|
|
121
|
+
"available": False, "reason": "sequence too short for the profile", "n": len(a), "window": chosen}
|
|
122
|
+
symbols = (a > np.median(a)).astype(int)
|
|
123
|
+
return {"n": len(a), "observation_indices": list(range(len(a))), "values": a.tolist(),
|
|
124
|
+
"matrix_profile": profile, "multiscale_entropy": multiscale_entropy(a),
|
|
125
|
+
"lempel_ziv": {"raw": int(antropy.lziv_complexity(symbols, normalize=False)),
|
|
126
|
+
"normalized": float(antropy.lziv_complexity(symbols, normalize=True)),
|
|
127
|
+
"threshold": float(np.median(a)), "alphabet": "0 if x<=median, 1 otherwise",
|
|
128
|
+
"normalization": "c(n)*log2(n)/n; finite samples can exceed one"},
|
|
129
|
+
"time_reversal": [time_reversal(a, lag) for lag in (1, 2, 7) if len(a) > 2*lag],
|
|
130
|
+
"interpretation": "Retrospective full-sequence diagnostics; not input to a forecast at an earlier origin."}
|