Coverage for /opt/conda/lib/python3.14/site-packages/medil/models.py: 89%

247 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 02:29 +0000

1"""MeDIL causal model classes for linear Gaussian and deep generative settings.""" 

2 

3import copy 

4import os 

5import pickle 

6import random 

7import warnings 

8from datetime import datetime 

9from pathlib import Path 

10 

11import numpy as np 

12import numpy.typing as npt 

13from numpy.random import default_rng 

14from scipy.optimize import minimize 

15from sklearn.model_selection import train_test_split 

16 

17from ._ecc_algorithms import _find_heuristic_1pc 

18from ._independence_testing import _estimate_UDG 

19 

20try: 

21 import torch 

22 import torch.nn.functional as F 

23 from torch.utils.data import DataLoader, TensorDataset 

24 from tqdm import tqdm 

25 from ._vae import VariationalAutoencoder 

26 _TORCH_AVAILABLE = True 

27except ImportError: 

28 _TORCH_AVAILABLE = False 

29 

30 

31class _MedilCausalModel(object): 

32 """Base class using principle of polymorphism to establish common 

33 interface for derived parametric estimators. 

34 """ 

35 

36 def __init__( 

37 self, 

38 biadj: npt.NDArray = np.array([]), 

39 udg: npt.NDArray = np.array([]), 

40 one_pure_child: bool = True, 

41 rng=default_rng(0), 

42 ) -> None: 

43 self.biadj = biadj 

44 self.udg = udg 

45 self.one_pure_child = one_pure_child 

46 self.rng = rng 

47 

48 def fit(self, dataset: npt.NDArray) -> "_MedilCausalModel": 

49 raise NotImplementedError 

50 

51 def sample(self, sample_size: int) -> npt.NDArray: 

52 raise NotImplementedError 

53 

54 

55class _Parameters(object): 

56 "Different parameterizations of MeDIL causal Models." 

57 

58 def __init__(self, parameterization: str) -> None: 

59 self.parameterization = parameterization 

60 

61 if parameterization == "Gaussian": 

62 self.error_means = np.array([]) 

63 self.error_variances = np.array([]) 

64 self.biadj_weights = np.array([]) 

65 elif parameterization == "VAE": 

66 self.weights = np.array([]) 

67 self.vae = None 

68 

69 def __str__(self) -> str: 

70 return "\n".join( 

71 f"parameters.{attr}: {val}" for attr, val in vars(self).items() 

72 ) 

73 

74 

75class GaussianMCM(_MedilCausalModel): 

76 """Linear Gaussian MeDIL causal model. 

77 

78 Learns a bipartite latent→measurement causal structure and estimates 

79 linear Gaussian parameters (edge weights, error means, error variances) 

80 by constraint-based structure learning and least-squares optimization 

81 of the covariance matrix. 

82 

83 Parameters 

84 ---------- 

85 biadj : ndarray of shape (num_latent, num_meas), optional 

86 Boolean biadjacency matrix. If empty (default), estimated from data 

87 during :meth:`fit`. 

88 udg : ndarray of shape (num_meas, num_meas), optional 

89 Boolean undirected dependence graph over observed variables. If empty 

90 (default), estimated from data during :meth:`fit`. 

91 rng : numpy.random.Generator, optional 

92 Random number generator used during :meth:`sample`. 

93 

94 Attributes 

95 ---------- 

96 biadj : ndarray of shape (num_latent, num_meas) 

97 Boolean biadjacency matrix (set after :meth:`fit` or at init). 

98 parameters : object 

99 Learned parameters with attributes ``biadj_weights`` 

100 (shape ``(num_latent, num_meas)``), ``error_means`` 

101 (shape ``(num_meas,)``), and ``error_variances`` 

102 (shape ``(num_meas,)``). 

103 """ 

104 

105 def __init__(self, **kwargs): 

106 super().__init__(**kwargs) 

107 self.parameters = _Parameters("Gaussian") 

108 

