Decontamination of single cell protein expression data with DecontPro

Introduction

DecontPro assess and decontaminate single-cell protein expression data, such as those generated from CITE-seq or Total-seq. The count matrix is decomposed into three matrices, the native, the ambient and the background that represent the contribution from the true protein expression on cells, the ambient material and other non-specific background contamination.

Installation

DecontX Package can be installed from Bioconductor:

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

Then the package can be loaded in R using the following command:

library(decontX)

To see the latest updates and releases or to post a bug, see our GitHub page at https://github.com/campbio/decontX.

Importing data

Here we use an example dataset from SingleCellMultiModal package.

library(SingleCellMultiModal)
dat <- CITEseq("cord_blood", dry.run = FALSE)
#> Warning: 'ExperimentList' contains 'data.frame' or 'DataFrame',
#>   potential for errors with mixed data types
counts <- experiments(dat)$scADT

For this tutorial, we sample only 1000 droplets from the dataset to demonstrate the use of functions. When analyzing your dataset, sub-sampling should be done with caution, as decontPro approximates contamination profile using the dataset. A biased sampling may introduce bias to the contamination profile approximation.

set.seed(42)
sample_id <- sample(dim(counts)[2], 1000, replace = FALSE)
counts_sample <- counts[, sample_id]

Generate cell clusters

decontPro requires a vector indicating the cell types of each droplet. Here we use Seurat for clustering.

library(Seurat)
library(dplyr)
adt_seurat <- CreateSeuratObject(counts_sample, assay = "ADT")
#> Warning: Data is of class matrix. Coercing to dgCMatrix.
adt_seurat <- NormalizeData(adt_seurat, normalization.method = "CLR", margin = 2) %>%
  ScaleData(assay = "ADT") %>%
  RunPCA(assay = "ADT", features = rownames(adt_seurat), npcs = 10,
  reduction.name = "pca_adt") %>%
  FindNeighbors(dims = 1:10, assay = "ADT", reduction = "pca_adt") %>%
  FindClusters(resolution = 0.5)
#> Warning: Requested number is larger than the number of available items (13).
#> Setting to 13.
#> Warning: Requested number is larger than the number of available items (13).
#> Setting to 13.
#> Warning: Requested number is larger than the number of available items (13).
#> Setting to 13.
#> Warning: Requested number is larger than the number of available items (13).
#> Setting to 13.
#> Warning: Requested number is larger than the number of available items (13).
#> Setting to 13.
#> Modularity Optimizer version 1.3.0 by Ludo Waltman and Nees Jan van Eck
#> 
#> Number of nodes: 1000
#> Number of edges: 32498
#> 
#> Running Louvain algorithm...
#> Maximum modularity in 10 random starts: 0.8567
#> Number of communities: 9
#> Elapsed time: 0 seconds
adt_seurat <- RunUMAP(adt_seurat,
                      dims = 1:10,
                      assay = "ADT",
                      reduction = "pca_adt",
                      reduction.name = "adtUMAP",
                      verbose = FALSE)
DimPlot(adt_seurat, reduction = "adtUMAP", label = TRUE)

FeaturePlot(adt_seurat, 
            features = c("CD3", "CD4", "CD8", "CD19", "CD14", "CD16", "CD56"))


clusters <- as.integer(Idents(adt_seurat))

Run DecontPro

You can run DecontPro by simply:

counts <- as.matrix(counts_sample)
out <- decontPro(counts,
                clusters)

Priors (delta_sd and background_sd) may need tuning with the help of plotting the decontaminated results. The two priors encode belief if a more spread-out count should be considered contamination vs. native. We tested the default values on datasets ranging 5k to 10k droplets and 10 to 30 ADTs and the results are reasonable. A more contaminated or a smaller dataset may need larger priors. In this tutorial, since we sampled only 1k droplets from the original 10k droplets, we use slightly larger priors:

counts <- as.matrix(counts_sample)
out <- decontPro(counts,
                 clusters,
                 delta_sd = 2e-4,
                 background_sd = 2e-5)
