dima / LGP.py
sparsetrace's picture
Create LGP.py
8054123 verified
Raw
History Blame Contribute Delete
13.4 kB
# LGP.py
# ============================================================
# LGP: Linear-GP (linear-kernel GP / KRR) decoder
#
# Goal:
# A "good linear GPLM" for linear encoders (e.g., SSA/ISA/ASA linear codes).
# It behaves like a GP/KRR with a *linear kernel*:
# k(r, r') = r^T r'
#
# Training data:
# R_ix : (N, d) latent features / codes
# R_iX : (N, D) targets in ambient/output space
#
# GP/KRR mean predictor (dual form):
# y(r) = k(r, R)^T (K + σ^2 I)^{-1} Y
# with K = R R^T.
#
# With a linear kernel, you can do this in the *primal* cheaply:
# V = (R^T R + σ^2 I)^{-1} R^T Y (d x D)
# y(r) = r^T V (D,)
#
# This avoids building K (N x N) entirely.
#
# Uncertainty:
# Under the GP interpretation (and for a BLR-equivalent prior), a cheap scalar
# function-variance proxy is:
# var_f(r) = σ^2 * r^T (R^T R + σ^2 I)^{-1} r
# (shared across output dims if you assume independent outputs sharing features).
#
# Complexity:
# - Form Gram in latent space: O(N d^2)
# - Solve dxd system: O(d^3) via Cholesky (cheap if d is small)
# - Predict A queries: O(A d D)
#
# Optional solver="cg" supports very large d using matvecs:
# A v = (R^T (R v) + σ^2 v)
#
# Dependencies:
# numpy, scipy
# ============================================================
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal, Optional, Tuple, Union
import numpy as np
import scipy.linalg as la
import scipy.sparse.linalg as sla
Solver = Literal["chol", "eigh", "cg"]
def _as_2d(X: np.ndarray) -> np.ndarray:
X = np.asarray(X)
if X.ndim == 1:
return X[:, None]
return X
@dataclass
class _LatentPreproc:
mean: np.ndarray
std: np.ndarray
def apply(self, R: np.ndarray) -> np.ndarray:
return (R - self.mean[None, :]) / self.std[None, :]
class LGP:
"""
LGP = Linear-kernel GP mean (KRR) decoder.
API is intentionally similar to GPLM:
- __init__(...) fits immediately
- __call__(R_ax, batch_size=None) predicts mean
- predict(R_ax, return_var=True) returns mean + scalar var proxy
Notes:
- If center_X=True, we center outputs during training and add mean back at inference.
- If whiten_latent=True, we standardize latents dimension-wise (recommended if feature scales vary).
- sigma2 is the ridge/noise term (λ). Larger sigma2 -> smoother / more stable rollout.
"""
def __init__(
self,
R_ix: np.ndarray,
R_iX: np.ndarray,
*,
sigma2: float = 1e-5,
jitter: float = 1e-10,
center_X: bool = True,
whiten_latent: bool = False,
dtype: Any = np.float32,
solver: Solver = "chol",
cg_maxiter: int = 500,
cg_rtol: float = 1e-6,
cg_atol: float = 0.0,
# numeric safety
eig_clip: float = 1e-12,
):
self.sigma2 = float(sigma2)
self.jitter = float(jitter)
self.center_X = bool(center_X)
self.whiten_latent = bool(whiten_latent)
self.dtype = dtype
self.solver: Solver = str(solver) # type: ignore
self.cg_maxiter = int(cg_maxiter)
self.cg_rtol = float(cg_rtol)
self.cg_atol = float(cg_atol)
self.eig_clip = float(eig_clip)
# cast/validate
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
# output centering
Y = R_iX.astype(np.float64)
if self.center_X:
self.mean_X = Y.mean(axis=0)
Yc = Y - self.mean_X[None, :]
else:
self.mean_X = np.zeros((self.D,), dtype=np.float64)
Yc = Y
# latent whitening
R = R_ix.astype(np.float64)
if self.whiten_latent:
mu = R.mean(axis=0)
sd = np.maximum(R.std(axis=0), 1e-12)
self._pp = _LatentPreproc(mu, sd)
Rw = self._pp.apply(R)
else:
self._pp = _LatentPreproc(np.zeros((self.d_lat,), dtype=np.float64),
np.ones((self.d_lat,), dtype=np.float64))
Rw = R
self.R_ix_w = np.ascontiguousarray(Rw, dtype=np.float64) # (N,d)
# Fit: V = (R^T R + (sigma2 + jitter) I)^-1 R^T Yc
lam = float(self.sigma2)
jit = float(self.jitter)
# We'll store A = R^T R + lam I (+ jitter)
# and a factorization/solver representation.
self._A = None
self._chol = None
self._eig = None # (w, V)
self.V_xX = None # (d,D)
if self.solver in ("chol", "eigh"):
self._fit_closed_form(Rw=self.R_ix_w, Yc=Yc, lam=lam, jit=jit)
elif self.solver == "cg":
self._fit_cg(Rw=self.R_ix_w, Yc=Yc, lam=lam, jit=jit)
else:
raise ValueError("solver must be one of {'chol','eigh','cg'}")
# ------------------------------------------------------------
# Training implementations
# ------------------------------------------------------------
def _fit_closed_form(self, *, Rw: np.ndarray, Yc: np.ndarray, lam: float, jit: float) -> None:
# A = R^T R + lam I
G = Rw.T @ Rw # (d,d)
d = G.shape[0]
A = G + (lam + jit) * np.eye(d, dtype=np.float64)
B = Rw.T @ Yc # (d,D)
if self.solver == "eigh":
w, V = np.linalg.eigh(A)
w = np.maximum(w, float(self.eig_clip))
self._eig = (w, V)
# V_xX = A^{-1} B = V diag(1/w) V^T B
self.V_xX = (V @ ((V.T @ B) / w[:, None])).astype(np.float64)
self._A = A
return
# chol (default)
cF = la.cho_factor(A, lower=True, check_finite=False)
self._chol = cF
self.V_xX = la.cho_solve(cF, B, check_finite=False).astype(np.float64)
self._A = A
def _fit_cg(self, *, Rw: np.ndarray, Yc: np.ndarray, lam: float, jit: float) -> None:
"""
Solve (R^T R + lam I) V = R^T Y with CG in d-space using matvecs.
Good when d is too large to factorize directly.
"""
N, d = Rw.shape
lam_eff = lam + jit
def matvec(v: np.ndarray) -> np.ndarray:
# A v = R^T (R v) + lam v
v = np.asarray(v, dtype=np.float64)
return (Rw.T @ (Rw @ v)) + lam_eff * v
Aop = sla.LinearOperator((d, d), matvec=matvec, dtype=np.float64)
B = Rw.T @ Yc # (d,D)
V = np.zeros((d, self.D), dtype=np.float64)
# Solve each output dim independently (D is often small; if D is large, consider block-CG)
for j in range(self.D):
x0 = np.zeros((d,), dtype=np.float64)
sol, info = sla.cg(
Aop,
B[:, j],
x0=x0,
maxiter=self.cg_maxiter,
rtol=self.cg_rtol,
atol=self.cg_atol,
)
if info != 0:
raise RuntimeError(f"LGP(CD) CG failed for output dim {j} with info={info}. "
f"Try larger cg_maxiter, looser rtol, or larger sigma2.")
V[:, j] = sol
self.V_xX = V
self._A = None
self._chol = None
self._eig = None
# ------------------------------------------------------------
# Inference
# ------------------------------------------------------------
def __call__(self, R_ax: Union[np.ndarray, list], *, batch_size: Optional[int] = None) -> np.ndarray:
"""
Mean prediction only.
R_ax: (A,d) or (d,)
returns: (A,D) or (D,)
"""
R_ax = np.asarray(R_ax)
single = (R_ax.ndim == 1)
if single:
R_ax = R_ax[None, :]
Ra = np.ascontiguousarray(R_ax.astype(np.float64, copy=False))
if Ra.shape[1] != self.d_lat:
raise ValueError(f"Expected latent dim d={self.d_lat}, got {Ra.shape[1]}.")
if batch_size is None:
Y = self._decode_mean(Ra)
else:
bs = int(batch_size)
out = []
for s in range(0, Ra.shape[0], bs):
out.append(self._decode_mean(Ra[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,
include_obs_noise: bool = False,
) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]:
"""
Predict mean and optional scalar variance proxy per query.
var_f(a) = sigma2 * r_a^T (R^T R + sigma2 I)^{-1} r_a
If include_obs_noise=True, returns var_y = var_f + sigma2.
"""
R_ax = np.asarray(R_ax)
single = (R_ax.ndim == 1)
if single:
R_ax = R_ax[None, :]
Ra = np.ascontiguousarray(R_ax.astype(np.float64, copy=False))
if Ra.shape[1] != self.d_lat:
raise ValueError(f"Expected latent dim d={self.d_lat}, got {Ra.shape[1]}.")
if not return_var:
mean = self.__call__(Ra, batch_size=batch_size)
if single and mean.ndim == 1:
mean = mean[None, :]
return mean[0] if single else mean
if batch_size is None:
mean, var = self._decode_mean_var(Ra, include_obs_noise=include_obs_noise)
else:
bs = int(batch_size)
ms, vs = [], []
for s in range(0, Ra.shape[0], bs):
m, v = self._decode_mean_var(Ra[s:s+bs], include_obs_noise=include_obs_noise)
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
# ------------------------------------------------------------
# core decode routines
# ------------------------------------------------------------
def _decode_mean(self, Ra: np.ndarray) -> np.ndarray:
assert self.V_xX is not None
# apply latent whitening
Raw = self._pp.apply(Ra)
Yc = Raw @ self.V_xX # (A,D)
Y = Yc + self.mean_X[None, :]
return Y
def _solve_Ainv_vecs(self, X_dA: np.ndarray) -> np.ndarray:
"""
Solve (R^T R + sigma2 I)^{-1} X for X shape (d,A).
Only available for chol/eigh solvers. For CG solver we can do per-column CG.
"""
X = np.asarray(X_dA, dtype=np.float64)
if self.solver == "chol":
assert self._chol is not None
return la.cho_solve(self._chol, X, check_finite=False)
if self.solver == "eigh":
assert self._eig is not None
w, V = self._eig
return V @ ((V.T @ X) / w[:, None])
# cg fallback
# (Ainv X) column-by-column with CG matvecs
Rw = self.R_ix_w
lam_eff = self.sigma2 + self.jitter
d = self.d_lat
def matvec(v: np.ndarray) -> np.ndarray:
return (Rw.T @ (Rw @ v)) + lam_eff * v
Aop = sla.LinearOperator((d, d), matvec=matvec, dtype=np.float64)
out = np.empty_like(X)
for j in range(X.shape[1]):
sol, info = sla.cg(
Aop,
X[:, j],
x0=np.zeros((d,), dtype=np.float64),
maxiter=self.cg_maxiter,
rtol=self.cg_rtol,
atol=self.cg_atol,
)
if info != 0:
raise RuntimeError(f"LGP variance CG solve failed with info={info}.")
out[:, j] = sol
return out
def _decode_mean_var(self, Ra: np.ndarray, *, include_obs_noise: bool) -> Tuple[np.ndarray, np.ndarray]:
"""
Mean + scalar variance proxy:
var_f[a] = sigma2 * r_a^T A^{-1} r_a
"""
assert self.V_xX is not None
Raw = self._pp.apply(Ra) # (A,d)
# mean
Yc = Raw @ self.V_xX
mean = Yc + self.mean_X[None, :]
# var_f: sigma2 * diag(Raw A^{-1} Raw^T)
# Compute A^{-1} Raw^T (d,A), then diag = sum(Raw^T * AinvRawT, axis=0)
Ainv_Rt = self._solve_Ainv_vecs(Raw.T) # (d,A)
quad = np.sum(Raw.T * Ainv_Rt, axis=0) # (A,)
var_f = self.sigma2 * quad
if include_obs_noise:
var_f = var_f + self.sigma2
return mean, var_f
# ------------------------------------------------------------
# Diagnostics
# ------------------------------------------------------------
def kernel_mass(self, R_ax: Union[np.ndarray, list]) -> np.ndarray:
"""
For linear kernel, a simple "mass" proxy is ||r||^2.
"""
R_ax = np.asarray(R_ax, dtype=np.float64)
single = (R_ax.ndim == 1)
if single:
R_ax = R_ax[None, :]
Raw = self._pp.apply(R_ax)
mass = np.sum(Raw * Raw, axis=1)
return mass[0] if single else mass
__all__ = ["LGP"]