LEMUR fits a low-dimensional embedding and assigns cells to neighborhoods based on similar responses to a given contrast and gene. Because these neighborhood assignments are themselves derived from a fitted model, they are subject to sampling variability: refitting the model on a different sample of cells can shift the neighborhood boundaries.
conformeR quantifies this uncertainty by wrapping the neighborhood construction step in a conformal prediction framework. It is designed to integrate naturally into existing Bioconductor workflows for single-cell data analysis, operating downstream of SingleCellExperiment-based preprocessing and LEMUR analyses to provide uncertainty-aware inference for neighborhood assignments.
It provides two complementary views of that uncertainty:
Why this matters. A single LEMUR fit gives you one
particular neighborhood assignment, with no way to tell how much of it
would survive resampling. conformeR turns that single
assignment into a statement with a formal, finite-sample coverage
guarantee.
Install conformeR running the following commands.
if (!requireNamespace("BiocManager", quietly = TRUE)) {
install.packages("BiocManager")
}
BiocManager::install("juslecl/conformeR")library(conformeR)
library(SingleCellExperiment)
library(dplyr)
library(ggplot2)
library(tibble)
library(knitr)
theme_set(
theme_minimal(base_size = 13) +
theme(
panel.grid.minor = element_blank(),
plot.title = element_text(face = "bold"),
legend.position = "bottom"
)
)
conformer_palette <- c(
"inside" = "#4472A8",
"outside" = "#D9822B",
"none" = "#B0B0B0"
)This vignette runs conformeR on a reduced version of the
single-cell RNA-seq dataset from Zhao, Dovas, Spinazzi, et al. (2021),
“Deconvolution of cell type-specific drug responses in human tumor
tissue with single-cell RNA-seq,” published in Genome
Medicine (GEO accession GSE148842). As with any input
to conformeR, the data have already been preprocessed and
quality-checked upstream.
data("sce_reduced")
sce_reduced
#> class: SingleCellExperiment
#> dim: 100 3000
#> metadata(0):
#> assays(2): counts logcounts
#> rownames(100): ENSG00000118785 ENSG00000210082 ... ENSG00000113070
#> ENSG00000181218
#> rowData names(2): gid gene
#> colnames(3000): cell 6769 cell 6786 ... cell 27922 cell 11673
#> colData names(3): cell_type condition patient_id
#> reducedDimNames(0):
#> mainExpName: NULL
#> altExpNames(0):
rownames(sce_reduced) <- rowData(sce_reduced)$gene
head(colData(sce_reduced))
#> DataFrame with 6 rows and 3 columns
#> cell_type condition patient_id
#> <character> <character> <character>
#> cell 6769 Oligodendrocytes ctrl PW030
#> cell 6786 Myeloid cells ctrl PW030
#> cell 27448 Myeloid cells panobinostat PW030
#> cell 76998 Myeloid cells ctrl PW034
#> cell 92754 Myeloid cells panobinostat PW034
#> cell 22521 Myeloid cells ctrl PW030We restrict the analysis to three genes with distinct expected
behavior, to illustrate a range of neighborhood sizes and uncertainty
patterns: HIST3H2A, PTPRZ1, and
HBEGF.
pan_affect_genes <- rowData(sce_reduced) |>
as_tibble() |>
dplyr::select(gid, gene) |>
filter(gene %in% c("HIST3H2A", "PTPRZ1", "HBEGF"))
kable(pan_affect_genes, caption = "Genes of interest for this vignette")| gid | gene |
|---|---|
| ENSG00000106278 | PTPRZ1 |
| ENSG00000113070 | HBEGF |
| ENSG00000181218 | HIST3H2A |
conformeR() fits LEMUR and layers both
conformal procedures on top of it in one call. The key arguments
are:
design_lemur — the LEMUR design formula; here we adjust
for patient_id and estimate the effect of
condition.contrast_column — which column of the design defines
the contrast of interest.genes_of_interest — restrict the (more expensive)
conformal layers to a gene subset; LEMUR itself is still fit
genome-wide.what — which conformal procedure(s) to run:
"conf_selection", "conf_clustering", or
both.By default the target coverage is cp_alpha, and the data
are split 50% training / 50% test, with the calibration set taking 25%
of the training set (cp_train_frac and
cp_cal_frac set these proportions).
For each gene, conformal selection returns the set of cells flagged
as belonging to the neighborhood affected by condition, at
the target coverage level. The table below reports both the raw
neighborhood size and its share of all cells in the test set.
fit <- res$fit_lemur
cp_out <- res$conf_results |>
dplyr::mutate(
inside_cs = purrr::map(inside_cs, ~ colnames(fit) %in% .x),
inside_cc = purrr::map(inside_cc, ~ colnames(fit) %in% .x),
outside_cc = purrr::map(outside_cc, ~ colnames(fit) %in% .x)
) |>
tidyr::unnest(c(inside_cs, inside_cc, outside_cc)) |>
dplyr::mutate(cell = rep(colnames(fit), nrow(fit)))
selection_summary <- cp_out |>
group_by(gene) |>
summarize(
`Neighborhood size` = sum(inside_cs),
`% of cells` = round(sum(inside_cs) / ncol(res$fit_lemur), 2) * 100,
.groups = "drop"
)
kable(selection_summary,
caption =
"Conformal selection: neighborhood size per gene"
)| gene | Neighborhood size | % of cells |
|---|---|---|
| HBEGF | 0 | 0 |
| HIST3H2A | 474 | 32 |
| PTPRZ1 | 2 | 0 |
Conformal clustering instead reports, per cell, a prediction set: the neighborhoods that cell could plausibly belong to at the target coverage. A prediction set of size zero or two means the procedure could not confidently assign that cell to any neighborhood. It is ambiguous.
clustering_summary <- cp_out |>
group_by(gene) |>
summarize(
`Prediction set = inside` =
sum(inside_cc + outside_cc == 1 & inside_cc == 1),
`Prediction set = outside` =
sum(inside_cc + outside_cc == 1 & outside_cc == 1),
`Prediction set size 2 or empty` = sum(inside_cc + outside_cc != 1),
.groups = "drop"
)
kable(clustering_summary,
caption =
"Conformal clustering: prediction set composition per gene"
)| gene | Prediction set = inside | Prediction set = outside | Prediction set size 2 or empty |
|---|---|---|---|
| HBEGF | 197 | 1260 | 44 |
| HIST3H2A | 975 | 488 | 38 |
| PTPRZ1 | 450 | 999 | 52 |
Conformal selection overlaid on LEMUR
Cells shaded as part of the selected neighborhood are those the procedure can confidently attribute to the condition effect for that gene; unshaded cells fall outside the guaranteed selection at the chosen coverage level.
Conformal prediction sets overlaid on LEMUR
Here color reflects the prediction set itself rather than a single hard assignment, making it easy to spot the boundary regions where neighborhood membership is genuinely uncertain versus the core regions where every resampling agrees.
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] knitr_1.52 tibble_3.3.1
#> [3] ggplot2_4.0.3 dplyr_1.2.1
#> [5] SingleCellExperiment_1.35.2 SummarizedExperiment_1.43.0
#> [7] Biobase_2.73.2 GenomicRanges_1.65.4
#> [9] Seqinfo_1.3.2 IRanges_2.47.5
#> [11] S4Vectors_0.51.10 BiocGenerics_0.59.12
#> [13] generics_0.1.4 MatrixGenerics_1.25.0
#> [15] matrixStats_1.5.0 conformeR_0.99.1
#> [17] BiocStyle_2.41.0
#>
#> loaded via a namespace (and not attached):
#> [1] tidyselect_1.2.1 farver_2.1.2 S7_0.2.2
#> [4] fastmap_1.2.0 digest_0.6.39 lifecycle_1.0.5
#> [7] statmod_1.5.2 lemur_1.11.2 magrittr_2.0.5
#> [10] compiler_4.6.1 rlang_1.3.0 sass_0.4.10
#> [13] tools_4.6.1 yaml_2.3.12 FNN_1.1.4.1
#> [16] labeling_0.4.3 S4Arrays_1.13.2 DelayedArray_0.39.8
#> [19] RColorBrewer_1.1-3 abind_1.4-8 BiocParallel_1.47.0
#> [22] withr_3.0.3 purrr_1.2.2 sys_3.4.3
#> [25] grid_4.6.1 beachmat_2.29.3 future_1.80.0
#> [28] edgeR_4.99.10 MASS_7.3-66 globals_0.20.0
#> [31] scales_1.4.0 isoband_0.3.0 cli_3.6.6
#> [34] rmarkdown_2.32 otel_0.2.0 RSpectra_0.16-2
#> [37] glmGamPoi_1.25.1 cachem_1.1.0 splines_4.6.1
#> [40] parallel_4.6.1 BiocManager_1.30.27 XVector_0.53.0
#> [43] vctrs_0.7.3 Matrix_1.7-6 jsonlite_2.0.0
#> [46] listenv_1.1.0 maketools_1.3.2 locfit_1.5-9.12
#> [49] limma_3.99.2 ggnewscale_0.5.2 jquerylib_0.1.4
#> [52] tidyr_1.3.2 glue_1.8.1 parallelly_1.48.0
#> [55] codetools_0.2-20 cowplot_1.2.0 uwot_0.2.5
#> [58] rsample_1.3.2 gtable_0.3.6 furrr_0.4.0
#> [61] pillar_1.11.1 htmltools_0.5.9 R6_2.6.1
#> [64] sparseMatrixStats_1.25.0 evaluate_1.0.5 lattice_0.23-1
#> [67] bslib_0.12.0 Rcpp_1.1.2 SparseArray_1.13.4
#> [70] xfun_0.62 buildtools_1.0.0 pkgconfig_2.0.3