| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from typing import Optional, Tuple, Dict, Any |
|
|
| import numpy as np |
| from numpy.lib.stride_tricks import sliding_window_view |
|
|
|
|
| |
| |
| |
| def _as_2d(X: np.ndarray) -> np.ndarray: |
| X = np.asarray(X, dtype=np.float64) |
| if X.ndim == 1: |
| X = X[:, None] |
| if X.ndim != 2: |
| raise ValueError("Expected 1D or 2D array.") |
| 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. |
| """ |
| F = _as_2d(F_tD) |
| L = int(L) |
| if L <= 0: |
| raise ValueError("L must be positive.") |
| if F.shape[0] < L: |
| raise ValueError(f"Need len(F) >= L. Got {F.shape[0]} < {L}.") |
|
|
| W = sliding_window_view(F, window_shape=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_KLD: np.ndarray) -> np.ndarray: |
| """ |
| Flatten windows (K, L, D) -> (K, L*D). |
| """ |
| W = np.asarray(W_KLD, dtype=np.float64) |
| if W.ndim != 3: |
| raise ValueError("Expected windows with shape (K, L, D).") |
| return W.reshape(W.shape[0], -1) |
|
|
|
|
| def _estimate_eps_from_library( |
| X_lib: np.ndarray, |
| *, |
| sample_pairs: int = 4096, |
| rng: Optional[np.random.Generator] = None, |
| eps_min: float = 1e-12, |
| ) -> float: |
| """ |
| Heuristic bandwidth: median of squared distances over random pairs. |
| Works well enough for analog forecasting, avoids O(C^2). |
| """ |
| X = np.asarray(X_lib, dtype=np.float64) |
| n = X.shape[0] |
| if n <= 1: |
| return 1.0 |
|
|
| if rng is None: |
| rng = np.random.default_rng(0) |
|
|
| m = int(min(max(64, sample_pairs), n * (n - 1) // 2)) |
| i = rng.integers(0, n, size=m) |
| j = rng.integers(0, n, size=m) |
| neq = (i != j) |
| if not np.any(neq): |
| j = (i + 1) % n |
| neq = np.ones_like(i, dtype=bool) |
| i = i[neq] |
| j = j[neq] |
|
|
| diff = X[i] - X[j] |
| d2 = np.einsum("ij,ij->i", diff, diff, optimize=True) |
| med = float(np.median(d2)) if d2.size else 1.0 |
| return float(max(med, eps_min)) |
|
|
|
|
| def _kernel_weights_rbf( |
| X_lib: np.ndarray, |
| x_q: np.ndarray, |
| lib_norm2: np.ndarray, |
| *, |
| beta: float, |
| eps: float, |
| top_k: Optional[int], |
| ) -> Tuple[np.ndarray, np.ndarray]: |
| """ |
| Compute row-normalized RBF weights over library windows. |
| |
| Returns: |
| idx: indices used (full or top_k subset) |
| w: weights over idx (sum to 1) |
| """ |
| X_lib = np.asarray(X_lib, dtype=np.float64) |
| x_q = np.asarray(x_q, dtype=np.float64).reshape(-1) |
| lib_norm2 = np.asarray(lib_norm2, dtype=np.float64).reshape(-1) |
|
|
| q_norm2 = float(np.dot(x_q, x_q)) |
| |
| dots = X_lib @ x_q |
| d2 = lib_norm2 + q_norm2 - 2.0 * dots |
| np.maximum(d2, 0.0, out=d2) |
|
|
| if top_k is not None: |
| k = int(top_k) |
| k = max(1, min(k, d2.size)) |
| |
| idx = np.argpartition(d2, kth=k - 1)[:k] |
| d2_use = d2[idx] |
| else: |
| idx = np.arange(d2.size) |
| d2_use = d2 |
|
|
| beta = float(beta) |
| eps = float(eps) |
| if eps <= 0: |
| raise ValueError("eps must be > 0.") |
|
|
| |
| |
| ex = -beta * (d2_use / eps) |
| ex = ex - float(np.max(ex)) |
| kvec = np.exp(ex) |
|
|
| s = float(np.sum(kvec)) |
| if not np.isfinite(s) or s <= 0: |
| |
| j = int(idx[np.argmin(d2_use)]) |
| return np.array([j], dtype=np.int64), np.array([1.0], dtype=np.float64) |
|
|
| w = (kvec / s).astype(np.float64, copy=False) |
| return idx.astype(np.int64, copy=False), w |
|
|
|
|
| @dataclass |
| class ALSAInfo: |
| L: int |
| D: int |
| ell: int |
| C: int |
| eps: float |
| beta: float |
| top_k: Optional[int] |
| |
| weights: Optional[list[Tuple[np.ndarray, np.ndarray]]] = None |
|
|
|
|
| |
| |
| |
| def alsa_forecast( |
| prefix: np.ndarray, |
| *, |
| steps: int, |
| L: int, |
| beta: float = 1.0, |
| eps: Optional[float] = None, |
| eps_mul: float = 1.0, |
| top_k: Optional[int] = None, |
| return_info: bool = False, |
| store_weights: bool = False, |
| seed: int = 0, |
| ) -> Tuple[np.ndarray, ALSAInfo] | np.ndarray: |
| """ |
| ALSA nonparametric autoregressive forecast from a prefix. |
| |
| Parameters |
| ---------- |
| prefix : array (ell, D) |
| Context time series (observed). |
| steps : int |
| Forecast horizon H. |
| L : int |
| Takens window length. Requires ell >= L+1 for at least one context pair. |
| beta : float |
| Kernel sharpness (larger -> more local / NN-like). |
| eps : float or None |
| Bandwidth scale in the kernel. If None, estimated from library windows. |
| eps_mul : float |
| Multiplier applied to estimated eps (or provided eps) to tune locality. |
| top_k : int or None |
| If set, restrict weights to the top_k nearest library windows (sparse analog). |
| This can massively speed up forecasting for large contexts. |
| return_info : bool |
| Return ALSAInfo diagnostics. |
| store_weights : bool |
| If True, store (indices, weights) per forecast step in ALSAInfo. |
| seed : int |
| RNG seed used only for eps estimation when eps is None. |
| |
| Returns |
| ------- |
| preds : (steps, D) float64 |
| info : ALSAInfo (if return_info=True) |
| |
| Notes |
| ----- |
| Library construction: |
| - windows W_A = prefix[A:A+L] for A=0..K-1, K=ell-L+1 |
| - context/library indices A=0..C-1 where C=ell-L |
| - targets y_A = prefix[A+L] |
| - query window starts at prefix[ell-L:ell] |
| """ |
| prefix = _as_2d(prefix) |
| ell, D = prefix.shape |
| L = int(L) |
| H = int(steps) |
| if H <= 0: |
| out = np.zeros((0, D), dtype=np.float64) |
| if return_info: |
| info = ALSAInfo(L=L, D=D, ell=ell, C=max(0, ell - L), eps=float(eps or 1.0), beta=float(beta), top_k=top_k) |
| return out, info |
| return out |
|
|
| if ell < L + 1: |
| raise ValueError(f"Need prefix length ell >= L+1 for ALSA. Got ell={ell}, L={L}.") |
|
|
| |
| W_all = _sliding_windows(prefix, L) |
| K = W_all.shape[0] |
| C = ell - L |
| |
| W_lib = np.ascontiguousarray(W_all[:C]) |
| Y_lib = np.ascontiguousarray(prefix[L : L + C]) |
|
|
| X_lib = _flatten_windows(W_lib) |
| lib_norm2 = np.sum(X_lib * X_lib, axis=1) |
|
|
| rng = np.random.default_rng(int(seed)) |
| if eps is None: |
| eps0 = _estimate_eps_from_library(X_lib, rng=rng) |
| else: |
| eps0 = float(eps) |
| eps_used = float(max(1e-12, eps0 * float(eps_mul))) |
|
|
| |
| cur = prefix[-L:, :].copy() |
| preds = np.zeros((H, D), dtype=np.float64) |
|
|
| weights_log = [] if (return_info and store_weights) else None |
|
|
| for h in range(H): |
| x_q = cur.reshape(-1) |
| idx, w = _kernel_weights_rbf( |
| X_lib, x_q, lib_norm2, |
| beta=float(beta), |
| eps=eps_used, |
| top_k=top_k, |
| ) |
| y_hat = (w[:, None] * Y_lib[idx]).sum(axis=0) |
| preds[h] = y_hat |
|
|
| if weights_log is not None: |
| |
| weights_log.append((idx.copy(), w.copy())) |
|
|
| |
| if L > 1: |
| cur[:-1] = cur[1:] |
| cur[-1] = y_hat |
|
|
| if not return_info: |
| return preds |
|
|
| info = ALSAInfo( |
| L=L, |
| D=D, |
| ell=ell, |
| C=C, |
| eps=eps_used, |
| beta=float(beta), |
| top_k=top_k, |
| weights=weights_log, |
| ) |
| return preds, info |
|
|
|
|
| __all__ = ["alsa_forecast", "ALSAInfo"] |
|
|