| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from typing import Any, Dict, Literal, Optional, Tuple, Union |
|
|
| import numpy as np |
| import scipy.sparse as sp |
| import scipy.sparse.linalg as spla |
|
|
| |
| from ann import ANNBackend, make_ann |
| from utils import median_eps_from_knn_d2 |
|
|
| InducingMode = Literal["random_subset", "fps", "kmeans_medoids", "given"] |
| SolveMethod = Literal["cg", "minres", "auto"] |
|
|
|
|
| def _as_2d(x: np.ndarray) -> np.ndarray: |
| x = np.asarray(x) |
| if x.ndim == 1: |
| return x[None, :] |
| return x |
|
|
|
|
| def _row_norm2(X: np.ndarray) -> np.ndarray: |
| return np.sum(X * X, axis=1) |
|
|
|
|
| def _rbf_weights_from_d2(D2: np.ndarray, beta: float, eps: float) -> np.ndarray: |
| |
| return np.exp(-float(beta) * (D2.astype(np.float64) / float(eps))) |
|
|
|
|
| def _symmetrize_coo(i: np.ndarray, j: np.ndarray, v: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: |
| """Add transpose entries and return concatenated arrays.""" |
| i2 = np.concatenate([i, j], axis=0) |
| j2 = np.concatenate([j, i], axis=0) |
| v2 = np.concatenate([v, v], axis=0) |
| return i2, j2, v2 |
|
|
|
|
| def _unique_coo_sum(N: int, i: np.ndarray, j: np.ndarray, v: np.ndarray) -> sp.csr_matrix: |
| """Build CSR matrix with duplicates summed.""" |
| K = sp.coo_matrix((v, (i, j)), shape=(N, N), dtype=np.float64).tocsr() |
| K.sum_duplicates() |
| return K |
|
|
|
|
| def _solve_multi_rhs( |
| A: sp.csr_matrix, |
| Y: np.ndarray, |
| *, |
| method: SolveMethod = "auto", |
| tol: float = 1e-6, |
| maxiter: int = 500, |
| verbose: bool = False, |
| ) -> np.ndarray: |
| """ |
| Solve A X = Y for X with multiple RHS columns using iterative solvers. |
| A is expected sparse and (typically) symmetric. |
| """ |
| Y = np.asarray(Y, dtype=np.float64, order="C") |
| N, D = Y.shape |
| X = np.zeros((N, D), dtype=np.float64) |
|
|
| |
| if method == "auto": |
| |
| method_use: SolveMethod = "cg" |
| else: |
| method_use = method |
|
|
| |
| for d in range(D): |
| b = Y[:, d] |
| x0 = None |
|
|
| if method_use == "cg": |
| x, info = spla.cg(A, b, x0=x0, rtol=tol, maxiter=maxiter) |
| if info != 0: |
| |
| x, info2 = spla.minres(A, b, x0=x0, rtol=tol, maxiter=maxiter) |
| if verbose: |
| print(f"[GPLM sparse] cg failed info={info}, minres info={info2} on dim {d}") |
| X[:, d] = x |
| elif method_use == "minres": |
| x, info = spla.minres(A, b, x0=x0, rtol=tol, maxiter=maxiter) |
| if verbose and info != 0: |
| print(f"[GPLM sparse] minres info={info} on dim {d}") |
| X[:, d] = x |
| else: |
| raise ValueError(f"Unknown solve method: {method_use}") |
|
|
| return X |
|
|
|
|
| @dataclass |
| class _SparseKernelConfig: |
| k_graph: int = 64 |
| mutual: bool = True |
| include_self: bool = True |
| normalize_rows: bool = False |
|
|
|
|
| class GPLM: |
| """ |
| GPLM (Sparse-kernel KRR decoder) |
| |
| Training: |
| Inputs: R_ix (N,d) latents, R_iX (N,D) outputs |
| Build sparse kernel K via kNN on R_ix: |
| K_ij = exp(-beta ||R_i-R_j||^2 / eps) for neighbors only |
| |
| Solve for weights S (N,D): |
| (K + sigma2 * I + jitter*I) S = Y_centered |
| |
| Inference: |
| For query R_ax: |
| Find k_pred nearest training points j_aK |
| Compute weights w_aK = exp(-beta d2/eps) |
| Predict: |
| Yc = sum_k w[a,k] * S[j_aK[a,k], :] |
| Return Y = Yc + mean_X |
| |
| Variance proxy: |
| Not full GP variance; return a support-based scalar: |
| mass = sum_k w[a,k] |
| var ≈ sigma2 / (mass + 1e-12) |
| """ |
|
|
| def __init__( |
| self, |
| R_ix: np.ndarray, |
| R_iX: np.ndarray, |
| *, |
| |
| beta: float = 1.0, |
| eps: Optional[float] = None, |
| k_eps: int = 256, |
| eps_use_kth: bool = True, |
| eps_mul: float = 1.0, |
| |
| sigma2: float = 1e-5, |
| jitter: float = 1e-8, |
| |
| m: int = 1024, |
| inducing: InducingMode = "kmeans_medoids", |
| Z_mx: Optional[np.ndarray] = None, |
| seed: int = 0, |
| |
| center_X: bool = True, |
| whiten_latent: bool = False, |
| dtype: Any = np.float32, |
| |
| k_graph: int = 64, |
| mutual_knn: bool = True, |
| include_self: bool = True, |
| normalize_rows: bool = False, |
| |
| solve_method: SolveMethod = "auto", |
| solve_tol: float = 1e-6, |
| solve_maxiter: int = 800, |
| solve_verbose: bool = False, |
| |
| pred_k: Optional[int] = 128, |
| ann_backend: ANNBackend = "auto", |
| ann_params: Optional[Dict[str, Any]] = None, |
| n_jobs: int = -1, |
| |
| **kwargs: Any, |
| ): |
| |
| 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_κ") |
|
|
| |
| _ = (m, inducing, Z_mx) |
|
|
| if kwargs: |
| raise TypeError(f"Unexpected kwargs: {sorted(kwargs.keys())}") |
|
|
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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 = np.ascontiguousarray(Ztrain_w.astype(np.float64, copy=False)) |
|
|
| |
| 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)) |
|
|
| |
| if eps is None: |
| k_eps_eff = int(min(max(8, int(k_eps)), self.N - 1)) |
| |
| j_iK1, D2_iK1 = self.ann_train.search(self.R_ix_w.astype(self.dtype, copy=False), k_eps_eff + 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_eff), dtype=np.float64) |
| for ii in range(self.N): |
| keep = (j_iK1[ii] != ii) |
| D2_iK[ii] = D2_iK1[ii][keep][:k_eps_eff] |
| else: |
| D2_iK = D2_iK1[:, :k_eps_eff].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 |
|
|
| |
| cfg = _SparseKernelConfig( |
| k_graph=int(min(max(4, int(k_graph)), self.N - 1)), |
| mutual=bool(mutual_knn), |
| include_self=bool(include_self), |
| normalize_rows=bool(normalize_rows), |
| ) |
| self._cfg = cfg |
|
|
| |
| j_iK1, D2_iK1 = self.ann_train.search(self.R_ix_w.astype(self.dtype, copy=False), cfg.k_graph + 1) |
|
|
| |
| rows = [] |
| cols = [] |
| vals = [] |
|
|
| for i in range(self.N): |
| nbrs = j_iK1[i] |
| d2 = D2_iK1[i].astype(np.float64, copy=False) |
| |
| mask = (nbrs != i) |
| nbrs = nbrs[mask][: cfg.k_graph] |
| d2 = d2[mask][: cfg.k_graph] |
|
|
| w = _rbf_weights_from_d2(d2, beta=self.beta, eps=self.eps) |
|
|
| rows.append(np.full(nbrs.shape[0], i, dtype=np.int64)) |
| cols.append(nbrs.astype(np.int64, copy=False)) |
| vals.append(w.astype(np.float64, copy=False)) |
|
|
| i_idx = np.concatenate(rows, axis=0) |
| j_idx = np.concatenate(cols, axis=0) |
| v_idx = np.concatenate(vals, axis=0) |
|
|
| |
| if cfg.mutual: |
| i_idx, j_idx, v_idx = _symmetrize_coo(i_idx, j_idx, v_idx) |
|
|
| |
| K = _unique_coo_sum(self.N, i_idx, j_idx, v_idx) |
|
|
| |
| if cfg.include_self: |
| K = K.tolil(copy=False) |
| diag = K.diagonal() |
| |
| diag_new = np.maximum(np.asarray(diag).reshape(-1), 1.0) |
| for ii in range(self.N): |
| K[ii, ii] = float(diag_new[ii]) |
| K = K.tocsr(copy=False) |
|
|
| |
| if cfg.normalize_rows: |
| rs = np.asarray(K.sum(axis=1)).reshape(-1) |
| rs = np.maximum(rs, 1e-12) |
| inv = 1.0 / rs |
| K = sp.diags(inv, format="csr") @ K |
|
|
| K.sum_duplicates() |
| self.K = K |
|
|
| |
| lam = float(self.sigma2) |
| jit = float(self.jitter) |
| A = self.K.tocsr(copy=True) |
| A = A + sp.diags((lam + jit) * np.ones(self.N), format="csr") |
| self._A = A.tocsr(copy=False) |
|
|
| |
| self.solve_method = str(solve_method) |
| self.solve_tol = float(solve_tol) |
| self.solve_maxiter = int(solve_maxiter) |
| self.solve_verbose = bool(solve_verbose) |
|
|
| self.S_iX = _solve_multi_rhs( |
| self._A, |
| Y, |
| method=self.solve_method, |
| tol=self.solve_tol, |
| maxiter=self.solve_maxiter, |
| verbose=self.solve_verbose, |
| ).astype(np.float64) |
|
|
| |
| self.S_iX_f32 = self.S_iX.astype(np.float32, copy=False) |
|
|
| |
| if pred_k is None: |
| self.pred_k = int(min(128, self.N - 1)) |
| else: |
| self.pred_k = int(min(max(1, int(pred_k)), self.N - 1)) |
| self.pred_κ = self.pred_k |
|
|
| |
| |
| |
| @classmethod |
| def fit(cls, R_ix: np.ndarray, R_iX: np.ndarray, **kwargs: Any) -> "GPLM": |
| return cls(R_ix, R_iX, **kwargs) |
|
|
| |
| |
| |
| def _whiten_query(self, R_ax: np.ndarray) -> np.ndarray: |
| R_ax = np.asarray(R_ax, dtype=np.float64) |
| if self.whiten_latent: |
| return (R_ax - self.lat_mean_x[None, :]) / self.lat_std_x[None, :] |
| return R_ax |
|
|
| def _predict_mean(self, R_ax: np.ndarray) -> np.ndarray: |
| """ |
| Mean prediction using only pred_k nearest training points. |
| """ |
| 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)) |
|
|
| |
| Rw = self._whiten_query(R_ax.astype(np.float64, copy=False)).astype(self.dtype, copy=False) |
|
|
| |
| j_aK, D2_aK = self.ann_train.search(Rw, self.pred_k) |
|
|
| |
| W = _rbf_weights_from_d2(D2_aK.astype(np.float64, copy=False), beta=self.beta, eps=self.eps) |
|
|
| |
| S = self.S_iX_f32 |
| Sj = S[j_aK] |
|
|
| |
| Yc = np.einsum("ak,akd->ad", W.astype(np.float32, copy=False), Sj, optimize=True).astype(np.float64) |
|
|
| |
| Y = Yc + self.mean_X[None, :] |
| return Y[0] if single else Y |
|
|
| def _predict_mean_var(self, R_ax: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: |
| """ |
| Mean + cheap scalar uncertainty proxy based on kernel mass on neighbors. |
| This is *not* exact GP posterior variance. |
| """ |
| 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)) |
|
|
| Rw = self._whiten_query(R_ax.astype(np.float64, copy=False)).astype(self.dtype, copy=False) |
| j_aK, D2_aK = self.ann_train.search(Rw, self.pred_k) |
|
|
| W = _rbf_weights_from_d2(D2_aK.astype(np.float64, copy=False), beta=self.beta, eps=self.eps) |
| mass = np.sum(W, axis=1) |
|
|
| S = self.S_iX_f32 |
| Sj = S[j_aK] |
| Yc = np.einsum("ak,akd->ad", W.astype(np.float32, copy=False), Sj, optimize=True).astype(np.float64) |
| mean = Yc + self.mean_X[None, :] |
|
|
| |
| var = (self.sigma2 / (mass + 1e-12)).astype(np.float64) |
|
|
| if single: |
| return mean[0], var[0] |
| return mean, var |
|
|
| |
| |
| |
| 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, :] |
|
|
| if batch_size is None: |
| Y = self._predict_mean(R_ax) |
| else: |
| bs = int(batch_size) |
| out = [] |
| for s in range(0, R_ax.shape[0], bs): |
| out.append(self._predict_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. |
| """ |
| R_ax = np.asarray(R_ax) |
| single = (R_ax.ndim == 1) |
| if single: |
| R_ax = R_ax[None, :] |
|
|
| if not return_var: |
| mean = self.__call__(R_ax, batch_size=batch_size) |
| if single and mean.ndim == 1: |
| return mean[None, :] |
| return mean |
|
|
| if batch_size is None: |
| mean, var = self._predict_mean_var(R_ax) |
| else: |
| bs = int(batch_size) |
| ms = [] |
| vs = [] |
| for s in range(0, R_ax.shape[0], bs): |
| m, v = self._predict_mean_var(R_ax[s : s + bs]) |
| ms.append(m) |
| vs.append(np.atleast_1d(v)) |
| mean = np.vstack(ms) |
| var = np.concatenate(vs, axis=0) |
|
|
| if single: |
| return mean, float(var[0]) if np.ndim(var) > 0 else float(var) |
| return mean, var |
|
|
| def kernel_mass(self, R_ax: Union[np.ndarray, list]) -> np.ndarray: |
| """ |
| Support diagnostic: |
| mass(x) = sum_{j in kNN(x)} exp(-beta ||x-x_j||^2 / eps) |
| """ |
| R_ax = np.asarray(R_ax, dtype=np.float64) |
| single = (R_ax.ndim == 1) |
| if single: |
| R_ax = R_ax[None, :] |
|
|
| Rw = self._whiten_query(R_ax).astype(self.dtype, copy=False) |
| _, D2_aK = self.ann_train.search(Rw, self.pred_k) |
| W = _rbf_weights_from_d2(D2_aK.astype(np.float64, copy=False), beta=self.beta, eps=self.eps) |
| mass = np.sum(W, axis=1) |
| return float(mass[0]) if single else mass |
|
|
| |
| |
| |
| def flow( |
| self, |
| R_ax: Union[np.ndarray, list], |
| v_ax: Union[np.ndarray, list], |
| *, |
| dt: float = 1e-2, |
| K_p: int = 5, |
| K_q: int = 5, |
| D_block: int = 8192, |
| lam: float = 1e-8, |
| metric_solver: str = "auto", |
| eig_clip: float = 1e-12, |
| k_tangent: Optional[int] = None, |
| force_fn: Optional[Any] = None, |
| ) -> Tuple[np.ndarray, np.ndarray]: |
| """ |
| Placeholder for compatibility with the original GPLM API. |
| |
| Sparse-kernel KRR decoder has a well-defined Jacobian via kernel gradients, |
| but a robust geodesic integrator is non-trivial and model-specific. |
| |
| If you truly need flow() (geodesic-ish latent motion), you can: |
| - implement it using local neighbor gradients, OR |
| - keep the original GPLM for geometry tasks. |
| |
| For now: not implemented. |
| """ |
| raise NotImplementedError( |
| "flow() is not implemented in sparse-kernel GPLM. " |
| "Use the original Nyström GPLM for geodesic flow, or implement " |
| "a local-neighborhood gradient-based flow." |
| ) |
|
|
|
|
| __all__ = ["GPLM"] |
|
|