Skip to contents

A single-cell analysis makes many versions of the same data: all droplets, the droplets that hold cells, the cells that pass quality control, the genes that vary most, a cell type re-analysed on its own. Usually each becomes a separate object, and each copy has to be kept in step with the others. With CITE-seq, which measures proteins with antibodies next to the RNA, each version also has a protein side.

This article runs a complete analysis of 5,000 peripheral blood mononuclear cells (PBMCs) from 10x Genomics, measured with RNA and 32 antibodies, and keeps every one of those versions as a subset of one ExperimentSubset object. A subset stores which rows and columns of its parent it uses, plus anything computed for it. It sees its parent’s assays without copying them. The antibody counts live in an alternative experiment of the main object, altExp("ADT"), and every subset sees them too, cut to its own cells.

The purpose of this article is to show how a whole analysis fits in one object, not to give a complete or definitive annotation of PBMCs. The quality-control thresholds, the cell-type labels and the protein checks are simple, common choices; a careful annotation would need more work, such as checking marker genes and proteins for each cluster.

The analysis takes about 20 minutes and downloads about 125 MB the first time it runs.

We store results in two ways, and each step says which it uses:

The names of the subsets, assays and reduced dimensions follow the MAMS conventions; the last section shows how they map.

The data

We use the 5k PBMC data set with a panel of TotalSeq-B antibodies, processed with Cell Ranger 3.1.0, from the DropletTestFiles package. It has two matrices: the raw matrix, with every barcode of the 10x whitelist, most of them empty, and the filtered matrix, with the droplets that Cell Ranger called as cells. Each has a row for every gene and for every antibody.

library(DropletTestFiles)

# download both matrices (cached after the first time)
rawFile <- getTestFile("tenx-3.1.0-5k_pbmc_protein_v3/1.0.0/raw.tar.gz")
filteredFile <- getTestFile(
  "tenx-3.1.0-5k_pbmc_protein_v3/1.0.0/filtered.tar.gz"
)

# unpack them into a temporary folder
dataFolder <- file.path(tempdir(), "pbmc5k")
untar(rawFile, exdir = dataFolder)
untar(filteredFile, exdir = dataFolder)
library(DropletUtils)

# read each matrix, naming the columns by barcode
rawFolder <- file.path(dataFolder, "raw_feature_bc_matrix")
filteredFolder <- file.path(dataFolder, "filtered_feature_bc_matrix")
raw <- read10xCounts(rawFolder, col.names = TRUE)
filtered <- read10xCounts(filteredFolder, col.names = TRUE)
dim(raw)
## [1]   33570 6794880
dim(filtered)
## [1] 33570  5247

We keep the raw matrix as it is, all 6,794,880 barcodes. Most were never seen in sequencing and have no counts at all: they are entries of the whitelist, not empty droplets. The matrix is sparse, so such a barcode costs almost nothing to store, and keeping everything is what ExperimentSubset is for.

The rows are named by Ensembl ID for genes and by antibody name for proteins. Gene symbols are easier to read, so we use them instead, keeping the ID where a symbol is repeated.

library(scuttle)

# use gene symbols as row names
featureIds <- rowData(raw)$ID
featureSymbols <- rowData(raw)$Symbol
featureNames <- uniquifyFeatureNames(featureIds, featureSymbols)
rownames(raw) <- featureNames

The Type column of the rowData says whether each row is a gene or an antibody. splitAltExps() keeps the genes in the main object and moves the antibodies to an alternative experiment, which we name ADT (antibody-derived tags). The antibody names end in _TotalSeqB, which we drop.

# the type of each row: gene or antibody
featureTypes <- rowData(raw)$Type
table(featureTypes)
## featureTypes
## Antibody Capture  Gene Expression 
##               32            33538
# genes in the main object, antibodies in the altExp "ADT"
raw <- splitAltExps(raw, featureTypes, ref = "Gene Expression")
altExpNames(raw) <- "ADT"

# shorter antibody names
adt <- altExp(raw, "ADT")
rownames(adt) <- sub("_TotalSeqB$", "", rownames(adt))
altExp(raw, "ADT") <- adt
rownames(adt)
##  [1] "CD3"           "CD4"           "CD8a"          "CD11b"        
##  [5] "CD14"          "CD15"          "CD16"          "CD19"         
##  [9] "CD20"          "CD25"          "CD27"          "CD28"         
## [13] "CD34"          "CD45RA"        "CD45RO"        "CD56"         
## [17] "CD62L"         "CD69"          "CD80"          "CD86"         
## [21] "CD127"         "CD137"         "CD197"         "CD274"        
## [25] "CD278"         "CD335"         "PD-1"          "HLA-DR"       
## [29] "TIGIT"         "IgG1_control"  "IgG2a_control" "IgG2b_control"

The last three antibodies are isotype controls, which bind nothing in particular; they measure the background.

One object for all droplets

ExperimentSubset() wraps the raw SingleCellExperiment. Its main assay, counts, holds every droplet, and its altExp ADT the antibody counts of every droplet. The cells Cell Ranger found become the first subset, filtered, chosen by barcode.

library(ExperimentSubset)

# all droplets in the main object
es <- ExperimentSubset(raw)

