# --- # 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 200–500. # %% # 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.