sparsetrace commited on
Commit
a29ae7b
·
verified ·
1 Parent(s): ffbea02

Create GPLMx.py

Browse files
Files changed (1) hide show
  1. GPLMx.py +580 -0
GPLMx.py ADDED
@@ -0,0 +1,580 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GPLM.py
2
+ # ============================================================
3
+ # GPLM (drop-in replacement):
4
+ # Sparse-kernel KRR / mean-GP decoder on latent coordinates.
5
+ #
6
+ # This version:
7
+ # - Builds a sparse kernel matrix K using kNN (ANN) on training latents.
8
+ # - Solves (K + sigma2 * I) S = Y for S (N,D) via iterative sparse solvers.
9
+ # - Predicts for new points using only k_pred nearest training points:
10
+ # y(x) ≈ sum_{j in kNN(x)} k(x, x_j) * S_j
11
+ #
12
+ # Key properties:
13
+ # - No Nyström anchors / no inducing points.
14
+ # - "Nonlinearity" corresponds to strong locality (small eps, small k_graph):
15
+ # sparse + high-rank-ish operator, but scalable because it's sparse.
16
+ #
17
+ # API compatibility:
18
+ # - class GPLM
19
+ # - __init__(...), fit(...)
20
+ # - __call__(R_ax), predict(..., return_var=True)
21
+ # - kernel_mass(...)
22
+ # - flow(...) present but NotImplemented (optional advanced geometry)
23
+ #
24
+ # Dependencies:
25
+ # numpy, scipy
26
+ # ann.py + utils.py (same repo assumptions as your previous GPLM)
27
+ # ============================================================
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+ from typing import Any, Dict, Literal, Optional, Tuple, Union
33
+
34
+ import numpy as np
35
+ import scipy.sparse as sp
36
+ import scipy.sparse.linalg as spla
37
+
38
+ # --- same repo dependencies as before ---
39
+ from ann import ANNBackend, make_ann
40
+ from utils import median_eps_from_knn_d2
41
+
42
+ InducingMode = Literal["random_subset", "fps", "kmeans_medoids", "given"] # kept for API compat
43
+ SolveMethod = Literal["cg", "minres", "auto"]
44
+
45
+
46
+ def _as_2d(x: np.ndarray) -> np.ndarray:
47
+ x = np.asarray(x)
48
+ if x.ndim == 1:
49
+ return x[None, :]
50
+ return x
51
+
52
+
53
+ def _row_norm2(X: np.ndarray) -> np.ndarray:
54
+ return np.sum(X * X, axis=1)
55
+
56
+
57
+ def _rbf_weights_from_d2(D2: np.ndarray, beta: float, eps: float) -> np.ndarray:
58
+ # weight = exp(-beta * d^2 / eps)
59
+ return np.exp(-float(beta) * (D2.astype(np.float64) / float(eps)))
60
+
61
+
62
+ def _symmetrize_coo(i: np.ndarray, j: np.ndarray, v: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
63
+ """Add transpose entries and return concatenated arrays."""
64
+ i2 = np.concatenate([i, j], axis=0)
65
+ j2 = np.concatenate([j, i], axis=0)
66
+ v2 = np.concatenate([v, v], axis=0)
67
+ return i2, j2, v2
68
+
69
+
70
+ def _unique_coo_sum(N: int, i: np.ndarray, j: np.ndarray, v: np.ndarray) -> sp.csr_matrix:
71
+ """Build CSR matrix with duplicates summed."""
72
+ K = sp.coo_matrix((v, (i, j)), shape=(N, N), dtype=np.float64).tocsr()
73
+ K.sum_duplicates()
74
+ return K
75
+
76
+
77
+ def _solve_multi_rhs(
78
+ A: sp.csr_matrix,
79
+ Y: np.ndarray,
80
+ *,
81
+ method: SolveMethod = "auto",
82
+ tol: float = 1e-6,
83
+ maxiter: int = 500,
84
+ verbose: bool = False,
85
+ ) -> np.ndarray:
86
+ """
87
+ Solve A X = Y for X with multiple RHS columns using iterative solvers.
88
+ A is expected sparse and (typically) symmetric.
89
+ """
90
+ Y = np.asarray(Y, dtype=np.float64, order="C")
91
+ N, D = Y.shape
92
+ X = np.zeros((N, D), dtype=np.float64)
93
+
94
+ # Choose solver
95
+ if method == "auto":
96
+ # CG is fastest if SPD; minres is safer if indefinite
97
+ method_use: SolveMethod = "cg"
98
+ else:
99
+ method_use = method
100
+
101
+ # Wrapper per RHS
102
+ for d in range(D):
103
+ b = Y[:, d]
104
+ x0 = None # could warm-start if you want
105
+
106
+ if method_use == "cg":
107
+ x, info = spla.cg(A, b, x0=x0, tol=tol, maxiter=maxiter)
108
+ if info != 0:
109
+ # fallback to MINRES
110
+ x, info2 = spla.minres(A, b, x0=x0, tol=tol, maxiter=maxiter)
111
+ if verbose:
112
+ print(f"[GPLM sparse] cg failed info={info}, minres info={info2} on dim {d}")
113
+ X[:, d] = x
114
+ elif method_use == "minres":
115
+ x, info = spla.minres(A, b, x0=x0, tol=tol, maxiter=maxiter)
116
+ if verbose and info != 0:
117
+ print(f"[GPLM sparse] minres info={info} on dim {d}")
118
+ X[:, d] = x
119
+ else:
120
+ raise ValueError(f"Unknown solve method: {method_use}")
121
+
122
+ return X
123
+
124
+
125
+ @dataclass
126
+ class _SparseKernelConfig:
127
+ k_graph: int = 64 # neighbors per training point for building sparse K
128
+ mutual: bool = True # mutual-kNN symmetrization (recommended)
129
+ include_self: bool = True # ensure diagonal has 1.0
130
+ normalize_rows: bool = False # optional row-normalization for stability
131
+
132
+
133
+ class GPLM:
134
+ """
135
+ GPLM (Sparse-kernel KRR decoder)
136
+
137
+ Training:
138
+ Inputs: R_ix (N,d) latents, R_iX (N,D) outputs
139
+ Build sparse kernel K via kNN on R_ix:
140
+ K_ij = exp(-beta ||R_i-R_j||^2 / eps) for neighbors only
141
+
142
+ Solve for weights S (N,D):
143
+ (K + sigma2 * I + jitter*I) S = Y_centered
144
+
145
+ Inference:
146
+ For query R_ax:
147
+ Find k_pred nearest training points j_aK
148
+ Compute weights w_aK = exp(-beta d2/eps)
149
+ Predict:
150
+ Yc = sum_k w[a,k] * S[j_aK[a,k], :]
151
+ Return Y = Yc + mean_X
152
+
153
+ Variance proxy:
154
+ Not full GP variance; return a support-based scalar:
155
+ mass = sum_k w[a,k]
156
+ var ≈ sigma2 / (mass + 1e-12)
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ R_ix: np.ndarray,
162
+ R_iX: np.ndarray,
163
+ *,
164
+ # Kernel params
165
+ beta: float = 1.0,
166
+ eps: Optional[float] = None,
167
+ k_eps: int = 256,
168
+ eps_use_kth: bool = True,
169
+ eps_mul: float = 1.0,
170
+ # Regularization (acts like ridge lambda)
171
+ sigma2: float = 1e-5,
172
+ jitter: float = 1e-8,
173
+ # "Inducing" params kept for API compat (ignored)
174
+ m: int = 1024,
175
+ inducing: InducingMode = "kmeans_medoids",
176
+ Z_mx: Optional[np.ndarray] = None,
177
+ seed: int = 0,
178
+ # Preprocess
179
+ center_X: bool = True,
180
+ whiten_latent: bool = False,
181
+ dtype: Any = np.float32,
182
+ # Sparse kernel build
183
+ k_graph: int = 64,
184
+ mutual_knn: bool = True,
185
+ include_self: bool = True,
186
+ normalize_rows: bool = False,
187
+ # Solve
188
+ solve_method: SolveMethod = "auto",
189
+ solve_tol: float = 1e-6,
190
+ solve_maxiter: int = 800,
191
+ solve_verbose: bool = False,
192
+ # Inference neighbor truncation
193
+ pred_k: Optional[int] = 128,
194
+ ann_backend: ANNBackend = "auto",
195
+ ann_params: Optional[Dict[str, Any]] = None,
196
+ n_jobs: int = -1,
197
+ # accept unicode kwargs (β, ε, κ_eps, σ2, pred_κ, ...)
198
+ **kwargs: Any,
199
+ ):
200
+ # ---- map unicode kwargs -> ascii ----
201
+ if "β" in kwargs:
202
+ beta = kwargs.pop("β")
203
+ if "ε" in kwargs:
204
+ eps = kwargs.pop("ε")
205
+ if "κ_eps" in kwargs:
206
+ k_eps = kwargs.pop("κ_eps")
207
+ if "ε_use_kth" in kwargs:
208
+ eps_use_kth = kwargs.pop("ε_use_kth")
209
+ if "ε_mul" in kwargs:
210
+ eps_mul = kwargs.pop("ε_mul")
211
+ if "σ2" in kwargs:
212
+ sigma2 = kwargs.pop("σ2")
213
+ if "pred_κ" in kwargs:
214
+ pred_k = kwargs.pop("pred_κ")
215
+
216
+ # ignore anchor arguments quietly (compat)
217
+ _ = (m, inducing, Z_mx)
218
+
219
+ if kwargs:
220
+ raise TypeError(f"Unexpected kwargs: {sorted(kwargs.keys())}")
221
+
222
+ self.beta = float(beta)
223
+ self.β = self.beta
224
+
225
+ self.sigma2 = float(sigma2) # ridge lambda in (K + sigma2 I)
226
+ self.σ2 = self.sigma2
227
+
228
+ self.jitter = float(jitter)
229
+ self.seed = int(seed)
230
+ self.dtype = dtype
231
+
232
+ # ---- validate / cast ----
233
+ R_ix = np.ascontiguousarray(np.asarray(R_ix).astype(self.dtype, copy=False))
234
+ R_iX = np.ascontiguousarray(np.asarray(R_iX).astype(self.dtype, copy=False))
235
+ if R_ix.ndim != 2 or R_iX.ndim != 2 or R_ix.shape[0] != R_iX.shape[0]:
236
+ raise ValueError("R_ix must be (N,d) and R_iX must be (N,D) with same N.")
237
+
238
+ self.R_ix = R_ix
239
+ self.R_iX = R_iX
240
+ self.N, self.d_lat = R_ix.shape
241
+ _, self.D = R_iX.shape
242
+
243
+ # ---- center output ----
244
+ self.center_X = bool(center_X)
245
+ if self.center_X:
246
+ self.mean_X = R_iX.mean(axis=0).astype(np.float64)
247
+ Y = (R_iX.astype(np.float64) - self.mean_X[None, :])
248
+ else:
249
+ self.mean_X = np.zeros((self.D,), dtype=np.float64)
250
+ Y = R_iX.astype(np.float64)
251
+
252
+ # ---- latent whitening (optional) ----
253
+ self.whiten_latent = bool(whiten_latent)
254
+ Ztrain = R_ix.astype(np.float64)
255
+ if self.whiten_latent:
256
+ self.lat_mean_x = Ztrain.mean(axis=0)
257
+ self.lat_std_x = np.maximum(Ztrain.std(axis=0), 1e-12)
258
+ Ztrain_w = (Ztrain - self.lat_mean_x) / self.lat_std_x
259
+ else:
260
+ self.lat_mean_x = np.zeros((self.d_lat,), dtype=np.float64)
261
+ self.lat_std_x = np.ones((self.d_lat,), dtype=np.float64)
262
+ Ztrain_w = Ztrain
263
+
264
+ self.R_ix_w = np.ascontiguousarray(Ztrain_w.astype(np.float64, copy=False)) # (N,d) float64
265
+
266
+ # ---- ANN on training latents ----
267
+ self.ann_train, self.ann_backend = make_ann(ann_backend, ann_params=ann_params, n_jobs=n_jobs)
268
+ self.ann_train.build(self.R_ix_w.astype(self.dtype, copy=False))
269
+
270
+ # ---- eps via kNN distances ----
271
+ if eps is None:
272
+ k_eps_eff = int(min(max(8, int(k_eps)), self.N - 1))
273
+ # ask for k_eps+1 to try include self
274
+ j_iK1, D2_iK1 = self.ann_train.search(self.R_ix_w.astype(self.dtype, copy=False), k_eps_eff + 1)
275
+
276
+ i = np.arange(self.N)[:, None]
277
+ is_self = (j_iK1 == i)
278
+
279
+ if np.any(is_self):
280
+ D2_iK = np.empty((self.N, k_eps_eff), dtype=np.float64)
281
+ for ii in range(self.N):
282
+ keep = (j_iK1[ii] != ii)
283
+ D2_iK[ii] = D2_iK1[ii][keep][:k_eps_eff]
284
+ else:
285
+ D2_iK = D2_iK1[:, :k_eps_eff].astype(np.float64, copy=False)
286
+
287
+ eps_hat = median_eps_from_knn_d2(D2_iK, use_kth=bool(eps_use_kth))
288
+ else:
289
+ eps_hat = float(eps)
290
+
291
+ eps_hat *= float(eps_mul)
292
+ if eps_hat <= 0:
293
+ raise ValueError("eps must be > 0.")
294
+ self.eps = float(eps_hat)
295
+ self.ε = self.eps
296
+
297
+ # ---- build sparse kernel K ----
298
+ cfg = _SparseKernelConfig(
299
+ k_graph=int(min(max(4, int(k_graph)), self.N - 1)),
300
+ mutual=bool(mutual_knn),
301
+ include_self=bool(include_self),
302
+ normalize_rows=bool(normalize_rows),
303
+ )
304
+ self._cfg = cfg
305
+
306
+ # query kNN on training set for graph edges
307
+ j_iK1, D2_iK1 = self.ann_train.search(self.R_ix_w.astype(self.dtype, copy=False), cfg.k_graph + 1)
308
+
309
+ # drop self if present
310
+ rows = []
311
+ cols = []
312
+ vals = []
313
+
314
+ for i in range(self.N):
315
+ nbrs = j_iK1[i]
316
+ d2 = D2_iK1[i].astype(np.float64, copy=False)
317
+ # remove self
318
+ mask = (nbrs != i)
319
+ nbrs = nbrs[mask][: cfg.k_graph]
320
+ d2 = d2[mask][: cfg.k_graph]
321
+
322
+ w = _rbf_weights_from_d2(d2, beta=self.beta, eps=self.eps)
323
+
324
+ rows.append(np.full(nbrs.shape[0], i, dtype=np.int64))
325
+ cols.append(nbrs.astype(np.int64, copy=False))
326
+ vals.append(w.astype(np.float64, copy=False))
327
+
328
+ i_idx = np.concatenate(rows, axis=0)
329
+ j_idx = np.concatenate(cols, axis=0)
330
+ v_idx = np.concatenate(vals, axis=0)
331
+
332
+ # symmetric adjacency
333
+ if cfg.mutual:
334
+ i_idx, j_idx, v_idx = _symmetrize_coo(i_idx, j_idx, v_idx)
335
+
336
+ # build sparse K (CSR)
337
+ K = _unique_coo_sum(self.N, i_idx, j_idx, v_idx)
338
+
339
+ # set diagonal to 1 (kernel self-sim), improves conditioning
340
+ if cfg.include_self:
341
+ K = K.tolil(copy=False)
342
+ diag = K.diagonal()
343
+ # if diagonal already has values from sym edges, top it up to 1
344
+ diag_new = np.maximum(np.asarray(diag).reshape(-1), 1.0)
345
+ for ii in range(self.N):
346
+ K[ii, ii] = float(diag_new[ii])
347
+ K = K.tocsr(copy=False)
348
+
349
+ # optional row normalization (turns kernel into a diffusion-like operator)
350
+ if cfg.normalize_rows:
351
+ rs = np.asarray(K.sum(axis=1)).reshape(-1)
352
+ rs = np.maximum(rs, 1e-12)
353
+ inv = 1.0 / rs
354
+ K = sp.diags(inv, format="csr") @ K
355
+
356
+ K.sum_duplicates()
357
+ self.K = K # (N,N) sparse
358
+
359
+ # ---- form A = K + (sigma2 + jitter) I ----
360
+ lam = float(self.sigma2)
361
+ jit = float(self.jitter)
362
+ A = self.K.tocsr(copy=True)
363
+ A = A + sp.diags((lam + jit) * np.ones(self.N), format="csr")
364
+ self._A = A.tocsr(copy=False)
365
+
366
+ # ---- solve for S: A S = Y ----
367
+ self.solve_method = str(solve_method)
368
+ self.solve_tol = float(solve_tol)
369
+ self.solve_maxiter = int(solve_maxiter)
370
+ self.solve_verbose = bool(solve_verbose)
371
+
372
+ self.S_iX = _solve_multi_rhs(
373
+ self._A,
374
+ Y,
375
+ method=self.solve_method, # type: ignore
376
+ tol=self.solve_tol,
377
+ maxiter=self.solve_maxiter,
378
+ verbose=self.solve_verbose,
379
+ ).astype(np.float64)
380
+
381
+ # store float32 copy for fast inference
382
+ self.S_iX_f32 = self.S_iX.astype(np.float32, copy=False)
383
+
384
+ # ---- inference neighbor count ----
385
+ if pred_k is None:
386
+ self.pred_k = int(min(128, self.N - 1))
387
+ else:
388
+ self.pred_k = int(min(max(1, int(pred_k)), self.N - 1))
389
+ self.pred_κ = self.pred_k # unicode alias
390
+
391
+ # ------------------------------------------------------------
392
+ # Convenience alternate constructor
393
+ # ------------------------------------------------------------
394
+ @classmethod
395
+ def fit(cls, R_ix: np.ndarray, R_iX: np.ndarray, **kwargs: Any) -> "GPLM":
396
+ return cls(R_ix, R_iX, **kwargs)
397
+
398
+ # ------------------------------------------------------------
399
+ # Internal prediction helpers
400
+ # ------------------------------------------------------------
401
+ def _whiten_query(self, R_ax: np.ndarray) -> np.ndarray:
402
+ R_ax = np.asarray(R_ax, dtype=np.float64)
403
+ if self.whiten_latent:
404
+ return (R_ax - self.lat_mean_x[None, :]) / self.lat_std_x[None, :]
405
+ return R_ax
406
+
407
+ def _predict_mean(self, R_ax: np.ndarray) -> np.ndarray:
408
+ """
409
+ Mean prediction using only pred_k nearest training points.
410
+ """
411
+ R_ax = np.asarray(R_ax)
412
+ single = (R_ax.ndim == 1)
413
+ if single:
414
+ R_ax = R_ax[None, :]
415
+ R_ax = np.ascontiguousarray(R_ax.astype(self.dtype, copy=False))
416
+
417
+ # whiten query for ANN
418
+ Rw = self._whiten_query(R_ax.astype(np.float64, copy=False)).astype(self.dtype, copy=False)
419
+
420
+ # neighbors in training set
421
+ j_aK, D2_aK = self.ann_train.search(Rw, self.pred_k)
422
+
423
+ # weights (A,k)
424
+ W = _rbf_weights_from_d2(D2_aK.astype(np.float64, copy=False), beta=self.beta, eps=self.eps)
425
+
426
+ # gather S for neighbors -> (A,k,D)
427
+ S = self.S_iX_f32 # (N,D)
428
+ Sj = S[j_aK] # (A,k,D)
429
+
430
+ # weighted sum -> (A,D)
431
+ Yc = np.einsum("ak,akd->ad", W.astype(np.float32, copy=False), Sj, optimize=True).astype(np.float64)
432
+
433
+ # add mean
434
+ Y = Yc + self.mean_X[None, :]
435
+ return Y[0] if single else Y
436
+
437
+ def _predict_mean_var(self, R_ax: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
438
+ """
439
+ Mean + cheap scalar uncertainty proxy based on kernel mass on neighbors.
440
+ This is *not* exact GP posterior variance.
441
+ """
442
+ R_ax = np.asarray(R_ax)
443
+ single = (R_ax.ndim == 1)
444
+ if single:
445
+ R_ax = R_ax[None, :]
446
+ R_ax = np.ascontiguousarray(R_ax.astype(self.dtype, copy=False))
447
+
448
+ Rw = self._whiten_query(R_ax.astype(np.float64, copy=False)).astype(self.dtype, copy=False)
449
+ j_aK, D2_aK = self.ann_train.search(Rw, self.pred_k)
450
+
451
+ W = _rbf_weights_from_d2(D2_aK.astype(np.float64, copy=False), beta=self.beta, eps=self.eps) # (A,k)
452
+ mass = np.sum(W, axis=1) # (A,)
453
+
454
+ S = self.S_iX_f32
455
+ Sj = S[j_aK] # (A,k,D)
456
+ Yc = np.einsum("ak,akd->ad", W.astype(np.float32, copy=False), Sj, optimize=True).astype(np.float64)
457
+ mean = Yc + self.mean_X[None, :]
458
+
459
+ # heuristic: low mass => off-support => higher uncertainty
460
+ var = (self.sigma2 / (mass + 1e-12)).astype(np.float64)
461
+
462
+ if single:
463
+ return mean[0], var[0]
464
+ return mean, var
465
+
466
+ # ------------------------------------------------------------
467
+ # Public API
468
+ # ------------------------------------------------------------
469
+ def __call__(self, R_ax: Union[np.ndarray, list], *, batch_size: Optional[int] = None) -> np.ndarray:
470
+ """
471
+ Mean prediction only. For uncertainty, use predict(..., return_var=True).
472
+ """
473
+ R_ax = np.asarray(R_ax)
474
+ single = (R_ax.ndim == 1)
475
+ if single:
476
+ R_ax = R_ax[None, :]
477
+
478
+ if batch_size is None:
479
+ Y = self._predict_mean(R_ax)
480
+ else:
481
+ bs = int(batch_size)
482
+ out = []
483
+ for s in range(0, R_ax.shape[0], bs):
484
+ out.append(self._predict_mean(R_ax[s : s + bs]))
485
+ Y = np.vstack(out)
486
+
487
+ return Y[0] if single else Y
488
+
489
+ def predict(
490
+ self,
491
+ R_ax: Union[np.ndarray, list],
492
+ *,
493
+ return_var: bool = False,
494
+ batch_size: Optional[int] = None,
495
+ ) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]:
496
+ """
497
+ Predict mean and (optional) scalar variance proxy per query.
498
+ """
499
+ R_ax = np.asarray(R_ax)
500
+ single = (R_ax.ndim == 1)
501
+ if single:
502
+ R_ax = R_ax[None, :]
503
+
504
+ if not return_var:
505
+ mean = self.__call__(R_ax, batch_size=batch_size)
506
+ if single and mean.ndim == 1:
507
+ return mean[None, :]
508
+ return mean
509
+
510
+ if batch_size is None:
511
+ mean, var = self._predict_mean_var(R_ax)
512
+ else:
513
+ bs = int(batch_size)
514
+ ms = []
515
+ vs = []
516
+ for s in range(0, R_ax.shape[0], bs):
517
+ m, v = self._predict_mean_var(R_ax[s : s + bs])
518
+ ms.append(m)
519
+ vs.append(np.atleast_1d(v))
520
+ mean = np.vstack(ms)
521
+ var = np.concatenate(vs, axis=0)
522
+
523
+ if single:
524
+ return mean, float(var[0]) if np.ndim(var) > 0 else float(var)
525
+ return mean, var
526
+
527
+ def kernel_mass(self, R_ax: Union[np.ndarray, list]) -> np.ndarray:
528
+ """
529
+ Support diagnostic:
530
+ mass(x) = sum_{j in kNN(x)} exp(-beta ||x-x_j||^2 / eps)
531
+ """
532
+ R_ax = np.asarray(R_ax, dtype=np.float64)
533
+ single = (R_ax.ndim == 1)
534
+ if single:
535
+ R_ax = R_ax[None, :]
536
+
537
+ Rw = self._whiten_query(R_ax).astype(self.dtype, copy=False)
538
+ _, D2_aK = self.ann_train.search(Rw, self.pred_k)
539
+ W = _rbf_weights_from_d2(D2_aK.astype(np.float64, copy=False), beta=self.beta, eps=self.eps)
540
+ mass = np.sum(W, axis=1)
541
+ return float(mass[0]) if single else mass
542
+
543
+ # ------------------------------------------------------------
544
+ # Geometry / flow (kept for API compatibility)
545
+ # ------------------------------------------------------------
546
+ def flow(
547
+ self,
548
+ R_ax: Union[np.ndarray, list],
549
+ v_ax: Union[np.ndarray, list],
550
+ *,
551
+ dt: float = 1e-2,
552
+ K_p: int = 5,
553
+ K_q: int = 5,
554
+ D_block: int = 8192,
555
+ lam: float = 1e-8,
556
+ metric_solver: str = "auto",
557
+ eig_clip: float = 1e-12,
558
+ k_tangent: Optional[int] = None,
559
+ force_fn: Optional[Any] = None,
560
+ ) -> Tuple[np.ndarray, np.ndarray]:
561
+ """
562
+ Placeholder for compatibility with the original GPLM API.
563
+
564
+ Sparse-kernel KRR decoder has a well-defined Jacobian via kernel gradients,
565
+ but a robust geodesic integrator is non-trivial and model-specific.
566
+
567
+ If you truly need flow() (geodesic-ish latent motion), you can:
568
+ - implement it using local neighbor gradients, OR
569
+ - keep the original GPLM for geometry tasks.
570
+
571
+ For now: not implemented.
572
+ """
573
+ raise NotImplementedError(
574
+ "flow() is not implemented in sparse-kernel GPLM. "
575
+ "Use the original Nyström GPLM for geodesic flow, or implement "
576
+ "a local-neighborhood gradient-based flow."
577
+ )
578
+
579
+
580
+ __all__ = ["GPLM"]