| Title: | Spatial Multi-Cellular Motifs |
|---|---|
| Description: | Discovery and statistical evaluation of recurrent multi-cellular motifs (size 2, 3, and 4) in spatial omics data. Provides a lightweight 'SpatialGraph' representation backed by parquet, enumeration over igraph, spatially-aware null models, motif-to-gene association, and cross-region or cross-sample comparison. Designed to be framework-agnostic so that the core can be folded into Giotto Suite later. |
| Authors: | Ruben Dries [aut, cre] |
| Maintainer: | Ruben Dries <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 0.2.0 |
| Built: | 2026-10-15 23:15:15 UTC |
| Source: | https://github.com/drieslab/smotif |
SpatialGraph
Builds an undirected igraph graph with one vertex per node and one
edge per row in sg$edges. Vertex attributes mirror sg$nodes columns;
edge attributes mirror non-endpoint columns of sg$edges. The graph is
memoized inside sg$.cache$igraph and reused on subsequent calls — the
cache is keyed on the row-counts of nodes/edges so it auto-invalidates
across subsets.
as_igraph(sg)as_igraph(sg)
sg |
a |
an igraph::igraph object.
sg <- SpatialGraph( data.frame(cell_id = c("a","b"), x = 0:1, y = 0, cell_type = "T", sample_id = "s1", stringsAsFactors = FALSE), data.frame(source = "a", target = "b", sample_id = "s1", stringsAsFactors = FALSE) ) g <- as_igraph(sg) igraph::vcount(g)sg <- SpatialGraph( data.frame(cell_id = c("a","b"), x = 0:1, y = 0, cell_type = "T", sample_id = "s1", stringsAsFactors = FALSE), data.frame(source = "a", target = "b", sample_id = "s1", stringsAsFactors = FALSE) ) g <- as_igraph(sg) igraph::vcount(g)
For each motif (or motif × orbit), test whether genes are differentially expressed between cells participating in that motif/orbit ("in") and the rest of the dataset ("out"). Two methods are supported:
associate_genes( motifs, expression, sg, by = c("motif", "orbit"), method = c("wilcoxon", "module_score"), modules = NULL, min_cells = 10L, min_in_expressed = 3L, pooling = c("pooled", "per_sample") )associate_genes( motifs, expression, sg, by = c("motif", "orbit"), method = c("wilcoxon", "module_score"), modules = NULL, min_cells = 10L, min_in_expressed = 3L, pooling = c("pooled", "per_sample") )
motifs |
a |
expression |
matrix or |
sg |
the |
by |
|
method |
|
modules |
named list of gene sets; required when
|
min_cells |
motifs/orbits with fewer than this many unique cells
in the "in" set are skipped (per-sample under |
min_in_expressed |
for |
pooling |
|
"wilcoxon": rank-sum test per gene with tie correction. The
full per-cell rank matrix is computed once and reused across motifs.
Genes are filtered by min_in_expressed (number of "in" cells with
non-zero expression).
"module_score": per-cell scores are computed as the mean of
row-standardized (z-scored) expression over each gene set, then
compared between motif and background using the same rank-sum
framework.
Two pooling modes are supported:
pooling = "pooled" (default): all cells across all samples
contribute to a single in/out test per motif. Fastest; appropriate
for single-sample data or when sample-level heterogeneity is not
of interest.
pooling = "per_sample": each sample is tested independently
and per-sample statistics are combined via a Stouffer-weighted Z
meta-analysis (weights = sqrt(n_in) per sample). The output
reports n_samples_tested, mean_log2FC and sd_log2FC so
between-sample heterogeneity is visible: a gene with a large
z_combined and a tiny sd_log2FC reproduces across samples; a
gene with a large z_combined and large sd_log2FC is being
driven by one sample.
a data.table. Columns under pooling = "pooled":
motif_id, orbit_id, gene, n_in, n_out, n_in_expressed, mean_in, mean_out, log2FC, U, z, p, p_adj_BH. Under
pooling = "per_sample": motif_id, orbit_id, gene, n_samples_tested, n_in_total, n_out_total, mean_log2FC, sd_log2FC, z_combined, p_combined, p_adj_BH.
set.seed(1) coords <- cbind(x = runif(60), y = runif(60)) types <- sample(c("T", "B"), 60, TRUE) sg <- build_spatial_graph(coords, types, "s1", method = "knn", k = 4L) m <- find_motifs(sg, size = 3L) expr <- Matrix::Matrix( matrix(stats::rpois(60 * 50, 1), nrow = 50), sparse = TRUE ) rownames(expr) <- paste0("g", 1:50) colnames(expr) <- sg$nodes$cell_id associate_genes(m, expr, sg, min_cells = 8L)[1:5]set.seed(1) coords <- cbind(x = runif(60), y = runif(60)) types <- sample(c("T", "B"), 60, TRUE) sg <- build_spatial_graph(coords, types, "s1", method = "knn", k = 4L) m <- find_motifs(sg, size = 3L) expr <- Matrix::Matrix( matrix(stats::rpois(60 * 50, 1), nrow = 50), sparse = TRUE ) rownames(expr) <- paste0("g", 1:50) colnames(expr) <- sg$nodes$cell_id associate_genes(m, expr, sg, min_cells = 8L)[1:5]
SpatialGraph from coordinatesBuild a SpatialGraph over a set of cells given their spatial coordinates,
cell types, and sample id, using one of three connectivity rules:
"knn" (default): each cell connects to its k nearest neighbors.
Edges are deduplicated and stored undirected, so the resulting degree
can exceed k (a cell may be in another's k-NN without the converse).
"delaunay": edges from the 2D Delaunay triangulation (xy plane).
"radius": edges between every pair within radius of each other.
build_spatial_graph( coords, cell_types, sample_id, region_id = NULL, method = c("knn", "delaunay", "radius"), k = 6L, radius = NULL, max_distance = NULL, cell_id = NULL )build_spatial_graph( coords, cell_types, sample_id, region_id = NULL, method = c("knn", "delaunay", "radius"), k = 6L, radius = NULL, max_distance = NULL, cell_id = NULL )
coords |
numeric matrix or |
cell_types |
character vector aligned with rows of |
sample_id |
character; recycled to length |
region_id |
optional character vector aligned with rows of |
method |
one of |
k |
integer; neighbors when |
radius |
numeric; cutoff when |
max_distance |
optional numeric cap applied after edge construction. |
cell_id |
optional character vector of cell ids; if missing, ids
|
Implementations rely on lightweight optional dependencies (FNN, deldir,
dbscan); install only the one(s) you need. An optional max_distance
cap is applied after edge construction.
a SpatialGraph.
set.seed(1) coords <- cbind(x = runif(20), y = runif(20)) sg <- build_spatial_graph( coords, cell_types = sample(c("T","B"), 20, TRUE), sample_id = "s1", method = "knn", k = 3 ) nrow(sg$edges)set.seed(1) coords <- cbind(x = runif(20), y = runif(20)) sg <- build_spatial_graph( coords, cell_types = sample(c("T","B"), 20, TRUE), sample_id = "s1", method = "knn", k = 3 ) nrow(sg$edges)
Pools per-group motif counts into a wide table and tests, for each motif, whether its count distribution across groups departs from the overall distribution of motif instances across groups (chi-square test for independence).
compare_motifs( motifs_list, group = NULL, design = NULL, method = c("wilcoxon", "negbin") )compare_motifs( motifs_list, group = NULL, design = NULL, method = c("wilcoxon", "negbin") )
motifs_list |
named list of |
group |
optional character vector of length |
design |
reserved for v0.2 covariate adjustment; ignored in v0.1. |
method |
|
If multiple Motifs objects are assigned to the same group via the
group argument, their counts are summed within the group — useful
for replicates. Motifs absent from a group contribute zero to that
column.
v0.1 uses a chi-square statistic for any number of groups. The
method argument is reserved for future per-replicate tests
("wilcoxon" rank-sum, "negbin" count regression); both currently
fall through to chi-square with a warning. design is reserved for
covariate adjustment in v0.2.
a data.table with one row per motif present in at least one
group: motif_id, size, canonical_iso_class, color_tuple, count_<group_1>...count_<group_n>, total, chi2, df, p, p_adj_BH.
set.seed(1) coords <- cbind(x = runif(40), y = runif(40)) types <- sample(c("T","B"), 40, TRUE) sg <- build_spatial_graph(coords, types, "s1", method = "knn", k = 4L, region_id = sample(c("a","b"), 40, TRUE)) sa <- subset_region(sg, region_id == "a") sb <- subset_region(sg, region_id == "b") ma <- find_motifs(sa, size = 3L) mb <- find_motifs(sb, size = 3L) compare_motifs(list(a = ma, b = mb))[1:3]set.seed(1) coords <- cbind(x = runif(40), y = runif(40)) types <- sample(c("T","B"), 40, TRUE) sg <- build_spatial_graph(coords, types, "s1", method = "knn", k = 4L, region_id = sample(c("a","b"), 40, TRUE)) sa <- subset_region(sg, region_id == "a") sb <- subset_region(sg, region_id == "b") ma <- find_motifs(sa, size = 3L) mb <- find_motifs(sb, size = 3L) compare_motifs(list(a = ma, b = mb))[1:3]
SpatialGraph
Enumerate all motifs of a given size in sg, returning a Motifs
S3 object. v0.1 supports sizes 2 and 3 (size 4 lands in step 8). All
connected sub-graphs of that size are enumerated; the resulting catalog
distinguishes topology classes (e.g., closed triangle vs open wedge for
size 3) via the canonical_iso_class column.
find_motifs( sg, size = 3L, backend = "igraph", colored = TRUE, anchored_on = NULL, max_instances = NULL )find_motifs( sg, size = 3L, backend = "igraph", colored = TRUE, anchored_on = NULL, max_instances = NULL )
sg |
a |
size |
integer; one of 2, 3 (4 in v0.2). |
backend |
|
colored |
logical; if |
anchored_on |
optional character vector of |
max_instances |
optional integer cap on enumerated instances per
motif. If exceeded, a deterministic random sample of size |
When colored = TRUE (default), motifs are further keyed by the sorted
tuple of cell-type colors per orbit, so e.g. a T-T-B triangle is a
different motif from a T-B-B triangle. The canonical labeling sorts
vertices by (degree-in-motif, color), which is exact for sizes 2 and
3 combined with topology class.
a Motifs object — a list with catalog, incidence, meta.
set.seed(1) coords <- cbind(x = runif(20), y = runif(20)) sg <- build_spatial_graph( coords, cell_types = sample(c("T","B"), 20, TRUE), sample_id = "s1", method = "knn", k = 3 ) m <- find_motifs(sg, size = 2) m$catalogset.seed(1) coords <- cbind(x = runif(20), y = runif(20)) sg <- build_spatial_graph( coords, cell_types = sample(c("T","B"), 20, TRUE), sample_id = "s1", method = "knn", k = 3 ) m <- find_motifs(sg, size = 2) m$catalog
Enumerates every connected induced subgraph of the requested size and tests each colored motif class against a permutation null. Enumeration happens once; each permutation is a relabel-and-count over the fixed instance set.
motif_enrichment( sg, size = 3L, n_perm = 1000L, seed = 1L, null = c("label", "stratified", "conditional"), strata_column = NULL, anchored_on = NULL, backend = c("auto", "rust", "r"), ... )motif_enrichment( sg, size = 3L, n_perm = 1000L, seed = 1L, null = c("label", "stratified", "conditional"), strata_column = NULL, anchored_on = NULL, backend = c("auto", "rust", "r"), ... )
sg |
|
size |
motif size: 2, 3 or 4. |
n_perm |
number of null draws. |
seed |
integer seed. |
null |
which null to test against. |
strata_column |
name of a column on |
anchored_on |
optional character vector of |
backend |
|
... |
passed to the backend (e.g. |
a data.table, one row per motif class: motif_id, topology,
size, color_tuple, observed, expected, sd_null, z, fold,
p_enrich, p_deplete, p_adj.
set.seed(1) coords <- cbind(x = runif(60), y = runif(60)) sg <- build_spatial_graph(coords, cell_types = sample(c("T", "B"), 60, TRUE), sample_id = "s1", method = "knn", k = 4 ) motif_enrichment(sg, size = 3L, n_perm = 99L)set.seed(1) coords <- cbind(x = runif(60), y = runif(60)) sg <- build_spatial_graph(coords, cell_types = sample(c("T", "B"), 60, TRUE), sample_id = "s1", method = "knn", k = 4 ) motif_enrichment(sg, size = 3L, n_perm = 99L)
Runs enrichment against a GiottoDisk parquetEdgeStore without building the
graph in R. Useful when the network is large enough that materializing an
igraph is itself the expensive step.
motif_enrichment_store( nodes_path, edges_path, cell_type, size = 3L, n_perm = 1000L, seed = 1L, null = c("label", "conditional"), ... )motif_enrichment_store( nodes_path, edges_path, cell_type, size = 3L, n_perm = 1000L, seed = 1L, null = c("label", "conditional"), ... )
nodes_path, edges_path
|
paths to the store's |
cell_type |
labels, one per node, in sidecar |
size, n_perm, seed, null, ...
|
passed through to the backend. |
Node ids are already integers in that format, so no string hashing happens
on either side. Cell type labels are supplied aligned to the store's
sidecar order, which smotifrs::edge_store_nodes() returns.
a data.table in the same shape as motif_enrichment().
Runs enrichment against edges pulled from an Arrow stream, so whatever produced the stream decides the edge set – a pending subset on a disk-backed store is honoured without the store's files being read here. The network's nodes are the stream's endpoints.
motif_enrichment_stream( edges, int_ids, cell_type, size = 3L, n_perm = 1000L, seed = 1L, null = c("label", "conditional"), ... )motif_enrichment_stream( edges, int_ids, cell_type, size = 3L, n_perm = 1000L, seed = 1L, null = c("label", "conditional"), ... )
edges |
anything |
int_ids |
integer node ids keying the label lookup, without duplicates. |
cell_type |
labels, one per entry of |
size, n_perm, seed, null, ...
|
passed through to the backend. |
Labels travel beside the stream as a lookup keyed by integer node id. Mapping cell IDs to those integers is the caller's business; the lookup may cover nodes the stream never reaches, but every endpoint needs a label.
a data.table in the same shape as motif_enrichment().
Pick the motifs worth looking at from motif_enrichment() first, then ask
where they are. Instances are returned for named classes only, so the output
is bounded by those classes' counts rather than by the whole enumeration –
at size 4 the full instance set is tens of millions of rows on a real
dataset, which is why it is never returned by default.
motif_instances(sg, motif_ids, size = 3L, max_per_class = 5000)motif_instances(sg, motif_ids, size = 3L, max_per_class = 5000)
sg |
|
motif_ids |
character vector of |
size |
motif size the ids came from. |
max_per_class |
cap on instances per class; |
a data.table with motif_id, instance, slot (structural
position within the motif) and cell_id, in long form.
set.seed(1) sg <- build_spatial_graph(cbind(x = runif(50), y = runif(50)), cell_types = sample(c("T", "B"), 50, TRUE), sample_id = "s1", method = "knn", k = 4 ) e <- motif_enrichment(sg, size = 3L, n_perm = 49L) motif_instances(sg, e$motif_id[1], size = 3L)set.seed(1) sg <- build_spatial_graph(cbind(x = runif(50), y = runif(50)), cell_types = sample(c("T", "B"), 50, TRUE), sample_id = "s1", method = "knn", k = 4 ) e <- motif_enrichment(sg, size = 3L, n_perm = 49L) motif_instances(sg, e$motif_id[1], size = 3L)
Motifs objectInternal-style constructor used by find_motifs() to assemble its
return value. Kept exported because tests and downstream code may
build Motifs objects from custom enumerators.
Motifs(catalog, incidence, instance_meta = NULL, meta = list()) ## S3 method for class 'Motifs' print(x, ...)Motifs(catalog, incidence, instance_meta = NULL, meta = list()) ## S3 method for class 'Motifs' print(x, ...)
catalog |
a |
incidence |
a |
instance_meta |
optional named list of per-topology-class wide
instance tables, used by |
meta |
a named list ( |
x |
a |
... |
ignored. |
a Motifs S3 object.
Returns a ggplot2 object (requires the optional ggplot2 Suggests).
Bars are sorted by count, descending. For colored motifs the labels
include the color tuple, which can get long — a default truncation
keeps display readable.
## S3 method for class 'Motifs' plot(x, top = 20L, max_label_chars = 60L, ...)## S3 method for class 'Motifs' plot(x, top = 20L, max_label_chars = 60L, ...)
x |
a |
top |
integer; number of top motifs to plot. |
max_label_chars |
integer; truncate |
... |
ignored. |
a ggplot object.
SpatialGraph from parquetReads nodes.parquet and edges.parquet, validates the schemas, and
returns a SpatialGraph. If a sidecar <nodes_path>.meta.json exists,
it is loaded into sg$meta; otherwise meta records the source paths
so a round-trip is reproducible.
read_spatial_graph(nodes_path, edges_path)read_spatial_graph(nodes_path, edges_path)
nodes_path |
path to a |
edges_path |
path to an |
a SpatialGraph.
## Not run: sg <- read_spatial_graph("nodes.parquet", "edges.parquet") ## End(Not run)## Not run: sg <- read_spatial_graph("nodes.parquet", "edges.parquet") ## End(Not run)
SpatialGraph
Validates schemas, normalizes column types, ensures undirected edges are
canonical (source < target), and returns an S3 object of class
SpatialGraph. The igraph view is built lazily by as_igraph().
SpatialGraph(nodes, edges, meta = list()) ## S3 method for class 'SpatialGraph' print(x, ...)SpatialGraph(nodes, edges, meta = list()) ## S3 method for class 'SpatialGraph' print(x, ...)
nodes |
a |
edges |
a |
meta |
named list of graph-construction metadata. |
x |
a |
... |
ignored. |
a SpatialGraph.
x invisibly (for print).
nodes <- data.frame( cell_id = c("a","b","c","d"), x = c(0,1,2,3), y = c(0,0,1,1), cell_type = c("T","B","T","B"), sample_id = "s1", stringsAsFactors = FALSE ) edges <- data.frame( source = c("a","b","c"), target = c("b","c","d"), distance = c(1, sqrt(2), 1), sample_id = "s1", stringsAsFactors = FALSE ) sg <- SpatialGraph(nodes, edges, meta = list(build_method = "manual"))nodes <- data.frame( cell_id = c("a","b","c","d"), x = c(0,1,2,3), y = c(0,0,1,1), cell_type = c("T","B","T","B"), sample_id = "s1", stringsAsFactors = FALSE ) edges <- data.frame( source = c("a","b","c"), target = c("b","c","d"), distance = c(1, sqrt(2), 1), sample_id = "s1", stringsAsFactors = FALSE ) sg <- SpatialGraph(nodes, edges, meta = list(build_method = "manual"))
The order motif_enrichment_store() expects cell_type in.
store_node_order(nodes_path)store_node_order(nodes_path)
nodes_path |
path to the store's |
a data.table with node_id and int_id.
SpatialGraph by a node-column expressionThese wrap a non-standard-evaluation filter on sg$nodes. After filtering
nodes, edges with at least one endpoint outside the surviving set are
dropped. The igraph cache is invalidated.
subset_region(sg, expr) subset_sample(sg, expr)subset_region(sg, expr) subset_sample(sg, expr)
sg |
a |
expr |
an unquoted expression evaluated against |
a new SpatialGraph.
nodes <- data.frame( cell_id = letters[1:5], x = 0:4, y = 0, cell_type = "T", sample_id = "s1", region_id = c("r1","r1","r2","r2","r2"), stringsAsFactors = FALSE ) edges <- data.frame( source = c("a","b","c","d"), target = c("b","c","d","e"), sample_id = "s1", stringsAsFactors = FALSE ) sg <- SpatialGraph(nodes, edges) sg1 <- subset_region(sg, region_id == "r1") nrow(sg1$nodes) # 2nodes <- data.frame( cell_id = letters[1:5], x = 0:4, y = 0, cell_type = "T", sample_id = "s1", region_id = c("r1","r1","r2","r2","r2"), stringsAsFactors = FALSE ) edges <- data.frame( source = c("a","b","c","d"), target = c("b","c","d","e"), sample_id = "s1", stringsAsFactors = FALSE ) sg <- SpatialGraph(nodes, edges) sg1 <- subset_region(sg, region_id == "r1") nrow(sg1$nodes) # 2
Compares observed per-motif counts against a permutation null
distribution and returns a data.table of test statistics. Three null
models are supported (see null_models):
test_motif_enrichment( motifs, sg, null = c("label_permutation", "region_stratified", "geometric_jitter"), n_perm = 1000L, seed = 1L, parallel = FALSE, null_args = list() )test_motif_enrichment( motifs, sg, null = c("label_permutation", "region_stratified", "geometric_jitter"), n_perm = 1000L, seed = 1L, parallel = FALSE, null_args = list() )
motifs |
a |
sg |
the |
null |
one of |
n_perm |
integer; number of permutations. |
seed |
integer; RNG seed for reproducibility. |
parallel |
logical; future hook — v0.1 ignores and runs serially. |
null_args |
optional list of arguments specific to the chosen
null. Currently used only by |
"label_permutation": shuffle cell_type across all nodes;
graph topology stays fixed.
"region_stratified": shuffle cell_type within each
region_id; preserves per-region cell-type composition. Requires
sg$nodes$region_id.
"geometric_jitter": perturb coordinates by Gaussian noise of
standard deviation null_args$sd, rebuild the graph using the
original build_spatial_graph parameters, recount. Requires
sg$meta$build_method to be one of "knn", "delaunay",
"radius".
Empirical p-values use the unbiased "+1" estimator
so the
result is in (0, 1] for finite n_perm. The fold change is
observed / max(mean_null, .Machine$double.eps). Adjustment for
multiple testing is BH on p_emp.
a data.table with columns
motif_id, observed, mean_null, sd_null, z, p_emp, p_adj_BH, fold.
set.seed(1) coords <- cbind(x = runif(40), y = runif(40)) sg <- build_spatial_graph( coords, cell_types = sample(c("T","B"), 40, TRUE), sample_id = "s1", method = "knn", k = 4 ) m <- find_motifs(sg, size = 3L) test_motif_enrichment(m, sg, null = "label_permutation", n_perm = 50L, seed = 1L)set.seed(1) coords <- cbind(x = runif(40), y = runif(40)) sg <- build_spatial_graph( coords, cell_types = sample(c("T","B"), 40, TRUE), sample_id = "s1", method = "knn", k = 4 ) m <- find_motifs(sg, size = 3L) test_motif_enrichment(m, sg, null = "label_permutation", n_perm = 50L, seed = 1L)
SpatialGraph to parquetWrites sg$nodes and sg$edges as separate parquet files. If
jsonlite is available, also writes <nodes_path>.meta.json so that
meta survives a round trip.
write_spatial_graph(sg, nodes_path, edges_path)write_spatial_graph(sg, nodes_path, edges_path)
sg |
a |
nodes_path |
output path for |
edges_path |
output path for |
sg, invisibly.
## Not run: write_spatial_graph(sg, "nodes.parquet", "edges.parquet") ## End(Not run)## Not run: write_spatial_graph(sg, "nodes.parquet", "edges.parquet") ## End(Not run)