| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
| try: |
| |
| from .NLSA import NLSA |
| except Exception: |
| from NLSA import NLSA |
|
|
| try: |
| from .GPLM import GPLM |
| except Exception: |
| try: |
| from GPLM import GPLM |
| except Exception: |
| from gplm import GPLM |
|
|
|
|
| |
| |
| |
|
|
| 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)) |
|
|
|
|
| |
| |
| |
|
|
| 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, |
| |
| 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_kwargs: Optional[dict] = None, |
| |
| ctx_min_windows: Optional[int] = None, |
| ctx_k0: float = 10.0, |
| gp_noise2: float = 1e-3, |
| gp_kernel: str = "rbf", |
| gp_rbf_ell: float | None = None, |
| gp_rbf_q: float = 0.5, |
| |
| use_var_gate: bool = True, |
| gate_tau2: float = 1.0, |
| gate_mode: str = "rational", |
| ): |
| if nlsa_kwargs is None: |
| nlsa_kwargs = {} |
| if gplm_kwargs is None: |
| gplm_kwargs = {} |
|
|
| self._rng = np.random.default_rng(int(seed)) |
|
|
| |
| 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]) |
|
|
| |
| 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) |
|
|
| |
| 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) |
| Y_train_c = np.asarray(base.R_[self.L:self.L + N_pairs, :], dtype=np.float64) |
|
|
| gkw = dict(gplm_kwargs) |
| gkw.setdefault("seed", int(seed)) |
| gkw.setdefault("center_X", False) |
| gkw.setdefault("sigma2", 1e-5) |
| gkw.setdefault("jitter", 1e-8) |
| gkw.setdefault("m", min(1024, X_train.shape[0])) |
| gkw.setdefault("inducing", "kmeans_medoids") |
| |
| |
|
|
| self.gplm = GPLM(X_train, Y_train_c, **gkw) |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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'") |
|
|
| |
| |
| |
|
|
| 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) |
| 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] |
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| 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 |
|
|
| 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)) |
| |
| return float(tau2 / (tau2 + v)) |
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| cur = prefix[-self.L:, :].copy() |
|
|
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| W_all = _sliding_windows(prefix, self.L) |
| |
| W_ctx = np.ascontiguousarray(W_all[:K_ctx, :, :]) |
| Y_ctx = np.asarray(prefix[self.L:self.L + K_ctx, :], dtype=np.float64) |
|
|
| |
| Y_ctx_c = (Y_ctx - self.mu_X[None, :]) if self.center_outputs else Y_ctx |
|
|
| |
| Psi_ctx = self._encode_batch(W_ctx) |
|
|
| |
| Y_glob_ctx_c = self._baseline_centered_batch(Psi_ctx) |
|
|
| |
| E_ctx = Y_ctx_c - Y_glob_ctx_c |
|
|
| |
| |
| |
| K_mat, ell_used = self._gp_kernel_matrix(Psi_ctx) |
| K_reg = K_mat + self.gp_noise2 * np.eye(K_ctx, dtype=np.float64) |
|
|
| |
| cf = cho_factor(K_reg, lower=True, check_finite=False) |
| alpha = cho_solve(cf, E_ctx, check_finite=False) |
| Lfac, lower = cf |
|
|
| |
| 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 |
|
|
| |
| |
| |
| for h in range(H): |
| psi_q = self.base.encode_window(cur) |
|
|
| |
| y_glob_c = self._baseline_centered_one(psi_q) |
|
|
| |
| k_eval = self._gp_kernel_eval(Psi_ctx, psi_q, ell_used) |
| e_mean = k_eval @ alpha |
|
|
| |
| |
| 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) |
|
|
| |
| 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_c = y_glob_c + w_eff * e_use |
| y = (y_c + self.mu_X) if self.center_outputs else y_c |
|
|
| preds[h] = y |
|
|
| |
| 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"] |