cellchatr: fast ligand-receptor pre-filtering for single-cell data

Introduction

Cell-cell communication tools such as CellChat start with the same pre-processing:

  1. restrict the expression data to genes in a ligand-receptor (L-R) database,
  2. find genes over-expressed in each cell group (one Wilcoxon test per gene and per group), and
  3. keep the L-R interactions whose ligand or receptor is over-expressed.

Step 2 dominates the run time on large datasets, because the number of tests grows with genes × cell groups and they run one at a time in R. cellchatr implements these steps in Rust and runs the tests in parallel on all CPU cores.

Intended users. Analysts working with single-cell RNA-seq data in SingleCellExperiment or SummarizedExperiment objects who want to find over-expressed L-R interactions between cell groups, either as a quick standalone screen or as a faster pre-filter before a full communication analysis.

Relationship to CellChat. cellchatr does not depend on CellChat and does not use CellChat objects. It takes any L-R table with ligand, receptor and interaction_name columns. The interaction table of CellChatDB.human / CellChatDB.mouse has exactly this layout, so CellChat users can pass it in directly (see Using CellChatDB), but CellChat is only an optional source of an L-R database. It is not listed in DESCRIPTION because it is distributed only through GitHub, which Bioconductor does not allow as a dependency.

Installation

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

Binary packages do not need Rust. Building from source requires cargo and rustc (see SystemRequirements in DESCRIPTION).

Example with a SingleCellExperiment

library(cellchatr)
library(SingleCellExperiment)

A small ligand-receptor table

lrdb <- data.frame(
    interaction_name = c("TGFB1_TGFBR1", "VEGFA_FLT1", "CXCL12_CXCR4",
                         "IL6_IL6R", "TNF_TNFRSF1A", "MIF_CD74",
                         "CCL2_CCR2", "PDGFB_PDGFRB", "EGF_EGFR",
                         "FGF2_FGFR1"),
    ligand = c("TGFB1", "VEGFA", "CXCL12", "IL6", "TNF",
               "MIF", "CCL2", "PDGFB", "EGF", "FGF2"),
    receptor = c("TGFBR1", "FLT1", "CXCR4", "IL6R", "TNFRSF1A",
                 "CD74", "CCR2", "PDGFRB", "EGFR", "FGFR1"),
    pathway_name = c("TGFb", "VEGF", "CXCL", "IL6", "TNF",
                     "MIF", "CCL", "PDGF", "EGF", "FGF")
)
lrdb
#>    interaction_name ligand receptor pathway_name
#> 1      TGFB1_TGFBR1  TGFB1   TGFBR1         TGFb
#> 2        VEGFA_FLT1  VEGFA     FLT1         VEGF
#> 3      CXCL12_CXCR4 CXCL12    CXCR4         CXCL
#> 4          IL6_IL6R    IL6     IL6R          IL6
#> 5      TNF_TNFRSF1A    TNF TNFRSF1A          TNF
#> 6          MIF_CD74    MIF     CD74          MIF
#> 7         CCL2_CCR2   CCL2     CCR2          CCL
#> 8      PDGFB_PDGFRB  PDGFB   PDGFRB         PDGF
#> 9          EGF_EGFR    EGF     EGFR          EGF
#> 10       FGF2_FGFR1   FGF2    FGFR1          FGF

Simulated data

We simulate log-normalized expression for 2,000 genes in 300 cells from three cell types. TGFB1 and PDGFB are made higher in fibroblasts, and FLT1 higher in endothelial cells.

set.seed(42)
genes <- unique(c(lrdb$ligand, lrdb$receptor, paste0("GENE", 1:2000)))
cell_type <- rep(c("Fibroblast", "Macrophage", "Endothelial"), each = 100)

mat <- matrix(rnorm(length(genes) * length(cell_type), mean = 1, sd = 1),
              nrow = length(genes),
              dimnames = list(genes, paste0("cell", seq_along(cell_type))))
mat[c("TGFB1", "PDGFB"), cell_type == "Fibroblast"] <-
    mat[c("TGFB1", "PDGFB"), cell_type == "Fibroblast"] + 3
mat["FLT1", cell_type == "Endothelial"] <-
    mat["FLT1", cell_type == "Endothelial"] + 3

sce <- SingleCellExperiment(
    assays = list(logcounts = mat),
    colData = DataFrame(cell_type = cell_type)
)
sce
#> class: SingleCellExperiment 
#> dim: 2020 300 
#> metadata(0):
#> assays(1): logcounts
#> rownames(2020): TGFB1 VEGFA ... GENE1999 GENE2000
#> rowData names(0):
#> colnames(300): cell1 cell2 ... cell299 cell300
#> colData names(1): cell_type
#> reducedDimNames(0):
#> mainExpName: NULL
#> altExpNames(0):

One-call workflow

identifyInteractions() restricts the data to L-R genes, tests every gene in every cell type, and returns the matching interactions as a DataFrame. No multiple-testing correction is applied, so we use a strict p-value cutoff:

res <- identifyInteractions(sce, lrdb, labels = "cell_type",
                            assay.type = "logcounts",
                            pval_threshold = 1e-6)
res
#> DataFrame with 3 rows and 4 columns
#>   interaction_name      ligand    receptor pathway_name
#>        <character> <character> <character>  <character>
#> 1     TGFB1_TGFBR1       TGFB1      TGFBR1         TGFb
#> 2       VEGFA_FLT1       VEGFA        FLT1         VEGF
#> 3     PDGFB_PDGFRB       PDGFB      PDGFRB         PDGF
metadata(res)$overexpressed
#> [1] "TGFB1" "PDGFB" "FLT1"