# the droplets Cell Ranger called as cells
cellBarcodes <- colnames(filtered)
es <- createSubset(es, subsetName = "filtered", cols = cellBarcodes)
es
## class: SubsetSingleCellExperiment 
## dim: 33538 6794880 
## metadata(1): Samples
## assays(1): counts
## rownames(33538): MIR1302-2HG FAM138A ... AC213203.1 FAM231C
## rowData names(3): ID Symbol Type
## colnames(6794880): AAACCCAAGAAACACT-1 AAACCCAAGAAACCAT-1 ...
##   TTTGTTGTCTTTGGCT-1 TTTGTTGTCTTTGTCG-1
## colData names(2): Sample Barcode
## reducedDimNames(0):
## mainExpName: Gene Expression
## altExpNames(1): ADT
## subsets(1): filtered
## subsetAssays(1): filtered

assay(es, "filtered") returns the counts of the filtered cells. They are not copied: the subset stores the column positions and reads the main object’s counts when asked. The same goes for the antibodies: the subset sees the main object’s ADT, cut to its cells.

# the antibody counts of the filtered cells, read from the main object
altExpNames(es, subsetName = "filtered")
## [1] "ADT"
filteredAdt <- altExp(es, "ADT", subsetName = "filtered")
filteredAdt
## class: SingleCellExperiment 
## dim: 32 5247 
## metadata(1): Samples
## assays(1): counts
## rownames(32): CD3 CD4 ... IgG2a_control IgG2b_control
## rowData names(3): ID Symbol Type
## colnames(5247): AAACCCAAGAGACAAG-1 AAACCCAAGGCCTAGA-1 ...
##   TTTGTTGCATTGCCGG-1 TTTGTTGGTCCGGCAT-1
## colData names(0):
## reducedDimNames(0):
## mainExpName: NULL
## altExpNames(0):

Finding cells among the droplets

On the main object. es is itself a SingleCellExperiment, so tools can run on it directly. singleCellTK’s runDropletQC() runs two other methods from DropletUtils on every droplet: emptyDrops, which tests each droplet against the ambient RNA profile, and barcodeRanks, which finds the knee in the curve of total counts. We leave out its third method, per-droplet QC metrics, which is slow on 6.8 million barcodes; we compute QC metrics for the cells below. emptyDrops uses random sampling, so we set a seed first.

library(singleCellTK)

# test every droplet; set a seed, since emptyDrops samples at random
set.seed(100)
es <- runDropletQC(es, algorithms = c("emptyDrops", "barcodeRanks"))

Each method gives its own set of cells: emptyDrops’ calls at a false discovery rate of 1%, and the droplets above barcodeRanks’ knee.

# droplets that emptyDrops calls as cells
dropletFdr <- colData(es)$dropletUtils_emptyDrops_fdr
emptyDropsCell <- !is.na(dropletFdr) & dropletFdr <= 0.01

# droplets above the knee of the barcode-rank curve
kneeCell <- colData(es)$dropletUtils_BarcodeRank_Knee == 1

# droplets that Cell Ranger called as cells
cellRangerCell <- colnames(es) %in% cellBarcodes
table(emptyDrops = emptyDropsCell, cellRanger = cellRangerCell)
##           cellRanger
## emptyDrops   FALSE    TRUE
##      FALSE 6789425     164
##      TRUE      208    5083
table(knee = kneeCell, cellRanger = cellRangerCell)
##        cellRanger
## knee      FALSE    TRUE
##   FALSE 6789633    2199
##   TRUE        0    3048

We keep each set of calls as a subset next to Cell Ranger’s filtered. A subset stores only the positions of its droplets, so the object barely grows.

# size of the object before
format(object.size(es), units = "MB")
## [1] "3412.1 Mb"
# emptyDrops' and barcodeRanks' cells as two more subsets
emptyDropsPositions <- which(emptyDropsCell)
kneePositions <- which(kneeCell)
es <- createSubset(es, subsetName = "emptydrops", cols = emptyDropsPositions)
es <- createSubset(es, subsetName = "barcoderanks", cols = kneePositions)

# size of the object after
format(object.size(es), units = "MB")
## [1] "3417.7 Mb"
# the number of genes and cells in each set of calls
subsetDim(es, subsetName = "filtered")
## [1] 33538  5247
subsetDim(es, subsetName = "emptydrops")
## [1] 33538  5291
subsetDim(es, subsetName = "barcoderanks")
## [1] 33538  3048

emptyDrops and Cell Ranger mostly agree; the knee is stricter and keeps fewer cells. We continue with Cell Ranger’s calls, the filtered subset; the others stay in the object for comparison.

Quality control of the cells

Experiment style. getSubsetExperiment() returns the filtered subset as an ordinary SingleCellExperiment, with the main object’s colData columns (the droplet QC above) and its ADT, both cut to the subset’s cells, included.

# the filtered cells as a SingleCellExperiment
filteredCells <- getSubsetExperiment(es, subsetName = "filtered")
filteredCells
## class: SingleCellExperiment 
## dim: 33538 5247 
## metadata(0):
## assays(1): counts
## rownames(33538): MIR1302-2HG FAM138A ... AC213203.1 FAM231C
## rowData names(3): ID Symbol Type
## colnames(5247): AAACCCAAGAGACAAG-1 AAACCCAAGGCCTAGA-1 ...
##   TTTGTTGCATTGCCGG-1 TTTGTTGGTCCGGCAT-1
## colData names(9): Sample Barcode ... dropletUtils_BarcodeRank_Knee
##   dropletUtils_BarcodeRank_Inflection
## reducedDimNames(0):
## mainExpName: Gene Expression
## altExpNames(1): ADT

