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/__init__.py +21 -0
- dspin/annotate.py +461 -0
- dspin/compute.py +1690 -0
- dspin/dspin.py +869 -0
- dspin/gpt.py +65 -0
- dspin/metadata_drug.py +13 -0
- dspin/plot.py +776 -0
- dspin-1.5.0.dist-info/METADATA +49 -0
- dspin-1.5.0.dist-info/RECORD +12 -0
- dspin-1.5.0.dist-info/WHEEL +5 -0
- dspin-1.5.0.dist-info/licenses/LICENSE +201 -0
- dspin-1.5.0.dist-info/top_level.txt +1 -0
dspin/dspin.py
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
# -*-coding:utf-8 -*-
|
|
2
|
+
'''
|
|
3
|
+
@Time : 2023/10/13 10:43
|
|
4
|
+
@Author : Jialong Jiang, Yingying Gong
|
|
5
|
+
'''
|
|
6
|
+
|
|
7
|
+
from .plot import onmf_to_csv
|
|
8
|
+
from .compute import (
|
|
9
|
+
onmf_discretize,
|
|
10
|
+
sample_corr_mean,
|
|
11
|
+
sample_states,
|
|
12
|
+
learn_network_adam,
|
|
13
|
+
learn_program_regulators,
|
|
14
|
+
compute_onmf_decomposition,
|
|
15
|
+
subsample_normalize_gene_matrix,
|
|
16
|
+
summary_components,
|
|
17
|
+
compute_relative_responses
|
|
18
|
+
)
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
import numpy as np
|
|
21
|
+
import matplotlib.pyplot as plt
|
|
22
|
+
import scanpy as sc
|
|
23
|
+
import anndata as ad
|
|
24
|
+
import pandas as pd
|
|
25
|
+
import os
|
|
26
|
+
from sklearn.cluster import KMeans
|
|
27
|
+
from sklearn.decomposition import NMF
|
|
28
|
+
from sklearn.preprocessing import normalize
|
|
29
|
+
from tqdm import tqdm
|
|
30
|
+
from scipy.io import savemat, loadmat
|
|
31
|
+
from scipy.sparse import issparse
|
|
32
|
+
import networkx as nx
|
|
33
|
+
import matplotlib.patheffects as patheffects
|
|
34
|
+
import warnings
|
|
35
|
+
import itertools
|
|
36
|
+
from typing import List, Dict, Optional, Tuple, Union
|
|
37
|
+
from scipy.stats import skew
|
|
38
|
+
import leidenalg as la
|
|
39
|
+
import igraph as ig
|
|
40
|
+
import matplotlib as mpl
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AbstractDSPIN(ABC):
|
|
44
|
+
"""
|
|
45
|
+
Abstract base class for DSPIN, providing common methods and properties for GeneDSPIN and ProgramDSPIN.
|
|
46
|
+
|
|
47
|
+
Attributes
|
|
48
|
+
----------
|
|
49
|
+
adata : ad.AnnData
|
|
50
|
+
Annotated single-cell gene expression data.
|
|
51
|
+
save_path : str
|
|
52
|
+
Directory path where results will be stored.
|
|
53
|
+
num_spin : int
|
|
54
|
+
Number of spins for modeling.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self,
|
|
58
|
+
adata: ad.AnnData,
|
|
59
|
+
save_path: str,
|
|
60
|
+
num_spin: int):
|
|
61
|
+
"""
|
|
62
|
+
Initialize an instance of AbstractDSPIN.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
adata : ad.AnnData
|
|
67
|
+
Annotated data matrix containing observations and gene expressions.
|
|
68
|
+
save_path : str
|
|
69
|
+
Directory path for saving results.
|
|
70
|
+
num_spin : int
|
|
71
|
+
Number of spins used in DSPIN.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
self.adata = adata
|
|
75
|
+
self.save_path = os.path.abspath(save_path) + '/'
|
|
76
|
+
self.num_spin = num_spin
|
|
77
|
+
|
|
78
|
+
if not os.path.exists(self.save_path):
|
|
79
|
+
os.makedirs(self.save_path)
|
|
80
|
+
print("Saving path does not exist. Creating a new folder.")
|
|
81
|
+
|
|
82
|
+
self.fig_folder = self.save_path + 'figures/'
|
|
83
|
+
os.makedirs(self.fig_folder, exist_ok=True)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def program_representation_raw(self):
|
|
87
|
+
return self._onmf_rep_ori
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def program_representation(self):
|
|
91
|
+
return self._onmf_rep_tri
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def raw_data(self):
|
|
95
|
+
return self._raw_data
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def network(self):
|
|
99
|
+
return self._network
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def responses(self):
|
|
103
|
+
return self._responses
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def relative_responses(self):
|
|
107
|
+
return self._relative_responses
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def sample_list(self):
|
|
111
|
+
return self._samp_list
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def name_list(self):
|
|
115
|
+
return self._name_list
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def name_list_short(self):
|
|
119
|
+
return self._name_list_short
|
|
120
|
+
|
|
121
|
+
@program_representation_raw.setter
|
|
122
|
+
def program_representation_raw(self, value):
|
|
123
|
+
self._onmf_rep_ori = value
|
|
124
|
+
|
|
125
|
+
@program_representation.setter
|
|
126
|
+
def program_representation(self, value):
|
|
127
|
+
self._onmf_rep_tri = value
|
|
128
|
+
|
|
129
|
+
@network.setter
|
|
130
|
+
def network(self, value):
|
|
131
|
+
self._network = value
|
|
132
|
+
|
|
133
|
+
@responses.setter
|
|
134
|
+
def responses(self, value):
|
|
135
|
+
self._responses = value
|
|
136
|
+
|
|
137
|
+
@relative_responses.setter
|
|
138
|
+
def relative_responses(self, value):
|
|
139
|
+
self._relative_responses = value
|
|
140
|
+
|
|
141
|
+
@sample_list.setter
|
|
142
|
+
def sample_list(self, value):
|
|
143
|
+
self._samp_list = value
|
|
144
|
+
|
|
145
|
+
@name_list.setter
|
|
146
|
+
def name_list(self, value):
|
|
147
|
+
self._name_list = value
|
|
148
|
+
|
|
149
|
+
@name_list_short.setter
|
|
150
|
+
def name_list_short(self, value):
|
|
151
|
+
self._name_list_short = value
|
|
152
|
+
|
|
153
|
+
def discretize(self,
|
|
154
|
+
clip_percentile: float = 100,
|
|
155
|
+
num_init: int = 10) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Discretizes the oNMF representation into three states (-1, 0, 1) using K-means clustering.
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
clip_percentile : float, optional
|
|
162
|
+
Percentile at which to clip values before discretization (default is 100).
|
|
163
|
+
num_init : int, optional
|
|
164
|
+
Number of initializations for K-means clustering (default is 10).
|
|
165
|
+
"""
|
|
166
|
+
onmf_rep_ori = self.program_representation_raw.copy()
|
|
167
|
+
fig_folder = self.fig_folder
|
|
168
|
+
|
|
169
|
+
if clip_percentile < 100:
|
|
170
|
+
for ii in range(onmf_rep_ori.shape[1]):
|
|
171
|
+
onmf_rep_ori[:, ii] = onmf_rep_ori[:, ii] / np.percentile(onmf_rep_ori[:, ii], clip_percentile)
|
|
172
|
+
onmf_rep_ori = onmf_rep_ori.clip(0, 1)
|
|
173
|
+
|
|
174
|
+
onmf_rep_tri = onmf_discretize(onmf_rep_ori, num_init, fig_folder)
|
|
175
|
+
self._onmf_rep_tri = onmf_rep_tri
|
|
176
|
+
|
|
177
|
+
def raw_data_corr(self, sample_id_key: str) -> None:
|
|
178
|
+
"""
|
|
179
|
+
Computes correlation of raw data based on a sample column name.
|
|
180
|
+
|
|
181
|
+
Parameters
|
|
182
|
+
----------
|
|
183
|
+
sample_id_key : str
|
|
184
|
+
Column name in adata.obs specifying sample identifiers.
|
|
185
|
+
"""
|
|
186
|
+
raw_data, samp_list = sample_corr_mean(
|
|
187
|
+
self.adata.obs[sample_id_key], self._onmf_rep_tri)
|
|
188
|
+
self._raw_data = raw_data
|
|
189
|
+
self._samp_list = samp_list
|
|
190
|
+
|
|
191
|
+
def raw_data_state(self, sample_id_key) -> None:
|
|
192
|
+
"""
|
|
193
|
+
Compute and return the state of raw data based on sample identifiers.
|
|
194
|
+
|
|
195
|
+
Parameters
|
|
196
|
+
----------
|
|
197
|
+
sample_id_key : str
|
|
198
|
+
Column name in `adata.obs` used to group and compute raw data states.
|
|
199
|
+
"""
|
|
200
|
+
state_list, samp_list = sample_states(
|
|
201
|
+
self.adata.obs[sample_id_key], self._onmf_rep_tri)
|
|
202
|
+
|
|
203
|
+
self._raw_data = state_list
|
|
204
|
+
self._samp_list = samp_list
|
|
205
|
+
|
|
206
|
+
def default_params(self,
|
|
207
|
+
method: str) -> dict:
|
|
208
|
+
"""
|
|
209
|
+
Provide the default parameters for the specified algorithm.
|
|
210
|
+
|
|
211
|
+
Parameters
|
|
212
|
+
----------
|
|
213
|
+
method : str
|
|
214
|
+
The inference method. Options: 'maximum_likelihood', 'mcmc_maximum_likelihood', 'pseudo_likelihood'.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
dict: Dictionary containing default parameters for the specified method.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
num_spin = self.num_spin
|
|
221
|
+
raw_data = self._raw_data
|
|
222
|
+
|
|
223
|
+
if self.sample_list_ordered is not None:
|
|
224
|
+
sample_list_ordered_ind = [list(self._samp_list).index(samp)
|
|
225
|
+
for samp in self.sample_list_ordered]
|
|
226
|
+
self._samp_list = self._samp_list[sample_list_ordered_ind]
|
|
227
|
+
raw_data = raw_data[sample_list_ordered_ind]
|
|
228
|
+
self._raw_data = raw_data
|
|
229
|
+
print("Using a ordered subset of {} samples for network inference.".format(len(self._samp_list)))
|
|
230
|
+
|
|
231
|
+
num_sample = len(raw_data)
|
|
232
|
+
params = {'num_epoch': 400,
|
|
233
|
+
'cur_j': np.zeros((num_spin, num_spin)),
|
|
234
|
+
'cur_h': np.zeros((num_spin, num_sample)),
|
|
235
|
+
'save_path': self.save_path,
|
|
236
|
+
'rec_gap': 10,
|
|
237
|
+
'seed': 0,
|
|
238
|
+
'save_log': False}
|
|
239
|
+
params.update({'lambda_l1_j': 0.01,
|
|
240
|
+
'lambda_l1_h': 0,
|
|
241
|
+
'lambda_l2_j': 0,
|
|
242
|
+
'lambda_l2_h': 0.005})
|
|
243
|
+
params.update({'backtrack_gap': 40,
|
|
244
|
+
'backtrack_tol': 5})
|
|
245
|
+
|
|
246
|
+
if method == 'maximum_likelihood':
|
|
247
|
+
params['stepsz'] = 0.2
|
|
248
|
+
elif method == 'mcmc_maximum_likelihood':
|
|
249
|
+
params['stepsz'] = 0.02
|
|
250
|
+
params['mcmc_samplingsz'] = 2e5
|
|
251
|
+
params['mcmc_samplingmix'] = 1e3
|
|
252
|
+
params['mcmc_samplegap'] = 1
|
|
253
|
+
elif method == 'pseudo_likelihood':
|
|
254
|
+
params['stepsz'] = 0.05
|
|
255
|
+
|
|
256
|
+
return params
|
|
257
|
+
|
|
258
|
+
def network_inference(self,
|
|
259
|
+
sample_id_key: str = 'sample_id',
|
|
260
|
+
method: str = 'auto',
|
|
261
|
+
directed: bool = False,
|
|
262
|
+
params: dict = None,
|
|
263
|
+
sample_list_ordered: List[str] = None,
|
|
264
|
+
prior_network: np.ndarray = None,
|
|
265
|
+
perturb_matrix: np.ndarray = None,
|
|
266
|
+
if_control_key: str = 'if_control',
|
|
267
|
+
batch_key: str = 'batch',
|
|
268
|
+
run_with_matlab: bool = False) -> None:
|
|
269
|
+
"""
|
|
270
|
+
Perform network inference using a specified method and parameters.
|
|
271
|
+
|
|
272
|
+
Parameters
|
|
273
|
+
----------
|
|
274
|
+
sample_id_key : str, optional
|
|
275
|
+
Column name in `adata.obs` for sample identifiers. Default is 'sample_id'.
|
|
276
|
+
method : str, optional
|
|
277
|
+
The method used for network inference. Options: 'maximum_likelihood', 'mcmc_maximum_likelihood', 'pseudo_likelihood', 'auto'. Default is 'auto'.
|
|
278
|
+
directed : bool, optional
|
|
279
|
+
Whether to infer a directed network. Default is False.
|
|
280
|
+
params : dict, optional
|
|
281
|
+
Additional parameters for network inference. Default is None.
|
|
282
|
+
sample_list_ordered : List[str], optional
|
|
283
|
+
List of ordered sample identifiers. Default is None.
|
|
284
|
+
prior_network : np.ndarray, optional
|
|
285
|
+
Binary matrix of preferred network connectivity from prior knowledge. Default is None.
|
|
286
|
+
perturb_matrix : np.ndarray, optional
|
|
287
|
+
Matrix of putative relative responses to control samples. Default is None.
|
|
288
|
+
if_control_key : str, optional
|
|
289
|
+
Column name in `adata.obs` indicating whether a sample is a control. Default is 'if_control'.
|
|
290
|
+
batch_key : str, optional
|
|
291
|
+
Column name in `adata.obs` specifying batch assignment for each sample. Default is 'batch'.
|
|
292
|
+
run_with_matlab : bool, optional
|
|
293
|
+
If True, prepares data for MATLAB execution instead of running inference in Python. Default is False.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
self.sample_id_key = sample_id_key
|
|
297
|
+
|
|
298
|
+
if method not in [
|
|
299
|
+
'maximum_likelihood',
|
|
300
|
+
'mcmc_maximum_likelihood',
|
|
301
|
+
'pseudo_likelihood',
|
|
302
|
+
'auto']:
|
|
303
|
+
raise ValueError(
|
|
304
|
+
"Method must be one of 'maximum_likelihood', 'mcmc_maximum_likelihood', 'pseudo_likelihood' or 'auto'.")
|
|
305
|
+
|
|
306
|
+
if method == 'auto':
|
|
307
|
+
samp_list = np.unique(self.adata.obs[sample_id_key])
|
|
308
|
+
num_sample = len(samp_list)
|
|
309
|
+
if sample_list_ordered is not None:
|
|
310
|
+
num_sample = len(sample_list_ordered)
|
|
311
|
+
if num_sample > 30:
|
|
312
|
+
method = 'pseudo_likelihood'
|
|
313
|
+
else:
|
|
314
|
+
if self.num_spin <= 12:
|
|
315
|
+
method = 'maximum_likelihood'
|
|
316
|
+
elif self.num_spin <= 25:
|
|
317
|
+
method = 'mcmc_maximum_likelihood'
|
|
318
|
+
else:
|
|
319
|
+
method = 'pseudo_likelihood'
|
|
320
|
+
if directed:
|
|
321
|
+
method = 'pseudo_likelihood'
|
|
322
|
+
|
|
323
|
+
print("Using {} for network inference.".format(method))
|
|
324
|
+
|
|
325
|
+
if method == 'pseudo_likelihood':
|
|
326
|
+
self.raw_data_state(sample_id_key)
|
|
327
|
+
else:
|
|
328
|
+
self.raw_data_corr(sample_id_key)
|
|
329
|
+
|
|
330
|
+
self.sample_list_ordered = sample_list_ordered
|
|
331
|
+
|
|
332
|
+
train_dat = self.default_params(method)
|
|
333
|
+
|
|
334
|
+
if prior_network is not None:
|
|
335
|
+
train_dat['j_prior_mask'] = prior_network
|
|
336
|
+
train_dat['lambda_l1_j_prior_mask'] = 0.01
|
|
337
|
+
|
|
338
|
+
if perturb_matrix is not None:
|
|
339
|
+
if if_control_key not in self.adata.obs.columns:
|
|
340
|
+
raise ValueError(f"Column '{if_control_key}' not found in adata.obs.")
|
|
341
|
+
if batch_key not in self.adata.obs.columns:
|
|
342
|
+
raise ValueError(f"Column '{batch_key}' not found in adata.obs.")
|
|
343
|
+
|
|
344
|
+
sample_list = self._samp_list
|
|
345
|
+
sample_to_control = self.adata.obs.groupby(sample_id_key, observed=False)[if_control_key].first().to_dict()
|
|
346
|
+
sample_to_batch = self.adata.obs.groupby(sample_id_key, observed=False)[batch_key].first().to_dict()
|
|
347
|
+
|
|
348
|
+
if_control = np.array([sample_to_control[sample] for sample in sample_list])
|
|
349
|
+
batch_index = np.array([sample_to_batch[sample] for sample in sample_list])
|
|
350
|
+
|
|
351
|
+
train_dat['perturb_matrix'] = perturb_matrix
|
|
352
|
+
train_dat['if_control'] = if_control
|
|
353
|
+
train_dat['batch_index'] = batch_index
|
|
354
|
+
train_dat['lambda_l2_h_rela_prior'] = 0.5
|
|
355
|
+
|
|
356
|
+
train_dat['directed'] = directed
|
|
357
|
+
if params is not None:
|
|
358
|
+
train_dat.update(params)
|
|
359
|
+
|
|
360
|
+
if run_with_matlab:
|
|
361
|
+
|
|
362
|
+
file_path = self.save_path + 'raw_data.mat'
|
|
363
|
+
savemat(file_path, {'raw_data': self._raw_data, 'sample_list': self._samp_list,
|
|
364
|
+
'method': method, 'directed': directed, **train_dat})
|
|
365
|
+
print("Data saved to {}. Please run the network inference in MATLAB".format(file_path))
|
|
366
|
+
|
|
367
|
+
else:
|
|
368
|
+
cur_j, cur_h, train_log = learn_network_adam(self.raw_data, method, train_dat)
|
|
369
|
+
self._network = cur_j
|
|
370
|
+
self._responses = cur_h
|
|
371
|
+
self.train_log = train_log
|
|
372
|
+
|
|
373
|
+
def response_relative_to_control(self,
|
|
374
|
+
sample_id_key: str = 'sample_id',
|
|
375
|
+
if_control_key: str = 'if_control',
|
|
376
|
+
batch_key: str = 'batch'):
|
|
377
|
+
"""
|
|
378
|
+
Compute the relative responses based on control samples in each batch.
|
|
379
|
+
|
|
380
|
+
Parameters
|
|
381
|
+
----------
|
|
382
|
+
sample_id_key : str, optional
|
|
383
|
+
Column name in `adata.obs` specifying sample identifiers. Default is 'sample_id'.
|
|
384
|
+
if_control_key : str, optional
|
|
385
|
+
Column name in `adata.obs` indicating whether a sample is a control. Default is 'if_control'.
|
|
386
|
+
batch_key : str, optional
|
|
387
|
+
Column name in `adata.obs` specifying batch assignment for each sample. Default is 'batch'.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
if if_control_key not in self.adata.obs.columns:
|
|
391
|
+
raise ValueError(f"Column '{if_control_key}' not found in adata.obs.")
|
|
392
|
+
if batch_key not in self.adata.obs.columns:
|
|
393
|
+
raise ValueError(f"Column '{batch_key}' not found in adata.obs.")
|
|
394
|
+
|
|
395
|
+
sample_list = self._samp_list
|
|
396
|
+
sample_to_control = self.adata.obs.groupby(sample_id_key, observed=False)[if_control_key].first().to_dict()
|
|
397
|
+
sample_to_batch = self.adata.obs.groupby(sample_id_key, observed=False)[batch_key].first().to_dict()
|
|
398
|
+
|
|
399
|
+
if_control = np.array([sample_to_control[sample] for sample in sample_list])
|
|
400
|
+
batch_index = np.array([sample_to_batch[sample] for sample in sample_list])
|
|
401
|
+
|
|
402
|
+
self._relative_responses = compute_relative_responses(self.responses, if_control, batch_index)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class GeneDSPIN(AbstractDSPIN):
|
|
406
|
+
"""
|
|
407
|
+
GeneDSPIN inherits the AbstractDSPIN class, optimized for building gene-level networks.
|
|
408
|
+
It doesn't need oNMF and can directly perform network inference on the genes.
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
def __init__(self,
|
|
412
|
+
adata: ad.AnnData,
|
|
413
|
+
save_path: str,
|
|
414
|
+
num_spin: int,
|
|
415
|
+
discretize_params: dict = {}):
|
|
416
|
+
"""
|
|
417
|
+
Initialize the GeneDSPIN object.
|
|
418
|
+
|
|
419
|
+
Parameters
|
|
420
|
+
----------
|
|
421
|
+
adata : ad.AnnData
|
|
422
|
+
Annotated single-cell gene expression data.
|
|
423
|
+
save_path : str
|
|
424
|
+
Directory path where results will be saved.
|
|
425
|
+
num_spin : int
|
|
426
|
+
Number of spins used in inference.
|
|
427
|
+
use_default_discretize : bool, optional
|
|
428
|
+
Whether to use default discretization. Default is True. Otherwise the discretized data should be explicited setted in program_representation.
|
|
429
|
+
clip_percentile : float, optional
|
|
430
|
+
Percentile threshold for clipping values before discretization. Default is 95.
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
super().__init__(adata, save_path, num_spin)
|
|
434
|
+
print("GeneDSPIN initialized.")
|
|
435
|
+
|
|
436
|
+
if issparse(adata.X):
|
|
437
|
+
self._onmf_rep_ori = adata.X.toarray()
|
|
438
|
+
else:
|
|
439
|
+
self._onmf_rep_ori = adata.X
|
|
440
|
+
|
|
441
|
+
gene_skew = np.zeros(num_spin)
|
|
442
|
+
|
|
443
|
+
for ii in range(num_spin):
|
|
444
|
+
cur_gene = self._onmf_rep_ori[:, ii]
|
|
445
|
+
gene_skew[ii] = skew(cur_gene, bias=False)
|
|
446
|
+
|
|
447
|
+
print(f'Skewness of the gene expression: {gene_skew.mean().round(3)}')
|
|
448
|
+
|
|
449
|
+
if gene_skew.mean() >= 1.5:
|
|
450
|
+
default_clip = 95
|
|
451
|
+
else:
|
|
452
|
+
default_clip = 100
|
|
453
|
+
|
|
454
|
+
dparams = {'use_default_discretize': True,
|
|
455
|
+
'clip_percentile': default_clip,
|
|
456
|
+
'num_init': 10}
|
|
457
|
+
dparams.update(discretize_params)
|
|
458
|
+
|
|
459
|
+
self.name_list = self.adata.var_names.tolist()
|
|
460
|
+
self.name_list_short = self.adata.var_names.tolist()
|
|
461
|
+
|
|
462
|
+
if dparams['use_default_discretize']:
|
|
463
|
+
|
|
464
|
+
if dparams['clip_percentile'] < 100:
|
|
465
|
+
print(f"Input gene expression seem to be heavy-tailed. Using {dparams['clip_percentile']} percentile for clipping before discretization.")
|
|
466
|
+
|
|
467
|
+
self.discretize(clip_percentile=dparams['clip_percentile'],
|
|
468
|
+
num_init=dparams['num_init'])
|
|
469
|
+
|
|
470
|
+
def program_regulator_discovery(self,
|
|
471
|
+
program_representation: np.ndarray,
|
|
472
|
+
sample_id_key: str = 'sample_id',
|
|
473
|
+
params: dict = None):
|
|
474
|
+
"""
|
|
475
|
+
Discover regulators of given gene programs using regression.
|
|
476
|
+
|
|
477
|
+
Parameters
|
|
478
|
+
----------
|
|
479
|
+
program_representation : np.ndarray
|
|
480
|
+
The gene program representation.
|
|
481
|
+
sample_id_key : str, optional
|
|
482
|
+
Column name in `adata.obs` specifying sample identifiers. Default is 'sample_id'.
|
|
483
|
+
params : dict, optional
|
|
484
|
+
Additional parameters for regression. Default is None.
|
|
485
|
+
"""
|
|
486
|
+
|
|
487
|
+
program_states, samp_list = sample_states(self.adata.obs[sample_id_key], program_representation)
|
|
488
|
+
|
|
489
|
+
gene_states, _ = sample_states(self.adata.obs[sample_id_key], self._onmf_rep_tri)
|
|
490
|
+
|
|
491
|
+
train_dat = {
|
|
492
|
+
'num_epoch': 500,
|
|
493
|
+
'stepsz': 0.01,
|
|
494
|
+
'lambda_l1_interaction': 0.01,
|
|
495
|
+
'rec_gap': 10,
|
|
496
|
+
'backtrack_gap': 40,
|
|
497
|
+
'backtrack_tol': 5
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if params is not None:
|
|
501
|
+
train_dat.update(params)
|
|
502
|
+
|
|
503
|
+
cur_interaction, cur_selfj, cur_selfh = learn_program_regulators(gene_states, program_states, train_dat)
|
|
504
|
+
|
|
505
|
+
self.program_interactions = cur_interaction
|
|
506
|
+
self.program_activities = cur_selfh
|
|
507
|
+
self.program_self_interactions = cur_selfj
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class ProgramDSPIN(AbstractDSPIN):
|
|
512
|
+
"""
|
|
513
|
+
ProgramDSPIN extends AbstractDSPIN to handle large datasets efficiently.
|
|
514
|
+
This class includes methods for subsampling, balancing gene matrices, and running repeated oNMF decompositions.
|
|
515
|
+
|
|
516
|
+
Attributes
|
|
517
|
+
----------
|
|
518
|
+
adata : ad.AnnData
|
|
519
|
+
Annotated single-cell gene expression data.
|
|
520
|
+
save_path : str
|
|
521
|
+
Directory path where results will be stored.
|
|
522
|
+
num_spin : int
|
|
523
|
+
Number of spins used in modeling.
|
|
524
|
+
num_onmf_components : int, optional
|
|
525
|
+
Number of oNMF components.
|
|
526
|
+
prior_programs : List[List[str]], optional
|
|
527
|
+
List of predefined gene programs.
|
|
528
|
+
num_repeat : int, optional
|
|
529
|
+
Number of oNMF repetitions for stability.
|
|
530
|
+
"""
|
|
531
|
+
|
|
532
|
+
def __init__(self,
|
|
533
|
+
adata: ad.AnnData,
|
|
534
|
+
save_path: str,
|
|
535
|
+
num_spin: int):
|
|
536
|
+
"""
|
|
537
|
+
Initialize the ProgramDSPIN object.
|
|
538
|
+
|
|
539
|
+
Parameters
|
|
540
|
+
----------
|
|
541
|
+
adata : ad.AnnData
|
|
542
|
+
Annotated data matrix containing observations and gene expressions.
|
|
543
|
+
save_path : str
|
|
544
|
+
Directory path for saving results.
|
|
545
|
+
num_spin : int
|
|
546
|
+
Number of spins used in DSPIN.
|
|
547
|
+
"""
|
|
548
|
+
super().__init__(adata, save_path, num_spin)
|
|
549
|
+
|
|
550
|
+
print("ProgramDSPIN initialized.")
|
|
551
|
+
|
|
552
|
+
self._onmf_summary = None
|
|
553
|
+
self._gene_matrix_large = None
|
|
554
|
+
self._use_data_list = None
|
|
555
|
+
self.gene_program_csv = None
|
|
556
|
+
|
|
557
|
+
@property
|
|
558
|
+
def onmf_decomposition(self):
|
|
559
|
+
return self._onmf_summary
|
|
560
|
+
|
|
561
|
+
@onmf_decomposition.setter
|
|
562
|
+
def onmf_decomposition(self, value):
|
|
563
|
+
self._onmf_summary = value
|
|
564
|
+
|
|
565
|
+
def compute_onmf_expression(self,
|
|
566
|
+
sample_matrix: np.ndarray,
|
|
567
|
+
gene_group_ind: List[List[int]]):
|
|
568
|
+
"""
|
|
569
|
+
Compute the oNMF expression for a given sample matrix and gene components of each program.
|
|
570
|
+
|
|
571
|
+
Parameters
|
|
572
|
+
----------
|
|
573
|
+
sample_matrix : np.ndarray
|
|
574
|
+
The sample matrix containing gene expressions.
|
|
575
|
+
gene_group_ind : List[List[int]]
|
|
576
|
+
List of lists containing indices of genes for each program component.
|
|
577
|
+
|
|
578
|
+
Returns
|
|
579
|
+
-------
|
|
580
|
+
NMF
|
|
581
|
+
An NMF object containing the computed components.
|
|
582
|
+
"""
|
|
583
|
+
num_spin = self.num_spin
|
|
584
|
+
adata = self.adata
|
|
585
|
+
|
|
586
|
+
# Initialize and compute the summary matrix
|
|
587
|
+
components_summary = np.zeros((num_spin, adata.shape[1]))
|
|
588
|
+
for ii in range(num_spin):
|
|
589
|
+
sub_matrix = sample_matrix[:, gene_group_ind[ii]]
|
|
590
|
+
sub_onmf = NMF(n_components=1, init='random', random_state=0).fit(sub_matrix)
|
|
591
|
+
components_summary[ii, gene_group_ind[ii]] = sub_onmf.components_
|
|
592
|
+
|
|
593
|
+
# Normalize the summary components and finalize the onmf_summary object
|
|
594
|
+
components_summary = normalize(components_summary, axis=1, norm='l2')
|
|
595
|
+
onmf_summary = NMF(n_components=num_spin, init='random', random_state=0)
|
|
596
|
+
onmf_summary.components_ = components_summary
|
|
597
|
+
|
|
598
|
+
return onmf_summary
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def summarize_onmf_result(self,
|
|
602
|
+
large_subsample_matrix: np.ndarray,
|
|
603
|
+
num_onmf_components: int,
|
|
604
|
+
num_repeat: int) -> NMF:
|
|
605
|
+
"""
|
|
606
|
+
Summarize the results of repeated oNMF decompositions,
|
|
607
|
+
integrating components from each oNMF run and prior programs if available.
|
|
608
|
+
|
|
609
|
+
Parameters
|
|
610
|
+
----------
|
|
611
|
+
num_onmf_components : int
|
|
612
|
+
Number of oNMF components.
|
|
613
|
+
num_repeat : int
|
|
614
|
+
Number of oNMF repetitions.
|
|
615
|
+
|
|
616
|
+
Returns
|
|
617
|
+
-------
|
|
618
|
+
NMF
|
|
619
|
+
An NMF object containing the summarized components.
|
|
620
|
+
"""
|
|
621
|
+
prior_programs_mask = self.prior_programs_mask
|
|
622
|
+
seed_list = self.onmf_parameters['seed_list']
|
|
623
|
+
summary_method = self.onmf_parameters['summary_method']
|
|
624
|
+
|
|
625
|
+
rec_components = np.zeros((num_repeat, num_onmf_components, np.sum(prior_programs_mask)))
|
|
626
|
+
|
|
627
|
+
for ii in range(num_repeat):
|
|
628
|
+
|
|
629
|
+
cur_seed = seed_list[ii]
|
|
630
|
+
cur_onmf = np.load(self.save_path + f'onmf/onmf_components_{num_onmf_components}_repeat_{cur_seed}.npy', allow_pickle=True).item()
|
|
631
|
+
rec_components[ii] = cur_onmf.components_
|
|
632
|
+
|
|
633
|
+
all_components = rec_components.reshape(- 1, np.sum(prior_programs_mask))
|
|
634
|
+
|
|
635
|
+
gene_group_ind = summary_components(all_components, num_onmf_components, summary_method=summary_method, figure_folder=self.fig_folder)
|
|
636
|
+
|
|
637
|
+
# Mask the prior programs and add them to the gene group indices
|
|
638
|
+
sub_mask_ind = np.where(prior_programs_mask)[0]
|
|
639
|
+
gene_group_ind = [sub_mask_ind[gene_list] for gene_list in gene_group_ind]
|
|
640
|
+
gene_group_ind += self.prior_programs_ind
|
|
641
|
+
|
|
642
|
+
# Evaluate the expression level of each program and the weights for each gene in the program
|
|
643
|
+
onmf_summary = self.compute_onmf_expression(large_subsample_matrix, gene_group_ind)
|
|
644
|
+
|
|
645
|
+
return onmf_summary
|
|
646
|
+
|
|
647
|
+
def gene_program_discovery(self,
|
|
648
|
+
num_repeat: int = 10,
|
|
649
|
+
seed: int = 0,
|
|
650
|
+
cluster_key: str = 'leiden',
|
|
651
|
+
mode: str = 'compute_summary',
|
|
652
|
+
prior_programs: List[List[str]] = None,
|
|
653
|
+
params: dict = {},
|
|
654
|
+
discretize_params: dict = {}) -> None:
|
|
655
|
+
"""
|
|
656
|
+
Discover gene programs by performing oNMF decomposition on the annotated data.
|
|
657
|
+
|
|
658
|
+
Parameters
|
|
659
|
+
----------
|
|
660
|
+
num_repeat : int, optional
|
|
661
|
+
Number of times to repeat oNMF. Default is 10.
|
|
662
|
+
seed : int, optional
|
|
663
|
+
Random seed for reproducibility. Default is 0.
|
|
664
|
+
cluster_key : str, optional
|
|
665
|
+
Key in `adata.obs` for clustering labels. Default is 'leiden'.
|
|
666
|
+
mode: str, optional
|
|
667
|
+
Mode of operation. Options: 'compute_summary', 'compute_only', 'summary_only'. Default is 'compute_summary'.
|
|
668
|
+
prior_programs : List[List[str]], optional
|
|
669
|
+
List of predefined gene programs. Default is None.
|
|
670
|
+
params : dict, optional
|
|
671
|
+
Additional parameters for oNMF. Default is an empty dictionary.
|
|
672
|
+
discretize_params : dict, optional
|
|
673
|
+
Parameters for discretization of the oNMF representation. Default is an empty dictionary.
|
|
674
|
+
"""
|
|
675
|
+
|
|
676
|
+
if mode not in ['compute_summary', 'compute_only', 'summary_only', 'precomputed']:
|
|
677
|
+
raise ValueError(
|
|
678
|
+
'Mode must be one of compute_summary, compute_only, summary_only, or precomputed.')
|
|
679
|
+
|
|
680
|
+
adata = self.adata
|
|
681
|
+
num_spin = self.num_spin
|
|
682
|
+
|
|
683
|
+
onmf_parameters = {'num_onmf_components': num_spin,
|
|
684
|
+
'num_subsample': 10000,
|
|
685
|
+
'num_subsample_large': None,
|
|
686
|
+
'balance_method': 'squareroot',
|
|
687
|
+
'max_sample_rate': 2,
|
|
688
|
+
'summary_method': 'kmeans',
|
|
689
|
+
'std_clip_percentile': 20,
|
|
690
|
+
'min_cluster_size': 20,
|
|
691
|
+
'onmf_epoch_number': 500,
|
|
692
|
+
'seed_list': seed + np.arange(num_repeat)}
|
|
693
|
+
|
|
694
|
+
onmf_parameters.update(params)
|
|
695
|
+
|
|
696
|
+
if onmf_parameters['num_subsample_large'] is None:
|
|
697
|
+
onmf_parameters['num_subsample_large'] = min(onmf_parameters['num_subsample'] * 5, adata.shape[0])
|
|
698
|
+
# print(onmf_parameters['num_subsample_large'])
|
|
699
|
+
|
|
700
|
+
if onmf_parameters['balance_method'] not in ['equal', 'proportional', 'squareroot', None]:
|
|
701
|
+
raise ValueError('balance_method must be one of equal, proportional, squareroot, or None')
|
|
702
|
+
|
|
703
|
+
if onmf_parameters['summary_method'] not in ['kmeans', 'leiden']:
|
|
704
|
+
raise ValueError('summary_method must be one of kmeans or leiden')
|
|
705
|
+
|
|
706
|
+
# Process prior programs if provided and validate against num_spin
|
|
707
|
+
if prior_programs is not None:
|
|
708
|
+
prior_program_ind = [[np.where(adata.var_names == gene)[0][0] for gene in gene_list] for gene_list in prior_programs]
|
|
709
|
+
preprogram_flat = [gene for program in prior_programs for gene in program]
|
|
710
|
+
if len(prior_programs) > num_spin:
|
|
711
|
+
raise ValueError('Number of prior programs must be less than the number of programs')
|
|
712
|
+
elif len(prior_programs) == num_spin:
|
|
713
|
+
warnings.warn('Number of prior programs is equal to the number of programs. No oNMF is performed.')
|
|
714
|
+
mode = 'summary_only'
|
|
715
|
+
elif len(prior_programs) == 0:
|
|
716
|
+
warnings.warn('Empty prior programs')
|
|
717
|
+
|
|
718
|
+
# Generate mask to exclude genes in prior programs from the annotated data
|
|
719
|
+
prior_program_mask = ~ np.isin(adata.var_names, preprogram_flat)
|
|
720
|
+
onmf_parameters['num_onmf_components'] -= len(prior_programs)
|
|
721
|
+
else:
|
|
722
|
+
prior_program_mask = np.ones(adata.shape[1], dtype=bool)
|
|
723
|
+
prior_program_ind = []
|
|
724
|
+
|
|
725
|
+
# number of oNMF components = number of spins - number of prior programs
|
|
726
|
+
self.onmf_parameters = onmf_parameters
|
|
727
|
+
self.prior_programs_mask = prior_program_mask
|
|
728
|
+
self.prior_programs_ind = prior_program_ind
|
|
729
|
+
|
|
730
|
+
num_onmf_components = onmf_parameters['num_onmf_components']
|
|
731
|
+
|
|
732
|
+
os.makedirs(self.save_path + 'onmf/', exist_ok=True)
|
|
733
|
+
|
|
734
|
+
if mode == 'compute_summary' or mode == 'compute_only':
|
|
735
|
+
|
|
736
|
+
cur_gene_matrix = adata.X[:, prior_program_mask]
|
|
737
|
+
cluster_label_raw = adata.obs[cluster_key].values
|
|
738
|
+
cluster_label = pd.factorize(cluster_label_raw)[0]
|
|
739
|
+
|
|
740
|
+
for rep in range(num_repeat):
|
|
741
|
+
|
|
742
|
+
cur_seed = onmf_parameters['seed_list'][rep]
|
|
743
|
+
|
|
744
|
+
std_clipped, sub_gene_matrix_normed = subsample_normalize_gene_matrix(cur_gene_matrix, cluster_label, num_subsample=onmf_parameters['num_subsample'], seed=cur_seed, params=onmf_parameters)
|
|
745
|
+
|
|
746
|
+
compute_onmf_decomposition(sub_gene_matrix_normed, num_onmf_components, seed=cur_seed, params=onmf_parameters, save_path=self.save_path + f'onmf/onmf_components_{num_onmf_components}_repeat_{cur_seed}.npy')
|
|
747
|
+
|
|
748
|
+
if mode == 'compute_summary' or mode == 'summary_only' or mode == 'precomputed':
|
|
749
|
+
|
|
750
|
+
cluster_label_raw = adata.obs[cluster_key].values
|
|
751
|
+
cluster_label = pd.factorize(cluster_label_raw)[0]
|
|
752
|
+
|
|
753
|
+
# Perform subsampling and standard deviation clipping on the matrix
|
|
754
|
+
std_clipped, sub_large_gene_matrix_normed = subsample_normalize_gene_matrix(adata.X, cluster_label, num_subsample=onmf_parameters['num_subsample_large'], seed=seed, params=onmf_parameters)
|
|
755
|
+
self.matrix_std = std_clipped
|
|
756
|
+
|
|
757
|
+
if mode != 'precomputed':
|
|
758
|
+
# Summarize the oNMF decompositions
|
|
759
|
+
if num_onmf_components > 0:
|
|
760
|
+
onmf_summary = self.summarize_onmf_result(sub_large_gene_matrix_normed, num_onmf_components, num_repeat)
|
|
761
|
+
else:
|
|
762
|
+
onmf_summary = self.compute_onmf_expression(sub_large_gene_matrix_normed, prior_program_ind)
|
|
763
|
+
|
|
764
|
+
onmf_save_path = self.save_path + f'onmf/onmf_summary_total_{num_spin}_onmf_{num_onmf_components}_prior_{len(prior_program_ind)}.npy'
|
|
765
|
+
np.save(onmf_save_path, onmf_summary)
|
|
766
|
+
print(f"Consensus oNMF results saved to {onmf_save_path}")
|
|
767
|
+
else:
|
|
768
|
+
assert 'precomputed_onmf_path' in params.keys(), "For precomputed mode, 'precomputed_onmf_path' must be provided in params."
|
|
769
|
+
onmf_summary = np.load(params['precomputed_onmf_path'], allow_pickle=True).item()
|
|
770
|
+
|
|
771
|
+
self._onmf_summary = onmf_summary
|
|
772
|
+
|
|
773
|
+
# Save the gene programs to a CSV file
|
|
774
|
+
file_path = onmf_to_csv(onmf_summary.components_, adata.var_names, self.save_path + f'gene_programs_total_{num_spin}_onmf_{num_onmf_components}_prior_{len(prior_program_ind)}.csv', thres=0.01)
|
|
775
|
+
print('Gene program information saved to {}'.format(file_path))
|
|
776
|
+
self.gene_program_csv = file_path
|
|
777
|
+
|
|
778
|
+
file_wpath = onmf_to_csv(onmf_summary.components_, adata.var_names, self.save_path + f'gene_programs_total_{num_spin}_onmf_{num_onmf_components}_prior_{len(prior_program_ind)}_weights.csv', thres=0.01, if_write_weights=True)
|
|
779
|
+
# print('Gene programs saved to {}'.format(file_path))
|
|
780
|
+
|
|
781
|
+
gene_matrix = self.adata.X
|
|
782
|
+
if issparse(gene_matrix):
|
|
783
|
+
gene_matrix = np.asarray(gene_matrix.toarray()).astype(np.float64)
|
|
784
|
+
# Transform the original matrix by the oNMF summary components and normalize by standard deviation
|
|
785
|
+
self._onmf_rep_ori = onmf_summary.transform(
|
|
786
|
+
gene_matrix / self.matrix_std)
|
|
787
|
+
|
|
788
|
+
dparams = {'clip_percentile': 100,
|
|
789
|
+
'num_init': 10}
|
|
790
|
+
dparams.update(discretize_params)
|
|
791
|
+
|
|
792
|
+
self.discretize(clip_percentile=dparams['clip_percentile'],
|
|
793
|
+
num_init=dparams['num_init'])
|
|
794
|
+
|
|
795
|
+
num_display_gene = 5
|
|
796
|
+
gene_pd = pd.read_csv(self.gene_program_csv, index_col=None)
|
|
797
|
+
|
|
798
|
+
top_gene_list = [gene_pd.iloc[:, ii].tolist()[:num_display_gene] for ii in range(num_spin)]
|
|
799
|
+
# remove nan
|
|
800
|
+
top_gene_list_filtered = []
|
|
801
|
+
for sub_list in top_gene_list:
|
|
802
|
+
sub_list_filtered = [gene for gene in sub_list if str(gene) != 'nan']
|
|
803
|
+
top_gene_list_filtered.append(sub_list_filtered)
|
|
804
|
+
|
|
805
|
+
program_names = [f'P{ii}-' + ','.join(top_gene_list_filtered[ii]) for ii in range(num_spin)]
|
|
806
|
+
self.name_list = program_names
|
|
807
|
+
self.name_list_short = [name.split('-')[0] for name in program_names]
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
class DSPIN(object):
|
|
811
|
+
"""
|
|
812
|
+
DSPIN class determines the appropriate subclass (GeneDSPIN or ProgramDSPIN)
|
|
813
|
+
based on the dataset's gene count and spin count.
|
|
814
|
+
"""
|
|
815
|
+
|
|
816
|
+
def __new__(cls,
|
|
817
|
+
adata: ad.AnnData,
|
|
818
|
+
save_path: str,
|
|
819
|
+
num_spin: int = None,
|
|
820
|
+
filter_threshold: float = 0.02,
|
|
821
|
+
**kwargs) -> Union[GeneDSPIN, ProgramDSPIN]:
|
|
822
|
+
"""
|
|
823
|
+
Initialize DSPIN and select the appropriate subclass.
|
|
824
|
+
|
|
825
|
+
Parameters
|
|
826
|
+
----------
|
|
827
|
+
adata : ad.AnnData
|
|
828
|
+
The annotated data matrix (n_cells x n_genes).
|
|
829
|
+
save_path : str
|
|
830
|
+
Path to save results.
|
|
831
|
+
num_spin : int, optional
|
|
832
|
+
Number of spins for network inference. Default is 15 or number of genes if lower.
|
|
833
|
+
filter_threshold : float, optional
|
|
834
|
+
Minimum fraction of cells expressing a gene for it to be retained. Default is 0.02.
|
|
835
|
+
**kwargs : dict
|
|
836
|
+
Additional keyword arguments passed to the selected subclass.
|
|
837
|
+
|
|
838
|
+
Returns
|
|
839
|
+
-------
|
|
840
|
+
Union[GeneDSPIN, ProgramDSPIN]
|
|
841
|
+
An instance of either GeneDSPIN or ProgramDSPIN based on gene count.
|
|
842
|
+
"""
|
|
843
|
+
|
|
844
|
+
if adata.X.min() < 0:
|
|
845
|
+
warnings.warn("Negative expression detected. Expect normalized log-transformed data.")
|
|
846
|
+
|
|
847
|
+
# Filtering genes based on the filter_threshold.
|
|
848
|
+
sc.pp.filter_genes(adata, min_cells=adata.shape[0] * filter_threshold)
|
|
849
|
+
print(
|
|
850
|
+
'{} genes have expression in more than {} of the cells'.format(
|
|
851
|
+
adata.shape[1],
|
|
852
|
+
filter_threshold))
|
|
853
|
+
|
|
854
|
+
# if the number of genes is less than the number of spins, use GeneDSPIN
|
|
855
|
+
if num_spin is None:
|
|
856
|
+
num_spin = np.min([15, adata.shape[1]])
|
|
857
|
+
|
|
858
|
+
if adata.shape[1] == num_spin:
|
|
859
|
+
return GeneDSPIN(adata, save_path, num_spin=num_spin, **kwargs)
|
|
860
|
+
elif adata.shape[1] < num_spin:
|
|
861
|
+
warnings.warn(
|
|
862
|
+
"Number of genes is less than the number of spins. Using number of genes as num_spin.")
|
|
863
|
+
num_spin = adata.shape[1]
|
|
864
|
+
return GeneDSPIN(adata, save_path, num_spin=num_spin, **kwargs)
|
|
865
|
+
# otherwise, use ProgramDSPIN because oNMF is needed
|
|
866
|
+
else:
|
|
867
|
+
return ProgramDSPIN(adata, save_path, num_spin=num_spin, **kwargs)
|
|
868
|
+
|
|
869
|
+
|