--- title: "scECODA tutorial" author: "Christian Halter" date: "`r Sys.Date()`" output: BiocStyle::html_document: toc_float: true BiocStyle::pdf_document: default editor_options: markdown: wrap: 80 bibliography: references.bib vignette: > %\VignetteIndexEntry{scECODA tutorial} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include=FALSE} # Solution for the "magick package is required to crop" error: knitr::opts_chunk$set( echo = TRUE, # keep showing code chunks message = FALSE, warning = FALSE, # This is the line that fixes the cropping issue: crop = NULL ) ``` # Introduction The scECODA package provides functions for *s*ingle-*c*ell *E*xploratory *CO*mpositional *D*ata *A*nalysis at the population scale. It enables users to explore their dataset by visualizing the sample structure and how groups of samples relate to each other by providing numerous convenient plotting and clustering functions. Additionally, it provides metrics to quantify how strongly groups of samples separate and which cell types are driving this separation (differential abundance analysis). ## Motivation for Bioconductor Integration While many tools exist for supervised comparative single-cell analysis, there is a distinct need for a standardized framework that handles unsupervised **exploratory compositional data analysis (ECODA)** specifically for large-scale cohorts. `scECODA` is motivated by the following goals for the Bioconductor ecosystem: - **Interoperability**: The package natively supports core Bioconductor classes, including `SingleCellExperiment` and `SummarizedExperiment`, allowing seamless integration into existing pipelines. - **Infrastructure Synergy**: It leverages the established Bioconductor tool `DESeq2` for pseudobulk normalization. -------------------------------------------------------------------------------- # Installation You can install the latest stable release of `scECODA` from Bioconductor. ```{r Install the scECODA package, eval=FALSE} if (!require("BiocManager", quietly = TRUE)) install.packages("BiocManager") BiocManager::install("scECODA") ``` ```{r Load scECODA} library(scECODA) ``` -------------------------------------------------------------------------------- # Create SummarizedExperiment objects for ECODA You can create an SummarizedExperiment object from a single-cell data (Seurat or SingleCellExperiment object) or from a data frame containing cell counts or frequencies (in % per sample) with `ecoda()`. Based on the input, the following data and transformations will be automatically calculated: - Cell counts per sample ("counts") - Relative abundance in % ("freq") - Centered log-ratio (CLR) transformed abundance ("clr") - Arc-sine square root transformed abundance ("asin_sqrt") - Optionally: you can get the pseudobulk gene expression per sample, normalized with DESeq2 ("pb") - Metadata Additionally, it will calculate useful metrics, such as: - The variance of each cell type across all samples (in CLR value) in a data frame. It also contains their average abundance (in CLR) and cumulative variance explained. - The top *n* most highly variable cell types (HVCs) ("top_n_hvcs"). The number of HVCs (*n*) is automatically determined by selecting the most highly variable cell types explaining approximately 50% of the total variance (sum of variances of all cell types). Alternatively, the user can choose the number of cell types *n* or a variance explained threshold. - The names of the top *n* most highly variable cell types (HVCs) ("hvcs") - The variance explained by the top_n_hvcs ("variance_explained") - Sample distance matrix ("sample_distances") **NOTE:** To calculate CLR-transformed cell type abundances, zero counts or relative abundances are not allowed (log of zero is not defined). By default, scECODA adds a pseudocount of +0.5 to all values if a counts matrix is provided; when the input is a frequency matrix, we add 2/3 times the smallest frequency value as suggested in [@martín-fernández2003]. ## From SingleCellExperiment object Load an example dataset from the `scRNAseq` BioC library. ```{r Load example SingleCellExperiment dataset, message=FALSE, warning=FALSE} # Loading an example SingleCellExperiment dataset library(scRNAseq) all.ds <- as.data.frame(surveyDatasets()) ds <- 1 sce_object <- fetchDataset( all.ds[["name"]][ds], all.ds[["version"]][ds] ) names(colData(sce_object)) ``` To represent this dataset in terms of cell type composition, we will use the cell type labels provided by the authors and stored in the "cluster" metadata field. We will do this for each individual sample ("sample" metadata field). The `ecoda()` function will do this for you: ```{r Create ECODA object} # Create ECODA object se <- ecoda( sce_object, sample_col = "sample", celltype_col = "cluster" ) ``` The resulting object stores row counts, frequencies and several transformations for the cell type composition by sample. For instance, the `clr` assay contains centered log-ratio values for the cell types in each sample: ```{r Show example of CLR df} assay(se, "clr")[1:5,1:5] ``` ## From Seurat object Alternatively, scECODA also supports generating compositional matrices from Seurat objects: ```{r Create ECODA object from Seurat object} # Commented out because Seurat depends on igraph which has not been updated # to work with R 4.6 yet. # library(Seurat) # # counts <- counts(sce_object) # metadata <- as.data.frame(SummarizedExperiment::colData(sce_object)) # # seurat_object <- CreateSeuratObject(counts = counts, meta.data = metadata) # # se <- ecoda( # seurat_object, # sample_col = "sample", # celltype_col = "cluster" # ) ``` ## From counts or frequency data frame If you have a pre-calculated sample x cell type compositional matrix (either as counts or raw frequencies), you can use them directly as input to scECODA. Metadata can be optionally provided and used downstream for visualization. In this example, we load the immune cell composition in samples from a breast cancer cohort [@zhang2021] **NOTES:** - If you create an SummarizedExperiment ECODA object from a data frame containing relative abundances (in %), please use percentage values (e.g. 30 for 30%) and not as decimals (NOT 0.3 for 30%). If the relative abundances sum up to 100%, they will be automatically re-scaled to 100 and you will be informed with a message. - If you do add metadata, **it is important to make sure that the counts/frequency and the metadata data frames have the same column names**. ```{r Load example datasets} # Load example datasets data(example_data) Zhang <- example_data$Zhang counts <- Zhang$cell_counts_lowresolution freq <- calc_freq(counts) metadata <- Zhang$metadata se <- ecoda(data = counts, metadata = metadata) se <- ecoda(data = freq, metadata = metadata) ``` -------------------------------------------------------------------------------- # Visualization and quantification of sample separation ## PCA For explorative analysis, Principal Component Analysis (PCA) can be very useful to find the major axes of variation between samples. You can plot a PCA of your se with `plot_pca(se)`. ```{r Show PCA based on cell type composition, message=FALSE, warning=FALSE} se <- ecoda( data = example_data$Zhang$cell_counts_lowresolution, metadata = example_data$Zhang$metadata, ) plot_pca( se, label_col = "Tissue", title = "PCA based on cell type composition", n_hv_feat_show = 5 # Shows the most highly variable features (cell types) ) ``` Not all cell types have informative composition (some may be very rare, others constant across samples). It is often more robust to restrict the analysis on highly-variable cell types (HVCs); for example, PCA can be calculated on HVCs: ```{r PCA based on highly variable cell types} plot_pca( se, assay = "clr_hvc", label_col = "Tissue", title = "PCA based on highly variable cell types", n_hv_feat_show = nrow(metadata(se)$clr_hvc) ) ``` Optionally, we can also add an extra dimension and plot the first 3 principal components: ```{r 3D PCA} plot_pca3d(se, label_col = "Tissue") ``` ## Quantifying group separation Sample separation (based on a user-defined grouping) can be quantified with different metrics, for example: - Analysis of similarities (ANOSIM) - Adjusted rand index (ARI) - Modularity score - Average silhouette score (not recommended as it expects gaussian cluster shapes and is not robust to outliers. ANOSIM and modularity are more robust.) ```{r Quantifying group separation, message=FALSE, warning=FALSE} # You can get the separation scores like this: dist_mat <- dist(assay(se, "clr")) # Or use the pre-calculated distance matrix based on the CLR abundances: dist_mat <- metadata(se)$sample_distances labels <- colData(se)$Tissue anosim_r_score <- calc_anosim(dist_mat, labels) ari_score <- calc_ari(dist_mat, labels) # modularity_score <- calc_modularity(dist_mat, labels) silhouette_score <- calc_sil(dist_mat, labels) print(paste("ANOSIM R:", anosim_r_score)) print(paste("ARI:", ari_score)) # print(paste("Modularity:", modularity_score)) print(paste("Silhouette:", silhouette_score)) ``` ## Box and bar plots Box plots are another useful tool to visualize the distribution of cell types across samples: ```{r Box plot} plot_boxplot(se, title = "CLR Abundance by Cell Type") ``` If there are biological groupings of interest, we can directly perform statistical tests between the per-sample cell type compositions: ```{r Grouped box plot} plot_boxplot( se, label_col = "Tissue", title = "CLR Abundance by Cell Type (with Wilcoxon Test)" ) ``` This can also be plotted as stacked bar plots but it is a bit harder to read. ```{r Bar plot by group} # Plotting average cell type abundance by experimental group plot_barplot( se, label_col = "Tissue", plot_by = "group", title = "Mean Relative Abundance by Condition" ) ``` ```{r Bar plot by sample} # Plotting cell type abundance for each sample separately plot_barplot( se, label_col = "Tissue", plot_by = "sample", title = "Relative Abundance for Each Sample" ) ``` **NOTE:** It is highly recommended to only do statistical testing on log-ratio-transformed compositional data and not on counts or relative abundances in % [@greenacre2021]. The main focus of scECODA is **exploratory** (unsupervised) analysis of samples based on cell type composition. If you want to do down-stream comparative (supervised) differential abundance testing of *a priori known groups* (including statistical designs with multiple known covariates), there are other tools available for that purpose, e.g. scCODA [@büttner2021] or tascCODA [@tasccoda]. ## Heatmap The clustered heatmap plot is a good alternative to not only visualize how samples and cell types correlate but also to cluster samples. It is important not to re-scale in order to avoid amplifying tiny differences. Also, it is possible that you see your samples forming separate clusters in the PCA or heatmap even though you do not find any (or only few) significant differences in cell type composition. However, there might be small differences among a large number of cell types which, taken all together, can still drive sample separation and explain differences between your biological groups. NOTE: you can specify multiple metadata columns in "label_col" to visualize how samples group across multiple covariates simultaneously. Here is an example for a simple dataset: ```{r Plot heatmap example 1} plot_heatmap( se, label_col = c("Clinical.efficacy.", "Tissue"), cutree_rows = 3, cutree_cols = 3 ) ``` We see, for example, an enrichment of plasma cells in the tumor samples, while neutrophils and platelets are more abundant in blood samples. As a second example, here's the same analysis using a large healthy cohort with 868 PBMC samples and 69 cell types [@gong2024]: ```{r Plot heatmap example 2, fig.height=16, fig.width=12} se <- ecoda( data = example_data$GongSharma_full$cell_counts_highresolution, metadata = example_data$GongSharma_full$metadata ) plot_heatmap( se, label_col = c("subject.cmv", "age_group"), # Additional arguments for pheatmap: cutree_rows = 3, cutree_cols = 5, show_colnames = FALSE ) ``` Subsetting to only the most highly variable cell types (HVCs) yields a very similar sample clustering, while being much more interpretable. ```{r Plot heatmap example 2 only HVCs} plot_heatmap( se, assay = "clr_hvc", label_col = c("subject.cmv", "age_group"), # Additional arguments for pheatmap: cutree_rows = 3, cutree_cols = 4, show_colnames = FALSE ) ``` Interestingly, we see that cell type composition tends to cluster subjects, in an unsupervised manner, based on their age group and their CMV status. For example, naive cell types appear to be enriched in younger subjects that were not exposed to CMV infection. ## Cell type correlation The cell type correlation plot offers the possibility to visualize which cell types correlate with each other, highlighting possible microenvironment hubs that might be of relevance to explain possible groups of samples in your data: ```{r Correlation plot, fig.height=16, fig.width=16} plot_corr(se) ``` While the correlation plot below showing only the HVCs is easier to read, it is nevertheless interesting to see the full correlation plot above. It seems that there is additional information that might be overlooked: Besides the main cell type clusters of effector memory cells (separating samples by cytomegalovirus (CMV) infection status) and SOX4+ naive T cells (separating samples by age), there seems to be a cluster of ISG+ cell types, memory T cells and monocytes/DCs whose abundance strongly correlate, respectively. ```{r Correlation plot only HVCs, fig.height=16, fig.width=16} plot_corr(se, assay = "clr_hvc") ``` ## Highly variable cell types As demonstrated in the heatmap of the extensive Gong & Sharma dataset, not all cell subtypes contribute equally to sample separation. Cell types at the top exhibit **high variance** across samples, indicating they are the main drivers of group separation, while those toward the bottom show minor variation. This principle is directly analogous to the selection of highly variable genes (HVGs) in (sc)RNA-seq data: **high variance is a powerful proxy for biological relevance.** We expect cell types that are differentially abundant between key biological groups to display this increased variance. scECODA provides convenient functions to calculate and visualize these cell type variances and mean abundance across all samples: ```{r Mean-variance plot 50 percent} library(ggplot2) plot_varmean(se, labels = "none") + ggtitle("Default - cell types explaining 50% variance") ``` ```{r Mean-variance plot 80 percent} se_new <- find_hvcs(se, variance_explained = 0.8) plot_varmean(se_new) + ggtitle("Cell types explaining 80% variance") ``` ```{r Mean-variance plot top 5 HVCs} se_new <- find_hvcs(se, top_n_hvcs = 5) plot_varmean(se_new) + ggtitle("Top 5 HVCs") ``` # scECODA and pseudobulk **ECODA is much more interpretable** (10-100 cell types) compared to gene expression (2'000 - 20'000 genes). However, pseudobulk analysis has the advantage of not requiring cell type annotations. The scECODA package provides convenient functions to explore sample structure at the population level based on cell type composition but also by default automatically calculates DESeq2-normalized [@love2015] sample pseudobulk gene expression. A PCA plot based on pseudobulk can be plotted like this: ```{r Load dataset including pseudobulk calculation, message=FALSE, warning=FALSE} # Loading an example SingleCellExperiment dataset library(scRNAseq) all.ds <- as.data.frame(surveyDatasets()) ds <- 1 sce_object <- fetchDataset( all.ds[["name"]][ds], all.ds[["version"]][ds] ) se <- ecoda( data = sce_object, sample_col = "sample", celltype_col = "cluster", get_pb = TRUE ) ``` ```{r PCA plot showing top 5 features} plot_pca( se, label_col = "Condition", title = "PCA based on cell type composition", n_hv_feat_show = 5 ) ``` ```{r PCA plot based on pseudobulk} plot_pca( se, assay = "pb", label_col = "Condition", title = "PCA based on pseudobulk gene expression" #, n_hv_feat_show = 5 # optionally, show top n most highly variable genes ) ``` # FAQ ## Can ECODA be affected by batch effect? Yes, ECODA can be influenced by technical variation between batches. A "batch effect" can manifests as an artificial inflation or depletion of specific cell types due to sample handling rather than biology. Common examples include: - Technical artifacts: Red blood cells or platelets resulting from tissue contamination or varied bleeding during surgery/dissection. - High-variance/Fragile types: Some cell types, e.g. neutrophils, can be sensitive to dissociation protocols, resulting in inconsistent recovery rates across samples. Removing these "nuisance" populations can help mitigating noise and/or batch effect, and to better capture true biological variation. ## Should I remove red blood cells, platelets and neutrophils? Yes, we recommended to exclude cell types that introduce technical noise rather than biological signal. ## How can I remove specific cell types? The recommended workflow depends on your data source and whether you require pseudobulk analysis: - From a Count Matrix: Simply remove the unwanted columns (cell types) from your matrix before passing it to ecoda(). - From Seurat/SCE (with Pseudobulk): If you set get_pb = TRUE, you must subset your Seurat or SCE object first. This ensures that the gene expression aggregation (pseudobulk) only includes the cells you are interested in. - From an existing ECODA object: You can easily extract the data, filter it, and re-initialize a clean object: ```{r Removing cell types, eval=FALSE} # Load example datasets data(example_data) Stephenson <- example_data$Stephenson counts <- Stephenson$cell_counts_lowresolution metadata <- Stephenson$metadata se <- ecoda(data = counts, metadata = metadata) # Specify cell types to drop cts_to_drop <- c("RBC", "Platelets", "Neutrophils") keep <- !rownames(assay(se, "counts")) %in% cts_to_drop counts_subset <- assay(se, "counts")[, keep] colData_subset <- as.data.frame(colData(se))[keep, ] # Re-initialize a new subset object # This ensures all transformations (CLR, HVCs, etc.) are recalculated correctly se_subset <- ecoda(data = counts_subset, metadata = colData_subset) ``` # References # Session Info ```{r sessionInfo} sessionInfo() ```