Chapter 11 Single-nuclei RNA-seq processing

11.1 Introduction

Single-nuclei RNA-seq (snRNA-seq) provides another strategy for performing single-cell transcriptomics where individual nuclei instead of cells are captured and sequenced. The major advantage of snRNA-seq over scRNA-seq is that the former does not require the preservation of cellular integrity during sample preparation, especially dissociation. We only need to extract nuclei in an intact state, meaning that snRNA-seq can be applied to cell types, tissues and samples that are not amenable to dissociation and later processing. The cost of this flexibility is the loss of transcripts that are primarily located in the cytoplasm, potentially limiting the availability of biological signal for genes with little nuclear localization.

The computational analysis of snRNA-seq data is very much like that of scRNA-seq data. We have a matrix of (UMI) counts for genes by cells that requires quality control, normalization and so on. (Technically, the columsn correspond to nuclei but we will use these two terms interchangeably in this chapter.) In fact, the biggest difference in processing occurs in the construction of the count matrix itself, where intronic regions must be included in the annotation for each gene to account for the increased abundance of unspliced transcripts. The rest of the analysis only requires a few minor adjustments to account for the loss of cytoplasmic transcripts. We demonstrate using a dataset from Wu et al. (2019) involving snRNA-seq on healthy and fibrotic mouse kidneys.

sce <- WuKidneyData()
sce <- sce[,sce$Technology=="sNuc-10x"]
## class: SingleCellExperiment 
## dim: 18249 8231 
## metadata(0):
## assays(1): counts
## rownames(18249): mt-Cytb mt-Nd6 ... Gm44613 Gm38304
## rowData names(0):
## colnames: NULL
## colData names(4): CellBarcode CellType Technology Status
## reducedDimNames(0):
## mainExpName: NULL
## altExpNames(0):

11.2 Quality control for stripped nuclei

The loss of the cytoplasm means that the stripped nuclei should not contain any mitochondrial transcripts. This means that the mitochondrial proportion becomes an excellent QC metric for the efficacy of the stripping process. Unlike scRNA-seq, there is no need to worry about variations in mitochondrial content due to genuine biology. High-quality nuclei should not contain any mitochondrial transcripts; the presence of any mitochondrial counts in a library indicates that the removal of the cytoplasm was not complete, possibly introducing irrelevant heterogeneity in downstream analyses.

sce <- addPerCellQC(sce, subsets=list(Mt=grep("^mt-", rownames(sce))))
summary(sce$subsets_Mt_percent == 0)
##    Mode   FALSE    TRUE 
## logical    2264    5967

We apply a simple filter to remove libraries corresponding to incompletely stripped nuclei. The outlier-based approach described in Section 12.3 can be used here, but some caution is required in low-coverage experiments where a majority of cells have zero mitochondrial counts. In such cases, the MAD may also be zero such that other libraries with very low but non-zero mitochondrial counts are removed. This is typically too conservative as such transcripts may be present due to sporadic ambient contamination rather than incomplete stripping.

stats <- quickPerCellQC(colData(sce), sub.fields="subsets_Mt_percent")
##            low_lib_size          low_n_features high_subsets_Mt_percent 
##                       0                       0                    2264 
##                 discard 
##                    2264

Instead, we enforce a minimum difference between the threshold and the median in isOutlier() (Figure 11.1). We arbitrarily choose +0.5% here, which takes precedence over the outlier-based threshold if the latter is too low. In this manner, we avoid discarding libraries with a very modest amount of contamination; the same code will automatically fall back to the outlier-based threshold in datasets where the stripping was systematically less effective.

stats$high_subsets_Mt_percent <- isOutlier(sce$subsets_Mt_percent, 
    type="higher", min.diff=0.5)
