Skip to content

feat(phenotyping): spatially-aware graph_mi target + opt-in denoising, debias scoring, marker QC - #39

Merged
nceglia merged 5 commits into
mainfrom
feat/spatial-phenotype-classification
Jun 7, 2026
Merged

feat(phenotyping): spatially-aware graph_mi target + opt-in denoising, debias scoring, marker QC#39
nceglia merged 5 commits into
mainfrom
feat/spatial-phenotype-classification

Conversation

@nceglia

@nceglia nceglia commented Jun 6, 2026

Copy link
Copy Markdown
Owner

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_mi target

Compute 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.

dataset mi ARI graph_mi ARI
crc_xenium 0.425 0.440
gbm_xenium 0.141 0.175

graph_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 adds graph_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 on device='cuda' → rayon Rust extension if built → torch CPU → numpy):

  • Vectorized torch kernel: one scatter_add per self-gene builds all neighbour joint histograms — O(P) Python loops, not O(P²). Runs on GPU (--device cuda) or CPU.
  • rayon Rust kernel (compute_cross_mi_pairs, sibling of the existing compute_mi_pairs): fastest on multi-core CPU.

Benchmark (Apple M4 Max, 16 cores, no CUDA):

panel numpy torch-CPU Rust (16c)
P=400, n=5k 24.9s 0.69s 0.22s
P=1000, n=10k 335s 15.5s 4.4s
P=2000, n=20k 129s 72s

→ Rust ~80–110x numpy and ~2–5x torch-CPU; torch-GPU leads for large panels on a CUDA box.

Other changes

CellEmbedding

  • Bug fix: dataset_vector was initialised to zeros and never set, silently disabling the contrastive subtraction in get_predictive_genes. It is now the mean cell vector.
  • phenotype_probability is vectorised and every regime-dependent lever is an opt-in arg (all default-off → previous behaviour): debias (subtract a fraction of the background vector), contrastive scoring, score_norm (zscore/rank per-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 synthetic marker_0, and on GBM flags OLIG2-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_mi for spatial data, marker QC, optional denoising, assignment. Outputs phenotyped.h5ad, assignments.csv, marker_qc.csv, summary.json.

python scripts/phenotype_workflow.py --input tile.h5ad --markers markers.json \
  --output out/ --target auto --denoise --grafiti-path /path/to/grafiti

Honest negative results (documented; shipped opt-in / default-off)

  • Subtracting the dataset vector (Welcome to genevector Discussions! #1) is regime-dependent: helps balanced tissue (GBM +0.03) but hurts epithelial-dominated CRC (−0.09, the mean ≈ epithelial). → debias opt-in, default off.
  • Label propagation (Installation via pip fails #4) trades partition-agreement for marker-coherence; net ≤0 vs reference. → opt-in, default off.
  • Cell-vector spatial smoothing helps sparse / spatially-pure data but contaminates identity in intermixed tissue → count-adaptive + opt-in.
  • Embedding-kNN denoise / hybrid scoring: no robust gain. For coarse typing the absolute-cosine scoring is already near-optimal, so the win came from the embedding/target (graph_mi), not post-hoc scoring tricks.
  • marker_auroc as 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) and tests/test_phenotyping.py (dataset_vector fix, debias/contrastive, label-prop, denoising, marker QC). 89 passed (1 pre-existing unrelated failure in test_grafiti_parity — grafiti's generate_synthetic_data now returns AnnData not DataFrame — is independent of this PR).

🤖 Generated with Claude Code

nceglia and others added 5 commits June 6, 2026 15:28
…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>
@nceglia
nceglia merged commit bbb7c5d into main Jun 7, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant