Analysis of single-cell genomic data with celda

knitr::opts_chunk$set(echo = TRUE, dev = "png")

Introduction

CEllular Latent Dirichlet Allocation (celda) is a collection of Bayesian hierarchical models to perform feature and cell bi-clustering for count data generated by single-cell platforms. This algorithm is an extension of the Latent Dirichlet Allocation (LDA) topic modeling framework that has been popular in text mining applications and has shown good performance with sparse data. celda simultaneously clusters features (i.e. gene expression) into modules based on co-expression patterns across cells and cells into subpopulations based on the probabilities of the feature modules within each cell.

In this vignette we will demonstrate how to use celda to perform cell and feature clustering with a simple simulated dataset.

Installation

celda can be installed from Bioconductor:

if (!requireNamespace("BiocManager", quietly=TRUE)){
    install.packages("BiocManager")}
BiocManager::install("celda")

The package can be loaded using the library command.

library(celda)

Complete list of help files are accessible using the help command with a package option.

help(package = celda)

To see the latest updates and releases or to post a bug, see our GitHub page at https://github.com/campbio/celda. To ask questions about running celda, post a thread on Bioconductor support site at https://support.bioconductor.org/.


Reproducibility note

Many functions in celda make use of stochastic algorithms or procedures which require the use of random number generator (RNG) for simulation or sampling. To maintain reproducibility, all these functions use a default seed of 12345 to make sure same results are generated each time one of these functions is called. Explicitly setting the seed arguments is needed for greater control and randomness.

Generation of a simulated single cell dataset

celda will take a matrix of counts where each row is a feature, each column is a cell, and each entry in the matrix is the number of counts of each feature in each cell. To illustrate the utility of celda, we will apply it to a simulated dataset.

In the function simulateCells, the K parameter designates the number of cell clusters, the L parameter determines the number of feature modules, the S parameter determines the number of samples in the simulated dataset, the G parameter determines the number of features to be simulated, and CRange specifies the lower and upper bounds of the number of cells to be generated in each sample.

simCounts <- simulateCells("celda_CG",
    K = 5, L = 10, S = 5, G = 200, CRange = c(30, 50))

The counts variable contains the counts matrix. The dimensions of counts matrix:

dim(simCounts$counts)

The z variable contains the cluster labels for each cell. Here is the number of cells in each subpopulation:

table(simCounts$z)

The y variable contains the feature module assignment for each feature. Here is the number of features in each feature module:

table(simCounts$y)

The sampleLabel is used to denote the sample from which each cell was derived. In this simulated dataset, we have 5 samples:

table(simCounts$sampleLabel)

Performing bi-clustering with celda

There are currently three models within this package: celda_C will cluster cells, celda_G will cluster features, and celda_CG will simultaneously cluster cells and features. Within the function the K parameter will be the number of cell populations to be estimated, while the L parameter will be the number of feature modules to be estimated in the output model.

celdaModel <- celda_CG(counts = simCounts$counts,
    K = 5, L = 10, verbose = FALSE)

Here is a comparison between the true cluster labels and the estimated cluster labels, which can be found within the z and y objects.

table(clusters(celdaModel)$z, simCounts$z)
table(clusters(celdaModel)$y, simCounts$y)

Matrix factorization

celda can also be thought of as performing matrix factorization of the original counts matrix into smaller matrices that describe the contribution of each feature in each module, each module in each cell population, or each cell population in each sample. Each of these following matrices can be viewed as raw counts, proportions, or posterior probabilities.

factorized <- factorizeMatrix(counts = simCounts$counts, celdaMod = celdaModel)
names(factorized)

The cell object contains each feature module's contribution to each cell subpopulation. Here, there are 10 feature modules to r ncol(factorized$proportions$cell) cells.

dim(factorized$proportions$cell)
head(factorized$proportions$cell[, seq(3)], 5)

The cellPopulation contains each feature module's contribution to each of the cell states. Since K and L were set to be 5 and 10, there are 5 cell subpopulations to 10 feature modules.

cellPop <- factorized$proportions$cellPopulation
dim(cellPop)
head(cellPop, 5)

The module object contains each feature's contribution to the module it belongs.

dim(factorized$proportions$module)
head(factorized$proportions$module, 5)

The top features in a feature module can be selected using the topRank function on the module matrix:

topGenes <- topRank(matrix = factorized$proportions$module,
    n = 10, threshold = NULL)
topGenes$names$L1

Visualization

Creating an expression heatmap using the celda model

The clustering results can be viewed with a heatmap of the normalized counts using the function celdaHeatmap. The top nfeatures in each module will be selected according to the factorized module probability matrix.

celdaHeatmap(counts = simCounts$counts, celdaMod = celdaModel, nfeatures = 10)

Plotting cell populations with tSNE

celda contains its own wrapper function for tSNE, celdaTsne, which can be used to embed cells into 2-dimensions. The output can be used in the downstream plotting functions plotDimReduceCluster, plotDimReduceModule, and plotDimReduceFeature to show cell population clusters, module probabilities, and expression of a individual features, respectively.

tsne <- celdaTsne(counts = simCounts$counts, celdaMod = celdaModel)
plotDimReduceCluster(dim1 = tsne[, 1],
    dim2 = tsne[, 2],
    cluster = clusters(celdaModel)$z)

plotDimReduceModule(dim1 = tsne[, 1],
    dim2 = tsne[, 2],
    celdaMod = celdaModel,
    counts = simCounts$counts,
    rescale = TRUE)

plotDimReduceFeature(dim1 = tsne[, 1],
    dim2 = tsne[, 2],
    counts = simCounts$counts,
    normalize = TRUE,
    features = "Gene_1")

Displaying relationships between modules and cell populations

The relationships between feature modules and cell populations can be visualized with celdaProbabilityMap. The absolute probabilities of each feature module in each cellular subpopulation is shown on the left. The normalized and z-scored expression of each module in each cell population is shown on the right.

celdaProbabilityMap(counts = simCounts$counts, celdaMod = celdaModel)

Examining co-expression with module heatmaps

moduleHeatmap creates a heatmap using only the features from a specific feature module. Cells are ordered from those with the lowest probability of the module to the highest. If more than one module is used, then cells will be ordered by the probabilities of the first module.

moduleHeatmap(counts = simCounts$counts, celdaMod = celdaModel,
    featureModule = 1, topCells = 100)

While celdaHeatmap will plot a heatmap directly with a celda object, the plotHeatmap function is a more general heatmap function which takes a normalized expression matrix as the input. Simple normalization of the counts matrix can be performed with the normalizeCounts function. For instance, if users want to display specific modules and cell populations, the featureIx and cells.ix parameters can be used to select rows and columns out of the matrix.

genes <- c(topGenes$names$L1, topGenes$names$L10)
geneIx <- which(rownames(simCounts$counts) %in% genes)
normCounts <- normalizeCounts(counts = simCounts$counts, scaleFun = scale)
plotHeatmap(counts = normCounts,
    z = clusters(celdaModel)$z,
    y = clusters(celdaModel)$y,
    featureIx = geneIx,
    showNamesFeature = TRUE)

Differential Expression Analysis

celda employs the MAST package (McDavid A, 2018) for differential expression analysis of single-cell data. The differentialExpression function can determine features that are differentially expressed in one cell subpopulation versus all other subpopulations, between two individual cell subpopulations, or between different groups of cell populations.

If you wish to compare one cell subpopulation compared to all others, leave c2 as NULL.

diffexpClust1 <- differentialExpression(counts = simCounts$counts,
    celdaMod = celdaModel,
    c1 = 1,
    c2 = NULL)

head(diffexpClust1, 5)

If you wish to compare two cell subpopulations, use both c1 and c2 parameters.

diffexpClust1vs2 <- differentialExpression(
    counts = simCounts$counts,
    celdaMod = celdaModel,
    c1 = 1,
    c2 = 2)

diffexpClust1vs2 <- diffexpClust1vs2[diffexpClust1vs2$FDR < 0.05 &
    abs(diffexpClust1vs2$Log2_FC) > 2, ]
head(diffexpClust1vs2, 5)

The differentially expressed genes can be visualized further with a heatmap:

diffexpGeneIx <- which(rownames(simCounts$counts) %in% diffexpClust1vs2$Gene)

normCounts <- normalizeCounts(counts = simCounts$counts, scaleFun = scale)
plotHeatmap(counts = normCounts[, clusters(celdaModel)$z %in% c(1, 2)],
    clusterCell = TRUE,
    z = clusters(celdaModel)$z[clusters(celdaModel)$z %in% c(1, 2)],
    y = clusters(celdaModel)$y,
    featureIx = diffexpGeneIx,
    showNamesFeature = TRUE)

Identifying the optimal number of cell subpopulations and feature modules

In the previous example, the best K (the number of cell clusters) and L (the number of feature modules) was already known. However, the optimal K and L for each new dataset will likely not be known beforehand and multiple choices of K and L may need to be tried and compared. celda offers two sets of functions to determine the optimum K and L, recursiveSplitModule/recursiveSplitCell, and celdaGridSearch.

recursiveSplitModule/recursiveSplitCell

Functions recursiveSplitModule and recursiveSplitCell offer a fast method to generate a celda model with optimum K and L.

First, recursiveSplitModule is used to determine the optimal L. recursiveSplitModule first splits features into however many modules are specified in initialL. The module labels are then recursively split in a way that would generate the highest log likelihood, all the way up to maxL.

moduleSplit <- recursiveSplitModule(counts = simCounts$counts,
    initialL = 2, maxL = 15)

Perplexity is a statistical measure of how well a probability model can predict new data. Lower perplexity indicates a better model. The perplexity of each model can be visualized with plotGridSearchPerplexity. In general, visual inspection of the plot can be used to select the optimal number of modules (L) or cell populations (K) by identifying the "elbow" - where the rate of decrease in the perplexity starts to drop off.

plotGridSearchPerplexity(celdaList = moduleSplit)

In this example, the perplexity for L stops decreasing at L = 10, thus L = 10 would be a good choice.

Once you have identified the optimal L (in this case, L is selected to be 10), the module labels are used for intialization in recursiveSplitCell. Similarly to recursiveSplitModule, cells are initially split into a small number of subpopulations, and the subpopulations are recursively split up by log-likelihood.

moduleSplitSelect <- subsetCeldaList(moduleSplit, params = list(L = 10))

cellSplit <- recursiveSplitCell(counts = simCounts$counts,
    initialK = 3,
    maxK = 12,
    yInit = clusters(moduleSplitSelect)$y)
plotGridSearchPerplexity(celdaList = cellSplit)

In this plot, the perplexity for K stops decreasing at K = 5, with a final K/L combination of K = 5, L = 10. Generally, this method can be used to pick a reasonable L and a potential range of K. However, manual review of specific selections of K is often be required to ensure results are biologically coherent.

Once users have chosen the K/L parameters for further analysis, the subsetCeldaList function can be used to subset the celda_list object to a single model.

celdaModel <- subsetCeldaList(celdaList = cellSplit,
    params = list(K = 5, L = 10))

celdaGridSearch

celda is able to run multiple combinations of K and L with multiple chains in parallel via the celdaGridSearch function. Setting verbose to TRUE will print the output of each model to a text file.

The resamplePerplexity function "perturbs" the original counts matrix by resampling the counts of each cell according to its normalized probability distribution. Perplexity is calculated on the resampled matrix and the procedure is repeated resample times. These results can be visualized with plotGridSearchPerplexity. The major goal is to pick the lowest K and L combination with relatively good perplexity. In general, visual inspection of the plot can be used to select the number of modules (L) or cell populations (K) where the rate of decrease in the perplexity starts to drop off.

cgs <- celdaGridSearch(simCounts$counts,
    paramsTest = list(K = seq(4, 6), L = seq(9, 11)),
    cores = 1,
    model = "celda_CG",
    nchains = 2,
    maxIter = 100,
    verbose = FALSE,
    bestOnly = TRUE)

bestOnly = TRUE indicates that only the chain with the best log likelihood will be returned for each K/L combination.

resamplePerplexity calculates the perplexity of each model's cluster assignments, as well as resamplings of that count matrix. The result of this function can be visualized with plotGridSearchPerplexity for determination of the optimal K/L values.

cgs <- resamplePerplexity(counts = simCounts$counts,
    celdaList = cgs, resample = 5)
plotGridSearchPerplexity(celdaList = cgs)

In this example, the perplexity for L stops decreasing at L = 10 for the majority of K values. For the line corresponding to L = 10, the perplexity stops decreasing at K = 5. Thus L = 10 and K = 5 would be a good choice. Again, manual review of specific selections of K is often be required to ensure results are biologically coherent.

Once users have chosen the K/L parameters for further analysis, the subsetCeldaList function can be used to subset the celda_list object to a single model.

celdaModel <- subsetCeldaList(celdaList = cgs, params = list(K = 5, L = 10))

If the "bestOnly" parameter is set to FALSE in the celdaGridSearch, then the selectBestModel function can be used to select the chains with the lowest log likelihoods within each combination of parameters. Alternatively, users can use select a specific chain by specifying the index within the subsetCeldaList function.

cgs <- celdaGridSearch(simCounts$counts,
    paramsTest = list(K = seq(4, 6), L = seq(9, 11)),
    cores = 1,
    model = "celda_CG",
    nchains = 2,
    maxIter = 100,
    verbose = FALSE,
    bestOnly = FALSE)

cgs <- resamplePerplexity(counts = simCounts$counts,
    celdaList = cgs,
    resample = 2)

cgsK5L10 <- subsetCeldaList(celdaList = cgs, params = list(K = 5, L = 10))

celdaModel1 <- selectBestModel(celdaList = cgsK5L10)

Miscellaneous utility functions

celda also contains several utility functions for the users' convenience.

featureModuleLookup

featureModuleLookup can be used to look up the module a specific feature was clustered to.

featureModuleLookup(counts = simCounts$counts, celdaMod = celdaModel,
    feature = c("Gene_99"))

recodeClusterZ, recodeClusterY

recodeClusterZ and recodeClusterY allows the user to recode the cell and feature cluster labels, respectively.

celdaModelZRecoded <- recodeClusterZ(celdaMod = celdaModel,
    from = c(1, 2, 3, 4, 5), to = c(2, 1, 3, 4, 5))

The model prior to reordering cell labels compared to after reordering cell labels:

table(clusters(celdaModel)$z, clusters(celdaModelZRecoded)$z)

Session Information

sessionInfo()


Try the celda package in your browser

Any scripts or data that you put into this service are public.

celda documentation built on June 9, 2020, 2 a.m.