Files
2026-07-13 13:26:28 +08:00

993 lines
33 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ---
# jupyter:
# jupytext:
# cell_metadata_filter: tags,-all
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.19.3
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# # Structural Break Detection
#
# **Docker image**: `ml4t`
#
# This notebook demonstrates classical and ML-based methods for detecting
# structural breaks in financial time series.
#
# **Learning Objectives**:
# - Apply Zivot-Andrews and Bai-Perron tests for structural break detection
# - Implement CUSUM and MOSUM monitoring statistics for online break detection
# - Frame break detection as binary classification (ADIA Lab approach)
# - Build statistical features for break classification: location shift,
# scale shift, distribution shift, dependence shift
# - Combine evidence across window sizes using Fisher aggregation
#
# **Book Reference**: Chapter 9, Section 9.1 (Diagnostics and Stationarity Features)
#
# **Prerequisites**: Complete `01_visual_diagnostics` for stationarity testing
# fundamentals. Familiarity with hypothesis testing.
# %%
"""Structural Break Detection — classical tests and ML-based classification."""
import warnings
from typing import Any
warnings.filterwarnings("ignore")
from datetime import datetime
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import polars as pl
import ruptures as rpt
from arch.unitroot import ZivotAndrews
from IPython.display import display
from lightgbm import LGBMClassifier
from ml4t.engineer.features.statistics import (
coefficient_of_variation,
rolling_drift,
rolling_kl_divergence,
rolling_wasserstein,
)
from scipy import stats
from scipy.spatial.distance import jensenshannon
from sklearn.metrics import roc_auc_score, roc_curve
from sklearn.model_selection import cross_val_predict, cross_val_score
from data import load_etfs
from utils.reproducibility import set_global_seeds
# %% tags=["parameters"]
# Production defaults — Papermill injects overrides for CI
START_DATE = "2000-01-01"
END_DATE = "2024-12-31"
SEED = 42
# %%
set_global_seeds(SEED)
# %% [markdown]
# ## Load Data
#
# We use SPY for demonstrating break detection on real financial data,
# plus synthetic series for the ADIA Lab classification formulation.
# %%
etfs = load_etfs(symbols=["SPY"])
START = datetime.strptime(START_DATE, "%Y-%m-%d")
END = datetime.strptime(END_DATE, "%Y-%m-%d")
spy = (
etfs.filter((pl.col("timestamp") >= START.date()) & (pl.col("timestamp") <= END.date()))
.sort("timestamp")
.select(["timestamp", "close"])
)
spy_pd = spy.to_pandas().set_index("timestamp")
spy_pd.index = pd.DatetimeIndex(spy_pd.index)
spy_pd["returns"] = np.log(spy_pd["close"]).diff() * 100
print(
f"SPY: {len(spy_pd):,} observations ({spy_pd.index.min().date()} to {spy_pd.index.max().date()})"
)
# %% [markdown]
# # Part 1 — Classical Structural Break Tests
#
# These tests detect break points in level, trend, or volatility of a series.
# The results produce features: `za_break_detected`, `days_since_break`,
# `pre_break_mean`, `post_break_mean`.
# %% [markdown]
# ### Zivot-Andrews Test
#
# Tests for a unit root while allowing one endogenously determined structural
# break. Unlike standard ADF, it doesn't assume constant data generation
# throughout the sample.
# %%
def run_zivot_andrews(series: pd.Series, name: str) -> dict:
"""Run Zivot-Andrews test and return results with break location."""
series = series.dropna()
za = ZivotAndrews(series, lags=12, trend="ct", trim=0.15)
return {
"series": name,
"za_stat": za.stat,
"za_pval": za.pvalue,
"critical_1pct": za.critical_values["1%"],
"critical_5pct": za.critical_values["5%"],
"reject_h0": za.pvalue < 0.05,
}
za_result = run_zivot_andrews(spy_pd["close"], "SPY Levels")
print("=== Zivot-Andrews Test: SPY Levels ===")
print(f"Statistic: {za_result['za_stat']:.4f}")
print(f"P-value: {za_result['za_pval']:.4f}")
print(f"5% critical: {za_result['critical_5pct']:.4f}")
print(f"Reject H0: {za_result['reject_h0']}")
# %% [markdown]
# ### Bai-Perron: Multiple Break Points via `ruptures`
#
# The Zivot-Andrews test finds at most one break. For multi-decade data,
# multiple structural breaks are common (2008 crisis, COVID, etc.).
# The `ruptures` library implements Bai-Perron style segmentation.
# %%
def detect_multiple_breaks(series: np.ndarray, n_breaks: int = 3, model: str = "l2") -> dict:
"""Detect multiple break points using Pelt or dynamic programming.
Args:
series: 1D array of values
n_breaks: Expected number of breaks
model: Cost model ('l2' for mean shift, 'rbf' for distribution shift)
"""
# Pelt with penalty (automatic n_breaks)
algo_pelt = rpt.Pelt(model=model, min_size=60).fit(series)
breaks_pelt = algo_pelt.predict(pen=np.log(len(series)) * series.var())
# Binary segmentation (fixed n_breaks)
algo_binseg = rpt.Binseg(model=model, min_size=60).fit(series)
breaks_binseg = algo_binseg.predict(n_bkps=n_breaks)
return {
"pelt_breaks": breaks_pelt[:-1], # Remove last (=len)
"binseg_breaks": breaks_binseg[:-1],
}
prices = spy_pd["close"].dropna().values
breaks = detect_multiple_breaks(prices, n_breaks=4)
print(f"PELT breaks: {len(breaks['pelt_breaks'])} detected")
print(f"BinSeg breaks: {len(breaks['binseg_breaks'])} detected (requested 4)")
# %%
fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
dates = spy_pd["close"].dropna().index
# PELT results
ax = axes[0]
ax.plot(dates, prices, linewidth=0.8)
for bp in breaks["pelt_breaks"]:
if bp < len(dates):
ax.axvline(dates[bp], color="red", linestyle="--", alpha=0.7)
ax.text(
dates[bp],
prices.max() * 0.95,
dates[bp].strftime("%Y-%m"),
rotation=45,
fontsize=8,
color="red",
)
ax.set_title(f"PELT: {len(breaks['pelt_breaks'])} Break Points Detected")
ax.set_ylabel("Price")
# BinSeg results
ax = axes[1]
ax.plot(dates, prices, linewidth=0.8)
for bp in breaks["binseg_breaks"]:
if bp < len(dates):
ax.axvline(dates[bp], color="red", linestyle="--", alpha=0.7)
ax.text(
dates[bp],
prices.max() * 0.95,
dates[bp].strftime("%Y-%m"),
rotation=45,
fontsize=8,
color="red",
)
ax.set_title("Binary Segmentation: 4 Break Points")
ax.set_ylabel("Price")
plt.tight_layout()
plt.show()
# %% [markdown]
# ### Break-Derived Features
#
# Convert detected breaks into features usable by downstream ML models.
# %%
def compute_break_features(
series: np.ndarray, breaks: list, dates: pd.DatetimeIndex
) -> pd.DataFrame:
"""Convert break points into time-varying features."""
n = len(series)
features = pd.DataFrame(index=dates[:n])
# Binary: has a break been detected before this point?
features["za_break_detected"] = 0
for bp in breaks:
if bp < n:
features.iloc[bp:, features.columns.get_loc("za_break_detected")] = 1
# Days since last break
features["days_since_break"] = n # Default: no break
for i in range(n):
past_breaks = [bp for bp in breaks if bp <= i]
if past_breaks:
features.iloc[i, features.columns.get_loc("days_since_break")] = i - max(past_breaks)
# Pre/post break means (using most recent break)
features["pre_break_mean"] = np.nan
features["post_break_mean"] = np.nan
for bp in sorted(breaks):
if bp < n and bp > 50:
pre_mean = series[max(0, bp - 252) : bp].mean()
post_end = min(bp + 252, n)
post_mean = series[bp:post_end].mean()
features.iloc[bp:, features.columns.get_loc("pre_break_mean")] = pre_mean
features.iloc[bp:, features.columns.get_loc("post_break_mean")] = post_mean
return features
break_features = compute_break_features(prices, breaks["pelt_breaks"], dates)
display(break_features.dropna().head(10))
# %% [markdown]
# ### CUSUM Monitoring
#
# The cumulative sum (CUSUM) statistic detects shifts in the mean of a process.
# Unlike retrospective tests, CUSUM can be used for online monitoring —
# it accumulates evidence sequentially and triggers when a threshold is exceeded.
# %%
def cusum_statistic(series: np.ndarray, target: float | None = None) -> np.ndarray:
"""Compute one-sided CUSUM statistic.
CUSUM accumulates deviations from the target mean.
Sustained positive CUSUM indicates upward shift; negative indicates downward.
"""
if target is None:
target = series.mean()
return np.cumsum(series - target)
returns = spy_pd["returns"].dropna()
cusum = cusum_statistic(returns.values)
fig, axes = plt.subplots(2, 1, figsize=(14, 6), sharex=True)
ax = axes[0]
ax.plot(returns.index, returns.values, linewidth=0.3, alpha=0.5)
ax.set_title("SPY Returns (%)")
ax.set_ylabel("Return (%)")
ax = axes[1]
ax.plot(returns.index, cusum, linewidth=0.8)
ax.axhline(0, color="red", linestyle="--", linewidth=0.5)
ax.set_title("CUSUM of Returns (Cumulative Deviation from Mean)")
ax.set_ylabel("Cumulative Sum")
plt.tight_layout()
plt.show()
# %% [markdown]
# ### MOSUM Monitoring
#
# The moving sum (MOSUM) statistic complements CUSUM by using a **fixed-width
# sliding window** rather than accumulating from the start. Where CUSUM detects
# gradual drift, MOSUM is better at detecting abrupt, localized shifts — the
# signal peaks at the break and decays as the window slides past.
#
# The MOSUM at time $t$ with bandwidth $h$ is the standardized mean difference
# between the observations in $[t-h, t]$ and the full-sample mean:
#
# $$M_t = \frac{1}{\hat{\sigma}\sqrt{h}} \sum_{i=t-h+1}^{t} (x_i - \bar{x})$$
#
# Large $|M_t|$ values indicate a local departure from the overall mean.
# %%
def mosum_statistic(
series: np.ndarray, bandwidth: int = 50, target: float | None = None
) -> np.ndarray:
"""Compute MOSUM (moving sum) monitoring statistic.
Unlike CUSUM which accumulates from the start, MOSUM uses a fixed-width
sliding window — better at detecting abrupt, localized shifts.
Parameters
----------
series : array
Input time series.
bandwidth : int
Window width (h). Larger h → smoother, less sensitive to short breaks.
target : float, optional
Reference mean. Defaults to full-sample mean.
Returns
-------
mosum : array
Standardized MOSUM statistic (same length as series, NaN-padded).
"""
if target is None:
target = series.mean()
sigma = series.std()
n = len(series)
mosum = np.full(n, np.nan)
for t in range(bandwidth, n):
window_sum = np.sum(series[t - bandwidth + 1 : t + 1] - target)
mosum[t] = window_sum / (sigma * np.sqrt(bandwidth))
return mosum
# %%
# Compare CUSUM and MOSUM on the same returns series
mosum_50 = mosum_statistic(returns.values, bandwidth=50)
mosum_20 = mosum_statistic(returns.values, bandwidth=20)
fig, axes = plt.subplots(3, 1, figsize=(14, 7), sharex=True)
ax = axes[0]
ax.plot(returns.index, returns.values, linewidth=0.3, alpha=0.5)
ax.set_title("SPY Returns (%)")
ax.set_ylabel("Return (%)")
ax = axes[1]
ax.plot(returns.index, cusum, linewidth=0.8, label="CUSUM")
ax.axhline(0, color="red", linestyle="--", linewidth=0.5)
ax.set_title("CUSUM — Accumulates Drift from Start")
ax.set_ylabel("Cumulative Sum")
ax.legend(loc="upper left")
ax = axes[2]
ax.plot(returns.index, mosum_50, linewidth=0.8, label="MOSUM (h=50)")
ax.plot(returns.index, mosum_20, linewidth=0.8, alpha=0.6, label="MOSUM (h=20)")
ax.axhline(0, color="red", linestyle="--", linewidth=0.5)
ax.set_title("MOSUM — Detects Localized Shifts")
ax.set_ylabel("Standardized MOSUM")
ax.legend(loc="upper left")
plt.tight_layout()
plt.show()
# %% [markdown]
# **CUSUM vs MOSUM**: CUSUM is cumulative — once it detects a shift, the
# statistic remains elevated permanently. MOSUM is local — it peaks at the
# break and decays as the window slides past, making it easier to date breaks
# precisely. The bandwidth $h$ controls sensitivity: smaller $h$ detects
# shorter breaks but is noisier; larger $h$ is smoother but may miss brief
# regime changes. Using both provides complementary evidence: CUSUM for
# sustained drift, MOSUM for abrupt shifts.
# %% [markdown]
# # Part 2 — Break Detection as Binary Classification (ADIA Lab Formulation)
#
# The ADIA Lab structural break competition reframes break detection as a
# supervised classification problem: given a time series split at a candidate
# boundary $\tau$, predict whether a structural break occurred at that point.
#
# The Alphabot solution built 58 statistical features comparing the
# pre-boundary and post-boundary windows. The construction combines
# multiple tests, each probing a different aspect of distributional
# change, into the features for a downstream classifier.
#
# We implement the core feature families below. These features are useful
# beyond competition — they provide a structured toolkit for detecting
# regime changes in financial data.
# %% [markdown]
# ### Generate Synthetic Data
#
# Create training examples with known break structure for demonstration.
# %%
def generate_break_series(
n: int = 500, break_type: str = "mean_shift", magnitude: float = 1.0, seed: int | None = None
) -> tuple[np.ndarray, bool, int]:
"""Generate a time series with or without a structural break at midpoint.
Returns (series, has_break, break_idx).
"""
rng = np.random.RandomState(seed)
mid = n // 2
if break_type == "none":
series = rng.randn(n)
return series, False, mid
elif break_type == "mean_shift":
pre = rng.randn(mid)
post = rng.randn(n - mid) + magnitude
return np.concatenate([pre, post]), True, mid
elif break_type == "var_shift":
pre = rng.randn(mid)
post = rng.randn(n - mid) * (1 + magnitude)
return np.concatenate([pre, post]), True, mid
elif break_type == "trend_shift":
t = np.arange(n, dtype=float)
pre_trend = 0.001 * t[:mid]
post_trend = 0.001 * t[:mid][-1] + magnitude * 0.01 * (t[mid:] - t[mid])
noise = rng.randn(n) * 0.5
return np.concatenate([pre_trend, post_trend]) + noise, True, mid
elif break_type == "autocorr_shift":
pre = np.zeros(mid)
for i in range(1, mid):
pre[i] = 0.2 * pre[i - 1] + rng.randn()
post = np.zeros(n - mid)
for i in range(1, n - mid):
post[i] = (0.2 + magnitude * 0.5) * post[i - 1] + rng.randn()
return np.concatenate([pre, post]), True, mid
msg = f"Unknown break_type: {break_type}"
raise ValueError(msg)
# Generate examples
rng = np.random.RandomState(42)
n_samples = 200
series_list = []
labels = []
for i in range(n_samples):
if i < n_samples // 2:
# No break
s, has_break, mid = generate_break_series(500, "none", seed=i)
else:
# Random break type
break_types = ["mean_shift", "var_shift", "trend_shift", "autocorr_shift"]
bt = break_types[(i - n_samples // 2) % len(break_types)]
mag = rng.uniform(0.3, 2.0)
s, has_break, mid = generate_break_series(500, bt, magnitude=mag, seed=i)
series_list.append(s)
labels.append(int(has_break))
labels = np.array(labels)
print(
f"Generated {n_samples} series: {(labels == 0).sum()} no-break, {(labels == 1).sum()} with break"
)
# %%
# Visualize examples
fig, axes = plt.subplots(2, 4, figsize=(16, 6))
types = ["none", "mean_shift", "var_shift", "trend_shift"]
for i, bt in enumerate(types):
s, _, mid = generate_break_series(500, bt, magnitude=1.0, seed=99)
ax = axes[0, i]
ax.plot(s, linewidth=0.5)
ax.axvline(mid, color="red", linestyle="--", alpha=0.7)
ax.set_title(bt.replace("_", " ").title())
# Show pre/post distributions
ax = axes[1, i]
ax.hist(s[:mid], bins=30, alpha=0.5, density=True, label="Pre")
ax.hist(s[mid:], bins=30, alpha=0.5, density=True, label="Post")
ax.legend(fontsize=8)
ax.set_title("Pre vs Post Distribution")
plt.suptitle("Structural Break Types", fontweight="bold")
plt.tight_layout()
plt.show()
# %% [markdown]
# ### Feature Family 1: Location Shift
#
# Compare the mean/median of pre-boundary vs post-boundary windows.
# The core test is Welch's t-test on absolute values (robust to scale differences).
# %%
def location_shift_features(series: np.ndarray, boundary: int) -> dict:
"""Compute location shift features comparing pre/post boundary.
Based on Alphabot features f01-f02: Welch t-test on absolute values
at multiple window sizes, with Fisher aggregation.
"""
features = {}
pre = series[:boundary]
post = series[boundary:]
# Welch t-test on raw values
t_stat, p_val = stats.ttest_ind(pre, post, equal_var=False)
features["welch_t_pval"] = p_val
features["welch_t_stat"] = abs(t_stat)
# Welch t-test on absolute values (captures scale + location shifts)
for window in [50, 100, 250]:
w = min(window, len(pre), len(post))
if w < 10:
features[f"local_t_abs_{window}"] = 0.5
continue
pre_w = np.abs(pre[-w:])
post_w = np.abs(post[:w])
_, p = stats.ttest_ind(pre_w, post_w, equal_var=False)
features[f"local_t_abs_{window}"] = p
# Fisher aggregation across window sizes (Alphabot f02)
p_values = [features.get(f"local_t_abs_{w}", 0.5) for w in [50, 100]]
p_values = [max(p, 1e-300) for p in p_values] # Avoid log(0)
fisher_stat = -2 * sum(np.log(p) for p in p_values)
features["fisher_location"] = 1 - stats.chi2.cdf(fisher_stat, df=2 * len(p_values))
# Mean difference (standardized)
pooled_std = np.sqrt((pre.var() + post.var()) / 2)
if pooled_std > 0:
features["mean_diff_std"] = abs(pre.mean() - post.mean()) / pooled_std
else:
features["mean_diff_std"] = 0.0
return features
# %% [markdown]
# ### Feature Family 2: Scale Shift
#
# Detect changes in variance/dispersion using F-test, Levene, and Fligner-Killeen.
# %%
def scale_shift_features(series: np.ndarray, boundary: int) -> dict:
"""Compute scale shift features: variance ratio, Levene, Fligner-Killeen."""
features = {}
pre = series[:boundary]
post = series[boundary:]
# F-test variance ratio
var_ratio = post.var(ddof=1) / max(pre.var(ddof=1), 1e-10)
features["var_ratio"] = var_ratio
features["log_var_ratio"] = abs(np.log(max(var_ratio, 1e-10)))
# F-test p-value (Alphabot f09)
f_stat = max(var_ratio, 1.0 / max(var_ratio, 1e-10))
df1 = len(post) - 1
df2 = len(pre) - 1
f_pval = 2 * (1 - stats.f.cdf(f_stat, df1, df2))
features["f_test_pval"] = f_pval
features["neg_log10_f_pval"] = -np.log10(max(f_pval, 1e-300))
# Fligner-Killeen test (robust to non-normality, Alphabot f05)
_, fk_pval = stats.fligner(pre, post)
features["fligner_pval"] = fk_pval
# Levene test
_, levene_pval = stats.levene(pre, post)
features["levene_pval"] = levene_pval
return features
# %% [markdown]
# ### Feature Family 3: Distribution Shift
#
# Detect full distributional changes using KS test, JSD, Hellinger distance,
# and Wasserstein distance. These capture shifts that go beyond mean/variance.
# %%
def distribution_shift_features(series: np.ndarray, boundary: int, n_bins: int = 50) -> dict:
"""Compute distribution shift features: KS, JSD, Hellinger, Wasserstein."""
features = {}
pre = series[:boundary]
post = series[boundary:]
# Kolmogorov-Smirnov test
ks_stat, ks_pval = stats.ks_2samp(pre, post)
features["ks_stat"] = ks_stat
features["ks_pval"] = ks_pval
# Jensen-Shannon divergence (Alphabot f08)
# Histogram-based
all_data = np.concatenate([pre, post])
bins = np.linspace(all_data.min() - 0.1, all_data.max() + 0.1, n_bins + 1)
pre_hist, _ = np.histogram(pre, bins=bins, density=True)
post_hist, _ = np.histogram(post, bins=bins, density=True)
# Add small epsilon to avoid zeros
pre_hist = pre_hist + 1e-10
post_hist = post_hist + 1e-10
pre_hist = pre_hist / pre_hist.sum()
post_hist = post_hist / post_hist.sum()
features["jsd"] = jensenshannon(pre_hist, post_hist) ** 2 # Squared JSD
# Hellinger distance (Alphabot f11)
hellinger = np.sqrt(1 - np.sum(np.sqrt(pre_hist * post_hist)))
features["hellinger"] = hellinger
# Wasserstein distance (Earth mover's distance)
features["wasserstein"] = stats.wasserstein_distance(pre, post)
return features
# %% [markdown]
# ### Feature Family 4: Dependence Shift
#
# Detect changes in autocorrelation structure. Structural breaks often alter
# the temporal dependence pattern (e.g., volatility clustering appears/disappears).
# %%
def dependence_shift_features(series: np.ndarray, boundary: int) -> dict:
"""Compute dependence shift features: autocorrelation change, BDS proxy."""
features = {}
pre = series[:boundary]
post = series[boundary:]
# Lag-1 autocorrelation difference
if len(pre) > 2 and len(post) > 2:
ac_pre = np.corrcoef(pre[:-1], pre[1:])[0, 1]
ac_post = np.corrcoef(post[:-1], post[1:])[0, 1]
features["autocorr_diff"] = abs(ac_post - ac_pre)
features["autocorr_pre"] = ac_pre
features["autocorr_post"] = ac_post
else:
features["autocorr_diff"] = 0.0
features["autocorr_pre"] = 0.0
features["autocorr_post"] = 0.0
# Variance of squared values (proxy for volatility clustering change)
sq_pre = pre**2
sq_post = post**2
if len(sq_pre) > 2 and len(sq_post) > 2:
ac_sq_pre = np.corrcoef(sq_pre[:-1], sq_pre[1:])[0, 1]
ac_sq_post = np.corrcoef(sq_post[:-1], sq_post[1:])[0, 1]
features["sq_autocorr_diff"] = abs(ac_sq_post - ac_sq_pre)
else:
features["sq_autocorr_diff"] = 0.0
return features
# %% [markdown]
# ### Feature Family 5: Change Point Alignment
#
# Use data-driven change point detection (CUSUM) and measure how well
# the estimated break aligns with the candidate boundary.
# %%
def alignment_features(series: np.ndarray, boundary: int) -> dict:
"""Compute change point alignment features (Alphabot f03)."""
features = {}
n = len(series)
# CUSUM-based change point
cusum = np.cumsum(series - series.mean())
cusum_cp = np.argmax(np.abs(cusum))
features["cusum_dist_to_boundary"] = abs(cusum_cp - boundary) / n
features["cusum_max"] = abs(cusum[cusum_cp]) / (series.std() * np.sqrt(n))
# Ruptures Pelt change point — Pelt/optimization can occasionally fail on
# short, near-constant series; fall back to "no detected break" (distance=1).
try:
algo = rpt.Pelt(model="l2", min_size=20).fit(series)
bkps = algo.predict(pen=np.log(n) * series.var())
bkps = [b for b in bkps if b < n]
if bkps:
closest = min(bkps, key=lambda b: abs(b - boundary))
features["ruptures_dist_to_boundary"] = abs(closest - boundary) / n
else:
features["ruptures_dist_to_boundary"] = 1.0
except (ValueError, RuntimeError):
features["ruptures_dist_to_boundary"] = 1.0
return features
# %% [markdown]
# ### Combine All Features
#
# Build the full feature matrix from all families and demonstrate classification.
# %%
def compute_all_break_features(series: np.ndarray, boundary: int) -> dict:
"""Compute all structural break features for a single series."""
features = {}
features.update(location_shift_features(series, boundary))
features.update(scale_shift_features(series, boundary))
features.update(distribution_shift_features(series, boundary))
features.update(dependence_shift_features(series, boundary))
features.update(alignment_features(series, boundary))
return features
# Build feature matrix
feature_rows = []
for i, series in enumerate(series_list):
mid = len(series) // 2
feat = compute_all_break_features(series, mid)
feature_rows.append(feat)
X = pd.DataFrame(feature_rows)
y = labels
print(f"Feature matrix: {X.shape}")
print(f"Features: {list(X.columns)}")
X.head()
# %% [markdown]
# ### Classification with Gradient Boosting
#
# Following the ADIA Lab winners, we use LightGBM to combine the statistical
# features. The 2nd-place solution generated 2,408 features from time series
# transformations (z-score, cumsum, ranking, moving average/std) and used
# SHAP-based selection to pick the top 200500.
# %%
# Handle any NaN/inf values
X = X.replace([np.inf, -np.inf], np.nan).fillna(0)
# Cross-validated AUC
clf = LGBMClassifier(n_estimators=100, max_depth=3, random_state=42, verbose=-1)
cv_scores = cross_val_score(clf, X, y, cv=5, scoring="roc_auc")
print("=== Structural Break Classification ===")
print(f"5-Fold CV AUC: {cv_scores.mean():.4f} (+/- {cv_scores.std():.4f})")
# Fit on full data for feature importance
clf.fit(X, y)
importances = pd.Series(clf.feature_importances_, index=X.columns).sort_values(ascending=False)
print("\nTop 10 Features by Importance:")
for feat, imp in importances.head(10).items():
print(f" {feat:<30} {imp:.4f}")
# %%
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Feature importance
ax = axes[0]
importances.head(15).plot.barh(ax=ax)
ax.set_title("Feature Importance (GBM)")
ax.set_xlabel("Importance")
# ROC curve via cross-validation predictions
y_prob = cross_val_predict(clf, X, y, cv=5, method="predict_proba")[:, 1]
fpr, tpr, _ = roc_curve(y, y_prob)
ax = axes[1]
ax.plot(fpr, tpr, linewidth=2, label=f"AUC = {roc_auc_score(y, y_prob):.3f}")
ax.plot([0, 1], [0, 1], "k--", linewidth=0.5)
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title("ROC Curve — Break Detection")
ax.legend()
plt.tight_layout()
plt.show()
# %% [markdown]
# ### Feature Family Contribution Analysis
#
# Which family of features contributes most to break detection?
# %%
family_map = {
"location": [
"welch_t_pval",
"welch_t_stat",
"local_t_abs_50",
"local_t_abs_100",
"local_t_abs_250",
"fisher_location",
"mean_diff_std",
],
"scale": [
"var_ratio",
"log_var_ratio",
"f_test_pval",
"neg_log10_f_pval",
"fligner_pval",
"levene_pval",
],
"distribution": ["ks_stat", "ks_pval", "jsd", "hellinger", "wasserstein"],
"dependence": ["autocorr_diff", "autocorr_pre", "autocorr_post", "sq_autocorr_diff"],
"alignment": ["cusum_dist_to_boundary", "cusum_max", "ruptures_dist_to_boundary"],
}
family_importance = {}
for family, feats in family_map.items():
existing = [f for f in feats if f in importances.index]
family_importance[family] = importances[existing].sum()
family_series = pd.Series(family_importance).sort_values(ascending=True)
fig, ax = plt.subplots(figsize=(8, 4))
family_series.plot.barh(ax=ax, color=["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd"])
ax.set_title("Feature Family Contribution to Break Detection")
ax.set_xlabel("Total Importance")
plt.tight_layout()
plt.show()
# %% [markdown]
# ## Application to Financial Data
#
# Apply the break classification features to SPY returns around known
# crisis periods to test whether the statistical features detect
# genuine structural breaks.
# %%
def analyze_period(
returns: pd.Series, center_date: str, window: int = 250, label: str = ""
) -> dict[str, Any] | None:
"""Compute break features around a specific date."""
center = pd.Timestamp(center_date)
mask = (returns.index >= center - pd.Timedelta(days=window * 2)) & (
returns.index <= center + pd.Timedelta(days=window * 2)
)
period_returns = returns[mask].dropna().values
if len(period_returns) < 100:
return None
mid = len(period_returns) // 2
features = compute_all_break_features(period_returns, mid)
features["period"] = label
features["center_date"] = center_date
features["n_obs"] = len(period_returns)
return features
spy_returns = spy_pd["returns"].dropna()
periods = [
("2008-09-15", "2008 Financial Crisis"),
("2020-03-11", "COVID-19 Crash"),
("2011-08-05", "US Debt Downgrade"),
("2015-06-15", "Mid-2015 (Quiet)"),
("2017-06-15", "Mid-2017 (Quiet)"),
("2019-06-15", "Mid-2019 (Quiet)"),
]
period_results = []
for date, label in periods:
result = analyze_period(spy_returns, date, label=label)
if result is not None:
period_results.append(result)
period_df = pd.DataFrame(period_results)
# Key metrics comparison
key_cols = [
"period",
"ks_stat",
"jsd",
"wasserstein",
"mean_diff_std",
"var_ratio",
"fisher_location",
]
display(period_df[key_cols])
# %%
# Predict break probability for each period
period_X = period_df[[c for c in X.columns if c in period_df.columns]]
period_X = period_X.replace([np.inf, -np.inf], np.nan).fillna(0)
period_df["break_probability"] = clf.predict_proba(period_X)[:, 1]
display(
period_df[["period", "break_probability"]].sort_values("break_probability", ascending=False)
)
fig, ax = plt.subplots(figsize=(10, 5))
colors = [
"red" if "Crisis" in p or "COVID" in p or "Downgrade" in p else "blue"
for p in period_df["period"]
]
ax.barh(period_df["period"], period_df["break_probability"], color=colors, alpha=0.7)
ax.axvline(0.5, color="black", linestyle="--", linewidth=0.5)
ax.set_xlabel("Break Probability")
ax.set_title("Structural Break Detection — SPY Around Key Dates")
plt.tight_layout()
plt.show()
# %% [markdown]
# **Finding**: The classifier returns near-certain break probability on every
# crisis period and on two of the three "quiet" periods (mid-2015 and mid-2019).
# Only mid-2017 — the one window with no embedded volatility shift — comes back
# with a low probability. The lesson is not that the features are useless. They
# correctly rank the magnitude of distributional change (compare `wasserstein`
# and `var_ratio` across periods in the table above). It is that a classifier
# trained on synthetic 500-step windows with magnitude-1 mean/variance shifts
# cannot be used as a calibrated detector on 1000-day SPY windows: almost any
# real market window contains a shift larger than the training-time threshold.
# %% [markdown]
# **Caveat**: For production use, train on historical breaks with labeled
# regime changes (e.g., NBER recession dates, central-bank-policy turning points,
# or Bai-Perron break dates from a long sample), or use the feature values
# directly as continuous inputs to a downstream model rather than relying on the
# classifier's probability calibration.
# %% [markdown]
# ## ml4t-engineer: Break Detection as Polars Expressions
#
# The manual feature engineering above computes statistical tests in NumPy.
# `ml4t-engineer` provides rolling break detection features as Polars
# expressions — coefficient of variation, KL divergence, Wasserstein
# distance, and drift — inspired by the ADIA Lab competition insights.
# %%
# Load SPY for ml4t-engineer demonstration
spy_break = (
etfs.filter(pl.col("symbol") == "SPY")
.select(["timestamp", "close"])
.sort("timestamp")
.with_columns(returns=pl.col("close").pct_change())
.drop_nulls()
)
spy_break = spy_break.with_columns(
cv=coefficient_of_variation("returns", window=50),
kl_div=rolling_kl_divergence("returns", window=100),
w_dist=rolling_wasserstein("returns", window=100),
drift=rolling_drift("returns", window=100),
)
print("=== ml4t-engineer: Break Detection Features ===")
for col in ["cv", "kl_div", "w_dist", "drift"]:
vals = spy_break[col].drop_nulls()
print(f" {col:<10}: mean={vals.mean():.4f}, std={vals.std():.4f}")
# %% [markdown]
# These features can be combined with the CUSUM/MOSUM statistics above
# as input to a classifier (Section 5) or used directly as regime change
# indicators in downstream ML pipelines.
# %% [markdown]
# ## Key Takeaways
#
# 1. **Classical tests** (Zivot-Andrews, Bai-Perron) detect level/trend breaks
# but are limited to specific null hypotheses
# 2. **CUSUM/MOSUM** provide online monitoring capability — useful for real-time
# regime change detection
# 3. **ML-based classification** (ADIA Lab approach) combines multiple
# statistical features into a single classifier; the 5-fold CV AUC of
# 0.998 on the synthetic benchmark in this notebook reflects the
# controlled known-break structure, not real-data performance
# 4. **Five feature families** capture different aspects of distributional change:
# location, scale, distribution shape, dependence structure, and alignment
# 5. **Fisher aggregation** combines p-values across window sizes for robust
# multi-scale evidence
# 6. The **2nd-place approach** (2,408 features from time series transformations +
# LightGBM) demonstrates that brute-force feature engineering combined with
# feature selection can rival domain-specific features
#
# **Previous**: `01_visual_diagnostics` for stationarity testing fundamentals.
# **Next**: `03_fractional_differencing` for memory-preserving stationarity transforms.