# Quality control for single-cell RNA-seq data

Aaron Lun*

*infinite.monkeys.with.keyboards@gmail.com

#### Revised: February 6, 2021

#### Package

scuttle 1.19.0

# 1 Introduction

scuttle provides various low-level utilities for single-cell RNA-seq data analysis, typically used at the start of the analysis workflows or within high-level functions in other packages. This vignette will discuss one of the earliest steps, namely, quality control (QC) to remove damaged cells.

To demonstrate, we will obtain the classic Zeisel dataset from the scRNAseq package. In this case, the dataset is provided as a `SingleCellExperiment` object. However, most scuttle functions can also be used with raw expression matrices or instances of the more general `SummarizedExperiment` class.

    library(scRNAseq)
    sce <- ZeiselBrainData()
    sce

    ## 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(9): tissue group # ... level1class level2class
    ## reducedDimNames(0):
    ## mainExpName: gene
    ## altExpNames(2): repeat ERCC

Note: A more comprehensive description of the use of scuttle functions (along with other packages) in a scRNA-seq analysis workflow is available at https://osca.bioconductor.org.

# 2 Computing per-cell QC metrics

The `perCellQCMetrics()` function computes a variety of basic cell-level metrics, including `sum`, total number of counts for the cell (i.e., the library size); and `detected`, the number of features for the cell that have counts above the detection limit (default of zero). Low values for either metric are indicative of poor cDNA capture.

    library(scuttle)
    is.mito <- grep("mt-", rownames(sce))
    per.cell <- perCellQCMetrics(sce, subsets=list(Mito=is.mito))
    summary(per.cell$sum)

    ##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
    ##    2574    8130   12913   14954   19284   63505

    summary(per.cell$detected)

    ##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
    ##     785    2484    3656    3777    4929    8167

In addition, we can compute `subsets_X_percent`, percentage of counts that come from the feature control set named `X`; and `altexps_Y_percent`, the percentage of all counts that come from an alternative feature set `Y`. Here, `X` contains the mitochondrial genes and `Y` contains the set of spike-ins. High proportions for either metric are indicative of cell damage.

    summary(per.cell$subsets_Mito_percent)

    ##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
    ##   0.000   3.992   6.653   7.956  10.290  56.955

    summary(per.cell$altexps_ERCC_percent)

    ##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
    ##   2.912  14.186  20.699  23.199  29.950  76.881

It is often convenient to store this in the `colData()` of our `SingleCellExperiment` object for future reference. One can either do so manually:

    colData(sce) <- cbind(colData(sce), per.cell)

… or just use the `addPerCellQCMetrics()` function, which does this automatically:

    sce2 <- addPerCellQCMetrics(sce, subsets=list(Mito=is.mito))
    colnames(colData(sce2))

    ##  [1] "tissue"                  "group #"
    ##  [3] "total mRNA mol"          "well"
    ##  [5] "sex"                     "age"
    ##  [7] "diameter"                "level1class"
    ##  [9] "level2class"             "sum"
    ## [11] "detected"                "subsets_Mito_sum"
    ## [13] "subsets_Mito_detected"   "subsets_Mito_percent"
    ## [15] "altexps_repeat_sum"      "altexps_repeat_detected"
    ## [17] "altexps_repeat_percent"  "altexps_ERCC_sum"
    ## [19] "altexps_ERCC_detected"   "altexps_ERCC_percent"
    ## [21] "total"                   "sum"
    ## [23] "detected"                "subsets_Mito_sum"
    ## [25] "subsets_Mito_detected"   "subsets_Mito_percent"
    ## [27] "altexps_repeat_sum"      "altexps_repeat_detected"
    ## [29] "altexps_repeat_percent"  "altexps_ERCC_sum"
    ## [31] "altexps_ERCC_detected"   "altexps_ERCC_percent"
    ## [33] "total"

# 3 Identifying outliers on QC metrics

We identify low-quality cells by setting a threshold on each of these metrics using the `isOutlier()` function. This defines the threshold at a certain number of median absolute deviations (MADs) away from the median; values beyond this threshold are considered outliers and can be filtered out, assuming that they correspond to low-quality cells. Here, we define small outliers (using `type="lower"`) for the log-total counts at 3 MADs from the median.

    low.total <- isOutlier(per.cell$sum, type="lower", log=TRUE)
    summary(low.total)

    ##    Mode   FALSE
    ## logical    3005

Note that the attributes of the `isOutlier()` output contain the thresholds, if this is of interest.

    attr(low.total, "threshold")

    ##   lower  higher
    ## 1928.56     Inf

Advanced users can set `batch=` to compute outliers within each batch, avoiding inflated MADs due to batch effects.

    low.total.batched <- isOutlier(per.cell$sum, type="lower", log=TRUE, batch=sce$tissue)
    summary(low.total.batched)

    ##    Mode   FALSE    TRUE
    ## logical    2996       9