109 def fit(self, dataset: npt.NDArray) -> "GaussianMCM": 

110 """Fit a GaussianMCM to a dataset. 

111 

112 Estimates the biadjacency matrix via constraint-based structure 

113 learning (if not pre-specified), then estimates edge weights and 

114 error variances by least-squares optimization of the covariance. 

115 

116 Parameters 

117 ---------- 

118 dataset : ndarray of shape (n_samples, n_features) 

119 Observed data matrix. Rows are observations, columns are variables. 

120 

121 Returns 

122 ------- 

123 self : GaussianMCM 

124 """ 

125 self.dataset = dataset 

126 if self.biadj.size == 0: 

127 self._compute_biadj() 

128 

129 self.parameters.error_means = self.dataset.mean(0) 

130 

131 cov = np.cov(self.dataset, rowvar=False) 

132 

133 num_weights = self.biadj.sum() 

134 num_err_vars = self.biadj.shape[1] 

135 

136 def _objective(weights_and_err_vars): 

137 weights = weights_and_err_vars[:num_weights] 

138 err_vars = weights_and_err_vars[num_weights:] 

139 

140 biadj_weights = np.zeros_like(self.biadj, float) 

141 biadj_weights[self.biadj] = weights 

142 

143 return ( 

144 (cov - biadj_weights.T @ biadj_weights - np.diagflat(err_vars)) ** 2 

145 ).sum() 

146 

147 result = minimize(_objective, np.ones(num_weights + num_err_vars)) 

148 if not result.success: 

149 warnings.warn(f"Optimization failed: {result.message}") 

150 

151 self.parameters.error_variances = result.x[num_weights:] 

152 

153 self.parameters.biadj_weights = np.zeros_like(self.biadj, float) 

154 self.parameters.biadj_weights[self.biadj] = result.x[:num_weights] 

155 

156 return self 

157 

158 def _compute_biadj(self): 

159 """Constraint-based structure learning.""" 

160 if self.udg.size == 0: 

161 self._estimate_udg() 

162 self.biadj = _find_heuristic_1pc(self.udg) 

163 

164 def _estimate_udg(self): 

165 """Constraint-based structure learning.""" 

166 samp_size = len(self.dataset) 

167 cov = np.cov(self.dataset, rowvar=False) 

168 corr = np.corrcoef(self.dataset, rowvar=False) 

169 inner_numerator = 1 - cov * corr # should never be <= 0? 

170 inner_numerator = inner_numerator.clip(min=0.00001) 

171 inner_numerator[np.tril_indices_from(inner_numerator)] = 1 

172 udg_triu = np.log(inner_numerator) < (-np.log(samp_size) / samp_size) 

173 udg = udg_triu + udg_triu.T 

174 self.udg = udg 

175 

176 def sample(self, sample_size: int, include_latent: bool = False) -> npt.NDArray: 

177 """Sample observations from a GaussianMCM. 

178 

179 Requires ``biadj`` and ``parameters`` to be set, either by calling 

180 :meth:`fit` or by constructing the model via :func:`medil.sample.mcm`. 

181 

182 Parameters 

183 ---------- 

184 sample_size : int 

185 Number of observations to draw. 

186 include_latent : bool, optional 

187 If True, also return the sampled latent variables. 

188 

189 Returns 

190 ------- 

191 samples : ndarray of shape (sample_size, num_meas) 

192 latent_samples : ndarray of shape (sample_size, num_latent), only if include_latent=True 

193 """ 

194 num_latent = len(self.biadj) 

195 latent_sample = self.rng.multivariate_normal( 

196 np.zeros(num_latent), np.eye(num_latent), sample_size 

197 ) 

198 error_sample = self.rng.multivariate_normal( 

199 self.parameters.error_means, 

200 np.diagflat(self.parameters.error_variances), 

201 sample_size, 

202 ) 

203 sample = latent_sample @ self.parameters.biadj_weights + error_sample 

204 

205 return (sample, latent_sample) if include_latent else sample 

