Skip to content

Repository files navigation

RIPPLE

ripple logo

Replicate-Aware Inference of Paracrine Profiles via Likelihood Estimation

R-CMD-check status DOI

RIPPLE is an R package that detects distance-dependent gene expression gradients from a chosen query cell type in spatial transcriptomics data.

This page walks through how RIPPLE works, with diagrams you can interact with! How RIPPLE Works (interactive explainer)


The question

"Which genes in cell type B change expression as a function of physical distance from cell type A, reproducibly across biological replicates?"

For each gene in each target cell type, RIPPLE fits a per-sample Poisson GLM with distance to the query population as the predictor and log(total_counts) as an offset. Per-sample p-values are combined across biological replicates via Fisher's method with a sign-consistency gate. The output is a ranked list of genes with signed gradient scores, BH-adjusted p-values, and per-sample reproducibility summaries.

Supported platforms: Xenium, CosMx, MERFISH, etc. Suited to any imaging-based platform with single-cell resolved coordinates and integer counts. Not designed for spot-resolution platforms (e.g. Visium without deconvolution) where one spot mixes multiple cell types.


Note: For users of v0.1.0, there are important updates and bug fixes in v0.2.0, so we recommend updating!

Installation

Install the required BiocNeighbors dependency and the Bioconductor packages used by the quick start and vignettes:

if (!requireNamespace("BiocManager", quietly = TRUE)) install.packages("BiocManager")
BiocManager::install(c(
  "BiocNeighbors", "SpatialExperiment", "SingleCellExperiment", "SummarizedExperiment",
  "S4Vectors", "fgsea", "msigdbr"
))
install.packages(c("R.utils", "knitr", "rmarkdown"))

Then, install RIPPLE without or with vignettes:

# install.packages("devtools")
devtools::install_github("Maier-Lab/RIPPLE")
# To build the vignettes at install time (they are skipped by default),  run this instead:
devtools::install_github("Maier-Lab/RIPPLE", build_vignettes = TRUE)

Optional functionality requires additional packages:

Optional feature Extra packages
Bundled data + quick start (ripple_mock_data is a SpatialExperiment) SpatialExperiment, SummarizedExperiment, S4Vectors (Bioconductor)
QC dashboard (ripple_plot_qc, reads cell_distances.csv.gz) R.utils
Multi-panel figures and vignettes knitr, rmarkdown
Pathway enrichment (run_ripple_fgsea) fgsea, msigdbr (Bioconductor)
Ligand-receptor integration (run_ripple_lr) nichenetr (GitHub: saeyslab/nichenetr)
Input from SingleCellExperiment SingleCellExperiment (Bioconductor)
GPU permutation testing (Stage 2) Python 3, PyTorch (CUDA), NumPy < 2, SciPy

Note: building the vignettes may pull in the magick R package, which needs ImageMagick installed at the system level. If you hit a magick/ImageMagick error, install it via your OS package manager (e.g. conda install -c conda-forge r-magick) and reinstall.


Quick start

The package ships with ripple_mock_data, a small synthetic SpatialExperiment (50 genes, 600 cells, 3 samples) containing a planted distance-dependent gradient. You can run the full pipeline on it without any external data:

library(ripple)
data(ripple_mock_data)

results <- run_ripple(
  input           = ripple_mock_data,
  query_celltype  = "Tumor",
  celltype_column = "cell_type",
  sample_column   = "sample_id",
  output_dir      = tempfile("ripple_demo_")
)

# The planted INDUCED_* and REPRESSED_* genes should top the list
head(results[order(fisher_fdr)], 12)

On real data, input can be a Seurat, SingleCellExperiment, or SpatialExperiment object (in memory or as an .rds path). For raw matrices or CSV inputs, use make_ripple_input() or read_ripple_csv() to build a canonical object first. See ?run_ripple for the full argument list. See the CosMx vignette to check how we converted h5ad files befor RIPPLE.


Vignettes

Four vignettes ship with the package:

Vignette Description
getting_started 5-minute end-to-end run on the bundled synthetic dataset. The fastest way to see what RIPPLE does.
cosmx_nsclc_walkthrough Applied walkthrough on public CosMx NSCLC data (He et al., 2022), using refreshed bundled gradient, pathway and ligand-receptor results.
parallelization How to fan out run_ripple() over cell types on a multi-core machine using future.apply. For large datasets where a single-core run would take hours.
benchmarks FDR calibration, power curves, and runtime measurements from the synthetic benchmark suite.