stats$discard <- Reduce("|", stats[,colnames(stats)!="discard"])
##            low_lib_size          low_n_features high_subsets_Mt_percent 
##                       0                       0                      42 
##                 discard 
##                      42
plotColData(sce, x="Status", y="subsets_Mt_percent",
Distribution of the mitochondrial proportions in the Wu kidney dataset. Each point represents a cell and is colored according to whether it was considered to be of low quality and discarded.

Figure 11.1: Distribution of the mitochondrial proportions in the Wu kidney dataset. Each point represents a cell and is colored according to whether it was considered to be of low quality and discarded.

11.3 Comments on downstream analyses

The rest of the analysis can then be performed using the same strategies discussed for scRNA-seq (Figure 11.2). Despite the loss of cytoplasmic transcripts, there is usually still enough biological signal to characterize population heterogeneity (Bakken et al. 2018; Wu et al. 2019). In fact, one could even say that snRNA-seq has a higher signal-to-noise ratio as sequencing coverage is not spent on highly abundant but typically uninteresting transcripts for mitochondrial and ribosomal protein genes. It also has the not inconsiderable advantage of being able to recover subpopulations that are not amenable to dissociation and would be lost by scRNA-seq protocols.


sce <- logNormCounts(sce[,!stats$discard])
dec <- modelGeneVarByPoisson(sce)
sce <- runPCA(sce, subset_row=getTopHVGs(dec, n=4000))
sce <- runTSNE(sce, dimred="PCA")

colLabels(sce) <- clusterRows(reducedDim(sce, "PCA"), NNGraphParam())
    plotTSNE(sce, colour_by="label", text_by="label"),
    plotTSNE(sce, colour_by="Status"),
$t$-SNE plots of the Wu kidney dataset. Each point is a cell and is colored by its cluster assignment (left) or its disease status (right).

Figure 11.2: \(t\)-SNE plots of the Wu kidney dataset. Each point is a cell and is colored by its cluster assignment (left) or its disease status (right).

We can also apply more complex procedures such as batch correction (Multi-sample Chapter 1). Here, we eliminate the disease effect to identify shared clusters (Figure 11.3).


merged <- multiBatchNorm(sce, batch=sce$Status)
merged <- correctExperiments(merged, batch=merged$Status, PARAM=FastMnnParam())
merged <- runTSNE(merged, dimred="corrected")
colLabels(merged) <- clusterRows(reducedDim(merged, "corrected"), NNGraphParam())

    plotTSNE(merged, colour_by="label", text_by="label"),
    plotTSNE(merged, colour_by="batch"),
More $t$-SNE plots of the Wu kidney dataset after applying MNN correction across diseases.

Figure 11.3: More \(t\)-SNE plots of the Wu kidney dataset after applying MNN correction across diseases.

Similarly, we can perform marker detection on the snRNA-seq expression values as discussed in Basic Chapter 6. For the most part, interpretation of these DE results makes the simplifying assumption that nuclear abundances are a good proxy for the overall expression profile. This is generally reasonable but may not always be true, resulting in some discrepancies in the marker sets between snRNA-seq and scRNA-seq datasets. For example, transcripts for strongly expressed genes might localize to the cytoplasm for efficient translation and subsequently be lost upon stripping, while genes with the same overall expression but differences in the rate of nuclear export may appear to be differentially expressed between clusters. In the most pathological case, higher snRNA-seq abundances may indicate nuclear sequestration of transcripts for protein-coding genes and reduced activity of the relevant biological process, contrary to the usual interpretation of the effect of upregulation.

markers <- findMarkers(merged, block=merged$Status, direction="up")
## DataFrame with 10 rows and 3 columns
##              Top      p.value          FDR
##        <integer>    <numeric>    <numeric>
## Sorcs1         1 8.31936e-262 1.51820e-258
## Ltn1           1  7.85490e-83  1.07778e-80
## Bmp6           1  0.00000e+00  0.00000e+00
## Il34           1  0.00000e+00  0.00000e+00
## Them7          1 9.46508e-208 8.22516e-205
## Pak1           1 1.35170e-184 8.22241e-182
## Kcnip4         1  0.00000e+00  0.00000e+00
## Mecom          1  5.30105e-20  7.11838e-19
## Pakap          1  0.00000e+00  0.00000e+00
## Wdr17          1 1.34668e-202 1.11707e-199
plotTSNE(merged, colour_by="Kcnip4")

Other analyses described for scRNA-seq require more care when they are applied to snRNA-seq data. Most obviously, cell type annotation based on reference profiles (Basic Chapter 7) should be treated with some caution as the majority of existing references are constructed from bulk or single-cell datasets with cytoplasmic transcripts. Interpretation of RNA velocity results may also be complicated by variation in the rate of nuclear export of spliced transcripts.

11.4 Tricks with ambient contamination

The expected absence of genuine mitochondrial expression can also be exploited to estimate the level of ambient contamination (Multi-sample Chapter 5). We demonstrate on mouse brain snRNA-seq data from 10X Genomics (Zheng et al. 2017), using the raw count matrix prior to any filtering for nuclei-containing barcodes.

raw.path <- getTestFile("tenx-2.0.1-nuclei_900/1.0.0/raw.tar.gz")
out.path <- file.path(tempdir(), "nuclei")
untar(raw.path, exdir=out.path)

fname <- file.path(out.path, "raw_gene_bc_matrices/mm10")
sce.brain <- read10xCounts(fname, col.names=TRUE)
## class: SingleCellExperiment 
## dim: 27998 737280 
## metadata(1): Samples
## assays(1): counts
## rownames(27998): ENSMUSG00000051951 ENSMUSG00000089699 ...
##   ENSMUSG00000096730 ENSMUSG00000095742
## rowData names(2): ID Symbol
## colData names(2): Sample Barcode
## reducedDimNames(0):
## mainExpName: NULL
## altExpNames(0):

We call non-empty droplets using emptyDrops() as previously described (Section 7.2).

e.out <- emptyDrops(counts(sce.brain))
summary(e.out$FDR <= 0.001)
##    Mode   FALSE    TRUE    NA's 
## logical    2317    1719  733244

If our libraries are of high quality, we can assume that any mitochondrial “expression” is due to contamination from the ambient solution. We then use the controlAmbience() function to estimate the proportion of ambient contamination for each gene, allowing us to mark potentially problematic genes in the DE results (Figure 11.4). In fact, we can use this information even earlier to remove these genes during dimensionality reduction and clustering. This is not generally possible for scRNA-seq as any notable contaminating transcripts may originate from a subpopulation that actually expresses that gene and thus cannot be blindly removed.

ambient <- estimateAmbience(counts(sce.brain), round=FALSE, good.turing=FALSE)
nuclei <- rowSums(counts(sce.brain)[,which(e.out$FDR <= 0.001)])

is.mito <- grepl("mt-", rowData(sce.brain)$Symbol)
contam <- controlAmbience(nuclei, ambient, features=is.mito, mode="proportion")

plot(log10(nuclei+1), contam*100, col=ifelse(is.mito, "red", "grey"), pch=16,
    xlab="Log-nuclei expression", ylab="Contamination (%)")
Percentage of counts in the nuclei of the 10X brain dataset that are attributed to contamination from the ambient solution. Each point represents a gene and mitochondrial genes are highlighted in red.

Figure 11.4: Percentage of counts in the nuclei of the 10X brain dataset that are attributed to contamination from the ambient solution. Each point represents a gene and mitochondrial genes are highlighted in red.

Session Info

R version 4.4.0 (2024-04-24)
Platform: x86_64-pc-linux-gnu
Running under: Ubuntu 22.04.4 LTS

Matrix products: default
BLAS:   /home/biocbuild/bbs-3.19-bioc/R/lib/ 
LAPACK: /usr/lib/x86_64-linux-gnu/lapack/

 [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
 [3] LC_TIME=en_GB              LC_COLLATE=C              
 [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
 [9] LC_ADDRESS=C               LC_TELEPHONE=C            

time zone: America/New_York
tzcode source: system (glibc)

attached base packages:
[1] stats4    stats     graphics  grDevices utils     datasets  methods  
[8] base     

other attached packages:
 [1] DropletUtils_1.24.0         DropletTestFiles_1.14.0    
 [3] batchelor_1.20.0            bluster_1.14.0             
 [5] scran_1.32.0                scater_1.32.0              
 [7] ggplot2_3.5.1               scuttle_1.14.0             
 [9] scRNAseq_2.18.0             SingleCellExperiment_1.26.0
[11] SummarizedExperiment_1.34.0 Biobase_2.64.0             
[13] GenomicRanges_1.56.0        GenomeInfoDb_1.40.1        
[15] IRanges_2.38.0              S4Vectors_0.42.0           
[17] BiocGenerics_0.50.0         MatrixGenerics_1.16.0      
[19] matrixStats_1.3.0           BiocStyle_2.32.0           
[21] rebook_1.14.0              

loaded via a namespace (and not attached):
  [1] BiocIO_1.14.0             bitops_1.0-7             
  [3] filelock_1.0.3            R.oo_1.26.0              
  [5] tibble_3.2.1              CodeDepends_0.6.6        
  [7] graph_1.82.0              XML_3.99-0.16.1          
  [9] lifecycle_1.0.4           httr2_1.0.1              
 [11] edgeR_4.2.0               lattice_0.22-6           
 [13] ensembldb_2.28.0          alabaster.base_1.4.1     
 [15] magrittr_2.0.3            limma_3.60.2             
 [17] sass_0.4.9                rmarkdown_2.27           
 [19] jquerylib_0.1.4           yaml_2.3.8               
 [21] metapod_1.12.0            cowplot_1.1.3            
 [23] DBI_1.2.3                 ResidualMatrix_1.14.0    
 [25] abind_1.4-5               zlibbioc_1.50.0          
 [27] Rtsne_0.17                purrr_1.0.2              
 [29] R.utils_2.12.3            AnnotationFilter_1.28.0  
 [31] RCurl_1.98-1.14           rappdirs_0.3.3           
 [33] GenomeInfoDbData_1.2.12   ggrepel_0.9.5            
 [35] irlba_2.3.5.1             alabaster.sce_1.4.0      
 [37] dqrng_0.4.1               DelayedMatrixStats_1.26.0
 [39] codetools_0.2-20          DelayedArray_0.30.1      
 [41] tidyselect_1.2.1          UCSC.utils_1.0.0         
 [43] farver_2.1.2              ScaledMatrix_1.12.0      
 [45] viridis_0.6.5             BiocFileCache_2.12.0     
 [47] GenomicAlignments_1.40.0  jsonlite_1.8.8           
 [49] BiocNeighbors_1.22.0      tools_4.4.0              
 [51] Rcpp_1.0.12               glue_1.7.0               
 [53] gridExtra_2.3             SparseArray_1.4.8        
 [55] xfun_0.44                 dplyr_1.1.4              
 [57] HDF5Array_1.32.0          gypsum_1.0.1             
 [59] withr_3.0.0               BiocManager_1.30.23      
 [61] fastmap_1.2.0             rhdf5filters_1.16.0      
 [63] fansi_1.0.6               digest_0.6.35            
 [65] rsvd_1.0.5                R6_2.5.1                 
 [67] mime_0.12                 colorspace_2.1-0         
 [69] RSQLite_2.3.7             R.methodsS3_1.8.2        
 [71] utf8_1.2.4                generics_0.1.3           
 [73] rtracklayer_1.64.0        httr_1.4.7               
 [75] S4Arrays_1.4.1            pkgconfig_2.0.3          
 [77] gtable_0.3.5              blob_1.2.4               
 [79] XVector_0.44.0            htmltools_0.5.8.1        
 [81] bookdown_0.39             ProtGenerics_1.36.0      
 [83] scales_1.3.0              alabaster.matrix_1.4.0   
 [85] png_0.1-8                 knitr_1.47               
 [87] rjson_0.2.21              curl_5.2.1               
 [89] cachem_1.1.0              rhdf5_2.48.0             
 [91] BiocVersion_3.19.1        parallel_4.4.0           
 [93] vipor_0.4.7               AnnotationDbi_1.66.0     
 [95] restfulr_0.0.15           pillar_1.9.0             
 [97] grid_4.4.0                alabaster.schemas_1.4.0  
 [99] vctrs_0.6.5               BiocSingular_1.20.0      
[101] dbplyr_2.5.0              beachmat_2.20.0          
[103] cluster_2.1.6             beeswarm_0.4.0           
[105] evaluate_0.23             GenomicFeatures_1.56.0   
[107] cli_3.6.2                 locfit_1.5-9.9           
[109] compiler_4.4.0            Rsamtools_2.20.0         
[111] rlang_1.1.4               crayon_1.5.2             
[113] labeling_0.4.3            ggbeeswarm_0.7.2         
[115] alabaster.se_1.4.1        viridisLite_0.4.2        
[117] BiocParallel_1.38.0       munsell_0.5.1            
[119] Biostrings_2.72.1         lazyeval_0.2.2           
[121] Matrix_1.7-0              dir.expiry_1.12.0        
[123] ExperimentHub_2.12.0      sparseMatrixStats_1.16.0 
[125] bit64_4.0.5               Rhdf5lib_1.26.0          
[127] KEGGREST_1.44.0           statmod_1.5.0            
[129] alabaster.ranges_1.4.1    highr_0.11               
[131] AnnotationHub_3.12.0      igraph_2.0.3             
[133] memoise_2.0.1             bslib_0.7.0              
[135] bit_4.0.5                


Bakken, T. E., R. D. Hodge, J. A. Miller, Z. Yao, T. N. Nguyen, B. Aevermann, E. Barkan, et al. 2018. “Single-nucleus and single-cell transcriptomes compared in matched cortical cell types.” PLoS ONE 13 (12): e0209648.

Wu, H., Y. Kirita, E. L. Donnelly, and B. D. Humphreys. 2019. “Advantages of Single-Nucleus over Single-Cell RNA Sequencing of Adult Kidney: Rare Cell Types and Novel Cell States Revealed in Fibrosis.” J. Am. Soc. Nephrol. 30 (1): 23–32.

Zheng, G. X., J. M. Terry, P. Belgrader, P. Ryvkin, Z. W. Bent, R. Wilson, S. B. Ziraldo, et al. 2017. “Massively parallel digital transcriptional profiling of single cells.” Nat Commun 8 (January): 14049.