Cell-cell communication tools such as CellChat start with the same pre-processing:
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.
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).
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 FGFWe 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):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"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 PDGFfindOverexpressedGenes() also accepts a plain or sparse
matrix with gene names as row names:
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.
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-44A gene is reported if its p-value is below
pval_threshold in at least one group. No multiple-testing
correction is applied.
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.541Speed-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.
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