Browse locally:

browseVignettes("ripple")

# Or build the full pkgdown site (shipped _pkgdown.yml):
pkgdown::build_site()

Pipeline overview

Each stage is optional except Stage 1.

Stage Function(s) Purpose
1. Distance correlation run_ripple() Per-sample Poisson GLM + Fisher combined p-value with sign-consistency gate
2. Merge and summarize merge_ripple_results(), compute_fisher_pval() Combines per-celltype results, recomputes Fisher p-values. IF you run_ripple(), you will get these out too, but you can use these functions if your run gets interrupted, for ex.
3. Permutation validation run_ripple(n_permutations = ...) or run_permutation_tests() Tests query-location specificity of the median coefficient using non-target pseudo-query candidates. The GPU script uses the same candidate pool by default.
4. Confounder control run_ripple_confounder() Bivariate GLM isolating query-specific from shared-niche effects
5. Atlas figures run_ripple_atlas(), run_ripple_fgsea(), plot_gradient_volcano(), plot_gradient_curve() Multi-panel figures, pathway enrichment, contamination flagging
6. Ligand-receptor integration run_ripple_lr(), classify_lr_artifacts() Matches gradient genes to L-R pairs via NicheNet

Diagnostics:

  • ripple_plot_qc() builds a one-glance multi-panel dashboard from the artefacts run_ripple() writes (per-sample distance density, cell composition, sign consistency, dispersion, specificity breakdown, and query-marker bleed-through).
  • classify_gene_specificity() flags broad-expression genes (significant in many cell types at once). Three candidate sources to combine: this cross-cell-type flag, the find_ambient_family_genes() blocklist for Ig / J-chain / ribosomal / mitochondrial, and a user-supplied query_signature_genes list. None of these is a contamination measurement. You decide using domain knowledge.
  • plot_k_diagnostics() helps pick k_neighbors before running the full pipeline.
  • check_spatial_autocorrelation() computes Moran's I on Poisson residuals for selected genes, flagging cases where the independence assumption may be violated.
  • check_data() and load_metadata_only() give fast metadata access without loading the full expression matrix.

Core statistical model

For each gene in each target cell type, per biological replicate:

glm(counts ~ distance_to_query + offset(log(total_counts)), family = poisson)
  • distance_to_query: within-sample Euclidean distance (um) to the nearest query cell at k_neighbors = 1, or mean distance to the k nearest query cells. Distances are capped at max_distance_um (default 200); farther cells remain in the fit.
  • offset(log(total_counts)): accounts for differences in total measured counts per cell. It does not identify or remove ambient RNA or segmentation errors.
  • Coefficient (beta): log-rate change per um. Negative = expression increases near query cells (induced). Positive = expression decreases (repressed).

The gradient score is the median per-sample coefficient, with equal weight per replicate. Fisher's method combines per-sample p-values after the sign gate (default sign_consistency = 1.0); consider relaxation to 0.75 only with at least six replicates and report it explicitly. Zero p-values remain in sign checking and are floored at 1e-15 for Fisher combination. At least two valid samples are required. BH adjustment is applied separately within each target cell type, and fisher_fdr is the primary significance metric.

Within-sample Wald p-values depend on the Poisson variance and cell-independence assumptions. Replicate aggregation does not correct violations of those assumptions; see the benchmarks vignette for pooled FPR and empirical FDR under overdispersion.

Updating existing workflows

  • GPU permutation tests read median_coef from package results. Legacy results require the accompanying coef_per_sample.csv to reconstruct observed medians. Poisson fits use layers['counts'] when present, otherwise raw counts in .X, for both expression and the library-size offset.

  • Optional R permutation tests now use permutation_pool = "non_target". run_ripple() supplies cell identities automatically; direct calls to run_permutation_test() or run_permutation_tests() require target_mask_all, aligned with coords_all. Use permutation_pool = "all" for the previous full-cell-pool null. The GPU script also defaults to non-target candidates; select its previous pool with --permutation-pool all or PERMUTATION_POOL=all. GPU outputs record the pool, and imported results without a label are marked "unspecified". Standalone R analysis scripts still use the full pool.

  • For check_spatial_autocorrelation(), pass the same input subset, k_neighbors, max_distance_um, and sample settings used for the main analysis. Its separate k argument controls the Moran neighbor graph. A small Moran's I does not establish independence.

  • Confounder fits with indistinguishable distance predictors are excluded and recorded as fit_status = "rank_deficient". Check stage2_n_rank_deficient; fewer than two valid fits yields no_stage2_result.