In cases where entire batches contain a majority of low-quality cells, we can set `subset=` to only use high-quality batches for computing the thresholds. Those thresholds are then extrapolated back to the low-quality batches for some more stringent QC.

# 4 Filtering out low-quality cells

We could manually apply `isOutlier()` to all of our metrics, but it is easier to use `perCellQCFilters()` to do this for us. This identifies low-quality cells as those that are low outliers for the log-total count, low outliers for the log-number of detected features, or high outliers for the percentage of counts in specified gene sets (e.g., mitochondrial genes, spike-in transcripts). The `discard` column contains the final call for whether a particular cell should be discarded.

    # An example with just mitochondrial filters.
    qc.stats <- perCellQCFilters(per.cell, sub.fields="subsets_Mito_percent")
    colSums(as.matrix(qc.stats))

    ##              low_lib_size            low_n_features high_subsets_Mito_percent
    ##                         0                         3                       128
    ##                   discard
    ##                       131

    # Another example with mitochondrial + spike-in filters.
    qc.stats2 <- perCellQCFilters(per.cell,
        sub.fields=c("subsets_Mito_percent", "altexps_ERCC_percent"))
    colSums(as.matrix(qc.stats2))

    ##              low_lib_size            low_n_features high_subsets_Mito_percent
    ##                         0                         3                       128
    ## high_altexps_ERCC_percent                   discard
    ##                        65                       189

For typical scRNA-seq applications, `quickPerCellQC()` will wrap the `perCellQCMetrics()` and `perCellQCFilters()` calls. This returns a filtered `SingleCellExperiment` that can be immediately used in downstream analyses.

    filtered <- quickPerCellQC(sce, subsets=list(Mito=is.mito), sub.fields="subsets_Mito_percent")
    filtered

    ## class: SingleCellExperiment
    ## dim: 20006 2874
    ## metadata(0):
    ## assays(1): counts
    ## rownames(20006): Tspan12 Tshz1 ... mt-Rnr1 mt-Nd4l
    ## rowData names(1): featureType
    ## colnames(2874): 1772071015_C02 1772071017_G12 ... 1772063068_D01
    ##   1772066098_A12
    ## colData names(37): tissue group # ... high_subsets_Mito_percent discard
    ## reducedDimNames(0):
    ## mainExpName: gene
    ## altExpNames(2): repeat ERCC

The outlier-based approach adjusts to experiment-specific aspects of the data, e.g., sequencing depth, amount of spike-in RNA added, differences in total RNA content between cell types. In contrast, a fixed threshold would require manual adjustment to account for changes to the experimental protocol or system. We refer readers to the book for more details.

# 5 Computing feature-level statistics

Some basic feature-level statistics are computed by the `perFeatureQCMetrics()` function. This includes `mean`, the mean count of the gene/feature across all cells; `detected`, the percentage of cells with non-zero counts for each gene; `subsets_Y_ratio`, ratio of mean counts between the cell control set named Y and all cells.

    # Pretending that the first 10 cells are empty wells, for demonstration.
    per.feat <- perFeatureQCMetrics(sce, subsets=list(Empty=1:10))
    summary(per.feat$mean)

    ##      Min.   1st Qu.    Median      Mean   3rd Qu.      Max.
    ##   0.00067   0.00965   0.13378   0.74749   0.57629 732.15241

    summary(per.feat$detected)

    ##     Min.  1st Qu.   Median     Mean  3rd Qu.     Max.
    ##  0.03328  0.76539  9.01830 18.87800 31.24792 99.96672

    summary(per.feat$subsets_Empty_ratio)

    ##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
    ##   0.000   0.000   0.601   1.872   2.016 300.500

A more refined calculation of the average is provided by the `calculateAverage()` function, which adjusts the counts by the relative library size (or size factor) prior to taking the mean.

    ave <- calculateAverage(sce)
    summary(ave)

    ##      Min.   1st Qu.    Median      Mean   3rd Qu.      Max.
    ##   0.00021   0.01094   0.14432   0.74749   0.56737 850.68800

These metrics tend to be more useful for informing the analyst about the overall behavior of the experiment, rather than being explicitly used to filter out genes. For example, one would hope that the most abundant genes are the “usual suspects”, e.g., ribosomal proteins, actin, histones.

Users may wish to filter out very lowly or non-expressed genes to reduce the size of the dataset prior to downstream processing. However, be sure to publish the unfiltered count matrix! It is very difficult to re-use a count matrix where the features are filtered; information from the filtered features cannot be recovered, complicating integration with other datasets.