singleCellTK’s runCellQC() computes per-cell QC metrics, including the percentage of counts from mitochondrial genes, and finds doublets with scDblFinder. Our row names are gene symbols, so we say so. scDblFinder samples at random; runCellQC() sets its own seed (seed = 12345 by default), so the results are the same on every run.

# QC metrics and doublet calls for each cell
filteredCells <- runCellQC(filteredCells,
                           algorithms = c("QCMetrics", "scDblFinder"),
                           mitoIDType = "symbol")

setSubsetExperiment() stores what runCellQC() added in the filtered subset. The counts and the antibody counts are unchanged, so they are not copied, and neither are the main object’s colData columns.

# store the QC results in the subset
es <- setSubsetExperiment(es, subsetName = "filtered", value = filteredCells)
colnames(colData(es, subsetName = "filtered"))
##  [1] "sum"                       "detected"                 
##  [3] "percent.top_50"            "percent.top_100"          
##  [5] "percent.top_200"           "percent.top_500"          
##  [7] "mito_sum"                  "mito_detected"            
##  [9] "mito_percent"              "total"                    
## [11] "scDblFinder_sample"        "scDblFinder_doublet_call" 
## [13] "scDblFinder_doublet_score" "scDblFinder_weighted"     
## [15] "scDblFinder_cxds_score"

Removing ambient RNA

Direct style. decontX’s decontX() estimates how much of each cell’s counts comes from ambient RNA, the free-floating RNA in every droplet, and removes it. It estimates the ambient profile from the cells themselves. Like runCellQC(), decontX() sets its own seed by default.

library(decontX)

# counts of the cells
cellCounts <- assay(es, "filtered")

# estimate and remove the ambient RNA
decontamination <- decontX(cellCounts)

The decontaminated counts become an assay of the filtered subset, and the estimated contamination becomes a colData column.

# store the decontaminated counts in the subset
decontaminatedCounts <- decontamination$decontXcounts
es <- setSubsetAssay(es, subsetName = "filtered",
                     inputMatrix = decontaminatedCounts,
                     subsetAssayName = "decontaminated")

# store each cell's contamination with its other QC columns
colData(es, subsetName = "filtered")$contamination <-
  decontamination$contamination

Keeping high-quality cells

These cells have more mitochondrial counts than many PBMC data sets: half of them have more than 9% (the median below). Most cells have up to about 15%, and a smaller group, probably damaged cells, has far more. We keep cells that have at least 300 detected genes, at most 15% of counts from mitochondrial genes, at most 30% contamination, and that scDblFinder calls singlets.

# the QC columns of the filtered cells
filteredData <- colData(es, subsetName = "filtered")
summary(filteredData$mito_percent)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##   0.000   7.230   8.995  14.786  13.335  93.907
# each QC rule
enoughGenes <- filteredData$detected >= 300
lowMito <- filteredData$mito_percent <= 15
lowContamination <- filteredData$contamination <= 0.3
singlet <- filteredData$scDblFinder_doublet_call == "Singlet"

# cells that pass all of them
keepCells <- enoughGenes & lowMito & lowContamination & singlet
table(keepCells)
## keepCells
## FALSE  TRUE 
##  1582  3665

The new subset, clean, is made from the decontaminated counts: its parentAssay is "filtered/decontaminated", the assay decontaminated stored in the subset filtered. It sees the antibody counts of its cells as well, still from the main object.

# the high-quality cells, from the decontaminated counts
cellsToKeep <- which(keepCells)
es <- createSubset(es, subsetName = "clean", cols = cellsToKeep,
                   parentAssay = "filtered/decontaminated")
subsetDim(es, subsetName = "clean")
## [1] 33538  3665

Clustering

Direct style. We follow a standard workflow with the scrapper package, storing each result in the clean subset or in its subset of variable genes.

Normalisation. Counts are divided by each cell’s size factor and log-transformed. delayed = FALSE computes the values now, rather than each time they are read.

library(scrapper)

# decontaminated counts of the clean cells
cleanCounts <- assay(es, "clean")

# size factors from each cell's total, then log-normalised values
cellTotals <- colSums(cleanCounts)
sizeFactors <- centerSizeFactors(cellTotals)
lognormalized <- normalizeCounts(cleanCounts, sizeFactors, delayed = FALSE)
es <- setSubsetAssay(es, subsetName = "clean",
                     inputMatrix = lognormalized,
                     subsetAssayName = "lognormalized")

Variable genes. We choose the 2,000 genes whose expression varies most across the clean cells, and keep them as a subset of rows, clean.variable, made from the log-normalised values.

# variance of each gene, and the 2,000 most variable
geneVariances <- modelGeneVariances(lognormalized)
geneResiduals <- geneVariances$statistics$residuals
variableGenes <- chooseHighlyVariableGenes(geneResiduals, top = 2000)

# the variable genes as a subset of rows
es <- createSubset(es, subsetName = "clean.variable", rows = variableGenes,
                   parentAssay = "clean/lognormalized")
variableLognormalized <- assay(es, "clean.variable")
dim(variableLognormalized)
## [1] 2000 3665

Scaling. Each variable gene’s log-normalised values are turned into z-scores across the clean cells: mean 0 and standard deviation 1. Only the variable genes are scaled: only they go into the PCA, and z-scores, which are rarely zero, take far more memory than the sparse log-normalised values. The z-scores are stored in the clean.variable subset.

