dspin 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dspin/compute.py ADDED
@@ -0,0 +1,1690 @@
1
+ # -*-coding:utf-8 -*-
2
+ '''
3
+ @Time : 2023/09/14 21:51
4
+ @Author : Jialong Jiang, Yingying Gong
5
+ '''
6
+
7
+ from scipy.linalg import orth
8
+ from collections import deque
9
+ import numba
10
+ from sklearn.preprocessing import normalize
11
+ from sklearn.decomposition import NMF, PCA
12
+ import numpy as np
13
+ import matplotlib.pyplot as plt
14
+ import scanpy as sc
15
+ import anndata as ad
16
+ import os
17
+ from sklearn.cluster import KMeans
18
+ import scipy.io as sio
19
+ from tqdm import tqdm
20
+ from typing import Tuple, List, Callable, Any, Dict
21
+ import warnings
22
+ from scipy.sparse import issparse
23
+ from joblib import Parallel, delayed, parallel_backend
24
+ from sklearn.cluster import AgglomerativeClustering
25
+ import leidenalg as la
26
+ import igraph as ig
27
+ import networkx as nx
28
+
29
+
30
+ def category_balance_number(
31
+ total_sample_size: int,
32
+ cluster_count: List[int],
33
+ method: str,
34
+ maximum_sample_rate: float) -> np.array:
35
+ """
36
+ Calculate the sampling number for each category based on the specified method.
37
+
38
+ Parameters
39
+ ----------
40
+ total_sample_size : int
41
+ The total number of samples.
42
+ cluster_count : List[int]
43
+ A list containing the count of elements in each cluster.
44
+ method : str
45
+ The method used for balancing the categories. Options are 'equal', 'proportional', or 'squareroot'.
46
+ maximum_sample_rate : float
47
+ The maximum sample rate allowed.
48
+
49
+ Returns
50
+ -------
51
+ np.array
52
+ An array containing the number of samples for each category computed using the given method.
53
+ """
54
+
55
+ # Validate the method parameter
56
+ if method not in ['equal', 'proportional', 'squareroot']:
57
+ raise ValueError('method must be one of equal, proportional, squareroot')
58
+
59
+ # Calculate the sampling number based on the selected method.
60
+ if method == 'squareroot':
61
+ # For the 'squareroot' method, compute values based on the square root of the cluster counts.
62
+ esti_size = (np.sqrt(cluster_count) / np.sum(np.sqrt(cluster_count)) * total_sample_size).astype(int)
63
+ weight_fun = np.min([esti_size, maximum_sample_rate * np.array(cluster_count)], axis=0)
64
+ elif method == 'equal':
65
+ # For the 'equal' method, divide the total sample size equally among clusters.
66
+ esti_size = total_sample_size / len(cluster_count)
67
+ weight_fun = np.min([esti_size * np.ones(len(cluster_count)), maximum_sample_rate * np.array(cluster_count)], axis=0)
68
+ else:
69
+ # For the 'proportional' method, use the cluster counts directly as weights.
70
+ weight_fun = cluster_count
71
+
72
+ # Compute the final sampling number for each category.
73
+ sampling_number = (weight_fun / np.sum(weight_fun) * total_sample_size).astype(int)
74
+ return sampling_number
75
+
76
+
77
+ def summary_components(all_components: np.array,
78
+ num_spin: int,
79
+ summary_method: str = 'kmeans',
80
+ figure_folder: str=None) -> List[np.array]:
81
+ """
82
+ Summarize components using a clustering algorithm.
83
+
84
+ Parameters
85
+ ----------
86
+ all_components : np.array
87
+ A 2D array where each row represents a sample and each column represents a feature.
88
+ num_spin : int
89
+ The number of clusters.
90
+ summary_method : str, optional
91
+ The method used for summarizing the components. Options are 'kmeans' or 'leiden'. Default is 'kmeans'.
92
+ figure_folder : str, optional
93
+ Folder in which to save the figure.
94
+
95
+ Returns
96
+ -------
97
+ List[np.array]
98
+ A list of numpy arrays, each containing the indices of the genes that belong to a specific cluster.
99
+ """
100
+ num_gene = all_components.shape[1]
101
+
102
+ if summary_method == 'kmeans':
103
+ # Fit KMeans clustering to the components and to their transposed version.
104
+ kmeans = KMeans(n_clusters=num_spin, random_state=0, n_init=50).fit(all_components)
105
+
106
+ # take the genes with larger weights for display only
107
+ gene_weight = np.sum(all_components ** 2, axis=0) ** 0.5
108
+ gene_sele_filt = gene_weight > np.mean(gene_weight)
109
+ all_components_sub = all_components[:, gene_sele_filt]
110
+ kmeans_gene = KMeans(n_clusters=min(2 * num_spin, all_components_sub.shape[1]), random_state=0, n_init=10).fit(all_components_sub.T)
111
+
112
+ # Initialize an array to store the average component for each cluster.
113
+ components_kmeans = np.zeros((num_spin, num_gene))
114
+
115
+ for ii in range(num_spin):
116
+ # Calculate the average component for the current cluster.
117
+ components_kmeans[ii] = np.mean(all_components[kmeans.labels_ == ii], axis=0)
118
+
119
+ # Normalize the computed components.
120
+ components_kmeans = normalize(components_kmeans, axis=1, norm='l2')
121
+
122
+ gene_groups_ind = []
123
+ for ii in range(num_spin):
124
+ # Determine gene cluster assignments based on the maximum component values.
125
+ gene_groups_ind.append(np.argmax(components_kmeans, axis=0) == ii)
126
+
127
+ sc.set_figure_params(figsize=(8, 4))
128
+
129
+ plt.subplot(1, 2, 1)
130
+ gene_order = np.argsort(kmeans_gene.labels_)
131
+ comp_order = np.argsort(kmeans.labels_)
132
+ plt.imshow(all_components_sub[comp_order, :][:, gene_order], aspect='auto', cmap='Blues', vmax=np.max(all_components) / 10, interpolation='none')
133
+ plt.title('All components')
134
+
135
+ plt.subplot(1, 2, 2)
136
+ plt.imshow(components_kmeans[:, gene_sele_filt][:, gene_order], aspect='auto', cmap='Blues', vmax=np.max(components_kmeans) / 10, interpolation='none')
137
+ plt.title('Kmeans components')
138
+
139
+ if figure_folder is not None:
140
+ plt.savefig(figure_folder + 'onmf_decomposition_summary.png', dpi=300, bbox_inches='tight')
141
+ plt.close()
142
+
143
+ elif summary_method == 'leiden':
144
+
145
+ consensus = np.einsum('ij,ik->jk', all_components, all_components) / all_components.shape[0]
146
+ consensus_filt = np.where(np.max(consensus, axis=0) > np.percentile(consensus.flatten(), 99))[0]
147
+ consensus_sub = consensus[:, consensus_filt][consensus_filt]
148
+
149
+ # Perform Agglomerative Clustering on consensus matrix
150
+ clusterer = AgglomerativeClustering(n_clusters=num_spin, metric='precomputed', linkage='complete')
151
+ cluster_labels = clusterer.fit_predict(np.sqrt(- np.log(consensus_sub)))
152
+
153
+ # Convert to graph and apply Leiden algorithm
154
+ G = nx.from_numpy_array(consensus)
155
+ G = ig.Graph.from_networkx(G)
156
+ np.random.seed(0)
157
+
158
+ optimiser = la.Optimiser()
159
+ membership = np.ones(consensus.shape[0]) * (1 + max(cluster_labels))
160
+ membership[consensus_filt] = cluster_labels
161
+ membership_fixed = np.zeros(consensus.shape[0])
162
+ membership_fixed[consensus_filt] = 1
163
+
164
+ membership = membership.astype(int)
165
+ membership_fixed = membership_fixed.astype(int)
166
+
167
+ partition = la.RBConfigurationVertexPartition(G, initial_membership=membership, weights='weight')
168
+ diff = optimiser.optimise_partition(partition, n_iterations=- 1, is_membership_fixed=list(membership_fixed))
169
+
170
+ gene_groups_ind = []
171
+ cluster_label_full = np.nan * np.ones(consensus.shape[0])
172
+
173
+ for ii, part in enumerate(partition):
174
+ gene_groups_ind.append(part)
175
+ cluster_label_full[part] = ii
176
+
177
+ gene_order = np.argsort(cluster_label_full)
178
+ gene_list_size = np.array([np.sum(cluster_label_full == ii) for ii in range(num_spin)])
179
+
180
+ plt.figure(figsize=[8, 8])
181
+ plt.imshow(consensus[gene_order][:, gene_order], cmap='Blues', vmin=0, vmax=np.percentile(consensus.flatten(), 98))
182
+ for ii in range(1, num_spin):
183
+ plt.axvline(np.sum(gene_list_size[:ii]) - 0.5, color='k')
184
+ plt.axhline(np.sum(gene_list_size[:ii]) - 0.5, color='k')
185
+ plt.grid()
186
+
187
+ if figure_folder is not None:
188
+ plt.savefig(figure_folder + 'onmf_decomposition_summary.png', dpi=300, bbox_inches='tight')
189
+ plt.close()
190
+
191
+ return gene_groups_ind
192
+
193
+
194
+ def onmf(X: np.array, rank: int, max_iter: int = 500) -> Tuple[np.array, np.array]:
195
+ """
196
+ Perform Orthogonal Non-Negative Matrix Factorization (ONMF) for a given rank.
197
+
198
+ Parameters
199
+ ----------
200
+ X : np.array
201
+ Input data matrix.
202
+ rank : int
203
+ Desired rank for factorization.
204
+ max_iter : int, optional
205
+ Maximum number of iterations. Default is 500.
206
+
207
+ Returns
208
+ -------
209
+ Tuple[np.array, np.array]
210
+ The factorized matrix S.T.
211
+ The matrix A.
212
+ """
213
+
214
+ m, n = X.shape
215
+
216
+ # Initialize matrices A and S
217
+ A = np.random.rand(m, rank)
218
+ S = np.random.rand(rank, n)
219
+ S = np.abs(orth(S.T).T)
220
+
221
+ pbar = tqdm(total=max_iter, desc="Iteration Progress")
222
+
223
+ for itr in range(max_iter):
224
+ # Update A and S using multiplicative update rules
225
+ coef_A = X.dot(S.T) / A.dot(S.dot(S.T))
226
+ A = np.nan_to_num(A * coef_A) #, posinf=1e5)
227
+
228
+ AtX = A.T.dot(X)
229
+ coef_S = AtX / S.dot(AtX.T).dot(S)
230
+ S = np.nan_to_num(S * coef_S) #, posinf=1e5)
231
+
232
+ # pbar.update(1)
233
+
234
+ # Calculate the reconstruction error every 10 iterations and update the
235
+ # progress bar
236
+ if itr % 20 == 0:
237
+ error = np.linalg.norm(X - np.dot(A, S), 'fro')
238
+ self_product = S.dot(S.T)
239
+ orthogonal_error = np.linalg.norm(self_product - np.diag(np.diag(self_product)), 'fro') / np.linalg.norm(S, 'fro')
240
+ pbar.set_postfix({"Reconstruction error": f"{error:.2f}", "Orthogonal error": f"{orthogonal_error:.4f}"})
241
+ pbar.update(20)
242
+
243
+ pbar.close()
244
+
245
+ # Normalize the components
246
+ norm_fac = np.sqrt(np.diag(S.dot(S.T)))
247
+ S /= norm_fac.reshape(-1, 1)
248
+ A *= norm_fac.reshape(1, -1)
249
+ A *= np.sum(X * A.dot(S)) / np.sum((A.dot(S)) ** 2)
250
+
251
+ return S.T, A
252
+
253
+
254
+ def compute_onmf(seed: int,
255
+ num_spin: int,
256
+ gene_matrix: np.array,
257
+ max_iter: int = 500) -> NMF:
258
+ """
259
+ Compute the ONMF model for the given gene matrix.
260
+
261
+ Parameters
262
+ ----------
263
+ seed : int
264
+ Seed for random number generation.
265
+ num_spin : int
266
+ The number of desired components (clusters/spins).
267
+ gene_matrix : np.array
268
+ Matrix representing gene expression data. Typically, the matrix should be normalized by library size and log1-transformed. Normalizing each gene by its standard deviation is also recommended.
269
+ max_iter : int, optional
270
+ Maximum number of iterations. Default is 500.
271
+
272
+ Returns
273
+ -------
274
+ NMF
275
+ The NMF model with computed components.
276
+ """
277
+ # Generate a random seed
278
+ np.random.seed(seed)
279
+
280
+ tqdm._instances.clear()
281
+ # Factorized Matrices
282
+ H, W = onmf(gene_matrix, num_spin, max_iter)
283
+
284
+ # Initialize the NMF model
285
+ nmf_model = NMF(n_components=num_spin, random_state=seed)
286
+
287
+ # Set the components and number of components
288
+ nmf_model.components_ = np.array(H).T
289
+ nmf_model.n_components_ = num_spin
290
+
291
+ return nmf_model
292
+
293
+
294
+ def onmf_discretize(onmf_rep_ori: np.array,
295
+ num_init: int = 10,
296
+ fig_folder: str = None) -> np.array:
297
+ """
298
+ Discretize the ONMF representation using KMeans clustering and visualize the sorted representations.
299
+
300
+ Parameters
301
+ ----------
302
+ onmf_rep_ori : np.array
303
+ Original ONMF representation.
304
+ fig_folder : str, optional
305
+ Folder in which to save the figure.
306
+
307
+ Returns
308
+ -------
309
+ np.array
310
+ Discretized ONMF representation.
311
+ """
312
+ num_spin = onmf_rep_ori.shape[1]
313
+ sc.set_figure_params(figsize=[2, 2])
314
+ _, grid = sc.pl._tools._panel_grid(
315
+ 0.3, 0.3, ncols=7, num_panels=min(
316
+ 21, num_spin))
317
+
318
+ onmf_rep_tri = np.zeros(onmf_rep_ori.shape)
319
+ print('Processing gene or program expression levels...')
320
+ for ii in tqdm(range(num_spin)):
321
+ # Perform KMeans clustering for each spin/component
322
+ km_fit = KMeans(n_clusters=3, n_init=num_init).fit(onmf_rep_ori[:, ii].reshape(-1, 1))
323
+
324
+ onmf_rep_tri[:, ii] = (km_fit.labels_ == np.argsort(km_fit.cluster_centers_.reshape(-1))[0]) * \
325
+ (-1) + (km_fit.labels_ == np.argsort(km_fit.cluster_centers_.reshape(-1))[2]) * 1
326
+
327
+ # Visualize the sorted representations
328
+ if ii < 21:
329
+ ax = plt.subplot(grid[ii])
330
+ plt.plot(np.sort(onmf_rep_ori[:, ii]))
331
+ plt.plot(np.sort(km_fit.cluster_centers_[km_fit.labels_].reshape(-1)))
332
+
333
+ # Save the visual representation
334
+ if fig_folder is not None:
335
+ print('Saving the example state partition figure to ' + fig_folder + 'onmf_discretize.png')
336
+ plt.savefig(f'{fig_folder}/onmf_discretize.png', bbox_inches='tight')
337
+ plt.close()
338
+
339
+ return onmf_rep_tri
340
+
341
+
342
+ def compute_onmf_decomposition(gene_matrix_norm: np.ndarray,
343
+ num_onmf_components: int,
344
+ seed: int = 0,
345
+ params: dict = None,
346
+ save_path: str = None) -> None:
347
+ """
348
+ Perform multiple oNMF decompositions on subsampled data to enhance stability.
349
+
350
+ Parameters
351
+ ----------
352
+ gene_matrix_norm : np.ndarray
353
+ Normalized gene expression matrix.
354
+ num_onmf_components : int
355
+ Number of oNMF components.
356
+ seed : int, optional
357
+ Random seed for reproducibility. Default is 0.
358
+ params : dict, optional
359
+ Additional parameters for oNMF. Default is None.
360
+ save_path : str, optional
361
+ Path to save the oNMF decomposition results. Default is None.
362
+ """
363
+ np.random.seed(seed)
364
+ print(f'Computing oNMF decomposition of seed {seed}')
365
+
366
+ # Check if oNMF decomposition for the current iteration exists
367
+ if os.path.exists(save_path):
368
+ print("oNMF decomposition with seed {} already exists. Skipping...".format(seed))
369
+ return
370
+
371
+ # Compute current oNMF decomposition and save the result
372
+ current_onmf = compute_onmf(
373
+ seed, num_onmf_components, gene_matrix_norm, max_iter=params['onmf_epoch_number'])
374
+ np.save(save_path, current_onmf)
375
+
376
+
377
+ def subsample_normalize_gene_matrix(gene_matrix: np.ndarray,
378
+ cluster_label: np.ndarray,
379
+ num_subsample: int = 10000,
380
+ seed: int = 0,
381
+ params: dict = None) -> Tuple[np.ndarray, np.ndarray]:
382
+ """
383
+ Subsample and balance the gene matrix based on desired sample size and standard deviation clipping.
384
+
385
+ Parameters
386
+ ----------
387
+ gene_matrix : np.ndarray
388
+ The gene expression matrix.
389
+ cluster_label : np.ndarray
390
+ Labels for clustering.
391
+ num_subsample : int, optional
392
+ Number of samples to subsample. Default is 10000.
393
+ seed : int, optional
394
+ Seed for reproducibility. Default is 0.
395
+ params : dict, optional
396
+ Additional parameters for subsampling and balancing. Default is None.
397
+
398
+ Returns
399
+ -------
400
+ Tuple[np.ndarray, np.ndarray]
401
+ Tuple containing the standard deviation of the subsampled matrix and the normalized gene matrix.
402
+ """
403
+ np.random.seed(seed)
404
+
405
+ num_cell, num_gene = gene_matrix.shape
406
+
407
+ method = params['balance_method']
408
+ maximum_sample_rate = params['max_sample_rate']
409
+ std_clip_percentile = params['std_clip_percentile']
410
+ min_cluster_size = params['min_cluster_size']
411
+
412
+ # If no balancing is required, simply subsample the matrix
413
+ if method is None:
414
+ gene_matrix_balanced = gene_matrix[np.random.choice(
415
+ num_cell, num_subsample, replace=False), :]
416
+ else:
417
+ cluster_list, cluster_count = np.unique(cluster_label, return_counts=True)
418
+
419
+ # Filter out clusters smaller than the minimum size
420
+ if np.any(cluster_count < min_cluster_size):
421
+ use_cluster = cluster_list[cluster_count >= min_cluster_size]
422
+ filt_ind = np.isin(cluster_label, use_cluster)
423
+ gene_matrix = gene_matrix[filt_ind, :]
424
+ cluster_label = cluster_label[filt_ind]
425
+ cluster_list, cluster_count = np.unique(cluster_label, return_counts=True)
426
+
427
+ warnings.warn("Clusters smaller than {} cells are filtered out.".format(min_cluster_size))
428
+
429
+ sampling_number = category_balance_number(num_subsample, cluster_count, method, maximum_sample_rate)
430
+
431
+ gene_matrix_balanced = np.zeros(
432
+ (np.sum(sampling_number), num_gene))
433
+
434
+ for ii in range(len(cluster_list)):
435
+ cur_num = sampling_number[ii]
436
+ cur_filt = cluster_label == cluster_list[ii]
437
+ sele_ind = np.random.choice(np.sum(cur_filt), cur_num)
438
+ strart_ind = np.sum(sampling_number[:ii])
439
+ end_ind = strart_ind + cur_num
440
+
441
+ if issparse(gene_matrix):
442
+ gene_matrix_balanced[strart_ind: end_ind, :] = gene_matrix[cur_filt, :][sele_ind, :].toarray()
443
+ else:
444
+ gene_matrix_balanced[strart_ind: end_ind, :] = gene_matrix[cur_filt, :][sele_ind, :]
445
+
446
+ std = gene_matrix_balanced.std(axis=0)
447
+ std_clipped = std.clip(np.percentile(std, std_clip_percentile), np.inf)
448
+ gene_matrix_balanced_normalized = gene_matrix_balanced / std_clipped
449
+
450
+ return std_clipped, gene_matrix_balanced_normalized
451
+
452
+
453
+ def corr_mean(cur_data: np.array) -> np.array:
454
+ """
455
+ Calculate the correlation matrix and mean of the given data.
456
+
457
+ Parameters
458
+ ----------
459
+ cur_data : np.array
460
+ Input data matrix.
461
+
462
+ Returns
463
+ -------
464
+ np.array
465
+ Array containing the correlation matrix and the mean of the data.
466
+ """
467
+ rec_data = np.zeros(2, dtype=object)
468
+ rec_data[0] = cur_data.T.dot(cur_data) / cur_data.shape[0]
469
+ rec_data[1] = np.mean(cur_data, axis=0).reshape(-1, 1)
470
+
471
+ return rec_data
472
+
473
+
474
+ def sample_corr_mean(samp_full: np.array, comp_bin: np.array) -> Tuple[np.array, np.array]:
475
+ """
476
+ Calculate the correlation mean for each unique sample in samp_full.
477
+
478
+ Parameters
479
+ ----------
480
+ samp_full : np.array
481
+ Array of samples.
482
+ comp_bin : np.array
483
+ Binary matrix representation of the samples.
484
+
485
+ Returns
486
+ -------
487
+ Tuple[np.array, np.array]
488
+ An array of correlation means for each unique sample.
489
+ An array of unique sample labels.
490
+ """
491
+ samp_list = np.unique(samp_full)
492
+ raw_corr_data = np.zeros(len(samp_list), dtype=object)
493
+
494
+ # Calculating correlation mean for each unique sample
495
+ for ind, samp in enumerate(samp_list):
496
+ filt_ind = samp_full == samp
497
+ raw_corr_data[ind] = corr_mean(comp_bin[filt_ind, :])
498
+
499
+ return raw_corr_data, samp_list
500
+
501
+
502
+ def sample_states(samp_full: np.array, onmf_rep_tri: np.array) -> Tuple[np.array, np.array]:
503
+ """
504
+ Extract the state representations for each unique sample.
505
+
506
+ Parameters
507
+ ----------
508
+ samp_full : np.array
509
+ Array of sample labels.
510
+ onmf_rep_tri : np.array
511
+ Discretized ONMF representation.
512
+
513
+ Returns
514
+ -------
515
+ Tuple[np.array, np.array]
516
+ An array of state representations (transposed) for each unique sample.
517
+ An array of unique sample labels.
518
+ """
519
+ samp_list = np.unique(samp_full)
520
+ state_list = np.zeros(len(samp_list), dtype=object)
521
+
522
+ for ii, cur_samp in enumerate(samp_list):
523
+ cur_filt = samp_full == cur_samp
524
+ cur_state = onmf_rep_tri[cur_filt, :]
525
+ state_list[ii] = cur_state.T
526
+
527
+ return state_list, samp_list
528
+
529
+
530
+ def para_moments_numpy(j_mat: np.array, h_vec: np.array) -> Tuple[np.array, np.array]:
531
+ """
532
+ Calculate the correlation and mean parameters given a j matrix and an h vector.
533
+
534
+ Parameters
535
+ ----------
536
+ j_mat : np.array
537
+ Interaction matrix.
538
+ h_vec : np.array
539
+ External field vector.
540
+
541
+ Returns
542
+ -------
543
+ Tuple[np.array, np.array]
544
+ The correlation parameter.
545
+ The mean parameter.
546
+ """
547
+ num_spin = j_mat.shape[0]
548
+ num_sample = 3 ** num_spin
549
+ sample_indices = np.indices((3,) * num_spin)
550
+ ordered_sample = (sample_indices - 1).reshape(num_spin, num_sample)
551
+
552
+ # Adding diagonal elements to themselves for calculation
553
+ j_mat = j_mat + np.diag(np.diag(j_mat))
554
+ ordered_energy = - (h_vec.T @ ordered_sample + \
555
+ np.sum((j_mat @ ordered_sample) * ordered_sample, axis=0) / 2)
556
+ ordered_exp = np.exp(-ordered_energy)
557
+ partition = np.sum(ordered_exp)
558
+ freq = ordered_exp / partition # Calculating the frequency
559
+ mean_para = np.sum(ordered_sample * freq.reshape(1, -1), axis=1)
560
+
561
+ corr_para = np.einsum(
562
+ 'i,ji,ki->jk',
563
+ freq.flatten(),
564
+ ordered_sample,
565
+ ordered_sample)
566
+
567
+ return corr_para, mean_para
568
+
569
+
570
+ @numba.njit(fastmath=True, cache=True)
571
+ def para_moments(j_mat: np.ndarray,
572
+ h_vec: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
573
+ """
574
+ Calculate the correlation and mean parameters given an interaction
575
+ matrix and an external field vector, using a Numba‑accelerated kernel.
576
+
577
+ Parameters
578
+ ----------
579
+ j_mat : np.ndarray
580
+ Interaction matrix.
581
+ h_vec : np.ndarray
582
+ External field vector.
583
+
584
+ Returns
585
+ -------
586
+ Tuple[np.ndarray, np.ndarray]
587
+ The correlation parameter array of shape (n, n).
588
+ The mean parameter array of shape (n,).
589
+ """
590
+ num_spin: int = j_mat.shape[0]
591
+
592
+ # Copy so we can safely modify the diagonal in‑place
593
+ j_dbl = j_mat.copy()
594
+ for ii in range(num_spin):
595
+ j_dbl[ii, ii] += j_mat[ii, ii]
596
+
597
+ num_sample: int = 3 ** num_spin
598
+
599
+ partition: float = 0.0
600
+ mean_para = np.zeros(num_spin, dtype=np.float64)
601
+ corr_para = np.zeros((num_spin, num_spin), dtype=np.float64)
602
+
603
+ spins = np.empty(num_spin, dtype=np.int8)
604
+
605
+ for idx in range(num_sample):
606
+ # Decode `idx` → base‑3 spin configuration in {‑1, 0, 1}
607
+ tmp = idx
608
+ for s in range(num_spin - 1, -1, -1):
609
+ spins[s] = (tmp % 3) - 1
610
+ tmp //= 3
611
+
612
+ # Energy: E = −h·s − s'Js / 2
613
+ energy: float = 0.0
614
+ for ii in range(num_spin):
615
+ energy -= h_vec[ii] * spins[ii]
616
+
617
+ js_term: float = 0.0
618
+ for ii in range(num_spin):
619
+ dot: float = 0.0
620
+ for jj in range(num_spin):
621
+ dot += j_dbl[ii, jj] * spins[jj]
622
+ js_term += spins[ii] * dot
623
+ energy -= 0.5 * js_term
624
+
625
+ weight: float = np.exp(-energy)
626
+
627
+ partition += weight
628
+ for ii in range(num_spin):
629
+ mean_para[ii] += spins[ii] * weight
630
+ for jj in range(num_spin):
631
+ corr_para[ii, jj] += spins[ii] * spins[jj] * weight
632
+
633
+ mean_para /= partition
634
+ corr_para /= partition
635
+
636
+ return corr_para, mean_para
637
+
638
+
639
+ @numba.njit(fastmath=True)
640
+ def pseudol_gradient(cur_j: np.ndarray,
641
+ cur_h: np.ndarray,
642
+ cur_state: np.ndarray,
643
+ directed: bool = False) -> Tuple[np.ndarray, np.ndarray]:
644
+ """
645
+ Compute the pseudo-likelihood gradients of j and h.
646
+
647
+ Parameters
648
+ ----------
649
+ cur_j : np.ndarray
650
+ Current j matrix.
651
+ cur_h : np.ndarray
652
+ Current h vector.
653
+ cur_state : np.ndarray
654
+ Current state matrix.
655
+ directed : bool, optional
656
+ Whether to compute directed gradients. Default is False.
657
+
658
+ Returns
659
+ -------
660
+ Tuple[np.array, np.array]
661
+ The gradient of j.
662
+ The gradient of h.
663
+ """
664
+ num_spin, num_sample = cur_state.shape
665
+ sample_factor = 1.0 / num_sample
666
+
667
+ diag_j = np.empty(num_spin, dtype=cur_j.dtype)
668
+ for ii in range(num_spin):
669
+ diag_j[ii] = cur_j[ii, ii]
670
+
671
+ j_no_diag = cur_j.copy()
672
+ for ii in range(num_spin):
673
+ j_no_diag[ii, ii] = 0.0
674
+
675
+ eff_h = j_no_diag @ cur_state + cur_h
676
+
677
+ exp_plus = np.empty_like(eff_h)
678
+ exp_minus = np.empty_like(eff_h)
679
+ denom = np.empty_like(eff_h)
680
+
681
+ for ii in range(num_spin):
682
+ exp_plus[ii, :] = np.exp(diag_j[ii] + eff_h[ii, :])
683
+ exp_minus[ii, :] = np.exp(diag_j[ii] - eff_h[ii, :])
684
+ denom[ii, :] = exp_plus[ii, :] + exp_minus[ii, :] + 1.0
685
+
686
+ # Diagonal (self‑coupling) gradients
687
+ term_diag = cur_state * cur_state - (exp_plus + exp_minus) / denom
688
+
689
+ # Off‑diagonal and field gradients
690
+ h_eff_grad = cur_state - (exp_plus - exp_minus) / denom
691
+ cur_j_grad = (h_eff_grad @ cur_state.T) * sample_factor
692
+ for ii in range(num_spin):
693
+ cur_j_grad[ii, ii] = np.sum(term_diag[ii, :]) * sample_factor
694
+
695
+ cur_h_grad = np.zeros((num_spin, 1), dtype=cur_j.dtype)
696
+ for ii in range(num_spin):
697
+ cur_h_grad[ii, 0] = np.sum(h_eff_grad[ii, :]) * sample_factor
698
+
699
+ if not directed:
700
+ cur_j_grad = 0.5 * (cur_j_grad + cur_j_grad.T)
701
+
702
+ return - cur_j_grad, - cur_h_grad
703
+
704
+
705
+ @numba.njit()
706
+ def samp_moments(j_mat: np.ndarray,
707
+ h_vec: np.ndarray,
708
+ sample_size: int,
709
+ mixing_time: int) -> Tuple[np.ndarray, np.ndarray]:
710
+ """
711
+ Sample moments for the Markov Chain Monte Carlo (MCMC).
712
+
713
+ Parameters
714
+ ----------
715
+ j_mat : np.ndarray
716
+ Interaction matrix.
717
+ h_vec : np.ndarray
718
+ External field vector.
719
+ sample_size : int
720
+ Size of the sample.
721
+ mixing_time : int
722
+ Mixing time for the MCMC.
723
+
724
+ Returns
725
+ -------
726
+ Tuple[np.array, np.array]
727
+ The correlation parameter.
728
+ The mean parameter.
729
+ """
730
+ sample_size = int(sample_size)
731
+ mixing_time = int(mixing_time)
732
+
733
+ num_spin = j_mat.shape[0]
734
+ rec_corr = np.zeros((num_spin, num_spin), dtype=np.float64)
735
+ rec_mean = np.zeros(num_spin, dtype=np.float64)
736
+ beta = 1.0
737
+
738
+ # Cast & make contiguous (Numba-safe)
739
+ j_mat = np.ascontiguousarray(j_mat.astype(np.float64))
740
+ h_vec = np.ascontiguousarray(h_vec.astype(np.float64)).reshape(-1)
741
+
742
+ # Spins in {-1,0,1}
743
+ cur_spin = np.sign(h_vec).astype(np.float64)
744
+
745
+ # Local field f = h + J @ s (update incrementally on changes)
746
+ f_vec = h_vec + j_mat.dot(cur_spin)
747
+
748
+ # Sampling schedule
749
+ tot_sampling = sample_size + mixing_time
750
+
751
+ # Pre-generate randomness (one uniform per site update)
752
+ rand_prob = np.random.rand(tot_sampling)
753
+
754
+ # Lazy accumulation buffers
755
+ last_outer = np.zeros((num_spin, num_spin), dtype=np.float64) # current s s^T
756
+ have_last_outer = False
757
+ recording_started = False
758
+ weight = 0 # how many identical samples to add for current last_outer
759
+
760
+ # MCMC with sweep updates + heat-bath conditionals
761
+ for ii in range(tot_sampling):
762
+ # sweep index (no random site index)
763
+ cur_ind = ii % num_spin
764
+
765
+ # current spin and local field excluding self-term
766
+ s_i = cur_spin[cur_ind]
767
+ Jii = j_mat[cur_ind, cur_ind]
768
+ hstar = f_vec[cur_ind] - Jii * s_i
769
+
770
+ # heat-bath weights for k in {+1, 0, -1}:
771
+ # log-weights: a = beta*( hstar + 0.5*Jii ), b = 0, c = beta*( -hstar + 0.5*Jii )
772
+ a = beta * (hstar + Jii)
773
+ b = 0.0
774
+ c = beta * (-hstar + Jii)
775
+ m = max(a, b, c)
776
+ pa = np.exp(a - m)
777
+ pb = np.exp(b - m) # == exp(-m)
778
+ pc = np.exp(c - m)
779
+ Z = pa + pb + pc
780
+
781
+ # sample new spin
782
+ u = rand_prob[ii]
783
+ thresh_a = pa / Z
784
+ thresh_ab = (pa + pb) / Z
785
+ if u < thresh_a:
786
+ new_spin = 1.0
787
+ elif u < thresh_ab:
788
+ new_spin = 0.0
789
+ else:
790
+ new_spin = - 1.0
791
+
792
+ # if spin changed, update cur_spin and cached field
793
+ changed = (new_spin != s_i)
794
+ if changed:
795
+ delta_s = new_spin - s_i
796
+ cur_spin[cur_ind] = new_spin
797
+ # incremental local-field update: f_k += J[k,i] * delta_s
798
+ for k in range(num_spin):
799
+ f_vec[k] += j_mat[k, cur_ind] * delta_s
800
+
801
+ # If we're recording and have accumulated weight, flush rec_corr BEFORE changing last_outer
802
+ if recording_started and weight > 0 and have_last_outer:
803
+ for k in range(num_spin):
804
+ row = rec_corr[k]
805
+ lo_row = last_outer[k]
806
+ for l in range(num_spin):
807
+ row[l] += weight * lo_row[l]
808
+ weight = 0
809
+
810
+ # Update last_outer incrementally if it exists
811
+ if recording_started and have_last_outer:
812
+ d = delta_s
813
+ i = cur_ind
814
+ # s's' = s s^T + d (e_i s'^T + s' e_i^T) - d^2 e_i e_i^T (uses updated cur_spin)
815
+ for j in range(num_spin):
816
+ last_outer[i, j] += d * cur_spin[j]
817
+ for j in range(num_spin):
818
+ last_outer[j, i] += d * cur_spin[j]
819
+ last_outer[i, i] -= d * d
820
+
821
+ # Record sample on schedule
822
+ if ii >= mixing_time:
823
+ # mean: O(N)
824
+ for k in range(num_spin):
825
+ rec_mean[k] += cur_spin[k]
826
+
827
+ # initialize last_outer once at the first record
828
+ if not recording_started:
829
+ for k in range(num_spin):
830
+ sk = cur_spin[k]
831
+ row = last_outer[k]
832
+ for l in range(num_spin):
833
+ row[l] = sk * cur_spin[l]
834
+ have_last_outer = True
835
+ recording_started = True
836
+
837
+ # lazily count how many times to add current outer
838
+ weight += 1
839
+
840
+ # Flush any remaining weight after the loop
841
+ if recording_started and weight > 0 and have_last_outer:
842
+ for k in range(num_spin):
843
+ row = rec_corr[k]
844
+ lo_row = last_outer[k]
845
+ for l in range(num_spin):
846
+ row[l] += weight * lo_row[l]
847
+
848
+ corr_para = rec_corr / sample_size
849
+ mean_para = rec_mean / sample_size
850
+
851
+ return corr_para, mean_para
852
+
853
+
854
+ @numba.njit()
855
+ def samp_mcmc_states(j_mat: np.ndarray,
856
+ h_vec: np.ndarray,
857
+ sample_size: int,
858
+ mixing_time: int,
859
+ samp_gap: int) -> np.ndarray:
860
+ """
861
+ Sample states for the Markov Chain Monte Carlo (MCMC).
862
+
863
+ Parameters
864
+ ----------
865
+ j_mat : np.ndarray
866
+ Interaction matrix.
867
+ h_vec : np.ndarray
868
+ External field vector.
869
+ sample_size : int
870
+ Size of the sample.
871
+ mixing_time : int
872
+ Mixing time for the MCMC.
873
+ samp_gap : int
874
+ Gap between samples.
875
+
876
+ Returns
877
+ -------
878
+ np.ndarray
879
+ The sampled states.
880
+ """
881
+ sample_size = int(sample_size)
882
+ mixing_time = int(mixing_time)
883
+ samp_gap = int(samp_gap)
884
+
885
+ num_spin = j_mat.shape[0]
886
+
887
+ beta = 1.0
888
+ batch_count = 1
889
+ rec_sample = np.empty((num_spin, int(sample_size)), dtype=np.float64)
890
+
891
+ # Cast & make contiguous (Numba-safe)
892
+ j_mat = np.ascontiguousarray(j_mat.astype(np.float64))
893
+ # Accept both (n,) and (n,1) h_vec; flatten to 1D
894
+ h_vec = h_vec.reshape(h_vec.size)
895
+ h_vec = np.ascontiguousarray(h_vec.astype(np.float64))
896
+
897
+ # Spins in {-1,0,1}
898
+ cur_spin = (np.random.randint(0, 3, num_spin) - 1).astype(np.float64)
899
+
900
+ # Local field f = h + J @ s (update incrementally on changes)
901
+ f_vec = h_vec + j_mat.dot(cur_spin)
902
+
903
+ # Sampling schedule (avoid modulo inside loop)
904
+ rem = mixing_time % samp_gap
905
+ first_sample = mixing_time if rem == 0 else (mixing_time + (samp_gap - rem))
906
+ last_sample = first_sample + (sample_size - 1) * samp_gap
907
+ tot_sampling = last_sample + 1
908
+ next_sample = first_sample
909
+
910
+ # Pre-generate randomness: one uniform per single-site update
911
+ rand_prob = np.random.rand(tot_sampling)
912
+
913
+ # MCMC (Gibbs) with deterministic sweep order
914
+ for ii in range(tot_sampling):
915
+ cur_ind = ii % num_spin
916
+
917
+ # ---- Compute conditional for s_i given s_-i (PRE-UPDATE neighborhood) ----
918
+ s_i = cur_spin[cur_ind]
919
+ Jii = j_mat[cur_ind, cur_ind]
920
+ # h*_i = h_i + sum_{j!=i} J_ij s_j = f_i - J_ii * s_i (pre-update)
921
+ hstar = f_vec[cur_ind] - Jii * s_i
922
+
923
+ # Unnormalized log-weights for k in {+1, 0, -1}
924
+ # Matches the enumerator convention (diagonal contributes +J_ii*k^2 here)
925
+ a = beta * (hstar + Jii) # log w(+1)
926
+ b = 0.0 # log w(0)
927
+ c = beta * (-hstar + Jii) # log w(-1)
928
+
929
+ # Stable normalization via max-trick
930
+ m = max(a, b, c)
931
+ pa = np.exp(a - m)
932
+ pb = np.exp(b - m)
933
+ pc = np.exp(c - m)
934
+ Z = pa + pb + pc
935
+
936
+ # Sample new spin from the conditional (heat-bath)
937
+ u = rand_prob[ii]
938
+ t1 = pa / Z
939
+ t2 = (pa + pb) / Z
940
+ if u < t1:
941
+ new_spin = 1.0
942
+ elif u < t2:
943
+ new_spin = 0.0
944
+ else:
945
+ new_spin = -1.0
946
+
947
+ # If changed, update spin and cached local field
948
+ if new_spin != s_i:
949
+ delta_s = new_spin - s_i
950
+ cur_spin[cur_ind] = new_spin
951
+ # Incremental local-field update: f_k += J[k, i] * delta_s
952
+ for k in range(num_spin):
953
+ f_vec[k] += j_mat[k, cur_ind] * delta_s
954
+
955
+ # Record sample on schedule
956
+ if ii == next_sample:
957
+ for k in range(num_spin):
958
+ rec_sample[k, batch_count - 1] = cur_spin[k]
959
+ batch_count += 1
960
+ next_sample += samp_gap
961
+
962
+ return rec_sample
963
+
964
+
965
+ def _gradient_one_round(kk: int,
966
+ cur_j: np.ndarray,
967
+ cur_h: np.ndarray,
968
+ raw_data_k,
969
+ method: str,
970
+ train_dat: Dict[str, Any]):
971
+ """Helper that compute gradient for a single round."""
972
+ if method == 'pseudo_likelihood':
973
+ j_grad, h_grad = pseudol_gradient(cur_j, cur_h[:, kk:kk + 1], raw_data_k, directed=train_dat['directed'])
974
+ h_grad = h_grad.flatten()
975
+ else:
976
+ if method == 'maximum_likelihood':
977
+ corr_para, mean_para = para_moments(cur_j, cur_h[:, kk])
978
+ elif method == 'mcmc_maximum_likelihood':
979
+ corr_para, mean_para = samp_moments(
980
+ cur_j, cur_h[:, kk], train_dat['mcmc_samplingsz'], train_dat['mcmc_samplingmix'])
981
+ j_grad = corr_para - raw_data_k[0]
982
+ h_grad = mean_para - raw_data_k[1].flatten()
983
+
984
+ return kk, j_grad, h_grad
985
+
986
+
987
+ def compute_gradient(cur_j: np.ndarray,
988
+ cur_h: np.ndarray,
989
+ raw_data: List[Tuple[np.ndarray, np.ndarray]],
990
+ method: str, train_dat: Dict[str, Any]) -> Tuple[np.ndarray, np.ndarray]:
991
+ """
992
+ Compute the gradient based on the specified method.
993
+
994
+ Parameters
995
+ ----------
996
+ cur_j : np.ndarray
997
+ Current j matrix.
998
+ cur_h : np.ndarray
999
+ Current h matrix.
1000
+ raw_data : List[Tuple[np.ndarray, np.ndarray]]
1001
+ Raw data used to calculate the gradient (each element is a tuple of correlation and mean).
1002
+ method : str
1003
+ The method to calculate the gradient; possible values are 'pseudo_likelihood',
1004
+ 'maximum_likelihood', and 'mcmc_maximum_likelihood'.
1005
+ train_dat : Dict[str, Any]
1006
+ Training data and hyperparameters.
1007
+
1008
+ Returns
1009
+ -------
1010
+ Tuple[np.array, np.array]
1011
+ The gradient of j.
1012
+ The gradient of h.
1013
+ """
1014
+ num_spin, num_round = cur_h.shape
1015
+
1016
+ rec_jgrad = np.zeros((num_spin, num_spin, num_round))
1017
+ rec_hgrad = np.zeros((num_spin, num_round))
1018
+
1019
+ if method == 'pseudo_likelihood':
1020
+ for kk in range(num_round):
1021
+ # Computing gradients using the specified method.
1022
+ j_grad, h_grad = pseudol_gradient(
1023
+ cur_j, cur_h[:, kk: kk + 1], raw_data[kk], directed=train_dat['directed'])
1024
+ h_grad = h_grad.flatten()
1025
+
1026
+ rec_jgrad[:, :, kk] = j_grad
1027
+ rec_hgrad[:, kk] = h_grad
1028
+ else:
1029
+ n_jobs = train_dat.get('max_workers', min(num_round, os.cpu_count()))
1030
+ chunk = train_dat.get('chunk_size', 1) # rounds per task
1031
+ backend = 'loky' # process‑based
1032
+
1033
+ # print(f'Processing with {n_jobs} workers...')
1034
+ with parallel_backend(backend, n_jobs=n_jobs):
1035
+ results = Parallel(batch_size=chunk)(
1036
+ delayed(_gradient_one_round)(kk, cur_j, cur_h, raw_data[kk], method, train_dat)
1037
+ for kk in range(num_round)
1038
+ )
1039
+
1040
+ for kk, j_grad, h_grad in results:
1041
+ rec_jgrad[:, :, kk] = j_grad
1042
+ rec_hgrad[:, kk] = h_grad
1043
+
1044
+ return rec_jgrad, rec_hgrad
1045
+
1046
+
1047
+ def compute_gradient_para(cur_j: np.ndarray,
1048
+ cur_h: np.ndarray,
1049
+ raw_data: List[Tuple[np.ndarray, np.ndarray]],
1050
+ method: str, train_dat: Dict[str, Any]) -> Tuple[np.ndarray, np.ndarray]:
1051
+ """
1052
+ Compute the gradient based on the specified method with parallel processing.
1053
+
1054
+ Parameters
1055
+ ----------
1056
+ cur_j : np.ndarray
1057
+ Current j matrix.
1058
+ cur_h : np.ndarray
1059
+ Current h matrix.
1060
+ raw_data : List[Tuple[np.ndarray, np.ndarray]]
1061
+ Raw data used to calculate the gradient (each element is a tuple of correlation and mean).
1062
+ method : str
1063
+ The method to calculate the gradient; possible values are 'pseudo_likelihood',
1064
+ 'maximum_likelihood', and 'mcmc_maximum_likelihood'.
1065
+ train_dat : Dict[str, Any]
1066
+ Training data and hyperparameters.
1067
+
1068
+ Returns
1069
+ -------
1070
+ Tuple[np.array, np.array]
1071
+ The gradient of j.
1072
+ The gradient of h.
1073
+ """
1074
+ num_spin, num_round = cur_h.shape
1075
+ rec_jgrad = np.zeros((num_spin, num_spin, num_round))
1076
+ rec_hgrad = np.zeros((num_spin, num_round))
1077
+
1078
+ n_jobs = train_dat.get('max_workers', min(num_round, os.cpu_count()))
1079
+ chunk = train_dat.get('chunk_size', 1) # rounds per task
1080
+ backend = 'loky' # process‑based
1081
+
1082
+ # print(f'Processing with {n_jobs} workers...')
1083
+ with parallel_backend(backend, n_jobs=n_jobs):
1084
+ results = Parallel(batch_size=chunk)(
1085
+ delayed(_gradient_one_round)(kk, cur_j, cur_h, raw_data[kk], method, train_dat)
1086
+ for kk in range(num_round)
1087
+ )
1088
+
1089
+ for kk, j_grad, h_grad in results:
1090
+ rec_jgrad[:, :, kk] = j_grad
1091
+ rec_hgrad[:, kk] = h_grad
1092
+
1093
+ return rec_jgrad, rec_hgrad
1094
+
1095
+
1096
+ def apply_regularization(rec_jgrad: np.ndarray,
1097
+ rec_hgrad: np.ndarray,
1098
+ cur_j: np.ndarray,
1099
+ cur_h: np.ndarray,
1100
+ train_dat: Dict[str, Any],
1101
+ print_regu: bool = False) -> Tuple[np.ndarray, np.ndarray]:
1102
+ """
1103
+ Apply regularization to the gradients.
1104
+
1105
+ Parameters
1106
+ ----------
1107
+ rec_jgrad : np.ndarray
1108
+ Gradients of j matrix.
1109
+ rec_hgrad : np.ndarray
1110
+ Gradients of h matrix.
1111
+ cur_j : np.ndarray
1112
+ Current j matrix.
1113
+ cur_h : np.ndarray
1114
+ Current h matrix.
1115
+ train_dat : Dict[str, Any]
1116
+ Training data and hyperparameters.
1117
+ print_regu : bool, optional
1118
+ If True, print regularization parameters. Default is False.
1119
+
1120
+ Returns
1121
+ -------
1122
+ Tuple[np.array, np.array]
1123
+ Regularized gradients of j.
1124
+ Regularized gradients of h.
1125
+ """
1126
+ num_spin, num_round = cur_h.shape
1127
+
1128
+ lambda_l1_j, lambda_l1_h, lambda_l2_j, lambda_l2_h = (
1129
+ train_dat.get(
1130
+ key, 0) for key in [
1131
+ "lambda_l1_j", "lambda_l1_h", "lambda_l2_j", "lambda_l2_h"])
1132
+
1133
+ if lambda_l1_j > 0:
1134
+ rec_jgrad += lambda_l1_j * \
1135
+ (cur_j / 1e-3).clip(-1, 1).reshape(num_spin, num_spin, 1)
1136
+ if lambda_l1_h > 0:
1137
+ rec_hgrad += lambda_l1_h * (cur_h / 1e-3).clip(-1, 1)
1138
+
1139
+ if lambda_l2_j > 0:
1140
+ rec_jgrad += lambda_l2_j * cur_j.reshape(num_spin, num_spin, 1)
1141
+ if lambda_l2_h > 0:
1142
+ rec_hgrad += lambda_l2_h * cur_h
1143
+
1144
+ if 'if_control' in train_dat:
1145
+ if_control = train_dat['if_control']
1146
+ batch_index = train_dat['batch_index']
1147
+ h_rela = compute_relative_responses(cur_h, if_control, batch_index)
1148
+
1149
+ if 'lambda_l2_h_rela_prior' in train_dat:
1150
+ perturb_matrix = train_dat['perturb_matrix']
1151
+ rec_hgrad = average_batch_control(rec_hgrad, if_control, batch_index)
1152
+ rec_hgrad += train_dat['lambda_l2_h_rela_prior'] * (h_rela - perturb_matrix)
1153
+
1154
+ if 'lambda_l1_h_rela' in train_dat:
1155
+ rec_hgrad += train_dat['lambda_l1_h_rela'] * (h_rela / 1e-3).clip(-1, 1)
1156
+
1157
+ if 'lambda_l2_h_rela' in train_dat:
1158
+ rec_hgrad += train_dat['lambda_l2_h_rela'] * h_rela
1159
+
1160
+ if 'lambda_l2_j_prior' in train_dat:
1161
+ rec_jgrad += (train_dat['lambda_l2_j_prior'] * (cur_j - train_dat['j_prior']))[:, :, np.newaxis]
1162
+
1163
+ if 'lambda_l2_j_prior_mask' in train_dat:
1164
+ rec_jgrad += train_dat['lambda_l2_j_prior_mask'] * (cur_j * (train_dat['j_prior_mask'] == 0))[:, :, np.newaxis]
1165
+
1166
+ if 'lambda_l1_j_prior_mask' in train_dat:
1167
+ rec_jgrad += train_dat['lambda_l1_j_prior_mask'] * ((cur_j * (train_dat['j_prior_mask'] == 0))[:, :, np.newaxis] / 1e-3).clip(-1, 1)
1168
+
1169
+ if print_regu:
1170
+ print('Regularization parameters')
1171
+ all_regu_list = ['lambda_l1_j', 'lambda_l1_h', 'lambda_l2_j', 'lambda_l2_h', 'lambda_l2_j_prior', 'lambda_l2_j_prior_mask', 'lambda_l2_h_rela_prior', 'lambda_l1_h_rela', 'lambda_l2_h_rela', 'lambda_l1_j_prior_mask']
1172
+ all_values = [train_dat.get(key, 0) for key in all_regu_list]
1173
+ for key, value in zip(all_regu_list, all_values):
1174
+ if value > 0:
1175
+ print(f'{key}: {value}')
1176
+
1177
+
1178
+ return rec_jgrad, rec_hgrad
1179
+
1180
+
1181
+ def update_adam(gradient: np.ndarray,
1182
+ m: np.ndarray,
1183
+ v: np.ndarray,
1184
+ counter: int,
1185
+ stepsz: float,
1186
+ beta1: float = 0.9,
1187
+ beta2: float = 0.999,
1188
+ epsilon: float = 1e-8) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
1189
+ """
1190
+ Adam optimizer update rule.
1191
+
1192
+ Parameters
1193
+ ----------
1194
+ gradient : np.ndarray
1195
+ The gradient of the objective function.
1196
+ m : np.ndarray
1197
+ 1st moment vector (moving average of the gradients).
1198
+ v : np.ndarray
1199
+ 2nd moment vector (moving average of the gradient squared).
1200
+ counter : int
1201
+ The current time step or epoch.
1202
+ stepsz : float
1203
+ Step size or learning rate.
1204
+ beta1 : float, optional
1205
+ The exponential decay rate for the 1st moment vector.
1206
+ beta2 : float, optional
1207
+ The exponential decay rate for the 2nd moment vector.
1208
+ epsilon : float, optional
1209
+ Small constant to prevent division by zero.
1210
+
1211
+ Returns
1212
+ -------
1213
+ Tuple[np.ndarray, np.ndarray, np.ndarray]
1214
+ The update value.
1215
+ The updated first moment vector.
1216
+ The updated second moment vector.
1217
+ """
1218
+ # Update biased first moment estimate and biased second raw moment estimate
1219
+ m = beta1 * m + (1 - beta1) * gradient
1220
+ v = beta2 * v + (1 - beta2) * (gradient ** 2)
1221
+
1222
+ # Compute bias-corrected first moment estimate and bias-corrected second
1223
+ # raw moment estimate
1224
+ m_hat = m / (1 - beta1 ** counter)
1225
+ v_hat = v / (1 - beta2 ** counter)
1226
+
1227
+ # Compute the update rule for parameters
1228
+ update = stepsz * m_hat / (np.sqrt(v_hat) + epsilon)
1229
+
1230
+ return update, m, v
1231
+
1232
+
1233
+ def learn_network_adam(raw_data: Any,
1234
+ method: str,
1235
+ train_dat: Dict[str, Any]) -> Tuple[np.ndarray, np.ndarray, Dict[str, Any]]:
1236
+ """
1237
+ Train the network using the Adam optimizer.
1238
+
1239
+ Parameters
1240
+ ----------
1241
+ raw_data : Any
1242
+ The input data.
1243
+ method : str
1244
+ The chosen method for training.
1245
+ train_dat : Dict[str, Any]
1246
+ Dictionary containing training data and hyperparameters.
1247
+
1248
+ Returns
1249
+ -------
1250
+ Tuple[np.ndarray, np.ndarray, dict]
1251
+ The trained j matrix.
1252
+ The trained h matrix.
1253
+ The training log.
1254
+ """
1255
+ np.random.seed(train_dat['seed'])
1256
+
1257
+ # Retrieve training parameters and data
1258
+ num_spin, num_round = train_dat['cur_h'].shape
1259
+ num_epoch, stepsz, rec_gap = (
1260
+ train_dat.get(
1261
+ key, None) for key in [
1262
+ "num_epoch", "stepsz", "rec_gap"])
1263
+ list_step = np.arange(num_epoch, 0, - rec_gap)[::-1]
1264
+ cur_j, cur_h = (train_dat.get(key, None) for key in ["cur_j", "cur_h"])
1265
+ save_path = train_dat.get('save_path', None)
1266
+ backtrack_gap, backtrack_tol = (train_dat.get(key, None) for key in ["backtrack_gap", "backtrack_tol"])
1267
+
1268
+ # Initialize variables to store parameters, gradients, and other values
1269
+ # during training
1270
+ rec_jmat_all = np.zeros((num_epoch, num_spin, num_spin))
1271
+ rec_hvec_all = np.zeros((num_epoch, num_spin, num_round))
1272
+ rec_jgrad_sum_norm = np.inf * np.ones(num_epoch)
1273
+ mjj, vjj = np.zeros(cur_j.shape), np.zeros(cur_j.shape)
1274
+ mhh, vhh = np.zeros(cur_h.shape), np.zeros(cur_h.shape)
1275
+ log_adam_grad = {name: deque(maxlen=backtrack_gap)
1276
+ for name in ["mjj", "vjj", "mhh", "vhh"]}
1277
+
1278
+ apply_regularization(np.zeros((num_spin, num_spin, num_round)), np.zeros((num_spin, num_round)), cur_j, cur_h, train_dat, print_regu=True)
1279
+
1280
+ backtrack_counter = 0
1281
+ counter = 1
1282
+
1283
+ tqdm._instances.clear()
1284
+ pbar = tqdm(total=num_epoch)
1285
+
1286
+ while counter <= num_epoch:
1287
+
1288
+ # Compute gradient and apply regularization
1289
+ rec_jgrad, rec_hgrad = compute_gradient(
1290
+ cur_j, cur_h, raw_data, method, train_dat)
1291
+ rec_jgrad, rec_hgrad = apply_regularization(
1292
+ rec_jgrad, rec_hgrad, cur_j, cur_h, train_dat, print_regu=False)
1293
+
1294
+ # Update parameters using Adam optimizer
1295
+ rec_jgrad_sum = np.sum(rec_jgrad, axis=2)
1296
+ update, mjj, vjj = update_adam(
1297
+ rec_jgrad_sum, mjj, vjj, counter, stepsz)
1298
+ cur_j -= update
1299
+ update, mhh, vhh = update_adam(rec_hgrad, mhh, vhh, counter, stepsz)
1300
+ cur_h -= update
1301
+
1302
+ # Store updated parameters and gradients for later analysis or use
1303
+ rec_jmat_all[counter - 1, :, :] = cur_j
1304
+ rec_hvec_all[counter - 1, :, :] = cur_h
1305
+ rec_jgrad_sum_norm[counter - 1] = np.linalg.norm(rec_jgrad_sum)
1306
+
1307
+ # Log Adam gradients for backtracking
1308
+ for name, value in zip(["mjj", "vjj", "mhh", "vhh"], [
1309
+ mjj, vjj, mhh, vhh]):
1310
+ log_adam_grad[name].append(value)
1311
+
1312
+ # Save training log and print progress
1313
+ if counter in list_step:
1314
+ if train_dat['save_log']:
1315
+ sio.savemat(save_path + 'train_log.mat',
1316
+ {'list_step': list_step,
1317
+ 'rec_hvec_all': rec_hvec_all,
1318
+ 'rec_jmat_all': rec_jmat_all,
1319
+ 'rec_jgrad_sum_norm': rec_jgrad_sum_norm})
1320
+
1321
+ pbar.update(rec_gap)
1322
+ pbar.set_postfix({"Network Gradient": f"{rec_jgrad_sum_norm[counter - 1]:.6f}"})
1323
+
1324
+ # Handle backtracking
1325
+
1326
+ if_backtrack = False
1327
+
1328
+ if (counter > backtrack_gap) and (rec_jgrad_sum_norm[counter - 1] > 1.5 * rec_jgrad_sum_norm[counter - 1 - backtrack_gap]):
1329
+ if_backtrack = True
1330
+
1331
+ if (counter > 2 * backtrack_gap) and (counter % backtrack_gap == 0):
1332
+
1333
+ est_cur_grad = rec_jgrad_sum_norm[counter - 1 - backtrack_gap: counter - 1].mean()
1334
+ est_prev_grad = rec_jgrad_sum_norm[counter - 1 - 2 * backtrack_gap: counter - 1 - backtrack_gap].mean()
1335
+
1336
+ if est_cur_grad > 1.05 * est_prev_grad:
1337
+ if_backtrack = True
1338
+
1339
+ if if_backtrack:
1340
+ print('Backtracking at epoch %d' % counter)
1341
+ backtrack_counter += 1
1342
+ mjj, vjj, mhh, vhh = [log_adam_grad[key][0]
1343
+ for key in ['mjj', 'vjj', 'mhh', 'vhh']]
1344
+ counter = counter - backtrack_gap
1345
+ stepsz = stepsz / 4
1346
+ if backtrack_counter > backtrack_tol:
1347
+ print(
1348
+ 'Backtracking more than %d times, stop training.' %
1349
+ backtrack_tol)
1350
+ break
1351
+ else:
1352
+ counter += 1
1353
+
1354
+ pbar.close()
1355
+
1356
+ # Retrieve parameters corresponding to the minimum gradient norm found during training
1357
+ trace_epoch = 50 + num_epoch - counter
1358
+ pos = num_epoch - trace_epoch + np.argmin(rec_jgrad_sum_norm[- trace_epoch: ])
1359
+ # print(pos)
1360
+ cur_h = rec_hvec_all[pos, :, :]
1361
+ cur_j = rec_jmat_all[pos, :, :]
1362
+
1363
+ train_log = {}
1364
+ train_log['network_gradient'] = rec_jgrad_sum_norm[:counter]
1365
+
1366
+ return cur_j, cur_h, train_log
1367
+
1368
+
1369
+ def learn_program_regulators(gene_states: List[np.ndarray],
1370
+ program_states: List[np.ndarray],
1371
+ train_dat: Dict[str, Any]) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
1372
+ """
1373
+ Discover regulators for gene programs via regression.
1374
+
1375
+ Parameters
1376
+ ----------
1377
+ gene_states : List[np.ndarray]
1378
+ Array of gene states.
1379
+ program_states : List[np.ndarray]
1380
+ Array of program states.
1381
+ train_dat : Dict[str, Any]
1382
+ Dictionary of training parameters.
1383
+
1384
+ Returns
1385
+ -------
1386
+ Tuple[np.ndarray, np.ndarray, np.ndarray]
1387
+ The learned interaction matrix of shape (n_spin, n_target).
1388
+ The learned self-interaction (regulator-specific bias) of shape (n_target,).
1389
+ The learned program activity bias of shape (n_target,).
1390
+ """
1391
+ num_gene = gene_states[0].shape[0]
1392
+ num_program = program_states[0].shape[0]
1393
+ num_round = len(gene_states)
1394
+
1395
+ num_epoch, stepsz, rec_gap = (
1396
+ train_dat.get(key, None) for key in [
1397
+ "num_epoch", "stepsz", "rec_gap"])
1398
+ list_step = np.arange(num_epoch, 0, - rec_gap)[::-1]
1399
+
1400
+ save_path = train_dat.get('save_path', None)
1401
+
1402
+ backtrack_gap, backtrack_tol = (
1403
+ train_dat.get(key, None) for key in [
1404
+ "backtrack_gap", "backtrack_tol"])
1405
+
1406
+ lambda_l1_j, lambda_l2_j = (train_dat.get(key, 0) for key in ["lambda_l1_j", "lambda_l2_j"])
1407
+
1408
+ # Initialize parameters (using zeros)
1409
+ cur_interaction = np.zeros((num_gene, num_program))
1410
+ cur_selfj = np.zeros(num_program)
1411
+ cur_selfh = np.zeros(num_program)
1412
+
1413
+ # Compute sample weights based on the number of states (columns) in each gene_states sample
1414
+ state_sizes = np.array([state.shape[1] for state in gene_states])
1415
+ samp_weight = np.sqrt(state_sizes)
1416
+ samp_weight = samp_weight / np.sum(samp_weight)
1417
+
1418
+ # Preallocate gradient norm recording (optional)
1419
+ rec_interaction_all = np.zeros((num_epoch, num_gene, num_program))
1420
+ rec_selfj_all = np.zeros((num_epoch, num_program))
1421
+ rec_selfh_all = np.zeros((num_epoch, num_program))
1422
+
1423
+ rec_grad = np.zeros(num_epoch)
1424
+
1425
+ m_interaction, v_interaction = np.zeros_like(cur_interaction), np.zeros_like(cur_interaction)
1426
+ m_selfj, v_selfj = np.zeros_like(cur_selfj), np.zeros_like(cur_selfj)
1427
+ m_selfh, v_selfh = np.zeros_like(cur_selfh), np.zeros_like(cur_selfh)
1428
+
1429
+ # Create l1 regularization schedule
1430
+ l1_base_start = 0.02
1431
+ l1_base_end = 1e-3
1432
+
1433
+ part1 = np.full((num_epoch // 4 + 1,), l1_base_start)
1434
+ part2 = np.logspace(np.log10(l1_base_start), np.log10(l1_base_end), num=num_epoch // 2)
1435
+ part3 = np.full((num_epoch // 4 + 1,), l1_base_end)
1436
+ l1_base_array = np.concatenate([part1, part2, part3])[:num_epoch]
1437
+
1438
+ # Preallocate arrays to collect per-sample gradients
1439
+ rec_interaction_grad = np.zeros((num_round, num_gene, num_program))
1440
+ rec_selfj_grad = np.zeros((num_round, num_program))
1441
+ rec_selfh_grad = np.zeros((num_round, num_program))
1442
+
1443
+ log_adam_grad = {name: deque(maxlen=backtrack_gap)
1444
+ for name in ["m_interaction", "v_interaction", "m_selfj", "v_selfj", "m_selfh", "v_selfh"]}
1445
+
1446
+ tqdm._instances.clear()
1447
+ pbar = tqdm(total=num_epoch)
1448
+ counter = 1
1449
+ backtrack_counter = 0
1450
+
1451
+ while counter <= num_epoch:
1452
+
1453
+ l1_base = l1_base_array[counter - 1]
1454
+
1455
+ for kk in range(num_round):
1456
+ cur_state = gene_states[kk]
1457
+ cur_target = program_states[kk]
1458
+
1459
+ effective_h = cur_state.T @ cur_interaction + cur_selfh.T
1460
+
1461
+ # j_sub is the bias for regulators (broadcasted as row vector)
1462
+ j_sub = cur_selfj.T # shape: (n_target,)
1463
+
1464
+ # Compute terms.
1465
+ # We follow MATLAB: term1 = exp(j_sub + effective_h)' so that term1 becomes (n_target, T)
1466
+ term1 = np.exp(j_sub + effective_h).T
1467
+ term2 = np.exp(j_sub - effective_h).T
1468
+
1469
+ # Compute gradients for the bias parameters per sample
1470
+ j_sub_grad = cur_target**2 - (term1 + term2) / (term1 + term2 + 1)
1471
+ h_eff_grad = cur_target - (term1 - term2) / (term1 + term2 + 1)
1472
+
1473
+ # Gradient for interaction parameters
1474
+ # cur_state: (n_spin, T), h_eff_grad.T: (T, n_target)
1475
+ j_off_sub_grad = cur_state @ h_eff_grad.T # shape: (n_spin, n_target)
1476
+
1477
+ # Average over the T (state) dimension
1478
+ rec_interaction_grad[kk] = j_off_sub_grad / cur_state.shape[1]
1479
+ rec_selfj_grad[kk] = np.mean(j_sub_grad, axis=1)
1480
+ rec_selfh_grad[kk] = np.mean(h_eff_grad, axis=1)
1481
+
1482
+ # Aggregate gradients across samples using sample weights
1483
+ interaction_grad = - np.sum(rec_interaction_grad * samp_weight[:, None, None], axis=0)
1484
+ selfj_grad = - np.sum(rec_selfj_grad * samp_weight[:, None], axis=0)
1485
+ selfh_grad = - np.sum(rec_selfh_grad * samp_weight[:, None], axis=0)
1486
+
1487
+ if lambda_l1_j > 0:
1488
+ interaction_grad += lambda_l1_j * (cur_interaction / l1_base).clip(- 1, 1)
1489
+ if lambda_l2_j > 0:
1490
+ interaction_grad += lambda_l2_j * cur_interaction
1491
+
1492
+ # Update parameters using the provided update_adam function
1493
+ update_val, m_interaction, v_interaction = update_adam(interaction_grad, m_interaction, v_interaction, counter, stepsz)
1494
+ cur_interaction = cur_interaction - update_val
1495
+
1496
+ update_val, m_selfj, v_selfj = update_adam(selfj_grad, m_selfj, v_selfj, counter, stepsz)
1497
+ cur_selfj = cur_selfj - update_val
1498
+
1499
+ update_val, m_selfh, v_selfh = update_adam(selfh_grad, m_selfh, v_selfh, counter, stepsz)
1500
+ cur_selfh = cur_selfh - update_val
1501
+
1502
+ rec_interaction_all[counter - 1, :, :] = cur_interaction
1503
+ rec_selfj_all[counter - 1, :] = cur_selfj
1504
+ rec_selfh_all[counter - 1, :] = cur_selfh
1505
+
1506
+ for name, value in zip(["m_interaction", "v_interaction", "m_selfj", "v_selfj", "m_selfh", "v_selfh"], [m_interaction, v_interaction, m_selfj, v_selfj, m_selfh, v_selfh]):
1507
+ log_adam_grad[name].append(value)
1508
+
1509
+ # Record gradient norm (optional)
1510
+ rec_grad[counter - 1] = np.linalg.norm(interaction_grad)
1511
+
1512
+ if counter in list_step:
1513
+ pbar.update(rec_gap)
1514
+ pbar.set_postfix({"Network Gradient": f"{rec_grad[counter - 1]:.6f}"})
1515
+
1516
+ if_backtrack = False
1517
+ if (counter > backtrack_gap) and (rec_grad[counter - 1] > 1.5 * rec_grad[counter - 1 - backtrack_gap]):
1518
+ if_backtrack = True
1519
+
1520
+ if (counter > 2 * backtrack_gap) and (counter % backtrack_gap == 0):
1521
+ est_cur_grad = rec_grad[counter - 1 - backtrack_gap: counter - 1].mean()
1522
+ est_prev_grad = rec_grad[counter - 1 - 2 * backtrack_gap: counter - 1 - backtrack_gap].mean()
1523
+
1524
+ if est_cur_grad > 1.05 * est_prev_grad:
1525
+ if_backtrack = True
1526
+
1527
+ if if_backtrack:
1528
+ print('Backtracking at epoch %d' % counter)
1529
+ backtrack_counter += 1
1530
+ m_interaction, v_interaction, m_selfj, v_selfj, m_selfh, v_selfh = [log_adam_grad[key][0] for key in ["m_interaction", "v_interaction", "m_selfj", "v_selfj", "m_selfh", "v_selfh"]]
1531
+ counter = counter - backtrack_gap
1532
+ stepsz = stepsz / 4
1533
+ if backtrack_counter > backtrack_tol:
1534
+ print(f'Backtracking more than {backtrack_tol} times, stop training.')
1535
+ break
1536
+ else:
1537
+ counter += 1
1538
+
1539
+ pbar.close()
1540
+
1541
+ # Retrieve parameters corresponding to the minimum gradient norm found during training
1542
+ trace_epoch = 50 + num_epoch - counter
1543
+ pos = num_epoch - trace_epoch + np.argmin(rec_grad[- trace_epoch: ])
1544
+ cur_interaction = rec_interaction_all[pos, :, :]
1545
+ cur_selfj = rec_selfj_all[pos, :]
1546
+ cur_selfh = rec_selfh_all[pos, :]
1547
+
1548
+ return cur_interaction, cur_selfj, cur_selfh
1549
+
1550
+
1551
+ def compute_relative_responses(cur_h: np.ndarray,
1552
+ if_control: np.ndarray,
1553
+ batch_index: np.ndarray) -> np.ndarray:
1554
+ """
1555
+ Compute the relative responses of samples by subtracting the average of control samples.
1556
+ This function calculates the relative responses for a set of samples by comparing each sample's response
1557
+ to the average response of control samples. The control samples can be specific to the batch a sample belongs to,
1558
+ or a global set of control samples if no control samples are present in the batch.
1559
+
1560
+ Parameters
1561
+ ----------
1562
+ cur_h : np.ndarray
1563
+ A 2D array where each column represents a sample and each row represents a feature.
1564
+ if_control : np.ndarray
1565
+ A boolean array indicating which samples are control samples.
1566
+ batch_index : np.ndarray
1567
+ An array indicating the batch assignment for each sample.
1568
+
1569
+ Returns
1570
+ -------
1571
+ np.ndarray
1572
+ A 2D array of the same shape as `cur_h`, containing the relative responses for each sample.
1573
+ """
1574
+ unique_batches = np.unique(batch_index)
1575
+ num_batches = len(unique_batches)
1576
+
1577
+ relative_h = np.zeros_like(cur_h)
1578
+
1579
+ if np.all(if_control == 0):
1580
+ all_controls_mean = np.mean(cur_h, axis=1)
1581
+ else:
1582
+ all_controls_mean = np.mean(cur_h[:, if_control], axis=1)
1583
+
1584
+ for current_batch in unique_batches:
1585
+ batch_samples_idx = np.where(batch_index == current_batch)[0]
1586
+
1587
+ if np.any(if_control[batch_samples_idx]):
1588
+ batch_controls_idx = batch_samples_idx[np.where(if_control[batch_samples_idx])[0]]
1589
+ batch_controls_mean = np.mean(cur_h[:, batch_controls_idx], axis=1)
1590
+ else:
1591
+ batch_controls_mean = all_controls_mean
1592
+
1593
+ for idx in batch_samples_idx:
1594
+ relative_h[:, idx] = cur_h[:, idx] - batch_controls_mean
1595
+
1596
+ return relative_h
1597
+
1598
+
1599
+ def average_batch_control(hgrad_raw: np.ndarray,
1600
+ if_control: np.ndarray,
1601
+ batch_index: np.ndarray) -> np.ndarray:
1602
+ """
1603
+ Assign the gradient of control samples as the average gradient of the samples in the same batch.
1604
+
1605
+ Parameters
1606
+ ----------
1607
+ hgrad : np.ndarray
1608
+ The gradient of the h matrix.
1609
+ if_control : np.ndarray
1610
+ A boolean array indicating which samples are control samples.
1611
+ batch_index : np.ndarray
1612
+ An array indicating the batch assignment for each sample.
1613
+ """
1614
+
1615
+ hgrad = hgrad_raw.copy()
1616
+
1617
+ unique_batches = np.unique(batch_index)
1618
+ num_batches = len(unique_batches)
1619
+
1620
+ for current_batch in unique_batches:
1621
+ batch_samples_idx = np.where(batch_index == current_batch)[0]
1622
+
1623
+ if np.any(if_control[batch_samples_idx]):
1624
+ if np.any(if_control[batch_samples_idx]):
1625
+ batch_controls_idx = batch_samples_idx[np.where(if_control[batch_samples_idx])[0]]
1626
+ batch_mean = np.mean(hgrad_raw[:, batch_samples_idx], axis=1)
1627
+
1628
+ hgrad[:, batch_controls_idx] = batch_mean[:, np.newaxis]
1629
+
1630
+ return hgrad
1631
+
1632
+
1633
+ def select_representative_sample(raw_data: List[Tuple[np.ndarray, np.ndarray]],
1634
+ num_select: int) -> List[int]:
1635
+ """
1636
+ Select representative samples by performing KMeans clustering on the samples'
1637
+ correlation and mean vectors and selecting the sample closest to the cluster center.
1638
+
1639
+ Parameters
1640
+ ----------
1641
+ raw_data : List[Tuple[np.ndarray, np.ndarray]]
1642
+ A list of tuples, each containing the correlation and mean vectors for a sample.
1643
+ num_select : int
1644
+ The number of representative samples to select.
1645
+
1646
+ Returns
1647
+ -------
1648
+ List[int]
1649
+ A list of indices of the selected representative samples.
1650
+ """
1651
+ num_spin = raw_data[0][0].shape[1]
1652
+ num_samp = len(raw_data)
1653
+ raw_data_cat_vec = np.zeros([num_samp, int(num_spin * (num_spin + 3) / 2)])
1654
+ ind1, ind2 = np.triu_indices(num_spin)
1655
+
1656
+ for ii in range(num_samp):
1657
+ cur_corrmean = raw_data[ii]
1658
+ triu_vect = cur_corrmean[0][ind1, ind2] / np.sqrt((num_spin + 1) / 2)
1659
+ triu_vect = np.concatenate([triu_vect, cur_corrmean[1].flatten()])
1660
+ raw_data_cat_vec[ii, :] = triu_vect
1661
+
1662
+ pca_all = PCA().fit(raw_data_cat_vec)
1663
+ pca_rep = pca_all.transform(raw_data_cat_vec)
1664
+ kmeans = KMeans(n_clusters=num_select, n_init=200)
1665
+ kmeans.fit(raw_data_cat_vec)
1666
+
1667
+ use_data_list = []
1668
+ for ii in range(num_select):
1669
+ cur_data = np.where(kmeans.labels_ == ii)[0]
1670
+
1671
+ cur_center = kmeans.cluster_centers_[ii, :]
1672
+ data_dist = np.linalg.norm(raw_data_cat_vec[cur_data, :] - cur_center, axis=1)
1673
+ data_select = cur_data[np.argmin(data_dist)]
1674
+
1675
+ use_data_list.append(data_select)
1676
+
1677
+ sc.set_figure_params(figsize=[1, 1])
1678
+ fig, grid = sc.pl._tools._panel_grid(0.2, 0.2, ncols=3, num_panels=9)
1679
+
1680
+ for ii in range(3):
1681
+ for jj in range(ii + 1):
1682
+ ax = plt.subplot(grid[ii * 3 + jj])
1683
+ plt.scatter(pca_rep[use_data_list, ii + 1], pca_rep[use_data_list, jj], s=90, color='#aaaaaa')
1684
+ plt.scatter(pca_rep[:, ii + 1], pca_rep[:, jj], s=20, c=kmeans.labels_, cmap='tab20', alpha=0.5)
1685
+ plt.xlabel('PC' + str(ii + 2))
1686
+ plt.ylabel('PC' + str(jj + 1))
1687
+ plt.xticks([])
1688
+ plt.yticks([])
1689
+
1690
+ return use_data_list