scATrans without spliced/unspliced layers: differential expression + enrichment + plotting#

This tutorial is for the majority of scRNA-seq users who do not have RNA velocity (spliced/unspliced) layers — just an ordinary count matrix from 10x, Smart-seq, or any other protocol. Everything below uses scat.differential_expression(...) instead of scat.active_score(...), and never touches a spliced/unspliced/mature/nascent layer. The exact same code works unmodified on a plain count-matrix .h5ad with no velocity layers at all.

For the velocity-aware active_score workflow on the same dataset, see the sibling tutorial Active Transcription Scoring on Real Spinal Cord Injury Data.

Dataset#

We use EC.h5ad: endothelial cells (EC) subset from a real mouse spinal cord single-nucleus RNA-seq dataset, comparing uninjured controls (UN, 3 replicates) against spinal cord injury (SCI, 3 replicates):

Squair, J.W., Gautier, M., Kathe, C., et al. (2021). Confronting false discoveries in single-cell differential expression. Nature Communications 12, 5692. DOI: 10.1038/s41467-021-25960-2. GEO: GSE165003.

See References & Data Sources for the full citation list. This paper is itself about pseudoreplication and false-discovery risk in single-cell DE — fitting context for this tutorial, which reports real, sometimes non-significant results below rather than a cherry-picked toy example (small-n designs like this 3-vs-3 comparison commonly have limited genome-wide DE power; see Statistical Guidance & Reporting Checklist).

differential_expression and active_score share the same downstream tooling — filter_active_genes, all scat.pl.* plots, and every enrichment function — the only difference is that differential_expression skips the unspliced-excess term entirely.

%matplotlib inline
import sys
sys.path.insert(0, "../../src")  # use the in-repo scatrans, not any installed copy

import warnings
warnings.filterwarnings("ignore")

import scanpy as sc
import scatrans as scat

print("scatrans:", scat.__file__)
scatrans: /home/lieber/scATrans-main/docs/tutorials/../../src/scatrans/__init__.py

Load and inspect#

We deliberately only ever look at adata.X (raw counts) below — no layers access anywhere in this notebook.

adata = sc.read_h5ad("../../EC.h5ad")
adata
AnnData object with n_obs × n_vars = 177 × 26451
    obs: 'n_genes_by_counts', 'log1p_n_genes_by_counts', 'total_counts', 'log1p_total_counts', 'pct_counts_in_top_50_genes', 'pct_counts_in_top_100_genes', 'pct_counts_in_top_200_genes', 'pct_counts_in_top_500_genes', 'condition', 'sample', 'GSM_ID', 'total_counts_mt', 'pct_counts_mt', 'n_genes', 'doublet_score', 'predicted_doublet'
    var: 'gene_symbol', 'mt', 'n_cells_by_counts', 'mean_counts', 'pct_dropout_by_counts', 'total_counts', 'n_cells'
    uns: 'sample_colors', 'scrublet'
    layers: 'ambiguous', 'mature', 'nascent', 'spliced', 'unspliced'
adata.obs[["condition", "sample", "GSM_ID"]].value_counts().sort_index()
condition  sample  GSM_ID    
SCI        rep1    GSM5024314    35
           rep2    GSM5024315    23
           rep3    GSM5024316    37
UN         rep1    GSM5024317    27
           rep2    GSM5024318    38
           rep3    GSM5024319    17
Name: count, dtype: int64

QC and light filtering#

This EC subset was already QC’d upstream (doublet scores, mito %, and gene counts are precomputed in .obs). We apply the same filters here for completeness / robustness — on this dataset they happen to remove zero cells, since the upstream authors already excluded low-quality cells.

