# Correcting batch effects in single-cell RNA-seq data

Aaron Lun1

1Cancer Research UK Cambridge Institute, Cambridge, United Kingdom

#### Revised: 3 February 2019

#### Package

batchelor 1.4.0

# 1 Introduction

Batch effects refer to differences between data sets generated at different times or in different laboratories. These often occur due to uncontrolled variability in experimental factors, e.g., reagent quality, operator skill, atmospheric ozone levels. The presence of batch effects can interfere with downstream analyses if they are not explicitly modelled. For example, differential expression analyses typically use a blocking factor to absorb any batch-to-batch differences.

For single-cell RNA sequencing (scRNA-seq) data analyses, explicit modelling of the batch effect is less relevant. Manny common downstream procedures for exploratory data analysis are not model-based, including clustering and visualization. It is more generally useful to have methods that can remove batch effects to create an corrected expression matrix for further analysis. This follows the same strategy as, e.g., the `removeBatchEffect()` function in the limma package (Ritchie et al. 2015).

Batch correction methods designed for bulk genomics data usually require knowledge of the other factors of variation. This is usually not known in scRNA-seq experiments where the aim is to explore unknown heterogeneity in cell populations. The batchelor package implements batch correction methods that do not rely on a priori knowledge about the population structure.

# 2 Setting up demonstration data

To demonstrate, we will use two brain data sets (Tasic et al. 2016; Zeisel et al. 2015) from the scRNAseq package.

    library(scRNAseq)
    sce1 <- ZeiselBrainData()
    sce1

    ## class: SingleCellExperiment
    ## dim: 20006 3005
    ## metadata(0):
    ## assays(1): counts
    ## rownames(20006): Tspan12 Tshz1 ... mt-Rnr1 mt-Nd4l
    ## rowData names(1): featureType
    ## colnames(3005): 1772071015_C02 1772071017_G12 ... 1772066098_A12
    ##   1772058148_F03
    ## colData names(10): tissue group # ... level1class level2class
    ## reducedDimNames(0):
    ## altExpNames(2): ERCC repeat

    sce2 <- TasicBrainData()
    sce2

    ## class: SingleCellExperiment
    ## dim: 24058 1809
    ## metadata(0):
    ## assays(1): counts
    ## rownames(24058): 0610005C13Rik 0610007C21Rik ... mt_X57780 tdTomato
    ## rowData names(0):
    ## colnames(1809): Calb2_tdTpositive_cell_1 Calb2_tdTpositive_cell_2 ...
    ##   Rbp4_CTX_250ng_2 Trib2_CTX_250ng_1
    ## colData names(13): sample_title mouse_line ... secondary_type
    ##   aibs_vignette_id
    ## reducedDimNames(0):
    ## altExpNames(1): ERCC

We apply some quick-and-dirty quality control to both datasets, using the outlier detection strategy from the scater package.

    library(scater)
    sce1 <- addPerCellQC(sce1, subsets=list(Mito=grep("mt-", rownames(sce1))))
    qc1 <- quickPerCellQC(colData(sce1), percent_subsets="subsets_Mito_percent")
    sce1 <- sce1[,!qc1$discard]

    sce2 <- addPerCellQC(sce2, subsets=list(Mito=grep("mt_", rownames(sce2))))
    qc2 <- quickPerCellQC(colData(sce2))
    sce2 <- sce2[,!qc2$discard]

Some preprocessing is required to render these two datasets comparable. We subset to the common subset of genes:

    universe <- intersect(rownames(sce1), rownames(sce2))
    sce1 <- sce1[universe,]
    sce2 <- sce2[universe,]

We compute log-normalized expression values using library size-derived size factors for simplicity. (More complex size factor calculation methods are available in the scran package.)

    out <- multiBatchNorm(sce1, sce2)
    sce1 <- out[[1]]
    sce2 <- out[[2]]

