jcandane commited on
Commit
f592d69
·
verified ·
1 Parent(s): cbff043

Update src/dima/dima.py

Browse files
Files changed (1) hide show
  1. src/dima/dima.py +344 -379
src/dima/dima.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
  import json
5
  import time
6
  from dataclasses import asdict, dataclass
7
- from typing import Any, Dict, Optional, Union
8
 
9
  import numpy as np
10
 
@@ -14,22 +14,25 @@ from jax import random
14
 
15
  from flax import serialization as flax_ser
16
 
 
 
17
  from .dmap import DMAP
18
  from .gplm import GPLM
19
- from .ddpm import DDPM
20
- from .ann import ANNBackend, make_ann
21
 
22
 
23
  # ----------------------------
24
  # Optional: Hugging Face Hub
25
  # ----------------------------
26
  try:
27
- from huggingface_hub import HfApi, HfFolder, upload_file, hf_hub_download
28
  _HAS_HF = True
29
  except Exception:
30
  _HAS_HF = False
31
 
32
 
 
 
 
33
  def _select_device(prefer: str = "auto"):
34
  """
35
  Safe JAX device selection.
@@ -44,7 +47,6 @@ def _select_device(prefer: str = "auto"):
44
  return gpu[0] if gpu else (cpu[0] if cpu else devs[0])
45
  if prefer == "cpu":
46
  return cpu[0] if cpu else devs[0]
47
- # unknown -> auto
48
  return gpu[0] if gpu else (cpu[0] if cpu else devs[0])
49
 
50
 
@@ -74,13 +76,18 @@ class FrozenDMAP:
74
  ):
75
  self.k = int(state["k"])
76
  self.beta = float(state["beta"])
 
77
  self.alpha = float(state["alpha"])
 
78
  self.eps = float(state["eps"])
 
79
  self.dtype = np.dtype(state.get("dtype", "float32"))
80
 
81
  self.R_iX = np.ascontiguousarray(np.asarray(state["R_iX"]).astype(self.dtype, copy=False))
82
  self.qalpha_i = np.asarray(state["qalpha_i"], dtype=np.float64)
 
83
  self.R_over_lam_ix = np.asarray(state["R_over_lam_ix"], dtype=np.float64) # (Nref,d)
 
84
 
85
  self.ann, self.ann_backend = make_ann(ann_backend, ann_params=ann_params, n_jobs=n_jobs)
86
  self.ann.build(self.R_iX)
@@ -122,81 +129,41 @@ class FrozenDMAP:
122
  return Z_ax
123
 
124
 
125
- class FrozenGPLM:
 
 
 
 
 
 
126
  """
127
- Inference-only GPLM decoder built from saved GPLM state.
128
- Supports optional pred_k via ANN over inducing points.
 
129
  """
 
130
 
131
- def __init__(
132
- self,
133
- state: Dict[str, Any],
134
- *,
135
- ann_backend: ANNBackend = "auto",
136
- ann_params: Optional[Dict[str, Any]] = None,
137
- n_jobs: int = -1,
138
- ):
139
- self.beta = float(state["beta"])
140
- self.eps = float(state["eps"])
141
- self.pred_k = None if state.get("pred_k", None) is None else int(state["pred_k"])
142
- self.dtype = np.dtype(state.get("dtype", "float32"))
143
-
144
- self.mean_X = np.asarray(state["mean_X"], dtype=np.float64)
145
- self.M_mX = np.asarray(state["M_mX"], dtype=np.float64)
146
 
147
- # latent whitening used at training time
148
- self.lat_mean_x = np.asarray(state["lat_mean_x"], dtype=np.float64)
149
- self.lat_std_x = np.asarray(state["lat_std_x"], dtype=np.float64)
150
 
151
- # inducing points in whitened latent space
152
- self.Z_mx_w = np.ascontiguousarray(np.asarray(state["Z_mx_w"]).astype(np.float64, copy=False))
153
- self.m = int(self.Z_mx_w.shape[0])
154
 
155
- self.ann_Z, self.ann_backend = make_ann(ann_backend, ann_params=ann_params, n_jobs=n_jobs)
156
- self.ann_Z.build(self.Z_mx_w.astype(self.dtype, copy=False))
157
 
158
- def __call__(self, R_ax: Union[np.ndarray, list], *, batch_size: Optional[int] = None) -> np.ndarray:
159
- R_ax = np.asarray(R_ax)
160
- single = (R_ax.ndim == 1)
161
- if single:
162
- R_ax = R_ax[None, :]
163
- R_ax = np.ascontiguousarray(R_ax.astype(self.dtype, copy=False))
164
 
165
- if batch_size is None:
166
- Y = self._decode(R_ax)
167
- else:
168
- out = []
169
- bs = int(batch_size)
170
- for s in range(0, R_ax.shape[0], bs):
171
- out.append(self._decode(R_ax[s : s + bs]))
172
- Y = np.vstack(out)
173
-
174
- return Y[0] if single else Y
175
-
176
- @staticmethod
177
- def _sqdist_ab(A: np.ndarray, B: np.ndarray) -> np.ndarray:
178
- A = np.asarray(A, dtype=np.float64)
179
- B = np.asarray(B, dtype=np.float64)
180
- A2 = np.sum(A * A, axis=1, keepdims=True)
181
- B2 = np.sum(B * B, axis=1, keepdims=True).T
182
- G = A @ B.T
183
- return np.maximum(A2 + B2 - 2.0 * G, 0.0)
184
-
185
- def _decode(self, R_ax: np.ndarray) -> np.ndarray:
186
- Za = R_ax.astype(np.float64)
187
- Za_w = (Za - self.lat_mean_x[None, :]) / self.lat_std_x[None, :]
188
-
189
- if self.pred_k is None or self.pred_k >= self.m:
190
- D2_am = self._sqdist_ab(Za_w, self.Z_mx_w)
191
- C_am = np.exp(-self.beta * (D2_am / self.eps))
192
- Y = C_am @ self.M_mX
193
- else:
194
- j_aK, D2_aK = self.ann_Z.search(Za_w.astype(self.dtype, copy=False), self.pred_k)
195
- W = np.exp(-self.beta * (D2_aK.astype(np.float64) / self.eps)) # (a,k)
196
- M = self.M_mX[j_aK] # (a,k,D)
197
- Y = np.sum(W[:, :, None] * M, axis=1) # (a,D)
198
-
199
- return Y + self.mean_X[None, :]
200
 
201
 
202
  # ----------------------------
@@ -205,9 +172,9 @@ class FrozenGPLM:
205
  @dataclass
206
  class DIMAConfig:
207
  d: int = 32
 
208
  ddpm_device: str = "auto" # "auto" | "cpu" | "gpu"
209
- # purely informational defaults (actual settings live in kwargs dicts)
210
- version: str = "0.1.0"
211
 
212
 
213
  # ----------------------------
@@ -215,99 +182,196 @@ class DIMAConfig:
215
  # ----------------------------
216
  class DIMA:
217
  """
