Create LGP.py
Browse files
LGP.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LGP.py
|
| 2 |
+
# ============================================================
|
| 3 |
+
# LGP: Linear-GP (linear-kernel GP / KRR) decoder
|
| 4 |
+
#
|
| 5 |
+
# Goal:
|
| 6 |
+
# A "good linear GPLM" for linear encoders (e.g., SSA/ISA/ASA linear codes).
|
| 7 |
+
# It behaves like a GP/KRR with a *linear kernel*:
|
| 8 |
+
# k(r, r') = r^T r'
|
| 9 |
+
#
|
| 10 |
+
# Training data:
|
| 11 |
+
# R_ix : (N, d) latent features / codes
|
| 12 |
+
# R_iX : (N, D) targets in ambient/output space
|
| 13 |
+
#
|
| 14 |
+
# GP/KRR mean predictor (dual form):
|
| 15 |
+
# y(r) = k(r, R)^T (K + σ^2 I)^{-1} Y
|
| 16 |
+
# with K = R R^T.
|
| 17 |
+
#
|
| 18 |
+
# With a linear kernel, you can do this in the *primal* cheaply:
|
| 19 |
+
# V = (R^T R + σ^2 I)^{-1} R^T Y (d x D)
|
| 20 |
+
# y(r) = r^T V (D,)
|
| 21 |
+
#
|
| 22 |
+
# This avoids building K (N x N) entirely.
|
| 23 |
+
#
|
| 24 |
+
# Uncertainty:
|
| 25 |
+
# Under the GP interpretation (and for a BLR-equivalent prior), a cheap scalar
|
| 26 |
+
# function-variance proxy is:
|
| 27 |
+
# var_f(r) = σ^2 * r^T (R^T R + σ^2 I)^{-1} r
|
| 28 |
+
# (shared across output dims if you assume independent outputs sharing features).
|
| 29 |
+
#
|
| 30 |
+
# Complexity:
|
| 31 |
+
# - Form Gram in latent space: O(N d^2)
|
| 32 |
+
# - Solve dxd system: O(d^3) via Cholesky (cheap if d is small)
|
| 33 |
+
# - Predict A queries: O(A d D)
|
| 34 |
+
#
|
| 35 |
+
# Optional solver="cg" supports very large d using matvecs:
|
| 36 |
+
# A v = (R^T (R v) + σ^2 v)
|
| 37 |
+
#
|
| 38 |
+
# Dependencies:
|
| 39 |
+
# numpy, scipy
|
| 40 |
+
# ============================================================
|
| 41 |
+
|
| 42 |
+
from __future__ import annotations
|
| 43 |
+
|
| 44 |
+
from dataclasses import dataclass
|
| 45 |
+
from typing import Any, Literal, Optional, Tuple, Union
|
| 46 |
+
|
| 47 |
+
import numpy as np
|
| 48 |
+
import scipy.linalg as la
|
| 49 |
+
import scipy.sparse.linalg as sla
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
Solver = Literal["chol", "eigh", "cg"]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _as_2d(X: np.ndarray) -> np.ndarray:
|
| 56 |
+
X = np.asarray(X)
|
| 57 |
+
if X.ndim == 1:
|
| 58 |
+
return X[:, None]
|
| 59 |
+
return X
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass
|
| 63 |
+
class _LatentPreproc:
|
| 64 |
+
mean: np.ndarray
|
| 65 |
+
std: np.ndarray
|
| 66 |
+
|
| 67 |
+
def apply(self, R: np.ndarray) -> np.ndarray:
|
| 68 |
+
return (R - self.mean[None, :]) / self.std[None, :]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class LGP:
|
| 72 |
+
"""
|
| 73 |
+
LGP = Linear-kernel GP mean (KRR) decoder.
|
| 74 |
+
|
| 75 |
+
API is intentionally similar to GPLM:
|
| 76 |
+
- __init__(...) fits immediately
|
| 77 |
+
- __call__(R_ax, batch_size=None) predicts mean
|
| 78 |
+
- predict(R_ax, return_var=True) returns mean + scalar var proxy
|
| 79 |
+
|
| 80 |
+
Notes:
|
| 81 |
+
- If center_X=True, we center outputs during training and add mean back at inference.
|
| 82 |
+
- If whiten_latent=True, we standardize latents dimension-wise (recommended if feature scales vary).
|
| 83 |
+
- sigma2 is the ridge/noise term (λ). Larger sigma2 -> smoother / more stable rollout.
|
| 84 |
+
"""
|
| 85 |
+
|
| 86 |
+
def __init__(
|
| 87 |
+
self,
|
| 88 |
+
R_ix: np.ndarray,
|
| 89 |
+
R_iX: np.ndarray,
|
| 90 |
+
*,
|
| 91 |
+
sigma2: float = 1e-5,
|
| 92 |
+
jitter: float = 1e-10,
|
| 93 |
+
center_X: bool = True,
|
| 94 |
+
whiten_latent: bool = False,
|
| 95 |
+
dtype: Any = np.float32,
|
| 96 |
+
solver: Solver = "chol",
|
| 97 |
+
cg_maxiter: int = 500,
|
| 98 |
+
cg_rtol: float = 1e-6,
|
| 99 |
+
cg_atol: float = 0.0,
|
| 100 |
+
# numeric safety
|
| 101 |
+
eig_clip: float = 1e-12,
|
| 102 |
+
):
|
| 103 |
+
self.sigma2 = float(sigma2)
|
| 104 |
+
self.jitter = float(jitter)
|
| 105 |
+
self.center_X = bool(center_X)
|
| 106 |
+
self.whiten_latent = bool(whiten_latent)
|
| 107 |
+
self.dtype = dtype
|
| 108 |
+
self.solver: Solver = str(solver) # type: ignore
|
| 109 |
+
|
| 110 |
+
self.cg_maxiter = int(cg_maxiter)
|
| 111 |
+
self.cg_rtol = float(cg_rtol)
|
| 112 |
+
self.cg_atol = float(cg_atol)
|
| 113 |
+
self.eig_clip = float(eig_clip)
|
| 114 |
+
|
| 115 |
+
# cast/validate
|
| 116 |
+
R_ix = np.ascontiguousarray(np.asarray(R_ix).astype(self.dtype, copy=False))
|
| 117 |
+
R_iX = np.ascontiguousarray(np.asarray(R_iX).astype(self.dtype, copy=False))
|
| 118 |
+
if R_ix.ndim != 2 or R_iX.ndim != 2 or R_ix.shape[0] != R_iX.shape[0]:
|
| 119 |
+
raise ValueError("R_ix must be (N,d) and R_iX must be (N,D) with same N.")
|
| 120 |
+
|
| 121 |
+
self.R_ix = R_ix
|
| 122 |
+
self.R_iX = R_iX
|
| 123 |
+
self.N, self.d_lat = R_ix.shape
|
| 124 |
+
_, self.D = R_iX.shape
|
| 125 |
+
|
| 126 |
+
# output centering
|
| 127 |
+
Y = R_iX.astype(np.float64)
|
| 128 |
+
if self.center_X:
|
| 129 |
+
self.mean_X = Y.mean(axis=0)
|
| 130 |
+
Yc = Y - self.mean_X[None, :]
|
| 131 |
+
else:
|
| 132 |
+
self.mean_X = np.zeros((self.D,), dtype=np.float64)
|
| 133 |
+
Yc = Y
|
| 134 |
+
|
| 135 |
+
# latent whitening
|
| 136 |
+
R = R_ix.astype(np.float64)
|
| 137 |
+
if self.whiten_latent:
|
| 138 |
+
mu = R.mean(axis=0)
|
| 139 |
+
sd = np.maximum(R.std(axis=0), 1e-12)
|
| 140 |
+
self._pp = _LatentPreproc(mu, sd)
|
| 141 |
+
Rw = self._pp.apply(R)
|
| 142 |
+
else:
|
| 143 |
+
self._pp = _LatentPreproc(np.zeros((self.d_lat,), dtype=np.float64),
|
| 144 |
+
np.ones((self.d_lat,), dtype=np.float64))
|
| 145 |
+
Rw = R
|
| 146 |
+
|
| 147 |
+
self.R_ix_w = np.ascontiguousarray(Rw, dtype=np.float64) # (N,d)
|
| 148 |
+
|
| 149 |
+
# Fit: V = (R^T R + (sigma2 + jitter) I)^-1 R^T Yc
|
| 150 |
+
lam = float(self.sigma2)
|
| 151 |
+
jit = float(self.jitter)
|
| 152 |
+
|
| 153 |
+
# We'll store A = R^T R + lam I (+ jitter)
|
| 154 |
+
# and a factorization/solver representation.
|
| 155 |
+
self._A = None
|
| 156 |
+
self._chol = None
|
| 157 |
+
self._eig = None # (w, V)
|
| 158 |
+
self.V_xX = None # (d,D)
|
| 159 |
+
|
| 160 |
+
if self.solver in ("chol", "eigh"):
|
| 161 |
+
self._fit_closed_form(Rw=self.R_ix_w, Yc=Yc, lam=lam, jit=jit)
|
| 162 |
+
elif self.solver == "cg":
|
| 163 |
+
self._fit_cg(Rw=self.R_ix_w, Yc=Yc, lam=lam, jit=jit)
|
| 164 |
+
else:
|
| 165 |
+
raise ValueError("solver must be one of {'chol','eigh','cg'}")
|
| 166 |
+
|
| 167 |
+
# ------------------------------------------------------------
|
| 168 |
+
# Training implementations
|
| 169 |
+
# ------------------------------------------------------------
|
| 170 |
+
def _fit_closed_form(self, *, Rw: np.ndarray, Yc: np.ndarray, lam: float, jit: float) -> None:
|
| 171 |
+
# A = R^T R + lam I
|
| 172 |
+
G = Rw.T @ Rw # (d,d)
|
| 173 |
+
d = G.shape[0]
|
| 174 |
+
A = G + (lam + jit) * np.eye(d, dtype=np.float64)
|
| 175 |
+
B = Rw.T @ Yc # (d,D)
|
| 176 |
+
|
| 177 |
+
if self.solver == "eigh":
|
| 178 |
+
w, V = np.linalg.eigh(A)
|
| 179 |
+
w = np.maximum(w, float(self.eig_clip))
|
| 180 |
+
self._eig = (w, V)
|
| 181 |
+
# V_xX = A^{-1} B = V diag(1/w) V^T B
|
| 182 |
+
self.V_xX = (V @ ((V.T @ B) / w[:, None])).astype(np.float64)
|
| 183 |
+
self._A = A
|
| 184 |
+
return
|
| 185 |
+
|
| 186 |
+
# chol (default)
|
| 187 |
+
cF = la.cho_factor(A, lower=True, check_finite=False)
|
| 188 |
+
self._chol = cF
|
| 189 |
+
self.V_xX = la.cho_solve(cF, B, check_finite=False).astype(np.float64)
|
| 190 |
+
self._A = A
|
| 191 |
+
|
| 192 |
+
def _fit_cg(self, *, Rw: np.ndarray, Yc: np.ndarray, lam: float, jit: float) -> None:
|
| 193 |
+
"""
|
| 194 |
+
Solve (R^T R + lam I) V = R^T Y with CG in d-space using matvecs.
|
| 195 |
+
Good when d is too large to factorize directly.
|
| 196 |
+
"""
|
| 197 |
+
N, d = Rw.shape
|
| 198 |
+
lam_eff = lam + jit
|
| 199 |
+
|
| 200 |
+
def matvec(v: np.ndarray) -> np.ndarray:
|
| 201 |
+
# A v = R^T (R v) + lam v
|
| 202 |
+
v = np.asarray(v, dtype=np.float64)
|
| 203 |
+
return (Rw.T @ (Rw @ v)) + lam_eff * v
|
| 204 |
+
|
| 205 |
+
Aop = sla.LinearOperator((d, d), matvec=matvec, dtype=np.float64)
|
| 206 |
+
|
| 207 |
+
B = Rw.T @ Yc # (d,D)
|
| 208 |
+
V = np.zeros((d, self.D), dtype=np.float64)
|
| 209 |
+
|
| 210 |
+
# Solve each output dim independently (D is often small; if D is large, consider block-CG)
|
| 211 |
+
for j in range(self.D):
|
| 212 |
+
x0 = np.zeros((d,), dtype=np.float64)
|
| 213 |
+
sol, info = sla.cg(
|
| 214 |
+
Aop,
|
| 215 |
+
B[:, j],
|
| 216 |
+
x0=x0,
|
| 217 |
+
maxiter=self.cg_maxiter,
|
| 218 |
+
rtol=self.cg_rtol,
|
| 219 |
+
atol=self.cg_atol,
|
| 220 |
+
)
|
| 221 |
+
if info != 0:
|
| 222 |
+
raise RuntimeError(f"LGP(CD) CG failed for output dim {j} with info={info}. "
|
| 223 |
+
f"Try larger cg_maxiter, looser rtol, or larger sigma2.")
|
| 224 |
+
V[:, j] = sol
|
| 225 |
+
|
| 226 |
+
self.V_xX = V
|
| 227 |
+
self._A = None
|
| 228 |
+
self._chol = None
|
| 229 |
+
self._eig = None
|
| 230 |
+
|
| 231 |
+
# ------------------------------------------------------------
|
| 232 |
+
# Inference
|
| 233 |
+
# ------------------------------------------------------------
|
| 234 |
+
def __call__(self, R_ax: Union[np.ndarray, list], *, batch_size: Optional[int] = None) -> np.ndarray:
|
| 235 |
+
"""
|
| 236 |
+
Mean prediction only.
|
| 237 |
+
R_ax: (A,d) or (d,)
|
| 238 |
+
returns: (A,D) or (D,)
|
| 239 |
+
"""
|
| 240 |
+
R_ax = np.asarray(R_ax)
|
| 241 |
+
single = (R_ax.ndim == 1)
|
| 242 |
+
if single:
|
| 243 |
+
R_ax = R_ax[None, :]
|
| 244 |
+
|
| 245 |
+
Ra = np.ascontiguousarray(R_ax.astype(np.float64, copy=False))
|
| 246 |
+
if Ra.shape[1] != self.d_lat:
|
| 247 |
+
raise ValueError(f"Expected latent dim d={self.d_lat}, got {Ra.shape[1]}.")
|
| 248 |
+
|
| 249 |
+
if batch_size is None:
|
| 250 |
+
Y = self._decode_mean(Ra)
|
| 251 |
+
else:
|
| 252 |
+
bs = int(batch_size)
|
| 253 |
+
out = []
|
| 254 |
+
for s in range(0, Ra.shape[0], bs):
|
| 255 |
+
out.append(self._decode_mean(Ra[s:s+bs]))
|
| 256 |
+
Y = np.vstack(out)
|
| 257 |
+
|
| 258 |
+
return Y[0] if single else Y
|
| 259 |
+
|
| 260 |
+
def predict(
|
| 261 |
+
self,
|
| 262 |
+
R_ax: Union[np.ndarray, list],
|
| 263 |
+
*,
|
| 264 |
+
return_var: bool = False,
|
| 265 |
+
batch_size: Optional[int] = None,
|
| 266 |
+
include_obs_noise: bool = False,
|
| 267 |
+
) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]:
|
| 268 |
+
"""
|
| 269 |
+
Predict mean and optional scalar variance proxy per query.
|
| 270 |
+
|
| 271 |
+
var_f(a) = sigma2 * r_a^T (R^T R + sigma2 I)^{-1} r_a
|
| 272 |
+
|
| 273 |
+
If include_obs_noise=True, returns var_y = var_f + sigma2.
|
| 274 |
+
"""
|
| 275 |
+
R_ax = np.asarray(R_ax)
|
| 276 |
+
single = (R_ax.ndim == 1)
|
| 277 |
+
if single:
|
| 278 |
+
R_ax = R_ax[None, :]
|
| 279 |
+
|
| 280 |
+
Ra = np.ascontiguousarray(R_ax.astype(np.float64, copy=False))
|
| 281 |
+
if Ra.shape[1] != self.d_lat:
|
| 282 |
+
raise ValueError(f"Expected latent dim d={self.d_lat}, got {Ra.shape[1]}.")
|
| 283 |
+
|
| 284 |
+
if not return_var:
|
| 285 |
+
mean = self.__call__(Ra, batch_size=batch_size)
|
| 286 |
+
if single and mean.ndim == 1:
|
| 287 |
+
mean = mean[None, :]
|
| 288 |
+
return mean[0] if single else mean
|
| 289 |
+
|
| 290 |
+
if batch_size is None:
|
| 291 |
+
mean, var = self._decode_mean_var(Ra, include_obs_noise=include_obs_noise)
|
| 292 |
+
else:
|
| 293 |
+
bs = int(batch_size)
|
| 294 |
+
ms, vs = [], []
|
| 295 |
+
for s in range(0, Ra.shape[0], bs):
|
| 296 |
+
m, v = self._decode_mean_var(Ra[s:s+bs], include_obs_noise=include_obs_noise)
|
| 297 |
+
ms.append(m)
|
| 298 |
+
vs.append(v)
|
| 299 |
+
mean = np.vstack(ms)
|
| 300 |
+
var = np.concatenate(vs, axis=0)
|
| 301 |
+
|
| 302 |
+
if single:
|
| 303 |
+
return mean[0], var[0]
|
| 304 |
+
return mean, var
|
| 305 |
+
|
| 306 |
+
# ------------------------------------------------------------
|
| 307 |
+
# core decode routines
|
| 308 |
+
# ------------------------------------------------------------
|
| 309 |
+
def _decode_mean(self, Ra: np.ndarray) -> np.ndarray:
|
| 310 |
+
assert self.V_xX is not None
|
| 311 |
+
|
| 312 |
+
# apply latent whitening
|
| 313 |
+
Raw = self._pp.apply(Ra)
|
| 314 |
+
|
| 315 |
+
Yc = Raw @ self.V_xX # (A,D)
|
| 316 |
+
Y = Yc + self.mean_X[None, :]
|
| 317 |
+
return Y
|
| 318 |
+
|
| 319 |
+
def _solve_Ainv_vecs(self, X_dA: np.ndarray) -> np.ndarray:
|
| 320 |
+
"""
|
| 321 |
+
Solve (R^T R + sigma2 I)^{-1} X for X shape (d,A).
|
| 322 |
+
Only available for chol/eigh solvers. For CG solver we can do per-column CG.
|
| 323 |
+
"""
|
| 324 |
+
X = np.asarray(X_dA, dtype=np.float64)
|
| 325 |
+
|
| 326 |
+
if self.solver == "chol":
|
| 327 |
+
assert self._chol is not None
|
| 328 |
+
return la.cho_solve(self._chol, X, check_finite=False)
|
| 329 |
+
|
| 330 |
+
if self.solver == "eigh":
|
| 331 |
+
assert self._eig is not None
|
| 332 |
+
w, V = self._eig
|
| 333 |
+
return V @ ((V.T @ X) / w[:, None])
|
| 334 |
+
|
| 335 |
+
# cg fallback
|
| 336 |
+
# (Ainv X) column-by-column with CG matvecs
|
| 337 |
+
Rw = self.R_ix_w
|
| 338 |
+
lam_eff = self.sigma2 + self.jitter
|
| 339 |
+
d = self.d_lat
|
| 340 |
+
|
| 341 |
+
def matvec(v: np.ndarray) -> np.ndarray:
|
| 342 |
+
return (Rw.T @ (Rw @ v)) + lam_eff * v
|
| 343 |
+
|
| 344 |
+
Aop = sla.LinearOperator((d, d), matvec=matvec, dtype=np.float64)
|
| 345 |
+
|
| 346 |
+
out = np.empty_like(X)
|
| 347 |
+
for j in range(X.shape[1]):
|
| 348 |
+
sol, info = sla.cg(
|
| 349 |
+
Aop,
|
| 350 |
+
X[:, j],
|
| 351 |
+
x0=np.zeros((d,), dtype=np.float64),
|
| 352 |
+
maxiter=self.cg_maxiter,
|
| 353 |
+
rtol=self.cg_rtol,
|
| 354 |
+
atol=self.cg_atol,
|
| 355 |
+
)
|
| 356 |
+
if info != 0:
|
| 357 |
+
raise RuntimeError(f"LGP variance CG solve failed with info={info}.")
|
| 358 |
+
out[:, j] = sol
|
| 359 |
+
return out
|
| 360 |
+
|
| 361 |
+
def _decode_mean_var(self, Ra: np.ndarray, *, include_obs_noise: bool) -> Tuple[np.ndarray, np.ndarray]:
|
| 362 |
+
"""
|
| 363 |
+
Mean + scalar variance proxy:
|
| 364 |
+
var_f[a] = sigma2 * r_a^T A^{-1} r_a
|
| 365 |
+
"""
|
| 366 |
+
assert self.V_xX is not None
|
| 367 |
+
|
| 368 |
+
Raw = self._pp.apply(Ra) # (A,d)
|
| 369 |
+
|
| 370 |
+
# mean
|
| 371 |
+
Yc = Raw @ self.V_xX
|
| 372 |
+
mean = Yc + self.mean_X[None, :]
|
| 373 |
+
|
| 374 |
+
# var_f: sigma2 * diag(Raw A^{-1} Raw^T)
|
| 375 |
+
# Compute A^{-1} Raw^T (d,A), then diag = sum(Raw^T * AinvRawT, axis=0)
|
| 376 |
+
Ainv_Rt = self._solve_Ainv_vecs(Raw.T) # (d,A)
|
| 377 |
+
quad = np.sum(Raw.T * Ainv_Rt, axis=0) # (A,)
|
| 378 |
+
var_f = self.sigma2 * quad
|
| 379 |
+
|
| 380 |
+
if include_obs_noise:
|
| 381 |
+
var_f = var_f + self.sigma2
|
| 382 |
+
|
| 383 |
+
return mean, var_f
|
| 384 |
+
|
| 385 |
+
# ------------------------------------------------------------
|
| 386 |
+
# Diagnostics
|
| 387 |
+
# ------------------------------------------------------------
|
| 388 |
+
def kernel_mass(self, R_ax: Union[np.ndarray, list]) -> np.ndarray:
|
| 389 |
+
"""
|
| 390 |
+
For linear kernel, a simple "mass" proxy is ||r||^2.
|
| 391 |
+
"""
|
| 392 |
+
R_ax = np.asarray(R_ax, dtype=np.float64)
|
| 393 |
+
single = (R_ax.ndim == 1)
|
| 394 |
+
if single:
|
| 395 |
+
R_ax = R_ax[None, :]
|
| 396 |
+
Raw = self._pp.apply(R_ax)
|
| 397 |
+
mass = np.sum(Raw * Raw, axis=1)
|
| 398 |
+
return mass[0] if single else mass
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
__all__ = ["LGP"]
|