censcyt
provides functionalities for differential abundance analysis in cytometry when a covariate is subject to right censoring (e.g. survival time).
censcyt 1.14.0
The censcyt
package provides functionalities for differential abundance analysis in cytometry when a covariate is subject to right censoring. It extends the differential
discovery capabilities of the diffcyt package (Weber et al. 2019)
by including association testing between the cell population abundance and a censored variable such as survival time. The general workflow is the same as described in the diffcyt package vignette
and it is advisable to be familiar with it before continuing.
As a short summary, the diffcyt
workflow consists of preprocessing of raw cytometry data, cell type assignment through automated clustering and differential abundance analysis (or differential state analysis) (Weber et al. 2019).
If a time-to-event variable (e.g. survival time) connected to a cytometry sample is available, one might be interested in the association between the cell population abundance and this time-to-event variable. In practice, a frequent problem with time-to-even variables is that not all events are observed. Instead it is possible that only a minimum time is known, with other words those values are (right) censored. If standard analysis would be used (such as testDA_GLMM
or testDA_edgeR
from diffcyt
) a bias is introduced in the parameter estimation. The simplest workaround is to exclude all incomplete samples, which is known under the name of complete case analysis or listwise deletion. But there are cases, such as high censoring rates or non random censoring, where this approach performs unfavorably, since it can greatly reduce statistical power or introduce a bias.
Normally, when a variable is censored, association testing can be done using classical survival analysis methods (e.g. Cox proportional hazard model). But in the context of differential abundance analysis in diffcyt
the censored variable is not the response but a covariate (in contrary to classical survival analyis). The approach used by censcyt
to overcome this hurdle is multiple imputation (Rubin 1988) which allows the use of the standard differential analysis methods at the cost of increased runtimes. Multiple imputation consists of the three steps Imputation, Analysis and Pooling which are shortly explained in the context of censcyt
.
In the first step (Imputation) multiple complete data sets are generated by replacing the missing (or censored) values by a random draw from a predictive distribution (See Table 1). Then in the second step (Analysis) a GLMM is fitted on each imputed dataset where the cell population counts are modeled as the response and the censored (or rather imputed) variable (e.g. survival time) as a covariate. In the last step (Pooling) the results from the second step are combined using Rubins’s rules (Rubin 1988) which take into account the additional uncertainties from the imputation.
The three main available imputation methods in the function testDA_censoredGLMM
are listed in Table 1.
Method | Description | Abbr. |
---|---|---|
Complete case analysis | Incomplete samples are removed. | cc |
Risk set imputation (Taylor et al. 2002) | Censored values are replace by a random value from its risk set. | rs |
Kaplan-Meier imputation (Taylor et al. 2002) | Censored values are replaced by a random draw from a survival function that has been fit on the risk set of the respective censored value. | km |
Mean residual life imputation (Conditional multiple imputation, Atem (2017)) | Bootstrapping of the incomplete data and replacement of the censored values by adding the mean residual life (the expected time until event given the censoring time). | mrl |
If the largest value is censored, the survival function does not reach its theoretical minimum of 0, which would leave some replacements undefined. Multiple options to handle this problem are implemented for use in Kaplan-Meier imputation (Table 2).
Description | Abbr. |
---|---|
Set the largest value as observed. (default) | km |
Model the tail of the survival function as an exponential distribution. (Moeschberger and Klein 1985) | km_exp |
Model the tail of the survival function as a weibull distribution. (Moeschberger and Klein 1985) | km_wei |
Replace all censored values larger than the largest observed value with expected values according to order statistics. (Moeschberger and Klein 1985) | km_os |
First, load the necessary packages. Important to note is that loading censcyt
will
also load diffcyt
.
suppressPackageStartupMessages({
library(censcyt)
library(ggplot2)
library(SummarizedExperiment)
library(tidyr)
})
The main input for DA analysis consists of a cluster times sample matrix of cell population abundances. How to obtain those counts is explained in the diffcyt vignette and will not be further considered here. Instead, for illustrative purposes, a dataset is simulated. In this case, the censored covariate is modeled according to an exponential distribution. To obtain censoring, an additional variable, the censoring time, is simulated as well and the actual measured value is the minimum of those two variables.
set.seed(1234)
nr_samples <- 50
nr_clusters <- 6
# the covariate is simulated from an exponential distribution:
X_true <- rexp(nr_samples)
# the censoring time is also sampled from an exponential distribution:
C <- rexp(nr_samples)
# the actual observed value is the minimum of the two:
X_obs <- pmin(X_true,C)
# additionally, we have the event indicator:
Event_ind <- ifelse(X_true>C,0,1)
# proportion censored:
(length(Event_ind)-sum(Event_ind))/length(Event_ind)
## [1] 0.52
Next, the cell counts are modeled according to a dirichlet-multinomial distribution where some clusters have an association with a covariate. The function simulate_multicluster
can be used for this.
# number of cells per sample
sizes <- round(runif(nr_samples,1e3,1e4))
# alpha parameters of the dirichlet-multinomial distribution.
alphas <- runif(nr_clusters,1,10)
# main simulation function
simulation_output <- simulate_multicluster(alphas = alphas,
sizes = sizes,
covariate = X_obs,
nr_diff = 2,
return_summarized_experiment = TRUE,
slope = list(0.9))
# counts as a SummarizedExperiment object
d_counts <- simulation_output$counts
To get a sense of how this simulated data looks like we can plot the relative cell population abundance for each sample vs. the censored covariate per cell population.
# vector indicating if a cluster has a modeled association or not
is_diff_cluster <- ifelse(is.na(simulation_output$row_data$paired),FALSE,TRUE)
# first convert to proportions:
proportion <- as.data.frame(t(apply(assay(d_counts),2,function(x)x/sum(x))))
names(proportion) <- paste0("Nr: ",names(proportion), " - ",
ifelse(is_diff_cluster,"DA","non DA"))
# then to long format for plotting
counts_long <-
pivot_longer(proportion,
cols= seq_len(nr_clusters),
names_to = "cluster_id",
values_to = "proportion")
# add observed (partly censored) variable
counts_long$X_obs <- rep(X_obs,each=nr_clusters)
# add event indicator
counts_long$Event_ind <- factor(rep(Event_ind,each=nr_clusters),
levels = c(0,1),
labels=c("censored","observed"))
ggplot(counts_long) +
geom_point(aes(x=X_obs,y=proportion,color=Event_ind)) +
facet_wrap(~cluster_id)
Next step is to set up the meta data, i.e. the covariates that are used in the testing. This is done the same way as in diffcyt
but additionally the event indicator for the censored covariate has to be given, since censored variables are represented by two values, the measured value itself and an indicator if the measured value is observed (1) or censored (0).
# all covariates and sample ids
experiment_info <- data.frame(sample_id = seq_len(nr_samples),
X_obs = X_obs,
Event_ind = Event_ind)
The createFormula
function is similar to the one used in diffcyt
but additionally the argument event_indicator
has to be specified by giving the respective column name in experiment_info
. If multiple covariates are given in argument cols_fixed
then event_indicator
is associated with the first element given to cols_fixed
.
da_formula <- createFormula(experiment_info = experiment_info,
cols_fixed = "X_obs",
cols_random = "sample_id",
event_indicator = "Event_ind")
# also create contrast matrix for testing
contrast <- matrix(c(0,1))
The main part is the differential testing which is done using the function testDA_censoredGLMM
. It consists of the same arguments as the non-censored version testDA_GLMM
(from diffcyt
) with two additional arguments specific to multiple imputation. The first additional argument is mi_reps
(multiple imputation repetitions) which is the number of multiple imputation steps performed. Unfortunately there is no clear rule as to how many imputations are needed and only rules of thumb are available (Buuren 2018). In general, more imputations are needed if the censoring rate is high and in applications where high statistical power is needed (Buuren 2018). One ‘rule’ is the quadratic rule of Hippel (2018) which can be shown if verbose
is set to TRUE
.
The second argument is imputation_method
which is used to specify the imputation method. See Table 1 and Table 2.
# test with 50 repetitions with method risk set imputation (rs)
da_out <- testDA_censoredGLMM(d_counts = d_counts,
formula = da_formula,
contrast = contrast,
mi_reps = 30,
imputation_method = "km",
verbose = TRUE,
BPPARAM=BiocParallel::MulticoreParam(workers = 1))
## 26 of 50 values are censored
## suggested number of imputations: 42
To look at the results we can use diffcyt
’s topTable
function:
topTable(da_out)
## DataFrame with 6 rows and 3 columns
## cluster_id p_val p_adj
## <character> <numeric> <numeric>
## 3 3 0.000632586 0.00379552
## 6 6 0.004310126 0.01293038
## 1 1 0.818395347 0.99716198
## 2 2 0.997161984 0.99716198
## 4 4 0.948989910 0.99716198
## 5 5 0.665591015 0.99716198
# compare to actual differential clusters:
which(!is.na(simulation_output$row_data$paired))
## [1] 3 6
Instead of using the single functions as shown above, it is possible use the wrapper
function censcyt
which is the analog to the diffcyt
wrapper. First, some expression data is simulated, i.e. a more realistic starting position than in the example above.
# Function to create expression data (one sample)
fcs_sim <- function(n = 2000, mean = 0, sd = 1, ncol = 10, cof = 5) {
d <- matrix(sinh(rnorm(n*ncol, mean, sd)) * cof,ncol=ncol)
# each marker is heavily expressed in one population, i.e. this should result
# in 'ncol' clusters
for(i in seq_len(ncol)){
d[seq(n/ncol*(i-1)+1,n/ncol*(i)),i] <-
sinh(rnorm(n/ncol, mean+2, sd)) * cof
}
colnames(d) <- paste0("marker", sprintf("%02d", 1:ncol))
d
}
# create multiple expression data samples with DA signal
comb_sim <- function(X, nr_samples = 10, mean = 0, sd = 1, ncol = 10, cof = 5){
# Create random data (without differential signal)
d_input <- lapply(seq_len(nr_samples),
function(i) fcs_sim(mean = mean,
sd = sd,
ncol = ncol,
cof = cof))
# Add differential abundance (DA) signal
for(i in seq_len(nr_samples)){
# number of cells in cluster 1
n_da <- round((plogis(X_true[i]))*300)
# set to random expression
d_input[[i]][seq_len(n_da), ] <-
matrix(sinh(rnorm(n_da*ncol, mean, sd)) * cof, ncol=ncol)
# increase expression for cluster 1
d_input[[i]][seq_len(n_da) ,1] <-
sinh(rnorm(n_da, mean + 2, sd)) * cof
}
d_input
}
# set parameter and simulat
d_input <- comb_sim(X_true, nr_samples = 50)
The objects experiment_info
, da_formula
and contrast
are the same as specified above, but additionally information about the measured markers has to be supplied.
marker_info <- data.frame(
channel_name = paste0("channel", sprintf("%03d", seq_len(10))),
marker_name = paste0("marker", sprintf("%02d", seq_len(10))),
marker_class = factor(c(rep("type", 10)),
levels = c("type", "state", "none")),
stringsAsFactors = FALSE
)
Finally the wrapper function can be run. Argument mi_reps
is to specify the number of repetitions in the multiple imputation and imputation_method
allows to set the imputation method. To speed up the computation, a BiocParallelParam
object (e.g. MulticoreParam
, from package BiocParallel) can be given to the argument BPPARAM
to parallelize parts of the computation.
# Run wrapper function
out_DA <- censcyt(d_input,
experiment_info,
marker_info,
formula = da_formula,
contrast = contrast,
meta_clustering = TRUE,
meta_k = 10,
seed_clustering = 123,
verbose = FALSE,
mi_reps = 10,
imputation_method = "km",
BPPARAM=BiocParallel::MulticoreParam(workers = 1))
## FlowSOM clustering completed in 5.2 seconds
# Display results for top DA clusters
topTable(out_DA, format_vals = TRUE)
## DataFrame with 10 rows and 3 columns
## cluster_id p_val p_adj
## <character> <numeric> <numeric>
## 8 8 0.000234 0.00234
## 3 3 0.001950 0.00976
## 1 1 0.875000 0.96700
## 2 2 0.656000 0.96700
## 4 4 0.951000 0.96700
## 5 5 0.856000 0.96700
## 6 6 0.782000 0.96700
## 7 7 0.442000 0.96700
## 9 9 0.967000 0.96700
## 10 10 0.313000 0.96700
# Plot heatmap for DA tests
plotHeatmap(out_DA,
analysis_type = "DA",
sample_order = order(X_true))
## R version 4.4.1 (2024-06-14)
## Platform: x86_64-pc-linux-gnu
## Running under: Ubuntu 24.04.1 LTS
##
## Matrix products: default
## BLAS: /home/biocbuild/bbs-3.20-bioc/R/lib/libRblas.so
## LAPACK: /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3.12.0
##
## locale:
## [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
## [3] LC_TIME=en_GB LC_COLLATE=C
## [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
## [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
## [9] LC_ADDRESS=C LC_TELEPHONE=C
## [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
##
## time zone: America/New_York
## tzcode source: system (glibc)
##
## attached base packages:
## [1] stats4 stats graphics grDevices utils datasets methods
## [8] base
##
## other attached packages:
## [1] tidyr_1.3.1 SummarizedExperiment_1.36.0
## [3] Biobase_2.66.0 GenomicRanges_1.58.0
## [5] GenomeInfoDb_1.42.0 IRanges_2.40.0
## [7] S4Vectors_0.44.0 BiocGenerics_0.52.0
## [9] MatrixGenerics_1.18.0 matrixStats_1.4.1
## [11] ggplot2_3.5.1 censcyt_1.14.0
## [13] diffcyt_1.26.0 BiocStyle_2.34.0
##
## loaded via a namespace (and not attached):
## [1] RColorBrewer_1.1-3 jsonlite_1.8.9
## [3] shape_1.4.6.1 magrittr_2.0.3
## [5] jomo_2.7-6 magick_2.8.5
## [7] TH.data_1.1-2 farver_2.1.2
## [9] nloptr_2.1.1 rmarkdown_2.28
## [11] GlobalOptions_0.1.2 zlibbioc_1.52.0
## [13] vctrs_0.6.5 Cairo_1.6-2
## [15] minqa_1.2.8 tinytex_0.53
## [17] rstatix_0.7.2 htmltools_0.5.8.1
## [19] S4Arrays_1.6.0 forcats_1.0.0
## [21] broom_1.0.7 SparseArray_1.6.0
## [23] Formula_1.2-5 mitml_0.4-5
## [25] parallelly_1.38.0 sass_0.4.9
## [27] bslib_0.8.0 plyr_1.8.9
## [29] sandwich_3.1-1 zoo_1.8-12
## [31] cachem_1.1.0 igraph_2.1.1
## [33] lifecycle_1.0.4 iterators_1.0.14
## [35] pkgconfig_2.0.3 Matrix_1.7-1
## [37] R6_2.5.1 fastmap_1.2.0
## [39] GenomeInfoDbData_1.2.13 future_1.34.0
## [41] clue_0.3-65 digest_0.6.37
## [43] colorspace_2.1-1 ggnewscale_0.5.0
## [45] furrr_0.3.1 ggpubr_0.6.0
## [47] labeling_0.4.3 cytolib_2.18.0
## [49] fansi_1.0.6 colorRamps_2.3.4
## [51] httr_1.4.7 polyclip_1.10-7
## [53] abind_1.4-8 compiler_4.4.1
## [55] withr_3.0.2 doParallel_1.0.17
## [57] ConsensusClusterPlus_1.70.0 backports_1.5.0
## [59] BiocParallel_1.40.0 carData_3.0-5
## [61] highr_0.11 ggforce_0.4.2
## [63] pan_1.9 broom.mixed_0.2.9.6
## [65] ggsignif_0.6.4 MASS_7.3-61
## [67] DelayedArray_0.32.0 rjson_0.2.23
## [69] FlowSOM_2.14.0 tools_4.4.1
## [71] nnet_7.3-19 glue_1.8.0
## [73] nlme_3.1-166 grid_4.4.1
## [75] Rtsne_0.17 cluster_2.1.6
## [77] reshape2_1.4.4 generics_0.1.3
## [79] gtable_0.3.6 car_3.1-3
## [81] utf8_1.2.4 XVector_0.46.0
## [83] foreach_1.5.2 pillar_1.9.0
## [85] stringr_1.5.1 limma_3.62.0
## [87] circlize_0.4.16 splines_4.4.1
## [89] flowCore_2.18.0 dplyr_1.1.4
## [91] tweenr_2.0.3 lattice_0.22-6
## [93] survival_3.7-0 dirmult_0.1.3-5
## [95] RProtoBufLib_2.18.0 tidyselect_1.2.1
## [97] ComplexHeatmap_2.22.0 locfit_1.5-9.10
## [99] knitr_1.48 bookdown_0.41
## [101] edgeR_4.4.0 xfun_0.48
## [103] statmod_1.5.0 stringi_1.8.4
## [105] UCSC.utils_1.2.0 yaml_2.3.10
## [107] boot_1.3-31 evaluate_1.0.1
## [109] codetools_0.2-20 tibble_3.2.1
## [111] BiocManager_1.30.25 cli_3.6.3
## [113] rpart_4.1.23 munsell_0.5.1
## [115] jquerylib_0.1.4 Rcpp_1.0.13
## [117] globals_0.16.3 png_0.1-8
## [119] XML_3.99-0.17 parallel_4.4.1
## [121] glmnet_4.1-8 lme4_1.1-35.5
## [123] listenv_0.9.1 mvtnorm_1.3-1
## [125] scales_1.3.0 purrr_1.0.2
## [127] crayon_1.5.3 GetoptLong_1.0.5
## [129] rlang_1.1.4 multcomp_1.4-26
## [131] mice_3.16.0
Atem, Folefac D. 2017. “Linear Regression Model with a Randomly Censored Predictor: Estimation Procedures.” Biostatistics and Biometrics Open Access Journal 1 (2). https://doi.org/10.19080/bboaj.2017.01.555556.
Buuren, Stef van. 2018. Flexible Imputation of Missing Data, Second Edition. https://doi.org/10.1201/9780429492259.
Hippel, Paul T. von. 2018. “How Many Imputations Do You Need? A Two-stage Calculation Using a Quadratic Rule.” Sociological Methods & Research. https://doi.org/10.1177/0049124117747303.
Moeschberger, M. L., and John P. Klein. 1985. “A Comparison of Several Methods of Estimating the Survival Function When There is Extreme Right Censoring.” Biometrics 41 (1): 253. https://doi.org/10.2307/2530660.
Rubin, Donald B. 1988. “An Overview of Multiple Imputation.” Proceedings of the Survey Research Methods Section of the American Statistical Association.
Taylor, Jeremy M. G., Kristine L. Cooper, John T. Wei, Aruna V. Sarma, Trivellore E. Raghunathan, and Steve G. Heeringa. 2002. “Use of multiple imputation to correct for nonresponse bias in a survey of urologic symptoms among African-American men.” American Journal of Epidemiology. https://doi.org/10.1093/aje/kwf110.
Weber, Lukas M., Malgorzata Nowicka, Charlotte Soneson, and Mark D. Robinson. 2019. “diffcyt: Differential discovery in high-dimensional cytometry via high-resolution clustering.” Communications Biology. https://doi.org/10.1038/s42003-019-0415-5.