n0 = adata.n_obs
adata = adata[~adata.obs["predicted_doublet"]].copy()
adata = adata[adata.obs["pct_counts_mt"] < 20].copy()
adata = adata[adata.obs["n_genes_by_counts"] > 200].copy()
sc.pp.filter_genes(adata, min_cells=3)
print(f"cells kept: {adata.n_obs} of {n0}")
print(f"genes kept: {adata.n_vars}")
cells kept: 177 of 177
genes kept: 9221
sc.pl.violin(
    adata, ["n_genes_by_counts", "pct_counts_mt", "doublet_score"],
    groupby="condition", jitter=0.3, multi_panel=True,
)
../_images/3ce6ae15657b92fb0ffc7097b8d89ed3c99f58fe65e0ddd3a2fc0050a87999c5.png

Preserve raw counts, then preprocess#

store_raw_counts snapshots .X into layers["counts"] (and .raw) before any HVG/normalize/log1p step — this is what lets enrichment functions later auto-supply the correct measured-gene background via adata=. Count-based DE backends (PyDESeq2, Memento) need these raw integer counts; the de_preprocess="auto" step inside differential_expression also uses the .uns["log1p"] marker scanpy sets to detect whether .X is already log-normalized (see Standalone Differential Expression (no velocity data required) for the anndata.concat() caveat around this marker).

scat.store_raw_counts(adata, layer="counts", save_raw=True)

adata_norm = adata.copy()
sc.pp.normalize_total(adata_norm, target_sum=1e4)
sc.pp.log1p(adata_norm)

Backend A: single-cell Wilcoxon DE#

The simplest, fastest path: scanpy’s rank_genes_groups directly on log-normalized single cells.

adata_norm, de_wilcoxon = scat.differential_expression(
    adata_norm,
    groupby="condition",
    target_group="SCI",
    reference_group="UN",
    de_method="wilcoxon",
    de_preprocess="none",  # we already normalized + log1p'd above
)
de_wilcoxon.sort_values("logFC", ascending=False).head(10)
WARNING: It seems you use rank_genes_groups on the raw count data. Please logarithmize your data before calling rank_genes_groups.
logFC p_val p_adj baseMean
Tpcn1 27.242462 0.227766 0.9938 0.116235
Prdm16 27.127773 0.334591 0.9938 0.118895
Pgls 27.004536 0.227766 0.9938 0.126359
Nxn 27.004536 0.227766 0.9938 0.137124
Gm57952 27.004536 0.227766 0.9938 0.124299
Rab11fip1 27.004536 0.277691 0.9938 0.103826
Dmtf1 27.004536 0.147796 0.9938 0.125101
Pacsin2 26.871260 0.334591 0.9938 0.104579
Kcnj10 26.871260 0.227766 0.9938 0.122506
Plcl1 26.871260 0.277691 0.9938 0.112288
print("min p_adj:", de_wilcoxon["p_adj"].min())
print("genes with p_adj < 0.05:", (de_wilcoxon["p_adj"] < 0.05).sum())
min p_adj: 0.9938003852887332
genes with p_adj < 0.05: 0

Note

With only 3 replicates per condition, no gene reaches genome-wide p_adj < 0.05 here — this is real, honest small-n biology, not a bug. filter_active_genes with strict cutoffs legitimately returns very few or zero candidates. The practical workaround (used throughout this notebook) is to rank by nominal (unadjusted) p-value and effect size for hypothesis-generating candidate lists, and to say so explicitly rather than reporting p_adj < 0.05 claims that the data does not support. See Statistical Guidance & Reporting Checklist for the full reporting checklist.

candidates_strict = scat.filter_active_genes(de_wilcoxon, pval_cutoff=0.2, logfc_cutoff=0.3)
print("strict candidates (p_adj<0.2, |logFC|>0.3):", len(candidates_strict))

