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

2189 lines
73 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
# formats: ipynb,py:percent
# 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]
# # Chapter 5: Sig-WGAN - Signature-Based Wasserstein GANs
#
# **Chapter 5: Synthetic Data Generation**
# **Section Reference**: Section 5.4 (GANs for Time Series)
#
# **Docker image**: `ml4t-gpu`
#
# > **Docker required**: This notebook uses `signatory` and `esig`, which are x86-only
# > packages not included in the default environment. Run with:
# > ```bash
# > docker compose --profile py312 run --rm py312 python 05_synthetic_data/03_sigcwgan_signatures.py
# > ```
#
# ## Purpose
#
# This notebook implements the **unconditional Sig-Wasserstein GAN** — the core of
# Sig-CWGAN (Ni et al., Mathematical Finance 2024) — which uses **path signatures**
# as a principled distance metric for comparing time-series distributions. We build
# and train the unconditional variant; the conditional extension P(future | past) is
# explained conceptually but not implemented here.
#
# ## Learning Objectives
#
# By completing this notebook, you will:
# - Transform financial time series into path signatures using the `signatory` library
# - Implement an (unconditional) Sig-Wasserstein GAN that matches expected signatures
# - Use the expected signature (Sig-W1) as the training objective instead of a
# trained discriminator
# - **Understand how the conditional Sig-CWGAN extends this** with a Monte Carlo
# expected-signature objective (the key paper insight; the conditional path is
# described, not built, in this notebook)
# - Evaluate generative fidelity using signature-based metrics and TSTR
#
# ## Cross-References
#
# - **Upstream**: ETF Universe loader (`data`)
# - **Downstream**: Conditional generation for scenario analysis
# - **Book**: Section 5.2 discusses signature-based methods
#
# ---
#
# ## The Evolution from TimeGAN
#
# TimeGAN (2019) introduced embedding spaces and stepwise supervision. But its
# discriminator still uses a binary classifier to distinguish real/fake - which
# doesn't provide a smooth gradient landscape for generator training.
#
# Sig-CWGAN (2020→2024) introduces two innovations:
#
# 1. **Signature Distance**: Instead of a trained discriminator, use the
# **expected signature** to characterize time series distributions. The
# signature is a universal feature set that uniquely identifies probability
# measures on path space.
#
# 2. **Conditional W1 Metric** (the paper's full method): learn the conditional
# distribution P(future | past) rather than the unconditional law — more useful
# for finance (given what happened yesterday, what might happen tomorrow?). This
# notebook implements the unconditional Sig-W1 core; the conditional metric is
# described here but left as an extension.
#
# ## What Are Path Signatures?
#
# The signature of a path is an infinite series of iterated integrals that
# completely characterizes the path up to reparameterization:
#
# ```
# Sig(X)_t = (1, ∫dX_s, ∫∫dX_s⊗dX_u, ∫∫∫dX_s⊗dX_u⊗dX_v, ...)
# ```
#
# **Key Properties**:
# - **Uniqueness**: The expected signature E[Sig(X)] uniquely determines
# the law of the stochastic process X (under mild conditions)
# - **Universality**: Any continuous function of paths can be approximated
# by a linear function of the signature
# - **Computability**: Truncated signatures are tractable to compute
#
# ## Architecture Overview
#
# Sig-CWGAN consists of three main components:
# 1. **Path Augmentation**: Transform raw returns into signature-ready paths
# 2. **Signature Computation**: Compute truncated path signatures
# 3. **AR-FNN Generator**: Autoregressive feedforward network conditioned on past
#
# See `figures/figure_5_10_sigcwgan_architecture.png` for the full architecture diagram.
#
# **Key Insight**: The loss is on **expected signatures**, not single-sample signatures.
# For each past, we generate m futures with different noise, compute their signatures,
# and average before comparing to the target. This is the core Sig-CWGAN algorithm.
#
# ## References
#
# - **Paper**: Ni, H., Szpruch, L., Wiese, M., Liao, S., & Xiao, B. (2024).
# "Sig-Wasserstein GANs for Conditional Time Series Generation."
# Mathematical Finance, 34(4), 1179-1227.
# https://onlinelibrary.wiley.com/doi/full/10.1111/mafi.12423
# - **ArXiv (2020)**: https://arxiv.org/abs/2006.05421
# - **Code**: https://github.com/SigCGANs/Conditional-Sig-Wasserstein-GANs
#
# ---
#
# ## Paper-Exact Data Source
#
# **This notebook now uses the EXACT same data as the paper**:
# - **Source**: S&P 500 index daily close prices
# - **Returns**: Log returns: `log(close_t) - log(close_{t-1})`
# - **Period**: 2005-01-01 to 2020-06-01 (paper period)
# - **Single asset**: Paper uses `.SPX` only (not multiple assets)
#
# The original paper used Oxford MAN Realized Library (now defunct), which
# provided the same S&P 500 index prices, loaded via `load_sp500_index()`.
#
# ## WARNING: Signature Dimension Scaling
#
# Signature dimension scales as O(d^depth) where d = feature count after augmentations.
#
# | Assets | After Augmentations | Depth 4 Sig Dim | Feasibility |
# |--------|---------------------|-----------------|-------------|
# | 1 | 5 features | 780 dims | [OK] Fast |
# | 2 | 7 features | 2,800 dims | [OK] Feasible |
# | 5 | 13 features | 30,940 dims | WARNING: Slow |
# | 10 | 23 features | 303,600 dims | [FAIL] OOM |
#
# **This notebook uses 1 asset (S&P 500) to match the paper exactly.**
# %%
"""Sig-CWGAN — Signature-based conditional Wasserstein GAN for time series generation."""
import json
import math
from datetime import UTC, datetime
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import plotly.graph_objects as go
import polars as pl
import torch
import torch.nn as nn
import torch.optim as optim
# %% [markdown]
# ## Sig-CWGAN Architecture
#
# The architecture uses path signatures as an analytic discriminator, replacing
# trained neural networks with a mathematically principled distance metric.
# %%
from IPython.display import Image, display
from plotly.subplots import make_subplots
from scipy import stats
from tqdm import tqdm
from utils.paths import get_chapter_dir, get_output_dir
from utils.reproducibility import set_global_seeds
from utils.style import COLORS, plot_fidelity_comparison
ASSETS_DIR = get_chapter_dir(5) / "assets"
if (ASSETS_DIR / "sigcwgan_architecture.jpeg").exists():
display(Image(ASSETS_DIR / "sigcwgan_architecture.jpeg", width=800))
# Checkpoint path for model persistence
CHECKPOINT_PATH = get_output_dir(5, "sigcwgan") / "checkpoints" / "sigcwgan_model.pt"
# %% tags=["parameters"]
N_LAGS = 16 # Path length (paper: 16)
SIG_DEPTH = 4 # Signature truncation depth (paper: 4)
LSTM_HIDDEN_DIM = 50 # LSTM hidden dimension
LSTM_N_LAYERS = 2 # LSTM layers
TOTAL_STEPS = 2500 # Gradient steps (paper: 2500)
BATCH_SIZE = 2000 # Batch size (paper: 2000)
MC_SAMPLES_TRAIN = 16 # Monte Carlo samples during training
MC_SAMPLES_EVAL = 500 # Monte Carlo samples during evaluation
RETRAIN = False # Set True to force retraining even if checkpoint exists
SEED = 42
# %%
set_global_seeds(SEED)
# %%
# =============================================================================
# SIGNATURE LIBRARY SETUP
# =============================================================================
# SigCWGAN training requires differentiable path signatures (autograd support).
# signatory provides GPU-accelerated differentiable signatures.
# Install: pip install --no-build-isolation signatory (requires torch first).
# =============================================================================
import signatory
# %%
def compute_signature_gpu(paths: torch.Tensor, depth: int) -> torch.Tensor:
"""Compute signatures on GPU (differentiable)."""
return signatory.signature(paths, depth)
# %%
def compute_signature_np(path: np.ndarray, depth: int) -> np.ndarray:
"""Compute signature of a single path (numpy)."""
path_tensor = torch.tensor(path, dtype=torch.float32).unsqueeze(0)
sig = signatory.signature(path_tensor, depth)
return sig.squeeze(0).numpy()
# %% [markdown]
# ### Configuration (Ni et al., Mathematical Finance 2024)
#
# Following the original paper exactly:
# - `configs/STOCKS/SigWGAN.json` specifies the exact settings
# - LSTMGenerator with Brownian motion noise input
# - Augmentations: Scale(2, dim=0) → AddTime → LeadLag → VisiTrans("I")
# - Factorial normalization on signature levels
# - batch_size=2000, depth=4, lr=1e-3, n_gradient_steps=2500
#
# **Data source**: S&P 500 index daily close prices (matching Oxford MAN source).
# Paper uses log returns of close prices for a single asset (.SPX),
# period 2005-01-01 to 2020-06-01.
#
# Signature dimension scales as $O(d^{\text{depth}})$ where $d$ is the feature count.
# With depth=4 and proper augmentations, we match the paper's methodology.
# %%
CONFIG = {
# Data - PAPER EXACT: Single asset (S&P 500), log returns
"data_source": "sp500", # Use bundled S&P 500 index data
"start_date": "2005-01-01", # Paper start date
"end_date": "2020-06-01", # Paper end date (for training)
"holdout_start": "2018-01-01", # Our holdout for TSTR evaluation
# Paper uses n_lags=16 for path length
"n_lags": N_LAGS,
# Signature - PAPER USES DEPTH 4
"sig_depth": SIG_DEPTH,
# Architecture - PAPER USES LSTM GENERATOR
"lstm_hidden_dim": LSTM_HIDDEN_DIM,
"lstm_n_layers": LSTM_N_LAYERS,
"noise_dim": 1, # Single asset (paper convention: noise_dim = input_dim)
# Training - MATCH PAPER EXACTLY
"total_steps": TOTAL_STEPS,
"batch_size": BATCH_SIZE,
"learning_rate": 1e-3, # Paper: 1e-3 (NOT 1e-2)
"lr_scheduler_step": 128, # Paper: StepLR(128, 0.95)
"lr_scheduler_gamma": 0.95,
# Augmentations (from configs/STOCKS/SigWGAN.json)
"scale_factor": 2.0,
"scale_dim": 0,
# MC samples for expected signature (conditional version)
"mc_samples_train": MC_SAMPLES_TRAIN,
"mc_samples_eval": MC_SAMPLES_EVAL,
# Factorial normalization (CRITICAL - paper uses this)
"normalise_sig": True,
}
# %%
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
# %% [markdown]
# ## Data Integrity and TSTR Methodology
#
# **Critical**: This notebook implements a proper train/holdout split to enable
# unbiased Train-Synthetic-Test-Real (TSTR) evaluation.
#
# ### Normalization: Preserving Sign (CRITICAL)
#
# **IMPORTANT**: We use z-score normalization (mean 0, std 1), NOT MinMax to [0,1].
#
# Why? The path signature uses cumulative sums. If returns are mapped to [0,1],
# every coordinate becomes monotonically increasing - the path can only "go up".
# This destroys the sign structure of returns, which is essential for meaningful
# financial paths and signatures.
#
# ```
# WRONG: returns ∈ [0,1] → cumsum always increases → no "down" moves
# RIGHT: returns ∈ (centered) → cumsum goes up and down → realistic paths
# ```
# %% [markdown]
# ## 1. Load Financial Data
#
# **PAPER-EXACT DATA SOURCE**: S&P 500 index daily close prices
#
# The paper uses the Oxford MAN Realized Library which provided S&P 500 index
# prices (now defunct). We use bundled S&P 500 data which is equivalent.
#
# **Key difference from previous implementation**:
# - **Before**: ETF percent returns (SPY, QQQ)
# - **Now**: S&P 500 index LOG returns (single asset, paper-exact)
# %%
def load_sp500_log_returns(
start_date: str, end_date: str | None = None
) -> tuple[np.ndarray, np.ndarray]:
"""
Load S&P 500 index LOG returns (PAPER-EXACT).
The paper uses: log(close_t) - log(close_{t-1})
NOT percent returns: (close_t - close_{t-1}) / close_{t-1}
For small returns these are nearly identical, but log returns have
nicer properties (additive over time, symmetric).
Args:
start_date: Start date (YYYY-MM-DD)
end_date: End date (YYYY-MM-DD), or None for all data
Returns:
Tuple of (log_returns, timestamps) where:
- log_returns: np.ndarray of shape (n_days, 1)
- timestamps: np.ndarray of datetime64 timestamps
"""
from data import load_sp500_index
df = load_sp500_index()
start_dt = pl.lit(start_date).str.to_date()
df = df.filter(pl.col("timestamp") >= start_dt)
if end_date is not None:
end_dt = pl.lit(end_date).str.to_date()
df = df.filter(pl.col("timestamp") <= end_dt)
df = df.sort("timestamp")
# Compute LOG returns (PAPER-EXACT)
# log(close_t) - log(close_{t-1}) = log(close_t / close_{t-1})
df = df.with_columns(pl.col("close").log().diff().alias("log_return")).drop_nulls()
timestamps = df.select("timestamp").to_numpy().flatten()
log_returns = df.select("log_return").to_numpy().astype(np.float32)
print(f"Loaded {len(log_returns)} days of S&P 500 LOG returns")
print(f"Date range: {timestamps[0]} to {timestamps[-1]}")
print("Return type: LOG returns (paper-exact)")
return log_returns, timestamps
all_returns, all_timestamps = load_sp500_log_returns(CONFIG["start_date"], CONFIG.get("end_date"))
n_total_days, n_assets = all_returns.shape
print(f"Data shape: {all_returns.shape} (days, assets)")
print(f"Number of assets: {n_assets} (paper uses 1 = S&P 500 only)")
# CRITICAL: Split at holdout_start date
holdout_dt = np.datetime64(CONFIG["holdout_start"])
train_mask = all_timestamps < holdout_dt
returns_train_raw = all_returns[train_mask]
holdout_returns_raw = all_returns[~train_mask]
train_timestamps = all_timestamps[train_mask]
holdout_timestamps = all_timestamps[~train_mask]
n_days = len(returns_train_raw)
print("\nTSTR Data Split (temporal at holdout_start):")
print(
f" Training: {len(returns_train_raw):,} days ({train_timestamps[0]} to {train_timestamps[-1]})"
)
print(
f" Holdout: {len(holdout_returns_raw):,} days ({holdout_timestamps[0]} to {holdout_timestamps[-1]})"
)
print("\nPaper reference period: 2005-01-01 to 2020-06-01 (~3,800 days)")
# =============================================================================
# PAPER-EXACT: NO NORMALIZATION
# =============================================================================
# The paper uses RAW log returns without any normalization.
# Daily log returns are tiny (~0.001), which keeps signature values small.
# This is critical for matching the paper's Sig-W1 values (~2.76).
# =============================================================================
# Use raw log returns (NO z-score normalization - paper-exact)
returns = returns_train_raw
holdout_returns = holdout_returns_raw
# Store statistics for reference (but don't apply normalization)
train_mean = returns_train_raw.mean(axis=0)
train_std = returns_train_raw.std(axis=0)
print("\nUsing RAW log returns (paper-exact, no normalization):")
print(f" Mean: {train_mean[0]:.6f}")
print(f" Std: {train_std[0]:.6f}")
print(f" Range: [{returns.min():.6f}, {returns.max():.6f}]")
print(" NOTE: Paper uses raw log returns to keep signature magnitudes small")
# Keep original scale for output
returns_original = returns_train_raw
holdout_returns_original = holdout_returns_raw
# %% [markdown]
# ## 2. Path Augmentation and Signature Computation
#
# Raw return paths need **augmentation** before signature computation.
#
# **PAPER PIPELINE** (configs/STOCKS/SigWGAN.json):
# 1. **Scale(2, dim=0)**: Scale first dimension by 2
# 2. **AddTime**: Prepend time coordinate [0,1]
# 3. **LeadLag**: Lead-lag transform for quadratic variation
# 4. **VisiTrans("I")**: I-visibility transform (adds 2 rows + 1 column)
#
# **CRITICAL**: Our previous implementation used cumsum_concat + lag_added,
# which is NOT what the paper uses. The paper's augmentations are specifically
# designed for their signature-based loss.
#
# The signature depth of 4 with these augmentations matches the paper methodology.
# %%
# =============================================================================
# PAPER-EXACT PATH TRANSFORMS (from lib/augmentations.py)
# =============================================================================
# These transforms MUST match the paper exactly for comparable results.
# Reference: configs/STOCKS/SigWGAN.json
# =============================================================================
def scale_transform(path: np.ndarray, scale: float = 2.0, dim: int = 0) -> np.ndarray:
"""
Scale a specific dimension of the path.
Paper config: {"name": "Scale", "scale": 2, "dim": 0}
Args:
path: Shape (seq_len, d) or (batch, seq_len, d)
scale: Scaling factor
dim: Dimension to scale (0 = first asset)
Returns:
Scaled path with same shape
"""
result = path.copy()
result[..., dim] = scale * result[..., dim]
return result
# %%
def add_time_transform(path: np.ndarray) -> np.ndarray:
"""
Prepend time coordinate [0,1] to the path.
Paper config: {"name": "AddTime"}
This makes time explicit in the signature, allowing it to capture
the rate of change, not just the path shape.
Args:
path: Shape (seq_len, d)
Returns:
Shape (seq_len, d+1) with time as first coordinate
"""
seq_len = path.shape[0]
time = np.linspace(0, 1, seq_len).reshape(-1, 1).astype(path.dtype)
return np.concatenate([time, path], axis=-1)
# %% [markdown]
# ### Lead-Lag Transform
#
# The lead-lag transform recovers quadratic variation from discrete observations
# by doubling the path with offset copies. This is critical because discrete
# sampling loses continuous-path information that signatures need.
# %%
def lead_lag_transform(path: np.ndarray) -> np.ndarray:
"""
Apply lead-lag transformation (PAPER VERSION).
Paper config: {"name": "LeadLag"}
This is crucial for signatures because it recovers quadratic variation
information that would otherwise be lost in discrete approximations.
Implementation from lib/augmentations.py:
x_rep = repeat_interleave(x, repeats=2, dim=1)
x_ll = cat([x_rep[:, :-1], x_rep[:, 1:]], dim=2)
Args:
path: Shape (seq_len, d)
Returns:
Shape (2 * seq_len - 1, 2 * d)
"""
# Paper implementation: repeat each point twice, then concatenate
# shifted versions to create lead-lag pairs
seq_len, d = path.shape
# Repeat each point twice along sequence dimension
x_rep = np.repeat(path, 2, axis=0) # (2*seq_len, d)
# Create lead-lag: [x_rep[:-1], x_rep[1:]]
x_ll = np.concatenate([x_rep[:-1], x_rep[1:]], axis=1) # (2*seq_len-1, 2*d)
return x_ll
# %% [markdown]
# ### Visibility Transform
#
# The I-visibility transform adds boundary rows and a visibility marker column,
# ensuring signature terms handle path initialization correctly.
# %%
def visibility_transform_I(path: np.ndarray) -> np.ndarray:
"""
I-visibility transform (PAPER VERSION).
Paper config: {"name": "VisiTrans", "type": "I"}
Implementation from lib/augmentations.py:
- Adds 2 rows at start (zeros, copy of first row)
- Adds 1 column (visibility marker: 0,0,1,1,1,...,1)
Args:
path: Shape (seq_len, d)
Returns:
Shape (seq_len + 2, d + 1)
"""
seq_len, d = path.shape
# Row 0: zeros
# Row 1: copy of original row 0
# Rows 2+: original path
init_zeros = np.zeros((1, d), dtype=path.dtype)
init_copy = path[:1, :] # First row of original path
main_path = np.concatenate([init_zeros, init_copy, path], axis=0) # (seq_len+2, d)
# New column: visibility marker [0, 0, 1, 1, 1, ..., 1]
new_col = np.concatenate([np.zeros(2), np.ones(seq_len)]).reshape(-1, 1).astype(path.dtype)
return np.concatenate([main_path, new_col], axis=1) # (seq_len+2, d+1)
# %%
def cumulative_sum_transform(path: np.ndarray) -> np.ndarray:
"""
Convert returns to cumulative sum (log-price path).
NOTE: This is NOT used in the paper's STOCKS config.
Kept for backwards compatibility but not part of the paper pipeline.
"""
return np.cumsum(path, axis=0)
# %% [markdown]
# ### Paper Augmentation Pipeline
#
# The full pipeline from `configs/STOCKS/SigWGAN.json`:
# Scale(2, dim=0) → AddTime → LeadLag → VisiTrans("I").
# This specific ordering is critical for reproducing paper results.
# %%
def augment_path_paper(path: np.ndarray, config: dict) -> np.ndarray:
"""
Apply PAPER-EXACT augmentation pipeline.
Pipeline from configs/STOCKS/SigWGAN.json:
1. Scale(2, dim=0)
2. AddTime
3. LeadLag
4. VisiTrans("I")
Args:
path: Shape (seq_len, d) - raw returns or prices
config: Configuration dict with scale_factor, scale_dim
Returns:
Augmented path ready for signature computation
"""
result = path.copy()
# Step 1: Scale(2, dim=0) - scale first dimension by 2
result = scale_transform(result, scale=config["scale_factor"], dim=config["scale_dim"])
# Step 2: AddTime - prepend time coordinate
result = add_time_transform(result)
# Step 3: LeadLag - lead-lag transform
result = lead_lag_transform(result)
# Step 4: VisiTrans("I") - I-visibility transform
result = visibility_transform_I(result)
return result
# %% [markdown]
# ### Signature Dimension Helpers
#
# Path and signature dimensions scale exponentially with augmentation count
# and signature depth. These helpers compute exact sizes for verification.
# %%
def compute_path_dim_paper(input_dim: int) -> int:
"""
Compute path dimension after paper augmentations.
Scale(2, dim=0): d → d (no change)
AddTime: d → d+1
LeadLag: d+1 → 2*(d+1)
VisiTrans("I"): 2*(d+1) → 2*(d+1)+1
Total: d → 2*(d+1)+1 = 2d+3
"""
d = input_dim
d = d + 1 # AddTime
d = d * 2 # LeadLag
d = d + 1 # VisiTrans adds 1 column
return d
# %%
def compute_sig_dim_paper(input_dim: int, depth: int) -> int:
"""
Compute signature dimension after paper augmentations.
Signature dimension = sum_{k=1}^{depth} path_dim^k
"""
path_dim = compute_path_dim_paper(input_dim)
return sum(path_dim**k for k in range(1, depth + 1))
# %% [markdown]
# ### Factorial-Normalized Signatures
#
# Without normalization, signature level $k$ has magnitude $O(1/k!)$, making
# higher levels invisible to the loss. The paper multiplies each level
# by $k!$ to bring all levels to comparable scale.
# %%
def compute_signature_with_factorial_norm(
path: np.ndarray, depth: int, normalise: bool = True
) -> np.ndarray:
"""
Compute signature WITH FACTORIAL NORMALIZATION (paper method).
From lib/trainers/sig_wgan.py:
for i in range(depth):
expected_signature[count:count + dim**(i+1)] *= math.factorial(i+1)
This rescales signature levels to comparable magnitudes:
- Level 1 terms × 1!
- Level 2 terms × 2!
- Level 3 terms × 3!
- Level 4 terms × 4!
Without this, higher-level terms have much smaller magnitudes due to
the iterated integral structure, causing training instability.
Args:
path: Augmented path, shape (seq_len, d)
depth: Signature truncation depth
normalise: Whether to apply factorial normalization (paper: True)
Returns:
Signature vector (possibly normalized)
"""
sig = compute_signature_np(path, depth)
if normalise:
dim = path.shape[-1] # Path dimension after augmentation
count = 0
for level in range(depth):
level_size = dim ** (level + 1)
# Multiply level (i+1) terms by (i+1)!
sig[count : count + level_size] *= math.factorial(level + 1)
count += level_size
return sig
# %% [markdown]
# ### Augment and Compute Signature
#
# Convenience function combining the full augmentation pipeline with
# factorial-normalized signature computation.
# %%
def augment_and_signature_paper(
path: np.ndarray, depth: int, config: dict, normalise: bool = True
) -> np.ndarray:
"""
Full paper pipeline: augment + compute signature with factorial normalization.
Args:
path: Raw path, shape (seq_len, d)
depth: Signature truncation depth
config: Configuration dict
normalise: Whether to apply factorial normalization
Returns:
Signature vector
"""
augmented = augment_path_paper(path, config)
return compute_signature_with_factorial_norm(augmented, depth, normalise=normalise)
# %% [markdown]
# ### Verify Augmentation Pipeline
#
# Confirm dimension calculations match actual signature output.
# %%
# =============================================================================
# VERIFY AUGMENTATION PIPELINE
# =============================================================================
print("\n=== Paper-Exact Signature Specification ===")
print("Pipeline: Scale(2, dim=0) → AddTime → LeadLag → VisiTrans('I')")
print(f"Input dimension: {n_assets}")
path_dim = compute_path_dim_paper(n_assets)
sig_dim = compute_sig_dim_paper(n_assets, CONFIG["sig_depth"])
print(f"Path dimension after augmentations: {path_dim}")
print(f"Signature dimension (depth={CONFIG['sig_depth']}): {sig_dim}")
print(f"Factorial normalization: {CONFIG['normalise_sig']}")
# Test signature computation
test_path = returns[: CONFIG["n_lags"]]
test_augmented = augment_path_paper(test_path, CONFIG)
test_sig = compute_signature_with_factorial_norm(
test_augmented, CONFIG["sig_depth"], normalise=CONFIG["normalise_sig"]
)
print(f"\nTest path shape: {test_path.shape}")
print(f"Augmented path shape: {test_augmented.shape}")
print(f"Signature dimension: {len(test_sig)}")
print(f"Expected signature dimension: {sig_dim}")
# Verify dimensions match
assert len(test_sig) == sig_dim, f"Signature dim {len(test_sig)} != expected {sig_dim}"
print("[OK] Signature dimensions verified")
# %% [markdown]
# ## 3. Create Training Dataset
#
# **Paper Approach**: The paper trains on UNCONDITIONAL generation - matching
# the distribution of full paths, not conditional on past.
#
# We create rolling windows of length `n_lags` for signature computation.
# %%
def create_rolling_windows(data: np.ndarray, n_lags: int) -> np.ndarray:
"""
Create overlapping windows of fixed length.
Paper uses rolling windows for training data - each window is a
complete path for signature computation.
Args:
data: Shape (n_days, n_assets)
n_lags: Window length (paper: 16)
Returns:
Shape (n_windows, n_lags, n_assets)
"""
n_windows = len(data) - n_lags + 1
windows = np.zeros((n_windows, n_lags, data.shape[1]), dtype=data.dtype)
for i in range(n_windows):
windows[i] = data[i : i + n_lags]
return windows
# Create training windows
n_lags = CONFIG["n_lags"]
train_windows = create_rolling_windows(returns, n_lags)
holdout_windows = create_rolling_windows(holdout_returns, n_lags)
print(f"Created {len(train_windows)} training windows of length {n_lags}")
print(f"Training windows shape: {train_windows.shape}")
print(f"Created {len(holdout_windows)} holdout windows for evaluation")
print(f"Holdout windows shape: {holdout_windows.shape}")
# %% [markdown]
# ## 4. Compute Expected Signature of Real Data
#
# **Paper Approach (UNCONDITIONAL)**: The Sig-W1 metric compares expected signatures:
#
# ```
# Sig-W1(μ, ν) = ||E[Sig(X)]_μ - E[Sig(X)]_ν||_2
# ```
#
# We precompute the expected signature of the REAL training data. During training,
# we minimize the distance between generated and real expected signatures.
#
# **FACTORIAL NORMALIZATION**: The paper multiplies level k signature terms by k!
# to rescale them to comparable magnitudes (higher levels have smaller raw values).
# %%
def compute_signatures_batch_paper(
paths: np.ndarray, depth: int, config: dict, normalise: bool = True, desc: str = "Computing"
) -> np.ndarray:
"""
Compute signatures for a batch of paths using paper's pipeline.
Args:
paths: Shape (n_paths, seq_len, n_assets)
depth: Signature truncation depth
config: Configuration dict with augmentation params
normalise: Whether to apply factorial normalization
desc: Progress bar description
Returns:
Shape (n_paths, sig_dim)
"""
signatures = []
for path in tqdm(paths, desc=desc):
sig = augment_and_signature_paper(path, depth, config, normalise=normalise)
signatures.append(sig)
return np.array(signatures)
# Compute expected signature of real training data
print("Computing signatures of real training windows...")
print(f"Using factorial normalization: {CONFIG['normalise_sig']}")
real_signatures = compute_signatures_batch_paper(
train_windows,
CONFIG["sig_depth"],
CONFIG,
normalise=CONFIG["normalise_sig"],
desc="Real signatures",
)
# Expected signature = mean over all training windows
expected_sig_real = real_signatures.mean(axis=0)
print(f"\nReal signatures shape: {real_signatures.shape}")
print(f"Expected signature shape: {expected_sig_real.shape}")
print(f"Expected signature norm: {np.linalg.norm(expected_sig_real):.4f}")
# =============================================================================
# SIGNATURE DIMENSION VERIFICATION
# =============================================================================
expected_sig_dim = compute_sig_dim_paper(n_assets, CONFIG["sig_depth"])
actual_sig_dim = real_signatures.shape[1]
print("\n=== Signature Dimension Check ===")
print(f" Input dimension (n_assets): {n_assets}")
print(f" Path dim after paper augmentations: {compute_path_dim_paper(n_assets)}")
print(f" Expected signature dim: {expected_sig_dim}")
print(f" Actual signature dim: {actual_sig_dim}")
if actual_sig_dim != expected_sig_dim:
print(" WARNING: Dimension mismatch! Check augmentations.")
else:
print(" [OK] Dimensions match as expected")
# %% [markdown]
# ## 5. Generator Architecture (PAPER: LSTMGenerator)
#
# **CRITICAL FIX**: The paper uses an **LSTMGenerator** with **Brownian motion noise**,
# NOT an AR-FNN with independent Gaussian noise.
#
# From `lib/networks/generators.py`:
# ```python
# z = (0.1 * torch.randn(batch_size, n_lags, input_dim)).cumsum(1) # Brownian!
# h1, _ = self.rnn(z, (h0, c0)) # LSTM processes Brownian path
# x = self.linear(h1) # Linear projection to output
# ```
#
# The Brownian motion noise is KEY - it provides smooth, continuous input paths
# to the LSTM, which then transforms them into realistic output paths.
# %% [markdown]
# ### Residual Feedforward Network
#
# Helper network for initializing LSTM hidden states from noise input.
# %%
class ResFNN(nn.Module):
"""
Feedforward network with residual connections and Tanh activation.
Used for initializing LSTM hidden states (paper implementation).
"""
def __init__(self, input_dim: int, output_dim: int, hidden_dims: list[int]):
super().__init__()
layers = []
dims = [input_dim] + hidden_dims
for i in range(len(dims) - 1):
layers.append(nn.Linear(dims[i], dims[i + 1]))
layers.append(nn.ReLU())
layers.append(nn.Linear(dims[-1], output_dim))
self.net = nn.Sequential(*layers)
# Residual projection if dimensions don't match
if input_dim != output_dim:
self.residual = nn.Linear(input_dim, output_dim)
else:
self.residual = nn.Identity()
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.net(x) + self.residual(x)
# %% [markdown]
# ### LSTM Generator
#
# The paper's generator processes Brownian motion noise (cumulative sum of
# Gaussian) through an LSTM, producing smooth output paths. This is different
# from AR-FNN approaches that use independent noise at each step.
# %%
class LSTMGenerator(nn.Module):
"""
LSTM Generator from the paper (lib/networks/generators.py).
Key differences from AR-FNN:
1. Uses BROWNIAN MOTION as input noise (cumsum of Gaussian)
2. LSTM processes the entire noise sequence at once
3. Linear projection to output dimension
This is the EXACT architecture from the paper.
"""
def __init__(
self,
input_dim: int,
output_dim: int,
hidden_dim: int = 50,
n_layers: int = 2,
init_fixed: bool = True,
):
super().__init__()
self.input_dim = input_dim
self.output_dim = output_dim
self.hidden_dim = hidden_dim
self.n_layers = n_layers
self.init_fixed = init_fixed
# LSTM processes Brownian motion noise
self.rnn = nn.LSTM(
input_size=input_dim, hidden_size=hidden_dim, num_layers=n_layers, batch_first=True
)
# Linear projection to output dimension (no bias, per paper)
self.linear = nn.Linear(hidden_dim, output_dim, bias=False)
# Optional: NN to initialize h0 from noise (if init_fixed=False)
if not init_fixed:
self.initial_nn = nn.Sequential(
ResFNN(input_dim, hidden_dim * n_layers, [hidden_dim, hidden_dim]), nn.Tanh()
)
# Initialize weights
self._init_weights()
def _init_weights(self):
"""Initialize weights following paper conventions."""
for name, param in self.named_parameters():
if "weight" in name:
nn.init.xavier_uniform_(param)
elif "bias" in name:
nn.init.zeros_(param)
def forward(self, batch_size: int, n_lags: int, device: torch.device) -> torch.Tensor:
"""
Generate paths using LSTM on Brownian motion noise.
Args:
batch_size: Number of paths to generate
n_lags: Length of each path
device: Torch device
Returns:
Generated paths, shape (batch_size, n_lags, output_dim)
"""
# =================================================================
# CRITICAL: BROWNIAN MOTION NOISE (paper implementation)
# =================================================================
# z = (0.1 * randn(...)).cumsum(1)
# First point is fixed at 0
# =================================================================
z = 0.1 * torch.randn(batch_size, n_lags, self.input_dim, device=device)
z[:, 0, :] = 0 # First point fixed at origin
z = z.cumsum(dim=1) # Cumulative sum → Brownian path
# Initialize hidden states
if self.init_fixed:
h0 = torch.zeros(self.n_layers, batch_size, self.hidden_dim, device=device)
else:
z0 = torch.randn(batch_size, self.input_dim, device=device)
h0 = self.initial_nn(z0)
h0 = h0.view(batch_size, self.n_layers, self.hidden_dim).permute(1, 0, 2).contiguous()
c0 = torch.zeros_like(h0)
# Process Brownian path through LSTM
h1, _ = self.rnn(z, (h0, c0))
# Project to output dimension
x = self.linear(h1)
assert x.shape[1] == n_lags
return x
# %% [markdown]
# ### Initialize Generator
#
# Instantiate the LSTM generator with paper hyperparameters.
# %%
# Initialize generator (PAPER ARCHITECTURE)
generator = LSTMGenerator(
input_dim=CONFIG["noise_dim"],
output_dim=n_assets,
hidden_dim=CONFIG["lstm_hidden_dim"],
n_layers=CONFIG["lstm_n_layers"],
init_fixed=True, # Paper default
).to(device)
print("\n=== LSTM Generator (Paper Architecture) ===")
print(f"Input dim (noise): {CONFIG['noise_dim']}")
print(f"Output dim (assets): {n_assets}")
print(f"Hidden dim: {CONFIG['lstm_hidden_dim']}")
print(f"LSTM layers: {CONFIG['lstm_n_layers']}")
print(f"Total parameters: {sum(p.numel() for p in generator.parameters()):,}")
# %% [markdown]
# ## 6. Sig-W1 Loss (PAPER IMPLEMENTATION)
#
# **Paper Loss**: The Sig-W1 metric is the L2 norm (NOT squared MSE) of the
# difference between expected signatures:
#
# ```
# Sig-W1(μ, ν) = ||E[Sig(X)]_μ - E[Sig(X)]_ν||_2
# ```
#
# For training:
# - Generate a batch of fake paths
# - Compute their expected signature (mean over batch)
# - Compare to precomputed expected signature of real data
# - Loss = L2 norm of difference (RMSE style)
#
# **FACTORIAL NORMALIZATION**: Applied to signatures during training too!
# %%
# =============================================================================
# GPU PATH TRANSFORMS (PAPER-EXACT, Differentiable)
# =============================================================================
# These MUST match the numpy transforms exactly:
# Scale(2, dim=0) → AddTime → LeadLag → VisiTrans("I")
# =============================================================================
def scale_transform_gpu(paths: torch.Tensor, scale: float = 2.0, dim: int = 0) -> torch.Tensor:
"""
Scale a specific dimension (GPU, differentiable).
Paper config: {"name": "Scale", "scale": 2, "dim": 0}
"""
result = paths.clone()
result[..., dim] = scale * result[..., dim]
return result
# %%
def add_time_transform_gpu(paths: torch.Tensor) -> torch.Tensor:
"""
Prepend time coordinate [0,1] (GPU, differentiable).
Paper config: {"name": "AddTime"}
"""
batch_size, seq_len, dim = paths.shape
time = torch.linspace(0, 1, seq_len, device=paths.device, dtype=paths.dtype)
time = time.view(1, -1, 1).expand(batch_size, -1, 1)
return torch.cat([time, paths], dim=-1)
# %% [markdown]
# ### GPU Lead-Lag and Visibility Transforms
#
# Differentiable versions of the structural transforms, needed for
# backpropagation through the signature computation during training.
# %%
def lead_lag_transform_gpu(paths: torch.Tensor) -> torch.Tensor:
"""
Lead-lag transformation (GPU, differentiable).
Paper config: {"name": "LeadLag"}
Implementation from lib/augmentations.py:
x_rep = repeat_interleave(x, repeats=2, dim=1)
x_ll = cat([x_rep[:, :-1], x_rep[:, 1:]], dim=2)
"""
# Repeat each point twice along sequence dimension
x_rep = paths.repeat_interleave(2, dim=1) # (batch, 2*seq_len, dim)
# Create lead-lag: [x_rep[:-1], x_rep[1:]]
x_ll = torch.cat([x_rep[:, :-1], x_rep[:, 1:]], dim=2) # (batch, 2*seq_len-1, 2*dim)
return x_ll
# %%
def visibility_transform_I_gpu(paths: torch.Tensor) -> torch.Tensor:
"""
I-visibility transform (GPU, differentiable).
Paper config: {"name": "VisiTrans", "type": "I"}
Implementation from lib/augmentations.py:
- Adds 2 rows at start (zeros, copy of first row)
- Adds 1 column (visibility marker: 0,0,1,1,1,...,1)
"""
batch_size, seq_len, dim = paths.shape
device = paths.device
dtype = paths.dtype
# Row 0: zeros, Row 1: copy of original row 0, Rows 2+: original path
init_zeros = torch.zeros(batch_size, 1, dim, device=device, dtype=dtype)
init_copy = paths[:, :1, :] # First row of original path
main_path = torch.cat([init_zeros, init_copy, paths], dim=1) # (batch, seq_len+2, dim)
# New column: visibility marker [0, 0, 1, 1, 1, ..., 1]
vis_marker = torch.cat(
[
torch.zeros(2, device=device, dtype=dtype),
torch.ones(seq_len, device=device, dtype=dtype),
]
)
vis_marker = vis_marker.view(1, -1, 1).expand(batch_size, -1, 1)
return torch.cat([main_path, vis_marker], dim=2) # (batch, seq_len+2, dim+1)
# %% [markdown]
# ### GPU Augmentation Pipeline and Expected Signature
#
# The full differentiable pipeline: augment → compute signature → average →
# normalize. This is used during training to compute the Sig-W1 loss.
# %%
def augment_paths_gpu_paper(paths: torch.Tensor, config: dict) -> torch.Tensor:
"""
Apply PAPER-EXACT augmentation pipeline on GPU (differentiable).
Pipeline from configs/STOCKS/SigWGAN.json:
1. Scale(2, dim=0)
2. AddTime
3. LeadLag
4. VisiTrans("I")
"""
result = paths
# Step 1: Scale(2, dim=0)
result = scale_transform_gpu(result, scale=config["scale_factor"], dim=config["scale_dim"])
# Step 2: AddTime
result = add_time_transform_gpu(result)
# Step 3: LeadLag
result = lead_lag_transform_gpu(result)
# Step 4: VisiTrans("I")
result = visibility_transform_I_gpu(result)
return result
# %% [markdown]
# ### Expected Signature with Factorial Normalization (GPU)
#
# Compute the mean signature over a batch of augmented paths, applying
# factorial rescaling per level to equalize magnitude across depths.
# %%
def compute_expected_signature_gpu(
paths: torch.Tensor, depth: int, config: dict, normalise: bool = True
) -> torch.Tensor:
"""
Compute expected signature WITH FACTORIAL NORMALIZATION (GPU, differentiable).
Paper implementation from lib/trainers/sig_wgan.py:
expected_signature = signatory.signature(x_path_augmented, depth).mean(0)
if normalise:
for i in range(depth):
expected_signature[count:count + dim**(i+1)] *= math.factorial(i+1)
Args:
paths: Shape (batch, seq_len, dim) - raw generated paths
depth: Signature truncation depth
config: Configuration dict
normalise: Whether to apply factorial normalization
Returns:
Expected signature vector, shape (sig_dim,)
"""
# Augment paths
augmented = augment_paths_gpu_paper(paths, config)
# Compute signatures
sigs = compute_signature_gpu(augmented, depth) # (batch, sig_dim)
# Expected signature = mean over batch
expected_sig = sigs.mean(dim=0)
# Factorial normalization
if normalise:
dim = augmented.shape[-1] # Path dimension after augmentation
count = 0
for level in range(depth):
level_size = dim ** (level + 1)
expected_sig[count : count + level_size] = expected_sig[
count : count + level_size
] * math.factorial(level + 1)
count += level_size
return expected_sig
# %% [markdown]
# ## 7. Training Loop (PAPER: Unconditional Sig-W1)
#
# **Paper Algorithm** (lib/trainers/sig_wgan.py):
#
# ```python
# for step in range(n_gradient_steps):
# x_fake = G(batch_size, n_lags, device) # Generate batch
# loss = sig_w1_metric(x_fake) # Sig-W1 loss
# loss.backward()
# optimizer.step()
# scheduler.step()
# ```
#
# **Sig-W1 Loss**:
# ```
# loss = ||E[Sig(fake)] - E[Sig(real)]||_2 (RMSE, not MSE!)
# ```
#
# The generator produces paths unconditionally - no conditioning on past.
# %%
def train_sigwgan_paper(
generator: nn.Module,
expected_sig_real: np.ndarray,
config: dict,
device: torch.device,
) -> list[float]:
"""
Train Sig-WGAN generator using PAPER-EXACT Sig-W1 objective.
This is UNCONDITIONAL generation - the generator learns to produce
paths whose expected signature matches the real data's expected signature.
Paper reference: lib/trainers/sig_wgan.py, SigWGANTrainer.fit()
"""
# Adam optimizer (paper default)
optimizer = optim.Adam(generator.parameters(), lr=config["learning_rate"])
# StepLR scheduler: gamma=0.95, step_size=128 (from paper)
scheduler = optim.lr_scheduler.StepLR(
optimizer,
step_size=config["lr_scheduler_step"],
gamma=config["lr_scheduler_gamma"],
)
# Convert expected sig to tensor (target)
target_sig = torch.tensor(expected_sig_real, dtype=torch.float32, device=device)
# Training params
total_steps = config["total_steps"]
batch_size = config["batch_size"]
n_lags = config["n_lags"]
sig_depth = config["sig_depth"]
normalise = config["normalise_sig"]
losses = []
best_loss = None
best_state = None
pbar = tqdm(range(total_steps), desc=f"Training ({total_steps} steps)")
for step in pbar:
optimizer.zero_grad()
# =================================================================
# PAPER ALGORITHM: Generate batch, compute Sig-W1 loss
# =================================================================
# x_fake = G(batch_size, n_lags, device)
# expected_sig_fake = compute_expected_signature(x_fake, ...)
# loss = rmse(expected_sig_real, expected_sig_fake)
# =================================================================
# Generate batch of fake paths (unconditional!)
x_fake = generator(batch_size, n_lags, device)
# Compute expected signature of fake paths (with factorial normalization)
expected_sig_fake = compute_expected_signature_gpu(
x_fake, sig_depth, config, normalise=normalise
)
# Sig-W1 loss: RMSE (L2 norm), NOT squared MSE
loss = torch.sqrt(torch.sum((expected_sig_fake - target_sig) ** 2))
loss.backward()
optimizer.step()
scheduler.step()
loss_val = loss.item()
losses.append(loss_val)
# Track best model
if best_loss is None or loss_val < best_loss:
best_loss = loss_val
best_state = {k: v.cpu().clone() for k, v in generator.state_dict().items()}
# Logging (every 100 steps, matching paper)
if (step + 1) % 100 == 0:
current_lr = scheduler.get_last_lr()[0]
pbar.set_postfix({"Sig-W1": f"{loss_val:.2e}", "LR": f"{current_lr:.2e}"})
print(f" Step {step + 1}: sig-w1 loss: {loss_val:.2e}", flush=True)
# Load best model (paper does this)
if best_state is not None:
generator.load_state_dict(best_state)
print(f"\nLoaded best model (loss: {best_loss:.2e})")
return losses
# %% [markdown]
# ### Run Training
#
# Execute the Sig-W1 optimization loop with paper-exact settings.
# %%
# Check if checkpoint exists and RETRAIN is False
SKIP_TRAINING = CHECKPOINT_PATH.exists() and not RETRAIN
if SKIP_TRAINING:
print("\n" + "=" * 70)
print("LOADING CHECKPOINT (set RETRAIN=True to force retraining)")
print("=" * 70)
print(f"\nCheckpoint path: {CHECKPOINT_PATH}")
# Load checkpoint
checkpoint = torch.load(CHECKPOINT_PATH, map_location=device, weights_only=False)
generator.load_state_dict(checkpoint["generator_state_dict"])
expected_sig_real = checkpoint["expected_sig_real"]
training_losses = checkpoint.get("training_history", [])
print("Loaded generator weights from checkpoint")
print(f"Training history: {len(training_losses)} steps")
if training_losses:
print(f"Final loss: {training_losses[-1]:.4f}")
print(f"Best loss: {min(training_losses):.4f}")
else:
# Train the generator
print("\n" + "=" * 70)
print("TRAINING SIG-WGAN (PAPER-EXACT IMPLEMENTATION)")
print("=" * 70)
print("\nSettings from configs/STOCKS/SigWGAN.json:")
print(f" Total gradient steps: {CONFIG['total_steps']} (paper: 2500)")
print(f" Batch size: {CONFIG['batch_size']} (paper: 2000)")
print(f" Learning rate: {CONFIG['learning_rate']} (paper: 1e-3)")
print(
f" LR scheduler: StepLR(step={CONFIG['lr_scheduler_step']}, gamma={CONFIG['lr_scheduler_gamma']})"
)
print(f" Signature depth: {CONFIG['sig_depth']} (paper: 4)")
print(f" Factorial normalization: {CONFIG['normalise_sig']}")
print(f" Path length (n_lags): {CONFIG['n_lags']} (paper: 16)")
print("\nAugmentation pipeline:")
print(" Scale(2, dim=0) → AddTime → LeadLag → VisiTrans('I')")
print("\nGenerator: LSTMGenerator with Brownian motion noise")
print(f" Device: {device}")
training_losses = train_sigwgan_paper(generator, expected_sig_real, CONFIG, device)
# %%
# Save checkpoint after training (skip if loaded from checkpoint)
if not SKIP_TRAINING:
print("\nSaving checkpoint...")
CHECKPOINT_PATH.parent.mkdir(parents=True, exist_ok=True)
checkpoint = {
"generator_state_dict": generator.state_dict(),
"expected_sig_real": expected_sig_real,
"training_history": training_losses,
"config": CONFIG,
"train_mean": train_mean.tolist(),
"train_std": train_std.tolist(),
}
torch.save(checkpoint, CHECKPOINT_PATH)
print(f"Checkpoint saved to: {CHECKPOINT_PATH}")
# %% [markdown]
# ## 8. Training Progress Visualization
# %%
fig = go.Figure()
fig.add_trace(
go.Scatter(y=training_losses, mode="lines", name="Sig-W1 Loss", line=dict(color=COLORS["blue"]))
)
fig.update_layout(
title=f"Sig-WGAN Training Progress ({CONFIG['total_steps']} Steps, Paper Implementation)",
xaxis_title="Generator Update Step",
yaxis_title="Sig-W1 Distance (RMSE)",
template="ml4t",
)
fig.show()
print(f"\nFinal Sig-W1 loss: {training_losses[-1]:.4f}")
print(f"Best Sig-W1 loss: {min(training_losses):.4f}")
print("Paper reference: ~2.76 (S&P 500 log returns, 2005-2020)")
print("\nUsing paper-exact data source: S&P 500 index log returns")
# %% [markdown]
# **Interpretation**: The Sig-W1 loss measures the L2 distance between expected
# signatures of real and generated paths. A decreasing loss curve confirms the
# generator is learning to produce paths whose signature statistics match the
# training data. Compare the final value to the paper's reference of ~2.76 on
# S&P 500 log returns.
# %% [markdown]
# ## 9. Generate Synthetic Paths
#
# The LSTM generator produces paths UNCONDITIONALLY - no conditioning on past.
# Each call generates a fresh batch of paths from Brownian motion noise.
# %%
def generate_paths(
generator: nn.Module,
n_samples: int,
n_lags: int,
device: torch.device,
) -> np.ndarray:
"""
Generate synthetic paths (UNCONDITIONAL).
The generator takes batch_size, n_lags, and device - no conditioning input.
Args:
generator: Trained LSTMGenerator
n_samples: Number of paths to generate
n_lags: Length of each path
device: Torch device
Returns:
Shape (n_samples, n_lags, n_assets)
"""
generator.eval()
with torch.no_grad():
paths = generator(n_samples, n_lags, device)
return paths.cpu().numpy()
# Generate synthetic paths
n_synthetic = len(train_windows) # Match training set size
synthetic_paths = generate_paths(generator, n_synthetic, CONFIG["n_lags"], device)
print(f"Generated {n_synthetic} synthetic paths")
print(f"Synthetic paths shape: {synthetic_paths.shape}")
# Also generate for holdout comparison
n_holdout_gen = len(holdout_windows)
synthetic_holdout = generate_paths(generator, n_holdout_gen, CONFIG["n_lags"], device)
print(f"Generated {n_holdout_gen} synthetic paths for holdout comparison")
# %% [markdown]
# ## 10. Evaluation
#
# ### 10.1 Fidelity: Visual Comparison with PCA and t-SNE
#
# We project both real and synthetic paths into 2D to assess whether the
# generator covers the same regions of the data manifold.
# %%
fig = plot_fidelity_comparison(
holdout_windows,
synthetic_holdout,
title="Sig-CWGAN: Real vs Synthetic Distribution",
n_samples=min(500, len(holdout_windows)),
flatten_method="flatten", # Flatten entire path for comparison
)
plt.show()
# %% [markdown]
# **Interpretation**: Overlapping point clouds confirm that synthetic paths occupy
# the same region of feature space as real data. Sig-CWGAN's strength is capturing
# path-wise properties via signatures -- the signature-based metrics below provide
# a more rigorous assessment than visual inspection alone.
# %% [markdown]
# ### 10.2 Compare Signature Distributions
#
# **Paper Evaluation**: Compare expected signatures of real vs synthetic paths.
# This is the same metric used for training (Sig-W1).
# %%
def evaluate_sig_w1(
real_paths: np.ndarray,
synthetic_paths: np.ndarray,
depth: int,
config: dict,
normalise: bool = True,
) -> dict:
"""
Evaluate using paper's Sig-W1 metric.
Computes:
- Sig-W1 distance (expected signature RMSE)
- Per-coordinate analysis
- Marginal statistics comparison
"""
# Compute expected signatures using paper pipeline
print("Computing real expected signature...")
real_sigs = compute_signatures_batch_paper(
real_paths, depth, config, normalise=normalise, desc="Real"
)
expected_sig_real = real_sigs.mean(axis=0)
print("Computing synthetic expected signature...")
syn_sigs = compute_signatures_batch_paper(
synthetic_paths, depth, config, normalise=normalise, desc="Synthetic"
)
expected_sig_syn = syn_sigs.mean(axis=0)
# Sig-W1 distance (paper metric)
sig_w1 = np.sqrt(np.sum((expected_sig_real - expected_sig_syn) ** 2))
# Per-coordinate analysis
diff = expected_sig_real - expected_sig_syn
per_coord_mae = np.abs(diff).mean()
per_coord_max = np.abs(diff).max()
# Marginal statistics
real_mean = real_paths.mean()
syn_mean = synthetic_paths.mean()
real_std = real_paths.std()
syn_std = synthetic_paths.std()
# KS statistic on marginals
real_flat = real_paths.flatten()
syn_flat = synthetic_paths.flatten()
ks_stat, _ = stats.ks_2samp(real_flat, syn_flat)
return {
"sig_w1": sig_w1,
"per_coord_mae": per_coord_mae,
"per_coord_max": per_coord_max,
"sig_dim": len(expected_sig_real),
"ks_statistic": ks_stat,
"real_mean": real_mean,
"syn_mean": syn_mean,
"real_std": real_std,
"syn_std": syn_std,
}
# %%
# Evaluate on holdout data
print("\n=== Sig-W1 Evaluation (Holdout Data) ===")
eval_results = evaluate_sig_w1(
holdout_windows,
synthetic_holdout,
CONFIG["sig_depth"],
CONFIG,
normalise=CONFIG["normalise_sig"],
)
print("\n--- Results ---")
print(f"Sig-W1 Distance: {eval_results['sig_w1']:.4f}")
print(" (Paper reference: ~2.76 on S&P 500 log returns)")
print(f"Signature dimension: {eval_results['sig_dim']}")
print(f"Per-coordinate MAE: {eval_results['per_coord_mae']:.6f}")
print(f"Per-coordinate Max Error: {eval_results['per_coord_max']:.6f}")
print("\nMarginal Statistics:")
print(f" Real mean: {eval_results['real_mean']:.6f}, std: {eval_results['real_std']:.6f}")
print(f" Synth mean: {eval_results['syn_mean']:.6f}, std: {eval_results['syn_std']:.6f}")
print(f" KS statistic: {eval_results['ks_statistic']:.4f}")
# %% [markdown]
# ### Signature Analysis: Why Factorial Normalization Matters
#
# Without factorial normalization, signature levels have vastly different scales:
# - Level 1: O(1) magnitude
# - Level 2: O(1/2!) magnitude
# - Level 3: O(1/3!) magnitude
# - Level 4: O(1/4!) magnitude
#
# The paper multiplies each level by k! to bring them to comparable scales.
# %%
# Diagnostic: Signature level analysis
print("\n=== Signature Level Analysis ===")
# Get expected signatures (already computed)
real_sigs_sample = compute_signatures_batch_paper(
holdout_windows[:100],
CONFIG["sig_depth"],
CONFIG,
normalise=CONFIG["normalise_sig"],
desc="Sample",
)
syn_sigs_sample = compute_signatures_batch_paper(
synthetic_holdout[:100],
CONFIG["sig_depth"],
CONFIG,
normalise=CONFIG["normalise_sig"],
desc="Sample",
)
# Analyze by signature level
path_dim = compute_path_dim_paper(n_assets)
print(f"Path dimension after augmentations: {path_dim}")
count = 0
for level in range(1, CONFIG["sig_depth"] + 1):
level_size = path_dim**level
level_real = real_sigs_sample[:, count : count + level_size]
level_syn = syn_sigs_sample[:, count : count + level_size]
real_mag = np.abs(level_real).mean()
syn_mag = np.abs(level_syn).mean()
level_error = np.abs(level_real.mean(0) - level_syn.mean(0)).mean()
print(f"\nLevel {level} ({level_size} dims):")
print(f" Real magnitude: {real_mag:.4f}")
print(f" Synth magnitude: {syn_mag:.4f}")
print(f" Mean error: {level_error:.4f}")
print(f" Factorial factor: {math.factorial(level)}")
count += level_size
# %% [markdown]
# ## 11. Visualization: Real vs Synthetic Paths
#
# Compare the distributional properties of real and synthetic paths.
# %%
def plot_path_comparison_unconditional(
real_paths: np.ndarray,
synthetic_paths: np.ndarray,
n_samples: int = 20,
asset_idx: int = 0,
) -> go.Figure:
"""Compare distributions of real and synthetic paths."""
fig = make_subplots(
rows=1,
cols=2,
subplot_titles=["Real Paths", "Synthetic Paths"],
shared_yaxes=True,
)
# Convert to cumsum for visualization
n_lags = real_paths.shape[1]
real_cumsums = np.stack(
[real_paths[i, :, asset_idx].cumsum() for i in range(min(n_samples, len(real_paths)))]
)
synth_cumsums = np.stack(
[
synthetic_paths[i, :, asset_idx].cumsum()
for i in range(min(n_samples, len(synthetic_paths)))
]
)
# Plot real paths
for i, cumsum_path in enumerate(real_cumsums):
fig.add_trace(
go.Scatter(
x=list(range(n_lags)),
y=cumsum_path,
mode="lines",
line=dict(color=COLORS["blue"], width=0.5),
opacity=0.3,
showlegend=(i == 0),
name="Real" if i == 0 else None,
),
row=1,
col=1,
)
# Plot synthetic paths
for i, cumsum_path in enumerate(synth_cumsums):
fig.add_trace(
go.Scatter(
x=list(range(n_lags)),
y=cumsum_path,
mode="lines",
line=dict(color=COLORS["copper"], width=0.5),
opacity=0.3,
showlegend=(i == 0),
name="Synthetic" if i == 0 else None,
),
row=1,
col=2,
)
# Shared y-axis range so visual spread is comparable across panels
y_lo = float(min(real_cumsums.min(), synth_cumsums.min()))
y_hi = float(max(real_cumsums.max(), synth_cumsums.max()))
pad = 0.05 * (y_hi - y_lo if y_hi > y_lo else 1.0)
shared_range = [y_lo - pad, y_hi + pad]
fig.update_layout(
title=f"Sig-WGAN: Path Comparison (Asset {asset_idx})",
template="ml4t",
height=400,
yaxis=dict(range=shared_range),
yaxis2=dict(range=shared_range),
)
fig.update_xaxes(title_text="Day", row=1, col=1)
fig.update_xaxes(title_text="Day", row=1, col=2)
fig.update_yaxes(title_text="Cumulative Return", row=1, col=1)
return fig
# %%
# Plot comparison
fig = plot_path_comparison_unconditional(
holdout_windows, synthetic_holdout, n_samples=30, asset_idx=0
)
fig.show()
# %% [markdown]
# **Interpretation**: Both panels should show paths with similar volatility
# and drift characteristics. Synthetic paths that are too smooth or too volatile
# indicate the generator has not fully captured the return distribution.
# The cumulative sum visualization amplifies differences: look for matching
# spread (volatility) and symmetry around zero (mean accuracy).
# %% [markdown]
# ## 12. TSTR Evaluation: Train Synthetic, Test Real
#
# TSTR (Train Synthetic, Test Real) measures whether a model trained on
# synthetic data can predict real outcomes as well as a model trained on
# real data.
#
# For unconditional generation, we use a next-step prediction task.
# %%
def tstr_evaluation_unconditional(
real_train: np.ndarray,
real_holdout: np.ndarray,
synthetic_train: np.ndarray,
) -> dict:
"""
TSTR evaluation for unconditional generation.
Task: Predict sign of next return from path features.
"""
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
# Feature engineering: use path statistics as features
def extract_features(paths):
"""Extract statistical features from paths."""
features = []
for path in paths:
# Rolling statistics
feat = [
path.mean(),
path.std(),
path[-1, 0] - path[0, 0], # Total return asset 0
path[-1, 1] - path[0, 1] if path.shape[1] > 1 else 0, # Asset 1
np.corrcoef(path[:, 0], path[:, 1])[0, 1] if path.shape[1] > 1 else 0,
]
features.append(feat)
return np.array(features)
# Extract labels: sign of last return
def extract_labels(paths):
"""Label: was last return positive?"""
return (paths[:, -1, 0] > 0).astype(int)
# Prepare data
X_real_train = extract_features(real_train)
y_real_train = extract_labels(real_train)
X_syn_train = extract_features(synthetic_train)
y_syn_train = extract_labels(synthetic_train)
X_test = extract_features(real_holdout)
y_test = extract_labels(real_holdout)
# Handle NaN in correlation features
X_real_train = np.nan_to_num(X_real_train, nan=0.0)
X_syn_train = np.nan_to_num(X_syn_train, nan=0.0)
X_test = np.nan_to_num(X_test, nan=0.0)
# Scale features
scaler = StandardScaler()
X_real_train_scaled = scaler.fit_transform(X_real_train)
X_syn_train_scaled = scaler.transform(X_syn_train)
X_test_scaled = scaler.transform(X_test)
# Train on real, test on holdout (baseline)
model_real = LogisticRegression(max_iter=1000)
model_real.fit(X_real_train_scaled, y_real_train)
acc_real = model_real.score(X_test_scaled, y_test)
# Train on synthetic, test on holdout (TSTR)
n_classes_syn = len(np.unique(y_syn_train))
if n_classes_syn < 2:
majority_class = int(y_syn_train.mean() > 0.5)
acc_syn = (y_test == majority_class).mean()
print(f"Warning: Synthetic data has only {n_classes_syn} class, using majority vote")
else:
model_syn = LogisticRegression(max_iter=1000)
model_syn.fit(X_syn_train_scaled, y_syn_train)
acc_syn = model_syn.score(X_test_scaled, y_test)
return {
"accuracy_train_real": acc_real,
"accuracy_train_synthetic": acc_syn,
"tstr_ratio": acc_syn / acc_real if acc_real > 0 else 0,
"n_train_samples": len(y_real_train),
"n_test_samples": len(y_test),
}
# %%
tstr_results = tstr_evaluation_unconditional(train_windows, holdout_windows, synthetic_paths)
print("\n=== TSTR Evaluation (Holdout Test Set) ===")
print("Task: Predict sign of last return from path features")
print(f"Training samples: {tstr_results['n_train_samples']}")
print(f"Test samples: {tstr_results['n_test_samples']}")
print(f"Accuracy (trained on REAL): {tstr_results['accuracy_train_real']:.4f}")
print(f"Accuracy (trained on SYNTHETIC): {tstr_results['accuracy_train_synthetic']:.4f}")
print(f"TSTR Ratio: {tstr_results['tstr_ratio']:.4f}")
print("\n(TSTR ratio near 1.0 indicates synthetic data preserves predictive utility)")
# %% [markdown]
# **Interpretation**: A TSTR ratio near 1.0 means a classifier trained on
# synthetic data performs as well on real holdout data as one trained on actual
# data. For return-sign prediction (a near-random task), ratios close to 1.0
# are expected even with moderate generation quality. The key diagnostic is
# whether the synthetic-trained model significantly underperforms the
# real-trained baseline.
# %% [markdown]
# ## 13. Summary Visualization
# %%
fig = make_subplots(
rows=1,
cols=2,
subplot_titles=["Sig-W1 and Marginal Metrics", "TSTR Performance"],
)
# Left: Signature and marginal metrics
metrics = ["Sig-W1 (÷10)", "KS Statistic", "Mean Error"]
values = [
eval_results["sig_w1"] / 10, # Scale down for visualization
eval_results["ks_statistic"],
abs(eval_results["real_mean"] - eval_results["syn_mean"]),
]
fig.add_trace(
go.Bar(
x=metrics,
y=values,
marker_color=[COLORS["blue"], COLORS["amber"], COLORS["slate"]],
text=[f"{v:.4f}" for v in values],
textposition="outside",
),
row=1,
col=1,
)
# Right: TSTR
methods = ["Train Real", "Train Synthetic"]
accuracies = [
tstr_results["accuracy_train_real"],
tstr_results["accuracy_train_synthetic"],
]
fig.add_trace(
go.Bar(
x=methods,
y=accuracies,
marker_color=[COLORS["blue"], COLORS["copper"]],
text=[f"{a:.3f}" for a in accuracies],
textposition="outside",
),
row=1,
col=2,
)
fig.update_yaxes(title_text="Metric value (lower is better)", row=1, col=1)
fig.update_yaxes(title_text="Classifier accuracy", row=1, col=2, range=[0, 1.05])
fig.update_layout(
title_text="Sig-WGAN Evaluation Summary (Paper Implementation)",
showlegend=False,
template="ml4t",
)
fig.show()
# %% [markdown]
# **Interpretation**: The left panel reports three distributional checks - the
# signature-level Sig-W1 distance (scaled down by 10 for visual co-display), the
# marginal Kolmogorov-Smirnov statistic, and the absolute mean-error between real
# and synthetic samples. A near-zero Mean Error bar is good news (centred
# generation), not a missing value - the bar is small because the generator has
# learned the marginal location. The right panel reports TSTR: classifier
# accuracy when trained on real vs synthetic. The two bars should sit at similar
# heights for utility preservation; a gap exceeding ~0.05 indicates the
# synthetic data is missing discriminative structure.
# %% [markdown]
# ## 13b. Additional Evaluation Metrics
#
# Beyond Sig-W1, we evaluate:
# - Autocorrelation structure (ACF)
# - Cross-asset correlation
# - Stylized facts (kurtosis, skewness)
# %%
def compute_stylized_facts(paths: np.ndarray) -> dict:
"""Compute stylized facts of financial returns."""
# Flatten to get marginal distribution
returns = paths.flatten()
# Basic statistics
mean = np.mean(returns)
std = np.std(returns)
skew = stats.skew(returns)
kurt = stats.kurtosis(returns)
# ACF of returns and squared returns
def acf(x, nlags=10):
"""Simple ACF computation."""
result = []
x_centered = x - x.mean()
var = np.var(x)
for lag in range(nlags + 1):
if lag == 0:
result.append(1.0)
else:
result.append(np.corrcoef(x_centered[:-lag], x_centered[lag:])[0, 1])
return np.array(result)
acf_returns = acf(returns, nlags=5)
acf_squared = acf(returns**2, nlags=5)
return {
"mean": mean,
"std": std,
"skewness": skew,
"kurtosis": kurt,
"acf_returns_lag1": acf_returns[1] if len(acf_returns) > 1 else np.nan,
"acf_squared_lag1": acf_squared[1] if len(acf_squared) > 1 else np.nan,
}
print("=" * 70)
print("STYLIZED FACTS COMPARISON")
print("=" * 70)
real_facts = compute_stylized_facts(holdout_windows)
syn_facts = compute_stylized_facts(synthetic_holdout)
print("\n{:<25} {:>12} {:>12}".format("Metric", "Real", "Synthetic"))
print("-" * 50)
for key in real_facts:
print(f"{key:<25} {real_facts[key]:>12.4f} {syn_facts[key]:>12.4f}")
# Cross-correlation comparison
if n_assets >= 2:
real_corr = np.corrcoef(holdout_windows[:, :, 0].flatten(), holdout_windows[:, :, 1].flatten())[
0, 1
]
syn_corr = np.corrcoef(
synthetic_holdout[:, :, 0].flatten(), synthetic_holdout[:, :, 1].flatten()
)[0, 1]
print(f"\nCross-asset correlation: Real={real_corr:.4f}, Synth={syn_corr:.4f}")
# %% [markdown]
# **Interpretation**: Key stylized facts to check: (1) kurtosis should be >3
# (fat tails), matching the well-known leptokurtic property of financial returns;
# (2) negative skewness reflects asymmetric crash risk; (3) near-zero lag-1 ACF
# of returns but positive lag-1 ACF of squared returns captures volatility
# clustering. Large deviations in any metric indicate the generator misses
# important market microstructure.
# %% [markdown]
# ## 14. Save Outputs
# %%
# Output directory
output_dir = get_output_dir(5, "sigcwgan")
checkpoint_subdir = "checkpoints"
checkpoint_dir = output_dir / checkpoint_subdir / "sigwgan_paper" / f"depth{CONFIG['sig_depth']}"
checkpoint_dir.mkdir(parents=True, exist_ok=True)
# Save model checkpoint
checkpoint = {
"generator": generator.state_dict(),
"expected_sig_real": expected_sig_real,
"train_mean": train_mean.tolist(), # For reference only (no normalization used)
"train_std": train_std.tolist(), # For reference only (no normalization used)
}
torch.save(checkpoint, checkpoint_dir / "checkpoint.pt")
# %%
# Save metadata — core fields
metadata = {
"version": "2.0", # Paper-exact implementation
"generator": {
"name": "sigwgan_paper",
"architecture": "LSTMGenerator",
"paper": "Ni et al., Sig-Wasserstein GANs for Time Series Generation, Mathematical Finance 2024",
},
"training": {
"created_at": datetime.now(UTC).isoformat(),
"device": str(device),
"random_seed": 42,
"final_loss": float(training_losses[-1]),
"best_loss": float(min(training_losses)),
},
"data": {
"source": "sp500_index",
"description": "S&P 500 daily log returns (paper-exact)",
"start_date": CONFIG["start_date"],
"end_date": CONFIG.get("end_date", "present"),
"holdout_start": CONFIG["holdout_start"],
"n_lags": int(CONFIG["n_lags"]),
"n_features": int(n_assets),
"n_total_days": int(n_total_days),
"n_train_windows": int(len(train_windows)),
"n_holdout_windows": int(len(holdout_windows)),
"split_method": "temporal_at_date",
},
"normalization": {
"method": "none",
"note": "Paper uses raw log returns without normalization",
"train_mean": train_mean.tolist(),
"train_std": train_std.tolist(),
},
}
# %%
# Save metadata — hyperparameters, augmentation spec, and evaluation results
metadata["hyperparameters"] = {
"sig_depth": int(CONFIG["sig_depth"]),
"lstm_hidden_dim": int(CONFIG["lstm_hidden_dim"]),
"lstm_n_layers": int(CONFIG["lstm_n_layers"]),
"noise_dim": int(CONFIG["noise_dim"]),
"total_steps": int(CONFIG["total_steps"]),
"batch_size": int(CONFIG["batch_size"]),
"learning_rate": float(CONFIG["learning_rate"]),
"lr_scheduler_step": int(CONFIG["lr_scheduler_step"]),
"lr_scheduler_gamma": float(CONFIG["lr_scheduler_gamma"]),
"normalise_sig": bool(CONFIG["normalise_sig"]),
}
metadata["augmentation_pipeline"] = [
{"name": "Scale", "scale": CONFIG["scale_factor"], "dim": CONFIG["scale_dim"]},
{"name": "AddTime"},
{"name": "LeadLag"},
{"name": "VisiTrans", "type": "I"},
]
metadata["signature_spec"] = {
"depth": int(CONFIG["sig_depth"]),
"path_dim": compute_path_dim_paper(n_assets),
"sig_dim": compute_sig_dim_paper(n_assets, CONFIG["sig_depth"]),
"factorial_normalization": bool(CONFIG["normalise_sig"]),
}
metadata["evaluation"] = {
"sig_w1": float(eval_results["sig_w1"]),
"ks_statistic": float(eval_results["ks_statistic"]),
"tstr_ratio": float(tstr_results["tstr_ratio"]),
"paper_reference_sig_w1": 2.76, # For comparison
}
with open(checkpoint_dir / "metadata.json", "w") as f:
json.dump(metadata, f, indent=2)
# %%
# Save pre-generated samples (already in log return scale, no denormalization needed)
np.save(checkpoint_dir / "samples.npy", synthetic_paths.astype(np.float32))
print("\nSample statistics (log returns scale):")
print(f" Mean: {synthetic_paths.mean():.6f}, Std: {synthetic_paths.std():.6f}")
print(f" Range: [{synthetic_paths.min():.6f}, {synthetic_paths.max():.6f}]")
# Save holdout data for external evaluation
np.save(checkpoint_dir / "holdout_returns.npy", holdout_returns_original)
np.save(checkpoint_dir / "train_returns.npy", returns_original)
print("\nSaved outputs to:")
print(f" {checkpoint_dir}/")
# %% [markdown]
# ## Key Takeaways
#
# **What Sig-WGAN Offers**:
#
# 1. **No Adversarial Training**: The "discriminator" is the expected signature,
# eliminating mode collapse and training instability
#
# 2. **Principled Distance**: Path signatures provide a mathematically rigorous
# way to compare time series distributions
#
# 3. **Factorial Normalization**: Rescales signature levels to comparable magnitudes,
# critical for stable training and meaningful loss values
#
# **PAPER-EXACT Implementation** (Ni et al., Mathematical Finance 2024):
#
# 1. **Generator**: LSTMGenerator with Brownian motion noise input
# - NOT AR-FNN with independent Gaussian noise
# - Noise: `z = 0.1 * randn(...).cumsum(1)` (Brownian path)
#
# 2. **Augmentations**: Scale(2,0) → AddTime → LeadLag → VisiTrans("I")
# - NOT cumsum_concat → lag_added → lead_lag
# - Critical: VisiTrans adds 2 rows + 1 column
#
# 3. **Factorial Normalization**: Level k terms multiplied by k!
# - Without this, higher levels have tiny magnitudes
# - Paper: `sig[count:count+dim**(i+1)] *= factorial(i+1)`
#
# 4. **Hyperparameters**: batch=2000, depth=4, lr=1e-3, steps=2500
# - Scheduler: StepLR(128, 0.95)
#
# 5. **Loss**: RMSE (L2 norm), NOT squared MSE
# - `loss = sqrt(sum((sig_fake - sig_real)**2))`
#
# **Paper-Exact Data Source**:
#
# This implementation now uses the SAME data as the paper:
# - S&P 500 index daily close prices (paper used Oxford MAN, now defunct)
# - Log returns: `log(close_t) - log(close_{t-1})`
# - Single asset (paper's `.SPX`)
# - Period: 2005-01-01 to 2020-06-01
#
# **Paper reference values** (Ni et al., 2024, on S&P 500 log returns):
#
# | Metric | Paper Reference |
# |--------|-----------------|
# | Sig-W1 | ~2.76 |
# | TSTR | ~1.0 |
#
# The Sig-W1 scale depends on signature depth, augmentation choices, and the
# magnitude of the input series, so direct numeric comparison to the paper is
# only meaningful when those settings match. The values printed above in the
# evaluation cell are the achieved metrics on the run in this notebook.
#
# **Limitations**:
#
# 1. **Signature Cost**: O(d^depth) where d = path dimension after augmentations
# 2. **Small Universe**: 1-5 assets max at depth 4 (exponential scaling)
# 3. **Single Asset**: Paper only demonstrates on single-asset time series