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 ADDED
@@ -0,0 +1,10 @@
1
+ from .embedder import (
2
+ Embedder, PathwayMetapath2vec, SVDEmbedder,
3
+ SpectralGraphEmbedder, LINEEmbedder, VAEEmbedder
4
+ )
5
+ from .net import Net
6
+ from . import ea
7
+ from . import utils
8
+ from . import compare
9
+
10
+ __version__ = "1.0.0"
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