# Exploratory ranking: top 100 upregulated genes by nominal p-value
candidates = de_wilcoxon[de_wilcoxon["logFC"] > 0].sort_values("p_val").head(100)
print("exploratory candidates (top 100 upregulated by nominal p-value):", len(candidates))
candidates.head(10)
strict candidates (p_adj<0.2, |logFC|>0.3): 0
exploratory candidates (top 100 upregulated by nominal p-value): 100
logFC p_val p_adj baseMean
Rpl37 2.161662 0.000302 0.9938 1.468993
Spock2 2.577669 0.000312 0.9938 1.783297
Rpl35 2.087135 0.000397 0.9938 1.085354
Igf1r 1.444869 0.000446 0.9938 1.513025
Gpcpd1 1.772408 0.000603 0.9938 1.247660
Rpl37a 2.572021 0.001323 0.9938 1.647515
Rpl38 2.344653 0.001573 0.9938 1.909690
Gm68844 2.785950 0.001696 0.9938 1.732615
Rps19 1.891919 0.002009 0.9938 1.650908
Cpe 2.122542 0.002111 0.9938 0.719209

Backend B: pseudobulk + PyDESeq2#

With 3 biological replicates per group, aggregating to pseudobulk and running count-based DESeq2 is the more defensible choice for a real publication (see the DE-backend decision guide in Core Workflow). We compare its top genes against the Wilcoxon single-cell result above — backend choice visibly changes which genes come out on top.

adata_pb = adata.copy()
adata_pb, de_pseudobulk = scat.differential_expression(
    adata_pb,
    groupby="condition",
    target_group="SCI",
    reference_group="UN",
    use_pseudobulk=True,
    sample_col="sample",
    pseudobulk_de_backend="pydeseq2",
)
print("min p_adj (pseudobulk):", de_pseudobulk["p_adj"].min())
de_pseudobulk.sort_values("logFC", ascending=False).head(10)
min p_adj (pseudobulk): 0.5584481513291821
logFC p_val p_adj baseMean
Prdm16 4.416334 0.043152 0.999513 2.833333
Jmy 4.315898 0.050361 0.999513 3.000000
Gm3764 4.147823 0.062356 0.999513 2.666667
Col4a3 3.979546 0.080037 0.999513 2.500000
Cd38 3.958031 0.080950 0.999513 2.000000
Fau 3.825012 0.093960 0.999513 2.000000
Gm57952 3.819496 0.096126 0.999513 2.000000
Itih3 3.744748 0.113096 0.999513 1.833333
Gkn3 3.726783 0.114265 0.999513 2.166667
Msantd4 3.667388 0.114968 0.999513 1.666667
top_wilcoxon = set(de_wilcoxon.sort_values("logFC", ascending=False).head(20).index)
top_pseudobulk = set(de_pseudobulk.sort_values("logFC", ascending=False).head(20).index)
print(f"overlap of top-20 genes (Wilcoxon vs. pseudobulk PyDESeq2): {len(top_wilcoxon & top_pseudobulk)} / 20")
overlap of top-20 genes (Wilcoxon vs. pseudobulk PyDESeq2): 7 / 20

Backend C: Memento (method-of-moments cell-level DE)#

Memento (Cell, 2024) is a method-of-moments estimator that works directly on raw single-cell counts (no pseudobulk aggregation, no log-normalization). It needs use_memento_de=True and raw integer counts, which is exactly why we called store_raw_counts above.