218
- DIMA: Diffusion–Intrinsic Manifold Autoencoder
219
-
220
- Pipeline:
221
- - DMAP encoder (NumPy/SciPy, CPU): ambient -> raw latent
222
- - GPLM decoder (NumPy/SciPy, CPU): raw latent -> ambient
223
- - DDPM latent model (JAX/Flax, CPU or GPU): trained on *normalized* latents
224
-
225
- API:
226
- model = DIMA(R_iX, dmap_kwargs=..., gplm_kwargs=..., ddpm_kwargs=..., ddpm_device="auto")
227
- Z = model.encode(X) # normalized latents (jnp on ddpm device)
228
- Xhat = model.decode(Z) # ambient (np on CPU), optionally with DDPM refinement
229
- Xhat2 = model(X) # polymorphic: ambient -> latent
230
- Xhat3 = model(Z) # polymorphic: latent -> ambient (refine=True default)
231
- Xsamp = model.sample(1000) # unconditional samples (ambient np)
 
 
232
  """
233
 
234
  def __init__(
235
  self,
236
  R_iX: np.ndarray,
237
  *,
 
238
  d: int = 32,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  dmap_kwargs: Optional[Dict[str, Any]] = None,
240
  gplm_kwargs: Optional[Dict[str, Any]] = None,
241
  ddpm_kwargs: Optional[Dict[str, Any]] = None,
242
- ddpm_device: str = "auto",
243
- key: Optional[jax.Array] = None ):
244
- self.config = DIMAConfig(d=int(d), ddpm_device=str(ddpm_device))
245
- self.training_time = 0.0
246
 
247
- t0 = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
 
249
- # data
250
  self.R_iX = np.asarray(R_iX)
251
  if self.R_iX.ndim != 2:
252
  raise ValueError("R_iX must be 2D (N,D).")
253
  self.N, self.D = self.R_iX.shape
254
  self.d = int(d)
255
 
256
- dmap_kwargs = {} if dmap_kwargs is None else dict(dmap_kwargs)
257
- gplm_kwargs = {} if gplm_kwargs is None else dict(gplm_kwargs)
258
- ddpm_kwargs = {} if ddpm_kwargs is None else dict(ddpm_kwargs)
259
 
260
- # choose device for DDPM
261
- self.ddpm_device = _select_device(self.config.ddpm_device)
262
- self.cpu_device = _select_device("cpu")
263
 
264
- if key is None:
265
- key = random.PRNGKey(0)
266
- self.rng = key
267
 
268
  # -------------------------
269
  # 1) Train DMAP (CPU)
270
  # -------------------------
271
- self.enc = DMAP(self.R_iX, d=self.d, **dmap_kwargs)
272
- R_ix = np.asarray(self.enc(self.R_iX)) # raw latents (N,d) float64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
  # -------------------------
275
- # 2) Fit latent normalization (ONCE)
276
  # -------------------------
277
- self.lat_mean_np = R_ix.mean(axis=0, keepdims=True).astype(np.float64)
278
- self.lat_std_np = (R_ix.std(axis=0, keepdims=True) + 1e-6).astype(np.float64)
279
-
280
- Z_ix = (R_ix - self.lat_mean_np) / self.lat_std_np # normalized latents (np)
281
 
282
- # keep norms on ddpm device too
283
  self.lat_mean_j = jax.device_put(jnp.asarray(self.lat_mean_np, dtype=jnp.float32), self.ddpm_device)
284
  self.lat_std_j = jax.device_put(jnp.asarray(self.lat_std_np, dtype=jnp.float32), self.ddpm_device)
285
 
 
 
286
  # -------------------------
287
  # 3) Train GPLM (CPU)
288
  # -------------------------
289
- self.dec = GPLM(R_ix, self.R_iX, **gplm_kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
  # -------------------------
292
  # 4) Train DDPM on normalized latents (DDPM device)
293
  # -------------------------
294
  Z_ix_j = jax.device_put(jnp.asarray(Z_ix, dtype=jnp.float32), self.ddpm_device)
295
 
296
- # defaults (can be overridden by ddpm_kwargs)
297
  ddpm_init = dict(
298
- T=200,
299
- hidden_dim=128,
300
- t_embed_dim=64,
301
- learning_rate=3e-4,
302
- n_iter=200_000,
303
- ema_decay=0.999,
304
- beta_max=0.02,
305
- batch_size=256,
306
  key=self.rng,
307
- verbose_every=0,
308
- eps=1e-5,
309
  )
310
- ddpm_init.update(ddpm_kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
311
 
312
  with jax.default_device(self.ddpm_device):
313
  self.dm = DDPM(Z_ix_j, **ddpm_init)
@@ -315,47 +379,69 @@ class DIMA:
315
  self.training_time = time.time() - t0
316
 
317
  # -------------------------
318
- # Encode / Decode
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  # -------------------------
320
- def encode(self, R_aX: Union[np.ndarray, jnp.ndarray], *, to_device: bool = True) -> jnp.ndarray:
321
  """
322
- ambient -> normalized latent (DDPM space)
323
- Returns jnp on ddpm_device by default.
324
  """
325
  X = np.asarray(R_aX)
326
- Z_raw = np.asarray(self.enc(X)) # CPU
327
- Z_norm = (Z_raw - self.lat_mean_np) / self.lat_std_np
328
- Zj = jnp.asarray(Z_norm, dtype=jnp.float32)
329
- return jax.device_put(Zj, self.ddpm_device) if to_device else Zj
330
 
331
  def decode(
332
  self,
333
- Z_ax: Union[np.ndarray, jnp.ndarray],
334
  *,
335
- refine: bool = True,
336
  t_start: int = 10,
337
  add_noise: bool = True,
338
  key: Optional[jax.Array] = None,
339
  batch_size: Optional[int] = None,
340
  ) -> np.ndarray:
341
  """
342
- normalized latent -> (optional DDPM refine) -> raw latent -> ambient
343
- Returns numpy array (CPU).
344
  """
345
- Z = jnp.asarray(Z_ax, dtype=jnp.float32)
346
- if Z.ndim == 1:
347
- Z = Z[None, :]
348
- Z = jax.device_put(Z, self.ddpm_device)
349
 
350
  if refine:
 
351
  Z = self.dm.refine_latents(Z, t_start=int(t_start), key=key, add_noise=bool(add_noise))
 
352
 
353
- # unnormalize to raw latent (still on device)
354
- R_ax = (Z * self.lat_std_j) + self.lat_mean_j
355
- R_ax_np = np.asarray(jax.device_get(R_ax)) # to CPU
356
-
357
- X_hat = self.dec(R_ax_np, batch_size=batch_size) # CPU decode
358
- return np.asarray(X_hat)
359
 
360
  def reconstruct(
361
  self,
@@ -367,248 +453,141 @@ class DIMA:
367
  key: Optional[jax.Array] = None,
368
  batch_size: Optional[int] = None,
369
  ) -> np.ndarray:
370
- """Convenience: decode(encode(X))."""
371
- Z = self.encode(R_aX, to_device=True)
372
- return self.decode(Z, refine=refine, t_start=t_start, add_noise=add_noise, key=key, batch_size=batch_size)
373
 
374
- def sample(self, n: int, *, decode: bool = True, batch_size: Optional[int] = None) -> Union[jnp.ndarray, np.ndarray]:
 
 
 
 
 
 
375
  """
376
  Unconditional samples from latent DDPM.
