jcandane commited on
Commit
204f4a4
·
verified ·
1 Parent(s): 09b5d98

Upload 9 files

Browse files
Files changed (9) hide show
  1. __init__.py +6 -0
  2. ann.py +300 -0
  3. ddim.py +463 -0
  4. ddpm.py +591 -0
  5. ddpmx.py +745 -0
  6. dima.py +905 -0
  7. dmap.py +645 -0
  8. gplm.py +712 -0
  9. utils.py +202 -0
__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from .dima import DIMA
2
+ from .dmap import DMAP
3
+ from .gplm import GPLM
4
+ from .ddpm import DDPM
5
+
6
+ __all__ = ["DIMA", "DMAP", "GPLM", "DDPM"]
ann.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/dima/ann.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict, Optional, Tuple
6
+
7
+ import numpy as np
8
+
9
+ from .utils import as_contig_f32, sqdist_ab
10
+
11
+
12
+ # -------------------------
13
+ # Public API
14
+ # -------------------------
15
+ ANNBackend = str # "auto" | "faiss" | "pynndescent" | "sklearn" | "brute"
16
+
17
+
18
+ class ANNBase:
19
+ """Minimal ANN interface used by DMAP/GPLM."""
20
+ def build(self, X: np.ndarray) -> "ANNBase":
21
+ raise NotImplementedError
22
+
23
+ def search(self, Q: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
24
+ """
25
+ Returns:
26
+ idx: (B,k) int64
27
+ D2 : (B,k) float32 (squared Euclidean distances)
28
+ """
29
+ raise NotImplementedError
30
+
31
+
32
+ def make_ann(
33
+ backend: ANNBackend = "auto",
34
+ ann_params: Optional[Dict[str, Any]] = None,
35
+ n_jobs: int = -1,
36
+ ) -> Tuple[ANNBase, str]:
37
+ """
38
+ Create an ANN implementation.
39
+ backend:
40
+ - "auto": prefers faiss, then pynndescent, then sklearn, else brute
41
+ - "faiss": FAISS (if installed)
42
+ - "pynndescent": NNDescent (if installed)
43
+ - "sklearn": sklearn NearestNeighbors (if installed)
44
+ - "brute": exact brute force
45
+ ann_params:
46
+ - for faiss:
47
+ index: "flat" | "hnsw" | "ivf_flat"
48
+ hnsw_M: int (default 32)
49
+ ef_search: int (default 64)
50
+ ef_construction: int (default 200)
51
+ ivf_nlist: int (default 1024)
52
+ ivf_nprobe: int (default 16)
53
+ use_float16: bool (default False; GPU only typically)
54
+ - for pynndescent:
55
+ n_trees: int
56
+ n_iters: int
57
+ metric: str (default "euclidean")
58
+ - for sklearn:
59
+ algorithm: str (default "auto")
60
+ leaf_size: int (default 40)
61
+ metric: str (default "euclidean")
62
+ """
63
+ ann_params = {} if ann_params is None else dict(ann_params)
64
+ b = (backend or "auto").lower()
65
+
66
+ if b == "auto":
67
+ for cand in ("faiss", "pynndescent", "sklearn", "brute"):
68
+ ann, used = make_ann(cand, ann_params=ann_params, n_jobs=n_jobs)
69
+ if used != "brute" or cand == "brute":
70
+ return ann, used
71
+ return BruteANN(), "brute"
72
+
73
+ if b == "faiss":
74
+ try:
75
+ return FaissANN(ann_params=ann_params), "faiss"
76
+ except Exception as e:
77
+ raise ImportError(
78
+ "FAISS backend requested but faiss is not available or failed to initialize. "
79
+ "Install with: pip install dima[faiss]"
80
+ ) from e
81
+
82
+ if b == "pynndescent":
83
+ try:
84
+ return PyNNDescentANN(ann_params=ann_params), "pynndescent"
85
+ except Exception as e:
86
+ raise ImportError(
87
+ "pynndescent backend requested but pynndescent is not available. "
88
+ "Install with: pip install pynndescent"
89
+ ) from e
90
+
91
+ if b == "sklearn":
92
+ try:
93
+ return SklearnANN(n_jobs=n_jobs, ann_params=ann_params), "sklearn"
94
+ except Exception as e:
95
+ raise ImportError(
96
+ "sklearn backend requested but scikit-learn is not available. "
97
+ "Install with: pip install scikit-learn"
98
+ ) from e
99
+
100
+ if b == "brute":
101
+ return BruteANN(), "brute"
102
+
103
+ raise ValueError(f"Unknown ANN backend: {backend!r}")
104
+
105
+
106
+ # -------------------------
107
+ # Brute-force (no deps)
108
+ # -------------------------
109
+ class BruteANN(ANNBase):
110
+ def __init__(self):
111
+ self.X = None
112
+
113
+ def build(self, X: np.ndarray) -> "BruteANN":
114
+ self.X = as_contig_f32(X)
115
+ return self
116
+
117
+ def search(self, Q: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
118
+ if self.X is None:
119
+ raise RuntimeError("BruteANN.search called before build().")
120
+ X = self.X
121
+ Q = as_contig_f32(Q)
122
+ k = int(k)
123
+ if k <= 0:
124
+ raise ValueError("k must be >= 1")
125
+ if k > X.shape[0]:
126
+ k = X.shape[0]
127
+
128
+ D2 = sqdist_ab(Q, X) # (B,N)
129
+ idx = np.argpartition(D2, kth=k - 1, axis=1)[:, :k]
130
+ rows = np.arange(Q.shape[0])[:, None]
131
+ d2 = D2[rows, idx]
132
+
133
+ # sort within k
134
+ ordk = np.argsort(d2, axis=1)
135
+ idx = idx[rows, ordk].astype(np.int64)
136
+ d2 = d2[rows, ordk].astype(np.float32)
137
+ return idx, d2
138
+
139
+
140
+ # -------------------------
141
+ # FAISS
142
+ # -------------------------
143
+ class FaissANN(ANNBase):
144
+ def __init__(self, ann_params: Optional[Dict[str, Any]] = None):
145
+ self.ann_params = {} if ann_params is None else dict(ann_params)
146
+ self.index = None
147
+ self.X = None # keep reference for possible rebuild
148
+
149
+ # delayed import
150
+ import faiss # type: ignore
151
+ self.faiss = faiss
152
+
153
+ def _build_index(self, d: int):
154
+ p = self.ann_params
155
+ faiss = self.faiss
156
+
157
+ index_kind = str(p.get("index", "flat")).lower()
158
+
159
+ if index_kind == "flat":
160
+ index = faiss.IndexFlatL2(d)
161
+
162
+ elif index_kind == "hnsw":
163
+ M = int(p.get("hnsw_M", 32))
164
+ index = faiss.IndexHNSWFlat(d, M)
165
+ # optional tuning
166
+ ef_search = int(p.get("ef_search", 64))
167
+ ef_constr = int(p.get("ef_construction", 200))
168
+ index.hnsw.efSearch = ef_search
169
+ index.hnsw.efConstruction = ef_constr
170
+
171
+ elif index_kind == "ivf_flat":
172
+ nlist = int(p.get("ivf_nlist", 1024))
173
+ quantizer = faiss.IndexFlatL2(d)
174
+ index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_L2)
175
+ nprobe = int(p.get("ivf_nprobe", 16))
176
+ index.nprobe = nprobe
177
+
178
+ else:
179
+ raise ValueError(f"Unknown faiss index kind: {index_kind!r}")
180
+
181
+ return index
182
+
183
+ def build(self, X: np.ndarray) -> "FaissANN":
184
+ X = as_contig_f32(X)
185
+ self.X = X
186
+ faiss = self.faiss
187
+ d = int(X.shape[1])
188
+
189
+ index = self._build_index(d)
190
+
191
+ # IVF needs training
192
+ if hasattr(index, "is_trained") and not index.is_trained:
193
+ index.train(X)
194
+
195
+ index.add(X)
196
+ self.index = index
197
+ return self
198
+
199
+ def search(self, Q: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
200
+ if self.index is None:
201
+ raise RuntimeError("FaissANN.search called before build().")
202
+ Q = as_contig_f32(Q)
203
+ k = int(k)
204
+ if k <= 0:
205
+ raise ValueError("k must be >= 1")
206
+
207
+ # FAISS returns (distances, indices); for L2 these are squared distances
208
+ D2, I = self.index.search(Q, k)
209
+ return I.astype(np.int64), D2.astype(np.float32)
210
+
211
+
212
+ # -------------------------
213
+ # PyNNDescent
214
+ # -------------------------
215
+ class PyNNDescentANN(ANNBase):
216
+ def __init__(self, ann_params: Optional[Dict[str, Any]] = None):
217
+ self.ann_params = {} if ann_params is None else dict(ann_params)
218
+ self.index = None
219
+ self.X = None
220
+
221
+ from pynndescent import NNDescent # type: ignore
222
+ self.NNDescent = NNDescent
223
+
224
+ def build(self, X: np.ndarray) -> "PyNNDescentANN":
225
+ X = as_contig_f32(X)
226
+ self.X = X
227
+ p = self.ann_params
228
+
229
+ metric = p.get("metric", "euclidean")
230
+ n_trees = p.get("n_trees", None)
231
+ n_iters = p.get("n_iters", None)
232
+
233
+ kwargs: Dict[str, Any] = {"metric": metric}
234
+ if n_trees is not None:
235
+ kwargs["n_trees"] = int(n_trees)
236
+ if n_iters is not None:
237
+ kwargs["n_iters"] = int(n_iters)
238
+
239
+ self.index = self.NNDescent(X, **kwargs)
240
+ return self
241
+
242
+ def search(self, Q: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
243
+ if self.index is None:
244
+ raise RuntimeError("PyNNDescentANN.search called before build().")
245
+ Q = as_contig_f32(Q)
246
+ k = int(k)
247
+ if k <= 0:
248
+ raise ValueError("k must be >= 1")
249
+
250
+ # NNDescent returns (indices, distances) with euclidean distances (not squared)
251
+ I, d = self.index.query(Q, k=k)
252
+ D2 = (d.astype(np.float32) ** 2)
253
+ return I.astype(np.int64), D2
254
+
255
+
256
+ # -------------------------
257
+ # scikit-learn NearestNeighbors
258
+ # -------------------------
259
+ class SklearnANN(ANNBase):
260
+ def __init__(self, n_jobs: int = -1, ann_params: Optional[Dict[str, Any]] = None):
261
+ self.ann_params = {} if ann_params is None else dict(ann_params)
262
+ self.n_jobs = int(n_jobs)
263
+ self.nn = None
264
+ self.X = None
265
+
266
+ from sklearn.neighbors import NearestNeighbors # type: ignore
267
+ self.NearestNeighbors = NearestNeighbors
268
+
269
+ def build(self, X: np.ndarray) -> "SklearnANN":
270
+ X = as_contig_f32(X)
271
+ self.X = X
272
+ p = self.ann_params
273
+
274
+ algorithm = p.get("algorithm", "auto")
275
+ leaf_size = int(p.get("leaf_size", 40))
276
+ metric = p.get("metric", "euclidean")
277
+
278
+ self.nn = self.NearestNeighbors(
279
+ n_neighbors=1, # set later in search
280
+ algorithm=algorithm,
281
+ leaf_size=leaf_size,
282
+ metric=metric,
283
+ n_jobs=self.n_jobs,
284
+ )
285
+ self.nn.fit(X)
286
+ return self
287
+
288
+ def search(self, Q: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
289
+ if self.nn is None:
290
+ raise RuntimeError("SklearnANN.search called before build().")
291
+ Q = as_contig_f32(Q)
292
+ k = int(k)
293
+ if k <= 0:
294
+ raise ValueError("k must be >= 1")
295
+
296
+ self.nn.set_params(n_neighbors=k)
297
+ d, I = self.nn.kneighbors(Q, return_distance=True)
298
+ # sklearn distances are euclidean; square them
299
+ D2 = (d.astype(np.float32) ** 2)
300
+ return I.astype(np.int64), D2
ddim.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- src/dima/ddpm.py
2
+ # +++ src/dima/ddpm.py
3
+ # @@ -368,4 +368,204 @@
4
+ # return self.reverse_from_T(noise)
5
+ # src/dima/ddpm.py
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Optional
9
+
10
+ import jax
11
+ import jax.numpy as jnp
12
+ from jax import random
13
+
14
+ from flax import linen as nn
15
+ from flax.training import train_state
16
+ from flax import struct
17
+ import optax
18
+ from .ann import ANNBackend, make_ann
19
+ from .ddpm import DDPM
20
+ # +from .ddpm import DDPM, DDIM
21
+ from .dmap import DMAP
22
+ from .gplm import GPLM
23
+
24
+ __all__ = ["DDPM", "EpsMLP", "cosine_schedule", "sinusoidal_embedding"]
25
+
26
+
27
+ # ------------------------------
28
+ # DDIM (deterministic sampler; eta=0.0)
29
+ # ------------------------------
30
+
31
+ class DDIM(DDPM):
32
+ """
33
+ DDIM sampler for D-dimensional latents.
34
+
35
+ Notes
36
+ -----
37
+ * Training is identical to DDPM (epsilon prediction).
38
+ * Sampling / refinement uses the DDIM update:
39
+ x_{t_prev} = sqrt(a_bar_prev) * x0_hat
40
+ + sqrt(1 - a_bar_prev - sigma^2) * eps_pred
41
+ + sigma * z,
42
+ where sigma = eta * sqrt( (1-a_bar_prev)/(1-a_bar_t) * (1 - a_bar_t/a_bar_prev) ).
43
+
44
+ With eta=0.0 the reverse process is deterministic conditioned on x_t.
45
+
46
+ Added (HF I/O):
47
+ - state_dict / save_local / load_local / from_state
48
+ - upload_to_huggingface / download_from_huggingface
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ Z_iX: jnp.ndarray,
54
+ *,
55
+ eta: float = 0.0,
56
+ steps: Optional[int] = None,
57
+ **ddpm_kwargs: Any,
58
+ ):
59
+ super().__init__(Z_iX, **ddpm_kwargs)
60
+ self.eta = float(eta)
61
+ self.steps = None if steps is None else int(steps)
62
+
63
+ @staticmethod
64
+ def _make_ddim_step(params_ema, apply_fn, alpha_bar_s, eps: float, eta: float):
65
+ eta = float(eta)
66
+
67
+ @jax.jit
68
+ def step(carry, t_pair):
69
+ key, x = carry # x: (B, D)
70
+ t, t_prev = t_pair # scalar int32
71
+
72
+ # gather ā_t and ā_prev (supports skipping)
73
+ a_bar_t = jnp.clip(alpha_bar_s[t], eps, 1.0)
74
+ a_bar_prev = jnp.clip(alpha_bar_s[t_prev], eps, 1.0)
75
+
76
+ B = x.shape[0]
77
+ t_batch = jnp.full((B, 1), t, dtype=jnp.int32)
78
+
79
+ eps_pred = apply_fn({"params": params_ema}, x, t_batch) # (B, D)
80
+
81
+ # x0 estimate
82
+ sqrt_one_minus = jnp.sqrt(jnp.clip(1.0 - a_bar_t, eps, 1.0))
83
+ x0_hat = (x - sqrt_one_minus * eps_pred) / jnp.sqrt(a_bar_t)
84
+
85
+ # sigma_t (controls stochasticity). eta=0 => sigma=0 (deterministic)
86
+ frac = (1.0 - a_bar_prev) / jnp.clip(1.0 - a_bar_t, eps, 1.0)
87
+ inside = 1.0 - a_bar_t / jnp.clip(a_bar_prev, eps, 1.0)
88
+ sigma = eta * jnp.sqrt(jnp.clip(frac * inside, 0.0, 1.0))
89
+
90
+ # direction term
91
+ dir_coeff = jnp.sqrt(jnp.clip(1.0 - a_bar_prev - sigma**2, 0.0, 1.0))
92
+ x_prev = jnp.sqrt(a_bar_prev) * x0_hat + dir_coeff * eps_pred
93
+
94
+ # optional noise injection (eta>0)
95
+ if eta > 0.0:
96
+ key, k = random.split(key)
97
+ z = random.normal(k, x.shape)
98
+ x_prev = x_prev + sigma * z
99
+
100
+ return (key, x_prev), x_prev
101
+
102
+ return step
103
+
104
+ def _t_schedule(self, t_start: int, *, steps: Optional[int] = None) -> jnp.ndarray:
105
+ """
106
+ Descending integer timesteps from t_start -> 0.
107
+
108
+ If steps is provided (or self.steps), we use a reduced DDIM schedule
109
+ by rounding a linear grid and making it unique.
110
+ """
111
+ t_start = int(t_start)
112
+ if t_start <= 0:
113
+ return jnp.array([0], dtype=jnp.int32)
114
+
115
+ S = self.steps if steps is None else int(steps)
116
+ if S is None or S >= (t_start + 1):
117
+ return jnp.arange(t_start, -1, -1, dtype=jnp.int32)
118
+
119
+ grid = jnp.linspace(float(t_start), 0.0, int(S), dtype=jnp.float32)
120
+ ts = jnp.unique(jnp.round(grid).astype(jnp.int32))
121
+ ts = ts[::-1] # descending
122
+
123
+ # guarantee endpoints
124
+ if ts[0] != t_start:
125
+ ts = jnp.concatenate([jnp.array([t_start], dtype=jnp.int32), ts])
126
+ if ts[-1] != 0:
127
+ ts = jnp.concatenate([ts, jnp.array([0], dtype=jnp.int32)])
128
+ return ts
129
+
130
+ def refine_latents(
131
+ self,
132
+ z0: jnp.ndarray,
133
+ t_start: int = 10,
134
+ key: Optional[jax.Array] = None,
135
+ add_noise: bool = True,
136
+ *,
137
+ eta: Optional[float] = None,
138
+ steps: Optional[int] = None,
139
+ ) -> jnp.ndarray:
140
+ """
141
+ Refine latents by:
142
+ (optional) forward-noise z0 to step t_start
143
+ DDIM reverse from t_start -> 0 using EMA params.
144
+ """
145
+ z0 = jnp.asarray(z0, dtype=jnp.float32)
146
+ if z0.ndim != 2 or z0.shape[1] != self.D:
147
+ raise ValueError(f"z0 must have shape (B,{self.D}).")
148
+ if not (0 <= int(t_start) < self.T):
149
+ raise ValueError(f"t_start must be in [0, {self.T-1}]")
150
+ t_start = int(t_start)
151
+
152
+ # RNG
153
+ if key is None:
154
+ self.key, key = random.split(self.key)
155
+ else:
156
+ # advance internal RNG too
157
+ self.key, _ = random.split(key)
158
+
159
+ # optional forward noise
160
+ if add_noise and t_start > 0:
161
+ key, k_eps = random.split(key)
162
+ eps_noise = random.normal(k_eps, z0.shape)
163
+ a_bar_t = jnp.clip(self.alpha_bar_s[t_start], self.eps, 1.0) # Corrected attribute name
164
+ z_t = jnp.sqrt(a_bar_t) * z0 + jnp.sqrt(1.0 - a_bar_t) * eps_noise
165
+ else:
166
+ z_t = z0
167
+
168
+ # schedule
169
+ ts = self._t_schedule(t_start, steps=steps)
170
+ if ts.shape[0] == 1:
171
+ return z_t
172
+
173
+ t_pairs = jnp.stack([ts[:-1], ts[1:]], axis=1) # (K,2)
174
+
175
+ step = self._make_ddim_step(
176
+ self.state.ema_params,
177
+ self.state.apply_fn,
178
+ self.alpha_bar_s, # Corrected attribute name
179
+ self.eps,
180
+ self.eta if eta is None else float(eta),
181
+ )
182
+
183
+ (final_key, _), trace = jax.lax.scan(step, (key, z_t), xs=t_pairs)
184
+
185
+ self.key = final_key
186
+ return trace[-1]
187
+
188
+ def __call__(
189
+ self,
190
+ z0: jnp.ndarray,
191
+ t_start: int = 10,
192
+ key: Optional[jax.Array] = None,
193
+ add_noise: bool = True,
194
+ *,
195
+ eta: Optional[float] = None,
196
+ steps: Optional[int] = None,
197
+ ) -> jnp.ndarray:
198
+ return self.refine_latents(
199
+ z0, t_start=t_start, key=key, add_noise=add_noise, eta=eta, steps=steps
200
+ )
201
+
202
+ def reverse_from_T(self, x_T: jnp.ndarray, *, eta: Optional[float] = None, steps: Optional[int] = None) -> jnp.ndarray:
203
+ x_T = jnp.asarray(x_T, dtype=jnp.float32)
204
+ if x_T.ndim != 2 or x_T.shape[1] != self.D:
205
+ raise ValueError(f"x_T must have shape (B,{self.D}).")
206
+
207
+ self.key, k0 = random.split(self.key)
208
+
209
+ ts = self._t_schedule(self.T - 1, steps=steps)
210
+ t_pairs = jnp.stack([ts[:-1], ts[1:]], axis=1)
211
+
212
+ step = self._make_ddim_step(
213
+ self.state.ema_params,
214
+ self.state.apply_fn,
215
+ self.alpha_bar_s, # Corrected attribute name
216
+ self.eps,
217
+ self.eta if eta is None else float(eta),
218
+ )
219
+
220
+ (_, _), trace = jax.lax.scan(step, (k0, x_T), xs=t_pairs)
221
+ return trace[-1]
222
+
223
+ def sample(self, N: int = 10_000, *, eta: Optional[float] = None, steps: Optional[int] = None) -> jnp.ndarray:
224
+ self.key, k = random.split(self.key)
225
+ noise = random.normal(k, (int(N), self.D))
226
+ return self.reverse_from_T(noise, eta=eta, steps=steps)
227
+
228
+ # ------------------------------------------------------------------
229
+ # Serialization + Hugging Face Hub I/O (integrated)
230
+ # ------------------------------------------------------------------
231
+
232
+ def state_dict(self):
233
+ """
234
+ Msgpack-safe checkpoint dict (includes DDIM-specific eta/steps).
235
+ Uses flax.serialization.to_state_dict for TrainStateEMA to avoid tuple issues (opt_state).
236
+ """
237
+ from flax import serialization as flax_ser # type: ignore
238
+ import numpy as np
239
+
240
+ return dict(
241
+ # DDPM core hyperparameters
242
+ T=int(self.T),
243
+ D=int(self.D),
244
+ hidden_dim=int(getattr(self, "hidden_dim", 0) or self.hidden_dim),
245
+ t_embed_dim=int(getattr(self, "t_embed_dim", 0) or self.t_embed_dim),
246
+ learning_rate=float(self.learning_rate),
247
+ ema_decay=float(self.ema_decay),
248
+ beta_max=float(self.beta_max),
249
+ batch_size=None if (self.batch_size is None) else int(self.batch_size),
250
+ eps=float(self.eps),
251
+
252
+ # DDIM specifics
253
+ eta=float(self.eta),
254
+ steps=None if (self.steps is None) else int(self.steps),
255
+
256
+ # RNG key (portable)
257
+ key=np.asarray(self.key),
258
+
259
+ # TrainStateEMA (params, ema_params, opt_state, step)
260
+ train_state=flax_ser.to_state_dict(self.state),
261
+ )
262
+
263
+ def save_local(self, weights_file: str = "ddim.msgpack", config_file: Optional[str] = "ddim_config.json") -> None:
264
+ from flax import serialization as flax_ser # type: ignore
265
+ import json as _json
266
+
267
+ ckpt = self.state_dict()
268
+ blob = flax_ser.msgpack_serialize(ckpt)
269
+
270
+ with open(weights_file, "wb") as f:
271
+ f.write(blob)
272
+
273
+ if config_file is not None:
274
+ cfg = dict(
275
+ T=int(ckpt["T"]),
276
+ D=int(ckpt["D"]),
277
+ hidden_dim=int(ckpt["hidden_dim"]),
278
+ t_embed_dim=int(ckpt["t_embed_dim"]),
279
+ learning_rate=float(ckpt["learning_rate"]),
280
+ ema_decay=float(ckpt["ema_decay"]),
281
+ beta_max=float(ckpt["beta_max"]),
282
+ batch_size=ckpt["batch_size"],
283
+ eps=float(ckpt["eps"]),
284
+ eta=float(ckpt["eta"]),
285
+ steps=ckpt["steps"],
286
+ )
287
+ with open(config_file, "w") as f:
288
+ _json.dump(cfg, f, indent=2)
289
+
290
+ @classmethod
291
+ def from_state(cls, ckpt, *, key: Optional[jax.Array] = None) -> "DDIM":
292
+ """
293
+ Rehydrate a DDIM instance without retraining:
294
+ - rebuilds a skeleton DDIM (which builds schedules/model/optimizer)
295
+ - restores TrainStateEMA via flax.serialization.from_state_dict
296
+ """
297
+ from flax import serialization as flax_ser # type: ignore
298
+ import numpy as np
299
+
300
+ T = int(ckpt["T"])
301
+ D = int(ckpt["D"])
302
+ hidden_dim = int(ckpt["hidden_dim"])
303
+ t_embed_dim = int(ckpt["t_embed_dim"])
304
+ learning_rate = float(ckpt["learning_rate"])
305
+ ema_decay = float(ckpt["ema_decay"])
306
+ beta_max = float(ckpt["beta_max"])
307
+ batch_size = ckpt.get("batch_size", None)
308
+ eps = float(ckpt.get("eps", 1e-5))
309
+
310
+ eta = float(ckpt.get("eta", 0.0))
311
+ steps = ckpt.get("steps", None)
312
+ steps = None if (steps is None) else int(steps)
313
+
314
+ if key is None:
315
+ if "key" in ckpt:
316
+ key = jnp.asarray(np.asarray(ckpt["key"]))
317
+ else:
318
+ key = random.PRNGKey(0)
319
+
320
+ # skeleton (no training)
321
+ dummy = jnp.zeros((1, D), dtype=jnp.float32)
322
+ obj = cls(
323
+ dummy,
324
+ T=T,
325
+ hidden_dim=hidden_dim,
326
+ t_embed_dim=t_embed_dim,
327
+ learning_rate=learning_rate,
328
+ n_iter=0,
329
+ ema_decay=ema_decay,
330
+ beta_max=beta_max,
331
+ batch_size=batch_size,
332
+ key=key,
333
+ verbose_every=0,
334
+ eps=eps,
335
+ eta=eta,
336
+ steps=steps,
337
+ )
338
+
339
+ obj.state = flax_ser.from_state_dict(obj.state, ckpt["train_state"])
340
+
341
+ # restore RNG key and DDIM attrs
342
+ if "key" in ckpt:
343
+ obj.key = jnp.asarray(np.asarray(ckpt["key"]))
344
+ obj.eta = eta
345
+ obj.steps = steps
346
+
347
+ return obj
348
+
349
+ @classmethod
350
+ def load_local(cls, weights_file: str = "ddim.msgpack", *, key: Optional[jax.Array] = None) -> "DDIM":
351
+ from flax import serialization as flax_ser # type: ignore
352
+
353
+ with open(weights_file, "rb") as f:
354
+ ckpt = flax_ser.msgpack_restore(f.read())
355
+ return cls.from_state(ckpt, key=key)
356
+
357
+ def upload_to_huggingface(
358
+ self,
359
+ repo_id: str,
360
+ *,
361
+ weights_file: str = "ddim.msgpack",
362
+ config_file: str = "ddim_config.json",
363
+ token: Optional[str] = None,
364
+ repo_type: str = "model",
365
+ revision: Optional[str] = None,
366
+ ) -> None:
367
+ """
368
+ Upload DDIM checkpoint to HF Hub (msgpack + JSON).
369
+ Mirrors the DMAP/DDPM pattern.
370
+ """
371
+ try:
372
+ from huggingface_hub import HfApi, HfFolder, upload_file # type: ignore
373
+ except Exception as e:
374
+ raise RuntimeError("huggingface_hub not installed. Install it (or `pip install dima[hf]`).") from e
375
+
376
+ self.save_local(weights_file=weights_file, config_file=config_file)
377
+
378
+ if token is None:
379
+ token = HfFolder.get_token()
380
+ if token is None:
381
+ raise RuntimeError("No HF token found. Run `huggingface-cli login`, or pass `token=...`.")
382
+
383
+ import os as _os
384
+
385
+ api = HfApi()
386
+ api.create_repo(repo_id=repo_id, repo_type=repo_type, exist_ok=True, token=token)
387
+
388
+ wf = _os.path.basename(weights_file)
389
+ cf = _os.path.basename(config_file)
390
+
391
+ upload_file(
392
+ path_or_fileobj=weights_file,
393
+ path_in_repo=wf,
394
+ repo_id=repo_id,
395
+ token=token,
396
+ repo_type=repo_type,
397
+ revision=revision,
398
+ )
399
+ upload_file(
400
+ path_or_fileobj=config_file,
401
+ path_in_repo=cf,
402
+ repo_id=repo_id,
403
+ token=token,
404
+ repo_type=repo_type,
405
+ revision=revision,
406
+ )
407
+
408
+ @classmethod
409
+ def download_from_huggingface(
410
+ cls,
411
+ repo_id: str,
412
+ *,
413
+ weights_file: str = "ddim.msgpack",
414
+ token: Optional[str] = None,
415
+ repo_type: str = "model",
416
+ revision: Optional[str] = None,
417
+ key: Optional[jax.Array] = None,
418
+ ) -> "DDIM":
419
+ """
420
+ Download DDIM checkpoint from HF Hub and return a rehydrated DDIM instance.
421
+ """
422
+ try:
423
+ from huggingface_hub import hf_hub_download # type: ignore
424
+ except Exception as e:
425
+ raise RuntimeError("huggingface_hub not installed. Install it (or `pip install dima[hf]`).") from e
426
+
427
+ path = hf_hub_download(
428
+ repo_id=repo_id,
429
+ filename=weights_file,
430
+ token=token,
431
+ repo_type=repo_type,
432
+ revision=revision,
433
+ )
434
+ return cls.load_local(path, key=key)
435
+
436
+ # optional alias for naming symmetry
437
+ download_to_huggingface = download_from_huggingface
438
+
439
+
440
+ __all__ = ["DDPM", "DDIM", "EpsMLP", "cosine_schedule", "sinusoidal_embedding"]
441
+
442
+ # --- src/dima/dima.py
443
+ # +++ src/dima/dima.py
444
+ # @@ -15,7 +15,7 @@
445
+ # from flax import serialization as flax_ser
446
+
447
+
448
+
449
+ # @@ -374,7 +374,13 @@
450
+ # ddpm_init["eps"] = float(ddpm_eps)
451
+
452
+ # with jax.default_device(self.ddpm_device):
453
+ # - self.dm = DDPM(Z_ix_j, **ddpm_init)
454
+ # + sampler = str(ddpm_init.pop("sampler", "ddpm")).lower()
455
+ # + if sampler == "ddim":
456
+ # + eta = float(ddpm_init.pop("eta", 0.0))
457
+ # + steps = ddpm_init.pop("steps", None)
458
+ # + self.dm = DDIM(Z_ix_j, eta=eta, steps=steps, **ddpm_init)
459
+ # + else:
460
+ # + self.dm = DDPM(Z_ix_j, **ddpm_init)
461
+
462
+ # self.training_time = time.time() - t0
463
+
ddpm.py ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/dima/ddpm.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Optional
5
+
6
+ import jax
7
+ import jax.numpy as jnp
8
+ from jax import random
9
+
10
+ from flax import linen as nn
11
+ from flax.training import train_state
12
+ from flax import struct
13
+ import optax
14
+
15
+
16
+ # ------------------------------
17
+ # Schedules & embeddings
18
+ # ------------------------------
19
+ def cosine_schedule(T: int, s: float = 0.008):
20
+ """
21
+ Nichol & Dhariwal cosine schedule.
22
+ Returns:
23
+ alpha: (T,)
24
+ beta: (T,)
25
+ alpha_bar: (T,)
26
+ """
27
+ steps = jnp.arange(T + 1, dtype=jnp.float32)
28
+ f = jnp.cos(((steps / T + s) / (1.0 + s)) * jnp.pi / 2.0) ** 2
29
+ alpha_bar_all = f / f[0]
30
+ alpha_bar = alpha_bar_all[1:] # (T,)
31
+ alpha = alpha_bar / jnp.concatenate([jnp.array([1.0], dtype=jnp.float32), alpha_bar[:-1]])
32
+ beta = 1.0 - alpha
33
+ return alpha, beta, alpha_bar
34
+
35
+
36
+ def sinusoidal_embedding(t_idx: jnp.ndarray, dim: int) -> jnp.ndarray:
37
+ """
38
+ t_idx: (B,1) int32
39
+ returns: (B,dim)
40
+ """
41
+ if t_idx.ndim != 2 or t_idx.shape[1] != 1:
42
+ raise ValueError("t_idx must have shape (B,1)")
43
+ t = t_idx.astype(jnp.float32)
44
+ half = dim // 2
45
+ denom = float(max(half - 1, 1))
46
+ freqs = jnp.exp(-jnp.log(10_000.0) * jnp.arange(half, dtype=jnp.float32) / denom)
47
+ args = t * freqs
48
+ emb = jnp.concatenate([jnp.sin(args), jnp.cos(args)], axis=-1)
49
+ if dim % 2 == 1:
50
+ emb = jnp.pad(emb, ((0, 0), (0, 1)))
51
+ return emb
52
+
53
+
54
+ # ------------------------------
55
+ # Model: epsilon predictor
56
+ # ------------------------------
57
+ class EpsMLP(nn.Module):
58
+ """Simple MLP epsilon-predictor for DDPM in R^D."""
59
+ hidden: int
60
+ t_dim: int
61
+ data_dim: int
62
+
63
+ @nn.compact
64
+ def __call__(self, x: jnp.ndarray, t_idx: jnp.ndarray) -> jnp.ndarray:
65
+ # x: (B,D), t_idx: (B,1)
66
+ t_emb = sinusoidal_embedding(t_idx, self.t_dim)
67
+ t_h = nn.Dense(self.hidden)(t_emb)
68
+ t_h = nn.gelu(t_h)
69
+
70
+ h = nn.Dense(self.hidden)(x)
71
+ h = nn.gelu(h + t_h)
72
+
73
+ t_h2 = nn.Dense(self.hidden)(t_h)
74
+ h = nn.Dense(self.hidden)(h)
75
+ h = nn.gelu(h + t_h2)
76
+
77
+ out = nn.Dense(self.data_dim)(h)
78
+ return out
79
+
80
+
81
+ # ------------------------------
82
+ # TrainState with EMA
83
+ # ------------------------------
84
+ @struct.dataclass
85
+ class TrainStateEMA(train_state.TrainState):
86
+ """Flax TrainState extended with EMA params."""
87
+ ema_params: Any = struct.field(pytree_node=True)
88
+
89
+ def apply_gradients(self, *, grads, ema_decay: float):
90
+ updates, new_opt_state = self.tx.update(grads, self.opt_state, self.params)
91
+ new_params = optax.apply_updates(self.params, updates)
92
+ new_ema = optax.incremental_update(new_params, self.ema_params, step_size=1.0 - ema_decay)
93
+ return self.replace(
94
+ step=self.step + 1,
95
+ params=new_params,
96
+ opt_state=new_opt_state,
97
+ ema_params=new_ema,
98
+ )
99
+
100
+
101
+ # ------------------------------
102
+ # DDPM
103
+ # ------------------------------
104
+ class DDPM:
105
+ """
106
+ DDPM for D-dimensional latents.
107
+
108
+ API expected by your DIMA wrapper:
109
+ - DDPM(Z_train, ...): trains in __init__ (n_iter can be 0 to skip)
110
+ - refine_latents(z0, t_start, key, add_noise) -> z_refined
111
+ - __call__(...) delegates to refine_latents
112
+ - sample(N) -> latent samples
113
+
114
+ Added:
115
+ - state_dict / save_local / load_local / from_state
116
+ - upload_to_huggingface / download_from_huggingface
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ Z_iX: jnp.ndarray,
122
+ *,
123
+ T: int = 100,
124
+ hidden_dim: int = 128,
125
+ t_embed_dim: int = 64,
126
+ learning_rate: float = 1e-3,
127
+ n_iter: int = 20_000,
128
+ ema_decay: float = 0.999,
129
+ beta_max: float = 0.02,
130
+ batch_size: Optional[int] = None,
131
+ key: jax.Array = random.PRNGKey(0),
132
+ verbose_every: int = 0,
133
+ eps: float = 1e-5,
134
+ ):
135
+ Z_iX = jnp.asarray(Z_iX, dtype=jnp.float32)
136
+ if Z_iX.ndim != 2:
137
+ raise ValueError("Z_iX must be 2D (N,D).")
138
+
139
+ self.D = int(Z_iX.shape[1])
140
+ self.T = int(T)
141
+
142
+ self.key = key
143
+ self.ema_decay = float(ema_decay)
144
+ self.batch_size = batch_size
145
+ self.verbose_every = int(verbose_every)
146
+ self.eps = float(eps)
147
+ self.beta_max = float(beta_max)
148
+
149
+ # store scalars for serialization/rebuild
150
+ self.learning_rate = float(learning_rate)
151
+ self.hidden_dim = int(hidden_dim)
152
+ self.t_embed_dim = int(t_embed_dim)
153
+
154
+ # schedule (cosine, clipped by beta_max)
155
+ alpha, beta, alpha_bar = cosine_schedule(self.T)
156
+ beta = jnp.minimum(beta, self.beta_max)
157
+ alpha = 1.0 - beta
158
+ alpha_bar = jnp.cumprod(alpha)
159
+
160
+ self.alpha_s = alpha.astype(jnp.float32)
161
+ self.beta_s = beta.astype(jnp.float32)
162
+ self.alpha_bar_s = alpha_bar.astype(jnp.float32)
163
+
164
+ # model + optimizer + EMA
165
+ self.model = EpsMLP(hidden=self.hidden_dim, t_dim=self.t_embed_dim, data_dim=self.D)
166
+
167
+ params = self.model.init(
168
+ self.key,
169
+ jnp.zeros((1, self.D), dtype=jnp.float32),
170
+ jnp.zeros((1, 1), dtype=jnp.int32),
171
+ )["params"]
172
+
173
+ tx = optax.adam(self.learning_rate)
174
+ self.state = TrainStateEMA.create(apply_fn=self.model.apply, params=params, tx=tx, ema_params=params)
175
+
176
+ # train
177
+ if int(n_iter) > 0:
178
+ self._train(Z_iX, int(n_iter))
179
+
180
+ # ---------- training ----------
181
+
182
+ @staticmethod
183
+ def _loss(params, apply_fn, x_t, t_idx, eps_true):
184
+ eps_pred = apply_fn({"params": params}, x_t, t_idx)
185
+ return jnp.mean((eps_pred - eps_true) ** 2)
186
+
187
+ @staticmethod
188
+ @jax.jit
189
+ def _train_step(
190
+ state: TrainStateEMA,
191
+ x0_batch: jnp.ndarray,
192
+ key: jax.Array,
193
+ alpha_bar_s: jnp.ndarray,
194
+ ema_decay: float,
195
+ eps: float,
196
+ ):
197
+ B = x0_batch.shape[0]
198
+ key, k_eps, k_t = random.split(key, 3);
199
+
200
+ eps_noise = random.normal(k_eps, shape=x0_batch.shape) # (B,D)
201
+ t_idx = random.randint(k_t, shape=(B, 1), minval=0, maxval=alpha_bar_s.shape[0])
202
+
203
+ a_bar_t = jnp.take(alpha_bar_s, t_idx.squeeze(-1))[:, None]
204
+ a_bar_t = jnp.clip(a_bar_t, eps, 1.0)
205
+
206
+ x_t = jnp.sqrt(a_bar_t) * x0_batch + jnp.sqrt(1.0 - a_bar_t) * eps_noise
207
+
208
+ def loss_fn(p):
209
+ return DDPM._loss(p, state.apply_fn, x_t, t_idx, eps_noise)
210
+
211
+ loss, grads = jax.value_and_grad(loss_fn)(state.params)
212
+ new_state = state.apply_gradients(grads=grads, ema_decay=ema_decay)
213
+ return new_state, loss, key
214
+
215
+ def _train(self, Z_iX: jnp.ndarray, n_iter: int):
216
+ N = int(Z_iX.shape[0])
217
+ bs = N if (self.batch_size is None) else min(int(self.batch_size), N)
218
+
219
+ for it in range(n_iter):
220
+ if bs >= N:
221
+ batch = Z_iX
222
+ else:
223
+ self.key, k_perm = random.split(self.key)
224
+ idx = random.permutation(k_perm, N)[:bs]
225
+ batch = Z_iX[idx]
226
+
227
+ self.state, loss, self.key = self._train_step(
228
+ self.state,
229
+ batch,
230
+ self.key,
231
+ self.alpha_bar_s,
232
+ self.ema_decay,
233
+ self.eps,
234
+ )
235
+
236
+ if self.verbose_every and (it % self.verbose_every == 0 or it == n_iter - 1):
237
+ print(f"iter {it:6d} loss {float(loss):.6f}", end="\r")
238
+
239
+ if self.verbose_every:
240
+ print("\ntraining complete.")
241
+
242
+ # ---------- diffusion utilities ----------
243
+
244
+ @staticmethod
245
+ def _posterior_variance(alpha_s, beta_s, alpha_bar_s, t):
246
+ a_bar_t = alpha_bar_s[t]
247
+ a_bar_prev = jnp.where(t > 0, alpha_bar_s[t - 1], jnp.array(1.0, dtype=alpha_bar_s.dtype))
248
+ return ((1.0 - a_bar_prev) / (1.0 - a_bar_t)) * beta_s[t]
249
+
250
+ @staticmethod
251
+ def _make_sampler_step(params_ema, apply_fn, alpha_s, beta_s, alpha_bar_s, eps: float):
252
+ @jax.jit
253
+ def step(carry, _):
254
+ key, t, x = carry # x: (B,D)
255
+ key, k = random.split(key)
256
+
257
+ alpha_t = jnp.clip(alpha_s[t], eps, 1.0)
258
+ a_bar_t = jnp.clip(alpha_bar_s[t], eps, 1.0)
259
+
260
+ sqrt_alpha = jnp.sqrt(alpha_t)
261
+ sqrt_one_minus_a_bar = jnp.sqrt(jnp.clip(1.0 - a_bar_t, eps, 1.0))
262
+
263
+ B = x.shape[0]
264
+ t_batch = jnp.full((B, 1), t, dtype=jnp.int32)
265
+
266
+ eps_pred = apply_fn({"params": params_ema}, x, t_batch) # (B,D)
267
+
268
+ # predict x0
269
+ x0_hat = (x - sqrt_one_minus_a_bar * eps_pred) / jnp.sqrt(a_bar_t)
270
+
271
+ a_bar_prev = jnp.where(t > 0, alpha_bar_s[t - 1], jnp.array(1.0, dtype=alpha_bar_s.dtype))
272
+ denom = jnp.clip(1.0 - a_bar_t, eps, 1.0)
273
+
274
+ coef1 = jnp.sqrt(jnp.clip(a_bar_prev, eps, 1.0)) * beta_s[t] / denom
275
+ coef2 = sqrt_alpha * (1.0 - a_bar_prev) / denom
276
+ mean = coef1 * x0_hat + coef2 * x
277
+
278
+ beta_tilde = DDPM._posterior_variance(alpha_s, beta_s, alpha_bar_s, t)
279
+ sigma = jnp.sqrt(jnp.clip(beta_tilde, 0.0, 1.0))
280
+
281
+ z = random.normal(k, x.shape)
282
+ z = jnp.where(t == 0, 0.0, z);
283
+
284
+ x_prev = mean + sigma * z
285
+ return (key, t - 1, x_prev), x_prev
286
+
287
+ return step
288
+
289
+ # ---------- API ----------
290
+
291
+ def refine_latents(
292
+ self,
293
+ z0: jnp.ndarray,
294
+ t_start: int = 10,
295
+ key: Optional[jax.Array] = None,
296
+ add_noise: bool = True,
297
+ ) -> jnp.ndarray:
298
+ """
299
+ Refine latents by:
300
+ (optional) forward-noise z0 to step t_start
301
+ reverse-diffuse from t_start -> 0 using EMA params.
302
+ """
303
+ z0 = jnp.asarray(z0, dtype=jnp.float32)
304
+ if z0.ndim != 2 or z0.shape[1] != self.D:
305
+ raise ValueError(f"z0 must have shape (B,{self.D}).")
306
+
307
+ if not (0 <= int(t_start) < self.T):
308
+ raise ValueError(f"t_start must be in [0, {self.T-1}]")
309
+ t_start = int(t_start)
310
+
311
+ if key is None:
312
+ self.key, key = random.split(self.key)
313
+ else:
314
+ # advance internal RNG too
315
+ self.key, _ = random.split(key)
316
+
317
+ # forward diffuse to t_start
318
+ key, k_eps = random.split(key)
319
+ eps_noise = random.normal(k_eps, z0.shape)
320
+
321
+ a_bar_t = jnp.clip(self.alpha_bar_s[t_start], self.eps, 1.0)
322
+
323
+ if add_noise:
324
+ z_t = jnp.sqrt(a_bar_t) * z0 + jnp.sqrt(1.0 - a_bar_t) * eps_noise
325
+ else:
326
+ z_t = z0
327
+
328
+ step = self._make_sampler_step(
329
+ self.state.ema_params,
330
+ self.state.apply_fn,
331
+ self.alpha_s,
332
+ self.beta_s,
333
+ self.alpha_bar_s,
334
+ self.eps,
335
+ )
336
+
337
+ (final_key, _, _), trace = jax.lax.scan(
338
+ step,
339
+ (key, t_start, z_t),
340
+ xs=None,
341
+ length=t_start + 1,
342
+ )
343
+
344
+ self.key = final_key
345
+ return trace[-1]
346
+
347
+ def __call__(
348
+ self,
349
+ z0: jnp.ndarray,
350
+ t_start: int = 10,
351
+ key: Optional[jax.Array] = None,
352
+ add_noise: bool = True,
353
+ ) -> jnp.ndarray:
354
+ return self.refine_latents(z0, t_start=t_start, key=key, add_noise=add_noise)
355
+
356
+ def reverse_from_T(self, x_T: jnp.ndarray) -> jnp.ndarray:
357
+ x_T = jnp.asarray(x_T, dtype=jnp.float32)
358
+ if x_T.ndim != 2 or x_T.shape[1] != self.D:
359
+ raise ValueError(f"x_T must have shape (B,{self.D}).")
360
+
361
+ step = self._make_sampler_step(
362
+ self.state.ema_params,
363
+ self.state.apply_fn,
364
+ self.alpha_s,
365
+ self.beta_s,
366
+ self.alpha_bar_s,
367
+ self.eps,
368
+ )
369
+
370
+ self.key, k0 = random.split(self.key)
371
+
372
+ (_, _, _), trace = jax.lax.scan(
373
+ step,
374
+ (k0, self.T - 1, x_T),
375
+ xs=None,
376
+ length=self.T,
377
+ )
378
+
379
+ return trace[-1]
380
+
381
+ def sample(self, N: int = 10_000) -> jnp.ndarray:
382
+ self.key, k = random.split(self.key)
383
+ noise = random.normal(k, (int(N), self.D))
384
+ return self.reverse_from_T(noise)
385
+
386
+ # -------------------------
387
+ # Serialization helpers
388
+ # -------------------------
389
+
390
+ def state_dict(self):
391
+ """
392
+ Msgpack-safe checkpoint dict.
393
+ Uses flax.serialization.to_state_dict to avoid tuple/namedtuple issues in opt_state.
394
+ """
395
+ from flax import serialization as flax_ser # type: ignore
396
+ import numpy as np
397
+
398
+ return dict(
399
+ # hyperparameters needed to rebuild the object
400
+ T=int(self.T),
401
+ D=int(self.D),
402
+ hidden_dim=int(self.hidden_dim),
403
+ t_embed_dim=int(self.t_embed_dim),
404
+ learning_rate=float(self.learning_rate),
405
+ ema_decay=float(self.ema_decay),
406
+ beta_max=float(self.beta_max),
407
+ batch_size=None if (self.batch_size is None) else int(self.batch_size),
408
+ eps=float(self.eps),
409
+
410
+ # RNG key (store as numpy for portability)
411
+ key=np.asarray(self.key),
412
+
413
+ # TrainStateEMA (params, ema_params, opt_state, step) in a serializable form
414
+ train_state=flax_ser.to_state_dict(self.state),
415
+ )
416
+
417
+
418
+ def save_local(self, weights_file: str = "ddpm.msgpack", config_file: Optional[str] = "ddpm_config.json") -> None:
419
+ from flax import serialization as flax_ser # type: ignore
420
+ import json as _json
421
+
422
+ ckpt = self.state_dict()
423
+ blob = flax_ser.msgpack_serialize(ckpt)
424
+
425
+ with open(weights_file, "wb") as f:
426
+ f.write(blob)
427
+
428
+ if config_file is not None:
429
+ cfg = dict(
430
+ T=int(ckpt["T"]),
431
+ D=int(ckpt["D"]),
432
+ hidden_dim=int(ckpt["hidden_dim"]),
433
+ t_embed_dim=int(ckpt["t_embed_dim"]),
434
+ learning_rate=float(ckpt["learning_rate"]),
435
+ ema_decay=float(ckpt["ema_decay"]),
436
+ beta_max=float(ckpt["beta_max"]),
437
+ batch_size=ckpt["batch_size"],
438
+ eps=float(ckpt["eps"]),
439
+ )
440
+ with open(config_file, "w") as f:
441
+ _json.dump(cfg, f, indent=2)
442
+
443
+
444
+ @classmethod
445
+
446
+ def from_state(cls, ckpt, *, key: Optional[jax.Array] = None) -> "DDPM":
447
+ """
448
+ Rehydrate a DDPM instance without retraining, restoring full TrainStateEMA
449
+ (including opt_state) via flax.serialization.from_state_dict.
450
+ """
451
+ from flax import serialization as flax_ser # type: ignore
452
+ import numpy as np
453
+
454
+ T = int(ckpt["T"])
455
+ D = int(ckpt["D"])
456
+ hidden_dim = int(ckpt["hidden_dim"])
457
+ t_embed_dim = int(ckpt["t_embed_dim"])
458
+ learning_rate = float(ckpt["learning_rate"])
459
+ ema_decay = float(ckpt["ema_decay"])
460
+ beta_max = float(ckpt["beta_max"])
461
+ batch_size = ckpt.get("batch_size", None)
462
+ eps = float(ckpt.get("eps", 1e-5))
463
+
464
+ if key is None:
465
+ # prefer explicit passed key; else use checkpoint key; else default
466
+ if "key" in ckpt:
467
+ key = jnp.asarray(np.asarray(ckpt["key"]))
468
+ else:
469
+ key = random.PRNGKey(0)
470
+
471
+ # Build a skeleton instance (no training)
472
+ dummy = jnp.zeros((1, D), dtype=jnp.float32)
473
+ obj = cls(
474
+ dummy,
475
+ T=T,
476
+ hidden_dim=hidden_dim,
477
+ t_embed_dim=t_embed_dim,
478
+ learning_rate=learning_rate,
479
+ n_iter=0,
480
+ ema_decay=ema_decay,
481
+ beta_max=beta_max,
482
+ batch_size=batch_size,
483
+ key=key,
484
+ verbose_every=0,
485
+ eps=eps,
486
+ )
487
+
488
+ # Restore TrainStateEMA (including opt_state tuples) safely
489
+ obj.state = flax_ser.from_state_dict(obj.state, ckpt["train_state"])
490
+
491
+ # Restore RNG key if present (keeps reproducibility)
492
+ if "key" in ckpt:
493
+ obj.key = jnp.asarray(np.asarray(ckpt["key"]))
494
+
495
+ return obj
496
+
497
+
498
+ @classmethod
499
+ def load_local(cls, weights_file: str = "ddpm.msgpack", *, key: Optional[jax.Array] = None) -> "DDPM":
500
+ from flax import serialization as flax_ser # type: ignore
501
+
502
+ with open(weights_file, "rb") as f:
503
+ ckpt = flax_ser.msgpack_restore(f.read())
504
+
505
+ return cls.from_state(ckpt, key=key)
506
+
507
+
508
+ def upload_to_huggingface(
509
+ self,
510
+ repo_id: str,
511
+ *,
512
+ weights_file: str = "ddpm.msgpack",
513
+ config_file: str = "ddpm_config.json",
514
+ token: Optional[str] = None,
515
+ repo_type: str = "model",
516
+ revision: Optional[str] = None,
517
+ ) -> None:
518
+ """
519
+ Upload DDPM weights to the Hugging Face Hub.
520
+ Saves locally first (msgpack + JSON), then creates repo (if needed) and uploads files.
521
+ """
522
+ try:
523
+ from huggingface_hub import HfApi, HfFolder, upload_file # type: ignore
524
+ except Exception as e:
525
+ raise RuntimeError("huggingface_hub not installed. Install it (or `pip install dima[hf]`).") from e
526
+
527
+ self.save_local(weights_file=weights_file, config_file=config_file)
528
+
529
+ if token is None:
530
+ token = HfFolder.get_token()
531
+ if token is None:
532
+ raise RuntimeError("No HF token found. Run `huggingface-cli login`, or pass `token=...`.")
533
+
534
+ import os as _os
535
+
536
+ api = HfApi()
537
+ api.create_repo(repo_id=repo_id, repo_type=repo_type, exist_ok=True, token=token)
538
+
539
+ wf = _os.path.basename(weights_file)
540
+ cf = _os.path.basename(config_file)
541
+
542
+ upload_file(
543
+ path_or_fileobj=weights_file,
544
+ path_in_repo=wf,
545
+ repo_id=repo_id,
546
+ token=token,
547
+ repo_type=repo_type,
548
+ revision=revision,
549
+ )
550
+ upload_file(
551
+ path_or_fileobj=config_file,
552
+ path_in_repo=cf,
553
+ repo_id=repo_id,
554
+ token=token,
555
+ repo_type=repo_type,
556
+ revision=revision,
557
+ )
558
+
559
+ @classmethod
560
+ def download_from_huggingface(
561
+ cls,
562
+ repo_id: str,
563
+ *,
564
+ weights_file: str = "ddpm.msgpack",
565
+ token: Optional[str] = None,
566
+ repo_type: str = "model",
567
+ revision: Optional[str] = None,
568
+ key: Optional[jax.Array] = None,
569
+ ) -> "DDPM":
570
+ """
571
+ Download DDPM weights from the Hugging Face Hub and return a rehydrated DDPM instance.
572
+ """
573
+ try:
574
+ from huggingface_hub import hf_hub_download # type: ignore
575
+ except Exception as e:
576
+ raise RuntimeError("huggingface_hub not installed. Install it (or `pip install dima[hf]`).") from e
577
+
578
+ path = hf_hub_download(
579
+ repo_id=repo_id,
580
+ filename=weights_file,
581
+ token=token,
582
+ repo_type=repo_type,
583
+ revision=revision,
584
+ )
585
+ return cls.load_local(path, key=key)
586
+
587
+ # Optional backward-compatible alias (if you ever used the other name)
588
+ download_to_huggingface = download_from_huggingface
589
+
590
+
591
+ __all__ = ["DDPM", "EpsMLP", "cosine_schedule", "sinusoidal_embedding"]
ddpmx.py ADDED
@@ -0,0 +1,745 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # how to use
2
+ # ddpm = DDPM(Z_train, T=100, n_iter=20_000, key=random.PRNGKey(0))
3
+ #
4
+ # Fast unconditional samples (try 15–30 first)
5
+ # z = ddpm.sample_dpmpp(N=4096, num_steps=20)
6
+ #
7
+ # Fast “refine latents” starting from an intermediate noise level
8
+ #z_ref = ddpm.refine_latents_dpmpp(z0, t_start=30, num_steps=20, add_noise=True)
9
+
10
+
11
+
12
+ # src/dima/ddpmx.py
13
+ from __future__ import annotations
14
+
15
+ from typing import Any, Optional, Dict
16
+ import os
17
+ import json
18
+
19
+ import numpy as np
20
+ import jax
21
+ import jax.numpy as jnp
22
+ from jax import random
23
+
24
+ from flax import linen as nn
25
+ from flax.training import train_state
26
+ from flax import struct, serialization as flax_ser
27
+ import optax
28
+
29
+
30
+ # ---------------------------------------------------------------------
31
+ # Helpers (as in ddpmx.py)
32
+ # ---------------------------------------------------------------------
33
+ def _sigma_to_alpha_sigma_t(sigma: jnp.ndarray) -> tuple[jnp.ndarray, jnp.ndarray]:
34
+ """
35
+ EDM-style sigma parameterization:
36
+ alpha_t = 1 / sqrt(1 + sigma^2)
37
+ sigma_t = sigma * alpha_t
38
+ so that x = alpha_t * x0 + sigma_t * eps
39
+ """
40
+ alpha_t = 1.0 / jnp.sqrt(1.0 + sigma**2)
41
+ sigma_t = sigma * alpha_t
42
+ return alpha_t, sigma_t
43
+
44
+
45
+ def _make_lu_sigma_schedule(sigma_start: float, sigma_end: float, num_steps: int) -> np.ndarray:
46
+ """
47
+ "Lu" schedule uniform in lambda = -log(sigma).
48
+ """
49
+ sigma_start = float(max(sigma_start, 1e-12))
50
+ sigma_end = float(max(sigma_end, 1e-12))
51
+ lam_start = -np.log(sigma_start)
52
+ lam_end = -np.log(sigma_end)
53
+ lambdas = np.linspace(lam_start, lam_end, int(num_steps), dtype=np.float32)
54
+ sigmas = np.exp(-lambdas).astype(np.float32)
55
+ return sigmas
56
+
57
+
58
+ def cosine_schedule(T: int, s: float = 0.008):
59
+ """
60
+ Nichol & Dhariwal cosine schedule.
61
+ Returns alpha, beta, alpha_bar with shape (T,).
62
+ """
63
+ steps = jnp.arange(T + 1, dtype=jnp.float32)
64
+ f = jnp.cos(((steps / T + s) / (1.0 + s)) * jnp.pi / 2.0) ** 2
65
+ alpha_bar_all = f / f[0]
66
+ alpha_bar = alpha_bar_all[1:] # (T,)
67
+ alpha = alpha_bar / jnp.concatenate([jnp.array([1.0], dtype=jnp.float32), alpha_bar[:-1]])
68
+ beta = 1.0 - alpha
69
+ return alpha, beta, alpha_bar
70
+
71
+
72
+ def sinusoidal_embedding(t_idx: jnp.ndarray, dim: int) -> jnp.ndarray:
73
+ """
74
+ t_idx: (B,1) int32 or float32
75
+ returns: (B,dim)
76
+ """
77
+ if t_idx.ndim != 2 or t_idx.shape[1] != 1:
78
+ raise ValueError("t_idx must have shape (B,1)")
79
+ t = t_idx.astype(jnp.float32)
80
+ half = dim // 2
81
+ denom = float(max(half - 1, 1))
82
+ freqs = jnp.exp(-jnp.log(10_000.0) * jnp.arange(half, dtype=jnp.float32) / denom)
83
+ args = t * freqs
84
+ emb = jnp.concatenate([jnp.sin(args), jnp.cos(args)], axis=-1)
85
+ if dim % 2 == 1:
86
+ emb = jnp.pad(emb, ((0, 0), (0, 1)))
87
+ return emb
88
+
89
+
90
+ class EpsMLP(nn.Module):
91
+ """Simple MLP epsilon-predictor for DDPM in R^D."""
92
+ hidden: int
93
+ t_dim: int
94
+ data_dim: int
95
+
96
+ @nn.compact
97
+ def __call__(self, x: jnp.ndarray, t_idx: jnp.ndarray) -> jnp.ndarray:
98
+ t_emb = sinusoidal_embedding(t_idx, self.t_dim)
99
+ t_h = nn.Dense(self.hidden)(t_emb)
100
+ t_h = nn.gelu(t_h)
101
+
102
+ h = nn.Dense(self.hidden)(x)
103
+ h = nn.gelu(h + t_h)
104
+
105
+ t_h2 = nn.Dense(self.hidden)(t_h)
106
+ h = nn.Dense(self.hidden)(h)
107
+ h = nn.gelu(h + t_h2)
108
+
109
+ out = nn.Dense(self.data_dim)(h)
110
+ return out
111
+
112
+
113
+ @struct.dataclass
114
+ class TrainStateEMA(train_state.TrainState):
115
+ """Flax TrainState extended with EMA params."""
116
+ ema_params: Any = struct.field(pytree_node=True)
117
+
118
+ def apply_gradients(self, *, grads, ema_decay: float):
119
+ updates, new_opt_state = self.tx.update(grads, self.opt_state, self.params)
120
+ new_params = optax.apply_updates(self.params, updates)
121
+ new_ema = optax.incremental_update(new_params, self.ema_params, step_size=1.0 - ema_decay)
122
+ return self.replace(
123
+ step=self.step + 1,
124
+ params=new_params,
125
+ opt_state=new_opt_state,
126
+ ema_params=new_ema,
127
+ )
128
+
129
+
130
+ # ---------------------------------------------------------------------
131
+ # DDPMX with HF upload/download integrated
132
+ # ---------------------------------------------------------------------
133
+ class DDPM:
134
+ """
135
+ DDPM (+ fast DPM-Solver++(2M) sampler utilities) for D-dimensional latents.
136
+
137
+ Added persistence utilities:
138
+ - save_local / load_local
139
+ - upload_to_huggingface / download_from_huggingface
140
+
141
+ Serialization is done via flax.serialization.to_state_dict / from_state_dict
142
+ to avoid msgpack failures with non-serializable Python objects (e.g., tuples).
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ Z_iX: jnp.ndarray,
148
+ *,
149
+ T: int = 100,
150
+ hidden_dim: int = 128,
151
+ t_embed_dim: int = 64,
152
+ learning_rate: float = 1e-3,
153
+ n_iter: int = 20_000,
154
+ ema_decay: float = 0.999,
155
+ beta_max: float = 0.02,
156
+ batch_size: Optional[int] = None,
157
+ key: jax.Array = random.PRNGKey(0),
158
+ verbose_every: int = 0,
159
+ eps: float = 1e-5,
160
+ ):
161
+ Z_iX = jnp.asarray(Z_iX, dtype=jnp.float32)
162
+ if Z_iX.ndim != 2:
163
+ raise ValueError("Z_iX must be 2D (N,D).")
164
+
165
+ self.D = int(Z_iX.shape[1])
166
+ self.T = int(T)
167
+
168
+ # store config for checkpointing
169
+ self.hidden_dim = int(hidden_dim)
170
+ self.t_embed_dim = int(t_embed_dim)
171
+ self.learning_rate = float(learning_rate)
172
+ self.ema_decay = float(ema_decay)
173
+ self.beta_max = float(beta_max)
174
+ self.batch_size = batch_size
175
+ self.verbose_every = int(verbose_every)
176
+ self.eps = float(eps)
177
+
178
+ self.key = key
179
+
180
+ # VP schedule (cosine + clip)
181
+ alpha, beta, alpha_bar = cosine_schedule(self.T)
182
+ beta = jnp.minimum(beta, self.beta_max)
183
+ alpha = 1.0 - beta
184
+ alpha_bar = jnp.cumprod(alpha)
185
+
186
+ self.alpha_s = alpha.astype(jnp.float32)
187
+ self.beta_s = beta.astype(jnp.float32)
188
+ self.alpha_bar_s = alpha_bar.astype(jnp.float32)
189
+
190
+ # Precompute EDM-style "sigma_in" for DPM++ schedule interpolation
191
+ # sigma_in = sqrt((1 - a_bar) / a_bar)
192
+ self.sigma_in_train = jnp.sqrt(
193
+ jnp.clip(
194
+ (1.0 - self.alpha_bar_s) / jnp.clip(self.alpha_bar_s, self.eps, 1.0),
195
+ self.eps,
196
+ 1e12,
197
+ )
198
+ ).astype(jnp.float32)
199
+
200
+ # model + optimizer + EMA state
201
+ self.model = EpsMLP(hidden=self.hidden_dim, t_dim=self.t_embed_dim, data_dim=self.D)
202
+ params = self.model.init(
203
+ self.key,
204
+ jnp.zeros((1, self.D), dtype=jnp.float32),
205
+ jnp.zeros((1, 1), dtype=jnp.int32),
206
+ )["params"]
207
+
208
+ tx = optax.adam(self.learning_rate)
209
+ self.state = TrainStateEMA.create(
210
+ apply_fn=self.model.apply,
211
+ params=params,
212
+ tx=tx,
213
+ ema_params=params,
214
+ )
215
+
216
+ if int(n_iter) > 0:
217
+ self._train(Z_iX, int(n_iter))
218
+
219
+ # -------------------------
220
+ # Training
221
+ # -------------------------
222
+ @staticmethod
223
+ def _loss(params, apply_fn, x_t, t_idx, eps_true):
224
+ eps_pred = apply_fn({"params": params}, x_t, t_idx)
225
+ return jnp.mean((eps_pred - eps_true) ** 2)
226
+
227
+ @staticmethod
228
+ @jax.jit
229
+ def _train_step(
230
+ state: TrainStateEMA,
231
+ x0_batch: jnp.ndarray,
232
+ key: jax.Array,
233
+ alpha_bar_s: jnp.ndarray,
234
+ ema_decay: float,
235
+ eps: float,
236
+ ):
237
+ B = x0_batch.shape[0]
238
+ key, k_eps, k_t = random.split(key, 3)
239
+ eps_noise = random.normal(k_eps, shape=x0_batch.shape)
240
+ t_idx = random.randint(k_t, shape=(B, 1), minval=0, maxval=alpha_bar_s.shape[0])
241
+
242
+ a_bar_t = jnp.take(alpha_bar_s, t_idx.squeeze(-1))[:, None]
243
+ a_bar_t = jnp.clip(a_bar_t, eps, 1.0)
244
+ x_t = jnp.sqrt(a_bar_t) * x0_batch + jnp.sqrt(1.0 - a_bar_t) * eps_noise
245
+
246
+ def loss_fn(p):
247
+ return DDPM._loss(p, state.apply_fn, x_t, t_idx, eps_noise)
248
+
249
+ loss, grads = jax.value_and_grad(loss_fn)(state.params)
250
+ new_state = state.apply_gradients(grads=grads, ema_decay=ema_decay)
251
+ return new_state, loss, key
252
+
253
+ def _train(self, Z_iX: jnp.ndarray, n_iter: int):
254
+ N = int(Z_iX.shape[0])
255
+ bs = N if (self.batch_size is None) else min(int(self.batch_size), N)
256
+
257
+ for it in range(n_iter):
258
+ if bs >= N:
259
+ batch = Z_iX
260
+ else:
261
+ self.key, k_perm = random.split(self.key)
262
+ idx = random.permutation(k_perm, N)[:bs]
263
+ batch = Z_iX[idx]
264
+
265
+ self.state, loss, self.key = self._train_step(
266
+ self.state,
267
+ batch,
268
+ self.key,
269
+ self.alpha_bar_s,
270
+ self.ema_decay,
271
+ self.eps,
272
+ )
273
+
274
+ if self.verbose_every and (it % self.verbose_every == 0 or it == n_iter - 1):
275
+ print(f"iter {it:6d} loss {float(loss):.6f}", end="\r")
276
+
277
+ if self.verbose_every:
278
+ print("\ntraining complete.")
279
+
280
+ # -------------------------
281
+ # Standard DDPM refine/sample
282
+ # -------------------------
283
+ @staticmethod
284
+ def _posterior_variance(alpha_s, beta_s, alpha_bar_s, t):
285
+ a_bar_t = alpha_bar_s[t]
286
+ a_bar_prev = jnp.where(t > 0, alpha_bar_s[t - 1], jnp.array(1.0, dtype=alpha_bar_s.dtype))
287
+ return ((1.0 - a_bar_prev) / (1.0 - a_bar_t)) * beta_s[t]
288
+
289
+ @staticmethod
290
+ def _make_sampler_step(params_ema, apply_fn, alpha_s, beta_s, alpha_bar_s, eps: float):
291
+ @jax.jit
292
+ def step(carry, _):
293
+ key, t, x = carry
294
+ key, k = random.split(key)
295
+
296
+ alpha_t = jnp.clip(alpha_s[t], eps, 1.0)
297
+ a_bar_t = jnp.clip(alpha_bar_s[t], eps, 1.0)
298
+
299
+ sqrt_alpha = jnp.sqrt(alpha_t)
300
+ sqrt_one_minus = jnp.sqrt(jnp.clip(1.0 - a_bar_t, eps, 1.0))
301
+
302
+ B = x.shape[0]
303
+ t_batch = jnp.full((B, 1), t, dtype=jnp.int32)
304
+
305
+ eps_pred = apply_fn({"params": params_ema}, x, t_batch)
306
+ x0_hat = (x - sqrt_one_minus * eps_pred) / jnp.sqrt(a_bar_t)
307
+
308
+ a_bar_prev = jnp.where(t > 0, alpha_bar_s[t - 1], jnp.array(1.0, dtype=alpha_bar_s.dtype))
309
+ denom = jnp.clip(1.0 - a_bar_t, eps, 1.0)
310
+
311
+ coef1 = jnp.sqrt(jnp.clip(a_bar_prev, eps, 1.0)) * beta_s[t] / denom
312
+ coef2 = sqrt_alpha * (1.0 - a_bar_prev) / denom
313
+ mean = coef1 * x0_hat + coef2 * x
314
+
315
+ beta_tilde = DDPM._posterior_variance(alpha_s, beta_s, alpha_bar_s, t)
316
+ sigma = jnp.sqrt(jnp.clip(beta_tilde, 0.0, 1.0))
317
+
318
+ z = random.normal(k, x.shape)
319
+ z = jnp.where(t == 0, 0.0, z)
320
+ x_prev = mean + sigma * z
321
+
322
+ return (key, t - 1, x_prev), x_prev
323
+
324
+ return step
325
+
326
+ def refine_latents(
327
+ self,
328
+ z0: jnp.ndarray,
329
+ t_start: int = 10,
330
+ key: Optional[jax.Array] = None,
331
+ add_noise: bool = True,
332
+ ) -> jnp.ndarray:
333
+ z0 = jnp.asarray(z0, dtype=jnp.float32)
334
+ if z0.ndim != 2 or z0.shape[1] != self.D:
335
+ raise ValueError(f"z0 must have shape (B,{self.D}).")
336
+ if not (0 <= int(t_start) < self.T):
337
+ raise ValueError(f"t_start must be in [0, {self.T-1}]")
338
+ t_start = int(t_start)
339
+
340
+ if key is None:
341
+ self.key, key = random.split(self.key)
342
+ else:
343
+ self.key, _ = random.split(key)
344
+
345
+ key, k_eps = random.split(key)
346
+ eps_noise = random.normal(k_eps, z0.shape)
347
+ a_bar_t = jnp.clip(self.alpha_bar_s[t_start], self.eps, 1.0)
348
+
349
+ if add_noise:
350
+ z_t = jnp.sqrt(a_bar_t) * z0 + jnp.sqrt(1.0 - a_bar_t) * eps_noise
351
+ else:
352
+ z_t = z0
353
+
354
+ step = self._make_sampler_step(
355
+ self.state.ema_params,
356
+ self.state.apply_fn,
357
+ self.alpha_s,
358
+ self.beta_s,
359
+ self.alpha_bar_s,
360
+ self.eps,
361
+ )
362
+
363
+ (final_key, _, _), trace = jax.lax.scan(
364
+ step,
365
+ (key, t_start, z_t),
366
+ xs=None,
367
+ length=t_start + 1,
368
+ )
369
+ self.key = final_key
370
+ return trace[-1]
371
+
372
+ def __call__(
373
+ self,
374
+ z0: jnp.ndarray,
375
+ t_start: int = 10,
376
+ key: Optional[jax.Array] = None,
377
+ add_noise: bool = True,
378
+ ) -> jnp.ndarray:
379
+ return self.refine_latents(z0, t_start=t_start, key=key, add_noise=add_noise)
380
+
381
+ def reverse_from_T(self, x_T: jnp.ndarray) -> jnp.ndarray:
382
+ x_T = jnp.asarray(x_T, dtype=jnp.float32)
383
+ if x_T.ndim != 2 or x_T.shape[1] != self.D:
384
+ raise ValueError(f"x_T must have shape (B,{self.D}).")
385
+
386
+ step = self._make_sampler_step(
387
+ self.state.ema_params,
388
+ self.state.apply_fn,
389
+ self.alpha_s,
390
+ self.beta_s,
391
+ self.alpha_bar_s,
392
+ self.eps,
393
+ )
394
+
395
+ self.key, k0 = random.split(self.key)
396
+ (_, _, _), trace = jax.lax.scan(
397
+ step,
398
+ (k0, self.T - 1, x_T),
399
+ xs=None,
400
+ length=self.T,
401
+ )
402
+ return trace[-1]
403
+
404
+ def sample(self, N: int = 10_000) -> jnp.ndarray:
405
+ self.key, k = random.split(self.key)
406
+ noise = random.normal(k, (int(N), self.D)).astype(jnp.float32)
407
+ return self.reverse_from_T(noise)
408
+
409
+ # -------------------------
410
+ # DPM-Solver++(2M) schedule + sampler
411
+ # -------------------------
412
+ def _make_dpmpp_schedule(self, *, num_steps: int, t_start: int) -> tuple[jnp.ndarray, jnp.ndarray]:
413
+ """
414
+ Returns:
415
+ sigmas_in: (K+1,) float32 decreasing, last one is 0
416
+ t_cont: (K,) float32 continuous "time" indices for model calls
417
+ """
418
+ t_start = int(t_start)
419
+ if not (0 <= t_start < self.T):
420
+ raise ValueError(f"t_start must be in [0, {self.T-1}]")
421
+ if int(num_steps) < 1:
422
+ raise ValueError("num_steps must be >= 1")
423
+
424
+ sigma_start = float(self.sigma_in_train[t_start])
425
+ sigma_end = float(self.sigma_in_train[0])
426
+
427
+ sigmas_k = _make_lu_sigma_schedule(sigma_start, sigma_end, int(num_steps))
428
+ sigmas = np.concatenate([sigmas_k, np.array([0.0], np.float32)], axis=0)
429
+
430
+ sigma_train = np.array(self.sigma_in_train).astype(np.float32) # (T,)
431
+ log_sig_train = np.log(np.maximum(sigma_train, 1e-12))
432
+ t_train = np.arange(self.T, dtype=np.float32)
433
+
434
+ log_sig = np.log(np.maximum(sigmas[:-1], 1e-12))
435
+ t_cont = np.interp(log_sig, log_sig_train, t_train).astype(np.float32)
436
+
437
+ return jnp.array(sigmas, dtype=jnp.float32), jnp.array(t_cont, dtype=jnp.float32)
438
+
439
+ @staticmethod
440
+ @jax.jit
441
+ def _dpmpp_2m_midpoint_sample(
442
+ params_ema: Any,
443
+ apply_fn: Any,
444
+ x_start: jnp.ndarray, # (B,D)
445
+ sigmas_in: jnp.ndarray, # (K+1,)
446
+ t_cont: jnp.ndarray, # (K,)
447
+ eps: float,
448
+ ) -> jnp.ndarray:
449
+ """
450
+ DPM-Solver++ (2M, midpoint) sampler.
451
+ """
452
+ sigma_s = sigmas_in[:-1] # (K,)
453
+ sigma_t = sigmas_in[1:] # (K,)
454
+
455
+ alpha_s, sigma_s_t = _sigma_to_alpha_sigma_t(sigma_s)
456
+ alpha_t, sigma_t_t = _sigma_to_alpha_sigma_t(sigma_t)
457
+
458
+ lambda_s = jnp.log(alpha_s) - jnp.log(sigma_s_t)
459
+ lambda_t = jnp.log(alpha_t) - jnp.log(sigma_t_t)
460
+
461
+ K = t_cont.shape[0]
462
+ is_first = jnp.arange(K) == 0
463
+ is_last = jnp.arange(K) == (K - 1)
464
+
465
+ def step(carry, inp):
466
+ x, m_prev, lam_prev = carry
467
+ (a_s, s_s, a_t, s_t, lam_s_i, lam_t_i, t_i, first_i, last_i) = inp
468
+
469
+ B = x.shape[0]
470
+ t_batch = jnp.full((B, 1), t_i, dtype=jnp.float32)
471
+
472
+ eps_pred = apply_fn({"params": params_ema}, x, t_batch)
473
+
474
+ a_s_b = jnp.clip(a_s, eps, 1.0)
475
+ x0 = (x - s_s * eps_pred) / a_s_b
476
+
477
+ h = lam_t_i - lam_s_i
478
+ exp_neg_h = jnp.exp(-h)
479
+
480
+ # 1st-order
481
+ x_first = (s_t / s_s) * x - (a_t * (exp_neg_h - 1.0)) * x0
482
+
483
+ def do_second(_):
484
+ h0 = lam_s_i - lam_prev
485
+ r0 = h0 / jnp.clip(h, 1e-12)
486
+ D1 = (x0 - m_prev) / jnp.clip(r0, 1e-12)
487
+ x_second = (s_t / s_s) * x - (a_t * (exp_neg_h - 1.0)) * (x0 + 0.5 * D1)
488
+ return x_second
489
+
490
+ x_next = jax.lax.cond(first_i | last_i, lambda _: x_first, do_second, operand=None)
491
+ return (x_next, x0, lam_s_i), x_next
492
+
493
+ xs = (
494
+ alpha_s, sigma_s_t,
495
+ alpha_t, sigma_t_t,
496
+ lambda_s, lambda_t,
497
+ t_cont, is_first, is_last
498
+ )
499
+
500
+ x0_init = jnp.zeros_like(x_start)
501
+ lam_init = jnp.array(0.0, dtype=jnp.float32)
502
+
503
+ (x_final, _, _), _ = jax.lax.scan(step, (x_start, x0_init, lam_init), xs)
504
+ return x_final
505
+
506
+ def refine_latents_dpmpp(
507
+ self,
508
+ z0: jnp.ndarray,
509
+ *,
510
+ t_start: int = 10,
511
+ num_steps: int = 20,
512
+ key: Optional[jax.Array] = None,
513
+ add_noise: bool = True,
514
+ ) -> jnp.ndarray:
515
+ z0 = jnp.asarray(z0, dtype=jnp.float32)
516
+ if z0.ndim != 2 or z0.shape[1] != self.D:
517
+ raise ValueError(f"z0 must have shape (B,{self.D}).")
518
+ if not (0 <= int(t_start) < self.T):
519
+ raise ValueError(f"t_start must be in [0, {self.T-1}]")
520
+
521
+ if key is None:
522
+ self.key, key = random.split(self.key)
523
+ else:
524
+ self.key, _ = random.split(key)
525
+
526
+ # forward-noise to t_start
527
+ key, k_eps = random.split(key)
528
+ eps_noise = random.normal(k_eps, z0.shape)
529
+ a_bar = jnp.clip(self.alpha_bar_s[int(t_start)], self.eps, 1.0)
530
+
531
+ if add_noise:
532
+ x_start = jnp.sqrt(a_bar) * z0 + jnp.sqrt(1.0 - a_bar) * eps_noise
533
+ else:
534
+ x_start = z0
535
+
536
+ sigmas_in, t_cont = self._make_dpmpp_schedule(num_steps=int(num_steps), t_start=int(t_start))
537
+ x_final = self._dpmpp_2m_midpoint_sample(
538
+ self.state.ema_params,
539
+ self.state.apply_fn,
540
+ x_start,
541
+ sigmas_in,
542
+ t_cont,
543
+ self.eps,
544
+ )
545
+ return x_final
546
+
547
+ def reverse_from_T_dpmpp(self, x_T: jnp.ndarray, *, num_steps: int = 20) -> jnp.ndarray:
548
+ x_T = jnp.asarray(x_T, dtype=jnp.float32)
549
+ if x_T.ndim != 2 or x_T.shape[1] != self.D:
550
+ raise ValueError(f"x_T must have shape (B,{self.D}).")
551
+
552
+ sigmas_in, t_cont = self._make_dpmpp_schedule(num_steps=int(num_steps), t_start=self.T - 1)
553
+ return self._dpmpp_2m_midpoint_sample(
554
+ self.state.ema_params,
555
+ self.state.apply_fn,
556
+ x_T,
557
+ sigmas_in,
558
+ t_cont,
559
+ self.eps,
560
+ )
561
+
562
+ def sample_dpmpp(self, N: int = 10_000, *, num_steps: int = 20) -> jnp.ndarray:
563
+ self.key, k = random.split(self.key)
564
+ x_T = random.normal(k, (int(N), self.D)).astype(jnp.float32)
565
+ return self.reverse_from_T_dpmpp(x_T, num_steps=int(num_steps))
566
+
567
+ # -----------------------------------------------------------------
568
+ # Persistence: state_dict / save_local / load_local
569
+ # -----------------------------------------------------------------
570
+ def _config_dict(self) -> Dict[str, Any]:
571
+ return {
572
+ "class_name": "DDPMX",
573
+ "T": int(self.T),
574
+ "D": int(self.D),
575
+ "hidden_dim": int(self.hidden_dim),
576
+ "t_embed_dim": int(self.t_embed_dim),
577
+ "learning_rate": float(self.learning_rate),
578
+ "ema_decay": float(self.ema_decay),
579
+ "beta_max": float(self.beta_max),
580
+ "batch_size": None if self.batch_size is None else int(self.batch_size),
581
+ "eps": float(self.eps),
582
+ "key": np.array(self.key).tolist(),
583
+ }
584
+
585
+ def save_local(self, weights_file: str = "ddpmx_weights.msgpack", config_file: str = "ddpmx_config.json") -> None:
586
+ """
587
+ Saves:
588
+ - config_file: JSON with hyperparams + PRNG key
589
+ - weights_file: msgpack with flax state_dict of TrainStateEMA
590
+ """
591
+ cfg = self._config_dict()
592
+ with open(config_file, "w", encoding="utf-8") as f:
593
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
594
+
595
+ # Robust serialization (avoid msgpack tuple errors)
596
+ state_sd = flax_ser.to_state_dict(self.state)
597
+ blob = flax_ser.msgpack_serialize(state_sd)
598
+ with open(weights_file, "wb") as f:
599
+ f.write(blob)
600
+
601
+ @classmethod
602
+ def load_local(
603
+ cls,
604
+ weights_file: str,
605
+ config_file: str,
606
+ *,
607
+ Z_iX: Optional[jnp.ndarray] = None,
608
+ ) -> "DDPM":
609
+ """
610
+ Reconstructs a DDPMX instance from local files.
611
+
612
+ Z_iX is only used to provide shape (N,D) for initialization; training is skipped.
613
+ If Z_iX is None, a dummy array of shape (1,D) is created.
614
+ """
615
+ with open(config_file, "r", encoding="utf-8") as f:
616
+ cfg = json.load(f)
617
+
618
+ D = int(cfg["D"])
619
+ if Z_iX is None:
620
+ Z_iX = jnp.zeros((1, D), dtype=jnp.float32)
621
+
622
+ # Build a fresh instance with the same architecture, skip training
623
+ obj = cls(
624
+ Z_iX,
625
+ T=int(cfg["T"]),
626
+ hidden_dim=int(cfg["hidden_dim"]),
627
+ t_embed_dim=int(cfg["t_embed_dim"]),
628
+ learning_rate=float(cfg["learning_rate"]),
629
+ n_iter=0,
630
+ ema_decay=float(cfg["ema_decay"]),
631
+ beta_max=float(cfg["beta_max"]),
632
+ batch_size=cfg["batch_size"],
633
+ key=random.PRNGKey(0),
634
+ verbose_every=0,
635
+ eps=float(cfg["eps"]),
636
+ )
637
+
638
+ with open(weights_file, "rb") as f:
639
+ state_sd = flax_ser.msgpack_restore(f.read())
640
+
641
+ obj.state = flax_ser.from_state_dict(obj.state, state_sd)
642
+
643
+ key_list = cfg.get("key", None)
644
+ if key_list is not None:
645
+ obj.key = jnp.array(key_list, dtype=jnp.uint32)
646
+
647
+ return obj
648
+
649
+ # -----------------------------------------------------------------
650
+ # Hugging Face Hub: upload / download
651
+ # -----------------------------------------------------------------
652
+ def upload_to_huggingface(
653
+ self,
654
+ repo_id: str,
655
+ *,
656
+ token: Optional[str] = None,
657
+ weights_file: str = "ddpmx_weights.msgpack",
658
+ config_file: str = "ddpmx_config.json",
659
+ repo_type: str = "model",
660
+ revision: Optional[str] = None,
661
+ ) -> Dict[str, str]:
662
+ """
663
+ Saves locally and uploads (weights_file, config_file) to Hugging Face Hub.
664
+ """
665
+ try:
666
+ from huggingface_hub import create_repo, upload_file
667
+ except Exception as e:
668
+ raise RuntimeError(
669
+ "huggingface_hub not installed. Install it (e.g., `pip install huggingface_hub`)."
670
+ ) from e
671
+
672
+ self.save_local(weights_file=weights_file, config_file=config_file)
673
+
674
+ create_repo(repo_id, token=token, repo_type=repo_type, exist_ok=True)
675
+
676
+ w_name = os.path.basename(weights_file)
677
+ c_name = os.path.basename(config_file)
678
+
679
+ upload_file(
680
+ path_or_fileobj=weights_file,
681
+ path_in_repo=w_name,
682
+ repo_id=repo_id,
683
+ repo_type=repo_type,
684
+ token=token,
685
+ revision=revision,
686
+ )
687
+ upload_file(
688
+ path_or_fileobj=config_file,
689
+ path_in_repo=c_name,
690
+ repo_id=repo_id,
691
+ repo_type=repo_type,
692
+ token=token,
693
+ revision=revision,
694
+ )
695
+
696
+ return {"repo_id": repo_id, "weights": w_name, "config": c_name}
697
+
698
+ @classmethod
699
+ def download_from_huggingface(
700
+ cls,
701
+ repo_id: str,
702
+ *,
703
+ token: Optional[str] = None,
704
+ weights_file: str = "ddpmx_weights.msgpack",
705
+ config_file: str = "ddpmx_config.json",
706
+ repo_type: str = "model",
707
+ revision: Optional[str] = None,
708
+ cache_dir: Optional[str] = None,
709
+ Z_iX: Optional[jnp.ndarray] = None,
710
+ ) -> "DDPM":
711
+ """
712
+ Downloads (weights_file, config_file) from Hugging Face Hub and reconstructs the class.
713
+ """
714
+ try:
715
+ from huggingface_hub import hf_hub_download
716
+ except Exception as e:
717
+ raise RuntimeError(
718
+ "huggingface_hub not installed. Install it (e.g., `pip install huggingface_hub`)."
719
+ ) from e
720
+
721
+ w_name = os.path.basename(weights_file)
722
+ c_name = os.path.basename(config_file)
723
+
724
+ w_path = hf_hub_download(
725
+ repo_id=repo_id,
726
+ filename=w_name,
727
+ repo_type=repo_type,
728
+ token=token,
729
+ revision=revision,
730
+ cache_dir=cache_dir,
731
+ )
732
+ c_path = hf_hub_download(
733
+ repo_id=repo_id,
734
+ filename=c_name,
735
+ repo_type=repo_type,
736
+ token=token,
737
+ revision=revision,
738
+ cache_dir=cache_dir,
739
+ )
740
+
741
+ return cls.load_local(w_path, c_path, Z_iX=Z_iX)
742
+
743
+
744
+
745
+ __all__ = ["DDPM", "EpsMLP", "cosine_schedule", "sinusoidal_embedding"]
dima.py ADDED
@@ -0,0 +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"]
dmap.py ADDED
@@ -0,0 +1,645 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/dima/dmap.py
2
+ from __future__ import annotations
3
+ import json
4
+ from typing import Any, Dict, Optional, Tuple, Union
5
+
6
+ import numpy as np
7
+ import scipy.sparse as sp
8
+ from scipy.sparse.linalg import eigsh, LinearOperator, lobpcg
9
+ from huggingface_hub import HfApi, HfFolder, upload_file, hf_hub_download
10
+
11
+ from .ann import ANNBackend, make_ann
12
+ from .utils import median_eps_from_knn_d2
13
+
14
+
15
+ def k_ideal(d: int, N: int) -> int:
16
+ """
17
+ Heuristic for kNN graph size in diffusion maps.
18
+ Stable default: grows slowly with N and linearly with d.
19
+ """
20
+ d = int(max(1, d))
21
+ N = int(max(2, N))
22
+ k = int(np.ceil(2.0 * d * np.log2(N)))
23
+ return int(min(max(8, k), N - 1))
24
+
25
+
26
+ def _sqdist_ab(A: np.ndarray, B: np.ndarray) -> np.ndarray:
27
+ """
28
+ Squared Euclidean distances between rows:
29
+ A: (a,d), B: (b,d) -> D2: (a,b)
30
+ """
31
+ A = np.asarray(A, dtype=np.float64)
32
+ B = np.asarray(B, dtype=np.float64)
33
+ A2 = np.sum(A * A, axis=1, keepdims=True)
34
+ B2 = np.sum(B * B, axis=1, keepdims=True).T
35
+ G = A @ B.T
36
+ return np.maximum(A2 + B2 - 2.0 * G, 0.0)
37
+
38
+
39
+ class DMAP:
40
+ """
41
+ Diffusion Maps encoder with Nyström out-of-sample extension.
42
+
43
+ Notation (arrays named by indices):
44
+ R_iX: reference ambient data
45
+ K_ij: kernel on graph edges (sparse CSR)
46
+ q_i = Σ_j K_ij
47
+ qα_i = (q_i)^α
48
+ Kα_ij = K_ij / (qα_i qα_j)
49
+ d_i = Σ_j Kα_ij
50
+ A_ij = Kα_ij / sqrt(d_i d_j) (symmetric)
51
+ eigsh(A) -> λ_x, u_ix
52
+ ψ_ix = u_ix / sqrt(d_i)
53
+ R_ix = (λ_x)^t ψ_ix
54
+
55
+ Nyström OOS for novel ambient R_aX:
56
+ K_ai = exp(-β * D2_ai / ε)
57
+ q_a = Σ_i K_ai, qα_a = (q_a)^α
58
+ Kα_ai = K_ai / (qα_a qα_i)
59
+ d_a = Σ_i Kα_ai
60
+ P_ai = Kα_ai / d_a
61
+ R_ax = Σ_i P_ai * (R_ix / λ_x)
62
+
63
+ This implementation uses a kNN graph, sparse eigensolver, and
64
+ provides optional dense refinement via streaming LOBPCG.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ R_iX: np.ndarray,
70
+ *,
71
+ d: int = 32,
72
+ beta: float = 1.0,
73
+ alpha: float = 0.0,
74
+ t: float = 1.0,
75
+ k: Optional[int] = None,
76
+ eps: Optional[float] = None,
77
+ eps_use_kth: bool = True,
78
+ eps_mul: float = 1.0,
79
+ drop_trivial: bool = True,
80
+ seed: int = 0,
81
+ sym: str = "max",
82
+ dtype: Any = np.float32,
83
+ ann_backend: ANNBackend = "auto",
84
+ ann_params: Optional[Dict[str, Any]] = None,
85
+ n_jobs: int = -1,
86
+ # dense refinement (O(N^2) compute, streaming memory)
87
+ refine_dense: bool = False,
88
+ stream_block: int = 4096,
89
+ lobpcg_maxiter: int = 3,
90
+ lobpcg_tol: float = 1e-6,
91
+ use_symmetry: bool = True,
92
+ # allow unicode kwargs (β, α, ε, ε_mul, ε_use_kth, ...)
93
+ **kwargs: Any,
94
+ ):
95
+ # ---- map unicode kwargs -> ascii ----
96
+ if "β" in kwargs:
97
+ beta = kwargs.pop("β")
98
+ if "α" in kwargs:
99
+ alpha = kwargs.pop("α")
100
+ if "ε" in kwargs:
101
+ eps = kwargs.pop("ε")
102
+ if "ε_use_kth" in kwargs:
103
+ eps_use_kth = kwargs.pop("ε_use_kth")
104
+ if "ε_mul" in kwargs:
105
+ eps_mul = kwargs.pop("ε_mul")
106
+ if "sym" in kwargs:
107
+ sym = kwargs.pop("sym")
108
+ if kwargs:
109
+ raise TypeError(f"Unexpected kwargs: {sorted(kwargs.keys())}")
110
+
111
+ self.d = int(d)
112
+ self.k = int(k_ideal(self.d, int(np.asarray(R_iX).shape[0])) if k is None else int(k))
113
+
114
+ self.beta = float(beta)
115
+ self.alpha = float(alpha)
116
+ self.t = float(t)
117
+ self.drop_trivial = bool(drop_trivial)
118
+ self.seed = int(seed)
119
+ self.sym = str(sym)
120
+ self.dtype = dtype
121
+
122
+ # unicode aliases (so older code + packers can find them)
123
+ self.β = self.beta
124
+ self.α = self.alpha
125
+
126
+ self.refine_dense = bool(refine_dense)
127
+ self.stream_block = int(stream_block)
128
+ self.lobpcg_maxiter = int(lobpcg_maxiter)
129
+ self.lobpcg_tol = float(lobpcg_tol)
130
+ self.use_symmetry = bool(use_symmetry)
131
+
132
+ rng = np.random.default_rng(self.seed)
133
+
134
+ # reference data
135
+ R_iX = np.asarray(R_iX)
136
+ if R_iX.ndim != 2:
137
+ raise ValueError(f"R_iX must be 2D array, got shape {R_iX.shape}")
138
+ self.R_iX = np.ascontiguousarray(R_iX.astype(self.dtype, copy=False))
139
+
140
+ Nref, D = self.R_iX.shape
141
+ self.Nref = int(Nref)
142
+ self.D = int(D)
143
+
144
+ if not (2 <= self.k < Nref):
145
+ raise ValueError(f"Invalid k={self.k} for Nref={Nref}")
146
+
147
+ # ANN index (ambient)
148
+ self.ann, self.ann_backend = make_ann(ann_backend, ann_params=ann_params, n_jobs=n_jobs)
149
+ self.ann.build(self.R_iX)
150
+
151
+ # kNN of reference points
152
+ j_iK, D2_iK = self.ann.search(self.R_iX, self.k) # (Nref,k)
153
+
154
+ # eps selection
155
+ if eps is None:
156
+ self.eps = float(median_eps_from_knn_d2(D2_iK, use_kth=bool(eps_use_kth)) * float(eps_mul))
157
+ else:
158
+ self.eps = float(eps)
159
+ self.ε = self.eps # unicode alias
160
+
161
+ # -------------------------
162
+ # 1) Build sparse graph and warm-start eigensolver
163
+ # -------------------------
164
+ K_iK = np.exp(-self.beta * (D2_iK / self.eps)).astype(np.float64, copy=False)
165
+
166
+ indptr = (np.arange(Nref + 1, dtype=np.int64) * self.k)
167
+ indices = j_iK.reshape(-1).astype(np.int64, copy=False)
168
+ data = K_iK.reshape(-1)
169
+
170
+ K_ij = sp.csr_matrix((data, indices, indptr), shape=(Nref, Nref), dtype=np.float64)
171
+
172
+ # symmetrize
173
+ if self.sym == "max":
174
+ K_ij = K_ij.maximum(K_ij.T)
175
+ elif self.sym == "mean":
176
+ K_ij = (K_ij + K_ij.T) * 0.5
177
+ else:
178
+ raise ValueError(f"Unknown sym={self.sym!r}")
179
+
180
+ # degrees q_i and qalpha_i (warm)
181
+ q_i_warm = np.asarray(K_ij.sum(axis=1)).ravel()
182
+ q_i_warm = np.maximum(q_i_warm, 1e-30)
183
+ qalpha_i_warm = np.maximum(np.power(q_i_warm, self.alpha), 1e-30)
184
+
185
+ Qinv = sp.diags(1.0 / qalpha_i_warm, format="csr")
186
+ Kalpha_ij = Qinv @ K_ij @ Qinv
187
+
188
+ d_i_warm = np.asarray(Kalpha_ij.sum(axis=1)).ravel()
189
+ d_i_warm = np.maximum(d_i_warm, 1e-30)
190
+
191
+ Dinv_sqrt = sp.diags(1.0 / np.sqrt(d_i_warm), format="csr")
192
+ A_ij = Dinv_sqrt @ Kalpha_ij @ Dinv_sqrt
193
+
194
+ nev = self.d + (1 if self.drop_trivial else 0)
195
+ v0 = rng.normal(size=Nref).astype(np.float64)
196
+ lam0, u0 = eigsh(A_ij, k=nev, which="LA", v0=v0)
197
+
198
+ ord0 = np.argsort(lam0)[::-1]
199
+ lam0 = lam0[ord0]
200
+ u0 = u0[:, ord0]
201
+
202
+ # LOBPCG warm-start block (orthonormalize)
203
+ X0, _ = np.linalg.qr(u0.astype(np.float64, copy=False))
204
+
205
+ # -------------------------
206
+ # 2) Optional refinement: streaming dense LOBPCG on dense PSD operator
207
+ # -------------------------
208
+ if self.refine_dense:
209
+ try:
210
+ # Rebuild dense operator using streaming blocks without allocating full N^2 matrix
211
+ X = self.R_iX.astype(np.float64, copy=False)
212
+ X2 = np.sum(X * X, axis=1, keepdims=True)
213
+
214
+ # compute q_i, qalpha_i, d_i for dense kernel operator:
215
+ # q_i = Σ_j K_ij, K_ij = exp(-β*||xi-xj||^2 / eps)
216
+ # Kα_ij = K_ij/(qα_i qα_j)
217
+ # d_i = Σ_j Kα_ij
218
+
219
+ # Step A: q_i
220
+ ones = np.ones((Nref, 1), dtype=np.float64)
221
+ q_i = self._K_matmat_dense(X, X2, ones).ravel()
222
+ q_i = np.maximum(q_i, 1e-30)
223
+ qalpha_i = np.maximum(np.power(q_i, self.alpha), 1e-30)
224
+
225
+ # Step B: d_i
226
+ inv_qalpha = 1.0 / qalpha_i
227
+ V = (ones * inv_qalpha[:, None]) # (N,1) actually N x 1
228
+ tmp = self._K_matmat_dense(X, X2, V).ravel() # Σ_j K_ij * inv_qalpha_j
229
+ d_i = inv_qalpha * tmp # Σ_j K_ij/(qα_i qα_j)
230
+ d_i = np.maximum(d_i, 1e-30)
231
+
232
+ # Build symmetric operator A(v) = D^{-1/2} Q^{-1} K Q^{-1} D^{-1/2} v
233
+ inv_sqrt_d = 1.0 / np.sqrt(d_i)
234
+ inv_qalpha = 1.0 / qalpha_i
235
+
236
+ def matvec(v: np.ndarray) -> np.ndarray:
237
+ v = v.astype(np.float64, copy=False).reshape(-1, 1) # (N,1)
238
+ w = v * inv_sqrt_d[:, None]
239
+ w = w * inv_qalpha[:, None]
240
+ y = self._K_matmat_dense(X, X2, w)
241
+ y = y * inv_qalpha[:, None]
242
+ y = y * inv_sqrt_d[:, None]
243
+ return y.ravel()
244
+
245
+ Aop = LinearOperator((Nref, Nref), matvec=matvec, dtype=np.float64)
246
+
247
+ # LOBPCG refine using warm-start X0
248
+ lam, u = lobpcg(Aop, X0, largest=True, maxiter=self.lobpcg_maxiter, tol=self.lobpcg_tol)
249
+
250
+ ord1 = np.argsort(lam)[::-1]
251
+ lam = lam[ord1]
252
+ u = u[:, ord1]
253
+
254
+ self.q_i = q_i.astype(np.float64, copy=False)
255
+ self.qalpha_i = qalpha_i.astype(np.float64, copy=False)
256
+ self.d_i = d_i.astype(np.float64, copy=False)
257
+ except Exception:
258
+ # fallback to warm start if refinement fails
259
+ lam, u = lam0, u0
260
+ self.q_i = q_i_warm.astype(np.float64, copy=False)
261
+ self.qalpha_i = qalpha_i_warm.astype(np.float64, copy=False)
262
+ self.d_i = d_i_warm.astype(np.float64, copy=False)
263
+ else:
264
+ lam, u = lam0, u0
265
+ self.q_i = q_i_warm.astype(np.float64, copy=False)
266
+ self.qalpha_i = qalpha_i_warm.astype(np.float64, copy=False)
267
+ self.d_i = d_i_warm.astype(np.float64, copy=False)
268
+
269
+ # provide unicode aliases for packers / older code
270
+ self.qα_i = self.qalpha_i
271
+ self.λ_x = lam.astype(np.float64, copy=False)
272
+
273
+ # psi and drop trivial
274
+ psi = u / np.sqrt(self.d_i)[:, None]
275
+
276
+ if self.drop_trivial:
277
+ lam = lam[1:]
278
+ psi = psi[:, 1:]
279
+ u = u[:, 1:]
280
+
281
+ # diffusion coords
282
+ R_ix = psi * (lam ** self.t)[None, :]
283
+
284
+ # store
285
+ self.λ_x = lam.astype(np.float64, copy=False) # (d,)
286
+ self.u_ix = u.astype(np.float64, copy=False) # (Nref,d)
287
+ self.ψ_ix = psi.astype(np.float64, copy=False) # (Nref,d)
288
+ self.R_ix = R_ix.astype(np.float64, copy=False) # (Nref,d)
289
+ self.π_i = (self.d_i / self.d_i.sum()).astype(np.float64, copy=False)
290
+
291
+ # for Nyström: R_ix / λ_x
292
+ self.R_over_λ_ix = (self.R_ix / self.λ_x[None, :]).astype(np.float64, copy=False)
293
+
294
+ # --------- Dense kernel streaming utilities ----------
295
+
296
+ def _rbf_block(self, Xi: np.ndarray, Xj: np.ndarray, X2i: np.ndarray, X2j: np.ndarray) -> np.ndarray:
297
+ # ||xi-xj||^2 = xi^2 + xj^2 - 2 xi·xj
298
+ G = Xi @ Xj.T
299
+ D2 = np.maximum(X2i + X2j.T - 2.0 * G, 0.0)
300
+ return np.exp(-self.beta * (D2 / self.eps))
301
+
302
+ def _K_matmat_dense(self, X: np.ndarray, X2: np.ndarray, V: np.ndarray) -> np.ndarray:
303
+ """
304
+ Compute (dense) K @ V without forming K explicitly, using streaming blocks.
305
+ X: (N,D), X2: (N,1), V: (N,m) -> out: (N,m)
306
+ """
307
+ X = np.asarray(X, dtype=np.float64)
308
+ X2 = np.asarray(X2, dtype=np.float64)
309
+ V = np.asarray(V, dtype=np.float64)
310
+ N = X.shape[0]
311
+ bs = self.stream_block
312
+
313
+ out = np.zeros((N, V.shape[1]), dtype=np.float64)
314
+
315
+ if not self.use_symmetry:
316
+ for i0 in range(0, N, bs):
317
+ i1 = min(N, i0 + bs)
318
+ Xi = X[i0:i1]
319
+ X2i = X2[i0:i1]
320
+ acc = np.zeros((i1 - i0, V.shape[1]), dtype=np.float64)
321
+ for j0 in range(0, N, bs):
322
+ j1 = min(N, j0 + bs)
323
+ Xj = X[j0:j1]
324
+ X2j = X2[j0:j1]
325
+ Kij = self._rbf_block(Xi, Xj, X2i, X2j)
326
+ acc += Kij @ V[j0:j1]
327
+ out[i0:i1] = acc
328
+ return out
329
+
330
+ # symmetric tiling
331
+ for i0 in range(0, N, bs):
332
+ i1 = min(N, i0 + bs)
333
+ Xi = X[i0:i1]
334
+ X2i = X2[i0:i1]
335
+ Vi = V[i0:i1]
336
+
337
+ # diagonal tile
338
+ Kii = self._rbf_block(Xi, Xi, X2i, X2i)
339
+ out[i0:i1] += Kii @ Vi
340
+
341
+ for j0 in range(i1, N, bs):
342
+ j1 = min(N, j0 + bs)
343
+ Xj = X[j0:j1]
344
+ X2j = X2[j0:j1]
345
+ Vj = V[j0:j1]
346
+
347
+ Kij = self._rbf_block(Xi, Xj, X2i, X2j)
348
+ out[i0:i1] += Kij @ Vj
349
+ out[j0:j1] += Kij.T @ Vi
350
+
351
+ return out
352
+
353
+ # --------- Nyström embedding ----------
354
+
355
+ def __call__(self, R_aX: Union[np.ndarray, list], *, batch_size: Optional[int] = None) -> np.ndarray:
356
+ R_aX = np.asarray(R_aX)
357
+ single = (R_aX.ndim == 1)
358
+ if single:
359
+ R_aX = R_aX[None, :]
360
+
361
+ R_aX = np.ascontiguousarray(R_aX.astype(self.dtype, copy=False))
362
+
363
+ if batch_size is None:
364
+ R_ax = self._embed(R_aX)
365
+ else:
366
+ bs = int(batch_size)
367
+ out = []
368
+ for s in range(0, R_aX.shape[0], bs):
369
+ out.append(self._embed(R_aX[s:s + bs]))
370
+ R_ax = np.vstack(out)
371
+
372
+ return R_ax[0] if single else R_ax
373
+
374
+ def _embed(self, R_aX: np.ndarray) -> np.ndarray:
375
+ # kNN for novel points
376
+ j_aK, D2_aK = self.ann.search(R_aX, self.k) # (a,k)
377
+
378
+ K_ai = np.exp(-self.beta * (D2_aK.astype(np.float64) / self.eps)) # (a,k)
379
+
380
+ q_a = np.maximum(K_ai.sum(axis=1), 1e-30)
381
+ qalpha_a = np.maximum(np.power(q_a, self.alpha), 1e-30)
382
+
383
+ qalpha_i = np.maximum(self.qalpha_i[j_aK], 1e-30)
384
+ Kalpha_ai = K_ai / (qalpha_a[:, None] * qalpha_i)
385
+
386
+ d_a = np.maximum(Kalpha_ai.sum(axis=1), 1e-30)
387
+ P_ai = Kalpha_ai / d_a[:, None]
388
+
389
+ R_over = self.R_over_λ_ix[j_aK, :] # (a,k,d)
390
+ R_ax = (P_ai[:, :, None] * R_over).sum(axis=1)
391
+ return R_ax
392
+
393
+ # -------------------------
394
+ # Serialization helpers
395
+ # -------------------------
396
+
397
+ @staticmethod
398
+ def _np_dtype_str(x) -> str:
399
+ """Best-effort numpy dtype string for serialization."""
400
+ try:
401
+ return str(np.dtype(x))
402
+ except Exception:
403
+ return "float32"
404
+
405
+ def state_dict(self) -> Dict[str, Any]:
406
+ """
407
+ Minimal, inference-sufficient state for Nyström out-of-sample embedding.
408
+
409
+ The resulting dict is intentionally compatible with `FrozenDMAP` in `dima.py`
410
+ (keys: R_iX, qalpha_i, R_over_lam_ix, k, beta, alpha, eps, dtype).
411
+ """
412
+ R_over = getattr(self, "R_over_λ_ix", None)
413
+ if R_over is None:
414
+ # Backward/alternate name safety
415
+ R_over = getattr(self, "R_over_lam_ix", None)
416
+
417
+ if R_over is None:
418
+ raise AttributeError("DMAP object has no Nyström matrix `R_over_λ_ix` (did training finish?).")
419
+
420
+ state: Dict[str, Any] = dict(
421
+ k=int(self.k),
422
+ beta=float(self.beta),
423
+ alpha=float(self.alpha),
424
+ eps=float(self.eps),
425
+ dtype=self._np_dtype_str(getattr(self, "dtype", "float32")),
426
+ # arrays
427
+ R_iX=np.asarray(self.R_iX),
428
+ qalpha_i=np.asarray(self.qalpha_i, dtype=np.float64),
429
+ R_over_lam_ix=np.asarray(R_over, dtype=np.float64),
430
+ # small meta (optional)
431
+ meta=dict(
432
+ Nref=int(np.asarray(self.R_iX).shape[0]),
433
+ D=int(np.asarray(self.R_iX).shape[1]),
434
+ d=int(np.asarray(R_over).shape[1]),
435
+ t=float(getattr(self, "t", 1.0)),
436
+ drop_trivial=bool(getattr(self, "drop_trivial", False)),
437
+ sym=str(getattr(self, "sym", "max")),
438
+ ),
439
+ )
440
+ return state
441
+
442
+ def save_local(self, weights_file: str = "dmap.msgpack", config_file: Optional[str] = "dmap_config.json") -> None:
443
+ """
444
+ Save DMAP weights (and optionally a lightweight JSON config) locally.
445
+
446
+ - `weights_file`: binary msgpack with arrays (via flax.serialization)
447
+ - `config_file`: JSON with scalar metadata only (no large arrays)
448
+ """
449
+ # local import keeps `dima` usable without flax unless you call this
450
+ from flax import serialization as flax_ser # type: ignore
451
+ import json as _json
452
+
453
+ state = self.state_dict()
454
+ blob = flax_ser.msgpack_serialize(state)
455
+
456
+ with open(weights_file, "wb") as f:
457
+ f.write(blob)
458
+
459
+ if config_file is not None:
460
+ cfg = dict(
461
+ k=int(state["k"]),
462
+ beta=float(state["beta"]),
463
+ alpha=float(state["alpha"]),
464
+ eps=float(state["eps"]),
465
+ dtype=str(state.get("dtype", "float32")),
466
+ **(state.get("meta", {}) or {}),
467
+ )
468
+ with open(config_file, "w") as f:
469
+ _json.dump(cfg, f, indent=2)
470
+
471
+ return None
472
+
473
+ @classmethod
474
+ def from_state(
475
+ cls,
476
+ state: Dict[str, Any],
477
+ *,
478
+ ann_backend: ANNBackend = "auto",
479
+ ann_params: Optional[Dict[str, Any]] = None,
480
+ n_jobs: int = -1,
481
+ ) -> "DMAP":
482
+ """
483
+ Rehydrate a DMAP object from `state_dict()` output WITHOUT retraining.
484
+
485
+ This bypasses `__init__` and rebuilds only what is needed for out-of-sample Nyström embedding:
486
+ reference points, normalization factors, Nyström matrix, and ANN index.
487
+ """
488
+ # allow passing a full DIMA state dict
489
+ if "encoder" in state and isinstance(state["encoder"], dict):
490
+ state = state["encoder"] # type: ignore[assignment]
491
+
492
+ # tolerate unicode key variants
493
+ k = int(state["k"])
494
+ beta = float(state.get("beta", state.get("β")))
495
+ alpha = float(state.get("alpha", state.get("α")))
496
+ eps = float(state.get("eps", state.get("ε")))
497
+
498
+ dtype = np.dtype(state.get("dtype", "float32"))
499
+ R_iX = np.ascontiguousarray(np.asarray(state["R_iX"]).astype(dtype, copy=False))
500
+ qalpha_i = np.asarray(state.get("qalpha_i", state.get("qα_i")), dtype=np.float64)
501
+
502
+ R_over = state.get("R_over_lam_ix", state.get("R_over_λ_ix"))
503
+ if R_over is None:
504
+ raise KeyError("State must contain 'R_over_lam_ix' (or 'R_over_λ_ix').")
505
+ R_over = np.asarray(R_over, dtype=np.float64)
506
+
507
+ obj = cls.__new__(cls) # bypass __init__
508
+
509
+ # required for embedding
510
+ obj.k = k
511
+ obj.beta = beta
512
+ obj.alpha = alpha
513
+ obj.eps = eps
514
+ obj.dtype = dtype
515
+ obj.R_iX = R_iX
516
+ obj.qalpha_i = qalpha_i
517
+ obj.qα_i = obj.qalpha_i # alias
518
+ obj.R_over_λ_ix = R_over
519
+ obj.R_over_lam_ix = obj.R_over_λ_ix # alias for external code
520
+ obj.β = obj.beta
521
+ obj.α = obj.alpha
522
+ obj.ε = obj.eps
523
+
524
+ # optional metadata for introspection
525
+ meta = state.get("meta", {}) if isinstance(state.get("meta", {}), dict) else {}
526
+ obj.d = int(meta.get("d", R_over.shape[1]))
527
+ obj.t = float(meta.get("t", 1.0))
528
+ obj.drop_trivial = bool(meta.get("drop_trivial", False))
529
+ obj.sym = str(meta.get("sym", "max"))
530
+
531
+ # rebuild ANN index
532
+ obj.ann, obj.ann_backend = make_ann(ann_backend, ann_params=ann_params, n_jobs=n_jobs)
533
+ obj.ann.build(obj.R_iX)
534
+
535
+ return obj
536
+
537
+ @classmethod
538
+ def load_local(
539
+ cls,
540
+ weights_file: str = "dmap.msgpack",
541
+ *,
542
+ ann_backend: ANNBackend = "auto",
543
+ ann_params: Optional[Dict[str, Any]] = None,
544
+ n_jobs: int = -1,
545
+ ) -> "DMAP":
546
+ """Load DMAP weights saved by `save_local()`."""
547
+ from flax import serialization as flax_ser # type: ignore
548
+
549
+ with open(weights_file, "rb") as f:
550
+ state = flax_ser.msgpack_restore(f.read())
551
+
552
+ return cls.from_state(state, ann_backend=ann_backend, ann_params=ann_params, n_jobs=n_jobs)
553
+
554
+ def upload_to_huggingface(
555
+ self,
556
+ repo_id: str,
557
+ *,
558
+ weights_file: str = "dmap.msgpack",
559
+ config_file: str = "dmap_config.json",
560
+ token: Optional[str] = None,
561
+ repo_type: str = "model",
562
+ revision: Optional[str] = None,
563
+ ) -> None:
564
+ """
565
+ Upload DMAP weights to the Hugging Face Hub.
566
+
567
+ This follows the same pattern as `DIMA.save_hf`:
568
+ - saves locally first (msgpack + JSON)
569
+ - creates the repo if needed
570
+ - uploads both files
571
+ """
572
+ try:
573
+ from huggingface_hub import HfApi, HfFolder, upload_file # type: ignore
574
+ except Exception as e:
575
+ raise RuntimeError("huggingface_hub not installed. Install extras: `pip install dima[hf]`.") from e
576
+
577
+ self.save_local(weights_file=weights_file, config_file=config_file)
578
+
579
+ if token is None:
580
+ token = HfFolder.get_token()
581
+ if token is None:
582
+ raise RuntimeError("No HF token found. Run `huggingface-cli login`, or pass `token=...`.")
583
+
584
+ import os as _os
585
+
586
+ api = HfApi()
587
+ api.create_repo(repo_id=repo_id, repo_type=repo_type, exist_ok=True, token=token)
588
+
589
+ wf = _os.path.basename(weights_file)
590
+ cf = _os.path.basename(config_file)
591
+
592
+ upload_file(
593
+ path_or_fileobj=weights_file,
594
+ path_in_repo=wf,
595
+ repo_id=repo_id,
596
+ token=token,
597
+ repo_type=repo_type,
598
+ revision=revision,
599
+ )
600
+ upload_file(
601
+ path_or_fileobj=config_file,
602
+ path_in_repo=cf,
603
+ repo_id=repo_id,
604
+ token=token,
605
+ repo_type=repo_type,
606
+ revision=revision,
607
+ )
608
+
609
+ return None
610
+
611
+ @classmethod
612
+ def download_from_huggingface(
613
+ cls,
614
+ repo_id: str,
615
+ *,
616
+ weights_file: str = "dmap.msgpack",
617
+ ann_backend: ANNBackend = "auto",
618
+ ann_params: Optional[Dict[str, Any]] = None,
619
+ n_jobs: int = -1,
620
+ token: Optional[str] = None,
621
+ repo_type: str = "model",
622
+ revision: Optional[str] = None,
623
+ ) -> "DMAP":
624
+ """
625
+ Download DMAP weights from Hugging Face Hub and rehydrate a DMAP instance.
626
+
627
+ Note: despite the name, this *downloads from* the Hub.
628
+ """
629
+ try:
630
+ from huggingface_hub import hf_hub_download # type: ignore
631
+ except Exception as e:
632
+ raise RuntimeError("huggingface_hub not installed. Install extras: `pip install dima[hf]`.") from e
633
+
634
+ path = hf_hub_download(
635
+ repo_id=repo_id,
636
+ filename=weights_file,
637
+ token=token,
638
+ repo_type=repo_type,
639
+ revision=revision,
640
+ )
641
+ return cls.load_local(path, ann_backend=ann_backend, ann_params=ann_params, n_jobs=n_jobs)
642
+
643
+
644
+
645
+ __all__ = ["DMAP", "k_ideal"]
gplm.py ADDED
@@ -0,0 +1,712 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/dima/gplm.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, Literal, Optional, Tuple, Union
5
+
6
+ import numpy as np
7
+ import scipy.linalg as la
8
+ import json
9
+ import os
10
+ import tempfile
11
+
12
+ from .ann import ANNBackend, make_ann
13
+ from .utils import fps_indices, median_eps_from_knn_d2, sqdist_ab
14
+
15
+
16
+ InducingMode = Literal["random_subset", "fps", "kmeans_medoids", "given"]
17
+
18
+
19
+
20
+
21
+ def _kmeans2_safe(Z: np.ndarray, m: int, seed: int = 0) -> np.ndarray:
22
+ """
23
+ KMeans centers with a safe fallback.
24
+ Uses scipy.cluster.vq.kmeans2 if available; otherwise samples points.
25
+ """
26
+ Z = np.asarray(Z)
27
+ m = int(min(max(1, m), Z.shape[0]))
28
+ try:
29
+ from scipy.cluster.vq import kmeans2 # type: ignore
30
+
31
+ C, _ = kmeans2(Z.astype(np.float64, copy=False), m, minit="points", seed=seed)
32
+ return C.astype(Z.dtype, copy=False)
33
+ except Exception:
34
+ rng = np.random.default_rng(seed)
35
+ idx = rng.choice(Z.shape[0], size=m, replace=False)
36
+ return Z[idx]
37
+
38
+
39
+ def _to_builtin(x: Any) -> Any:
40
+ """
41
+ Convierte tipos numpy / tuplas / dicts anidados a tipos nativos serializables.
42
+ - Para msgpack (flax.serialization) las ndarrays se dejan como ndarrays.
43
+ - Para JSON, este helper se usa solo sobre objetos escalares/dicts (sin ndarrays).
44
+ """
45
+ if x is None:
46
+ return None
47
+ if isinstance(x, (bool, int, float, str)):
48
+ return x
49
+ if isinstance(x, (np.integer, np.floating)):
50
+ return x.item()
51
+ if isinstance(x, (tuple, list)):
52
+ return [_to_builtin(v) for v in x]
53
+ if isinstance(x, dict):
54
+ return {str(k): _to_builtin(v) for k, v in x.items()}
55
+ # fallback conservador
56
+ return str(x)
57
+
58
+
59
+ class GPLM:
60
+ """
61
+ Inducing-point / Nyström GP (kernel ridge) decoder on latents.
62
+
63
+ Entrenamiento:
64
+ - Construye puntos inductores Z_mx_w (en latente blanqueado o no),
65
+ - Estima eps (si no se provee) a partir de distancias kNN,
66
+ - Resuelve M_mX por Cholesky (Nyström KRR/GP mean).
67
+
68
+ Además:
69
+ - decode() vía __call__.
70
+ - flow() integra un paso tipo generalized-leapfrog (geodesic flow) sobre el pullback manifold.
71
+ - NUEVO: save_local/load_local + upload_to_huggingface/download_from_huggingface.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ R_ix: np.ndarray,
77
+ R_iX: np.ndarray,
78
+ *,
79
+ # ASCII
80
+ beta: float = 1.0,
81
+ # eps estimation
82
+ eps: Optional[float] = None,
83
+ k_eps: int = 256,
84
+ eps_use_kth: bool = True,
85
+ eps_mul: float = 1.0,
86
+ # regularization
87
+ sigma2: float = 1e-5,
88
+ jitter: float = 1e-8,
89
+ # inducing
90
+ m: int = 1024,
91
+ inducing: InducingMode = "kmeans_medoids",
92
+ Z_mx: Optional[np.ndarray] = None,
93
+ seed: int = 0,
94
+ # preprocess
95
+ center_X: bool = True,
96
+ whiten_latent: bool = False,
97
+ dtype: Any = np.float32,
98
+ # compute/memory
99
+ fit_block: int = 8192,
100
+ # inference
101
+ pred_k: Optional[int] = None,
102
+ ann_backend: ANNBackend = "auto",
103
+ ann_params: Optional[Dict[str, Any]] = None,
104
+ n_jobs: int = -1,
105
+ # unicode aliases
106
+ **kwargs: Any,
107
+ ):
108
+ # ---- map unicode kwargs -> ascii
109
+ if "β" in kwargs:
110
+ beta = kwargs.pop("β")
111
+ if "ε" in kwargs:
112
+ eps = kwargs.pop("ε")
113
+ if "κ_eps" in kwargs:
114
+ k_eps = kwargs.pop("κ_eps")
115
+ if "ε_use_kth" in kwargs:
116
+ eps_use_kth = kwargs.pop("ε_use_kth")
117
+ if "ε_mul" in kwargs:
118
+ eps_mul = kwargs.pop("ε_mul")
119
+ if "σ2" in kwargs:
120
+ sigma2 = kwargs.pop("σ2")
121
+ if "pred_κ" in kwargs:
122
+ pred_k = kwargs.pop("pred_κ")
123
+ if kwargs:
124
+ raise TypeError(f"Unexpected kwargs: {sorted(kwargs.keys())}")
125
+
126
+ # ---- store params (and keep enough meta to reconstruct on load)
127
+ self.beta = float(beta)
128
+ self.β = self.beta
129
+
130
+ self.sigma2 = float(sigma2)
131
+ self.σ2 = self.sigma2
132
+
133
+ self.jitter = float(jitter)
134
+ self.seed = int(seed)
135
+
136
+ self.dtype = dtype
137
+ self.dtype_str = str(np.dtype(dtype))
138
+
139
+ self.fit_block = int(fit_block)
140
+ self.center_X = bool(center_X)
141
+ self.whiten_latent = bool(whiten_latent)
142
+ self.inducing = inducing
143
+
144
+ self.ann_backend = ann_backend
145
+ self.ann_params = ann_params if ann_params is None else dict(ann_params)
146
+ self.n_jobs = int(n_jobs)
147
+
148
+ # ---- validate / cast
149
+ R_ix = np.ascontiguousarray(np.asarray(R_ix).astype(self.dtype, copy=False))
150
+ R_iX = np.ascontiguousarray(np.asarray(R_iX).astype(self.dtype, copy=False))
151
+ if R_ix.ndim != 2 or R_iX.ndim != 2 or R_ix.shape[0] != R_iX.shape[0]:
152
+ raise ValueError("R_ix must be (N,d) and R_iX must be (N,D) with same N.")
153
+
154
+ self.R_ix = R_ix
155
+ self.R_iX = R_iX
156
+ self.N, self.d_lat = R_ix.shape
157
+ _, self.D = R_iX.shape
158
+
159
+ # ---- center output
160
+ if self.center_X:
161
+ self.mean_X = R_iX.mean(axis=0).astype(np.float64)
162
+ Y = (R_iX.astype(np.float64) - self.mean_X[None, :])
163
+ else:
164
+ self.mean_X = np.zeros((self.D,), dtype=np.float64)
165
+ Y = R_iX.astype(np.float64)
166
+
167
+ # ---- latent whitening (optional)
168
+ Ztrain = R_ix.astype(np.float64)
169
+ if self.whiten_latent:
170
+ self.lat_mean_x = Ztrain.mean(axis=0)
171
+ self.lat_std_x = np.maximum(Ztrain.std(axis=0), 1e-12)
172
+ Ztrain_w = (Ztrain - self.lat_mean_x) / self.lat_std_x
173
+ else:
174
+ self.lat_mean_x = np.zeros((self.d_lat,), dtype=np.float64)
175
+ self.lat_std_x = np.ones((self.d_lat,), dtype=np.float64)
176
+ Ztrain_w = Ztrain
177
+ self.R_ix_w = Ztrain_w # (N,d) float64
178
+
179
+ # ---- ANN on training latents (eps + medoids snapping)
180
+ self.ann_train, _ = make_ann(self.ann_backend, ann_params=self.ann_params, n_jobs=self.n_jobs)
181
+ self.ann_train.build(self.R_ix_w.astype(self.dtype, copy=False))
182
+
183
+ # ---- eps via kNN distances on latents
184
+ if eps is None:
185
+ k_eps = int(min(max(8, int(k_eps)), self.N - 1))
186
+ j_iK1, D2_iK1 = self.ann_train.search(self.R_ix_w.astype(self.dtype, copy=False), k_eps + 1)
187
+
188
+ i = np.arange(self.N)[:, None]
189
+ is_self = (j_iK1 == i)
190
+
191
+ if np.any(is_self):
192
+ D2_iK = np.empty((self.N, k_eps), dtype=np.float64)
193
+ for ii in range(self.N):
194
+ keep = (j_iK1[ii] != ii)
195
+ D2_iK[ii] = D2_iK1[ii][keep][:k_eps]
196
+ else:
197
+ D2_iK = D2_iK1[:, :k_eps].astype(np.float64, copy=False)
198
+
199
+ eps_hat = median_eps_from_knn_d2(D2_iK, use_kth=bool(eps_use_kth))
200
+ else:
201
+ eps_hat = float(eps)
202
+
203
+ eps_hat *= float(eps_mul)
204
+ if eps_hat <= 0:
205
+ raise ValueError("eps must be > 0.")
206
+ self.eps = float(eps_hat)
207
+ self.ε = self.eps
208
+
209
+ # ---- choose inducing points (in whitened latent space)
210
+ rng = np.random.default_rng(self.seed)
211
+ m_eff = int(min(max(1, int(m)), self.N))
212
+
213
+ if Z_mx is not None:
214
+ Zm = np.asarray(Z_mx, dtype=np.float64)
215
+ if Zm.ndim != 2 or Zm.shape[1] != self.d_lat:
216
+ raise ValueError("Z_mx must be (m, d_lat).")
217
+ Zm_w = (Zm - self.lat_mean_x) / self.lat_std_x
218
+ else:
219
+ if inducing == "random_subset":
220
+ idx = rng.choice(self.N, size=m_eff, replace=False)
221
+ Zm_w = self.R_ix_w[idx]
222
+ elif inducing == "fps":
223
+ idx = fps_indices(self.R_ix_w, m=m_eff, seed=self.seed) # fps_indices is not defined
224
+ # For now, fallback to random_subset if fps_indices is not available
225
+ # idx = rng.choice(self.N, size=m_eff, replace=False)
226
+ Zm_w = self.R_ix_w[idx]
227
+ elif inducing == "kmeans_medoids":
228
+ C = _kmeans2_safe(self.R_ix_w, m_eff, seed=self.seed).astype(np.float64, copy=False)
229
+ j_cm, _ = self.ann_train.search(C.astype(self.dtype, copy=False), 1)
230
+ idx = j_cm.reshape(-1).astype(np.int64)
231
+
232
+ # de-duplicate and refill if needed
233
+ idx_u = np.unique(idx)
234
+ if idx_u.size < m_eff:
235
+ needed = m_eff - idx_u.size
236
+ pool = np.setdiff1d(np.arange(self.N), idx_u, assume_unique=False)
237
+ extra = rng.choice(pool, size=needed, replace=False) if pool.size >= needed else rng.choice(self.N, size=needed, replace=True)
238
+ idx = np.concatenate([idx_u, extra])
239
+ else:
240
+ idx = idx_u[:m_eff]
241
+ Zm_w = self.R_ix_w[idx]
242
+ elif inducing == "given":
243
+ raise ValueError("Provide Z_mx when inducing='given'.")
244
+ else:
245
+ raise ValueError(f"Unknown inducing mode: {inducing!r}")
246
+
247
+ self.Z_mx_w = np.ascontiguousarray(Zm_w.astype(np.float64, copy=False))
248
+ self.m = int(self.Z_mx_w.shape[0])
249
+
250
+ # store raw inducing points (unwhitened) for convenience
251
+ self.Z_mx = (self.Z_mx_w * self.lat_std_x[None, :]) + self.lat_mean_x[None, :]
252
+
253
+ # ---- ANN on inducing points for fast prediction
254
+ self.ann_Z, _ = make_ann(self.ann_backend, ann_params=self.ann_params, n_jobs=self.n_jobs)
255
+ self.ann_Z.build(self.Z_mx_w.astype(self.dtype, copy=False))
256
+
257
+ # pred_k
258
+ if pred_k is None:
259
+ self.pred_k = None
260
+ else:
261
+ self.pred_k = int(min(max(1, int(pred_k)), self.m))
262
+ self.pred_κ = self.pred_k # unicode alias
263
+
264
+ # ---- W_mm and reduced solve
265
+ D2_mm = sqdist_ab(self.Z_mx_w, self.Z_mx_w)
266
+ W_mm = np.exp(-self.beta * (D2_mm.astype(np.float64) / self.eps))
267
+ W_mm.flat[:: self.m + 1] += self.jitter
268
+ self.W_mm = W_mm # (m,m)
269
+
270
+ G_mm = np.zeros((self.m, self.m), dtype=np.float64)
271
+ B_mX = np.zeros((self.m, self.D), dtype=np.float64)
272
+ bs = int(self.fit_block)
273
+
274
+ for i0 in range(0, self.N, bs):
275
+ i1 = min(self.N, i0 + bs)
276
+ Zi = self.R_ix_w[i0:i1] # (b,d)
277
+ D2_im = sqdist_ab(Zi, self.Z_mx_w)
278
+ C_im = np.exp(-self.beta * (D2_im.astype(np.float64) / self.eps))
279
+ G_mm += C_im.T @ C_im
280
+ B_mX += C_im.T @ Y[i0:i1]
281
+
282
+ A_mm = G_mm + self.sigma2 * W_mm
283
+ A_mm.flat[:: self.m + 1] += self.jitter
284
+
285
+ cF = la.cho_factor(A_mm, lower=True, check_finite=False)
286
+ self.M_mX = la.cho_solve(cF, B_mX, check_finite=False) # (m,D)
287
+
288
+ # ---------------------------
289
+ # Inference
290
+ # ---------------------------
291
+ def __call__(self, R_ax: Union[np.ndarray, list], *, batch_size: Optional[int] = None) -> np.ndarray:
292
+ R_ax = np.asarray(R_ax)
293
+ single = (R_ax.ndim == 1)
294
+ if single:
295
+ R_ax = R_ax[None, :]
296
+ R_ax = np.ascontiguousarray(R_ax.astype(self.dtype, copy=False))
297
+
298
+ if batch_size is None:
299
+ Y = self._decode(R_ax)
300
+ else:
301
+ bs = int(batch_size)
302
+ out = []
303
+ for s in range(0, R_ax.shape[0], bs):
304
+ out.append(self._decode(R_ax[s : s + bs]))
305
+ Y = np.vstack(out)
306
+
307
+ return Y[0] if single else Y
308
+
309
+ def _decode(self, R_ax: np.ndarray) -> np.ndarray:
310
+ Za = R_ax.astype(np.float64, copy=False)
311
+ Za_w = (Za - self.lat_mean_x) / self.lat_std_x
312
+
313
+ if self.pred_k is None or self.pred_k == self.m:
314
+ D2_am = sqdist_ab(Za_w, self.Z_mx_w)
315
+ C_am = np.exp(-self.beta * (D2_am.astype(np.float64) / self.eps))
316
+ Y = C_am @ self.M_mX
317
+ else:
318
+ j_aK, D2_aK = self.ann_Z.search(Za_w.astype(self.dtype, copy=False), self.pred_k)
319
+ W = np.exp(-self.beta * (D2_aK.astype(np.float64) / self.eps)) # (a,k)
320
+ M = self.M_mX[j_aK] # (a,k,D)
321
+ Y = np.sum(W[:, :, None] * M, axis=1) # (a,D)
322
+
323
+ return Y + self.mean_X[None, :]
324
+
325
+ # ============================================================
326
+ # Geodesic flow on pullback manifold (no C_mm storage)
327
+ # ============================================================
328
+ def _rbf_cache_single(self, r_x: np.ndarray, *, idx_m: Optional[np.ndarray]) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
329
+ """
330
+ Cache kernel terms at position r (single point).
331
+ """
332
+ c = self.beta / self.eps
333
+ inv_std = 1.0 / self.lat_std_x
334
+
335
+ r = r_x.astype(np.float64, copy=False)
336
+ rw = (r - self.lat_mean_x) / self.lat_std_x
337
+
338
+ Zw = self.Z_mx_w if idx_m is None else self.Z_mx_w[idx_m]
339
+ Dw = rw[None, :] - Zw
340
+ D2 = np.sum(Dw * Dw, axis=1)
341
+
342
+ k_m = np.exp(-c * D2) # (k,)
343
+ Dw_over = Dw * inv_std[None, :] # (k,d) = (r-z)/std^2
344
+ grad_k = -(2.0 * c) * (k_m[:, None] * Dw_over) # (k,d)
345
+
346
+ return k_m, Dw_over, grad_k
347
+
348
+ def _metric_from_gradM(
349
+ self,
350
+ grad_k: np.ndarray, # (k,d)
351
+ M_kX: np.ndarray, # (k,D)
352
+ *,
353
+ D_block: int = 8192,
354
+ lam: float = 1e-10,
355
+ ) -> Tuple[np.ndarray, Tuple[np.ndarray, bool]]:
356
+ """
357
+ Compute pullback metric g = J^T J without forming C_mm.
358
+ """
359
+ k, d = grad_k.shape
360
+ D = M_kX.shape[1]
361
+
362
+ g = np.zeros((d, d), dtype=np.float64)
363
+ for j0 in range(0, D, int(D_block)):
364
+ j1 = min(D, j0 + int(D_block))
365
+ Mb = M_kX[:, j0:j1] # (k,block)
366
+ Jb = Mb.T @ grad_k # (block,d)
367
+ g += Jb.T @ Jb # (d,d)
368
+
369
+ g = 0.5 * (g + g.T)
370
+ g.flat[:: d + 1] += float(lam)
371
+
372
+ cF = la.cho_factor(g, lower=True, check_finite=False)
373
+ return g, cF
374
+
375
+ def _force_from_cache_noC(
376
+ self,
377
+ k_m: np.ndarray, # (k,)
378
+ Dw_over: np.ndarray, # (k,d)
379
+ v_x: np.ndarray, # (d,)
380
+ M_kX: np.ndarray, # (k,D)
381
+ *,
382
+ D_block: int = 8192,
383
+ ) -> np.ndarray:
384
+ """
385
+ Geodesic momentum force without storing C_mm, using blocked contractions over ambient dim D.
386
+ """
387
+ c = self.beta / self.eps
388
+ v = v_x.astype(np.float64, copy=False)
389
+
390
+ inv_std2 = (1.0 / self.lat_std_x) ** 2 # (d,)
391
+ s_m = Dw_over @ v # (k,)
392
+ S = -(2.0 * c) * (k_m * s_m) # (k,)
393
+
394
+ term1 = (4.0 * c * c) * (k_m * s_m)[:, None] * Dw_over
395
+ term2 = (2.0 * c) * k_m[:, None] * (v[None, :] * inv_std2[None, :])
396
+ T = (term1 - term2).T # (d,k)
397
+
398
+ d = v.shape[0]
399
+ D = M_kX.shape[1]
400
+ f = np.zeros((d,), dtype=np.float64)
401
+
402
+ for j0 in range(0, D, int(D_block)):
403
+ j1 = min(D, j0 + int(D_block))
404
+ Mb = M_kX[:, j0:j1] # (k,block)
405
+ Jv_b = S @ Mb # (block,)
406
+ Hv_b = T @ Mb # (d,block)
407
+ f += Hv_b @ Jv_b # (d,)
408
+
409
+ return f
410
+
411
+ def flow(
412
+ self,
413
+ R_ax: Union[np.ndarray, list],
414
+ v_ax: Union[np.ndarray, list],
415
+ *,
416
+ eps: float = 1e-2,
417
+ K_p: int = 5,
418
+ K_q: int = 5,
419
+ D_block: int = 8192,
420
+ lam: float = 1e-10,
421
+ ) -> Tuple[np.ndarray, np.ndarray]:
422
+ """
423
+ One generalized-leapfrog step for geodesic flow on the pullback manifold.
424
+ """
425
+ R = np.asarray(R_ax, dtype=np.float64)
426
+ v = np.asarray(v_ax, dtype=np.float64)
427
+ single = (R.ndim == 1)
428
+
429
+ if single:
430
+ R = R[None, :]
431
+ v = v[None, :]
432
+
433
+ if R.ndim != 2 or v.ndim != 2 or R.shape != v.shape or R.shape[1] != self.d_lat:
434
+ raise ValueError(f"Expected R_ax and v_ax shape (A,{self.d_lat}) (or ({self.d_lat},)).")
435
+
436
+ A, _d = R.shape
437
+
438
+ # Optional inducing subset indices per point
439
+ if self.pred_k is None or self.pred_k == self.m:
440
+ idx_aK = None
441
+ else:
442
+ Rw = (R - self.lat_mean_x[None, :]) / self.lat_std_x[None, :]
443
+ idx_aK, _ = self.ann_Z.search(Rw.astype(self.dtype, copy=False), self.pred_k)
444
+ idx_aK = idx_aK.astype(np.int64, copy=False)
445
+
446
+ R_next = np.empty_like(R)
447
+ v_next = np.empty_like(v)
448
+
449
+ for a in range(A):
450
+ idx = None if idx_aK is None else idx_aK[a]
451
+ M_kX = self.M_mX if idx is None else self.M_mX[idx]
452
+
453
+ r_n = R[a]
454
+ v_n = v[a]
455
+
456
+ # geometry at r_n
457
+ k_m_n, Dw_over_n, grad_k_n = self._rbf_cache_single(r_n, idx_m=idx)
458
+ g_n, cF_n = self._metric_from_gradM(grad_k_n, M_kX, D_block=D_block, lam=lam)
459
+
460
+ # momentum p_n = g(r_n) v_n
461
+ p_n = g_n @ v_n
462
+
463
+ # (1) implicit half-step in momentum
464
+ p = p_n.copy()
465
+ for _ in range(int(K_p)):
466
+ v_k = la.cho_solve(cF_n, p, check_finite=False)
467
+ f_k = self._force_from_cache_noC(k_m_n, Dw_over_n, v_k, M_kX, D_block=D_block)
468
+ p = p_n + 0.5 * float(eps) * f_k
469
+ p_half = p
470
+
471
+ # (2) implicit position update
472
+ v_half_n = la.cho_solve(cF_n, p_half, check_finite=False)
473
+ r = r_n + float(eps) * v_half_n
474
+ for _ in range(int(K_q)):
475
+ k_m_r, Dw_over_r, grad_k_r = self._rbf_cache_single(r, idx_m=idx)
476
+ g_r, cF_r = self._metric_from_gradM(grad_k_r, M_kX, D_block=D_block, lam=lam)
477
+ v_half_r = la.cho_solve(cF_r, p_half, check_finite=False)
478
+ r = r_n + 0.5 * float(eps) * (v_half_n + v_half_r)
479
+ r_np1 = r
480
+
481
+ # (3) explicit half-step in momentum at r_{n+1}
482
+ k_m_np1, Dw_over_np1, grad_k_np1 = self._rbf_cache_single(r_np1, idx_m=idx)
483
+ g_np1, cF_np1 = self._metric_from_gradM(grad_k_np1, M_kX, D_block=D_block, lam=lam)
484
+ v_mid = la.cho_solve(cF_np1, p_half, check_finite=False)
485
+ f_np1 = self._force_from_cache_noC(k_m_np1, Dw_over_np1, v_mid, M_kX, D_block=D_block)
486
+
487
+ p_np1 = p_half + 0.5 * float(eps) * f_np1
488
+ v_np1 = la.cho_solve(cF_np1, p_np1, check_finite=False)
489
+
490
+ R_next[a] = r_np1
491
+ v_next[a] = v_np1
492
+
493
+ if single:
494
+ return R_next[0], v_next[0]
495
+ return R_next, v_next
496
+
497
+ # ============================================================
498
+ # (NEW) Serialization + Hugging Face Hub
499
+ # ============================================================
500
+ def config_dict(self) -> Dict[str, Any]:
501
+ """
502
+ Config mínima (sin arrays grandes) para inspección/reproducibilidad.
503
+ """
504
+ return {
505
+ "class": "GPLM",
506
+ "beta": float(self.beta),
507
+ "eps": float(self.eps),
508
+ "sigma2": float(self.sigma2),
509
+ "jitter": float(self.jitter),
510
+ "seed": int(self.seed),
511
+ "center_X": bool(self.center_X),
512
+ "whiten_latent": bool(self.whiten_latent),
513
+ "inducing": str(self.inducing),
514
+ "fit_block": int(self.fit_block),
515
+ "pred_k": None if self.pred_k is None else int(self.pred_k),
516
+ "ann_backend": str(self.ann_backend),
517
+ "ann_params": None if self.ann_params is None else _to_builtin(self.ann_params),
518
+ "n_jobs": int(self.n_jobs),
519
+ "dtype": str(self.dtype_str),
520
+ "d_lat": int(self.d_lat),
521
+ "D": int(self.D),
522
+ "m": int(self.m),
523
+ }
524
+
525
+ def state_dict(self) -> Dict[str, Any]:
526
+ """
527
+ Estado completo necesario para inferencia/flow (sin datos de entrenamiento completos).
528
+ """
529
+ st: Dict[str, Any] = {
530
+ "config": self.config_dict(),
531
+ "M_mX": np.asarray(self.M_mX, dtype=np.float64),
532
+ "Z_mx_w": np.asarray(self.Z_mx_w, dtype=np.float64),
533
+ "mean_X": np.asarray(self.mean_X, dtype=np.float64),
534
+ "lat_mean_x": np.asarray(self.lat_mean_x, dtype=np.float64),
535
+ "lat_std_x": np.asarray(self.lat_std_x, dtype=np.float64),
536
+ }
537
+ return st
538
+
539
+ @classmethod
540
+ def from_state_dict(cls, st: Dict[str, Any]) -> "GPLM":
541
+ """
542
+ Reconstruye un objeto GPLM entrenado SIN re-entrenar.
543
+ """
544
+ if "config" not in st:
545
+ raise ValueError("state_dict inválido: falta 'config'.")
546
+
547
+ cfg = st["config"]
548
+ # construir instancia vacía
549
+ obj = cls.__new__(cls)
550
+
551
+ # meta/config
552
+ obj.beta = float(cfg["beta"])
553
+ obj.β = obj.beta
554
+ obj.eps = float(cfg["eps"])
555
+ obj.ε = obj.eps
556
+ obj.sigma2 = float(cfg["sigma2"])
557
+ obj.σ2 = obj.sigma2
558
+ obj.jitter = float(cfg["jitter"])
559
+ obj.seed = int(cfg["seed"])
560
+ obj.center_X = bool(cfg["center_X"])
561
+ obj.whiten_latent = bool(cfg["whiten_latent"])
562
+ obj.inducing = cfg.get("inducing", "given")
563
+ obj.fit_block = int(cfg["fit_block"])
564
+ obj.pred_k = cfg["pred_k"] if cfg["pred_k"] is None else int(cfg["pred_k"])
565
+ obj.pred_κ = obj.pred_k
566
+ obj.ann_backend = cfg.get("ann_backend", "auto")
567
+ obj.ann_params = cfg.get("ann_params", None)
568
+ obj.n_jobs = int(cfg.get("n_jobs", -1))
569
+
570
+ obj.dtype_str = str(cfg.get("dtype", "float32"))
571
+ obj.dtype = np.dtype(obj.dtype_str).type
572
+
573
+ obj.d_lat = int(cfg["d_lat"])
574
+ obj.D = int(cfg["D"])
575
+ obj.m = int(cfg["m"])
576
+
577
+ # arrays
578
+ obj.M_mX = np.asarray(st["M_mX"], dtype=np.float64)
579
+ obj.Z_mx_w = np.asarray(st["Z_mx_w"], dtype=np.float64)
580
+ obj.mean_X = np.asarray(st["mean_X"], dtype=np.float64)
581
+ obj.lat_mean_x = np.asarray(st["lat_mean_x"], dtype=np.float64)
582
+ obj.lat_std_x = np.asarray(st["lat_std_x"], dtype=np.float64)
583
+
584
+ # derived
585
+ obj.Z_mx = (obj.Z_mx_w * obj.lat_std_x[None, :]) + obj.lat_mean_x[None, :]
586
+
587
+ # ANN on inducing points (needed if pred_k is used)
588
+ obj.ann_Z, _ = make_ann(obj.ann_backend, ann_params=obj.ann_params, n_jobs=obj.n_jobs)
589
+ obj.ann_Z.build(obj.Z_mx_w.astype(obj.dtype, copy=False))
590
+
591
+ # no training data kept
592
+ obj.R_ix = None
593
+ obj.R_iX = None
594
+ obj.R_ix_w = None
595
+ obj.ann_train = None
596
+ obj.N = 0 # unknown/not needed for inference
597
+
598
+ return obj
599
+
600
+ def save_local(self, weights_file: str = "gplm.msgpack", config_file: str = "gplm_config.json") -> None:
601
+ """
602
+ Guarda:
603
+ - weights_file: msgpack con state_dict (arrays + config)
604
+ - config_file: JSON legible con la config
605
+ """
606
+ st = self.state_dict()
607
+ cfg = st["config"]
608
+
609
+ with open(config_file, "w", encoding="utf-8") as f:
610
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
611
+
612
+ # msgpack (preferentemente flax.serialization)
613
+ try:
614
+ import flax.serialization as flax_ser # type: ignore
615
+ except Exception as e:
616
+ raise RuntimeError("flax.serialization no está disponible; instale flax o use un backend alternativo.") from e
617
+
618
+ blob = flax_ser.msgpack_serialize(st)
619
+ with open(weights_file, "wb") as f:
620
+ f.write(blob)
621
+
622
+ @classmethod
623
+ def load_local(cls, weights_file: str = "gplm.msgpack") -> "GPLM":
624
+ """
625
+ Carga desde msgpack (state_dict completo) y reconstruye el objeto.
626
+ """
627
+ try:
628
+ import flax.serialization as flax_ser # type: ignore
629
+ except Exception as e:
630
+ raise RuntimeError("flax.serialization no está disponible; instale flax o use un backend alternativo.") from e
631
+
632
+ with open(weights_file, "rb") as f:
633
+ blob = f.read()
634
+
635
+ st = flax_ser.msgpack_restore(blob)
636
+ return cls.from_state_dict(st)
637
+
638
+ def upload_to_huggingface(
639
+ self,
640
+ repo_id: str,
641
+ *,
642
+ token: Optional[str] = None,
643
+ weights_file: str = "gplm.msgpack",
644
+ config_file: str = "gplm_config.json",
645
+ repo_type: str = "model",
646
+ revision: Optional[str] = None,
647
+ ) -> None:
648
+ """
649
+ Sube (weights + config) a Hugging Face Hub.
650
+ """
651
+ try:
652
+ from huggingface_hub import HfApi, create_repo # type: ignore
653
+ except Exception as e:
654
+ raise RuntimeError("huggingface_hub no está instalado. Instale con `pip install huggingface_hub`.") from e
655
+
656
+ if token is None:
657
+ raise ValueError("token es requerido para subir al Hub (HUGGINGFACE_TOKEN/HF_TOKEN).")
658
+
659
+ with tempfile.TemporaryDirectory() as td:
660
+ wpath = os.path.join(td, weights_file)
661
+ cpath = os.path.join(td, config_file)
662
+
663
+ self.save_local(weights_file=wpath, config_file=cpath)
664
+
665
+ create_repo(repo_id, token=token, repo_type=repo_type, exist_ok=True)
666
+ api = HfApi(token=token)
667
+
668
+ api.upload_file(
669
+ path_or_fileobj=wpath,
670
+ path_in_repo=weights_file,
671
+ repo_id=repo_id,
672
+ repo_type=repo_type,
673
+ revision=revision,
674
+ )
675
+ api.upload_file(
676
+ path_or_fileobj=cpath,
677
+ path_in_repo=config_file,
678
+ repo_id=repo_id,
679
+ repo_type=repo_type,
680
+ revision=revision,
681
+ )
682
+
683
+ @classmethod
684
+ def download_from_huggingface(
685
+ cls,
686
+ repo_id: str,
687
+ *,
688
+ token: Optional[str] = None,
689
+ weights_file: str = "gplm.msgpack",
690
+ repo_type: str = "model",
691
+ revision: Optional[str] = None,
692
+ ) -> "GPLM":
693
+ """
694
+ Descarga weights_file desde el Hub y reconstruye el objeto sin re-entrenar.
695
+ """
696
+ try:
697
+ from huggingface_hub import hf_hub_download # type: ignore
698
+ except Exception as e:
699
+ raise RuntimeError("huggingface_hub no está instalado. Instale con `pip install huggingface_hub`.") from e
700
+
701
+ local_path = hf_hub_download(
702
+ repo_id=repo_id,
703
+ filename=weights_file,
704
+ repo_type=repo_type,
705
+ token=token,
706
+ revision=revision,
707
+ )
708
+ return cls.load_local(local_path)
709
+
710
+
711
+
712
+ __all__ = ["GPLM", "InducingMode"]
utils.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/dima/utils.py
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import asdict, is_dataclass
5
+ from typing import Any, Dict, Iterable, Iterator, Optional, Sequence, Tuple, Union
6
+
7
+ import numpy as np
8
+
9
+ ArrayLike = Union[np.ndarray, Sequence[float]]
10
+
11
+
12
+ # -------------------------
13
+ # Basic math helpers
14
+ # -------------------------
15
+ def ensure_2d(X: np.ndarray) -> np.ndarray:
16
+ """Ensure X is 2D: (D,) -> (1,D)."""
17
+ X = np.asarray(X)
18
+ return X[None, :] if X.ndim == 1 else X
19
+
20
+
21
+ def as_contig_f32(X: np.ndarray) -> np.ndarray:
22
+ """Contiguous float32 array (good default for ANN + kernels)."""
23
+ return np.ascontiguousarray(np.asarray(X, dtype=np.float32))
24
+
25
+
26
+ def sqdist_ab(A: np.ndarray, B: np.ndarray) -> np.ndarray:
27
+ """
28
+ Squared Euclidean distances between rows:
29
+ A: (a,d), B: (b,d) -> D2: (a,b)
30
+ """
31
+ A = np.asarray(A)
32
+ B = np.asarray(B)
33
+ A2 = np.sum(A * A, axis=1, keepdims=True) # (a,1)
34
+ B2 = np.sum(B * B, axis=1, keepdims=True).T # (1,b)
35
+ G = A @ B.T # (a,b)
36
+ return np.maximum(A2 + B2 - 2.0 * G, 0.0)
37
+
38
+
39
+ def rbf_from_D2(D2: np.ndarray, *, beta: float, eps: float) -> np.ndarray:
40
+ """RBF kernel weights from squared distances."""
41
+ eps = float(eps)
42
+ if eps <= 0:
43
+ raise ValueError("eps must be > 0")
44
+ return np.exp(-float(beta) * (np.asarray(D2) / eps))
45
+
46
+
47
+ # -------------------------
48
+ # ε heuristics
49
+ # -------------------------
50
+ def median_eps_from_knn_d2(D2_iK: np.ndarray, *, use_kth: bool = True) -> float:
51
+ """
52
+ Median bandwidth from kNN squared distances.
53
+ D2_iK: (N,K) squared distances to K nearest neighbors (excluding self).
54
+ - use_kth=True: use the Kth neighbor distance per point, then median over points
55
+ - use_kth=False: use all distances, then median
56
+ """
57
+ D2_iK = np.asarray(D2_iK)
58
+ if D2_iK.size == 0:
59
+ return 1.0
60
+ v = D2_iK[:, -1] if use_kth else D2_iK.reshape(-1)
61
+ eps = float(np.median(v))
62
+ return max(eps, 1e-12)
63
+
64
+
65
+ def median_eps_from_pairs(X: np.ndarray, *, max_pairs: int = 200_000, seed: int = 0) -> float:
66
+ """
67
+ Median of random-pair squared distances (rough fallback if you don't have kNN distances).
68
+ """
69
+ X = np.asarray(X)
70
+ N = X.shape[0]
71
+ if N < 2:
72
+ return 1.0
73
+
74
+ rng = np.random.default_rng(seed)
75
+ p = int(min(max_pairs, N * (N - 1) // 2))
76
+
77
+ i = rng.integers(0, N, size=p, endpoint=False)
78
+ j = rng.integers(0, N, size=p, endpoint=False)
79
+ mask = (i != j)
80
+ i = i[mask]
81
+ j = j[mask]
82
+ if i.size == 0:
83
+ return 1.0
84
+
85
+ D2 = np.sum((X[i] - X[j]) ** 2, axis=1)
86
+ eps = float(np.median(D2))
87
+ return max(eps, 1e-12)
88
+
89
+
90
+ # -------------------------
91
+ # Inducing / landmark selection
92
+ # -------------------------
93
+ def fps_indices(X: np.ndarray, m: int, *, seed: int = 0) -> np.ndarray:
94
+ """
95
+ Farthest Point Sampling indices (O(N*m)).
96
+ Good for space-filling inducing points / landmarks.
97
+ X: (N,d)
98
+ Returns idx: (m,)
99
+ """
100
+ X = np.asarray(X)
101
+ N = X.shape[0]
102
+ m = int(min(max(1, m), N))
103
+
104
+ rng = np.random.default_rng(seed)
105
+ idx = np.empty(m, dtype=np.int64)
106
+
107
+ idx[0] = int(rng.integers(0, N))
108
+ d2 = np.sum((X - X[idx[0]]) ** 2, axis=1)
109
+
110
+ for t in range(1, m):
111
+ idx[t] = int(np.argmax(d2))
112
+ new_d2 = np.sum((X - X[idx[t]]) ** 2, axis=1)
113
+ d2 = np.minimum(d2, new_d2)
114
+
115
+ return idx
116
+
117
+
118
+ # -------------------------
119
+ # Batching utilities
120
+ # -------------------------
121
+ def batched_range(n: int, batch_size: int) -> Iterator[Tuple[int, int]]:
122
+ """Yield (start, end) slices covering [0, n) in batches."""
123
+ bs = int(batch_size)
124
+ if bs <= 0:
125
+ raise ValueError("batch_size must be > 0")
126
+ for s in range(0, int(n), bs):
127
+ yield s, min(int(n), s + bs)
128
+
129
+
130
+ def batch_iter(X: np.ndarray, batch_size: int) -> Iterator[np.ndarray]:
131
+ """Yield contiguous batches from X."""
132
+ X = np.asarray(X)
133
+ for s, e in batched_range(X.shape[0], batch_size):
134
+ yield X[s:e]
135
+
136
+
137
+ # -------------------------
138
+ # Metrics
139
+ # -------------------------
140
+ def rmse(a: np.ndarray, b: np.ndarray) -> float:
141
+ a = np.asarray(a)
142
+ b = np.asarray(b)
143
+ return float(np.sqrt(np.mean((a - b) ** 2)))
144
+
145
+
146
+ def mse(a: np.ndarray, b: np.ndarray) -> float:
147
+ a = np.asarray(a)
148
+ b = np.asarray(b)
149
+ return float(np.mean((a - b) ** 2))
150
+
151
+
152
+ # -------------------------
153
+ # Device helpers (JAX optional)
154
+ # -------------------------
155
+ def get_jax_device(prefer: str = "auto"):
156
+ """
157
+ Safe JAX device selection.
158
+ prefer: "auto" | "gpu" | "cpu"
159
+ - returns a jax Device if jax is installed, else None
160
+ """
161
+ prefer = (prefer or "auto").lower()
162
+ try:
163
+ import jax # local import
164
+ except Exception:
165
+ return None
166
+
167
+ devs = jax.devices()
168
+ gpu = [d for d in devs if d.platform == "gpu"]
169
+ cpu = [d for d in devs if d.platform == "cpu"]
170
+
171
+ if prefer in ("auto", "gpu"):
172
+ return gpu[0] if gpu else cpu[0] if cpu else devs[0]
173
+ if prefer == "cpu":
174
+ return cpu[0] if cpu else devs[0]
175
+ # fallback
176
+ return gpu[0] if gpu else cpu[0] if cpu else devs[0]
177
+
178
+
179
+ # -------------------------
180
+ # JSON helpers (for configs)
181
+ # -------------------------
182
+ def to_jsonable(x: Any) -> Any:
183
+ """
184
+ Convert common objects (numpy scalars/arrays, dataclasses) into JSON-serializable types.
185
+ """
186
+ if is_dataclass(x):
187
+ return {k: to_jsonable(v) for k, v in asdict(x).items()}
188
+
189
+ if isinstance(x, (np.floating, np.integer)):
190
+ return x.item()
191
+
192
+ if isinstance(x, np.ndarray):
193
+ # prefer list for small arrays; for large arrays you typically store separately
194
+ return x.tolist()
195
+
196
+ if isinstance(x, dict):
197
+ return {str(k): to_jsonable(v) for k, v in x.items()}
198
+
199
+ if isinstance(x, (list, tuple)):
200
+ return [to_jsonable(v) for v in x]
201
+
202
+ return x