# z-scores of each variable gene; scale() works on columns, so we
# transpose to one column per gene and back
variableDense <- as.matrix(variableLognormalized)
genesByColumn <- t(variableDense)
scaledByColumn <- scale(genesByColumn)
scaled <- t(scaledByColumn)
es <- setSubsetAssay(es, subsetName = "clean.variable",
                     inputMatrix = scaled,
                     subsetAssayName = "scaled")

PCA, clusters and UMAP. We keep the first 25 principal components (scrapper’s default) of the z-scores. They describe the clean cells, so we store them, the clusters and the UMAP in the clean subset. runPca() returns them with one column per cell, the layout the other scrapper functions take; reduced dimensions are stored with one row per cell, so we transpose them for storing. scrapper’s PCA, clustering and UMAP use fixed seeds by default, so they give the same results on every run.

# principal components of the z-scores of the variable genes
pca <- runPca(scaled)
cellPcs <- t(pca$components)
reducedDim(es, "reduction.pca", subsetName = "clean") <- cellPcs
# a nearest-neighbour graph of the cells, and its clusters
neighbourGraph <- buildSnnGraph(pca$components)
clusters <- clusterGraph(neighbourGraph)

# a two-dimensional UMAP embedding
umap <- runUmap(pca$components)
reducedDim(es, "embedding.umap", subsetName = "clean") <- umap

Annotating cell types

Direct style. SingleR labels each cell with the most similar cell type in a reference, here the Monaco immune reference from celldex.

library(SingleR)
library(celldex)

# the reference, and the predicted main cell type of each clean cell
monaco <- MonacoImmuneData()
cleanLognormalized <- assay(es, "clean/lognormalized")
predictions <- SingleR(test = cleanLognormalized, ref = monaco,
                       labels = monaco$label.main)

The clusters and labels go into the clean subset’s colData, and a few known marker genes are flagged in its rowData.

# clusters and labels in the subset's colData
colData(es, subsetName = "clean")$cluster <- clusters$membership
colData(es, subsetName = "clean")$label <- predictions$labels
table(predictions$labels)
## 
##         B cells    CD4+ T cells    CD8+ T cells Dendritic cells       Monocytes 
##             278            1065             173             116            1203 
##        NK cells         T cells 
##             315             515
# marker genes flagged in the subset's rowData
markers <- c("CD3E", "CD4", "CD8A", "MS4A1", "CD14", "FCGR3A", "NKG7",
             "LYZ", "PPBP")
cleanGenes <- subsetRownames(es, subsetName = "clean")
rowData(es, subsetName = "clean")$marker <- cleanGenes %in% markers

We take the subset out again to plot it with scater.

library(scater)

# the clean cells, coloured by cell type
cleanCells <- getSubsetExperiment(es, subsetName = "clean")
plotReducedDim(cleanCells, dimred = "embedding.umap", colour_by = "label")

UMAP of the clean cells, coloured by cell type

Removing ambient protein

Direct style. Antibody counts have a background too: antibodies that float free in the droplet or bind where they shouldn’t. decontX’s decontPro() estimates the background of each antibody in each cell and removes it. It needs a cell type for each cell, for which we use the RNA clusters, numbered from 1 as decontPro() expects.

# the antibody counts of the clean cells, still read from the main object
cleanAdt <- altExp(es, "ADT", subsetName = "clean")
cleanAdtCounts <- counts(cleanAdt)

# the RNA clusters as cell types, numbered from 1
clusterFactor <- factor(clusters$membership)
cellTypes <- as.integer(clusterFactor)

decontPro() can also learn the background from droplets that hold no cell: it uses each antibody’s share of their counts. The main object has every droplet, so they are at hand: we use the droplets that neither Cell Ranger nor emptyDrops called as cells but that have some antibody counts. Droplets that only one of them calls may hold a cell, whose antibodies aren’t background.

# the antibody counts of every droplet, and each droplet's total
allAdt <- altExp(es, "ADT")
allAdtCounts <- counts(allAdt)
adtTotals <- colSums(allAdtCounts)

# droplets that aren't cells but have antibody counts
notCell <- !cellRangerCell & !emptyDropsCell
isAmbient <- notCell & adtTotals > 0
sum(isAmbient)
## [1] 317164
ambientCounts <- allAdtCounts[, isAmbient]

decontPro() fits a model to the counts of the clean cells, which takes a few minutes and prints its progress, hidden here. It sets its own seed, so the results are the same on every run.

# estimate and remove the background of each antibody in each cell
proteinDecontamination <- decontPro(cleanAdtCounts, cellTypes,
                                    ambient_counts = ambientCounts)

# the decontaminated counts, with the antibody and cell names
decontaminatedAdt <- proteinDecontamination$decontaminated_counts
dimnames(decontaminatedAdt) <- dimnames(cleanAdtCounts)

The decontaminated antibody counts become an assay, decontPro, of the clean subset’s ADT. We add it to the object altExp() returned and store that back with altExp(es, "ADT", subsetName = "clean") <-. The subset stores only what is new: counts is unchanged, so it stays linked to the main object’s, and the object grows by about the size of the new matrix.

# size of the object before
format(object.size(es), units = "MB")
## [1] "3675.1 Mb"
# add the decontaminated counts to the clean cells' ADT and store it
assay(cleanAdt, "decontPro") <- decontaminatedAdt
altExp(es, "ADT", subsetName = "clean") <- cleanAdt

# size of the object after, and of the new matrix
format(object.size(es), units = "MB")
## [1] "3676.6 Mb"
format(object.size(decontaminatedAdt), units = "MB")
## [1] "1.2 Mb"

