spatial-de
Load when ranking spatial cluster markers or comparing two spatial groups in spatial transcriptomics. Skip when the data is single-cell (use sc-de); bulk (use bulkrna-de); spatially variable expressio
SKILL.mdskills/spatial-de/SKILL.md
GitHubspatial-de
When to use
The user has a preprocessed spatial transcriptomics AnnData (Visium /
Xenium / MERFISH / Slide-seq) and wants either (a) cluster-marker
ranking via Scanpy wilcoxon / t-test, or (b) replicate-aware
two-group condition DE via pydeseq2 pseudobulk. The wrapper exposes
the official Scanpy filter controls and PyDESeq2 GLM controls directly,
and refuses to fabricate replicates — pseudobulk requires a real
sample_key.
Inputs & Outputs
<!-- AUTO-GENERATED from skill.yaml (interface) — do not edit by hand. Regenerate: python scripts/generate_skill_md.py <skill_dir> -->Inputs
- File types:
.h5ad - Requires a preprocessed AnnData (
Xnormalised, PCA/neighbours present)
Outputs
tables/de_full.csvtables/de_plot_points.csvtables/de_run_summary.csvtables/de_significant.csvtables/de_spatial_points.csvtables/de_umap_points.csvtables/group_de_metrics.csvtables/markers_top.csvtables/sample_counts_by_group.csvtables/skipped_sample_groups.csvtables/top_de_hits.csvfigures/de_effect_burden_spatial.pngfigures/de_effect_burden_umap.pngfigures/de_group_spatial_context.pngfigures/de_marker_dotplot.pngfigures/de_marker_heatmap.pngfigures/de_pvalue_distribution.pngfigures/de_top_hits_barplot.pngfigures/de_volcano.pngfigures/group_de_burden.pngfigures/sample_counts_by_group.pngfigures/skipped_sample_groups.pngprocessed.h5adreport.mdresult.json- Processed AnnData (
saves_h5ad)
Flow
- Load the preprocessed AnnData; if absent and not
--demo, runspatial-preprocessfirst (raisesRuntimeErroratspatial_de.py:1370if upstream script missing). - Validate the matrix contract and method-specific arguments (Scanpy methods need
X = log_normalized;pydeseq2needs counts). - For Scanpy paths: run
rank_genes_groupsand (default-on) the officialfilter_rank_genes_groupspost-filter. - For
pydeseq2: pseudobulk bysample_key× group, drop bins below--min-cells-per-sample/--min-counts-per-gene, fit PyDESeq2 GLM. If the same biological sample is in both groups, auto-switch to paired design~ sample_id + condition. - Render the recipe-driven standard gallery.
- Write
processed.h5ad, tables,figure_data/manifest.json,report.md,result.json, and reproducibility script.
Gotchas
pydeseq2requires--sample-keydistinct from--groupby. Hard-fails atspatial_de.py:1455with"--sample-key and --groupby must be different for pydeseq2". Defaultsample_keyissample_id; if the user's grouping column happens to be the same name, swap one.pydeseq2requires explicit--group1and--group2. Hard-fails atspatial_de.py:1458(the--demopath bypasses this by picking the first two groups it finds). Other methods treat them as optional (cluster-vs-rest if absent).- Counts-layer fallback to
adata.Xis logged but not blocked. Whenpydeseq2cannot findlayers["counts"]oradata.raw,spatial_de.py:1684-1686falls through toadata.Xwith a warning that says verbatim "Ifadata.Xis log-normalized, pseudobulk DE will be statistically invalid." Verifyresult.json["summary"]["expression_source"]after every pseudobulk run; do not assume the warning blocked the run. - Paired design auto-activates when ≥2 samples each contribute to both groups (with ≥2 cells per side). Per
skills/spatial/_lib/de.py:485-506(_choose_pydeseq2_design): the wrapper switches the DESeq2 formula to~ sample_id + conditiononly when at least 2 distinctsample_idvalues appear in both groups AND each side of the resulting paired split retains ≥2 cells; otherwise it stays unpaired (~ condition). Checkresult.json["summary"]["paired_design"](also surfaced atspatial_de.py:454); if the run was unintentionally paired (e.g. the user merged samples by accident), the LFC interpretation changes. - Skipped sample-group bins ARE surfaced (unlike sc-de's silent drops). The full reason list lives in
result.json's skipped-sample summary (each row carriessample_id,condition,reason,n_cellsperspatial_de.py:1198). When pydeseq2 reports few DEGs, inspect this list before assuming biological null. - Scanpy
filter_markersis cluster-style only. The--filter-markerspost-filter (default on) enforces themin_in_group_fraction/min_fold_change/max_out_group_fractiontriplet fromscanpy.tl.filter_rank_genes_groups— appropriate for cluster markers, but for a--group1 vs --group2contrast it can drop genuine effect genes whose between-condition cell coverage is low. Pass--no-filter-markersfor two-group condition comparisons.
Key CLI
# Demo: 200-spot synthetic Visium with three domains
python omicsclaw.py run spatial-de --demo
# Default exploratory cluster-marker discovery
python omicsclaw.py run spatial-de \
--input processed.h5ad --output results/ \
--groupby leiden --method wilcoxon
# Replicate-aware condition contrast via PyDESeq2 pseudobulk
python omicsclaw.py run spatial-de \
--input processed.h5ad --output results/ \
--method pydeseq2 --groupby condition \
--group1 treated --group2 control \
--sample-key sample_id \
--min-cells-per-sample 10 --min-counts-per-gene 10
See also
references/parameters.md— every CLI flag and per-method tuning hintreferences/methodology.md— Scanpywilcoxon/t-testpaths, PyDESeq2 GLM details, design validation, dependenciesreferences/output_contract.md— Visualization Contract (4 gallery roles) + Output Structurereferences/r_visualization.md— R customization layer readingfigure_data/; templates live inr_visualization/- Adjacent skills:
spatial-preprocess(upstream prerequisite),spatial-domains(upstream cluster discovery),spatial-genes(sibling: spatially variable gene discovery, not group-DE),spatial-condition(sibling: condition comparison without a per-cluster slice — usespatial-de --method pydeseq2when you also need agroupbycluster context),sc-de/bulkrna-de(same-question DE for the other two data modalities)