# LISA.py # ============================================================ # LISA: NLSA encoder + GPLM nonlinear baseline decoder # + dense in-context Gaussian Process Regression (GPR) # on residuals in diffusion coordinate space. # # Baseline: # window W (L,D) -> ψ(W) in R^r (via NLSA Nyström OOS) # y_glob = GPLM(ψ) in R^D (nonlinear stable decoder) # # In-context: # Given prefix length ℓ >= L: # Context pairs count: K_ctx = ℓ - L # For each context window: # ψ_i = encode(W_i) # e_i = y_true_i - y_glob(ψ_i) # Fit GP on residuals e(ψ) # # Rollout: # y = y_glob(ψ_q) + w_eff(var_q) * e_gp_mean(ψ_q) # (optionally sample residual using GP posterior variance) # # If ℓ == L (no context pairs), LISA == baseline AR (NLSA + GPLM). # # Dependencies: # numpy, scipy # # Repo assumptions: # - NLSA.py exports class NLSA with: # .L, .D_, .K_, .psi_ (K,r), .R_ (N,D centered if center=True), .mu_ # .encode_window(W_LD) -> (r,) # - GPLM.py or gplm.py exports class GPLM with: # GPLM(X_train, Y_train, ...) where X_train is (N,r), Y_train is (N,D) # __call__(Xq) maps (B,r)->(B,D) and (r,)->(D,) # # ============================================================ from __future__ import annotations from typing import Optional, Tuple import numpy as np from numpy.lib.stride_tricks import sliding_window_view from scipy.linalg import cho_factor, cho_solve, solve_triangular # ------------------------------------------------------------ # Robust imports (package or flat) # ------------------------------------------------------------ try: # if you're inside a package from .NLSA import NLSA # type: ignore except Exception: from NLSA import NLSA # type: ignore try: from .GPLM import GPLM # type: ignore except Exception: try: from GPLM import GPLM # type: ignore except Exception: from gplm import GPLM # type: ignore # ============================================================ # Small utilities # ============================================================ 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 _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)) # ============================================================ # LISA main class # ============================================================ class LISA: """ LISA: NLSA encoder + GPLM baseline + dense GPR residual correction. - If prefix length ℓ == L: no in-context pairs -> baseline AR only. - If ℓ > L: residual GP is fit on the prefix windows and applied during rollout. Notes: - Residual GP is multi-output with independent output dims sharing a kernel. That means GP mean is vector in R^D, but variance is a scalar (same for all dims). """ def __init__( self, F_tX: np.ndarray, *, L: int, rank: int, # ------------------ NLSA encoder hyperparams ------------------ beta: float | None = None, alpha: float = 1.0, center_outputs: bool = True, drop_first: bool = True, seed: int = 0, nlsa_kwargs: Optional[dict] = None, # ------------------ GPLM baseline decoder hyperparams ---------- gplm_kwargs: Optional[dict] = None, # ------------------ IC / GPR controls ------------------------- ctx_min_windows: Optional[int] = None, ctx_k0: float = 10.0, # base mixing K_ctx/(K_ctx+ctx_k0) gp_noise2: float = 1e-3, # σ_n^2 in (K + σ_n^2 I) gp_kernel: str = "rbf", # "rbf" or "linear" gp_rbf_ell: float | None = None, # fixed, or auto from context gp_rbf_q: float = 0.5, # ------------------ stability / trust gating ----------------- use_var_gate: bool = True, gate_tau2: float = 1.0, gate_mode: str = "rational", # "rational" or "exp" ): if nlsa_kwargs is None: nlsa_kwargs = {} if gplm_kwargs is None: gplm_kwargs = {} self._rng = np.random.default_rng(int(seed)) # ---- Encoder ---- self.base = NLSA( F_tX, L=int(L), rank=int(rank), beta=beta, alpha=float(alpha), center=bool(center_outputs), drop_first=bool(drop_first), seed=int(seed), **nlsa_kwargs, ) base = self.base if base.psi_ is None or base.psi_.shape[1] == 0: raise ValueError("NLSA produced zero latent dims. Increase rank or set drop_first=False.") self.L = int(base.L) self.D = int(base.D_) self.r = int(base.psi_.shape[1]) # mean handling: baseline GPLM will be trained in *centered output space* self.center_outputs = bool(base.center) self.mu_X = base.mu_.reshape(-1).astype(np.float64) if self.center_outputs else np.zeros((self.D,), dtype=np.float64) # ---- Train baseline GPLM: ψ -> next sample (centered) ---- K = int(base.K_) N_pairs = K - 1 if N_pairs < 4: raise ValueError("Training series too short for LISA with this L.") X_train = np.asarray(base.psi_[:N_pairs, :], dtype=np.float64) # (K-1,r) Y_train_c = np.asarray(base.R_[self.L:self.L + N_pairs, :], dtype=np.float64) # (K-1,D) centered if base.center gkw = dict(gplm_kwargs) gkw.setdefault("seed", int(seed)) gkw.setdefault("center_X", False) # IMPORTANT: Y_train is already centered gkw.setdefault("sigma2", 1e-5) gkw.setdefault("jitter", 1e-8) gkw.setdefault("m", min(1024, X_train.shape[0])) gkw.setdefault("inducing", "kmeans_medoids") # optional speedup at inference: # gkw.setdefault("pred_k", 128) self.gplm = GPLM(X_train, Y_train_c, **gkw) # ---- 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(1, self.r + 1) # ---- GP residual controls ---- self.gp_noise2 = float(gp_noise2) self.gp_kernel = str(gp_kernel).lower().strip() if self.gp_kernel not in ("rbf", "linear"): raise ValueError("gp_kernel must be 'rbf' or 'linear'") 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 # ---- variance 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'") # ============================================================ # Baseline utilities # ============================================================ def _encode_batch(self, W_BLD: np.ndarray) -> np.ndarray: """ Encode windows (B,L,D) -> (B,r) using base.encode_window in a loop. """ W = np.asarray(W_BLD, dtype=np.float64) if W.ndim == 2: W = W[None, :, :] B = W.shape[0] out = np.zeros((B, self.r), dtype=np.float64) for i in range(B): out[i] = self.base.encode_window(W[i]) return out def _baseline_centered_batch(self, Psi_Br: np.ndarray) -> np.ndarray: """ GPLM baseline in centered output space: (B,r) -> (B,D) """ Psi = np.asarray(Psi_Br, dtype=np.float64) if Psi.ndim == 1: Psi = Psi[None, :] Yc = self.gplm(Psi) # (B,D) return np.asarray(Yc, dtype=np.float64) def _baseline_centered_one(self, psi_r: np.ndarray) -> np.ndarray: return self._baseline_centered_batch(np.asarray(psi_r, dtype=np.float64))[0] # ============================================================ # GP kernel utilities (residual GP) # ============================================================ def _gp_kernel_matrix(self, Psi_ctx: np.ndarray) -> Tuple[np.ndarray, Optional[float]]: """ Build dense kernel matrix K(Ψ,Ψ) on context points. Returns (K_mat, ell_used). """ Psi_ctx = np.asarray(Psi_ctx, dtype=np.float64) if self.gp_kernel == "linear": return Psi_ctx @ Psi_ctx.T, None # RBF d2 = _pairwise_sq_dists(Psi_ctx) if self.gp_rbf_ell is None: ell_used = _estimate_rbf_ell_from_d2(d2, q=self.gp_rbf_q) else: ell_used = float(self.gp_rbf_ell) self.last_gp_rbf_ell_ = ell_used K = np.exp(-0.5 * d2 / (ell_used**2 + 1e-12)) return K, ell_used def _gp_kernel_eval(self, Psi_ctx: np.ndarray, psi_q: np.ndarray, ell_used: Optional[float]) -> np.ndarray: """ k(Ψ,ψ_q) for query ψ_q. Returns (K_ctx,). """ Psi_ctx = np.asarray(Psi_ctx, dtype=np.float64) psi_q = np.asarray(psi_q, dtype=np.float64) if self.gp_kernel == "linear": return Psi_ctx @ psi_q assert ell_used is not None diff = Psi_ctx - psi_q[None, :] d2 = np.einsum("kr,kr->k", diff, diff, optimize=True) return np.exp(-0.5 * d2 / (ell_used**2 + 1e-12)) def _k_qq(self, psi_q: np.ndarray) -> float: """ Kernel self-similarity k(ψ,ψ). """ psi_q = np.asarray(psi_q, dtype=np.float64) if self.gp_kernel == "linear": return float(np.dot(psi_q, psi_q)) return 1.0 # RBF def _gate_from_var(self, var_f: float) -> float: """ Convert predictive function variance -> trust weight in [0,1]. """ 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)) # rational return float(tau2 / (tau2 + v)) # ============================================================ # Public API # ============================================================ def predict_one_step(self, W_LD: np.ndarray) -> np.ndarray: """ Predict one step from a single window (L,D) using baseline only. Returns (D,) in original (uncentered) scale. """ 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}") psi = self.base.encode_window(W) y_c = self._baseline_centered_one(psi) return y_c + self.mu_X if self.center_outputs else y_c 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 (ℓ,D), ℓ >= L. If ℓ == L (IC nullset): baseline AR only (NLSA+GPLM). If ℓ > L: uses in-context dense GPR on residuals. Parameters ---------- prefix : (ℓ,D) steps : horizon H return_var : return scalar GP function variance per step (H,) sample : sample GP residual instead of mean (generative mode) rng : RNG for sampling include_obs_noise : if sample=True, draw using var_y = var_f + gp_noise2 Returns ------- preds : (H,D) (or (D,) if H==1) vars : (H,) if return_var=True """ prefix = _as_2d(prefix) ell, D = prefix.shape if D != self.D: raise ValueError(f"LISA 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 window for AR rollout cur = prefix[-self.L:, :].copy() # --------------------------- # If IC nullset -> baseline AR # --------------------------- K_ctx = ell - self.L if K_ctx <= 0 or self.r == 0: 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 if 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) # (K_n,L,D), K_n = ell-L+1 # context pairs correspond to windows that have next sample observed W_ctx = np.ascontiguousarray(W_all[:K_ctx, :, :]) # (K_ctx,L,D) Y_ctx = np.asarray(prefix[self.L:self.L + K_ctx, :], dtype=np.float64) # (K_ctx,D) # center targets consistently with baseline training Y_ctx_c = (Y_ctx - self.mu_X[None, :]) if self.center_outputs else Y_ctx # encode ψ for context Psi_ctx = self._encode_batch(W_ctx) # (K_ctx,r) # baseline predictions on context Y_glob_ctx_c = self._baseline_centered_batch(Psi_ctx) # (K_ctx,D) # residual table E_ctx = Y_ctx_c - Y_glob_ctx_c # (K_ctx,D) # --------------------------- # Fit dense GP on residuals # --------------------------- K_mat, ell_used = self._gp_kernel_matrix(Psi_ctx) # (K_ctx,K_ctx) K_reg = K_mat + self.gp_noise2 * np.eye(K_ctx, dtype=np.float64) # Cholesky factorization (preferred) cf = cho_factor(K_reg, lower=True, check_finite=False) alpha = cho_solve(cf, E_ctx, check_finite=False) # (K_ctx,D) Lfac, lower = cf # base context mixing weight w_ctx_base = float(K_ctx) / float(K_ctx + 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 # --------------------------- # AR rollout with GP residual # --------------------------- for h in range(H): psi_q = self.base.encode_window(cur) # (r,) # baseline y_glob_c = self._baseline_centered_one(psi_q) # (D,) # GP residual mean k_eval = self._gp_kernel_eval(Psi_ctx, psi_q, ell_used) # (K_ctx,) e_mean = k_eval @ alpha # (D,) # GP residual variance (function variance) # var_f = k(qq) - 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, self._k_qq(psi_q) - quad) if return_var: vars_out[h] = float(var_f) # trust gating from variance w_gate = self._gate_from_var(var_f) w_eff = w_ctx_base * w_gate # optionally sample residual 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,)) # combine y_c = y_glob_c + w_eff * e_use y = (y_c + self.mu_X) if self.center_outputs else y_c preds[h] = y # update rolling 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 def _rollout_baseline(self, seed_LD: np.ndarray, H: int) -> np.ndarray: """ Baseline AR rollout only: NLSA encode + GPLM decode. """ 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.base.encode_window(cur) y_c = self._baseline_centered_one(psi) y = (y_c + self.mu_X) if self.center_outputs else y_c out[h] = y if self.L > 1: cur[:-1] = cur[1:] cur[-1] = y return out __all__ = ["LISA"]