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)
|