adata_mem = adata.copy()
adata_mem, de_memento = scat.differential_expression(
    adata_mem,
    groupby="condition",
    target_group="SCI",
    reference_group="UN",
    use_memento_de=True,
)
de_memento.sort_values("logFC", ascending=False).head(10)
[Parallel(n_jobs=-1)]: Using backend LokyBackend with 12 concurrent workers.
[Parallel(n_jobs=-1)]: Done  26 tasks      | elapsed:    1.4s
[Parallel(n_jobs=-1)]: Done 476 tasks      | elapsed:    2.9s
[Parallel(n_jobs=-1)]: Done 1476 tasks      | elapsed:    5.7s
[Parallel(n_jobs=-1)]: Done 2083 out of 2083 | elapsed:    7.5s finished
logFC p_val p_adj memento_de_se memento_dv_coef memento_dv_se memento_dv_pval baseMean
Gfap 2.883938 0.065733 0.987457 0.899865 1.124937 2.501670 0.873866 0.401130
Slc6a20a 2.396857 0.011514 0.666649 0.602903 1.624803 1.066352 0.204310 0.225989
Lama4 2.044974 0.030588 0.810178 0.590835 0.891034 1.457454 0.750794 0.175141
Mecom 1.958642 0.008734 0.606432 0.492907 1.074449 1.579175 0.649023 0.288136
Ptp4a2 1.812726 0.025814 0.810178 0.518275 1.446629 1.680044 0.983735 0.197740
Mcf2l 1.801148 0.086967 0.989809 0.644036 -0.380125 1.863114 0.596109 0.276836
Phf20l1 1.773532 0.224842 0.989809 0.801671 -2.442645 1.840142 0.092357 0.158192
Sar1b 1.726292 0.026552 0.810178 0.518877 1.886126 1.580312 0.884016 0.152542
Ifnar1 1.685207 0.035636 0.839459 0.518997 1.173949 1.289252 0.419856 0.225989
Taok1 1.676860 0.136702 0.989809 0.678254 -0.386383 2.072747 0.504406 0.163842

Three backends, three slightly different answers — this is exactly the kind of pseudoreplication-sensitivity that the Squair et al. (2021) paper behind this dataset warns about. Always report which backend was used, per Statistical Guidance & Reporting Checklist.

Functional enrichment: the full tour#

We use the 100-gene exploratory candidate list from Backend A for the ORA methods below (run_enrichment, run_kegg, run_go), and the full ranked logFC list for run_gsea.

candidate_genes = candidates.index.tolist()
down_genes = de_wilcoxon[de_wilcoxon["logFC"] < 0].sort_values("p_val").head(100).index.tolist()
len(candidate_genes), len(down_genes)
(100, 100)

Over-representation analysis (ORA): GO Biological Process#