377
- If decode=True, returns ambient samples as np.ndarray.
378
- Otherwise returns normalized latents as jnp.ndarray.
379
  """
380
  with jax.default_device(self.ddpm_device):
381
  Z = self.dm.sample(int(n))
 
382
  if not decode:
383
- return Z
384
- return self.decode(Z, refine=False, batch_size=batch_size)
385
-
386
- # -------------------------
387
- # helpers: normalize <-> raw
388
- # -------------------------
389
- def _to_np(self, A: Union[np.ndarray, jnp.ndarray]) -> np.ndarray:
390
- if isinstance(A, jax.Array):
391
- return np.asarray(jax.device_get(A))
392
- return np.asarray(A)
393
-
394
- def _norm_to_raw_np(self, Z_ax: np.ndarray) -> np.ndarray:
395
- # Z_ax: normalized (a,d) -> raw r_ax (a,d)
396
- return Z_ax * self.lat_std_np + self.lat_mean_np
397
-
398
- def _raw_to_norm_np(self, R_ax: np.ndarray) -> np.ndarray:
399
- # raw r_ax (a,d) -> normalized (a,d)
400
- return (R_ax - self.lat_mean_np) / self.lat_std_np
401
-
402
- def _metric_raw_to_norm(self, g_raw_add: np.ndarray) -> np.ndarray:
403
- # g_norm_xy = sigma_x sigma_y g_raw_xy
404
- sig = self.lat_std_np.reshape(-1) # (d,)
405
- return g_raw_add * (sig[None, :, None] * sig[None, None, :])
406
-
407
- def _jac_raw_to_norm(self, J_raw_aDd: np.ndarray) -> np.ndarray:
408
- # J_norm_{D,x} = dR/dz_x = dR/dr_y * dr_y/dz_x = J_raw_{D,x} * sigma_x
409
- sig = self.lat_std_np.reshape(-1) # (d,)
410
- return J_raw_aDd * sig[None, None, :]
411
 
412
  # -------------------------
413
- # new: flow wrapper
414
  # -------------------------
415
  def flow(
416
  self,
417
- Z_ax: Union[np.ndarray, jnp.ndarray],
418
- v0_ax: Union[np.ndarray, jnp.ndarray],
419
  *,
420
  dt: float = 0.05,
421
  reg: float = 1e-8,
422
  keep_speed: bool = True,
423
- # optional: decode + metric/J outputs at the new point
424
- decode: bool = True,
425
- jacobian: bool = False,
426
- metric: bool = False,
427
- metric_in: str = "norm", # "norm" or "raw"
428
- # optional DDPM projection (stochastic): OFF by default for deterministic geodesics
429
  refine: bool = False,
430
- ddpm_kwargs: Optional[Dict[str, Any]] = None,
431
- ):
 
 
 
 
 
432
  """
433
- One geodesic step in latent space using GPLM.flow (analytic Christoffels + velocity-Verlet),
434
- where Z_ax and v0_ax are in *normalized* latent coordinates.
435
 
436
- Returns (if decode=True):
437
- Xhat_aX, Q_ax, v_ax (and optionally J/g depending on flags)
438
- Returns (if decode=False):
439
- Q_ax, v_ax
440
- """
441
- ddpm_kwargs = {} if ddpm_kwargs is None else dict(ddpm_kwargs)
442
 
443
- Z_np = self._to_np(Z_ax)
444
- v_np = self._to_np(v0_ax)
445
 
446
- single = (Z_np.ndim == 1)
 
 
 
 
 
 
 
 
447
  if single:
448
- Z_np = Z_np[None, :]
449
- if v_np.ndim == 1:
450
- v_np = v_np[None, :]
451
-
452
- if Z_np.shape != v_np.shape:
453
- raise ValueError(f"Z_ax and v0_ax must have same shape; got {Z_np.shape} vs {v_np.shape}")
454
 
455
- # normalized -> raw for GPLM.flow
456
- R_raw = self._norm_to_raw_np(Z_np) # (a,d)
457
- v_raw = v_np * self.lat_std_np # (a,d) (dr/dt = sigma * dz/dt)
458
-
459
- # one step geodesic update in raw coordinates
460
- Q_raw, v_raw_new = self.dec.flow(R_raw, v_raw, dt=dt, reg=reg, keep_speed=keep_speed)
461
 
462
- # raw -> normalized
463
- Q_norm = self._raw_to_norm_np(Q_raw)
464
- v_norm = v_raw_new / self.lat_std_np
465
 
466
- # optional: DDPM projection in normalized coords
467
  if refine:
468
- # keep this minimal; adapt to your DDPM API if needed
469
- Zj = jax.device_put(jnp.asarray(Q_norm, dtype=jnp.float32), self.ddpm_device)
470
- if hasattr(self.dm, "refine_latents"):
471
- Zj2 = self.dm.refine_latents(Zj, **ddpm_kwargs)
472
- else:
473
- Zj2 = self.dm(Zj, **ddpm_kwargs)
474
- Q_norm = np.asarray(jax.device_get(Zj2))
475
- # velocity after projection is ambiguous; keep v as-is in normalized chart
476
-
477
- # return latents on ddpm device (consistent with encode())
478
- Q_out = jax.device_put(jnp.asarray(Q_norm, dtype=jnp.float32), self.ddpm_device)
479
- v_out = jax.device_put(jnp.asarray(v_norm, dtype=jnp.float32), self.ddpm_device)
480
 
481
  if not decode:
482
  if single:
483
- return Q_out[0], v_out[0]
484
- return Q_out, v_out
485
-
486
- # decode at Q_norm: need raw coords for GPLM.__call__
487
- Q_raw2 = self._norm_to_raw_np(np.asarray(Q_norm))
488
 
489
- if (not jacobian) and (not metric):
490
- Xhat = self.dec(Q_raw2)
491
- if single:
492
- return Xhat[0], Q_out[0], v_out[0]
493
- return Xhat, Q_out, v_out
494
-
495
- # request J and/or g from GPLM in raw coords
496
- out = self.dec(Q_raw2, jacobian=jacobian, metric=metric)
497
-
498
- # normalize J/g if requested
499
- if jacobian and metric:
500
- Xhat, J_raw, g_raw = out
501
- if metric_in == "norm":
502
- J_use = self._jac_raw_to_norm(J_raw)
503
- g_use = self._metric_raw_to_norm(g_raw)
504
- else:
505
- J_use, g_use = J_raw, g_raw
506
- if single:
507
- return Xhat[0], J_use[0], g_use[0], Q_out[0], v_out[0]
508
- return Xhat, J_use, g_use, Q_out, v_out
509
-
510
- if jacobian and (not metric):
511
- Xhat, J_raw = out
512
- J_use = self._jac_raw_to_norm(J_raw) if metric_in == "norm" else J_raw
513
- if single:
514
- return Xhat[0], J_use[0], Q_out[0], v_out[0]
515
- return Xhat, J_use, Q_out, v_out
516
-
517
- # metric only
518
- Xhat, g_raw = out
519
- g_use = self._metric_raw_to_norm(g_raw) if metric_in == "norm" else g_raw
520
  if single:
521
- return Xhat[0], g_use[0], Q_out[0], v_out[0]
522
- return Xhat, g_use, Q_out, v_out
523
 
524
  # -------------------------
525
- # modify __call__ to support metric/jacobian and velocity
526
  # -------------------------
527
  def __call__(
528
  self,
529
- R_any: Union[np.ndarray, jnp.ndarray],
530
  *,
531
- refine: bool = True,
532
- jacobian: bool = False,
533
- metric: bool = False,
534
- metric_in: str = "norm", # for latent->ambient: return g in "norm" or "raw"
535
- velocity: Optional[Union[np.ndarray, jnp.ndarray]] = None,
536
- dt: float = 0.05,
537
- reg: float = 1e-8,
538
- keep_speed: bool = True,
539
- ddpm_kwargs: Optional[Dict[str, Any]] = None,
540
- ):
541
  """
