Introduction to ConvergeR

Introduction

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.

1. Environment Setup and Data Preparation

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]

2. Importing MSigDB Signatures and Scoring

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.

sce <- scuttle::logNormCounts(sce)

sce$Score_IFN <- colMeans(as.matrix(logcounts(sce)[group1_genes, , drop = FALSE]))
sce$Score_Inflam <- colMeans(as.matrix(logcounts(sce)[group2_genes, , drop = FALSE]))

3. Computing the Convergence Score

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                              196

4. Visualization

ConvergeR automatically reads the color metadata generated in the previous step to produce standardized plots.

Proportion Barplot

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"
)

Crossed Proportion Barplot

Faceted by tumor status to identify subgroup trends.

PlotConvergenceCrossedProportion(
  object = sce,
  x_var = "SS",
  fill_var = "Converged_Immune_Direction",
  facet_var = "tumor_status",
  title = "Immune Convergence split by Tumor Status"
)

Density Distribution

Continuous relationship of the convergence direction with patient age.

PlotConvergenceDensity(
  object = sce,
  x_var = "age",
  fill_var = "Converged_Immune_Direction",
  x_label = "Patient Age"
)

5. Statistical Testing

We evaluate the statistical significance at the patient level (pseudobulk) to prevent pseudoreplication bias caused by single-cell testing.

Bivariate Test

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.115

How 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.

Multivariate Linear Model

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.

6. Working with Seurat Objects

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_1

Here 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] TRUE
PlotConvergenceProportion(
  object = seu,
  x_var = "SS",
  fill_var = "Converged_Immune_Direction",
  title = "Immune Convergence by Patient (Seurat object)"
)

TestConvergenceScore(
  object = seu,
  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.115

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] 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