---
title: "CyTOF workflow: differential discovery in high-throughput high-dimensional cytometry datasets. BioC 2017 workshop."
author:
- name: Malgorzata Nowicka
affiliation:
- &IMLS Institute for Molecular Life Sciences, University of Zurich, 8057 Zurich, Switzerland
- &SIB SIB Swiss Institute of Bioinformatics, University of Zurich, 8057 Zurich, Switzerland
email: gosia.nowicka@uzh.ch
- name: Carsten Krieg
affiliation:
- &IEI Institute of Experimental Immunology, University of Zurich, 8057 Zurich, Switzerland
- name: Lukas M. Weber
affiliation:
- *IMLS
- *SIB
- name: Felix J. Hartmann
affiliation:
- *IEI
- name: Silvia Guglietta
affiliation:
- &EIO Department of Experimental Oncology, European Institute of Oncology, Via Adamello 16, I-20139 Milan, Italy
- name: Burkhard Becher
affiliation:
- &IEI Institute of Experimental Immunology, University of Zurich, 8057 Zurich, Switzerland
- name: Mitchell P. Levesque
affiliation:
- &DERM Department of Dermatology, University Hospital Zurich, CH-8091 Zurich, Switzerland
- name: Mark D. Robinson
affiliation:
- *IMLS
- *SIB
email: mark.robinson@imls.uzh.ch
abstract: High dimensional mass and flow cytometry (HDCyto) experiments have become a method of choice for high throughput interrogation and characterization of cell populations. Here, we present an R-based pipeline for differential analyses of HDCyto data, largely based on Bioconductor packages. We computationally define cell populations using FlowSOM clustering, and facilitate an optional but reproducible strategy for manual merging of algorithm-generated clusters. Our workflow offers different analysis paths, including association of cell type abundance with a phenotype or changes in signaling markers within specific subpopulations, or differential analyses of aggregated signals. Importantly, the differential analyses we show are based on regression frameworks where the HDCyto data is the response; thus, we are able to model arbitrary experimental designs, such as those with batch effects, paired designs and so on. In particular, we apply generalized linear mixed models to analyses of cell population abundance or cell-population-specific analyses of signaling markers, allowing overdispersion in cell count or aggregated signals across samples to be appropriately modeled. To support the formal statistical analyses, we encourage exploratory data analysis at every step, including quality control (e.g. multi-dimensional scaling plots), reporting of clustering results (dimensionality reduction, heatmaps with dendrograms) and differential analyses (e.g. plots of aggregated signals).
bibliography: bibliography.bib
# output: BiocWorkflowTools::f1000_article
output:
BiocStyle::html_document:
fig_caption: true
self_contained: yes
vignette: >
%\VignetteIndexEntry{A workflow for differential discovery in high-throughput high-dimensional cytometry datasets}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
---
```{r setup_knitr, include=FALSE, cache=FALSE}
# Decide whether to display parts for BioC (TRUE) or F1000 (FALSE)
on.bioc <- TRUE
library(knitr)
# Use fig.width = 7 for html and fig.width = 6 for pdf
fig.width <- ifelse(on.bioc, 7, 6)
knitr::opts_chunk$set(cache = 2, warning = FALSE, message = FALSE,
cache.path = "cache/", fig.path = "figure/", fig.width = fig.width)
library(BiocStyle)
```
```{r libraries, echo = FALSE, results = "hide"}
suppressPackageStartupMessages({
library(captioner)
library(readxl)
library(flowCore)
library(matrixStats)
library(ggplot2)
library(reshape2)
library(dplyr)
library(limma)
library(ggrepel)
library(RColorBrewer)
library(pheatmap)
library(FlowSOM)
library(ConsensusClusterPlus)
library(Rtsne)
library(cowplot)
library(lme4)
library(multcomp)
})
```
```{r captioner, echo = FALSE, results = "hide"}
library(captioner)
fig_nums <- captioner(prefix = "Figure")
fig_nums(name = "plot-merker-expression-distribution", caption = "Per-sample smoothed densities of marker expression (arcsinh-transformed) of 10 lineage markers and 14 functional markers in the PBMC dataset. Two conditions: unstimulated (Ref) and stimulated with BCR/FcR-XL (BCRXL) for each of the 8 healthy donors are presented and colored by experimental condition.")
fig_nums(name = "plot-mds", caption = "MDS plot for the unstimulated (Ref) and stimulated with BCR/FcR-XL (BCRXL) samples obtained for each of the 8 healthy donors in the PBMC dataset. Calculations are based on the median (arcsinh-transformed) marker expression of 10 lineage markers and 14 functional markers across all cells measured for each sample. Distances between samples on the plot approximate the typical change in medians. Numbers in the label names indicate patient IDs.")
fig_nums(name = "plot-clustering-heatmap1", caption = "Heatmap of the median marker intensities of the 10 lineage markers across the 20 cell populations obtained with FlowSOM after the metaclustering step with ConsensusClusterPlus (PBMC data). The color in the heatmap represents the median of the arcsinh, 0-1 transformed marker expression. The dendrogram on the left represents the hierarchical similarity between the 20 metaclusters (metric: Euclidean distance; linkage: average). Values in the brackets indicate the relative size of a given cluster.")
fig_nums(name = "tsne-plot-one-expr-CD4", caption = "t-SNE plot based on the arcsinh-transformed expression of the 10 lineage markers in the cells from the PBMC dataset (t-SNE was run with no PCA step, perplexity = 30, 1000 iterations). From each of the 16 samples, 2000 cells were randomly selected. Cells are colored according to the expression level of the CD4 marker.")
fig_nums(name = "tsne-plot-one-clustering1", caption = "t-SNE plot based on the arcsinh-transformed expression of the 10 lineage markers in the cells from the PBMC dataset. From each of the 16 samples, 2000 cells were randomly selected. Cells are colored according to the 20 cell populations obtained with FlowSOM after the metaclustering step with ConsensusClusterPlus.")
fig_nums(name = "tsne-plot-one-clustering1m", caption = "t-SNE plot based on the arcsinh-transformed expression of the 10 lineage markers in the cells from the PBMC dataset. From each of the 16 samples, 1000 cells were randomly selected. Cells are colored according to the manual merging of the 20 cell populations obtained with FlowSOM into 8 PBMC populations. ")
fig_nums(name = "tsne-plot-facet-sample", caption = paste0("t-SNE plot as in the Figure ", ifelse(on.bioc, fig_nums("tsne-plot-one-clustering1m", display = "num"), "\\@ref(fig:tsne-plot-one-clustering1m)"), ", but stratified by sample."))
fig_nums(name = "tsne-plot-facet-condition", caption = paste0("t-SNE plot as in the Figure ", ifelse(on.bioc, fig_nums("tsne-plot-one-clustering1m", display = "num"), "\\@ref(fig:tsne-plot-one-clustering1m)"), ", but stratified by condition."))
fig_nums(name = "plot-clustering-heatmap1m-merging", caption = paste0("Heatmap as in Figure ", ifelse(on.bioc, fig_nums("plot-clustering-heatmap1", display = "num"), "\\@ref(fig:plot-clustering-heatmap1)"), " with the color bars on the left indicating how the 20 metaclusters obtained with FlowSOM are merged into the 8 PBMC populations."))
fig_nums(name = "plot-clustering-heatmap1m", caption = "Heatmap of the median marker intensities of the 10 lineage markers in the 8 PBMC cell populations obtained by manual merging of the 20 metaclusters generated by FlowSOM. The heat represents the median of arcsinh and 0-1 transformed marker expression. Values in the brackets indicate the relative size of each of the cell populations across all the samples.")
fig_nums(name = "diff-freqs-plot-props-barplot", caption = "Relative abundance of the 8 PBMC populations in each sample (x-axis), in the PBMC dataset, represented with a barplot. The 8 cell populations are a result of manual merging of the 20 FlowSOM metaclusters. ")
fig_nums(name = "diff-freqs-plot-props-boxplot", caption = "Relative abundance of the 8 PBMC populations in each sample, in the PBMC dataset, represented with boxplots. Different colors are used for the two conditions: unstimulated (Ref) and stimulated with BCR/FcR-XL (BCRXL). Values for each patient are indicated with different shape. The 8 cell populations are a result of manual merging of the 20 FlowSOM metaclusters.")
fig_nums(name = "diff-freqs-plot-heatmap-with-significant-clusters", caption = "Normalized proportions of PBMC cell populations that are significantly differentially abundant between BCR/FcR-XL stimulated and unstimulated condition.")
fig_nums(name = "diff-expr2-plot-median-expr", caption = "Median (arcsinh-transformed) expression of 14 signaling markers across the 8 identified PBMC cell populations. Different colors are used for the two conditions unstimulated (Ref) and stimulated with BCR/FcR-XL (BCRXL). Values for each patient are indicated with different shape. The 8 cell populations are a result of manual merging of the 20 metaclusters.")
fig_nums(name = "diff-expr2-plot-heatmap-with-significant-markers", caption = "Normalized expression of signaling markers in the 8 PBMC populations that are significantly differentially expressed between BCR/FcR-XL stimulated and unstimulated condition.")
```
```{r, results = 'asis', eval = on.bioc, echo = FALSE}
cat("***Note:*** *The full version of this workflow is available at F1000 under the link:
https://f1000research.com/articles/6-748/v1 and at Bioconductor workflows under the link: http://bioconductor.org/help/workflows/cytofWorkflow/ *")
```
# Introduction
Flow cytometry and the more recently introduced CyTOF (cytometry by time-of-flight mass spectrometry or mass cytometry) are high-throughput technologies that measure protein abundance on the surface or within cells. In flow cytometry, antibodies are labeled with fluorescent dyes and fluorescence intensity is measured using lasers and photodetectors. CyTOF utilizes antibodies tagged with metal isotopes from the lanthanide series, which have favorable chemistry and do not occur in biological systems; abundances per cell are recorded with a time-of-flight mass spectrometer. In either case, fluorescence intensities (flow cytometry) or ion counts (mass cytometry) are assumed to be proportional to the expression level of the antibody-targeted antigens of interest.
Due to the differences in acquisition, further distinct characteristics should be noted. Conventional fluorophore-based flow cytometry is non-destructive and can be used to sort cells for further analysis. However, because of the spectral overlap between fluorophores, *compensation* of the data needs to be performed [@Roederer2001], which also limits the number of parameters that can be measured simultaneously. Thus, standard flow cytometry experiments measure 6-12 parameters with modern systems measuring up to 20 channels [@Mahnke2007], while new developments (e.g. BD FACSymphony) promise to increase this capacity towards 50. Moreover, flow cytometry offers the highest throughput with tens of thousands of cells measured per second at relatively low operating costs per sample.
By using rare metal isotopes in CyTOF, cell autofluorescence can be avoided and spectral overlap is drastically reduced. However, the sensitivity of mass spectrometry results in the measurement of metal impurities and oxide formations, which need to be carefully considered in antibody panel design (e.g. through antibody concentrations and coupling of antibodies to neighboring metals). Leipold *et al.* recently commented that *minimal spillover does not equal no spillover* [@Leipold2015]. Nonetheless, CyTOF offers a high dimension of parameters measured per cell, with current panels using ~40 parameters and the promise of up to 100. Throughput of CyTOF is lower, at the rate of hundreds of cells per second, and cells are destroyed during ionization.
The ability of flow cytometry and mass cytometry to analyze individual cells at high-throughput scales has resulted in a wide range of biological and medical applications. For example, immunophenotyping assays are used to detect and quantify cell populations of interest, to uncover new cell populations and compare abundance of cell populations between different conditions, for example between patient groups [@vanUnen20161227]. Thus, it can be used as a biomarker discovery tool.
Various methodological approaches aim for biomarker discovery [@Saeys2016]. A common strategy, which we will refer to through this workflow as the "classic" approach, is to first identify cell populations of interest by manual gating or automated clustering [@Hartmann2016; @Pejoski4814]. Second, using statistical tests, one can determine which of the cell subpopulations or protein markers are associated with a phenotype (e.g. clinical outcome) of interest. Typically, cell subpopulation abundance expressed as cluster cell counts or median marker expression would be used in the statistical model to relate to the sample-level phenotype.
Importantly, there are many alternatives to what we propose below, and several new methods are emerging.
For instance, `r Githubpkg("nolanlab/citrus")` [@Bruggner2014] tackles the differential discovery problem by strong over-clustering of the cells, and by building a hierarchy of clusters from very specific to general ones. Using model selection and regularization techniques, clusters and markers that associate with the outcome are identified. A new machine learning approach, `r Githubpkg("eiriniar/CellCnn")` [@Arvaniti2016], learns the representation of clusters that are associated with the considered phenotype by means of convolutional neural networks, which makes it particularly applicable to detecting discriminating rare cell populations. However, there are tradeoffs to consider. `r Githubpkg("nolanlab/citrus")` performs feature selection but does not provide significance levels, such as p-values, for the strength of associations. Due to its computational requirements, `r Githubpkg("nolanlab/citrus")` can not be run on entire mass cytometry datasets and one typically must analyze a subset of the data. The "filters" from `r Githubpkg("eiriniar/CellCnn")` may identify one or more cell subsets that distinguish experimental groups, while these groups may not necessarily correspond to any of the canonical cell types, since they are learned with a data-driven approach.
A noticeable distinction between the machine-learning approaches and our classical regression approach is how the model is designed. `r Githubpkg("nolanlab/citrus")` and `r Githubpkg("eiriniar/CellCnn")` model the patient response as a function of the measured HDCyto values, whereas the classical approach models the HDCyto data itself as the response, thus putting the distributional assumptions on the experimental HDCyto data. This carries the distinct advantage that covariates (e.g. age, gender, batch) can be included, together with finding associations of the phenotype to the predictors of interest (e.g. cell type abundance). Specifically, neither `r Githubpkg("nolanlab/citrus")` nor `r Githubpkg("eiriniar/CellCnn")` are able to directly account for complex designs, such as paired experiments or presence of batches.
Within the classical approach, hybrid methods are certainly possible, where discovery of interesting cell populations is done with one algorithm, and quantifications or signal aggregations are modeled in standard regression frameworks. For instance, `r Githubpkg("eiriniar/CellCnn")` provides p-values from a t-test or Mann-Whitney U-test conducted on the frequencies of previously detected cell populations. The models we propose below are flexible extensions of this strategy.
Step by step, this workflow presents differential discovery analyses assembled from a suite of tools and methods that, in our view, lead to a higher level of flexibility and robust, statistically-supported and interpretable results. Cell population identification is conducted by means of unsupervised clustering using the `r Biocpkg("FlowSOM")` and `r Biocpkg("ConsensusClusterPlus")` packages, which together were among the best performing clustering approaches for high-dimensional cytometry data [@Weber2016]. Notably, `r Biocpkg("FlowSOM")` scales easily to millions of cells and thus no subsetting of the data is required.
To be able to analyze arbitrary experimental designs (e.g. batch effects, paired experiments, etc.), we show how to conduct the differential analysis of cell population abundances using the generalized linear mixed models (GLMM) and of marker intensities using linear models (LM) and linear mixed models (LMM). Model fitting is performed with `r CRANpkg("lme4")` and `r CRANpkg("stats")` packages, and hypothesis testing with the `r CRANpkg("multcomp")` package.
We use the `r CRANpkg("ggplot2")` package as our graphical engine. Notably, we propose a suite of useful visual representations of HDCyto data characteristics, such as an MDS (multidimensional scaling) plot of aggregated signal for exploring sample similarities. The obtained cell populations are visualized using dimension reduction techniques (e.g. t-SNE via the `r CRANpkg("Rtsne")` package) and heatmaps (via the `r CRANpkg("pheatmap")` package) to represent characteristics of the annotated cell populations and identified biomarkers.
The workflow is intentionally not fully automatic. First, we strongly advocate for exploratory data analysis to get an understanding of data characteristics before formal statistical modeling. Second, the workflow involves an optional step where the user can manually merge and annotate clusters (see [Cluster merging and annotation](#cluster-merging-and-annotation) section) but in a way that is easily reproducible. The CyTOF data used here (see [Data description](#data-description) section) is already preprocessed; i.e. the normalization and de-barcoding, as well as removal of doublets, debris and dead cells, were already performed. To see how such an analysis could be performed, please see the [Data preprocessing](#data-preprocessing) section.
Notably, this workflow is equally applicable to flow or mass cytometry datasets, for which the preprocessing steps have already been performed. In addition, the workflow is modular and can be adapted as new algorithms or new knowledge about how to best use existing tools comes to light. Alternative clustering algorithms such as the popular PhenoGraph algorithm [@Levine2015] (e.g. via the `r Githubpkg("JinmiaoChenLab/Rphenograph")` package), dimensionality reduction techniques, such as diffusion maps [@Haghverdi2015] via the `r Biocpkg("destiny")` package [@Angerer2016]), and SIMLR [@Wang2017] via the `r Biocpkg("SIMLR")` package could be inserted to the workflow.
# Data description
We use a subset of CyTOF data originating from Bodenmiller *et al.* [@Bodenmiller2012] that was also used in the `r Githubpkg("nolanlab/citrus")` paper [@Bruggner2014]. Specifically, we perform our analysis on samples of peripheral blood mononuclear cells (PBMCs) from 8 healthy donors, where for each individual, an unstimulated and a stimulated samples (for 30 minutes with B cell receptor/Fc receptor crosslinking, known as BCR/FcR-XL) were collected, resulting in 16 samples in total. For each sample, 14 signaling markers and 10 cell surface markers were measured.
The original data is available from the [Cytobank report](http://reports.cytobank.org/105/v2). The subset used here can be downloaded from the [Citrus Cytobank repository](https://community.cytobank.org/cytobank/experiments/15713/) (files with `_BCR-XL.fcs` or `_Reference.fcs` endings) or from our web server (see [Data import](#data-import) section).
In both the Bodenmiller *et al.* and `r Githubpkg("nolanlab/citrus")` manuscripts, the 10 lineage markers were used to identify cell subpopulations. These were then investigated for differences between reference and stimulated cell subpopulations separately for each of the 14 functional markers. The same strategy is used in this workflow; 10 lineage markers are used for cell clustering and 14 functional markers are tested for differential expression between the reference and BCR/FcR-XL stimulation. Even though differential analysis of cell abundance was not in the scope of the Bodenmiller *et al.* experiment, we present it here to highlight the generality of the discovery.
# Data preprocessing
Conventional flow cytometers and mass cytometers produce .fcs files that can be manually analyzed using programs such as FlowJo [TriStar] or Cytobank [@Kotecha2001], or using the R/Bioconductor packages, such as the `r Biocpkg("flowCore")` package [@Ellis2017]. During this initial analysis step, dead cells are removed, compensation is checked and with simple two dimensional scatter plots (e.g. marker intensity versus time), marker expression patterns are checked. Often, modern experiments are barcoded in order to remove analytical biases due to individual sample variation or acquisition time. Preprocessing steps including normalization using bead standards, de-barcoding and compensation can be completed with the `r Biocpkg("CATALYST")` package, which provides an implementation of the de-barcoding algorithm described by Zunder *et al.* [@Zunder2015] and the bead-based normalization from Finck *et al.* [@Finck2013]. Of course, preprocessing steps can occur using custom scripts within R or outside of R (e.g. [Normalizer](https://github.com/nolanlab/bead-normalization/releases/latest) [@Finck2013]).
# Data import
We recommend as standard practice to keep an independent record of all samples collected, with additional information about the experimental condition, including sample or patient identifiers, processing batch and so on. That is, we recommend having a trail of metadata for each experiment. In our example, the metadata file, PBMC8_metadata.xlsx, can be downloaded from the [Robinson Lab server](http://imlspenticton.uzh.ch/robinson_lab/cytofWorkflow) with the `download.file` function. For the workflow, the user should place it in the current working directory (`getwd()`). Here, we load it into R with the `read_excel` function from the `r CRANpkg("readxl")` package and save it into a variable called `md`, but other file types and interfaces to read them in are also possible.
The data frame `md` contains the following columns:
* `file_name` with names of the .fcs files corresponding to the reference (suffix "Reference") and BCR/FcR-XL stimulation (suffix "BCR-XL") samples,
* `sample_id` with shorter unique names for each sample containing information about conditions and patient IDs,
* `condition` describes whether samples originate from the reference (`Ref`) or stimulated (`BCRXL`) condition,
* `patient_id` defines the IDs of patients.
The `sample_id` variable is used as row names in metadata and will be used all over the workflow to label the samples. It is important to carefully check whether variables are of the desired type (factor, numeric, character), since input methods may convert columns into different data types. For the statistical modeling, we want to make the condition variable a factor with the reference (`Ref`) samples being the reference level, where the order of factor levels can be defined with the `levels` parameter of the `factor` function. We also specify colors for the different conditions in a variable `color_conditions`.
```{r load-metadata}
library(readxl)
url <- "http://imlspenticton.uzh.ch/robinson_lab/cytofWorkflow"
metadata_filename <- "PBMC8_metadata.xlsx"
download.file(paste0(url, "/", metadata_filename), destfile = metadata_filename,
mode = "wb")
md <- read_excel(metadata_filename)
## Make sure condition variables are factors with the right levels
md$condition <- factor(md$condition, levels = c("Ref", "BCRXL"))
head(md)
## Define colors for conditions
color_conditions <- c("#6A3D9A", "#FF7F00")
names(color_conditions) <- levels(md$condition)
```
The .fcs files listed in the metadata can be downloaded manually from the [Citrus Cytobank repository](https://community.cytobank.org/cytobank/experiments/15713/download_files) or automatically from the [Robinson Lab server](http://imlspenticton.uzh.ch/robinson_lab/cytofWorkflow) where they are saved in a compressed archive file, PBMC8_fcs_files.zip.
```{r download-fcs}
fcs_filename <- "PBMC8_fcs_files.zip"
download.file(paste0(url, "/", fcs_filename), destfile = fcs_filename,
mode = "wb")
unzip(fcs_filename)
```
To load the content of the .fcs files into R, we use the `r Biocpkg("flowCore")`. Using `read.flowSet`, we read in all files into a `flowSet` object, which is a general container for HDCyto data. Importantly, `read.flowSet` and the underlying `read.FCS` functions, by default, may transform the marker intensities and remove cells with extreme positive values. We keep these options off to be sure that we control the exact preprocessing steps.
```{r load-fcs, include=FALSE}
library(flowCore)
fcs_raw <- read.flowSet(md$file_name, transformation = FALSE,
truncate_max_range = FALSE)
fcs_raw
```
```{r load-fcs2, eval=FALSE}
library(flowCore)
fcs_raw <- read.flowSet(md$file_name, transformation = FALSE,
truncate_max_range = FALSE)
fcs_raw
```
In our example, information about the panel is also available in a file called PBMC8_panel.xlsx, and can be downloaded from the [Robinson Lab server](http://imlspenticton.uzh.ch/robinson_lab/cytofWorkflow) and loaded into a variable called `panel`. It contains columns for `Isotope` and `Metal` that define the atomic mass number and the symbol of the chemical element conjugated to the antibody, respectively, and `Antigen`, which specifies the protein marker that was targeted; two additional columns specify whether a channel belongs to the lineage or surface type of marker.
The isotope, metal and antigen information that the instrument receives is also stored in the `flowFrame` (container for one sample) or `flowSet` (container for multiple samples) objects. You can type `fcs_raw[[1]]` to see the first `flowFrame`, which contains a table with columns `name` and `desc`. Their content can be accessed with functions `pData(parameters())`, which is identical for all the `flowFrame` objects in the `flowSet`. The variable `name` corresponds to the column names in the `flowSet` object, you can type in R `colnames(fcs_raw)`.
It should be checked that elements from `panel` can be matched to their corresponding entries in the `flowSet` object to make the analysis less prone to subsetting mistakes. Here, for example, the entries in `panel$Antigen` have their exact equivalents in the `desc` columns of the `flowFrame` objects.
In the following analysis, we will often use marker IDs as column names in the tables containing expression values. As a cautionary note, during object conversion from one type to another (e.g. in the creation of data.frame from a matrix), some characters (e.g. dashes) in the dimension names are replaced with dots, which may cause problems in matching. To avoid this problem, we replace all the dashes with underscores. Also, we define two variables that indicate the lineage and functional markers.
```{r load-panel}
panel_filename <- "PBMC8_panel.xlsx"
download.file(paste0(url, "/", panel_filename), destfile = panel_filename,
mode = "wb")
panel <- read_excel(panel_filename)
head(data.frame(panel))
# Replace problematic characters
panel$Antigen <- gsub("-", "_", panel$Antigen)
panel_fcs <- pData(parameters(fcs_raw[[1]]))
head(panel_fcs)
# Replace problematic characters
panel_fcs$desc <- gsub("-", "_", panel_fcs$desc)
# Lineage markers
(lineage_markers <- panel$Antigen[panel$Lineage == 1])
# Functional markers
(functional_markers <- panel$Antigen[panel$Functional == 1])
# Spot checks
all(lineage_markers %in% panel_fcs$desc)
all(functional_markers %in% panel_fcs$desc)
```
# Data transformation
Usually, the raw marker intensities read by a cytometer have strongly skewed distributions with varying ranges of expression, thus making it difficult to distinguish between the negative and positive cell populations. It is common practice to transform CyTOF marker intensities using, for example, arcsinh (hyperbolic inverse sine) with cofactor 5 [@Bendall687 Figure S2; @Bruggner2014] to make the distributions more symmetric and to map them to a comparable range of expression, which is important for clustering. A cofactor of 150 has been promoted for flow cytometry, but users are free to implement alternative transformations, some of which are available from the `transform` function of the `r Biocpkg("flowCore")` package. In the following step, we include only those channels that correspond to the lineage and functional markers. We also rename the columns in the `flowSet` to the antigen names from `panel$desc`.
```{r arcsinh-transformation}
## arcsinh transformation and column subsetting
fcs <- fsApply(fcs_raw, function(x, cofactor = 5){
colnames(x) <- panel_fcs$desc
expr <- exprs(x)
expr <- asinh(expr[, c(lineage_markers, functional_markers)] / cofactor)
exprs(x) <- expr
x
})
fcs
```
For some of the further analysis, it is more convenient for us to work using a matrix (called `expr`) that contains marker expression for cells from all samples. We create such a matrix with the `fsApply` function that extracts the expression matrices (function `exprs`) from each element of the `flowSet` object.
```{r extract-expression}
## Extract expression
expr <- fsApply(fcs, exprs)
dim(expr)
```
As the ranges of marker intensities can vary substantially, we apply another transformation that scales expression of all markers to values between 0 and 1 using low (e.g. 1\%) and high (e.g. 99\%) percentiles as the boundary. This additional transformation of the arcsinh-transformed data can sometimes give better representation of relative differences in marker expression between annotated cell populations, however, it is only used here for visualization.
```{r 01-transformation}
library(matrixStats)
rng <- colQuantiles(expr, probs = c(0.01, 0.99))
expr01 <- t((t(expr) - rng[, 1]) / (rng[, 2] - rng[, 1]))
expr01[expr01 < 0] <- 0
expr01[expr01 > 1] <- 1
```
# Diagnostic plots
We propose some quick checks to verify whether the data we analyze globally represents what we expect; for example, whether samples that are replicates of one condition are more similar and are distinct from samples from another condition. Another important check is to verify that marker expression distributions do not have any abnormalities such as having different ranges or distinct distributions for a subset of the samples. This could highlight problems with the sample collection or HDCyto acquisition, or batch effects that were unexpected. Depending on the situation, one can then consider removing problematic markers or samples from further analysis; in the case of batch effects, a covariate column could be added to the metadata table and used below in the statistical analyses.
The step below generates a plot with per-sample marker expression distributions, colored by condition (see Figure `r ifelse(on.bioc, fig_nums("plot-merker-expression-distribution", display = "num"), "\\@ref(fig:plot-merker-expression-distribution)")`). Here, we can already see distinguishing markers, such as pNFkB and CD20, between stimulated and unstimulated conditions.
```{r sample-ids}
## Generate sample IDs corresponding to each cell in the `expr` matrix
sample_ids <- rep(md$sample_id, fsApply(fcs_raw, nrow))
```
```{r plot-merker-expression-distribution, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("plot-merker-expression-distribution"))}
library(ggplot2)
library(reshape2)
ggdf <- data.frame(sample_id = sample_ids, expr)
ggdf <- melt(ggdf, id.var = "sample_id",
value.name = "expression", variable.name = "antigen")
mm <- match(ggdf$sample_id, md$sample_id)
ggdf$condition <- md$condition[mm]
ggplot(ggdf, aes(x = expression, color = condition,
group = sample_id)) +
geom_density() +
facet_wrap(~ antigen, nrow = 4, scales = "free") +
theme_bw() +
theme(axis.text.x = element_text(angle = 90, hjust = 1),
strip.text = element_text(size = 7), axis.text = element_text(size = 5)) +
guides(color = guide_legend(ncol = 1)) +
scale_color_manual(values = color_conditions)
```
## MDS plot
In transcriptomics applications, one of the most utilized exploratory plots is the multi-dimensional scaling (MDS) plot or a principal component analysis (PCA) plot. Such plots show similarities between samples measured in an unsupervised way and give a sense of how much differential expression can be detected before conducting any formal tests. An MDS plot can be generated with the `plotMDS` function from the `r Biocpkg("limma")` package. In transcriptomics, distances between samples are calculated based on the expression of the top varying genes. We propose a similar plot for HDCyto data using median marker expression over all cells to calculate dissimilarities between samples (other aggregations are also possible, and one could reduce the number of top varying markers to include in the calculation). Ideally, samples should cluster well within the same condition, although this depends on the magnitude of the difference between experimental conditions. With this diagnostic, one can identify the outlier samples and eliminate them if the circumstances warrant it.
In our MDS plot on median marker expression values (see Figure `r ifelse(on.bioc, fig_nums("plot-mds", display = "num"), "\\@ref(fig:plot-mds)")`), we can see that the first dimension (MDS1) separates the unstimulated and stimulated samples reasonably well. The second dimension (MDS2) represents, to some degree, differences between patients. Most of the samples that originate from the same patient are placed at a similar point along the y-axis, for example, samples from patients 7, 5, and 8 are at the top of the plot, samples from patient 4 are located at the bottom of the plot. This also indicates that the marker expression of individual patients is driving similarity and perhaps should be formally accounted for in the downstream statistical modeling.
```{r plot-mds, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("plot-mds"))}
# Get the median marker expression per sample
library(dplyr)
expr_median_sample_tbl <- data.frame(sample_id = sample_ids, expr) %>%
group_by(sample_id) %>%
summarize_all(funs(median))
expr_median_sample <- t(expr_median_sample_tbl[, -1])
colnames(expr_median_sample) <- expr_median_sample_tbl$sample_id
library(limma)
mds <- plotMDS(expr_median_sample, plot = FALSE)
library(ggrepel)
ggdf <- data.frame(MDS1 = mds$x, MDS2 = mds$y,
sample_id = colnames(expr_median_sample))
mm <- match(ggdf$sample_id, md$sample_id)
ggdf$condition <- md$condition[mm]
ggplot(ggdf, aes(x = MDS1, y = MDS2, color = condition)) +
geom_point(size = 2, alpha = 0.8) +
geom_label_repel(aes(label = sample_id)) +
theme_bw() +
scale_color_manual(values = color_conditions)
```
# Cell population identification with FlowSOM and ConsensusClusterPlus
Cell population identification typically has been carried out by manual gating, a method based on visual inspection of a series of two-dimensional scatterplots. At each step, a subset of cells, either positive or negative for the two visualized markers, is selected and further stratified in the subsequent iterations until populations of interest across a range of marker combinations are captured. However, manual gating has drawbacks, such as subjectivity, bias toward well-known cell types, and inefficiency when analyzing large datasets, which also contribute to a lack of reproducibility [@Saeys2016].
Considerable effort has been made to improve and automate cell population identification, such as unsupervised clustering [@Aghaeepour2013]. However, not all methods scale well in terms of performance and speed from the lower dimensionality flow cytometry data to the higher dimensionality mass cytometry data [@Weber2016], since clustering in higher dimensions can suffer the "curse of dimensionality".
Beside the mathematical and algorithmic challenges of clustering, cell population identification may be difficult due to the chemical and biological aspects of the cytometry experiment itself. Therefore, caution should be taken when designing panels aimed at detecting rare cell populations by assigning higher sensitivity metals to rare markers. The right choice of a marker panel used for clustering can also be important. It should include all markers that are relevant for cell type identification.
In this workflow, we conduct cell clustering with `r Biocpkg("FlowSOM")` [@VanGassen2015] and `r Biocpkg("ConsensusClusterPlus")` [@Wilkerson2010], which appeared amongst the fastest and best performing clustering approaches in a recent study of HDCyto datasets [@Weber2016]. This ensemble showed strong performance in detecting both high and low frequency cell populations and is one of the fastest methods to run, which enables its interactive usage. We use a slight modification of the original workflow presented in the `r Biocpkg("FlowSOM")` vignette, which we find more flexible. In particular, we directly call the `ConsensusClusterPlus` function that is embedded in `metaClustering_consensus`. Thus, we are able to access all the functionality of the `r Biocpkg("ConsensusClusterPlus")` package to identify the number of clusters.
The `r Biocpkg("FlowSOM")` workflow consists of three main steps. First, a self-organizing map (SOM) is built using the `BuildSOM` function, where cells are assigned according to their similarities to 100 (by default) grid points (or, so-called codebook vectors or codes) of the SOM. The building of a minimal spanning tree, which is mainly used for graphical representation of the clusters, is skipped in this pipeline. And finally, *metaclustering* of the SOM codes, is performed directly with the `ConsensusClusterPlus` function. Additionally, we add an optional round of manual expert-based merging of the metaclusters and allow this to be done in a reproducible fashion.
`r Biocpkg("FlowSOM")` output can be sensitive to random starts [@Weber2016]. To make results reproducible, one must specify the seed for the random number generation in R using function `set.seed`. It is also advisable to rerun analyses with multiple random seeds, for two reasons. First, one can see how robust the detected clusters are, and second, when the goal is to find smaller cell populations, it may happen that, in some runs, random starting points do not represent rare cell populations, as the chance of selecting starting cells from them is low and they are merged into a larger cluster.
It is important to point out that we cluster all cells from all samples together. This strategy is beneficial, since we label cell populations only once and the mapping of cell types between samples is automatically consistent. In our analysis, cell populations are identified using only the 10 lineage markers as defined in the `BuildSOM` function with the `colsToUse` argument.
```{r flowsom-som}
library(FlowSOM)
fsom <- ReadInput(fcs, transform = FALSE, scale = FALSE)
set.seed(1234)
som <- BuildSOM(fsom, colsToUse = lineage_markers)
```
Automatic approaches for selecting the number of clusters in HDCyto data do not always succeed [@Weber2016]. In general, we therefore recommend some level of over-clustering, and if desired, manual merging of clusters. Such a hierarchical approach is especially suited when the goal is to detect smaller cell populations.
The SPADE analysis performed by Bodenmiller *et al.* [@Bodenmiller2012] identified 6 main cell types (T-cells, monocytes, dendritic cells, B-cells, NK cells and surface- cells) that were further stratified into 14 more specific subpopulations (CD4+ T-cells, CD8+ T-cells, CD14+ HLA-DR high monocytes, CD14+ HLA-DR med monocytes, CD14+ HLA-DR low monocytes, CD14- HLA-DR high monocytes, CD14- HLA-DR med monocytes, CD14- HLA-DR low monocytes, dendritic cells, IgM+ B-cells, IgM- B-cells, NK cells, surface- CD14+ cells and surface- CD14- cells). In our analysis, we are interested in identifying the 6 main PBMC populations, including: CD4+ T-cells, CD8+ T-cells, monocytes, dendritic cells, NK cells and B-cells. Following the concept of over-clustering we perform the metaclustering of the (by default) 100 SOM codes into more than expected number of groups. For example, stratification into 20 groups should give enough resolution. We can explore the clustering in a wide variety of visualizations: t-SNE plots, heatmaps and a plot generated by `ConsensusClusterPlus` called "delta area".
We call `ConsensusClusterPlus` with maximum number of clusters `maxK = 20` and other clustering parameters set to the values as in the `metaClustering_consensus` function. Again, to ensure that the analyses are reproducible, we define the random seed.
```{r flowsom-meta-clustering, message = FALSE}
## Metaclustering into 20 clusters with ConsensusClusterPlus
library(ConsensusClusterPlus)
codes <- som$map$codes
plot_outdir <- "consensus_plots"
nmc <- 20
mc <- ConsensusClusterPlus(t(codes), maxK = nmc, reps = 100,
pItem = 0.9, pFeature = 1, title = plot_outdir, plot = "png",
clusterAlg = "hc", innerLinkage = "average", finalLinkage = "average",
distance = "euclidean", seed = 1234)
## Get cluster ids for each cell
code_clustering1 <- mc[[nmc]]$consensusClass
cell_clustering1 <- code_clustering1[som$map$mapping[,1]]
```
We can then investigate characteristics of identified clusters with heatmaps that illustrate median marker expression in each cluster (see Figure `r ifelse(on.bioc, fig_nums("plot-clustering-heatmap1", display = "num"), "\\@ref(fig:plot-clustering-heatmap1)")`). As the range of marker expression can vary substantially from marker to marker, we use the 0-1 transformed data for some visualizations. However, to stay consistent with `r Biocpkg("FlowSOM")` and `r Biocpkg("ConsensusClusterPlus")`, we use the (arcsinh-transformed) unscaled data to generate the dendrogram of the hierarchical structure of metaclusters.
Since we will use the heatmap plots again later on in this workflow, in code chunks below, we create a wrapper function that generates these plots.
```{r color-clusters}
color_clusters <- c("#DC050C", "#FB8072", "#1965B0", "#7BAFDE", "#882E72",
"#B17BA6", "#FF7F00", "#FDB462", "#E7298A", "#E78AC3",
"#33A02C", "#B2DF8A", "#55A1B1", "#8DD3C7", "#A6761D",
"#E6AB02", "#7570B3", "#BEAED4", "#666666", "#999999",
"#aa8282", "#d4b7b7", "#8600bf", "#ba5ce3", "#808000",
"#aeae5c", "#1e90ff", "#00bfff", "#56ff0d", "#ffff00")
```
```{r plot-clustering-heatmap1, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("plot-clustering-heatmap1"))}
plot_clustering_heatmap_wrapper <- function(expr, expr01,
cell_clustering, color_clusters, cluster_merging = NULL){
# Calculate the median expression
expr_median <- data.frame(expr, cell_clustering = cell_clustering) %>%
group_by(cell_clustering) %>%
summarize_all(funs(median))
expr01_median <- data.frame(expr01, cell_clustering = cell_clustering) %>%
group_by(cell_clustering) %>%
summarize_all(funs(median))
# Calculate cluster frequencies
clustering_table <- as.numeric(table(cell_clustering))
# This clustering is based on the markers that were used for the main clustering
d <- dist(expr_median[, colnames(expr)], method = "euclidean")
cluster_rows <- hclust(d, method = "average")
expr_heat <- as.matrix(expr01_median[, colnames(expr01)])
rownames(expr_heat) <- expr01_median$cell_clustering
labels_row <- paste0(rownames(expr_heat), " (",
round(clustering_table / sum(clustering_table) * 100, 2), "%)")
labels_col <- colnames(expr_heat)
# Row annotation for the heatmap
annotation_row <- data.frame(cluster = factor(expr01_median$cell_clustering))
rownames(annotation_row) <- rownames(expr_heat)
color_clusters <- color_clusters[1:nlevels(annotation_row$cluster)]
names(color_clusters) <- levels(annotation_row$cluster)
annotation_colors <- list(cluster = color_clusters)
annotation_legend <- FALSE
if(!is.null(cluster_merging)){
cluster_merging$new_cluster <- factor(cluster_merging$new_cluster)
annotation_row$cluster_merging <- cluster_merging$new_cluster
color_clusters <- color_clusters[1:nlevels(cluster_merging$new_cluster)]
names(color_clusters) <- levels(cluster_merging$new_cluster)
annotation_colors$cluster_merging <- color_clusters
annotation_legend <- TRUE
}
# Colors for the heatmap
color <- colorRampPalette(rev(brewer.pal(n = 9, name = "RdYlBu")))(100)
pheatmap(expr_heat, color = color,
cluster_cols = FALSE, cluster_rows = cluster_rows,
labels_col = labels_col, labels_row = labels_row,
display_numbers = TRUE, number_color = "black",
fontsize = 8, fontsize_number = 4,
annotation_row = annotation_row, annotation_colors = annotation_colors,
annotation_legend = annotation_legend)
}
plot_clustering_heatmap_wrapper(expr = expr[, lineage_markers],
expr01 = expr01[, lineage_markers],
cell_clustering = cell_clustering1, color_clusters = color_clusters)
```
## Visual representation with t-SNE
One of the most popular plots for representing single cell data are t-SNE plots, where each cell is represented in a lower, usually two-dimensional, space computed using t-stochastic neighbor embedding (t-SNE) [@VanDerMaaten2008]. More generally, dimensionality reduction techniques represent the similarity of points in 2 or 3 dimensions, such that similar objects in high dimensional space are also similar in lower dimensional space. Mathematically, there are a myriad of ways to define this similarity. For example, principal components analysis (PCA) uses linear combinations of the original features to find orthogonal dimensions that show the highest levels of variability; the top 2 or 3 principal components can then be visualized.
Nevertheless, there are few notes of caution when using t-SNE or any other dimensionality reduction technique. Since they are based on preserving similarities between cells, those that are similar in the original space will be close in the 2D/3D representation, but the opposite does not always hold. In our experience, t-SNE with default parameters for HDCyto data is often suitable; for more guidance on the specifics of t-SNE, see [How to Use t-SNE Effectively](http://distill.pub/2016/misread-tsne/) [@Wattenberg2016]. Due to the stochastic nature of t-SNE optimization, rerunning the method will result in different lower dimensional projections, thus it is advisable to run it a few times to identify the common trends and get a feeling about the variability of the results. As with other methods, to be sure that the analysis is reproducible, the user can define the random seed.
t-SNE is a method that requires significant computational time to process the data even for tens of thousands of cells. CyTOF datasets are usually much larger and thus to keep running times reasonable, one may use a subset of cells; for example, here we use 1000 cells from each sample. The t-SNE map below is colored according to the expression level of the CD4 marker, highlighting that the CD4+ T-cells are placed to the left side of the plot (see Figure `r ifelse(on.bioc, fig_nums("tsne-plot-one-expr-CD4", display = "num"), "\\@ref(fig:tsne-plot-one-expr-CD4)")`). In this way, one can use a collection of markers to highlight where cell types of interest are located on the *map*.
Instead of t-SNE, one could also use other dimension reduction techniques, such as PCA, diffusion maps, SIMLR [@Wang2017] or isomaps, some of which are conveniently available via the `cytof_dimReduction` function from the `r Biocpkg("cytofkit")` package [@Chen2016]. To speed up the t-SNE analysis, one could use a multicore version that is available via the `r Githubpkg("RGLab/Rtsne.multicore")` package. Alternative algorithms, such as `largeVis` [@Tang2016] (available via the `r Githubpkg("elbamos/largeVis")` package), can be used for dimensionality reduction of very large datasets without downsampling.
```{r tsne-duplicates-subsampling}
## Find and skip duplicates
dups <- which(!duplicated(expr[, lineage_markers]))
## Data subsampling: create indices by sample
inds <- split(1:length(sample_ids), sample_ids)
## How many cells to downsample per-sample
tsne_ncells <- pmin(table(sample_ids), 1000)
## Get subsampled indices
set.seed(1234)
tsne_inds <- lapply(names(inds), function(i){
s <- sample(inds[[i]], tsne_ncells[i], replace = FALSE)
intersect(s, dups)
})
tsne_inds <- unlist(tsne_inds)
tsne_expr <- expr[tsne_inds, lineage_markers]
```
```{r tsne-run}
## Run t-SNE
library(Rtsne)
set.seed(1234)
tsne_out <- Rtsne(tsne_expr, check_duplicates = FALSE, pca = FALSE)
```
```{r tsne-plot-one-expr-CD4, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("tsne-plot-one-expr-CD4"))}
## Plot t-SNE colored by CD4 expression
dr <- data.frame(tSNE1 = tsne_out$Y[, 1], tSNE2 = tsne_out$Y[, 2],
expr[tsne_inds, lineage_markers])
ggplot(dr, aes(x = tSNE1, y = tSNE2, color = CD4)) +
geom_point(size = 0.8) +
theme_bw() +
scale_color_gradientn("CD4",
colours = colorRampPalette(rev(brewer.pal(n = 11, name = "Spectral")))(50))
```
We can color the cells by cluster. Ideally, cells of the same color should be close to each other (see Figure `r ifelse(on.bioc, fig_nums("tsne-plot-one-clustering1", display = "num"), "\\@ref(fig:tsne-plot-one-clustering1)")`).
```{r tsne-plot-one-clustering1, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("tsne-plot-one-clustering1"))}
dr$sample_id <- sample_ids[tsne_inds]
mm <- match(dr$sample_id, md$sample_id)
dr$condition <- md$condition[mm]
dr$cell_clustering1 <- factor(cell_clustering1[tsne_inds], levels = 1:nmc)
## Plot t-SNE colored by clusters
ggplot(dr, aes(x = tSNE1, y = tSNE2, color = cell_clustering1)) +
geom_point(size = 0.8) +
theme_bw() +
scale_color_manual(values = color_clusters) +
guides(color = guide_legend(override.aes = list(size = 4), ncol = 2))
```
## Cluster merging and annotation
In our experience, manual merging of clusters leads to slightly different results compared to an algorithm with a specified number of clusters. In order to detect somewhat rare populations, some level of over-clustering is necessary so that the more subtle populations become separated from the main populations. In addition, merging can always follow an over-clustering step, but splitting of existing clusters is not generally feasible.
In our setup, over-clustering is also useful when the interest is identifying the "natural" number of clusters present in the data. In addition to the t-SNE plots, one could investigate the delta area plot from the `r Biocpkg("ConsensusClusterPlus")` package and the hierarchical clustering dendrogram of the over-clustered subpopulations, as shown below.
In our example, we expect around 6 specific cell types, and we have performed `r Biocpkg("FlowSOM")` clustering into 20 groups as a reasonable over-estimate. After analyzing the heatmaps and t-SNE plots, we can clearly see that stratification of the data into 20 clusters may be too strong. Many clusters are placed very close to each other, indicating that they could be merged together. The same can be deduced from the heatmaps, highlighting that marker expression patterns for some neighboring clusters are very similar. Cluster merging and annotating is somewhat manual, based partially on visual inspection of t-SNE plots and heatmaps and thus, benefits from expert knowledge of the cell types.
### Manual cluster merging and annotating based on heatmaps
In our experience, the main reference for manual merging of clusters is the heatmap of marker characteristics across metaclusters, with dendrograms showing the hierarchy of similarities. Such plots aggregate information over many cells and thus show average marker expression for each cluster. Together with dimensionality reduction, these plots give good insight into the relationships between clusters and the marker levels within each cluster. Given expert knowledge of the cell types and markers, it is then left to the researcher to decide how exactly to merge clusters (e.g., with higher weight given to some markers).
The dendrogram highlights the similarity between the metaclusters and can be used explicitly for the merging. However, there are reasons why we would not always follow the dendrogram exactly. In general, when it comes to clustering, blindly following the hierarchy of codes will lead to identification of populations of similar cells, but it does not necessarily mean that they are of biological interest. The distances between metaclusters are calculated across all the markers, and it may be that some markers carry higher weight for certain cell types. In addition, different linkage methods may lead to different hierarchy, especially when clusters are not fully distinct. Another aspect to consider in cluster merging is the cluster size, represented in the parentheses next to the cluster label in our plots. If the cluster size is very small, but the cluster seems relevant and distinct, one can keep it as separate. However, if it is small and different from the neighboring clustering only in a somewhat unimportant marker, it could be merged. And, if some of the metaclusters do not represent any specific cell types, they could be dropped out of the downstream analysis instead of being merged. However, in case an automated solution for cluster merging is truly needed, one could use the `cutree()` function applied to the dendrogram.
Based on the seed that was set, cluster merging of the 20 metaclusters is defined in the PBMC8_cluster_merging1.xlsx file on the [Robinson Lab server](http://imlspenticton.uzh.ch/robinson_lab/cytofWorkflow) with the IDs of the original clusters and new cluster names, and we save it as a `cluster_merging1` data frame.
The expert has annotated 8 cell populations: CD8 T-cells, CD4 T-cells, B-cells IgM-, B-cells IgM+, NK cells, dendritic cells (DCs), monocytes and surface negative cells; monocytes could be further subdivided based on HLA-DR into high, medium and low subtypes.
```{r cluster-merging1}
cluster_merging1_filename <- "PBMC8_cluster_merging1.xlsx"
download.file(paste0(url, "/", cluster_merging1_filename),
destfile = cluster_merging1_filename, mode = "wb")
cluster_merging1 <- read_excel(cluster_merging1_filename)
data.frame(cluster_merging1)
## New clustering1m
mm <- match(cell_clustering1, cluster_merging1$original_cluster)
cell_clustering1m <- cluster_merging1$new_cluster[mm]
mm <- match(code_clustering1, cluster_merging1$original_cluster)
code_clustering1m <- cluster_merging1$new_cluster[mm]
```
We update the t-SNE plot with the new annotated cell populations, Figure `r ifelse(on.bioc, fig_nums("tsne-plot-one-clustering1m", display = "num"), "\\@ref(fig:tsne-plot-one-clustering1m)")`.
```{r tsne-plot-one-clustering1m, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("tsne-plot-one-clustering1m"))}
dr$cell_clustering1m <- factor(cell_clustering1m[tsne_inds])
gg_tsne_cl1m <- ggplot(dr, aes(x = tSNE1, y = tSNE2, color = cell_clustering1m)) +
geom_point(size = 0.8) +
theme_bw() +
scale_color_manual(values = color_clusters) +
guides(color = guide_legend(override.aes = list(size = 4)))
gg_tsne_cl1m
```
When the plots are further stratified by sample (see Figure `r ifelse(on.bioc, fig_nums("tsne-plot-facet-sample", display = "num"), "\\@ref(fig:tsne-plot-facet-sample)")`), we can verify whether similar cell populations are present in all replicates, which can help in identifying outlying samples. Optionally, stratification can be done by condition (see Figure `r ifelse(on.bioc, fig_nums("tsne-plot-facet-condition", display = "num"), "\\@ref(fig:tsne-plot-facet-condition)")`). With such a spot-check plot, we can inspect whether differences in cell abundance are strong between conditions, and we can identify distinguishing clusters.
```{r tsne-plot-facet-sample, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("tsne-plot-facet-sample"))}
## Facet per sample
gg_tsne_cl1m + facet_wrap(~ sample_id)
```
```{r tsne-plot-facet-condition, fig.height = 3, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("tsne-plot-facet-condition"))}
## Facet per condition
gg_tsne_cl1m + facet_wrap(~ condition)
```
One of the usefull representations of merging is a heatmap of median marker expression in each of the original clusters, which are labeled according to the proposed merging, Figure `r ifelse(on.bioc, fig_nums("plot-clustering-heatmap1m-merging", display = "num"), "\\@ref(fig:plot-clustering-heatmap1m-merging)")`.
```{r plot-clustering-heatmap1m-merging, fig.height = 5, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("plot-clustering-heatmap1m-merging"))}
plot_clustering_heatmap_wrapper(expr = expr[, lineage_markers],
expr01 = expr01[, lineage_markers], cell_clustering = cell_clustering1,
color_clusters = color_clusters, cluster_merging = cluster_merging1)
```
To get a final summary of the annotated cell types, one can plot a heatmap of median marker expression, calculated based on the cells in each of the annotated populations, Figure `r ifelse(on.bioc, fig_nums("plot-clustering-heatmap1m", display = "num"), "\\@ref(fig:plot-clustering-heatmap1m)")`.
```{r plot-clustering-heatmap1m, fig.height = 3, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("plot-clustering-heatmap1m"))}
plot_clustering_heatmap_wrapper(expr = expr[, lineage_markers],
expr01 = expr01[, lineage_markers], cell_clustering = cell_clustering1m,
color_clusters = color_clusters)
```
# Differential analysis
For the dataset used in this workflow [@Bodenmiller2012; @Bruggner2014], we perform three types of analyses that aim to identify subsets of PBMCs and signaling markers that respond to BCR/FcR-XL stimulation, by comparing stimulated samples to unstimulated samples. We first describe the differential abundance of the defined cell populations, followed by differential analysis of marker expression within each cluster. Finally, differential analysis of the overall aggregated marker expression could also be of interest.
The PBMC subset analyzed in this workflow originates from a paired experiment, where samples from 8 patients were treated with 12 different stimulation conditions for 30 minutes, together with unstimulated reference samples [@Bodenmiller2012]. This is a natural example where one would choose a mixed model to model the response (abundance or marker signal), and patients would be treated as a random effect. In this way, one can formally account for within-patient variability, observed to be quite strong in the MDS plot (see [MDS plot](#mds-plot) section), and this should give a gain in power to detect differences between conditions.
We use the `r CRANpkg("stats")` and `r CRANpkg("lme4")` packages to fit the fixed and mixed models, respectively, and the `r CRANpkg("multcomp")` package for hypothesis testing. In all differential analyses here, we want to test for differences between the reference (`Ref`) and BCR/FcR-XL treatment (`BCRXL`). The fixed model formula is straightforward: `~ condition`, where `condition` indicates the treatment group. The corresponding full model design matrix consists of the intercept and dummy variable indicating the treated samples. In the presence of batches, one can include them in the model by using a formula `~ condition + batch`, or if they affect the treatment, a formula with interactions `~ condition * batch`.
For testing, we use the general linear hypotheses function `glht`, which allows testing of arbitrary hypotheses. The `linfct` parameter specifies the linear hypotheses to be tested. It should be a matrix where each row corresponds to one comparison (contrast), and the number of columns must be the same as in the design matrix. In our analysis, the contrast matrix indicates that the regression coefficient corresponding to `conditionBCRXL` is tested to be equal to zero; i.e. we test the null hypothesis that there is no effect of the BCR/FcR-XL treatment. The result of the test is a p-value, which indicates the probability of observing an as strong (or stronger) difference between the two conditions assuming the null hypothesis is true.
Testing is performed on each cluster and marker separately, resulting in 8 tests for differential abundance (one for each merged population), 14 tests for overall differential marker expression analysis and 8 x 14 tests for differential marker expression within-populations. Thus, to account for the multiple testing correction, we apply the Benjamini & Hochberg adjustment to each of them using an FDR cutoff of 5\%.
```{r diff-freqs-define_model}
library(lme4)
library(multcomp)
## Model formula without random effects
model.matrix( ~ condition, data = md)
## Create contrasts
contrast_names <- c("BCRXLvsRef")
k1 <- c(0, 1)
K <- matrix(k1, nrow = 1, byrow = TRUE, dimnames = list(contrast_names))
K
```
```{r diff-FDR-cutoff}
FDR_cutoff <- 0.05
```
## Differential cell population abundance
Differential analysis of cell population abundance compares the proportions of cell types across experimental conditions and aims to highlight populations that are present at different ratios. First, we calculate two tables: one that contains cell counts for each sample and population and one with the corresponding proportions of cell types by sample. The proportions are used only for plotting, since the statistical modeling takes the cell counts by cluster and sample as input.
```{r diff-freqs}
counts_table <- table(cell_clustering1m, sample_ids)
props_table <- t(t(counts_table) / colSums(counts_table)) * 100
counts <- as.data.frame.matrix(counts_table)
props <- as.data.frame.matrix(props_table)
```
For each sample, we plot its PBMC cell type composition represented with colored bars, where the size of a given stripe reflects the proportion of the corresponding cell type in a given sample (see Figure `r ifelse(on.bioc, fig_nums("diff-freqs-plot-props-barplot", display = "num"), "\\@ref(fig:diff-freqs-plot-props-barplot)")`).
```{r diff-freqs-plot-props-barplot, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("diff-freqs-plot-props-barplot"))}
ggdf <- melt(data.frame(cluster = rownames(props), props),
id.vars = "cluster", value.name = "proportion", variable.name = "sample_id")
## Add condition info
mm <- match(ggdf$sample_id, md$sample_id)
ggdf$condition <- factor(md$condition[mm])
ggplot(ggdf, aes(x = sample_id, y = proportion, fill = cluster)) +
geom_bar(stat = "identity") +
facet_wrap(~ condition, scales = "free_x") +
theme_bw() +
theme(axis.text.x = element_text(angle = 90, hjust = 1)) +
scale_fill_manual(values = color_clusters)
```
It may be quite hard to see the differences in cluster abundances in the plot above, especially for clusters with very low frequency. And, since boxplots cannot represent multimodal distributions, we show boxplots with jittered points of the sample-level cluster proportions overlaid (see Figure `r ifelse(on.bioc, fig_nums("diff-freqs-plot-props-boxplot", display = "num"), "\\@ref(fig:diff-freqs-plot-props-boxplot)")`). The y-axes are scaled to the range of data plotted for each cluster, to better visualize the differences in frequency of lower abundance clusters. For this experiment, it may be interesting to additionally depict the patient information. We do this by plotting a different point shape for each patient. Indeed, we can see that often the direction of abundance changes between the two conditions are concordant among the patients.
```{r diff-freqs-plot-props-boxplot, fig.height = 4, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("diff-freqs-plot-props-boxplot"))}
ggdf$patient_id <- factor(md$patient_id[mm])
ggplot(ggdf) +
geom_boxplot(aes(x = condition, y = proportion, color = condition,
fill = condition), position = position_dodge(), alpha = 0.5,
outlier.color = NA) +
geom_point(aes(x = condition, y = proportion, color = condition,
shape = patient_id), alpha = 0.8, position = position_jitterdodge()) +
facet_wrap(~ cluster, scales = "free", nrow = 2) +
theme_bw() +
theme(axis.text.x = element_blank(), axis.ticks.x = element_blank(),
strip.text = element_text(size = 6)) +
scale_color_manual(values = color_conditions) +
scale_fill_manual(values = color_conditions) +
scale_shape_manual(values = c(16, 17, 8, 3, 12, 0, 1, 2))
```
As our goal is to compare proportions, one could take these values, transform them (e.g. logit) and use them as a dependent variable in a linear model. However, this approach does not take into account the uncertainty of proportion estimates, which is higher when ratios are calculated for samples with lower total cell counts. A distribution that naturally accounts for such uncertainty is the binomial distribution (i.e. logistic regression), which takes the cell counts as input (relative to the total for each sample). Nevertheless, as in the genomic data analysis, the pure logistic regression is not able to capture the overdispersion that is present in HDCyto data. A natural extension to model the extra variation is the generalized linear mixed model (GLMM), where the random effect is defined by the sample ID [@Zhao2013; @Jia2014]. Additionally, in our example the patient pairing could be accounted in the model by incorporating a random intercept for each patient. Thus, we present two GLMMs. Both of them comprise a random effect defined by the sample ID to model the overdispersion in proportions. The second model includes a random effect defined by the patient ID to account for the experiment pairing.
In our model, the blocking variable is patient ID $i = 1, ..., n$, where $n=8$. For each patient, there are $n_i$ samples measured, and $j = 1,..., n_i$ indicates the sample ID. Here, $n_i=2$ for all $i$ (one from reference and one from BCR/FcR-XL stimulated).
We assume that for a given cell population, the cell counts $Y_{ij}$ follow a binomial distribution $Y_{ij} \sim Bin(m_{ij}, \pi_{ij})$, where $m_{ij}$ is a total number of cells in a sample corresponding to patient $i$ and condition $j$. The generalized linear mixed model with observation-level random effects $\xi_{ij}$ is defined as follows:
$$E(Y_{ij}|\beta_0, \beta_1, \xi_{ij}) = logit^{-1}(\beta_0 + \beta_1 x_{ij} + \xi_{ij}),$$
where $\xi_{ij} \sim N(0, \sigma^2_\xi)$ and $x_{ij}$ corresponds to the `conditionBCRXL` column in the design matrix and indicates whether a sample $ij$ belongs to the reference ($x_{ij}=0$) or treated condition ($x_{ij}=1$). Since $E(Y_{ij}|\beta_0, \beta_1, \xi_{ij}) = \pi_{ij}$, the above formula can be written as follows:
$$ logit(\pi_{ij}) = \beta_0 + \beta_1 x_{ij} + \xi_{ij}.$$
The generalized linear mixed model that furthermore accounts for the patient pairing incorporates additionally a random intercept for each patient $i$:
$$E(Y_{ij}|\beta_0, \beta_1, \gamma_i, \xi_{ij}) = logit^{-1}(\beta_0 + \beta_1 x_{ij} + \gamma_i + \xi_{ij}),$$
where $\gamma_{i} \sim N(0, \sigma^2_\gamma)$.
```{r diff-formula-glmer-binomial}
formula_glmer_binomial1 <- y/total ~ condition + (1|sample_id)
formula_glmer_binomial2 <- y/total ~ condition + (1|patient_id) + (1|sample_id)
```
The wrapper function below takes as input a data frame with cell counts (each row is a population, each column is a sample), the metadata table, and the formula, and performs the differential analysis specified with contrast `K` for each population separately, returning a list with coefficients, non-adjusted and adjusted p-values.
```{r diff-differential-abundance-wrapper}
differential_abundance_wrapper <- function(counts, md, formula, K){
## Fit the GLMM for each cluster separately
ntot <- colSums(counts)
fit_binomial <- lapply(1:nrow(counts), function(i){
data_tmp <- data.frame(y = as.numeric(counts[i, md$sample_id]),
total = ntot[md$sample_id], md)
fit_tmp <- glmer(formula, weights = total, family = binomial,
data = data_tmp)
## Fit contrasts one by one
out <- apply(K, 1, function(k){
contr_tmp <- glht(fit_tmp, linfct = matrix(k, 1))
summ_tmp <- summary(contr_tmp)
out <- c(summ_tmp$test$coefficients, summ_tmp$test$pvalues)
names(out) <- c("coeff", "pval")
return(out)
})
return(t(out))
})
### Extract fitted contrast coefficients
coeffs <- lapply(fit_binomial, function(x){
x[, "coeff"]
})
coeffs <- do.call(rbind, coeffs)
colnames(coeffs) <- paste0("coeff_", contrast_names)
rownames(coeffs) <- rownames(counts)
### Extract p-values
pvals <- lapply(fit_binomial, function(x){
x[, "pval"]
})
pvals <- do.call(rbind, pvals)
colnames(pvals) <- paste0("pval_", contrast_names)
rownames(pvals) <- rownames(counts)
### Adjust the p-values
adjp <- apply(pvals, 2, p.adjust, method = "BH")
colnames(adjp) <- paste0("adjp_", contrast_names)
return(list(coeffs = coeffs, pvals = pvals, adjp = adjp))
}
```
We fit both of the GLMMs specified above. We can see that accounting for the patient pairing increases the sensitivity to detect differentially abundant cell populations.
```{r diff-freqs-fit-model}
da_out1 <- differential_abundance_wrapper(counts, md = md,
formula = formula_glmer_binomial1, K = K)
apply(da_out1$adjp < FDR_cutoff, 2, table)
da_out2 <- differential_abundance_wrapper(counts, md = md,
formula = formula_glmer_binomial2, K = K)
apply(da_out2$adjp < FDR_cutoff, 2, table)
```
An output table containing the observed cell population proportions in each sample and p-values can be assembled (and optionally written to a file).
```{r diff-freqs-fit-model-output}
da_output2 <- data.frame(cluster = rownames(props), props,
da_out2$coeffs, da_out2$pvals, da_out2$adjp, row.names = NULL)
print(head(da_output2), digits = 2)
```
We use a heatmap to report the differential cell populations (see Figure `r ifelse(on.bioc, fig_nums("diff-freqs-plot-heatmap-with-significant-clusters", display = "num"), "\\@ref(fig:diff-freqs-plot-heatmap-with-significant-clusters)")`). Proportions are first scaled with the arcsine-square-root transformation (as an alternative to logit that does not return NAs when ratios are equal to zero or one). Then, Z-score normalization is applied to each population to better highlight the relative differences between compared conditions. We created two wrapper functions: `normalization_wrapper` performs the normalization of submitted expression `expr` to mean 0 and standard deviation 1, and `plot_differential_heatmap_wrapper` generates a heatmap of submitted expression `expr_norm`, where samples are grouped by `condition`, indicated with a color bar on top of the plot. Additionally, labels of clusters contain the adjusted p-values in parenthesis.
```{r normalization-wrapper}
normalization_wrapper <- function(expr, th = 2.5){
expr_norm <- apply(expr, 1, function(x){
sdx <- sd(x, na.rm = TRUE)
if(sdx == 0){
x <- (x - mean(x, na.rm = TRUE))
}else{
x <- (x - mean(x, na.rm = TRUE)) / sdx
}
x[x > th] <- th
x[x < -th] <- -th
return(x)
})
expr_norm <- t(expr_norm)
}
```
```{r diff-plot-differential-heatmap-wrapper}
plot_differential_heatmap_wrapper <- function(expr_norm, sign_adjp,
condition, color_conditions, th = 2.5){
## Order samples by condition
oo <- order(condition)
condition <- condition[oo]
expr_norm <- expr_norm[, oo, drop = FALSE]
## Create the row labels with adj p-values and other objects for pheatmap
labels_row <- paste0(rownames(expr_norm), " (", sprintf( "%.02e", sign_adjp), ")")
labels_col <- colnames(expr_norm)
annotation_col <- data.frame(condition = factor(condition))
rownames(annotation_col) <- colnames(expr_norm)
annotation_colors <- list(condition = color_conditions)
color <- colorRampPalette(c("#87CEFA", "#56B4E9", "#56B4E9", "#0072B2",
"#000000", "#D55E00", "#E69F00", "#E69F00", "#FFD700"))(100)
breaks = seq(from = -th, to = th, length.out = 101)
legend_breaks = seq(from = -round(th), to = round(th), by = 1)
gaps_col <- as.numeric(table(annotation_col$condition))
pheatmap(expr_norm, color = color, breaks = breaks,
legend_breaks = legend_breaks, cluster_cols = FALSE, cluster_rows = FALSE,
labels_col = labels_col, labels_row = labels_row, gaps_col = gaps_col,
annotation_col = annotation_col, annotation_colors = annotation_colors,
fontsize = 8)
}
```
```{r diff-freqs-asin-sqrt-transformation}
## Apply the arcsine-square-root transformation
asin_table <- asin(sqrt((t(t(counts_table) / colSums(counts_table)))))
asin <- as.data.frame.matrix(asin_table)
## Keep significant clusters and sort them by significance
sign_clusters <- names(which(sort(da_out2$adjp[, "adjp_BCRXLvsRef"]) < FDR_cutoff))
## Get the adjusted p-values
sign_adjp <- da_out2$adjp[sign_clusters , "adjp_BCRXLvsRef", drop=FALSE]
## Keep samples for condition A and normalize to mean = 0 and sd = 1
asin_norm <- normalization_wrapper(asin[sign_clusters, ])
```
```{r diff-freqs-plot-heatmap-with-significant-clusters, fig.height = 3, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("diff-freqs-plot-heatmap-with-significant-clusters"))}
mm <- match(colnames(asin_norm), md$sample_id)
plot_differential_heatmap_wrapper(expr_norm = asin_norm, sign_adjp = sign_adjp,
condition = md$condition[mm], color_conditions = color_conditions)
```
## Differential analysis of marker expression stratified by cell population
For this part of the analysis, we calculate the median expression of the 14 signaling markers in each cell population (merged cluster) and sample. These will be used as the response variable $Y_{ij}$ in the linear model (LM) or linear mixed model (LMM), for which we assume that the median marker expression follows a Gaussian distribution (on the already arcsinh-transformed scale).
The linear model is formulated as follows:
$$Y_{ij} = \beta_0 + \beta_1 x_{ij} + \epsilon_{ij},$$
where $\epsilon_{ij} \sim N(0, \sigma^2)$, and the mixed model includes a random intercept for each patient:
$$Y_{ij} = \beta_0 + \beta_1 x_{ij} + \gamma_{i} + \epsilon_{ij},$$
where $\gamma_{i} \sim N(0, \sigma^2_\gamma)$. In the current experiment, we have an intercept (basal level) and a single covariate, $x_{ij}$, which is represented as a binary (stimulated/unstimulated) variable. For more complicated designs or batch effects, additional columns of a design matrix can be used.
One drawback of summarizing the protein marker intensity with a median over cells is that all the other characteristics of the distribution, such as bimodality, skewness and variance, are ignored. On the other hand, it results in a simple, easy to interpret approach, which in many cases will be able to detect interesting changes. Another issue that arises from using a summary statistic is the level of uncertainty, which increases as the number of cells used to calculate it decreases. In the statistical modeling, this problem could be partially handled by assigning observation weights (number of cells) to each cluster and sample. However, since each cluster is tested separately, these weights do not account for the differences in size between clusters.
There might be instances of small cell populations for which no cells are observed in some samples or where the number of cells is very low. For clusters absent from a sample (e.g. due to biological variance or insufficient sampling), NAs are introduced because no median expression can be calculated; in the case of few cells, the median may be quite variable. Thus, we apply a filter to remove samples that have fewer than 5 cells. We also remove cases where marker expression is equal to zero in all the samples, as this leads to an error during model fitting.
```{r diff-expr2-median-expression}
## Get median marker expression per sample and cluster
expr_median_sample_cluster_tbl <- data.frame(expr[, functional_markers],
sample_id = sample_ids, cluster = cell_clustering1m) %>%
group_by(sample_id, cluster) %>%
summarize_all(funs(median))
## Melt
expr_median_sample_cluster_melt <- melt(expr_median_sample_cluster_tbl,
id.vars = c("sample_id", "cluster"), value.name = "median_expression",
variable.name = "antigen")
## Rearange so the rows represent clusters and markers
expr_median_sample_cluster <- dcast(expr_median_sample_cluster_melt,
cluster + antigen ~ sample_id, value.var = "median_expression")
rownames(expr_median_sample_cluster) <- paste0(expr_median_sample_cluster$cluster,
"_", expr_median_sample_cluster$antigen)
## Eliminate clusters with low frequency
clusters_keep <- names(which((rowSums(counts < 5) == 0)))
keepLF <- expr_median_sample_cluster$cluster %in% clusters_keep
expr_median_sample_cluster <- expr_median_sample_cluster[keepLF, ]
## Eliminate cases with zero expression in all samples
keep0 <- rowSums(expr_median_sample_cluster[, md$sample_id]) > 0
expr_median_sample_cluster <- expr_median_sample_cluster[keep0, ]
```
It is helpful to plot the median expression of all the markers in each cluster for each sample colored by condition, to get a rough image of how strong the differences might be (see Figure `r ifelse(on.bioc, fig_nums("diff-expr2-plot-median-expr", display = "num"), "\\@ref(fig:diff-expr2-plot-median-expr)")`). We do this by combining boxplots and jitter.
```{r diff-expr2-plot-median-expr, fig.height = 8, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("diff-expr2-plot-median-expr"))}
ggdf <- expr_median_sample_cluster_melt[expr_median_sample_cluster_melt$cluster
%in% clusters_keep, ]
## Add info about samples
mm <- match(ggdf$sample_id, md$sample_id)
ggdf$condition <- factor(md$condition[mm])
ggdf$patient_id <- factor(md$patient_id[mm])
ggplot(ggdf) +
geom_boxplot(aes(x = antigen, y = median_expression,
color = condition, fill = condition),
position = position_dodge(), alpha = 0.5, outlier.color = NA) +
geom_point(aes(x = antigen, y = median_expression, color = condition,
shape = patient_id), alpha = 0.8, position = position_jitterdodge(),
size = 0.7) +
facet_wrap(~ cluster, scales = "free_y", ncol=2) +
theme_bw() +
theme(axis.text.x = element_text(angle = 90, vjust = 0.5, hjust = 1)) +
scale_color_manual(values = color_conditions) +
scale_fill_manual(values = color_conditions) +
scale_shape_manual(values = c(16, 17, 8, 3, 12, 0, 1, 2))
```
We created a wrapper function `differential_expression_wrapper` that performs the differential analysis of marker expression. The user needs to specify a data frame `expr_median` with marker expression, where each column corresponds to a sample and each row to a cluster/marker combination. One can choose between fitting a regular linear model `model = "lm"` or a linear mixed model `model = "lmer"`. The `formula` parameter must be adjusted adequately to the model choice. The wrapper function returns the contrast coefficients, non-adjusted and adjusted p-values for each of the specified contrasts `K` for each cluster/marker combination.
```{r diff-differential-expression-wrapper}
differential_expression_wrapper <- function(expr_median, md, model = "lmer", formula, K){
## Fit LMM or LM for each marker separately
fit_gaussian <- lapply(1:nrow(expr_median), function(i){
data_tmp <- data.frame(y = as.numeric(expr_median[i, md$sample_id]), md)
switch(model,
lmer = {
fit_tmp <- lmer(formula, data = data_tmp)
},
lm = {
fit_tmp <- lm(formula, data = data_tmp)
})
## Fit contrasts one by one
out <- apply(K, 1, function(k){
contr_tmp <- glht(fit_tmp, linfct = matrix(k, 1))
summ_tmp <- summary(contr_tmp)
out <- c(summ_tmp$test$coefficients, summ_tmp$test$pvalues)
names(out) <- c("coeff", "pval")
return(out)
})
return(t(out))
})
### Extract fitted contrast coefficients
coeffs <- lapply(fit_gaussian, function(x){
x[, "coeff"]
})
coeffs <- do.call(rbind, coeffs)
colnames(coeffs) <- paste0("coeff_", contrast_names)
rownames(coeffs) <- rownames(expr_median)
### Extract p-values
pvals <- lapply(fit_gaussian, function(x){
x[, "pval"]
})
pvals <- do.call(rbind, pvals)
colnames(pvals) <- paste0("pval_", contrast_names)
rownames(pvals) <- rownames(expr_median)
### Adjust the p-values
adjp <- apply(pvals, 2, p.adjust, method = "BH")
colnames(adjp) <- paste0("adjp_", contrast_names)
return(list(coeffs = coeffs, pvals = pvals, adjp = adjp))
}
```
To present how accounting for the within patient variability with the mixed model increases sensitivity, we also fit a regular linear model. The linear mixed model has a random intercept for each patient.
```{r diff-expr2-formula}
formula_lm <- y ~ condition
formula_lmer <- y ~ condition + (1|patient_id)
```
By accounting for the patient effect, we detect almost twice as many cases of differential signaling compared to the regular linear model.
```{r diff-expr2-fit-model}
de_out1 <- differential_expression_wrapper(expr_median = expr_median_sample_cluster,
md = md, model = "lm", formula = formula_lm, K = K)
apply(de_out1$adjp < FDR_cutoff, 2, table)
de_out2 <- differential_expression_wrapper(expr_median = expr_median_sample_cluster,
md = md, model = "lmer", formula = formula_lmer, K = K)
apply(de_out2$adjp < FDR_cutoff, 2, table)
```
One can assemble together an output table with the information about median marker expression in each cluster and sample, and the obtained contrast coefficients and p-values.
```{r diff-expr2-fit-model-output}
de_output2 <- data.frame(expr_median_sample_cluster,
de_out2$coeffs, de_out2$pvals, de_out2$adjp, row.names = NULL)
print(head(de_output2), digits = 2)
```
To report the significant results, we use a heatmap (see Figure `r ifelse(on.bioc, fig_nums("diff-expr2-plot-heatmap-with-significant-markers", display = "num"), "\\@ref(fig:diff-expr2-plot-heatmap-with-significant-markers)")`). Instead of plotting the absolute expression, we display the normalized expression, which better highlights the direction of marker changes. Additionally, we order the cluster-marker instances by their significance and group them by cell type (cluster).
```{r diff-expr2-plot-heatmap-with-significant-markers, fig.height = 10, fig.cap = gsub(ifelse(on.bioc, "", "Figure [0-9]{1,2}: "), "", fig_nums("diff-expr2-plot-heatmap-with-significant-markers"))}
## Keep the significant markers, sort them by significance and group by cluster
sign_clusters_markers <- names(which(de_out2$adjp[, "adjp_BCRXLvsRef"] < FDR_cutoff))
oo <- order(expr_median_sample_cluster[sign_clusters_markers, "cluster"],
de_out2$adjp[sign_clusters_markers, "adjp_BCRXLvsRef"])
sign_clusters_markers <- sign_clusters_markers[oo]
## Get the significant adjusted p-values
sign_adjp <- de_out2$adjp[sign_clusters_markers , "adjp_BCRXLvsRef"]
## Normalize expression to mean = 0 and sd = 1
expr_s <- expr_median_sample_cluster[sign_clusters_markers,md$sample_id]
expr_median_sample_cluster_norm <- normalization_wrapper(expr_s)
mm <- match(colnames(expr_median_sample_cluster_norm), md$sample_id)
plot_differential_heatmap_wrapper(expr_norm = expr_median_sample_cluster_norm,
sign_adjp = sign_adjp, condition = md$condition[mm],
color_conditions = color_conditions)
```
# Software availability
All software packages used in this workflow are publicly available from the Comprehensive R Archive Network (https://cran.r-project.org) or the Bioconductor project (http://bioconductor.org).
The specific version numbers of the packages used are shown below, along with the version of the R installation.
Version numbers of all Bioconductor packages correspond to release version 3.6 of the Bioconductor project.
```{r, results = 'asis', eval = !on.bioc, echo = FALSE}
cat("Users can install all required packages and execute the workflow by following the instructions at https://github.com/gosianow/cytofWorkflow_BioC2017_workshop.\n")
```
```{r sessionInfo}
sessionInfo()
```
# References