#> Chain 1: ------------------------------------------------------------
#> Chain 1: EXPERIMENTAL ALGORITHM:
#> Chain 1:   This procedure has not been thoroughly tested and may be unstable
#> Chain 1:   or buggy. The interface is subject to change.
#> Chain 1: ------------------------------------------------------------
#> Chain 1: 
#> Chain 1: 
#> Chain 1: 
#> Chain 1: Gradient evaluation took 0.003921 seconds
#> Chain 1: 1000 transitions using 10 leapfrog steps per transition would take 39.21 seconds.
#> Chain 1: Adjust your expectations accordingly!
#> Chain 1: 
#> Chain 1: 
#> Chain 1: Begin eta adaptation.
#> Chain 1: Iteration:   1 / 250 [  0%]  (Adaptation)
#> Chain 1: Iteration:  50 / 250 [ 20%]  (Adaptation)
#> Chain 1: Iteration: 100 / 250 [ 40%]  (Adaptation)
#> Chain 1: Iteration: 150 / 250 [ 60%]  (Adaptation)
#> Chain 1: Iteration: 200 / 250 [ 80%]  (Adaptation)
#> Chain 1: Success! Found best value [eta = 1] earlier than expected.
#> Chain 1: 
#> Chain 1: Begin stochastic gradient ascent.
#> Chain 1:   iter             ELBO   delta_ELBO_mean   delta_ELBO_med   notes 
#> Chain 1:    100   -513832879.079             1.000            1.000
#> Chain 1:    200     -7192008.793            35.722           70.445
#> Chain 1:    300      -347838.122            30.374           19.676
#> Chain 1:    400      -253383.045            22.874           19.676
#> Chain 1:    500      -228763.670            18.320            1.000
#> Chain 1:    600      -214481.186            15.278            1.000
#> Chain 1:    700      -208941.224            13.099            0.373
#> Chain 1:    800      -204635.947            11.464            0.373
#> Chain 1:    900      -201011.587            10.193            0.108
#> Chain 1:   1000      -199376.334             9.174            0.108
#> Chain 1:   1100      -198748.896             8.340            0.067   MAY BE DIVERGING... INSPECT ELBO
#> Chain 1:   1200      -198094.401             7.646            0.067   MAY BE DIVERGING... INSPECT ELBO
#> Chain 1:   1300      -197608.584             7.058            0.027   MAY BE DIVERGING... INSPECT ELBO
#> Chain 1:   1400      -197015.589             6.554            0.027   MAY BE DIVERGING... INSPECT ELBO
#> Chain 1:   1500      -196562.589             6.117            0.021   MAY BE DIVERGING... INSPECT ELBO
#> Chain 1:   1600      -196362.533             5.735            0.021   MAY BE DIVERGING... INSPECT ELBO
#> Chain 1:   1700      -196119.605             5.398            0.018   MAY BE DIVERGING... INSPECT ELBO
#> Chain 1:   1800      -196050.018             5.098            0.018   MAY BE DIVERGING... INSPECT ELBO
#> Chain 1:   1900      -195912.599             4.829            0.008   MEDIAN ELBO CONVERGED   MAY BE DIVERGING... INSPECT ELBO
#> Chain 1: 
#> Chain 1: Drawing a sample of size 2 from the approximate posterior... 
#> Chain 1: COMPLETED.

The output contains three matrices, and model parameters after inference. decontaminated_counts represent the true protein expression on cells.

decontaminated_counts <- out$decontaminated_counts

Plot Results

Plot ADT density before and after decontamination. For bimodal ADTs, the background peak should be removed. Note CD4 is tri-modal with the intermediate mode corresponding to monocytes. This can be used as a QC metric for decontamination as only the lowest mode should be removed.

plotDensity(counts,
            decontaminated_counts,
            c("CD3", "CD4", "CD8", "CD14", "CD16", "CD19"))

We can also visualize the decontamination by each cell cluster.

plotBoxByCluster(counts,
                 decontaminated_counts,
                 clusters,
                 c("CD3", "CD4", "CD8", "CD16"))

