gpath2vec 3.0.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.
- gpath2vec/__init__.py +10 -0
- gpath2vec/aucell.py +274 -0
- gpath2vec/cli.py +602 -0
- gpath2vec/compare.py +86 -0
- gpath2vec/ea.py +201 -0
- gpath2vec/embedder.py +554 -0
- gpath2vec/net.py +140 -0
- gpath2vec/utils.py +124 -0
- gpath2vec-3.0.0.dist-info/METADATA +342 -0
- gpath2vec-3.0.0.dist-info/RECORD +14 -0
- gpath2vec-3.0.0.dist-info/WHEEL +5 -0
- gpath2vec-3.0.0.dist-info/entry_points.txt +2 -0
- gpath2vec-3.0.0.dist-info/licenses/LICENSE +21 -0
- gpath2vec-3.0.0.dist-info/top_level.txt +1 -0
gpath2vec/__init__.py
ADDED
gpath2vec/aucell.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""AUCell per-niche pathway scoring, as an alternative enrichment source.
|
|
2
|
+
|
|
3
|
+
WHAT THIS COMPUTES (read this before using it)
|
|
4
|
+
----------------------------------------------
|
|
5
|
+
The unit of analysis is the *niche*: an upstream-defined spatial group of cells
|
|
6
|
+
(e.g. a center spot plus its spatial neighbors), summarized as one aggregated
|
|
7
|
+
"pseudobulk" expression vector per niche. The input here is therefore an
|
|
8
|
+
(n_niches x n_genes) matrix where each row is that niche's aggregated counts.
|
|
9
|
+
|
|
10
|
+
For each niche, AUCell:
|
|
11
|
+
1. ranks all genes by the niche's aggregated expression (descending),
|
|
12
|
+
2. for each Reactome pathway gene set, walks down that ranking and integrates
|
|
13
|
+
the recovery curve (how early the pathway's genes appear) over the top
|
|
14
|
+
`n_up` features. With decoupler's default (n_up=None) the recovery window
|
|
15
|
+
is the top 5% of ranked genes by magnitude.
|
|
16
|
+
3. returns one continuous score in [0, ~1] per (niche, pathway): high = the
|
|
17
|
+
pathway's genes are concentrated among the niche's most-expressed genes.
|
|
18
|
+
|
|
19
|
+
There is no per-niche gene-set selection step (no top-100, no MAD/mean): AUCell
|
|
20
|
+
uses the *full* gene ranking, so the "which genes count per niche" choice that
|
|
21
|
+
Fisher's exact requires does not exist here. The only parameters are the
|
|
22
|
+
gene-set size band and the AUC recovery window (n_up); both are logged.
|
|
23
|
+
|
|
24
|
+
This is a faithful generalization of the validated niche_aucell.py pipeline:
|
|
25
|
+
same normalization (normalize_total to 1e4 then log1p), same decoupler call
|
|
26
|
+
(`dc.mt.aucell`, tmin = min gene-set size), same gene-set-size band. It is
|
|
27
|
+
generalized by (a) taking all paths/params as arguments, (b) drawing the
|
|
28
|
+
pathway universe from `ea.filter_pathways` so the AUCell and Fisher enrichment
|
|
29
|
+
paths use the IDENTICAL level-filtered Reactome universe (comparable by
|
|
30
|
+
construction), and (c) writing a provenance JSON for the methods section.
|
|
31
|
+
|
|
32
|
+
decoupler + anndata are imported lazily; install via the `aucell` extra.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import hashlib
|
|
38
|
+
import json
|
|
39
|
+
import warnings
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
import numpy as np
|
|
43
|
+
import pandas as pd
|
|
44
|
+
import scipy.sparse as sp
|
|
45
|
+
|
|
46
|
+
from . import ea
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sha256_of_array(X) -> str:
|
|
50
|
+
"""stable content hash of the niche x gene matrix, for provenance."""
|
|
51
|
+
h = hashlib.sha256()
|
|
52
|
+
if sp.issparse(X):
|
|
53
|
+
Xc = X.tocsr()
|
|
54
|
+
for a in (Xc.data, Xc.indices, Xc.indptr,
|
|
55
|
+
np.asarray(Xc.shape, dtype=np.int64)):
|
|
56
|
+
h.update(np.ascontiguousarray(a).tobytes())
|
|
57
|
+
else:
|
|
58
|
+
h.update(np.ascontiguousarray(np.asarray(X)).tobytes())
|
|
59
|
+
return h.hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_net(level, gene_filter, min_genes, max_genes):
|
|
63
|
+
"""long-form pathway->gene table for decoupler, drawn from the SAME
|
|
64
|
+
`ea.filter_pathways` universe the Fisher path uses. `source` is the bare
|
|
65
|
+
Reactome stId so the resulting score columns are drop-in compatible with
|
|
66
|
+
`Net` (which keys pathway nodes by stId)."""
|
|
67
|
+
gm = ea.filter_pathways(level=level, gene_filter=gene_filter,
|
|
68
|
+
min_genes=min_genes)
|
|
69
|
+
if gm.empty:
|
|
70
|
+
raise ValueError(f"no pathways for level={level!r} "
|
|
71
|
+
f"(gene_filter set: {gene_filter is not None})")
|
|
72
|
+
rows = []
|
|
73
|
+
kept = 0
|
|
74
|
+
for r in gm.itertuples(index=False):
|
|
75
|
+
g = list(r.genes)
|
|
76
|
+
# gene-set-size band: min_genes (also passed to decoupler as tmin)
|
|
77
|
+
# and an upper cap to drop very large, non-specific sets.
|
|
78
|
+
if min_genes <= len(g) <= max_genes:
|
|
79
|
+
kept += 1
|
|
80
|
+
for sym in g:
|
|
81
|
+
rows.append((r.stId, sym))
|
|
82
|
+
if not rows:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"no pathways within size band [{min_genes}, {max_genes}] "
|
|
85
|
+
f"at level={level!r}")
|
|
86
|
+
net = pd.DataFrame(rows, columns=["source", "target"])
|
|
87
|
+
return net, kept
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def compute_aucell(X, genes, niche_ids, *, level="all", gene_filter=None,
|
|
91
|
+
min_genes=3, max_genes=500, normalize=True,
|
|
92
|
+
provenance_path=None, verbose=True):
|
|
93
|
+
"""per-niche AUCell scores against the level-filtered Reactome universe.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
X : (n_niches, n_genes) array or scipy.sparse
|
|
98
|
+
per-niche aggregated (pseudobulk) expression. Raw counts by default;
|
|
99
|
+
pass `normalize=False` if already library-normalized + log1p'd.
|
|
100
|
+
genes : sequence of str, length n_genes
|
|
101
|
+
gene symbols aligned to the columns of X.
|
|
102
|
+
niche_ids : sequence, length n_niches
|
|
103
|
+
identifier per row of X (used as the output index).
|
|
104
|
+
level : {"low","mid","high","all"}
|
|
105
|
+
Reactome pathway level (same semantics as ea.filter_pathways).
|
|
106
|
+
gene_filter : optional set/list of gene symbols
|
|
107
|
+
restrict the pathway universe (e.g. TF genes). None = no restriction.
|
|
108
|
+
min_genes, max_genes : int
|
|
109
|
+
pathway gene-set size band. min_genes is also passed to decoupler as
|
|
110
|
+
`tmin`. Defaults [3, 500] match the validated pipeline.
|
|
111
|
+
normalize : bool
|
|
112
|
+
if True (default) apply normalize_total(target_sum=1e4) then log1p
|
|
113
|
+
in-place, exactly as the validated niche_aucell pipeline. The score
|
|
114
|
+
ranking is invariant to a per-niche scalar, but this is kept for
|
|
115
|
+
parity with the reference and is logged.
|
|
116
|
+
provenance_path : optional path
|
|
117
|
+
if given, write a JSON of every parameter (decoupler version, n_up
|
|
118
|
+
policy, tmin/tmax, normalization, input sha256, shapes) for the
|
|
119
|
+
methods section / reproducibility.
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
aucell_df : DataFrame (n_niches x n_pathways), index = niche_ids,
|
|
124
|
+
columns = Reactome stIds, values = continuous AUCell scores.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
import anndata as ad
|
|
128
|
+
import decoupler as dc
|
|
129
|
+
except ImportError as e: # pragma: no cover - environment-dependent
|
|
130
|
+
raise ImportError(
|
|
131
|
+
"AUCell requires 'decoupler' and 'anndata'. Install the extra: "
|
|
132
|
+
"pip install 'gpath2vec[aucell]'") from e
|
|
133
|
+
|
|
134
|
+
genes = list(map(str, genes))
|
|
135
|
+
niche_ids = list(niche_ids)
|
|
136
|
+
if sp.issparse(X):
|
|
137
|
+
X = X.tocsr()
|
|
138
|
+
if X.shape != (len(niche_ids), len(genes)):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"X shape {X.shape} does not match "
|
|
141
|
+
f"(n_niches={len(niche_ids)}, n_genes={len(genes)})")
|
|
142
|
+
|
|
143
|
+
input_sha = _sha256_of_array(X)
|
|
144
|
+
|
|
145
|
+
adata = ad.AnnData(
|
|
146
|
+
X=X.tocsr() if sp.issparse(X) else np.asarray(X),
|
|
147
|
+
obs=pd.DataFrame({"niche_id": niche_ids}),
|
|
148
|
+
var=pd.DataFrame(index=genes),
|
|
149
|
+
)
|
|
150
|
+
adata.obs_names = [str(n) for n in niche_ids]
|
|
151
|
+
adata.obs_names_make_unique()
|
|
152
|
+
|
|
153
|
+
if normalize:
|
|
154
|
+
# normalize_total(target_sum=1e4) then log1p, done explicitly so the
|
|
155
|
+
# exact transform is auditable and matches the reference pipeline.
|
|
156
|
+
if verbose:
|
|
157
|
+
print(" normalizing: target_sum=1e4 -> log1p")
|
|
158
|
+
Xc = adata.X.tocsr() if sp.issparse(adata.X) else sp.csr_matrix(adata.X)
|
|
159
|
+
lib = np.asarray(Xc.sum(axis=1)).ravel()
|
|
160
|
+
scaling = 1e4 / np.maximum(lib, 1.0)
|
|
161
|
+
Xc = Xc.multiply(scaling[:, None]).tocsr()
|
|
162
|
+
Xc.data = np.log1p(Xc.data)
|
|
163
|
+
adata.X = Xc
|
|
164
|
+
|
|
165
|
+
net, n_path = _build_net(level, gene_filter, min_genes, max_genes)
|
|
166
|
+
if verbose:
|
|
167
|
+
print(f" pathway net: {net['source'].nunique()} pathways, "
|
|
168
|
+
f"{len(net)} pathway-gene edges (level={level}, "
|
|
169
|
+
f"size band [{min_genes}, {max_genes}])")
|
|
170
|
+
print(" running AUCell (decoupler)...")
|
|
171
|
+
|
|
172
|
+
# tmin = minimum gene-set size; n_up left at decoupler default (None ->
|
|
173
|
+
# top 5% of ranked features). Both recorded in provenance below.
|
|
174
|
+
dc.mt.aucell(adata, net=net, tmin=min_genes, verbose=verbose)
|
|
175
|
+
scores = adata.obsm["score_aucell"].copy()
|
|
176
|
+
scores.index = niche_ids[: len(scores)] if len(scores) == len(niche_ids) \
|
|
177
|
+
else scores.index
|
|
178
|
+
|
|
179
|
+
if provenance_path is not None:
|
|
180
|
+
prov = {
|
|
181
|
+
"method": "AUCell",
|
|
182
|
+
"decoupler_version": dc.__version__,
|
|
183
|
+
"anndata_version": ad.__version__,
|
|
184
|
+
"n_up": None,
|
|
185
|
+
"n_up_policy": ("decoupler default: top 5% of ranked features "
|
|
186
|
+
"by magnitude"),
|
|
187
|
+
"tmin": int(min_genes),
|
|
188
|
+
"tmax_genesize_band": int(max_genes),
|
|
189
|
+
"reactome_level": level,
|
|
190
|
+
"gene_filter_applied": gene_filter is not None,
|
|
191
|
+
"n_gene_filter": (len(set(gene_filter))
|
|
192
|
+
if gene_filter is not None else 0),
|
|
193
|
+
"normalize_total_target_sum": 1e4 if normalize else None,
|
|
194
|
+
"log1p": bool(normalize),
|
|
195
|
+
"pre_normalized_input": (not normalize),
|
|
196
|
+
"n_niches": int(len(niche_ids)),
|
|
197
|
+
"n_genes": int(len(genes)),
|
|
198
|
+
"n_pathways_scored": int(scores.shape[1]),
|
|
199
|
+
"input_matrix_sha256": input_sha,
|
|
200
|
+
}
|
|
201
|
+
Path(provenance_path).write_text(json.dumps(prov, indent=2))
|
|
202
|
+
if verbose:
|
|
203
|
+
print(f" wrote AUCell provenance -> {provenance_path}")
|
|
204
|
+
|
|
205
|
+
return scores
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def topk_per_niche(aucell_df, k, standardize="none"):
|
|
209
|
+
"""top-k AUCell pathways per niche -> clusters dict for `Net`.
|
|
210
|
+
|
|
211
|
+
aucell_df : DataFrame (n_niches x n_pathways), continuous AUCell scores.
|
|
212
|
+
k : pathways to keep per niche (the primary sparsity parameter; ablate
|
|
213
|
+
over e.g. {20, 50, 100}). Low k = sparse, sharp per-niche identity but
|
|
214
|
+
risk of under-connected niches; high k = denser, smoother, re-approaches
|
|
215
|
+
the everything-connects-to-everything dilution. Always ablate + log k.
|
|
216
|
+
standardize : {"none", "zscore"}
|
|
217
|
+
how to RANK pathways for the per-niche top-k selection.
|
|
218
|
+
"none" (default): rank by the absolute AUCell score. on pseudobulk this
|
|
219
|
+
is dominated by ubiquitously high-expression machinery (translation,
|
|
220
|
+
ribosome, metabolism) that scores ~0.9 in nearly every niche, so most
|
|
221
|
+
niches connect to the SAME pathways and the niche->pathway edges
|
|
222
|
+
carry little between-niche signal.
|
|
223
|
+
"zscore": z-score each pathway ACROSS niches first, then rank by that
|
|
224
|
+
relative score. a pathway that is high in every niche has z~0
|
|
225
|
+
everywhere and is never selected; the top-k becomes the pathways
|
|
226
|
+
where THIS niche is most elevated relative to the cohort. this is the
|
|
227
|
+
cross-niche contrast that the within-niche AUCell ranking alone does
|
|
228
|
+
not provide. removes the shared housekeeping floor.
|
|
229
|
+
|
|
230
|
+
NOTE on defaults: this function defaults to standardize="none", while the
|
|
231
|
+
cli `--aucell-standardize` defaults to "zscore". this divergence is
|
|
232
|
+
deliberate: direct library calls keep the pre-fix absolute selection so
|
|
233
|
+
existing runs stay reproducible, while new cli runs get the contrastive
|
|
234
|
+
selection. pass standardize explicitly if you care which you get.
|
|
235
|
+
|
|
236
|
+
edge weight is the RAW AUCell score in both modes (non-negative, in
|
|
237
|
+
[0, ~1], directly usable by the weighted random walker); only the SELECTION
|
|
238
|
+
criterion changes. pathways with no cross-niche variance are treated as
|
|
239
|
+
non-discriminative (z = 0) and effectively drop out of the ranking.
|
|
240
|
+
|
|
241
|
+
returns: {niche_id: {pathway_stId: aucell_score, ...}} -- identical shape
|
|
242
|
+
to the Fisher path's clusters dict, drop-in for the Net builder.
|
|
243
|
+
"""
|
|
244
|
+
if standardize not in ("none", "zscore"):
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"standardize must be 'none' or 'zscore', got {standardize!r}")
|
|
247
|
+
|
|
248
|
+
if standardize == "zscore" and len(aucell_df) < 2:
|
|
249
|
+
# cross-niche z-scoring is undefined with a single niche (zero variance
|
|
250
|
+
# everywhere); fall back to absolute selection rather than emit garbage.
|
|
251
|
+
warnings.warn(
|
|
252
|
+
"standardize='zscore' needs >=2 niches for a cross-niche contrast; "
|
|
253
|
+
f"got {len(aucell_df)}. falling back to absolute (standardize='none').",
|
|
254
|
+
RuntimeWarning, stacklevel=2)
|
|
255
|
+
standardize = "none"
|
|
256
|
+
|
|
257
|
+
if standardize == "zscore":
|
|
258
|
+
mu = aucell_df.mean(axis=0)
|
|
259
|
+
sd = aucell_df.std(axis=0, ddof=0)
|
|
260
|
+
# zero-variance pathways carry no between-niche signal -> z = 0 so they
|
|
261
|
+
# never win the top-k; avoid 0/0 by masking their std to NaN then 0.
|
|
262
|
+
rank_df = (aucell_df - mu) / sd.replace(0.0, np.nan)
|
|
263
|
+
rank_df = rank_df.fillna(0.0)
|
|
264
|
+
else:
|
|
265
|
+
rank_df = aucell_df
|
|
266
|
+
|
|
267
|
+
clusters = {}
|
|
268
|
+
for niche_id in aucell_df.index:
|
|
269
|
+
raw = aucell_df.loc[niche_id]
|
|
270
|
+
order = rank_df.loc[niche_id].nlargest(k).index
|
|
271
|
+
sel = raw.loc[order]
|
|
272
|
+
sel = sel[sel > 0] # never connect to a zero-AUCell pathway
|
|
273
|
+
clusters[str(niche_id)] = sel.to_dict()
|
|
274
|
+
return clusters
|