--- title: "Introduction to MultiAssaySpatialExperiment" author: - name: Patrick Aboyoun email: aboyounp@gene.com affiliation: Genentech, Inc. date: "Compiled: `r BiocStyle::doc_date()`; Modified: 6 July 2026" package: MultiAssaySpatialExperiment output: BiocStyle::html_document: toc: true number_sections: true toc_depth: 3 toc_float: collapsed: true vignette: > %\VignetteIndexEntry{1. Introduction to MultiAssaySpatialExperiment} %\VignetteEncoding{UTF-8} %\VignetteEngine{knitr::rmarkdown} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", cache = TRUE ) ``` # Installation ```{r install, eval = FALSE} if (!require("BiocManager")) install.packages("BiocManager") BiocManager::install("MultiAssaySpatialExperiment") ``` Load the package: ```{r load_packages, message = FALSE, warning = FALSE} library(MultiAssaySpatialExperiment) library(SummarizedExperiment) library(S4Vectors) ``` # Citing MultiAssaySpatialExperiment If you use `MultiAssaySpatialExperiment` in your research, please cite the package. Citation information is available via: ```{r citation, eval = FALSE} citation("MultiAssaySpatialExperiment") ``` # Why another spatial data structure? 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. # When to use MASE Use `MultiAssaySpatialExperiment` when: - You have **multiple spatial assays** from the same or related tissue (e.g., transcriptomics, proteomics, ATAC-seq). - You need **one object** that holds the experiments, the specimen metadata, the spatial elements (coordinates, shapes, images), and the links between them. - You want **subsetting and merging** that keep all links consistent, no manual cleanup, no orphaned geometry. - You need to **convert** to or from `SpatialExperiment` or `SpatialFeatureExperiment` for downstream tools. # Prerequisites 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](https://r-spatial.github.io/sf/articles/) are the reference. # A worked example 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. ```{r libs, message = FALSE} library(MultiAssaySpatialExperiment) library(SummarizedExperiment) library(S4Vectors) library(sf) ``` ## Building the object Two specimens, `P1` and `P2`, contribute four observations between them. Both an RNA and a protein assay were run on those same four observations. ```{r build_assays} 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: ```{r build_spatial} 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 `SummarizedExperiment`s (`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: ```{r build_mase} 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 ``` ## Looking at what it holds The `MultiAssayExperiment` accessors behave as they always do, and the spatial slots have accessors of their own: ```{r accessors} experiments(mase) spatialPoints(mase)[["centroids"]] spatialShapes(mase)[["cells"]] ``` `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: ```{r show_spatialmap} head(spatialMap(mase), 4) ``` ## Subsetting keeps the links consistent This is the property that motivates the container. Take a rectangle covering only the lower-left corner of the tissue: ```{r subset_region} corner <- subsetByBoundingBox(mase, xmin = 0, xmax = 3, ymin = 0, ymax = 3) vapply(experiments(corner), ncol, integer(1)) spatialPoints(corner)[["centroids"]] ``` 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: ```{r subset_axes} vapply(experiments(mase[, "P1"]), ncol, integer(1)) vapply(experiments(mase[, list(rna = c("S1", "S3"))]), ncol, integer(1)) ``` ## Annotating and aggregating `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`: ```{r annotate} mase <- annotateWithRegions(mase, points = "centroids", shapes = "cells") spatialMap(mase) ``` `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: ```{r aggregate} aggregateByRegion(mase, by = "cells", FUN = "sum") ``` 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. ## Handing the data to other tools A single-assay slice can be converted to `SpatialExperiment` for tools written against that class, and converted back: ```{r coerce} 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) identical(unname(spatialCoords(as(from_spe, "SpatialExperiment"))), unname(spatialCoords(spe))) ``` Converting *to* `SpatialExperiment` or `SpatialFeatureExperiment` requires exactly one compatible assay, since neither class has a notion of several assays over shared geometry. # See also That example touched every part of the object. The remaining vignettes go deeper: - *Working with MultiAssaySpatialExperiment*: construction, subsetting, annotation and aggregation - *MultiAssaySpatialExperiment use cases*: platform readers and complete analysis workflows - *Design of MultiAssaySpatialExperiment*: slots, mapping tables and the relational schema - *MultiAssaySpatialExperiment cheatsheet*: one-page API reference # Session info ```{r sessionInfo} sessionInfo() ```