pytrance 0.1.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.
- pytrance/__init__.py +17 -0
- pytrance/cell_score.py +489 -0
- pytrance/data/__init__.py +5 -0
- pytrance/data/celldata.py +144 -0
- pytrance/gnn.py +280 -0
- pytrance/models/DGI/__init__.py +6 -0
- pytrance/models/DGI/layers/__init__.py +3 -0
- pytrance/models/DGI/layers/discriminator.py +33 -0
- pytrance/models/DGI/layers/gcn.py +41 -0
- pytrance/models/DGI/layers/readout.py +16 -0
- pytrance/models/DGI/models/__init__.py +4 -0
- pytrance/models/DGI/models/dgi.py +47 -0
- pytrance/models/DGI/models/logreg.py +22 -0
- pytrance/models/DGI/utils/__init__.py +0 -0
- pytrance/models/DGI/utils/process.py +211 -0
- pytrance/models/__init__.py +5 -0
- pytrance/plotting.py +855 -0
- pytrance/tools.py +255 -0
- pytrance/utils.py +312 -0
- pytrance-0.1.0.dist-info/METADATA +38 -0
- pytrance-0.1.0.dist-info/RECORD +24 -0
- pytrance-0.1.0.dist-info/WHEEL +5 -0
- pytrance-0.1.0.dist-info/licenses/LICENSE +339 -0
- pytrance-0.1.0.dist-info/top_level.txt +1 -0
pytrance/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""pyTrance: subcellular spatial transcriptomics analysis."""
|
|
2
|
+
|
|
3
|
+
from .cell_score import clq, clq_pairwise, clq_significance
|
|
4
|
+
from . import data
|
|
5
|
+
from .gnn import train_epoch
|
|
6
|
+
from .utils import sparse_mx_to_torch_sparse_tensor
|
|
7
|
+
from .models import DGI
|
|
8
|
+
from . import tools as tl
|
|
9
|
+
from . import plotting as pl
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"clq", "clq_pairwise", "clq_significance",
|
|
13
|
+
"CellData", "train_epoch", "get_neighbors",
|
|
14
|
+
"cluster_gene_embeddings_leiden",
|
|
15
|
+
"sparse_mx_to_torch_sparse_tensor",
|
|
16
|
+
"DGI",
|
|
17
|
+
]
|
pytrance/cell_score.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
from itertools import combinations
|
|
2
|
+
from multiprocessing import Manager, Process
|
|
3
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from anndata import AnnData
|
|
7
|
+
from pandas import DataFrame
|
|
8
|
+
from scipy.sparse import spmatrix
|
|
9
|
+
from tqdm import tqdm
|
|
10
|
+
|
|
11
|
+
from .utils import get_neighbors
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def clq_pairwise(
|
|
15
|
+
adata: AnnData,
|
|
16
|
+
genes: Sequence[Any],
|
|
17
|
+
graph: Optional[spmatrix] = None,
|
|
18
|
+
radius: float = 2,
|
|
19
|
+
n_neighbors: Optional[int] = None,
|
|
20
|
+
n_permutations: int = 0,
|
|
21
|
+
cell_key: str = "cell",
|
|
22
|
+
cat_key: str = "gene",
|
|
23
|
+
min_counts: int = 2,
|
|
24
|
+
n_processes: int = 1,
|
|
25
|
+
seed: int = 808,
|
|
26
|
+
**kwargs: Any,
|
|
27
|
+
) -> Tuple[Dict, Dict]:
|
|
28
|
+
"""Compute co-localization quotient (CLQ) scores between gene pairs.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
adata : AnnData
|
|
33
|
+
Annotated data object with transcript-level observations.
|
|
34
|
+
genes : array-like
|
|
35
|
+
List of gene names to compute pairwise CLQ for.
|
|
36
|
+
graph : sparse matrix, optional
|
|
37
|
+
Precomputed spatial neighbor graph. If None, computed from coordinates.
|
|
38
|
+
Default is None.
|
|
39
|
+
radius : float, optional
|
|
40
|
+
Spatial radius for neighbor graph construction. Default is 2.
|
|
41
|
+
n_neighbors : int, optional
|
|
42
|
+
Number of nearest neighbors if radius is None. Default is None.
|
|
43
|
+
n_permutations : int, optional
|
|
44
|
+
Number of permutations for score adjustment. Default is 0.
|
|
45
|
+
cell_key : str, optional
|
|
46
|
+
Column name in adata.obs for cell IDs. Default is "cell".
|
|
47
|
+
cat_key : str, optional
|
|
48
|
+
Column name in adata.obs for gene/category assignment. Default is "gene".
|
|
49
|
+
min_counts : int, optional
|
|
50
|
+
Minimum transcript count per gene per cell to include in analysis.
|
|
51
|
+
Default is 2.
|
|
52
|
+
n_processes : int, optional
|
|
53
|
+
Number of parallel processes for computation. Default is 1.
|
|
54
|
+
seed : int, optional
|
|
55
|
+
Random seed for reproducibility. Default is 808.
|
|
56
|
+
**kwargs
|
|
57
|
+
Additional keyword arguments passed to clq().
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
tuple
|
|
62
|
+
- pairwise_clqs : dict
|
|
63
|
+
Dictionary mapping gene pairs to dictionaries containing 'clqs', 'clqs_adj',
|
|
64
|
+
and 'clqs_perm' (raw, adjusted, and permuted CLQ scores by cell).
|
|
65
|
+
- aggregated_norm_clqs : dict
|
|
66
|
+
Aggregated statistics with 'mean' and 'median' keys, mapping gene pairs
|
|
67
|
+
to aggregated normalized CLQ scores.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
gene_pairs = list(combinations(genes, 2))
|
|
71
|
+
if radius is None: # otherwise clq is symmetric
|
|
72
|
+
gene_pairs.extend(
|
|
73
|
+
list(combinations(genes[::-1], 2))
|
|
74
|
+
) # add pairs in reverse order
|
|
75
|
+
gene_pairs.extend([(g, g) for g in genes]) # add self pairs
|
|
76
|
+
pairwise_clqs = {}
|
|
77
|
+
for gene_pair in tqdm(gene_pairs, desc="gene pairs"):
|
|
78
|
+
# only keep cells with certain number of transcripts for both genes
|
|
79
|
+
adata_gene_pair = adata.copy()
|
|
80
|
+
for gene in gene_pair:
|
|
81
|
+
gene_cell_counts = (
|
|
82
|
+
adata_gene_pair[adata_gene_pair.obs[cat_key] == gene]
|
|
83
|
+
.obs[cell_key]
|
|
84
|
+
.value_counts()
|
|
85
|
+
)
|
|
86
|
+
gene_cell_counts_idxs = gene_cell_counts.index[
|
|
87
|
+
gene_cell_counts >= min_counts
|
|
88
|
+
]
|
|
89
|
+
adata_gene_pair = adata_gene_pair[
|
|
90
|
+
adata_gene_pair.obs[cell_key].isin(gene_cell_counts_idxs)
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
cell_clqs, cell_clqs_adjusted, cell_clqs_permuted = clq(
|
|
94
|
+
adata_gene_pair,
|
|
95
|
+
genes=gene_pair,
|
|
96
|
+
graph=graph,
|
|
97
|
+
n_neighbors=n_neighbors,
|
|
98
|
+
radius=radius,
|
|
99
|
+
n_permutations=n_permutations,
|
|
100
|
+
cell_key=cell_key,
|
|
101
|
+
cat_key=cat_key,
|
|
102
|
+
seed=seed,
|
|
103
|
+
pairwise=True,
|
|
104
|
+
verbose=0,
|
|
105
|
+
n_processes=n_processes,
|
|
106
|
+
**kwargs,
|
|
107
|
+
)
|
|
108
|
+
pairwise_clqs[gene_pair] = {
|
|
109
|
+
"clqs": cell_clqs,
|
|
110
|
+
"clqs_adj": cell_clqs_adjusted,
|
|
111
|
+
"clqs_perm": cell_clqs_permuted,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# for each pair aggregate (mean or median) normalized clq scores
|
|
115
|
+
aggregated_norm_clqs = {"mean": {}, "median": {}}
|
|
116
|
+
for pair, clq_dicts in pairwise_clqs.items():
|
|
117
|
+
clqs_adj_vals = list(clq_dicts["clqs_adj"].values())
|
|
118
|
+
aggregated_norm_clqs["median"][pair] = np.median(clqs_adj_vals)
|
|
119
|
+
aggregated_norm_clqs["mean"][pair] = np.mean(clqs_adj_vals)
|
|
120
|
+
if radius is not None: # symmetric
|
|
121
|
+
aggregated_norm_clqs["median"][(pair[1], pair[0])] = aggregated_norm_clqs[
|
|
122
|
+
"median"
|
|
123
|
+
][pair]
|
|
124
|
+
aggregated_norm_clqs["mean"][(pair[1], pair[0])] = aggregated_norm_clqs[
|
|
125
|
+
"mean"
|
|
126
|
+
][pair]
|
|
127
|
+
|
|
128
|
+
return pairwise_clqs, aggregated_norm_clqs
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def clq(
|
|
132
|
+
adata: AnnData,
|
|
133
|
+
genes: Union[str, Sequence[Any]],
|
|
134
|
+
graph: Optional[spmatrix] = None,
|
|
135
|
+
radius: float = 2,
|
|
136
|
+
n_neighbors: Optional[int] = None,
|
|
137
|
+
n_permutations: int = 0,
|
|
138
|
+
n_processes: int = 1,
|
|
139
|
+
cell_key: str = "cell",
|
|
140
|
+
cat_key: str = "gene",
|
|
141
|
+
x_key: str = "x",
|
|
142
|
+
y_key: str = "y",
|
|
143
|
+
z_key: str = "z",
|
|
144
|
+
verbose: int = 1,
|
|
145
|
+
seed: int = 808,
|
|
146
|
+
**kwargs: Any,
|
|
147
|
+
) -> Union[Dict, Tuple[Dict, Dict, Dict]]:
|
|
148
|
+
"""Compute co-localization quotient (CLQ) scores across all cells.
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
adata : AnnData
|
|
153
|
+
Annotated data object with transcript-level observations.
|
|
154
|
+
genes : str or array-like
|
|
155
|
+
Single gene name (str) for self co-localization or list of genes for
|
|
156
|
+
category definitions.
|
|
157
|
+
graph : sparse matrix, optional
|
|
158
|
+
Precomputed spatial neighbor graph. If None, computed from coordinates.
|
|
159
|
+
Default is None.
|
|
160
|
+
radius : float, optional
|
|
161
|
+
Spatial radius for neighbor graph construction. Default is 2.
|
|
162
|
+
n_neighbors : int, optional
|
|
163
|
+
Number of nearest neighbors if radius is None. Default is None.
|
|
164
|
+
n_permutations : int, optional
|
|
165
|
+
Number of permutations for score adjustment. Default is 0.
|
|
166
|
+
n_processes : int, optional
|
|
167
|
+
Number of parallel processes. Default is 1.
|
|
168
|
+
cell_key : str, optional
|
|
169
|
+
Column name in adata.obs for cell IDs. Default is "cell".
|
|
170
|
+
cat_key : str, optional
|
|
171
|
+
Column name in adata.obs for gene/category assignment. Default is "gene".
|
|
172
|
+
x_key : str, optional
|
|
173
|
+
Column name in adata.obs for x coordinates. Default is "x".
|
|
174
|
+
y_key : str, optional
|
|
175
|
+
Column name in adata.obs for y coordinates. Default is "y".
|
|
176
|
+
z_key : str, optional
|
|
177
|
+
Column name in adata.obs for z coordinates. Default is "z".
|
|
178
|
+
verbose : int, optional
|
|
179
|
+
Verbosity level (0=silent, 1+=with progress bars). Default is 1.
|
|
180
|
+
seed : int, optional
|
|
181
|
+
Random seed for permutation testing. Default is 808.
|
|
182
|
+
**kwargs
|
|
183
|
+
Additional keyword arguments passed to clq_single_cell().
|
|
184
|
+
|
|
185
|
+
Returns
|
|
186
|
+
-------
|
|
187
|
+
dict or tuple
|
|
188
|
+
If n_permutations > 0, returns tuple of:
|
|
189
|
+
- cell_clqs : dict - Raw CLQ scores per cell
|
|
190
|
+
- cell_clqs_adjusted : dict - Permutation-adjusted CLQ scores per cell
|
|
191
|
+
- cell_clqs_permuted : dict - Permutation distributions per cell
|
|
192
|
+
|
|
193
|
+
If n_permutations == 0, returns:
|
|
194
|
+
- cell_clqs : dict - Raw CLQ scores per cell
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
if radius is None and n_neighbors is None:
|
|
198
|
+
raise ValueError("either radius or n_neighbors must be specified")
|
|
199
|
+
|
|
200
|
+
if cat_key not in adata.obs.columns:
|
|
201
|
+
raise ValueError(f'"{cat_key}" not found in adata.obs')
|
|
202
|
+
|
|
203
|
+
if x_key not in adata.obs.columns:
|
|
204
|
+
raise ValueError(f'"{x_key}" not found in adata.obs')
|
|
205
|
+
|
|
206
|
+
if y_key not in adata.obs.columns:
|
|
207
|
+
raise ValueError(f'"{y_key}" not found in adata.obs')
|
|
208
|
+
|
|
209
|
+
if z_key and z_key not in adata.obs.columns:
|
|
210
|
+
raise ValueError(f'"{z_key}" not found in adata.obs')
|
|
211
|
+
|
|
212
|
+
categories = genes
|
|
213
|
+
if type(categories) is str: # single gene passed as string -> self co-localization
|
|
214
|
+
categories = [categories]
|
|
215
|
+
cat_a = categories
|
|
216
|
+
cat_b = categories
|
|
217
|
+
|
|
218
|
+
if n_permutations:
|
|
219
|
+
cell_clqs_permuted = {}
|
|
220
|
+
|
|
221
|
+
cell_ids = adata.obs[cell_key].unique()
|
|
222
|
+
|
|
223
|
+
# helper that processes a chunk of cells
|
|
224
|
+
def _worker(cells, shared_clqs, shared_norm, shared_perms, adata_sub):
|
|
225
|
+
# local copy of parameters to avoid closure issues
|
|
226
|
+
n_perm = n_permutations
|
|
227
|
+
grouped = adata_sub.obs.groupby(cell_key, observed=False)
|
|
228
|
+
for cell in tqdm(cells, desc="cells", disable=not bool(verbose)):
|
|
229
|
+
cell_df = grouped.get_group(cell)
|
|
230
|
+
if n_perm:
|
|
231
|
+
clq_val, clq_norm, clq_perms = clq_single_cell(
|
|
232
|
+
cell_df,
|
|
233
|
+
cat_a,
|
|
234
|
+
cat_b,
|
|
235
|
+
cat_key,
|
|
236
|
+
graph=graph,
|
|
237
|
+
radius=radius,
|
|
238
|
+
n_neighbors=n_neighbors,
|
|
239
|
+
n_permutations=n_perm,
|
|
240
|
+
x_key=x_key,
|
|
241
|
+
y_key=y_key,
|
|
242
|
+
z_key=z_key,
|
|
243
|
+
seed=seed,
|
|
244
|
+
**kwargs,
|
|
245
|
+
)
|
|
246
|
+
shared_norm[cell] = clq_norm
|
|
247
|
+
shared_perms[cell] = clq_perms
|
|
248
|
+
else: # no permutations case
|
|
249
|
+
clq_val = clq_single_cell(
|
|
250
|
+
cell_df,
|
|
251
|
+
cat_a,
|
|
252
|
+
cat_b,
|
|
253
|
+
cat_key,
|
|
254
|
+
graph=graph,
|
|
255
|
+
radius=radius,
|
|
256
|
+
n_neighbors=n_neighbors,
|
|
257
|
+
x_key=x_key,
|
|
258
|
+
y_key=y_key,
|
|
259
|
+
z_key=z_key,
|
|
260
|
+
seed=seed,
|
|
261
|
+
**kwargs,
|
|
262
|
+
)
|
|
263
|
+
shared_clqs[cell] = clq_val
|
|
264
|
+
|
|
265
|
+
# split list into n_processes chunks
|
|
266
|
+
cell_ids_split = [
|
|
267
|
+
cell_ids[i : i + max(1, len(cell_ids) // n_processes)]
|
|
268
|
+
for i in range(0, len(cell_ids), max(1, len(cell_ids) // n_processes))
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
# prepare shared dicts
|
|
272
|
+
with Manager() as manager:
|
|
273
|
+
# manager = Manager()
|
|
274
|
+
cell_clqs = manager.dict()
|
|
275
|
+
cell_clqs_adjusted = manager.dict()
|
|
276
|
+
cell_clqs_permuted = manager.dict()
|
|
277
|
+
|
|
278
|
+
jobs = []
|
|
279
|
+
for sublist in cell_ids_split:
|
|
280
|
+
adata_sub = adata[adata.obs[cell_key].isin(sublist)]
|
|
281
|
+
if n_processes > 1:
|
|
282
|
+
p = Process(
|
|
283
|
+
target=_worker,
|
|
284
|
+
args=(
|
|
285
|
+
sublist,
|
|
286
|
+
cell_clqs,
|
|
287
|
+
cell_clqs_adjusted,
|
|
288
|
+
cell_clqs_permuted,
|
|
289
|
+
adata_sub,
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
p.daemon = True
|
|
293
|
+
jobs.append(p)
|
|
294
|
+
p.start()
|
|
295
|
+
else:
|
|
296
|
+
# run in current process when only one worker
|
|
297
|
+
_worker(
|
|
298
|
+
sublist,
|
|
299
|
+
cell_clqs,
|
|
300
|
+
cell_clqs_adjusted,
|
|
301
|
+
cell_clqs_permuted,
|
|
302
|
+
adata_sub,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
for proc in jobs:
|
|
306
|
+
proc.join()
|
|
307
|
+
|
|
308
|
+
if n_permutations:
|
|
309
|
+
return (
|
|
310
|
+
cell_clqs.copy(),
|
|
311
|
+
cell_clqs_adjusted.copy(),
|
|
312
|
+
cell_clqs_permuted.copy(),
|
|
313
|
+
)
|
|
314
|
+
else:
|
|
315
|
+
return cell_clqs.copy()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def clq_single_cell(
|
|
319
|
+
cell_df: DataFrame,
|
|
320
|
+
cat_a: Sequence[Any],
|
|
321
|
+
cat_b: Sequence[Any],
|
|
322
|
+
cat_key: str,
|
|
323
|
+
graph: Optional[spmatrix] = None,
|
|
324
|
+
radius: float = 2,
|
|
325
|
+
n_neighbors: Optional[int] = None,
|
|
326
|
+
n_permutations: Optional[int] = None,
|
|
327
|
+
x_key: str = "x",
|
|
328
|
+
y_key: str = "y",
|
|
329
|
+
z_key: str = "z",
|
|
330
|
+
seed: int = 808,
|
|
331
|
+
**kwargs: Any,
|
|
332
|
+
) -> Union[float, Tuple[float, float, List]]:
|
|
333
|
+
"""Compute co-localization quotient (CLQ) for a single cell.
|
|
334
|
+
|
|
335
|
+
Parameters
|
|
336
|
+
----------
|
|
337
|
+
cell_df : DataFrame
|
|
338
|
+
Cell-specific transcript data.
|
|
339
|
+
cat_a : array-like
|
|
340
|
+
First gene category (list of gene names).
|
|
341
|
+
cat_b : array-like
|
|
342
|
+
Second gene category (list of gene names).
|
|
343
|
+
cat_key : str
|
|
344
|
+
Column name in cell_df for gene/category assignment.
|
|
345
|
+
graph : sparse matrix, optional
|
|
346
|
+
Precomputed spatial neighbor graph. If None, constructed from coordinates.
|
|
347
|
+
Default is None.
|
|
348
|
+
radius : float, optional
|
|
349
|
+
Spatial radius for neighbor graph construction. Default is 2.
|
|
350
|
+
n_neighbors : int, optional
|
|
351
|
+
Number of nearest neighbors if radius is None. Default is None.
|
|
352
|
+
n_permutations : int, optional
|
|
353
|
+
Number of permutations for normalized CLQ computation. Default is None.
|
|
354
|
+
x_key : str, optional
|
|
355
|
+
Column name in cell_df for x coordinates. Default is "x".
|
|
356
|
+
y_key : str, optional
|
|
357
|
+
Column name in cell_df for y coordinates. Default is "y".
|
|
358
|
+
z_key : str, optional
|
|
359
|
+
Column name in cell_df for z coordinates. Default is "z".
|
|
360
|
+
seed : int, optional
|
|
361
|
+
Random seed for permutation testing. Default is 808.
|
|
362
|
+
**kwargs
|
|
363
|
+
Additional keyword arguments passed to get_neighbors().
|
|
364
|
+
|
|
365
|
+
Returns
|
|
366
|
+
-------
|
|
367
|
+
float or tuple
|
|
368
|
+
If n_permutations is None or 0:
|
|
369
|
+
- clq : float - Raw CLQ score
|
|
370
|
+
|
|
371
|
+
If n_permutations > 0:
|
|
372
|
+
- clq : float - Raw CLQ score
|
|
373
|
+
- clq_adjusted : float - Permutation-normalized CLQ score
|
|
374
|
+
- clq_perms : list - Distribution of CLQ scores from permutations
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
n_a = (cell_df[cat_key].isin(cat_a)).sum()
|
|
378
|
+
if cat_a == cat_b:
|
|
379
|
+
n_b = n_a - 1
|
|
380
|
+
else:
|
|
381
|
+
n_b = (cell_df[cat_key].isin(cat_b)).sum()
|
|
382
|
+
if n_a == 0 or n_b == 0: # avoid 0 division
|
|
383
|
+
if n_permutations:
|
|
384
|
+
return 0, np.nan, [0]
|
|
385
|
+
else:
|
|
386
|
+
return 0
|
|
387
|
+
|
|
388
|
+
if graph is None: # build graph from scratch if not provided
|
|
389
|
+
graph = get_neighbors(
|
|
390
|
+
cell_df,
|
|
391
|
+
radius=radius,
|
|
392
|
+
n_neighbors=n_neighbors,
|
|
393
|
+
x_key=x_key,
|
|
394
|
+
y_key=y_key,
|
|
395
|
+
z_key=z_key,
|
|
396
|
+
n_jobs=1, # disable parallel in multiprocessing context
|
|
397
|
+
)
|
|
398
|
+
else: # in case provided graph includes not only given cell
|
|
399
|
+
graph = graph[np.ix_(cell_df.index.astype(int), cell_df.index.astype(int))]
|
|
400
|
+
|
|
401
|
+
cell_df.reset_index(drop=True, inplace=True) # to ensure index matches graph size
|
|
402
|
+
c_ab = graph[
|
|
403
|
+
np.ix_(
|
|
404
|
+
cell_df[cell_df[cat_key].isin(cat_a)].index,
|
|
405
|
+
cell_df[cell_df[cat_key].isin(cat_b)].index,
|
|
406
|
+
)
|
|
407
|
+
].sum()
|
|
408
|
+
|
|
409
|
+
n = cell_df.shape[0]
|
|
410
|
+
clq = (c_ab / n_a) / (n_b / (n - 1))
|
|
411
|
+
|
|
412
|
+
if n_permutations is None or n_permutations == 0:
|
|
413
|
+
return clq
|
|
414
|
+
|
|
415
|
+
# permute to have control for score
|
|
416
|
+
else:
|
|
417
|
+
rng = np.random.default_rng(seed)
|
|
418
|
+
clq_perms = []
|
|
419
|
+
for p in range(n_permutations):
|
|
420
|
+
# random shuffling of gene labels
|
|
421
|
+
perm_idx = rng.permutation(cell_df.shape[0])
|
|
422
|
+
permuted_categories = cell_df[cat_key].copy()
|
|
423
|
+
permuted_categories = np.asarray(permuted_categories)[perm_idx]
|
|
424
|
+
cell_df_permuted = cell_df.copy()
|
|
425
|
+
cell_df_permuted[cat_key] = permuted_categories
|
|
426
|
+
|
|
427
|
+
# only recompute c_ab, other values are same as unpermuted
|
|
428
|
+
c_ab_permuted = graph[
|
|
429
|
+
np.ix_(
|
|
430
|
+
cell_df_permuted[cell_df_permuted[cat_key].isin(cat_a)].index,
|
|
431
|
+
cell_df_permuted[cell_df_permuted[cat_key].isin(cat_b)].index,
|
|
432
|
+
)
|
|
433
|
+
].sum()
|
|
434
|
+
|
|
435
|
+
clq_perms.append((c_ab_permuted / n_a) / (n_b / (n - 1)))
|
|
436
|
+
|
|
437
|
+
if np.std(clq_perms) == 0 and np.mean(clq_perms) == 0:
|
|
438
|
+
clq_adjusted = clq
|
|
439
|
+
elif np.std(clq_perms) == 0 and np.mean(clq_perms) == clq:
|
|
440
|
+
clq_adjusted = 0
|
|
441
|
+
else:
|
|
442
|
+
clq_adjusted = clq / np.mean(clq_perms)
|
|
443
|
+
|
|
444
|
+
return clq, clq_adjusted, clq_perms
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def clq_significance(
|
|
448
|
+
cell_clqs: Dict,
|
|
449
|
+
cell_clqs_permuted: Dict,
|
|
450
|
+
percentile: float = 5,
|
|
451
|
+
) -> Tuple[List, Dict]:
|
|
452
|
+
"""Assess statistical significance of CLQ scores using permutation distributions.
|
|
453
|
+
|
|
454
|
+
Parameters
|
|
455
|
+
----------
|
|
456
|
+
cell_clqs : dict
|
|
457
|
+
Observed raw CLQ per cell, mapping cell IDs to float values.
|
|
458
|
+
cell_clqs_permuted : dict
|
|
459
|
+
Permutation distributions per cell, mapping cell IDs to lists of
|
|
460
|
+
CLQs from permutations.
|
|
461
|
+
percentile : float, optional
|
|
462
|
+
Percentile threshold for significance testing. A cell is significant if
|
|
463
|
+
its observed CLQ is beyond the [percentile, 100-percentile] range.
|
|
464
|
+
Default is 5.
|
|
465
|
+
|
|
466
|
+
Returns
|
|
467
|
+
-------
|
|
468
|
+
tuple
|
|
469
|
+
- significant_clq_cells : list - Cell IDs with significant CLQ scores
|
|
470
|
+
- observed_vs_percentile : dict - Fold-change between observed CLQ and
|
|
471
|
+
the nearest percentile threshold for each cell (1.0 for non-significant)
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
observed_vs_percentile = {}
|
|
475
|
+
significant_clq_cells = []
|
|
476
|
+
for cell, clqs in cell_clqs_permuted.items():
|
|
477
|
+
lower_percentile = np.percentile(clqs, percentile)
|
|
478
|
+
upper_percentile = np.percentile(clqs, 100 - percentile)
|
|
479
|
+
observed_clq = cell_clqs[cell]
|
|
480
|
+
if observed_clq < lower_percentile:
|
|
481
|
+
observed_vs_percentile[cell] = lower_percentile / observed_clq
|
|
482
|
+
significant_clq_cells.append(cell)
|
|
483
|
+
elif observed_clq > upper_percentile:
|
|
484
|
+
observed_vs_percentile[cell] = observed_clq / upper_percentile
|
|
485
|
+
significant_clq_cells.append(cell)
|
|
486
|
+
else:
|
|
487
|
+
observed_vs_percentile[cell] = 1
|
|
488
|
+
|
|
489
|
+
return significant_clq_cells, observed_vs_percentile
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from typing import Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from anndata import AnnData
|
|
5
|
+
from scipy import sparse as sp
|
|
6
|
+
from torch import from_numpy, long, tensor
|
|
7
|
+
from torch.utils import data
|
|
8
|
+
from torch_geometric.data import Data
|
|
9
|
+
from tqdm import tqdm
|
|
10
|
+
|
|
11
|
+
from ..utils import get_neighbors
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CellData(data.Dataset):
|
|
15
|
+
"""PyTorch Dataset for cell-level graph data of transcripts.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
adata : AnnData
|
|
20
|
+
Annotated data object with transcript-level observations and features.
|
|
21
|
+
cell_indices : dict
|
|
22
|
+
Dictionary mapping cell IDs to arrays of transcript indices for each cell.
|
|
23
|
+
graph_kwargs : dict, optional
|
|
24
|
+
Keyword arguments for neighbor graph construction passed to get_neighbors().
|
|
25
|
+
Default is None.
|
|
26
|
+
adj : sparse matrix, optional
|
|
27
|
+
Precomputed adjacency matrix. If None, compute per-cell from coordinates.
|
|
28
|
+
Default is None.
|
|
29
|
+
corruption : {'feature_shuffling'}, optional
|
|
30
|
+
Corruption strategy for negative samples in contrastive learning.
|
|
31
|
+
Default is "feature_shuffling".
|
|
32
|
+
seed : int, optional
|
|
33
|
+
Random seed for reproducibility. Default is 808.
|
|
34
|
+
|
|
35
|
+
Attributes
|
|
36
|
+
----------
|
|
37
|
+
data : list
|
|
38
|
+
List of torch_geometric.data.Data objects, one per cell.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
adata: AnnData,
|
|
44
|
+
cell_indices: Dict,
|
|
45
|
+
graph_kwargs: Optional[Dict] = None,
|
|
46
|
+
adj: Optional[sp.spmatrix] = None,
|
|
47
|
+
corruption: str = "feature_shuffling",
|
|
48
|
+
seed: int = 808,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.data = self.__partition__(
|
|
51
|
+
adata, cell_indices, graph_kwargs, adj, corruption, seed
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def __partition__(
|
|
55
|
+
self,
|
|
56
|
+
adata: AnnData,
|
|
57
|
+
cell_indices: Dict,
|
|
58
|
+
graph_kwargs: Optional[Dict],
|
|
59
|
+
adj: Optional[sp.spmatrix],
|
|
60
|
+
corruption: str,
|
|
61
|
+
seed: int,
|
|
62
|
+
) -> List[Data]:
|
|
63
|
+
"""Partition data into per-cell graph objects with features and corruption.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
adata : AnnData
|
|
68
|
+
Annotated data object with transcript-level observations and features.
|
|
69
|
+
cell_indices : dict
|
|
70
|
+
Dictionary mapping cell IDs to arrays of transcript indices for each cell.
|
|
71
|
+
graph_kwargs : dict
|
|
72
|
+
Keyword arguments for neighbor graph construction passed to get_neighbors().
|
|
73
|
+
adj : sparse matrix or None
|
|
74
|
+
Precomputed global adjacency matrix. If None, compute per-cell from coordinates.
|
|
75
|
+
corruption : str
|
|
76
|
+
Feature corruption strategy.
|
|
77
|
+
seed : int
|
|
78
|
+
Random seed for reproducibility.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
list
|
|
83
|
+
List of torch_geometric.data.Data objects with graph, features, and
|
|
84
|
+
corrupted features for each cell.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
data_cells = []
|
|
88
|
+
rng = np.random.default_rng(seed)
|
|
89
|
+
print("setting up torch data set ...")
|
|
90
|
+
for node_idxs in tqdm(cell_indices.values()):
|
|
91
|
+
# construct feature tensor
|
|
92
|
+
cell_adata = adata[node_idxs]
|
|
93
|
+
cell_features_transcripts = cell_adata.X
|
|
94
|
+
|
|
95
|
+
# Convert IDs to sparse format
|
|
96
|
+
cell_ids = cell_adata.obs.cell_encoded.values[np.newaxis].T
|
|
97
|
+
spot_ids = node_idxs[np.newaxis].T
|
|
98
|
+
cell_ids_sparse = sp.csr_matrix(cell_ids)
|
|
99
|
+
spot_ids_sparse = sp.csr_matrix(spot_ids)
|
|
100
|
+
|
|
101
|
+
# Concatenate
|
|
102
|
+
cell_features = sp.hstack(
|
|
103
|
+
[cell_features_transcripts, cell_ids_sparse, spot_ids_sparse]
|
|
104
|
+
)
|
|
105
|
+
cell_features = from_numpy(cell_features.toarray()).to_sparse()
|
|
106
|
+
|
|
107
|
+
# permute features
|
|
108
|
+
if corruption == "feature_shuffling":
|
|
109
|
+
perm_idxs_cell = rng.permutation(np.arange(cell_adata.n_obs))
|
|
110
|
+
corr_fts = cell_features_transcripts[perm_idxs_cell, :]
|
|
111
|
+
else:
|
|
112
|
+
raise ValueError(f"Corruption method '{corruption}' not implemented")
|
|
113
|
+
|
|
114
|
+
corr_fts = from_numpy(corr_fts.toarray()).to_sparse()
|
|
115
|
+
|
|
116
|
+
if adj is not None: # use provided graph
|
|
117
|
+
adj_cell = adj[np.ix_(node_idxs, node_idxs)]
|
|
118
|
+
|
|
119
|
+
else: # compute graph from scratch
|
|
120
|
+
adj_cell = get_neighbors(cell_adata.obs, **graph_kwargs)
|
|
121
|
+
|
|
122
|
+
# convert adjacency matrix to sparse tensor
|
|
123
|
+
if sp.issparse(adj_cell):
|
|
124
|
+
adj_cell = adj_cell.tocoo()
|
|
125
|
+
else:
|
|
126
|
+
adj_cell = sp.coo_matrix(adj_cell)
|
|
127
|
+
adj_cell = tensor(
|
|
128
|
+
np.vstack((adj_cell.row, adj_cell.col)), dtype=long, device="cpu"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
data_cells.append(
|
|
132
|
+
Data(x=cell_features, edge_index=adj_cell, corr_fts=corr_fts)
|
|
133
|
+
)
|
|
134
|
+
return data_cells
|
|
135
|
+
|
|
136
|
+
def __len__(self):
|
|
137
|
+
return len(self.data)
|
|
138
|
+
|
|
139
|
+
def __getitem__(self, index):
|
|
140
|
+
out = self.data[index]
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
def __repr__(self) -> str:
|
|
144
|
+
return f"{self.__class__.__name__}"
|