542
- Polymorphic call:
543
- - if trailing dim == D: ambient -> normalized latent (jnp on ddpm device)
544
- - if trailing dim == d: normalized latent -> ambient (np), optionally returning J/g
545
- - if trailing dim == d and velocity is provided: one flow step then decode
546
 
547
- Returns:
548
- ambient->latent: Z (jnp)
549
- latent->ambient:
550
- - if no velocity: Xhat (or (Xhat,J)/(Xhat,g)/(Xhat,J,g))
551
- - if velocity: Xhat, Q, v (and optionally J/g depending on flags)
 
552
  """
553
- ddpm_kwargs = {} if ddpm_kwargs is None else dict(ddpm_kwargs)
554
-
555
- X = self._to_np(R_any)
556
- last = X.shape[-1]
557
-
558
- if last == self.D:
559
- # keep whatever your encode() does; here’s the minimal consistent behavior:
560
- Z = np.asarray(self.enc(X)) # raw latents (np)
561
- Z = (Z - self.lat_mean_np) / self.lat_std_np # normalized
562
- return jax.device_put(jnp.asarray(Z, dtype=jnp.float32), self.ddpm_device)
563
-
564
- if last == self.d:
565
- # If velocity: do one geodesic step (normalized chart), then decode
566
- if velocity is not None:
567
- return self.flow(
568
- X, velocity,
569
- dt=dt, reg=reg, keep_speed=keep_speed,
570
- decode=True, jacobian=jacobian, metric=metric, metric_in=metric_in,
571
- refine=refine, ddpm_kwargs=ddpm_kwargs,
572
- )
573
-
574
- # Otherwise: decode directly (optionally refine in normalized coords)
575
- Z_norm = X
576
- if refine:
577
- Zj = jax.device_put(jnp.asarray(Z_norm, dtype=jnp.float32), self.ddpm_device)
578
- if hasattr(self.dm, "refine_latents"):
579
- Zj2 = self.dm.refine_latents(Zj, **ddpm_kwargs)
580
- else:
581
- Zj2 = self.dm(Zj, **ddpm_kwargs)
582
- Z_norm = np.asarray(jax.device_get(Zj2))
583
-
584
- R_raw = self._norm_to_raw_np(np.asarray(Z_norm))
585
- out = self.dec(R_raw, jacobian=jacobian, metric=metric)
586
-
587
- if (not jacobian) and (not metric):
588
- return out
589
-
590
- if jacobian and metric:
591
- Xhat, J_raw, g_raw = out
592
- if metric_in == "norm":
593
- return Xhat, self._jac_raw_to_norm(J_raw), self._metric_raw_to_norm(g_raw)
594
- return out
595
-
596
- if jacobian and (not metric):
597
- Xhat, J_raw = out
598
- return (Xhat, self._jac_raw_to_norm(J_raw)) if metric_in == "norm" else out
599
-
600
- # metric only
601
- Xhat, g_raw = out
602
- return (Xhat, self._metric_raw_to_norm(g_raw)) if metric_in == "norm" else out
603
-
604
- raise ValueError(f"Trailing dim must be D={self.D} (ambient) or d={self.d} (latent), got {last}.")
605
 
 
606
 
607
  # -------------------------
608
  # Save / Load
609
  # -------------------------
610
  def _pack_encoder(self) -> Dict[str, Any]:
611
- # minimal state for Nyström OOS
612
  return dict(
613
  R_iX=np.asarray(self.enc.R_iX),
614
  qalpha_i=np.asarray(getattr(self.enc, "qalpha_i", getattr(self.enc, "qα_i"))),
@@ -622,7 +601,7 @@ class DIMA:
622
 
623
  def _pack_decoder(self) -> Dict[str, Any]:
624
  return dict(
625
- Z_mx_w=np.asarray(getattr(self.dec, "Z_mx_w", getattr(self.dec, "Z_mx_w", None))),
626
  M_mX=np.asarray(self.dec.M_mX),
627
  mean_X=np.asarray(getattr(self.dec, "mean_X", np.zeros((self.D,), dtype=np.float64))),
628
  lat_mean_x=np.asarray(getattr(self.dec, "lat_mean_x", np.zeros((self.d,), dtype=np.float64))),
@@ -634,9 +613,6 @@ class DIMA:
634
  )
635
 
636
  def state_dict(self) -> Dict[str, Any]:
637
- """
638
- Serializable state (msgpack via flax.serialization).
639
- """
640
  dd = dict(
641
  T=int(self.dm.T),
642
  D=int(self.dm.D),
@@ -654,7 +630,7 @@ class DIMA:
654
  N=int(self.N),
655
  D=int(self.D),
656
  d=int(self.d),
657
- training_time=float(self.training_time),
658
  ),
659
  config=asdict(self.config),
660
  latent_norm=dict(
@@ -702,9 +678,11 @@ class DIMA:
702
  obj.d = int(state["meta"]["d"])
703
 
704
  # RNG
705
- if key is None:
706
- key = random.PRNGKey(0)
707
- obj.rng = key
 
 
708
 
709
  # latent norm
710
  obj.lat_mean_np = np.asarray(state["latent_norm"]["mean"], dtype=np.float64)
@@ -713,9 +691,9 @@ class DIMA:
713
  obj.lat_mean_j = jax.device_put(jnp.asarray(obj.lat_mean_np, dtype=jnp.float32), obj.ddpm_device)
714
  obj.lat_std_j = jax.device_put(jnp.asarray(obj.lat_std_np, dtype=jnp.float32), obj.ddpm_device)
715
 
716
- # encoder/decoder frozen
717
  obj.enc = FrozenDMAP(state["encoder"], ann_backend=ann_backend, ann_params=ann_params, n_jobs=n_jobs)
718
- obj.dec = FrozenGPLM(state["decoder"], ann_backend=ann_backend, ann_params=ann_params, n_jobs=n_jobs)
719
 
720
  # rebuild DDPM skeleton with dummy data, then load params
721
  dd = state["ddpm"]
@@ -749,36 +727,26 @@ class DIMA:
749
  return obj
750
 
751
  # -------------------------
752
- # Hugging Face helpers
753
  # -------------------------
754
- def upload_to_huggingface(
755
- self,
756
- repo_id: str,
757
- *,
758
- hf_token: Optional[str] = None,
759
- weights_file: str = "dima.msgpack",
760
- config_file: str = "config.json",
761
- ) -> None:
762
  if not _HAS_HF:
763
- raise ImportError("huggingface_hub not installed. pip install dima[hf] (or huggingface_hub).")
764
- if hf_token is not None:
765
- HfFolder.save_token(hf_token)
766
- api = HfApi()
767
- _ = api.whoami(token=hf_token) # sanity check token (no-op if public)
768
-
769
  self.save_local(weights_file=weights_file, config_file=config_file)
770
- for fname in (weights_file, config_file):
771
- upload_file(path_or_fileobj=fname, path_in_repo=fname, repo_id=repo_id, token=hf_token)
772
- return None
 
 
 
 
773
 
774
  @classmethod
775
- def load_from_huggingface(
776
  cls,
777
  repo_id: str,
778
  *,
779
- hf_token: Optional[str] = None,
780
  weights_file: str = "dima.msgpack",
781
- config_file: str = "config.json",
782
  ddpm_device: str = "auto",
783
  ann_backend: ANNBackend = "auto",
784
  ann_params: Optional[Dict[str, Any]] = None,
@@ -786,13 +754,10 @@ class DIMA:
786
  key: Optional[jax.Array] = None,
787
  ) -> "DIMA":
788
  if not _HAS_HF:
789
- raise ImportError("huggingface_hub not installed. pip install dima[hf] (or huggingface_hub).")
790
-
791
- wpath = hf_hub_download(repo_id, weights_file, token=hf_token)
792
- _ = hf_hub_download(repo_id, config_file, token=hf_token) # for humans / future-proofing
793
-
794
  return cls.load_local(
795
- wpath,
796
  ddpm_device=ddpm_device,
797
  ann_backend=ann_backend,
798
  ann_params=ann_params,
 
4
  import json
5
  import time
6
  from dataclasses import asdict, dataclass
7
+ from typing import Any, Dict, Optional, Tuple, Union
8
 
9
  import numpy as np
10
 
 
14
 
15
  from flax import serialization as flax_ser
16
 
17
+ from .ann import ANNBackend, make_ann
18
+ from .ddpm import DDPM
19
  from .dmap import DMAP
20
  from .gplm import GPLM
 
 
21
 
22
 
23
  # ----------------------------
24
  # Optional: Hugging Face Hub
25
  # ----------------------------
26
  try:
27
+ from huggingface_hub import HfApi, HfFolder, upload_file, hf_hub_download # type: ignore
28
  _HAS_HF = True
29
  except Exception:
30
  _HAS_HF = False
31
 
32
 
33
+ _UNSET = object()
34
+
35
+
36
  def _select_device(prefer: str = "auto"):
37
  """