Finally, we identify all genes with positive biological components. We will use these for all high-dimensional procedures such as PCA and nearest-neighbor searching.

    library(scran)
    dec1 <- modelGeneVar(sce1)
    dec2 <- modelGeneVar(sce2)
    combined.dec <- combineVar(dec1, dec2)
    chosen.hvgs <- combined.dec$bio > 0
    summary(chosen.hvgs)

    ##    Mode   FALSE    TRUE
    ## logical    9531    9167

As a diagnostic, we check that there actually is a batch effect across these datasets by checking that they cluster separately. Here, we combine the two `SingleCellExperiment` objects without any correction using the `NoCorrectParam()` flag, and we informally verify that cells from different batches are separated using a \(t\)-SNE plot.

    library(scater)
    combined <- correctExperiments(A=sce1, B=sce2, PARAM=NoCorrectParam())
    combined <- runPCA(combined, subset_row=chosen.hvgs)
    combined <- runTSNE(combined, dimred="PCA")
    plotTSNE(combined, colour_by="batch")

# 3 Mutual nearest neighbors

## 3.1 Overview

Mutual nearest neighbors (MNNs) are defined as pairs of cells - one from each batch - that are within each other’s set of `k` nearest neighbors. The idea is that MNN pairs across batches refer to the same cell type, assuming that the batch effect is orthogonal to the biological subspace (Haghverdi et al. 2018). Once MNN pairs are identified, the difference between the paired cells is used to infer the magnitude and direction of the batch effect. It is then straightforward to remove the batch effect and obtain a set of corrected expression values.

## 3.2 The new, fast method

The `fastMNN()` function performs a principal components analysis (PCA) on the HVGs to obtain a low-dimensional representation of the input data. MNN identification and correction is performed in this low-dimensional space, which offers some advantages with respect to speed and denoising.
This returns a `SingleCellExperiment` containing a matrix of corrected PC scores is returned, which can be used directly for downstream analyses such as clustering and visualization.

    library(batchelor)
    f.out <- fastMNN(A=sce1, B=sce2, subset.row=chosen.hvgs)
    str(reducedDim(f.out, "corrected"))

    ##  num [1:4559, 1:50] -0.0651 -0.0803 -0.084 -0.0972 -0.077 ...
    ##  - attr(*, "dimnames")=List of 2
    ##   ..$ : chr [1:4559] "1772071015_C02" "1772071017_G12" "1772071017_A05" "1772071014_B06" ...
    ##   ..$ : NULL

The batch of origin for each row/cell in the output matrix is also returned:

    rle(f.out$batch)

    ## Run Length Encoding
    ##   lengths: int [1:2] 2874 1685
    ##   values : chr [1:2] "A" "B"

Another way to call `fastMNN()` is to specify the batch manually. This will return a corrected matrix with the cells in the same order as that in the input `combined` object. In this case, it doesn’t matter as the two batches were concatenated to created `combined` anyway, but these semantics may be useful when cells from the same batch are not contiguous.

    f.out2 <- fastMNN(combined, batch=combined$batch, subset.row=chosen.hvgs)
    str(reducedDim(f.out2, "corrected"))

    ##  num [1:4559, 1:50] 0.0651 0.0803 0.084 0.0972 0.077 ...
    ##  - attr(*, "dimnames")=List of 2
    ##   ..$ : chr [1:4559] "1772071015_C02" "1772071017_G12" "1772071017_A05" "1772071014_B06" ...
    ##   ..$ : NULL

As we can see, cells from the batches are more intermingled in the \(t\)-SNE plot below. This suggests that the batch effect was removed - assuming, of course, that the intermingled cells represent the same population.

    f.out <- runTSNE(f.out, dimred="corrected")
    plotTSNE(f.out, colour_by="batch")

We can also obtain per-gene corrected expression values by using the rotation vectors stored in the output. This reverses the original projection used to obtain the initial low-dimensional representation. There are, however, several caveats to using these values for downstream analyses.

    cor.exp <- assay(f.out)[1,]
    hist(cor.exp, xlab="Corrected expression for gene 1", col="grey80")

While the default arguments are usually satisfactory, there are many options for running `fastMNN()`, e.g., to improve speed or to achieve a particular merge order. Refer to the documentation at `?fastMNN` or the book for more details.

