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/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
+