dima / ASA.py
sparsetrace's picture
Create ASA.py
ffccf5c verified
Raw
History Blame Contribute Delete
18.7 kB
# ASA.py
# ============================================================
# ASA: Adaptive Singular-spectrum Analysis
#
# Components
# ----------
# 1) SSA encoder (linear):
# - Build Hankel windows W_t (L,D) from training series F (N,D)
# - Flatten each window into z_t in R^{L*D}
# - Learn top-r principal directions via randomized SVD / PCA on window space
# - Encode any window: psi = (vec(W) - mean_window) @ V_r in R^r
#
# 2) LGP decoder (linear-kernel GP / ridge in primal):
# - Learn global one-step map: psi_t -> x_{t+L} (predict next sample)
#
# 3) Markovian In-Context Mechanism (nonlinear):
# - At inference, if prefix length ell > L:
# context pairs: (psi_i, residual_i) for i=0..K_ctx-1
# residual_i = y_true_i - y_glob(psi_i)
# Fit dense RBF GP on residuals in psi-space and apply during rollout:
# y = y_glob(psi_q) + w_eff(var_q) * e_gp_mean(psi_q)
# (Markovian: correction depends only on current psi_q.)
#
# Notes
# -----
# - This is essentially "LISA with SSA+LGP instead of NLSA+GPLM".
# - Dense IC GP is O(M^3) with M=context points used; use ctx_max_points to cap.
#
# Dependencies
# ------------
# numpy, scipy
# and LGP.py (this repo)
# ============================================================
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal, Optional, Tuple, Union
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from scipy.linalg import cho_factor, cho_solve, solve_triangular
try:
from .LGP import LGP # type: ignore
except Exception:
from LGP import LGP # type: ignore
# ============================================================
# Helpers
# ============================================================
def _as_2d(X: np.ndarray) -> np.ndarray:
X = np.asarray(X, dtype=float)
if X.ndim == 1:
X = X[:, None]
return X
def _sliding_windows(F_tD: np.ndarray, L: int) -> np.ndarray:
"""
Return windows W (K,L,D) from F (N,D) with K=N-L+1.
Handles possible (K,D,L) output from sliding_window_view.
"""
F = _as_2d(F_tD)
W = sliding_window_view(F, window_shape=int(L), axis=0)
a, b = W.shape[1], W.shape[2]
if (a, b) == (L, F.shape[1]):
return np.ascontiguousarray(W)
if (a, b) == (F.shape[1], L):
return np.ascontiguousarray(np.transpose(W, (0, 2, 1)))
raise ValueError(f"Unexpected window shape {W.shape} for L={L}, D={F.shape[1]}.")
def _flatten_windows(W_BLD: np.ndarray) -> np.ndarray:
"""
(B,L,D) -> (B, L*D)
"""
W = np.asarray(W_BLD, dtype=np.float64)
if W.ndim == 2:
W = W[None, :, :]
B, L, D = W.shape
return np.ascontiguousarray(W.reshape(B, L * D))
def _pairwise_sq_dists(X: np.ndarray) -> np.ndarray:
"""
Dense pairwise squared Euclidean distances (n,n) for X (n,r).
"""
X = np.asarray(X, dtype=np.float64)
x2 = np.sum(X * X, axis=1, keepdims=True)
d2 = x2 + x2.T - 2.0 * (X @ X.T)
np.maximum(d2, 0.0, out=d2)
return d2
def _estimate_rbf_ell_from_d2(d2_mat: np.ndarray, q: float = 0.5, eps: float = 1e-12, min_ell: float = 1e-6) -> float:
"""
If kernel is exp(-||x-y||^2/(2 ell^2)), a good heuristic is:
ell^2 ~= quantile(d^2)/2
"""
iu = np.triu_indices_from(d2_mat, k=1)
vals = d2_mat[iu]
if vals.size == 0:
return 1.0
qv = float(np.quantile(vals, q))
ell = np.sqrt(max(qv, 0.0) / 2.0 + eps)
return float(max(ell, min_ell))
# ============================================================
# Randomized SVD (Halko et al.) for SSA window PCA
# ============================================================
@dataclass
class _RandSVDParams:
oversample: int = 8
n_iter: int = 2
seed: int = 0
def _randomized_top_right_singular_vectors(X: np.ndarray, r: int, *, params: _RandSVDParams) -> np.ndarray:
"""
Return V_r (p,r) approximate top right singular vectors of X (n,p),
using randomized SVD.
Complexity: O(n*p*(r+o)) + O((r+o)^2*(n+p))
"""
X = np.asarray(X, dtype=np.float64)
n, p = X.shape
r = int(min(max(1, r), p))
k = int(min(p, r + int(params.oversample)))
rng = np.random.default_rng(int(params.seed))
Omega = rng.standard_normal(size=(p, k)) # (p,k)
# Y = X Omega
Y = X @ Omega # (n,k)
# Power iterations: (X X^T)^q X Omega improves separation
for _ in range(int(params.n_iter)):
Y = X @ (X.T @ Y) # (n,k)
# Orthonormalize Y -> Q
Q, _ = np.linalg.qr(Y, mode="reduced") # (n,k)
# Small matrix B = Q^T X
B = Q.T @ X # (k,p)
# SVD of B
# B = Uhat S V^T => right vectors of X approx V
_, _, Vt = np.linalg.svd(B, full_matrices=False)
V = Vt.T # (p,k)
return np.ascontiguousarray(V[:, :r])
# ============================================================
# ASA main class
# ============================================================
SubsampleMode = Literal["uniform", "random"]
class ASA:
"""
ASA: SSA encoder + LGP global head + nonlinear Markovian IC GP on residuals.
Baseline:
window W (L,D) -> psi(W) in R^r (SSA linear projection)
y_glob = LGP(psi) in R^D
In-context (Markovian nonlinear residual GP):
For prefix length ell >= L:
K_ctx = ell - L context pairs
psi_i = encode(window_i)
e_i = y_true_i - y_glob(psi_i)
Fit RBF GP on (psi_i -> e_i), apply during rollout.
"""
def __init__(
self,
F_tX: np.ndarray,
*,
L: int,
rank: int,
# ---------------- SSA / PCA encoder ----------------
center_windows: bool = True,
whiten_windows: bool = False, # standardize window coords before PCA (optional)
randomized_svd: bool = True,
rsvd_oversample: int = 8,
rsvd_n_iter: int = 2,
seed: int = 0,
# ---------------- LGP decoder ----------------------
lgp_kwargs: Optional[dict] = None,
# ---------------- IC GP controls -------------------
ctx_min_windows: Optional[int] = None,
ctx_max_points: Optional[int] = 2000, # cap dense GP points (set None to use all)
ctx_subsample: SubsampleMode = "uniform",
ctx_k0: float = 10.0, # base mixing K/(K+ctx_k0)
gp_noise2: float = 1e-3, # σ_n^2 in (K + σ_n^2 I)
gp_rbf_ell: Optional[float] = None, # fixed, or auto from context
gp_rbf_q: float = 0.5,
# ---------------- trust gating ---------------------
use_var_gate: bool = True,
gate_tau2: float = 1.0,
gate_mode: Literal["rational", "exp"] = "rational",
):
if lgp_kwargs is None:
lgp_kwargs = {}
self._rng = np.random.default_rng(int(seed))
F = _as_2d(F_tX).astype(np.float64, copy=False)
self.N, self.D = F.shape
self.L = int(L)
self.r = int(rank)
if self.L < 2:
raise ValueError("ASA requires L>=2 for meaningful SSA windows.")
if self.N <= self.L + 2:
raise ValueError("Training series too short for this L.")
# ---- Build training windows ----
W_all = _sliding_windows(F, self.L) # (K,L,D)
K = W_all.shape[0]
N_pairs = K - 1
if N_pairs < 4:
raise ValueError("Not enough training pairs for ASA (increase N or reduce L).")
Z_all = _flatten_windows(W_all) # (K, P), P=L*D
P = Z_all.shape[1]
# ---- Window preprocessing for encoder ----
self.center_windows = bool(center_windows)
self.whiten_windows = bool(whiten_windows)
if self.center_windows:
self.win_mean = Z_all.mean(axis=0)
else:
self.win_mean = np.zeros((P,), dtype=np.float64)
Zc = Z_all - self.win_mean[None, :]
if self.whiten_windows:
self.win_std = np.maximum(Zc.std(axis=0), 1e-12)
else:
self.win_std = np.ones((P,), dtype=np.float64)
Zp = Zc / self.win_std[None, :]
# ---- SSA basis via PCA (top right singular vectors) ----
r_eff = int(min(max(1, self.r), P))
self.r = r_eff
if randomized_svd:
V_r = _randomized_top_right_singular_vectors(
Zp, r_eff,
params=_RandSVDParams(
oversample=int(rsvd_oversample),
n_iter=int(rsvd_n_iter),
seed=int(seed),
),
)
else:
# Exact SVD (can be heavy for large K,P)
_, _, Vt = np.linalg.svd(Zp, full_matrices=False)
V_r = Vt.T[:, :r_eff]
self.V_r = np.ascontiguousarray(V_r, dtype=np.float64) # (P,r)
# ---- Encode training windows (psi) ----
Psi_all = Zp @ self.V_r # (K,r)
Psi_train = np.ascontiguousarray(Psi_all[:N_pairs, :]) # (K-1,r)
# ---- Targets: next sample after each window ----
Y_train = np.ascontiguousarray(F[self.L:self.L + N_pairs, :]) # (K-1,D)
# ---- Fit global decoder (LGP) ----
lkw = dict(lgp_kwargs)
lkw.setdefault("sigma2", 1e-5)
lkw.setdefault("jitter", 1e-10)
lkw.setdefault("center_X", True)
lkw.setdefault("whiten_latent", True) # often helps linear features
lkw.setdefault("solver", "chol")
self.lgp = LGP(Psi_train, Y_train, **lkw)
# ---- IC controls ----
self.ctx_k0 = float(ctx_k0)
self.ctx_min_windows = int(ctx_min_windows) if ctx_min_windows is not None else max(8, self.r + 1)
self.ctx_max_points = None if ctx_max_points is None else int(ctx_max_points)
self.ctx_subsample = str(ctx_subsample).lower().strip()
if self.ctx_subsample not in ("uniform", "random"):
raise ValueError("ctx_subsample must be 'uniform' or 'random'")
# ---- GP residual controls ----
self.gp_noise2 = float(gp_noise2)
self.gp_rbf_ell = None if gp_rbf_ell is None else float(gp_rbf_ell)
self.gp_rbf_q = float(gp_rbf_q)
self.last_gp_rbf_ell_: Optional[float] = None
# ---- gating ----
self.use_var_gate = bool(use_var_gate)
self.gate_tau2 = float(gate_tau2)
self.gate_mode = str(gate_mode).lower().strip()
if self.gate_mode not in ("rational", "exp"):
raise ValueError("gate_mode must be 'rational' or 'exp'")
# ============================================================
# SSA encoder
# ============================================================
def encode_window(self, W_LD: np.ndarray) -> np.ndarray:
"""
Encode one window (L,D) -> (r,)
"""
W = np.asarray(W_LD, dtype=np.float64)
if W.ndim == 1:
W = W[:, None]
if W.shape != (self.L, self.D):
raise ValueError(f"Expected window shape {(self.L, self.D)}, got {W.shape}")
z = W.reshape(-1).astype(np.float64, copy=False) # (P,)
zc = z - self.win_mean
zp = zc / self.win_std
return zp @ self.V_r
def _encode_batch(self, W_BLD: np.ndarray) -> np.ndarray:
"""
Vectorized encode windows (B,L,D) -> (B,r)
"""
Z = _flatten_windows(W_BLD) # (B,P)
Zc = Z - self.win_mean[None, :]
Zp = Zc / self.win_std[None, :]
return np.ascontiguousarray(Zp @ self.V_r)
# ============================================================
# GP kernel utilities (RBF on psi-space)
# ============================================================
def _gp_kernel_matrix(self, Psi_ctx: np.ndarray) -> Tuple[np.ndarray, float]:
Psi_ctx = np.asarray(Psi_ctx, dtype=np.float64)
d2 = _pairwise_sq_dists(Psi_ctx)
if self.gp_rbf_ell is None:
ell = _estimate_rbf_ell_from_d2(d2, q=self.gp_rbf_q)
else:
ell = float(self.gp_rbf_ell)
self.last_gp_rbf_ell_ = float(ell)
K = np.exp(-0.5 * d2 / (ell**2 + 1e-12))
return K, float(ell)
def _gp_kernel_eval(self, Psi_ctx: np.ndarray, psi_q: np.ndarray, ell: float) -> np.ndarray:
Psi_ctx = np.asarray(Psi_ctx, dtype=np.float64)
psi_q = np.asarray(psi_q, dtype=np.float64)
diff = Psi_ctx - psi_q[None, :]
d2 = np.einsum("kr,kr->k", diff, diff, optimize=True)
return np.exp(-0.5 * d2 / (ell**2 + 1e-12))
def _gate_from_var(self, var_f: float) -> float:
if not self.use_var_gate:
return 1.0
v = max(float(var_f), 0.0)
tau2 = max(float(self.gate_tau2), 1e-18)
if self.gate_mode == "exp":
return float(np.exp(-v / tau2))
return float(tau2 / (tau2 + v))
# ============================================================
# Public API
# ============================================================
def predict_one_step(self, W_LD: np.ndarray) -> np.ndarray:
"""
Baseline one-step prediction from a single window (L,D).
"""
psi = self.encode_window(W_LD)
return self.lgp(psi)
def __call__(
self,
prefix: np.ndarray,
steps: int = 1,
*,
return_var: bool = False,
sample: bool = False,
rng: Optional[np.random.Generator] = None,
include_obs_noise: bool = True,
):
"""
Autoregressive forecast from prefix (ell,D), ell >= L.
If ell == L: baseline AR only (SSA+LGP).
If ell > L: Markovian IC GP on residuals in psi-space.
"""
prefix = _as_2d(prefix).astype(np.float64, copy=False)
ell, D = prefix.shape
if D != self.D:
raise ValueError(f"ASA trained with D={self.D}, got prefix D={D}.")
if ell < self.L:
raise ValueError(f"Need prefix length ell >= L={self.L}.")
H = int(steps)
if H <= 0:
out = np.zeros((0, self.D), dtype=np.float64)
return (out, np.zeros((0,), dtype=np.float64)) if return_var else out
if rng is None:
rng = self._rng
# seed for rollout
cur = prefix[-self.L:, :].copy()
# ---------------------------
# Baseline only if no context
# ---------------------------
K_ctx = ell - self.L
if K_ctx <= 0 or K_ctx < self.ctx_min_windows:
preds = self._rollout_baseline(cur, H)
if return_var:
return preds[0] if H == 1 else preds, np.zeros((H,), dtype=np.float64)
return preds[0] if H == 1 else preds
# ---------------------------
# Build context (windows + targets)
# ---------------------------
W_all = _sliding_windows(prefix, self.L) # (ell-L+1, L, D)
W_ctx = np.ascontiguousarray(W_all[:K_ctx, :, :]) # (K_ctx, L, D)
Y_ctx = np.ascontiguousarray(prefix[self.L:self.L + K_ctx, :]) # (K_ctx, D)
# encode all context windows
Psi_ctx_full = self._encode_batch(W_ctx) # (K_ctx, r)
# optionally subsample context to keep dense GP feasible
if self.ctx_max_points is not None and K_ctx > self.ctx_max_points:
M = int(self.ctx_max_points)
if self.ctx_subsample == "uniform":
idx = np.linspace(0, K_ctx - 1, M).round().astype(np.int64)
else:
idx = self._rng.choice(K_ctx, size=M, replace=False).astype(np.int64)
Psi_ctx = Psi_ctx_full[idx]
Y_ctx_use = Y_ctx[idx]
else:
Psi_ctx = Psi_ctx_full
Y_ctx_use = Y_ctx
M = Psi_ctx.shape[0]
# baseline predictions on context
Y_glob_ctx = self.lgp(Psi_ctx) # (M, D)
# residuals to learn in-context
E_ctx = Y_ctx_use - Y_glob_ctx # (M, D)
# ---------------------------
# Fit dense RBF GP on residuals
# ---------------------------
K_mat, ell_used = self._gp_kernel_matrix(Psi_ctx) # (M,M)
K_reg = K_mat + self.gp_noise2 * np.eye(M, dtype=np.float64)
cf = cho_factor(K_reg, lower=True, check_finite=False)
alpha = cho_solve(cf, E_ctx, check_finite=False) # (M, D)
Lfac, lower = cf
# base mixing strength from amount of context
w_ctx_base = float(M) / float(M + self.ctx_k0) if self.ctx_k0 > 0 else 1.0
preds = np.zeros((H, self.D), dtype=np.float64)
vars_out = np.zeros((H,), dtype=np.float64) if return_var else None
# ---------------------------
# Rollout with Markovian IC correction
# ---------------------------
for h in range(H):
psi_q = self.encode_window(cur) # (r,)
# baseline
y_glob = self.lgp(psi_q) # (D,)
# residual mean
k_eval = self._gp_kernel_eval(Psi_ctx, psi_q, ell_used) # (M,)
e_mean = k_eval @ alpha # (D,)
# function variance: var_f = 1 - k^T (K+σ^2I)^{-1} k
u = solve_triangular(Lfac, k_eval, lower=lower, check_finite=False)
quad = float(np.dot(u, u))
var_f = max(0.0, 1.0 - quad)
if return_var:
vars_out[h] = float(var_f)
# trust gate
w_gate = self._gate_from_var(var_f)
w_eff = w_ctx_base * w_gate
e_use = e_mean
if sample:
var_y = var_f + (self.gp_noise2 if include_obs_noise else 0.0)
var_y = max(0.0, float(var_y))
if var_y > 0:
e_use = e_mean + np.sqrt(var_y) * rng.standard_normal(size=(self.D,))
y = y_glob + w_eff * e_use
preds[h] = y
# update window
if self.L > 1:
cur[:-1] = cur[1:]
cur[-1] = y
if return_var:
if H == 1:
return preds[0], vars_out
return preds, vars_out
return preds[0] if H == 1 else preds
# ============================================================
# Baseline rollout
# ============================================================
def _rollout_baseline(self, seed_LD: np.ndarray, H: int) -> np.ndarray:
cur = np.asarray(seed_LD, dtype=np.float64).copy()
out = np.zeros((H, self.D), dtype=np.float64)
for h in range(int(H)):
psi = self.encode_window(cur)
y = self.lgp(psi)
out[h] = y
if self.L > 1:
cur[:-1] = cur[1:]
cur[-1] = y
return out
__all__ = ["ASA"]