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.
- GroupMultiNeSS/MASE.py +72 -0
- GroupMultiNeSS/__init__.py +7 -0
- GroupMultiNeSS/base.py +337 -0
- GroupMultiNeSS/data_generation.py +205 -0
- GroupMultiNeSS/group_multiness.py +524 -0
- GroupMultiNeSS/multiness.py +1307 -0
- GroupMultiNeSS/shared_space_hunting.py +315 -0
- GroupMultiNeSS/utils.py +379 -0
- groupmultiness-0.0.1.dist-info/METADATA +102 -0
- groupmultiness-0.0.1.dist-info/RECORD +13 -0
- groupmultiness-0.0.1.dist-info/WHEEL +5 -0
- groupmultiness-0.0.1.dist-info/licenses/LICENSE +22 -0
- groupmultiness-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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())
|
GroupMultiNeSS/utils.py
ADDED
|
@@ -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
|