bstabdiff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1122 @@
1
+ # block_subunit_gen.py
2
+ # ============================================================
3
+ # Block--Subunit HDLSS Tabular Generator (NSC-free)
4
+ # - Block latents h in R^M
5
+ # - Continuous: Gaussian copula latent -> inverse empirical CDF marginal
6
+ # - Categorical: logits from h_t
7
+ # - Missingness: Bernoulli rates (optionally class-conditional)
8
+ # - Prior: diffusion OR RealNVP flow on h
9
+ # - Train-time best-checkpoint tracking (lowest loss)
10
+ # - Optional EMA for diffusion prior (recommended)
11
+ # - Optional checkpoint saving to disk (save_dir/save_name)
12
+ # - Optional return_train_info from fit helper
13
+ # ============================================================
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Dict, List, Optional, Tuple, Union
19
+
20
+ import os
21
+ import copy
22
+ import math
23
+ import numpy as np
24
+ import torch
25
+ import torch.nn as nn
26
+ import torch.nn.functional as F
27
+
28
+
29
+ # --------------------------
30
+ # Utilities
31
+ # --------------------------
32
+
33
+ def set_seed(seed: int = 0) -> None:
34
+ np.random.seed(seed)
35
+ torch.manual_seed(seed)
36
+ torch.cuda.manual_seed_all(seed)
37
+
38
+
39
+ def erf_normal_cdf(x: torch.Tensor) -> torch.Tensor:
40
+ # Phi(x) = 0.5 * (1 + erf(x/sqrt(2)))
41
+ return 0.5 * (1.0 + torch.erf(x / math.sqrt(2.0)))
42
+
43
+
44
+ def approx_normal_icdf(u: torch.Tensor, eps: float = 1e-6) -> torch.Tensor:
45
+ """
46
+ Approximate inverse CDF for standard normal using erfinv:
47
+ Phi^{-1}(u) = sqrt(2) * erfinv(2u - 1)
48
+ """
49
+ u = torch.clamp(u, eps, 1.0 - eps)
50
+ return math.sqrt(2.0) * torch.erfinv(2.0 * u - 1.0)
51
+
52
+
53
+ def make_equal_blocks(m: int, M: int) -> List[np.ndarray]:
54
+ """
55
+ Partition features 0..m-1 into M contiguous equal-ish blocks.
56
+ """
57
+ idx = np.arange(m)
58
+ blocks = np.array_split(idx, M)
59
+ return [b.astype(int) for b in blocks]
60
+
61
+
62
+ def apply_permutation(X: np.ndarray, perm: np.ndarray) -> np.ndarray:
63
+ return X[:, perm]
64
+
65
+
66
+ def invert_permutation(perm: np.ndarray) -> np.ndarray:
67
+ inv = np.empty_like(perm)
68
+ inv[perm] = np.arange(len(perm))
69
+ return inv
70
+
71
+
72
+ # --------------------------
73
+ # EMA helper (for diffusion prior)
74
+ # --------------------------
75
+
76
+ class EMA:
77
+ """
78
+ Exponential moving average of parameters (for more stable sampling).
79
+ Works on state_dict tensors.
80
+ """
81
+ def __init__(self, model: nn.Module, decay: float = 0.999):
82
+ if not (0.0 < decay < 1.0):
83
+ raise ValueError("EMA decay must be in (0,1).")
84
+ self.decay = float(decay)
85
+ self.shadow = {k: v.detach().clone() for k, v in model.state_dict().items()}
86
+
87
+ @torch.no_grad()
88
+ def update(self, model: nn.Module) -> None:
89
+ sd = model.state_dict()
90
+ for k, v in sd.items():
91
+ self.shadow[k].mul_(self.decay).add_(v.detach(), alpha=1.0 - self.decay)
92
+
93
+ def state_dict(self) -> Dict[str, torch.Tensor]:
94
+ return self.shadow
95
+
96
+
97
+ # --------------------------
98
+ # Feature schema
99
+ # --------------------------
100
+
101
+ @dataclass
102
+ class FeatureSpec:
103
+ name: str
104
+ kind: str # "continuous" or "categorical"
105
+ n_categories: int = 0 # for categorical only
106
+
107
+
108
+ def _validate_feature_specs(feature_specs: List[FeatureSpec], m: int) -> None:
109
+ if len(feature_specs) != m:
110
+ raise ValueError(f"feature_specs length ({len(feature_specs)}) must equal m ({m}).")
111
+ for fs in feature_specs:
112
+ if fs.kind not in ("continuous", "categorical"):
113
+ raise ValueError(f"Unknown feature kind: {fs.kind}")
114
+ if fs.kind == "categorical" and fs.n_categories <= 1:
115
+ raise ValueError(f"Categorical feature {fs.name} must have n_categories >= 2.")
116
+
117
+
118
+ # --------------------------
119
+ # Empirical inverse CDF for marginals
120
+ # --------------------------
121
+
122
+ class EmpiricalMarginals:
123
+ """
124
+ Stores per-feature (and optionally per-class) sorted values for inverse CDF sampling.
125
+ For continuous features only.
126
+
127
+ Supports:
128
+ - fit from observed X with mask
129
+ - inverse_cdf(u, feature_index, class_index)
130
+ """
131
+
132
+ def __init__(self, m: int, n_classes: Optional[int], device: torch.device):
133
+ self.m = m
134
+ self.n_classes = n_classes # None => unconditional
135
+ self.device = device
136
+
137
+ # For each feature j:
138
+ # unconditional: values[(j, None)] = 1D tensor sorted
139
+ # class-cond: values[(j, y)] = 1D tensor sorted
140
+ self.values: Dict[Tuple[int, Optional[int]], torch.Tensor] = {}
141
+
142
+ @staticmethod
143
+ def _to_sorted_tensor(x: np.ndarray, device: torch.device) -> torch.Tensor:
144
+ x = x.astype(np.float32)
145
+ x = x[np.isfinite(x)]
146
+ if x.size == 0:
147
+ # fallback to standard normal samples if feature fully missing
148
+ x = np.random.normal(size=256).astype(np.float32)
149
+ x = np.sort(x)
150
+ return torch.from_numpy(x).to(device)
151
+
152
+ def fit(
153
+ self,
154
+ X: np.ndarray,
155
+ R: np.ndarray,
156
+ feature_specs: List[FeatureSpec],
157
+ y: Optional[np.ndarray] = None,
158
+ ) -> None:
159
+ """
160
+ X: (n, m) with np.nan for missing continuous
161
+ R: (n, m) {0,1}
162
+ y: (n,) optional class labels
163
+ """
164
+ n, m = X.shape
165
+ assert m == self.m
166
+
167
+ if self.n_classes is None:
168
+ for j in range(m):
169
+ if feature_specs[j].kind != "continuous":
170
+ continue
171
+ xj = X[:, j]
172
+ rj = R[:, j].astype(bool)
173
+ vals = xj[rj]
174
+ self.values[(j, None)] = self._to_sorted_tensor(vals, self.device)
175
+ else:
176
+ if y is None:
177
+ raise ValueError("Class-conditional marginals requested but y is None.")
178
+ for j in range(m):
179
+ if feature_specs[j].kind != "continuous":
180
+ continue
181
+ xj = X[:, j]
182
+ rj = R[:, j].astype(bool)
183
+ for c in range(self.n_classes):
184
+ mask = (y == c) & rj
185
+ vals = xj[mask]
186
+ self.values[(j, c)] = self._to_sorted_tensor(vals, self.device)
187
+
188
+ def inverse_cdf(self, u: torch.Tensor, j: int, y: Optional[int] = None) -> torch.Tensor:
189
+ """
190
+ u: tensor in (0,1) arbitrary shape
191
+ returns x with same shape, sampled from empirical inverse CDF
192
+ using linear interpolation between sorted values.
193
+ """
194
+ key = (j, y if self.n_classes is not None else None)
195
+ if key not in self.values:
196
+ # fallback: standard normal mapped
197
+ return approx_normal_icdf(u)
198
+
199
+ v = self.values[key] # (K,)
200
+ K = v.numel()
201
+ if K < 2:
202
+ return v[0].expand_as(u)
203
+
204
+ # Map u -> index in [0, K-1]
205
+ u = torch.clamp(u, 1e-6, 1.0 - 1e-6)
206
+ pos = u * (K - 1)
207
+ idx0 = torch.floor(pos).long()
208
+ idx1 = torch.clamp(idx0 + 1, max=K - 1)
209
+ w = (pos - idx0.float())
210
+
211
+ # Gather via indexing (broadcast-safe)
212
+ x0 = v[idx0]
213
+ x1 = v[idx1]
214
+ return (1.0 - w) * x0 + w * x1
215
+
216
+
217
+ # --------------------------
218
+ # Simple h inference (plug-in estimator)
219
+ # --------------------------
220
+
221
+ def infer_block_latents_mean_gaussianized(
222
+ X: np.ndarray,
223
+ R: np.ndarray,
224
+ blocks: List[np.ndarray],
225
+ feature_specs: List[FeatureSpec],
226
+ y: Optional[np.ndarray] = None,
227
+ ) -> np.ndarray:
228
+ """
229
+ A lightweight plug-in estimator for h:
230
+ - for each continuous feature, map observed values to rank-based u then to z=Phi^{-1}(u)
231
+ - per block t, h_t is the mean of z over features in that block (ignoring missing)
232
+
233
+ Returns:
234
+ h_hat: (n, M)
235
+ """
236
+ n, m = X.shape
237
+ M = len(blocks)
238
+
239
+ # Precompute per-feature empirical CDF ranks (unconditional) for gaussianization
240
+ Z = np.zeros((n, m), dtype=np.float32)
241
+ Z[:] = 0.0
242
+
243
+ for j in range(m):
244
+ if feature_specs[j].kind != "continuous":
245
+ continue
246
+ obs = (R[:, j] == 1) & np.isfinite(X[:, j])
247
+ vals = X[obs, j].astype(np.float64)
248
+ if vals.size < 5:
249
+ continue
250
+ order = np.argsort(vals)
251
+ ranks = np.empty_like(order)
252
+ ranks[order] = np.arange(vals.size)
253
+ u = (ranks + 0.5) / vals.size
254
+ u_t = torch.from_numpy(u.astype(np.float32))
255
+ z_t = approx_normal_icdf(u_t).numpy()
256
+ Z[obs, j] = z_t.astype(np.float32)
257
+
258
+ # block means
259
+ h_hat = np.zeros((n, M), dtype=np.float32)
260
+ for t, idx in enumerate(blocks):
261
+ idx = idx.astype(int)
262
+ cont_mask = np.array([feature_specs[j].kind == "continuous" for j in idx], dtype=bool)
263
+ idx_cont = idx[cont_mask]
264
+ if idx_cont.size == 0:
265
+ continue
266
+ block_obs = (R[:, idx_cont] == 1)
267
+ denom = np.maximum(block_obs.sum(axis=1), 1)
268
+ h_hat[:, t] = (Z[:, idx_cont] * block_obs).sum(axis=1) / denom
269
+
270
+ # standardize across samples
271
+ h_hat = (h_hat - h_hat.mean(axis=0, keepdims=True)) / (h_hat.std(axis=0, keepdims=True) + 1e-6)
272
+ return h_hat
273
+
274
+
275
+ # --------------------------
276
+ # Emission model (decoder): continuous + categorical + missingness
277
+ # --------------------------
278
+
279
+ @dataclass
280
+ class EmissionParams:
281
+ # Continuous: z_j = a_j * h_t + b_j(y) + eps, eps~N(0, sigma_j^2)
282
+ a: torch.Tensor # (m,)
283
+ sigma: torch.Tensor # (m,) positive
284
+ b: Optional[torch.Tensor] # (n_classes, m) or None
285
+
286
+ # Categorical: logits_j = W_j * h_t + c_j(y)
287
+ cat_W: Dict[int, torch.Tensor] # j -> (K_j,)
288
+ cat_c: Dict[int, torch.Tensor] # j -> (n_classes, K_j) or (K_j,)
289
+
290
+ # Missingness: Bernoulli rates (store missing probability)
291
+ miss_rate: torch.Tensor # (m,) unconditional P(missing)
292
+ miss_rate_y: Optional[torch.Tensor] # (n_classes, m) or None
293
+
294
+
295
+ def fit_emissions_from_inferred_h(
296
+ X: np.ndarray,
297
+ R: np.ndarray,
298
+ y: Optional[np.ndarray],
299
+ blocks: List[np.ndarray],
300
+ feature_specs: List[FeatureSpec],
301
+ h_hat: np.ndarray,
302
+ n_classes: Optional[int],
303
+ device: torch.device,
304
+ ) -> EmissionParams:
305
+ """
306
+ Fit simple emission parameters from inferred h_hat.
307
+ - Continuous: regress gaussianized z on scalar h_t (+ optional class bias)
308
+ - Categorical: fit per-feature logits as linear in h_t + optional class bias
309
+ - Missingness: empirical missing rates (optional class-conditional)
310
+
311
+ Note: this is a practical initialization / baseline fitting.
312
+ """
313
+ n, m = X.shape
314
+ M = len(blocks)
315
+
316
+ # Map each feature to its block id
317
+ feat_to_block = np.zeros(m, dtype=int)
318
+ for t, idx in enumerate(blocks):
319
+ feat_to_block[idx.astype(int)] = t
320
+
321
+ # Missing rates: P(missing)
322
+ miss_rate = 1.0 - R.mean(axis=0).astype(np.float32)
323
+ miss_rate_t = torch.from_numpy(np.clip(miss_rate, 1e-4, 1 - 1e-4)).to(device)
324
+
325
+ miss_rate_y_t = None
326
+ if n_classes is not None and y is not None:
327
+ mr_y = np.zeros((n_classes, m), dtype=np.float32)
328
+ for c in range(n_classes):
329
+ mask = (y == c)
330
+ if mask.sum() < 2:
331
+ mr_y[c] = miss_rate
332
+ else:
333
+ mr_y[c] = 1.0 - R[mask].mean(axis=0)
334
+ miss_rate_y_t = torch.from_numpy(np.clip(mr_y, 1e-4, 1 - 1e-4)).to(device)
335
+
336
+ # Gaussianize continuous targets z by rank -> normal (unconditional)
337
+ Z = np.zeros((n, m), dtype=np.float32)
338
+ Z[:] = 0.0
339
+ for j in range(m):
340
+ if feature_specs[j].kind != "continuous":
341
+ continue
342
+ obs = (R[:, j] == 1) & np.isfinite(X[:, j])
343
+ vals = X[obs, j].astype(np.float64)
344
+ if vals.size < 8:
345
+ continue
346
+ order = np.argsort(vals)
347
+ ranks = np.empty_like(order)
348
+ ranks[order] = np.arange(vals.size)
349
+ u = (ranks + 0.5) / vals.size
350
+ u_t = torch.from_numpy(u.astype(np.float32))
351
+ z_t = approx_normal_icdf(u_t).numpy()
352
+ Z[obs, j] = z_t.astype(np.float32)
353
+
354
+ # Fit a_j, sigma_j, and b_j(y)
355
+ a = np.zeros(m, dtype=np.float32)
356
+ sigma = np.ones(m, dtype=np.float32)
357
+ b = None
358
+ if n_classes is not None:
359
+ b = np.zeros((n_classes, m), dtype=np.float32)
360
+
361
+ for j in range(m):
362
+ if feature_specs[j].kind != "continuous":
363
+ continue
364
+ t = feat_to_block[j]
365
+ hj = h_hat[:, t] # (n,)
366
+ obs = (R[:, j] == 1) & np.isfinite(X[:, j])
367
+
368
+ if obs.sum() < 10:
369
+ a[j] = 0.0
370
+ sigma[j] = 1.0
371
+ if b is not None:
372
+ b[:, j] = 0.0
373
+ continue
374
+
375
+ z = Z[obs, j].astype(np.float64)
376
+ h = hj[obs].astype(np.float64)
377
+
378
+ if (n_classes is not None) and (y is not None):
379
+ yy = y[obs]
380
+ # z ~ a*h + b_y
381
+ H = h.reshape(-1, 1)
382
+ OH = np.zeros((len(h), n_classes), dtype=np.float64)
383
+ OH[np.arange(len(h)), yy] = 1.0
384
+ A = np.concatenate([H, OH], axis=1)
385
+ coef, *_ = np.linalg.lstsq(A, z, rcond=None)
386
+ a[j] = float(coef[0])
387
+ b[:, j] = coef[1:].astype(np.float32)
388
+ resid = z - A @ coef
389
+ else:
390
+ # z ~ a*h + b0
391
+ H = np.stack([h, np.ones_like(h)], axis=1)
392
+ coef, *_ = np.linalg.lstsq(H, z, rcond=None)
393
+ a[j] = float(coef[0])
394
+ resid = z - (H @ coef)
395
+
396
+ sigma[j] = float(np.sqrt(np.maximum(np.mean(resid ** 2), 1e-4)))
397
+
398
+ a_t = torch.from_numpy(a).to(device)
399
+ sigma_t = torch.from_numpy(sigma).to(device)
400
+ b_t = torch.from_numpy(b).to(device) if b is not None else None
401
+
402
+ # Fit categorical parameters (simple torch training per feature)
403
+ cat_W: Dict[int, torch.Tensor] = {}
404
+ cat_c: Dict[int, torch.Tensor] = {}
405
+
406
+ for j in range(m):
407
+ fs = feature_specs[j]
408
+ if fs.kind != "categorical":
409
+ continue
410
+ K = fs.n_categories
411
+ t = feat_to_block[j]
412
+ hj = torch.from_numpy(h_hat[:, t].astype(np.float32)).to(device) # (n,)
413
+ obs = (R[:, j] == 1) & np.isfinite(X[:, j])
414
+ if obs.sum() < 10:
415
+ cat_W[j] = torch.zeros(K, device=device)
416
+ if n_classes is not None:
417
+ cat_c[j] = torch.zeros((n_classes, K), device=device)
418
+ else:
419
+ cat_c[j] = torch.zeros(K, device=device)
420
+ continue
421
+
422
+ xj = torch.from_numpy(X[obs, j].astype(np.int64)).to(device)
423
+ idx_obs = torch.from_numpy(np.where(obs)[0]).to(device)
424
+ hj_obs = hj[idx_obs]
425
+
426
+ if n_classes is not None and y is not None:
427
+ yj = torch.from_numpy(y[obs].astype(np.int64)).to(device)
428
+ W = torch.zeros(K, device=device, requires_grad=True)
429
+ Cb = torch.zeros((n_classes, K), device=device, requires_grad=True)
430
+ opt = torch.optim.Adam([W, Cb], lr=5e-2)
431
+ for _ in range(200):
432
+ logits = hj_obs[:, None] * W[None, :] + Cb[yj]
433
+ loss = F.cross_entropy(logits, xj)
434
+ opt.zero_grad()
435
+ loss.backward()
436
+ opt.step()
437
+ cat_W[j] = W.detach()
438
+ cat_c[j] = Cb.detach()
439
+ else:
440
+ W = torch.zeros(K, device=device, requires_grad=True)
441
+ b0 = torch.zeros(K, device=device, requires_grad=True)
442
+ opt = torch.optim.Adam([W, b0], lr=5e-2)
443
+ for _ in range(200):
444
+ logits = hj_obs[:, None] * W[None, :] + b0[None, :]
445
+ loss = F.cross_entropy(logits, xj)
446
+ opt.zero_grad()
447
+ loss.backward()
448
+ opt.step()
449
+ cat_W[j] = W.detach()
450
+ cat_c[j] = b0.detach()
451
+
452
+ return EmissionParams(
453
+ a=a_t,
454
+ sigma=sigma_t,
455
+ b=b_t,
456
+ cat_W=cat_W,
457
+ cat_c=cat_c,
458
+ miss_rate=miss_rate_t,
459
+ miss_rate_y=miss_rate_y_t,
460
+ )
461
+
462
+
463
+ # --------------------------
464
+ # Prior option 1: Diffusion on h (latent DDPM)
465
+ # --------------------------
466
+
467
+ class TimeEmbedding(nn.Module):
468
+ def __init__(self, dim: int):
469
+ super().__init__()
470
+ self.dim = dim
471
+
472
+ def forward(self, t: torch.Tensor) -> torch.Tensor:
473
+ half = self.dim // 2
474
+ freqs = torch.exp(
475
+ -math.log(10000.0) * torch.arange(half, device=t.device).float() / max(half - 1, 1)
476
+ )
477
+ args = t.float().unsqueeze(1) * freqs.unsqueeze(0)
478
+ emb = torch.cat([torch.sin(args), torch.cos(args)], dim=1)
479
+ if self.dim % 2 == 1:
480
+ emb = torch.cat([emb, torch.zeros_like(emb[:, :1])], dim=1)
481
+ return emb
482
+
483
+
484
+ class DiffusionPrior(nn.Module):
485
+ """
486
+ Diffusion prior on h in R^M:
487
+ - train denoiser eps_theta(h_t, t, y)
488
+ - sample via reverse steps
489
+ """
490
+ def __init__(
491
+ self,
492
+ M: int,
493
+ n_classes: Optional[int],
494
+ T: int = 200,
495
+ hidden: int = 256,
496
+ y_embed_dim: int = 64,
497
+ ):
498
+ super().__init__()
499
+ self.M = M
500
+ self.n_classes = n_classes
501
+ self.T = T
502
+
503
+ self.time_emb = TimeEmbedding(128)
504
+ self.y_embed = None
505
+ y_dim = 0
506
+ if n_classes is not None:
507
+ self.y_embed = nn.Embedding(n_classes, y_embed_dim)
508
+ y_dim = y_embed_dim
509
+
510
+ inp = M + 128 + y_dim
511
+ self.net = nn.Sequential(
512
+ nn.Linear(inp, hidden),
513
+ nn.SiLU(),
514
+ nn.Linear(hidden, hidden),
515
+ nn.SiLU(),
516
+ nn.Linear(hidden, M),
517
+ )
518
+
519
+ # simple linear beta schedule
520
+ beta_start = 1e-4
521
+ beta_end = 0.02
522
+ betas = torch.linspace(beta_start, beta_end, T)
523
+ alphas = 1.0 - betas
524
+ alpha_bar = torch.cumprod(alphas, dim=0)
525
+ self.register_buffer("betas", betas)
526
+ self.register_buffer("alphas", alphas)
527
+ self.register_buffer("alpha_bar", alpha_bar)
528
+
529
+ def eps_theta(self, h_t: torch.Tensor, t: torch.Tensor, y: Optional[torch.Tensor]) -> torch.Tensor:
530
+ te = self.time_emb(t)
531
+ parts = [h_t, te]
532
+ if self.n_classes is not None:
533
+ assert y is not None
534
+ parts.append(self.y_embed(y))
535
+ x = torch.cat(parts, dim=1)
536
+ return self.net(x)
537
+
538
+ def q_sample(self, h0: torch.Tensor, t: torch.Tensor, eps: torch.Tensor) -> torch.Tensor:
539
+ ab = self.alpha_bar[t].unsqueeze(1) # (b,1)
540
+ return torch.sqrt(ab) * h0 + torch.sqrt(1.0 - ab) * eps
541
+
542
+ def training_loss(self, h0: torch.Tensor, y: Optional[torch.Tensor]) -> torch.Tensor:
543
+ b = h0.size(0)
544
+ t = torch.randint(0, self.T, (b,), device=h0.device)
545
+ eps = torch.randn_like(h0)
546
+ h_t = self.q_sample(h0, t, eps)
547
+ eps_hat = self.eps_theta(h_t, t, y)
548
+ return F.mse_loss(eps_hat, eps)
549
+
550
+ @torch.no_grad()
551
+ def sample(
552
+ self,
553
+ n: int,
554
+ y: Optional[torch.Tensor],
555
+ device: torch.device,
556
+ steps: Optional[int] = None,
557
+ ) -> torch.Tensor:
558
+ _ = steps # kept for future; currently uses full schedule
559
+ h = torch.randn(n, self.M, device=device)
560
+ for t_int in reversed(range(self.T)):
561
+ t = torch.full((n,), t_int, device=device, dtype=torch.long)
562
+ eps_hat = self.eps_theta(h, t, y)
563
+ beta = self.betas[t_int]
564
+ alpha = self.alphas[t_int]
565
+ ab = self.alpha_bar[t_int]
566
+ mean = (1.0 / torch.sqrt(alpha)) * (h - (beta / torch.sqrt(1.0 - ab)) * eps_hat)
567
+ if t_int > 0:
568
+ noise = torch.randn_like(h)
569
+ h = mean + torch.sqrt(beta) * noise
570
+ else:
571
+ h = mean
572
+ return h
573
+
574
+
575
+ # --------------------------
576
+ # Prior option 2: Simple conditional RealNVP flow on h
577
+ # --------------------------
578
+
579
+ class AffineCoupling(nn.Module):
580
+ def __init__(self, dim: int, hidden: int, mask: torch.Tensor, cond_dim: int):
581
+ super().__init__()
582
+ self.dim = dim
583
+ self.register_buffer("mask", mask) # (dim,)
584
+ inp = dim + cond_dim
585
+ self.net = nn.Sequential(
586
+ nn.Linear(inp, hidden),
587
+ nn.ReLU(),
588
+ nn.Linear(hidden, hidden),
589
+ nn.ReLU(),
590
+ nn.Linear(hidden, 2 * dim), # scale, shift
591
+ )
592
+
593
+ def forward(self, x: torch.Tensor, cond: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
594
+ x_masked = x * self.mask
595
+ h = torch.cat([x_masked, cond], dim=1)
596
+ st = self.net(h)
597
+ s, t = st.chunk(2, dim=1)
598
+ s = torch.tanh(s)
599
+ y = x_masked + (1 - self.mask) * (x * torch.exp(s) + t)
600
+ logdet = ((1 - self.mask) * s).sum(dim=1)
601
+ return y, logdet
602
+
603
+ def inverse(self, y: torch.Tensor, cond: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
604
+ y_masked = y * self.mask
605
+ h = torch.cat([y_masked, cond], dim=1)
606
+ st = self.net(h)
607
+ s, t = st.chunk(2, dim=1)
608
+ s = torch.tanh(s)
609
+ x = y_masked + (1 - self.mask) * ((y - t) * torch.exp(-s))
610
+ logdet = -((1 - self.mask) * s).sum(dim=1)
611
+ return x, logdet
612
+
613
+
614
+ class FlowPrior(nn.Module):
615
+ def __init__(
616
+ self,
617
+ M: int,
618
+ n_classes: Optional[int],
619
+ n_layers: int = 6,
620
+ hidden: int = 256,
621
+ y_embed_dim: int = 64,
622
+ ):
623
+ super().__init__()
624
+ self.M = M
625
+ self.n_classes = n_classes
626
+ self.y_embed = None
627
+ cond_dim = 0
628
+ if n_classes is not None:
629
+ self.y_embed = nn.Embedding(n_classes, y_embed_dim)
630
+ cond_dim = y_embed_dim
631
+
632
+ masks = []
633
+ for k in range(n_layers):
634
+ mask = torch.zeros(M)
635
+ mask[k % 2::2] = 1.0
636
+ masks.append(mask)
637
+
638
+ self.layers = nn.ModuleList([AffineCoupling(M, hidden, masks[k], cond_dim) for k in range(n_layers)])
639
+
640
+ def _cond(self, y: Optional[torch.Tensor], device: torch.device, n: int) -> torch.Tensor:
641
+ if self.n_classes is None:
642
+ return torch.zeros(n, 0, device=device)
643
+ assert y is not None
644
+ return self.y_embed(y)
645
+
646
+ def log_prob(self, h: torch.Tensor, y: Optional[torch.Tensor]) -> torch.Tensor:
647
+ device = h.device
648
+ cond = self._cond(y, device, h.size(0))
649
+ z = h
650
+ logdet_sum = torch.zeros(h.size(0), device=device)
651
+ for layer in self.layers:
652
+ z, logdet = layer.forward(z, cond)
653
+ logdet_sum += logdet
654
+ log_pz = -0.5 * (z ** 2).sum(dim=1) - 0.5 * self.M * math.log(2 * math.pi)
655
+ return log_pz + logdet_sum
656
+
657
+ @torch.no_grad()
658
+ def sample(self, n: int, y: Optional[torch.Tensor], device: torch.device) -> torch.Tensor:
659
+ cond = self._cond(y, device, n)
660
+ z = torch.randn(n, self.M, device=device)
661
+ x = z
662
+ for layer in reversed(self.layers):
663
+ x, _ = layer.inverse(x, cond)
664
+ return x
665
+
666
+
667
+ # --------------------------
668
+ # Main model: BlockSubunitGenerator
669
+ # --------------------------
670
+
671
+ class BlockSubunitGenerator:
672
+ """
673
+ End-to-end generator with:
674
+ - feature specs
675
+ - blocks + optional permutation
676
+ - emission params (fitted from inferred h)
677
+ - marginals (empirical inverse CDFs)
678
+ - prior on h: diffusion OR flow
679
+
680
+ Typical flow:
681
+ 1) Provide blocks (or build via make_equal_blocks)
682
+ 2) Fit marginals from X, R (and y if class-conditional)
683
+ 3) Infer h_hat from X, R
684
+ 4) Fit emission params from (X,R,y,h_hat)
685
+ 5) Train prior on h_hat (diffusion loss or flow NLL)
686
+ 6) Sample: y -> h -> (R, X) -> optional permutation
687
+ """
688
+
689
+ def __init__(
690
+ self,
691
+ feature_specs: List[FeatureSpec],
692
+ blocks: List[np.ndarray],
693
+ n_classes: Optional[int],
694
+ prior_type: str = "diffusion", # "diffusion" or "flow"
695
+ device: Union[str, torch.device] = "cpu",
696
+ use_class_cond_marginals: bool = True,
697
+ use_class_cond_missingness: bool = True,
698
+ ):
699
+ self.device = torch.device(device)
700
+ self.feature_specs = feature_specs
701
+ self.m = len(feature_specs)
702
+ _validate_feature_specs(feature_specs, self.m)
703
+
704
+ self.blocks = blocks
705
+ self.M = len(blocks)
706
+ self.n_classes = n_classes
707
+
708
+ self.use_class_cond_marginals = bool(use_class_cond_marginals and n_classes is not None)
709
+ self.use_class_cond_missingness = bool(use_class_cond_missingness and n_classes is not None)
710
+
711
+ self.marginals = EmpiricalMarginals(
712
+ m=self.m,
713
+ n_classes=self.n_classes if self.use_class_cond_marginals else None,
714
+ device=self.device,
715
+ )
716
+
717
+ self.emission: Optional[EmissionParams] = None
718
+
719
+ prior_type = prior_type.lower().strip()
720
+ if prior_type not in ("diffusion", "flow"):
721
+ raise ValueError("prior_type must be 'diffusion' or 'flow'")
722
+ self.prior_type = prior_type
723
+
724
+ if self.prior_type == "diffusion":
725
+ self.prior = DiffusionPrior(M=self.M, n_classes=self.n_classes).to(self.device)
726
+ else:
727
+ self.prior = FlowPrior(M=self.M, n_classes=self.n_classes).to(self.device)
728
+
729
+ self.perm: Optional[np.ndarray] = None
730
+ self.inv_perm: Optional[np.ndarray] = None
731
+
732
+ def set_permutation(self, perm: Optional[np.ndarray]) -> None:
733
+ if perm is None:
734
+ self.perm = None
735
+ self.inv_perm = None
736
+ return
737
+ perm = np.asarray(perm).astype(int)
738
+ if perm.shape != (self.m,):
739
+ raise ValueError(f"perm must have shape ({self.m},)")
740
+ self.perm = perm
741
+ self.inv_perm = invert_permutation(perm)
742
+
743
+ # --------------------------
744
+ # Fit routines
745
+ # --------------------------
746
+
747
+ def fit_marginals(self, X: np.ndarray, R: np.ndarray, y: Optional[np.ndarray] = None) -> None:
748
+ self.marginals.fit(X=X, R=R, feature_specs=self.feature_specs, y=y)
749
+
750
+ def infer_h(self, X: np.ndarray, R: np.ndarray, y: Optional[np.ndarray] = None) -> np.ndarray:
751
+ return infer_block_latents_mean_gaussianized(X=X, R=R, blocks=self.blocks, feature_specs=self.feature_specs, y=y)
752
+
753
+ def fit_emissions(self, X: np.ndarray, R: np.ndarray, y: Optional[np.ndarray], h_hat: np.ndarray) -> None:
754
+ self.emission = fit_emissions_from_inferred_h(
755
+ X=X,
756
+ R=R,
757
+ y=y,
758
+ blocks=self.blocks,
759
+ feature_specs=self.feature_specs,
760
+ h_hat=h_hat,
761
+ n_classes=self.n_classes,
762
+ device=self.device,
763
+ )
764
+
765
+ def train_prior(
766
+ self,
767
+ h_hat: np.ndarray,
768
+ y: Optional[np.ndarray] = None,
769
+ epochs: int = 2000,
770
+ batch_size: int = 128,
771
+ lr: float = 1e-3,
772
+ weight_decay: float = 0.0,
773
+ verbose_every: int = 200,
774
+ # NEW:
775
+ save_dir: Optional[str] = None,
776
+ save_name: str = "blocksubunit",
777
+ save_best: bool = True,
778
+ use_ema: bool = True,
779
+ ema_decay: float = 0.999,
780
+ return_train_info: bool = False,
781
+ ) -> Union[None, Dict[str, Union[int, float, str, bool, None]]]:
782
+ """
783
+ Train diffusion prior (MSE eps loss) OR flow prior (negative log-likelihood) on inferred h_hat.
784
+
785
+ New behavior:
786
+ - Tracks best (lowest) loss and loads it at end.
787
+ - Optionally uses EMA for diffusion and saves/loads EMA weights as "best".
788
+ - Optionally saves best checkpoint to disk.
789
+ """
790
+ h_t = torch.from_numpy(h_hat.astype(np.float32)).to(self.device)
791
+ n = h_t.size(0)
792
+
793
+ y_t = None
794
+ if self.n_classes is not None:
795
+ if y is None:
796
+ raise ValueError("n_classes is not None but y is None.")
797
+ y_t = torch.from_numpy(np.asarray(y).astype(np.int64)).to(self.device)
798
+
799
+ opt = torch.optim.Adam(self.prior.parameters(), lr=lr, weight_decay=weight_decay)
800
+
801
+ if save_dir is not None:
802
+ os.makedirs(save_dir, exist_ok=True)
803
+
804
+ train_info: Dict[str, Union[int, float, str, bool, None]] = {
805
+ "best_epoch": None,
806
+ "best_loss": float("inf"),
807
+ "best_ckpt_path": None,
808
+ "loaded_at_end": False,
809
+ "prior_type": self.prior_type,
810
+ "used_ema": bool(use_ema and self.prior_type == "diffusion"),
811
+ "ema_decay": float(ema_decay) if (use_ema and self.prior_type == "diffusion") else None,
812
+ }
813
+
814
+ ema = None
815
+ if self.prior_type == "diffusion" and use_ema:
816
+ ema = EMA(self.prior, decay=ema_decay)
817
+
818
+ best_state: Optional[Dict[str, torch.Tensor]] = None
819
+
820
+ for ep in range(1, epochs + 1):
821
+ idx = torch.randint(0, n, (min(batch_size, n),), device=self.device)
822
+ hb = h_t[idx]
823
+ yb = y_t[idx] if y_t is not None else None
824
+
825
+ if self.prior_type == "diffusion":
826
+ loss = self.prior.training_loss(hb, yb)
827
+ else:
828
+ logp = self.prior.log_prob(hb, yb)
829
+ loss = -logp.mean()
830
+
831
+ opt.zero_grad()
832
+ loss.backward()
833
+ torch.nn.utils.clip_grad_norm_(self.prior.parameters(), 1.0)
834
+ opt.step()
835
+
836
+ # EMA update after optimizer step
837
+ if ema is not None:
838
+ ema.update(self.prior)
839
+
840
+ loss_val = float(loss.detach().cpu().item())
841
+
842
+ if verbose_every and (ep % verbose_every == 0 or ep == 1 or ep == epochs):
843
+ print(f"[prior:{self.prior_type}] epoch {ep}/{epochs} | loss={loss_val:.6f}")
844
+
845
+ if save_best and loss_val < float(train_info["best_loss"]):
846
+ train_info["best_loss"] = loss_val
847
+ train_info["best_epoch"] = ep
848
+
849
+ # store best weights (EMA for diffusion if enabled; otherwise raw weights)
850
+ if ema is not None:
851
+ best_state = copy.deepcopy(ema.state_dict())
852
+ else:
853
+ best_state = copy.deepcopy(self.prior.state_dict())
854
+
855
+ if save_dir is not None:
856
+ ckpt_path = os.path.join(save_dir, f"{save_name}_best.pt")
857
+ torch.save(
858
+ {
859
+ "epoch": ep,
860
+ "best_loss": loss_val,
861
+ "prior_type": self.prior_type,
862
+ "used_ema": bool(ema is not None),
863
+ "ema_decay": float(ema_decay) if (ema is not None) else None,
864
+ "prior_state_dict": best_state,
865
+ },
866
+ ckpt_path,
867
+ )
868
+ train_info["best_ckpt_path"] = ckpt_path
869
+
870
+ # load best at end
871
+ if save_best and best_state is not None:
872
+ self.prior.load_state_dict(best_state, strict=True)
873
+ train_info["loaded_at_end"] = True
874
+
875
+ if return_train_info:
876
+ return train_info
877
+ return None
878
+
879
+ # --------------------------
880
+ # Sampling routines
881
+ # --------------------------
882
+
883
+ def _sample_y(self, n: int, y: Optional[Union[int, np.ndarray]] = None) -> Optional[torch.Tensor]:
884
+ if self.n_classes is None:
885
+ return None
886
+ if y is None:
887
+ return torch.randint(0, self.n_classes, (n,), device=self.device)
888
+ if isinstance(y, int):
889
+ return torch.full((n,), int(y), device=self.device, dtype=torch.long)
890
+ y = np.asarray(y).astype(int)
891
+ if y.shape != (n,):
892
+ raise ValueError("If y is an array, it must have shape (n,).")
893
+ return torch.from_numpy(y.astype(np.int64)).to(self.device)
894
+
895
+ @torch.no_grad()
896
+ def sample_h(self, n: int, y: Optional[Union[int, np.ndarray]] = None) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
897
+ y_t = self._sample_y(n, y)
898
+ h = self.prior.sample(n=n, y=y_t, device=self.device) if self.prior_type == "diffusion" else self.prior.sample(n=n, y=y_t, device=self.device)
899
+ return h, y_t
900
+
901
+ @torch.no_grad()
902
+ def sample(
903
+ self,
904
+ n: int,
905
+ y: Optional[Union[int, np.ndarray]] = None,
906
+ apply_perm: bool = True,
907
+ ) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
908
+ """
909
+ Generate synthetic samples:
910
+ returns X_out (n,m) float32 (categoricals stored as float codes),
911
+ R_out (n,m) int64 {0,1},
912
+ y_out (n,) int64 or None.
913
+ """
914
+ if self.emission is None:
915
+ raise RuntimeError("Emission parameters not fitted. Call fit_emissions() first.")
916
+ em = self.emission
917
+
918
+ h, y_t = self.sample_h(n, y=y)
919
+
920
+ # Missingness
921
+ if self.n_classes is not None and self.use_class_cond_missingness and em.miss_rate_y is not None and y_t is not None:
922
+ miss_p = em.miss_rate_y[y_t] # (n,m)
923
+ else:
924
+ miss_p = em.miss_rate.unsqueeze(0).expand(n, self.m)
925
+
926
+ U_m = torch.rand_like(miss_p)
927
+ R = (U_m > miss_p).long() # 1 observed, 0 missing
928
+
929
+ # Initialize X (canonical)
930
+ X = torch.empty(n, self.m, device=self.device, dtype=torch.float32)
931
+
932
+ # feature -> block
933
+ ftb_np = np.zeros(self.m, dtype=int)
934
+ for t, idx in enumerate(self.blocks):
935
+ ftb_np[idx.astype(int)] = t
936
+ feat_to_block = torch.from_numpy(ftb_np).to(self.device)
937
+
938
+ for j in range(self.m):
939
+ t = int(feat_to_block[j].item())
940
+ hj = h[:, t] # (n,)
941
+ miss = (R[:, j] == 0)
942
+
943
+ if self.feature_specs[j].kind == "continuous":
944
+ mu = em.a[j] * hj
945
+ if self.n_classes is not None and em.b is not None and y_t is not None:
946
+ mu = mu + em.b[y_t, j]
947
+ sig = torch.clamp(em.sigma[j], min=1e-4)
948
+ z = mu + sig * torch.randn_like(mu)
949
+ u01 = erf_normal_cdf(z)
950
+
951
+ if self.use_class_cond_marginals and y_t is not None:
952
+ xj = torch.empty_like(u01)
953
+ for c in range(self.n_classes):
954
+ mask_c = (y_t == c)
955
+ if mask_c.any():
956
+ xj[mask_c] = self.marginals.inverse_cdf(u01[mask_c], j=j, y=int(c))
957
+ else:
958
+ xj = self.marginals.inverse_cdf(u01, j=j, y=None)
959
+
960
+ xj = xj.float()
961
+ xj[miss] = float("nan")
962
+ X[:, j] = xj
963
+
964
+ else:
965
+ fs = self.feature_specs[j]
966
+ K = fs.n_categories
967
+ W = em.cat_W.get(j, torch.zeros(K, device=self.device))
968
+ default_c = torch.zeros((self.n_classes, K), device=self.device) if self.n_classes else torch.zeros(K, device=self.device)
969
+ Cb = em.cat_c.get(j, default_c)
970
+
971
+ if self.n_classes is not None and y_t is not None and Cb.dim() == 2:
972
+ logits = hj[:, None] * W[None, :] + Cb[y_t]
973
+ else:
974
+ logits = hj[:, None] * W[None, :] + Cb[None, :]
975
+
976
+ probs = F.softmax(logits, dim=1)
977
+ cat = torch.multinomial(probs, num_samples=1).squeeze(1).float()
978
+ cat[miss] = float("nan")
979
+ X[:, j] = cat
980
+
981
+ X_out = X.detach().cpu().numpy().astype(np.float32)
982
+ R_out = R.detach().cpu().numpy().astype(np.int64)
983
+ y_out = y_t.detach().cpu().numpy().astype(np.int64) if y_t is not None else None
984
+
985
+ if apply_perm and (self.perm is not None):
986
+ X_out = X_out[:, self.perm]
987
+ R_out = R_out[:, self.perm]
988
+
989
+ return X_out, R_out, y_out
990
+
991
+
992
+ # --------------------------
993
+ # Convenience: end-to-end fit helper
994
+ # --------------------------
995
+
996
+ def fit_block_subunit_generator(
997
+ X: np.ndarray,
998
+ feature_specs: List[FeatureSpec],
999
+ y: Optional[np.ndarray] = None,
1000
+ M: int = 32,
1001
+ blocks: Optional[List[np.ndarray]] = None,
1002
+ permute_features: bool = False,
1003
+ prior_type: str = "diffusion",
1004
+ device: str = "cpu",
1005
+ seed: int = 0,
1006
+ prior_epochs: int = 1500,
1007
+ prior_batch: int = 128,
1008
+ prior_lr: float = 1e-3,
1009
+ verbose_every: int = 200,
1010
+ # NEW:
1011
+ save_dir: Optional[str] = None,
1012
+ save_name: str = "blocksubunit",
1013
+ save_best: bool = True,
1014
+ use_ema: bool = True,
1015
+ ema_decay: float = 0.999,
1016
+ return_train_info: bool = False,
1017
+ ) -> Union[BlockSubunitGenerator, Tuple[BlockSubunitGenerator, Dict[str, Union[int, float, str, bool, None]]]]:
1018
+ """
1019
+ One-shot fitting pipeline:
1020
+ - create blocks
1021
+ - create mask R from nan
1022
+ - fit marginals
1023
+ - infer h_hat
1024
+ - fit emissions
1025
+ - train prior on h_hat (with best checkpoint + optional EMA)
1026
+ """
1027
+ set_seed(seed)
1028
+ X = np.asarray(X)
1029
+ n, m = X.shape
1030
+ _validate_feature_specs(feature_specs, m)
1031
+
1032
+ # Missing mask: treat nan as missing for all kinds
1033
+ R = np.isfinite(X).astype(np.int64)
1034
+
1035
+ if blocks is None:
1036
+ blocks = make_equal_blocks(m=m, M=M)
1037
+
1038
+ n_classes = None
1039
+ if y is not None:
1040
+ y = np.asarray(y).astype(int)
1041
+ n_classes = int(y.max()) + 1
1042
+
1043
+ gen = BlockSubunitGenerator(
1044
+ feature_specs=feature_specs,
1045
+ blocks=blocks,
1046
+ n_classes=n_classes,
1047
+ prior_type=prior_type,
1048
+ device=device,
1049
+ use_class_cond_marginals=True,
1050
+ use_class_cond_missingness=True,
1051
+ )
1052
+
1053
+ if permute_features:
1054
+ perm = np.random.permutation(m)
1055
+ gen.set_permutation(perm)
1056
+ else:
1057
+ gen.set_permutation(None)
1058
+
1059
+ # Fit marginals on canonical space (unpermuted data assumed)
1060
+ gen.fit_marginals(X=X, R=R, y=y)
1061
+
1062
+ # Infer h
1063
+ h_hat = gen.infer_h(X=X, R=R, y=y)
1064
+
1065
+ # Fit emissions
1066
+ gen.fit_emissions(X=X, R=R, y=y, h_hat=h_hat)
1067
+
1068
+ # Train prior (+ best / EMA)
1069
+ train_info = gen.train_prior(
1070
+ h_hat=h_hat,
1071
+ y=y,
1072
+ epochs=prior_epochs,
1073
+ batch_size=prior_batch,
1074
+ lr=prior_lr,
1075
+ verbose_every=verbose_every,
1076
+ save_dir=save_dir,
1077
+ save_name=save_name,
1078
+ save_best=save_best,
1079
+ use_ema=use_ema,
1080
+ ema_decay=ema_decay,
1081
+ return_train_info=True,
1082
+ )
1083
+
1084
+ if return_train_info:
1085
+ return gen, train_info # type: ignore[return-value]
1086
+ return gen
1087
+
1088
+
1089
+ # --------------------------
1090
+ # Minimal usage example (remove or keep)
1091
+ # --------------------------
1092
+ if __name__ == "__main__":
1093
+ set_seed(0)
1094
+ n, m = 80, 200
1095
+ X = np.random.randn(n, m).astype(np.float32)
1096
+ mask = np.random.rand(n, m) > 0.1
1097
+ X[~mask] = np.nan
1098
+
1099
+ feature_specs = [FeatureSpec(name=f"f{j}", kind="continuous") for j in range(m)]
1100
+ y = np.random.randint(0, 2, size=n)
1101
+
1102
+ gen, info = fit_block_subunit_generator(
1103
+ X=X,
1104
+ feature_specs=feature_specs,
1105
+ y=y,
1106
+ M=20,
1107
+ prior_type="diffusion",
1108
+ device="cpu",
1109
+ prior_epochs=300,
1110
+ verbose_every=100,
1111
+ save_dir="checkpoints_demo",
1112
+ save_name="demo_M20_diff",
1113
+ save_best=True,
1114
+ use_ema=True,
1115
+ ema_decay=0.999,
1116
+ return_train_info=True,
1117
+ )
1118
+
1119
+ print("Train info:", info)
1120
+
1121
+ X_syn, R_syn, y_syn = gen.sample(n=50)
1122
+ print("Synthetic shapes:", X_syn.shape, R_syn.shape, y_syn.shape if y_syn is not None else None)