MultiAssaySpatialExperiment 0.99.12
if (!require("BiocManager"))
install.packages("BiocManager")
BiocManager::install("MultiAssaySpatialExperiment")
Load the package:
library(MultiAssaySpatialExperiment)
library(SummarizedExperiment)
library(S4Vectors)
If you use MultiAssaySpatialExperiment in your research, please cite the
package. Citation information is available via:
citation("MultiAssaySpatialExperiment")
If you have spent a few decades designing databases, you have seen the same story many times: someone builds a system that works for one use case, and it spreads. Later, a new use case appears that does not quite fit. People bolt on workarounds, and the design gets messy. Eventually, someone steps back and asks: what would we build if we started from the problem we actually have?
Spatial omics is at that stage. We already have excellent tools for
single-assay spatial data: SpatialExperiment and SpatialFeatureExperiment
handle one experiment at a time, one technology, one matrix, one set of spatial
coordinates. They work well when your question is “what genes are expressed in
this Visium section?” or “where are these MERFISH cells in tissue?” But
increasingly, people ask questions that cut across assays: “I have RNA, protein,
and chromatin from the same tissue, how do I link them and their spatial
coordinates in one place?” That is a different problem. It needs a multi-assay
structure that can carry spatial context and keep everything consistent when you
subset, or merge.
MultiAssayExperiment (MAE) solves the multi-assay part. It gives you a central
mapping table, the sampleMap, that links assay columns to specimens. One source
of truth, explicit foreign keys, and validity checks that keep the links
correct. When you subset by specimen, the maps update. No orphaned rows. No
hunting through metadata to figure out which assay column belongs to which
specimen.
What MAE does not have is spatial geometry: coordinates for cells or spots, cell
boundaries, tissue images. That is where MultiAssaySpatialExperiment (MASE)
comes in. MASE extends MAE with spatial elements, points, shapes, images,
labels, and a second central mapping table, the spatialMap, that links assay
columns to those elements. Same philosophy as MAE: one place to look, one place
to update, referential integrity enforced. Subsetting updates spatialMap (and,
for most column- and assay-level operations, linked spatial layers) so you do
not have to clean up coordinates or drop orphaned geometries manually.
Use MultiAssaySpatialExperiment when:
SpatialExperiment or
SpatialFeatureExperiment for downstream tools.This vignette assumes you are comfortable with SummarizedExperiment and, ideally,
MultiAssayExperiment: MASE is a MultiAssayExperiment with spatial slots added, and
the assay, colData and sampleMap machinery behaves exactly as it does there.
No prior sf experience is needed to read this vignette. sf appears in one place
only, the shapes slot, whose geometries are stored in an sf geometry column so that
polygon operations (point-in-polygon, area, intersection) come from a standard spatial
package rather than a bespoke one. If you only work with point coordinates, images or
labels, you can use MASE without touching sf at all. When you do reach for shapes,
the sf vignettes are the reference.
The rest of this vignette follows one small example from construction to a
per-cell summary, building the object by hand, argument by argument, so that
every piece the constructor expects is visible. Real data rarely arrives this
way: if you already have Xenium, Visium, CosMx or MERSCOPE output, the
read*() functions build a MASE directly from platform files, and the
MultiAssaySpatialExperiment use cases vignette runs this same workflow on
that real output instead.
The scenario is the one MASE exists for: two assays measured over the same piece of tissue, sharing one set of cell boundaries.
library(MultiAssaySpatialExperiment)
library(SummarizedExperiment)
library(S4Vectors)
library(sf)
Two specimens, P1 and P2, contribute four observations between them. Both an
RNA and a protein assay were run on those same four observations.
make_assay <- function(seed) {
set.seed(seed)
SummarizedExperiment(
assays = list(counts = matrix(rpois(20, 10), nrow = 5, ncol = 4,
dimnames = list(paste0("Gene", 1:5), paste0("S", 1:4)))))
}
rna <- make_assay(1)
protein <- make_assay(2)
The observations sit somewhere in the tissue, and each falls inside one of two cell boundaries:
centroids <- DataFrame(
x = c(1.5, 2.5, 4.5, 5.5),
y = c(1.5, 2.5, 4.5, 5.5),
instance_id = paste0("S", 1:4))
square <- function(x0, y0, side) {
st_polygon(list(cbind(
c(x0, x0 + side, x0 + side, x0, x0),
c(y0, y0, y0 + side, y0 + side, y0))))
}
cells <- DataFrame(
instance_id = c("left", "right"),
geometry = st_sfc(square(0, 0, 3), square(3, 3, 3)))
st_polygon() builds one polygon from a matrix of ring coordinates, and
st_sfc() collects one or more such geometries into a single list-column (an
sfc_POLYGON) that a DataFrame can hold like any other column. That is the
only sf machinery this vignette needs; everything downstream (point-in-polygon
joins, subsetting by a bounding box) works through this geometry column.
Three tables tie the pieces together. ExperimentList is a named list of the
assay SummarizedExperiments (rna, protein). colData holds one row per
specimen (P1, P2). sampleMap is the crosswalk between them: each row
reads “column colname of assay assay belongs to specimen primary.”
spatialMap is the same idea for geometry: each row reads “column colname
of assay assay sits at instance instance_id of the region layer.” Both
assays point at the same centroids layer, which is the part a single-assay
container cannot express:
observations <- paste0("S", 1:4)
specimens <- c("P1", "P1", "P2", "P2")
mase <- MultiAssaySpatialExperiment(
experiments = ExperimentList(rna = rna, protein = protein),
colData = DataFrame(row.names = c("P1", "P2")),
sampleMap = DataFrame(
assay = factor(rep(c("rna", "protein"), each = 4)),
primary = rep(specimens, 2),
colname = rep(observations, 2)),
points = PointsLayerList(centroids = centroids),
shapes = ShapesLayerList(cells = cells),
spatialMap = DataFrame(
assay = factor(rep(c("rna", "protein"), each = 4)),
colname = rep(observations, 2),
element_type = "points",
region = "centroids",
instance_id = rep(observations, 2)))
mase
#> A MultiAssaySpatialExperiment object of 2 listed
#> experiments with user-defined names and respective classes.
#> Containing an ExperimentList class object of length 2:
#> [1] rna: SummarizedExperiment with 5 rows and 4 columns
#> [2] protein: SummarizedExperiment with 5 rows and 4 columns
#> Functionality:
#> experiments() - obtain the ExperimentList instance
#> colData() - the primary/phenotype DataFrame
#> sampleMap() - the sample coordination DataFrame
#> `$`, `[`, `[[` - extract colData columns, subset, or experiment
#> *Format() - convert into a long or wide DataFrame
#> assays() - convert ExperimentList to a SimpleList of matrices
#> exportClass() - save data to flat files
#> Spatial elements:
#> spatialImages: 0 elements
#> spatialLabels: 0 elements
#> spatialPoints: 1 element
#> spatialShapes: 1 element
#> imgData: NULL
#> spatialMap: present
The MultiAssayExperiment accessors behave as they always do, and the spatial
slots have accessors of their own:
experiments(mase)
#> ExperimentList class object of length 2:
#> [1] rna: SummarizedExperiment with 5 rows and 4 columns
#> [2] protein: SummarizedExperiment with 5 rows and 4 columns
spatialPoints(mase)[["centroids"]]
#> DataFrame with 4 rows and 3 columns
#> x y instance_id
#> <numeric> <numeric> <character>
#> 1 1.5 1.5 S1
#> 2 2.5 2.5 S2
#> 3 4.5 4.5 S3
#> 4 5.5 5.5 S4
spatialShapes(mase)[["cells"]]
#> DataFrame with 2 rows and 2 columns
#> instance_id geometry
#> <character> <sfc_POLYGON>
#> 1 left list(c(0, 3, 3, 0, 0..
#> 2 right list(c(3, 6, 6, 3, 3..
S1 and S2 sit inside left; S3 and S4 sit inside right, the layout
the bounding-box subset and the annotation step below both act on.
spatialMap() is the table that ties the two together, one row per assay column:
head(spatialMap(mase), 4)
#> DataFrame with 4 rows and 5 columns
#> assay colname element_type region instance_id
#> <factor> <character> <character> <character> <character>
#> 1 rna S1 points centroids S1
#> 2 rna S2 points centroids S2
#> 3 rna S3 points centroids S3
#> 4 rna S4 points centroids S4
This is the property that motivates the container. Take a rectangle covering only the lower-left corner of the tissue:
corner <- subsetByBoundingBox(mase, xmin = 0, xmax = 3, ymin = 0, ymax = 3)
#> harmonizing input:
#> removing 4 sampleMap rows with 'colname' not in colnames of experiments
#> removing 1 colData rownames not in sampleMap 'primary'
vapply(experiments(corner), ncol, integer(1))
#> rna protein
#> 2 2
spatialPoints(corner)[["centroids"]]
#> DataFrame with 2 rows and 3 columns
#> x y instance_id
#> <numeric> <numeric> <character>
#> 1 1.5 1.5 S1
#> 2 2.5 2.5 S2
Both assays narrowed to the two observations inside the box, the points layer was
trimmed to match, and spatialMap was rewritten. Nothing was left dangling, and
no coordinate bookkeeping was needed.
Cutting on the specimen axis instead affects every assay, whereas passing a named list targets one:
vapply(experiments(mase[, "P1"]), ncol, integer(1))
#> rna protein
#> 2 2
vapply(experiments(mase[, list(rna = c("S1", "S3"))]), ncol, integer(1))
#> harmonizing input:
#> removing 2 sampleMap rows with 'colname' not in colnames of experiments
#> rna protein
#> 2 4
annotateWithRegions() runs the point-in-polygon join between a points layer
and a shapes layer, and stores the answer as a new spatialMap column named
after the shapes layer, here cells:
mase <- annotateWithRegions(mase, points = "centroids", shapes = "cells")
spatialMap(mase)
#> DataFrame with 8 rows and 6 columns
#> assay colname element_type region instance_id cells
#> <factor> <character> <character> <character> <character> <character>
#> 1 rna S1 points centroids S1 left
#> 2 rna S2 points centroids S2 left
#> 3 rna S3 points centroids S3 right
#> 4 rna S4 points centroids S4 right
#> 5 protein S1 points centroids S1 left
#> 6 protein S2 points centroids S2 left
#> 7 protein S3 points centroids S3 right
#> 8 protein S4 points centroids S4 right
spatialMap now has two layer-related columns, and they mean different
things. region was set when the object was built; it names the points
layer each row’s instance_id comes from (centroids). The new cells
column is the annotation annotateWithRegions() just added; it holds, for
each observation, the instance_id of the shape (left or right) its
point fell inside.
aggregateByRegion() then summarises the assays over that annotation, turning
observation-level counts into per-shape ones:
aggregateByRegion(mase, by = "cells", FUN = "sum")
#> $rna
#> left right
#> Gene1 20 20
#> Gene2 21 13
#> Gene3 16 20
#> Gene4 25 22
#> Gene5 25 17
#>
#> $protein
#> left right
#> Gene1 16 21
#> Gene2 22 17
#> Gene3 15 22
#> Gene4 21 18
#> Gene5 20 22
Because the annotation lives in spatialMap rather than in one assay’s metadata,
both assays were aggregated over the same geometry in a single call.
A single-assay slice can be converted to SpatialExperiment for tools written
against that class, and converted back:
library(SpatialExperiment)
spe <- SpatialExperiment(
assays = list(counts = matrix(rpois(20, 5), 5, 4,
dimnames = list(paste0("Gene", 1:5), paste0("S", 1:4)))),
colData = DataFrame(sample_id = rep("P1", 4), row.names = paste0("S", 1:4)),
spatialCoords = cbind(x = c(1.5, 2.5, 4.5, 5.5), y = c(1.5, 2.5, 4.5, 5.5)))
from_spe <- as(spe, "MultiAssaySpatialExperiment")
spatialPoints(from_spe)
#> PointsLayerList of length 1
#> [1] coordinates: DFrame (4 x 3)
identical(unname(spatialCoords(as(from_spe, "SpatialExperiment"))),
unname(spatialCoords(spe)))
#> [1] TRUE
Converting to SpatialExperiment or SpatialFeatureExperiment requires exactly
one compatible assay, since neither class has a notion of several assays over
shared geometry.
That example touched every part of the object. The remaining vignettes go deeper:
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.4 LTS
#>
#> Matrix products: default
#> BLAS: /home/biocbuild/bbs-3.24-bioc/R/lib/libRblas.so
#> LAPACK: /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3.12.0 LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_GB LC_COLLATE=C
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: America/New_York
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats4 stats graphics grDevices utils datasets methods
#> [8] base
#>
#> other attached packages:
#> [1] SpatialExperiment_1.23.0 ggplot2_4.0.3
#> [3] sf_1.1-2 SingleCellExperiment_1.35.2
#> [5] MultiAssaySpatialExperiment_0.99.12 MultiAssayExperiment_1.39.1
#> [7] SummarizedExperiment_1.43.0 Biobase_2.73.2
#> [9] GenomicRanges_1.65.4 Seqinfo_1.3.2
#> [11] IRanges_2.47.5 S4Vectors_0.51.9
#> [13] BiocGenerics_0.59.12 generics_0.1.4
#> [15] MatrixGenerics_1.25.0 matrixStats_1.5.0
#> [17] BiocStyle_2.41.0
#>
#> loaded via a namespace (and not attached):
#> [1] gtable_0.3.6 rjson_0.2.23 xfun_0.60
#> [4] bslib_0.12.0 lattice_0.23-1 vctrs_0.7.3
#> [7] tools_4.6.1 tibble_3.3.1 proxy_0.4-29
#> [10] BiocBaseUtils_1.15.1 pkgconfig_2.0.3 Matrix_1.7-6
#> [13] KernSmooth_2.23-27 RColorBrewer_1.1-3 S7_0.2.2
#> [16] lifecycle_1.0.5 compiler_4.6.1 farver_2.1.2
#> [19] tinytex_0.60 codetools_0.2-20 htmltools_0.5.9
#> [22] class_7.3-24 sass_0.4.10 yaml_2.3.12
#> [25] pillar_1.11.1 jquerylib_0.1.4 classInt_0.4-11
#> [28] DelayedArray_0.39.6 cachem_1.1.0 magick_2.9.1
#> [31] abind_1.4-8 tidyselect_1.2.1 digest_0.6.39
#> [34] dplyr_1.2.1 bookdown_0.48 fastmap_1.2.0
#> [37] grid_4.6.1 cli_3.6.6 SparseArray_1.13.2
#> [40] magrittr_2.0.5 S4Arrays_1.13.0 dichromat_2.0-1
#> [43] e1071_1.7-17 withr_3.0.3 scales_1.4.0
#> [46] rmarkdown_2.32 XVector_0.53.0 otel_0.2.0
#> [49] evaluate_1.0.5 knitr_1.51 rlang_1.3.0
#> [52] Rcpp_1.1.2 glue_1.8.1 DBI_1.3.0
#> [55] BiocManager_1.30.27 jsonlite_2.0.0 R6_2.6.1
#> [58] units_1.0-1