Getting Started with conformeR

Introduction

Overview

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:

  • Conformal selection asks, for a given gene, which cells can we confidently say belong to the affected neighborhood? It returns a subset of neighborhood cells with a guaranteed bound on the proportion of false inclusions.
  • Conformal clustering asks, for each cell, how uncertain are we about its neighborhood assignment? It returns a prediction set of plausible neighborhoods per cell, which can contain zero, one, or several labels.

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.

Installation

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

Use-case of conformeR

Example dataset

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       PW030

We 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")
Genes of interest for this vignette
gid gene
ENSG00000106278 PTPRZ1
ENSG00000113070 HBEGF
ENSG00000181218 HIST3H2A

Running conformeR

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

set.seed(2002)
res <- conformeR(
    sce_reduced,
    design_lemur = ~ patient_id + condition,
    contrast_column = "condition",
    genes_of_interest = pan_affect_genes$gene,
    what = c("conf_selection", "conf_clustering"),
    n_cores = 1,
    verbose = TRUE
)

Conformal selection

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"
)
Conformal selection: neighborhood size per gene
gene Neighborhood size % of cells
HBEGF 0 0
HIST3H2A 474 32
PTPRZ1 2 0

Conformal clustering

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

Visualizing results on the LEMUR embedding

Conformal selection

plotter_conformal_selection(res, pan_affect_genes$gene)
Conformal selection overlaid on LEMUR

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 clustering

plotter_conformal_clustering(res, pan_affect_genes$gene)
Conformal prediction sets overlaid on LEMUR

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.

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