In this tutorial we walk through a typical spatial transcriptomics analysis using Bioconductor packages.
Spatial transcriptomics is a fast evolving set of technologies and we cannot cover all protocols here. However, we will show an example of spot-based protocols, namely 10X Genomics Visium, and an example of imaging-based methods, namely Nanostring CosMX. Note that this is an area of current development and some methods will likely be improved in the next months.
Most of the steps covered here, especially those for spot-based data, are described in the Best Practices ST book.
The Voyager Bioconductor package (Moses et al. 2023) also has an extensive set of tutorials that cover most of the currently available spatial transcriptomics technologies.
While not covered in this tutorial, there are packages and software tools for the analysis of spatial transcriptomics data outside of Bioconductor too. Popular tools include the Seurat R package, the Giotto R package and the SpatialData python package.
We will initially focus on 10X Genomics Visium. We start from the output of the Space Ranger preprocessing software. This is the 10X Genomics software suite that allows to pre-process the FASTQ files generated by the sequencing platform and perform alignment and quantification. We will perform exploratory data analysis (EDA) and quality control (QC). We will then cover normalization, the identification of spatially variable genes dimensionality reduction and cell type identification and .
We will use one sample of human brain from the dorsolateral prefrontal cortex (DLPFC) region, measured using the 10x Genomics Visium platform.
In the full dataset, there are 12 samples in total, from 3 individuals, with 2 pairs of spatially adjacent replicates (serial sections) per individual (4 samples per individual). The individuals and spatially adjacent replicates can be used as blocking factors. Each sample spans the six layers of the cortex plus white matter in a perpendicular tissue section. For the examples in this workflow we use a single sample from this dataset (sample 151673).
For more details on the dataset, see Maynard et al. (2021). The full dataset is publicly available through the spatialLIBD Bioconductor package.
SpatialExperiment is a S4 class that extends SingleCellExperiment and can be used for efficiently storing and working with spatial data in R/Bioconductor.
This class is itself extended by MoleculeExperiment and SpatialFeatureExperiment, which allow to more easily work with imaging-based data. For the moment, we will use SpatialExperiment.
A more thorough overview of SingleCellExperiment can be found in Righelli et al. (2022).
We start by loading the data.
library(SpatialExperiment)
library(STexampleData)
spe <- Visium_humanDLPFC()
spe
## class: SpatialExperiment
## dim: 33538 4992
## metadata(0):
## assays(1): counts
## rownames(33538): ENSG00000243485 ENSG00000237613 ... ENSG00000277475
## ENSG00000268674
## rowData names(3): gene_id gene_name feature_type
## colnames(4992): AAACAACGAATAGTTC-1 AAACAAGTATCTCCCA-1 ...
## TTGTTTGTATTACACG-1 TTGTTTGTGTAAATTC-1
## colData names(8): barcode_id sample_id ... reference cell_count
## reducedDimNames(0):
## mainExpName: NULL
## altExpNames(0):
## spatialCoords names(2) : pxl_col_in_fullres pxl_row_in_fullres
## imgData names(4): sample_id image_id data scaleFactor
The SpatialExperiment class should be fairly familiar, since it is heavily based on the SingleCellExperiment class. In addition to the slots that you already know, a SpatialExperiment object includes the spatialCoords and the imgData slots.
head(spatialCoords(spe))
## pxl_col_in_fullres pxl_row_in_fullres
## AAACAACGAATAGTTC-1 3913 2435
## AAACAAGTATCTCCCA-1 9791 8468
## AAACAATCTACTAGCA-1 5769 2807
## AAACACCAATAACTGC-1 4068 9505
## AAACAGAGCGACTCCT-1 9271 4151
## AAACAGCTTTCAGAAG-1 3393 7583
imgData(spe)
## DataFrame with 2 rows and 4 columns
## sample_id image_id data scaleFactor
## <character> <character> <list> <numeric>
## 1 sample_151673 lowres #### 0.0450045
## 2 sample_151673 hires #### 0.1500150
Note that the colData DataFrame is still where most of the useful information about the spots are available.
colData(spe)
## DataFrame with 4992 rows and 8 columns
## barcode_id sample_id in_tissue array_row
## <character> <character> <integer> <integer>
## AAACAACGAATAGTTC-1 AAACAACGAATAGTTC-1 sample_151673 0 0
## AAACAAGTATCTCCCA-1 AAACAAGTATCTCCCA-1 sample_151673 1 50
## AAACAATCTACTAGCA-1 AAACAATCTACTAGCA-1 sample_151673 1 3
## AAACACCAATAACTGC-1 AAACACCAATAACTGC-1 sample_151673 1 59
## AAACAGAGCGACTCCT-1 AAACAGAGCGACTCCT-1 sample_151673 1 14
## ... ... ... ... ...
## TTGTTTCACATCCAGG-1 TTGTTTCACATCCAGG-1 sample_151673 1 58
## TTGTTTCATTAGTCTA-1 TTGTTTCATTAGTCTA-1 sample_151673 1 60
## TTGTTTCCATACAACT-1 TTGTTTCCATACAACT-1 sample_151673 1 45
## TTGTTTGTATTACACG-1 TTGTTTGTATTACACG-1 sample_151673 1 73
## TTGTTTGTGTAAATTC-1 TTGTTTGTGTAAATTC-1 sample_151673 1 7
## array_col ground_truth reference cell_count
## <integer> <character> <character> <integer>
## AAACAACGAATAGTTC-1 16 NA NA NA
## AAACAAGTATCTCCCA-1 102 Layer3 Layer3 6
## AAACAATCTACTAGCA-1 43 Layer1 Layer1 16
## AAACACCAATAACTGC-1 19 WM WM 5
## AAACAGAGCGACTCCT-1 94 Layer3 Layer3 2
## ... ... ... ... ...
## TTGTTTCACATCCAGG-1 42 WM WM 3
## TTGTTTCATTAGTCTA-1 30 WM WM 4
## TTGTTTCCATACAACT-1 27 Layer6 Layer6 3
## TTGTTTGTATTACACG-1 41 WM WM 16
## TTGTTTGTGTAAATTC-1 51 Layer2 Layer2 5
In this case, we have a ground truth annotation of the spots, as well as an indication of the number of cells covered by each spots.
One nice feature of spatial transcriptomics is that we can “see” the tissue under study and visualize the spots on top of the histology image. We will use the `ggspavis package to do so.
library(ggspavis)
plotVisium(spe)
We can also visualize the ground truth annotation of the spots.
plotSpots(spe, annotate = "ground_truth",
pal = "libd_layer_colors")
We can see that, while the Visium platform consists of a grid of spots, only some of them will be covered by the tissue: these are called “in_tissue” in SpatialExperiment.
spe$in_tissue <- as.factor(spe$in_tissue)
plotSpots(spe, in_tissue = NULL, annotate = "in_tissue")
For the rest of the workflow, we will only keep the in-tissue spots.
spe <- spe[, colData(spe)$in_tissue == 1]
Spot-based spatial transcriptomics is based on short-read sequencing of RNA molecules and as such many methods originally developed for single-cell RNA-seq can be used to analyze Visium data.
In particular, scater’s perCellQCMetrics function can be used to compute a set of metrics useful to evaluate the quality of the samples. The isOutlier function uses a data driven threshold to define cells of lower quality compared to the rest of the dataset.
Similarly to what we have done in the single-cell workflow, it is useful to identify spots with high percentages of mitochondrial reads, which may be a symptom of poor sample quality.
library(EnsDb.Hsapiens.v86)
rowData(spe)$location <- mapIds(EnsDb.Hsapiens.v86,
keys=rowData(spe)$gene_id,
column="SEQNAME", keytype="GENEID")
table(rowData(spe)$location=="MT")
##
## FALSE TRUE
## 33213 13
We can now compute the QC metrics with scater.
library(scater)
spe <- addPerCellQC(spe, subsets = list(mito = which(rowData(spe)$location=="MT")))
colData(spe)
## DataFrame with 3639 rows and 14 columns
## barcode_id sample_id in_tissue array_row
## <character> <character> <factor> <integer>
## AAACAAGTATCTCCCA-1 AAACAAGTATCTCCCA-1 sample_151673 1 50
## AAACAATCTACTAGCA-1 AAACAATCTACTAGCA-1 sample_151673 1 3
## AAACACCAATAACTGC-1 AAACACCAATAACTGC-1 sample_151673 1 59
## AAACAGAGCGACTCCT-1 AAACAGAGCGACTCCT-1 sample_151673 1 14
## AAACAGCTTTCAGAAG-1 AAACAGCTTTCAGAAG-1 sample_151673 1 43
## ... ... ... ... ...
## TTGTTTCACATCCAGG-1 TTGTTTCACATCCAGG-1 sample_151673 1 58
## TTGTTTCATTAGTCTA-1 TTGTTTCATTAGTCTA-1 sample_151673 1 60
## TTGTTTCCATACAACT-1 TTGTTTCCATACAACT-1 sample_151673 1 45
## TTGTTTGTATTACACG-1 TTGTTTGTATTACACG-1 sample_151673 1 73
## TTGTTTGTGTAAATTC-1 TTGTTTGTGTAAATTC-1 sample_151673 1 7
## array_col ground_truth reference cell_count sum
## <integer> <character> <character> <integer> <numeric>
## AAACAAGTATCTCCCA-1 102 Layer3 Layer3 6 8458
## AAACAATCTACTAGCA-1 43 Layer1 Layer1 16 1667
## AAACACCAATAACTGC-1 19 WM WM 5 3769
## AAACAGAGCGACTCCT-1 94 Layer3 Layer3 2 5433
## AAACAGCTTTCAGAAG-1 9 Layer5 Layer5 4 4278
## ... ... ... ... ... ...
## TTGTTTCACATCCAGG-1 42 WM WM 3 4324
## TTGTTTCATTAGTCTA-1 30 WM WM 4 2761
## TTGTTTCCATACAACT-1 27 Layer6 Layer6 3 2322
## TTGTTTGTATTACACG-1 41 WM WM 16 2331
## TTGTTTGTGTAAATTC-1 51 Layer2 Layer2 5 6281
## detected subsets_mito_sum subsets_mito_detected
## <numeric> <numeric> <numeric>
## AAACAAGTATCTCCCA-1 3586 1407 13
## AAACAATCTACTAGCA-1 1150 204 11
## AAACACCAATAACTGC-1 1960 430 13
## AAACAGAGCGACTCCT-1 2424 1316 13
## AAACAGCTTTCAGAAG-1 2264 651 12
## ... ... ... ...
## TTGTTTCACATCCAGG-1 2170 370 12
## TTGTTTCATTAGTCTA-1 1560 314 12
## TTGTTTCCATACAACT-1 1343 476 13
## TTGTTTGTATTACACG-1 1420 308 12
## TTGTTTGTGTAAATTC-1 2927 991 13
## subsets_mito_percent total
## <numeric> <numeric>
## AAACAAGTATCTCCCA-1 16.6351 8458
## AAACAATCTACTAGCA-1 12.2376 1667
## AAACACCAATAACTGC-1 11.4089 3769
## AAACAGAGCGACTCCT-1 24.2223 5433
## AAACAGCTTTCAGAAG-1 15.2174 4278
## ... ... ...
## TTGTTTCACATCCAGG-1 8.55689 4324
## TTGTTTCATTAGTCTA-1 11.37269 2761
## TTGTTTCCATACAACT-1 20.49957 2322
## TTGTTTGTATTACACG-1 13.21321 2331
## TTGTTTGTGTAAATTC-1 15.77774 6281
We can visualize the metrics and compute data-driven thresholds to flag low-quality cells.
hist(spe$subsets_mito_percent)
high.mito <- isOutlier(spe$subsets_mito_percent, type="higher")
table(high.mito)
## high.mito
## FALSE TRUE
## 3627 12
hist(spe$sum)
low.umi <- isOutlier(spe$sum, type="lower")
table(low.umi)
## low.umi
## FALSE
## 3639
In this case, only a few spots are discarded for high mitochondrial content. We can check whether there are spatial patterns of low quality.
colData(spe)$high.mito <- high.mito
plotSpotQC(spe, plot_type = "spot",
annotate = "high.mito")
We can plot the library sizes against the number of cells per spot (which is available for this dataset). We expect that spots with more cells will tend to have a higher number of UMIs.
plotSpotQC(spe, plot_type = "scatter",
x_metric = "cell_count", y_metric = "sum")
We see that the spots with very high cell counts also have low numbers of expressed genes. This indicates that the experiments have failed for these spots, and they should be removed. We select a threshold of 10 cells per spot. The number of spots above this threshold is relatively small, and there is a clear downward trend in the number of expressed genes above this threshold.
high.cellcount <- colData(spe)$cell_count > 10
table(high.cellcount)
## high.cellcount
## FALSE TRUE
## 3549 90
spe$high.cellcount <- high.cellcount
plotSpotQC(spe, plot_type = "spot",
annotate = "high.cellcount")
The discarded spots are all on the edges of the tissue. It seems plausible that something has gone wrong with the cell segmentation on the edges of the images, so it makes sense to remove these spots.
Since only a small number of spots are flagged as low quality, we can leave them in the object and simply flag them as low-quality.
table(high.cellcount | high.mito)
##
## FALSE TRUE
## 3537 102
low_quality <- which(high.cellcount | high.mito)
As for quality control, normalization can be borrowed from the single-cell literature. Published work use simple methods, such as the library size normalization. We use here this simple strategy, but more advanced methods can be used (see the single-cell workflow).
spe <- logNormCounts(spe)
spe
## class: SpatialExperiment
## dim: 33538 3639
## metadata(0):
## assays(2): counts logcounts
## rownames(33538): ENSG00000243485 ENSG00000237613 ... ENSG00000277475
## ENSG00000268674
## rowData names(4): gene_id gene_name feature_type location
## colnames(3639): AAACAAGTATCTCCCA-1 AAACAATCTACTAGCA-1 ...
## TTGTTTGTATTACACG-1 TTGTTTGTGTAAATTC-1
## colData names(17): barcode_id sample_id ... high.cellcount sizeFactor
## reducedDimNames(0):
## mainExpName: NULL
## altExpNames(0):
## spatialCoords names(2) : pxl_col_in_fullres pxl_row_in_fullres
## imgData names(4): sample_id image_id data scaleFactor
Exercise: Use the scran package to normalize the data with the pool deconvolution method and compare the size factors with the library sizes.
In a typical single-cell analysis, we focus on the highly variable genes in the hope that they will carry most of the biological information. Spatial transcriptomics allows us to identify spatially variable genes (SVGs), i.e., genes with spatially correlated patterns of expression across the tissue area.
Several methods to identify SVGs in ST data have recently been developed; here we focus on the nnSVG method (Weber et al. 2023).
In this example, we run nnSVG using a small subset of the dataset for faster runtime. We select a subset by subsampling on the set of spots and including stringent filtering for low-expressed genes. A full analysis using all spots for this dataset and default filtering parameters for Visium data from human brain tissue takes around 45 minutes for one Visium sample on a standard laptop.
library(nnSVG)
n <- 100
set.seed(123)
ix <- sample(seq_len(n), n)
spe_nnSVG <- spe[, ix]
spe_nnSVG <- filter_genes(
spe_nnSVG, filter_genes_ncounts = 10, filter_genes_pcspots = 3
)
spe_nnSVG <- logNormCounts(spe_nnSVG)
spe_nnSVG <- nnSVG(spe_nnSVG)
head(rowData(spe_nnSVG), 3)
## DataFrame with 3 rows and 18 columns
## gene_id gene_name feature_type location
## <character> <character> <character> <character>
## ENSG00000074800 ENSG00000074800 ENO1 Gene Expression 1
## ENSG00000171603 ENSG00000171603 CLSTN1 Gene Expression 1
## ENSG00000162545 ENSG00000162545 CAMK2N1 Gene Expression 1
## sigma.sq tau.sq phi loglik runtime mean
## <numeric> <numeric> <numeric> <numeric> <numeric> <numeric>
## ENSG00000074800 0.00689397 0.537541 21.01207 -111.492 0.009 1.71503
## ENSG00000171603 0.57866902 0.125757 23.32804 -120.270 0.010 2.09564
## ENSG00000162545 0.18362513 0.562470 4.97238 -123.776 0.011 2.58247
## var spcov prop_sv loglik_lm LR_stat rank
## <numeric> <numeric> <numeric> <numeric> <numeric> <numeric>
## ENSG00000074800 0.549317 0.048413 0.0126626 -111.437 -0.109614 161
## ENSG00000171603 0.721747 0.362993 0.8214763 -125.087 9.634798 59
## ENSG00000162545 0.759200 0.165932 0.2461151 -127.617 7.682376 75
## pval padj
## <numeric> <numeric>
## ENSG00000074800 1.0000000 1.0000000
## ENSG00000171603 0.0080878 0.0234409
## ENSG00000162545 0.0214681 0.0489472
We can for instance inspect the top SVGs by ordering by the rank column.
rowData(spe_nnSVG)[order(rowData(spe_nnSVG)$rank),]
## DataFrame with 171 rows and 18 columns
## gene_id gene_name feature_type location
## <character> <character> <character> <character>
## ENSG00000197971 ENSG00000197971 MBP Gene Expression 18
## ENSG00000123560 ENSG00000123560 PLP1 Gene Expression X
## ENSG00000173786 ENSG00000173786 CNP Gene Expression 17
## ENSG00000109846 ENSG00000109846 CRYAB Gene Expression 11
## ENSG00000131095 ENSG00000131095 GFAP Gene Expression 17
## ... ... ... ... ...
## ENSG00000173812 ENSG00000173812 EIF1 Gene Expression 17
## ENSG00000128989 ENSG00000128989 ARPP19 Gene Expression 15
## ENSG00000150991 ENSG00000150991 UBC Gene Expression 12
## ENSG00000131143 ENSG00000131143 COX4I1 Gene Expression 16
## ENSG00000138326 ENSG00000138326 RPS24 Gene Expression 10
## sigma.sq tau.sq phi loglik runtime mean
## <numeric> <numeric> <numeric> <numeric> <numeric> <numeric>
## ENSG00000197971 2.83483 0.173900 1.550925 -120.999 0.011 3.74783
## ENSG00000123560 3.80992 0.513980 0.942131 -143.802 0.010 2.78344
## ENSG00000173786 2.02307 0.390224 1.151300 -126.786 0.009 1.73816
## ENSG00000109846 2.00210 0.303676 1.793466 -127.424 0.008 1.80283
## ENSG00000131095 2.49706 0.513030 2.687935 -154.650 0.009 2.01780
## ... ... ... ... ... ... ...
## ENSG00000173812 0.01120531 0.552721 11.405284 -113.2418 0.009 2.20198
## ENSG00000128989 0.01913710 0.724128 11.921848 -127.0426 0.008 2.17656
## ENSG00000150991 0.00563829 0.332138 23.772113 -87.6236 0.007 2.69460
## ENSG00000131143 0.01643595 0.654543 15.903211 -121.9345 0.008 2.69960
## ENSG00000138326 0.00746273 0.580789 0.227044 -115.3014 0.009 2.44050
## var spcov prop_sv loglik_lm LR_stat rank
## <numeric> <numeric> <numeric> <numeric> <numeric> <numeric>
## ENSG00000197971 2.74088 0.449245 0.942201 -191.805 141.6122 1
## ENSG00000123560 3.16595 0.701256 0.881130 -199.014 110.4231 2
## ENSG00000173786 1.92044 0.818305 0.838303 -174.019 94.4664 3
## ENSG00000109846 1.90257 0.784854 0.868298 -173.552 92.2545 4
## ENSG00000131095 2.77227 0.783134 0.829563 -192.375 75.4484 5
## ... ... ... ... ... ... ...
## ENSG00000173812 0.568611 0.0480727 0.0198702 -113.1634 -0.156703 167
## ENSG00000128989 0.749152 0.0635576 0.0257474 -126.9506 -0.183908 168
## ENSG00000150991 0.340548 0.0278663 0.0166924 -87.5314 -0.184430 169
## ENSG00000131143 0.676280 0.0474896 0.0244955 -121.8339 -0.201117 170
## ENSG00000138326 0.592135 0.0353973 0.0126863 -115.1903 -0.222210 171
## pval padj
## <numeric> <numeric>
## ENSG00000197971 0 0
## ENSG00000123560 0 0
## ENSG00000173786 0 0
## ENSG00000109846 0 0
## ENSG00000131095 0 0
## ... ... ...
## ENSG00000173812 1 1
## ENSG00000128989 1 1
## ENSG00000150991 1 1
## ENSG00000131143 1 1
## ENSG00000138326 1 1
These results already offer a way to explore potentially interesting spatial signal, e.g., by plotting the expression pattern of the top ranked genes.
plotSpots(spe, annotate = "ENSG00000197971")