Normalisation. Antibody counts are commonly normalised with the centred log ratio (CLR): log-transform each count, then subtract the mean of the cell’s log counts, which takes out differences in each cell’s total. We store it as a second assay, clr, of the same ADT.

# log counts, minus each cell's mean log count
logAdt <- log1p(decontaminatedAdt)
cellMeans <- colMeans(logAdt)
clrAdt <- sweep(logAdt, MARGIN = 2, cellMeans)

# store it next to decontPro
assay(cleanAdt, "clr") <- clrAdt
altExp(es, "ADT", subsetName = "clean") <- cleanAdt

# the clean cells' ADT now has three assays
storedAdt <- altExp(es, "ADT", subsetName = "clean")
assayNames(storedAdt)
## [1] "counts"    "decontPro" "clr"

Checking the cell labels with protein

The cell types came from RNA alone. The antibodies give an independent check: T cells should carry CD3, CD4+ T cells CD4 and CD8+ T cells CD8a. getSubsetExperiment() returns the clean cells with their ADT, including the two new assays.

library(ggplot2)

# the clean cells, with their ADT
cleanCells <- getSubsetExperiment(es, subsetName = "clean")
cleanProtein <- altExp(cleanCells, "ADT")

# each cell's RNA label, next to its protein values
cleanProtein$label <- cleanCells$label

# CLR values by cell type, with the labels turned to fit
proteinPlot <- plotExpression(cleanProtein,
                              features = c("CD3", "CD4", "CD8a"),
                              x = "label", assay.type = "clr",
                              colour_by = "label")
slantedText <- element_text(angle = 45, hjust = 1)
slantedLabels <- theme(axis.text.x = slantedText)
proteinPlot + slantedLabels

CLR of CD3, CD4 and CD8a by cell type

# median CD3 protein in each cell type
cd3 <- assay(cleanProtein, "clr")["CD3", ]
tapply(cd3, cleanProtein$label, median)
##         B cells    CD4+ T cells    CD8+ T cells Dendritic cells       Monocytes 
##     -0.21100149      5.80974283      5.52746881      1.43082349     -0.09437538 
##        NK cells         T cells 
##      0.10469913      5.27590801

CD3 is high in all three T-cell labels and near zero in B cells and monocytes, and CD4+ T cells carry CD4 but not CD8a. The cells labelled just T cells split into those that carry CD4 and those that carry CD8a. The protein also shows what RNA missed: part of the cells labelled CD8+ T cells carry CD4 and not CD8a. Monocytes and dendritic cells carry some CD4 too, as they do in blood. A careful annotation would use both, for example by clustering on RNA and protein together.

Re-analysing the T cells

Experiment style. T cells are the largest group. We make them a subset of clean, then repeat the analysis on them alone.

# the clean cells labelled as T cells
tcellLabels <- c("CD4+ T cells", "CD8+ T cells", "T cells")
isTcell <- predictions$labels %in% tcellLabels
tcellPositions <- which(isTcell)
es <- createSubset(es, subsetName = "tcells", cols = tcellPositions,
                   parentAssay = "clean")
subsetDim(es, subsetName = "tcells")
## [1] 33538  1753

The new subset already sees the assays of clean, and those clean sees in turn, linked rather than copied:

subsetAssayNames(es, subsetName = "tcells")
## [1] "counts"         "decontaminated" "lognormalized"

A cell’s log-normalised values depend on its own counts and size factor, so the T cells reuse lognormalized from clean, as subclustering usually does. The variable genes and their z-scores depend on which cells are included, so we compute them again on the T cells.

# the T cells as a SingleCellExperiment, with the linked assays
tcells <- getSubsetExperiment(es, subsetName = "tcells")
assayNames(tcells)
## [1] "decontaminated" "counts"         "lognormalized"
# their ADT, with the counts, decontPro and clr of clean
tcellAdtSeen <- altExp(tcells, "ADT")
assayNames(tcellAdtSeen)
## [1] "counts"    "decontPro" "clr"
# the 2,000 genes that vary most among T cells
tcellLognormalized <- assay(tcells, "lognormalized")
tcellVariances <- modelGeneVariances(tcellLognormalized)
tcellResiduals <- tcellVariances$statistics$residuals
tcellVariableGenes <- chooseHighlyVariableGenes(tcellResiduals, top = 2000)
# z-scores of the T cells' variable genes, across the T cells only
tcellVariableLognormalized <- tcellLognormalized[tcellVariableGenes, ]
tcellDense <- as.matrix(tcellVariableLognormalized)
tcellGenesByColumn <- t(tcellDense)
tcellScaledByColumn <- scale(tcellGenesByColumn)
tcellScaled <- t(tcellScaledByColumn)
# principal components, stored with one row per cell
tcellPca <- runPca(tcellScaled)
tcellPcs <- t(tcellPca$components)
reducedDim(tcells, "reduction.pca") <- tcellPcs

# clusters of the T cells
tcellGraph <- buildSnnGraph(tcellPca$components)
tcellClusters <- clusterGraph(tcellGraph)
tcells$cluster <- tcellClusters$membership

# a UMAP embedding of the T cells
tcellUmap <- runUmap(tcellPca$components)
reducedDim(tcells, "embedding.umap") <- tcellUmap
# the T-cell part of the reference, so every cell gets a T-cell subtype
isTcellReference <- monaco$label.main %in% tcellLabels
tcellReference <- monaco[, isTcellReference]