206 

207 

208class NeuroCausalFactorAnalysis(_MedilCausalModel): 

209 """Nonlinear MeDIL causal model represented by a masked variational autoencoder. 

210 

211 Learns a nonlinear MeDIL causal model in two phases: pairwise independence 

212 tests identify the causal factor structure (``biadj``), then a masked VAE 

213 whose decoder connectivity encodes that structure is trained to learn 

214 nonlinear generative mechanisms :cite:`markham2023neuro`. 

215 

216 Requires PyTorch: ``pip install medil[ncfa]``. 

217 

218 For continuous data, input should be standardized (zero mean, unit variance 

219 per feature) before calling :meth:`fit`. For categorical data, pass raw 

220 class indices (integers 0 to K-1) and set ``hyperparams["num_classes"] = K``. 

221 

222 Parameters 

223 ---------- 

224 seed : int, optional 

225 Random seed for reproducibility. Default 0. 

226 log_path : str, optional 

227 Directory for training artifacts (model weights, loss history). 

228 Created if it does not exist. No artifacts written if empty (default). 

229 verbose : bool, optional 

230 Print timestamped training log entries to stdout. Default False. 

231 biadj : ndarray of shape (num_latent, num_meas), optional 

232 Boolean biadjacency matrix. If empty (default), estimated from data 

233 during :meth:`fit` using xi correlation. 

234 **kwargs 

235 Additional keyword arguments passed to the base class (``udg``, ``rng``). 

236 

237 Attributes 

238 ---------- 

239 biadj : ndarray of shape (num_latent, num_meas) 

240 Boolean biadjacency matrix (set after :meth:`fit` or at init). 

241 parameters : object 

242 Learned VAE, accessible as ``parameters.vae``. 

243 loss : dict or None 

244 Train/validation ELBO and reconstruction losses after :meth:`fit`, 

245 keyed by ``"elbo_train"``, ``"elbo_valid"``, ``"recon_train"``, 

246 ``"recon_valid"``. 

247 hyperparams : dict 

248 Training hyperparameters. Modify via ``model.hyperparams.update({...})`` 

249 before calling :meth:`fit`. Keys: 

250 

251 - ``"method"`` : independence test for structure learning 

252 (``"xicor"``, ``"dcov_fast"``, or ``"g-test"`` for integer data; 

253 default ``"xicor"``) 

254 - ``"alpha"`` : significance level for independence tests (default 0.05) 

255 - ``"num_epochs"`` : maximum training epochs (default 200) 

256 - ``"lr"`` : AdamW learning rate (default 1e-3) 

257 - ``"beta"`` : KL weight in the ELBO (default 1.0) 

258 - ``"latent_width"`` : hidden units per latent variable in the decoder (default 2) 

259 - ``"meas_width"`` : hidden units per measurement variable in the decoder (default 2) 

260 - ``"num_hidden_layers"`` : number of decoder hidden layers (default 1) 

261 - ``"encoder_hidden_dim"`` : hidden dimension of the encoder MLP (default 64) 

262 - ``"batch_size"`` : mini-batch size (default 128) 

263 - ``"early_stopping"`` : stop when validation ELBO stagnates (default True) 

264 - ``"patience"`` : early stopping patience in epochs (default 20) 

265 - ``"min_delta"`` : minimum ELBO improvement to reset patience (default 1e-4) 

266 - ``"num_classes"`` : number of categories per measurement (1 = continuous 

267 with MSE reconstruction, K ≥ 2 = categorical with cross-entropy loss; 

268 a single K is applied uniformly to all measurements — if some variables 

269 have fewer than K categories the model trains correctly but 

270 :meth:`sample` may return out-of-range class indices; default 1) 

271 """ 

272 

273 def __init__( 

274 self, 

275 seed: int = 0, 

276 log_path: str = "", 

277 verbose: bool = False, 

278 **kwargs, 

279 ): 

280 if not _TORCH_AVAILABLE: 

