jcandane commited on
Commit
92cc372
·
verified ·
1 Parent(s): 204f4a4

Update dima.py

Browse files
Files changed (1) hide show
  1. dima.py +904 -904
dima.py CHANGED
@@ -1,905 +1,905 @@
1
- # src/dima/dima.py
2
- from __future__ import annotations
3
-
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
-
11
- import jax
12
- import jax.numpy as jnp
13
- from jax import random
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.
39
- prefer: "auto" | "gpu" | "cpu"
40
- """
41
- prefer = (prefer or "auto").lower()
42
- devs = jax.devices()
43
- gpu = [d for d in devs if d.platform == "gpu"]
44
- cpu = [d for d in devs if d.platform == "cpu"]
45
-
46
- if prefer in ("auto", "gpu"):
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
-
53
- def _np_dtype_str(x) -> str:
54
- try:
55
- return str(np.dtype(x))
56
- except Exception:
57
- return "float32"
58
-
59
-
60
- # ----------------------------
61
- # Frozen inference-only models
62
- # ----------------------------
63
- class FrozenDMAP:
64
- """
65
- Inference-only Nyström DMAP embedder built from saved DMAP state.
66
- Uses kNN in ambient space against reference points.
67
- """
68
-
69
- def __init__(
70
- self,
71
- state: Dict[str, Any],
72
- *,
73
- ann_backend: ANNBackend = "auto",
74
- ann_params: Optional[Dict[str, Any]] = None,
75
- n_jobs: int = -1,
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)
94
-
95
- def __call__(self, R_aX: Union[np.ndarray, list], *, batch_size: Optional[int] = None) -> np.ndarray:
96
- R_aX = np.asarray(R_aX)
97
- single = (R_aX.ndim == 1)
98
- if single:
99
- R_aX = R_aX[None, :]
100
-
101
- R_aX = np.ascontiguousarray(R_aX.astype(self.dtype, copy=False))
102
-
103
- if batch_size is None:
104
- Z = self._embed(R_aX)
105
- else:
106
- out = []
107
- bs = int(batch_size)
108
- for s in range(0, R_aX.shape[0], bs):
109
- out.append(self._embed(R_aX[s : s + bs]))
110
- Z = np.vstack(out)
111
-
112
- return Z[0] if single else Z
113
-
114
- def _embed(self, R_aX: np.ndarray) -> np.ndarray:
115
- j_aK, D2_aK = self.ann.search(R_aX, self.k) # (a,k)
116
- K_ai = np.exp(-self.beta * (D2_aK.astype(np.float64) / self.eps)) # (a,k)
117
-
118
- q_a = np.maximum(K_ai.sum(axis=1), 1e-30)
119
- qalpha_a = np.maximum(np.power(q_a, self.alpha), 1e-30)
120
-
121
- qalpha_i = np.maximum(self.qalpha_i[j_aK], 1e-30)
122
- Kalpha_ai = K_ai / (qalpha_a[:, None] * qalpha_i)
123
-
124
- d_a = np.maximum(Kalpha_ai.sum(axis=1), 1e-30)
125
- P_ai = Kalpha_ai / d_a[:, None]
126
-
127
- R_over = self.R_over_lam_ix[j_aK, :] # (a,k,d)
128
- Z_ax = (P_ai[:, :, None] * R_over).sum(axis=1) # (a,d)
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
- # ----------------------------
170
- # Config
171
- # ----------------------------
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
- # ----------------------------
181
- # Main wrapper
182
- # ----------------------------
183
- class DIMA:
184
- """
185
- DIMA: DMAP encoder + (latent DDPM) + GPLM decoder.
186
- Public “user-facing” convention in this wrapper:
187
- - Raw DMAP coordinates are the *public latent* (np.ndarray): R_ax (a,d)
188
- - Normalized latents are the DDPM coordinates (jax/np): Z_ax (a,d)
189
- Minimal user API (what you asked for):
190
- dima = DIMA(R_iX, d=20, beta=2.0)
191
- R_ax = dima(R_aX) # encode ambient -> raw latents
192
- Q_aX = dima(R_ax) # decode raw latents -> ambient
193
- You can still pass full dict overrides for any submodule:
194
- dima = DIMA(..., dmap_kwargs={...}, gplm_kwargs={...}, ddpm_kwargs={...})
195
- and you can also tweak the “headline” DDPM knobs directly in __init__ (below).
196
- """
197
-
198
- def __init__(
199
- self,
200
- R_iX: np.ndarray,
201
- *,
202
- # main knobs
203
- d: int = 32,
204
- beta: float = 1.0,
205
- # allow per-module override (if None -> uses global beta)
206
- dmap_beta: Optional[float] = None,
207
- gplm_beta: Optional[float] = None,
208
- # DMAP headline knobs (everything else via dmap_kwargs)
209
- dmap_alpha: float = 0.0,
210
- dmap_t: float = 1.0,
211
- dmap_k: Optional[int] = None,
212
- # GPLM headline knobs (everything else via gplm_kwargs)
213
- gplm_m: int = 1024,
214
- gplm_pred_k: Optional[int] = None,
215
- # DDPM headline knobs (the ones worth surfacing)
216
- ddpm_T: int = 200,
217
- ddpm_hidden_dim: int = 128,
218
- ddpm_t_embed_dim: int = 64,
219
- ddpm_learning_rate: float = 3e-4,
220
- ddpm_n_iter: int = 200_000,
221
- ddpm_ema_decay: float = 0.999,
222
- ddpm_beta_max: float = 0.02,
223
- ddpm_batch_size: int = 256,
224
- ddpm_verbose_every: int = 0,
225
- ddpm_eps: float = 1e-5,
226
- # runtime
227
- ddpm_device: str = "auto",
228
- key: Optional[jax.Array] = None,
229
- # ann
230
- ann_backend: ANNBackend = "auto",
231
- ann_params: Optional[Dict[str, Any]] = None,
232
- n_jobs: int = -1,
233
- # “escape hatches”
234
- dmap_kwargs: Optional[Dict[str, Any]] = None,
235
- gplm_kwargs: Optional[Dict[str, Any]] = None,
236
- ddpm_kwargs: Optional[Dict[str, Any]] = None,
237
- # unicode aliases (β)
238
- **kwargs: Any,
239
- ):
240
- # ---- unicode aliases ----
241
- if "β" in kwargs:
242
- beta = float(kwargs.pop("β"))
243
- if "β_dmap" in kwargs:
244
- dmap_beta = float(kwargs.pop("β_dmap"))
245
- if "β_gplm" in kwargs:
246
- gplm_beta = float(kwargs.pop("β_gplm"))
247
- if kwargs:
248
- raise TypeError(f"Unexpected kwargs: {sorted(kwargs.keys())}")
249
-
250
- self.config = DIMAConfig(d=int(d), beta=float(beta), ddpm_device=str(ddpm_device))
251
-
252
- # devices + rng
253
- self.ddpm_device = _select_device(ddpm_device)
254
- self.cpu_device = _select_device("cpu")
255
- self.rng = random.PRNGKey(0) if key is None else key
256
-
257
- # training data
258
- self.R_iX = np.asarray(R_iX)
259
- if self.R_iX.ndim != 2:
260
- raise ValueError("R_iX must be 2D (N,D).")
261
- self.N, self.D = self.R_iX.shape
262
- self.d = int(d)
263
-
264
- # betas
265
- self.beta = float(beta)
266
- self.β = self.beta
267
-
268
- self.dmap_beta = float(self.beta if dmap_beta is None else dmap_beta)
269
- self.gplm_beta = float(self.beta if gplm_beta is None else gplm_beta)
270
-
271
- t0 = time.time()
272
-
273
- # -------------------------
274
- # 1) Train DMAP (CPU)
275
- # -------------------------
276
- dmap_init = dict(
277
- d=self.d,
278
- beta=self.dmap_beta,
279
- alpha=float(dmap_alpha),
280
- t=float(dmap_t),
281
- k=dmap_k,
282
- ann_backend=ann_backend,
283
- ann_params=ann_params,
284
- n_jobs=n_jobs,
285
- )
286
- if dmap_kwargs:
287
- dmap_init.update(dict(dmap_kwargs))
288
- # enforce headline knobs
289
- dmap_init["d"] = self.d
290
- dmap_init["beta"] = self.dmap_beta
291
- dmap_init["alpha"] = float(dmap_alpha)
292
- dmap_init["t"] = float(dmap_t)
293
- dmap_init["k"] = dmap_k
294
-
295
- self.enc = DMAP(self.R_iX, **dmap_init)
296
-
297
- # raw DMAP coordinates for *all* training points (Nyström OOS on training set)
298
- R_ix = np.asarray(self.enc(self.R_iX), dtype=np.float64) # (N,d)
299
-
300
- # -------------------------
301
- # 2) Latent normalization for DDPM
302
- # -------------------------
303
- self.lat_mean_np = R_ix.mean(axis=0)
304
- self.lat_std_np = np.maximum(R_ix.std(axis=0), 1e-12)
305
-
306
- self.lat_mean_j = jax.device_put(jnp.asarray(self.lat_mean_np, dtype=jnp.float32), self.ddpm_device)
307
- self.lat_std_j = jax.device_put(jnp.asarray(self.lat_std_np, dtype=jnp.float32), self.ddpm_device)
308
-
309
- Z_ix = (R_ix - self.lat_mean_np) / self.lat_std_np # (N,d)
310
-
311
- # -------------------------
312
- # 3) Train GPLM (CPU)
313
- # -------------------------
314
- gplm_init = dict(
315
- beta=self.gplm_beta,
316
- m=int(gplm_m),
317
- pred_k=gplm_pred_k,
318
- ann_backend=ann_backend,
319
- ann_params=ann_params,
320
- n_jobs=n_jobs,
321
- )
322
- if gplm_kwargs:
323
- gplm_init.update(dict(gplm_kwargs))
324
- # enforce headline knobs
325
- gplm_init["beta"] = self.gplm_beta
326
- gplm_init["m"] = int(gplm_m)
327
- gplm_init["pred_k"] = gplm_pred_k
328
-
329
- self.dec = GPLM(R_ix.astype(np.float32, copy=False), self.R_iX, **gplm_init)
330
-
331
- # -------------------------
332
- # 4) Train DDPM on normalized latents (DDPM device)
333
- # -------------------------
334
- Z_ix_j = jax.device_put(jnp.asarray(Z_ix, dtype=jnp.float32), self.ddpm_device)
335
-
336
- ddpm_init = dict(
337
- T=int(ddpm_T),
338
- hidden_dim=int(ddpm_hidden_dim),
339
- t_embed_dim=int(ddpm_t_embed_dim),
340
- learning_rate=float(ddpm_learning_rate),
341
- n_iter=int(ddpm_n_iter),
342
- ema_decay=float(ddpm_ema_decay),
343
- beta_max=float(ddpm_beta_max),
344
- batch_size=int(ddpm_batch_size),
345
- key=self.rng,
346
- verbose_every=int(ddpm_verbose_every),
347
- eps=float(ddpm_eps),
348
- )
349
- if ddpm_kwargs:
350
- ddpm_init.update(dict(ddpm_kwargs))
351
- # enforce headline knobs
352
- ddpm_init["T"] = int(ddpm_T)
353
- ddpm_init["hidden_dim"] = int(ddpm_hidden_dim)
354
- ddpm_init["t_embed_dim"] = int(ddpm_t_embed_dim)
355
- ddpm_init["learning_rate"] = float(ddpm_learning_rate)
356
- ddpm_init["n_iter"] = int(ddpm_n_iter)
357
- ddpm_init["ema_decay"] = float(ddpm_ema_decay)
358
- ddpm_init["beta_max"] = float(ddpm_beta_max)
359
- ddpm_init["batch_size"] = int(ddpm_batch_size)
360
- ddpm_init["verbose_every"] = int(ddpm_verbose_every)
361
- ddpm_init["eps"] = float(ddpm_eps)
362
-
363
- with jax.default_device(self.ddpm_device):
364
- self.dm = DDPM(Z_ix_j, **ddpm_init)
365
-
366
- self.training_time = time.time() - t0
367
-
368
- # -------------------------
369
- # Latent conversions
370
- # -------------------------
371
- def normalize(self, R_ax: Union[np.ndarray, jnp.ndarray]) -> jnp.ndarray:
372
- """raw latents (R) -> normalized latents (Z) on ddpm_device."""
373
- R = np.asarray(R_ax, dtype=np.float64)
374
- if R.ndim == 1:
375
- R = R[None, :]
376
- Z = (R - self.lat_mean_np) / self.lat_std_np
377
- Zj = jnp.asarray(Z, dtype=jnp.float32)
378
- return jax.device_put(Zj, self.ddpm_device)
379
-
380
- def unnormalize(self, Z_ax: Union[np.ndarray, jnp.ndarray]) -> np.ndarray:
381
- """normalized latents (Z) -> raw latents (R) on CPU (np)."""
382
- if isinstance(Z_ax, jax.Array):
383
- Z_np = np.asarray(jax.device_get(Z_ax))
384
- else:
385
- Z_np = np.asarray(Z_ax)
386
- if Z_np.ndim == 1:
387
- Z_np = Z_np[None, :]
388
- R = Z_np * self.lat_std_np + self.lat_mean_np
389
- return np.asarray(R)
390
-
391
- # -------------------------
392
- # Encode / Decode (public: raw latents)
393
- # -------------------------
394
- def encode(self, R_aX: Union[np.ndarray, jnp.ndarray], *, normalize: bool = False) -> Union[np.ndarray, jnp.ndarray]:
395
- """
396
- ambient -> raw DMAP latents (np) by default.
397
- If normalize=True, returns normalized latents (jnp) on ddpm_device.
398
- """
399
- X = np.asarray(R_aX)
400
- R_raw = np.asarray(self.enc(X)) # CPU, (a,d)
401
- if not normalize:
402
- return R_raw
403
- return self.normalize(R_raw)
404
-
405
- def decode(
406
- self,
407
- R_ax: Union[np.ndarray, jnp.ndarray],
408
- *,
409
- refine: bool = False,
410
- t_start: int = 10,
411
- add_noise: bool = True,
412
- key: Optional[jax.Array] = None,
413
- batch_size: Optional[int] = None,
414
- ) -> np.ndarray:
415
- """
416
- raw latent -> (optional DDPM refine in normalized coords) -> raw latent -> ambient.
417
- Returns ambient np.ndarray on CPU.
418
- """
419
- R_raw = np.asarray(R_ax, dtype=np.float64)
420
- single = (R_raw.ndim == 1)
421
- if single:
422
- R_raw = R_raw[None, :]
423
-
424
- if refine:
425
- Z = self.normalize(R_raw) # on device
426
- Z = self.dm.refine_latents(Z, t_start=int(t_start), key=key, add_noise=bool(add_noise))
427
- R_raw = self.unnormalize(Z) # back to CPU raw
428
-
429
- X_hat = self.dec(R_raw.astype(np.float32, copy=False), batch_size=batch_size)
430
- X_hat = np.asarray(X_hat)
431
- return X_hat[0] if single else X_hat
432
-
433
- def reconstruct(
434
- self,
435
- R_aX: Union[np.ndarray, jnp.ndarray],
436
- *,
437
- refine: bool = False,
438
- t_start: int = 10,
439
- add_noise: bool = True,
440
- key: Optional[jax.Array] = None,
441
- batch_size: Optional[int] = None,
442
- ) -> np.ndarray:
443
- """decode(encode(X))."""
444
- R_raw = self.encode(R_aX, normalize=False)
445
- return self.decode(R_raw, refine=refine, t_start=t_start, add_noise=add_noise, key=key, batch_size=batch_size)
446
-
447
- def sample(
448
- self,
449
- n: int,
450
- *,
451
- decode: bool = True,
452
- batch_size: Optional[int] = None,
453
- ) -> Union[np.ndarray, np.ndarray]:
454
- """
455
- Unconditional samples from latent DDPM.
456
- If decode=True: returns ambient samples (np) on CPU.
457
- If decode=False: returns raw latents (np) on CPU.
458
- """
459
- with jax.default_device(self.ddpm_device):
460
- Z = self.dm.sample(int(n))
461
- R = self.unnormalize(Z) # raw (np)
462
- if not decode:
463
- return R
464
- return self.dec(R.astype(np.float32, copy=False), batch_size=batch_size)
465
-
466
- # -------------------------
467
- # Geodesic-ish flow wrapper (delegates to GPLM.flow)
468
- # -------------------------
469
- def flow(
470
- self,
471
- R_ax: Union[np.ndarray, jnp.ndarray],
472
- v_ax: Union[np.ndarray, jnp.ndarray],
473
- *,
474
- dt: float = 0.05,
475
- reg: float = 1e-8,
476
- keep_speed: bool = True,
477
- # optional DDPM projection step (in normalized coords)
478
- refine: bool = False,
479
- t_start: int = 10,
480
- add_noise: bool = True,
481
- key: Optional[jax.Array] = None,
482
- # decode return
483
- decode: bool = False,
484
- batch_size: Optional[int] = None,
485
- ) -> Union[Tuple[np.ndarray, np.ndarray], Tuple[np.ndarray, np.ndarray, np.ndarray]]:
486
- """
487
- One step of latent flow in *raw* coordinates.
488
- Requires: your GPLM class implements:
489
- R_next, v_next = gplm.flow(R, v, dt=..., reg=..., keep_speed=...)
490
- If refine=True, we project the *position* through DDPM in normalized coords after the step.
491
- (Velocity after projection is left unchanged—projection isn’t a deterministic diffeo.)
492
- Returns:
493
- if decode=False:
494
- (R_next, v_next) both np arrays
495
- if decode=True:
496
- (X_next, R_next, v_next)
497
- """
498
- R = np.asarray(R_ax, dtype=np.float64)
499
- v = np.asarray(v_ax, dtype=np.float64)
500
- single = (R.ndim == 1)
501
- if single:
502
- R = R[None, :]
503
- v = v[None, :]
504
-
505
- if not hasattr(self.dec, "flow"):
506
- raise AttributeError(
507
- "Decoder does not have .flow(). Make sure you updated GPLM to include flow()."
508
- )
509
-
510
- Rn, vn = self.dec.flow(R, v, dt=float(dt), reg=float(reg), keep_speed=bool(keep_speed))
511
-
512
- if refine:
513
- Z = self.normalize(Rn) # device
514
- Z = self.dm.refine_latents(Z, t_start=int(t_start), key=key, add_noise=bool(add_noise))
515
- Rn = self.unnormalize(Z)
516
-
517
- if not decode:
518
- if single:
519
- return np.asarray(Rn[0]), np.asarray(vn[0])
520
- return np.asarray(Rn), np.asarray(vn)
521
-
522
- Xn = self.dec(Rn.astype(np.float32, copy=False), batch_size=batch_size)
523
- Xn = np.asarray(Xn)
524
- if single:
525
- return Xn[0], np.asarray(Rn[0]), np.asarray(vn[0])
526
- return Xn, np.asarray(Rn), np.asarray(vn)
527
-
528
- # -------------------------
529
- # Convenience __call__
530
- # -------------------------
531
- def __call__(
532
- self,
533
- A: Union[np.ndarray, jnp.ndarray],
534
- *,
535
- refine: bool = False,
536
- t_start: int = 10,
537
- add_noise: bool = True,
538
- key: Optional[jax.Array] = None,
539
- batch_size: Optional[int] = None,
540
- normalize_latent: bool = False,
541
- ) -> Union[np.ndarray, jnp.ndarray]:
542
- """
543
- Dispatch by last dimension:
544
- - if A is (a,D): encode -> raw latents (np) by default
545
- - if A is (a,d): decode -> ambient (np)
546
- Options:
547
- - normalize_latent=True only affects encoding (returns Z on device)
548
- - refine/t_start/add_noise/key only affect decoding
549
- """
550
- A_np = np.asarray(A)
551
- if A_np.ndim == 1:
552
- A_np = A_np[None, :]
553
-
554
- if A_np.shape[1] == self.D:
555
- return self.encode(A_np, normalize=bool(normalize_latent))
556
-
557
- if A_np.shape[1] == self.d:
558
- return self.decode(
559
- A_np,
560
- refine=bool(refine),
561
- t_start=int(t_start),
562
- add_noise=bool(add_noise),
563
- key=key,
564
- batch_size=batch_size,
565
- )
566
-
567
- raise ValueError(f"Input has last-dim {A_np.shape[1]}, expected D={self.D} or d={self.d}.")
568
-
569
- # -------------------------
570
- # Save / Load
571
- # -------------------------
572
- def _pack_encoder(self) -> Dict[str, Any]:
573
- enc = self.enc
574
-
575
- # qalpha_i (ascii/unicode)
576
- qalpha_i = np.asarray(getattr(enc, "qalpha_i", getattr(enc, "qα_i")))
577
-
578
- # 1) Try to read R_over_* from whichever name exists
579
- R_over = getattr(enc, "R_over_lam_ix", None)
580
- if R_over is None:
581
- R_over = getattr(enc, "R_over_λ_ix", None)
582
-
583
- # 2) If missing, compute it from R_ix and λ_x (most robust)
584
- if R_over is None:
585
- R_ix = getattr(enc, "R_ix", None)
586
-
587
- # λ_x is unicode in upstream; add fallbacks for safety
588
- lam = getattr(enc, "λ_x", None)
589
- if lam is None:
590
- lam = getattr(enc, "lam_x", None)
591
- if lam is None:
592
- lam = getattr(enc, "lambda_x", None)
593
-
594
- if R_ix is None or lam is None:
595
- raise AttributeError(
596
- "DMAP encoder is missing R_over_{λ,lam}_ix and also lacks (R_ix, λ_x) "
597
- "to reconstruct it. Please ensure your DMAP computes diffusion coords."
598
- )
599
-
600
- lam = np.asarray(lam, dtype=np.float64)
601
- lam = np.maximum(lam, 1e-30) # avoid division by 0
602
- R_ix = np.asarray(R_ix, dtype=np.float64)
603
-
604
- R_over = (R_ix / lam[None, :]).astype(np.float64, copy=False)
605
-
606
- return dict(
607
- R_iX=np.asarray(enc.R_iX),
608
- qalpha_i=qalpha_i,
609
- # Always store with ASCII key expected by FrozenDMAP loader
610
- R_over_lam_ix=np.asarray(R_over, dtype=np.float64),
611
- k=int(enc.k),
612
- beta=float(getattr(enc, "beta", getattr(enc, "β"))),
613
- alpha=float(getattr(enc, "alpha", getattr(enc, "α"))),
614
- eps=float(getattr(enc, "eps", getattr(enc, "ε"))),
615
- dtype=_np_dtype_str(getattr(enc, "dtype", np.float32)),
616
- )
617
-
618
-
619
- def _pack_decoder(self) -> Dict[str, Any]:
620
- return dict(
621
- Z_mx_w=np.asarray(getattr(self.dec, "Z_mx_w", None)),
622
- M_mX=np.asarray(self.dec.M_mX),
623
- mean_X=np.asarray(getattr(self.dec, "mean_X", np.zeros((self.D,), dtype=np.float64))),
624
- lat_mean_x=np.asarray(getattr(self.dec, "lat_mean_x", np.zeros((self.d,), dtype=np.float64))),
625
- lat_std_x=np.asarray(getattr(self.dec, "lat_std_x", np.ones((self.d,), dtype=np.float64))),
626
- beta=float(getattr(self.dec, "beta", getattr(self.dec, "β"))),
627
- eps=float(getattr(self.dec, "eps", getattr(self.dec, "ε"))),
628
- pred_k=getattr(self.dec, "pred_k", getattr(self.dec, "pred_κ", None)),
629
- dtype=_np_dtype_str(getattr(self.dec, "dtype", np.float32)),
630
- )
631
-
632
- def state_dict(self) -> Dict[str, Any]:
633
- dd = dict(
634
- T=int(self.dm.T),
635
- D=int(self.dm.D),
636
- hidden_dim=int(getattr(self.dm.model, "hidden", 128)),
637
- t_embed_dim=int(getattr(self.dm.model, "t_dim", 64)),
638
- ema_decay=float(getattr(self.dm, "ema_decay", 0.999)),
639
- beta_max=float(getattr(self.dm, "beta_max", 0.02)),
640
- eps=float(getattr(self.dm, "eps", 1e-5)),
641
- params=self.dm.state.params,
642
- ema_params=self.dm.state.ema_params,
643
- )
644
-
645
- state = dict(
646
- meta=dict(
647
- N=int(self.N),
648
- D=int(self.D),
649
- d=int(self.d),
650
- training_time=float(getattr(self, "training_time", 0.0)),
651
- ),
652
- config=asdict(self.config),
653
- latent_norm=dict(
654
- mean=np.asarray(self.lat_mean_np, dtype=np.float64),
655
- std=np.asarray(self.lat_std_np, dtype=np.float64),
656
- ),
657
- encoder=self._pack_encoder(),
658
- decoder=self._pack_decoder(),
659
- ddpm=dd,
660
- )
661
- return state
662
-
663
- def save_local(self, weights_file: str = "dima.msgpack", config_file: str = "config.json") -> None:
664
- """
665
- Save full DIMA state to a msgpack file + a readable JSON config.
666
-
667
- This version is robust to accidental tuples inside the state tree
668
- (msgpack cannot serialize tuples by default).
669
- """
670
- def _sanitize(x):
671
- # Convert tuples -> lists recursively (msgpack-safe)
672
- if isinstance(x, tuple):
673
- return [_sanitize(v) for v in x]
674
- if isinstance(x, list):
675
- return [_sanitize(v) for v in x]
676
- if isinstance(x, dict):
677
- return {k: _sanitize(v) for k, v in x.items()}
678
- return x
679
-
680
- state = _sanitize(self.state_dict())
681
- blob = flax_ser.msgpack_serialize(state)
682
-
683
- with open(weights_file, "wb") as f:
684
- f.write(blob)
685
-
686
- with open(config_file, "w") as f:
687
- json.dump(state["config"], f, indent=2)
688
-
689
- return None
690
-
691
-
692
- @classmethod
693
- def load_local(
694
- cls,
695
- weights_file: str = "dima.msgpack",
696
- *,
697
- ddpm_device: str = "auto",
698
- ann_backend: ANNBackend = "auto",
699
- ann_params: Optional[Dict[str, Any]] = None,
700
- n_jobs: int = -1,
701
- key: Optional[jax.Array] = None,
702
- ) -> "DIMA":
703
- with open(weights_file, "rb") as f:
704
- state = flax_ser.msgpack_restore(f.read())
705
-
706
- obj = cls.__new__(cls) # bypass __init__
707
-
708
- obj.config = DIMAConfig(**state["config"])
709
- obj.ddpm_device = _select_device(ddpm_device)
710
- obj.cpu_device = _select_device("cpu")
711
- obj.training_time = float(state["meta"].get("training_time", 0.0))
712
-
713
- obj.N = int(state["meta"]["N"])
714
- obj.D = int(state["meta"]["D"])
715
- obj.d = int(state["meta"]["d"])
716
-
717
- # RNG
718
- obj.rng = random.PRNGKey(0) if key is None else key
719
-
720
- # beta (for display / convenience)
721
- obj.beta = float(obj.config.beta)
722
- obj.β = obj.beta
723
-
724
- # latent norm
725
- obj.lat_mean_np = np.asarray(state["latent_norm"]["mean"], dtype=np.float64)
726
- obj.lat_std_np = np.asarray(state["latent_norm"]["std"], dtype=np.float64)
727
-
728
- obj.lat_mean_j = jax.device_put(jnp.asarray(obj.lat_mean_np, dtype=jnp.float32), obj.ddpm_device)
729
- obj.lat_std_j = jax.device_put(jnp.asarray(obj.lat_std_np, dtype=jnp.float32), obj.ddpm_device)
730
-
731
- # frozen encoder + rehydrated decoder-as-GPLM (so flow works if GPLM.flow exists)
732
- obj.enc = FrozenDMAP(state["encoder"], ann_backend=ann_backend, ann_params=ann_params, n_jobs=n_jobs)
733
- obj.dec = _restore_gplm_as_object(state["decoder"], ann_backend=ann_backend, ann_params=ann_params, n_jobs=n_jobs)
734
-
735
- # rebuild DDPM skeleton with dummy data, then load params
736
- dd = state["ddpm"]
737
- T = int(dd["T"])
738
- D = int(dd["D"])
739
- hidden_dim = int(dd["hidden_dim"])
740
- t_embed_dim = int(dd["t_embed_dim"])
741
- ema_decay = float(dd.get("ema_decay", 0.999))
742
- beta_max = float(dd.get("beta_max", 0.02))
743
- eps = float(dd.get("eps", 1e-5))
744
-
745
- dummy = jnp.zeros((1, D), dtype=jnp.float32)
746
- with jax.default_device(obj.ddpm_device):
747
- obj.dm = DDPM(
748
- dummy,
749
- T=T,
750
- hidden_dim=hidden_dim,
751
- t_embed_dim=t_embed_dim,
752
- learning_rate=1e-3,
753
- n_iter=0, # skip training on load
754
- ema_decay=ema_decay,
755
- beta_max=beta_max,
756
- batch_size=1,
757
- key=obj.rng,
758
- verbose_every=0,
759
- eps=eps,
760
- )
761
- obj.dm.state = obj.dm.state.replace(params=dd["params"], ema_params=dd["ema_params"])
762
-
763
- obj.R_iX = None # training data not stored by default
764
- return obj
765
-
766
- # -------------------------
767
- # (Optional) HF helpers
768
- # -------------------------
769
-
770
- def upload_to_huggingface(
771
- self,
772
- repo_id: str,
773
- *,
774
- weights_file: str = "dima.msgpack",
775
- config_file: str = "config.json",
776
- token: Optional[str] = None,
777
- repo_type: str = "model",
778
- revision: Optional[str] = None,
779
- ) -> None:
780
- """
781
- Serialize locally (weights + config) and upload them to Hugging Face Hub.
782
-
783
- Parameters
784
- ----------
785
- repo_id : str
786
- e.g. "username/my-dima-model"
787
- weights_file : str
788
- Filename used both locally and in the HF repo.
789
- config_file : str
790
- Human-readable JSON config filename (also uploaded).
791
- token : Optional[str]
792
- HF token. If None, uses HfFolder.get_token().
793
- repo_type : str
794
- Usually "model".
795
- revision : Optional[str]
796
- Optional target branch/revision (if your hub client supports it).
797
- """
798
- if not _HAS_HF:
799
- raise RuntimeError("huggingface_hub not installed. Install it (or `pip install dima[hf]`).")
800
-
801
- # 1) Save artifacts locally
802
- self.save_local(weights_file=weights_file, config_file=config_file)
803
-
804
- # 2) Resolve token
805
- if token is None:
806
- token = HfFolder.get_token()
807
- if token is None:
808
- raise RuntimeError("No HF token found. Provide `token=...` or run `huggingface-cli login`.")
809
-
810
- # 3) Create repo if needed
811
- api = HfApi()
812
- api.create_repo(repo_id=repo_id, repo_type=repo_type, exist_ok=True, token=token)
813
-
814
- # 4) Upload files
815
- common_kwargs = dict(repo_id=repo_id, repo_type=repo_type, token=token)
816
- if revision is not None:
817
- common_kwargs["revision"] = revision
818
-
819
- upload_file(
820
- path_or_fileobj=weights_file,
821
- path_in_repo=weights_file,
822
- **common_kwargs,
823
- )
824
- upload_file(
825
- path_or_fileobj=config_file,
826
- path_in_repo=config_file,
827
- **common_kwargs,
828
- )
829
- return None
830
-
831
-
832
- @classmethod
833
- def download_from_huggingface(
834
- cls,
835
- repo_id: str,
836
- *,
837
- weights_file: str = "dima.msgpack",
838
- ddpm_device: str = "auto",
839
- ann_backend: "ANNBackend" = "auto",
840
- ann_params: Optional[Dict[str, Any]] = None,
841
- n_jobs: int = -1,
842
- key: Optional["jax.Array"] = None,
843
- token: Optional[str] = None,
844
- repo_type: str = "model",
845
- revision: Optional[str] = None,
846
- ) -> "DIMA":
847
- """
848
- Download weights from HF Hub and rehydrate a DIMA object via load_local.
849
-
850
- Returns
851
- -------
852
- DIMA
853
- A ready-to-use (inference) DIMA instance.
854
- """
855
- if not _HAS_HF:
856
- raise RuntimeError("huggingface_hub not installed. Install it (or `pip install dima[hf]`).")
857
-
858
- if token is None:
859
- token = HfFolder.get_token()
860
-
861
- dl_kwargs = dict(repo_id=repo_id, filename=weights_file, repo_type=repo_type)
862
- if token is not None:
863
- dl_kwargs["token"] = token
864
- if revision is not None:
865
- dl_kwargs["revision"] = revision
866
-
867
- path = hf_hub_download(**dl_kwargs)
868
-
869
- return cls.load_local(
870
- path,
871
- ddpm_device=ddpm_device,
872
- ann_backend=ann_backend,
873
- ann_params=ann_params,
874
- n_jobs=n_jobs,
875
- key=key,
876
- )
877
-
878
-
879
- # Backward-compatible aliases (the upstream file uses save_hf/load_hf)
880
- def save_hf(self, repo_id: str, weights_file: str = "dima.msgpack", config_file: str = "config.json") -> None:
881
- return self.upload_to_huggingface(repo_id, weights_file=weights_file, config_file=config_file)
882
-
883
- @classmethod
884
- def load_hf(
885
- cls,
886
- repo_id: str,
887
- *,
888
- weights_file: str = "dima.msgpack",
889
- ddpm_device: str = "auto",
890
- ann_backend: "ANNBackend" = "auto",
891
- ann_params: Optional[Dict[str, Any]] = None,
892
- n_jobs: int = -1,
893
- key: Optional["jax.Array"] = None,
894
- ) -> "DIMA":
895
- return cls.download_from_huggingface(
896
- repo_id,
897
- weights_file=weights_file,
898
- ddpm_device=ddpm_device,
899
- ann_backend=ann_backend,
900
- ann_params=ann_params,
901
- n_jobs=n_jobs,
902
- key=key,
903
- )
904
-
905
  __all__ = ["DIMA", "DIMAConfig"]
 
1
+ # src/dima/dima.py
2
+ from __future__ import annotations
3
+
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
+
11
+ import jax
12
+ import jax.numpy as jnp
13
+ from jax import random
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.
39
+ prefer: "auto" | "gpu" | "cpu"
40
+ """
41
+ prefer = (prefer or "auto").lower()
42
+ devs = jax.devices()
43
+ gpu = [d for d in devs if d.platform == "gpu"]
44
+ cpu = [d for d in devs if d.platform == "cpu"]
45
+
46
+ if prefer in ("auto", "gpu"):
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
+
53
+ def _np_dtype_str(x) -> str:
54
+ try:
55
+ return str(np.dtype(x))
56
+ except Exception:
57
+ return "float32"
58
+
59
+
60
+ # ----------------------------
61
+ # Frozen inference-only models
62
+ # ----------------------------
63
+ class FrozenDMAP:
64
+ """
65
+ Inference-only Nyström DMAP embedder built from saved DMAP state.
66
+ Uses kNN in ambient space against reference points.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ state: Dict[str, Any],
72
+ *,
73
+ ann_backend: ANNBackend = "auto",
74
+ ann_params: Optional[Dict[str, Any]] = None,
75
+ n_jobs: int = -1,
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)
94
+
95
+ def __call__(self, R_aX: Union[np.ndarray, list], *, batch_size: Optional[int] = None) -> np.ndarray:
96
+ R_aX = np.asarray(R_aX)
97
+ single = (R_aX.ndim == 1)
98
+ if single:
99
+ R_aX = R_aX[None, :]
100
+
101
+ R_aX = np.ascontiguousarray(R_aX.astype(self.dtype, copy=False))
102
+
103
+ if batch_size is None:
104
+ Z = self._embed(R_aX)
105
+ else:
106
+ out = []
107
+ bs = int(batch_size)
108
+ for s in range(0, R_aX.shape[0], bs):
109
+ out.append(self._embed(R_aX[s : s + bs]))
110
+ Z = np.vstack(out)
111
+
112
+ return Z[0] if single else Z
113
+
114
+ def _embed(self, R_aX: np.ndarray) -> np.ndarray:
115
+ j_aK, D2_aK = self.ann.search(R_aX, self.k) # (a,k)
116
+ K_ai = np.exp(-self.beta * (D2_aK.astype(np.float64) / self.eps)) # (a,k)
117
+
118
+ q_a = np.maximum(K_ai.sum(axis=1), 1e-30)
119
+ qalpha_a = np.maximum(np.power(q_a, self.alpha), 1e-30)
120
+
121
+ qalpha_i = np.maximum(self.qalpha_i[j_aK], 1e-30)
122
+ Kalpha_ai = K_ai / (qalpha_a[:, None] * qalpha_i)
123
+
124
+ d_a = np.maximum(Kalpha_ai.sum(axis=1), 1e-30)
125
+ P_ai = Kalpha_ai / d_a[:, None]
126
+
127
+ R_over = self.R_over_lam_ix[j_aK, :] # (a,k,d)
128
+ Z_ax = (P_ai[:, :, None] * R_over).sum(axis=1) # (a,d)
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
+ # ----------------------------
170
+ # Config
171
+ # ----------------------------
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
+ # ----------------------------
181
+ # Main wrapper
182
+ # ----------------------------
183
+ class DIMA:
184
+ """
185
+ DIMA: DMAP encoder + (latent DDPM) + GPLM decoder.
186
+ Public “user-facing” convention in this wrapper:
187
+ - Raw DMAP coordinates are the *public latent* (np.ndarray): R_ax (a,d)
188
+ - Normalized latents are the DDPM coordinates (jax/np): Z_ax (a,d)
189
+ Minimal user API (what you asked for):
190
+ dima = DIMA(R_iX, d=20, beta=2.0)
191
+ R_ax = dima(R_aX) # encode ambient -> raw latents
192
+ Q_aX = dima(R_ax) # decode raw latents -> ambient
193
+ You can still pass full dict overrides for any submodule:
194
+ dima = DIMA(..., dmap_kwargs={...}, gplm_kwargs={...}, ddpm_kwargs={...})
195
+ and you can also tweak the “headline” DDPM knobs directly in __init__ (below).
196
+ """
197
+
198
+ def __init__(
199
+ self,
200
+ R_iX: np.ndarray,
201
+ *,
202
+ # main knobs
203
+ d: int = 32,
204
+ beta: float = 1.0,
205
+ # allow per-module override (if None -> uses global beta)
206
+ dmap_beta: Optional[float] = None,
207
+ gplm_beta: Optional[float] = None,
208
+ # DMAP headline knobs (everything else via dmap_kwargs)
209
+ dmap_alpha: float = 0.0,
210
+ dmap_t: float = 1.0,
211
+ dmap_k: Optional[int] = None,
212
+ # GPLM headline knobs (everything else via gplm_kwargs)
213
+ gplm_m: int = 1024,
214
+ gplm_pred_k: Optional[int] = None,
215
+ # DDPM headline knobs (the ones worth surfacing)
216
+ ddpm_T: int = 200,
217
+ ddpm_hidden_dim: int = 128,
218
+ ddpm_t_embed_dim: int = 64,
219
+ ddpm_learning_rate: float = 3e-4,
220
+ ddpm_n_iter: int = 200_000,
221
+ ddpm_ema_decay: float = 0.999,
222
+ ddpm_beta_max: float = 0.02,
223
+ ddpm_batch_size: int = 256,
224
+ ddpm_verbose_every: int = 0,
225
+ ddpm_eps: float = 1e-5,
226
+ # runtime
227
+ ddpm_device: str = "auto",
228
+ key: Optional[jax.Array] = None,
229
+ # ann
230
+ ann_backend: ANNBackend = "auto",
231
+ ann_params: Optional[Dict[str, Any]] = None,
232
+ n_jobs: int = -1,
233
+ # “escape hatches”
234
+ dmap_kwargs: Optional[Dict[str, Any]] = None,
235
+ gplm_kwargs: Optional[Dict[str, Any]] = None,
236
+ ddpm_kwargs: Optional[Dict[str, Any]] = None,
237
+ # unicode aliases (β)
238
+ **kwargs: Any,
239
+ ):
240
+ # ---- unicode aliases ----
241
+ if "β" in kwargs:
242
+ beta = float(kwargs.pop("β"))
243
+ if "β_dmap" in kwargs:
244
+ dmap_beta = float(kwargs.pop("β_dmap"))
245
+ if "β_gplm" in kwargs:
246
+ gplm_beta = float(kwargs.pop("β_gplm"))
247
+ if kwargs:
248
+ raise TypeError(f"Unexpected kwargs: {sorted(kwargs.keys())}")
249
+
250
+ self.config = DIMAConfig(d=int(d), beta=float(beta), ddpm_device=str(ddpm_device))
251
+
252
+ # devices + rng
253
+ self.ddpm_device = _select_device(ddpm_device)
254
+ self.cpu_device = _select_device("cpu")
255
+ self.rng = random.PRNGKey(0) if key is None else key
256
+
257
+ # training data
258
+ self.R_iX = np.asarray(R_iX)
259
+ if self.R_iX.ndim != 2:
260
+ raise ValueError("R_iX must be 2D (N,D).")
261
+ self.N, self.D = self.R_iX.shape
262
+ self.d = int(d)
263
+
264
+ # betas
265
+ self.beta = float(beta)
266
+ self.β = self.beta
267
+
268
+ self.dmap_beta = float(self.beta if dmap_beta is None else dmap_beta)
269
+ self.gplm_beta = float(self.beta if gplm_beta is None else gplm_beta)
270
+
271
+ t0 = time.time()
272
+
273
+ # -------------------------
274
+ # 1) Train DMAP (CPU)
275
+ # -------------------------
276
+ dmap_init = dict(
277
+ d=self.d,
278
+ beta=self.dmap_beta,
279
+ alpha=float(dmap_alpha),
280
+ t=float(dmap_t),
281
+ k=dmap_k,
282
+ ann_backend=ann_backend,
283
+ ann_params=ann_params,
284
+ n_jobs=n_jobs,
285
+ )
286
+ if dmap_kwargs:
287
+ dmap_init.update(dict(dmap_kwargs))
288
+ # enforce headline knobs
289
+ dmap_init["d"] = self.d
290
+ dmap_init["beta"] = self.dmap_beta
291
+ dmap_init["alpha"] = float(dmap_alpha)
292
+ dmap_init["t"] = float(dmap_t)
293
+ dmap_init["k"] = dmap_k
294
+
295
+ self.enc = DMAP(self.R_iX, **dmap_init)
296
+
297
+ # raw DMAP coordinates for *all* training points (Nyström OOS on training set)
298
+ R_ix = np.asarray(self.enc(self.R_iX), dtype=np.float64) # (N,d)
299
+
300
+ # -------------------------
301
+ # 2) Latent normalization for DDPM
302
+ # -------------------------
303
+ self.lat_mean_np = R_ix.mean(axis=0)
304
+ self.lat_std_np = np.maximum(R_ix.std(axis=0), 1e-12)
305
+
306
+ self.lat_mean_j = jax.device_put(jnp.asarray(self.lat_mean_np, dtype=jnp.float32), self.ddpm_device)
307
+ self.lat_std_j = jax.device_put(jnp.asarray(self.lat_std_np, dtype=jnp.float32), self.ddpm_device)
308
+
309
+ Z_ix = (R_ix - self.lat_mean_np) / self.lat_std_np # (N,d)
310
+
311
+ # -------------------------
312
+ # 3) Train GPLM (CPU)
313
+ # -------------------------
314
+ gplm_init = dict(
315
+ beta=self.gplm_beta,
316
+ m=int(gplm_m),
317
+ pred_k=gplm_pred_k,
318
+ ann_backend=ann_backend,
319
+ ann_params=ann_params,
320
+ n_jobs=n_jobs,
321
+ )
322
+ if gplm_kwargs:
323
+ gplm_init.update(dict(gplm_kwargs))
324
+ # enforce headline knobs
325
+ gplm_init["beta"] = self.gplm_beta
326
+ gplm_init["m"] = int(gplm_m)
327
+ gplm_init["pred_k"] = gplm_pred_k
328
+
329
+ self.dec = GPLM(R_ix.astype(np.float32, copy=False), self.R_iX, **gplm_init)
330
+
331
+ # -------------------------
332
+ # 4) Train DDPM on normalized latents (DDPM device)
333
+ # -------------------------
334
+ Z_ix_j = jax.device_put(jnp.asarray(Z_ix, dtype=jnp.float32), self.ddpm_device)
335
+
336
+ ddpm_init = dict(
337
+ T=int(ddpm_T),
338
+ hidden_dim=int(ddpm_hidden_dim),
339
+ t_embed_dim=int(ddpm_t_embed_dim),
340
+ learning_rate=float(ddpm_learning_rate),
341
+ n_iter=int(ddpm_n_iter),
342
+ ema_decay=float(ddpm_ema_decay),
343
+ beta_max=float(ddpm_beta_max),
344
+ batch_size=int(ddpm_batch_size),
345
+ key=self.rng,
346
+ verbose_every=int(ddpm_verbose_every),
347
+ eps=float(ddpm_eps),
348
+ )
349
+ if ddpm_kwargs:
350
+ ddpm_init.update(dict(ddpm_kwargs))
351
+ # enforce headline knobs
352
+ ddpm_init["T"] = int(ddpm_T)
353
+ ddpm_init["hidden_dim"] = int(ddpm_hidden_dim)
354
+ ddpm_init["t_embed_dim"] = int(ddpm_t_embed_dim)
355
+ ddpm_init["learning_rate"] = float(ddpm_learning_rate)
356
+ ddpm_init["n_iter"] = int(ddpm_n_iter)
357
+ ddpm_init["ema_decay"] = float(ddpm_ema_decay)
358
+ ddpm_init["beta_max"] = float(ddpm_beta_max)
359
+ ddpm_init["batch_size"] = int(ddpm_batch_size)
360
+ ddpm_init["verbose_every"] = int(ddpm_verbose_every)
361
+ ddpm_init["eps"] = float(ddpm_eps)
362
+
363
+ with jax.default_device(self.ddpm_device):
364
+ self.dm = DDPM(Z_ix_j, **ddpm_init)
365
+
366
+ self.training_time = time.time() - t0
367
+
368
+ # -------------------------
369
+ # Latent conversions
370
+ # -------------------------
371
+ def normalize(self, R_ax: Union[np.ndarray, jnp.ndarray]) -> jnp.ndarray:
372
+ """raw latents (R) -> normalized latents (Z) on ddpm_device."""
373
+ R = np.asarray(R_ax, dtype=np.float64)
374
+ if R.ndim == 1:
375
+ R = R[None, :]
376
+ Z = (R - self.lat_mean_np) / self.lat_std_np
377
+ Zj = jnp.asarray(Z, dtype=jnp.float32)
378
+ return jax.device_put(Zj, self.ddpm_device)
379
+
380
+ def unnormalize(self, Z_ax: Union[np.ndarray, jnp.ndarray]) -> np.ndarray:
381
+ """normalized latents (Z) -> raw latents (R) on CPU (np)."""
382
+ if isinstance(Z_ax, jax.Array):
383
+ Z_np = np.asarray(jax.device_get(Z_ax))
384
+ else:
385
+ Z_np = np.asarray(Z_ax)
386
+ if Z_np.ndim == 1:
387
+ Z_np = Z_np[None, :]
388
+ R = Z_np * self.lat_std_np + self.lat_mean_np
389
+ return np.asarray(R)
390
+
391
+ # -------------------------
392
+ # Encode / Decode (public: raw latents)
393
+ # -------------------------
394
+ def encode(self, R_aX: Union[np.ndarray, jnp.ndarray], *, normalize: bool = False) -> Union[np.ndarray, jnp.ndarray]:
395
+ """
396
+ ambient -> raw DMAP latents (np) by default.
397
+ If normalize=True, returns normalized latents (jnp) on ddpm_device.
398
+ """
399
+ X = np.asarray(R_aX)
400
+ R_raw = np.asarray(self.enc(X)) # CPU, (a,d)
401
+ if not normalize:
402
+ return R_raw
403
+ return self.normalize(R_raw)
404
+
405
+ def decode(
406
+ self,
407
+ R_ax: Union[np.ndarray, jnp.ndarray],
408
+ *,
409
+ refine: bool = False,
410
+ t_start: int = 10,
411
+ add_noise: bool = True,
412
+ key: Optional[jax.Array] = None,
413
+ batch_size: Optional[int] = None,
414
+ ) -> np.ndarray:
415
+ """
416
+ raw latent -> (optional DDPM refine in normalized coords) -> raw latent -> ambient.
417
+ Returns ambient np.ndarray on CPU.
418
+ """
419
+ R_raw = np.asarray(R_ax, dtype=np.float64)
420
+ single = (R_raw.ndim == 1)
421
+ if single:
422
+ R_raw = R_raw[None, :]
423
+
424
+ if refine:
425
+ Z = self.normalize(R_raw) # on device
426
+ Z = self.dm.refine_latents(Z, t_start=int(t_start), key=key, add_noise=bool(add_noise))
427
+ R_raw = self.unnormalize(Z) # back to CPU raw
428
+
429
+ X_hat = self.dec(R_raw.astype(np.float32, copy=False), batch_size=batch_size)
430
+ X_hat = np.asarray(X_hat)
431
+ return X_hat[0] if single else X_hat
432
+
433
+ def reconstruct(
434
+ self,
435
+ R_aX: Union[np.ndarray, jnp.ndarray],
436
+ *,
437
+ refine: bool = False,
438
+ t_start: int = 10,
439
+ add_noise: bool = True,
440
+ key: Optional[jax.Array] = None,
441
+ batch_size: Optional[int] = None,
442
+ ) -> np.ndarray:
443
+ """decode(encode(X))."""
444
+ R_raw = self.encode(R_aX, normalize=False)
445
+ return self.decode(R_raw, refine=refine, t_start=t_start, add_noise=add_noise, key=key, batch_size=batch_size)
446
+
447
+ def sample(
448
+ self,
449
+ n: int,
450
+ *,
451
+ decode: bool = True,
452
+ batch_size: Optional[int] = None,
453
+ ) -> Union[np.ndarray, np.ndarray]:
454
+ """
455
+ Unconditional samples from latent DDPM.
456
+ If decode=True: returns ambient samples (np) on CPU.
457
+ If decode=False: returns raw latents (np) on CPU.
458
+ """
459
+ with jax.default_device(self.ddpm_device):
460
+ Z = self.dm.sample(int(n))
461
+ R = self.unnormalize(Z) # raw (np)
462
+ if not decode:
463
+ return R
464
+ return self.dec(R.astype(np.float32, copy=False), batch_size=batch_size)
465
+
466
+ # -------------------------
467
+ # Geodesic-ish flow wrapper (delegates to GPLM.flow)
468
+ # -------------------------
469
+ def flow(
470
+ self,
471
+ R_ax: Union[np.ndarray, jnp.ndarray],
472
+ v_ax: Union[np.ndarray, jnp.ndarray],
473
+ *,
474
+ dt: float = 0.05,
475
+ reg: float = 1e-8,
476
+ keep_speed: bool = True,
477
+ # optional DDPM projection step (in normalized coords)
478
+ refine: bool = False,
479
+ t_start: int = 10,
480
+ add_noise: bool = True,
481
+ key: Optional[jax.Array] = None,
482
+ # decode return
483
+ decode: bool = False,
484
+ batch_size: Optional[int] = None,
485
+ ) -> Union[Tuple[np.ndarray, np.ndarray], Tuple[np.ndarray, np.ndarray, np.ndarray]]:
486
+ """
487
+ One step of latent flow in *raw* coordinates.
488
+ Requires: your GPLM class implements:
489
+ R_next, v_next = gplm.flow(R, v, dt=..., reg=..., keep_speed=...)
490
+ If refine=True, we project the *position* through DDPM in normalized coords after the step.
491
+ (Velocity after projection is left unchanged—projection isn’t a deterministic diffeo.)
492
+ Returns:
493
+ if decode=False:
494
+ (R_next, v_next) both np arrays
495
+ if decode=True:
496
+ (X_next, R_next, v_next)
497
+ """
498
+ R = np.asarray(R_ax, dtype=np.float64)
499
+ v = np.asarray(v_ax, dtype=np.float64)
500
+ single = (R.ndim == 1)
501
+ if single:
502
+ R = R[None, :]
503
+ v = v[None, :]
504
+
505
+ if not hasattr(self.dec, "flow"):
506
+ raise AttributeError(
507
+ "Decoder does not have .flow(). Make sure you updated GPLM to include flow()."
508
+ )
509
+
510
+ Rn, vn = self.dec.flow(R, v, dt=float(dt), reg=float(reg), keep_speed=bool(keep_speed))
511
+
512
+ if refine:
513
+ Z = self.normalize(Rn) # device
514
+ Z = self.dm.refine_latents(Z, t_start=int(t_start), key=key, add_noise=bool(add_noise))
515
+ Rn = self.unnormalize(Z)
516
+
517
+ if not decode:
518
+ if single:
519
+ return np.asarray(Rn[0]), np.asarray(vn[0])
520
+ return np.asarray(Rn), np.asarray(vn)
521
+
522
+ Xn = self.dec(Rn.astype(np.float32, copy=False), batch_size=batch_size)
523
+ Xn = np.asarray(Xn)
524
+ if single:
525
+ return Xn[0], np.asarray(Rn[0]), np.asarray(vn[0])
526
+ return Xn, np.asarray(Rn), np.asarray(vn)
527
+
528
+ # -------------------------
529
+ # Convenience __call__
530
+ # -------------------------
531
+ def __call__(
532
+ self,
533
+ A: Union[np.ndarray, jnp.ndarray],
534
+ *,
535
+ refine: bool = False,
536
+ t_start: int = 10,
537
+ add_noise: bool = True,
538
+ key: Optional[jax.Array] = None,
539
+ batch_size: Optional[int] = None,
540
+ normalize_latent: bool = False,
541
+ ) -> Union[np.ndarray, jnp.ndarray]:
542
+ """
543
+ Dispatch by last dimension:
544
+ - if A is (a,D): encode -> raw latents (np) by default
545
+ - if A is (a,d): decode -> ambient (np)
546
+ Options:
547
+ - normalize_latent=True only affects encoding (returns Z on device)
548
+ - refine/t_start/add_noise/key only affect decoding
549
+ """
550
+ A_np = np.asarray(A)
551
+ if A_np.ndim == 1:
552
+ A_np = A_np[None, :]
553
+
554
+ if A_np.shape[1] == self.D:
555
+ return self.encode(A_np, normalize=bool(normalize_latent))
556
+
557
+ if A_np.shape[1] == self.d:
558
+ return self.decode(
559
+ A_np,
560
+ refine=bool(refine),
561
+ t_start=int(t_start),
562
+ add_noise=bool(add_noise),
563
+ key=key,
564
+ batch_size=batch_size,
565
+ )
566
+
567
+ raise ValueError(f"Input has last-dim {A_np.shape[1]}, expected D={self.D} or d={self.d}.")
568
+
569
+ # -------------------------
570
+ # Save / Load
571
+ # -------------------------
572
+ def _pack_encoder(self) -> Dict[str, Any]:
573
+ enc = self.enc
574
+
575
+ # qalpha_i (ascii/unicode)
576
+ qalpha_i = np.asarray(getattr(enc, "qalpha_i", getattr(enc, "qα_i")))
577
+
578
+ # 1) Try to read R_over_* from whichever name exists
579
+ R_over = getattr(enc, "R_over_lam_ix", None)
580
+ if R_over is None:
581
+ R_over = getattr(enc, "R_over_λ_ix", None)
582
+
583
+ # 2) If missing, compute it from R_ix and λ_x (most robust)
584
+ if R_over is None:
585
+ R_ix = getattr(enc, "R_ix", None)
586
+
587
+ # λ_x is unicode in upstream; add fallbacks for safety
588
+ lam = getattr(enc, "λ_x", None)
589
+ if lam is None:
590
+ lam = getattr(enc, "lam_x", None)
591
+ if lam is None:
592
+ lam = getattr(enc, "lambda_x", None)
593
+
594
+ if R_ix is None or lam is None:
595
+ raise AttributeError(
596
+ "DMAP encoder is missing R_over_{λ,lam}_ix and also lacks (R_ix, λ_x) "
597
+ "to reconstruct it. Please ensure your DMAP computes diffusion coords."
598
+ )
599
+
600
+ lam = np.asarray(lam, dtype=np.float64)
601
+ lam = np.maximum(lam, 1e-30) # avoid division by 0
602
+ R_ix = np.asarray(R_ix, dtype=np.float64)
603
+
604
+ R_over = (R_ix / lam[None, :]).astype(np.float64, copy=False)
605
+
606
+ return dict(
607
+ R_iX=np.asarray(enc.R_iX),
608
+ qalpha_i=qalpha_i,
609
+ # Always store with ASCII key expected by FrozenDMAP loader
610
+ R_over_lam_ix=np.asarray(R_over, dtype=np.float64),
611
+ k=int(enc.k),
612
+ beta=float(getattr(enc, "beta", getattr(enc, "β"))),
613
+ alpha=float(getattr(enc, "alpha", getattr(enc, "α"))),
614
+ eps=float(getattr(enc, "eps", getattr(enc, "ε"))),
615
+ dtype=_np_dtype_str(getattr(enc, "dtype", np.float32)),
616
+ )
617
+
618
+
619
+ def _pack_decoder(self) -> Dict[str, Any]:
620
+ return dict(
621
+ Z_mx_w=np.asarray(getattr(self.dec, "Z_mx_w", None)),
622
+ M_mX=np.asarray(self.dec.M_mX),
623
+ mean_X=np.asarray(getattr(self.dec, "mean_X", np.zeros((self.D,), dtype=np.float64))),
624
+ lat_mean_x=np.asarray(getattr(self.dec, "lat_mean_x", np.zeros((self.d,), dtype=np.float64))),
625
+ lat_std_x=np.asarray(getattr(self.dec, "lat_std_x", np.ones((self.d,), dtype=np.float64))),
626
+ beta=float(getattr(self.dec, "beta", getattr(self.dec, "β"))),
627
+ eps=float(getattr(self.dec, "eps", getattr(self.dec, "ε"))),
628
+ pred_k=getattr(self.dec, "pred_k", getattr(self.dec, "pred_κ", None)),
629
+ dtype=_np_dtype_str(getattr(self.dec, "dtype", np.float32)),
630
+ )
631
+
632
+ def state_dict(self) -> Dict[str, Any]:
633
+ dd = dict(
634
+ T=int(self.dm.T),
635
+ D=int(self.dm.D),
636
+ hidden_dim=int(getattr(self.dm.model, "hidden", 128)),
637
+ t_embed_dim=int(getattr(self.dm.model, "t_dim", 64)),
638
+ ema_decay=float(getattr(self.dm, "ema_decay", 0.999)),
639
+ beta_max=float(getattr(self.dm, "beta_max", 0.02)),
640
+ eps=float(getattr(self.dm, "eps", 1e-5)),
641
+ params=self.dm.state.params,
642
+ ema_params=self.dm.state.ema_params,
643
+ )
644
+
645
+ state = dict(
646
+ meta=dict(
647
+ N=int(self.N),
648
+ D=int(self.D),
649
+ d=int(self.d),
650
+ training_time=float(getattr(self, "training_time", 0.0)),
651
+ ),
652
+ config=asdict(self.config),
653
+ latent_norm=dict(
654
+ mean=np.asarray(self.lat_mean_np, dtype=np.float64),
655
+ std=np.asarray(self.lat_std_np, dtype=np.float64),
656
+ ),
657
+ encoder=self._pack_encoder(),
658
+ decoder=self._pack_decoder(),
659
+ ddpm=dd,
660
+ )
661
+ return state
662
+
663
+ def save_local(self, weights_file: str = "dima.msgpack", config_file: str = "config.json") -> None:
664
+ """
665
+ Save full DIMA state to a msgpack file + a readable JSON config.
666
+
667
+ This version is robust to accidental tuples inside the state tree
668
+ (msgpack cannot serialize tuples by default).
669
+ """
670
+ def _sanitize(x):
671
+ # Convert tuples -> lists recursively (msgpack-safe)
672
+ if isinstance(x, tuple):
673
+ return [_sanitize(v) for v in x]
674
+ if isinstance(x, list):
675
+ return [_sanitize(v) for v in x]
676
+ if isinstance(x, dict):
677
+ return {k: _sanitize(v) for k, v in x.items()}
678
+ return x
679
+
680
+ state = _sanitize(self.state_dict())
681
+ blob = flax_ser.msgpack_serialize(state)
682
+
683
+ with open(weights_file, "wb") as f:
684
+ f.write(blob)
685
+
686
+ with open(config_file, "w") as f:
687
+ json.dump(state["config"], f, indent=2)
688
+
689
+ return None
690
+
691
+
692
+ @classmethod
693
+ def load_local(
694
+ cls,
695
+ weights_file: str = "dima.msgpack",
696
+ *,
697
+ ddpm_device: str = "auto",
698
+ ann_backend: ANNBackend = "auto",
699
+ ann_params: Optional[Dict[str, Any]] = None,
700
+ n_jobs: int = -1,
701
+ key: Optional[jax.Array] = None,
702
+ ) -> "DIMA":
703
+ with open(weights_file, "rb") as f:
704
+ state = flax_ser.msgpack_restore(f.read())
705
+
706
+ obj = cls.__new__(cls) # bypass __init__
707
+
708
+ obj.config = DIMAConfig(**state["config"])
709
+ obj.ddpm_device = _select_device(ddpm_device)
710
+ obj.cpu_device = _select_device("cpu")
711
+ obj.training_time = float(state["meta"].get("training_time", 0.0))
712
+
713
+ obj.N = int(state["meta"]["N"])
714
+ obj.D = int(state["meta"]["D"])
715
+ obj.d = int(state["meta"]["d"])
716
+
717
+ # RNG
718
+ obj.rng = random.PRNGKey(0) if key is None else key
719
+
720
+ # beta (for display / convenience)
721
+ obj.beta = float(obj.config.beta)
722
+ obj.β = obj.beta
723
+
724
+ # latent norm
725
+ obj.lat_mean_np = np.asarray(state["latent_norm"]["mean"], dtype=np.float64)
726
+ obj.lat_std_np = np.asarray(state["latent_norm"]["std"], dtype=np.float64)
727
+
728
+ obj.lat_mean_j = jax.device_put(jnp.asarray(obj.lat_mean_np, dtype=jnp.float32), obj.ddpm_device)
729
+ obj.lat_std_j = jax.device_put(jnp.asarray(obj.lat_std_np, dtype=jnp.float32), obj.ddpm_device)
730
+
731
+ # frozen encoder + rehydrated decoder-as-GPLM (so flow works if GPLM.flow exists)
732
+ obj.enc = FrozenDMAP(state["encoder"], ann_backend=ann_backend, ann_params=ann_params, n_jobs=n_jobs)
733
+ obj.dec = _restore_gplm_as_object(state["decoder"], ann_backend=ann_backend, ann_params=ann_params, n_jobs=n_jobs)
734
+
735
+ # rebuild DDPM skeleton with dummy data, then load params
736
+ dd = state["ddpm"]
737
+ T = int(dd["T"])
738
+ D = int(dd["D"])
739
+ hidden_dim = int(dd["hidden_dim"])
740
+ t_embed_dim = int(dd["t_embed_dim"])
741
+ ema_decay = float(dd.get("ema_decay", 0.999))
742
+ beta_max = float(dd.get("beta_max", 0.02))
743
+ eps = float(dd.get("eps", 1e-5))
744
+
745
+ dummy = jnp.zeros((1, D), dtype=jnp.float32)
746
+ with jax.default_device(obj.ddpm_device):
747
+ obj.dm = DDPM(
748
+ dummy,
749
+ T=T,
750
+ hidden_dim=hidden_dim,
751
+ t_embed_dim=t_embed_dim,
752
+ learning_rate=1e-3,
753
+ n_iter=0, # skip training on load
754
+ ema_decay=ema_decay,
755
+ beta_max=beta_max,
756
+ batch_size=1,
757
+ key=obj.rng,
758
+ verbose_every=0,
759
+ eps=eps,
760
+ )
761
+ obj.dm.state = obj.dm.state.replace(params=dd["params"], ema_params=dd["ema_params"])
762
+
763
+ obj.R_iX = None # training data not stored by default
764
+ return obj
765
+
766
+ # -------------------------
767
+ # (Optional) HF helpers
768
+ # -------------------------
769
+
770
+ def upload_to_huggingface(
771
+ self,
772
+ repo_id: str,
773
+ *,
774
+ weights_file: str = "dima.msgpack",
775
+ config_file: str = "config.json",
776
+ token: Optional[str] = None,
777
+ repo_type: str = "model",
778
+ revision: Optional[str] = None,
779
+ ) -> None:
780
+ """
781
+ Serialize locally (weights + config) and upload them to Hugging Face Hub.
782
+
783
+ Parameters
784
+ ----------
785
+ repo_id : str
786
+ e.g. "username/my-dima-model"
787
+ weights_file : str
788
+ Filename used both locally and in the HF repo.
789
+ config_file : str
790
+ Human-readable JSON config filename (also uploaded).
791
+ token : Optional[str]
792
+ HF token. If None, uses HfFolder.get_token().
793
+ repo_type : str
794
+ Usually "model".
795
+ revision : Optional[str]
796
+ Optional target branch/revision (if your hub client supports it).
797
+ """
798
+ if not _HAS_HF:
799
+ raise RuntimeError("huggingface_hub not installed. Install it (or `pip install dima[hf]`).")
800
+
801
+ # 1) Save artifacts locally
802
+ self.save_local(weights_file=weights_file, config_file=config_file)
803
+
804
+ # 2) Resolve token
805
+ if token is None:
806
+ token = HfFolder.get_token()
807
+ if token is None:
808
+ raise RuntimeError("No HF token found. Provide `token=...` or run `huggingface-cli login`.")
809
+
810
+ # 3) Create repo if needed
811
+ api = HfApi()
812
+ api.create_repo(repo_id=repo_id, repo_type=repo_type, exist_ok=True, token=token)
813
+
814
+ # 4) Upload files
815
+ common_kwargs = dict(repo_id=repo_id, repo_type=repo_type, token=token)
816
+ if revision is not None:
817
+ common_kwargs["revision"] = revision
818
+
819
+ upload_file(
820
+ path_or_fileobj=weights_file,
821
+ path_in_repo=weights_file,
822
+ **common_kwargs,
823
+ )
824
+ upload_file(
825
+ path_or_fileobj=config_file,
826
+ path_in_repo=config_file,
827
+ **common_kwargs,
828
+ )
829
+ return None
830
+
831
+
832
+ @classmethod
833
+ def download_from_huggingface(
834
+ cls,
835
+ repo_id: str,
836
+ *,
837
+ weights_file: str = "dima.msgpack",
838
+ ddpm_device: str = "auto",
839
+ ann_backend: "ANNBackend" = "auto",
840
+ ann_params: Optional[Dict[str, Any]] = None,
841
+ n_jobs: int = -1,
842
+ key: Optional["jax.Array"] = None,
843
+ token: Optional[str] = None,
844
+ repo_type: str = "model",
845
+ revision: Optional[str] = None,
846
+ ) -> "DIMA":
847
+ """
848
+ Download weights from HF Hub and rehydrate a DIMA object via load_local.
849
+
850
+ Returns
851
+ -------
852
+ DIMA
853
+ A ready-to-use (inference) DIMA instance.
854
+ """
855
+ if not _HAS_HF:
856
+ raise RuntimeError("huggingface_hub not installed. Install it (or `pip install dima[hf]`).")
857
+
858
+ if token is None:
859
+ token = HfFolder.get_token()
860
+
861
+ dl_kwargs = dict(repo_id=repo_id, filename=weights_file, repo_type=repo_type)
862
+ if token is not None:
863
+ dl_kwargs["token"] = token
864
+ if revision is not None:
865
+ dl_kwargs["revision"] = revision
866
+
867
+ path = hf_hub_download(**dl_kwargs)
868
+
869
+ return cls.load_local(
870
+ path,
871
+ ddpm_device=ddpm_device,
872
+ ann_backend=ann_backend,
873
+ ann_params=ann_params,
874
+ n_jobs=n_jobs,
875
+ key=key,
876
+ )
877
+
878
+
879
+ # Backward-compatible aliases (the upstream file uses save_hf/load_hf)
880
+ def save_hf(self, repo_id: str, weights_file: str = "dima.msgpack", config_file: str = "config.json") -> None:
881
+ return self.upload_to_huggingface(repo_id, weights_file=weights_file, config_file=config_file)
882
+
883
+ @classmethod
884
+ def load_hf(
885
+ cls,
886
+ repo_id: str,
887
+ *,
888
+ weights_file: str = "dima.msgpack",
889
+ ddpm_device: str = "auto",
890
+ ann_backend: "ANNBackend" = "auto",
891
+ ann_params: Optional[Dict[str, Any]] = None,
892
+ n_jobs: int = -1,
893
+ key: Optional["jax.Array"] = None,
894
+ ) -> "DIMA":
895
+ return cls.download_from_huggingface(
896
+ repo_id,
897
+ weights_file=weights_file,
898
+ ddpm_device=ddpm_device,
899
+ ann_backend=ann_backend,
900
+ ann_params=ann_params,
901
+ n_jobs=n_jobs,
902
+ key=key,
903
+ )
904
+
905
  __all__ = ["DIMA", "DIMAConfig"]