ConvergeR is an analytical toolkit designed to compute, visualize, and statistically evaluate the convergence of single-cell states, based on the methodology established by Whitfield et al. (2026).
All functions work directly on a SingleCellExperiment
object (or any SummarizedExperiment) as well as on a
Seurat object. They only read and write the cell-level
metadata (colData() for a
SingleCellExperiment, meta.data for a
Seurat object): the object is never converted, and is
returned with its original class and all its content (assays, reduced
dimensions, …) untouched, with the new score columns added to the cell
metadata.
In this vignette, we demonstrate the package workflow on a subset of
the PBMC3k dataset (Peripheral Blood Mononuclear Cells). For each cell,
we evaluate the balance between two opposing programs from the MSigDB
Hallmark collection: the interferon alpha response versus the
inflammatory response. The main workflow uses a
SingleCellExperiment; a short section at the end shows that
the exact same calls work on a Seurat object.
Let’s load the required packages and the lightweight subset of the PBMC dataset bundled with the package.
library(ConvergeR)
library(SingleCellExperiment)
#> Loading required package: SummarizedExperiment
#> Loading required package: MatrixGenerics
#> Loading required package: matrixStats
#>
#> Attaching package: 'MatrixGenerics'
#> The following objects are masked from 'package:matrixStats':
#>
#> colAlls, colAnyNAs, colAnys, colAvgsPerRowSet, colCollapse,
#> colCounts, colCummaxs, colCummins, colCumprods, colCumsums,
#> colDiffs, colIQRDiffs, colIQRs, colLogSumExps, colMadDiffs,
#> colMads, colMaxs, colMeans2, colMedians, colMins, colOrderStats,
#> colProds, colQuantiles, colRanges, colRanks, colSdDiffs, colSds,
#> colSums2, colTabulates, colVarDiffs, colVars, colWeightedMads,
#> colWeightedMeans, colWeightedMedians, colWeightedSds,
#> colWeightedVars, rowAlls, rowAnyNAs, rowAnys, rowAvgsPerColSet,
#> rowCollapse, rowCounts, rowCummaxs, rowCummins, rowCumprods,
#> rowCumsums, rowDiffs, rowIQRDiffs, rowIQRs, rowLogSumExps,
#> rowMadDiffs, rowMads, rowMaxs, rowMeans2, rowMedians, rowMins,
#> rowOrderStats, rowProds, rowQuantiles, rowRanges, rowRanks,
#> rowSdDiffs, rowSds, rowSums2, rowTabulates, rowVarDiffs, rowVars,
#> rowWeightedMads, rowWeightedMeans, rowWeightedMedians,
#> rowWeightedSds, rowWeightedVars
#> Loading required package: GenomicRanges
#> Loading required package: stats4
#> Loading required package: BiocGenerics
#> Loading required package: generics
#>
#> Attaching package: 'generics'
#> The following objects are masked from 'package:base':
#>
#> as.difftime, as.factor, as.ordered, intersect, is.element, setdiff,
#> setequal, union
#>
#> Attaching package: 'BiocGenerics'
#> The following objects are masked from 'package:stats':
#>
#> IQR, mad, sd, var, xtabs
#> The following object is masked from 'package:utils':
#>
#> data
#> The following objects are masked from 'package:base':
#>
#> anyDuplicated, aperm, append, as.data.frame, basename, cbind,
#> colnames, dirname, do.call, duplicated, eval, evalq, Filter, Find,
#> get, grep, grepl, is.unsorted, lapply, Map, mapply, match, mget,
#> order, paste, pmax, pmax.int, pmin, pmin.int, Position, rank,
#> rbind, Reduce, rownames, sapply, saveRDS, scale, sequence, table,
#> tapply, transform, unique, unsplit, which.max, which.min
#> Loading required package: S4Vectors
#>
#> Attaching package: 'S4Vectors'
#> The following object is masked from 'package:utils':
#>
#> findMatches
#> The following objects are masked from 'package:base':
#>
#> expand.grid, I, unname
#> Loading required package: IRanges
#> Loading required package: Seqinfo
#> Loading required package: Biobase
#> Welcome to Bioconductor
#>
#> Vignettes contain introductory material; view with
#> 'browseVignettes()'. To cite Bioconductor, see
#> 'citation("Biobase")', and for packages 'citation("pkgname")'.
#>
#> Attaching package: 'Biobase'
#> The following object is masked from 'package:MatrixGenerics':
#>
#> rowMedians
#> The following objects are masked from 'package:matrixStats':
#>
#> anyMissing, rowMedians
library(ggplot2)
# Load the built-in example dataset
sce_path <- system.file("extdata", "pbmc3k_subset_sce.rds", package = "ConvergeR")
sce <- readRDS(sce_path)
sce
#> class: SingleCellExperiment
#> dim: 13714 1000
#> metadata(0):
#> assays(1): counts
#> rownames(13714): AL627309.1 AP006222.2 ... PNRC2.1 SRSF10.1
#> rowData names(0):
#> colnames(1000): AAACATTGAGCTAC AAACATTGATCAGC ... TTTCTACTGAGGCA
#> TTTGCATGCCTCAC
#> colData names(4): orig.ident nCount_RNA nFeature_RNA ident
#> reducedDimNames(0):
#> mainExpName: RNA
#> altExpNames(0):To simulate a multi-patient cohort, we inject mock clinical metadata. Patient-level covariates (age, tumor status, treatment) are constant within a patient, which is what the patient-level tests below assume. These data are simulated, so the results shown in this vignette have no biological meaning.
set.seed(42)
patients <- paste0("Patient_", 1:8)
clinical <- data.frame(
SS = patients,
age = c(25, 33, 47, 52, 68, 41, 59, 36),
tumor_status = rep(c("Primary", "Metastatic"), 4),
treatment = rep(c("Treated", "Treated", "Untreated", "Untreated"), 2)
)
# Randomly assign each cell to a patient, then copy the patient's covariates
sce$SS <- sample(patients, ncol(sce), replace = TRUE)
idx <- match(sce$SS, clinical$SS)
sce$age <- clinical$age[idx]
sce$tumor_status <- clinical$tumor_status[idx]
sce$treatment <- clinical$treatment[idx]Instead of manually importing .gmt files, we use the
msigdbr package to directly retrieve the official Hallmark
signatures.
library(msigdbr)
hallmark_sets <- msigdbr(species = "Homo sapiens", category = "H")
#> Downloading gene sets (first use only, may take a few minutes)...
raw_group1 <- hallmark_sets[hallmark_sets$gs_name == "HALLMARK_INTERFERON_ALPHA_RESPONSE", ]$gene_symbol
raw_group2 <- hallmark_sets[hallmark_sets$gs_name == "HALLMARK_INFLAMMATORY_RESPONSE", ]$gene_symbol
# Keep only the genes actually present in the dataset
group1_genes <- intersect(raw_group1, rownames(sce))
group2_genes <- intersect(raw_group2, rownames(sce))ConvergeR only needs one numeric score per cell and per program,
stored in the cell metadata. Any scoring method can be used (for example
AUCell, UCell or singscore;
AddModuleScore() for Seurat objects). For simplicity, we
use here the mean log-normalized expression of each signature.
CalculateConvergedScore() standardizes each score
(Z-score), combines the scores of each group, rescales them to \([0, 1]\) and returns their log2 ratio. A
positive value means that the cell converges towards the principal
group, a negative value towards the other group. Each group can be made
of a single score or of several scores
(principal_score = c("Score_A", "Score_B")).
sce <- CalculateConvergedScore(
object = sce,
principal_score = "Score_IFN",
other_scores = "Score_Inflam",
principal_name = "Interferon",
other_name = "Inflammatory",
output_colname = "Converged_Immune",
principal_color = "#9b59b6", # Purple for Interferon
other_color = "#e67e22" # Orange for Inflammatory
)
head(colData(sce)[, c("Converged_Immune", "Converged_Immune_Direction")])
#> DataFrame with 6 rows and 2 columns
#> Converged_Immune Converged_Immune_Direction
#> <numeric> <character>
#> AAACATTGAGCTAC -0.369121 Convergence to Infla..
#> AAACATTGATCAGC -0.324899 Convergence to Infla..
#> AAACGCTGGTTCTT -1.264272 Convergence to Infla..
#> AAACGCTGTTTCTG 0.128533 Convergence to Inter..
#> AAAGAGACGCGAGA -0.433977 Convergence to Infla..
#> AAAGAGACGGCATT 0.338368 Convergence to Inter..
table(sce$Converged_Immune_Direction)
#>
#> Convergence to Inflammatory (<0) Convergence to Interferon (>0)
#> 804 196ConvergeR automatically reads the color metadata generated in the previous step to produce standardized plots.
Proportion of cells converging to either Immune state across our 8 mock patients.
PlotConvergenceProportion(
object = sce,
x_var = "SS",
fill_var = "Converged_Immune_Direction",
title = "Immune Convergence by Patient"
)We evaluate the statistical significance at the patient level (pseudobulk) to prevent pseudoreplication bias caused by single-cell testing.
TestConvergenceScore(
object = sce,
score_var = "Converged_Immune",
test_var = "age",
level = "patient",
patient_id_var = "SS"
)
#> Spearman correlation | level: patient (pseudobulk) | N = 8 | testing 'Converged_Immune' by 'age' | p = 0.115How to read this result: The output message displays the statistical test used (e.g., Spearman correlation for continuous variables), the analysis level (patient pseudobulk to avoid single-cell pseudoreplication), the number of observations (\(N\) patients), and the p-value. A p-value below 0.05 indicates a statistically significant association between the convergence score and the tested variable.
TestMultivariateConvergence(
object = sce,
score_var = "Converged_Immune",
test_vars = c("age", "tumor_status", "treatment"),
level = "patient",
patient_id_var = "SS"
)
#> ======================================================
#> Multivariate Linear Model | level: patient (pseudobulk) | N = 8
#> Formula: Converged_Immune ~ age + tumor_status + treatment
#> Global Model R-squared: 0.995 | Global p-value: 4.23e-05
#> ------------------------------------------------------
#> Independent Variable Effects:
#> - age : p = 1.17e-05 *** (Estimate = -0.003)
#> - tumor_statusPrimary : p = 6.37e-05 *** (Estimate = 0.043)
#> - treatmentUntreated : p = 0.00014 *** (Estimate = 0.034)
#> ======================================================How to read this result: This model fits a multivariable linear regression on pseudobulk data. It details the global model performance (\(R^2\) and global p-value) and breaks down the independent effect (Estimate) and significance (p-value accompanied by conventional significance stars) for each covariate, allowing you to assess the effect of a variable while controlling for potential confounders.
The same functions accept a Seurat object, with the same
arguments. Here we build a Seurat object from the counts
and the metadata of the SingleCellExperiment used above
(before the convergence score was computed).
seu <- SeuratObject::CreateSeuratObject(
counts = counts(sce),
meta.data = as.data.frame(
colData(sce)[, c("SS", "age", "tumor_status", "treatment",
"Score_IFN", "Score_Inflam")]
)
)With a Seurat object, the module scores would typically
be computed with Seurat::AddModuleScore():
seu <- Seurat::NormalizeData(seu)
seu <- Seurat::AddModuleScore(seu, features = list(group1_genes), name = "Score_IFN_")
seu <- Seurat::AddModuleScore(seu, features = list(group2_genes), name = "Score_Inflam_")
seu$Score_IFN <- seu$Score_IFN_1
seu$Score_Inflam <- seu$Score_Inflam_1Here we simply reuse the scores computed above so that both objects
can be compared. The convergence score is then computed, plotted and
tested exactly as for the SingleCellExperiment:
seu <- CalculateConvergedScore(
object = seu,
principal_score = "Score_IFN",
other_scores = "Score_Inflam",
principal_name = "Interferon",
other_name = "Inflammatory",
output_colname = "Converged_Immune",
principal_color = "#9b59b6",
other_color = "#e67e22"
)
class(seu)
#> [1] "Seurat"
#> attr(,"package")
#> [1] "SeuratObject"
# Same result as with the SingleCellExperiment
all.equal(unname(seu$Converged_Immune), unname(sce$Converged_Immune))
#> [1] TRUEPlotConvergenceProportion(
object = seu,
x_var = "SS",
fill_var = "Converged_Immune_Direction",
title = "Immune Convergence by Patient (Seurat object)"
)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] msigdbr_26.1.1 ggplot2_4.0.3
#> [3] SingleCellExperiment_1.35.2 SummarizedExperiment_1.43.0
#> [5] Biobase_2.73.2 GenomicRanges_1.65.4
#> [7] Seqinfo_1.3.2 IRanges_2.47.5
#> [9] S4Vectors_0.51.10 BiocGenerics_0.59.12
#> [11] generics_0.1.4 MatrixGenerics_1.25.0
#> [13] matrixStats_1.5.0 ConvergeR_0.99.2
#> [15] BiocStyle_2.41.0
#>
#> loaded via a namespace (and not attached):
#> [1] RColorBrewer_1.1-3 sys_3.4.3 jsonlite_2.0.0
#> [4] magrittr_2.0.5 spatstat.utils_3.2-5 farver_2.1.2
#> [7] rmarkdown_2.32 vctrs_0.7.3 ROCR_1.0-12
#> [10] spatstat.explore_3.8-3 S4Arrays_1.13.2 htmltools_0.5.9
#> [13] curl_8.0.0 SparseArray_1.13.4 sass_0.4.10
#> [16] sctransform_0.4.3 parallelly_1.48.0 KernSmooth_2.23-27
#> [19] bslib_0.12.0 htmlwidgets_1.6.4 ica_1.0-3
#> [22] plyr_1.8.9 plotly_4.12.1 zoo_1.9-1
#> [25] cachem_1.1.0 buildtools_1.0.0 igraph_2.3.4
#> [28] mime_0.13 lifecycle_1.0.5 pkgconfig_2.0.3
#> [31] Matrix_1.7-6 R6_2.6.1 fastmap_1.2.0
#> [34] fitdistrplus_1.2-6 future_1.76.0 shiny_1.14.0
#> [37] digest_0.6.39 patchwork_1.3.2 Seurat_5.6.0
#> [40] tensor_1.5.1 RSpectra_0.16-2 irlba_2.4.1
#> [43] beachmat_2.29.3 labeling_0.4.3 progressr_1.0.0
#> [46] spatstat.sparse_3.2-0 httr_1.4.9 polyclip_1.10-7
#> [49] abind_1.4-8 compiler_4.6.1 withr_3.0.3
#> [52] S7_0.2.2 BiocParallel_1.47.0 fastDummies_1.7.6
#> [55] MASS_7.3-66 DelayedArray_0.39.8 tools_4.6.1
#> [58] lmtest_0.9-40 otel_0.2.0 httpuv_1.6.17
#> [61] future.apply_1.20.2 goftest_1.2-3 glue_1.8.1
#> [64] nlme_3.1-171 promises_1.5.0 grid_4.6.1
#> [67] Rtsne_0.17 cluster_2.1.8.3 reshape2_1.4.5
#> [70] gtable_0.3.6 spatstat.data_3.1-9 tidyr_1.3.2
#> [73] data.table_1.18.6.1 XVector_0.53.0 sp_2.2-3
#> [76] spatstat.geom_3.8-3 RcppAnnoy_0.0.23 ggrepel_0.9.8
#> [79] RANN_2.6.3 pillar_1.11.1 stringr_1.6.0
#> [82] spam_2.11-4 RcppHNSW_0.7.0 later_1.4.8
#> [85] splines_4.6.1 dplyr_1.2.1 lattice_0.23-1
#> [88] survival_3.8-12 deldir_2.0-4 tidyselect_1.2.1
#> [91] scuttle_1.23.2 maketools_1.3.2 miniUI_0.1.2
#> [94] pbapply_1.7-5 knitr_1.52 gridExtra_2.3.1
#> [97] scattermore_1.2 xfun_0.61 stringi_1.8.9
#> [100] yaml_2.3.12 evaluate_1.0.5 codetools_0.2-20
#> [103] tibble_3.3.1 BiocManager_1.30.27 cli_3.6.6
#> [106] uwot_0.2.5 xtable_1.8-8 reticulate_1.47.0
#> [109] jquerylib_0.1.4 Rcpp_1.1.2 globals_0.19.1
#> [112] spatstat.random_3.5-2 png_0.1-9 spatstat.univar_3.2-0
#> [115] parallel_4.6.1 assertthat_0.2.1 dotCall64_1.2
#> [118] listenv_1.1.0 viridisLite_0.4.3 scales_1.4.0
#> [121] ggridges_0.5.7 SeuratObject_5.4.0 purrr_1.2.2
#> [124] rlang_1.3.0 cowplot_1.2.0