Python implementation of the scDblFinder workflow for doublet detection in single-cell RNA-seq data, designed to run on AnnData/Scanpy objects.
This module mirrors the core ideas of the R package:
- optional pre-clustering
- artificial doublet generation
- iterative classifier training
- threshold optimization based on expected doublet rate
Given a count matrix in an AnnData object, scDblFinderPy estimates a
doublet score for each real cell and returns a final class (doublet or
singlet).
At a high level, the pipeline is:
- Optional clustering of real cells (clustered mode).
- Feature selection and artificial doublet generation.
- Combined real + artificial embedding (PCA) and KNN feature extraction.
- Iterative XGBoost training and score refinement.
- Final thresholding to obtain doublet calls.
Clustering, PCA, and nearest-neighbor search are delegated to scanpy
(CPU) / rapids-singlecell (GPU, when use_gpu=True) rather than
reimplemented — only the doublet-detection-specific logic (artificial
doublet generation, CXDS scoring, KNN-derived doublet features, iterative
XGBoost training, threshold optimization) is custom to this package.
scDblFinderPy/ ← repo root (this directory is the Python package)
├── pyproject.toml package metadata + pinned dependency versions
├── scDblFinder.py main pipeline — contains compute_doublet_score()
├── clustering.py fast_cluster(): scanpy/rapids-singlecell clustering
├── doublet_generation.py
├── misc.py cxds2, select_features, GPU-backend helpers
├── thresholding.py
├── rng.py coerce_rng(): numpy Generator seeding helper
git clone <repo-url>
cd scDblFinderPypython -m venv .venv
source .venv/bin/activate
pip install --upgrade pippip install .
# or, for local development (edits take effect without reinstalling):
pip install -e .This installs the exact dependency versions pinned in pyproject.toml
(numpy, pandas, scipy, anndata, scanpy, scikit-learn, xgboost,
statsmodels, leidenalg, igraph) and registers scDblFinderPy as a
normal importable package — no sys.path hacks needed. See
Reproducibility below for why the pins matter.
Optional — GPU acceleration (requires a CUDA-capable machine). When
use_gpu=True, clustering/PCA/neighbor search run via rapids-singlecell
and XGBoost trains on the GPU (device='cuda'):
pip install .[gpu]rapids-singlecell and cuml are not reliably pip-installable in general
(the available wheels depend on your exact CUDA toolkit version) — NVIDIA's
recommended path is conda/mamba via the
RAPIDS release selector. This package was
developed and tested against rapids-singlecell==0.15.0, cuml==26.4.0,
cupy==14.0.1 installed that way.
Once installed (see Setup), import it directly:
from scDblFinderPy.scDblFinder import compute_doublet_scoreIf you'd rather not install it — e.g. quick experimentation without
pip install — you can still fall back to adding the repo's parent
directory to sys.path (this skips the pinned-version guarantee below,
since it relies on whatever is already on sys.path):
import sys
sys.path.insert(0, "/path/to") # parent of the scDblFinderPy/ directory
from scDblFinderPy.scDblFinder import compute_doublet_scorecompute_doublet_score(...) expects an AnnData object where:
adata.Xcontains raw counts (preferred), oradata.layers['counts']contains raw counts.
import scanpy as sc
import sys
sys.path.insert(0, "/path/to")
from scDblFinderPy.scDblFinder import compute_doublet_score
adata = sc.read_h5ad("your_data.h5ad")
adata_out = compute_doublet_score(
adata,
clusters_col=None, # random mode — no clustering step
n_iters=3,
random_state=42,
verbose=True,
)
print(adata_out.obs[["scDblFinder_score", "scDblFinder_class"]].head())
print("Threshold:", adata_out.uns.get("scDblFinder_threshold"))adata_out = compute_doublet_score(
adata,
clusters_col="clusters", # column is computed and stored here if absent
n_iters=3,
random_state=42,
verbose=True,
)adata.obs["my_clusters"] = ... # your own cluster labels
adata_out = compute_doublet_score(adata, clusters_col="my_clusters")In adata.obs:
scDblFinder_score— continuous doublet score (higher = more likely doublet)scDblFinder_class— final call:doubletorsinglet
In adata.uns:
scDblFinder_threshold— the score threshold used for the final classification
If return_type='full', the returned object also includes artificial doublets.
| Parameter | Default | Description |
|---|---|---|
clusters_col |
None |
None for random mode; column name for clustered mode |
n_features |
1352 |
number of genes used for feature selection (matches R's nfeatures) |
n_components |
20 |
number of PCA components |
n_artificial |
None |
override number of artificial doublets (auto if None) |
prop_random |
0 |
fraction of artificial doublets generated randomly (matches R's propRandom) |
n_iters |
3 |
iterative classifier refinement rounds |
dbr_per1k |
0.008 |
expected doublet rate per 1k cells |
stringency |
0.5 |
threshold optimisation aggressiveness |
random_state |
42 |
reproducibility seed |
use_gpu |
False |
enable GPU-accelerated steps (requires rapids/cuml) |
verbose |
True |
print progress at each stage |
The benchmark scripts live in benchmarking/ and must be run from inside
that directory so that relative dataset paths resolve correctly.
Run all datasets:
cd benchmarking
python run_python_benchmark.pyResults are saved to benchmarking/python_benchmark_metrics.csv.
Run a single dataset (e.g. hm-6k):
cd benchmarking
python run_dataset.py hm-6k
# optionally pass a repeat count: python run_dataset.py hm-6k 3Results are saved to benchmarking/python_benchmark_hm-6k.csv.
Datasets must be present as benchmarking/datasets/<name>.h5ad and must
contain a truth column in adata.obs with values doublet / singlet.
Given the same input data, random_state, and library versions, two runs on
the same machine produce bit-identical scDblFinder_score output (verified
for both use_gpu=False and use_gpu=True, in random and clustered mode).
That guarantee has some real limits worth knowing before you rely on it:
- Pin your dependency versions.
scanpy,leidenalg,xgboost, andnumpyall change numerical internals (PCA solver defaults, clustering backend, histogram construction) across releases, independent of RNG seeding. Installing viapip install .(see Setup) pins the exact versions this package was developed against — an unpinnedpip install scanpy ...will drift over time. - Different machines are not guaranteed to match, even with identical package versions. numpy/scipy delegate to BLAS (OpenBLAS/MKL), which picks different code paths per CPU (AVX2/AVX512/thread count), producing ULP-level floating-point differences that can cascade through PCA into clustering and scores. This is a general property of the numpy/scipy/scanpy stack, not specific to this package.
- GPU mode (
use_gpu=True) is best-effort, not guaranteed, across different GPU models, driver versions, or CUDA toolkit versions — RAPIDS/ cuML has known non-deterministic reduction operations in places. Treat GPU reproducibility as "consistent within one fixed environment," not "identical everywhere." - Use the same preprocessing assumptions (counts in
adata.Xoradata.layers['counts']) when comparing runs.
- Multi-sample mode (R's
samples/multiSampleModearguments) is not implemented; there is nosamples_col-equivalent parameter. All cells are processed together regardless of sample of origin. - Some low-level numerical differences from the R package are expected due to library backend differences.
Results look unstable or weak:
- Confirm counts are raw (not log-normalised or otherwise transformed).
- Try both modes (
clusters_col=Noneand a clustered mode). - Check
xgboostversion is compatible with your Python version. - Run with
verbose=Trueto inspect each stage.
Clustering looks poor:
- Pass your own precomputed cluster labels via
clusters_colinstead of relying on the built-in fast clustering.