281 raise ImportError( 

282 "NeuroCausalFactorAnalysis requires PyTorch. " 

283 "Install it with: pip install medil[ncfa]" 

284 ) 

285 super().__init__(**kwargs) 

286 

287 if log_path: 

288 Path(log_path).mkdir(exist_ok=True) 

289 

290 self.log_path = log_path 

291 self.verbose = verbose 

292 self.seed = seed 

293 

294 self.hyperparams = { 

295 "method": "xicor", 

296 "alpha": 0.05, 

297 "batch_size": 128, 

298 "num_epochs": 200, 

299 "lr": 1e-3, 

300 "beta": 1.0, 

301 "latent_width": 2, 

302 "meas_width": 2, 

303 "num_hidden_layers": 1, 

304 "encoder_hidden_dim": 64, 

305 "shuffle": True, 

306 "early_stopping": True, 

307 "patience": 20, 

308 "min_delta": 1e-4, 

309 "num_classes": 1, 

310 } 

311 

312 self.parameters = _Parameters("VAE") 

313 self.loss = None 

314 self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") 

315 

316 def _log(self, entry: str) -> None: 

317 if not (self.log_path or self.verbose): 

318 return 

319 

320 time_stamped_entry = f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {entry}" 

321 

322 if self.log_path: 

323 with open(os.path.join(self.log_path, "training.log"), "a") as log_file: 

324 log_file.write(time_stamped_entry + "\n") 

325 

326 if self.verbose: 

327 print(time_stamped_entry) 

328 

329 def _set_deterministic_seed(self): 

330 os.environ["PYTHONHASHSEED"] = str(self.seed) 

331 random.seed(self.seed) 

332 np.random.seed(self.seed) 

333 

334 torch.manual_seed(self.seed) 

335 if torch.cuda.is_available(): 

336 torch.cuda.manual_seed(self.seed) 

337 torch.cuda.manual_seed_all(self.seed) 

338 

339 torch.backends.cudnn.deterministic = True 

340 torch.backends.cudnn.benchmark = False 

341 torch.use_deterministic_algorithms(True) 

342 

343 def fit(self, dataset: npt.NDArray, split_idcs=None) -> "NeuroCausalFactorAnalysis": 

344 """Fit a NeuroCausalFactorAnalysis model to a dataset using a masked VAE. 

345 

346 Parameters 

347 ---------- 

348 dataset : ndarray of shape (n_samples, n_features) 

349 Observed data matrix. Should be standardized (zero mean, unit 

350 variance per feature) for best results. 

351 split_idcs : tuple of index arrays, optional 

352 (train_indices, valid_indices). If None, a 70/30 train/valid split 

353 is created automatically using self.seed. 

354 

355 Returns 

356 ------- 

357 self : NeuroCausalFactorAnalysis 

358 """ 

359 self._set_deterministic_seed() 

360 self.dataset = dataset 

361 

362 if self.biadj.size == 0: 

363 self._compute_biadj() 

364 

365 if split_idcs is None: 

366 train_split, valid_split = train_test_split( 

367 dataset, train_size=0.7, random_state=self.seed 

368 ) 

369 else: 

370 train_split = dataset[split_idcs[0]] 

371 valid_split = dataset[split_idcs[1]] 

372 

373 train_loader = self._data_loader(train_split) 

374 valid_loader = self._data_loader(valid_split) 

375 

376 model_recon, loss_recon, error_recon = self._train_vae( 

377 train_loader, valid_loader 

378 ) 

379 

380 if self.log_path: 

381 torch.save( 

382 model_recon.state_dict(), os.path.join(self.log_path, "model_recon.pt") 

383 ) 

384 with open(os.path.join(self.log_path, "loss_recon.pkl"), "wb") as handle: 

385 pickle.dump(loss_recon, handle, protocol=pickle.HIGHEST_PROTOCOL) 

386 with open(os.path.join(self.log_path, "error_recon.pkl"), "wb") as handle: 