38
  Safe JAX device selection.
 
47
  return gpu[0] if gpu else (cpu[0] if cpu else devs[0])
48
  if prefer == "cpu":
49
  return cpu[0] if cpu else devs[0]
 
50
  return gpu[0] if gpu else (cpu[0] if cpu else devs[0])
51
 
52
 
 
76
  ):
77
  self.k = int(state["k"])
78
  self.beta = float(state["beta"])
79
+ self.β = self.beta
80
  self.alpha = float(state["alpha"])
81
+ self.α = self.alpha
82
  self.eps = float(state["eps"])
83
+ self.ε = self.eps
84
  self.dtype = np.dtype(state.get("dtype", "float32"))
85
 
86
  self.R_iX = np.ascontiguousarray(np.asarray(state["R_iX"]).astype(self.dtype, copy=False))
87
  self.qalpha_i = np.asarray(state["qalpha_i"], dtype=np.float64)
88
+ self.qα_i = self.qalpha_i # alias
89
  self.R_over_lam_ix = np.asarray(state["R_over_lam_ix"], dtype=np.float64) # (Nref,d)
90
+ self.R_over_λ_ix = self.R_over_lam_ix # alias
91
 
92
  self.ann, self.ann_backend = make_ann(ann_backend, ann_params=ann_params, n_jobs=n_jobs)
93
  self.ann.build(self.R_iX)
 
129
  return Z_ax
130
 
131
 
132
+ def _restore_gplm_as_object(
133
+ state: Dict[str, Any],
134
+ *,
135
+ ann_backend: ANNBackend = "auto",
136
+ ann_params: Optional[Dict[str, Any]] = None,
137
+ n_jobs: int = -1,
138
+ ) -> GPLM:
139
  """
140
+ Rehydrate a GPLM instance from saved state WITHOUT retraining.
141
+ This is deliberately done as a true GPLM instance so you also get GPLM.flow(...)
142
+ (assuming your GPLM class implements .flow()).
143
  """
144
+ obj = GPLM.__new__(GPLM) # type: ignore
145
 
146
+ obj.beta = float(state["beta"])
147
+ obj.β = obj.beta
148
+ obj.eps = float(state["eps"])
149
+ obj.ε = obj.eps
150
+ obj.pred_k = None if state.get("pred_k", None) is None else int(state["pred_k"])
151
+ obj.pred_κ = obj.pred_k
152
+ obj.dtype = np.dtype(state.get("dtype", "float32"))
 
 
 
 
 
 
 
 
153
 
154
+ obj.mean_X = np.asarray(state["mean_X"], dtype=np.float64)
155
+ obj.M_mX = np.asarray(state["M_mX"], dtype=np.float64)
 
156
 
157
+ obj.lat_mean_x = np.asarray(state["lat_mean_x"], dtype=np.float64)
158
+ obj.lat_std_x = np.asarray(state["lat_std_x"], dtype=np.float64)
 
159
 
160
+ obj.Z_mx_w = np.ascontiguousarray(np.asarray(state["Z_mx_w"]).astype(np.float64, copy=False))
161
+ obj.m = int(obj.Z_mx_w.shape[0])
162
 
163
+ obj.ann_Z, _ = make_ann(ann_backend, ann_params=ann_params, n_jobs=n_jobs)
164
+ obj.ann_Z.build(obj.Z_mx_w.astype(obj.dtype, copy=False))
 
 
 
 
165
 
166
+ return obj
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
 
169
  # ----------------------------
 
172
  @dataclass
173
  class DIMAConfig:
174
  d: int = 32
175
+ beta: float = 1.0
176
  ddpm_device: str = "auto" # "auto" | "cpu" | "gpu"
177
+ version: str = "0.2.0"
 
178
 
179
 
180
  # ----------------------------
 
182
  # ----------------------------
183
  class DIMA:
184
  """
185
+ DIMA: DMAP encoder + (latent DDPM) + GPLM decoder.
186
+
187
+ Public “user-facing” convention in this wrapper:
188
+
189
+ - Raw DMAP coordinates are the *public latent* (np.ndarray): R_ax (a,d)
190
+ - Normalized latents are the DDPM coordinates (jax/np): Z_ax (a,d)
191
+
192
+ Minimal user API (what you asked for):
193
+
194
+ dima = DIMA(R_iX, d=20, beta=2.0)
195
+ R_ax = dima(R_aX) # encode ambient -> raw latents
196
+ Q_aX = dima(R_ax) # decode raw latents -> ambient
197
+
198
+ You can still pass full dict overrides for any submodule:
199
+ dima = DIMA(..., dmap_kwargs={...}, gplm_kwargs={...}, ddpm_kwargs={...})
200
+ and you can also tweak the “headline” DDPM knobs directly in __init__ (below).
201
  """
202
 
