GroupMultiNeSS 0.0.1__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,1307 @@
1
+ import numpy as np
2
+ from typing import List, Dict, Callable, Union, Tuple
3
+ import matplotlib.pyplot as plt
4
+ from functools import partial
5
+ import seaborn as sns
6
+ from multiprocessing import Manager
7
+ from copy import deepcopy
8
+ from itertools import product
9
+ from utils import MultipleNetworkTrainTestSplitter
10
+ from more_itertools import zip_equal
11
+
12
+ from .utils import soft_thresholding_operator, hard_thresholding_operator, make_error_report, \
13
+ mean_frobenius_error, EarlyStopper, if_scalar_or_given_length_array, fill_diagonals
14
+ from .base import BaseMultiplexNetworksModel, BaseRefitting, SharedMemoryMatrixFitter
15
+ from .MASE import ASE
16
+
17
+
18
+ class BaseMultiNeSS(BaseMultiplexNetworksModel, SharedMemoryMatrixFitter):
19
+ """
20
+ Base class for implementing the MultiNeSS and GroupMultiNeSS models.
21
+
22
+ Inherits from
23
+ ----------
24
+ BaseMultiplexNetworksModel
25
+ Provides core multiplex network modeling functionalities.
26
+ SharedMemoryMatrixFitter
27
+ Provides methods for fitting and managing shared memory matrices.
28
+ """
29
+
30
+ def get_all_fitted_matrices_by_type(self):
31
+ """
32
+ Retrieve all fitted matrices, separated by shared and individual components.
33
+
34
+ Returns
35
+ -------
36
+ list of np.ndarray
37
+ A list containing:
38
+ - The shared latent space matrix.
39
+ - A list of individual latent space matrices.
40
+ """
41
+ return [self.get_shared_latent_space(), self.get_individual_latent_spaces()]
42
+
43
+ def get_all_matrix_latent_positions(self, ds: List[int] = None, check_if_symmetric=True):
44
+ """
45
+ Compute latent positions for all fitted matrices using adjacency spectral embedding (ASE).
46
+
47
+ Parameters
48
+ ----------
49
+ ds : list of int, optional
50
+ Dimensions of embeddings for each matrix. If None, the matrix ranks are used.
51
+ check_if_symmetric : bool, default=True
52
+ Whether to check if matrices are symmetric before embedding.
53
+
54
+ Returns
55
+ -------
56
+ list of np.ndarray
57
+ List of latent position matrices for each fitted matrix, one per component.
58
+ """
59
+ matrices = self.get_all_fitted_matrices()
60
+ if ds is None:
61
+ ds = [np.linalg.matrix_rank(matrix) for matrix in matrices]
62
+ return [ASE(matrix, d, check_if_symmetric=check_if_symmetric) if d != 0 else np.empty((self.n_nodes_, 0))
63
+ for matrix, d in zip_equal(matrices, ds)]
64
+
65
+ def get_node_latent_positions_across_layers(self):
66
+ """
67
+ Compute node latent positions across all layers, concatenating shared and relevant individual embeddings.
68
+
69
+ Returns
70
+ -------
71
+ list of np.ndarray
72
+ A list of latent position matrices, one for each layer. Each matrix has shape (n_nodes, total_dim).
73
+ """
74
+ matrix_positions = self.get_all_matrix_latent_positions()
75
+ all_param_indices = np.arange(len(matrix_positions))
76
+ latent_positions = []
77
+ for idx in range(self.n_layers_):
78
+ layer_depend_indices = all_param_indices[self._param_participance_masks[:, idx]]
79
+ layer_pos = np.hstack([matrix_positions[param_idx] for param_idx in layer_depend_indices])
80
+ latent_positions.append(layer_pos)
81
+ return latent_positions
82
+
83
+ def get_shared_latent_space(self):
84
+ """
85
+ Retrieve the shared latent space matrix.
86
+
87
+ Returns
88
+ -------
89
+ np.ndarray
90
+ The shared latent space matrix estimated across all layers.
91
+ """
92
+ return self.get_all_fitted_matrices()[0]
93
+
94
+ def get_individual_latent_spaces(self):
95
+ """
96
+ Retrieve the individual latent space matrices.
97
+
98
+ Returns
99
+ -------
100
+ list of np.ndarray
101
+ List of latent space matrices specific to each network layer.
102
+ """
103
+ return self.get_all_fitted_matrices()[1:]
104
+
105
+ def _get_param_participance_masks(self):
106
+ """
107
+ Construct boolean masks indicating which parameters (shared or individual)
108
+ contribute to each network layer.
109
+
110
+ Returns
111
+ -------
112
+ np.ndarray of bool, shape (n_parameters, n_layers)
113
+ Participation mask where True indicates inclusion of a parameter in a layer.
114
+ """
115
+ return np.stack([np.ones(self.n_layers_, dtype=bool), *np.eye(self.n_layers_, dtype=bool)])
116
+
117
+ @staticmethod
118
+ def _get_all_fitted_parameter_names():
119
+ """
120
+ Get the names of all fitted parameter types.
121
+
122
+ Returns
123
+ -------
124
+ list of str
125
+ Names of the model components: shared and individual.
126
+ """
127
+ return ["Shared component", "Individual components"]
128
+
129
+ def compute_expected_adjacency(self):
130
+ """
131
+ Compute the expected adjacency matrices from the fitted latent spaces.
132
+
133
+ Returns
134
+ -------
135
+ list of np.ndarray
136
+ Expected adjacency matrices obtained via the model link function.
137
+ """
138
+ return self.link_(self.compute_latent_spaces())
139
+
140
+ def compute_latent_spaces(self):
141
+ """
142
+ Compute latent spaces combining shared and individual components,
143
+ optionally zeroing diagonals if loops are not allowed.
144
+
145
+ Returns
146
+ -------
147
+ list of np.ndarray
148
+ Latent space matrices adjusted for model constraints.
149
+ """
150
+ ls = self.get_shared_latent_space() + self.get_individual_latent_spaces()
151
+ if not self.loops_allowed:
152
+ fill_diagonals(ls, val=0., inplace=True)
153
+ return ls
154
+
155
+
156
+ class BaseMultiNeSSRefined(BaseMultiNeSS, BaseRefitting):
157
+ """
158
+ Refined version of the BaseMultiNeSS model with optimization, refitting, and
159
+ training history management. Implements parameter optimization loops,
160
+ hyperparameter validation, and history tracking during training.
161
+
162
+ Inherits from
163
+ ----------
164
+ BaseMultiNeSS
165
+ Provides multiplex latent space modeling functionality.
166
+ BaseRefitting
167
+ Adds post-optimization refitting procedures.
168
+ """
169
+
170
+ def __init__(self, edge_distrib: str = "normal", loops_allowed: bool = True,
171
+ max_rank: int = None, init_rank: int = None,
172
+ sigmas: Union[float, List, np.ndarray] = None):
173
+ """
174
+ Initialize a refined MultiNeSS model with specified configuration.
175
+
176
+ Parameters
177
+ ----------
178
+ edge_distrib : {'normal', 'bernoulli'}, default='normal'
179
+ Distributional assumption for edge weights.
180
+ loops_allowed : bool, default=True
181
+ Whether self-loops are allowed in adjacency matrices.
182
+ max_rank : int, optional
183
+ Maximum rank allowed during optimization.
184
+ init_rank : int, optional
185
+ Initial rank for spectral thresholding.
186
+ sigmas : float or array-like, optional
187
+ Edge variance(s) or standard deviations for Gaussian edges.
188
+ """
189
+ super().__init__(edge_distrib=edge_distrib, loops_allowed=loops_allowed)
190
+ self.max_rank = max_rank
191
+ self.init_rank = init_rank
192
+ self.sigmas = sigmas
193
+
194
+ def _optimized_params_init(self, As, key: Tuple[int, int]) -> None:
195
+ """
196
+ Initialize model parameters before optimization via rank-thresholded averaging.
197
+
198
+ Parameters
199
+ ----------
200
+ As : np.ndarray
201
+ Input observed adjacency tensors of shape (n_layers, n_nodes, n_nodes).
202
+ key : tuple of int
203
+ Key identifying optimization stage.
204
+ """
205
+ param_indices = self._key_2_param_indices[key]
206
+ param_inits = []
207
+ for idx in param_indices:
208
+ mask = self._param_participance_masks[idx]
209
+ param_init = hard_thresholding_operator(np.nansum(As[mask], axis=0) / mask.sum(), max_rank=self.init_rank)
210
+ param_inits.append(param_init)
211
+ self._set_matrices(param_inits, indices=param_indices)
212
+
213
+ def _update_history(self, nll: float, key: Tuple[int, int],
214
+ save_nll_history: bool = True,
215
+ save_latent_history: bool = False) -> None:
216
+ """
217
+ Update optimization history (NLL and optionally latent states).
218
+
219
+ Parameters
220
+ ----------
221
+ nll : float
222
+ Current negative log-likelihood value.
223
+ key : tuple of int
224
+ Identifier for optimization stage.
225
+ save_nll_history : bool, default=True
226
+ Whether to save NLL history.
227
+ save_latent_history : bool, default=False
228
+ Whether to save latent variable history.
229
+ """
230
+ if save_nll_history:
231
+ self._update_nll_history(nll=nll, key=key)
232
+ if save_latent_history:
233
+ self._update_latent_history(key)
234
+
235
+ def _reset_history(self, key: Tuple[int, int]):
236
+ """
237
+ Reset optimization history for a given key.
238
+
239
+ Parameters
240
+ ----------
241
+ key : tuple of int
242
+ Identifier for optimization stage.
243
+ """
244
+ hist_key = self._key_2_history_key[key]
245
+ self.nll_history_[hist_key][:] = []
246
+
247
+ def _update_nll_history(self, nll: float, key: Tuple[int, int]):
248
+ """
249
+ Append new negative log-likelihood value to history.
250
+
251
+ Parameters
252
+ ----------
253
+ nll : float
254
+ Negative log-likelihood value.
255
+ key : tuple of int
256
+ Identifier for optimization stage.
257
+ """
258
+ history_key = self._key_2_history_key[key]
259
+ self.nll_history_[history_key].append(nll)
260
+
261
+ def _update_latent_history(self, key: Tuple[int, int]):
262
+ """
263
+ Append current latent space matrices to history.
264
+
265
+ Parameters
266
+ ----------
267
+ key : tuple of int
268
+ Identifier for optimization stage.
269
+ """
270
+ history_key = self._key_2_history_key[key]
271
+ self.latent_history_[history_key].append(deepcopy(self.get_all_fitted_matrices_by_type()))
272
+
273
+ def _make_key_2_history_key_dict(self) -> None:
274
+ """Create mapping from optimization keys to history keys."""
275
+ self._key_2_history_key = {(0, 0): "First Stage"}
276
+
277
+ def _make_key_2_param_indices_dict(self) -> None:
278
+ """Create mapping from optimization keys to parameter index lists."""
279
+ self._key_2_param_indices = {(0, 0): list(range(self.n_fitted_matrices))}
280
+
281
+ def _make_key_2_hyperparams_dict(self) -> None:
282
+ """Initialize empty dictionary for hyperparameters per optimization key."""
283
+ self._key_2_hyperparams_dict = dict()
284
+
285
+ def _init_keys(self):
286
+ """Initialize all internal key mapping dictionaries."""
287
+ self._make_key_2_history_key_dict()
288
+ self._make_key_2_param_indices_dict()
289
+ self._make_key_2_hyperparams_dict()
290
+
291
+ def _set_hyperparams(self, params_dict: Dict[str, float], key: Tuple[int, int]):
292
+ """
293
+ Set hyperparameter values for the given key.
294
+
295
+ Parameters
296
+ ----------
297
+ params_dict : dict
298
+ Dictionary mapping hyperparameter names to their values.
299
+ key : tuple of int
300
+ Optimization stage identifier.
301
+ """
302
+ for param_name, param_val in params_dict.items():
303
+ old_val = self._key_2_hyperparams_dict[key][param_name]
304
+ param_val = param_val if np.isscalar(old_val) else param_val * np.ones_like(old_val)
305
+ self._key_2_hyperparams_dict[key][param_name] = param_val
306
+
307
+ def _init_history(self, manager: Manager):
308
+ """
309
+ Initialize shared memory-based history tracking structures.
310
+
311
+ Parameters
312
+ ----------
313
+ manager : multiprocessing.Manager
314
+ Shared memory manager for inter-process synchronization.
315
+ """
316
+ self._shm_lock = manager.Lock()
317
+ self.optim_info = manager.dict({'n_svd_calls': 0})
318
+ self.nll_history_ = manager.dict({hist_key: manager.list() for hist_key in self._key_2_history_key.values()})
319
+ self.latent_history_ = manager.dict({hist_key: manager.list() for hist_key in self._key_2_history_key.values()})
320
+
321
+ def _validate_hyperparams(self):
322
+ """
323
+ Validate and initialize hyperparameters such as ranks and sigma scales.
324
+
325
+ Raises
326
+ ------
327
+ AssertionError
328
+ If initial or maximum rank constraints are violated.
329
+ NotImplementedError
330
+ If sigmas dimensionality is unsupported.
331
+ """
332
+ if self.init_rank is None:
333
+ self.init_rank = 1.
334
+
335
+ assert self.n_nodes_ >= self.init_rank, "Initial rank should not be greater than the number of nodes"
336
+ if self.max_rank is not None:
337
+ assert self.max_rank >= self.init_rank, "Initial rank should not be greater than maximum rank"
338
+ else:
339
+ self.max_rank = int(np.sqrt(self.n_nodes_))
340
+
341
+ if self.sigmas is None:
342
+ self.sigmas_ = np.ones((self.n_layers_, self.n_nodes_, self.n_nodes_), dtype=float)
343
+ else:
344
+ self.sigmas_ = np.array(self.sigmas, dtype=float)
345
+
346
+ if self.sigmas_.ndim == 0:
347
+ self.sigmas_ = self.sigmas_ * np.ones((self.n_layers_, self.n_nodes_, self.n_nodes_), dtype=float)
348
+ elif self.sigmas_.ndim == 1:
349
+ self.sigmas_ = self.sigmas_[:, None, None] * np.ones((1, self.n_nodes_, self.n_nodes_))
350
+ elif self.sigmas_.ndim == 2:
351
+ assert self.sigmas_.shape == (self.n_nodes_, self.n_nodes_)
352
+ self.sigmas_ = np.stack([self.sigmas_] * self.n_layers_)
353
+ elif self.sigmas_.ndim == 3:
354
+ assert self.sigmas_.shape == (self.n_layers_, self.n_nodes_, self.n_nodes_)
355
+ else:
356
+ raise NotImplementedError("sigmas should be a scalar, 1d, 2d, or 3d array.")
357
+
358
+ def _compute_projected_map(self, idx: int, lr: float, key: Tuple[int, int]) -> Callable:
359
+ """Placeholder for projected map computation (to be implemented in subclass)."""
360
+ pass
361
+
362
+ def _update_matrix(self, idx, lr: float, sample_grads: np.array, key: Tuple[int, int]):
363
+ """
364
+ Update a single parameter matrix via gradient step and projection.
365
+
366
+ Parameters
367
+ ----------
368
+ idx : int
369
+ Index of the parameter matrix to update.
370
+ lr : float
371
+ Learning rate.
372
+ sample_grads : np.ndarray
373
+ Gradient tensor of the same shape as observed data.
374
+ key : tuple of int
375
+ Optimization stage identifier.
376
+ """
377
+ projected_map = self._compute_projected_map(idx, lr=lr, key=key)
378
+ mask = self._param_participance_masks[idx]
379
+ matrix_grad = np.nansum(sample_grads[mask], axis=0)
380
+ cur_matrix = self.get_all_fitted_matrices()[idx]
381
+ upd_matrix = projected_map(cur_matrix - lr / mask.sum() * matrix_grad)
382
+ self._set_matrix(upd_matrix, idx)
383
+
384
+ @property
385
+ def n_fitted_matrices(self) -> int:
386
+ """int: Total number of fitted matrices (shared + individual)."""
387
+ return 1 + self.n_layers_
388
+
389
+ def _pre_fit(self):
390
+ """Perform pre-fitting initialization including parameters, keys, and history."""
391
+ self._init_param_matrices(np.zeros((self.n_fitted_matrices, self.n_nodes_, self.n_nodes_)))
392
+ self._validate_hyperparams()
393
+ self._init_keys()
394
+ self._init_history(manager=Manager())
395
+
396
+ def _optimization_step(self, param_indices, key: Tuple[int, int], As, lr: float, iteration: int,
397
+ verbose: bool, tol: float,
398
+ save_latent_history: bool, save_nll_history: bool) -> float:
399
+ """
400
+ Execute one optimization iteration over a subset of parameters.
401
+
402
+ Parameters
403
+ ----------
404
+ param_indices : list of int
405
+ Indices of parameters to optimize.
406
+ key : tuple of int
407
+ Optimization stage identifier.
408
+ As : np.ndarray
409
+ Input adjacency tensors.
410
+ lr : float
411
+ Learning rate.
412
+ iteration : int
413
+ Current iteration number.
414
+ verbose : bool
415
+ Whether to print progress.
416
+ tol : float
417
+ Tolerance for rounding output.
418
+ save_latent_history : bool
419
+ Whether to store latent variable history.
420
+ save_nll_history : bool
421
+ Whether to store NLL history.
422
+
423
+ Returns
424
+ -------
425
+ float
426
+ Negative log-likelihood after the update step.
427
+ """
428
+ for idx in param_indices:
429
+ sample_grads = self._nll_sample_grads(As)
430
+ self._update_matrix(idx, lr=lr, sample_grads=sample_grads, key=key)
431
+
432
+ obs_mask = self._param_participance_masks[param_indices].any(0)
433
+ nll = self._compute_nll(As, obs_mask=obs_mask)
434
+
435
+ with self._shm_lock:
436
+ self._update_history(nll, key=key,
437
+ save_latent_history=save_latent_history, save_nll_history=save_nll_history)
438
+
439
+ if verbose:
440
+ nll = np.round(nll, 1 - int(np.log10(tol))) if tol > 0 else nll
441
+ print(f"{self._key_2_history_key[key]}, Iteration {iteration}, NLL {nll}")
442
+ return nll
443
+
444
+ def _optimize_params(self, key: Tuple[int, int], As, lr: float,
445
+ max_iter: int, tol: float, patience,
446
+ verbose: bool, verbose_interval: int,
447
+ save_latent_history: bool, save_nll_history: bool,
448
+ refit: bool):
449
+ """
450
+ Optimize model parameters via iterative updates and early stopping.
451
+
452
+ Parameters
453
+ ----------
454
+ key : tuple of int
455
+ Optimization stage identifier.
456
+ As : np.ndarray
457
+ Observed adjacency tensors.
458
+ lr : float
459
+ Learning rate.
460
+ max_iter : int
461
+ Maximum number of iterations.
462
+ tol : float
463
+ Stopping tolerance for early stopping.
464
+ patience : int
465
+ Number of iterations with no improvement before stopping.
466
+ verbose : bool
467
+ Whether to print progress.
468
+ verbose_interval : int
469
+ Interval (in iterations) for printing updates.
470
+ save_latent_history : bool
471
+ Whether to save latent variables at each step.
472
+ save_nll_history : bool
473
+ Whether to save NLL values.
474
+ refit : bool
475
+ Whether to refit parameters after optimization.
476
+ """
477
+ self._reset_history(key=key)
478
+ param_indices = self._key_2_param_indices[key]
479
+ self._optimized_params_init(As, key=key)
480
+ early_stopper = EarlyStopper(patience=patience, higher_better=False, rtol=tol)
481
+ for iteration in range(max_iter):
482
+ nll = self._optimization_step(param_indices, key=key, As=As, lr=lr, iteration=iteration, tol=tol,
483
+ verbose=verbose and iteration % verbose_interval == 0,
484
+ save_latent_history=save_latent_history, save_nll_history=save_nll_history)
485
+
486
+ if early_stopper.stop_check(nll):
487
+ with self._shm_lock:
488
+ self.optim_info["n_svd_calls"] += (iteration + 1) * len(param_indices)
489
+ if verbose:
490
+ print(f"Early stop activated on iteration {iteration}!\n")
491
+ break
492
+
493
+ if refit:
494
+ self.refit(As, refit_matrix_indices=param_indices)
495
+
496
+ def _fit(self, As, **optim_kwargs):
497
+ """
498
+ Full model fitting routine combining initialization and optimization.
499
+
500
+ Parameters
501
+ ----------
502
+ As : np.ndarray
503
+ Input adjacency tensors.
504
+ **optim_kwargs
505
+ Optimization keyword arguments passed to `_optimize_params`.
506
+ """
507
+ self._pre_fit()
508
+ self._optimize_params(As=As, key=(0, 0), **optim_kwargs)
509
+
510
+ def _nll_sample_grads(self, As):
511
+ """
512
+ Compute sample gradients for the negative log-likelihood objective.
513
+
514
+ Parameters
515
+ ----------
516
+ As : np.ndarray
517
+ Observed adjacency matrices.
518
+
519
+ Returns
520
+ -------
521
+ np.ndarray
522
+ Gradient tensor for each layer.
523
+ """
524
+ resids = self.compute_expected_adjacency() - As
525
+ if self.edge_distrib == "normal":
526
+ resids /= self.sigmas_ ** 2
527
+ return resids
528
+
529
+ def _compute_nll(self, As, obs_mask=None, eps=1e-8):
530
+ """
531
+ Compute negative log-likelihood for observed data under the model.
532
+
533
+ Parameters
534
+ ----------
535
+ As : np.ndarray
536
+ Observed adjacency matrices.
537
+ obs_mask : np.ndarray, optional
538
+ Boolean mask for observed layers.
539
+ eps : float, default=1e-8
540
+ Small constant for numerical stability.
541
+
542
+ Returns
543
+ -------
544
+ float
545
+ Negative log-likelihood value.
546
+ """
547
+ if obs_mask is None:
548
+ obs_mask = np.ones(self.n_layers_, dtype=bool)
549
+ triu_mask = np.triu(np.ones((self.n_nodes_, self.n_nodes_)), k=0 if self.loops_allowed else 1)
550
+ Ps = self.compute_expected_adjacency()
551
+ if self.edge_distrib == "normal":
552
+ full_nll = (As - Ps) ** 2 / (2. * self.sigmas_ ** 2)
553
+ else:
554
+ Ps = np.clip(Ps, eps, 1. - eps)
555
+ full_nll = -As * np.log(Ps) - (1. - As) * np.log(1. - Ps)
556
+ return np.nansum(full_nll[obs_mask] * triu_mask)
557
+
558
+ def make_final_error_report(self, *true_params, Ps=None, relative_errors=True, round_digits: int = 3):
559
+ """
560
+ Compute final error report comparing estimated and true parameters.
561
+
562
+ Parameters
563
+ ----------
564
+ *true_params : np.ndarray
565
+ Ground-truth parameter matrices (shared, individual, etc.).
566
+ Ps : np.ndarray, optional
567
+ True expected adjacency matrices.
568
+ relative_errors : bool, default=True
569
+ Whether to compute relative Frobenius errors.
570
+ round_digits : int, default=3
571
+ Number of digits to round results.
572
+
573
+ Returns
574
+ -------
575
+ dict
576
+ Mapping of parameter names to error metrics.
577
+ """
578
+ estimate_params = self.get_all_fitted_matrices_by_type()
579
+ param_names = self._get_all_fitted_parameter_names()
580
+ assert len(true_params) == len(estimate_params)
581
+ assert all([param.shape == pred_param.shape for param, pred_param in zip(true_params, estimate_params)])
582
+ if Ps is not None:
583
+ true_params = [*true_params, Ps]
584
+ estimate_params.append(self.compute_expected_adjacency())
585
+ param_names.append("Ps")
586
+ return {param_name: round(mean_frobenius_error(true_param, pred_param,
587
+ relative=relative_errors, include_offdiag=self.loops_allowed),
588
+ round_digits)
589
+ for param_name, true_param, pred_param in zip(param_names, true_params, estimate_params)}
590
+
591
+ def get_error_history(self, *true_params, relative_errors=True):
592
+ """
593
+ Retrieve history of parameter estimation errors across iterations.
594
+
595
+ Parameters
596
+ ----------
597
+ true_params : tuple[np.array]
598
+ Ground truth parameters.
599
+ relative_errors : bool, default=True
600
+ Whether to compute relative errors.
601
+
602
+ Returns
603
+ -------
604
+ list of dict
605
+ Error values for each iteration.
606
+ """
607
+ assert self.latent_history_, "Set save_latent_history == True during fit to access latent_history!"
608
+ assert len(true_params) == len(self._get_all_fitted_parameter_names()), \
609
+ f"Length of true_params should be equal to {len(self._get_all_fitted_parameter_names())}"
610
+ err_hist = []
611
+ for estimate_params in self.latent_history_:
612
+ iteration_errs = make_error_report(true_params, estimate_params, relative_errors=relative_errors)
613
+ err_hist.append(iteration_errs)
614
+ return err_hist
615
+
616
+ def plot_nll_history(self, n_cols: int = 4, fontsize: int = 14, figsize=(12, 20)):
617
+ """
618
+ Plot negative log-likelihood history across optimization stages.
619
+
620
+ Parameters
621
+ ----------
622
+ n_cols : int, default=4
623
+ Number of subplot columns.
624
+ fontsize : int, default=14
625
+ Font size for labels and titles.
626
+ figsize : tuple, default=(12, 20)
627
+ Figure size.
628
+ """
629
+ n_cols = min(n_cols, len(self.nll_history_))
630
+ n_rows = int(np.ceil(len(self.nll_history_) / n_cols))
631
+ fig, axs = plt.subplots(ncols=n_cols, nrows=n_rows, figsize=figsize)
632
+ axs = np.array(axs).flatten()
633
+ for ax, (stage, nll_hist) in zip(axs, self.nll_history_.items()):
634
+ ax.plot(nll_hist)
635
+ ax.set_xlabel("Iteration", fontsize=fontsize)
636
+ ax.set_ylabel("Negative Loglikelihood", fontsize=fontsize)
637
+ ax.set_title(f"{stage}", fontsize=fontsize)
638
+ ax.tick_params(axis='both', labelsize=fontsize)
639
+ for idx in range(len(self.nll_history_), n_cols * n_rows):
640
+ fig.delaxes(axs[idx]) # Remove extra axes
641
+ plt.tight_layout()
642
+ plt.show()
643
+
644
+ def plot_error_history(self, *true_params, relative_errors=True, labels=None, fontsize=14,
645
+ print_final_errors=True):
646
+ """
647
+ Plot evolution of parameter estimation errors over iterations.
648
+
649
+ Parameters
650
+ ----------
651
+ *true_params : list of np.ndarray
652
+ Ground truth parameters.
653
+ relative_errors : bool, default=True
654
+ Whether to plot relative errors.
655
+ labels : list of str, optional
656
+ Custom labels for parameters.
657
+ fontsize : int, default=14
658
+ Font size for plot.
659
+ print_final_errors : bool, default=True
660
+ Whether to print final iteration errors.
661
+ """
662
+ if labels is None:
663
+ labels = self._get_all_fitted_parameter_names()
664
+ else:
665
+ assert len(true_params) == len(labels), \
666
+ "labels should be of the same length as the length of true_params"
667
+
668
+ err_hist = self.get_error_history(*true_params, relative_errors=relative_errors)
669
+ if print_final_errors:
670
+ print("Final errors:", np.round(err_hist[-1], 2))
671
+ plt.figure(figsize=(12, 8))
672
+ plt.plot(err_hist, label=labels)
673
+ plt.legend(fontsize=fontsize)
674
+ plt.xlabel("Iteration", fontsize=fontsize)
675
+ plt.ylabel("Error", fontsize=fontsize)
676
+ plt.title("Relative Frobenius Errors", fontsize=fontsize)
677
+
678
+
679
+ class BaseMultiNeSSRefinedCV(BaseMultiNeSSRefined):
680
+ """
681
+ Cross-validated refinement class for MultiNeSS models.
682
+
683
+ This class extends `BaseMultiNeSSRefined` by adding cross-validation (CV) procedures
684
+ for hyperparameter selection. It supports k-fold CV or a single hold-out test split,
685
+ and selects optimal parameters based on the mean negative log-likelihood (NLL).
686
+
687
+ Parameters
688
+ ----------
689
+ param_grid : dict of {str: list}, optional
690
+ Dictionary defining the hyperparameter grid to explore.
691
+ cv_folds : int, optional
692
+ Number of cross-validation folds. If None, a single train/test split is used.
693
+ test_prop : float, default=0.1
694
+ Proportion of data used for the test set in a single split.
695
+ fix_layer_split : bool, default=False
696
+ Whether to fix the split of layers across folds.
697
+ use_1se_rule : bool, default=False
698
+ If True, selects the most parsimonious model within one standard error of the best mean NLL.
699
+ random_seed : int, optional
700
+ Random seed for reproducibility.
701
+ """
702
+
703
+ def __init__(self, param_grid: Dict[str, List] = None, cv_folds=None, test_prop=0.1,
704
+ fix_layer_split: bool = False, use_1se_rule=False, random_seed: int = None, **kwargs):
705
+ super().__init__(**kwargs)
706
+ self.cv_folds = cv_folds
707
+ self.test_prop = test_prop
708
+ if param_grid is None:
709
+ self.param_grid = param_grid
710
+ else:
711
+ self.param_grid = dict(sorted(param_grid.items(), key=lambda kv: kv[0], reverse=True))
712
+ self.use_1se_rule = use_1se_rule
713
+ self.fix_layer_split = fix_layer_split
714
+ self.random_seed = random_seed
715
+
716
+ def _make_key_2_hyperparams_grid_dict(self) -> None:
717
+ """
718
+ This method is typically called during setup to prepare
719
+ storage for layer-specific hyperparameter grids.
720
+ """
721
+ self._key_2_hyperparams_grid = dict()
722
+
723
+ def _init_keys(self):
724
+ """
725
+ Initialize model keys and corresponding hyperparameter grids.
726
+
727
+ Calls the parent `_init_keys` and then creates the
728
+ `_key_2_hyperparams_grid` dictionary.
729
+ """
730
+ super()._init_keys()
731
+ self._make_key_2_hyperparams_grid_dict()
732
+
733
+ def _init_history(self, manager: Manager):
734
+ """
735
+ Initialize multiprocessing-safe containers for cross-validation results.
736
+
737
+ Parameters
738
+ ----------
739
+ manager : multiprocessing.Manager
740
+ Shared-memory manager used to create thread-safe dictionaries and lists.
741
+ """
742
+ super()._init_history(manager)
743
+ self._cv_results = manager.dict(
744
+ {key: manager.dict({params: manager.list() for params in product(*param_grid_dict.values())})
745
+ for key, param_grid_dict in self._key_2_hyperparams_grid.items()}
746
+ )
747
+ self._best_params_dict = manager.dict()
748
+
749
+ def _set_best_hyperparams(self, key: Tuple[int, int]) -> None:
750
+ """
751
+ Determine and store the best hyperparameters for a given key.
752
+
753
+ Parameters
754
+ ----------
755
+ key : tuple of int
756
+ Identifier for the network-layer combination being optimized.
757
+ """
758
+ res_dict = self._cv_results[key]
759
+ params, param_nlls = np.stack(list(res_dict.keys())), np.stack(list(res_dict.values()))
760
+ optim_nll_idx = np.argmin(param_nlls.mean(1))
761
+ if self.use_1se_rule and self.cv_folds > 1:
762
+ optim_param_nll_std = param_nlls.std(1, ddof=1)[optim_nll_idx]
763
+ param_nll_means = param_nlls.mean(1)
764
+ param_nll_1se_ub = param_nll_means[optim_nll_idx] + optim_param_nll_std / np.sqrt(self.cv_folds)
765
+ best_params = max(params[param_nll_means <= param_nll_1se_ub].tolist())
766
+ else:
767
+ best_params = params[optim_nll_idx]
768
+
769
+ with self._shm_lock:
770
+ self._best_params_dict[key] = dict(zip(self._key_2_hyperparams_grid[key].keys(), best_params))
771
+ self._set_hyperparams(params_dict=self._best_params_dict[key], key=key)
772
+
773
+ def _cv_hyperparams(self, As, fit_fun: Callable, comp_nll_fun: Callable, key: Tuple[int, int],
774
+ verbose=False):
775
+ """
776
+ Perform cross-validation for a given key and hyperparameter grid.
777
+
778
+ Parameters
779
+ ----------
780
+ As : list or np.ndarray
781
+ Input adjacency matrices or network representations.
782
+ fit_fun : callable
783
+ Function to fit the model on the training data.
784
+ comp_nll_fun : callable
785
+ Function to compute the negative log-likelihood on the test data.
786
+ key : tuple of int
787
+ Identifier for the network-layer combination.
788
+ verbose : bool, default=False
789
+ Whether to print progress and intermediate results.
790
+ """
791
+ param_grid_dict = self._key_2_hyperparams_grid[key]
792
+ param_name_tuples = list(param_grid_dict.keys())
793
+
794
+ tts = MultipleNetworkTrainTestSplitter(loops_allowed=self.loops_allowed,
795
+ fix_layer_split=self.fix_layer_split, random_seed=self.random_seed)
796
+ if self.cv_folds is None:
797
+ fold_gen = [tts.train_test_split(As, test_prop=self.test_prop)]
798
+ else:
799
+ fold_gen = tts.kfold_split(As, n_splits=self.cv_folds)
800
+
801
+ for fold, (As_train, As_test) in enumerate(fold_gen):
802
+ if verbose:
803
+ print(f"#### CV Fold {fold + 1} ####\n")
804
+
805
+ for params in product(*param_grid_dict.values()):
806
+ params_dict = dict(zip(param_name_tuples, params))
807
+ if verbose:
808
+ print(f"\nStart working on {params_dict}", end="\n\n")
809
+ self._set_hyperparams(params_dict=params_dict, key=key)
810
+ fit_fun(As=As_train)
811
+ nll = comp_nll_fun(As_test)
812
+ with self._shm_lock:
813
+ self._cv_results[key][params].append(nll)
814
+
815
+ self._set_best_hyperparams(key=key)
816
+ if verbose:
817
+ print("\n#### Fitting on full data with best parameters! ####", end="\n\n")
818
+ fit_fun(As=As)
819
+
820
+ def get_cv_results(self):
821
+ """
822
+ Retrieve the stored cross-validation results.
823
+
824
+ Returns
825
+ -------
826
+ dict
827
+ Nested dictionary mapping each key to its parameter combinations and
828
+ the list of NLL values across folds.
829
+ """
830
+ assert hasattr(self, "_cv_results"), "Model was not fitted!"
831
+ return {key: {param: list(nll_list) for param, nll_list in param_dict.items()}
832
+ for key, param_dict in self._cv_results.items()}
833
+
834
+ def plot_cv_results(self, log_params=True, fontsize=12):
835
+ """
836
+ Plot the cross-validation results for all keys.
837
+
838
+ Parameters
839
+ ----------
840
+ log_params : bool, default=True
841
+ Whether to display hyperparameter values on a log10 scale.
842
+ fontsize : int, default=12
843
+ Font size for plot labels and titles.
844
+ """
845
+ cv_res = sorted(self.get_cv_results().items(), key=lambda kv: kv[0])
846
+ fig, axs = plt.subplots(1, len(cv_res), figsize=(20, 6))
847
+ if len(cv_res) == 1:
848
+ axs = [axs]
849
+ for ax, (key, key_res) in zip(axs, cv_res):
850
+ hyperparam_2_grid = self._key_2_hyperparams_grid[key]
851
+ param_names = hyperparam_2_grid.keys()
852
+ ax.set_title(self._key_2_history_key[key], fontsize=fontsize + 3)
853
+ if len(param_names) == 1:
854
+ param_vals = np.hstack(list(key_res.keys()))
855
+ param_vals = np.log10(param_vals).round(2) if log_params else param_vals.round(2)
856
+ param_name = r'$\lambda$' if list(param_names)[0].startswith("lmbda") else r'$\alpha$'
857
+ cv_errs_over_folds = np.stack(list(key_res.values()))
858
+ for fold in range(cv_errs_over_folds.shape[1]):
859
+ ax.plot(param_vals, cv_errs_over_folds[:, fold], label=f"Fold={fold + 1}", marker="o")
860
+ ax.set_xticks(param_vals, param_vals, rotation=30, fontsize=fontsize)
861
+ ax.set_xlabel(r'$\log_{10}$' + f'({param_name})' if log_params else param_name, fontsize=fontsize + 3)
862
+ ax.set_ylabel("NLL", fontsize=fontsize + 3)
863
+ ax.legend(fontsize=fontsize)
864
+ else:
865
+ lmbda_grid, alpha_grid = hyperparam_2_grid.values()
866
+ heatmap_vals = [[np.mean(key_res[(lmbda, alpha)]) for lmbda in lmbda_grid] for alpha in alpha_grid]
867
+ best_lmbda, best_alpha = self.get_best_params_dict()[key].values()
868
+ col, row = list(lmbda_grid).index(best_lmbda), list(alpha_grid).index(best_alpha)
869
+ sns.heatmap(heatmap_vals, ax=ax)
870
+ ax.text(col + 0.5, row + 0.5, "X", c="red", fontsize=fontsize)
871
+
872
+ ax.set_xlabel(r'$\lambda$', fontsize=fontsize + 3)
873
+ ax.set_ylabel(r'$\alpha$', fontsize=fontsize + 3)
874
+ ax.set_xticklabels(lmbda_grid.round(2), fontsize=fontsize, rotation=30)
875
+ ax.set_yticklabels(alpha_grid.round(2), fontsize=fontsize)
876
+ plt.show()
877
+
878
+ def get_best_params_dict(self):
879
+ """
880
+ Retrieve the best hyperparameters for each key.
881
+
882
+ Returns
883
+ -------
884
+ dict
885
+ Mapping from keys to the best parameter combinations selected by CV.
886
+ """
887
+ assert hasattr(self, "_best_params_dict"), "Model was not fitted!"
888
+ return dict(self._best_params_dict)
889
+
890
+ def _optimize_params(self, key: Tuple[int, int], As, **optim_params):
891
+ """
892
+ Optimize parameters for a given key, with optional cross-validation.
893
+
894
+ Parameters
895
+ ----------
896
+ key : tuple of int
897
+ Identifier for the network-layer combination.
898
+ As : list or np.ndarray
899
+ Input adjacency matrices or network representations.
900
+ **optim_params : dict
901
+ Additional optimization parameters such as verbosity or stopping criteria.
902
+ """
903
+ param_indices = self._key_2_param_indices[key]
904
+ obs_mask = self._param_participance_masks[param_indices].any(0)
905
+ comp_nll_fun = partial(super()._compute_nll, obs_mask=obs_mask)
906
+ fit_fun = partial(super()._optimize_params, key=key, **optim_params)
907
+ if self._key_2_hyperparams_grid[key]:
908
+ self._cv_hyperparams(As, fit_fun=fit_fun, comp_nll_fun=comp_nll_fun, key=key,
909
+ verbose=optim_params["verbose"])
910
+ else:
911
+ super()._optimize_params(key=key, As=As, **optim_params)
912
+
913
+
914
+ class MultiNeSS(BaseMultiNeSSRefined, BaseRefitting):
915
+ """
916
+ This class extends `BaseMultiNeSSRefined` by adding refitting and fixing updates
917
+ as soft-thresholding with given parameters
918
+
919
+ Parameters
920
+ ----------
921
+ lmbda : float, optional
922
+ Global penalty scaling factor.
923
+ alpha : float, list, or np.ndarray, optional
924
+ Layer-specific scaling factors for penalties.
925
+ max_rank : int, optional
926
+ Maximum allowed rank for low-rank projection.
927
+ init_rank : int, optional
928
+ Initial rank used for parameter initialization.
929
+ edge_distrib : str, default='normal'
930
+ Edge distribution assumed ('normal' or 'bernoulli').
931
+ sigmas : float, list, or np.ndarray, optional
932
+ Standard deviation(s) for edges when edge_distrib='normal'.
933
+ loops_allowed : bool, default=True
934
+ Whether self-loops are allowed in the network.
935
+ refit_threshold : float, default=1e-8
936
+ Threshold for triggering refit in BaseRefitting.
937
+ """
938
+
939
+ def __init__(self,
940
+ lmbda: float = None,
941
+ alpha: Union[float, List, np.ndarray] = None,
942
+ max_rank: int = None, init_rank: int = None,
943
+ edge_distrib: str = "normal",
944
+ sigmas: Union[float, List, np.ndarray] = None,
945
+ loops_allowed: bool = True,
946
+ refit_threshold=1e-8):
947
+
948
+ BaseMultiNeSSRefined.__init__(self, edge_distrib=edge_distrib, max_rank=max_rank, init_rank=init_rank,
949
+ sigmas=sigmas, loops_allowed=loops_allowed)
950
+ BaseRefitting.__init__(self, edge_distrib=edge_distrib, max_rank=max_rank, loops_allowed=loops_allowed,
951
+ refit_threshold=refit_threshold)
952
+ self.lmbda = lmbda
953
+ self.alpha = alpha
954
+
955
+ def _compute_penalty_coef(self, idx: int, key: Tuple[int, int]) -> float:
956
+ """
957
+ Compute the penalty coefficient for a specific parameter index.
958
+
959
+ Parameters
960
+ ----------
961
+ idx : int
962
+ Index of the parameter matrix.
963
+ key : tuple of int
964
+ Network-layer key for selecting hyperparameters.
965
+
966
+ Returns
967
+ -------
968
+ float
969
+ Penalty coefficient applied to the soft-thresholding operator.
970
+ """
971
+ hyperparam_dict = self._key_2_hyperparams_dict[key]
972
+ lmbda, alpha = hyperparam_dict["lmbda"], hyperparam_dict["alpha"]
973
+ return lmbda if idx == 0 else lmbda * alpha[idx - 1]
974
+
975
+ def _compute_projected_map(self, idx: int, lr, key: Tuple[int, int]):
976
+ """
977
+ Generate a projected update function (soft-thresholding) for a parameter.
978
+
979
+ Parameters
980
+ ----------
981
+ idx : int
982
+ Index of the parameter matrix.
983
+ lr : float
984
+ Learning rate.
985
+ key : tuple of int
986
+ Network-layer key.
987
+
988
+ Returns
989
+ -------
990
+ callable
991
+ A function that applies soft-thresholding with appropriate threshold.
992
+ """
993
+ mask = self._param_participance_masks[idx]
994
+ penalty = self._compute_penalty_coef(idx=idx, key=key)
995
+ return partial(soft_thresholding_operator, threshold=penalty * lr / mask.sum(), max_rank=self.max_rank)
996
+
997
+ def _make_key_2_hyperparams_dict(self):
998
+ """
999
+ Initialize the mapping from network-layer key to hyperparameters.
1000
+
1001
+ Sets default values for `lmbda` and `alpha` if not provided.
1002
+ """
1003
+ lmbda = 2. * np.sqrt(self.n_layers_ * self.n_nodes_) if self.lmbda is None else self.lmbda
1004
+ if self.alpha is None:
1005
+ alpha = np.ones(self.n_layers_) / np.sqrt(self.n_layers_)
1006
+ else:
1007
+ alpha = if_scalar_or_given_length_array(self.alpha, length=self.n_layers_, name="alpha")
1008
+ self._key_2_hyperparams_dict = {(0, 0): {"lmbda": lmbda, "alpha": alpha}}
1009
+
1010
+ def fit(self, As: List[np.array],
1011
+ lr: float = 1.,
1012
+ max_iter: int = 100, tol=1e-5, patience=10,
1013
+ refit=True,
1014
+ verbose=True, verbose_interval=1,
1015
+ save_latent_history=False, save_nll_history=True):
1016
+ """
1017
+ Fit the MultiNeSS model to a list of adjacency matrices.
1018
+
1019
+ Parameters
1020
+ ----------
1021
+ As : list of np.ndarray
1022
+ Observed adjacency matrices.
1023
+ lr : float, default=1.
1024
+ Learning rate for optimization.
1025
+ max_iter : int, default=100
1026
+ Maximum number of iterations.
1027
+ tol : float, default=1e-5
1028
+ Convergence tolerance.
1029
+ patience : int, default=10
1030
+ Number of iterations for early stopping patience.
1031
+ refit : bool, default=True
1032
+ Whether to perform refitting after convergence.
1033
+ verbose : bool, default=True
1034
+ Whether to print progress messages.
1035
+ verbose_interval : int, default=1
1036
+ Interval between printing verbose messages.
1037
+ save_latent_history : bool, default=False
1038
+ Whether to save latent matrix history.
1039
+ save_nll_history : bool, default=True
1040
+ Whether to save negative log-likelihood history.
1041
+
1042
+ Returns
1043
+ -------
1044
+ self
1045
+ Fitted MultiNeSS object.
1046
+ """
1047
+ As = self._validate_input(As)
1048
+ self._fit(As, lr=lr,
1049
+ max_iter=max_iter, tol=tol, patience=patience,
1050
+ refit=refit,
1051
+ verbose=verbose, verbose_interval=verbose_interval,
1052
+ save_latent_history=save_latent_history, save_nll_history=save_nll_history)
1053
+ return self
1054
+
1055
+
1056
+ class MultiNeSSCV(MultiNeSS, BaseMultiNeSSRefinedCV):
1057
+ """
1058
+ Cross-validated MultiNeSS model.
1059
+
1060
+ Extends `MultiNeSS` with hyperparameter selection via cross-validation.
1061
+ """
1062
+
1063
+ def __init__(self, param_grid: Dict[str, List[float]] = None, cv_folds=3, test_prop=0.1,
1064
+ lmbda: float = None,
1065
+ alpha: Union[float, np.ndarray, list] = None,
1066
+ edge_distrib: str = "normal",
1067
+ max_rank: int = None, init_rank: int = None,
1068
+ sigmas: Union[float, List, np.ndarray] = None,
1069
+ loops_allowed: bool = True,
1070
+ refit_threshold: float = 1e-8,
1071
+ fix_layer_split: bool = False,
1072
+ use_1se_rule: bool = False,
1073
+ random_seed: int = None):
1074
+ """
1075
+ Initialize a MultiNeSS model with cross-validation.
1076
+
1077
+ Parameters
1078
+ ----------
1079
+ param_grid : dict of {str: list of float}, optional
1080
+ Grid of hyperparameters for CV.
1081
+ cv_folds : int, default=3
1082
+ Number of CV folds.
1083
+ test_prop : float, default=0.1
1084
+ Test set proportion for single split.
1085
+ lmbda : float, optional
1086
+ Global penalty factor.
1087
+ alpha : float, array-like, optional
1088
+ Layer-specific penalty factors.
1089
+ edge_distrib : str, default='normal'
1090
+ Edge distribution.
1091
+ max_rank : int, optional
1092
+ Maximum rank for projection.
1093
+ init_rank : int, optional
1094
+ Initial rank for initialization.
1095
+ sigmas : float or array-like, optional
1096
+ Edge standard deviations for 'normal' distribution.
1097
+ loops_allowed : bool, default=True
1098
+ Allow self-loops.
1099
+ refit_threshold : float, default=1e-8
1100
+ Threshold for refitting.
1101
+ fix_layer_split : bool, default=False
1102
+ Fix layer split across folds.
1103
+ use_1se_rule : bool, default=False
1104
+ Use 1-SE rule for selecting parsimonious model.
1105
+ random_seed : int, optional
1106
+ Random seed for reproducibility.
1107
+ """
1108
+ BaseMultiNeSSRefinedCV.__init__(self, param_grid=param_grid, cv_folds=cv_folds, test_prop=test_prop,
1109
+ loops_allowed=loops_allowed,
1110
+ fix_layer_split=fix_layer_split,
1111
+ use_1se_rule=use_1se_rule,
1112
+ random_seed=random_seed)
1113
+
1114
+ MultiNeSS.__init__(self, lmbda=lmbda, alpha=alpha, edge_distrib=edge_distrib,
1115
+ max_rank=max_rank, init_rank=init_rank,
1116
+ sigmas=sigmas, loops_allowed=loops_allowed, refit_threshold=refit_threshold)
1117
+
1118
+ def _make_key_2_hyperparams_grid_dict(self) -> None:
1119
+ """
1120
+ Create a default or user-specified hyperparameter grid for cross-validation.
1121
+ """
1122
+ if self.param_grid is None:
1123
+ scale = np.sqrt(self.n_nodes_ * self.n_layers_)
1124
+ const_range = np.array([0.03, 0.1, 0.3, 1, 3, 10])
1125
+ param_grid = {"lmbda": scale * const_range} if self.param_grid is None else self.param_grid
1126
+ self._key_2_hyperparams_grid = {(0, 0): param_grid}
1127
+ else:
1128
+ self._key_2_hyperparams_grid = {(0, 0): self.param_grid}
1129
+
1130
+
1131
+ class BaseOracleMultiNeSS(BaseMultiNeSSRefined):
1132
+ """
1133
+ Oracle MultiNeSS with rank thresholding.
1134
+
1135
+ Parameters
1136
+ ----------
1137
+ Inherits all parameters from BaseMultiNeSSRefined.
1138
+ """
1139
+
1140
+ def _compute_threshold_rank(self, idx: int, key: Tuple[int, int]) -> int:
1141
+ """
1142
+ Compute the threshold rank for hard-thresholding operator.
1143
+
1144
+ Parameters
1145
+ ----------
1146
+ idx : int
1147
+ Parameter matrix index.
1148
+ key : tuple of int
1149
+ Network-layer key.
1150
+
1151
+ Returns
1152
+ -------
1153
+ int
1154
+ Threshold rank.
1155
+ """
1156
+ pass
1157
+
1158
+ def _compute_projected_map(self, idx: int, lr, key: Tuple[int, int]):
1159
+ """
1160
+ Generate a projected update function (hard-thresholding) for a parameter.
1161
+
1162
+ Parameters
1163
+ ----------
1164
+ idx : int
1165
+ Index of the parameter matrix.
1166
+ lr : float
1167
+ Learning rate.
1168
+ key : tuple of int
1169
+ Network-layer key.
1170
+
1171
+ Returns
1172
+ -------
1173
+ callable
1174
+ Function applying hard-thresholding with computed max rank.
1175
+ """
1176
+ rank = self._compute_threshold_rank(idx=idx, key=key)
1177
+ return partial(hard_thresholding_operator, max_rank=rank)
1178
+
1179
+ def _optimized_params_init(self, As, key: Tuple[int, int]) -> None:
1180
+ """
1181
+ Initialize parameter matrices using hard-thresholding with threshold ranks.
1182
+
1183
+ Parameters
1184
+ ----------
1185
+ As : list of np.ndarray
1186
+ Observed adjacency matrices.
1187
+ key : tuple of int
1188
+ Network-layer key.
1189
+ """
1190
+ param_indices = self._key_2_param_indices[key]
1191
+ param_inits = []
1192
+ for idx in param_indices:
1193
+ mask = self._param_participance_masks[idx]
1194
+ max_rank = self._compute_threshold_rank(idx=idx, key=key)
1195
+ param_init = hard_thresholding_operator(np.nansum(As[mask], axis=0) / mask.sum(), max_rank=max_rank)
1196
+ param_inits.append(param_init)
1197
+ self._set_matrices(param_inits, indices=param_indices)
1198
+
1199
+ def fit(self, As: List[np.array],
1200
+ lr: float = 1.,
1201
+ max_iter: int = 100, tol=1e-5, patience=10,
1202
+ verbose=True, verbose_interval=1,
1203
+ save_latent_history=False, save_nll_history=True):
1204
+ """
1205
+ Fit the oracle MultiNeSS model.
1206
+
1207
+ Parameters
1208
+ ----------
1209
+ As : list of np.ndarray
1210
+ Observed adjacency matrices.
1211
+ lr : float, default=1.
1212
+ Learning rate for optimization.
1213
+ max_iter : int, default=100
1214
+ Maximum number of iterations.
1215
+ tol : float, default=1e-5
1216
+ Convergence tolerance.
1217
+ patience : int, default=10
1218
+ Patience for early stopping.
1219
+ verbose : bool, default=True
1220
+ Print progress messages.
1221
+ verbose_interval : int, default=1
1222
+ Verbose print interval.
1223
+ save_latent_history : bool, default=False
1224
+ Whether to store latent matrices at each iteration.
1225
+ save_nll_history : bool, default=True
1226
+ Whether to store NLL at each iteration.
1227
+
1228
+ Returns
1229
+ -------
1230
+ self
1231
+ Fitted oracle MultiNeSS model.
1232
+ """
1233
+ As = self._validate_input(As)
1234
+ self._fit(As, lr=lr,
1235
+ max_iter=max_iter, tol=tol, patience=patience,
1236
+ verbose=verbose, verbose_interval=verbose_interval,
1237
+ refit=False,
1238
+ save_latent_history=save_latent_history, save_nll_history=save_nll_history)
1239
+ return self
1240
+
1241
+
1242
+ class OracleMultiNeSS(BaseOracleMultiNeSS):
1243
+ """
1244
+ Oracle MultiNeSS model with fixed shared and individual ranks.
1245
+
1246
+ Inherits from `BaseOracleMultiNeSS` and sets the threshold ranks based on
1247
+ user-specified shared and individual layer dimensions.
1248
+
1249
+ Parameters
1250
+ ----------
1251
+ d_shared : int
1252
+ Rank of the shared component across all layers.
1253
+ d_individs : int or list of int
1254
+ Ranks of the individual components for each layer.
1255
+ edge_distrib : str, default='normal'
1256
+ Edge distribution ('normal' or 'bernoulli').
1257
+ loops_allowed : bool, default=True
1258
+ Whether self-loops are allowed in the network.
1259
+ """
1260
+
1261
+ def __init__(self, d_shared: int, d_individs: Union[int, List[int]],
1262
+ edge_distrib: str = "normal", loops_allowed: bool = True):
1263
+ """
1264
+ Initialize the OracleMultiNeSS model.
1265
+
1266
+ Parameters
1267
+ ----------
1268
+ d_shared : int
1269
+ Rank of the shared component.
1270
+ d_individs : int or list of int
1271
+ Rank(s) of individual components for each layer.
1272
+ edge_distrib : str, default='normal'
1273
+ Edge distribution.
1274
+ loops_allowed : bool, default=True
1275
+ Whether self-loops are allowed.
1276
+ """
1277
+ BaseMultiNeSSRefined.__init__(self, edge_distrib=edge_distrib, loops_allowed=loops_allowed)
1278
+ self.d_shared = d_shared
1279
+ self.d_individs = d_individs
1280
+
1281
+ def _make_key_2_hyperparams_dict(self):
1282
+ """
1283
+ Initialize the mapping from network-layer key to hyperparameters.
1284
+ """
1285
+ d_shared = if_scalar_or_given_length_array(self.d_shared, length=1, name="d_shared")
1286
+ d_individs = if_scalar_or_given_length_array(self.d_individs, length=self.n_layers_, name="d_individs")
1287
+ self._key_2_hyperparams_dict = {(0, 0): {"d_shared": d_shared, "d_individs": d_individs}}
1288
+
1289
+ def _compute_threshold_rank(self, idx: int, key: Tuple[int, int]) -> int:
1290
+ """
1291
+ Compute the threshold rank for the hard-thresholding operator.
1292
+
1293
+ Parameters
1294
+ ----------
1295
+ idx : int
1296
+ Index of the parameter matrix (0 for shared component, >0 for individual layers).
1297
+ key : tuple of int
1298
+ Network-layer key.
1299
+
1300
+ Returns
1301
+ -------
1302
+ int
1303
+ Rank threshold for hard-thresholding.
1304
+ """
1305
+ hyperparam_dict = self._key_2_hyperparams_dict[key]
1306
+ d_shared, d_individs = hyperparam_dict["d_shared"], hyperparam_dict["d_individs"]
1307
+ return d_shared if idx == 0 else d_individs[idx - 1]