# --- # 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] # # Macro-Based Regime Detection # # **Chapter 1 · §1.4 Market Regimes: Change Is the Constant** # # **Docker image**: `ml4t` # # ## Purpose # # Demonstrates unsupervised learning for market regime detection using macroeconomic # indicators from FRED, paired with S&P 500 volatility and drawdown for validation. # # ## Learning Objectives # # - Cluster monthly macro indicators with GMM, K-Means, and hierarchical methods. # - Validate macro clusters against realized equity volatility and drawdown. # - Compare a four-indicator core view to an extended FRED panel. # - Use PCA preprocessing to filter noise before clustering. # # ## Book Reference # # Section 1.4 of Chapter 1, "Market Regimes: Change Is the Constant" — macro-regime # follow-up to `factor_regimes.py`. Figure 1.6 in the chapter is generated here. # # ## Prerequisites # # - Familiarity with monthly time-series resampling and standardization. # - Conceptual exposure to mixture models, K-Means, and dendrograms. # - FRED panel and S&P 500 daily series materialized via `data/macro/` and # `data/equities/sp500/` loaders. # # ## Structure # # 1. **Core Analysis (4 indicators)** — UNRATE, DFF, T10Y2Y, CPIAUCSL → CPI YoY. # 2. **Extended Analysis** — full FRED panel (after coverage filtering) for a richer # but noisier view of economic conditions. # 3. **Comparison** — silhouette scores across core, extended, and PCA-reduced models. # # ## Key Insight # # Macro regimes line up with distinct *volatility* environments more cleanly than they # line up with average returns. This makes macro indicators useful for risk management # (anticipating volatility shifts) rather than return prediction. # %% [markdown] # ## Imports # %% """Macro-Based Regime Detection — unsupervised regime detection using FRED macro indicators.""" from __future__ import annotations import warnings warnings.filterwarnings("ignore") import matplotlib.pyplot as plt import numpy as np import pandas as pd import polars as pl import seaborn as sns from matplotlib.gridspec import GridSpec from scipy.cluster.hierarchy import cophenet, dendrogram, linkage from scipy.spatial.distance import pdist from sklearn.cluster import KMeans from sklearn.decomposition import PCA from sklearn.metrics import silhouette_score from sklearn.mixture import GaussianMixture from sklearn.preprocessing import StandardScaler, scale from data import load_macro, load_sp500_index from utils.paths import get_output_dir from utils.reproducibility import set_global_seeds # %% tags=["parameters"] # Production defaults (Papermill overrides for testing) SEED = 42 # %% [markdown] # ## Configuration # %% OUTPUT_DIR = get_output_dir(1, "macro_regimes") OUTPUT_DIR.mkdir(parents=True, exist_ok=True) set_global_seeds(SEED) DATE_COL = "timestamp" # %% [markdown] # ## Helper Function # # Reusable function for regime visualization. Both GMM and K-Means results # use the same heatmap format for easy comparison. # %% def plot_regime_heatmap( assignments: np.ndarray, dates: pd.DatetimeIndex, title: str, n_regimes: int = 4, is_probability: bool = True, figsize: tuple = (14, 4), ) -> None: """ Plot regime assignments as a heatmap. Parameters ---------- assignments : np.ndarray Either probability matrix (n_samples, n_regimes) for GMM or hard labels (n_samples,) for K-Means dates : pd.DatetimeIndex Dates corresponding to each sample title : str Plot title n_regimes : int Number of regimes is_probability : bool True if assignments are probabilities, False for hard labels figsize : tuple Figure size """ fig, ax = plt.subplots(figsize=figsize, layout="tight") # Convert hard labels to one-hot if needed if not is_probability: one_hot = np.zeros((len(assignments), n_regimes)) one_hot[np.arange(len(assignments)), assignments] = 1 data = one_hot else: data = assignments im = ax.imshow(data.T, aspect="auto", cmap="Blues", vmin=0, vmax=1) # X-axis: years years = [pd.Timestamp(ts).year for ts in dates] year_ticks = [j for j, y in enumerate(years) if j == 0 or years[j - 1] != y] year_labels = [str(years[j]) for j in year_ticks] ax.set_xticks(year_ticks[::2]) ax.set_xticklabels(year_labels[::2], fontsize=9) # Y-axis: regimes ax.set_yticks(range(n_regimes)) ax.set_yticklabels([f"Regime {i + 1}" for i in range(n_regimes)]) ax.set_ylabel("Regime") ax.set_xlabel("Year") ax.set_title(title, fontsize=12) cbar = plt.colorbar(im, ax=ax, shrink=0.8) cbar.set_label("Probability" if is_probability else "Assignment") plt.show() # %% [markdown] # --- # # # Load FRED Macro Data # # We load all available FRED macro indicators once, then select subsets for analysis. # The dataset includes ~17 series after filtering for data quality. # %% [markdown] # ## Load Full Dataset # %% macro_raw = load_macro() # group_by_dynamic requires Datetime, not Date if macro_raw["timestamp"].dtype == pl.Date: macro_raw = macro_raw.with_columns(pl.col("timestamp").cast(pl.Datetime)) print(f"Loaded FRED macro data: {macro_raw.shape}") print(f"Date range: {macro_raw['timestamp'].min()} to {macro_raw['timestamp'].max()}") print(f"Available series: {[c for c in macro_raw.columns if c != 'timestamp']}") # %% [markdown] # ## Available Indicators # # | Category | Series | Description | # |----------|--------|-------------| # | **Labor** | UNRATE | Unemployment rate (%) | # | **Interest Rates** | DFF | Federal Funds effective rate (%) | # | | T10Y2Y | 10Y-2Y Treasury spread (yield curve) | # | | T10YIE | 10Y breakeven inflation | # | **Prices** | CPIAUCSL | Consumer Price Index | # | | CPILFESL | Core CPI (ex food & energy) | # | **Volatility** | VIXCLS | VIX volatility index | # | **Credit** | BAMLHE00EHYIEY | High yield spread | # | **Housing** | CSUSHPISA | Case-Shiller home price index | # | **Money** | M2SL | M2 money supply | # # For the **Core Analysis**, we use only 4 fundamental macro indicators. # %% [markdown] # --- # # # Core Analysis: 4 Macro Indicators # # We use four indicators that reflect real economic conditions: # # - **UNRATE**: Unemployment rate - labor market health # - **DFF**: Federal Funds rate - monetary policy stance # - **T10Y2Y**: Yield curve slope - recession signal # - **CPIAUCSL → cpi_yoy**: CPI year-over-year inflation rate (not the level, which trends upward) # %% [markdown] # ## Select Core Indicators # %% # Core 4 indicators (case-insensitive matching) CORE_INDICATORS = ["unrate", "dff", "t10y2y", "cpiaucsl"] macro_columns = macro_raw.columns core_cols = [] col_name_map = {} for indicator in CORE_INDICATORS: for col in macro_columns: if col.lower() == indicator: core_cols.append(col) col_name_map[col] = indicator break print(f"Core indicators selected: {len(core_cols)}/{len(CORE_INDICATORS)}") for col in core_cols: print(f" {col} -> {col_name_map[col]}") # %% [markdown] # ## Resample to Monthly # %% macro_monthly = ( macro_raw.select([DATE_COL] + core_cols) .sort(DATE_COL) .group_by_dynamic(DATE_COL, every="1mo", label="right") .agg([pl.col(c).last() for c in core_cols]) # Forward-fill carries last observation through release lags; the small # backward-fill catches any leading nulls in early months. The latter # introduces a one-period look-ahead at the panel boundary — acceptable # for a regime-detection demo, not for a backtested strategy. .fill_null(strategy="forward") .fill_null(strategy="backward") .drop_nulls(subset=core_cols) ) # Filter to 2002+ where most FRED series have good coverage if macro_monthly.height > 0: min_date = macro_monthly[DATE_COL].min() if min_date is not None and str(min_date) < "2002-01-01": macro_monthly = macro_monthly.filter(pl.col(DATE_COL) >= pl.datetime(2002, 1, 1)) print(f"Monthly data: {macro_monthly.height} months") if macro_monthly.height > 0: print(f"Date range: {macro_monthly[DATE_COL].min()} to {macro_monthly[DATE_COL].max()}") # %% [markdown] # ## Standardize for Clustering # %% if macro_monthly.height > 0: macro_df = macro_monthly.select(core_cols).to_pandas() macro_df.columns = [col_name_map.get(c, c) for c in macro_df.columns] macro_df.index = macro_monthly[DATE_COL].to_pandas() # Convert CPI level to YoY percentage change — the level is non-stationary # and would make the clustering capture "early vs recent" rather than # "inflationary vs non-inflationary" macro_df["cpi_yoy"] = macro_df["cpiaucsl"].pct_change(12) * 100 macro_df = macro_df.drop(columns=["cpiaucsl"]).dropna() macro_scaled = StandardScaler().fit_transform(macro_df) print("Core Indicator Statistics (raw):") print(macro_df.describe().round(2)) else: macro_df = pd.DataFrame() macro_scaled = np.array([]) # %% [markdown] # ## Fit GMM with 4 Regimes # %% if len(macro_df) > 0: n_macro_regimes = 4 gmm_macro = GaussianMixture( n_components=n_macro_regimes, covariance_type="full", random_state=SEED, n_init=10, reg_covar=1e-6, ) gmm_macro.fit(macro_scaled) macro_labels = gmm_macro.predict(macro_scaled) macro_probs = gmm_macro.predict_proba(macro_scaled) macro_silhouette = silhouette_score(macro_scaled, macro_labels) print(f"Macro regime silhouette score (n={n_macro_regimes}): {macro_silhouette:.3f}") else: macro_labels = np.array([]) macro_probs = np.array([]) # %% [markdown] # We use 4 regimes to match the Two Sigma approach. The silhouette score measures # cluster separation — higher is better, with values above 0.25 indicating # reasonable structure. Unlike factor_regimes, we skip BIC/AIC model selection here # because the goal is interpretability (matching known economic phases), not # statistical optimality. # %% [markdown] # ## Regime Characteristics # %% if len(macro_df) > 0: regime_chars = macro_df.copy() regime_chars["regime"] = macro_labels regime_means = regime_chars.groupby("regime").mean() print("Regime Characteristics (mean values):") print(regime_means.round(2)) else: regime_means = pd.DataFrame() # %% [markdown] # ## Assign Interpretive Labels # %% def create_regime_labels(chars: pd.DataFrame) -> dict[int, str]: """Create short, descriptive labels based on economic characteristics. Uses a priority cascade on cluster-mean values. Thresholds are approximate and tuned for the 2002-2025 US macro environment. """ labels = {} for regime in chars.index: c = chars.loc[regime] if c["unrate"] > 10: labels[regime] = "Crisis" elif c["unrate"] > 6 and c["dff"] < 0.5: labels[regime] = "Recovery" elif c["dff"] > 3 and c["t10y2y"] < 0.5: labels[regime] = "Tightening" elif c["cpi_yoy"] > 4: labels[regime] = "Inflation" elif c["unrate"] < 5 and c["dff"] < 2: labels[regime] = "Expansion" else: labels[regime] = "Transition" # Ensure unique labels seen = {} for r, label in list(labels.items()): if label in seen: if chars.loc[r, "unrate"] > chars.loc[seen[label], "unrate"]: labels[r] = f"{label} (High Unemp.)" else: labels[seen[label]] = f"{label} (High Unemp.)" seen[label] = r return labels # %% if len(regime_means) > 0: regime_labels_map = create_regime_labels(regime_means) print("Assigned Regime Labels:") for regime, label in sorted(regime_labels_map.items()): print(f" Cluster {regime}: {label}") else: regime_labels_map = {} # %% [markdown] # ## Macro Regimes and Market Volatility # # **Key insight**: Macro regimes coincide with different VOLATILITY environments, # even when return patterns are less clear. # # **Implication**: Macro regime indicators are useful for RISK MANAGEMENT # (anticipating volatility shifts) rather than RETURN PREDICTION. # %% [markdown] # ### Load S&P 500 for Validation # %% sp500_raw = load_sp500_index().to_pandas() sp500_raw["timestamp"] = pd.to_datetime(sp500_raw["timestamp"]) sp500_raw = sp500_raw.set_index("timestamp") sp500_monthly = sp500_raw["close"].resample("ME").last().to_frame() sp500_monthly["returns"] = sp500_monthly["close"].pct_change() sp500_df = sp500_monthly print( f"Loaded S&P 500 for validation: {len(sp500_df)} months, " f"{sp500_df.index.min().date()} to {sp500_df.index.max().date()}" ) # %% [markdown] # ### Compute Regime Statistics # %% if len(macro_df) > 0 and sp500_df is not None: events = {2008: "GFC", 2020: "COVID", 2022: "Inflation"} sp500_aligned = sp500_df.reindex(macro_df.index, method="nearest", tolerance=pd.Timedelta("5D")) sp500_aligned["regime"] = macro_labels sp500_aligned["regime_label"] = [regime_labels_map[r] for r in macro_labels] sp500_aligned["peak"] = sp500_aligned["close"].cummax() sp500_aligned["drawdown"] = (sp500_aligned["close"] - sp500_aligned["peak"]) / sp500_aligned[ "peak" ] sp500_aligned["volatility"] = sp500_aligned["returns"].rolling(12).std() * np.sqrt(12) regime_stats_fig = sp500_aligned.groupby("regime_label").agg( {"returns": ["mean", "std", "count"], "drawdown": "min", "volatility": "mean"} ) regime_stats_fig.columns = ["mean_ret", "std_ret", "months", "max_dd", "avg_vol"] regime_stats_fig["annual_vol"] = regime_stats_fig["std_ret"] * np.sqrt(12) * 100 regime_stats_fig["max_dd_pct"] = -regime_stats_fig["max_dd"] * 100 regime_stats_fig = regime_stats_fig.sort_values("annual_vol", ascending=True) regime_order = regime_stats_fig.index.tolist() n_regimes_fig = len(regime_order) print("Regime Statistics (sorted by volatility):") print(regime_stats_fig[["months", "annual_vol", "max_dd_pct"]].round(1)) # %% [markdown] # ### Regime Timeline with Market Validation # %% def plot_regime_timeline_validation(): """Plot regime swim lanes with volatility and drawdown validation bars.""" if len(macro_df) > 0 and sp500_df is not None: years = [pd.Timestamp(ts).year for ts in macro_df.index] n_months = len(macro_df) year_ticks = [] year_labels_fig = [] for j, year in enumerate(years): if j == 0 or years[j - 1] != year: if year % 4 == 0: year_ticks.append(j) year_labels_fig.append(str(year)) fig = plt.figure(figsize=(14, 6)) gs = GridSpec( n_regimes_fig, 4, figure=fig, width_ratios=[3.5, 1, 1, 0.05], wspace=0.12, hspace=0.08 ) swimlane_axes = [fig.add_subplot(gs[i, 0]) for i in range(n_regimes_fig)] vol_axes = [fig.add_subplot(gs[i, 1]) for i in range(n_regimes_fig)] dd_axes = [fig.add_subplot(gs[i, 2]) for i in range(n_regimes_fig)] # Grayscale-friendly palette (distinct in B&W print) # Ordered from light to dark to match volatility ordering REGIME_COLORS = ["#e0e0e0", "#a0a0a0", "#606060", "#202020"] colors = REGIME_COLORS[:n_regimes_fig] for i, regime_label in enumerate(regime_order): raw_regime = [r for r, lbl in regime_labels_map.items() if lbl == regime_label][0] mask = macro_labels == raw_regime stats = regime_stats_fig.loc[regime_label] color = colors[i] ax_swim = swimlane_axes[i] for j in range(n_months - 1): if mask[j]: ax_swim.axvspan(j, j + 1, color=color, alpha=0.85) ax_swim.set_xlim(0, n_months) ax_swim.set_ylim(0, 1) ax_swim.set_yticks([]) ax_swim.set_ylabel(regime_label, fontsize=9, rotation=0, ha="right", va="center") if i < n_regimes_fig - 1: ax_swim.set_xticks([]) else: ax_swim.set_xticks(year_ticks) ax_swim.set_xticklabels(year_labels_fig, fontsize=8) ax_swim.set_xlabel("Year", fontsize=9) for event_year, event_label in events.items(): try: idx = next(j for j, y in enumerate(years) if y == event_year) ax_swim.axvline(x=idx, color="black", linestyle="-", alpha=0.3, linewidth=0.8) if i == 0: ax_swim.annotate( event_label, xy=(idx, 1.25), xycoords=("data", "axes fraction"), ha="center", fontsize=7, color="black", fontweight="bold", ) except StopIteration: pass ax_vol = vol_axes[i] vol_val = stats["annual_vol"] ax_vol.barh([0], [vol_val], color=color, height=0.6, edgecolor="black", linewidth=0.5) ax_vol.set_xlim(0, 22) ax_vol.set_ylim(-0.5, 0.5) ax_vol.set_yticks([]) ax_vol.text( vol_val + 0.3, 0, f"{vol_val:.0f}%", ha="left", va="center", fontsize=9, fontweight="bold", ) if i == 0: ax_vol.set_title("Volatility\n(ann. %)", fontsize=9, fontweight="bold") if i < n_regimes_fig - 1: ax_vol.set_xticks([]) else: ax_vol.set_xticks([0, 10, 20]) ax_vol.tick_params(labelsize=7) ax_dd = dd_axes[i] dd_val = stats["max_dd_pct"] ax_dd.barh([0], [dd_val], color=color, height=0.6, edgecolor="black", linewidth=0.5) ax_dd.set_xlim(0, 60) ax_dd.set_ylim(-0.5, 0.5) ax_dd.set_yticks([]) ax_dd.text( dd_val + 1, 0, f"{dd_val:.0f}%", ha="left", va="center", fontsize=9, fontweight="bold", ) if i == 0: ax_dd.set_title("Max\nDrawdown", fontsize=9, fontweight="bold") if i < n_regimes_fig - 1: ax_dd.set_xticks([]) else: ax_dd.set_xticks([0, 20, 40, 60]) ax_dd.tick_params(labelsize=7) start_year = macro_df.index.min().year end_year = macro_df.index.max().year fig.suptitle( f"Macro Regimes and Market Volatility ({start_year}-{end_year})", fontsize=11, fontweight="bold", y=0.98, ) fig.text( 0.5, 0.02, "Regimes from: Unemployment, Fed Funds Rate, Yield Curve (10Y-2Y), CPI | Sorted by volatility", ha="center", fontsize=8, style="italic", color="#505050", ) plt.show() plot_regime_timeline_validation() # %% [markdown] # ### Persist Figure 1.6 inputs # # The publication-quality version of Figure 1.6 is rendered by # `book/01_process_is_edge/figures/scripts/generate_figure_1_6_macro_regimes_volatility.py`. # That script reads the arrays persisted below so the book build does not # re-fit the clustering pipeline. # %% if len(macro_df) > 0 and sp500_df is not None: ARTIFACT_DIR = OUTPUT_DIR / "figure_1_6" ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) annual_vol_by_regime = regime_stats_fig.loc[regime_order, "annual_vol"].to_numpy(dtype=float) max_dd_by_regime = regime_stats_fig.loc[regime_order, "max_dd_pct"].to_numpy(dtype=float) np.savez( ARTIFACT_DIR / "inputs.npz", dates=macro_df.index.astype("datetime64[ns]").astype("int64"), macro_labels=np.asarray(macro_labels, dtype=np.int64), regime_order=np.asarray(regime_order, dtype=object), raw_regime_for_order=np.asarray( [ next(r for r, lbl in regime_labels_map.items() if lbl == label) for label in regime_order ], dtype=np.int64, ), annual_vol=annual_vol_by_regime, max_dd_pct=max_dd_by_regime, event_years=np.asarray(list(events.keys()), dtype=np.int64), event_labels=np.asarray(list(events.values()), dtype=object), start_year=np.int64(macro_df.index.min().year), end_year=np.int64(macro_df.index.max().year), ) # %% [markdown] # ### Key Insight Summary # %% insight_table = ( regime_stats_fig.loc[regime_order, ["months", "annual_vol", "max_dd_pct"]] .rename( columns={ "months": "Months", "annual_vol": "Annual vol (%)", "max_dd_pct": "Max drawdown (%)", } ) .round(1) ) insight_table # %% [markdown] # ## Correlation Heatmap # %% if len(macro_df) > 0: fig, ax = plt.subplots(figsize=(8, 6), layout="tight") sns.heatmap( macro_df.corr(), annot=True, fmt=".2f", cmap="RdBu_r", center=0, ax=ax, square=True, ) ax.set_title("Macro Indicator Correlations", fontsize=12) plt.show() # %% [markdown] # **Interpretation**: Unemployment moves *with* the 10y-2y spread (positive # correlation ~0.70): when the labour market deteriorates, the curve typically # steepens as the Fed cuts the front end. The Fed Funds rate moves *against* # the spread (correlation ~−0.73): hiking cycles flatten or invert the curve. # CPI YoY is largely orthogonal to the other three — inflation regimes can # coexist with both recession and expansion. These pairwise correlations show # why a single indicator is a noisy regime signal and motivate joint clustering. # %% [markdown] # --- # # # Extended Analysis: All FRED Indicators # # Now we expand beyond the 4 core indicators to use all available FRED series # (~17 indicators after quality filtering). This provides a richer but noisier # view of economic conditions. # %% [markdown] # ## Prepare Full Dataset # # Using the same data loaded at the start, we now select ALL series with # sufficient data coverage (< 50% missing). # %% # Use the already-loaded macro_raw data value_cols_full = [c for c in macro_raw.columns if c != DATE_COL] macro_monthly_full = ( macro_raw.sort(DATE_COL) .group_by_dynamic(DATE_COL, every="1mo", label="right") .agg([pl.col(c).last() for c in value_cols_full]) .filter(pl.col(DATE_COL) >= pl.datetime(2002, 1, 1)) ) # Identify columns with >50% missing, exclude market price columns total_rows = macro_monthly_full.height null_fractions = { col: macro_monthly_full.select(pl.col(col).is_null().sum()).item() / total_rows for col in value_cols_full } # Exclude columns with too much missing data and market price columns (SP500) exclude_cols = {"sp500", "SP500"} good_cols = [col for col, frac in null_fractions.items() if frac < 0.5 and col not in exclude_cols] macro_clean_full = ( macro_monthly_full.select([DATE_COL] + good_cols).fill_null(strategy="forward").drop_nulls() ) macro_data_full = macro_clean_full.select(good_cols).to_pandas() macro_data_full.index = macro_clean_full[DATE_COL].to_pandas() macro_data_full = macro_data_full.apply(scale) print(f"Extended dataset: {macro_data_full.shape[0]} months, {macro_data_full.shape[1]} series") print(f"Series used: {list(macro_data_full.columns)}") # %% [markdown] # ## Visualize All Series # %% n_cols = min(len(macro_data_full.columns), 20) n_rows = (n_cols + 4) // 5 n_plot_cols = min(5, n_cols) fig, axes = plt.subplots( nrows=n_rows, ncols=n_plot_cols, figsize=(16, 2 * n_rows), sharex=True, sharey=True ) axes = axes.flatten() if n_rows > 1 else [axes] if n_cols == 1 else axes for i, ax in enumerate(axes[:n_cols]): if i < len(macro_data_full.columns): macro_data_full.iloc[:, i].plot(ax=ax, color="steelblue", linewidth=0.8) ax.set_title(macro_data_full.columns[i], fontsize=9) ax.tick_params(axis="both", labelsize=7) else: ax.set_visible(False) for i in range(n_cols, len(axes)): axes[i].set_visible(False) sns.despine() start_yr = macro_data_full.index.min().year end_yr = macro_data_full.index.max().year fig.suptitle(f"FRED Macro Series - Standardized ({start_yr}-{end_yr})", fontsize=12, y=1.02) plt.show() # %% [markdown] # **Interpretation**: The visibly regime-bearing series are VIX (sharp spikes # in 2008 and 2020), ICSA initial claims (same crisis peaks), DFF (regime # shifts during hiking cycles), and the yield-curve spread T10Y2Y (recession # warnings ahead of 2008 and 2020). Slow-moving series (housing, monetary # aggregates) carry less regime information. Clustering will lean heavily on # the high-variance subset. # %% [markdown] # ## Hierarchical Clustering # # Cluster the correlation matrix to reveal indicator groupings. # %% # clustermap manages its own gridspec; disable constrained_layout to avoid # matplotlib colorbar/engine-switch errors under non-interactive backends. with plt.rc_context({"figure.constrained_layout.use": False}): fig = sns.clustermap(macro_data_full.corr(), cmap="RdBu_r", center=0, figsize=(10, 10)) fig.fig.suptitle("FRED Indicator Correlations (Hierarchical Clustering)", y=1.02) plt.show() # %% [markdown] # **Interpretation**: Four blocks emerge: a labour/yield-curve block # (CIVPART, UNRATE, T10Y2Y), a stress block (VIX, ICSA, high-yield spread), a # growth/price-level block (INDPRO, CPI, M2), and a short-rate block # (DFF, T10YIE). Most off-diagonal correlations are modest in magnitude, and # the dendrogram's high cophenetic correlation confirms that this block # structure is genuinely hierarchical. # %% [markdown] # ## Comparing GMM and K-Means # # Both methods identify 4 regimes. The key difference: # - **GMM**: Soft assignments with probabilities (uncertainty quantified) # - **K-Means**: Hard assignments (each month belongs to exactly one regime) # %% [markdown] # ### Fit Both Models # %% n_regimes_full = 4 # GMM gmm_full = GaussianMixture( n_components=n_regimes_full, covariance_type="full", random_state=SEED, n_init=10, reg_covar=1e-6, ) gmm_full.fit(macro_data_full) gmm_probs_full = gmm_full.predict_proba(macro_data_full) gmm_labels_full = gmm_full.predict(macro_data_full) # K-Means kmeans_full = KMeans(n_clusters=n_regimes_full, random_state=SEED, n_init=10) kmeans_labels_full = kmeans_full.fit_predict(macro_data_full) print(f"GMM silhouette: {silhouette_score(macro_data_full, gmm_labels_full):.3f}") print(f"K-Means silhouette: {silhouette_score(macro_data_full, kmeans_labels_full):.3f}") # %% [markdown] # ### GMM Regime Probabilities # %% plot_regime_heatmap( gmm_probs_full, macro_data_full.index, f"GMM Regime Probabilities ({start_yr}-{end_yr})", n_regimes=n_regimes_full, is_probability=True, ) # %% [markdown] # ### K-Means Regime Assignments # %% plot_regime_heatmap( kmeans_labels_full, macro_data_full.index, f"K-Means Regime Assignments ({start_yr}-{end_yr})", n_regimes=n_regimes_full, is_probability=False, ) # %% [markdown] # ## Agglomerative Clustering # # Hierarchical clustering on observations (not features) shows how months group together. # %% [markdown] # ### Linkage Matrix # %% Z_full = linkage(macro_data_full, "ward") pairwise_dist = pdist(macro_data_full) c, _ = cophenet(Z_full, pairwise_dist) print(f"Cophenetic correlation: {c:.3f}") # %% [markdown] # **Note**: The cophenetic correlation measures how faithfully the dendrogram # preserves the original pairwise distances; good dendrograms score above 0.7. # Here it reaches 0.71, so the Ward-linkage tree is a reliable summary of the # indicator distance structure — the block grouping above reflects real # hierarchy rather than an artefact of the linkage choice. # %% [markdown] # ### Dendrogram # %% fig, ax = plt.subplots(figsize=(16, 5)) # no_labels=True since the point of this figure is the cophenetic-correlation # summary in the title, not per-observation identity. dendrogram(Z_full, orientation="top", no_labels=True, ax=ax) ax.set_title(f"Ward Linkage Dendrogram | Cophenetic Correlation: {c:.2f}", fontsize=12) ax.set_ylabel("Distance") sns.despine() plt.show() # %% [markdown] # ## PCA Preprocessing # # Reduce dimensionality before clustering to filter noise. # %% [markdown] # ### Fit PCA # %% n_pca = min(10, macro_data_full.shape[1]) pca = PCA(n_components=n_pca) reduced = pca.fit_transform(macro_data_full) print("Cumulative Explained Variance:") cumvar = pd.Series(pca.explained_variance_ratio_).cumsum() for i, v in enumerate(cumvar): print(f" PC{i + 1}: {v:.1%}") # %% [markdown] # ### GMM on PCA-Reduced Data # %% gmm_pca = GaussianMixture( n_components=n_regimes_full, covariance_type="full", random_state=SEED, n_init=10, reg_covar=1e-6, ) gmm_pca.fit(reduced) pca_probs = gmm_pca.predict_proba(reduced) pca_labels = gmm_pca.predict(reduced) print(f"GMM on PCA silhouette: {silhouette_score(reduced, pca_labels):.3f}") # %% plot_regime_heatmap( pca_probs, macro_data_full.index, f"GMM Regime Probabilities (PCA-reduced, {start_yr}-{end_yr})", n_regimes=n_regimes_full, is_probability=True, ) # %% [markdown] # --- # # # Comparison: 4 Indicators vs Extended Dataset # # How do results differ between the simple (4-indicator) and extended approaches? # %% [markdown] # ## Visual Comparison # # Side-by-side heatmaps showing regime probabilities from both approaches. # %% # Create side-by-side comparison (if both datasets available) if len(macro_probs) > 0 and len(gmm_probs_full) > 0: fig, axes = plt.subplots(2, 1, figsize=(14, 6)) # Panel 1: Core 4 indicators ax1 = axes[0] im1 = ax1.imshow(macro_probs.T, aspect="auto", cmap="Blues", vmin=0, vmax=1) years_core = [pd.Timestamp(ts).year for ts in macro_df.index] year_ticks_core = [j for j, y in enumerate(years_core) if j == 0 or years_core[j - 1] != y] year_labels_core = [str(years_core[j]) for j in year_ticks_core] ax1.set_xticks(year_ticks_core[::2]) ax1.set_xticklabels(year_labels_core[::2], fontsize=8) ax1.set_yticks(range(n_macro_regimes)) ax1.set_yticklabels([f"Regime {i + 1}" for i in range(n_macro_regimes)]) ax1.set_title(f"Core 4 Indicators (Silhouette: {macro_silhouette:.3f})", fontsize=11) # Panel 2: Extended dataset ax2 = axes[1] im2 = ax2.imshow(gmm_probs_full.T, aspect="auto", cmap="Blues", vmin=0, vmax=1) years_ext = macro_data_full.index.year.tolist() year_ticks_ext = [j for j, y in enumerate(years_ext) if j == 0 or years_ext[j - 1] != y] year_labels_ext = [str(years_ext[j]) for j in year_ticks_ext] ax2.set_xticks(year_ticks_ext[::2]) ax2.set_xticklabels(year_labels_ext[::2], fontsize=8) ax2.set_yticks(range(n_regimes_full)) ax2.set_yticklabels([f"Regime {i + 1}" for i in range(n_regimes_full)]) ax2.set_xlabel("Year") ext_sil = silhouette_score(macro_data_full, gmm_labels_full) ax2.set_title( f"Extended {macro_data_full.shape[1]} Indicators (Silhouette: {ext_sil:.3f})", fontsize=11 ) fig.suptitle( "GMM Regime Comparison: Core vs Extended Indicators", fontsize=12, fontweight="bold" ) plt.show() else: print("Comparison visualization skipped - insufficient data") # %% [markdown] # ## Quantitative Comparison # %% core_sil = macro_silhouette if len(macro_df) > 0 else float("nan") extended_sil = silhouette_score(macro_data_full, gmm_labels_full) pca_sil = silhouette_score(reduced, pca_labels) silhouette_compare = pd.DataFrame( { "Model": [ "Core (4 indicators)", f"Extended ({macro_data_full.shape[1]} indicators)", "Extended + PCA", ], "Silhouette": [core_sil, extended_sil, pca_sil], } ).set_index("Model") if core_sil > extended_sil: interpretation = "Core 4 indicators provide cleaner separation; additional series add noise." else: interpretation = ( "Extended indicators provide better separation; the broader panel captures " "meaningful economic variation despite added noise." ) print(interpretation) silhouette_compare.style.format("{:.3f}") # %% [markdown] # ## Key Takeaways # # - **Macro indicators line up with realised volatility**: The four regimes' mean # annualised volatility runs from 12.0% (Expansion, 77 months) to 16.0% (Crisis, # 4 months), with Tightening at 15.0% and Recovery at 15.2%. The Crisis cell # covers only 4 months on this 2002-2024 panel, so the 16.0% figure is an # anecdotal upper bound rather than a regime-level statistic. # - **Extended indicators improve cluster quality**: The 25-indicator model achieves # higher silhouette (0.42) than the 4-indicator model (0.25), but the core model # is more interpretable — the trade-off depends on the use case. # - **Hierarchical structure is genuine**: A cophenetic correlation of 0.71, above the # 0.7 quality bar, means the dendrogram faithfully represents the indicator distance # structure rather than imposing an arbitrary tree. # - **Use rates, not levels**: We use CPI year-over-year change rather than the CPI level, # because clustering on a trending level would capture "early vs late" rather than # "inflationary vs non-inflationary." # - **GMM vs K-Means**: Similar performance (silhouette 0.42 vs 0.45), but GMM provides # probability assignments that quantify regime uncertainty. # # **Book reference**: §1.4 of Chapter 1 frames these regimes as inputs to risk # management — exposure caps, hedging triggers, and de-risking rules — rather than # as return forecasts. Figure 1.6 in the chapter is the macro-regime panel rendered # above.