203
  def __init__(
204
  self,
205
  R_iX: np.ndarray,
206
  *,
207
+ # main knobs
208
  d: int = 32,
209
+ beta: float = 1.0,
210
+
211
+ # allow per-module override (if None -> uses global beta)
212
+ dmap_beta: Optional[float] = None,
213
+ gplm_beta: Optional[float] = None,
214
+
215
+ # DMAP headline knobs (everything else via dmap_kwargs)
216
+ dmap_alpha: float = 0.0,
217
+ dmap_t: float = 1.0,
218
+ dmap_k: Optional[int] = None,
219
+
220
+ # GPLM headline knobs (everything else via gplm_kwargs)
221
+ gplm_m: int = 1024,
222
+ gplm_pred_k: Optional[int] = None,
223
+
224
+ # DDPM headline knobs (the ones worth surfacing)
225
+ ddpm_T: int = 200,
226
+ ddpm_hidden_dim: int = 128,
227
+ ddpm_t_embed_dim: int = 64,
228
+ ddpm_learning_rate: float = 3e-4,
229
+ ddpm_n_iter: int = 200_000,
230
+ ddpm_ema_decay: float = 0.999,
231
+ ddpm_beta_max: float = 0.02,
232
+ ddpm_batch_size: int = 256,
233
+ ddpm_verbose_every: int = 0,
234
+ ddpm_eps: float = 1e-5,
235
+
236
+ # runtime
237
+ ddpm_device: str = "auto",
238
+ key: Optional[jax.Array] = None,
239
+
240
+ # ann
241
+ ann_backend: ANNBackend = "auto",
242
+ ann_params: Optional[Dict[str, Any]] = None,
243
+ n_jobs: int = -1,
244
+
245
+ # “escape hatches”
246
  dmap_kwargs: Optional[Dict[str, Any]] = None,
247
  gplm_kwargs: Optional[Dict[str, Any]] = None,
248
  ddpm_kwargs: Optional[Dict[str, Any]] = None,
 
 
 
 
249
 
250
+ # unicode aliases (β)
251
+ **kwargs: Any,
252
+ ):
253
+ # ---- unicode aliases ----
254
+ if "β" in kwargs:
255
+ beta = float(kwargs.pop("β"))
256
+ if "β_dmap" in kwargs:
257
+ dmap_beta = float(kwargs.pop("β_dmap"))
258
+ if "β_gplm" in kwargs:
259
+ gplm_beta = float(kwargs.pop("β_gplm"))
260
+ if kwargs:
261
+ raise TypeError(f"Unexpected kwargs: {sorted(kwargs.keys())}")
262
+
263
+ self.config = DIMAConfig(d=int(d), beta=float(beta), ddpm_device=str(ddpm_device))
264
+
265
+ # devices + rng
266
+ self.ddpm_device = _select_device(ddpm_device)
267
+ self.cpu_device = _select_device("cpu")
268
+ self.rng = random.PRNGKey(0) if key is None else key
269
 
270
+ # training data
271
  self.R_iX = np.asarray(R_iX)
272
  if self.R_iX.ndim != 2:
273
  raise ValueError("R_iX must be 2D (N,D).")
274
  self.N, self.D = self.R_iX.shape
275
  self.d = int(d)
276
 
277
+ # betas
278
+ self.beta = float(beta)
279
+ self.β = self.beta
280
 
281
+ self.dmap_beta = float(self.beta if dmap_beta is None else dmap_beta)
282
+ self.gplm_beta = float(self.beta if gplm_beta is None else gplm_beta)
 
283
 
284
+ t0 = time.time()
 
 
285
 
286
  # -------------------------
287
  # 1) Train DMAP (CPU)
288
  # -------------------------
289
+ dmap_init = dict(
290
+ d=self.d,
291
+ beta=self.dmap_beta,
292
+ alpha=float(dmap_alpha),
293
+ t=float(dmap_t),
294
+ k=dmap_k,
295
+ ann_backend=ann_backend,
296
+ ann_params=ann_params,
297
+ n_jobs=n_jobs,
298
+ )
299
+ if dmap_kwargs:
300
+ dmap_init.update(dict(dmap_kwargs))
301
+ # enforce headline knobs
302
+ dmap_init["d"] = self.d
303
+ dmap_init["beta"] = self.dmap_beta
304
+ dmap_init["alpha"] = float(dmap_alpha)
305
+ dmap_init["t"] = float(dmap_t)
306
+ dmap_init["k"] = dmap_k
307
+
308
+ self.enc = DMAP(self.R_iX, **dmap_init)
309
+
310
+ # raw DMAP coordinates for *all* training points (Nyström OOS on training set)
311
+ R_ix = np.asarray(self.enc(self.R_iX), dtype=np.float64) # (N,d)
312
 
313
  # -------------------------
314
+ # 2) Latent normalization for DDPM
315
  # -------------------------
316
+ self.lat_mean_np = R_ix.mean(axis=0)
317
+ self.lat_std_np = np.maximum(R_ix.std(axis=0), 1e-12)
 
 
318
 
 
319
  self.lat_mean_j = jax.device_put(jnp.asarray(self.lat_mean_np, dtype=jnp.float32), self.ddpm_device)
320
  self.lat_std_j = jax.device_put(jnp.asarray(self.lat_std_np, dtype=jnp.float32), self.ddpm_device)
321
 
322
+ Z_ix = (R_ix - self.lat_mean_np) / self.lat_std_np # (N,d)
323
+
324
  # -------------------------
325
  # 3) Train GPLM (CPU)
326
  # -------------------------
327
+ gplm_init = dict(
328
+ beta=self.gplm_beta,
329
+ m=int(gplm_m),
330
+ pred_k=gplm_pred_k,
331
+ ann_backend=ann_backend,
332
+ ann_params=ann_params,
333
+ n_jobs=n_jobs,
334
+ )
335
+ if gplm_kwargs:
336
+ gplm_init.update(dict(gplm_kwargs))
337
+ # enforce headline knobs
338
+ gplm_init["beta"] = self.gplm_beta
339
+ gplm_init["m"] = int(gplm_m)
340
+ gplm_init["pred_k"] = gplm_pred_k
341
+
342
+ self.dec = GPLM(R_ix.astype(np.float32, copy=False), self.R_iX, **gplm_init)
343
 
344
  # -------------------------
345
  # 4) Train DDPM on normalized latents (DDPM device)
346
  # -------------------------
347
  Z_ix_j = jax.device_put(jnp.asarray(Z_ix, dtype=jnp.float32), self.ddpm_device)
348
 
 
349
  ddpm_init = dict(
350
+ T=int(ddpm_T),
351
+ hidden_dim=int(ddpm_hidden_dim),
352
+ t_embed_dim=int(ddpm_t_embed_dim),
353
+ learning_rate=float(ddpm_learning_rate),
354
+ n_iter=int(ddpm_n_iter),
355
+ ema_decay=float(ddpm_ema_decay),
356
+ beta_max=float(ddpm_beta_max),
357
+ batch_size=int(ddpm_batch_size),
358
  key=self.rng,
359
+ verbose_every=int(ddpm_verbose_every),
360
+ eps=float(ddpm_eps),
361
  )
