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

649 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ---
# jupyter:
# jupytext:
# cell_metadata_filter: tags,-all
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.19.3
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# # Path Signatures for Time Series Feature Engineering
#
# **Docker image**: `ml4t-py312`
#
# > **Docker required**: This notebook uses `esig`, which is an x86-only package
# > not included in the default environment. Run with:
# > ```bash
# > docker compose --profile py312 run --rm py312 python 09_model_based_features/06_path_signatures.py
# > ```
#
# Path signatures provide a mathematical framework for capturing sequential information
# in time series. Unlike lagged features that compress history into fixed windows, signatures
# encode the complete path dynamics — direction changes, reversals, and higher-order patterns.
#
# **Learning Objectives**:
# - Understand path signatures as feature extraction for ordered data
# - Compute signatures using the esig library with time augmentation
# - Apply depth truncation and log-signatures for dimensionality control
# - Compare signature features against traditional lag features for predictability
#
# **Book Reference**: Chapter 9, Section 9.2 (Signal Transforms and Spectral Features)
#
# **Prerequisites**: `04_kalman_filter` and `05_spectral_features` for other
# signal transform approaches.
# %%
"""Path Signatures for Time Series Feature Engineering — extract sequential features using signature transforms."""
import importlib
import warnings
warnings.filterwarnings("ignore")
import matplotlib.pyplot as plt
import numpy as np
import polars as pl
from ml4t.diagnostic.metrics import pooled_ic
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.metrics import r2_score
from data import load_etfs
from utils.reproducibility import set_global_seeds
# %% tags=["parameters"]
# Production defaults — Papermill injects overrides for CI
WINDOW_SIZE = 20
FORECAST_HORIZON = 5
SIGNATURE_DEPTH = 3
SEED = 42
# %%
set_global_seeds(SEED)
# %%
# `esig` ships only in the py312 image (x86 only); fail loud if we're
# running outside it.
try:
esig = importlib.import_module("esig")
except ImportError as exc:
raise ImportError(
"`esig` is not available in the current image.\n"
"This notebook runs in the `ml4t-py312` image:\n"
" docker compose --profile py312 run --rm py312 \\\n"
" python 09_model_based_features/06_path_signatures.py"
) from exc
# %% [markdown]
# ## What Are Path Signatures?
#
# A **path signature** is a mathematical object that summarizes a continuous path's shape.
# For a $d$-dimensional path $X: [0,T] \rightarrow \mathbb{R}^d$, the signature collects
# all iterated integrals up to a given depth:
#
# $$S(X)^{i_1, \ldots, i_k} = \int_{0 < t_1 < \cdots < t_k < T} dX_{t_1}^{i_1} \cdots dX_{t_k}^{i_k}$$
#
# At **depth 1**, the signature records the total displacement along each coordinate —
# equivalent to net return. At **depth 2**, cross-terms appear: $S^{1,2}$ captures the
# "area" swept between dimensions 1 and 2, encoding whether dimension 1 moved before or
# after dimension 2 (lead-lag structure). Higher depths capture progressively finer
# path geometry.
#
# **Key properties for feature engineering**:
# - **Reparameterization invariance**: Signatures capture *what* happened, not *when* — the
# same price trajectory traversed quickly or slowly yields identical features.
# - **Universality**: Different paths produce different signatures (the signature separates paths).
# - **Truncation**: The full signature is infinite-dimensional. In practice, truncation at depth
# $K$ (typically 24) retains the most informative terms.
#
# For finance: a price path rising from 100 to 110 then falling to 105 has a different
# signature than one that falls then rises, even if both end at 105.
# %% [markdown]
# ### Truncation Depth and Feature Interpretation
#
# Each truncation depth adds a new "layer" of path structure:
#
# | Depth | Terms | Interpretation | Example (price + volume path) |
# |-------|-------|---------------|-------------------------------|
# | 1 | $d$ | Total displacement (net changes) | Net return, net volume change |
# | 2 | $d + d^2$ | Pairwise interactions (lead-lag, area) | Did volume lead or lag the price move? |
# | 3 | $d + d^2 + d^3$ | Third-order interactions | Acceleration patterns, reversal sequences |
#
# **Log-signatures** provide a more compact alternative. The log-signature lives in the
# free Lie algebra and removes algebraic redundancies present in the full signature.
# For a $d$-dimensional path at depth $K$, the log-signature has dimension
# $\sum_{k=1}^{K} \frac{1}{k} \sum_{j|k} \mu(k/j) \, d^j$ (Möbius function),
# which is substantially smaller than the full signature's $\sum_{k=1}^{K} d^k$ terms.
# In practice, log-signatures are preferred for regression tasks: more compact, less
# redundant, and equally expressive.
# %% [markdown]
# ### Time Augmentation
#
# For financial data, **time-augmented paths** are essential. Without time as an explicit
# coordinate, a path that rises from 100 to 110 and one that falls from 100 to 90 differ
# only in sign — but a path rising then falling (ending at 100) and one falling then rising
# (also ending at 100) produce *identical* depth-1 signatures. Adding a monotonically
# increasing time coordinate $t \in [0, 1]$ breaks this ambiguity: the depth-2 cross-term
# between time and price captures whether the path rose early or late.
#
# The augmented path takes the form $(t, \text{price}_t, \text{volume}_t, \ldots)$. For a
# 5-day window on an ETF with price rising then falling, the depth-1 signature captures
# net return $\approx 0$; the depth-2 cross-term between time and price captures whether
# the peak occurred early or late in the window — information invisible to standard lag
# features. The depth-2 cross-term between price and volume captures whether volume led or
# lagged the reversal.
# %% [markdown]
# ## Load Data
# %%
# Load ETF universe (filtered to major index ETFs for demonstration)
symbols = ["SPY", "QQQ", "IWM", "EFA"]
df = load_etfs(symbols=symbols)
df = df.sort(["symbol", "timestamp"])
# Check date range
print(f"Assets: {df['symbol'].unique().to_list()}")
print(f"Date range: {df['timestamp'].min()} to {df['timestamp'].max()}")
print(f"Total rows: {len(df):,}")
# %% [markdown]
# ## Prepare Price Paths
#
# For signatures, we need to convert discrete price series into paths.
# We'll use overlapping windows to create many training examples.
# %%
# WINDOW_SIZE, FORECAST_HORIZON, SIGNATURE_DEPTH set in parameters cell
def create_paths_and_targets(
symbol_df: pl.DataFrame, window_size: int = 20, horizon: int = 5
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Create overlapping windows for signature computation.
Returns:
paths: Array of shape (n_windows, window_size, n_features)
targets: Forward returns
dates: End dates for each window
"""
# Compute returns and normalize prices
symbol_df = symbol_df.sort("timestamp")
symbol_df = symbol_df.with_columns(
ret=pl.col("close").pct_change(),
log_ret=pl.col("close").log().diff(),
norm_vol=(pl.col("volume") - pl.col("volume").mean()) / pl.col("volume").std(),
).drop_nulls()
# Compute forward returns for targets
symbol_df = symbol_df.with_columns(
fwd_ret=pl.col("close").pct_change(horizon).shift(-horizon),
)
# Convert to numpy for windowing
timestamps = symbol_df["timestamp"].to_numpy()
closes = symbol_df["close"].to_numpy()
returns = symbol_df["ret"].to_numpy()
fwd_returns = symbol_df["fwd_ret"].to_numpy()
# Create overlapping windows
n = len(symbol_df)
n_windows = n - window_size - horizon + 1
if n_windows <= 0:
return np.array([]), np.array([]), np.array([])
# Each path has (time, price, return) - 3D path for richer signatures
paths = []
targets = []
dates = []
for i in range(n_windows):
# Normalize time to [0, 1]
t = np.linspace(0, 1, window_size).reshape(-1, 1)
# Normalize price within window (start at 0)
window_closes = closes[i : i + window_size]
norm_price = (window_closes - window_closes[0]) / window_closes[0]
norm_price = norm_price.reshape(-1, 1)
# Cumulative returns
window_rets = returns[i : i + window_size]
cum_ret = np.cumsum(window_rets).reshape(-1, 1)
# Combine into path: (time, price, cumulative return)
path = np.hstack([t, norm_price, cum_ret])
paths.append(path)
# Target: forward return at window end
target_idx = i + window_size - 1
targets.append(fwd_returns[target_idx])
dates.append(timestamps[target_idx])
paths = np.array(paths)
targets = np.array(targets)
dates = np.array(dates)
# Remove NaN targets
valid_mask = ~np.isnan(targets)
return paths[valid_mask], targets[valid_mask], dates[valid_mask]
# Create paths for SPY
spy = df.filter(pl.col("symbol") == "SPY")
paths, targets, dates = create_paths_and_targets(spy, WINDOW_SIZE, FORECAST_HORIZON)
print(f"Created {len(paths)} paths of shape {paths.shape[1:]}")
print(f"Target stats: mean={targets.mean():.4f}, std={targets.std():.4f}")
# %% [markdown]
# ## Compute Path Signatures
#
# The `esig` library computes signatures efficiently. We use:
# - **Depth 3**: Captures up to third-order interactions between path components
# - **Log signature**: A more compact representation that avoids redundancy
# %%
def compute_signatures(paths: np.ndarray, depth: int = 3) -> np.ndarray:
"""Compute path signatures for a batch of paths."""
n_paths = len(paths)
signatures = []
for i in range(n_paths):
path = paths[i]
# esig expects paths as 2D arrays (n_points, n_dims)
sig = esig.stream2sig(path, depth)
signatures.append(sig)
return np.array(signatures)
# %%
def compute_logsignatures(paths: np.ndarray, depth: int = 3) -> np.ndarray:
"""Compute log signatures (more compact) for a batch of paths."""
n_paths = len(paths)
logsigs = []
for i in range(n_paths):
path = paths[i]
logsig = esig.stream2logsig(path, depth)
logsigs.append(logsig)
return np.array(logsigs)
# Compute both signature types
signatures = compute_signatures(paths, SIGNATURE_DEPTH)
logsignatures = compute_logsignatures(paths, SIGNATURE_DEPTH)
print(f"Signature dimension (depth {SIGNATURE_DEPTH}): {signatures.shape[1]}")
print(f"Log-signature dimension (depth {SIGNATURE_DEPTH}): {logsignatures.shape[1]}")
# %% [markdown]
# ### Computational Complexity
#
# The signature dimension grows exponentially with depth: for a $d$-dimensional path
# truncated at depth $K$, the full signature has $\sum_{k=1}^{K} d^k$ terms. Our
# 3-dimensional path (time, price, cumulative return) at depth 3 produces
# $3 + 9 + 27 = 39$ features. A 5-dimensional path (e.g., multi-asset) at depth 3
# would produce $5 + 25 + 125 = 155$ features per window.
#
# Log-signatures are substantially more compact: the same 3D path at depth 3 yields
# only 14 log-signature terms (vs. 39 full signature terms). This compactness makes
# log-signatures the pragmatic default for production use.
#
# **Practical guideline**: Truncation at depth 2 is sufficient for most daily-frequency
# applications. Depth 3 adds marginal value for intraday data where third-order
# interactions (acceleration patterns) carry signal. Depth 4+ rarely justifies the
# exponential feature growth and overfitting risk.
# %% [markdown]
# ## Baseline: Traditional Lag Features
#
# For comparison, we create standard technical features from the same windows.
# %%
def create_lag_features(paths: np.ndarray) -> np.ndarray:
"""
Create traditional lag-based features from price windows.
Features:
- Window return
- Volatility (std of returns)
- First-half vs second-half momentum
- Min/max relative to end
- Skewness proxy
"""
n_paths = len(paths)
features = []
for i in range(n_paths):
path = paths[i]
price = path[:, 1] # Normalized price column
cum_ret = path[:, 2] # Cumulative return column
# Total window return
total_ret = price[-1] - price[0]
# Volatility (std of increments)
increments = np.diff(price)
vol = np.std(increments)
# Momentum: second half vs first half
mid = len(price) // 2
first_half_ret = price[mid] - price[0]
second_half_ret = price[-1] - price[mid]
momentum = second_half_ret - first_half_ret
# Drawdown and runup
max_price = np.max(price)
min_price = np.min(price)
max_relative = max_price - price[-1]
min_relative = price[-1] - min_price
# Skewness proxy (mean - median)
skew_proxy = np.mean(price) - np.median(price)
# Path roughness (sum of absolute increments)
roughness = np.sum(np.abs(increments))
feat = [
total_ret,
vol,
momentum,
max_relative,
min_relative,
skew_proxy,
roughness,
]
features.append(feat)
return np.array(features)
lag_features = create_lag_features(paths)
print(f"Lag features dimension: {lag_features.shape[1]}")
# %% [markdown]
# ## Predictive Comparison: Signatures vs Lags
#
# We compare three feature sets for predicting 5-day forward returns using
# a simple GBM as a test harness. This is **feature evaluation, not model
# building** — we use a single learner to measure relative feature
# informativeness. Model selection and tuning are covered in Chapter 12.
#
# Feature sets:
# 1. Traditional lag features
# 2. Path signatures
# 3. Combined (signatures + lags)
# %%
# Split data temporally (avoid look-ahead)
train_size = int(0.7 * len(paths))
val_size = int(0.15 * len(paths))
X_lag_train = lag_features[:train_size]
X_lag_val = lag_features[train_size : train_size + val_size]
X_lag_test = lag_features[train_size + val_size :]
X_sig_train = logsignatures[:train_size]
X_sig_val = logsignatures[train_size : train_size + val_size]
X_sig_test = logsignatures[train_size + val_size :]
y_train = targets[:train_size]
y_val = targets[train_size : train_size + val_size]
y_test = targets[train_size + val_size :]
# Combined features
X_comb_train = np.hstack([X_lag_train, X_sig_train])
X_comb_val = np.hstack([X_lag_val, X_sig_val])
X_comb_test = np.hstack([X_lag_test, X_sig_test])
print(f"Train: {len(y_train)}, Val: {len(y_val)}, Test: {len(y_test)}")
# %%
# Train models
models = {}
def train_and_evaluate(X_train, X_val, X_test, y_train, y_val, y_test, name):
"""Train GradientBoosting and return metrics."""
model = GradientBoostingRegressor(
n_estimators=100, max_depth=3, learning_rate=0.1, random_state=42
)
model.fit(X_train, y_train)
train_r2 = r2_score(y_train, model.predict(X_train))
val_r2 = r2_score(y_val, model.predict(X_val))
test_r2 = r2_score(y_test, model.predict(X_test))
# Information coefficient (rank correlation)
test_pred = model.predict(X_test)
ic = pooled_ic(y_test, test_pred)
return {"train_r2": train_r2, "val_r2": val_r2, "test_r2": test_r2, "ic": ic, "model": model}
results = {
"Lag Features": train_and_evaluate(
X_lag_train, X_lag_val, X_lag_test, y_train, y_val, y_test, "Lag"
),
"Log-Signatures": train_and_evaluate(
X_sig_train, X_sig_val, X_sig_test, y_train, y_val, y_test, "Sig"
),
"Combined": train_and_evaluate(
X_comb_train, X_comb_val, X_comb_test, y_train, y_val, y_test, "Combined"
),
}
# %% [markdown]
# ## Results Comparison
# %%
print("=" * 60)
print("Predictive Performance Comparison (5-day forward returns)")
print("=" * 60)
print(f"{'Feature Set':<20} {'Train R²':>10} {'Val R²':>10} {'Test R²':>10} {'Test IC':>10}")
print("-" * 60)
for name, res in results.items():
print(
f"{name:<20} {res['train_r2']:>10.4f} {res['val_r2']:>10.4f} {res['test_r2']:>10.4f} {res['ic']:>10.4f}"
)
print("-" * 60)
# %% [markdown]
# **Interpretation**:
#
# Out-of-sample $R^2$ is near zero for every feature set (lag features: $-0.010$,
# log-signatures: $-0.016$, combined: $+0.015$), which is typical for daily
# return prediction. Rank correlation is more informative on this single-split
# evaluation: combined features achieve a test IC of $0.136$, versus $0.093$
# for lag features alone and $0.091$ for log-signatures alone. Signatures and
# lag features are therefore complementary on this dataset; the notebook does
# not evaluate turnover, capacity, or transaction costs, so it does not
# establish tradeability of the IC gap.
# %% [markdown]
# ## Visualize Signature Feature Importance
# %%
# Feature importance for combined model
combined_model = results["Combined"]["model"]
n_lag = lag_features.shape[1]
n_sig = logsignatures.shape[1]
importances = combined_model.feature_importances_
lag_imp = importances[:n_lag].sum()
sig_imp = importances[n_lag:].sum()
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# Importance by category
ax = axes[0]
ax.bar(["Lag Features", "Signatures"], [lag_imp, sig_imp], color=["steelblue", "darkorange"])
ax.set_ylabel("Total Feature Importance")
ax.set_title("Feature Category Importance in Combined Model")
# Top individual features
ax = axes[1]
feature_names = [f"Lag_{i}" for i in range(n_lag)] + [f"Sig_{i}" for i in range(n_sig)]
top_k = 15
top_idx = np.argsort(importances)[-top_k:][::-1]
ax.barh(
[feature_names[i] for i in top_idx],
importances[top_idx],
color=["steelblue" if i < n_lag else "darkorange" for i in top_idx],
)
ax.set_xlabel("Feature Importance")
ax.set_title(f"Top {top_k} Individual Features")
ax.invert_yaxis()
plt.tight_layout()
plt.show()
# %% [markdown]
# ## Multi-Asset Signature Analysis
#
# Signatures can capture cross-asset dynamics by computing signatures on multi-asset paths.
# %%
def create_multi_asset_paths(
df: pl.DataFrame, symbols: list[str], window_size: int = 20
) -> tuple[np.ndarray, list]:
"""
Create paths where each path has multiple asset returns as dimensions.
"""
# Pivot to wide format
wide = df.pivot(on="symbol", index="timestamp", values="close").sort("timestamp").drop_nulls()
# Compute returns
for sym in symbols:
wide = wide.with_columns(
((pl.col(sym) - pl.col(sym).shift(1)) / pl.col(sym).shift(1)).alias(f"{sym}_ret")
)
wide = wide.drop_nulls()
# Create windows
n = len(wide)
n_windows = n - window_size
if n_windows <= 0:
return np.array([]), []
paths = []
end_dates = []
ret_cols = [f"{sym}_ret" for sym in symbols]
for i in range(n_windows):
window = wide[i : i + window_size]
# Time augmentation
t = np.linspace(0, 1, window_size).reshape(-1, 1)
# Cumulative returns for each asset
asset_paths = []
for col in ret_cols:
cum_ret = window[col].cum_sum().to_numpy().reshape(-1, 1)
asset_paths.append(cum_ret)
# Combine: (time, asset1_cumret, asset2_cumret, ...)
path = np.hstack([t] + asset_paths)
paths.append(path)
end_dates.append(window["timestamp"][-1])
return np.array(paths), end_dates
# Create multi-asset paths
multi_paths, multi_dates = create_multi_asset_paths(df, symbols, WINDOW_SIZE)
if len(multi_paths) > 0:
print(f"Multi-asset paths: {len(multi_paths)} windows, dimension {multi_paths.shape[2]}")
# Compute signatures
multi_sigs = compute_logsignatures(multi_paths, depth=2) # Lower depth due to higher dim
print(f"Multi-asset log-signature dimension: {multi_sigs.shape[1]}")
else:
print("Insufficient data for multi-asset paths")
# %% [markdown]
# ## When to Use Signatures
#
# **Signatures improve over baseline lag features when path shape genuinely matters**:
# intraday microstructure (execution timing, order flow patterns), options expiration
# dynamics (gamma hedging creates distinctive intraday paths), and momentum decay
# patterns (distinguishing gradual trends from sharp reversals).
#
# **For daily equity data with trend-following objectives, the marginal gain over
# well-constructed lag features is often modest.** Test signatures when simpler features
# plateau and when execution timing depends on path characteristics.
#
# **Signatures excel when**:
# - Path shape matters more than specific values (pattern recognition)
# - You need features invariant to speed of execution (reparameterization invariance)
# - Higher-order interactions between path components are informative
# - You're building features for ML models (tree-based, neural networks)
#
# **Signatures may not help when**:
# - The signal is purely in levels/returns (simple momentum)
# - Computational budget is very tight (cost is $O(n \times d^K)$ per window)
# - Interpretability is paramount (individual signature terms are abstract)
#
# **Multi-asset signatures** capture lead-lag dynamics across instruments — for example,
# whether tech momentum (QQQ) consistently leads broad-market moves (SPY). This
# cross-asset application connects to the panel features in §9.6.
#
# **Rolling computation** over trailing windows maintains causality: at time $t$, compute
# the signature over $[t - W, t]$ only. Typical windows range from 5 to 60 days.
#
# **Practical tip**: Start with depth-2 log-signatures. Higher depths rarely help and
# increase overfitting risk. For multi-asset paths, reduce depth to 2 to keep feature
# counts manageable.
# %% [markdown]
# ## Summary
# %%
summary = {
"Data": "SPY ETF daily prices",
"Window size": f"{WINDOW_SIZE} days",
"Forecast horizon": f"{FORECAST_HORIZON} days",
"Signature depth": SIGNATURE_DEPTH,
"Signature dimension": signatures.shape[1],
"Log-signature dimension": logsignatures.shape[1],
"Lag features dimension": lag_features.shape[1],
"Best IC (Test)": f"{max(r['ic'] for r in results.values()):.4f}",
"Best model": max(results.keys(), key=lambda k: results[k]["ic"]),
}
print("\n=== Summary ===")
for key, value in summary.items():
print(f"{key}: {value}")
# %% [markdown]
# ## Key Takeaways
#
# 1. **Path signatures encode sequential structure** that traditional features miss
# 2. **Time augmentation is critical** - without it, rising and falling paths look identical
# 3. **Log-signatures are preferred** in practice (more compact, less redundant)
# 4. **Depth 2-3 is usually sufficient** - higher depths risk overfitting
# 5. **Signatures complement, don't replace** traditional features
# 6. **Cross-asset signatures** can capture lead-lag and correlation dynamics
#
# **Previous**: `04_kalman_filter` for Kalman-based features,
# `05_spectral_features` for frequency-domain features.
# **Next**: `07_arima_features` for time-domain forecasting features.
# Ch12 uses these enriched features in gradient boosting models.