387 pickle.dump(error_recon, handle, protocol=pickle.HIGHEST_PROTOCOL) 

388 

389 self.parameters.vae = model_recon 

390 self.loss = { 

391 "elbo_train": loss_recon[0], 

392 "elbo_valid": loss_recon[1], 

393 "recon_train": error_recon[0], 

394 "recon_valid": error_recon[1], 

395 } 

396 

397 return self 

398 

399 def _compute_biadj(self): 

400 if self.udg.size == 0: 

401 self._estimate_udg() 

402 self.biadj = _find_heuristic_1pc(self.udg) 

403 

404 def _estimate_udg(self): 

405 self.udg, _ = _estimate_UDG( 

406 self.dataset, 

407 method=self.hyperparams["method"], 

408 significance_level=self.hyperparams["alpha"], 

409 ) 

410 

411 def _data_loader(self, sample): 

412 sample_x = sample.astype(np.float32) 

413 dataset = TensorDataset(torch.tensor(sample_x)) 

414 return DataLoader( 

415 dataset, 

416 batch_size=self.hyperparams["batch_size"], 

417 shuffle=self.hyperparams["shuffle"], 

418 num_workers=0, 

419 ) 

420 

421 def _train_vae(self, train_loader, valid_loader): 

422 num_meas = self.dataset.shape[1] 

423 biadj = torch.tensor(self.biadj.T, dtype=torch.float32) 

424 num_classes = self.hyperparams["num_classes"] 

425 

426 model = VariationalAutoencoder( 

427 num_latent=biadj.shape[1], 

428 num_meas=num_meas, 

429 num_hidden_layers=self.hyperparams["num_hidden_layers"], 

430 latent_width=self.hyperparams["latent_width"], 

431 meas_width=self.hyperparams["meas_width"], 

432 biadj=biadj, 

433 encoder_hidden_dim=self.hyperparams["encoder_hidden_dim"], 

434 num_classes=num_classes, 

435 ).to(self.device) 

436 

437 optimizer = torch.optim.AdamW(model.parameters(), lr=self.hyperparams["lr"]) 

438 

439 num_params = sum(p.numel() for p in model.parameters() if p.requires_grad) 

440 self._log(f"Number of parameters: {num_params}") 

441 

442 train_elbo, train_error = [], [] 

443 valid_elbo, valid_error = [], [] 

444 

445 best_valid = float("inf") 

446 best_state = copy.deepcopy(model.state_dict()) 

447 epochs_without_improvement = 0 

448 

449 pbar = tqdm( 

450 range(self.hyperparams["num_epochs"]), desc="Training NCFA", unit="epoch" 

451 ) 

452 

453 for epoch in pbar: 

454 model.train() 

455 

456 for (x_batch,) in train_loader: 

457 x_batch = x_batch.to(self.device) 

458 

459 x_recon, mu, logvar = model(x_batch) 

460 loss = self._vae_loss( 

461 x_batch, x_recon, mu, logvar, beta=self.hyperparams["beta"], 

462 num_classes=num_classes, 

463 ) 

464 

465 optimizer.zero_grad() 

466 loss.backward() 

467 optimizer.step() 

468 

469 train_lb, train_er = self._eval_loss(model, train_loader, num_classes) 

470 train_elbo.append(train_lb) 

471 train_error.append(train_er) 

472 

473 valid_lb, valid_er = self._eval_loss(model, valid_loader, num_classes) 

474 valid_elbo.append(valid_lb) 

475 valid_error.append(valid_er) 

476 

477 pbar.set_postfix({"train": train_lb, "valid": valid_lb}) 

478 

479 improved = valid_lb < (best_valid - self.hyperparams["min_delta"]) 

480 if improved: 

481 best_valid = valid_lb 

482 best_state = copy.deepcopy(model.state_dict()) 

483 epochs_without_improvement = 0 

484 else: 

485 epochs_without_improvement += 1 

486 