362
+ if ddpm_kwargs:
363
+ ddpm_init.update(dict(ddpm_kwargs))
364
+ # enforce headline knobs
365
+ ddpm_init["T"] = int(ddpm_T)
366
+ ddpm_init["hidden_dim"] = int(ddpm_hidden_dim)
367
+ ddpm_init["t_embed_dim"] = int(ddpm_t_embed_dim)
368
+ ddpm_init["learning_rate"] = float(ddpm_learning_rate)
369
+ ddpm_init["n_iter"] = int(ddpm_n_iter)
370
+ ddpm_init["ema_decay"] = float(ddpm_ema_decay)
371
+ ddpm_init["beta_max"] = float(ddpm_beta_max)
372
+ ddpm_init["batch_size"] = int(ddpm_batch_size)
373
+ ddpm_init["verbose_every"] = int(ddpm_verbose_every)
374
+ ddpm_init["eps"] = float(ddpm_eps)
375
 
376
  with jax.default_device(self.ddpm_device):
377
  self.dm = DDPM(Z_ix_j, **ddpm_init)
 
379
  self.training_time = time.time() - t0
380
 
381
  # -------------------------
382
+ # Latent conversions
383
+ # -------------------------
384
+ def normalize(self, R_ax: Union[np.ndarray, jnp.ndarray]) -> jnp.ndarray:
385
+ """raw latents (R) -> normalized latents (Z) on ddpm_device."""
386
+ R = np.asarray(R_ax, dtype=np.float64)
387
+ if R.ndim == 1:
388
+ R = R[None, :]
389
+ Z = (R - self.lat_mean_np) / self.lat_std_np
390
+ Zj = jnp.asarray(Z, dtype=jnp.float32)
391
+ return jax.device_put(Zj, self.ddpm_device)
392
+
393
+ def unnormalize(self, Z_ax: Union[np.ndarray, jnp.ndarray]) -> np.ndarray:
394
+ """normalized latents (Z) -> raw latents (R) on CPU (np)."""
395
+ if isinstance(Z_ax, jax.Array):
396
+ Z_np = np.asarray(jax.device_get(Z_ax))
397
+ else:
398
+ Z_np = np.asarray(Z_ax)
399
+ if Z_np.ndim == 1:
400
+ Z_np = Z_np[None, :]
401
+ R = Z_np * self.lat_std_np + self.lat_mean_np
402
+ return np.asarray(R)
403
+
404
+ # -------------------------
405
+ # Encode / Decode (public: raw latents)
406
  # -------------------------
407
+ def encode(self, R_aX: Union[np.ndarray, jnp.ndarray], *, normalize: bool = False) -> Union[np.ndarray, jnp.ndarray]:
408
  """
409
+ ambient -> raw DMAP latents (np) by default.
410
+ If normalize=True, returns normalized latents (jnp) on ddpm_device.
411
  """
412
  X = np.asarray(R_aX)
413
+ R_raw = np.asarray(self.enc(X)) # CPU, (a,d)
414
+ if not normalize:
415
+ return R_raw
416
+ return self.normalize(R_raw)
417
 
418
  def decode(
419
  self,
420
+ R_ax: Union[np.ndarray, jnp.ndarray],
421
  *,
422
+ refine: bool = False,
423
  t_start: int = 10,
424
  add_noise: bool = True,
425
  key: Optional[jax.Array] = None,
426
  batch_size: Optional[int] = None,
427
  ) -> np.ndarray:
428
  """
429
+ raw latent -> (optional DDPM refine in normalized coords) -> raw latent -> ambient.
430
+ Returns ambient np.ndarray on CPU.
431
  """
432
+ R_raw = np.asarray(R_ax, dtype=np.float64)
433
+ single = (R_raw.ndim == 1)
434
+ if single:
435
+ R_raw = R_raw[None, :]
436
 
437
  if refine:
438
+ Z = self.normalize(R_raw) # on device
439
  Z = self.dm.refine_latents(Z, t_start=int(t_start), key=key, add_noise=bool(add_noise))
440
+ R_raw = self.unnormalize(Z) # back to CPU raw
441
 
442
+ X_hat = self.dec(R_raw.astype(np.float32, copy=False), batch_size=batch_size)
443
+ X_hat = np.asarray(X_hat)
444
+ return X_hat[0] if single else X_hat
 
 
 
445
 
446
  def reconstruct(
447
  self,
 
453
  key: Optional[jax.Array] = None,
454
  batch_size: Optional[int] = None,
455
  ) -> np.ndarray:
456
+ """decode(encode(X))."""
457
+ R_raw = self.encode(R_aX, normalize=False)
458
+ return self.decode(R_raw, refine=refine, t_start=t_start, add_noise=add_noise, key=key, batch_size=batch_size)
459
 
460
+ def sample(
461
+ self,
462
+ n: int,
463
+ *,
464
+ decode: bool = True,
465
+ batch_size: Optional[int] = None,
466
+ ) -> Union[np.ndarray, np.ndarray]:
467
  """
468
  Unconditional samples from latent DDPM.
469
+ If decode=True: returns ambient samples (np) on CPU.
470
+ If decode=False: returns raw latents (np) on CPU.
471
  """
472
  with jax.default_device(self.ddpm_device):
473
  Z = self.dm.sample(int(n))
474
+ R = self.unnormalize(Z) # raw (np)
475
  if not decode:
476
+ return R
477
+ return self.dec(R.astype(np.float32, copy=False), batch_size=batch_size)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
 
479
  # -------------------------
480
+ # Geodesic-ish flow wrapper (delegates to GPLM.flow)
481
  # -------------------------
482
  def flow(
483
  self,
484
+ R_ax: Union[np.ndarray, jnp.ndarray],
485
+ v_ax: Union[np.ndarray, jnp.ndarray],
486
  *,
487
  dt: float = 0.05,
488
  reg: float = 1e-8,
489
  keep_speed: bool = True,
490
+ # optional DDPM projection step (in normalized coords)
 
 
 
 
 
491
  refine: bool = False,
492
+ t_start: int = 10,
493
+ add_noise: bool = True,
494
+ key: Optional[jax.Array] = None,
495
+ # decode return
496
+ decode: bool = False,
497
+ batch_size: Optional[int] = None,
498
+ ) -> Union[Tuple[np.ndarray, np.ndarray], Tuple[np.ndarray, np.ndarray, np.ndarray]]:
499
  """
500
+ One step of latent flow in *raw* coordinates.
 
501
 
502
+ Requires: your GPLM class implements:
503
+ R_next, v_next = gplm.flow(R, v, dt=..., reg=..., keep_speed=...)
 
 
 
 
504
 
505
+ If refine=True, we project the *position* through DDPM in normalized coords after the step.
506
+ (Velocity after projection is left unchanged—projection isn’t a deterministic diffeo.)
507
 
508
+ Returns:
509
+ if decode=False:
510
+ (R_next, v_next) both np arrays
511
+ if decode=True:
512
+ (X_next, R_next, v_next)
513
+ """
514
+ R = np.asarray(R_ax, dtype=np.float64)
515
+ v = np.asarray(v_ax, dtype=np.float64)
516
+ single = (R.ndim == 1)
517
  if single:
518
+ R = R[None, :]
519
+ v = v[None, :]
 
 
 
 
520
 