Step by step

The steps can also be run separately:

oe <- findOverexpressedGenes(sce, labels = "cell_type", lrdb = lrdb,
                             pval_threshold = 1e-6)
oe
#> [1] "TGFB1" "PDGFB" "FLT1"
matchLRPairs(oe, lrdb)
#> DataFrame with 3 rows and 4 columns
#>   interaction_name      ligand    receptor pathway_name
#>        <character> <character> <character>  <character>
#> 1     TGFB1_TGFBR1       TGFB1      TGFBR1         TGFb
#> 2       VEGFA_FLT1       VEGFA        FLT1         VEGF
#> 3     PDGFB_PDGFRB       PDGFB      PDGFRB         PDGF

findOverexpressedGenes() also accepts a plain or sparse matrix with gene names as row names:

findOverexpressedGenes(Matrix::Matrix(mat, sparse = TRUE),
                       labels = cell_type, lrdb = lrdb,
                       pval_threshold = 1e-6)
#> [1] "TGFB1" "PDGFB" "FLT1"

Using CellChatDB

If CellChat is installed, its database can be used as-is:

library(CellChat)
res <- identifyInteractions(sce, CellChatDB.human$interaction,
                            labels = "cell_type")

Ligands and receptors are matched by name. CellChatDB entries that refer to multi-subunit complexes are only matched if the complex name itself is among the over-expressed genes.

Statistical details

For each gene and each cell group, a one-sided Wilcoxon rank-sum test compares the cells in the group against all other cells (alternative: higher in the group). P-values use the normal approximation with tie-corrected variance and no continuity correction, matching wilcox.test(x, y, alternative = "greater", exact = FALSE, correct = FALSE):

x <- mat["TGFB1", cell_type == "Fibroblast"]
y <- mat["TGFB1", cell_type != "Fibroblast"]
wilcox.test(x, y, alternative = "greater",
            exact = FALSE, correct = FALSE)$p.value
#> [1] 7.8543e-44

A gene is reported if its p-value is below pval_threshold in at least one group. No multiple-testing correction is applied.

Performance

The low-level functions rust_subset_genes(), rust_wilcoxon_filter() and rust_match_lr_pairs() work on plain vectors and matrices. Here we compare rust_wilcoxon_filter() with an equivalent loop over wilcox.test():

set.seed(42)
n_genes <- 500
labels_bm <- rep(c("A", "B"), each = 100)
counts_bm <- matrix(rnorm(n_genes * 200), nrow = n_genes)
gene_names <- paste0("GENE", seq_len(n_genes))

r_wilcoxon <- function(counts, labels, gene_names, pval_threshold) {
    hit <- vapply(seq_len(nrow(counts)), function(g) {
        any(vapply(unique(labels), function(ct) {
            wilcox.test(counts[g, labels == ct], counts[g, labels != ct],
                        alternative = "greater", exact = FALSE,
                        correct = FALSE)$p.value < pval_threshold
        }, logical(1)))
    }, logical(1))
    gene_names[hit]
}

t_rust <- system.time(
    rust_res <- rust_wilcoxon_filter(counts_bm, labels_bm, gene_names, 0.05)
)
t_r <- system.time(
    r_res <- r_wilcoxon(counts_bm, labels_bm, gene_names, 0.05)
)
identical(sort(rust_res), sort(r_res))
#> [1] TRUE
c(rust = t_rust[["elapsed"]], base_r = t_r[["elapsed"]])
#>   rust base_r 
#>  0.004  0.541

Speed-ups depend on the number of CPU cores and grow with the number of genes and cell groups. For the simple lookups (rust_subset_genes(), rust_match_lr_pairs()), base R’s %in% is already fast, and the Rust versions are provided mainly so the whole workflow runs in one place.

Session info

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] SingleCellExperiment_1.35.2 SummarizedExperiment_1.43.0
#>  [3] Biobase_2.73.2              GenomicRanges_1.65.4       
#>  [5] Seqinfo_1.3.2               IRanges_2.47.5             
#>  [7] S4Vectors_0.51.10           BiocGenerics_0.59.12       
#>  [9] generics_0.1.4              MatrixGenerics_1.25.0      
#> [11] matrixStats_1.5.0           cellchatr_0.99.1           
#> [13] BiocStyle_2.41.0           
#> 
#> loaded via a namespace (and not attached):
#>  [1] Matrix_1.7-6        jsonlite_2.0.0      compiler_4.6.1     
#>  [4] BiocManager_1.30.27 jquerylib_0.1.4     yaml_2.3.12        
#>  [7] fastmap_1.2.0       lattice_0.23-1      XVector_0.53.0     
#> [10] R6_2.6.1            S4Arrays_1.13.2     knitr_1.52         
#> [13] DelayedArray_0.39.7 maketools_1.3.2     bslib_0.12.0       
#> [16] rlang_1.3.0         cachem_1.1.0        xfun_0.61          
#> [19] sass_0.4.10         sys_3.4.3           otel_0.2.0         
#> [22] SparseArray_1.13.4  cli_3.6.6           digest_0.6.39      
#> [25] grid_4.6.1          lifecycle_1.0.5     evaluate_1.0.5     
#> [28] buildtools_1.0.0    abind_1.4-8         rmarkdown_2.32     
#> [31] tools_4.6.1         htmltools_0.5.9