## 3.3 The old, classic method

The original method described by Haghverdi et al. (2018) is implemented in the `mnnCorrect()` method. This performs the MNN identification and correction in the gene expression space, and uses a different strategy to overcome high-dimensional noise. `mnnCorrect()` is called with the same semantics as `fastMNN()`:

    # Using fewer genes as it is much slower.
    fewer.hvgs <- head(order(combined.dec$bio, decreasing=TRUE), 500)
    classic.out <- mnnCorrect(sce1, sce2, subset.row=fewer.hvgs)

… but returns the corrected gene expression matrix directly, rather than using a low-dimensional representation1 1 1 Again, those readers wanting to use the corrected values for per-gene analyses should consider the caveats mentioned previously.. This is wrapped in a `SingleCellExperiment` object to store various batch-related metadata.

    classic.out

    ## class: SingleCellExperiment
    ## dim: 500 4559
    ## metadata(1): merge.info
    ## assays(1): corrected
    ## rownames(500): Plp1 Gad1 ... Syngr3 Bend4
    ## rowData names(0):
    ## colnames(4559): 1772071015_C02 1772071017_G12 ... Rbp4_CTX_250ng_2
    ##   Trib2_CTX_250ng_1
    ## colData names(1): batch
    ## reducedDimNames(0):
    ## altExpNames(0):

For scRNA-seq data, `fastMNN()` tends to be both faster and better at achieving a satisfactory merge. `mnnCorrect()` is mainly provided here for posterity’s sake, though it is more robust than `fastMNN()` to certain violations of the orthogonality assumptions.

## 3.4 The cluster-based method

In some scenarios, we have already separately characterized the heterogeneity in each batch, identifying clusters and annotating them with relevant biological states. The `clusterMNN()` function allows us to examine the relationships between clusters across batches, as demonstrated below with the Tasic and Zeisel datasets using the author-provided labels.

    # Removing the 'unclassified' cluster, which makes no sense:
    not.unclass <- sce2$broad_type!="Unclassified"
    clust.out <- clusterMNN(sce1, sce2[,not.unclass],
        subset.row=chosen.hvgs,
        clusters=list(sce1$level1class, sce2$broad_type[not.unclass]))

The most relevant output is extracted from the metadata and describes the “meta-clusters”, i.e., groupings of matching clusters corresponding to the same biological state across different batches.

    clust.info <- metadata(clust.out)$cluster
    split(clust.info$cluster, clust.info$meta)

    ## $`1`
    ## [1] "astrocytes_ependymal" "Astrocyte"
    ##
    ## $`2`
    ## [1] "endothelial-mural" "Endothelial Cell"
    ##
    ## $`3`
    ## [1] "interneurons"      "GABA-ergic Neuron"
    ##
    ## $`4`
    ## [1] "microglia" "Microglia"
    ##
    ## $`5`
    ## [1] "oligodendrocytes" "Oligodendrocyte"
    ##
    ## $`6`
    ## [1] "pyramidal CA1"
    ##
    ## $`7`
    ## [1] "pyramidal SS"         "Glutamatergic Neuron"
    ##
    ## $`8`
    ## [1] "Oligodendrocyte Precursor Cell"

The output object itself is a `SingleCellExperiment` that can be used for downstream processing in the same manner as the output of `fastMNN()`. Compared to `fastMNN()`, `clusterMNN()` is more faithful with respect to preseving the separation between clusters; however, it will not attempt to adjust for differences in intra-cluster structure between batches. This can generally be considered a more conservative strategy than `fastMNN()` for merging batches.

# 4 Batch rescaling

`rescaleBatches()` effectively centers the batches in log-expression space on a per-gene basis. This is conceptually equivalent to running `removeBatchEffect()` with no covariates other than the batch. However, `rescaleBatches()` achieves this rescaling by reversing the log-transformation, downscaling the counts so that the average of each batch is equal to the smallest value, and then re-transforming. This preserves sparsity by ensuring that zeroes remain so after correction, and mitigates differences in the variance when dealing with counts of varying size between batches2 2 2 Done by downscaling, which increases the shrinkage from the added pseudo-count..

