feat(phenotyping): spatially-aware graph_mi target + opt-in denoising, debias scoring, marker QC - #39
Merged
Merged
Conversation
…or denoising, debias scoring, and marker QC Improve coarse cell-type classification in spatial data while keeping the spirit of genevector. Findings driven by a benchmark over the synthetic dataset (true labels, Poisson-dropout) and GBM/CRC Xenium tiles (ref = grafiti_celltype). Headline (robust, generalizes): - New `graph_mi` target: computes the co-expression target on spatially-aggregated expression so neighbour aggregation denoises sparse counts BEFORE estimating MI. +0.025 ARI over `mi` on real spatial tiles (7/8 tiles, never meaningfully hurts). Also adds `graph_cross_mi` (asymmetric ligand->neighbour) for niche/communication directionality. CellEmbedding: - Fix: `dataset_vector` was initialized to zeros and never set, silently disabling the contrastive subtraction in `get_predictive_genes`. It is now the mean cell vector. - `phenotype_probability` is vectorized and gains opt-in `debias` (subtract a fraction of the dataset/background vector) and `contrastive` scoring, plus optional spatial label propagation (`lp_graph`/`lp_alpha`). Defaults reproduce the previous behaviour. - `denoise_cell_vectors`: opt-in, count-adaptive spatial message passing on cell vectors (low-count cells borrow from neighbours; dense cells barely move). Large gains under severe dropout / spatially-pure data; opt-in because it can contaminate identity in intermixed tissue. - `qc_marker_dict`: flags absent / low-specificity (tau index) / off-target markers and thin phenotypes, to guide building marker matrices. Honest negatives (documented, shipped opt-in/default-off): subtracting the dataset vector and label propagation are regime-dependent (help balanced data, hurt epithelial-dominated or intermixed tissue); embedding-kNN denoising and hybrid scoring gave no robust gain. Also adds `scripts/phenotype_workflow.py`: a runnable marker-driven phenotyping CLI (AnnData + JSON markers + output dir) doing HVG∪SVG selection (grafiti Moran's I when spatial), graph_mi for spatial data, marker QC, optional denoising, and assignment. Tests: graph_mi/graph_cross_mi targets and the new phenotyping features (89 passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make every lever that helped on some datasets configurable from a single
phenotype_probability call (all default-off / current behaviour):
- score_norm: per-phenotype column normalization ('zscore'/'rank') so a phenotype
close to every cell doesn't win the argmax by default (won 4/6 tiles in testing).
- smooth_graph/smooth_alpha/smooth_adaptive/smooth_counts: count-adaptive spatial
denoising of cell vectors for scoring only (does not mutate self.matrix/UMAP);
complements the persistent denoise_cell_vectors method.
- debias / contrastive / lp_graph were already exposed; refactored the smoothing math
into a shared _graph_smooth helper used by both denoise_cell_vectors and scoring.
CLI gains --score-norm. Tests cover score_norm options and non-mutating scoring-time
smoothing; defaults unchanged (91 passing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add _cross_mi_matrix_torch: for each self gene, one scatter_add builds every neighbour gene's joint histogram at once (chunked for memory), so it is O(P) Python iterations instead of O(P^2). Runs on any torch device, so device='cuda' gives a GPU graph_mi and device='cpu' is validated against the numpy path (max abs diff ~4e-16). Since torch is a core dependency, graph_mi/graph_cross_mi now use this kernel by default (backend 'auto'/'gpu'; 'numpy' forces the pure path). Measured ~19x (150 genes) to ~35x (400 genes) faster than the numpy double-loop even on CPU; GPU scales further to whole-transcriptome panels. cuda-unavailable degrades to torch CPU, then numpy. data.py already forwards device/backend to targets, so the workflow's --device cuda runs graph_mi on GPU. Tests: torch==numpy parity (incl. chunking). 94 passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add compute_cross_mi_pairs to the Rust crate (rayon-parallel over all P*P self/neighbour pairs), mirroring the existing compute_mi_pairs. Wire a 'rust' backend into graph_mi / graph_cross_mi via _cross_mi_matrix_rust. Kernel auto-selection in _graph_mi_core: GPU torch when device='cuda', else the Rust extension if built (fastest on multi-core CPU), else torch CPU, else numpy. All numerically identical (diff ~1e-15); only speed differs. Benchmarks (Apple M4 Max, 16 cores, no CUDA): Rust is ~80-110x faster than numpy and ~2-5x faster than the torch CPU kernel (P=1000,n=10k: numpy 335s, torch 15.5s, rust 4.4s). On a CUDA box torch GPU leads for large panels. The workflow logs which kernel it uses. Rust parity test skips when _rust isn't built (CI builds it via maturin, so it runs there). Updated _rust.pyi stub. 95 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Improves coarse cell-type classification in spatial data (scRNA by proxy) while keeping the spirit of genevector. Changes are driven by a benchmark (kept in a gitignored
scratch/lab notebook) over the synthetic dataset (true labels; Poisson-dropout to mimic Xenium sparsity) and GBM/CRC Xenium tiles (reference =grafiti_celltype), using HVG∪SVG gene selection via grafiti's Moran's I.★ Headline: spatially-aware
graph_mitargetCompute the co-expression target on spatially-aggregated expression so neighbour aggregation denoises sparse per-cell counts before estimating MI → a cleaner gene embedding → better coarse typing.
miARIgraph_miARIgraph_mi − mi: mean +0.025 ARI, 7/8 tiles win, worst −0.0004 (never meaningfully hurts). Matches the requested "graph-based target for speedy MI / spatially-motivated computation." Also addsgraph_cross_mi(asymmetric ligand→neighbour) for niche/communication directionality.Multi-kernel
graph_mi/graph_cross_mi(all numerically identical, diff ~1e-15; auto-selected: GPU torch ondevice='cuda'→ rayon Rust extension if built → torch CPU → numpy):scatter_addper self-gene builds all neighbour joint histograms — O(P) Python loops, not O(P²). Runs on GPU (--device cuda) or CPU.compute_cross_mi_pairs, sibling of the existingcompute_mi_pairs): fastest on multi-core CPU.Benchmark (Apple M4 Max, 16 cores, no CUDA):
→ Rust ~80–110x numpy and ~2–5x torch-CPU; torch-GPU leads for large panels on a CUDA box.
Other changes
CellEmbeddingdataset_vectorwas initialised to zeros and never set, silently disabling the contrastive subtraction inget_predictive_genes. It is now the mean cell vector.phenotype_probabilityis vectorised and every regime-dependent lever is an opt-in arg (all default-off → previous behaviour):debias(subtract a fraction of the background vector),contrastivescoring,score_norm(zscore/rankper-phenotype column normalization so a phenotype close to everyone doesn't win by default), scoring-time spatial smoothing (smooth_graph/smooth_alpha/smooth_adaptive, non-mutating), and spatial label propagation (lp_graph/lp_alpha).denoise_cell_vectors: opt-in count-adaptive spatial message passing on cell vectors (low-count cells borrow from neighbours; dense cells barely move). +0.2 ARI under severe dropout on the synthetic.qc_marker_dict: flags absent / low-specificity (tau index) / off-target markers and thin phenotypes — guidance for building marker matrices. Correctly flags the weak syntheticmarker_0, and on GBM flagsOLIG2-as-Malignant (peaks in OPC) and broadly-expressed myelin genes.scripts/phenotype_workflow.py— runnable marker-driven phenotyping CLI: AnnData + JSON markers + output dir → HVG∪SVG selection (grafiti Moran's I when spatial),graph_mifor spatial data, marker QC, optional denoising, assignment. Outputsphenotyped.h5ad,assignments.csv,marker_qc.csv,summary.json.Honest negative results (documented; shipped opt-in / default-off)
debiasopt-in, default off.marker_aurocas a metric is anti-correlated with true accuracy under dropout (it rewards raw-marker matching) — use true labels.Tests
tests/test_graph_targets.py(graph_mi/graph_cross_mi) andtests/test_phenotyping.py(dataset_vector fix, debias/contrastive, label-prop, denoising, marker QC). 89 passed (1 pre-existing unrelated failure intest_grafiti_parity— grafiti'sgenerate_synthetic_datanow returns AnnData not DataFrame — is independent of this PR).🤖 Generated with Claude Code