2249 lines
82 KiB
Python
2249 lines
82 KiB
Python
# ---
|
||
# 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]
|
||
# # Diffusion-TS: Interpretable Diffusion with Conditional Generation
|
||
#
|
||
# **Chapter 5: Synthetic Data Generation**
|
||
# **Section Reference**: Section 5.5 (Diffusion Models for Financial Time Series)
|
||
#
|
||
# **Docker image**: `ml4t-gpu`
|
||
#
|
||
# > **GPU recommended**: This notebook trains models with PyTorch/CUDA. It will run on CPU
|
||
# > but training may be very slow. For GPU acceleration:
|
||
# > ```bash
|
||
# > docker compose run --rm ml4t-gpu python 05_synthetic_data/05_diffusion_ts.py
|
||
# > ```
|
||
#
|
||
#
|
||
# ## Purpose
|
||
#
|
||
# This notebook implements **Diffusion-TS** (Yuan & Qiao, ICLR 2024), a diffusion
|
||
# model that decomposes the denoising prediction into **trend** (polynomial regression)
|
||
# and **seasonal** (Fourier basis) components. This interpretable structure encourages
|
||
# the model to separate slow drift from periodic patterns, analogous to classical STL
|
||
# decomposition but learned end-to-end within the diffusion framework.
|
||
#
|
||
# Unlike vanilla diffusion models that predict noise $\varepsilon$, Diffusion-TS
|
||
# predicts $x_0$ directly. The Fourier-domain loss further regularizes spectral
|
||
# fidelity -- critical for preserving autocorrelation structure in financial returns.
|
||
#
|
||
# ## Learning Objectives
|
||
#
|
||
# By completing this notebook, you will:
|
||
# - Implement a diffusion model with **interpretable trend+seasonal decomposition**
|
||
# - Train with a combined **time-domain + Fourier-domain** loss
|
||
# - Use **DDIM** fast sampling to reduce generation from 500 to 50 reverse steps
|
||
# - Build a **regime classifier** on noised data and apply **classifier guidance**
|
||
# to generate regime-conditional synthetic returns
|
||
# - Evaluate unconditional and conditional generation quality
|
||
#
|
||
# ## Cross-References
|
||
#
|
||
# - **Upstream**: ETF Universe loader (`data`)
|
||
# - **Downstream**: Regime-conditioned synthetic data for stress testing (Ch 20)
|
||
# - **Book**: Section 5.5 discusses diffusion + conditional generation
|
||
# - **Related**: [`02_tailgan_tail_risk`](02_tailgan_tail_risk.ipynb) (GAN), [`03_sigcwgan_signatures`](03_sigcwgan_signatures.ipynb) (GAN)
|
||
#
|
||
# ---
|
||
#
|
||
# ## From GANs to Interpretable Diffusion
|
||
#
|
||
# GANs face training instability, mode collapse, and limited interpretability.
|
||
# Diffusion models solve the first two by replacing adversarial training with a
|
||
# simple regression objective. Diffusion-TS goes further by decomposing the
|
||
# denoising network's output into components with known semantics:
|
||
#
|
||
# $$\hat{x}_0 = \text{Trend}(z) + \text{Season}(z) + \text{Residual}(z)$$
|
||
#
|
||
# where $z$ is the latent representation at diffusion step $t$. The trend block
|
||
# uses polynomial regression, the seasonal block selects top-$k$ Fourier modes,
|
||
# and the residual captures everything else. This makes the model's behavior
|
||
# inspectable -- you can visualize what the model attributes to drift versus
|
||
# cyclical patterns.
|
||
#
|
||
# ## References
|
||
#
|
||
# - **Paper**: Yuan, X. & Qiao, Y. (2024). "Diffusion-TS: Interpretable Diffusion
|
||
# for General Time Series Generation." ICLR 2024.
|
||
# - **Code**: https://github.com/Y-debug-sys/Diffusion-TS
|
||
#
|
||
# ## Key Adaptation Decisions
|
||
#
|
||
# 1. **No clamp(-1, 1)**: The original code clamps predicted $x_0$ to [-1, 1] for
|
||
# image-like data. Financial returns are StandardScaler-normalized (unbounded),
|
||
# so we remove the clamp entirely.
|
||
# 2. **Predict $x_0$** (not noise): The model outputs $\hat{x}_0 = \text{trend} +
|
||
# \text{season}$, then derives noise analytically. This pairs naturally with the
|
||
# decomposition.
|
||
# 3. **Fourier loss preserved**: The frequency-domain regularizer matches spectral
|
||
# properties -- essential for autocorrelation fidelity in returns.
|
||
# 4. **Classifier guidance**: A separate Transformer classifier trained on noised
|
||
# sequences enables regime-conditional generation at sampling time.
|
||
|
||
# %%
|
||
"""Diffusion-TS: Interpretable Diffusion with Conditional Generation."""
|
||
|
||
import json
|
||
import math
|
||
import warnings
|
||
from copy import deepcopy
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
|
||
warnings.filterwarnings("ignore")
|
||
|
||
import matplotlib.pyplot as plt
|
||
import numpy as np
|
||
import polars as pl
|
||
import seaborn as sns
|
||
import torch
|
||
import torch.nn as nn
|
||
import torch.nn.functional as F
|
||
from einops import rearrange, reduce, repeat
|
||
from hmmlearn.hmm import GaussianHMM
|
||
from IPython.display import Image, display
|
||
from scipy import stats
|
||
from scipy.stats import kurtosis as calc_kurtosis
|
||
from sklearn.preprocessing import StandardScaler
|
||
from torch.utils.data import DataLoader, TensorDataset
|
||
|
||
from data import load_etfs
|
||
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
|
||
|
||
# %% [markdown]
|
||
# ## Diffusion Process Overview
|
||
#
|
||
# Diffusion models work by gradually adding noise (forward process) and then
|
||
# learning to reverse it (denoising). Diffusion-TS adds interpretable
|
||
# trend+seasonal decomposition to the denoiser.
|
||
|
||
# %%
|
||
ASSETS_DIR = get_chapter_dir(5) / "assets"
|
||
if (ASSETS_DIR / "diffusion_forward_reverse.jpeg").exists():
|
||
display(Image(ASSETS_DIR / "diffusion_forward_reverse.jpeg", width=800))
|
||
|
||
# %% [markdown]
|
||
# ## Configuration
|
||
#
|
||
# The original Diffusion-TS paper uses seq_length=24, 6 stocks, and 10,000 epochs.
|
||
# We adapt for financial applications with the following considerations:
|
||
#
|
||
# | Parameter | Paper | Ours | Rationale |
|
||
# |-----------|-------|------|-----------|
|
||
# | seq_length | 24 | 60 | ~3 months of trading context |
|
||
# | feature_size | 6 | 20 | Balance diversity vs complexity |
|
||
# | epochs | 10,000 | 10,000 | Match paper for convergence |
|
||
# | lr | 1e-5→8e-4 | 1e-5→8e-4 | Warmup schedule from paper |
|
||
#
|
||
# With 20 assets × 60 timesteps = 1,200 values per sample (vs paper's 144),
|
||
# we train longer to capture the richer cross-asset structure.
|
||
|
||
# %% tags=["parameters"]
|
||
# Production defaults (Yuan & Qiao, ICLR 2024)
|
||
SEQ_LENGTH = 60 # ~3 months of trading context (paper uses 24)
|
||
FEATURE_SIZE = 20 # Number of ETFs (paper uses 6)
|
||
N_LAYER_DEC = 4 # Decoder layers (paper uses 2; we use 4 for more capacity)
|
||
TIMESTEPS = 500 # Forward diffusion steps
|
||
SAMPLING_TIMESTEPS = 50 # DDIM fast sampling steps
|
||
EPOCHS = 10000 # Training epochs (paper uses 10000)
|
||
BATCH_SIZE = 64 # Training batch size
|
||
WARMUP_STEPS = 500 # LR warmup steps
|
||
GRADIENT_ACCUMULATE_EVERY = 2 # Gradient accumulation steps
|
||
CLASSIFIER_EPOCHS = 1500 # Regime classifier training epochs
|
||
SCHEDULER_PATIENCE = 500 # ReduceLROnPlateau patience
|
||
N_SYNTHETIC = 500 # Number of unconditional synthetic sequences
|
||
N_COND = 100 # Number of conditional samples per regime
|
||
SEED = 42
|
||
|
||
# %%
|
||
set_global_seeds(SEED)
|
||
|
||
# Configuration
|
||
RETRAIN = False # Set True to retrain even if checkpoint exists
|
||
|
||
CONFIG = {
|
||
# Sequence/architecture - scaled from paper (24×6) to financial use case
|
||
"seq_length": SEQ_LENGTH,
|
||
"feature_size": FEATURE_SIZE,
|
||
"d_model": 64,
|
||
"n_heads": 4,
|
||
"n_layer_enc": 2,
|
||
"n_layer_dec": N_LAYER_DEC,
|
||
# Diffusion process
|
||
"timesteps": TIMESTEPS,
|
||
"sampling_timesteps": SAMPLING_TIMESTEPS,
|
||
"eta": 0.0, # Deterministic DDIM (eta=1 made variance worse, not better)
|
||
"beta_schedule": "cosine",
|
||
"loss_type": "l1",
|
||
# Training - match paper's schedule
|
||
"epochs": EPOCHS,
|
||
"batch_size": BATCH_SIZE,
|
||
"lr": 1e-5, # Paper's base_lr (not warmup target)
|
||
"warmup_lr": 8e-4, # Paper's warmup target
|
||
"warmup_steps": WARMUP_STEPS,
|
||
"ema_decay": 0.995,
|
||
"gradient_accumulate_every": GRADIENT_ACCUMULATE_EVERY,
|
||
# Data split
|
||
"start_date": "2005-01-01",
|
||
"holdout_start": "2024-01-01",
|
||
# Classifier guidance for regime-conditional generation
|
||
"classifier_epochs": CLASSIFIER_EPOCHS,
|
||
"classifier_lr": 5e-4,
|
||
# Regime-specific guidance: minority class (high-vol) needs gentler steering
|
||
# to prevent mode collapse (all samples pushed to extreme volatility).
|
||
# Low-Vol (majority): standard guidance, temperature=1, eta=0.5
|
||
# High-Vol (minority): lower scale, higher temperature for diversity, eta=0.7
|
||
"guidance_settings": {
|
||
0: {"scale": 0.75, "temperature": 1.0, "eta": 0.5}, # Low-Vol
|
||
1: {"scale": 0.3, "temperature": 2.0, "eta": 0.7}, # High-Vol: gentler
|
||
},
|
||
"n_regimes": 2, # Low-Vol vs High-Vol (imbalanced data precludes 3-way)
|
||
}
|
||
|
||
# %%
|
||
# Output paths and reproducibility
|
||
OUTPUT_DIR = get_output_dir(5, "diffusion_ts")
|
||
CHECKPOINT_DIR = OUTPUT_DIR / "checkpoints"
|
||
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
|
||
CHECKPOINT_PATH = CHECKPOINT_DIR / "diffusion_ts_model.pt"
|
||
|
||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||
print(f"Using device: {device}")
|
||
|
||
# %% [markdown]
|
||
# ## 1. Data Loading and Preparation
|
||
#
|
||
# We load daily ETF returns and create overlapping sequences. The temporal
|
||
# train/holdout split at 2024-01-01 ensures unbiased TSTR evaluation.
|
||
|
||
|
||
# %%
|
||
def load_returns_data(start_date: str, n_assets: int) -> tuple[np.ndarray, np.ndarray, list[str]]:
|
||
"""Load daily returns for ETF assets with temporal split."""
|
||
df = load_etfs()
|
||
start_dt = pl.lit(start_date).str.to_date()
|
||
|
||
returns_df = (
|
||
df.filter(pl.col("timestamp") >= start_dt)
|
||
.sort(["symbol", "timestamp"])
|
||
.with_columns(pl.col("close").pct_change().over("symbol").alias("return"))
|
||
.pivot(on="symbol", index="timestamp", values="return")
|
||
.sort("timestamp")
|
||
.drop_nulls()
|
||
)
|
||
|
||
data_cols = [c for c in returns_df.columns if c != "timestamp"][:n_assets]
|
||
timestamps = returns_df.select("timestamp").to_numpy().flatten()
|
||
returns = returns_df.select(data_cols).to_numpy().astype(np.float32)
|
||
|
||
print(f"Loaded {len(returns)} days of returns for {len(data_cols)} assets")
|
||
print(f"Date range: {timestamps[0]} to {timestamps[-1]}")
|
||
return returns, timestamps, data_cols
|
||
|
||
|
||
# %% [markdown]
|
||
# ### Create Overlapping Sequences
|
||
#
|
||
# Sliding windows maximize sample count from limited financial data.
|
||
|
||
|
||
# %%
|
||
def create_sequences(data: np.ndarray, seq_length: int) -> np.ndarray:
|
||
"""Create overlapping sequences from time series data."""
|
||
n_seq = len(data) - seq_length + 1
|
||
seqs = np.zeros((n_seq, seq_length, data.shape[1]), dtype=np.float32)
|
||
for i in range(n_seq):
|
||
seqs[i] = data[i : i + seq_length]
|
||
return seqs
|
||
|
||
|
||
# %%
|
||
all_returns, all_timestamps, asset_names = load_returns_data(
|
||
CONFIG["start_date"], CONFIG["feature_size"]
|
||
)
|
||
n_assets = all_returns.shape[1]
|
||
|
||
# Temporal split
|
||
holdout_dt = np.datetime64(CONFIG["holdout_start"])
|
||
train_mask = all_timestamps < holdout_dt
|
||
|
||
returns = all_returns[train_mask]
|
||
holdout_returns = all_returns[~train_mask]
|
||
|
||
print(f"\nTrain: {len(returns):,} days | Holdout: {len(holdout_returns):,} days")
|
||
|
||
sequences = create_sequences(returns, CONFIG["seq_length"])
|
||
holdout_sequences = create_sequences(holdout_returns, CONFIG["seq_length"])
|
||
print(f"Train sequences: {sequences.shape} | Holdout: {holdout_sequences.shape}")
|
||
|
||
# %% [markdown]
|
||
# ## 2. Utility Modules
|
||
#
|
||
# These building blocks support the Transformer architecture: sinusoidal
|
||
# timestep embeddings, learnable positional encoding, and adaptive layer
|
||
# normalization that conditions on the diffusion timestep.
|
||
|
||
|
||
# %%
|
||
# Inline helpers (from Diffusion-TS model_utils)
|
||
def extract(a, t, x_shape):
|
||
"""Gather values from `a` at indices `t`, reshape for broadcasting."""
|
||
b, *_ = t.shape
|
||
out = a.gather(-1, t)
|
||
return out.reshape(b, *((1,) * (len(x_shape) - 1)))
|
||
|
||
|
||
# %%
|
||
class SinusoidalPosEmb(nn.Module):
|
||
"""Sinusoidal positional embedding for diffusion timestep."""
|
||
|
||
def __init__(self, dim):
|
||
super().__init__()
|
||
self.dim = dim
|
||
|
||
def forward(self, x):
|
||
half_dim = self.dim // 2
|
||
emb = math.log(10000) / (half_dim - 1)
|
||
emb = torch.exp(torch.arange(half_dim, device=x.device) * -emb)
|
||
emb = x[:, None] * emb[None, :]
|
||
return torch.cat((emb.sin(), emb.cos()), dim=-1)
|
||
|
||
|
||
# %% [markdown]
|
||
# ### Adaptive Layer Normalization
|
||
#
|
||
# AdaLayerNorm modulates the normalized activations by a scale and shift
|
||
# derived from the diffusion timestep embedding. This gives each Transformer
|
||
# layer information about the current noise level.
|
||
|
||
|
||
# %%
|
||
class AdaLayerNorm(nn.Module):
|
||
"""Layer norm conditioned on diffusion timestep via scale+shift."""
|
||
|
||
def __init__(self, n_embd):
|
||
super().__init__()
|
||
self.emb = SinusoidalPosEmb(n_embd)
|
||
self.silu = nn.SiLU()
|
||
self.linear = nn.Linear(n_embd, n_embd * 2)
|
||
self.layernorm = nn.LayerNorm(n_embd, elementwise_affine=False)
|
||
|
||
def forward(self, x, timestep, label_emb=None):
|
||
emb = self.emb(timestep)
|
||
if label_emb is not None:
|
||
emb = emb + label_emb
|
||
emb = self.linear(self.silu(emb)).unsqueeze(1)
|
||
scale, shift = torch.chunk(emb, 2, dim=2)
|
||
return self.layernorm(x) * (1 + scale) + shift
|
||
|
||
|
||
# %% [markdown]
|
||
# ### Learnable Positional Encoding and Conv Embedding
|
||
#
|
||
# LearnablePositionalEncoding adds a learned position vector to each timestep.
|
||
# Conv_MLP projects the input feature dimension to the model dimension using
|
||
# a 1D convolution, which captures local patterns in the feature axis.
|
||
|
||
|
||
# %%
|
||
class LearnablePositionalEncoding(nn.Module):
|
||
"""Learned positional encoding added to sequence embeddings."""
|
||
|
||
def __init__(self, d_model, dropout=0.1, max_len=1024):
|
||
super().__init__()
|
||
self.dropout = nn.Dropout(p=dropout)
|
||
self.pe = nn.Parameter(torch.empty(1, max_len, d_model))
|
||
nn.init.uniform_(self.pe, -0.02, 0.02)
|
||
|
||
def forward(self, x):
|
||
return self.dropout(x + self.pe[:, : x.size(1)])
|
||
|
||
|
||
# %%
|
||
class Transpose(nn.Module):
|
||
"""Transpose wrapper for use in nn.Sequential."""
|
||
|
||
def __init__(self, shape: tuple):
|
||
super().__init__()
|
||
self.shape = shape
|
||
|
||
def forward(self, x):
|
||
return x.transpose(*self.shape)
|
||
|
||
|
||
# %%
|
||
class Conv_MLP(nn.Module):
|
||
"""1D convolution embedding: features → model dimension."""
|
||
|
||
def __init__(self, in_dim, out_dim, resid_pdrop=0.0):
|
||
super().__init__()
|
||
self.sequential = nn.Sequential(
|
||
Transpose(shape=(1, 2)),
|
||
nn.Conv1d(in_dim, out_dim, 3, stride=1, padding=1),
|
||
nn.Dropout(p=resid_pdrop),
|
||
)
|
||
|
||
def forward(self, x):
|
||
return self.sequential(x).transpose(1, 2)
|
||
|
||
|
||
# %% [markdown]
|
||
# ## 3. Interpretable Decomposition
|
||
#
|
||
# The key innovation of Diffusion-TS: each decoder layer extracts **trend**
|
||
# and **seasonal** components from its intermediate representation.
|
||
#
|
||
# - **TrendBlock**: Learns a polynomial basis (degree 3) via 1D convolutions,
|
||
# then multiplies by a polynomial space $[t, t^2, t^3]$ to produce a smooth trend.
|
||
# - **FourierLayer**: Computes the DFT of the latent, selects top-$k$ frequencies
|
||
# by magnitude, then reconstructs via inverse DFT. This extracts dominant
|
||
# periodic patterns without assuming a fixed period.
|
||
#
|
||
# Across decoder layers, trend and seasonal residuals accumulate, building up
|
||
# the full decomposition progressively.
|
||
|
||
|
||
# %%
|
||
class TrendBlock(nn.Module):
|
||
"""Polynomial regression on latent representation → smooth trend."""
|
||
|
||
def __init__(self, in_dim, out_dim, in_feat, out_feat, act):
|
||
super().__init__()
|
||
trend_poly = 3
|
||
self.trend = nn.Sequential(
|
||
nn.Conv1d(in_channels=in_dim, out_channels=trend_poly, kernel_size=3, padding=1),
|
||
act,
|
||
Transpose(shape=(1, 2)),
|
||
nn.Conv1d(in_feat, out_feat, 3, stride=1, padding=1),
|
||
)
|
||
lin_space = torch.arange(1, out_dim + 1, 1) / (out_dim + 1)
|
||
self.poly_space = torch.stack([lin_space ** float(p + 1) for p in range(trend_poly)], dim=0)
|
||
|
||
def forward(self, x):
|
||
x = self.trend(x).transpose(1, 2)
|
||
trend_vals = torch.matmul(x.transpose(1, 2), self.poly_space.to(x.device))
|
||
return trend_vals.transpose(1, 2)
|
||
|
||
|
||
# %% [markdown]
|
||
# ### FourierLayer: Top-k Frequency Selection
|
||
#
|
||
# Rather than using all Fourier coefficients, the layer selects the top-$k$
|
||
# frequencies (by magnitude) and reconstructs only those. This acts as a
|
||
# learned bandpass filter that adapts to the data's spectral content.
|
||
|
||
|
||
# %%
|
||
class FourierLayer(nn.Module):
|
||
"""Extract seasonal component via top-k inverse DFT."""
|
||
|
||
def __init__(self, d_model, low_freq=1, factor=1):
|
||
super().__init__()
|
||
self.d_model = d_model
|
||
self.factor = factor
|
||
self.low_freq = low_freq
|
||
|
||
def forward(self, x):
|
||
"""x: (b, t, d)"""
|
||
b, t, d = x.shape
|
||
x_freq = torch.fft.rfft(x, dim=1)
|
||
|
||
if t % 2 == 0:
|
||
x_freq = x_freq[:, self.low_freq : -1]
|
||
f = torch.fft.rfftfreq(t)[self.low_freq : -1]
|
||
else:
|
||
x_freq = x_freq[:, self.low_freq :]
|
||
f = torch.fft.rfftfreq(t)[self.low_freq :]
|
||
|
||
x_freq, index_tuple = self.topk_freq(x_freq)
|
||
f = repeat(f, "f -> b f d", b=x_freq.size(0), d=x_freq.size(2)).to(x_freq.device)
|
||
f = rearrange(f[index_tuple], "b f d -> b f () d").to(x_freq.device)
|
||
return self.extrapolate(x_freq, f, t)
|
||
|
||
def extrapolate(self, x_freq, f, t):
|
||
x_freq = torch.cat([x_freq, x_freq.conj()], dim=1)
|
||
f = torch.cat([f, -f], dim=1)
|
||
t_range = rearrange(torch.arange(t, dtype=torch.float), "t -> () () t ()").to(x_freq.device)
|
||
amp = rearrange(x_freq.abs(), "b f d -> b f () d")
|
||
phase = rearrange(x_freq.angle(), "b f d -> b f () d")
|
||
x_time = amp * torch.cos(2 * math.pi * f * t_range + phase)
|
||
return reduce(x_time, "b f t d -> b t d", "sum")
|
||
|
||
def topk_freq(self, x_freq):
|
||
length = x_freq.shape[1]
|
||
top_k = int(self.factor * math.log(max(length, 2)))
|
||
top_k = max(top_k, 1)
|
||
values, indices = torch.topk(x_freq.abs(), top_k, dim=1, largest=True, sorted=True)
|
||
mesh_a, mesh_b = torch.meshgrid(
|
||
torch.arange(x_freq.size(0)), torch.arange(x_freq.size(2)), indexing="ij"
|
||
)
|
||
index_tuple = (mesh_a.unsqueeze(1), indices, mesh_b.unsqueeze(1))
|
||
x_freq = x_freq[index_tuple]
|
||
return x_freq, index_tuple
|
||
|
||
|
||
# %% [markdown]
|
||
# ## 4. Transformer Architecture
|
||
#
|
||
# The encoder-decoder Transformer processes noised sequences. The **encoder**
|
||
# creates a contextual representation conditioned on the diffusion timestep.
|
||
# The **decoder** cross-attends to the encoder output and, at each layer,
|
||
# extracts trend and seasonal residuals that accumulate across layers.
|
||
|
||
|
||
# %%
|
||
class FullAttention(nn.Module):
|
||
"""Multi-head self-attention."""
|
||
|
||
def __init__(self, n_embd, n_head, attn_pdrop=0.0, resid_pdrop=0.0):
|
||
super().__init__()
|
||
assert n_embd % n_head == 0
|
||
self.key = nn.Linear(n_embd, n_embd)
|
||
self.query = nn.Linear(n_embd, n_embd)
|
||
self.value = nn.Linear(n_embd, n_embd)
|
||
self.attn_drop = nn.Dropout(attn_pdrop)
|
||
self.resid_drop = nn.Dropout(resid_pdrop)
|
||
self.proj = nn.Linear(n_embd, n_embd)
|
||
self.n_head = n_head
|
||
|
||
def forward(self, x, mask=None):
|
||
B, T, C = x.size()
|
||
k = self.key(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
|
||
q = self.query(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
|
||
v = self.value(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
|
||
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))
|
||
att = F.softmax(att, dim=-1)
|
||
att = self.attn_drop(att)
|
||
y = att @ v
|
||
y = y.transpose(1, 2).contiguous().view(B, T, C)
|
||
return self.resid_drop(self.proj(y))
|
||
|
||
|
||
# %% [markdown]
|
||
# ### Cross-Attention
|
||
#
|
||
# The decoder cross-attends to the encoder's output. Queries come from the
|
||
# decoder, while keys and values come from the encoder -- this lets each
|
||
# decoder position gather relevant context from the full encoded sequence.
|
||
|
||
|
||
# %%
|
||
class CrossAttention(nn.Module):
|
||
"""Multi-head cross-attention (decoder queries, encoder keys/values)."""
|
||
|
||
def __init__(self, n_embd, condition_embd, n_head, attn_pdrop=0.0, resid_pdrop=0.0):
|
||
super().__init__()
|
||
assert n_embd % n_head == 0
|
||
self.key = nn.Linear(condition_embd, n_embd)
|
||
self.query = nn.Linear(n_embd, n_embd)
|
||
self.value = nn.Linear(condition_embd, n_embd)
|
||
self.attn_drop = nn.Dropout(attn_pdrop)
|
||
self.resid_drop = nn.Dropout(resid_pdrop)
|
||
self.proj = nn.Linear(n_embd, n_embd)
|
||
self.n_head = n_head
|
||
|
||
def forward(self, x, encoder_output, mask=None):
|
||
B, T, C = x.size()
|
||
B, T_E, _ = encoder_output.size()
|
||
k = self.key(encoder_output).view(B, T_E, self.n_head, C // self.n_head).transpose(1, 2)
|
||
q = self.query(x).view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
|
||
v = self.value(encoder_output).view(B, T_E, self.n_head, C // self.n_head).transpose(1, 2)
|
||
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))
|
||
att = F.softmax(att, dim=-1)
|
||
att = self.attn_drop(att)
|
||
y = att @ v
|
||
y = y.transpose(1, 2).contiguous().view(B, T, C)
|
||
return self.resid_drop(self.proj(y))
|
||
|
||
|
||
# %% [markdown]
|
||
# ### Encoder and Decoder Blocks
|
||
#
|
||
# Each encoder block applies adaptive layer norm → self-attention → FFN.
|
||
# Each decoder block adds cross-attention to the encoder output, then
|
||
# splits the representation into two branches: one for trend extraction
|
||
# (polynomial regression) and one for seasonal extraction (Fourier layer).
|
||
|
||
|
||
# %%
|
||
class EncoderBlock(nn.Module):
|
||
"""Transformer encoder block with timestep-conditioned AdaLayerNorm."""
|
||
|
||
def __init__(self, n_embd=64, n_head=4, attn_pdrop=0.0, resid_pdrop=0.0, mlp_hidden_times=4):
|
||
super().__init__()
|
||
self.ln1 = AdaLayerNorm(n_embd)
|
||
self.ln2 = nn.LayerNorm(n_embd)
|
||
self.attn = FullAttention(n_embd, n_head, attn_pdrop, resid_pdrop)
|
||
self.mlp = nn.Sequential(
|
||
nn.Linear(n_embd, mlp_hidden_times * n_embd),
|
||
nn.GELU(),
|
||
nn.Linear(mlp_hidden_times * n_embd, n_embd),
|
||
nn.Dropout(resid_pdrop),
|
||
)
|
||
|
||
def forward(self, x, timestep, mask=None):
|
||
x = x + self.attn(self.ln1(x, timestep), mask=mask)
|
||
x = x + self.mlp(self.ln2(x))
|
||
return x
|
||
|
||
|
||
# %%
|
||
class Encoder(nn.Module):
|
||
"""Stack of encoder blocks."""
|
||
|
||
def __init__(self, n_layer=2, n_embd=64, n_head=4, attn_pdrop=0.0, resid_pdrop=0.0):
|
||
super().__init__()
|
||
self.blocks = nn.ModuleList(
|
||
[EncoderBlock(n_embd, n_head, attn_pdrop, resid_pdrop) for _ in range(n_layer)]
|
||
)
|
||
|
||
def forward(self, x, t):
|
||
for block in self.blocks:
|
||
x = block(x, t)
|
||
return x
|
||
|
||
|
||
# %%
|
||
class DecoderBlock(nn.Module):
|
||
"""Decoder block: self-attn + cross-attn + trend/season extraction."""
|
||
|
||
def __init__(
|
||
self,
|
||
n_channel,
|
||
n_feat,
|
||
n_embd=64,
|
||
n_head=4,
|
||
attn_pdrop=0.0,
|
||
resid_pdrop=0.0,
|
||
mlp_hidden_times=4,
|
||
condition_dim=64,
|
||
):
|
||
super().__init__()
|
||
self.ln1 = AdaLayerNorm(n_embd)
|
||
self.ln2 = nn.LayerNorm(n_embd)
|
||
self.ln1_1 = AdaLayerNorm(n_embd)
|
||
|
||
self.attn1 = FullAttention(n_embd, n_head, attn_pdrop, resid_pdrop)
|
||
self.attn2 = CrossAttention(n_embd, condition_dim, n_head, attn_pdrop, resid_pdrop)
|
||
|
||
act = nn.GELU()
|
||
self.trend = TrendBlock(n_channel, n_channel, n_embd, n_feat, act=act)
|
||
self.seasonal = FourierLayer(d_model=n_embd)
|
||
|
||
self.mlp = nn.Sequential(
|
||
nn.Linear(n_embd, mlp_hidden_times * n_embd),
|
||
nn.GELU(),
|
||
nn.Linear(mlp_hidden_times * n_embd, n_embd),
|
||
nn.Dropout(resid_pdrop),
|
||
)
|
||
self.proj = nn.Conv1d(n_channel, n_channel * 2, 1)
|
||
self.linear = nn.Linear(n_embd, n_feat)
|
||
|
||
def forward(self, x, encoder_output, timestep, mask=None):
|
||
x = x + self.attn1(self.ln1(x, timestep), mask=mask)
|
||
x = x + self.attn2(self.ln1_1(x, timestep), encoder_output, mask=mask)
|
||
x1, x2 = self.proj(x).chunk(2, dim=1)
|
||
trend, season = self.trend(x1), self.seasonal(x2)
|
||
x = x + self.mlp(self.ln2(x))
|
||
m = torch.mean(x, dim=1, keepdim=True)
|
||
return x - m, self.linear(m), trend, season
|
||
|
||
|
||
# %%
|
||
class Decoder(nn.Module):
|
||
"""Stack of decoder blocks, accumulating trend and seasonal components."""
|
||
|
||
def __init__(
|
||
self,
|
||
n_channel,
|
||
n_feat,
|
||
n_embd=64,
|
||
n_head=4,
|
||
n_layer=4,
|
||
attn_pdrop=0.0,
|
||
resid_pdrop=0.0,
|
||
condition_dim=64,
|
||
):
|
||
super().__init__()
|
||
self.d_model = n_embd
|
||
self.n_feat = n_feat
|
||
self.blocks = nn.ModuleList(
|
||
[
|
||
DecoderBlock(
|
||
n_feat=n_feat,
|
||
n_channel=n_channel,
|
||
n_embd=n_embd,
|
||
n_head=n_head,
|
||
attn_pdrop=attn_pdrop,
|
||
resid_pdrop=resid_pdrop,
|
||
condition_dim=condition_dim,
|
||
)
|
||
for _ in range(n_layer)
|
||
]
|
||
)
|
||
|
||
def forward(self, x, t, enc):
|
||
b, c, _ = x.shape
|
||
mean = []
|
||
season = torch.zeros((b, c, self.d_model), device=x.device)
|
||
trend = torch.zeros((b, c, self.n_feat), device=x.device)
|
||
for block in self.blocks:
|
||
x, residual_mean, residual_trend, residual_season = block(x, enc, t)
|
||
season += residual_season
|
||
trend += residual_trend
|
||
mean.append(residual_mean)
|
||
mean = torch.cat(mean, dim=1)
|
||
return x, mean, trend, season
|
||
|
||
|
||
# %% [markdown]
|
||
# ### Full Transformer
|
||
#
|
||
# The Transformer wraps encoder and decoder with input/output projections.
|
||
# The final output combines: (1) accumulated trend across decoder layers,
|
||
# (2) seasonal component projected back to feature space, and (3) a residual
|
||
# from the decoder output. This produces $\hat{x}_0 = \text{trend} + \text{season\_error}$.
|
||
|
||
|
||
# %%
|
||
class DiffusionTransformer(nn.Module):
|
||
"""Encoder-decoder Transformer with interpretable trend+seasonal decomposition."""
|
||
|
||
def __init__(
|
||
self,
|
||
n_feat,
|
||
n_channel,
|
||
n_layer_enc=2,
|
||
n_layer_dec=4,
|
||
n_embd=64,
|
||
n_heads=4,
|
||
attn_pdrop=0.0,
|
||
resid_pdrop=0.0,
|
||
mlp_hidden_times=4,
|
||
max_len=2048,
|
||
):
|
||
super().__init__()
|
||
self.emb = Conv_MLP(n_feat, n_embd, resid_pdrop=resid_pdrop)
|
||
self.inverse = Conv_MLP(n_embd, n_feat, resid_pdrop=resid_pdrop)
|
||
|
||
kernel_size, padding = (1, 0) if n_feat < 32 and n_channel < 64 else (5, 2)
|
||
|
||
self.combine_s = nn.Conv1d(
|
||
n_embd,
|
||
n_feat,
|
||
kernel_size=kernel_size,
|
||
stride=1,
|
||
padding=padding,
|
||
padding_mode="circular",
|
||
bias=False,
|
||
)
|
||
self.combine_m = nn.Conv1d(
|
||
n_layer_dec,
|
||
1,
|
||
kernel_size=1,
|
||
stride=1,
|
||
padding=0,
|
||
padding_mode="circular",
|
||
bias=False,
|
||
)
|
||
|
||
self.encoder = Encoder(n_layer_enc, n_embd, n_heads, attn_pdrop, resid_pdrop)
|
||
self.pos_enc = LearnablePositionalEncoding(n_embd, dropout=resid_pdrop, max_len=max_len)
|
||
|
||
self.decoder = Decoder(
|
||
n_channel,
|
||
n_feat,
|
||
n_embd,
|
||
n_heads,
|
||
n_layer_dec,
|
||
attn_pdrop,
|
||
resid_pdrop,
|
||
condition_dim=n_embd,
|
||
)
|
||
self.pos_dec = LearnablePositionalEncoding(n_embd, dropout=resid_pdrop, max_len=max_len)
|
||
|
||
def forward(self, x, t, return_res=False):
|
||
emb = self.emb(x)
|
||
inp_enc = self.pos_enc(emb)
|
||
enc_cond = self.encoder(inp_enc, t)
|
||
|
||
inp_dec = self.pos_dec(emb)
|
||
output, mean, trend, season = self.decoder(inp_dec, t, enc_cond)
|
||
|
||
res = self.inverse(output)
|
||
res_m = torch.mean(res, dim=1, keepdim=True)
|
||
season_error = self.combine_s(season.transpose(1, 2)).transpose(1, 2) + res - res_m
|
||
trend = self.combine_m(mean) + res_m + trend
|
||
|
||
if return_res:
|
||
return trend, self.combine_s(season.transpose(1, 2)).transpose(1, 2), res - res_m
|
||
|
||
return trend, season_error
|
||
|
||
|
||
# %% [markdown]
|
||
# ## 5. Diffusion Process
|
||
#
|
||
# The `DiffusionTS` class implements the full DDPM framework with $x_0$
|
||
# prediction. Key methods:
|
||
# - `q_sample`: Forward process -- add noise to clean data
|
||
# - `model_predictions`: Get model's $\hat{x}_0$ and derived noise
|
||
# - `p_mean_variance`: Compute posterior $p(x_{t-1}|x_t)$ -- **no clamp**
|
||
# - `_train_loss`: L1 + Fourier loss (time + frequency domain)
|
||
# - `fast_sample`: DDIM-style accelerated sampling
|
||
#
|
||
# **Critical adaptation**: We remove `clamp(-1, 1)` from `p_mean_variance`
|
||
# and `model_predictions`. Financial returns are StandardScaler-normalized
|
||
# but unbounded -- clamping would truncate the distribution tails.
|
||
|
||
|
||
# %%
|
||
def cosine_beta_schedule(timesteps, s=0.008):
|
||
"""Cosine schedule (Nichol & Dhariwal 2021)."""
|
||
steps = timesteps + 1
|
||
x = torch.linspace(0, timesteps, steps, dtype=torch.float64)
|
||
alphas_cumprod = torch.cos(((x / timesteps) + s) / (1 + s) * math.pi * 0.5) ** 2
|
||
alphas_cumprod = alphas_cumprod / alphas_cumprod[0]
|
||
betas = 1 - (alphas_cumprod[1:] / alphas_cumprod[:-1])
|
||
return torch.clip(betas, 0, 0.999)
|
||
|
||
|
||
# %%
|
||
class DiffusionTS(nn.Module):
|
||
"""Diffusion-TS: x_0-prediction diffusion with Fourier loss."""
|
||
|
||
def __init__(
|
||
self,
|
||
seq_length,
|
||
feature_size,
|
||
n_layer_enc=2,
|
||
n_layer_dec=4,
|
||
d_model=64,
|
||
timesteps=500,
|
||
sampling_timesteps=None,
|
||
loss_type="l1",
|
||
n_heads=4,
|
||
mlp_hidden_times=4,
|
||
eta=0.0,
|
||
reg_weight=None,
|
||
):
|
||
super().__init__()
|
||
self.eta = eta
|
||
self.seq_length = seq_length
|
||
self.feature_size = feature_size
|
||
self.ff_weight = reg_weight if reg_weight is not None else math.sqrt(seq_length) / 5
|
||
|
||
self.model = DiffusionTransformer(
|
||
n_feat=feature_size,
|
||
n_channel=seq_length,
|
||
n_layer_enc=n_layer_enc,
|
||
n_layer_dec=n_layer_dec,
|
||
n_heads=n_heads,
|
||
mlp_hidden_times=mlp_hidden_times,
|
||
max_len=seq_length,
|
||
n_embd=d_model,
|
||
)
|
||
|
||
betas = cosine_beta_schedule(timesteps)
|
||
alphas = 1.0 - betas
|
||
alphas_cumprod = torch.cumprod(alphas, dim=0)
|
||
alphas_cumprod_prev = F.pad(alphas_cumprod[:-1], (1, 0), value=1.0)
|
||
|
||
self.num_timesteps = int(timesteps)
|
||
self.loss_type = loss_type
|
||
self.sampling_timesteps = (
|
||
sampling_timesteps if sampling_timesteps is not None else timesteps
|
||
)
|
||
self.fast_sampling = self.sampling_timesteps < timesteps
|
||
|
||
def register(name, val):
|
||
self.register_buffer(name, val.to(torch.float32))
|
||
|
||
register("betas", betas)
|
||
register("alphas_cumprod", alphas_cumprod)
|
||
register("alphas_cumprod_prev", alphas_cumprod_prev)
|
||
register("sqrt_alphas_cumprod", torch.sqrt(alphas_cumprod))
|
||
register("sqrt_one_minus_alphas_cumprod", torch.sqrt(1.0 - alphas_cumprod))
|
||
register("log_one_minus_alphas_cumprod", torch.log(1.0 - alphas_cumprod))
|
||
register("sqrt_recip_alphas_cumprod", torch.sqrt(1.0 / alphas_cumprod))
|
||
register("sqrt_recipm1_alphas_cumprod", torch.sqrt(1.0 / alphas_cumprod - 1))
|
||
|
||
posterior_variance = betas * (1.0 - alphas_cumprod_prev) / (1.0 - alphas_cumprod)
|
||
register("posterior_variance", posterior_variance)
|
||
register("posterior_log_variance_clipped", torch.log(posterior_variance.clamp(min=1e-20)))
|
||
register(
|
||
"posterior_mean_coef1",
|
||
betas * torch.sqrt(alphas_cumprod_prev) / (1.0 - alphas_cumprod),
|
||
)
|
||
register(
|
||
"posterior_mean_coef2",
|
||
(1.0 - alphas_cumprod_prev) * torch.sqrt(alphas) / (1.0 - alphas_cumprod),
|
||
)
|
||
register(
|
||
"loss_weight",
|
||
torch.sqrt(alphas) * torch.sqrt(1.0 - alphas_cumprod) / betas / 100,
|
||
)
|
||
|
||
def predict_noise_from_start(self, x_t, t, x0):
|
||
return (extract(self.sqrt_recip_alphas_cumprod, t, x_t.shape) * x_t - x0) / extract(
|
||
self.sqrt_recipm1_alphas_cumprod, t, x_t.shape
|
||
)
|
||
|
||
def q_posterior(self, x_start, x_t, t):
|
||
posterior_mean = (
|
||
extract(self.posterior_mean_coef1, t, x_t.shape) * x_start
|
||
+ extract(self.posterior_mean_coef2, t, x_t.shape) * x_t
|
||
)
|
||
posterior_variance = extract(self.posterior_variance, t, x_t.shape)
|
||
posterior_log_variance = extract(self.posterior_log_variance_clipped, t, x_t.shape)
|
||
return posterior_mean, posterior_variance, posterior_log_variance
|
||
|
||
def output(self, x, t):
|
||
"""Model forward: predict x_0 as trend + season."""
|
||
trend, season = self.model(x, t)
|
||
return trend + season
|
||
|
||
def model_predictions(self, x, t):
|
||
"""Predict x_0 (NO clamp -- returns are unbounded) and derive noise."""
|
||
x_start = self.output(x, t)
|
||
pred_noise = self.predict_noise_from_start(x, t, x_start)
|
||
return pred_noise, x_start
|
||
|
||
def p_mean_variance(self, x, t):
|
||
"""Posterior mean and variance -- NO clamp on x_start."""
|
||
_, x_start = self.model_predictions(x, t)
|
||
model_mean, posterior_variance, posterior_log_variance = self.q_posterior(
|
||
x_start=x_start, x_t=x, t=t
|
||
)
|
||
return model_mean, posterior_variance, posterior_log_variance, x_start
|
||
|
||
def p_sample(self, x, t: int, cond_fn=None, model_kwargs=None):
|
||
"""Single DDPM reverse step with optional classifier guidance."""
|
||
batched_times = torch.full((x.shape[0],), t, device=x.device, dtype=torch.long)
|
||
model_mean, _, model_log_variance, x_start = self.p_mean_variance(x=x, t=batched_times)
|
||
noise = torch.randn_like(x) if t > 0 else 0.0
|
||
if cond_fn is not None:
|
||
model_mean = self.condition_mean(
|
||
cond_fn,
|
||
model_mean,
|
||
model_log_variance,
|
||
x,
|
||
t=batched_times,
|
||
model_kwargs=model_kwargs,
|
||
)
|
||
return model_mean + (0.5 * model_log_variance).exp() * noise, x_start
|
||
|
||
@torch.no_grad()
|
||
def sample(self, shape):
|
||
"""Full DDPM reverse sampling (all timesteps)."""
|
||
img = torch.randn(shape, device=self.betas.device)
|
||
for t in reversed(range(self.num_timesteps)):
|
||
img, _ = self.p_sample(img, t)
|
||
return img
|
||
|
||
@torch.no_grad()
|
||
def fast_sample(self, shape):
|
||
"""DDIM-style accelerated sampling."""
|
||
batch, total_timesteps = shape[0], self.num_timesteps
|
||
sampling_timesteps, eta = self.sampling_timesteps, self.eta
|
||
|
||
times = torch.linspace(-1, total_timesteps - 1, steps=sampling_timesteps + 1)
|
||
times = list(reversed(times.int().tolist()))
|
||
time_pairs = list(zip(times[:-1], times[1:], strict=False))
|
||
|
||
img = torch.randn(shape, device=self.betas.device)
|
||
|
||
for time, time_next in time_pairs:
|
||
time_cond = torch.full((batch,), time, device=self.betas.device, dtype=torch.long)
|
||
pred_noise, x_start = self.model_predictions(img, time_cond)
|
||
|
||
if time_next < 0:
|
||
img = x_start
|
||
continue
|
||
|
||
alpha = self.alphas_cumprod[time]
|
||
alpha_next = self.alphas_cumprod[time_next]
|
||
sigma = eta * ((1 - alpha / alpha_next) * (1 - alpha_next) / (1 - alpha)).sqrt()
|
||
c = (1 - alpha_next - sigma**2).sqrt()
|
||
noise = torch.randn_like(img)
|
||
img = x_start * alpha_next.sqrt() + c * pred_noise + sigma * noise
|
||
|
||
return img
|
||
|
||
@torch.no_grad()
|
||
def fast_sample_cond(self, shape, cond_fn=None, model_kwargs=None, eta=None):
|
||
"""DDIM-style sampling with classifier guidance."""
|
||
batch, total_timesteps = shape[0], self.num_timesteps
|
||
sampling_timesteps = self.sampling_timesteps
|
||
eta = eta if eta is not None else self.eta
|
||
|
||
times = torch.linspace(-1, total_timesteps - 1, steps=sampling_timesteps + 1)
|
||
times = list(reversed(times.int().tolist()))
|
||
time_pairs = list(zip(times[:-1], times[1:], strict=False))
|
||
|
||
img = torch.randn(shape, device=self.betas.device)
|
||
|
||
for time, time_next in time_pairs:
|
||
time_cond = torch.full((batch,), time, device=self.betas.device, dtype=torch.long)
|
||
pred_noise, x_start = self.model_predictions(img, time_cond)
|
||
|
||
if cond_fn is not None:
|
||
_, x_start = self.condition_score(
|
||
cond_fn, x_start, img, time_cond, model_kwargs=model_kwargs
|
||
)
|
||
pred_noise = self.predict_noise_from_start(img, time_cond, x_start)
|
||
|
||
if time_next < 0:
|
||
img = x_start
|
||
continue
|
||
|
||
alpha = self.alphas_cumprod[time]
|
||
alpha_next = self.alphas_cumprod[time_next]
|
||
sigma = eta * ((1 - alpha / alpha_next) * (1 - alpha_next) / (1 - alpha)).sqrt()
|
||
c = (1 - alpha_next - sigma**2).sqrt()
|
||
noise = torch.randn_like(img)
|
||
img = x_start * alpha_next.sqrt() + c * pred_noise + sigma * noise
|
||
|
||
return img
|
||
|
||
@torch.no_grad()
|
||
def sample_cond(self, shape, cond_fn=None, model_kwargs=None, eta=None):
|
||
"""Full DDPM reverse sampling with classifier guidance.
|
||
|
||
Note: eta parameter is ignored for full DDPM (inherently stochastic).
|
||
"""
|
||
img = torch.randn(shape, device=self.betas.device)
|
||
for t in reversed(range(self.num_timesteps)):
|
||
img, _ = self.p_sample(img, t, cond_fn=cond_fn, model_kwargs=model_kwargs)
|
||
return img
|
||
|
||
def generate_mts(self, batch_size=16, model_kwargs=None, cond_fn=None, eta=None):
|
||
"""Entry point: generate multivariate time series.
|
||
|
||
Args:
|
||
eta: Stochasticity for DDIM sampling. eta=0 is deterministic, eta=1 is full DDPM.
|
||
For conditional sampling, eta>0 adds diversity and prevents mode collapse.
|
||
"""
|
||
shape = (batch_size, self.seq_length, self.feature_size)
|
||
if cond_fn is not None:
|
||
sample_fn = self.fast_sample_cond if self.fast_sampling else self.sample_cond
|
||
return sample_fn(shape, cond_fn=cond_fn, model_kwargs=model_kwargs, eta=eta)
|
||
sample_fn = self.fast_sample if self.fast_sampling else self.sample
|
||
return sample_fn(shape)
|
||
|
||
@property
|
||
def loss_fn(self):
|
||
if self.loss_type == "l1":
|
||
return F.l1_loss
|
||
elif self.loss_type == "l2":
|
||
return F.mse_loss
|
||
raise ValueError(f"invalid loss type {self.loss_type}")
|
||
|
||
def q_sample(self, x_start, t, noise=None):
|
||
"""Forward process: add noise to x_0."""
|
||
if noise is None:
|
||
noise = torch.randn_like(x_start)
|
||
return (
|
||
extract(self.sqrt_alphas_cumprod, t, x_start.shape) * x_start
|
||
+ extract(self.sqrt_one_minus_alphas_cumprod, t, x_start.shape) * noise
|
||
)
|
||
|
||
def _train_loss(self, x_start, t, target=None, noise=None):
|
||
"""Combined time-domain (L1) + frequency-domain (Fourier) loss."""
|
||
if noise is None:
|
||
noise = torch.randn_like(x_start)
|
||
if target is None:
|
||
target = x_start
|
||
|
||
x = self.q_sample(x_start=x_start, t=t, noise=noise)
|
||
model_out = self.output(x, t)
|
||
|
||
train_loss = self.loss_fn(model_out, target, reduction="none")
|
||
|
||
# Fourier loss: match spectral content
|
||
fft1 = torch.fft.fft(model_out.transpose(1, 2), norm="forward")
|
||
fft2 = torch.fft.fft(target.transpose(1, 2), norm="forward")
|
||
fft1, fft2 = fft1.transpose(1, 2), fft2.transpose(1, 2)
|
||
fourier_loss = self.loss_fn(
|
||
torch.real(fft1), torch.real(fft2), reduction="none"
|
||
) + self.loss_fn(torch.imag(fft1), torch.imag(fft2), reduction="none")
|
||
train_loss = train_loss + self.ff_weight * fourier_loss
|
||
|
||
train_loss = reduce(train_loss, "b ... -> b (...)", "mean")
|
||
train_loss = train_loss * extract(self.loss_weight, t, train_loss.shape)
|
||
return train_loss.mean()
|
||
|
||
def forward(self, x, **kwargs):
|
||
b, c, n, device = *x.shape, x.device
|
||
assert n == self.feature_size, f"expected {self.feature_size} features, got {n}"
|
||
t = torch.randint(0, self.num_timesteps, (b,), device=device).long()
|
||
return self._train_loss(x_start=x, t=t, **kwargs)
|
||
|
||
def return_components(self, x, t: int):
|
||
"""Return trend, seasonal, residual decomposition for visualization."""
|
||
b, c, n, device = *x.shape, x.device
|
||
t_tensor = torch.tensor([t]).repeat(b).to(device)
|
||
x_noised = self.q_sample(x, t_tensor)
|
||
trend, season, residual = self.model(x_noised, t_tensor, return_res=True)
|
||
return trend, season, residual, x_noised
|
||
|
||
def condition_mean(self, cond_fn, mean, log_variance, x, t, model_kwargs=None):
|
||
"""Shift mean by σ² · ∇_x log p(y|x) for classifier guidance."""
|
||
gradient = cond_fn(x=x, t=t, **(model_kwargs or {}))
|
||
return mean.float() + torch.exp(log_variance) * gradient.float()
|
||
|
||
def condition_score(self, cond_fn, x_start, x, t, model_kwargs=None):
|
||
"""Score-based conditioning (Song et al. 2020)."""
|
||
alpha_bar = extract(self.alphas_cumprod, t, x.shape)
|
||
eps = self.predict_noise_from_start(x, t, x_start)
|
||
eps = eps - (1 - alpha_bar).sqrt() * cond_fn(x=x, t=t, **(model_kwargs or {}))
|
||
pred_xstart = (
|
||
extract(self.sqrt_recip_alphas_cumprod, t, x.shape) * x
|
||
- extract(self.sqrt_recipm1_alphas_cumprod, t, x.shape) * eps
|
||
)
|
||
model_mean, _, _ = self.q_posterior(x_start=pred_xstart, x_t=x, t=t)
|
||
return model_mean, pred_xstart
|
||
|
||
|
||
# %% [markdown]
|
||
# ## 6. Exponential Moving Average
|
||
#
|
||
# EMA maintains a shadow copy of model weights that is updated as a running
|
||
# average: $\theta_{\text{ema}} \leftarrow \beta \theta_{\text{ema}} + (1-\beta) \theta$.
|
||
# Sampling from the EMA model produces smoother, higher-quality outputs.
|
||
|
||
|
||
# %%
|
||
class EMA:
|
||
"""Simple exponential moving average of model parameters."""
|
||
|
||
def __init__(self, model, decay=0.995, update_every=10):
|
||
self.decay = decay
|
||
self.update_every = update_every
|
||
self.step = 0
|
||
self.ema_model = deepcopy(model)
|
||
self.ema_model.eval()
|
||
for p in self.ema_model.parameters():
|
||
p.requires_grad_(False)
|
||
|
||
def update(self, model):
|
||
self.step += 1
|
||
if self.step % self.update_every != 0:
|
||
return
|
||
with torch.no_grad():
|
||
for ema_p, model_p in zip(
|
||
self.ema_model.parameters(), model.parameters(), strict=False
|
||
):
|
||
ema_p.data.mul_(self.decay).add_(model_p.data, alpha=1.0 - self.decay)
|
||
|
||
def to(self, device):
|
||
self.ema_model = self.ema_model.to(device)
|
||
return self
|
||
|
||
|
||
# %% [markdown]
|
||
# ## 7. Training
|
||
#
|
||
# Training follows the standard diffusion objective: sample a timestep $t$,
|
||
# add noise to create $x_t$, predict $\hat{x}_0$, and minimize the combined
|
||
# time-domain + Fourier loss. We use gradient clipping, warmup scheduler,
|
||
# gradient accumulation, and EMA for stable convergence.
|
||
|
||
# %%
|
||
# Normalize data before training
|
||
scaler = StandardScaler()
|
||
scaler.fit(returns) # Fit on raw returns, not sequences
|
||
|
||
seq_shape = sequences.shape
|
||
sequences_flat = sequences.reshape(-1, seq_shape[-1])
|
||
sequences_norm = scaler.transform(sequences_flat).reshape(seq_shape).astype(np.float32)
|
||
|
||
print(f"Normalized: mean={sequences_norm.mean():.4f}, std={sequences_norm.std():.4f}")
|
||
|
||
# %%
|
||
# Initialize model
|
||
diffusion_model = DiffusionTS(
|
||
seq_length=CONFIG["seq_length"],
|
||
feature_size=n_assets,
|
||
n_layer_enc=CONFIG["n_layer_enc"],
|
||
n_layer_dec=CONFIG["n_layer_dec"],
|
||
d_model=CONFIG["d_model"],
|
||
timesteps=CONFIG["timesteps"],
|
||
sampling_timesteps=CONFIG["sampling_timesteps"],
|
||
eta=CONFIG["eta"],
|
||
loss_type=CONFIG["loss_type"],
|
||
n_heads=CONFIG["n_heads"],
|
||
).to(device)
|
||
|
||
n_params = sum(p.numel() for p in diffusion_model.parameters())
|
||
print(f"Model parameters: {n_params:,}")
|
||
print(f"DDIM: {CONFIG['timesteps']} training steps → {CONFIG['sampling_timesteps']} sampling steps")
|
||
|
||
# %% [markdown]
|
||
# ### Checkpoint Loading
|
||
#
|
||
# If a trained model exists and `RETRAIN=False`, we skip training and load
|
||
# the saved weights. This allows iterating on evaluation/visualization
|
||
# without retraining.
|
||
|
||
# %%
|
||
# Check for existing checkpoint
|
||
checkpoint_exists = CHECKPOINT_PATH.exists()
|
||
training_losses = []
|
||
|
||
if checkpoint_exists and not RETRAIN:
|
||
print(f"Loading checkpoint from {CHECKPOINT_PATH}")
|
||
checkpoint = torch.load(CHECKPOINT_PATH, map_location=device, weights_only=False)
|
||
diffusion_model.load_state_dict(checkpoint["model_state"])
|
||
scaler = StandardScaler()
|
||
scaler.mean_ = np.array(checkpoint["scaler_mean"])
|
||
scaler.scale_ = np.array(checkpoint["scaler_scale"])
|
||
training_losses = checkpoint.get("training_losses", [])
|
||
print(f"Loaded model trained for {len(training_losses)} epochs")
|
||
|
||
# Create EMA wrapper with loaded weights
|
||
ema = EMA(diffusion_model, decay=CONFIG["ema_decay"]).to(device)
|
||
ema.ema_model.load_state_dict(checkpoint["ema_state"])
|
||
SKIP_TRAINING = True
|
||
else:
|
||
if checkpoint_exists:
|
||
print("RETRAIN=True: Ignoring existing checkpoint")
|
||
else:
|
||
print(f"No checkpoint found at {CHECKPOINT_PATH}")
|
||
SKIP_TRAINING = False
|
||
|
||
# %%
|
||
# Create tensor data and utilities (needed for visualization/classifier even when loading from checkpoint)
|
||
tensor_data = torch.FloatTensor(sequences_norm).to(device)
|
||
grad_accum = CONFIG["gradient_accumulate_every"]
|
||
|
||
|
||
def cycle(dl):
|
||
"""Infinite iterator over a dataloader."""
|
||
while True:
|
||
yield from dl
|
||
|
||
|
||
# %% [markdown]
|
||
# ### Training (or Loading from Checkpoint)
|
||
|
||
# %%
|
||
# Training setup: optimizer, scheduler, dataloader
|
||
if not SKIP_TRAINING:
|
||
ema = EMA(diffusion_model, decay=CONFIG["ema_decay"]).to(device)
|
||
|
||
optimizer = torch.optim.Adam(diffusion_model.parameters(), lr=CONFIG["lr"], betas=(0.9, 0.96))
|
||
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
|
||
optimizer, factor=0.5, patience=SCHEDULER_PATIENCE, min_lr=1e-5, threshold=0.1
|
||
)
|
||
|
||
warmup_steps = CONFIG["warmup_steps"]
|
||
warmup_lr = CONFIG["warmup_lr"]
|
||
base_lr = CONFIG["lr"]
|
||
|
||
dataset = TensorDataset(tensor_data)
|
||
dataloader = DataLoader(dataset, batch_size=CONFIG["batch_size"], shuffle=True, drop_last=True)
|
||
data_iter = cycle(dataloader)
|
||
|
||
print(f"Training samples: {len(dataset):,}")
|
||
print(f"Effective batch size: {CONFIG['batch_size'] * grad_accum}")
|
||
|
||
# %%
|
||
# Training loop
|
||
if not SKIP_TRAINING:
|
||
print(f"Training Diffusion-TS for {CONFIG['epochs']} epochs...")
|
||
|
||
training_losses = []
|
||
for step in range(CONFIG["epochs"]):
|
||
diffusion_model.train()
|
||
total_loss = 0.0
|
||
|
||
# Warmup LR
|
||
if step < warmup_steps:
|
||
lr = base_lr + (warmup_lr - base_lr) * step / max(warmup_steps, 1)
|
||
for pg in optimizer.param_groups:
|
||
pg["lr"] = lr
|
||
|
||
# Gradient accumulation
|
||
for _ in range(grad_accum):
|
||
(batch,) = next(data_iter)
|
||
loss = diffusion_model(batch, target=batch)
|
||
loss = loss / grad_accum
|
||
loss.backward()
|
||
total_loss += loss.item()
|
||
|
||
torch.nn.utils.clip_grad_norm_(diffusion_model.parameters(), 1.0)
|
||
optimizer.step()
|
||
if step >= warmup_steps:
|
||
scheduler.step(total_loss)
|
||
optimizer.zero_grad()
|
||
ema.update(diffusion_model)
|
||
training_losses.append(total_loss)
|
||
|
||
if (step + 1) % 100 == 0 or step == 0:
|
||
current_lr = optimizer.param_groups[0]["lr"]
|
||
print(
|
||
f" Epoch {step + 1}/{CONFIG['epochs']}: Loss = {total_loss:.6f}, LR = {current_lr:.2e}",
|
||
flush=True,
|
||
)
|
||
|
||
print(f"Training complete. Final loss: {training_losses[-1]:.6f}")
|
||
|
||
# %%
|
||
# Save checkpoint
|
||
if not SKIP_TRAINING:
|
||
checkpoint = {
|
||
"model_state": diffusion_model.state_dict(),
|
||
"ema_state": ema.ema_model.state_dict(),
|
||
"scaler_mean": scaler.mean_.tolist(),
|
||
"scaler_scale": scaler.scale_.tolist(),
|
||
"training_losses": training_losses,
|
||
"config": CONFIG,
|
||
}
|
||
torch.save(checkpoint, CHECKPOINT_PATH)
|
||
print(f"Saved checkpoint to {CHECKPOINT_PATH}")
|
||
|
||
# %% [markdown]
|
||
# ### Training Progress
|
||
|
||
# %%
|
||
if training_losses:
|
||
fig, ax = plt.subplots(figsize=(8, 4))
|
||
ax.plot(training_losses, linewidth=1)
|
||
ax.set_yscale("log")
|
||
ax.set_xlabel("Epoch")
|
||
ax.set_ylabel("Loss (L1 + Fourier)")
|
||
ax.set_title("Diffusion-TS Training Progress")
|
||
plt.tight_layout()
|
||
plt.show()
|
||
else:
|
||
print("No training losses available (loaded from checkpoint)")
|
||
|
||
|
||
# %% [markdown]
|
||
# ## 8. Generate Synthetic Sequences
|
||
#
|
||
# We sample from the EMA model using DDIM fast sampling (50 steps instead
|
||
# of 500). The samples are generated in normalized space and then
|
||
# denormalized back to original return scale.
|
||
|
||
# %%
|
||
# N_SYNTHETIC is set in the parameters cell above
|
||
|
||
print(
|
||
f"Generating {N_SYNTHETIC} synthetic sequences via DDIM ({CONFIG['sampling_timesteps']} steps)..."
|
||
)
|
||
synthetic_norm = ema.ema_model.generate_mts(batch_size=N_SYNTHETIC).detach().cpu().numpy()
|
||
|
||
# Diagnostic: compare normalized variance
|
||
print("\nNormalized space (before scaling):")
|
||
print(f" Training std: {sequences_norm.std():.4f}")
|
||
print(f" Synthetic std: {synthetic_norm.std():.4f}")
|
||
variance_ratio = synthetic_norm.std() / sequences_norm.std()
|
||
print(f" Ratio: {variance_ratio:.2%}")
|
||
|
||
# Variance scaling: Diffusion-TS with trend+seasonal decomposition tends to underestimate
|
||
# variance. We scale outputs to match training distribution variance.
|
||
# This scale_factor is also applied to regime-conditional samples below.
|
||
if variance_ratio < 0.9:
|
||
VARIANCE_SCALE_FACTOR = sequences_norm.std() / synthetic_norm.std()
|
||
synthetic_norm = synthetic_norm * VARIANCE_SCALE_FACTOR
|
||
print(f"\nApplied variance scaling: {VARIANCE_SCALE_FACTOR:.3f}x")
|
||
print(f" Scaled std: {synthetic_norm.std():.4f}")
|
||
else:
|
||
VARIANCE_SCALE_FACTOR = 1.0
|
||
|
||
# Denormalize
|
||
syn_shape = synthetic_norm.shape
|
||
synthetic_flat = synthetic_norm.reshape(-1, syn_shape[-1])
|
||
synthetic_sequences = scaler.inverse_transform(synthetic_flat).reshape(syn_shape).astype(np.float32)
|
||
|
||
print("\nDenormalized (return space):")
|
||
print(f" Synthetic: mean={synthetic_sequences.mean():.6f}, std={synthetic_sequences.std():.6f}")
|
||
print(f" Real: mean={sequences.mean():.6f}, std={sequences.std():.6f}")
|
||
|
||
|
||
# %% [markdown]
|
||
# ## 9. Unconditional Evaluation
|
||
#
|
||
# We evaluate the generated data on three axes:
|
||
#
|
||
# ### Statistical Tests
|
||
#
|
||
# - **Kolmogorov-Smirnov (KS) test**: Measures the maximum distance between
|
||
# two empirical CDFs. Values range from 0 (identical distributions) to 1
|
||
# (completely different). For returns, KS < 0.1 indicates good marginal fit.
|
||
#
|
||
# - **Correlation error**: Mean absolute difference between real and synthetic
|
||
# cross-asset correlation matrices. Captures whether the model learned
|
||
# dependence structure (e.g., sector correlations).
|
||
#
|
||
# - **Autocorrelation error**: Compares lag-1 autocorrelation. Returns have
|
||
# near-zero AC (weak form efficiency) but volatility clusters (squared returns
|
||
# have positive AC). Good generators preserve these stylized facts.
|
||
#
|
||
# ### Visual Comparison
|
||
#
|
||
# - **PCA/t-SNE**: Project high-dimensional sequences to 2D. Real and synthetic
|
||
# distributions should overlap if the generator captures the data manifold.
|
||
#
|
||
# ### Utility (TSTR)
|
||
#
|
||
# - **Train-Synthetic-Test-Real**: Train a classifier on synthetic data, test
|
||
# on real data. High accuracy means synthetic data is useful for downstream
|
||
# ML tasks -- the ultimate practical validation.
|
||
|
||
|
||
# %%
|
||
def evaluate_statistics(real_data: np.ndarray, synthetic_data: np.ndarray) -> dict:
|
||
"""Compare distributional properties of real and synthetic data."""
|
||
n_assets = real_data.shape[2]
|
||
real_flat = real_data.reshape(-1, n_assets)
|
||
syn_flat = synthetic_data.reshape(-1, n_assets)
|
||
|
||
# KS test per asset
|
||
ks_stats = [stats.ks_2samp(real_flat[:, i], syn_flat[:, i])[0] for i in range(n_assets)]
|
||
|
||
# Correlation matrix comparison
|
||
real_corr = np.corrcoef(real_flat.T)
|
||
syn_corr = np.corrcoef(syn_flat.T)
|
||
corr_error = np.mean(np.abs(real_corr - syn_corr))
|
||
|
||
# Autocorrelation (lag-1) per asset
|
||
def autocorr(x, lag=1):
|
||
return np.corrcoef(x[:-lag], x[lag:])[0, 1]
|
||
|
||
real_ac = [autocorr(real_flat[:, i]) for i in range(n_assets)]
|
||
syn_ac = [autocorr(syn_flat[:, i]) for i in range(n_assets)]
|
||
ac_error = np.mean(np.abs(np.array(real_ac) - np.array(syn_ac)))
|
||
|
||
return {
|
||
"mean_ks_statistic": np.mean(ks_stats),
|
||
"mean_error": np.mean(np.abs(real_flat.mean(0) - syn_flat.mean(0))),
|
||
"std_error": np.mean(np.abs(real_flat.std(0) - syn_flat.std(0))),
|
||
"correlation_error": corr_error,
|
||
"autocorrelation_error": ac_error,
|
||
}
|
||
|
||
|
||
stats_results = evaluate_statistics(sequences, synthetic_sequences)
|
||
|
||
print("\n=== Statistical Evaluation ===")
|
||
for key, value in stats_results.items():
|
||
print(f" {key}: {value:.4f}")
|
||
|
||
# %% [markdown]
|
||
# **Interpretation**: Low KS statistics indicate that marginal distributions
|
||
# per asset are well-matched. Correlation error tests cross-asset dependence
|
||
# preservation. Autocorrelation error verifies that the model reproduces
|
||
# the weak serial dependence and volatility clustering of daily returns.
|
||
|
||
|
||
# %%
|
||
fig = plot_fidelity_comparison(
|
||
sequences,
|
||
synthetic_sequences,
|
||
title="Diffusion-TS: Real vs Synthetic Distribution",
|
||
n_samples=500,
|
||
flatten_method="flatten", # Flatten all timesteps for full sequence comparison
|
||
)
|
||
plt.show()
|
||
|
||
# %% [markdown]
|
||
# **Interpretation**: Overlapping PCA/t-SNE point clouds confirm that synthetic
|
||
# sequences occupy the same region of feature space as real data. Gaps or
|
||
# isolated clusters would indicate missing regimes.
|
||
|
||
# %% [markdown]
|
||
# ### TSTR Evaluation
|
||
#
|
||
# We evaluate downstream utility via **extreme-move classification**: predict
|
||
# whether the next-day absolute return exceeds the 90th percentile.
|
||
#
|
||
# **Important context**: This is a highly imbalanced task -- by definition only
|
||
# 10% of samples are positive (extreme moves). A naive "always predict normal"
|
||
# classifier achieves **90% accuracy** as baseline. The meaningful metric is
|
||
# **TSTR Ratio** (synthetic/real), not raw accuracy.
|
||
|
||
|
||
# %%
|
||
def tstr_evaluation(train_data, holdout_data, synthetic_data):
|
||
"""TSTR on extreme-move classification (90th percentile threshold)."""
|
||
from sklearn.linear_model import LogisticRegression
|
||
from sklearn.metrics import precision_recall_fscore_support
|
||
|
||
train_returns = train_data[:, -1, 0]
|
||
threshold = np.percentile(np.abs(train_returns), 90)
|
||
|
||
X_train_real = train_data[:, :-1, :].reshape(len(train_data), -1)
|
||
y_train_real = (np.abs(train_returns) > threshold).astype(int)
|
||
|
||
X_train_syn = synthetic_data[:, :-1, :].reshape(len(synthetic_data), -1)
|
||
y_train_syn = (np.abs(synthetic_data[:, -1, 0]) > threshold).astype(int)
|
||
|
||
X_test = holdout_data[:, :-1, :].reshape(len(holdout_data), -1)
|
||
y_test = (np.abs(holdout_data[:, -1, 0]) > threshold).astype(int)
|
||
|
||
scaler_r = StandardScaler()
|
||
X_tr_s = scaler_r.fit_transform(X_train_real)
|
||
X_te_r = scaler_r.transform(X_test)
|
||
|
||
scaler_s = StandardScaler()
|
||
X_ts_s = scaler_s.fit_transform(X_train_syn)
|
||
X_te_s = scaler_s.transform(X_test)
|
||
|
||
model_r = LogisticRegression(max_iter=1000)
|
||
model_r.fit(X_tr_s, y_train_real)
|
||
acc_real = model_r.score(X_te_r, y_test)
|
||
y_pred_real = model_r.predict(X_te_r)
|
||
prec_r, rec_r, f1_r, _ = precision_recall_fscore_support(
|
||
y_test, y_pred_real, average="binary", zero_division=0
|
||
)
|
||
|
||
if len(np.unique(y_train_syn)) < 2:
|
||
acc_syn = (y_test == int(y_train_syn.mean() > 0.5)).mean()
|
||
prec_s, rec_s, f1_s = 0, 0, 0
|
||
else:
|
||
model_s = LogisticRegression(max_iter=1000)
|
||
model_s.fit(X_ts_s, y_train_syn)
|
||
acc_syn = model_s.score(X_te_s, y_test)
|
||
y_pred_syn = model_s.predict(X_te_s)
|
||
prec_s, rec_s, f1_s, _ = precision_recall_fscore_support(
|
||
y_test, y_pred_syn, average="binary", zero_division=0
|
||
)
|
||
|
||
return {
|
||
"accuracy_real": acc_real,
|
||
"accuracy_synthetic": acc_syn,
|
||
"precision_real": prec_r,
|
||
"precision_synthetic": prec_s,
|
||
"recall_real": rec_r,
|
||
"recall_synthetic": rec_s,
|
||
"f1_real": f1_r,
|
||
"f1_synthetic": f1_s,
|
||
"tstr_ratio": acc_syn / acc_real if acc_real > 0 else 0,
|
||
"positive_rate": y_test.mean(),
|
||
"baseline_accuracy": 1 - y_test.mean(),
|
||
"n_test_samples": len(y_test),
|
||
}
|
||
|
||
|
||
tstr_results = tstr_evaluation(sequences, holdout_sequences, synthetic_sequences)
|
||
|
||
print("\n=== TSTR Evaluation: Extreme-Move Classification ===")
|
||
print(" Task: Predict |return| > 90th percentile")
|
||
print(
|
||
f" Test samples: {tstr_results['n_test_samples']:,} ({tstr_results['positive_rate']:.1%} positive)"
|
||
)
|
||
print(f" Naive baseline: {tstr_results['baseline_accuracy']:.1%} (always predict 'normal')")
|
||
print()
|
||
print(f" {'Metric':<12} {'Real-Trained':>14} {'Synth-Trained':>14}")
|
||
print(f" {'-' * 42}")
|
||
print(
|
||
f" {'Accuracy':<12} {tstr_results['accuracy_real']:>14.1%} {tstr_results['accuracy_synthetic']:>14.1%}"
|
||
)
|
||
print(
|
||
f" {'Precision':<12} {tstr_results['precision_real']:>14.1%} {tstr_results['precision_synthetic']:>14.1%}"
|
||
)
|
||
print(
|
||
f" {'Recall':<12} {tstr_results['recall_real']:>14.1%} {tstr_results['recall_synthetic']:>14.1%}"
|
||
)
|
||
print(f" {'F1':<12} {tstr_results['f1_real']:>14.1%} {tstr_results['f1_synthetic']:>14.1%}")
|
||
print()
|
||
print(f" TSTR Ratio: {tstr_results['tstr_ratio']:.3f}")
|
||
|
||
# %% [markdown]
|
||
# **Interpretation**: Raw accuracy is misleading here (90% baseline). The **TSTR
|
||
# Ratio** is the key metric: a ratio near 1.0 means synthetic-trained models
|
||
# perform comparably to real-trained ones. Precision/recall for the extreme-move
|
||
# class reveal how well each model identifies rare large moves.
|
||
#
|
||
# **Trading context**: Extreme-move prediction exploits volatility clustering.
|
||
# High recall means we catch most extreme moves; high precision means fewer
|
||
# false alarms. TSTR ≥ 0.9 indicates synthetic data preserves this structure.
|
||
|
||
# %% [markdown]
|
||
# ### Sample Sequences and Decomposition
|
||
#
|
||
# Visualize example synthetic paths and the model's internal trend+seasonal
|
||
# decomposition. The decomposition shows what the model attributes to slow
|
||
# drift versus periodic patterns at a given noise level.
|
||
|
||
# %%
|
||
# Sample paths: Daily returns (left) and Cumulative returns (right)
|
||
# Daily returns show moment-to-moment behavior; cumulative reveals drift patterns
|
||
n_sample_paths = 10
|
||
|
||
fig, axes = plt.subplots(2, 2, figsize=(12, 6))
|
||
|
||
# Top row: Real data
|
||
for i in range(n_sample_paths):
|
||
axes[0, 0].plot(sequences[i, :, 0], color=COLORS["blue"], alpha=0.4, linewidth=0.8)
|
||
axes[0, 1].plot(sequences[i, :, 0].cumsum(), color=COLORS["blue"], alpha=0.4, linewidth=0.8)
|
||
axes[0, 0].set_ylabel("Daily Return")
|
||
axes[0, 0].set_title("Real - Daily Returns")
|
||
axes[0, 1].set_ylabel("Cumulative Return")
|
||
axes[0, 1].set_title("Real - Cumulative")
|
||
for ax in axes[0]:
|
||
ax.axhline(0, color=COLORS["neutral"], linestyle="--", linewidth=0.5, alpha=0.5)
|
||
|
||
# Bottom row: Synthetic data
|
||
for i in range(n_sample_paths):
|
||
axes[1, 0].plot(synthetic_sequences[i, :, 0], color=COLORS["copper"], alpha=0.4, linewidth=0.8)
|
||
axes[1, 1].plot(
|
||
synthetic_sequences[i, :, 0].cumsum(), color=COLORS["copper"], alpha=0.4, linewidth=0.8
|
||
)
|
||
axes[1, 0].set_ylabel("Daily Return")
|
||
axes[1, 0].set_xlabel("Time Step")
|
||
axes[1, 0].set_title("Synthetic - Daily Returns")
|
||
axes[1, 1].set_ylabel("Cumulative Return")
|
||
axes[1, 1].set_xlabel("Time Step")
|
||
axes[1, 1].set_title("Synthetic - Cumulative")
|
||
for ax in axes[1]:
|
||
ax.axhline(0, color=COLORS["neutral"], linestyle="--", linewidth=0.5, alpha=0.5)
|
||
|
||
# Match y-axis for fair comparison
|
||
for col in range(2):
|
||
ymin = min(axes[0, col].get_ylim()[0], axes[1, col].get_ylim()[0])
|
||
ymax = max(axes[0, col].get_ylim()[1], axes[1, col].get_ylim()[1])
|
||
axes[0, col].set_ylim(ymin, ymax)
|
||
axes[1, col].set_ylim(ymin, ymax)
|
||
|
||
fig.suptitle("Real vs Synthetic Sample Paths (Asset 0)", fontsize=12, y=1.02)
|
||
plt.tight_layout()
|
||
plt.show()
|
||
|
||
# %%
|
||
# Trend + Seasonal decomposition visualization (Matplotlib, vertically stacked)
|
||
with torch.no_grad():
|
||
sample_batch = tensor_data[:4]
|
||
# Show decomposition at moderate noise level (t = T/4)
|
||
t_vis = CONFIG["timesteps"] // 4
|
||
trend, season, residual, x_noised = ema.ema_model.return_components(sample_batch, t_vis)
|
||
|
||
asset_idx = 0
|
||
components = [
|
||
(trend[0, :, asset_idx].cpu().numpy(), "Trend", COLORS["blue"]),
|
||
(season[0, :, asset_idx].cpu().numpy(), "Seasonal", COLORS["amber"]),
|
||
(residual[0, :, asset_idx].cpu().numpy(), "Residual", COLORS["copper"]),
|
||
]
|
||
|
||
fig, axes = plt.subplots(3, 1, figsize=(8, 6), sharex=True)
|
||
for ax, (data, label, color) in zip(axes, components, strict=False):
|
||
ax.plot(data, color=color, linewidth=1)
|
||
ax.set_ylabel(label)
|
||
ax.axhline(0, color=COLORS["neutral"], linestyle="--", linewidth=0.5, alpha=0.5)
|
||
|
||
axes[-1].set_xlabel("Time Step")
|
||
fig.suptitle(
|
||
f"Diffusion-TS Learned Decomposition at Intermediate Noise (t={t_vis}/{CONFIG['timesteps']})",
|
||
fontsize=12,
|
||
y=1.02,
|
||
)
|
||
plt.tight_layout()
|
||
plt.show()
|
||
|
||
# %% [markdown]
|
||
# **Interpretation**: The trend component captures slow drift (market direction),
|
||
# the seasonal component captures periodic oscillations (day-of-week effects,
|
||
# monthly cycles), and the residual captures high-frequency noise. This
|
||
# decomposition is analogous to classical STL but learned end-to-end within
|
||
# the diffusion framework. At higher noise levels (larger $t$), the
|
||
# decomposition becomes noisier as the model has less signal to work with.
|
||
|
||
|
||
# %% [markdown]
|
||
# ## 10. Regime-Conditional Generation
|
||
#
|
||
# This section implements **classifier guidance** for conditional diffusion:
|
||
#
|
||
# 1. Fit a Gaussian HMM on SPY returns to identify market regimes
|
||
# 2. Train a classifier on noised sequences labeled by regime
|
||
# 3. At sampling time, compute $\nabla_x \log p(\text{regime}|x_t)$ and
|
||
# shift the denoising trajectory toward the target regime
|
||
#
|
||
# ### Hidden Markov Models for Regime Detection
|
||
#
|
||
# A **Hidden Markov Model (HMM)** assumes the market cycles through unobserved
|
||
# "states" (regimes), each with its own return distribution. The Gaussian HMM
|
||
# assumes each regime has a distinct mean and variance. Given observed returns,
|
||
# the Viterbi algorithm infers the most likely regime sequence.
|
||
#
|
||
# Here we use a simple 2-state HMM as a label source for conditional generation.
|
||
# **Chapter 9 (Model-Based Feature Extraction)** covers HMMs in depth, including:
|
||
# - Multi-state regime models for volatility forecasting
|
||
# - Regime-switching strategies
|
||
# - Comparison with Markov-switching GARCH
|
||
|
||
# %%
|
||
# Fit HMM on SPY returns (first asset) from training period
|
||
spy_returns = returns[:, 0].reshape(-1, 1)
|
||
|
||
hmm = GaussianHMM(
|
||
n_components=CONFIG["n_regimes"], covariance_type="full", n_iter=200, random_state=42
|
||
)
|
||
hmm.fit(spy_returns)
|
||
|
||
regime_labels = hmm.predict(spy_returns)
|
||
regime_means = hmm.means_.flatten()
|
||
regime_vols = np.sqrt(hmm.covars_.flatten())
|
||
|
||
# Sort regimes by volatility (0=low-vol, 1=normal, 2=crisis)
|
||
sort_idx = np.argsort(regime_vols)
|
||
label_map = {old: new for new, old in enumerate(sort_idx)}
|
||
regime_labels = np.array([label_map[r] for r in regime_labels])
|
||
|
||
regime_names = ["Low-Vol", "High-Vol"] # 2 regimes for cleaner separation
|
||
print("\nHMM Regime Detection (SPY):")
|
||
for i in range(CONFIG["n_regimes"]):
|
||
mask = regime_labels == i
|
||
print(
|
||
f" {regime_names[i]}: {mask.sum():,} days, "
|
||
f"mean={regime_means[sort_idx[i]]:.4f}, vol={regime_vols[sort_idx[i]]:.4f}"
|
||
)
|
||
|
||
# %% [markdown]
|
||
# ### Label Training Sequences by Majority Regime
|
||
#
|
||
# Each training sequence of length $L$ spans multiple days. We assign the
|
||
# regime label that covers the majority of days in the window.
|
||
|
||
# %%
|
||
seq_len = CONFIG["seq_length"]
|
||
n_seq = len(returns) - seq_len + 1
|
||
|
||
# Label by last day in window (preserves rare regime labels better than majority vote)
|
||
seq_regime_labels = np.array([regime_labels[i + seq_len - 1] for i in range(n_seq)], dtype=np.int64)
|
||
|
||
# Check for underrepresented regimes and merge if needed
|
||
MIN_SEQUENCES = 20
|
||
regime_counts = {i: (seq_regime_labels == i).sum() for i in range(CONFIG["n_regimes"])}
|
||
active_regimes = [i for i, c in regime_counts.items() if c >= MIN_SEQUENCES]
|
||
|
||
if len(active_regimes) < CONFIG["n_regimes"]:
|
||
print(f"\nWarning: Only {len(active_regimes)} regimes have >= {MIN_SEQUENCES} sequences")
|
||
# Merge underrepresented regimes into the nearest active regime (by volatility)
|
||
for i in range(CONFIG["n_regimes"]):
|
||
if i not in active_regimes:
|
||
# Merge into nearest active regime by volatility
|
||
nearest = min(
|
||
active_regimes,
|
||
key=lambda a: abs(regime_vols[sort_idx[a]] - regime_vols[sort_idx[i]]),
|
||
)
|
||
seq_regime_labels[seq_regime_labels == i] = nearest
|
||
print(
|
||
f" Merged {regime_names[i]} ({regime_counts[i]} seq) into {regime_names[nearest]}"
|
||
)
|
||
|
||
# Remap labels to contiguous range
|
||
unique_labels = sorted(set(seq_regime_labels))
|
||
label_remap = {old: new for new, old in enumerate(unique_labels)}
|
||
seq_regime_labels = np.array([label_remap[l] for l in seq_regime_labels], dtype=np.int64)
|
||
n_active_regimes = len(unique_labels)
|
||
active_regime_names = [regime_names[l] for l in unique_labels]
|
||
else:
|
||
n_active_regimes = CONFIG["n_regimes"]
|
||
active_regime_names = regime_names
|
||
|
||
print(f"\nSequence regime distribution ({n_active_regimes} active regimes):")
|
||
for i in range(n_active_regimes):
|
||
count = (seq_regime_labels == i).sum()
|
||
print(f" {active_regime_names[i]}: {count} sequences ({count / len(seq_regime_labels):.1%})")
|
||
|
||
|
||
# %% [markdown]
|
||
# ### Regime Classifier on Noised Data
|
||
#
|
||
# The classifier must operate on noised sequences $x_t$ (not clean data),
|
||
# because during sampling we compute gradients w.r.t. partially-denoised
|
||
# samples. We reuse the Encoder architecture from Diffusion-TS with an
|
||
# attention pooling head for classification.
|
||
|
||
|
||
# %%
|
||
class GroupNorm32(nn.GroupNorm):
|
||
"""GroupNorm cast to float32 for numerical stability."""
|
||
|
||
def forward(self, input: torch.Tensor) -> torch.Tensor: # type: ignore[override]
|
||
return super().forward(input.float()).type(input.dtype)
|
||
|
||
|
||
# %%
|
||
class QKVAttention(nn.Module):
|
||
"""QKV attention for attention pooling."""
|
||
|
||
def __init__(self, n_heads):
|
||
super().__init__()
|
||
self.n_heads = n_heads
|
||
|
||
def forward(self, qkv):
|
||
bs, width, length = qkv.shape
|
||
ch = width // (3 * self.n_heads)
|
||
q, k, v = qkv.chunk(3, dim=1)
|
||
scale = 1 / math.sqrt(math.sqrt(ch))
|
||
weight = torch.einsum(
|
||
"bct,bcs->bts",
|
||
(q * scale).view(bs * self.n_heads, ch, length),
|
||
(k * scale).view(bs * self.n_heads, ch, length),
|
||
)
|
||
weight = torch.softmax(weight.float(), dim=-1).type(weight.dtype)
|
||
a = torch.einsum("bts,bcs->bct", weight, v.reshape(bs * self.n_heads, ch, length))
|
||
return a.reshape(bs, -1, length)
|
||
|
||
|
||
# %%
|
||
class AttentionPool(nn.Module):
|
||
"""Attention pooling: sequence → single vector."""
|
||
|
||
def __init__(self, embed_dim, num_heads_channels, output_dim=None):
|
||
super().__init__()
|
||
self.qkv_proj = nn.Conv1d(embed_dim, 3 * embed_dim, 1)
|
||
self.c_proj = nn.Conv1d(embed_dim, output_dim or embed_dim, 1)
|
||
self.num_heads = embed_dim // num_heads_channels
|
||
self.attention = QKVAttention(self.num_heads)
|
||
|
||
def forward(self, x):
|
||
b, c, *_ = x.shape
|
||
x = x.reshape(b, c, -1)
|
||
x = torch.cat([x.mean(dim=-1, keepdim=True), x], dim=-1)
|
||
x = self.qkv_proj(x)
|
||
x = self.attention(x)
|
||
x = self.c_proj(x)
|
||
return x[:, :, 0]
|
||
|
||
|
||
# %%
|
||
class RegimeClassifier(nn.Module):
|
||
"""Transformer classifier for noised time series sequences."""
|
||
|
||
def __init__(
|
||
self,
|
||
feature_size,
|
||
seq_length,
|
||
num_classes=3,
|
||
n_layer_enc=2,
|
||
n_embd=64,
|
||
n_heads=4,
|
||
num_head_channels=8,
|
||
):
|
||
super().__init__()
|
||
self.emb = Conv_MLP(feature_size, n_embd)
|
||
self.encoder = Encoder(n_layer_enc, n_embd, n_heads)
|
||
self.pos_enc = LearnablePositionalEncoding(n_embd, max_len=seq_length)
|
||
|
||
# Encoder output is (B, seq, n_embd); transpose to (B, n_embd, seq) for Conv1d ops
|
||
self.out = nn.Sequential(
|
||
Transpose((1, 2)),
|
||
GroupNorm32(32, n_embd),
|
||
nn.SiLU(),
|
||
AttentionPool(n_embd, num_head_channels, num_classes),
|
||
)
|
||
|
||
def forward(self, x, t):
|
||
emb = self.emb(x)
|
||
inp_enc = self.pos_enc(emb)
|
||
output = self.encoder(inp_enc, t)
|
||
return self.out(output)
|
||
|
||
|
||
# %% [markdown]
|
||
# ### Train the Classifier
|
||
#
|
||
# Training samples noised sequences at random timesteps and classifies them.
|
||
# The classifier must learn to identify regime characteristics even through
|
||
# heavy noise -- this is what makes the gradient signal useful during sampling.
|
||
|
||
# %%
|
||
classifier = RegimeClassifier(
|
||
feature_size=n_assets,
|
||
seq_length=CONFIG["seq_length"],
|
||
num_classes=n_active_regimes,
|
||
n_layer_enc=CONFIG["n_layer_enc"],
|
||
n_embd=CONFIG["d_model"],
|
||
n_heads=CONFIG["n_heads"],
|
||
).to(device)
|
||
|
||
cls_optimizer = torch.optim.Adam(classifier.parameters(), lr=CONFIG["classifier_lr"])
|
||
|
||
# Prepare labeled dataset
|
||
labels_tensor = torch.LongTensor(seq_regime_labels).to(device)
|
||
cls_dataset = TensorDataset(tensor_data, labels_tensor)
|
||
cls_loader = DataLoader(cls_dataset, batch_size=CONFIG["batch_size"], shuffle=True, drop_last=True)
|
||
cls_iter = cycle(cls_loader)
|
||
|
||
print(f"\nTraining regime classifier for {CONFIG['classifier_epochs']} epochs...")
|
||
|
||
for step in range(CONFIG["classifier_epochs"]):
|
||
classifier.train()
|
||
total_loss = 0.0
|
||
|
||
for _ in range(grad_accum):
|
||
batch_x, batch_y = next(cls_iter)
|
||
|
||
# Forward-noise the batch
|
||
t = torch.randint(
|
||
0, diffusion_model.num_timesteps, (batch_x.shape[0],), device=device
|
||
).long()
|
||
noise = torch.randn_like(batch_x)
|
||
x_noised = diffusion_model.q_sample(batch_x, t, noise)
|
||
|
||
logits = classifier(x_noised, t)
|
||
loss = F.cross_entropy(logits, batch_y) / grad_accum
|
||
loss.backward()
|
||
total_loss += loss.item()
|
||
|
||
cls_optimizer.step()
|
||
cls_optimizer.zero_grad()
|
||
|
||
if (step + 1) % 100 == 0 or step == 0:
|
||
print(
|
||
f" Epoch {step + 1}/{CONFIG['classifier_epochs']}: Loss = {total_loss:.4f}", flush=True
|
||
)
|
||
|
||
print("Classifier training complete.")
|
||
|
||
|
||
# %% [markdown]
|
||
# ### Classifier Guidance Function
|
||
#
|
||
# The `cond_fn` computes $\nabla_x \log p(y|x_t)$ -- the gradient of the
|
||
# classifier's log-probability for the target regime with respect to the
|
||
# noised input. This gradient steers the diffusion sampling toward
|
||
# sequences that the classifier recognizes as belonging to the target regime.
|
||
|
||
|
||
# %%
|
||
def make_cond_fn(classifier, target_regime, scale=1.0, temperature=1.0):
|
||
"""Create a conditioning function for classifier guidance.
|
||
|
||
Args:
|
||
classifier: Trained regime classifier
|
||
target_regime: Target regime index
|
||
scale: Gradient scale - higher = stronger steering toward target regime
|
||
temperature: Softmax temperature - higher = softer classifier, more diverse samples
|
||
"""
|
||
|
||
def cond_fn(x, t, **kwargs):
|
||
with torch.enable_grad():
|
||
x_in = x.detach().requires_grad_(True)
|
||
logits = classifier(x_in, t)
|
||
# Temperature scaling: higher temp = softer distribution, less confident
|
||
log_probs = F.log_softmax(logits / temperature, dim=-1)
|
||
target = torch.full((x.shape[0],), target_regime, device=x.device, dtype=torch.long)
|
||
selected = log_probs[range(len(logits)), target]
|
||
return torch.autograd.grad(selected.sum(), x_in)[0] * scale
|
||
|
||
return cond_fn
|
||
|
||
|
||
# %% [markdown]
|
||
# ### Generate Regime-Conditional Samples
|
||
#
|
||
# For each regime, we generate synthetic sequences steered by the classifier.
|
||
# Then we compare the volatility characteristics of generated samples against
|
||
# historical data from that regime.
|
||
|
||
# %%
|
||
# N_COND is set in the parameters cell above
|
||
|
||
regime_samples = {}
|
||
for regime_id in range(n_active_regimes):
|
||
# Regime-specific guidance settings
|
||
settings = CONFIG["guidance_settings"].get(
|
||
regime_id, {"scale": 0.5, "temperature": 1.0, "eta": 0.5}
|
||
)
|
||
print(f"\nGenerating {N_COND} samples for regime: {active_regime_names[regime_id]}...")
|
||
print(
|
||
f" Guidance: scale={settings['scale']}, temp={settings['temperature']}, eta={settings['eta']}"
|
||
)
|
||
|
||
cond_fn = make_cond_fn(
|
||
classifier, regime_id, scale=settings["scale"], temperature=settings["temperature"]
|
||
)
|
||
|
||
with torch.no_grad():
|
||
samples = (
|
||
ema.ema_model.generate_mts(
|
||
batch_size=N_COND,
|
||
cond_fn=cond_fn,
|
||
eta=settings["eta"],
|
||
)
|
||
.detach()
|
||
.cpu()
|
||
.numpy()
|
||
)
|
||
|
||
# Note: Do NOT apply VARIANCE_SCALE_FACTOR here. Classifier guidance already
|
||
# adjusts variance (and tends to inflate it for minority regimes). The variance
|
||
# scaling is only for unconditional samples where the raw model output is low.
|
||
|
||
# Denormalize
|
||
samples_flat = samples.reshape(-1, n_assets)
|
||
samples_denorm = scaler.inverse_transform(samples_flat).reshape(samples.shape)
|
||
regime_samples[regime_id] = samples_denorm.astype(np.float32)
|
||
|
||
vol = np.std(samples_denorm[:, :, 0])
|
||
print(f" {active_regime_names[regime_id]}: generated vol = {vol:.4f}")
|
||
|
||
|
||
# %% [markdown]
|
||
# ### Compare Regime-Conditional Statistics
|
||
#
|
||
# For each regime, we compare the volatility and mean return of the generated
|
||
# samples against the historical sequences labeled with that regime.
|
||
|
||
# %%
|
||
# Reference: unconditional synthetic volatility
|
||
uncond_vol = np.std(synthetic_sequences[:, :, 0])
|
||
real_vol = np.std(sequences[:, :, 0])
|
||
|
||
print("\n=== Regime-Conditional Evaluation ===")
|
||
print(f"Reference: Real vol={real_vol:.4f}, Unconditional synth vol={uncond_vol:.4f}")
|
||
print("Guidance settings: regime-specific (see above)")
|
||
print()
|
||
print(
|
||
f"{'Regime':<12} {'Hist Vol':>10} {'Gen Vol':>10} {'Vol Ratio':>10} {'Hist Mean':>10} {'Gen Mean':>10}"
|
||
)
|
||
print("-" * 66)
|
||
|
||
for regime_id in range(n_active_regimes):
|
||
mask = seq_regime_labels == regime_id
|
||
hist_seqs = sequences[mask]
|
||
gen_seqs = regime_samples[regime_id]
|
||
|
||
hist_vol = np.std(hist_seqs[:, :, 0])
|
||
gen_vol = np.std(gen_seqs[:, :, 0])
|
||
vol_ratio = gen_vol / hist_vol
|
||
hist_mean = np.mean(hist_seqs[:, :, 0])
|
||
gen_mean = np.mean(gen_seqs[:, :, 0])
|
||
|
||
print(
|
||
f"{active_regime_names[regime_id]:<12} {hist_vol:>10.4f} {gen_vol:>10.4f} {vol_ratio:>10.2f}x "
|
||
f"{hist_mean:>10.6f} {gen_mean:>10.6f}"
|
||
)
|
||
|
||
# %%
|
||
# Per-sequence volatility distribution diagnostics
|
||
print("\n=== Per-Sequence Volatility Distribution ===")
|
||
print(
|
||
f"{'Regime':<12} {'Source':<10} {'Min':>8} {'25%':>8} {'50%':>8} {'75%':>8} {'Max':>8} {'Kurtosis':>10}"
|
||
)
|
||
print("-" * 80)
|
||
|
||
for regime_id in range(n_active_regimes):
|
||
mask = seq_regime_labels == regime_id
|
||
hist_vols = np.std(sequences[mask][:, :, 0], axis=1)
|
||
gen_vols = np.std(regime_samples[regime_id][:, :, 0], axis=1)
|
||
|
||
for name, vols in [("Historical", hist_vols), ("Generated", gen_vols)]:
|
||
pcts = np.percentile(vols, [0, 25, 50, 75, 100])
|
||
kurt = calc_kurtosis(vols)
|
||
print(
|
||
f"{active_regime_names[regime_id]:<12} {name:<10} {pcts[0]:>8.4f} {pcts[1]:>8.4f} "
|
||
f"{pcts[2]:>8.4f} {pcts[3]:>8.4f} {pcts[4]:>8.4f} {kurt:>10.2f}"
|
||
)
|
||
|
||
# %% [markdown]
|
||
# **Interpretation**: Classifier guidance produces regime separation. Key observations:
|
||
#
|
||
# 1. **Variance scaling**: The model generates ~66% of real variance in normalized space.
|
||
# This is a known issue with diffusion models using trend+seasonal decomposition --
|
||
# the smooth components underestimate high-frequency variation. We apply post-hoc
|
||
# variance scaling to match training distribution.
|
||
#
|
||
# 2. **Low-Vol (majority, 88%)**: Unconditional samples are already Low-Vol-like, so
|
||
# gentle guidance suffices. With variance scaling, generated volatility matches
|
||
# historical (~1.0x ratio).
|
||
#
|
||
# 3. **High-Vol (minority, 12%)**: Classifier gradients must push harder to shift
|
||
# samples toward this regime, which can add variance. Some inflation (~1.3-1.5x)
|
||
# is expected for minority regimes.
|
||
#
|
||
# **Scale tuning**: Higher scale = more regime separation but more variance. Scale 0.75
|
||
# balances accuracy for the majority class with reasonable minority-class separation.
|
||
|
||
# %%
|
||
# Regime comparison: Historical vs Generated side-by-side
|
||
# Shared y-axis across ALL panels for fair volatility comparison
|
||
|
||
sns.set_style("whitegrid")
|
||
|
||
fig, axes = plt.subplots(
|
||
n_active_regimes, 2, figsize=(10, 3 * n_active_regimes), sharex=True, sharey=True
|
||
)
|
||
if n_active_regimes == 1:
|
||
axes = axes.reshape(1, 2)
|
||
|
||
regime_colors = {"hist": COLORS["blue"], "gen": COLORS["copper"]}
|
||
n_paths = 10
|
||
|
||
for regime_id in range(n_active_regimes):
|
||
mask = seq_regime_labels == regime_id
|
||
hist_seqs = sequences[mask]
|
||
gen_seqs = regime_samples[regime_id]
|
||
|
||
# Left: Historical paths
|
||
ax_hist = axes[regime_id, 0]
|
||
for j in range(min(n_paths, len(hist_seqs))):
|
||
ax_hist.plot(hist_seqs[j, :, 0], color=regime_colors["hist"], alpha=0.5, linewidth=0.8)
|
||
ax_hist.set_ylabel(f"{active_regime_names[regime_id]}\nDaily Return")
|
||
ax_hist.axhline(0, color=COLORS["neutral"], linestyle="--", linewidth=0.5, alpha=0.5)
|
||
sns.despine(ax=ax_hist)
|
||
|
||
# Right: Generated paths
|
||
ax_gen = axes[regime_id, 1]
|
||
for j in range(min(n_paths, len(gen_seqs))):
|
||
ax_gen.plot(gen_seqs[j, :, 0], color=regime_colors["gen"], alpha=0.5, linewidth=0.8)
|
||
ax_gen.axhline(0, color=COLORS["neutral"], linestyle="--", linewidth=0.5, alpha=0.5)
|
||
sns.despine(ax=ax_gen)
|
||
|
||
# Column titles
|
||
axes[0, 0].set_title(
|
||
f"Historical (vol: Low={np.std(sequences[seq_regime_labels == 0][:, :, 0]):.4f}, High={np.std(sequences[seq_regime_labels == 1][:, :, 0]):.4f})"
|
||
)
|
||
axes[0, 1].set_title(
|
||
f"Generated (vol: Low={np.std(regime_samples[0][:, :, 0]):.4f}, High={np.std(regime_samples[1][:, :, 0]):.4f})"
|
||
)
|
||
axes[-1, 0].set_xlabel("Time Step")
|
||
axes[-1, 1].set_xlabel("Time Step")
|
||
fig.suptitle("Conditional Generation Separates Volatility Regimes", fontsize=12, y=1.02)
|
||
plt.tight_layout()
|
||
plt.show()
|
||
|
||
# %%
|
||
# Collect all volatilities to set shared bins
|
||
all_hist_vols = []
|
||
all_gen_vols = []
|
||
for regime_id in range(n_active_regimes):
|
||
mask = seq_regime_labels == regime_id
|
||
all_hist_vols.extend(np.std(sequences[mask][:, :, 0], axis=1))
|
||
all_gen_vols.extend(np.std(regime_samples[regime_id][:, :, 0], axis=1))
|
||
|
||
all_vols = np.concatenate([all_hist_vols, all_gen_vols])
|
||
bins = np.linspace(all_vols.min(), all_vols.max(), 31)
|
||
|
||
# %%
|
||
# Volatility distribution comparison - shared x-axis across regimes
|
||
fig, axes = plt.subplots(
|
||
1, n_active_regimes, figsize=(5 * n_active_regimes, 4), sharex=True, sharey=True
|
||
)
|
||
if n_active_regimes == 1:
|
||
axes = [axes]
|
||
|
||
for regime_id, ax in enumerate(axes):
|
||
mask = seq_regime_labels == regime_id
|
||
hist_seqs = sequences[mask]
|
||
gen_seqs = regime_samples[regime_id]
|
||
|
||
hist_vols = np.std(hist_seqs[:, :, 0], axis=1)
|
||
gen_vols = np.std(gen_seqs[:, :, 0], axis=1)
|
||
|
||
ax.hist(
|
||
hist_vols,
|
||
bins=bins,
|
||
alpha=0.6,
|
||
label=f"Historical (μ={hist_vols.mean():.4f})",
|
||
color=COLORS["blue"],
|
||
density=True,
|
||
)
|
||
ax.hist(
|
||
gen_vols,
|
||
bins=bins,
|
||
alpha=0.6,
|
||
label=f"Generated (μ={gen_vols.mean():.4f})",
|
||
color=COLORS["copper"],
|
||
density=True,
|
||
)
|
||
ax.set_xlabel("Sequence Volatility (std)")
|
||
ax.set_title(f"{active_regime_names[regime_id]}")
|
||
ax.legend(fontsize=9)
|
||
sns.despine(ax=ax)
|
||
|
||
axes[0].set_ylabel("Density")
|
||
fig.suptitle(
|
||
"Generated Volatilities Track the Historical Distribution per Regime", fontsize=12, y=1.02
|
||
)
|
||
plt.tight_layout()
|
||
plt.show()
|
||
|
||
|
||
# %% [markdown]
|
||
# ## 11. Save Samples (Model checkpoint saved after training)
|
||
|
||
# %%
|
||
# Save samples and metadata (model checkpoint already saved in training section)
|
||
samples_dir = OUTPUT_DIR / "samples"
|
||
samples_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
np.save(samples_dir / "synthetic_sequences.npy", synthetic_sequences)
|
||
np.save(samples_dir / "holdout_returns.npy", holdout_returns)
|
||
np.save(samples_dir / "train_returns.npy", returns)
|
||
np.save(samples_dir / "historical_sequences.npy", sequences)
|
||
np.save(samples_dir / "seq_regime_labels.npy", seq_regime_labels)
|
||
|
||
# Save regime-conditional samples
|
||
for regime_id, samples in regime_samples.items():
|
||
np.save(samples_dir / f"regime_{regime_id}_samples.npy", samples)
|
||
np.save(samples_dir / "active_regime_names.npy", np.array(active_regime_names))
|
||
|
||
# Save evaluation metadata
|
||
metadata = {
|
||
"created_at": datetime.now(UTC).isoformat(),
|
||
"config": CONFIG,
|
||
"n_samples": N_SYNTHETIC,
|
||
"evaluation": {
|
||
"mean_ks_statistic": stats_results["mean_ks_statistic"],
|
||
"correlation_error": stats_results["correlation_error"],
|
||
"autocorrelation_error": stats_results["autocorrelation_error"],
|
||
"tstr_ratio": tstr_results["tstr_ratio"],
|
||
},
|
||
}
|
||
with open(samples_dir / "metadata.json", "w") as f:
|
||
json.dump(metadata, f, indent=2, default=str)
|
||
|
||
print(f"Saved samples to: {samples_dir}/")
|
||
|
||
|
||
# %% [markdown]
|
||
# ## Key Takeaways
|
||
#
|
||
# 1. **Interpretable decomposition**: Diffusion-TS separates denoising predictions
|
||
# into trend (polynomial), seasonal (Fourier), and residual components, making
|
||
# the generator's behavior inspectable.
|
||
#
|
||
# 2. **Fourier loss preserves spectral structure**: The frequency-domain regularizer
|
||
# encourages the model to match autocorrelation and periodicity patterns, which
|
||
# is critical for realistic financial time series.
|
||
#
|
||
# 3. **Classifier guidance enables conditional generation**: By training a separate
|
||
# classifier on noised data, we can steer sampling toward specific regimes
|
||
# (low-volatility, normal, crisis) -- enabling regime-specific stress testing
|
||
# with synthetic data.
|
||
#
|
||
# 4. **DDIM fast sampling**: Reducing reverse steps from 500 to 50 provides ~10x
|
||
# speedup with minimal quality loss, making generation practical.
|
||
#
|
||
# **Limitations**:
|
||
# - Classifier guidance quality depends on regime label accuracy (HMM is simple)
|
||
# - The guidance scale requires tuning per application
|
||
# - High-dimensional generation (50 assets) is computationally intensive
|
||
#
|
||
# **Next**: Section 5.5 discusses the Fidelity-Utility-Privacy framework for
|
||
# systematic evaluation of any synthetic generator.
|