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,315 @@
1
+ import numpy as np
2
+ from typing import List, Union, Tuple
3
+ from warnings import warn
4
+
5
+ from .utils import if_scalar_or_given_length_array, hard_thresholding_operator, psd_projection, truncated_svd
6
+ from .multiness import BaseMultiNeSS, OracleMultiNeSS
7
+
8
+
9
+ class SharedSpaceHunt(BaseMultiNeSS):
10
+ """
11
+ Estimate shared and individual latent spaces in multiplex networks using
12
+ Shared Space Hunt method proposed in [1].
13
+
14
+ References
15
+ ----------
16
+ [1] Tian, Y. et al. (2024). Efficient Analysis of Latent Spaces in Heterogeneous Networks. arXiv:2412.02151.
17
+ """
18
+
19
+ def __init__(self, d_shared: int = None, d_individs: Union[int, List[int]] = None, tau: float = None,
20
+ edge_distrib: str = "normal", loops_allowed: bool = True):
21
+ """
22
+ Initialize the SharedSpaceHunt object.
23
+
24
+ Parameters
25
+ ----------
26
+ d_shared : int, optional
27
+ Number of shared latent dimensions. If None, estimated automatically.
28
+ d_individs : int or list of int, optional
29
+ Number of individual latent dimensions per layer. If None, estimated automatically.
30
+ tau : float, optional
31
+ Threshold parameter for SVD truncation. Defaults to max(sqrt(2*log(n)), 2) if None.
32
+ edge_distrib : str, default="normal"
33
+ Edge distribution ("normal" or "bernoulli").
34
+ loops_allowed : bool, default=True
35
+ Whether self-loops are allowed in adjacency matrices.
36
+ """
37
+ super().__init__(edge_distrib=edge_distrib, loops_allowed=loops_allowed)
38
+ self.d_shared = d_shared
39
+ self.d_individs = d_individs
40
+ self.tau = tau
41
+ if edge_distrib == "normal":
42
+ self.inv_link_ = lambda x: x
43
+ elif edge_distrib == "bernoulli":
44
+ self.inv_link_ = lambda x: np.log(x / (1. - x))
45
+
46
+ def _validate_hyperparams(self):
47
+ """
48
+ Validate or set default hyperparameters (d_shared, d_individs, tau).
49
+ """
50
+ if self.tau is None:
51
+ self.tau = max(np.sqrt(2 * np.log(self.n_nodes_)), 2)
52
+ if self.d_individs is not None:
53
+ self.d_individs = if_scalar_or_given_length_array(self.d_individs, length=self.n_layers_,
54
+ name="d_individs").astype(int)
55
+ if self.d_shared is None:
56
+ warn("d_individs would not be used in estimation if d_shared is not provided!")
57
+
58
+ def _compute_latent_space_matrix(self, A: np.ndarray):
59
+ """
60
+ Compute a latent space matrix from a single adjacency matrix using hard thresholding
61
+ and PSD projection.
62
+
63
+ Parameters
64
+ ----------
65
+ A : np.ndarray
66
+ Adjacency matrix.
67
+
68
+ Returns
69
+ -------
70
+ np.ndarray
71
+ Estimated latent space matrix.
72
+ """
73
+ eigval_threshold = np.linalg.norm(A, ord="fro") / np.sqrt(self.n_nodes_)
74
+ Theta = hard_thresholding_operator(A, threshold=eigval_threshold)
75
+ Theta = self.inv_link_(np.clip(Theta, self.link_(-5), self.link_(5)))
76
+ return psd_projection(Theta)
77
+
78
+ def _compute_latent_space_matrix_rank(self, Theta: np.ndarray):
79
+ """
80
+ Estimate the rank of a latent space matrix using a thresholding scheme.
81
+
82
+ Parameters
83
+ ----------
84
+ Theta : np.ndarray
85
+ Latent space matrix.
86
+
87
+ Returns
88
+ -------
89
+ int
90
+ Estimated rank.
91
+ """
92
+ thresholds = self.n_nodes_ ** (-1. / (4 * np.arange(1, self.n_nodes_) + 8))
93
+ eigvals = np.linalg.svd(Theta, compute_uv=False)
94
+ rank = 1 + np.argmax(eigvals[1:] <= eigvals[:-1] * thresholds)
95
+ return rank
96
+
97
+ def _compute_latent_space_positions_and_ranks(self, As: np.ndarray) -> Tuple[List, List]:
98
+ """
99
+ Compute latent space positions and ranks for all layers.
100
+
101
+ Parameters
102
+ ----------
103
+ As : np.ndarray
104
+ Stack of adjacency matrices.
105
+
106
+ Returns
107
+ -------
108
+ Tuple[List, List]
109
+ List of latent positions per layer and list of ranks per layer.
110
+ """
111
+ Ys = []
112
+ ranks = []
113
+ for t, A in enumerate(As):
114
+ if self.d_shared is None or self.d_individs is None:
115
+ Theta = self._compute_latent_space_matrix(A)
116
+ rank = self._compute_latent_space_matrix_rank(Theta)
117
+ else:
118
+ Theta = A
119
+ rank = self.d_shared + self.d_individs[t]
120
+ u, s, _ = truncated_svd(Theta, max_rank=rank)
121
+ Y = u @ np.diag(np.sqrt(s))
122
+ Ys.append(Y)
123
+ ranks.append(rank)
124
+ return Ys, ranks
125
+
126
+ def _estimate_shared_and_individ_latent_space_ranks(self, Ys, joint_ranks):
127
+ """
128
+ Estimate ranks for shared and individual latent spaces.
129
+
130
+ Parameters
131
+ ----------
132
+ Ys : list of np.ndarray
133
+ Latent positions per layer.
134
+ joint_ranks : list of int
135
+ Joint ranks per layer.
136
+ """
137
+ if self.d_shared is not None:
138
+ self.d_shared_ = self.d_shared
139
+ else:
140
+ threshold = self.n_nodes_ ** (-1. / 8)
141
+ K_ts = []
142
+ for t in range(self.n_layers_):
143
+ for s in range(t + 1, self.n_layers_):
144
+ Y_ts = np.hstack([Ys[t], Ys[s]])
145
+ sing_vals = np.linalg.svd(Y_ts, compute_uv=False)
146
+ null_rank = 1 + np.argmax(sing_vals[1:] <= sing_vals[0] * threshold)
147
+ K_ts.append(joint_ranks[t] + joint_ranks[s] - null_rank)
148
+ self.d_shared_ = min(K_ts)
149
+ if self.d_individs is None:
150
+ self.d_individs_ = np.array(joint_ranks).astype(int) - self.d_shared_
151
+ else:
152
+ self.d_individs_ = self.d_individs
153
+
154
+ def get_all_matrix_latent_positions(self, ds: List[int] = None, check_if_symmetric=True):
155
+ """
156
+ Return latent positions for shared and individual matrices.
157
+
158
+ Parameters
159
+ ----------
160
+ ds : list of int, optional
161
+ Dimension specifications (ignored here).
162
+
163
+ check_if_symmetric : bool, default=True
164
+ Check if matrices are symmetric.
165
+
166
+ Returns
167
+ -------
168
+ list of np.ndarray
169
+ Latent positions for all matrices.
170
+ """
171
+ ds = [self.d_shared_, self.d_individs_]
172
+ return super().get_all_matrix_latent_positions(ds=ds)
173
+
174
+ def _estimate_shared_latent_space_matrix(self, Ys):
175
+ """
176
+ Estimate the shared latent space component from latent positions Ys.
177
+
178
+ Parameters
179
+ ----------
180
+ Ys : list of np.ndarray
181
+ Latent positions per layer.
182
+ """
183
+ self.ts_pairs_ = []
184
+ shared_comp = np.zeros((self.n_nodes_, self.n_nodes_))
185
+ for t in range(self.n_layers_):
186
+ for s in range(t + 1, self.n_layers_):
187
+ Y_ts = np.hstack([Ys[t], Ys[s]])
188
+ Y_tms = np.hstack([Ys[t], -Ys[s]])
189
+ d_ts = self.d_shared_ + self.d_individs_[t] + self.d_individs_[s]
190
+ _, s_vals, vh = np.linalg.svd(Y_ts)
191
+ V_ts = vh.T[:, -self.d_shared_:]
192
+ if s_vals[0] <= self.tau * s_vals[d_ts - 1]:
193
+ self.ts_pairs_.append((t, s))
194
+ shared_comp += Y_tms @ V_ts @ V_ts.T @ Y_tms.T
195
+ if len(self.ts_pairs_) == 0:
196
+ warn("Shared latent space component estimated as zero! Try increasing tau.")
197
+ else:
198
+ shared_comp = shared_comp / (2. * len(self.ts_pairs_))
199
+ self._set_matrix(shared_comp, 0)
200
+
201
+ def _estimate_individ_latent_space_matrices(self, Ys):
202
+ """
203
+ Estimate individual latent space components by removing shared component.
204
+
205
+ Parameters
206
+ ----------
207
+ Ys : list of np.ndarray
208
+ Latent positions per layer.
209
+ """
210
+ shared_comp = self.get_shared_latent_space()
211
+ individ_comps = [Y @ Y.T - shared_comp for Y in Ys]
212
+ individ_comp_indices = list(range(1, self.n_layers_ + 1))
213
+ self._set_matrices(vals=individ_comps, indices=individ_comp_indices)
214
+
215
+ def _pre_fit(self, As: np.ndarray):
216
+ """
217
+ Preprocessing before fitting: initialize latent matrices.
218
+
219
+ Parameters
220
+ ----------
221
+ As : np.ndarray
222
+ Stack of adjacency matrices.
223
+ """
224
+ M, n, _ = As.shape
225
+ self._validate_hyperparams()
226
+ self._init_param_matrices(shape=(M + 1, n, n))
227
+
228
+ def _fit(self, As: np.ndarray):
229
+ """
230
+ Fit shared and individual latent space matrices.
231
+
232
+ Parameters
233
+ ----------
234
+ As : np.ndarray
235
+ Stack of adjacency matrices.
236
+ """
237
+ self._pre_fit(As)
238
+ Ys, joint_ranks = self._compute_latent_space_positions_and_ranks(As)
239
+ self._estimate_shared_and_individ_latent_space_ranks(Ys, joint_ranks)
240
+ self._estimate_shared_latent_space_matrix(Ys)
241
+ self._estimate_individ_latent_space_matrices(Ys)
242
+
243
+ def fit(self, As: List[np.ndarray]):
244
+ """
245
+ Public fit method.
246
+
247
+ Parameters
248
+ ----------
249
+ As : list of np.ndarray
250
+ List of adjacency matrices.
251
+
252
+ Returns
253
+ -------
254
+ self
255
+ """
256
+ As = self._validate_input(As)
257
+ self._fit(As)
258
+ return self
259
+
260
+
261
+ class SharedSpaceHuntRefined(OracleMultiNeSS):
262
+ """
263
+ SharedSpaceHunt with Refinement proposed in [1].
264
+
265
+ References
266
+ ----------
267
+ [1] Tian, Y. et al. (2024). Efficient Analysis of Latent Spaces in Heterogeneous Networks. arXiv:2412.02151.
268
+ """
269
+
270
+ def __init__(self, tau: float = None, d_shared: int = None, d_individs: List[int] = None,
271
+ edge_distrib: str = "normal", loops_allowed: bool = True):
272
+ """
273
+ Initialize SharedSpaceHuntRefined.
274
+
275
+ Parameters
276
+ ----------
277
+ tau : float, optional
278
+ Threshold parameter for SVD.
279
+ d_shared : int, optional
280
+ Shared latent space dimension.
281
+ d_individs : list of int, optional
282
+ Individual latent space dimensions per layer.
283
+ edge_distrib : str, default="normal"
284
+ Edge distribution.
285
+ loops_allowed : bool, default=True
286
+ Whether self-loops are allowed.
287
+ """
288
+ super().__init__(d_shared=d_shared, d_individs=d_individs,
289
+ edge_distrib=edge_distrib, loops_allowed=loops_allowed)
290
+ self.tau = tau
291
+ self.d_shared = d_shared
292
+ self.d_individs = d_individs
293
+
294
+ def _make_key_2_hyperparams_dict(self):
295
+ """Placeholder for hyperparameter dictionary creation."""
296
+ pass
297
+
298
+ def _optimized_params_init(self, As, key: Tuple[int, int]) -> None:
299
+ """
300
+ Initialize parameters using SharedSpaceHunt.
301
+
302
+ Parameters
303
+ ----------
304
+ As : list of np.ndarray
305
+ Adjacency matrices.
306
+ key : tuple
307
+ Key for parameter dictionary.
308
+ """
309
+ ssh = SharedSpaceHunt(d_shared=self.d_shared, d_individs=self.d_individs, tau=self.tau,
310
+ edge_distrib=self.edge_distrib, loops_allowed=self.loops_allowed)
311
+ ssh.fit(As)
312
+ self.d_shared_ = ssh.d_shared_
313
+ self.d_individs_ = ssh.d_individs_
314
+ self._key_2_hyperparams_dict = {(0, 0): {"d_shared": self.d_shared_, "d_individs": self.d_individs_}}
315
+ self._set_matrices(ssh.get_all_fitted_matrices())
@@ -0,0 +1,379 @@
1
+ import numpy as np
2
+ from scipy.sparse.linalg import svds
3
+ from typing import List, Union, Tuple
4
+ from copy import copy
5
+ from scipy.sparse.linalg import eigsh
6
+ from sklearn.model_selection import KFold
7
+
8
+
9
+ class MultipleNetworkTrainTestSplitter:
10
+ """
11
+ Class to generate train/test splits for multiple adjacency matrices
12
+ representing layers of a multiplex network. Supports k-fold cross-validation
13
+ or a simple random split. Can handle symmetric adjacency matrices and optionally
14
+ ignores diagonal entries when loops are not allowed.
15
+
16
+ Parameters
17
+ ----------
18
+ loops_allowed : bool, default=True
19
+ Whether self-loops (diagonal entries) are allowed in the adjacency matrices.
20
+ fix_layer_split : bool, default=True
21
+ Whether to use the same train/test mask for all layers.
22
+ random_seed : int or None, default=None
23
+ Seed for reproducibility.
24
+ check_if_sym_input : bool, default=False
25
+ Whether to assert that input adjacency matrices are symmetric.
26
+
27
+ Methods
28
+ -------
29
+ train_test_split(As, test_prop=0.1)
30
+ Returns train/test split of the adjacency matrices with given proportion.
31
+ kfold_split(As, n_splits=5)
32
+ Yields train/test splits according to k-fold cross-validation.
33
+ """
34
+ def __init__(self, loops_allowed=True, fix_layer_split=True, random_seed=None, check_if_sym_input=False):
35
+ self.loops_allowed = loops_allowed
36
+ self.fix_layer_split = fix_layer_split
37
+ self.random_seed = random_seed
38
+ self.check_if_sym_input = check_if_sym_input
39
+
40
+ def _validate_input(self, As: np.array):
41
+ assert As.ndim == 3
42
+ assert As.shape[1] == As.shape[2]
43
+ if self.check_if_sym_input:
44
+ assert np.all([np.allclose(A, A.T) for A in As])
45
+ self.n_layers_, self.n_nodes_, _ = As.shape
46
+
47
+ def _make_train_test_layers(self, As, triu_test_masks: np.array):
48
+ triu_x, triu_y = np.triu_indices(self.n_nodes_, k=0 if self.loops_allowed else 1)
49
+ triu_train_masks = ~triu_test_masks
50
+ As_test = As.copy()
51
+ As_train = As.copy()
52
+ for i, (test_mask, train_mask) in enumerate(zip(triu_test_masks, triu_train_masks)):
53
+ test_triu_x, test_triu_y = triu_x[test_mask], triu_y[test_mask]
54
+ train_triu_x, train_triu_y = triu_x[train_mask], triu_y[train_mask]
55
+ As_train[i][test_triu_x, test_triu_y] = np.nan
56
+ As_train[i][test_triu_y, test_triu_x] = np.nan
57
+ As_test[i][train_triu_x, train_triu_y] = np.nan
58
+ As_test[i][train_triu_y, train_triu_x] = np.nan
59
+ return As_train, As_test
60
+
61
+ def train_test_split(self, As, test_prop: float = 0.1):
62
+ self._validate_input(As=As)
63
+ triu_x, triu_y = np.triu_indices(self.n_nodes_, k=0 if self.loops_allowed else 1)
64
+ np.random.seed(self.random_seed)
65
+ if self.fix_layer_split:
66
+ triu_test_masks = np.stack([np.random.rand(len(triu_x)) <= test_prop] * self.n_layers_)
67
+ else:
68
+ triu_test_masks = np.random.rand(self.n_layers_, len(triu_x)) <= test_prop
69
+ return self._make_train_test_layers(As=As, triu_test_masks=triu_test_masks)
70
+
71
+ def kfold_split(self, As, n_splits=5):
72
+ self._validate_input(As=As)
73
+ kfold = KFold(n_splits=n_splits, random_state=self.random_seed, shuffle=True)
74
+ triu_x, triu_y = np.triu_indices(self.n_nodes_, k=0 if self.loops_allowed else 1)
75
+ if self.fix_layer_split:
76
+ rng = np.arange(len(triu_x))
77
+ for _, test_idx in kfold.split(rng):
78
+ triu_test_masks = np.stack([np.isin(rng, test_idx)] * self.n_layers_)
79
+ yield self._make_train_test_layers(As, triu_test_masks)
80
+ else:
81
+ rng = np.arange(self.n_layers_ * len(triu_x))
82
+ for _, test_idx in kfold.split(rng):
83
+ triu_test_masks = np.isin(rng, test_idx).reshape((self.n_layers_, len(triu_x)))
84
+ yield self._make_train_test_layers(As, triu_test_masks)
85
+
86
+
87
+ class EarlyStopper:
88
+ def __init__(self, patience=10, higher_better=True, rtol=1e-4):
89
+ self.patience = patience
90
+ self.higher_better = higher_better
91
+ self.rtol = rtol
92
+ self.patience_counter = -1
93
+ self.best_metric = None
94
+
95
+ def stop_check(self, val) -> bool:
96
+ if self.patience_counter == -1:
97
+ self.best_metric = val
98
+ self.patience_counter = 0
99
+ return False
100
+
101
+ tolerance = self.rtol * abs(self.best_metric)
102
+ if self.higher_better:
103
+ improvement = val > self.best_metric + tolerance
104
+ else:
105
+ improvement = val < self.best_metric - tolerance
106
+
107
+ if improvement:
108
+ self.patience_counter = 0
109
+ self.best_metric = val
110
+ else:
111
+ self.patience_counter += 1
112
+
113
+ return self.patience_counter >= self.patience
114
+
115
+
116
+ def sigmoid(x):
117
+ return 1. / (1 + np.exp(-x))
118
+
119
+
120
+ def fill_nan(x: np.ndarray, val: float = 0.):
121
+ return np.where(np.isnan(x), val, x)
122
+
123
+
124
+ def leading_left_eigenvectors(A, k: int, eigval_threshold=None):
125
+ assert len(A.shape) == 2, "Matrix should have two dimensions"
126
+ if k >= min(*A.shape):
127
+ u, s, _ = np.linalg.svd(A, compute_uv=True)
128
+ else:
129
+ u, s, _ = svds(A, k=k)
130
+ s_indices = np.argsort(s)[::-1]
131
+ u, s = u[:, s_indices], s[s_indices]
132
+ return u if eigval_threshold is None else u[:, s >= eigval_threshold]
133
+
134
+
135
+ def leading_eigenvectors(A, k: int):
136
+ assert len(A.shape) == 2, "Matrix should have two dimensions"
137
+ if k >= min(*A.shape):
138
+ u, s, vt = np.linalg.svd(A, compute_uv=True)
139
+ else:
140
+ u, s, vt = svds(A, k=k)
141
+ s_indices = np.argsort(s)[::-1]
142
+ u, s, vt = u[:, s_indices], s[s_indices], vt[s_indices]
143
+ return u, vt.T
144
+
145
+
146
+ def extract_single_member_from_each_group(objects, group_indices) -> list:
147
+ unique_objects = []
148
+ for group in sorted(np.unique(group_indices)):
149
+ unique_objects.append(objects[group_indices == group][0])
150
+ return unique_objects
151
+
152
+
153
+ def psd_projection(A):
154
+ assert np.allclose(A, A.T), "A should be a symmetric square matrix"
155
+ eigvals, eigvecs = np.linalg.eig(A)
156
+ eigvals, eigvecs = np.real(eigvals), np.real(eigvecs)
157
+ trunc_eigvals = np.clip(eigvals, 0, None)
158
+ return eigvecs @ np.diag(trunc_eigvals) @ eigvecs.T
159
+
160
+
161
+ def truncated_eigen_decomposition(A, max_rank=None, which="LM"):
162
+ assert np.allclose(A, A.T), "A should be a symmetric square matrix"
163
+ if max_rank is None or max_rank >= len(A):
164
+ eigvals, eigvecs = np.linalg.eig(A)
165
+ else:
166
+ assert which in ["LM", "LA"], "which should be either LM (largest magnitude) or LA (largest algebraic)"
167
+ eigvals, eigvecs = eigsh(A, k=max_rank, which=which)
168
+ return eigvals, np.real(eigvecs)
169
+
170
+
171
+ def truncated_svd(A, max_rank=None, compute_uv=True):
172
+ min_dim = min(*A.shape)
173
+ if max_rank is None or max_rank >= min_dim:
174
+ max_rank = min_dim
175
+ if max_rank == min_dim:
176
+ u, s, vt = np.linalg.svd(A, compute_uv=compute_uv)
177
+ u, vt = u[:, :min_dim], vt[:min_dim, :]
178
+ elif max_rank == 0:
179
+ u, s, vt = np.zeros((A.shape[0], 1)), np.array([0.]), np.zeros((1, A.shape[1]))
180
+ else:
181
+ u, s, vt = svds(A, k=max_rank, return_singular_vectors=compute_uv)
182
+ s_indices = np.argsort(s)[::-1]
183
+ u, s, vt = u[:, s_indices], s[s_indices], vt[s_indices]
184
+ return u, s, vt
185
+
186
+
187
+ def hard_thresholding_operator(A, threshold=None, max_rank=None):
188
+ u, s, vt = truncated_svd(A, max_rank=max_rank)
189
+ if threshold is not None:
190
+ s = np.where(s >= threshold, s, 0.)
191
+ return u @ np.diag(s) @ vt
192
+
193
+
194
+ def soft_thresholding_operator(A, threshold, max_rank=None):
195
+ u, s, vt = truncated_svd(A, max_rank=max_rank)
196
+ s = np.clip(s - threshold, 0, None)
197
+ return u @ np.diag(s) @ vt
198
+
199
+
200
+ def matrix_power(A: np.array, p: Union[int, float], eps=1e-8):
201
+ if isinstance(p, int):
202
+ return np.linalg.matrix_power(A, p)
203
+ u, s, vt = np.linalg.svd(A)
204
+ assert np.all(s > eps), "float powers defined only for matrices with all positive singular values"
205
+ return u @ np.diag(s ** p) @ vt
206
+
207
+
208
+ def frobenius_error(A_true, A_pred, relative=False, include_offdiag=True):
209
+ assert A_true.shape == A_pred.shape
210
+ assert A_true.ndim <= 2
211
+ mask = np.ones(A_true.shape, dtype=bool) if include_offdiag else ~np.eye(A_true.shape[0], dtype=bool)
212
+ abs_error = np.sqrt(np.sum((A_true - A_pred) ** 2, where=mask))
213
+ return abs_error / np.sqrt(np.sum(A_true ** 2, where=mask)) if relative else abs_error
214
+
215
+
216
+ def mean_frobenius_error(As_true, As_pred, relative=True, include_offdiag=True):
217
+ assert len(As_true) == len(As_pred)
218
+ assert As_true.ndim in (2, 3)
219
+ if As_true.ndim == 2:
220
+ As_true = [As_true]
221
+ As_pred = [As_pred]
222
+ return np.mean([frobenius_error(A_true, A, relative=relative, include_offdiag=include_offdiag)
223
+ for A_true, A in zip(As_true, As_pred)])
224
+
225
+
226
+ def make_error_report(params_true: Union[List[np.array], Tuple[np.array]],
227
+ params_pred: Union[List[np.array], Tuple[np.array]],
228
+ relative_errors: bool = True):
229
+ assert len(params_true) == len(params_pred)
230
+ errs = []
231
+ for true, pred in zip(params_true, params_pred):
232
+ err = mean_frobenius_error(true, pred, relative=relative_errors)
233
+ errs.append(err)
234
+ return errs
235
+
236
+
237
+ def make_group_averages(As, group_indices, groups=None) -> np.array:
238
+ A_bars = []
239
+ if groups is not None:
240
+ assert set(groups).issubset(set(group_indices))
241
+ else:
242
+ groups = sorted(np.unique(group_indices))
243
+ for group in groups:
244
+ group_mask = group_indices == group
245
+ A_bar = np.nansum(As[group_mask], axis=0) / group_mask.sum()
246
+ A_bars.append(A_bar)
247
+ return np.stack(A_bars)
248
+
249
+
250
+ def random_orthonormal_matrix(m: int, n: int):
251
+ assert m >= n
252
+ random_matrix = np.random.randn(m, n)
253
+ Q, R = np.linalg.qr(random_matrix)
254
+ return Q[:, :n]
255
+
256
+
257
+ def generate_correlated_matrix(A, d, cor=0., max_eigval=None, eps=1e-5):
258
+ n, d0 = A.shape
259
+ assert d0 > 0
260
+ proj_A = A @ np.linalg.pinv(A.T @ A + eps * np.eye(d0)) @ A.T
261
+ A_cor = (np.eye(n) - proj_A) @ np.random.randn(n, d) # uncorrelated matrix
262
+ scales = np.sqrt(1. - cor ** 2) * np.ones(min(d, d0))
263
+ Q = random_orthonormal_matrix(d0, min(d, d0)).T
264
+ if d > d0:
265
+ Q = np.vstack([Q, np.zeros((d - d0, d0))])
266
+ scales = np.hstack([scales, np.ones(d - d0)])
267
+ A_cor = cor * (A @ Q.T) + A_cor @ np.diag(scales)
268
+ if max_eigval is not None:
269
+ A_cor = A_cor / np.linalg.norm(A_cor, ord=2) * max_eigval
270
+ return A_cor
271
+
272
+
273
+ def rectangular_eye(n: int, d: int):
274
+ min_dim = min(n, d)
275
+ eye = np.eye(min_dim)
276
+ if n > d:
277
+ eye = np.vstack([eye, np.zeros((n - d, d))])
278
+ elif n < d:
279
+ eye = np.hstack([eye, np.zeros((n, d - n))])
280
+ return eye
281
+
282
+
283
+ def generate_matrices_given_pairwise_max_cosines(n: int, ds: list[int], pairwise_cos_mat: np.ndarray):
284
+ assert pairwise_cos_mat.ndim == 2
285
+ assert len(ds) == len(pairwise_cos_mat)
286
+ assert np.allclose(pairwise_cos_mat, pairwise_cos_mat.T), "pairwise_cos_mat should be symmetric"
287
+ assert n >= np.sum(ds)
288
+ assert np.allclose(np.diag(pairwise_cos_mat), 1)
289
+ assert np.all(pairwise_cos_mat >= 0) & np.all(pairwise_cos_mat <= 1), "pairwise_cos_mat entries should be in [0, 1]"
290
+ m = len(ds)
291
+ stacked_Vs = np.random.randn(n, np.sum(ds))
292
+ cur_gram_mat = stacked_Vs.T @ stacked_Vs / n
293
+ target_gram_mat = np.block([[pairwise_cos_mat[i, j] * rectangular_eye(ds[i], ds[j]) for j in range(m)]
294
+ for i in range(m)])
295
+ stacked_Vs = stacked_Vs @ matrix_power(cur_gram_mat, -0.5) @ matrix_power(target_gram_mat, 0.5)
296
+ cumsum_ds = [0] + list(np.cumsum(ds))
297
+ return [stacked_Vs[:, s: e] for s, e in zip(cumsum_ds[:-1], cumsum_ds[1:])]
298
+
299
+
300
+ def make_group_indices(group_props, num_layers):
301
+ groups_sizes = (np.array(group_props) * num_layers).astype(int)
302
+ groups_sizes[-1] = num_layers - groups_sizes[:-1].sum()
303
+
304
+ group_indices = np.hstack([[group_idx for _ in range(size)]
305
+ for group_idx, size in enumerate(groups_sizes)])
306
+ return group_indices
307
+
308
+
309
+ def estimate_sigma_mad(M):
310
+ # Center columns
311
+ Mc = M - np.mean(M, axis=0)
312
+
313
+ # Dimensions
314
+ n, p = Mc.shape
315
+
316
+ # Auxiliary quantities
317
+ beta = min(n, p) / max(n, p)
318
+ lambdastar = np.sqrt(
319
+ 2 * (beta + 1) + 8 * beta / (beta + 1 + np.sqrt(beta ** 2 + 14 * beta + 1))
320
+ )
321
+ wbstar = 0.56 * beta ** 3 - 0.95 * beta ** 2 + 1.82 * beta + 1.43
322
+
323
+ # Sigma estimate
324
+ singular_values = np.linalg.svd(Mc, compute_uv=False)
325
+ sigma = np.median(singular_values) / (np.sqrt(max(n, p)) * (lambdastar / wbstar))
326
+
327
+ return sigma
328
+
329
+
330
+ def if_scalar_or_given_length_array(val, length: int, name: str = None) -> np.array:
331
+ name = f'{val=}'.split('=')[0] if name is None else name
332
+ if isinstance(val, (np.ndarray, list)):
333
+ assert len(val) == length, \
334
+ f"If {name} is not a scalar, it should have length {len(val)}"
335
+ val = np.array(val)
336
+ elif np.isscalar(val):
337
+ val = val * np.ones(length)
338
+ else:
339
+ raise NotImplementedError(f"{name} should be a list, np.ndarray or scalar!")
340
+ return val
341
+
342
+
343
+ def fill_diagonals(As: Union[np.array, List[np.array]], val: float = 0, inplace: bool = True):
344
+ if not inplace:
345
+ As = copy(As)
346
+ for idx in range(len(As)):
347
+ np.fill_diagonal(As[idx], val)
348
+ if not inplace:
349
+ return As
350
+
351
+
352
+ def pairwise_metric_matrix(matrices, metric, **metric_kwargs):
353
+ dist_mat = np.zeros((len(matrices), len(matrices)))
354
+ for i in range(len(matrices)):
355
+ for j in range(i, len(matrices)):
356
+ dist = metric(matrices[i], matrices[j], **metric_kwargs)
357
+ dist_mat[i, j] = dist
358
+ dist_mat[j, i] = dist
359
+ return dist_mat
360
+
361
+
362
+ def cos_sim(A, B, max_rank=10):
363
+ assert A.ndim == 2
364
+ assert B.ndim == 2
365
+ assert A.shape[0] == B.shape[0]
366
+ U1, _, _ = truncated_svd(A, max_rank=max_rank)
367
+ U2, _, _ = truncated_svd(B, max_rank=max_rank)
368
+ return np.linalg.norm(U1.T @ U2, ord=2)
369
+
370
+
371
+ def avg_lp_error(y_true, y_pred, p=2):
372
+ assert y_true.shape == y_true.shape
373
+ return (np.nanmean((y_true - y_pred) ** p)) ** (1. / p)
374
+
375
+
376
+ def fisher_transform(x, eps=1e-6):
377
+ assert np.all(x <= 1) and np.all(x >= -1)
378
+ x = np.clip(x, -1 + eps, 1 - eps)
379
+ return np.log((1. + x) / (1. - x)) / 2