Input expectations

Component Expected
Counts Raw integer counts in assays(spe, "counts") for a SpatialExperiment, or obj[["RNA"]]$counts for Seurat. The Poisson model handles normalization internally via the offset. Pre-normalized data will produce incorrect results.
Spatial coordinates spatialCoords() for SpatialExperiment, or X/Y columns in cell metadata for Seurat/SCE. Common column names (x_centroid/y_centroid, spatial_x/spatial_y, x/y) are auto-detected; override via x_column / y_column.
Cell types Metadata column named by celltype_column, containing the query population.
Replicate ID Metadata column named by sample_column (default "sample_id"). Combination requires at least two valid samples per gene; use more biological replicates where possible.
Condition (optional) Metadata column named by condition_column, with the target value in condition_value.

Output structure

run_ripple() writes to {output_dir}/{analysis_name}/ (with a _k{n} suffix if k_neighbors > 1):

{output_dir}/{analysis_name}/
  per_celltype/
    {CellType}/
      meta_analysis_results.csv   # Per-gene meta-analysis (coefficient, FDR)
      coef_per_sample.csv         # Per-sample GLM coefficients
      gradient_scores.csv         # Gradient magnitude scores
      decay_classification.csv    # Decay pattern classification
      gradient_volcano.pdf        # Per-celltype volcano
      coefficient_strips.pdf      # Per-sample coefficient strips
      forest_plots/               # Per-gene forest plots
  summary/
    all_genes_results.csv         # Merged across all cell types
    top_gradient_genes.csv        # Top 50 per cell type by FDR
    decay_pattern_summary.csv     # Decay pattern counts
  qc/
    distance_distribution.pdf     # QC: distance-to-query distribution

run_ripple_atlas() adds a plots/ (or atlas/) subdirectory with multi-panel figures and fGSEA output.


Recommended QC workflow

After running run_ripple() and merge_ripple_results(), run the specificity check before interpreting individual genes:

# 1. Classify gene specificity. Do this FIRST.
specificity <- classify_gene_specificity(all_results, fdr_threshold = 0.05)
table(specificity$specificity_class)
#   broad  moderate  specific
#      68       187       412
# Three classes: "specific" (1 cell type), "moderate" (2 up to
# broad_threshold-1), and "broad" (>= broad_threshold cell types).
# broad_threshold (default 4) is the single boundary - raise it to flag
# fewer genes as broad. See ?classify_gene_specificity.

# 2. Pull the broad-class candidates aside.
broad_genes <- specificity[specificity_class == "broad"]$gene
clean_results <- all_results[!gene %in% broad_genes]

# 3. THEN look at individual genes.
plot_gradient_volcano(clean_results[cell_type == "CD8_T"], query_label = "Tumor")

Genes flagged as broad are significant in many cell types at once. Common reasons: ambient RNA (query transcripts leaking into neighbouring cells), housekeeping genes, or real shared biology (cytokines, MHC II, stress programs). The flag is a heuristic, not a verdict. You decide what is actually contamination using domain knowledge. The getting_started vignette walks through the full curation workflow.

For a one-glance summary of the whole run, ripple_plot_qc() builds a multi-panel QC dashboard from the artefacts run_ripple() writes:

ripple_plot_qc(
  results_dir           = "ripple_output/tumor_ripple",
  query_signature_genes = c("EPCAM", "KRT8", "KRT19"),  # your query markers
  query_label           = "Tumor"
)

For k-selection, use plot_k_diagnostics() before the full pipeline:

plot_k_diagnostics(my_spe, query_celltype = "Tumor", celltype_column = "cell_type")

RIPPLE was developed with Claude Opus 4.6/8 on Claude Code by Anthropic.


Citation

If you use RIPPLE, please cite the preprint:

Mangana C, Maier BB (2026). RIPPLE: replicate-aware detection of cell-type-anchored proximity gradients in spatial transcriptomics. bioRxiv, doi: 10.64898/2026.07.23.740288.

A machine-readable bibentry ships in inst/CITATION and can be retrieved with citation("ripple").

About

RIPPLE - Spatial transcriptomics analysis of a cell's influence on its neighbors' transcriptome

Resources

Stars

41 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages