# GPLM.py # ============================================================ # GPLM: Gaussian-Process-Like Manifold decoder (Nyström KRR / GP) # # Core idea: # Given latent coordinates R_ix (N,d) and corresponding outputs R_iX (N,D), # learn a smooth decoder f: R^d -> R^D using inducing points (Nyström). # # This file is designed to be a *building block* for: # - DIMA decoding (latent -> ambient) # - LISA / ELSA global heads (psi -> next sample) # - any kernel-smooth regression w/ optional geometric flow # # Main API: # - __init__(...) trains automatically # - __call__(R_ax, batch_size=None) returns mean predictions # - predict(R_ax, return_var=True) returns mean + scalar uncertainty # - kernel_mass(R_ax) returns a cheap "support" diagnostic # - flow(...) integrates geodesic-ish latent motion under pullback metric # # Flow extensions: # flow(..., k_tangent=K) -> projects to top-K metric eigendirections # flow(..., force_fn=callable) -> adds an external force to momentum update # # NOTE on geometry: # The pullback metric g(r) = J(r)^T J(r) is PSD and can be singular when d > D. # This implementation uses robust eigendecomposition fallback when Cholesky fails. # # ============================================================ from __future__ import annotations from dataclasses import dataclass from typing import Any, Callable, Dict, Literal, Optional, Tuple, Union import numpy as np import scipy.linalg as la from ann import ANNBackend, make_ann from utils import fps_indices, median_eps_from_knn_d2, sqdist_ab InducingMode = Literal["random_subset", "fps", "kmeans_medoids", "given"] MetricSolver = Literal["auto", "chol", "eigh"] def _kmeans2_safe(Z: np.ndarray, m: int, seed: int = 0) -> np.ndarray: """ KMeans centers with a safe fallback. Uses scipy.cluster.vq.kmeans2 if available; otherwise samples points. """ Z = np.asarray(Z) m = int(min(max(1, m), Z.shape[0])) try: from scipy.cluster.vq import kmeans2 # type: ignore # minit="points" picks initial centers from data -> stable for medoids snapping C, _ = kmeans2(Z.astype(np.float64, copy=False), m, minit="points", seed=seed) return C.astype(Z.dtype, copy=False) except Exception: rng = np.random.default_rng(seed) idx = rng.choice(Z.shape[0], size=m, replace=False) return Z[idx] @dataclass class _MetricFact: """A small wrapper that can solve g x = b robustly.""" method: MetricSolver g: np.ndarray # (d,d) chol: Optional[Tuple[np.ndarray, bool]] = None eigvals: Optional[np.ndarray] = None eigvecs: Optional[np.ndarray] = None # for pseudo-inverse safety eig_clip: float = 1e-12 def solve(self, b: np.ndarray) -> np.ndarray: """Return x = g^{-1} b (vector).""" b = np.asarray(b, dtype=np.float64) if self.method == "chol" and self.chol is not None: return la.cho_solve(self.chol, b, check_finite=False) # eigen fallback if self.eigvals is None or self.eigvecs is None: # last-resort: direct solve return np.linalg.solve(self.g, b) lam = np.maximum(self.eigvals, float(self.eig_clip)) V = self.eigvecs return V @ ((V.T @ b) / lam) def project_topk(self, v: np.ndarray, k: int) -> np.ndarray: """ Project v onto the span of the top-k eigenvectors of g. """ v = np.asarray(v, dtype=np.float64) k = int(k) if k <= 0: return np.zeros_like(v) d = v.shape[0] # Ensure eigendecomposition exists if self.eigvals is None or self.eigvecs is None: w, V = np.linalg.eigh(self.g) idx = np.argsort(w)[::-1] self.eigvals = w[idx] self.eigvecs = V[:, idx] k = min(k, d) Vk = self.eigvecs[:, :k] return Vk @ (Vk.T @ v) class GPLM: """ GPLM = Inducing-point / Nyström GP-like decoder on latents. Training: R_ix: (N,d) latents R_iX: (N,D) ambients Choose inducing Z_mx (m << N), typically subset (medoids) of R_ix. Latent kernel (unnormalized Gaussian affinity): C_im = exp(-β * ||R_ix - Z_mx||^2 / ε) (N,m) W_mn = exp(-β * ||Z_mx - Z_nx||^2 / ε) (m,m) Reduced KRR/GP mean solve: M_mX = (C^T C + σ2 W + jitter I)^-1 (C^T (R_iX - mean_X)) Predict: For novel R_ax: C_am = exp(-β * ||R_ax - Z_mx||^2 / ε) R_aX = C_am M_mX + mean_X Uncertainty: We provide a cheap scalar "leverage" uncertainty: u(r) = C(r)^T A^{-1} C(r) (A = C^T C + σ2 W + jitter I) and return σ2 * u(r) as a proxy variance (scalar per query). Geometry: flow() integrates geodesic-ish motion on the pullback metric induced by decoder, with options: - k_tangent projection to avoid drift in nullspace when d > D - force_fn to add "prior forces" (e.g. DDPM score, density barrier) """ def __init__( self, R_ix: np.ndarray, R_iX: np.ndarray, *, # ASCII names (preferred for library APIs) beta: float = 1.0, # ε estimation eps: Optional[float] = None, k_eps: int = 256, eps_use_kth: bool = True, eps_mul: float = 1.0, # regularization sigma2: float = 1e-5, jitter: float = 1e-8, # inducing m: int = 1024, inducing: InducingMode = "kmeans_medoids", Z_mx: Optional[np.ndarray] = None, seed: int = 0, # preprocess center_X: bool = True, whiten_latent: bool = False, dtype: Any = np.float32, # compute/memory fit_block: int = 8192, # inference speed pred_k: Optional[int] = None, ann_backend: ANNBackend = "auto", ann_params: Optional[Dict[str, Any]] = None, n_jobs: int = -1, # accept unicode kwargs (β, ε, κ_eps, σ2, pred_κ, ...) **kwargs: Any, ): # ---- map unicode kwargs -> ascii ---- if "β" in kwargs: beta = kwargs.pop("β") if "ε" in kwargs: eps = kwargs.pop("ε") if "κ_eps" in kwargs: k_eps = kwargs.pop("κ_eps") if "ε_use_kth" in kwargs: eps_use_kth = kwargs.pop("ε_use_kth") if "ε_mul" in kwargs: eps_mul = kwargs.pop("ε_mul") if "σ2" in kwargs: sigma2 = kwargs.pop("σ2") if "pred_κ" in kwargs: pred_k = kwargs.pop("pred_κ") if kwargs: raise TypeError(f"Unexpected kwargs: {sorted(kwargs.keys())}") # ---- store params (provide both spellings) ---- self.beta = float(beta) self.β = self.beta self.sigma2 = float(sigma2) self.σ2 = self.sigma2 self.jitter = float(jitter) self.seed = int(seed) self.dtype = dtype self.fit_block = int(fit_block) # ---- validate / cast ---- R_ix = np.ascontiguousarray(np.asarray(R_ix).astype(self.dtype, copy=False)) R_iX = np.ascontiguousarray(np.asarray(R_iX).astype(self.dtype, copy=False)) if R_ix.ndim != 2 or R_iX.ndim != 2 or R_ix.shape[0] != R_iX.shape[0]: raise ValueError("R_ix must be (N,d) and R_iX must be (N,D) with same N.") self.R_ix = R_ix self.R_iX = R_iX self.N, self.d_lat = R_ix.shape _, self.D = R_iX.shape # ---- center output ---- self.center_X = bool(center_X) if self.center_X: self.mean_X = R_iX.mean(axis=0).astype(np.float64) Y = (R_iX.astype(np.float64) - self.mean_X[None, :]) else: self.mean_X = np.zeros((self.D,), dtype=np.float64) Y = R_iX.astype(np.float64) # ---- latent whitening (optional) ---- self.whiten_latent = bool(whiten_latent) Ztrain = R_ix.astype(np.float64) if self.whiten_latent: self.lat_mean_x = Ztrain.mean(axis=0) self.lat_std_x = np.maximum(Ztrain.std(axis=0), 1e-12) Ztrain_w = (Ztrain - self.lat_mean_x) / self.lat_std_x else: self.lat_mean_x = np.zeros((self.d_lat,), dtype=np.float64) self.lat_std_x = np.ones((self.d_lat,), dtype=np.float64) Ztrain_w = Ztrain self.R_ix_w = Ztrain_w # (N,d) float64 # ---- ANN on training latents (for eps + medoids snapping) ---- self.ann_train, self.ann_backend = make_ann(ann_backend, ann_params=ann_params, n_jobs=n_jobs) self.ann_train.build(self.R_ix_w.astype(self.dtype, copy=False)) # ---- eps via kNN distances on latents ---- if eps is None: k_eps = int(min(max(8, int(k_eps)), self.N - 1)) # ask for k_eps+1 to try to include self j_iK1, D2_iK1 = self.ann_train.search(self.R_ix_w.astype(self.dtype, copy=False), k_eps + 1) i = np.arange(self.N)[:, None] is_self = (j_iK1 == i) if np.any(is_self): D2_iK = np.empty((self.N, k_eps), dtype=np.float64) for ii in range(self.N): keep = (j_iK1[ii] != ii) D2_iK[ii] = D2_iK1[ii][keep][:k_eps] else: D2_iK = D2_iK1[:, :k_eps].astype(np.float64, copy=False) eps_hat = median_eps_from_knn_d2(D2_iK, use_kth=bool(eps_use_kth)) else: eps_hat = float(eps) eps_hat *= float(eps_mul) if eps_hat <= 0: raise ValueError("eps must be > 0.") self.eps = float(eps_hat) self.ε = self.eps # ---- choose inducing points (in whitened latent space) ---- rng = np.random.default_rng(self.seed) m = int(min(max(1, int(m)), self.N)) if Z_mx is not None: Zm = np.asarray(Z_mx, dtype=np.float64) if Zm.ndim != 2 or Zm.shape[1] != self.d_lat: raise ValueError("Z_mx must be (m, d_lat).") Zm_w = (Zm - self.lat_mean_x) / self.lat_std_x else: if inducing == "random_subset": idx = rng.choice(self.N, size=m, replace=False) Zm_w = self.R_ix_w[idx] elif inducing == "fps": idx = fps_indices(self.R_ix_w, m=m, seed=self.seed) Zm_w = self.R_ix_w[idx] elif inducing == "kmeans_medoids": C = _kmeans2_safe(self.R_ix_w, m, seed=self.seed).astype(np.float64, copy=False) j_cm, _ = self.ann_train.search(C.astype(self.dtype, copy=False), 1) idx = j_cm.reshape(-1).astype(np.int64) # de-duplicate and refill if needed idx_u = np.unique(idx) if idx_u.size < m: needed = m - idx_u.size pool = np.setdiff1d(np.arange(self.N), idx_u, assume_unique=False) if pool.size >= needed: extra = rng.choice(pool, size=needed, replace=False) else: extra = rng.choice(self.N, size=needed, replace=True) idx = np.concatenate([idx_u, extra]) else: idx = idx_u[:m] Zm_w = self.R_ix_w[idx] elif inducing == "given": raise ValueError("Provide Z_mx when inducing='given'.") else: raise ValueError(f"Unknown inducing mode: {inducing!r}") self.Z_mx_w = np.ascontiguousarray(Zm_w.astype(np.float64, copy=False)) self.m = int(self.Z_mx_w.shape[0]) # also store raw inducing points (unwhitened) for convenience self.Z_mx = (self.Z_mx_w * self.lat_std_x[None, :]) + self.lat_mean_x[None, :] # ---- ANN on inducing points for fast prediction ---- self.ann_Z, _ = make_ann(ann_backend, ann_params=ann_params, n_jobs=n_jobs) self.ann_Z.build(self.Z_mx_w.astype(self.dtype, copy=False)) # pred_k if pred_k is None: self.pred_k = None else: self.pred_k = int(min(max(1, int(pred_k)), self.m)) self.pred_κ = self.pred_k # unicode alias # ---- W_mm ---- D2_mm = sqdist_ab(self.Z_mx_w, self.Z_mx_w) # (m,m) W_mm = np.exp(-self.beta * (D2_mm.astype(np.float64) / self.eps)) W_mm.flat[:: self.m + 1] += self.jitter self.W_mm = W_mm # (m,m) # ---- accumulate G=C^T C and B=C^T Y ---- G_mm = np.zeros((self.m, self.m), dtype=np.float64) B_mX = np.zeros((self.m, self.D), dtype=np.float64) bs = int(self.fit_block) for i0 in range(0, self.N, bs): i1 = min(self.N, i0 + bs) Zi = self.R_ix_w[i0:i1] # (b,d) float64 D2_im = sqdist_ab(Zi, self.Z_mx_w) # (b,m) C_im = np.exp(-self.beta * (D2_im.astype(np.float64) / self.eps)) G_mm += C_im.T @ C_im B_mX += C_im.T @ Y[i0:i1] A_mm = G_mm + self.sigma2 * W_mm A_mm.flat[:: self.m + 1] += self.jitter # Store factorization for mean *and* uncertainty self._A_mm = A_mm self._A_cF = la.cho_factor(A_mm, lower=True, check_finite=False) self.M_mX = la.cho_solve(self._A_cF, B_mX, check_finite=False) # (m,D) # ------------------------------------------------------------ # Convenience alternate constructor # ------------------------------------------------------------ @classmethod def fit(cls, R_ix: np.ndarray, R_iX: np.ndarray, **kwargs: Any) -> "GPLM": """Alias constructor (kept for convenience).""" return cls(R_ix, R_iX, **kwargs) # ------------------------------------------------------------ # Inference # ------------------------------------------------------------ def __call__(self, R_ax: Union[np.ndarray, list], *, batch_size: Optional[int] = None) -> np.ndarray: """ Mean prediction only. For uncertainty, use predict(..., return_var=True). """ R_ax = np.asarray(R_ax) single = (R_ax.ndim == 1) if single: R_ax = R_ax[None, :] R_ax = np.ascontiguousarray(R_ax.astype(self.dtype, copy=False)) if batch_size is None: Y = self._decode_mean(R_ax) else: bs = int(batch_size) out = [] for s in range(0, R_ax.shape[0], bs): out.append(self._decode_mean(R_ax[s : s + bs])) Y = np.vstack(out) return Y[0] if single else Y def predict( self, R_ax: Union[np.ndarray, list], *, return_var: bool = False, batch_size: Optional[int] = None, ) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]: """ Predict mean and (optional) scalar variance proxy per query. Returns: mean: (A,D) var_scalar: (A,) approximately sigma2 * C A^{-1} C^T """ R_ax = np.asarray(R_ax) single = (R_ax.ndim == 1) if single: R_ax = R_ax[None, :] R_ax = np.ascontiguousarray(R_ax.astype(self.dtype, copy=False)) if not return_var: mean = self.__call__(R_ax, batch_size=batch_size) return mean[None, :] if single and mean.ndim == 1 else mean if batch_size is None: mean, var = self._decode_mean_var(R_ax) else: bs = int(batch_size) ms = [] vs = [] for s in range(0, R_ax.shape[0], bs): m, v = self._decode_mean_var(R_ax[s : s + bs]) ms.append(m) vs.append(v) mean = np.vstack(ms) var = np.concatenate(vs, axis=0) if single: return mean[0], var[0] return mean, var def _decode_mean(self, R_ax: np.ndarray) -> np.ndarray: """Mean decoder: (A,d)->(A,D)""" Za = R_ax.astype(np.float64) Za_w = (Za - self.lat_mean_x) / self.lat_std_x # (A,d) if self.pred_k is None or self.pred_k == self.m: D2_am = sqdist_ab(Za_w, self.Z_mx_w) # (A,m) C_am = np.exp(-self.beta * (D2_am.astype(np.float64) / self.eps)) Yc = C_am @ self.M_mX # centered outputs (A,D) else: j_aK, D2_aK = self.ann_Z.search(Za_w.astype(self.dtype, copy=False), self.pred_k) W = np.exp(-self.beta * (D2_aK.astype(np.float64) / self.eps)) # (A,k) M = self.M_mX[j_aK] # (A,k,D) Yc = np.sum(W[:, :, None] * M, axis=1) # (A,D) return Yc + self.mean_X[None, :] def _decode_mean_var(self, R_ax: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: """ Mean + scalar uncertainty proxy. var_scalar[a] = sigma2 * C(a)^T A^{-1} C(a) """ Za = R_ax.astype(np.float64) Za_w = (Za - self.lat_mean_x) / self.lat_std_x # (A,d) # Use full inducing for uncertainty (simple + stable) D2_am = sqdist_ab(Za_w, self.Z_mx_w) # (A,m) C_am = np.exp(-self.beta * (D2_am.astype(np.float64) / self.eps)) # (A,m) # mean Yc = C_am @ self.M_mX # (A,D) mean = Yc + self.mean_X[None, :] # scalar variance proxy: sigma2 * diag(C A^{-1} C^T) # Compute each row's quadratic form via cho_solve # tmp = A^{-1} C^T -> shape (m,A) tmp = la.cho_solve(self._A_cF, C_am.T, check_finite=False) # (m,A) quad = np.sum(C_am.T * tmp, axis=0) # (A,) var = self.sigma2 * quad # scalar variance proxy return mean, var # ------------------------------------------------------------ # Diagnostics # ------------------------------------------------------------ def kernel_mass(self, R_ax: Union[np.ndarray, list]) -> np.ndarray: """ S(r) = sum_m exp(-beta ||r - z_m||^2 / eps) Useful as a quick "support" indicator: - large mass: near inducing cloud - tiny mass: off-support (decoder tends to mean) """ R_ax = np.asarray(R_ax, dtype=np.float64) single = (R_ax.ndim == 1) if single: R_ax = R_ax[None, :] Za_w = (R_ax - self.lat_mean_x[None, :]) / self.lat_std_x[None, :] D2 = sqdist_ab(Za_w, self.Z_mx_w) C = np.exp(-self.beta * (D2 / self.eps)) mass = np.sum(C, axis=1) return mass[0] if single else mass # ============================================================ # Geodesic flow on pullback manifold (no C_mm storage) # ============================================================ # # Key math: # Decoder: X = f(r) # Metric: g(r) = J(r)^T J(r) # # This integrator uses the same "generalized leapfrog" structure you had, # but: # - "eps" is renamed to dt # - a robust metric solver is used (Cholesky fallback to eig) # - optional top-k tangent projection is supported # - optional external force can be added to momentum updates # # External force API: # force_fn(r, v) -> f shape (d,) # Interpreted as "dp/dt = f" in coordinates (same object you add to momentum). # # ============================================================ def _rbf_cache_single( self, r_x: np.ndarray, *, idx_m: Optional[np.ndarray] ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """ Cache kernel terms at position r (single point): rw = (r - mean)/std Dw = rw - Zw = (r - z)/std Dw_over = Dw / std = (r - z)/std^2 (chain-rule for unwhitened coordinates) k_m: (k,) Dw_over: (k,d) grad_k: (k,d) where grad_k[m,x] = d/d r_x k_m If idx_m is None -> use all inducing points (k=m). Otherwise use subset (k=pred_k). """ c = self.beta / self.eps inv_std = 1.0 / self.lat_std_x # (d,) r = r_x.astype(np.float64, copy=False) rw = (r - self.lat_mean_x) / self.lat_std_x # (d,) Zw = self.Z_mx_w if idx_m is None else self.Z_mx_w[idx_m] # (k,d) Dw = rw[None, :] - Zw # (k,d) = (r-z)/std D2 = np.sum(Dw * Dw, axis=1) # (k,) k_m = np.exp(-c * D2) # (k,) Dw_over = Dw * inv_std[None, :] # (k,d) = (r-z)/std^2 # grad_k[m,x] = d/d r_x exp(-c ||Dw||^2) = -(2c) k_m * (Dw_x/std_x) grad_k = -(2.0 * c) * (k_m[:, None] * Dw_over) # (k,d) return k_m, Dw_over, grad_k def _metric_from_gradM( self, grad_k: np.ndarray, # (k,d) M_kX: np.ndarray, # (k,D) *, D_block: int = 8192, lam: float = 1e-8, metric_solver: MetricSolver = "auto", eig_clip: float = 1e-12, ) -> _MetricFact: """ Compute pullback metric g = J^T J without forming C_mm: J_{Xx} = sum_m grad_k[m,x] M_{mX} g_{xy} = sum_X J_{Xx} J_{Xy} Implemented by blocking over ambient dimension D: Jb = M_block^T @ grad_k (block,d) g += Jb^T @ Jb Returns a robust solver wrapper for g. """ k, d = grad_k.shape D = M_kX.shape[1] g = np.zeros((d, d), dtype=np.float64) for j0 in range(0, D, int(D_block)): j1 = min(D, j0 + int(D_block)) Mb = M_kX[:, j0:j1] # (k,block) Jb = Mb.T @ grad_k # (block,d) g += Jb.T @ Jb # (d,d) # sym + diagonal floor g = 0.5 * (g + g.T) g.flat[:: d + 1] += float(lam) # Robust factorization if metric_solver == "eigh": w, V = np.linalg.eigh(g) idx = np.argsort(w)[::-1] w = w[idx] V = V[:, idx] return _MetricFact(method="eigh", g=g, eigvals=w, eigvecs=V, eig_clip=float(eig_clip)) if metric_solver in ("chol", "auto"): try: cF = la.cho_factor(g, lower=True, check_finite=False) return _MetricFact(method="chol", g=g, chol=cF, eig_clip=float(eig_clip)) except la.LinAlgError: # fallback to eigen w, V = np.linalg.eigh(g) idx = np.argsort(w)[::-1] w = w[idx] V = V[:, idx] return _MetricFact(method="eigh", g=g, eigvals=w, eigvecs=V, eig_clip=float(eig_clip)) # final fallback w, V = np.linalg.eigh(g) idx = np.argsort(w)[::-1] w = w[idx] V = V[:, idx] return _MetricFact(method="eigh", g=g, eigvals=w, eigvecs=V, eig_clip=float(eig_clip)) def _force_from_cache_noC( self, k_m: np.ndarray, # (k,) Dw_over: np.ndarray, # (k,d) = (r-z)/std^2 v_x: np.ndarray, # (d,) M_kX: np.ndarray, # (k,D) *, D_block: int = 8192, ) -> np.ndarray: """ Geodesic momentum force (in your notation f_x = 0.5 v^y v^z ∂_x g_yz) computed without C_mm using ambient contraction: Jv_X = sum_m S_m M_{mX} Hv_{xX} = sum_m T_{xm} M_{mX} f_x = sum_X Hv_{xX} Jv_X where for RBF kernel with whitened latent (chain rule included): s_m = sum_y Dw_over[m,y] v_y S_m = -(2c) k_m s_m T_{xm} = k_m [ 4c^2 Dw_over[m,x] s_m - 2c v_x / std_x^2 ] c = beta/eps """ c = self.beta / self.eps v = v_x.astype(np.float64, copy=False) inv_std2 = (1.0 / self.lat_std_x) ** 2 # (d,) # s_m = sum_y Dw_over[m,y] v_y s_m = Dw_over @ v # (k,) S = -(2.0 * c) * (k_m * s_m) # (k,) # T as (d,k) term1 = (4.0 * c * c) * (k_m * s_m)[:, None] * Dw_over # (k,d) term2 = (2.0 * c) * k_m[:, None] * (v[None, :] * inv_std2[None, :]) # (k,d) T = (term1 - term2).T # (d,k) d = v.shape[0] D = M_kX.shape[1] f = np.zeros((d,), dtype=np.float64) for j0 in range(0, D, int(D_block)): j1 = min(D, j0 + int(D_block)) Mb = M_kX[:, j0:j1] # (k,block) Jv_b = S @ Mb # (block,) Hv_b = T @ Mb # (d,block) f += Hv_b @ Jv_b # (d,) return f def flow( self, R_ax: Union[np.ndarray, list], v_ax: Union[np.ndarray, list], *, dt: float = 1e-2, # fixed-point iterations for symplectic-ish generalized leapfrog K_p: int = 5, K_q: int = 5, # geometry compute D_block: int = 8192, lam: float = 1e-8, metric_solver: MetricSolver = "auto", eig_clip: float = 1e-12, # tangent projection (optional) k_tangent: Optional[int] = None, # external force on momentum: dp/dt += force_fn(r, v) force_fn: Optional[Callable[[np.ndarray, np.ndarray], np.ndarray]] = None, ) -> Tuple[np.ndarray, np.ndarray]: """ One generalized-leapfrog step for *geodesic flow* on the pullback manifold, with optional tangent projection and external force. Inputs: R_ax: (A,d) or (d,) latent positions (unwhitened coordinates) v_ax: (A,d) or (d,) latent velocities (unwhitened coordinates) Outputs: (R_next, v_next) with same shapes as inputs. Notes: - If k_tangent is set, we project velocities onto top-k eigendirections of the metric g(r) at each evaluation point. - If force_fn is provided, it is added to momentum update as dp/dt. - Robust metric solver avoids Cholesky failures when g is PSD/singular. """ R = np.asarray(R_ax, dtype=np.float64) v = np.asarray(v_ax, dtype=np.float64) single = (R.ndim == 1) if single: R = R[None, :] v = v[None, :] if R.ndim != 2 or v.ndim != 2 or R.shape != v.shape or R.shape[1] != self.d_lat: raise ValueError(f"Expected R_ax and v_ax shape (A,{self.d_lat}) (or ({self.d_lat},)).") A, d = R.shape dt = float(dt) # Inducing subset indices per point (optional) if self.pred_k is None or self.pred_k == self.m: idx_aK = None else: Rw = (R - self.lat_mean_x[None, :]) / self.lat_std_x[None, :] idx_aK, _ = self.ann_Z.search(Rw.astype(self.dtype, copy=False), self.pred_k) idx_aK = idx_aK.astype(np.int64, copy=False) R_next = np.empty_like(R) v_next = np.empty_like(v) for a in range(A): idx = None if idx_aK is None else idx_aK[a] M_kX = self.M_mX if idx is None else self.M_mX[idx] # (k,D) r_n = R[a] v_n = v[a] # ---- geometry at r_n ---- k_m_n, Dw_over_n, grad_k_n = self._rbf_cache_single(r_n, idx_m=idx) mf_n = self._metric_from_gradM( grad_k_n, M_kX, D_block=D_block, lam=lam, metric_solver=metric_solver, eig_clip=eig_clip ) # Optional tangent projection at start if k_tangent is not None: v_n = mf_n.project_topk(v_n, int(k_tangent)) # momentum p_n = g(r_n) v_n p_n = mf_n.g @ v_n # ---- (1) implicit half-step in momentum (fixed point) ---- p = p_n.copy() for _ in range(int(K_p)): v_k = mf_n.solve(p) # v = g_n^{-1} p if k_tangent is not None: v_k = mf_n.project_topk(v_k, int(k_tangent)) f_geo = self._force_from_cache_noC(k_m_n, Dw_over_n, v_k, M_kX, D_block=D_block) f_ext = 0.0 if force_fn is not None: f_ext = np.asarray(force_fn(r_n, v_k), dtype=np.float64) if f_ext.shape != (d,): raise ValueError("force_fn must return shape (d,)") p = p_n + 0.5 * dt * (f_geo + f_ext) p_half = p # ---- (2) implicit position update (fixed point) ---- v_half_n = mf_n.solve(p_half) if k_tangent is not None: v_half_n = mf_n.project_topk(v_half_n, int(k_tangent)) r = r_n + dt * v_half_n # init guess for _ in range(int(K_q)): k_m_r, Dw_over_r, grad_k_r = self._rbf_cache_single(r, idx_m=idx) mf_r = self._metric_from_gradM( grad_k_r, M_kX, D_block=D_block, lam=lam, metric_solver=metric_solver, eig_clip=eig_clip ) v_half_r = mf_r.solve(p_half) if k_tangent is not None: v_half_r = mf_r.project_topk(v_half_r, int(k_tangent)) r = r_n + 0.5 * dt * (v_half_n + v_half_r) r_np1 = r # ---- (3) explicit half-step in momentum at r_{n+1} ---- k_m_np1, Dw_over_np1, grad_k_np1 = self._rbf_cache_single(r_np1, idx_m=idx) mf_np1 = self._metric_from_gradM( grad_k_np1, M_kX, D_block=D_block, lam=lam, metric_solver=metric_solver, eig_clip=eig_clip ) v_mid = mf_np1.solve(p_half) if k_tangent is not None: v_mid = mf_np1.project_topk(v_mid, int(k_tangent)) f_geo_np1 = self._force_from_cache_noC(k_m_np1, Dw_over_np1, v_mid, M_kX, D_block=D_block) f_ext_np1 = 0.0 if force_fn is not None: f_ext_np1 = np.asarray(force_fn(r_np1, v_mid), dtype=np.float64) if f_ext_np1.shape != (d,): raise ValueError("force_fn must return shape (d,)") p_np1 = p_half + 0.5 * dt * (f_geo_np1 + f_ext_np1) v_np1 = mf_np1.solve(p_np1) if k_tangent is not None: v_np1 = mf_np1.project_topk(v_np1, int(k_tangent)) R_next[a] = r_np1 v_next[a] = v_np1 if single: return R_next[0], v_next[0] return R_next, v_next __all__ = ["GPLM", "InducingMode"]