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,217 @@
|
|
|
1
|
+
"""Autocorrelation analysis: ACF, PACF (Durbin-Levinson), Ljung-Box, Box-Pierce, Durbin-Watson.
|
|
2
|
+
|
|
3
|
+
The structure of a series' dependence on its own past is read off the ACF/PACF and quantified by the
|
|
4
|
+
portmanteau (Ljung-Box / Box-Pierce) and serial-correlation (Durbin-Watson) tests. Each is delegated to its
|
|
5
|
+
authoritative implementation in statsmodels, wrapped in one stable, NaN-safe API that returns a typed result
|
|
6
|
+
carrying the statistic, the p-value where defined, the Bartlett significance band, and the primary reference.
|
|
7
|
+
|
|
8
|
+
Tests / references (verified 2026-07-04):
|
|
9
|
+
* ACF - sample autocorrelation r_k = c_k / c_0, with c_k the autocovariance at lag k. Box, Jenkins,
|
|
10
|
+
Reinsel & Ljung 2015, *Time Series Analysis*, 5th ed., Wiley, ISBN 978-1-118-67502-1.
|
|
11
|
+
``statsmodels.tsa.stattools.acf``. Under white noise, r_k ~ N(0, 1/n) so the 95% band is
|
|
12
|
+
+/-1.96/sqrt(n) (Bartlett's approximation for independent data; wider under a MA process).
|
|
13
|
+
* PACF - partial autocorrelation: the correlation at lag k after removing the effect of lags 1..k-1,
|
|
14
|
+
computed here by the Durbin-Levinson recursion (``method='ld'``). Identifies the AR order p
|
|
15
|
+
(PACF cuts off after p). Same reference.
|
|
16
|
+
* Ljung-Box - portmanteau test that the first m autocorrelations are jointly zero; the standard
|
|
17
|
+
"are the residuals white noise?" check. Ljung & Box 1978, Biometrika 65(2):297-303,
|
|
18
|
+
DOI 10.1093/biomet/65.2.297. ``statsmodels.stats.diagnostic.acorr_ljungbox``.
|
|
19
|
+
* Box-Pierce - the older portmanteau (Ljung-Box is its small-sample refinement). Box & Pierce 1970,
|
|
20
|
+
JASA 65(332):1509-1526, DOI 10.2307/2284333. Same function, ``boxpierce=True``.
|
|
21
|
+
* Durbin-Watson - tests first-order serial correlation in regression residuals (DW~2 = none, <2 positive
|
|
22
|
+
serial correlation, >2 negative). Durbin & Watson 1950, Biometrika 37(3-4):409-428,
|
|
23
|
+
DOI 10.1093/biomet/37.3-4.409. ``statsmodels.stats.stattools.durbin_watson``.
|
|
24
|
+
|
|
25
|
+
How to read them together (Box-Jenkins identification): a slowly-decaying ACF with a sharp PACF cut-off after
|
|
26
|
+
lag p suggests an AR(p); a sharp ACF cut-off after lag q with a slowly-decaying PACF suggests an MA(q); both
|
|
27
|
+
tailing off suggests ARMA. White noise has every r_k inside the Bartlett band and a non-significant Ljung-Box.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import math
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Correlogram:
|
|
39
|
+
"""ACF or PACF up to ``nlags``, with point values, the Bartlett band, and the read-out.
|
|
40
|
+
|
|
41
|
+
``signif_lags`` lists the lags whose value escapes the +/-1.96/sqrt(n) band (the spikes a reader would
|
|
42
|
+
mark), already translated so callers do not have to re-derive the threshold.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
lags: np.ndarray
|
|
47
|
+
values: np.ndarray
|
|
48
|
+
band: float
|
|
49
|
+
reference: str
|
|
50
|
+
signif_lags: list[int] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _clean(x) -> np.ndarray:
|
|
54
|
+
a = np.asarray(x, dtype=float).ravel()
|
|
55
|
+
return a[np.isfinite(a)]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def bartlett_band(n: int, alpha: float = 0.05) -> float:
|
|
59
|
+
"""The +/- z_{1-alpha/2}/sqrt(n) significance bound for the ACF of an independent series.
|
|
60
|
+
|
|
61
|
+
For alpha=0.05 this is the familiar +/-1.96/sqrt(n). Bartlett's formula gives a wider, variance-
|
|
62
|
+
dependent band under a moving-average process; the simple form is the standard first-pass threshold.
|
|
63
|
+
"""
|
|
64
|
+
if n <= 0:
|
|
65
|
+
return float("inf")
|
|
66
|
+
# Normal quantile via the inverse error function (accurate; matches statsmodels' default band).
|
|
67
|
+
from scipy.special import erfinv
|
|
68
|
+
|
|
69
|
+
z = math.sqrt(2.0) * erfinv(1.0 - alpha)
|
|
70
|
+
return float(z / math.sqrt(n))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def acf(x, nlags: int = 40, alpha: float = 0.05) -> Correlogram:
|
|
74
|
+
"""Sample autocorrelation function with the Bartlett significance band (statsmodels, FFT-based)."""
|
|
75
|
+
from statsmodels.tsa.stattools import acf as _acf
|
|
76
|
+
|
|
77
|
+
a = _clean(x)
|
|
78
|
+
n = len(a)
|
|
79
|
+
nlags = min(nlags, n - 1)
|
|
80
|
+
values = _acf(a, nlags=nlags, fft=True, missing="drop")
|
|
81
|
+
lags = np.arange(len(values))
|
|
82
|
+
band = bartlett_band(n, alpha)
|
|
83
|
+
signif = [int(k) for k in lags[1:] if abs(values[k]) > band] # exclude the trivial lag-0 = 1
|
|
84
|
+
return Correlogram(
|
|
85
|
+
name="ACF", lags=lags, values=values, band=band, signif_lags=signif,
|
|
86
|
+
reference="Box, Jenkins, Reinsel & Ljung 2015, Time Series Analysis, 5th ed., Wiley, ISBN 978-1-118-67502-1",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def pacf(x, nlags: int = 40, alpha: float = 0.05) -> Correlogram:
|
|
91
|
+
"""Partial autocorrelation via the Durbin-Levinson recursion (``method='ld'``) with the Bartlett band."""
|
|
92
|
+
from statsmodels.tsa.stattools import pacf as _pacf
|
|
93
|
+
|
|
94
|
+
a = _clean(x)
|
|
95
|
+
n = len(a)
|
|
96
|
+
nlags = min(nlags, n - 1)
|
|
97
|
+
values = _pacf(a, nlags=nlags, method="ld")
|
|
98
|
+
lags = np.arange(len(values))
|
|
99
|
+
band = bartlett_band(n, alpha)
|
|
100
|
+
signif = [int(k) for k in lags[1:] if abs(values[k]) > band]
|
|
101
|
+
return Correlogram(
|
|
102
|
+
name="PACF", lags=lags, values=values, band=band, signif_lags=signif,
|
|
103
|
+
reference="Durbin-Levinson recursion; Box, Jenkins, Reinsel & Ljung 2015, ISBN 978-1-118-67502-1",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class PortmanteauTest:
|
|
109
|
+
"""Ljung-Box or Box-Pierce: a joint test that the first ``lags`` autocorrelations are all zero."""
|
|
110
|
+
|
|
111
|
+
name: str
|
|
112
|
+
stat: float
|
|
113
|
+
pvalue: float
|
|
114
|
+
lags: int
|
|
115
|
+
df: int
|
|
116
|
+
white_noise: bool | None
|
|
117
|
+
reference: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def ljung_box(x, lags: int | list[int] = 10, model_df: int = 0, alpha: float = 0.05) -> PortmanteauTest:
|
|
121
|
+
"""Ljung-Box Q. Small p => reject "no autocorrelation up to lag m" => the series is NOT white noise.
|
|
122
|
+
|
|
123
|
+
``model_df`` is the number of ARMA parameters already fitted, subtracted from the degrees of freedom when
|
|
124
|
+
the test is run on residuals (so the residual diagnostic is not biased toward significance).
|
|
125
|
+
"""
|
|
126
|
+
from statsmodels.stats.diagnostic import acorr_ljungbox
|
|
127
|
+
|
|
128
|
+
a = _clean(x)
|
|
129
|
+
m = max(lags) if isinstance(lags, (list, tuple)) else int(lags)
|
|
130
|
+
res = acorr_ljungbox(a, lags=[m], model_df=model_df, return_df=True)
|
|
131
|
+
stat = float(res["lb_stat"].iloc[0])
|
|
132
|
+
pvalue = float(res["lb_pvalue"].iloc[0])
|
|
133
|
+
return PortmanteauTest(
|
|
134
|
+
name="Ljung-Box", stat=stat, pvalue=pvalue, lags=m, df=max(0, m - model_df),
|
|
135
|
+
white_noise=bool(pvalue >= alpha),
|
|
136
|
+
reference="Ljung & Box 1978, Biometrika 65(2):297-303, DOI 10.1093/biomet/65.2.297",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def box_pierce(x, lags: int = 10, model_df: int = 0, alpha: float = 0.05) -> PortmanteauTest:
|
|
141
|
+
"""Box-Pierce: the older portmanteau; Ljung-Box is its small-sample refinement."""
|
|
142
|
+
from statsmodels.stats.diagnostic import acorr_ljungbox
|
|
143
|
+
|
|
144
|
+
a = _clean(x)
|
|
145
|
+
res = acorr_ljungbox(a, lags=[int(lags)], boxpierce=True, model_df=model_df, return_df=True)
|
|
146
|
+
stat = float(res["bp_stat"].iloc[0])
|
|
147
|
+
pvalue = float(res["bp_pvalue"].iloc[0])
|
|
148
|
+
return PortmanteauTest(
|
|
149
|
+
name="Box-Pierce", stat=stat, pvalue=pvalue, lags=int(lags), df=max(0, int(lags) - model_df),
|
|
150
|
+
white_noise=bool(pvalue >= alpha),
|
|
151
|
+
reference="Box & Pierce 1970, JASA 65(332):1509-1526, DOI 10.2307/2284333",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def durbin_watson(x) -> float:
|
|
156
|
+
"""Durbin-Watson statistic on (residual) series: ~2 = no 1st-order serial correlation, <2 positive, >2 negative."""
|
|
157
|
+
from statsmodels.stats.stattools import durbin_watson as _dw
|
|
158
|
+
|
|
159
|
+
a = _clean(x)
|
|
160
|
+
return float(_dw(a))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def lag_plot_pairs(x, lag: int = 1) -> tuple[np.ndarray, np.ndarray]:
|
|
164
|
+
"""Return the (x_{t-lag}, x_t) pairs a lag plot would render, dropping non-finite points.
|
|
165
|
+
|
|
166
|
+
A linear band => AR(1)-style dependence; a circular cloud => white noise; curved => nonlinearity;
|
|
167
|
+
isolated off-diagonal points => outliers (NIST e-Handbook sec. 1.3.3.15).
|
|
168
|
+
"""
|
|
169
|
+
a = _clean(x)
|
|
170
|
+
if lag < 1 or lag >= len(a):
|
|
171
|
+
raise ValueError(f"lag must be in 1..{len(a) - 1}, got {lag}")
|
|
172
|
+
return a[:-lag], a[lag:]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def autocorrelation_report(x, nlags: int = 40, lags_m: int = 10) -> dict:
|
|
176
|
+
"""Run the full autocorrelation panel and return a JSON-ready report (for the baked artifact + docs)."""
|
|
177
|
+
a = _clean(x)
|
|
178
|
+
n = len(a)
|
|
179
|
+
acf_c = acf(a, nlags=nlags)
|
|
180
|
+
pacf_c = pacf(a, nlags=nlags)
|
|
181
|
+
lb = ljung_box(a, lags=lags_m)
|
|
182
|
+
bp = box_pierce(a, lags=lags_m)
|
|
183
|
+
return {
|
|
184
|
+
"n": int(n),
|
|
185
|
+
"bartlett_band": acf_c.band,
|
|
186
|
+
"acf": [{"lag": int(k), "value": float(v)} for k, v in zip(acf_c.lags, acf_c.values)],
|
|
187
|
+
"acf_signif_lags": acf_c.signif_lags,
|
|
188
|
+
"pacf": [{"lag": int(k), "value": float(v)} for k, v in zip(pacf_c.lags, pacf_c.values)],
|
|
189
|
+
"pacf_signif_lags": pacf_c.signif_lags,
|
|
190
|
+
"ljung_box": {"stat": lb.stat, "pvalue": lb.pvalue, "lags": lb.lags, "white_noise": lb.white_noise,
|
|
191
|
+
"reference": lb.reference},
|
|
192
|
+
"box_pierce": {"stat": bp.stat, "pvalue": bp.pvalue, "lags": bp.lags, "white_noise": bp.white_noise,
|
|
193
|
+
"reference": bp.reference},
|
|
194
|
+
"durbin_watson": durbin_watson(a),
|
|
195
|
+
"identification_hint": _identify(acf_c, pacf_c, lb),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _identify(acf_c: Correlogram, pacf_c: Correlogram, lb: PortmanteauTest) -> str:
|
|
200
|
+
"""A conservative Box-Jenkins reading of the correlograms (a hint, not a fitted model)."""
|
|
201
|
+
if lb.white_noise:
|
|
202
|
+
return "no significant autocorrelation (white-noise-like); ARMA identification not warranted"
|
|
203
|
+
# tail-off vs cut-off via the count of signif lags and the decay of |acf| over the last quarter
|
|
204
|
+
a_vals = np.abs(acf_c.values[1:])
|
|
205
|
+
p_vals = np.abs(pacf_c.values[1:])
|
|
206
|
+
if len(a_vals) < 4:
|
|
207
|
+
return "series too short for a reliable ARMA-shape reading"
|
|
208
|
+
a_tail = float(np.mean(a_vals[len(a_vals) // 2:])) # ACF in its later half
|
|
209
|
+
p_tail = float(np.mean(p_vals[len(p_vals) // 2:]))
|
|
210
|
+
a_band, p_band = acf_c.band, pacf_c.band
|
|
211
|
+
if p_tail < a_band and a_tail > a_band:
|
|
212
|
+
return "ACF tails off, PACF cuts off -> suggests an AR(p) (p ~ last significant PACF lag)"
|
|
213
|
+
if a_tail < a_band and p_tail > p_band:
|
|
214
|
+
return "ACF cuts off, PACF tails off -> suggests an MA(q) (q ~ last significant ACF lag)"
|
|
215
|
+
if a_tail > a_band and p_tail > p_band:
|
|
216
|
+
return "both tail off -> suggests ARMA; fit by AIC over a small (p,q) grid"
|
|
217
|
+
return "autocorrelation present but shape is mixed; inspect the correlograms directly"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Cross-series relationships: cross-correlation, Granger causality, and cointegration.
|
|
2
|
+
|
|
3
|
+
The single-series diagnostics answer "what is this series?"; these answer "how do two series relate?" - which
|
|
4
|
+
one leads, whether past values of one improve forecasts of the other (Granger), and whether two nonstationary
|
|
5
|
+
series share a long-run equilibrium (cointegration) so their spread is stationary and forecastable even when
|
|
6
|
+
the levels are not. This is the analysis foundation for the covariate and multivariate cases in the product.
|
|
7
|
+
|
|
8
|
+
Methods / references (verified 2026-07-04):
|
|
9
|
+
* Cross-correlation function (CCF) - correlation of x_t with y_{t-k} across lags; the lead/lag detector.
|
|
10
|
+
Box-Jenkins tradition. ``statsmodels.tsa.stattools.ccf``.
|
|
11
|
+
* Granger causality - does the past of x improve a forecast of y beyond y's own past? An F-test on nested
|
|
12
|
+
VAR models. Granger 1969, Econometrica 37(3):424-438, DOI 10.2307/1912791. PREDICTIVE, not true cause.
|
|
13
|
+
``statsmodels.tsa.stattools.grangercausalitytests``.
|
|
14
|
+
* Engle-Granger cointegration - two-step: regress one series on the other, ADF-test the residuals for a
|
|
15
|
+
unit root; a single cointegrating vector. Engle & Granger 1987, Econometrica 55(2):251-276,
|
|
16
|
+
DOI 10.2307/1913236. ``statsmodels.tsa.stattools.coint``.
|
|
17
|
+
* Johansen cointegration - ML/VECM rank test (trace + max-eigenvalue) for MULTIPLE cointegrating relations.
|
|
18
|
+
Johansen 1991, Econometrica 59(6):1551-1580, DOI 10.2307/2938278.
|
|
19
|
+
``statsmodels.tsa.vector_ar.vecm.coint_johansen``.
|
|
20
|
+
|
|
21
|
+
HONESTY: Granger "causality" is a statement about PREDICTABILITY, not mechanism - a common driver or a
|
|
22
|
+
confound produces Granger causality without a causal link. Cointegration requires both series to be I(1)
|
|
23
|
+
(unit-root nonstationary); it is meaningless on already-stationary series. The report checks and records this.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _clean_pair(x, y) -> tuple[np.ndarray, np.ndarray]:
|
|
33
|
+
a = np.asarray(x, dtype=float).ravel()
|
|
34
|
+
b = np.asarray(y, dtype=float).ravel()
|
|
35
|
+
m = min(len(a), len(b))
|
|
36
|
+
a, b = a[:m], b[:m]
|
|
37
|
+
mask = np.isfinite(a) & np.isfinite(b)
|
|
38
|
+
return a[mask], b[mask]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class CrossCorrelation:
|
|
43
|
+
"""CCF of x vs y across signed lags, with the lag of peak absolute correlation (the lead/lag)."""
|
|
44
|
+
|
|
45
|
+
lags: np.ndarray
|
|
46
|
+
values: np.ndarray
|
|
47
|
+
peak_lag: int # >0: x leads y; <0: y leads x
|
|
48
|
+
peak_value: float
|
|
49
|
+
band: float # +/-1.96/sqrt(n) significance band
|
|
50
|
+
reference: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cross_correlation(x, y, max_lag: int = 40) -> CrossCorrelation:
|
|
54
|
+
"""Cross-correlation across signed lags; a positive peak lag means x leads y.
|
|
55
|
+
|
|
56
|
+
Defined unambiguously as corr(x_t, y_{t+k}) for k = -max_lag..max_lag. If the peak is at k > 0 then a
|
|
57
|
+
future value of y correlates with the present of x (x leads y); at k < 0, y leads x. Computed directly on
|
|
58
|
+
mean-centred, shifted overlaps so the sign convention is explicit (statsmodels' ccf mixes the convention).
|
|
59
|
+
"""
|
|
60
|
+
a, b = _clean_pair(x, y)
|
|
61
|
+
n = len(a)
|
|
62
|
+
max_lag = min(max_lag, n - 1)
|
|
63
|
+
am, bm = a - a.mean(), b - b.mean()
|
|
64
|
+
denom = float(np.sqrt(np.sum(am ** 2) * np.sum(bm ** 2)))
|
|
65
|
+
if denom == 0:
|
|
66
|
+
denom = 1e-12
|
|
67
|
+
lags = np.arange(-max_lag, max_lag + 1)
|
|
68
|
+
values = np.empty(len(lags))
|
|
69
|
+
for i, k in enumerate(lags):
|
|
70
|
+
if k >= 0:
|
|
71
|
+
# corr(x_t, y_{t+k}): overlap x[:n-k] with y[k:]
|
|
72
|
+
cov = float(np.sum(am[: n - k] * bm[k:]))
|
|
73
|
+
else:
|
|
74
|
+
cov = float(np.sum(am[-k:] * bm[: n + k]))
|
|
75
|
+
values[i] = cov / denom
|
|
76
|
+
peak_idx = int(np.argmax(np.abs(values)))
|
|
77
|
+
band = float(1.96 / np.sqrt(n))
|
|
78
|
+
return CrossCorrelation(
|
|
79
|
+
lags=lags, values=values, peak_lag=int(lags[peak_idx]), peak_value=float(values[peak_idx]),
|
|
80
|
+
band=band, reference="Box & Jenkins (CCF); direct shifted-overlap correlation",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class GrangerResult:
|
|
86
|
+
"""Granger-causality F-test across lags: does the past of ``cause`` help predict ``effect``?"""
|
|
87
|
+
|
|
88
|
+
direction: str # "cause -> effect"
|
|
89
|
+
best_lag: int
|
|
90
|
+
pvalues: dict[int, float]
|
|
91
|
+
causes: bool # min p-value across lags < alpha
|
|
92
|
+
alpha: float
|
|
93
|
+
reference: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def granger_causality(cause, effect, max_lag: int = 6, alpha: float = 0.05) -> GrangerResult:
|
|
97
|
+
"""Test whether ``cause`` Granger-causes ``effect`` (past of cause improves the forecast of effect)."""
|
|
98
|
+
from statsmodels.tsa.stattools import grangercausalitytests
|
|
99
|
+
|
|
100
|
+
a, b = _clean_pair(cause, effect)
|
|
101
|
+
# statsmodels tests whether the SECOND column Granger-causes the FIRST -> stack [effect, cause]
|
|
102
|
+
data = np.column_stack([b, a])
|
|
103
|
+
res = grangercausalitytests(data, maxlag=max_lag)
|
|
104
|
+
pvals = {lag: float(res[lag][0]["ssr_ftest"][1]) for lag in res}
|
|
105
|
+
best_lag = min(pvals, key=pvals.get)
|
|
106
|
+
return GrangerResult(
|
|
107
|
+
direction="cause -> effect", best_lag=int(best_lag), pvalues=pvals,
|
|
108
|
+
causes=bool(pvals[best_lag] < alpha), alpha=alpha,
|
|
109
|
+
reference="Granger 1969, Econometrica 37(3):424-438, DOI 10.2307/1912791",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class CointegrationResult:
|
|
115
|
+
"""Engle-Granger + Johansen cointegration verdicts for a pair of (ideally I(1)) series."""
|
|
116
|
+
|
|
117
|
+
eg_stat: float
|
|
118
|
+
eg_pvalue: float
|
|
119
|
+
eg_cointegrated: bool
|
|
120
|
+
johansen_trace_stat: float
|
|
121
|
+
johansen_trace_crit95: float
|
|
122
|
+
johansen_rank_at_least_1: bool
|
|
123
|
+
both_i1: bool # both series are unit-root nonstationary (precondition)
|
|
124
|
+
alpha: float
|
|
125
|
+
reference: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_i1(a: np.ndarray, alpha: float) -> bool:
|
|
129
|
+
"""A series is I(1) if ADF does NOT reject a unit root on levels but DOES on first differences."""
|
|
130
|
+
from statsmodels.tsa.stattools import adfuller
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
p_level = adfuller(a, autolag="AIC")[1]
|
|
134
|
+
p_diff = adfuller(np.diff(a), autolag="AIC")[1]
|
|
135
|
+
return bool(p_level >= alpha and p_diff < alpha)
|
|
136
|
+
except Exception: # noqa: BLE001
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cointegration(x, y, alpha: float = 0.05) -> CointegrationResult:
|
|
141
|
+
"""Engle-Granger (residual ADF) + Johansen (trace rank) cointegration, with the I(1) precondition check."""
|
|
142
|
+
from statsmodels.tsa.stattools import coint
|
|
143
|
+
from statsmodels.tsa.vector_ar.vecm import coint_johansen
|
|
144
|
+
|
|
145
|
+
a, b = _clean_pair(x, y)
|
|
146
|
+
eg_stat, eg_p, _crit = coint(a, b)
|
|
147
|
+
j = coint_johansen(np.column_stack([a, b]), det_order=0, k_ar_diff=1)
|
|
148
|
+
trace0 = float(j.trace_stat[0])
|
|
149
|
+
crit95_0 = float(j.trace_stat_crit_vals[0][1]) # 95% critical value for rank r=0
|
|
150
|
+
both_i1 = bool(_is_i1(a, alpha) and _is_i1(b, alpha))
|
|
151
|
+
return CointegrationResult(
|
|
152
|
+
eg_stat=float(eg_stat), eg_pvalue=float(eg_p), eg_cointegrated=bool(eg_p < alpha),
|
|
153
|
+
johansen_trace_stat=trace0, johansen_trace_crit95=crit95_0,
|
|
154
|
+
johansen_rank_at_least_1=bool(trace0 > crit95_0), both_i1=both_i1, alpha=alpha,
|
|
155
|
+
reference="Engle & Granger 1987, DOI 10.2307/1913236; Johansen 1991, DOI 10.2307/2938278",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def causality_report(x, y, max_lag: int = 6) -> dict:
|
|
160
|
+
"""Run the cross-series panel (CCF + bidirectional Granger + cointegration) and return a JSON-ready report."""
|
|
161
|
+
a, b = _clean_pair(x, y)
|
|
162
|
+
ccf_res = cross_correlation(a, b)
|
|
163
|
+
g_xy = granger_causality(a, b, max_lag=max_lag) # does x cause y?
|
|
164
|
+
g_yx = granger_causality(b, a, max_lag=max_lag) # does y cause x?
|
|
165
|
+
coint_res = cointegration(a, b)
|
|
166
|
+
return {
|
|
167
|
+
"n": len(a),
|
|
168
|
+
"ccf": {"peak_lag": ccf_res.peak_lag, "peak_value": ccf_res.peak_value, "band": ccf_res.band,
|
|
169
|
+
"lead": ("x leads y" if ccf_res.peak_lag > 0 else "y leads x" if ccf_res.peak_lag < 0 else "synchronous"),
|
|
170
|
+
"reference": ccf_res.reference},
|
|
171
|
+
"granger": {"x_causes_y": {"causes": g_xy.causes, "best_lag": g_xy.best_lag,
|
|
172
|
+
"pvalue": g_xy.pvalues[g_xy.best_lag]},
|
|
173
|
+
"y_causes_x": {"causes": g_yx.causes, "best_lag": g_yx.best_lag,
|
|
174
|
+
"pvalue": g_yx.pvalues[g_yx.best_lag]},
|
|
175
|
+
"reference": g_xy.reference},
|
|
176
|
+
"cointegration": {"engle_granger_pvalue": coint_res.eg_pvalue,
|
|
177
|
+
"engle_granger_cointegrated": coint_res.eg_cointegrated,
|
|
178
|
+
"johansen_rank_at_least_1": coint_res.johansen_rank_at_least_1,
|
|
179
|
+
"both_series_I1": coint_res.both_i1,
|
|
180
|
+
"valid": coint_res.both_i1, # cointegration is only meaningful when both are I(1)
|
|
181
|
+
"reference": coint_res.reference},
|
|
182
|
+
"note": "Granger = predictability, not mechanism; cointegration requires both series I(1)",
|
|
183
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Change-point and regime analysis: PELT, Binary Segmentation, CUSUM, Markov-switching regimes.
|
|
2
|
+
|
|
3
|
+
A structural break (a shift in mean, variance, or dynamics) violates the "one process generated all of it"
|
|
4
|
+
assumption every fitted model makes. This module locates WHERE a series changes (offline change-point
|
|
5
|
+
detection), tests WHETHER parameters are stable (CUSUM), and models WHICH regime each point belongs to
|
|
6
|
+
(Markov switching). Drift, level shifts, and regime changes are exactly the structures that fool both the
|
|
7
|
+
classical ladder and a zero-shot foundation model, so the case narratives lean on this panel.
|
|
8
|
+
|
|
9
|
+
Methods / references (verified 2026-07-04):
|
|
10
|
+
* PELT - exact penalized change-point search in expected linear time via pruning. Killick, Fearnhead &
|
|
11
|
+
Eckley 2012, JASA 107(500):1590-1598, DOI 10.1080/01621459.2012.737745. ``ruptures.Pelt``.
|
|
12
|
+
* Binary Segmentation - greedy sequential single-break search, O(n log n), approximate. ``ruptures.Binseg``.
|
|
13
|
+
Library reference: Truong, Oudre & Vayatis 2020, Signal Processing 167:107299,
|
|
14
|
+
DOI 10.1016/j.sigpro.2019.107299 (the ruptures review, its recommended citation).
|
|
15
|
+
* OLS-CUSUM - parameter-stability test on cumulative OLS residuals against Brownian-bridge bounds.
|
|
16
|
+
Brown, Durbin & Evans 1975 lineage; implemented by
|
|
17
|
+
``statsmodels.stats.diagnostic.breaks_cusumolsresid``.
|
|
18
|
+
* Markov switching - Hamilton 1989, Econometrica 57(2):357-384 (JSTOR 1912559): latent K-regime Markov
|
|
19
|
+
chain over mean/variance; smoothed regime probabilities per point.
|
|
20
|
+
``statsmodels.tsa.regime_switching.markov_regression.MarkovRegression``.
|
|
21
|
+
|
|
22
|
+
NOTE: the Chow test (single KNOWN break date) is deliberately not wrapped - it is NOT first-class in
|
|
23
|
+
statsmodels' public API (verified); PELT/Binseg + Zivot-Andrews (stationarity page) cover the unknown-date
|
|
24
|
+
problem the product actually has. BOCPD (Adams & MacKay 2007) is an online method; it belongs to the
|
|
25
|
+
streaming lane (preqts) and is recorded in the plan, not duplicated here.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from itertools import pairwise
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _clean(x) -> np.ndarray:
|
|
36
|
+
a = np.asarray(x, dtype=float).ravel()
|
|
37
|
+
return a[np.isfinite(a)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ChangePoints:
|
|
42
|
+
"""Detected break indices (each the FIRST index of a new segment; excludes 0 and n) + segment stats."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
breakpoints: list[int]
|
|
46
|
+
segments: list[dict] # per segment: {start, end, mean, std}
|
|
47
|
+
model: str # the cost model used ("l2" mean-shift, "normal" mean+var, "rbf" generic)
|
|
48
|
+
penalty: float | None
|
|
49
|
+
reference: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _segments(a: np.ndarray, bkps_first_index: list[int]) -> list[dict]:
|
|
53
|
+
bounds = [0, *bkps_first_index, len(a)]
|
|
54
|
+
out = []
|
|
55
|
+
for s, e in pairwise(bounds):
|
|
56
|
+
seg = a[s:e]
|
|
57
|
+
out.append({"start": int(s), "end": int(e), "mean": float(np.mean(seg)), "std": float(np.std(seg))})
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pelt(x, model: str = "l2", penalty: float | None = None, min_size: int = 5) -> ChangePoints:
|
|
62
|
+
"""PELT with a BIC-style default penalty (2 * sigma^2 * log n for the l2 cost when none is given)."""
|
|
63
|
+
import ruptures as rpt
|
|
64
|
+
|
|
65
|
+
a = _clean(x)
|
|
66
|
+
n = len(a)
|
|
67
|
+
if penalty is None:
|
|
68
|
+
sigma2 = float(np.var(np.diff(a))) / 2.0 or 1e-8 # robust noise-variance proxy
|
|
69
|
+
penalty = 2.0 * sigma2 * np.log(max(n, 2))
|
|
70
|
+
algo = rpt.Pelt(model=model, min_size=min_size).fit(a)
|
|
71
|
+
ends = algo.predict(pen=penalty) # ruptures returns segment END indices incl. n
|
|
72
|
+
bkps = [int(b) for b in ends if b < n]
|
|
73
|
+
return ChangePoints(
|
|
74
|
+
name="PELT", breakpoints=bkps, segments=_segments(a, bkps), model=model, penalty=float(penalty),
|
|
75
|
+
reference="Killick, Fearnhead & Eckley 2012, JASA 107(500):1590-1598, DOI 10.1080/01621459.2012.737745",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def binseg(x, n_bkps: int, model: str = "l2", min_size: int = 5) -> ChangePoints:
|
|
80
|
+
"""Binary segmentation for a KNOWN number of breaks (fast, approximate)."""
|
|
81
|
+
import ruptures as rpt
|
|
82
|
+
|
|
83
|
+
a = _clean(x)
|
|
84
|
+
algo = rpt.Binseg(model=model, min_size=min_size).fit(a)
|
|
85
|
+
ends = algo.predict(n_bkps=n_bkps)
|
|
86
|
+
bkps = [int(b) for b in ends if b < len(a)]
|
|
87
|
+
return ChangePoints(
|
|
88
|
+
name="BinSeg", breakpoints=bkps, segments=_segments(a, bkps), model=model, penalty=None,
|
|
89
|
+
reference="Truong, Oudre & Vayatis 2020, Signal Processing 167:107299, DOI 10.1016/j.sigpro.2019.107299",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class CusumResult:
|
|
95
|
+
"""OLS-CUSUM parameter-stability test: sup |cumulative scaled OLS residual| vs Brownian-bridge bounds."""
|
|
96
|
+
|
|
97
|
+
stat: float
|
|
98
|
+
pvalue: float
|
|
99
|
+
stable: bool
|
|
100
|
+
alpha: float
|
|
101
|
+
reference: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cusum_stability(x, alpha: float = 0.05) -> CusumResult:
|
|
105
|
+
"""OLS-CUSUM on the series demeaned by OLS-on-constant: rejects when the mean is unstable over time."""
|
|
106
|
+
from statsmodels.stats.diagnostic import breaks_cusumolsresid
|
|
107
|
+
|
|
108
|
+
a = _clean(x)
|
|
109
|
+
resid = a - a.mean() # OLS residuals of y ~ const
|
|
110
|
+
stat, pvalue, _crit = breaks_cusumolsresid(resid)
|
|
111
|
+
return CusumResult(
|
|
112
|
+
stat=float(stat), pvalue=float(pvalue), stable=bool(pvalue >= alpha), alpha=alpha,
|
|
113
|
+
reference="Brown, Durbin & Evans 1975 lineage; statsmodels breaks_cusumolsresid (Ploberger-Kramer OLS-CUSUM)",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class RegimeFit:
|
|
119
|
+
"""A fitted K-regime Markov-switching mean(+variance) model with per-point smoothed probabilities."""
|
|
120
|
+
|
|
121
|
+
k_regimes: int
|
|
122
|
+
regime_means: list[float]
|
|
123
|
+
smoothed_probabilities: np.ndarray # (n, k) - P(regime j | full sample) per point
|
|
124
|
+
most_likely: np.ndarray # (n,) argmax regime per point
|
|
125
|
+
transition_matrix: list[list[float]]
|
|
126
|
+
reference: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def markov_regimes(x, k_regimes: int = 2, switching_variance: bool = True) -> RegimeFit:
|
|
130
|
+
"""Hamilton Markov-switching regression (mean per regime; optionally variance per regime)."""
|
|
131
|
+
from statsmodels.tsa.regime_switching.markov_regression import MarkovRegression
|
|
132
|
+
|
|
133
|
+
a = _clean(x)
|
|
134
|
+
model = MarkovRegression(a, k_regimes=k_regimes, trend="c", switching_variance=switching_variance)
|
|
135
|
+
res = model.fit(disp=False)
|
|
136
|
+
smoothed = np.asarray(res.smoothed_marginal_probabilities)
|
|
137
|
+
if smoothed.ndim == 1:
|
|
138
|
+
smoothed = smoothed.reshape(-1, k_regimes)
|
|
139
|
+
# Pull each regime's mean by its named parameter (the leading params are transition probabilities).
|
|
140
|
+
names = list(res.model.param_names)
|
|
141
|
+
means = [float(res.params[names.index(f"const[{j}]")]) for j in range(k_regimes)]
|
|
142
|
+
tm = np.asarray(res.regime_transition)[..., 0] if hasattr(res, "regime_transition") else np.full((k_regimes, k_regimes), np.nan)
|
|
143
|
+
return RegimeFit(
|
|
144
|
+
k_regimes=k_regimes, regime_means=means, smoothed_probabilities=smoothed,
|
|
145
|
+
most_likely=np.asarray(smoothed.argmax(axis=1)),
|
|
146
|
+
transition_matrix=[[float(v) for v in row] for row in np.asarray(tm).reshape(k_regimes, k_regimes)],
|
|
147
|
+
reference="Hamilton 1989, Econometrica 57(2):357-384, JSTOR 1912559; statsmodels MarkovRegression",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def changepoints_report(x, k_regimes: int = 2) -> dict:
|
|
152
|
+
"""Run the change-point panel (PELT + CUSUM + Markov regimes) and return a JSON-ready report."""
|
|
153
|
+
a = _clean(x)
|
|
154
|
+
p = pelt(a)
|
|
155
|
+
c = cusum_stability(a)
|
|
156
|
+
out = {
|
|
157
|
+
"n": len(a),
|
|
158
|
+
"pelt": {"breakpoints": p.breakpoints, "segments": p.segments, "model": p.model,
|
|
159
|
+
"penalty": p.penalty, "reference": p.reference},
|
|
160
|
+
"cusum": {"stat": c.stat, "pvalue": c.pvalue, "stable": c.stable, "reference": c.reference},
|
|
161
|
+
}
|
|
162
|
+
# Markov regimes can fail to converge on degenerate series; report honestly instead of crashing.
|
|
163
|
+
try:
|
|
164
|
+
r = markov_regimes(a, k_regimes=k_regimes)
|
|
165
|
+
out["markov"] = {
|
|
166
|
+
"k_regimes": r.k_regimes, "regime_means": r.regime_means,
|
|
167
|
+
"most_likely": r.most_likely.tolist(), "transition_matrix": r.transition_matrix,
|
|
168
|
+
"reference": r.reference,
|
|
169
|
+
}
|
|
170
|
+
except Exception as exc: # noqa: BLE001 - baked artifact must record the failure, not die
|
|
171
|
+
out["markov"] = {"error": f"{type(exc).__name__}: {exc}"}
|
|
172
|
+
return out
|