# finer T-cell subtypes
tcellPredictions <- SingleR(test = tcellLognormalized, ref = tcellReference,
                            labels = tcellReference$label.fine)
tcells$subtype <- tcellPredictions$labels
table(tcells$subtype)
## 
##    Central memory CD8 T cells   Effector memory CD8 T cells 
##                            15                            17 
##     Follicular helper T cells                    MAIT cells 
##                            21                            73 
##             Naive CD4 T cells             Naive CD8 T cells 
##                           726                            79 
##            Non-Vd2 gd T cells            T regulatory cells 
##                            80                           103 
## Terminal effector CD4 T cells Terminal effector CD8 T cells 
##                            29                            25 
##                     Th1 cells                Th1/Th17 cells 
##                            35                           192 
##                    Th17 cells                     Th2 cells 
##                            76                            94 
##                Vd2 gd T cells 
##                           188

setSubsetExperiment() stores the reduced dimensions and colData columns we added in the tcells subset. The assays and the ADT are unchanged, so they stay linked and nothing is copied.

# store the T-cell results
es <- setSubsetExperiment(es, subsetName = "tcells", value = tcells)

As for the clean cells, the T cells’ variable genes become a subset of rows, tcells.variable, which stores their z-scores.

# the T cells' variable genes, and their z-scores
es <- createSubset(es, subsetName = "tcells.variable",
                   rows = tcellVariableGenes,
                   parentAssay = "tcells/lognormalized")
es <- setSubsetAssay(es, subsetName = "tcells.variable",
                     inputMatrix = tcellScaled,
                     subsetAssayName = "scaled")

clean.variable and tcells.variable each store their own scaled: an assay stored in one subset doesn’t clash with one of the same name in another.

# the T cells, coloured by subtype
tcellCells <- getSubsetExperiment(es, subsetName = "tcells")
plotReducedDim(tcellCells, dimred = "embedding.umap", colour_by = "subtype")

UMAP of the T cells, coloured by T-cell subtype

Removing ambient protein in the T cells alone

decontPro() fits each antibody’s background and its true signal to the cell types it is given. The T cells now have clusters of their own, finer than the clean cells’ clusters, so we run it again on the T cells alone, with those clusters and the same ambient droplets.

# the T cells' antibody counts, and their clusters numbered from 1
tcellAdt <- altExp(es, "ADT", subsetName = "tcells")
tcellAdtCounts <- counts(tcellAdt)
tcellClusterFactor <- factor(tcells$cluster)
tcellTypes <- as.integer(tcellClusterFactor)

# estimate and remove the background, on the T cells only
tcellProteinDecontamination <- decontPro(tcellAdtCounts, tcellTypes,
                                         ambient_counts = ambientCounts)
tcellDecontaminatedAdt <-
  tcellProteinDecontamination$decontaminated_counts
dimnames(tcellDecontaminatedAdt) <- dimnames(tcellAdtCounts)

# CLR of the new values
tcellLogAdt <- log1p(tcellDecontaminatedAdt)
tcellCellMeans <- colMeans(tcellLogAdt)
tcellClrAdt <- sweep(tcellLogAdt, MARGIN = 2, tcellCellMeans)

# both replace the ones the T cells see from clean
assay(tcellAdt, "decontPro") <- tcellDecontaminatedAdt
assay(tcellAdt, "clr") <- tcellClrAdt

The T cells see decontPro and clr linked from clean. Storing values of their own under the same names would break that link without saying so, so it is an error, which names the linked assays:

# store the T cells' own decontPro and clr
altExp(es, "ADT", subsetName = "tcells") <- tcellAdt
## Error in `.checkUnlinked()`:
## ! 'ADT/decontPro' in subset 'tcells' is linked to altExp 'ADT' of subset 'clean' and 'ADT/clr' to altExp 'ADT' of subset 'clean'. Set unlink = TRUE or unlink = c("ADT/decontPro", "ADT/clr") to store separate 'ADT/decontPro' and 'ADT/clr' computed on the cells of 'tcells'.

unlink names the altExp assays to unlink, as "ADT/decontPro". The T cells then store their own decontPro and clr, with a message for each, such as “Subset ‘tcells’ now stores its own ‘ADT/decontPro’, unlinked from altExp ‘ADT’ of subset ‘clean’.” Subsets made from the T cells will use these; clean’s are unchanged, and counts stays linked to the main object.

# store them, unlinked from clean's
tcellUnlink <- c("ADT/decontPro", "ADT/clr")
altExp(es, "ADT", subsetName = "tcells", unlink = tcellUnlink) <- tcellAdt

The whole analysis in one object

subsetSummary() lists every subset, what it was made from, the assays and reduced dimensions it stores, and the altExps it sees. AltExperiments doesn’t say whether a subset stores any assays of an altExp: clean and tcells store their own decontPro and clr, the others none.

## Main assay(s):
##  counts 
## 
## Subset(s):
##              Name         Dim
## 1        filtered 33538, 5247
## 2      emptydrops 33538, 5291
## 3    barcoderanks 33538, 3048
## 4           clean 33538, 3665
## 5  clean.variable  2000, 3665
## 6          tcells 33538, 1753
## 7 tcells.variable  2000, 1753
##                                                                                     Parent
## 1                                                                                   counts
## 2                                                                                   counts
## 3                                                                                   counts
## 4                                            filtered/decontaminated -> filtered -> counts
## 5            clean/lognormalized -> clean -> filtered/decontaminated -> filtered -> counts
## 6                                   clean -> filtered/decontaminated -> filtered -> counts
## 7 tcells/lognormalized -> tcells -> clean -> filtered/decontaminated -> filtered -> counts
##           Assays AltExperiments                   ReducedDims
## 1 decontaminated            ADT                              
## 2                           ADT                              
## 3                           ADT                              
## 4  lognormalized            ADT reduction.pca, embedding.umap
## 5         scaled            ADT                              
## 6                           ADT reduction.pca, embedding.umap
## 7         scaled            ADT

