| name | bio-single-cell-doublet-detection |
| description | Detect and remove doublets (two or more cells in one droplet) from single-cell RNA-seq using scDblFinder (R), Scrublet (Python), and DoubletFinder (R). Use when flagging artificial intermediate populations before clustering, setting the expected doublet rate from recovered-cell counts, running detection per sample before integration, choosing between simulate-and-score methods, or interpreting a non-bimodal score histogram. |
| tool_type | mixed |
| primary_tool | scDblFinder |
Version Compatibility
Reference examples tested with: scanpy 1.10+, scDblFinder 1.16+, Seurat 5.0+
Before using code patterns, verify installed versions match. If versions differ:
- Python:
pip show <package> then help(module.function) to check signatures
- R:
packageVersion('<pkg>') then ?function_name to verify parameters
If code throws ImportError, AttributeError, or TypeError, introspect the installed
package and adapt the example to match the actual API rather than retrying.
Doublet Detection
"Remove doublets from my data" -> Flag droplets that captured two or more cells, which masquerade as fake intermediate cell states.
- Python:
sc.pp.scrublet() per sample on raw counts
- R:
scDblFinder(sce, samples=...) per sample on raw counts
Governing Principle
Doublets fabricate fake biology, so the goal is not a "doublet-free" dataset but avoiding false conclusions. Three facts govern every decision.
Doublets create fake intermediate populations. A heterotypic doublet (two distinct types, e.g. T cell + monocyte) sums to a profile that lands between clusters and reads as a novel "transitional" state - the most damaging failure mode, because it corrupts trajectory inference and RNA velocity by building false bridges between lineages. Treat any small cluster co-expressing two lineage programs (CD3+LYZ, EPCAM+PTPRC) as doublet-suspect until proven otherwise.
Detect per sample, before integration or clustering. A doublet is a physical event within one droplet in one capture, so two cells from different samples can never share one - any cross-sample doublet called on a merged object is meaningless. Merging also corrupts the kNN/PCA neighborhood that scoring depends on. scDblFinder's samples= handles this internally; Scrublet and DoubletFinder must be looped per sample. All three want raw counts after basic QC.
Removal is never complete, and over-removal deletes real cells. Homotypic doublets (two cells of the same type) sum to a profile that looks like one bigger cell of that type and are nearly invisible to any expression-based method, so reported "doublet rates" only cover the heterotypic-detectable fraction. Conversely, doublet scores correlate with total counts, the same axis as count-based QC, so aggressive filtering on both double-penalizes and strips genuine high-RNA populations (megakaryocytes, plasma cells, large neurons). Coordinate the two filters and prefer flag-and-inspect over blind deletion.
Expected Doublet Rate
10X Chromium loading is near-Poisson, so the multiplet rate scales roughly linearly with recovered cells: ~0.8% per 1,000 cells recovered (dbr.per1k = 0.008).
| Cells recovered (~) | Expected rate (~) |
|---|
| 1,000 | 0.8% |
| 2,000 | 1.6% |
| 5,000 | 3.9% |
| 10,000 | 7.6-8% |
Rule: rate ~= 0.008 x recovered/1000. Always set the expected rate from the actual recovered-cell count of that lane; Scrublet's flat expected_doublet_rate=0.05 is a placeholder, not a recommendation. High-throughput chips have lower per-cell rates. For multiplexed pools (genotype/HTO-demultiplexed), the physical doublet rate is set by TOTAL lane loading, not the demultiplexed subset: deriving the rate from one sample's cells underestimates it (four 5k samples in one 20k lane is ~15% real, not the ~3.9% implied by 5k), so set the rate from the total lane cell count.
Heterotypic, Homotypic, Neotypic
| Type | Composition | Detectability |
|---|
| Heterotypic | Two transcriptionally distinct types | Detectable; lands between clusters; the dangerous "fake transitional" ones |
| Homotypic | Two cells of the same type | Nearly undetectable by expression; persists after removal |
| Neotypic | Heterotypic blend occupying a region no singlet occupies | Most detectable; most misleading if missed (looks like a rare new type) |
modelHomotypic-style adjustments only change the number expected to be detectable; they cannot recover undetectable homotypic doublets.
Choosing a Method
| Method | Model | Use when | Fails / weak when |
|---|
| scDblFinder (R) | xgboost on kNN features vs simulated doublets | Default; best accuracy-speed balance; built-in per-sample via samples= | R/Bioconductor only |
| Scrublet (Python) | kNN density of simulated doublets | scanpy-native pipelines | Auto-threshold fails on unimodal histograms; loop per sample manually |
| DoubletFinder (R) | pANN from PC neighborhood | Legacy Seurat workflows | Brittle; pK needs per-dataset sweep; *_v3 names removed; Seurat-version-coupled |
| solo (Python) | scVI VAE + classifier | Have a trained scVI model; GPU available | Heavier setup |
| scds cxds/bcds/hybrid (R) | Co-expression / boosted tree | Fast first pass on very large data | Lower accuracy than scDblFinder/DoubletFinder |
scDblFinder is the 2024-2026 best-balance default and is recommended by sc-best-practices. The older Xi and Li 2021 ranking ("DoubletFinder is most accurate") predates major scDblFinder improvements and is superseded - do not cite it against current scDblFinder. Methods compete and drift; verify current standing against the installed tool's docs before committing.
scDblFinder (R, recommended)
Goal: Call doublets with a fast gradient-boosted classifier, per sample, with the rate inferred from cell count.
Approach: Convert to SingleCellExperiment, pass the per-sample key so each capture is processed independently, then read the class/score back.
library(scDblFinder)
library(SingleCellExperiment)
sce <- as.SingleCellExperiment(seurat_obj)
sce <- scDblFinder(sce, samples = 'sample_id')
table(sce$scDblFinder.class)
seurat_obj$scDblFinder_class <- sce$scDblFinder.class
seurat_obj$scDblFinder_score <- sce$scDblFinder.score
clusters=NULL (default) generates purely random artificial doublets and is generally recommended; pass a vector for cluster-based generation. dbr=NULL computes the rate from cell count; set dbr/dbr.sd explicitly to encode a known loading.
Scrublet (Python)
Goal: Score doublets in a scanpy pipeline, per sample, with the rate set from recovered cells.
Approach: Use the maintained sc.pp.scrublet path on raw counts; set expected_doublet_rate per lane; inspect the histogram when the auto-threshold looks wrong.
import scanpy as sc
n_cells = adata.n_obs
expected_rate = 0.008 * n_cells / 1000
sc.pp.scrublet(adata, expected_doublet_rate=expected_rate)
adata_singlets = adata[~adata.obs['predicted_doublet']].copy()
For pooled samples, loop sc.pp.scrublet(adata[adata.obs.sample == s], ...) per sample (or pass batch_key), never on the merged object.
DoubletFinder (R, legacy Seurat)
Goal: Run DoubletFinder on a fully preprocessed Seurat object, tuning pK and homotypic-adjusting the expected count.
Approach: Sweep pK, pick the BCmvn maximum, then set nExp from the rate adjusted for the homotypic fraction.
library(DoubletFinder)
sweep.res <- paramSweep(seurat_obj, PCs = 1:20, sct = FALSE)
bcmvn <- find.pK(summarizeSweep(sweep.res, GT = FALSE))
pK <- as.numeric(as.character(bcmvn$pK[which.max(bcmvn$BCmetric)]))
rate <- 0.008 * ncol(seurat_obj) / 1000
nExp <- round(rate * ncol(seurat_obj))
nExp <- round(nExp * (1 modelHomotypicseurat_objseurat_clusters
seurat_obj doubletFinderseurat_obj PCs pN pK pK nExp nExp sct
pN (artificial-doublet proportion) defaults to 0.25 and performance is largely insensitive to it. DoubletFinder requires a normalized, PCA'd, clustered object and is the most version-sensitive of the three.
Deeper Cautions
Simulated doublets are a model, not the real thing: real doublets share one RT/PCR reaction (capture competition, barcode effects), so simulated-doublet density only approximates where real doublets sit, and even the best method has a low ceiling (max mean AUPRC ~0.537 in Xi and Li 2021 - every method misses a lot). Over-removal culls proliferating (S/G2M) and genuine transitional cells that legitimately score high, so cross-check removed cells against cell-cycle and activation signatures. When available, experimental ground truth beats inference: cell hashing (CITE-seq HTOs) and MULTI-seq call inter-sample doublets directly regardless of expression similarity (catching even cross-sample homotypic doublets), and serve as a complementary filter. Heavy ambient RNA can mimic co-expression and nudge scores, so handle empty droplets and ambient RNA first (see single-cell/preprocessing).
Common Errors
| Symptom | Cause | Fix |
|---|
| Doublet calls look random / too many | Run on merged multi-sample data | Run per sample before integration (samples= or loop) |
| Auto-threshold splits the histogram badly | Scrublet histogram is unimodal | Inspect the histogram and set threshold manually |
| A high-RNA cell type was wiped out | Count-based QC and doublet removal double-penalized the same axis | Coordinate the filters; do not stack aggressive cutoffs |
| "Novel transitional state" co-expresses two lineages | Heterotypic doublets masquerading as a cluster | Confirm per-sample detection; check marker co-expression / hashing before claiming a new type |
| Trajectory has an implausible bridge between lineages | Doublets forming a false intermediate | Remove/flag doublets before trajectory inference |
| Reported "0% doublets" | Homotypic doublets are invisible | Do not claim doublet-free; report only the detectable fraction |
| DoubletFinder call errors after a Seurat upgrade | *_v3 names removed; API drift | Use current function names; re-tune pK |
| Expected rate clearly wrong | Used a package default | Set rate from recovered cells (~0.008 x cells/1000) |
| Multiplexed pool underestimates doublets | Rate derived from one demultiplexed sample, not total lane | Set the expected rate from total capture-lane cells |
Related Skills
- single-cell/preprocessing - QC and ambient-RNA handling before doublet detection
- single-cell/hashing-demultiplexing - Hashtag-based cross-sample doublet calling (complements expression-based detection)
- single-cell/data-io - load raw per-sample matrices before processing
- single-cell/clustering - run clustering after doublet removal
- single-cell/batch-integration - integrate samples only after per-sample doublet calling
- single-cell/trajectory-inference - doublets create false bridges; remove them first
References
- Wolock SL, Lopez R, Klein AM (2019) Scrublet: computational identification of cell doublets in single-cell transcriptomic data. Cell Systems 8(4):281-291.e9. DOI 10.1016/j.cels.2018.11.005
- McGinnis CS, Murrow LM, Gartner ZJ (2019) DoubletFinder: doublet detection in single-cell RNA sequencing data using artificial nearest neighbors. Cell Systems 8(4):329-337.e4. DOI 10.1016/j.cels.2019.03.003
- Germain P-L, Lun A, Macnair W, Robinson MD (2021) Doublet identification in single-cell sequencing data using scDblFinder. F1000Research 10:979. DOI 10.12688/f1000research.73600
- Xi NM, Li JJ (2021) Benchmarking computational doublet-detection methods for single-cell RNA sequencing data. Cell Systems 12(2):176-194.e6. DOI 10.1016/j.cels.2020.11.008
- Bernstein NJ, Fong NL, Lam I, et al. (2020) Solo: doublet identification in single-cell RNA-seq via semi-supervised deep learning. Cell Systems 11(1):95-101.e5. DOI 10.1016/j.cels.2020.05.010
- Bais AS, Kostka D (2020) scds: computational annotation of doublets in single-cell RNA sequencing data. Bioinformatics 36(4):1150-1158. DOI 10.1093/bioinformatics/btz698
- McGinnis CS, Patterson DM, Winkler J, et al. (2019) MULTI-seq: sample multiplexing for single-cell RNA sequencing using lipid-tagged indices. Nature Methods 16(7):619-626. DOI 10.1038/s41592-019-0433-8
- Heumos L, Schaar AC, Lance C, et al. (2023) Best practices for single-cell analysis across modalities. Nature Reviews Genetics 24:550-572. DOI 10.1038/s41576-023-00586-w