if (!require("BiocManager"))
install.packages("BiocManager")
BiocManager::install("MultiAssaySpatialExperiment")Load the package:
If you use MultiAssaySpatialExperiment in your research,
please cite the package. Citation information is available via:
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.
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: presentThe 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 S4This 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 S2Both 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:
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 rightspatialMap 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 22Because 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] TRUEConverting 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 26.04 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [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: Etc/UTC
#> 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] codetools_0.2-20 htmltools_0.5.9 sys_3.4.3
#> [22] buildtools_1.0.0 class_7.3-24 sass_0.4.10
#> [25] yaml_2.3.12 pillar_1.11.1 jquerylib_0.1.4
#> [28] classInt_0.4-11 DelayedArray_0.39.6 cachem_1.1.0
#> [31] magick_2.9.1 abind_1.4-8 tidyselect_1.2.1
#> [34] digest_0.6.39 dplyr_1.2.1 maketools_1.3.2
#> [37] fastmap_1.2.0 grid_4.6.1 cli_3.6.6
#> [40] SparseArray_1.13.2 magrittr_2.0.5 S4Arrays_1.13.0
#> [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