All of this lives in one object. The subsets store row and column positions, and only what was computed for them: decontaminated in filtered; lognormalized, and decontPro and clr of ADT, in clean; scaled in the two variable-gene subsets; the T cells’ own decontPro and clr; and the colData, rowData and reduced dimensions added along the way. A linked assay, such as the T cells’ lognormalized or their antibody counts, is only built when it is read.

# the whole object
format(object.size(es), units = "MB")
## [1] "3710.1 Mb"
# the RNA counts of all barcodes, stored once
allCounts <- assay(es, "counts")
format(object.size(allCounts), units = "MB")
## [1] "761.9 Mb"
# the same counts without the barcode names
unnamedCounts <- allCounts
dimnames(unnamedCounts) <- NULL
format(object.size(unnamedCounts), units = "MB")
## [1] "189.5 Mb"

The counts themselves are small: a barcode with no counts takes 4 bytes. Most of the object’s size is the 6.8 million barcode names, in the column names of the counts and of ADT and in the colData, followed by the droplet QC columns that runDropletQC() added for every barcode. object.size() counts each name again in every vector that holds it, so it overstates the memory the object uses.

Kept as separate objects instead, the three sets of cell calls, the clean cells and the T cells would each carry their own copy of the RNA and antibody counts, and the T cells another copy of the log-normalised values.

How this maps to MAMS

MAMS (Matrix and Analysis Metadata Standards) describes the matrices and annotations a single-cell analysis produces. The names used here follow its suggested values where it has them (filtered, clean, variable, counts, decontaminated, lognormalized, scaled, reduction, embedding); emptydrops, barcoderanks and tcells are our own names for subsets, ADT is the usual name for antibody counts, and decontPro and clr are named after the methods.

In this article ExperimentSubset MAMS
filtered, emptydrops, barcoderanks, clean, tcells subsets of columns obs_subset
clean.variable, tcells.variable subsets of rows feature_subset (variable)
counts, decontaminated, lognormalized, scaled assays processing
ADT an altExp, seen by every subset a matrix with analyte protein
decontPro, clr of ADT assays of the altExp processing (decontaminated, lognormalized)
reduction.pca, embedding.umap reducedDims processing (reduction, embedding)
parentAssay, e.g. "filtered/decontaminated" the assay or subset a subset was made from parent_id
a subset made from its parent’s rows or columns createSubset() parent_relationship (subset)

Session information

