sparsetrace commited on
Commit
8054123
·
verified ·
1 Parent(s): 817cd27

Create LGP.py

Browse files
Files changed (1) hide show
  1. LGP.py +401 -0
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"]