521
+ if not hasattr(self.dec, "flow"):
522
+ raise AttributeError(
523
+ "Decoder does not have .flow(). Make sure you updated GPLM to include flow()."
524
+ )
 
 
525
 
526
+ Rn, vn = self.dec.flow(R, v, dt=float(dt), reg=float(reg), keep_speed=bool(keep_speed))
 
 
527
 
 
528
  if refine:
529
+ Z = self.normalize(Rn) # device
530
+ Z = self.dm.refine_latents(Z, t_start=int(t_start), key=key, add_noise=bool(add_noise))
531
+ Rn = self.unnormalize(Z)
 
 
 
 
 
 
 
 
 
532
 
533
  if not decode:
534
  if single:
535
+ return np.asarray(Rn[0]), np.asarray(vn[0])
536
+ return np.asarray(Rn), np.asarray(vn)
 
 
 
537
 
538
+ Xn = self.dec(Rn.astype(np.float32, copy=False), batch_size=batch_size)
539
+ Xn = np.asarray(Xn)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
  if single:
541
+ return Xn[0], np.asarray(Rn[0]), np.asarray(vn[0])
542
+ return Xn, np.asarray(Rn), np.asarray(vn)
543
 
544
  # -------------------------
545
+ # Convenience __call__
546
  # -------------------------
547
  def __call__(
548
  self,
549
+ A: Union[np.ndarray, jnp.ndarray],
550
  *,
551
+ refine: bool = False,
552
+ t_start: int = 10,
553
+ add_noise: bool = True,
554
+ key: Optional[jax.Array] = None,
555
+ batch_size: Optional[int] = None,
556
+ normalize_latent: bool = False,
557
+ ) -> Union[np.ndarray, jnp.ndarray]:
 
 
 
558
  """
559
+ Dispatch by last dimension:
 
 
 
560
 
561
+ - if A is (a,D): encode -> raw latents (np) by default
562
+ - if A is (a,d): decode -> ambient (np)
563
+
564
+ Options:
565
+ - normalize_latent=True only affects encoding (returns Z on device)
566
+ - refine/t_start/add_noise/key only affect decoding
567
  """
568
+ A_np = np.asarray(A)
569
+ if A_np.ndim == 1:
570
+ A_np = A_np[None, :]
571
+
572
+ if A_np.shape[1] == self.D:
573
+ return self.encode(A_np, normalize=bool(normalize_latent))
574
+
575
+ if A_np.shape[1] == self.d:
576
+ return self.decode(
577
+ A_np,
578
+ refine=bool(refine),
579
+ t_start=int(t_start),
580
+ add_noise=bool(add_noise),
581
+ key=key,
582
+ batch_size=batch_size,
583
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
584
 
585
+ raise ValueError(f"Input has last-dim {A_np.shape[1]}, expected D={self.D} or d={self.d}.")
586
 
587
  # -------------------------
588
  # Save / Load
589
  # -------------------------
590
  def _pack_encoder(self) -> Dict[str, Any]:
 
591
  return dict(
592
  R_iX=np.asarray(self.enc.R_iX),
593
  qalpha_i=np.asarray(getattr(self.enc, "qalpha_i", getattr(self.enc, "qα_i"))),
 
601
 
602
  def _pack_decoder(self) -> Dict[str, Any]:
603
  return dict(
604
+ Z_mx_w=np.asarray(getattr(self.dec, "Z_mx_w", None)),
605
  M_mX=np.asarray(self.dec.M_mX),
606
  mean_X=np.asarray(getattr(self.dec, "mean_X", np.zeros((self.D,), dtype=np.float64))),
607
  lat_mean_x=np.asarray(getattr(self.dec, "lat_mean_x", np.zeros((self.d,), dtype=np.float64))),
 
613
  )
614
 
615
  def state_dict(self) -> Dict[str, Any]:
 
 
 
616
  dd = dict(
617
  T=int(self.dm.T),
618
  D=int(self.dm.D),
 
630
  N=int(self.N),
631
  D=int(self.D),
632
  d=int(self.d),
633
+ training_time=float(getattr(self, "training_time", 0.0)),
634
  ),
635
  config=asdict(self.config),
636
  latent_norm=dict(
 
678
  obj.d = int(state["meta"]["d"])
679
 
680
  # RNG
681
+ obj.rng = random.PRNGKey(0) if key is None else key
682
+
683
+ # beta (for display / convenience)
684
+ obj.beta = float(obj.config.beta)
685
+ obj.β = obj.beta
686
 
687
  # latent norm
688
  obj.lat_mean_np = np.asarray(state["latent_norm"]["mean"], dtype=np.float64)
 
691
  obj.lat_mean_j = jax.device_put(jnp.asarray(obj.lat_mean_np, dtype=jnp.float32), obj.ddpm_device)
692
  obj.lat_std_j = jax.device_put(jnp.asarray(obj.lat_std_np, dtype=jnp.float32), obj.ddpm_device)
693
 
694
+ # frozen encoder + rehydrated decoder-as-GPLM (so flow works if GPLM.flow exists)
695
  obj.enc = FrozenDMAP(state["encoder"], ann_backend=ann_backend, ann_params=ann_params, n_jobs=n_jobs)
696
+ obj.dec = _restore_gplm_as_object(state["decoder"], ann_backend=ann_backend, ann_params=ann_params, n_jobs=n_jobs)
697
 
698
  # rebuild DDPM skeleton with dummy data, then load params
699
  dd = state["ddpm"]
 
727
  return obj
728
 
729
  # -------------------------
730
+ # (Optional) HF helpers
731
  # -------------------------
732
+ def save_hf(self, repo_id: str, weights_file: str = "dima.msgpack", config_file: str = "config.json") -> None:
 
 
 
 
 
 
 
733
  if not _HAS_HF:
734
+ raise RuntimeError("huggingface_hub not installed.")
 
 
 
 
 
735
  self.save_local(weights_file=weights_file, config_file=config_file)
736
+ token = HfFolder.get_token()
737
+ if token is None:
738
+ raise RuntimeError("No HF token found. Run `huggingface-cli login`.")
739
+ api = HfApi()
740
+ api.create_repo(repo_id=repo_id, repo_type="model", exist_ok=True, token=token)
741
+ upload_file(path_or_fileobj=weights_file, path_in_repo=weights_file, repo_id=repo_id, token=token)
742
+ upload_file(path_or_fileobj=config_file, path_in_repo=config_file, repo_id=repo_id, token=token)
743
 
744
  @classmethod
745
+ def load_hf(
746
  cls,
747
  repo_id: str,
748
  *,
 
749
  weights_file: str = "dima.msgpack",
 
750
  ddpm_device: str = "auto",
751
  ann_backend: ANNBackend = "auto",
752
  ann_params: Optional[Dict[str, Any]] = None,
 
754
  key: Optional[jax.Array] = None,
755
  ) -> "DIMA":
756
  if not _HAS_HF:
757
+ raise RuntimeError("huggingface_hub not installed.")
758
+ path = hf_hub_download(repo_id=repo_id, filename=weights_file)
 
 
 
759
  return cls.load_local(
760
+ path,
761
  ddpm_device=ddpm_device,
762
  ann_backend=ann_backend,
763
  ann_params=ann_params,