Session Information

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
#>  [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8    
#>  [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
#>  [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
#>  [9] LC_ADDRESS=C               LC_TELEPHONE=C            
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       
#> 
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats4    stats     graphics  grDevices utils     datasets  methods  
#> [8] base     
#> 
#> other attached packages:
#>  [1] dplyr_1.2.1                 Seurat_5.6.0               
#>  [3] SeuratObject_5.4.0          sp_2.2-3                   
#>  [5] SingleCellMultiModal_1.25.3 MultiAssayExperiment_1.39.1
#>  [7] SummarizedExperiment_1.43.0 Biobase_2.73.2             
#>  [9] GenomicRanges_1.65.4        Seqinfo_1.3.2              
#> [11] IRanges_2.47.5              S4Vectors_0.51.10          
#> [13] BiocGenerics_0.59.12        generics_0.1.4             
#> [15] MatrixGenerics_1.25.0       matrixStats_1.5.0          
#> [17] decontX_1.11.2              BiocStyle_2.41.0           
#> 
#> loaded via a namespace (and not attached):
#>   [1] RcppAnnoy_0.0.23            splines_4.6.1              
#>   [3] later_1.4.8                 filelock_1.0.3             
#>   [5] tibble_3.3.1                polyclip_1.10-7            
#>   [7] fastDummies_1.7.6           lifecycle_1.0.5            
#>   [9] httr2_1.3.0                 StanHeaders_2.39.1         
#>  [11] globals_0.19.1              lattice_0.23-1             
#>  [13] MASS_7.3-66                 magrittr_2.0.5             
#>  [15] plotly_4.12.1               sass_0.4.10                
#>  [17] rmarkdown_2.32              jquerylib_0.1.4            
#>  [19] yaml_2.3.12                 httpuv_1.6.17              
#>  [21] otel_0.2.0                  sctransform_0.4.3          
#>  [23] spam_2.11-4                 spatstat.sparse_3.2-0      
#>  [25] pkgbuild_1.4.8              reticulate_1.47.0          
#>  [27] cowplot_1.2.0               pbapply_1.7-5              
#>  [29] DBI_1.3.0                   buildtools_1.0.0           
#>  [31] RColorBrewer_1.1-3          abind_1.4-8                
#>  [33] Rtsne_0.17                  purrr_1.2.2                
#>  [35] rappdirs_0.3.4              ggrepel_0.9.8              
#>  [37] inline_0.3.21               irlba_2.4.1                
#>  [39] spatstat.utils_3.2-5        listenv_1.1.0              
#>  [41] maketools_1.3.2             goftest_1.2-3              
#>  [43] RSpectra_0.16-2             spatstat.random_3.5-2      
#>  [45] fitdistrplus_1.2-6          parallelly_1.48.0          
#>  [47] codetools_0.2-20            DelayedArray_0.39.8        
#>  [49] tidyselect_1.2.1            farver_2.1.2               
#>  [51] spatstat.explore_3.8-3      BiocFileCache_3.3.0        
#>  [53] jsonlite_2.0.0              progressr_1.0.0            
#>  [55] ggridges_0.5.7              survival_3.8-12            
#>  [57] tools_4.6.1                 ica_1.0-3                  
#>  [59] Rcpp_1.1.2                  glue_1.8.1                 
#>  [61] gridExtra_2.3.1             SparseArray_1.13.4         
#>  [63] BiocBaseUtils_1.15.1        xfun_0.61                  
#>  [65] loo_2.10.1                  withr_3.0.3                
#>  [67] combinat_0.0-9              BiocManager_1.30.27        
#>  [69] fastmap_1.2.0               MCMCprecision_0.4.2        
#>  [71] digest_0.6.39               R6_2.6.1                   
#>  [73] mime_0.13                   scattermore_1.2            
#>  [75] tensor_1.5.1                spatstat.data_3.1-9        
#>  [77] RSQLite_3.53.3              tidyr_1.3.2                
#>  [79] data.table_1.18.6.1         httr_1.4.9                 
#>  [81] htmlwidgets_1.6.4           S4Arrays_1.13.2            
#>  [83] uwot_0.2.5                  pkgconfig_2.0.3            
#>  [85] gtable_0.3.6                blob_1.3.0                 
#>  [87] lmtest_0.9-40               S7_0.2.2                   
#>  [89] SingleCellExperiment_1.35.2 XVector_0.53.0             
#>  [91] sys_3.4.3                   htmltools_0.5.9            
#>  [93] dotCall64_1.2               scales_1.4.0               
#>  [95] png_0.1-9                   SpatialExperiment_1.23.0   
#>  [97] spatstat.univar_3.2-0       knitr_1.52                 
#>  [99] reshape2_1.4.5              rjson_0.2.23               
#> [101] nlme_3.1-171                curl_8.0.0                 
#> [103] cachem_1.1.0                zoo_1.9-1                  
#> [105] stringr_1.6.0               BiocVersion_3.24.0         
#> [107] KernSmooth_2.23-27          parallel_4.6.1             
#> [109] miniUI_0.1.2                AnnotationDbi_1.75.2       
#> [111] pillar_1.11.1               grid_4.6.1                 
#> [113] vctrs_0.7.3                 RANN_2.6.3                 
#> [115] promises_1.5.0              dbplyr_2.6.0               
#> [117] xtable_1.8-8                cluster_2.1.8.3            
#> [119] evaluate_1.0.5              magick_2.9.1               
#> [121] cli_3.6.6                   compiler_4.6.1             
#> [123] rlang_1.3.0                 crayon_1.5.3               
#> [125] rstantools_2.7.1            future.apply_1.20.2        
#> [127] labeling_0.4.3              plyr_1.8.9                 
#> [129] stringi_1.8.9               rstan_2.32.7               
#> [131] deldir_2.0-4                viridisLite_0.4.3          
#> [133] QuickJSR_1.11.0             Biostrings_2.81.9          
#> [135] spatstat.geom_3.8-3         V8_8.2.0                   
#> [137] Matrix_1.7-6                ExperimentHub_3.3.2        
#> [139] RcppHNSW_0.7.0              patchwork_1.3.2            
#> [141] bit64_4.8.6                 future_1.76.0              
#> [143] ggplot2_4.0.3               KEGGREST_1.53.6            
#> [145] shiny_1.14.0                AnnotationHub_4.3.2        
#> [147] ROCR_1.0-12                 igraph_2.3.4               
#> [149] memoise_2.0.1               RcppParallel_6.2.1         
#> [151] bslib_0.12.0                bit_4.6.0