scDiagnosticsscDiagnostics 1.7.15
Automated cell type annotation - transferring labels from a reference dataset onto a new query dataset - is now a routine step in single-cell RNA-seq (scRNA-seq) analysis. It is fast and reproducible, but it is only as trustworthy as the alignment between reference and query: batch effects, cell states missing from the reference, or systematic differences in sequencing depth can all produce confidently-labeled cells that are, in fact, misannotated.
scDiagnostics provides diagnostics for exactly this problem. Rather
than another annotation method, it is a toolkit for auditing
annotations you already have: is the query well-aligned with the
reference in PCA space? Are there cells that look anomalous relative to
their assigned cell type? If so, which genes distinguish them from the
reference? Answering these questions helps decide whether an annotation
transfer can be trusted, and where to look if it cannot.
The package operates on SingleCellExperiment
objects and is not specific to scRNA-seq: the same diagnostics apply
directly to spatial data stored as a r BiocStyle::Biocpkg("SpatialExperiment") or r BiocStyle::Biocpkg("SpatialFeatureExperiment") object, without any
modification, since both extend SingleCellExperiment. Vignette
4
demonstrates this on MERFISH spatial data.
Across the case-study vignettes in this package (see below), the same three-step diagnostic pattern recurs:
plotCellTypePCA(),
projectPCA()).detectAnomaly()).calculateGeneShifts()).The four panels below illustrate this on the COVID-19 case study
described in vignette
3:
CD14 monocytes from a severe COVID-19 query project further along PC1
and PC2 than the healthy reference (panel A); a subset of those cells is
flagged as anomalous by detectAnomaly() (panel B); and
calculateGeneShifts() shows that the anomalous cells specifically
over-express a panel of interferon-response genes, both as a heatmap of
per-gene z-scores (panel C) and as fold-changes relative to the
reference (panel D).
A. Projection onto reference PCA space
B. Anomaly detection within CD14 monocytes
C. Interferon-response genes distinguishing anomalous cells
D. Fold-change of the same genes, anomalous vs. non-anomalous query cells
These specific results are from the COVID-19 case study and should not be read as a general property of every dataset - see vignette 2 for how detection accuracy was benchmarked against known ground truth more broadly.
Users interested in using the stable release version of the
scDiagnostics package: please follow the installation instructions
here.
This is the recommended way of installing the package.
To install the development version of the package from Github, use the following command:
BiocManager::install("ccb-hms/scDiagnostics")
To build the package vignettes upon installation use:
BiocManager::install("ccb-hms/scDiagnostics",
build_vignettes = TRUE,
dependencies = TRUE)
Once you have installed the package, you can load it with the following code:
library(scDiagnostics)
Before applying detectAnomaly() to real data (as in the case-study
vignettes), it helps to see it work on data where the “right answer” is
known. We simulate two batches of cells with the
splatter package, three cell types each, and
treat one batch as the reference and the other as the query.
library(splatter)
library(scuttle)
library(scater)
library(SingleR)
set.seed(100)
# Simulate two batches of 500 cells, 3 balanced cell types
sce_ref <- mockSCE()
params <- splatEstimate(sce_ref)
params <- setParams(
params,
batchCells = c(500, 500), batch.facLoc = 0, batch.facScale = 0,
group.prob = c(1 / 3, 1 / 3, 1 / 3),
de.prob = c(0.1, 0.2, 0.2),
de.facLoc = c(0.250, 0.375, 0.375),
de.facScale = c(0.2, 0.3, 0.4),
out.prob = 0, out.facLoc = 4, out.facScale = 0.5)
simulated_data <- splatSimulate(params, method = "groups", verbose = FALSE)
# Treat Batch1 as reference, Batch2 as query
reference_data <- simulated_data[, simulated_data$Batch == "Batch1"]
query_data <- simulated_data[, simulated_data$Batch == "Batch2"]
reference_data$Cell_Type <- factor(reference_data$Group)
levels(reference_data$Cell_Type) <- c("Cell Type A", "Cell Type B", "Cell Type C")
query_data$Cell_Type <- factor(query_data$Group)
levels(query_data$Cell_Type) <- c("Cell Type A", "Cell Type B", "Cell Type C")
reference_data <- logNormCounts(reference_data)
#> Warning in .library_size_factors(assay(x, assay.type), ...): 'librarySizeFactors' is deprecated.
#> Use 'scrapper::centerSizeFactors' instead.
#> See help("Deprecated")
#> Warning in .local(x, ...): 'normalizeCounts' is deprecated.
#> Use 'scrapper::normalizeCounts' instead.
#> See help("Deprecated")
query_data <- logNormCounts(query_data)
#> Warning in .library_size_factors(assay(x, assay.type), ...): 'librarySizeFactors' is deprecated.
#> Use 'scrapper::centerSizeFactors' instead.
#> See help("Deprecated")
#> Warning in .library_size_factors(assay(x, assay.type), ...): 'normalizeCounts' is deprecated.
#> Use 'scrapper::normalizeCounts' instead.
#> See help("Deprecated")
When the reference contains all three cell types, SingleR recovers
the ground-truth labels essentially perfectly:
reference_data <- runPCA(reference_data, ncomponents = 10)
query_data <- runPCA(query_data, ncomponents = 10)
pred <- SingleR(query_data, reference_data, labels = reference_data$Cell_Type)
query_data$SingleR_annotation <- pred$labels
mean(query_data$SingleR_annotation == query_data$Cell_Type)
#> [1] 1
Now suppose “Cell Type C” is missing from the reference entirely - a
common real-world scenario where a cell state present in the query
simply was not sampled in the reference. SingleR is forced to assign
those cells to the closest remaining type:
reference_missing <- reference_data[, reference_data$Cell_Type != "Cell Type C"]
reference_missing <- runPCA(reference_missing, ncomponents = 10)
pred_missing <- SingleR(query_data, reference_missing,
labels = reference_missing$Cell_Type)
query_data$SingleR_annotation_missing <- pred_missing$labels
# Where do the true Cell Type C cells get misannotated to?
table(query_data$SingleR_annotation_missing[query_data$Cell_Type == "Cell Type C"])
#>
#> Cell Type A Cell Type B
#> 173 5
Most of the true “Cell Type C” cells are misannotated as “Cell Type A”.
Because we know which cells are truly misannotated in this simulation,
we can check whether detectAnomaly() actually flags them as such,
relative to the correctly-annotated “Cell Type A” cells:
anomaly_output <- detectAnomaly(
reference_data = reference_missing,
query_data = query_data,
ref_cell_type_col = "Cell_Type",
query_cell_type_col = "SingleR_annotation_missing",
cell_types = "Cell Type A",
pc_subset = 1:2,
n_tree = 1000,
threshold_method = "absolute",
anomaly_threshold = 0.5)
is_anomalous <- anomaly_output[["Cell Type A"]]$query_anomaly
labels_a <- query_data$Cell_Type[query_data$SingleR_annotation_missing == "Cell Type A"]
# Fraction flagged as anomalous, split by true identity
tapply(is_anomalous, labels_a, mean)
#> Cell Type A Cell Type B Cell Type C
#> 0.08695652 NA 0.47398844
In this run, roughly half of the truly-misannotated “Cell Type C” cells
are flagged as anomalous, compared to a small fraction of the correctly
labeled “Cell Type A” cells - detectAnomaly() is picking up a real
signal here, though far from a perfect separation. We can visualize the
same result:
plot(anomaly_output, cell_type = "Cell Type A", data_type = "query", pc_subset = 1:2)
For a systematic evaluation of how detection accuracy holds up across label noise, class imbalance, and batch effects - rather than this one simulated example - see vignette 2.
scDiagnostics groups its functions into five broad categories.
Functions marked with a vignette link are walked through in more depth
there; the rest are documented in the reference manual (e.g.
?detectAnomaly). The full, finer-grained reference
index is
also available if you’d rather browse by a more specific task.
Visualizing cell types, marker genes, and QC/annotation scores across reference and query.
plotCellTypePCA(), plotCellTypeMDS() - PCA/MDS visualization of
cell types across reference and query. plotCellTypePCA() is used in
vignette 3
and vignette 4.boxplotPCA() - boxplots of PC scores by cell type.calculateDiscriminantSpace(), calculateSIRSpace() - projection
onto a discriminant (FDA) or Sliced Inverse Regression space fit on
the reference.plotMarkerExpression(), plotGeneExpressionDimred() - marker gene
expression as density plots or on a dimensionality reduction.plotQCvsAnnotation(), histQCvsAnnotation(), plotGeneSetScores() -
relate QC metrics and annotation confidence scores.Comparing reference and query datasets as a whole - are they well-aligned, and is any difference statistically significant?
comparePCA(), comparePCASubspace() - compare PCA results/subspaces
between reference and query.calculateWassersteinDistance() - Wasserstein distance between
reference and query, per cell type.plotPairwiseDistancesDensity() - density of pairwise distances or
correlations.calculateGraphIntegration() - graph-based integration diagnostics.calculateAveragePairwiseCorrelation(), calculateCramerPValue(),
calculateHotellingPValue(), calculateMMDPValue(), regressPC() -
formal statistical tests/summaries of reference-query alignment.Flagging specific cells that look anomalous, and quantifying how far they are from reference/query populations.
detectAnomaly() - Isolation Forest anomaly detection on PCA
projections. Used in vignette 1 above and in vignette
2,
vignette 3,
and vignette 4.calculateReconstructionError() - PCA reconstruction-error anomaly
detection. Used in vignette
2.calculateCellSimilarityPCA() - cosine similarity between cells and
PCA loadings.calculateCellDistances(), calculateCellDistancesSimilarity() -
distances (and Bhattacharyya/Hellinger similarity) between specific
cells and reference/query populations.Comparing which genes matter, and how they behave, between reference and query.
calculateGeneShifts() - expression shifts in top-loading genes
between reference and query, optionally focused on anomalous cells.
Used in vignette 3
and vignette 4.calculateHVGOverlap() - overlap of highly variable genes.calculateVarImpOverlap() - overlap of random-forest gene importance.compareMarkers() - compare marker gene expression between reference
and query.Lower-level building blocks used internally by the functions above, and available directly for custom workflows.
processPCA(), projectPCA(), projectSIR() - PCA/SIR computation
and projection.calculateCategorizationEntropy() - entropy of a cell-type-by-score
category matrix.R version 4.6.1 (2026-06-24)
Platform: x86_64-pc-linux-gnu
Running under: Ubuntu 24.04.5 LTS
Matrix products: default
BLAS: /home/biocbuild/bbs-3.24-bioc/R/lib/libRblas.so
LAPACK: /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3.12.0 LAPACK version 3.12.0
locale:
[1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
[3] LC_TIME=en_GB LC_COLLATE=C
[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: America/New_York
tzcode source: system (glibc)
attached base packages:
[1] stats4 stats graphics grDevices utils datasets methods
[8] base
other attached packages:
[1] SingleR_2.15.4 scater_1.41.2
[3] ggplot2_4.0.3 scuttle_1.23.2
[5] splatter_1.37.1 SingleCellExperiment_1.35.2
[7] SummarizedExperiment_1.43.0 Biobase_2.73.2
[9] GenomicRanges_1.65.4 Seqinfo_1.3.2
[11] IRanges_2.47.5 S4Vectors_0.51.10
[13] BiocGenerics_0.59.12 generics_0.1.4
[15] MatrixGenerics_1.25.0 matrixStats_1.5.0
[17] scDiagnostics_1.7.15 BiocStyle_2.41.0
loaded via a namespace (and not attached):
[1] gridExtra_2.3.1 rlang_1.3.0 magrittr_2.0.5
[4] clue_0.3-68 GetoptLong_1.1.1 otel_0.2.0
[7] ggridges_0.5.7 compiler_4.6.1 png_0.1-9
[10] systemfonts_1.3.2 vctrs_0.7.3 shape_1.4.6.1
[13] crayon_1.5.3 pkgconfig_2.0.3 fastmap_1.2.0
[16] backports_1.5.1 magick_2.9.1 XVector_0.53.0
[19] labeling_0.4.3 rmarkdown_2.32 ggbeeswarm_0.7.3
[22] ragg_1.5.2 tinytex_0.61 purrr_1.2.2
[25] xfun_0.61 bluster_1.23.1 cachem_1.1.0
[28] beachmat_2.29.3 jsonlite_2.0.0 DelayedArray_0.39.7
[31] BiocParallel_1.47.0 irlba_2.3.7 parallel_4.6.1
[34] cluster_2.1.8.3 R6_2.6.1 bslib_0.12.0
[37] RColorBrewer_1.1-3 limma_3.99.0 GGally_2.4.0
[40] jquerylib_0.1.4 iterators_1.0.14 Rcpp_1.1.2
[43] bookdown_0.48 knitr_1.52 splines_4.6.1
[46] Matrix_1.7-6 igraph_2.3.3 tidyselect_1.2.1
[49] dichromat_2.0-1 abind_1.4-8 yaml_2.3.12
[52] viridis_0.6.5 doParallel_1.0.17 codetools_0.2-20
[55] lattice_0.23-1 tibble_3.3.1 withr_3.0.3
[58] S7_0.2.2 evaluate_1.0.5 survival_3.8-12
[61] ggstats_0.14.0 fitdistrplus_1.2-6 circlize_0.4.18
[64] pillar_1.11.1 BiocManager_1.30.27 checkmate_2.3.4
[67] foreach_1.5.2 scales_1.4.0 RhpcBLASctl_0.23-42
[70] glue_1.8.1 metapod_1.21.0 tools_4.6.1
[73] BiocNeighbors_2.7.3 ScaledMatrix_1.21.0 locfit_1.5-9.12
[76] scran_1.41.1 Cairo_1.7-0 grid_4.6.1
[79] tidyr_1.3.2 colorspace_2.1-3 edgeR_4.99.6
[82] beeswarm_0.4.0 BiocSingular_1.29.1 vipor_0.4.7
[85] cli_3.6.6 rsvd_1.0.5 textshaping_1.0.5
[88] S4Arrays_1.13.1 viridisLite_0.4.3 ComplexHeatmap_2.29.0
[91] dplyr_1.2.1 gtable_0.3.6 isotree_0.6.1-5
[94] sass_0.4.10 digest_0.6.39 SparseArray_1.13.3
[97] ggrepel_0.9.8 dqrng_0.4.1 rjson_0.2.23
[100] farver_2.1.2 htmltools_0.5.9 lifecycle_1.0.5
[103] GlobalOptions_0.1.4 statmod_1.5.2 MASS_7.3-66