Skip to content

Commit 2f30943

Browse files
committed
Make qc consensus floor changeable
1 parent 8ccf21a commit 2f30943

4 files changed

Lines changed: 34 additions & 17 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ The pipeline requires the following primary inputs, typically configured via com
7474

7575
* Gather mode (`--gather_mode`): `auto`, `starsolo`, or `cellbender` (default: `cellbender`). In `auto` mode, samples annotated as `cell` use `cellbender`; samples annotated as `nuclei` use `starsolo`.
7676
* QC mode (`--qc_mode`): `original`, `multires`, or `combined` (default: `original`).
77+
* Consensus floor (`--consensus_floor`): minimum consensus fraction for multires/combined consensus rescue (default: `0.90`).
7778
* CellTypist model (`--celltypist_model`): `gut` preset or a path to a custom model.
7879
* Integration HVG strategy (`--hvg_strategy`): `cell_nuclei_union` or `global` (default: `cell_nuclei_union`).
7980
* Optional batch-aware HVG selection (`--hvg_batch_key`): obs column passed to `scanpy.pp.highly_variable_genes(batch_key=...)`.
@@ -396,8 +397,8 @@ This step requires the output of `gather_matrices` step which is the h5ad object
396397

397398
After optional CellTypist annotation, `run_qc` applies the automatic QC to each sample. Three QC modes are supported and selectable via `qc_mode`:
398399
* `original` mode uses the main automatic QC logic (summarised [here](https://teichlab.github.io/sctk/notebooks/automatic_qc.html)). It (i) computes the eight QC metrics, (ii) builds a QC embedding (PCA → neighbors → UMAP → clustering), then (iii) loops over mitochondrial upper bounds (20, 50, 80). For each bound it refits Gaussian Mixture Models (GMMs) for four core metrics (n_counts, n_genes, percent_mito, percent_spliced) (default thresholds for these four core metrics differ if the sample is single-nuc `nuclei` or single-cell `cell`) to derive sample‑specific pass ranges, assigns per‑cell pass/fail across all required metrics, and calls clusters good if ≥50% of their cells pass (via `min_frac`). The loop produces cellwise and clusterwise pass flags per mito threshold; final summary columns (`pass_auto_filter`, `good_qc_cluster`) encode the most stringent (lowest mito) threshold each cell/cluster still satisfies (smaller value = stricter pass).
399-
* `multires` mode keeps the same metric computation and initial QC embedding but fixes the mitochondrial bound at 20 (no mito loop). It runs the GMM cellwise QC once to obtain `cell_passed_qc`, then performs multi‑resolution clustering (grid 0.1–1.0) applying clusterwise QC at each resolution. From these runs it records, for every cell, a `consensus_fraction` (fraction of resolutions where its cluster passes) and selects a consensus cut‑off that maximizes the Jaccard overlap of failing cells versus the cellwise GMM calls (explicit search over unique consensus_fraction values). Cells with consensus_fraction ≥ chosen threshold become `consensus_passed_qc`; a degeneracy guard forces all False if the fractions carry no signal, and an additional `keep_multires` flag rescues high‑agreement cells (≥0.90) or cluster passes. This yields per‑cell QC flags less sensitive to one arbitrary clustering resolution.
400-
* `combined` mode nests the multi‑resolution procedure inside the mitochondrial loop (20, 50, 80). For each mito bound it refits the GMMs (adjusting percent_mito upper limit), performs cellwise QC (`good_qc_cell_mitoX`), runs multi‑resolution cluster QC (producing `good_qc_cluster_mitoX`, `consensus_fraction_mitoX`), searches a consensus threshold (same Jaccard strategy) to define `consensus_passed_qc_mitoX`, and applies a consensus fraction floor (0.90) plus degeneracy checks. Pass propagation merges stricter passes upward (20 → 50 → 80) and collapses to numeric summaries (`pass_auto_filter`, `consensus_pass_auto_filter`) representing the most stringent mitochondrial window retained. This delivers robust QC calls across both clustering resolution and mitochondrial burden.
400+
* `multires` mode keeps the same metric computation and initial QC embedding but fixes the mitochondrial bound at 20 (no mito loop). It runs the GMM cellwise QC once to obtain `cell_passed_qc`, then performs multi‑resolution clustering (grid 0.1–1.0) applying clusterwise QC at each resolution. From these runs it records, for every cell, a `consensus_fraction` (fraction of resolutions where its cluster passes) and selects a consensus cut‑off that maximizes the Jaccard overlap of failing cells versus the cellwise GMM calls (explicit search over unique consensus_fraction values). Cells with consensus_fraction ≥ chosen threshold become `consensus_passed_qc`; a degeneracy guard forces all False if the fractions carry no signal, and an additional `keep_multires` flag rescues high‑agreement cells controlled by `--consensus_floor` or cluster passes. This yields per‑cell QC flags less sensitive to one arbitrary clustering resolution.
401+
* `combined` mode nests the multi‑resolution procedure inside the mitochondrial loop (20, 50, 80). For each mito bound it refits the GMMs (adjusting percent_mito upper limit), performs cellwise QC (`good_qc_cell_mitoX`), runs multi‑resolution cluster QC (producing `good_qc_cluster_mitoX`, `consensus_fraction_mitoX`), searches a consensus threshold (same Jaccard strategy) to define `consensus_passed_qc_mitoX`, and applies the `--consensus_floor` consensus fraction floor plus degeneracy checks. Pass propagation merges stricter passes upward (20 → 50 → 80) and collapses to numeric summaries (`pass_auto_filter`, `consensus_pass_auto_filter`) representing the most stringent mitochondrial window retained. This delivers robust QC calls across both clustering resolution and mitochondrial burden.
401402

402403
A few more details on `run_qc` step:
403404
- Thresholds for the four core metrics (n_counts, n_genes, percent_mito, percent_spliced) to use in GMM are predefined in this step. Different thresholds are used if a sample is single-nuc `nuclei `or single-cell `cell`.

bin/qc.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ def run_qc_mito_loop_original(ad, qc_metrics, metrics_custom, threshold):
174174
for max_mito in reversed(MITO_THRESHOLDS):
175175
ad.obs.loc[ad.obs[f"good_qc_cluster_mito{max_mito}"], "good_qc_cluster"] = max_mito
176176

177-
def run_qc_multi_res(ad, qc_metrics, metrics_custom, threshold):
177+
def run_qc_multi_res(ad, qc_metrics, metrics_custom, threshold, consensus_floor):
178178
"""Multi-resolution QC approach with consensus across clusterings."""
179179

180180
sk._pipeline.generate_qc_clusters(ad, metrics=qc_metrics)
@@ -229,14 +229,13 @@ def run_qc_multi_res(ad, qc_metrics, metrics_custom, threshold):
229229
ad.uns["consensus_threshold_degenerate"] = False
230230

231231
# Optional stricter rescue: require a high raw fraction on top of kp9's boolean.
232-
CONS_FLOOR = 0.90 # adjust (0.85–0.95) as you like
233232
ad.obs["keep_multires"] = (
234233
ad.obs["cluster_passed_qc"] |
235-
(ad.obs["consensus_passed_qc"] & (frac >= CONS_FLOOR))
234+
(ad.obs["consensus_passed_qc"] & (frac >= consensus_floor))
236235
)
237236

238237

239-
def run_qc_combined(ad, qc_metrics, metrics_custom, threshold):
238+
def run_qc_combined(ad, qc_metrics, metrics_custom, threshold, consensus_floor):
240239
"""Combined original + multi-resolution QC across mitochondrial thresholds."""
241240

242241
for max_mito in MITO_THRESHOLDS:
@@ -309,8 +308,6 @@ def run_qc_combined(ad, qc_metrics, metrics_custom, threshold):
309308

310309
# --- Degenerate guard + controlled collapse (combined) ---
311310
eps = 1e-9
312-
CONS_FLOOR = 0.90 # optional: demand high cross-resolution agreement
313-
314311
for max_mito in MITO_THRESHOLDS:
315312
frac_key = f"consensus_fraction_mito{max_mito}"
316313
pass_key = f"consensus_passed_qc_mito{max_mito}"
@@ -321,8 +318,7 @@ def run_qc_combined(ad, qc_metrics, metrics_custom, threshold):
321318
ad.uns.setdefault("consensus_threshold_degenerate", {})[max_mito] = "degenerate_all_zero"
322319

323320
# Optional stricter rescue: require high raw fraction too
324-
if CONS_FLOOR is not None:
325-
ad.obs[pass_key] &= (ad.obs[frac_key] >= CONS_FLOOR)
321+
ad.obs[pass_key] &= (ad.obs[frac_key] >= consensus_floor)
326322

327323
# Optional interpretability: if pass at stricter mito, pass at looser too
328324
ad.obs["consensus_passed_qc_mito50"] |= ad.obs["consensus_passed_qc_mito20"]
@@ -490,7 +486,14 @@ def generate_qc_plots(ad, qc_metrics, qc_mode, metric_pairs, ctp_models, ctp_nam
490486
qc_consensus_ufig,
491487
qc_consensus_venn
492488
)
493-
def run_qc(ad, ctp_models=None, qc_mode="original", metrics_custom=None, threshold=0.5):
489+
def run_qc(
490+
ad,
491+
ctp_models=None,
492+
qc_mode="original",
493+
metrics_custom=None,
494+
threshold=0.5,
495+
consensus_floor=0.90,
496+
):
494497
"""End-to-end QC run: compute metrics, run QC, and build figures."""
495498

496499
qc_metrics = list(DEFAULT_QC_METRICS)
@@ -513,28 +516,33 @@ def run_qc(ad, ctp_models=None, qc_mode="original", metrics_custom=None, thresho
513516
logging.info("Calculating QC metrics")
514517
calculate_qc(ad, run_scrublet=("scrublet_score" in qc_metrics))
515518

516-
logging.info(f"Starting QC: mode={qc_mode}, cutoff={args.gmm_cutoff}")
519+
logging.info(
520+
f"Starting QC: mode={qc_mode}, cutoff={args.gmm_cutoff}, "
521+
f"consensus_floor={consensus_floor}"
522+
)
523+
ad.uns["consensus_floor"] = float(consensus_floor)
517524
if qc_mode == "original":
518525
run_qc_mito_loop_original(ad, qc_metrics, metrics_custom, threshold)
519526
elif qc_mode == "multires":
520-
run_qc_multi_res(ad, qc_metrics, metrics_custom, threshold)
527+
run_qc_multi_res(ad, qc_metrics, metrics_custom, threshold, consensus_floor)
521528
elif qc_mode == "combined":
522-
run_qc_combined(ad, qc_metrics, metrics_custom, threshold)
529+
run_qc_combined(ad, qc_metrics, metrics_custom, threshold, consensus_floor)
523530
else:
524531
raise ValueError(f"Unknown QC mode: {qc_mode}")
525532

526533
logging.info("Generating QC plots")
527534
return generate_qc_plots(ad, qc_metrics, qc_mode, metric_pairs, ctp_models, ctp_name)
528535

529536

530-
def process_sample(ad, ctp_models, qc_mode, metrics_custom, min_frac):
537+
def process_sample(ad, ctp_models, qc_mode, metrics_custom, min_frac, consensus_floor):
531538
"""Wrapper around run_qc to keep the original structure."""
532539
return run_qc(
533540
ad=ad,
534541
ctp_models=ctp_models,
535542
qc_mode=qc_mode,
536543
metrics_custom=metrics_custom,
537544
threshold=min_frac,
545+
consensus_floor=consensus_floor,
538546
)
539547

540548

@@ -571,6 +579,10 @@ def main(args):
571579
else:
572580
min_frac = float(0.5)
573581

582+
consensus_floor = float(args.consensus_floor)
583+
if not 0 <= consensus_floor <= 1:
584+
raise ValueError("--consensus_floor must be between 0 and 1.")
585+
574586
if args.metrics_csv is not None:
575587
logging.info(f"Using custom metrics from {args.metrics_csv}")
576588
metrics_custom = pd.read_csv(Path(args.metrics_csv), index_col=0)
@@ -596,7 +608,9 @@ def main(args):
596608
qc_cluster_ufig,
597609
qc_consensus_ufig,
598610
qc_consensus_venn
599-
) = process_sample(ad, ctp_models, qc_mode, metrics_custom, min_frac)
611+
) = process_sample(
612+
ad, ctp_models, qc_mode, metrics_custom, min_frac, consensus_floor
613+
)
600614

601615
logging.info("Saving QC plots")
602616
def _save(fig, filename):
@@ -705,6 +719,7 @@ def _save(fig, filename):
705719
parser.add_argument("--metrics_csv", type=nullable_string, nargs='?', help="CSV file of metric cutoffs")
706720
parser.add_argument("--celltypist_model", nargs='?', help="comma-separated <name>:<model.pkl> pairs or a predefined set key (e.g., 'gut')")
707721
parser.add_argument("--min_frac", default=None, help="min frac of pass_auto_filter for a cluster to be called good [default: 0.5]")
722+
parser.add_argument("--consensus_floor", default=0.90, type=float, help="minimum consensus fraction required for multires/combined consensus rescue [default: 0.90]")
708723
parser.add_argument("--gath_obj", default=None, help="path to the AnnData object from gather_matrices step")
709724
parser.add_argument("--gmm_cutoff", default=None, help="whether to use closest or furthest point relative to GMM PDF")
710725
parser.add_argument("--sample_id", default=None, help="sample id")

main.nf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ process run_qc {
4141
script:
4242
"""
4343
export BASE_DIR=${projectDir}
44-
python ${projectDir}/bin/qc.py --sample_id ${samp} --metrics_csv ${params.metrics_csv} --celltypist_model ${params.celltypist_model} --qc_mode ${params.qc_mode} --gath_obj ${gath_out} --gmm_cutoff ${params.gmm_cutoff}
44+
python ${projectDir}/bin/qc.py --sample_id ${samp} --metrics_csv ${params.metrics_csv} --celltypist_model ${params.celltypist_model} --qc_mode ${params.qc_mode} --gath_obj ${gath_out} --gmm_cutoff ${params.gmm_cutoff} --consensus_floor ${params.consensus_floor}
4545
"""
4646
}
4747

nextflow.config

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ params {
7474
gather_mode = "cellbender" // Options: auto, cellbender, starsolo
7575
gmm_cutoff = "inner" // Options: inner, outer
7676
qc_mode = "original" // Options: original, multires, combined
77+
consensus_floor = 0.90 // Minimum consensus fraction for multires/combined rescue
7778
celltypist_model = ""
7879
from_scautoqc = true
7980
n_top_genes = 5000

0 commit comments

Comments
 (0)