## R version 4.6.1 (2026-06-24)
## Platform: x86_64-pc-linux-gnu
## Running under: Ubuntu 24.04.5 LTS
## 
## Matrix products: default
## BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
## LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so;  LAPACK version 3.12.0
## 
## locale:
##  [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
##  [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8    
##  [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
##  [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
##  [9] LC_ADDRESS=C               LC_TELEPHONE=C            
## [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       
## 
## time zone: Etc/UTC
## tzcode source: system (glibc)
## 
## attached base packages:
## [1] stats4    stats     graphics  grDevices utils     datasets  methods  
## [8] base     
## 
## other attached packages:
##  [1] scater_1.41.3                   ggplot2_4.0.3                  
##  [3] celldex_1.23.0                  SingleR_2.15.4                 
##  [5] scrapper_1.7.11                 decontX_1.11.2                 
##  [7] singleCellTK_2.23.6             DelayedArray_0.39.8            
##  [9] SparseArray_1.13.4              S4Arrays_1.13.2                
## [11] abind_1.4-8                     Matrix_1.7-6                   
## [13] ExperimentSubset_1.23.50        TreeSummarizedExperiment_2.21.0
## [15] Biostrings_2.81.9               XVector_0.53.0                 
## [17] SpatialExperiment_1.23.0        scuttle_1.23.2                 
## [19] DropletUtils_1.33.2             SingleCellExperiment_1.35.2    
## [21] SummarizedExperiment_1.43.0     Biobase_2.73.2                 
## [23] GenomicRanges_1.65.4            Seqinfo_1.3.2                  
## [25] IRanges_2.47.5                  S4Vectors_0.51.10              
## [27] BiocGenerics_0.59.12            generics_0.1.4                 
## [29] MatrixGenerics_1.25.0           matrixStats_1.5.0              
## [31] DropletTestFiles_1.23.0        
## 
## loaded via a namespace (and not attached):
##   [1] fs_2.1.0                  bitops_1.1-0             
##   [3] httr_1.4.9                RColorBrewer_1.1-3       
##   [5] doParallel_1.0.17         alabaster.base_1.13.5    
##   [7] tools_4.6.1               R6_2.6.1                 
##   [9] HDF5Array_1.41.3          lazyeval_0.2.3           
##  [11] rhdf5filters_1.25.5       GetoptLong_1.1.1         
##  [13] sp_2.2-4                  withr_3.0.3              
##  [15] gridExtra_2.3.1           progressr_1.1.0          
##  [17] cli_3.6.6                 textshaping_1.0.5        
##  [19] labeling_0.4.3            alabaster.se_1.13.1      
##  [21] eds_1.15.0                sass_0.4.10              
##  [23] S7_0.2.2                  QuickJSR_1.11.0          
##  [25] pkgdown_2.2.1             Rsamtools_2.29.0         
##  [27] systemfonts_1.3.2         yulab.utils_0.2.5        
##  [29] StanHeaders_2.39.1        dbscan_1.2.7             
##  [31] R.utils_2.13.0            parallelly_1.48.0        
##  [33] limma_3.99.4              RSQLite_3.53.3           
##  [35] shape_1.4.6.1             BiocIO_1.23.3            
##  [37] combinat_0.0-9            dplyr_1.2.1              
##  [39] inline_0.3.21             loo_2.10.1               
##  [41] ggbeeswarm_0.7.3          R.methodsS3_1.8.2        
##  [43] lifecycle_1.0.5           yaml_2.3.12              
##  [45] edgeR_4.99.12             rhdf5_2.57.20            
##  [47] BiocFileCache_3.3.2       grid_4.6.1               
##  [49] blob_1.3.0                dqrng_0.4.1              
##  [51] ExperimentHub_3.3.2       crayon_1.5.3             
##  [53] lattice_0.23-1            cowplot_1.2.0            
##  [55] beachmat_2.29.3           cigarillo_1.3.1          
##  [57] annotate_1.91.0           KEGGREST_1.53.6          
##  [59] magick_2.9.1              pillar_1.11.1            
##  [61] knitr_1.52                ComplexHeatmap_2.29.0    
##  [63] rjson_0.2.23              xgboost_3.2.1.1          
##  [65] future.apply_1.21.0       codetools_0.2-20         
##  [67] glue_1.8.1                V8_8.2.1                 
##  [69] data.table_1.18.6.1       gypsum_1.9.0             
##  [71] spam_2.11-4               vctrs_0.7.3              
##  [73] png_0.1-9                 treeio_1.37.1            
##  [75] gtable_0.3.6              cachem_1.1.0             
##  [77] xfun_0.62                 GSVAdata_1.49.2          
##  [79] iterators_1.0.14          statmod_1.5.2            
##  [81] bluster_1.23.1            nlme_3.1-171             
##  [83] bit64_4.8.6               alabaster.ranges_1.13.1  
##  [85] filelock_1.0.3            rstan_2.32.7             
##  [87] GenomeInfoDb_1.49.1       bslib_0.12.0             
##  [89] irlba_2.4.1               vipor_0.4.7              
##  [91] otel_0.2.0                colorspace_2.1-4         
##  [93] DBI_1.3.0                 tidyselect_1.2.1         
##  [95] bit_4.6.0                 compiler_4.6.1           
##  [97] curl_8.1.0                httr2_1.3.0              
##  [99] graph_1.91.0              BiocNeighbors_2.7.3      
## [101] h5mread_1.5.3             desc_1.4.3               
## [103] rtracklayer_1.73.0        scales_1.4.0             
## [105] rappdirs_0.3.4            stringr_1.6.0            
## [107] digest_0.6.39             alabaster.matrix_1.13.4  
## [109] rmarkdown_2.32            htmltools_0.5.9          
## [111] pkgconfig_2.0.3           sparseMatrixStats_1.25.0 
## [113] dbplyr_2.6.0              fastmap_1.2.0            
## [115] rlang_1.3.0               GlobalOptions_0.1.4      
## [117] htmlwidgets_1.6.4         UCSC.utils_1.9.0         
## [119] DelayedMatrixStats_1.35.0 farver_2.1.2             
## [121] jquerylib_0.1.4           jsonlite_2.0.0           
## [123] BiocParallel_1.47.0       R.oo_1.27.1              
## [125] BiocSingular_1.29.2       RCurl_1.98-1.20          
## [127] magrittr_2.0.5            dotCall64_1.2            
## [129] Rhdf5lib_2.1.0            Rcpp_1.1.2               
## [131] ape_5.8-1                 viridis_0.6.5            
## [133] reticulate_1.47.0         alabaster.schemas_1.13.0 
## [135] stringi_1.8.9             MCMCprecision_0.4.2      
## [137] MASS_7.3-66               AnnotationHub_4.3.3      
## [139] plyr_1.8.9                pkgbuild_1.4.8           
## [141] parallel_4.6.1            listenv_1.1.0            
## [143] ggrepel_0.9.8             scDblFinder_1.27.12      
## [145] circlize_0.4.18           igraph_2.3.4             
## [147] reshape2_1.4.5            ScaledMatrix_1.21.0      
## [149] rstantools_2.7.1          BiocVersion_3.24.0       
## [151] XML_3.99-0.25             evaluate_1.0.5           
## [153] SeuratObject_5.4.0        RcppParallel_6.2.1       
## [155] BiocManager_1.30.27       foreach_1.5.2            
## [157] tidyr_1.3.2               purrr_1.2.2              
## [159] future_1.80.0             clue_0.3-68              
## [161] BiocBaseUtils_1.15.1      rsvd_1.0.6               
## [163] xtable_1.8-8              restfulr_0.0.17          
## [165] tidytree_0.4.8            viridisLite_0.4.3        
## [167] ragg_1.5.2                tibble_3.3.1             
## [169] memoise_2.0.1             beeswarm_0.4.0           
## [171] AnnotationDbi_1.75.2      GenomicAlignments_1.49.2 
## [173] cluster_2.1.8.3           globals_0.20.0           
## [175] GSEABase_1.75.0