single-cell/doublet-detection/SKILL.md
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.
npx skillsauth add GPTomics/bioSkills bio-single-cell-doublet-detectionInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Reference examples tested with: scanpy 1.10+, scDblFinder 1.16+, Seurat 5.0+
Before using code patterns, verify installed versions match. If versions differ:
pip show <package> then help(module.function) to check signaturespackageVersion('<pkg>') then ?function_name to verify parametersIf code throws ImportError, AttributeError, or TypeError, introspect the installed package and adapt the example to match the actual API rather than retrying.
"Remove doublets from my data" -> Flag droplets that captured two or more cells, which masquerade as fake intermediate cell states.
sc.pp.scrublet() per sample on raw countsscDblFinder(sce, samples=...) per sample on raw countsDoublets 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.
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.
| 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.
| 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.
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) # or build directly from a counts matrix
sce <- scDblFinder(sce, samples = 'sample_id') # per-capture; dbr defaults from cell count via dbr.per1k=0.008
table(sce$scDblFinder.class) # adds scDblFinder.class ('singlet'/'doublet') and .score
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.
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 # from recovered cells, not the 0.05 placeholder
sc.pp.scrublet(adata, expected_doublet_rate=expected_rate) # adds obs['doublet_score'], obs['predicted_doublet']
# auto-threshold needs a bimodal histogram; if unimodal, inspect uns['scrublet'] and set threshold manually
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.
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) # *_v3 function names were removed in Nov 2023; verify installed API
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)])) # no default pK; tune per dataset
rate <- 0.008 * ncol(seurat_obj) / 1000
nExp <- round(rate * ncol(seurat_obj))
nExp <- round(nExp * (1 - modelHomotypic(seurat_obj$seurat_clusters))) # discount undetectable homotypic doublets
seurat_obj <- doubletFinder(seurat_obj, PCs = 1:20, pN = 0.25, pK = pK, nExp = nExp, sct = FALSE)
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.
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).
| 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 |
tools
End-to-end CLIP-seq pipeline from FASTQ to ENCODE-compliant binding sites, single-nucleotide crosslink maps, annotation, motifs, and (optionally) differential binding. Use when running the full Yeo lab eCLIP / iCLIP / iCLIP2 / iCLIP3 / irCLIP / PAR-CLIP analysis with SMInput control, protocol-specific UMI extraction, ENCODE STAR parameters, CLIPper or Skipper peak calling with stringent log2 FC and -log10 p thresholds, IDR rescue and self-consistency QC, and downstream motif registration with mCross or PEKA.
development
Detect, date, and contextualize whole-genome duplication (WGD / paleopolyploidy) events using wgd v2 (Chen et al 2024), KsRates (Sensalari 2022 substitution-rate-corrected Ks dating), DupGen_finder (Qiao 2019), MAPS (Li 2018 phylogenomic), POInT (Conant 2008 ordered-block), SLEDGe (2024 ML-based), Whale.jl (Bayesian DL+WGD), and synteny-anchored paranome construction. Use when identifying ancient polyploidy from Ks distributions and synteny block analysis, positioning WGD events relative to speciation, distinguishing tandem from segmental from WGD duplications, dating the 2R/3R vertebrate / fish / salmonid WGDs, building paranome and Ks-age mixture models, applying KsRates substitution-rate correction across lineages, or testing alternative biased-fractionation / dosage-balance models post-WGD.
tools
Build whole-genome alignments using Progressive Cactus (Armstrong 2020 reference-free clade-level WGA), Minigraph-Cactus (Hickey 2024 pangenome-aware), LASTZ chain/net (UCSC pipeline), MUMmer4 (Marçais 2018 pairwise), minimap2 -x asm5/10/20 (Li 2018 fast pairwise), AnchorWave (Song 2022 WGD-aware), and Mauve / progressiveMauve (bacterial). Operates the HAL toolkit (Hickey 2013) for downstream extraction including halSynteny, halLiftover, halBranchMutations, and hal2maf. Use when constructing multi-species alignments for comparative-annotation projection (TOGA), synteny detection, conservation analyses (phyloP / PhastCons), or pangenome graph construction; selecting between reference-free (Cactus) and reference-anchored (LASTZ chains/nets) approaches; tuning sensitivity for closely vs distantly related genomes; or producing HAL files for genome-wide downstream tools.
development
Detect syntenic blocks and structural rearrangements between genomes using MCScanX (Wang 2012), JCVI/MCScan (Tang 2008 Python), GENESPACE (Lovell 2022) for orthology-anchored riparian visualization, SyRI for structural variation, AnchorWave for sequence-level synteny, i-ADHoRe 3.0 for highly diverged species, SynNet for synteny networks, and ntSynt for multi-genome macrosynteny. Use when identifying collinear gene blocks across species, distinguishing macrosynteny from microsynteny, detecting inversions/translocations/duplications, anchoring orthology in WGD lineages, producing publication riparian plots, computing synteny block age via Ks (cross-references whole-genome-duplication), or running synteny-aware ortholog inference in polyploids.