go_res = scat.run_enrichment(
    candidate_genes,
    gene_sets="GO_Biological_Process",
    organism="mouse",
    adata=adata_norm,   # auto-supplies the stored measured-gene background
    return_all=True,
)
print(f"{len(go_res)} GO BP terms tested, {(go_res['p.adjust'] < 0.05).sum()} significant at p.adjust<0.05")
go_res.sort_values("pvalue").head(10)[["Term", "Count", "GeneRatio", "pvalue", "p.adjust"]]
6744 GO BP terms tested, 47 significant at p.adjust<0.05
Term Count GeneRatio pvalue p.adjust
0 cytoplasmic translation (GO:0002181) 23 0.237113 3.700163e-17 2.495390e-13
1 negative regulation of myoblast fusion (GO:190... 13 0.134021 1.380523e-15 4.655122e-12
2 negative regulation of syncytium formation by ... 13 0.134021 2.655028e-15 5.968503e-12
3 regulation of myoblast fusion (GO:1901739) 13 0.134021 1.184686e-14 1.997380e-11
4 translation at synapse (GO:0140241) 12 0.123711 7.259096e-14 7.073882e-11
6 translation at postsynapse (GO:0140242) 12 0.123711 7.259096e-14 7.073882e-11
5 regulation of syncytium formation by plasma me... 13 0.134021 7.342405e-14 7.073882e-11
7 myoblast fusion (GO:0007520) 13 0.134021 1.860772e-13 1.568630e-10
8 cell-cell fusion (GO:0140253) 13 0.134021 1.443489e-12 9.734891e-10
9 syncytium formation by plasma membrane fusion ... 13 0.134021 1.443489e-12 9.734891e-10

Several ribosomal-protein genes (Rpl37, Rpl35, Rpl37a, Rpl38, Rps19, …) are in the candidate list, so “cytoplasmic translation” comes out as the top term — unlike the per-gene DE p-values, gene-set-level ORA here has genuine, well-powered signal.

KEGG pathways#

kegg_res = scat.run_kegg(
    candidate_genes,
    organism="mouse",
    adata=adata_norm,
    return_all=True,
)
kegg_res.sort_values("pvalue").head(10)[["Term", "Count", "pvalue", "p.adjust"]]
Term Count pvalue p.adjust
0 Ribosome(mmu03010) 21 4.654655e-18 9.402402e-16
2 Parkinson disease(mmu05012) 7 9.762118e-03 6.892085e-01
1 Oxidative phosphorylation(mmu00190) 7 1.023577e-02 6.892085e-01
193 Alzheimer disease(mmu05010) 7 2.363543e-02 1.000000e+00
32 Bile secretion(mmu04976) 3 2.908569e-02 1.000000e+00
201 ABC transporters(mmu02010) 2 5.766020e-02 1.000000e+00
66 Focal adhesion(mmu04510) 6 6.453936e-02 1.000000e+00
38 Cardiac muscle contraction(mmu04260) 3 7.023010e-02 1.000000e+00
72 Glioma(mmu05214) 3 7.808674e-02 1.000000e+00
169 Small cell lung cancer(mmu05222) 3 9.056147e-02 1.000000e+00

“Ribosome” is overwhelmingly the top KEGG hit here (p.adjust ≈ 1e-15) — consistent with the GO result above.

GO, all three ontologies at once (ontology="ALL")#

go_all = scat.run_go(
    candidate_genes,
    ontology="ALL",
    organism="mouse",
    adata=adata_norm,
    return_all=True,
    adjust_across_all=True,  # single BH correction across BP+CC+MF combined
)
print("ontologies covered:", list(go_all.attrs["per_ontology_attrs"].keys()))
go_all.sort_values("pvalue").head(6)[["Term", "Count", "pvalue", "p.adjust"]]
ontologies covered: ['BP', 'CC', 'MF']
Term Count pvalue p.adjust
0 cytoplasmic translation (GO:0002181) 23 3.700163e-17 2.495390e-13
1 negative regulation of myoblast fusion (GO:190... 13 1.380523e-15 4.655122e-12
2 negative regulation of syncytium formation by ... 13 2.655028e-15 5.968503e-12
3 regulation of myoblast fusion (GO:1901739) 13 1.184686e-14 1.997380e-11
6 translation at postsynapse (GO:0140242) 12 7.259096e-14 7.073882e-11
5 translation at synapse (GO:0140241) 12 7.259096e-14 7.073882e-11

Pre-ranked GSEA#

run_gsea takes a full ranked gene list (here, logFC from the Wilcoxon DE result) rather than a fixed candidate cutoff — every gene contributes, weighted by its rank.

Note

nperm controls the permutation-based null distribution; we use a modest nperm=100 here to keep this tutorial cell fast (real run: ~27s). For a manuscript, prefer a larger nperm (≥1000). Roughly 88% of genes in this ranked list share tied logFC values (a side effect of the small-n design above), which run_gsea warns about — tie-breaking within those ranks is arbitrary, so treat exact NES values here as illustrative rather than final.

ranked = de_wilcoxon["logFC"].sort_values(ascending=False)
gsea_res = scat.run_gsea(
    ranked_genes=ranked,
    gene_sets="GO_Biological_Process",
    organism="mouse",
    nperm=100,
    min_size=15,
)
gsea_res.sort_values("p.adjust")[["Term", "NES", "pvalue", "p.adjust", "leading_edge"]].head(10)
2026-07-05 11:40:29,063 [WARNING] Duplicated values found in preranked stats: 87.82% of genes
The order of those genes will be arbitrary, which may produce unexpected results.
Term NES pvalue p.adjust leading_edge
0 urogenital system development (GO:0001655) -2.180388 0.0 0.079007 Igf1;Acd;Osr1;Sulf1;Serpinf1;Acvr1
3 cerebral cortex radially oriented cell migrati... -2.063498 0.0 0.102709 Bmerb1;Lamb1;Dcx;Fbxo45;Reln;Col3a1
4 regulation of fibroblast growth factor recepto... -2.013076 0.0 0.116930 Smoc2;Runx2;Sulf1;Wnt4
2 diterpenoid metabolic process (GO:0016101) -2.074478 0.0 0.121144 Prmt3;Dgat1;Aldh1a1;Rbp4;Dhrs4;Cyp1b1
5 telencephalon glial cell migration (GO:0022030) -1.977310 0.0 0.137698 Bmerb1;Lamb1;Dcx;Reln;Col3a1
6 cerebral cortex radial glia-guided migration (... -1.977310 0.0 0.137698 Bmerb1;Lamb1;Dcx;Reln;Col3a1
1 fibroblast growth factor receptor signaling pa... -2.077310 0.0 0.181716 Sos1;Smoc2;Runx2;Sulf1;Wnt4;Fgfr2;Fgf9;Ccn2;Fg...
9 negative regulation of reproductive process (G... -1.914572 0.0 0.205418 Igf1;Shb;Sulf1;Ptgds;Serpinf1;Wnt4
8 negative regulation of release of cytochrome c... -1.915757 0.0 0.228242 Igf1
14 chondrocyte development (GO:0002063) -1.863570 0.0 0.243341 Runx2;Sulf1

Note

Both scat.pl.gseaplot(...) (the running-sum plot) and scat.pl.enrich_dotplot(gsea_res, x="NES", ...) are shown below. An earlier draft of this tutorial found these calls effectively hung (>1 minute, no error) on a real, thousands-of-terms GSEA result. The root cause turned out to be a real scATrans performance bug, since fixed: run_gsea stored gseapy’s full per-term running-enrichment-score curves in gsea_res.attrs["gsea_details"], and pandas deep-copies .attrs on essentially every DataFrame operation (.head(), .copy(), slicing) via NDFrame.__finalize__ — for a genome-wide ranked list against thousands of gene sets that is tens of millions of floats being deep-copied on every such call. See CHANGELOG.md for the fix (the payload is now wrapped so its __deepcopy__ is an O(1) identity return).

scat.pl.enrich_dotplot(gsea_res, top_n=15, x="NES", color_by="NES", title="GSEA (GO Biological Process)");
../_images/6058b8af45d6bbdd63fc8e4ce452f280db95f0b7a127bef715d66ccbf558d993.png
top_term = gsea_res.iloc[0]["Term"]
scat.pl.gseaplot(ranked, gsea_res, term=top_term, title=f"GSEA running score — {top_term[:40]}");
../_images/0ee3d6d01b6ed66f4ad16e94dfa35de9a12a3d828b7f1505d3b2bf496dcd5d44.png
simplified_jaccard = scat.simplify_enrichment(
    go_res, method="jaccard", similarity_cutoff=0.5, min_count=3,
)
print(f"jaccard: {len(go_res)} -> {len(simplified_jaccard)} terms")

simplified_denester = scat.simplify_enrichment(
    go_res, method="pathway_denester", min_count=3,
)
print(f"pathway_denester: {len(go_res)} -> {len(simplified_denester)} terms")
jaccard: 6744 -> 162 terms
pathway_denester: 6744 -> 130 terms

Comparing gene sets: compare_enrichment + concat_compare_results#

compare_enrichment runs ORA across several named gene lists at once (clusterProfiler’s compareCluster style) and returns a single table with a Cluster column — exactly the shape enrich_upsetplot / enrich_vennplot expect. Here we compare the up- vs. down-regulated candidate genes.

cmp_res = scat.compare_enrichment(
    {"up": candidate_genes, "down": down_genes},
    organism="mouse",
    gene_sets="GO_Biological_Process",
    adata=adata_norm,
)
cmp_res["Cluster"].value_counts()
Cluster
up    47
Name: count, dtype: int64

concat_compare_results does the same thing when you already have separate result tables in hand (e.g. combining our GO and KEGG results above into one comparable table):

concat_res = scat.concat_compare_results({"GO": go_res, "KEGG": kegg_res})
concat_res["Cluster"].value_counts()
Cluster
GO      6744
KEGG     202
Name: count, dtype: int64

Exporting results#

save_enrichment_report writes CSV/TSV/Excel + a metadata JSON (gene-set provenance, universe info, package version); expand_enrichment_genes pivots to one row per gene for network/follow-up analyses. We write to a scratch temp directory here rather than into the docs tree.

import tempfile, os

tmpdir = tempfile.mkdtemp()
saved_paths = scat.save_enrichment_report(
    kegg_res,
    prefix=os.path.join(tmpdir, "kegg_demo"),
    save_excel=True, save_csv=True, save_tsv=True,
    save_metadata=True, save_term_gene_table=True,
)
saved_paths
{'results_csv': '/tmp/tmplqv4ijao/kegg_demo_results.csv',
 'term_gene_table_csv': '/tmp/tmplqv4ijao/kegg_demo_term_gene_table.csv',
 'results_tsv': '/tmp/tmplqv4ijao/kegg_demo_results.tsv',
 'term_gene_table_tsv': '/tmp/tmplqv4ijao/kegg_demo_term_gene_table.tsv',
 'metadata_json': '/tmp/tmplqv4ijao/kegg_demo_metadata.json',
 'results_xlsx': '/tmp/tmplqv4ijao/kegg_demo_results.xlsx'}
long_table = scat.expand_enrichment_genes(kegg_res)
print(long_table.shape)
long_table.head()
(146, 14)
Term Description Gene Count GeneRatio GeneRatio_str BgRatio BgRatio_str FoldEnrichment RichFactor Overlap pvalue p.adjust TermSize
0 Ribosome(mmu03010) Rpl14 21 0.333333 21/63 0.029197 84/2877 11.416667 0.25 21/84 4.654655e-18 9.402402e-16 84
1 Ribosome(mmu03010) Rpl18a 21 0.333333 21/63 0.029197 84/2877 11.416667 0.25 21/84 4.654655e-18 9.402402e-16 84
2 Ribosome(mmu03010) Rpl21 21 0.333333 21/63 0.029197 84/2877 11.416667 0.25 21/84 4.654655e-18 9.402402e-16 84
3 Ribosome(mmu03010) Rpl23a 21 0.333333 21/63 0.029197 84/2877 11.416667 0.25 21/84 4.654655e-18 9.402402e-16 84
4 Ribosome(mmu03010) Rpl26 21 0.333333 21/63 0.029197 84/2877 11.416667 0.25 21/84 4.654655e-18 9.402402e-16 84

Recap#

Without ever touching a spliced/unspliced layer, this notebook covered:

  • Three DE backends (de_method="wilcoxon", use_pseudobulk=True + PyDESeq2, use_memento_de=True) on the same real SCI vs. UN comparison.

  • Honest reporting of low per-gene DE power at n=3/group, with an exploratory-ranking workaround.

  • The full enrichment toolkit: run_enrichment, run_kegg, run_go(ontology="ALL"), run_gsea, simplify_enrichment (both methods), compare_enrichment / concat_compare_results, and the export helpers.

  • The plotting gallery: volcano (3 styles), enrichment dot/bar plots (including the GSEA NES dotplot and running-score plot), UpSet/Venn comparison plots, and a marker-gene heatmap.

See also: Standalone Differential Expression (no velocity data required), Functional Enrichment, Visualization, Statistical Guidance & Reporting Checklist, References & Data Sources, and Active Transcription Scoring on Real Spinal Cord Injury Data if your data does have spliced/unspliced layers.