Calling `rescaleBatches()` returns a corrected matrix of per-gene log-expression values, wrapped in a `SummarizedExperiment` containin batch-related metadata. This function operates on a per-gene basis so there is no need to perform subsetting (other than to improve speed).

    rescale.out <- rescaleBatches(sce1, sce2)
    rescale.out

    ## class: SingleCellExperiment
    ## dim: 18698 4559
    ## metadata(0):
    ## assays(1): corrected
    ## rownames(18698): Tspan12 Tshz1 ... Uty Usp9y
    ## rowData names(0):
    ## colnames(4559): 1772071015_C02 1772071017_G12 ... Rbp4_CTX_250ng_2
    ##   Trib2_CTX_250ng_1
    ## colData names(1): batch
    ## reducedDimNames(0):
    ## altExpNames(0):

While this method is fast and simple, it makes the strong assumption that the population composition of each batch is the same. This is usually not the case for scRNA-seq experiments in real systems that exhibit biological variation. Thus, `rescaleBatches()` is best suited for merging technical replicates of the same sample, e.g., that have been sequenced separately.

    rescale.out <- runPCA(rescale.out, subset_row=chosen.hvgs,
        exprs_values="corrected")
    plotPCA(rescale.out, colour_by="batch")

Alternatively, a more direct linear regression of the batch effect can be performed with `regressBatches()`. This does not preserve sparsity but uses a different set of tricks to avoid explicitly creating a dense matrix, specifically by using the `ResidualMatrix` class from the BiocSingular package.

    regress.out <- regressBatches(sce1, sce2)
    assay(regress.out)

    ## <18698 x 4559> matrix of class ResidualMatrix and type "double":
    ##             1772071015_C02    1772071017_G12 ...  Rbp4_CTX_250ng_2
    ##  Tspan12       -0.22000582       -0.22000582   .        -0.3969407
    ##    Tshz1        1.28232832        0.43041200   .         0.4595142
    ##   Fnbp1l        0.95064469        0.09872836   .         0.8200829
    ## Adamts15       -0.03420318       -0.03420318   .         0.1299367
    ##   Cldn12        0.45006856        0.44471813   .         0.4758732
    ##      ...                 .                 .   .                 .
    ##     Mid1     -0.0439706444     -0.0439706444   .       0.688198065
    ##     Asmt     -0.0007053182     -0.0007053182   .       0.001109631
    ##    Kdm5d     -0.1204722330     -0.1204722330   .       0.282483756
    ##      Uty     -0.1956776800     -0.1956776800   .      -0.169770975
    ##    Usp9y     -0.0021688275     -0.0021688275   .      -0.001291024
    ##          Trib2_CTX_250ng_1
    ##  Tspan12        -0.4025510
    ##    Tshz1         0.4644437
    ##   Fnbp1l         0.9644197
    ## Adamts15         0.1531212
    ##   Cldn12         0.3853231
    ##      ...                 .
    ##     Mid1       0.908662110
    ##     Asmt       0.001200483
    ##    Kdm5d       0.419502356
    ##      Uty      -0.129589251
    ##    Usp9y      -0.001291024

# 5 Using data subsets

## 5.1 Selecting genes

As shown above, the `subset.row=` argument will only perform the correction on a subset of genes in the data set. This is useful for focusing on highly variable or marker genes during high-dimensional procedures like PCA or neighbor searches, mitigating noise from irrelevant genes. For per-gene methods, this argument provides a convenient alternative to subsetting the input.

For some functions, it is also possible to set `correct.all=TRUE` when `subset.row=` is specified. This will compute corrected values for the unselected genes as well, which is possible once the per-cell statistics are obtained with the gene subset. With this setting, we can guarantee that the output contains all the genes provided in the input.

## 5.2 Restricted correction

