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.
@@ -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