# Session information

    sessionInfo()

    ## R version 4.5.0 (2025-04-11 ucrt)
    ## Platform: x86_64-w64-mingw32/x64
    ## Running under: Windows Server 2022 x64 (build 20348)
    ##
    ## Matrix products: default
    ##   LAPACK version 3.12.1
    ##
    ## locale:
    ## [1] LC_COLLATE=C
    ## [2] LC_CTYPE=English_United States.utf8
    ## [3] LC_MONETARY=English_United States.utf8
    ## [4] LC_NUMERIC=C
    ## [5] LC_TIME=English_United States.utf8
    ##
    ## time zone: America/New_York
    ## tzcode source: internal
    ##
    ## attached base packages:
    ## [1] stats4    stats     graphics  grDevices utils     datasets  methods
    ## [8] base
    ##
    ## other attached packages:
    ##  [1] scran_1.37.0                ensembldb_2.33.0
    ##  [3] AnnotationFilter_1.33.0     GenomicFeatures_1.61.3
    ##  [5] AnnotationDbi_1.71.0        scuttle_1.19.0
    ##  [7] scRNAseq_2.23.0             SingleCellExperiment_1.31.0
    ##  [9] SummarizedExperiment_1.39.0 Biobase_2.69.0
    ## [11] GenomicRanges_1.61.0        GenomeInfoDb_1.45.4
    ## [13] IRanges_2.43.0              S4Vectors_0.47.0
    ## [15] BiocGenerics_0.55.0         generics_0.1.4
    ## [17] MatrixGenerics_1.21.0       matrixStats_1.5.0
    ## [19] BiocStyle_2.37.0
    ##
    ## loaded via a namespace (and not attached):
    ##   [1] DBI_1.2.3                bitops_1.0-9             httr2_1.1.2
    ##   [4] rlang_1.1.6              magrittr_2.0.3           gypsum_1.5.0
    ##   [7] compiler_4.5.0           RSQLite_2.4.0            png_0.1-8
    ##  [10] vctrs_0.6.5              ProtGenerics_1.41.0      pkgconfig_2.0.3
    ##  [13] crayon_1.5.3             fastmap_1.2.0            dbplyr_2.5.0
    ##  [16] XVector_0.49.0           Rsamtools_2.25.0         rmarkdown_2.29
    ##  [19] UCSC.utils_1.5.0         purrr_1.0.4              bit_4.6.0
    ##  [22] bluster_1.19.0           xfun_0.52                cachem_1.1.0
    ##  [25] beachmat_2.25.1          jsonlite_2.0.0           blob_1.2.4
    ##  [28] rhdf5filters_1.21.0      DelayedArray_0.35.1      Rhdf5lib_1.31.0
    ##  [31] BiocParallel_1.43.3      cluster_2.1.8.1          irlba_2.3.5.1
    ##  [34] parallel_4.5.0           R6_2.6.1                 bslib_0.9.0
    ##  [37] limma_3.65.1             rtracklayer_1.69.0       jquerylib_0.1.4
    ##  [40] Rcpp_1.0.14              bookdown_0.43            knitr_1.50
    ##  [43] igraph_2.1.4             Matrix_1.7-3             tidyselect_1.2.1
    ##  [46] abind_1.4-8              yaml_2.3.10              codetools_0.2-20
    ##  [49] curl_6.2.3               lattice_0.22-7           alabaster.sce_1.9.0
    ##  [52] tibble_3.2.1             withr_3.0.2              KEGGREST_1.49.0
    ##  [55] evaluate_1.0.3           BiocFileCache_2.99.5     alabaster.schemas_1.9.0
    ##  [58] ExperimentHub_2.99.5     Biostrings_2.77.1        pillar_1.10.2
    ##  [61] BiocManager_1.30.25      filelock_1.0.3           RCurl_1.98-1.17
    ##  [64] BiocVersion_3.22.0       sparseMatrixStats_1.21.0 alabaster.base_1.9.1
    ##  [67] glue_1.8.0               alabaster.ranges_1.9.0   metapod_1.17.0
    ##  [70] alabaster.matrix_1.9.0   lazyeval_0.2.2           tools_4.5.0
    ##  [73] AnnotationHub_3.99.5     BiocIO_1.19.0            BiocNeighbors_2.3.1
    ##  [76] ScaledMatrix_1.17.0      locfit_1.5-9.12          GenomicAlignments_1.45.0
    ##  [79] XML_3.99-0.18            rhdf5_2.53.1             grid_4.5.0
    ##  [82] edgeR_4.7.2              BiocSingular_1.25.0      HDF5Array_1.37.0
    ##  [85] restfulr_0.0.15          rsvd_1.0.5               cli_3.6.5
    ##  [88] rappdirs_0.3.3           S4Arrays_1.9.1           dplyr_1.1.4
    ##  [91] alabaster.se_1.9.0       sass_0.4.10              digest_0.6.37
    ##  [94] dqrng_0.4.1              SparseArray_1.9.0        rjson_0.2.23
    ##  [97] memoise_2.0.1            htmltools_0.5.8.1        lifecycle_1.0.4
    ## [100] h5mread_1.1.1            httr_1.4.7               statmod_1.5.0
    ## [103] bit64_4.6.0-1