Replicate-Aware Inference of Paracrine Profiles via Likelihood Estimation
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)
"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!
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.
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.
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()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 artefactsrun_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, thefind_ambient_family_genes()blocklist for Ig / J-chain / ribosomal / mitochondrial, and a user-suppliedquery_signature_geneslist. None of these is a contamination measurement. You decide using domain knowledge.plot_k_diagnostics()helps pickk_neighborsbefore 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()andload_metadata_only()give fast metadata access without loading the full expression matrix.
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 atk_neighbors = 1, or mean distance to the k nearest query cells. Distances are capped atmax_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.
-
GPU permutation tests read
median_coeffrom package results. Legacy results require the accompanyingcoef_per_sample.csvto reconstruct observed medians. Poisson fits uselayers['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 torun_permutation_test()orrun_permutation_tests()requiretarget_mask_all, aligned withcoords_all. Usepermutation_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 allorPERMUTATION_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 separatekargument 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". Checkstage2_n_rank_deficient; fewer than two valid fits yieldsno_stage2_result.
| 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. |
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.
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.
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").