Many functions support the `restrict=` argument whereby the correction is determined using only a restricted subset of cells in each batch. The effect of the correction is then - for want of a better word - “extrapolated” to all other cells in that batch. This is useful for experimental designs where a control set of cells from the same source population were run on different batches. Any difference in the controls between batches must be artificial in origin, allowing us to estimate and remove the batch effect without making further biological assumptions.

    # Pretend the first X cells in each batch are controls.
    restrict <- list(1:100, 1:200)
    rescale.out <- rescaleBatches(sce1, sce2, restrict=restrict)

# 6 Other utilities

## 6.1 Multi-batch normalization

Differences in sequencing depth between batches are an obvious cause for batch-to-batch differences. These can be removed by `multiBatchNorm()`, which downscales all batches to match the coverage of the least-sequenced batch. This function returns a list of `SingleCellExperiment` objects with log-transformed normalized expression values that can be directly used for further correction.

    normed <- multiBatchNorm(A=sce1, B=sce2,
        norm.args=list(use_altexps=FALSE))
    names(normed)

    ## [1] "A" "B"

Downscaling mitigates differences in variance between batches due to the mean-variance relationship of count data. It is achieved using a median-based estimator to avoid problems with composition biases between batches (Lun, Bach, and Marioni 2016). Note that this assumes that most genes are not DE between batches, which we try to avoid violating by performing the rescaling on all genes rather than just the HVGs.

## 6.2 Multi-batch PCA

Users can perform a PCA across multiple batches using the `multiBatchPCA()` function. The output of this function is roughly equivalent to `cbind`ing all batches together and performing PCA on the merged matrix. The main difference is that each sample is forced to contribute equally to the identification of the rotation vectors. This allows small batches with unique subpopulations to contribute meaningfully to the definition of the low-dimensional space.

    # Using the same BSPARAM argument as fastMNN(), for speed.
    pca.out <- multiBatchPCA(A=sce1, B=sce2, subset.row=chosen.hvgs,
        BSPARAM=BiocSingular::IrlbaParam(deferred=TRUE))
    names(pca.out)

    ## [1] "A" "B"

This function is used internally in `fastMNN()` but can be explicitly called by the user. Reduced dimensions can be input into `reducedMNN()`, which is occasionally convenient as it allows different correction parameters to be repeated without having to repeat the time-consuming PCA step.