487 if ( 

488 self.hyperparams["early_stopping"] 

489 and epochs_without_improvement >= self.hyperparams["patience"] 

490 ): 

491 self._log(f"Early stopping at epoch {epoch}") 

492 break 

493 

494 model.load_state_dict(best_state) 

495 

496 return ( 

497 model, 

498 [np.array(train_elbo), np.array(valid_elbo)], 

499 [np.array(train_error), np.array(valid_error)], 

500 ) 

501 

502 def _eval_loss(self, model, loader, num_classes=1): 

503 model.eval() 

504 total_loss = 0.0 

505 total_recon = 0.0 

506 n = 0 

507 

508 with torch.no_grad(): 

509 for (x_batch,) in loader: 

510 x_batch = x_batch.to(self.device) 

511 x_recon, mu, logvar = model(x_batch) 

512 loss = self._vae_loss( 

513 x_batch, x_recon, mu, logvar, beta=self.hyperparams["beta"], 

514 num_classes=num_classes, 

515 ) 

516 recon = self._recon_error(x_batch, x_recon, num_classes=num_classes) 

517 

518 bs = x_batch.shape[0] 

519 total_loss += loss.item() 

520 total_recon += recon.item() 

521 n += bs 

522 

523 return total_loss / n, total_recon / n 

524 

525 @staticmethod 

526 def _vae_loss(x, x_recon, mu, logvar, beta=1.0, num_classes=1): 

527 if num_classes >= 2: 

528 recon_loss = F.cross_entropy( 

529 x_recon.view(-1, num_classes), x.long().view(-1), reduction="sum" 

530 ) 

531 else: 

532 recon_loss = F.mse_loss(x_recon, x, reduction="sum") 

533 kl_div = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp()) 

534 return recon_loss + beta * kl_div 

535 

536 @staticmethod 

537 def _recon_error(x, x_recon, num_classes=1): 

538 if num_classes >= 2: 

539 return F.cross_entropy( 

540 x_recon.view(-1, num_classes), x.long().view(-1), reduction="sum" 

541 ) 

542 return torch.linalg.norm(x - x_recon, ord=2) 

543 

544 def sample(self, sample_size: int, include_latent: bool = False) -> npt.NDArray: 

545 """Sample observations from a fitted NeuroCausalFactorAnalysis model. 

546 

547 Parameters 

548 ---------- 

549 sample_size : int 

550 Number of samples to generate. 

551 include_latent : bool, optional 

552 If True, also return the latent codes z drawn from the prior. 

553 Note: z has shape (sample_size, num_latent * latent_width). 

554 

555 Returns 

556 ------- 

557 sample : ndarray of shape (sample_size, num_meas) 

558 latent_sample : ndarray of shape (sample_size, latent_dim), only if include_latent=True 

559 """ 

560 if self.parameters.vae is None: 

561 raise ValueError("Model must be fitted before sampling.") 

562 vae = self.parameters.vae 

563 latent_dim = vae.decoder.latent_dim 

564 num_classes = self.hyperparams["num_classes"] 

565 z = torch.randn(sample_size, latent_dim, device=self.device) 

566 with torch.no_grad(): 

567 vae.eval() 

568 x_recon = vae.decoder(z) 

569 if num_classes >= 2: 

570 num_meas = vae.decoder.num_meas 

571 probs = torch.softmax(x_recon.view(sample_size, num_meas, num_classes), dim=-1) 

572 x_recon = torch.multinomial(probs.view(-1, num_classes), 1).view(sample_size, num_meas).float() 

573 out = x_recon.cpu().numpy() 

574 return (out, z.cpu().numpy()) if include_latent else out 

575 

576 def _set_full_decoder_mask(self, num_meas=None): 

577 if num_meas is None: 

578 if not hasattr(self, "dataset"): 

579 raise ValueError("Provide num_meas or set dataset first.") 

580 num_meas = self.dataset.shape[1] 

581 

582 num_latent = num_meas 

583 self.biadj = np.ones((num_latent, num_meas), dtype=float)