--- title: "scRNA-seq Quality Control: Gating Before Downstream Analysis" task: "" lineage_type: import upstream_source: https://github.com/mims-harvard/ToolUniverse/blob/e2520a96/skills/tooluniverse-single-cell/references/scrna_qc.md upstream_sha: e2520a96 imported_at: 2026-06-26 prompt_class: prompt upstream_changes: accepted author: upstream validated: false --- # scRNA-seq Quality Control: Gating Before Downstream Analysis The QC-gating step decides **which cells (and genes) are real** before any normalization, clustering, or DE. Bad gating propagates silently: doublets become fake "intermediate" cell states, ambient RNA makes every cluster express every marker, and empty droplets inflate cell counts. This doc covers the per-cell QC metrics, **why** each one flags a problem, how to choose thresholds from the data (not magic numbers), and the technical artifacts (doublets, ambient RNA, empty droplets) that per-cell metrics alone do not catch. > **Honesty note**: every command here runs scanpy/AnnData via Bash/Python. > If scanpy is not installed, do NOT fabricate numbers. Emit the install plan > (`pip install scanpy scrublet`) and run `scripts/scrna_qc.py --install-plan` > to confirm the environment, then stop and report what is missing. --- ## 1. The standard per-cell QC metrics After `sc.pp.calculate_qc_metrics(adata, qc_vars=['mt','ribo','hb'], ...)`, each cell (row of `adata.obs`) carries: | Metric | `.obs` column | What it measures | |--------|---------------|------------------| | Genes detected | `n_genes_by_counts` | # genes with >=1 count in the cell | | Total UMIs | `total_counts` | library size (depth) of the cell | | Mito fraction | `pct_counts_mt` | % of UMIs from mitochondrial genes | | Ribo fraction | `pct_counts_ribo` | % of UMIs from ribosomal-protein genes | | Hemoglobin frac | `pct_counts_hb` | % from hemoglobin genes (RBC contamination) | Gene flags are set on `adata.var` BEFORE calling `calculate_qc_metrics`: ```python adata.var['mt'] = adata.var_names.str.upper().str.startswith('MT-') adata.var['ribo'] = adata.var_names.str.upper().str.startswith(('RPS', 'RPL')) adata.var['hb'] = adata.var_names.str.upper().str.contains(r'^HB[^P]') # HBA, HBB... sc.pp.calculate_qc_metrics( adata, qc_vars=['mt', 'ribo', 'hb'], percent_top=None, # REQUIRED for small gene panels (<500), else IndexError log1p=False, inplace=True, ) ``` --- ## 2. WHY each metric flags a problem (the biology) - **High `pct_counts_mt` -> dying / stressed cell.** When a cell's membrane ruptures during dissociation, cytoplasmic mRNA leaks out but mitochondrial transcripts stay trapped inside mitochondria. The surviving captured RNA is therefore enriched for mito transcripts. A high mito fraction is a hallmark of a broken/apoptotic cell. Typical cutoff 5-20% but **tissue-dependent**: cardiomyocytes, hepatocytes, and brown fat are mito-rich at baseline, so a 10% blanket cutoff would discard healthy cells. - **Low `n_genes_by_counts` / low `total_counts` -> empty droplet or debris.** An empty droplet captures only ambient RNA, so it has few distinct genes and low depth. Cells below ~200 genes are usually not real cells. - **Very high `n_genes_by_counts` / `total_counts` -> doublet.** Two cells in one droplet contribute two transcriptomes, roughly doubling both depth and gene diversity. Extreme upper-tail cells are doublet-suspicious — but a high count alone is weak evidence (a large, transcriptionally active cell also has high counts). Use a dedicated doublet caller (Section 4) rather than a hard upper gene cap. - **High `pct_counts_hb` -> red-blood-cell / blood contamination** in a solid tissue dissociation. Often filtered in non-blood tissues. - **`pct_counts_ribo`** is informative but rarely a hard filter: very high ribo fraction can indicate low-complexity / stressed cells, and ribo content is strongly cell-type-specific (proliferating cells are ribo-high), so flag and investigate rather than blindly filter. --- ## 3. Choosing thresholds — distribution-aware, not magic numbers Hardcoded cutoffs (`mt < 5%`, `n_genes < 2500`) are a starting point, not an answer. They fail on mito-rich tissues and on shallow vs deep libraries. Prefer a **data-driven, MAD-based** outlier rule that adapts to the dataset. ### MAD-based outlier detection (recommended) Flag a cell as an outlier on a metric when it lies more than `nmads` median absolute deviations from the median. MAD is robust to the very outliers we are trying to find (unlike mean/SD). ```python import numpy as np def is_outlier(adata, metric, nmads=5, upper_only=False): M = adata.obs[metric].astype(float) med = np.median(M) mad = np.median(np.abs(M - med)) if mad == 0: return np.zeros(len(M), dtype=bool) lower = med - nmads * mad upper = med + nmads * mad if upper_only: return M > upper return (M < lower) | (M > upper) # Apply on log1p-scaled count metrics (counts are right-skewed) import scanpy as sc sc.pp.calculate_qc_metrics(adata, qc_vars=['mt'], percent_top=None, log1p=True, inplace=True) adata.obs['outlier'] = ( is_outlier(adata, 'log1p_total_counts', 5) | is_outlier(adata, 'log1p_n_genes_by_counts', 5) ) # Mito is asymmetric: only the HIGH side is bad. Use a tighter nmads (3) AND a # biological ceiling so a mito-rich tissue doesn't pass everything. adata.obs['mt_outlier'] = ( is_outlier(adata, 'pct_counts_mt', 3, upper_only=True) | (adata.obs['pct_counts_mt'] > 20) ) keep = ~(adata.obs['outlier'] | adata.obs['mt_outlier']) print(f"Keeping {keep.sum()}/{adata.n_obs} cells") adata = adata[keep].copy() ``` Guidance: - `nmads=5` for `total_counts` / `n_genes_by_counts` (catches extreme tails on both ends — empty droplets and gross doublets). - `nmads=3`, upper-only for `pct_counts_mt` (dying cells are one-sided). - Always pair the MAD rule with a **biological sanity ceiling** on mito (e.g. 20% general, higher for mito-rich tissue) so a uniformly degraded sample doesn't "pass" just because everything is equally bad. - **Visualize before committing**: violin/scatter of the three metrics, and a `total_counts` vs `pct_counts_mt` scatter (the classic L-shape — dying cells sit in the low-count / high-mito corner). ### Always inspect distributions first ```python sc.pl.violin(adata, ['n_genes_by_counts', 'total_counts', 'pct_counts_mt'], jitter=0.4, multi_panel=True) sc.pl.scatter(adata, x='total_counts', y='pct_counts_mt') sc.pl.scatter(adata, x='total_counts', y='n_genes_by_counts') ``` --- ## 4. Doublet detection (Scrublet / scDblFinder) Per-cell counts cannot reliably separate a doublet from a large active cell. Use a simulation-based caller: ```python # Scrublet via scanpy — run on RAW counts, per-sample (not on merged batches) sc.pp.scrublet(adata, expected_doublet_rate=0.06) # scanpy >=1.10 # older scanpy: sc.external.pp.scrublet(adata, expected_doublet_rate=0.06) n_doub = int(adata.obs['predicted_doublet'].sum()) print(f"{n_doub} predicted doublets ({100*n_doub/adata.n_obs:.1f}%)") # Often better to FLAG, cluster, then drop — doublets form bridge clusters adata = adata[~adata.obs['predicted_doublet']].copy() ``` - `expected_doublet_rate` scales with loading: ~0.8% per 1,000 cells recovered (10x). 5,000 cells -> ~4%; 10,000 -> ~8%. - **Run per sample/lane**, before merging — cross-sample "doublets" aren't real. - **scDblFinder** (R/Bioconductor) is the alternative and often more accurate; run via `Rscript` if R is available. Same principle: simulate doublets, score each cell, threshold. - Prefer flag-cluster-drop over hard pre-clustering removal: real doublets collapse into recognizable bridge clusters between two parent types. --- ## 5. Ambient RNA awareness (SoupX / DecontX) Ambient ("soup") RNA is cell-free mRNA released by lysed cells that gets co-encapsulated into every droplet. Effect: highly expressed genes from one population bleed into all clusters, smearing marker specificity. - Per-cell QC metrics do NOT detect ambient contamination — it is a count-correction step, not a cell-filtering step. - **SoupX** (R) estimates the soup profile from empty droplets and subtracts it from cell counts. Needs the **raw (unfiltered) + filtered** matrices. - **DecontX** (celda, R) is a Python-callable-via-R alternative. - Awareness rule for this skill: if downstream markers look implausibly ubiquitous (e.g. hemoglobin in every cluster of a non-blood tissue, or a dominant cell type's markers everywhere), suspect ambient RNA and recommend SoupX/DecontX correction. Do not silently proceed. --- ## 6. Empty-droplet filtering (knee point / EmptyDrops) Distinguishing real cells from empty droplets is upstream of per-cell QC. - **Knee/inflection on the barcode-rank plot**: rank barcodes by total UMI, plot rank vs total counts (log-log). The "knee" separates real cells (high counts) from the ambient plateau. CellRanger's filtered matrix already applies an EmptyDrops-style call. - **EmptyDrops** (DropletUtils, R) tests each low-count barcode against the ambient profile — recovers real small cells that a hard knee cutoff drops. - If you only have the **filtered** matrix, empty-droplet removal is largely already done; still apply `min_genes` (~200) and `min_counts` as a backstop. - If you have the **raw** matrix, run EmptyDrops (or the knee heuristic) before per-cell QC. ```python # Knee heuristic (when only raw counts available, no DropletUtils) import numpy as np tot = np.asarray(adata.X.sum(1)).ravel() order = np.argsort(tot)[::-1] ranked = tot[order] # crude knee: largest drop in log-counts among the top barcodes log_counts = np.log10(ranked + 1) knee = np.argmax(np.diff(log_counts[:5000]) * -1) if len(ranked) > 1 else 0 print(f"Approx knee at rank {knee}, threshold ~{ranked[knee]:.0f} UMIs") ``` --- ## 7. Recommended order of operations 1. (raw matrix only) Empty-droplet call — EmptyDrops or knee. Skip if you have the CellRanger filtered matrix. 2. `calculate_qc_metrics` with `mt`/`ribo`/`hb` flags (`percent_top=None`). 3. Inspect distributions (violin + count-vs-mito scatter). 4. Gate cells: MAD-based outliers on counts/genes + mito (3 MAD upper, capped), plus `min_genes ~200`, `min_cells ~3` for genes. 5. Doublet detection (Scrublet/scDblFinder) **per sample** — flag, optionally cluster, then drop. 6. (optional) Ambient correction (SoupX/DecontX) if markers look smeared. 7. Proceed to normalization (`references/scanpy_workflow.md` Phase 3+). **Thresholds are dataset-specific.** Report the cutoffs used and how many cells each step removed. Never report a filtered cell count without the gates applied. --- ## See also - `references/scanpy_workflow.md` — Phase 2 inline QC + full pipeline - `scripts/scrna_qc.py` — run-if-available helper (computes metrics + MAD gating from an .h5ad; prints an install plan if scanpy is absent) - `scripts/qc_metrics.py` — existing filter/scrublet helpers