# 7 Session information

    sessionInfo()

    ## R version 4.0.0 (2020-04-24)
    ## Platform: x86_64-w64-mingw32/x64 (64-bit)
    ## Running under: Windows Server 2012 R2 x64 (build 9600)
    ##
    ## Matrix products: default
    ##
    ## locale:
    ## [1] LC_COLLATE=C
    ## [2] LC_CTYPE=English_United States.1252
    ## [3] LC_MONETARY=English_United States.1252
    ## [4] LC_NUMERIC=C
    ## [5] LC_TIME=English_United States.1252
    ##
    ## attached base packages:
    ## [1] parallel  stats4    stats     graphics  grDevices utils     datasets
    ## [8] methods   base
    ##
    ## other attached packages:
    ##  [1] scran_1.16.0                scater_1.16.0
    ##  [3] ggplot2_3.3.0               scRNAseq_2.1.7
    ##  [5] batchelor_1.4.0             SingleCellExperiment_1.10.0
    ##  [7] SummarizedExperiment_1.18.0 DelayedArray_0.14.0
    ##  [9] matrixStats_0.56.0          Biobase_2.48.0
    ## [11] GenomicRanges_1.40.0        GenomeInfoDb_1.24.0
    ## [13] IRanges_2.22.0              S4Vectors_0.26.0
    ## [15] BiocGenerics_0.34.0         knitr_1.28
    ## [17] BiocStyle_2.16.0
    ##
    ## loaded via a namespace (and not attached):
    ##  [1] bitops_1.0-6                  bit64_0.9-7
    ##  [3] httr_1.4.1                    tools_4.0.0
    ##  [5] R6_2.4.1                      irlba_2.3.3
    ##  [7] vipor_0.4.5                   DBI_1.1.0
    ##  [9] colorspace_1.4-1              withr_2.2.0
    ## [11] tidyselect_1.0.0              gridExtra_2.3
    ## [13] bit_1.1-15.2                  curl_4.3
    ## [15] compiler_4.0.0                BiocNeighbors_1.6.0
    ## [17] labeling_0.3                  bookdown_0.18
    ## [19] scales_1.1.0                  rappdirs_0.3.1
    ## [21] stringr_1.4.0                 digest_0.6.25
    ## [23] rmarkdown_2.1                 XVector_0.28.0
    ## [25] pkgconfig_2.0.3               htmltools_0.4.0
    ## [27] limma_3.44.0                  fastmap_1.0.1
    ## [29] dbplyr_1.4.3                  rlang_0.4.5
    ## [31] RSQLite_2.2.0                 shiny_1.4.0.2
    ## [33] DelayedMatrixStats_1.10.0     farver_2.0.3
    ## [35] BiocParallel_1.22.0           dplyr_0.8.5
    ## [37] RCurl_1.98-1.2                magrittr_1.5
    ## [39] BiocSingular_1.4.0            GenomeInfoDbData_1.2.3
    ## [41] Matrix_1.2-18                 Rcpp_1.0.4.6
    ## [43] ggbeeswarm_0.6.0              munsell_0.5.0
    ## [45] viridis_0.5.1                 lifecycle_0.2.0
    ## [47] edgeR_3.30.0                  stringi_1.4.6
    ## [49] yaml_2.2.1                    zlibbioc_1.34.0
    ## [51] Rtsne_0.15                    BiocFileCache_1.12.0
    ## [53] AnnotationHub_2.20.0          grid_4.0.0
    ## [55] blob_1.2.1                    dqrng_0.2.1
    ## [57] promises_1.1.0                ExperimentHub_1.14.0
    ## [59] crayon_1.3.4                  lattice_0.20-41
    ## [61] cowplot_1.0.0                 magick_2.3
    ## [63] locfit_1.5-9.4                pillar_1.4.3
    ## [65] igraph_1.2.5                  glue_1.4.0
    ## [67] BiocVersion_3.11.1            evaluate_0.14
    ## [69] BiocManager_1.30.10           vctrs_0.2.4
    ## [71] httpuv_1.5.2                  gtable_0.3.0
    ## [73] purrr_0.3.4                   assertthat_0.2.1
    ## [75] xfun_0.13                     mime_0.9
    ## [77] rsvd_1.0.3                    xtable_1.8-4
    ## [79] later_1.0.0                   viridisLite_0.3.0
    ## [81] tibble_3.0.1                  AnnotationDbi_1.50.0
    ## [83] beeswarm_0.2.3                memoise_1.1.0
    ## [85] statmod_1.4.34                ellipsis_0.3.0
    ## [87] interactiveDisplayBase_1.26.0

# References

Haghverdi, L., A. T. L. Lun, M. D. Morgan, and J. C. Marioni. 2018. “Batch effects in single-cell RNA-sequencing data are corrected by matching mutual nearest neighbors.” Nat. Biotechnol. 36 (5): 421–27.

Lun, A. T., K. Bach, and J. C. Marioni. 2016. “Pooling across cells to normalize single-cell RNA sequencing data with many zero counts.” Genome Biol. 17 (April): 75.

Ritchie, M. E., B. Phipson, D. Wu, Y. Hu, C. W. Law, W. Shi, and G. K. Smyth. 2015. “limma powers differential expression analyses for RNA-sequencing and microarray studies.” Nucleic Acids Res. 43 (7): e47.

Tasic, B., V. Menon, T. N. Nguyen, T. K. Kim, T. Jarsky, Z. Yao, B. Levi, et al. 2016. “Adult mouse cortical cell taxonomy revealed by single cell transcriptomics.” Nat. Neurosci. 19 (2): 335–46.

Zeisel, A., A. B. Munoz-Manchado, S. Codeluppi, P. Lonnerberg, G. La Manno, A. Jureus, S. Marques, et al. 2015. “Brain structure. Cell types in the mouse cortex and hippocampus revealed by single-cell RNA-seq.” Science 347 (6226): 1138–42.