Сравнение методов причинного поиска во финансовых временных рядах
Сводка
В ноутбуке сравниваются методы поиска связей в панели доходностей ETF: NOTEARS для одновременных линейных ориентированных ациклических графов, VAR-LiNGAM для структуры с запаздывающими и мгновенными связями, PCMCI для связей условной независимости и тесты Грейнджера как попарные прогнозные фильтры. NOTEARS делает обучение графа непрерывным, сочетая разреженную регрессионную целевую функцию с дифференцируемым ограничением ацикличности. Остальные подходы основаны на иных предположениях и предназначены для поиска связей разных типов, поэтому их результаты нельзя считать взаимозаменяемыми.
Рабочий процесс включает синтетическую проверку на данных с известной структурой, поправку на множественное тестирование и блочный бутстрэп для проверки устойчивости рёбер. Рёбра, повторяющиеся в разных повторных выборках или методах, остаются гипотезами, а не установленными причинными эффектами или торговыми сигналами. В ноутбуке отмечены пропущенные общие факторы, нестационарность, чувствительность к периоду выборки и порогам настройки как источники ложных связей. Перед торговым применением рекомендуется временная валидация, экономическая интерпретация и дополнительная оценка причинных эффектов; представленные данные сами по себе не доказывают полезность вне выборки.
Ключевые идеи
- NOTEARS оценивает одновременную структуру с помощью разреженной целевой функции и гладкого ограничения ацикличности.
- VAR-LiNGAM, PCMCI и тесты Грейнджера выявляют разные запаздывающие или прогнозные связи при разных предположениях.
- Поправка на множественное тестирование и блочный бутстрэп помогают оценить плотность и устойчивость найденных рёбер.
- Повторное обнаружение связи в бутстрэп-выборках не доказывает причинность и не подтверждает торговую ценность ребра.
- Смешение факторов, смена режимов, выборка и чувствительность к гиперпараметрам могут привести к неустойчивым графам.
Теги
Полный текст
# 08_neural_causal_discovery.py
```py
# ---
# 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]
# # Continuous and Time-Series Causal Discovery
#
# **Chapter 15: Causal Machine Learning**
# **Docker image**: `ml4t`
#
# **Book Reference**: Chapter 15, §15.6 (Causal Discovery from Observational Data)
#
# This notebook compares continuous DAG learning with time-series discovery methods on
# one seven-ETF panel, emphasizing how strongly the resulting graph depends on assumptions.
#
# **Why Compare Discovery Methods?**
#
# Constraint-based methods such as PC, FCI, and PCMCI test conditional independence
# to recover graph structure or an equivalence class, depending on their assumptions.
# They have limitations:
# 1. **Combinatorial explosion**: Testing all possible edges is expensive
# 2. **Discrete decisions**: No gradient-based optimization
# 3. **Limited scalability**: Struggles with many variables
#
# The comparison includes:
# - **NOTEARS** for contemporaneous linear DAG learning
# - **VAR-LiNGAM** for lagged and instantaneous linear structure
# - **PCMCI** for multivariate conditional-independence discovery
# - **Granger tests** as pairwise predictive screens
#
# **Learning Outcomes**:
# - LO1: Understand the NOTEARS formulation for DAG learning
# - LO2: Apply contemporaneous and lagged discovery methods to financial time series
# - LO3: Compare score-based and constraint-based methods with proper multiple testing
# - LO4: Assess edge stability via bootstrap analysis
#
# **Prerequisites**: [`07_tigramite_time_series`](07_tigramite_time_series.ipynb) for constraint-based
# discovery; ETF OHLCV data from Ch2 data pipeline
# %% [markdown]
# ## 1. Setup and Configuration
# %%
"""Compare continuous DAG learning with time-series causal discovery."""
import io
from collections import defaultdict
from contextlib import redirect_stdout
from datetime import datetime
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import polars as pl
from causallearn.search.FCMBased.lingam import VARLiNGAM
from IPython.display import display
from scipy import linalg, optimize
from sklearn.linear_model import Ridge
from sklearn.preprocessing import StandardScaler
from statsmodels.stats.multitest import multipletests
from statsmodels.tsa.stattools import grangercausalitytests
from tigramite import data_processing as pp
from tigramite.independence_tests.parcorr import ParCorr
from tigramite.pcmci import PCMCI
from data import load_etfs
from utils.reproducibility import set_global_seeds
from utils.style import COLORS, show_plotly_with_alt
# %% tags=["parameters"]
N_SAMPLES = 2000
MAX_LAG = 3
N_BOOTSTRAP = 100
SEED = 42
BLOCK_SIZE = 20
SYNTHETIC_SAMPLE_SIZE = 0
RETURN_SAMPLE_LIMIT = 0
NOTEARS_LAMBDA1 = 0.05
NOTEARS_MAX_ITER = 100
BOOTSTRAP_MAX_ITER = 30
GRANGER_MAX_LAG = 5
START_DATE = "2015-01-01"
END_DATE = "2024-06-01"
# %%
set_global_seeds(SEED)
print("Continuous and Time-Series Causal Discovery")
print(f"Bootstrap iterations: {N_BOOTSTRAP}")
# %% [markdown]
# ## 2. The NOTEARS Algorithm
#
# The implementation below is the notebook's own. Every other method here is imported:
# VAR-LiNGAM from `causal-learn`, PCMCI and its partial-correlation test from `tigramite`,
# the Granger tests from `statsmodels`. `causal-learn` is the library a reader would expect
# NOTEARS to live in, and it ships no NOTEARS, so a continuous-optimization baseline has to
# be written out. That suits the teaching: the augmented-Lagrangian loop is short enough to
# read, and the acyclicity constraint is the whole idea.
#
# **NOTEARS** (Zheng et al., 2018) reformulates DAG learning as:
#
# $$\min_W \frac{1}{2n} \|X - XW\|_F^2 + \lambda \|W\|_1$$
# $$\text{subject to } h(W) = \text{tr}(e^{W \circ W}) - d = 0$$
#
# Where:
# - $W$ is the weighted adjacency matrix (W[i,j] = edge i→j)
# - $h(W) = 0$ is the **acyclicity constraint** (trace exponential trick)
# - The $\ell_1$ penalty induces sparsity
#
# The key insight: $h(W) = 0$ iff $W$ represents a DAG. This makes the
# combinatorial problem continuous and differentiable.
# %%
def _notears_objectives(X, n, d):
"""Build the NOTEARS least-squares loss and acyclicity constraint.
Each returns ``(value, gradient)`` so the augmented-Lagrangian subproblem
can be handed to a smooth optimizer (Zheng et al., 2018).
"""
def loss(W):
R = X - X @ W
value = 0.5 / n * np.sum(R**2)
grad = -1.0 / n * X.T @ R
return value, grad
def h(W):
# h(W) = tr(exp(W∘W)) - d, which is zero iff W encodes a DAG.
E = linalg.expm(W * W)
value = np.trace(E) - d
grad = E.T * W * 2
return value, grad
return loss, h
# %% [markdown]
# Each augmented-Lagrangian step solves one smooth, bounded optimization problem over
# positive and negative parts of the adjacency matrix.
# %%
def _solve_notears_subproblem(w_est, d, loss, h, rho, alpha, lambda1):
"""Solve one NOTEARS augmented-Lagrangian subproblem."""
def adjacency(w):
return (w[: d * d] - w[d * d :]).reshape(d, d)
def objective(w):
W = adjacency(w)
loss_val, g_loss = loss(W)
h_cur, g_h = h(W)
value = loss_val + 0.5 * rho * h_cur**2 + alpha * h_cur + lambda1 * w.sum()
g_smooth = g_loss + (rho * h_cur + alpha) * g_h
gradient = np.concatenate((g_smooth + lambda1, -g_smooth + lambda1), axis=None)
return value, gradient
bounds = [(0, 0) if i == j else (0, None) for _ in range(2) for i in range(d) for j in range(d)]
solution = optimize.minimize(objective, w_est, method="L-BFGS-B", jac=True, bounds=bounds)
W_new = adjacency(solution.x)
return solution.x, W_new, h(W_new)[0]
# %% [markdown]
# The outer loop raises the acyclicity penalty until the graph satisfies the smooth DAG
# constraint, then thresholds small coefficients for a readable sparse graph.
# %%
def notears_linear(
X: np.ndarray,
lambda1: float = 0.1,
max_iter: int = 100,
h_tol: float = 1e-8,
rho_max: float = 1e16,
w_threshold: float = 0.3,
) -> np.ndarray:
"""NOTEARS linear DAG learning via the augmented Lagrangian (Zheng et al., 2018).
The L1-penalized least-squares objective is minimized under the smooth
acyclicity constraint ``h(W) = 0``. Each subproblem is solved with L-BFGS-B
over the ``(W+, W-)`` positive-part split that makes the L1 term
differentiable, then ``rho`` is escalated until the constraint is met.
"""
n, d = X.shape
loss, h = _notears_objectives(X, n, d)
rho, alpha, h_val = 1.0, 0.0, np.inf
w_est = np.zeros(2 * d * d)
W_est = np.zeros((d, d))
for _ in range(max_iter):
while rho < rho_max:
w_new, W_new, h_new = _solve_notears_subproblem(w_est, d, loss, h, rho, alpha, lambda1)
if h_new > 0.25 * h_val:
rho *= 10
else:
break
w_est, W_est, h_val = w_new, W_new, h_new
alpha += rho * h_val
if h_val <= h_tol or rho >= rho_max:
break
W_est[np.abs(W_est) < w_threshold] = 0
return W_est
# %% [markdown]
# ## 3. VAR-LiNGAM: Combining VAR with Non-Gaussianity
#
# **VAR-LiNGAM** extends the Linear Non-Gaussian Acyclic Model to time series:
# 1. Fit a VAR model to capture lagged relationships
# 2. Apply ICA to residuals to identify instantaneous causal order
# 3. Use non-Gaussianity for identification (unlike Gaussian methods)
#
# We first fit an explicit ridge-VAR screen to expose the lagged regression step,
# then use the full `causal-learn` VAR-LiNGAM implementation for structural discovery.
# %% [markdown]
# ### Explicit VAR Screen
#
# A one-lag ridge VAR provides an intentionally modest reference. It estimates predictive
# lagged coefficients but does not claim to solve LiNGAM's ICA permutation and scaling problem.
# %%
def ridge_var_screen(
X: np.ndarray,
threshold: float = 0.1,
) -> np.ndarray:
"""Estimate a one-lag ridge VAR coefficient matrix for comparison."""
_, d = X.shape
Y = X[1:]
X_lagged = X[:-1]
B_lag = np.zeros((d, d))
for j in range(d):
model = Ridge(alpha=1.0)
model.fit(X_lagged, Y[:, j])
B_lag[j, :] = model.coef_
B_lag[np.abs(B_lag) < threshold] = 0
return B_lag
# %% [markdown]
# ### Library Implementation: causal-learn
#
# The `causal-learn` library provides a production-grade `VARLiNGAM` that fits the
# requested one-lag VAR, applies DirectLiNGAM to the innovations for instantaneous
# ordering, and optionally prunes the result - all in three lines.
# %%
def var_lingam_library(
X: np.ndarray,
lags: int = 1,
threshold: float = 0.1,
) -> tuple[np.ndarray, np.ndarray]:
"""VAR-LiNGAM via causal-learn: proper VAR + DirectLiNGAM on residuals."""
model = VARLiNGAM(lags=lags, criterion=None, prune=True, random_state=SEED)
model.fit(X)
B0 = model.adjacency_matrices_[0].copy()
B_lag = model.adjacency_matrices_[1].copy()
B0[np.abs(B0) < threshold] = 0
B_lag[np.abs(B_lag) < threshold] = 0
return B0, B_lag
# %% [markdown]
# ## 4. Block Bootstrap for Stability Analysis
#
# Continuous DAG estimates are sensitive to noise. We assess edge stability via block
# bootstrap (preserving autocorrelation) to see how often each edge appears.
# %%
def block_bootstrap_indices(n: int, block_size: int = 20) -> np.ndarray:
"""
Generate block bootstrap indices preserving temporal dependence.
Args:
n: Length of time series
block_size: Size of contiguous blocks
Returns:
Bootstrapped indices
"""
n_blocks = n // block_size + 1
# Sample blocks with replacement
block_starts = np.random.randint(0, n - block_size + 1, size=n_blocks)
# Concatenate blocks
indices = np.concatenate([np.arange(start, start + block_size) for start in block_starts])
return indices[:n] # Trim to original length
# %% [markdown]
# Convert weighted adjacency matrices into edge sets for evaluation and stability counting.
# %%
def extract_edges(W: np.ndarray, labels: list, threshold: float = 0.0) -> set:
"""Extract edges from adjacency matrix as set of tuples."""
edges = set()
for i in range(len(labels)):
for j in range(len(labels)):
if abs(W[i, j]) > threshold:
edges.add((labels[i], labels[j]))
return edges
# %% [markdown]
# ## 5. Synthetic Validation with Known Ground Truth
#
# Before applying to real data, we validate methods on synthetic data
# with known causal structure to measure precision and sensitivity.
# %% [markdown]
# The generator is the linear SEM that NOTEARS targets (Zheng et al. 2018): equal-variance
# noise on a unit scale, with accumulating coefficients along the known DAG
# X0 -> X1 -> X2, X0 -> X2, X2 -> X3 -> X4. Downstream variables inherit their parents'
# variance, so the variance ordering lines up with the causal order and the orientation is
# identifiable from observational data alone.
# %%
print("\n=== SYNTHETIC VALIDATION ===\n")
n_synthetic = SYNTHETIC_SAMPLE_SIZE if SYNTHETIC_SAMPLE_SIZE > 0 else N_SAMPLES
TRUE_EDGES = {(0, 1), (1, 2), (0, 2), (2, 3), (3, 4)}
np.random.seed(SEED)
X_syn = np.zeros((n_synthetic, 5))
X_syn[:, 0] = np.random.randn(n_synthetic)
X_syn[:, 1] = 0.8 * X_syn[:, 0] + np.random.randn(n_synthetic)
X_syn[:, 2] = 0.7 * X_syn[:, 0] + 0.8 * X_syn[:, 1] + np.random.randn(n_synthetic)
X_syn[:, 3] = 0.9 * X_syn[:, 2] + np.random.randn(n_synthetic)
X_syn[:, 4] = 0.8 * X_syn[:, 3] + np.random.randn(n_synthetic)
# %%
# Run NOTEARS and compute precision/sensitivity against known truth
W_syn = notears_linear(X_syn, lambda1=0.05, max_iter=50)
discovered_edges = extract_edges(W_syn, list(range(5)))
tp = len(discovered_edges & TRUE_EDGES)
fp = len(discovered_edges - TRUE_EDGES)
fn = len(TRUE_EDGES - discovered_edges)
precision = tp / max(len(discovered_edges), 1)
sensitivity = tp / len(TRUE_EDGES)
f1_score = 2 * precision * sensitivity / max(precision + sensitivity, 1e-6)
print(f" Discovered: {discovered_edges}")
print(f" TP={tp}, FP={fp}, FN={fn}")
print(f" Precision: {precision:.1%}, Sensitivity: {sensitivity:.1%}, F1: {f1_score:.2f}")
SYNTHETIC_RESULTS = {"precision": precision, "sensitivity": sensitivity, "f1": f1_score}
# %% [markdown]
# ## 6. Load Financial Time Series Data
#
# We use multi-asset ETF returns to discover causal structure.
# %%
print("\n=== LOADING DATA ===\n")
# Configuration
ASSETS = ["SPY", "QQQ", "IWM", "TLT", "GLD", "EEM", "XLF"]
# Load ETF data
etf_data = load_etfs()
# Filter to assets and date range
start_dt = datetime.fromisoformat(START_DATE)
end_dt = datetime.fromisoformat(END_DATE)
etf_data = etf_data.filter(
(pl.col("symbol").is_in(ASSETS))
& (pl.col("timestamp") >= start_dt)
& (pl.col("timestamp") <= end_dt)
)
# %%
# Pivot to wide format
prices = (
etf_data.select(["timestamp", "symbol", "close"])
.pivot(on="symbol", index="timestamp", values="close")
.sort("timestamp")
)
# Keep ordinary table transformations in Polars. NumPy and pandas conversions occur
# only at estimator and display boundaries below.
ASSETS = [asset for asset in ASSETS if asset in prices.columns]
returns = prices.select(
"timestamp",
*[(pl.col(asset) / pl.col(asset).shift(1) - 1).alias(asset) for asset in ASSETS],
).drop_nulls()
if RETURN_SAMPLE_LIMIT > 0:
returns = returns.tail(RETURN_SAMPLE_LIMIT)
print(f"Assets: {ASSETS}")
sample_start = datetime.fromisoformat(str(returns["timestamp"].min()))
sample_end = datetime.fromisoformat(str(returns["timestamp"].max()))
print(f"Sample period: {sample_start} to {sample_end}")
print(f"Observations: {len(returns)}")
# Keep raw returns so every bootstrap replicate can refit its own scaler. The full-sample
# transform below is used only for the descriptive full-period estimates.
X_raw = returns.select(ASSETS).to_numpy()
scaler = StandardScaler()
X = scaler.fit_transform(X_raw)
print(f"\nData shape: {X.shape}")
# %% [markdown]
# ## 7. Apply NOTEARS with Bootstrap Stability
# %%
print("\n=== NOTEARS CAUSAL DISCOVERY WITH STABILITY ===\n")
# Apply NOTEARS to full data
lambda1 = NOTEARS_LAMBDA1
W_notears = notears_linear(X, lambda1=lambda1, max_iter=NOTEARS_MAX_ITER)
# Create adjacency dataframe
adj_df = pd.DataFrame(W_notears, index=ASSETS, columns=ASSETS)
print("Discovered Adjacency Matrix (NOTEARS):")
display(adj_df.round(3))
# Count full-sample edges
full_sample_edges = extract_edges(W_notears, ASSETS)
n_edges = len(full_sample_edges)
print(f"\nTotal edges discovered: {n_edges}")
# Bootstrap stability analysis
print(f"\nBootstrap stability ({N_BOOTSTRAP} iterations)...")
edge_counts = defaultdict(int)
progress_step = max(N_BOOTSTRAP // 10, 1)
for b in range(N_BOOTSTRAP):
boot_indices = block_bootstrap_indices(len(X), BLOCK_SIZE)
X_boot = StandardScaler().fit_transform(X_raw[boot_indices])
W_boot = notears_linear(X_boot, lambda1=lambda1, max_iter=BOOTSTRAP_MAX_ITER)
for i, source in enumerate(ASSETS):
for j, target in enumerate(ASSETS):
if abs(W_boot[i, j]) > 0:
edge_counts[(source, target)] += 1
if (b + 1) % progress_step == 0 or b + 1 == N_BOOTSTRAP:
print(f" completed {b + 1}/{N_BOOTSTRAP} bootstrap fits")
# %%
# Report edge stability
bootstrap_edges = []
for edge, count in sorted(edge_counts.items(), key=lambda x: -x[1]):
freq = count / N_BOOTSTRAP
if freq >= 0.3:
in_full_sample = edge in full_sample_edges
print(
f" {edge[0]} → {edge[1]}: {freq:.0%} "
f"({'CONSENSUS' if freq >= 0.5 else 'sample-sensitive'}, "
f"{'full graph' if in_full_sample else 'bootstrap only'})"
)
bootstrap_edges.append(
{
"Source": edge[0],
"Target": edge[1],
"Frequency": freq,
"Consensus": freq >= 0.5,
"In_Full_Sample": in_full_sample,
}
)
n_consensus = sum(1 for e in bootstrap_edges if e["Consensus"])
n_confirmed = sum(1 for e in bootstrap_edges if e["Consensus"] and e["In_Full_Sample"])
print(f"\nBootstrap-consensus edges (>=50%): {n_consensus}")
print(f"Full-sample edges confirmed by bootstrap: {n_confirmed} / {n_edges}")
# %% [markdown]
# ## 8. Apply VAR-LiNGAM for Time Series Structure
#
# We compare the explicit ridge-VAR screen with the `causal-learn` structural
# estimate on the same data. Only the library estimate uses DirectLiNGAM, so overlap
# is evidence of agreement between a predictive screen and a structural specification.
# %%
print("\n=== RIDGE-VAR LAGGED SCREEN ===\n")
B_lag_screen = ridge_var_screen(X, threshold=0.1)
print("Lagged Predictive Coefficients (B1, lag-1):")
display(pd.DataFrame(B_lag_screen, index=ASSETS, columns=ASSETS).round(3))
# %%
print("\n=== VAR-LiNGAM: CAUSAL-LEARN LIBRARY ===\n")
B0, B_lag = var_lingam_library(X, lags=1, threshold=0.1)
inst_df = pd.DataFrame(B0, index=ASSETS, columns=ASSETS)
lag_df = pd.DataFrame(B_lag, index=ASSETS, columns=ASSETS)
print("Instantaneous Effects (B0):")
display(inst_df.round(3))
print("\nLagged Effects (B1, lag-1):")
display(lag_df.round(3))
# %% [markdown]
# Compare edge agreement between implementations.
# %%
screen_lag_edges = set()
for i in range(len(ASSETS)):
for j in range(len(ASSETS)):
if abs(B_lag_screen[j, i]) > 0:
screen_lag_edges.add((ASSETS[i], ASSETS[j]))
library_lag_edges = set()
for i in range(len(ASSETS)):
for j in range(len(ASSETS)):
if abs(B_lag[j, i]) > 0:
library_lag_edges.add((ASSETS[i], ASSETS[j]))
shared = screen_lag_edges & library_lag_edges
print(f"Ridge-screen-only edges: {len(screen_lag_edges - library_lag_edges)}")
print(f"VAR-LiNGAM-only edges: {len(library_lag_edges - screen_lag_edges)}")
print(f"Shared edges: {len(shared)}")
# %%
# Identify lagged causal edges (from library implementation)
lag_edges = []
for i, source in enumerate(ASSETS):
for j, target in enumerate(ASSETS):
if abs(B_lag[j, i]) > 0: # B_lag[j,i] means i at t-1 → j at t
lag_edges.append(
{
"Source": f"{source}(t-1)",
"Target": f"{target}(t)",
"Weight": B_lag[j, i],
}
)
if lag_edges:
lag_edges_df = pd.DataFrame(lag_edges).sort_values("Weight", key=abs, ascending=False)
print("\nDiscovered Lagged Causal Edges (causal-learn VARLiNGAM):")
display(lag_edges_df.head(10))
# %% [markdown]
# ## 9. PCMCI on the Same Universe
#
# `07_tigramite_time_series` runs PCMCI on 4 assets (SPY, IEF, GLD, VIX).
# For a fair comparison, we run PCMCI here on the same 7-asset panel used
# by NOTEARS, VAR-LiNGAM, and Granger above.
# %%
print("\n=== PCMCI ON 7-ASSET PANEL ===\n")
dataframe = pp.DataFrame(X, var_names=ASSETS)
parcorr = ParCorr(significance="analytic")
pcmci = PCMCI(dataframe=dataframe, cond_ind_test=parcorr)
pcmci_results = pcmci.run_pcmci(tau_max=MAX_LAG, pc_alpha=0.05)
# Correct all source-target-lag hypotheses together. The 7 x 7 x 3 family contains
# 147 tests, including each series' own lags.
p_matrix = pcmci_results["p_matrix"]
val_matrix = pcmci_results["val_matrix"]
n_vars = len(ASSETS)
total_possible = n_vars * n_vars * MAX_LAG
lagged_p = p_matrix[:, :, 1 : MAX_LAG + 1]
_, pcmci_q_flat, _, _ = multipletests(lagged_p.reshape(-1), alpha=0.05, method="fdr_bh")
pcmci_q_matrix = pcmci_q_flat.reshape(lagged_p.shape)
# %% [markdown]
# Retain only FDR-significant lagged links, then summarize their conditional-association
# magnitudes. These remain candidate links rather than identified causal effects.
# %%
pcmci_sig_links = 0
pcmci_link_details = []
effect_sizes = []
for i, source in enumerate(ASSETS):
for j, target in enumerate(ASSETS):
for tau in range(1, MAX_LAG + 1):
q_value = pcmci_q_matrix[i, j, tau - 1]
if q_value < 0.05:
pcmci_sig_links += 1
effect_sizes.append(abs(val_matrix[i, j, tau]))
pcmci_link_details.append(
f" {source}(t-{tau}) → {target}: "
f"val={val_matrix[i, j, tau]:.3f}, q={q_value:.4f}"
)
# %% [markdown]
# Compare raw and adjusted discovery counts and report the effect sizes that survive
# correction.
# %%
pcmci_raw_links = int((lagged_p < 0.05).sum())
print(f"Raw significant lagged links at 5%: {pcmci_raw_links}/{total_possible}")
print(f"FDR-significant lagged links at 5%: {pcmci_sig_links}/{total_possible}")
for detail in sorted(pcmci_link_details):
print(detail)
if effect_sizes:
effect_sizes = np.array(effect_sizes)
print(
f"\nEffect sizes (|partial corr|): min={effect_sizes.min():.3f}, "
f"median={np.median(effect_sizes):.3f}, max={effect_sizes.max():.3f}"
)
print(f"Links with |val| > 0.10: {(effect_sizes > 0.10).sum()}")
# %% [markdown]
# ## 10. Compare with Granger Causality (FDR-Corrected)
#
# **Multiple Testing Correction**:
# - 7 assets imply 42 directed pairs ($i \rightarrow j$, where $i \neq j$)
# - Each pair receives one joint test of all coefficients through lag 5
# - Benjamini-Hochberg correction controls FDR across the 42 pair-level tests
# %%
# Pairwise Granger causality tests
print("\n=== GRANGER CAUSALITY WITH FDR CORRECTION ===\n")
all_granger_tests = []
max_lag_granger = GRANGER_MAX_LAG
n_pairs = len(ASSETS) * (len(ASSETS) - 1)
for i, source in enumerate(ASSETS):
for j, target in enumerate(ASSETS):
if i != j:
data = returns.select([target, source]).to_numpy()
with redirect_stdout(io.StringIO()):
result = grangercausalitytests(data, maxlag=max_lag_granger)
joint_p = result[max_lag_granger][0]["ssr_ftest"][1]
all_granger_tests.append(
{
"Source": source,
"Target": target,
"P_value_raw": joint_p,
}
)
print(f"Computed {len(all_granger_tests)} pairwise Granger tests")
assert len(all_granger_tests) == n_pairs
# %%
# Apply FDR correction (Benjamini-Hochberg)
granger_edges = []
if all_granger_tests:
granger_df = pd.DataFrame(all_granger_tests)
rejected, p_corrected, _, _ = multipletests(
granger_df["P_value_raw"].values, method="fdr_bh", alpha=0.05
)
granger_df["P_value_FDR"] = p_corrected
granger_df["Significant_FDR"] = rejected
n_raw = sum(granger_df["P_value_raw"] < 0.05)
n_fdr = sum(rejected)
print(f" Uncorrected significant: {n_raw}, FDR-corrected: {n_fdr}")
significant_edges = granger_df[granger_df["Significant_FDR"]].sort_values("P_value_FDR")
if len(significant_edges) > 0:
print("\nFDR-Significant Granger Edges:")
display(significant_edges[["Source", "Target", "P_value_raw", "P_value_FDR"]])
granger_edges = significant_edges.to_dict("records")
else:
print("\nNo edges survive FDR correction.")
# %% [markdown]
# ## 11. Method Summary and Comparison
# %%
print("\n=== METHOD SUMMARY ===\n")
methods_summary = {
"Method": ["NOTEARS", "VAR-LiNGAM", "Granger (FDR)", "PCMCI"],
"Type": ["Continuous DAG", "ICA-based", "Predictive test", "Constraint-based"],
"Edges_Found": [
n_edges,
int(np.sum(np.abs(B_lag) > 0)),
len(granger_edges) if "granger_edges" in dir() else "N/A",
pcmci_sig_links,
],
"Key_Assumption": [
"Linear equal-error SEM, acyclicity",
"Linear non-Gaussian SEM, no latent confounding",
"Stationarity, correctly specified lag order",
"Stationarity, causal sufficiency, faithfulness",
],
"Strengths": [
"Differentiable, scalable",
"ICA identification",
"Simple, well-understood",
"Rigorous CI tests",
],
}
summary_df = pd.DataFrame(methods_summary)
display(summary_df)
# %% [markdown]
# The counts are not directly interchangeable: NOTEARS reports contemporaneous edges,
# VAR-LiNGAM reports lagged structural edges, Granger reports directed pairs, and PCMCI
# reports source-target-lag links. Their spread nevertheless shows how conclusions depend
# on the estimand and identifying assumptions.
# %%
fig_counts = go.Figure(
go.Bar(
x=summary_df["Method"],
y=summary_df["Edges_Found"],
marker_color=[COLORS["blue"], COLORS["amber"], COLORS["copper"], COLORS["slate"]],
text=summary_df["Edges_Found"],
textposition="outside",
hovertemplate="%{x}: %{y}<extra></extra>",
)
)
fig_counts.update_layout(
title=dict(
text="Edges selected by each discovery method",
x=0.02,
xanchor="left",
),
xaxis_title="Discovery method",
yaxis_title="Selected edges, pairs, or lagged links (count)",
showlegend=False,
)
fig_counts.update_yaxes(rangemode="tozero")
show_plotly_with_alt(
fig_counts,
"Bar chart with one bar per discovery method - NOTEARS, VAR-LiNGAM, Granger and PCMCI - "
"showing how many edges, directed pairs or lagged links each selected, with the count "
"printed above each bar. The four quantities are counts of different objects and are not "
"interchangeable.",
)
# %% [markdown]
# ## 12. Visualize Discovered Causal Graph
# %%
def _add_directed_edge(fig, x0, y0, x1, y1, mid_x, mid_y, color, width, hover):
"""Render one directed edge with a curved segment and arrow annotation."""
dx, dy = x1 - x0, y1 - y0
distance = np.hypot(dx, dy)
node_padding = 0.14
start_x = x0 + node_padding * dx / distance
start_y = y0 + node_padding * dy / distance
end_x = x1 - node_padding * dx / distance
end_y = y1 - node_padding * dy / distance
fig.add_trace(
go.Scatter(
x=[start_x, mid_x, end_x],
y=[start_y, mid_y, end_y],
mode="lines",
line=dict(color=color, width=width),
hoverinfo="text",
hovertext=hover,
showlegend=False,
)
)
fig.add_annotation(
x=end_x,
y=end_y,
ax=mid_x,
ay=mid_y,
xref="x",
yref="y",
axref="x",
ayref="y",
showarrow=True,
arrowhead=2,
arrowsize=1.0,
arrowwidth=width,
arrowcolor=color,
)
# %% [markdown]
# Iterate through adjacency entries and draw only non-zero directed edges.
# %%
def _add_graph_edges(fig, W, labels, x_nodes, y_nodes, stability=None):
"""Add directed edges with optional stability coloring."""
for i in range(len(labels)):
for j in range(len(labels)):
if abs(W[i, j]) <= 0:
continue
weight = W[i, j]
edge_key = (labels[i], labels[j])
has_stability = stability and edge_key in stability
if has_stability:
stability_value = stability[edge_key]
color = COLORS["positive"] if stability_value >= 0.5 else COLORS["amber"]
else:
stability_value = None
color = COLORS["positive"] if weight > 0 else COLORS["negative"]
width = min(max(abs(weight) * 3, 1.5), 4)
mid_x = (x_nodes[i] + x_nodes[j]) / 2 + 0.1 * (y_nodes[j] - y_nodes[i])
mid_y = (y_nodes[i] + y_nodes[j]) / 2 - 0.1 * (x_nodes[j] - x_nodes[i])
hover = f"{labels[i]} → {labels[j]}: {weight:.3f}"
if stability_value is not None:
hover += f" ({stability_value:.0%})"
_add_directed_edge(
fig,
x_nodes[i],
y_nodes[i],
x_nodes[j],
y_nodes[j],
mid_x,
mid_y,
color,
width,
hover,
)
# %% [markdown]
# Build a circular network view for the discovered weighted adjacency matrix.
# %%
def create_causal_graph_viz(
W: np.ndarray,
labels: list,
title: str,
stability: dict = None,
) -> go.Figure:
"""Create circular network visualization of a causal graph."""
angles = np.linspace(0, 2 * np.pi, len(labels), endpoint=False)
x_nodes, y_nodes = np.cos(angles), np.sin(angles)
fig = go.Figure()
_add_graph_edges(fig, W, labels, x_nodes, y_nodes, stability)
fig.add_trace(
go.Scatter(
x=x_nodes,
y=y_nodes,
mode="markers+text",
marker=dict(size=40, color=COLORS["blue"]),
text=labels,
textposition="middle center",
textfont=dict(size=10, color=COLORS["silver"]),
hoverinfo="text",
hovertext=labels,
showlegend=False,
)
)
# Edge colour is the only thing carrying stability, or sign, so it needs naming on the
# chart: an arrow drawn in one of two colours says nothing to a reader without a key.
key = (
[("Recovered in most resamples", "positive"), ("Recovered in few", "amber")]
if stability
else [("Positive weight", "positive"), ("Negative weight", "negative")]
)
for name, color in key:
fig.add_trace(
go.Scatter(
x=[None],
y=[None],
mode="lines",
line=dict(color=COLORS[color], width=3),
name=name,
)
)
fig.update_layout(
title=dict(text=title, x=0.02, xanchor="left"),
showlegend=True,
legend=dict(orientation="h", yanchor="bottom", y=-0.08, xanchor="left", x=0),
height=520,
width=720,
margin=dict(l=40, r=40, t=80, b=60),
xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
)
return fig
# %% [markdown]
# The first network shows contemporaneous NOTEARS edges with bootstrap frequency in the
# hover label. The second shows the lagged edges retained by structural VAR-LiNGAM.
# %%
edge_stability_dict = {edge: edge_counts.get(edge, 0) / N_BOOTSTRAP for edge in full_sample_edges}
fig1 = create_causal_graph_viz(
W_notears,
ASSETS,
"Contemporaneous edges NOTEARS retains, coloured by bootstrap frequency",
stability=edge_stability_dict,
)
show_plotly_with_alt(
fig1,
"Network diagram with the assets placed around an ellipse, one labelled disc each, and "
"an arrow for every contemporaneous edge the NOTEARS fit retains on the full sample. "
"Arrow thickness follows the edge weight and arrow colour separates edges recovered in "
"most block-bootstrap resamples from the rest, as the legend below the plot states. Each "
"arrow's hover label carries its weight and its bootstrap frequency, and an asset the "
"fit gives no retained edge sits on the ellipse unconnected.",
)
# Lagged effects from the causal-learn VAR-LiNGAM fit
n_var_lagged = int(np.sum(np.abs(B_lag) > 0))
fig2 = create_causal_graph_viz(
B_lag.T,
ASSETS,
"Lagged edges VAR-LiNGAM retains after pruning",
)
show_plotly_with_alt(
fig2,
"Network diagram with the same assets in the same positions, and an arrow for every "
"lagged edge the pruned VAR-LiNGAM fit retains. Arrow colour separates positive from "
"negative coefficients, as the legend below the plot states, thickness follows the "
"coefficient's size, and the hover label carries its value. An asset the pruned fit "
"leaves with no lagged edge sits on the ellipse unconnected.",
)
# %% [markdown]
# ## 13. Interpretation: Hypotheses for Further Investigation
#
# **CRITICAL**: Discovered edges are **HYPOTHESES**, not proven causation.
#
# ### Validation Steps Before Any Trading Use
#
# 1. **Out-of-sample testing**: Split data temporally, discover on training, validate on test
# 2. **Bootstrap stability**: consider only edges recovered in a majority of the bootstrap
# resamples
# 3. **Multiple method agreement**: Edges confirmed by NOTEARS, VAR-LiNGAM, AND Granger
# 4. **Economic rationale**: Does the relationship make economic sense?
# 5. **DML/BSTS validation**: Use proper causal inference to estimate effect magnitude
#
# ### Why Edges May Be Spurious
#
# - **Omitted confounders**: Common macro factors drive both assets
# - **Non-stationarity**: Regime changes invalidate constant edge weights
# - **Sample dependence**: Results sensitive to time period
# - **Hyperparameter sensitivity**: Different λ gives different graphs
# %%
print("\n=== INTERPRETATION: HYPOTHESES FOR INVESTIGATION ===\n")
# Identify most robust relationships
print("Most Robust Findings:")
# Report only edges that are present in the full-sample graph and recur in at least
# half of bootstrap samples.
robust_edges = []
for e in bootstrap_edges:
if e["Consensus"] and e["In_Full_Sample"]:
source, target = e["Source"], e["Target"]
i, j = ASSETS.index(source), ASSETS.index(target)
weight = W_notears[i, j]
robust_edges.append(
{
"Edge": f"{source} → {target}",
"Weight": weight,
"Bootstrap_Freq": e["Frequency"],
}
)
if robust_edges:
robust_df = pd.DataFrame(robust_edges).sort_values("Bootstrap_Freq", ascending=False)
display(robust_df)
else:
print("No edges meet stability threshold (≥50% bootstrap frequency).")
print("This suggests high sensitivity to sample - proceed with caution.")
# %% [markdown]
# ## 14. Key Takeaways
#
# - **Method choice changes the object being selected.** NOTEARS estimates contemporaneous
# structure, while the time-series methods select lagged links or predictive pairs.
# - **Multiplicity control materially thins the graphs.** FDR is applied across all PCMCI
# source-target-lag hypotheses and across the fixed-order Granger pair tests.
# - **Bootstrap frequency is not enough by itself.** A robust NOTEARS claim now requires an
# edge to appear in the full-sample graph and in at least half of refitted bootstrap graphs.
# - **Every discovered edge remains a hypothesis.** Latent confounding, nonstationarity,
# threshold sensitivity, and the absence of any out-of-sample test prevent a trading
# interpretation.
#
# Next, use `09_adia_causal_benchmark` to examine what supervised discovery can learn when
# many labeled synthetic graphs are available. See Chapter 15, Section 15.6.
```Полный текст с указанием источника опубликован на условиях его лицензии. Лицензия: MIT
Это краткое изложение подготовлено исследовательским агентом Stratmill по оригиналу и не является его копией.