Skip to content

Make Milo differential abundance testing match R Milo - #1109

Merged
Zethson merged 3 commits into
mainfrom
milo-match-r
Sep 23, 2026
Merged

Zethson merged 3 commits into
mainfrom
milo-match-r

Conversation

@Zethson

@Zethson Zethson commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

This aligns Milo.da_nhoods and make_nhoods with miloR 2.8.1, the current Bioconductor release: the edger solver uses the legacy quasi-likelihood fit that miloR pins, the pydeseq2 solver tests the same coefficient as the other solvers instead of falling back to the last level for contrasts without - and scaling continuous covariates by their range, and index cells now belong to their own neighbourhood.
The table compares pertpy with miloR on identical neighbourhood counts of the Stephenson COVID data (113 donors, 4307 neighbourhoods), plus one end-to-end run for the index cell change.
The remaining pydeseq2 differences come from the more conservative Wald test of DESeq2, e.g. 128 of the 144 Asymptomatic neighbourhoods that miloR calls draw at least 80% of their Asymptomatic cells from a single donor.

Design Solver logFC ρ logFC slope p-value ρ SpatialFDR < 0.1 (shared with miloR) miloR
~Status edger 1.000 → 1.000 1.00 → 1.00 1.000 → 1.000 852 (802) → 803 (803) 803
~Status pydeseq2 0.995 → 0.995 0.99 → 0.99 0.976 → 0.976 858 (743) → 858 (743) 803
~Site+Status edger 1.000 → 1.000 1.00 → 1.00 0.999 → 1.000 948 (914) → 926 (926) 926
~Site+Status pydeseq2 0.994 → 0.994 0.88 → 0.88 0.965 → 0.965 915 (810) → 915 (810) 926
~Site+COVID_severity, COVID_severityAsymptomatic edger 1.000 → 1.000 1.00 → 1.00 0.998 → 1.000 129 (127) → 144 (144) 144
~Site+COVID_severity, COVID_severityAsymptomatic pydeseq2 0.467 → 0.992 0.52 → 0.85 0.186 → 0.967 484 (12) → 1 (0) 144
~Site+COVID_severity_continuous edger 1.000 → 1.000 1.00 → 1.00 0.999 → 1.000 756 (734) → 747 (747) 747
~Site+COVID_severity_continuous pydeseq2 0.998 → 0.998 6.55 → 1.31 0.987 → 0.987 795 (679) → 795 (679) 747
~Status, end to end on 15k cells with k = 30 edger 196 → 211 218 to 225 over 3 seeds

The edger solver called glmQLFit without legacy=TRUE, so under edgeR 4 it
ran the new quasi-likelihood method while miloR pins the legacy one.
On identical neighbourhood counts logFC and p-values now agree with
miloR::testNhoods to machine precision.

The pydeseq2 solver rebuilt contrasts from the formula string and fell back
to the last against the first level whenever model_contrasts had no "-".
COVID_severityAsymptomatic therefore tested Critical against Healthy, and a
continuous covariate reported its effect over its whole range instead of
per unit.
It now tests the coefficient vector from _contrast_vector like the edger and
mixed model paths, including their "+ 0" rule for contrasts.

make_nhoods left every index cell out of its own neighbourhood because
scanpy connectivities have an empty diagonal, while miloR includes it.
Each neighbourhood thereby lost one cell of the index cell's sample, which
biased the counts towards the null.

Verified against miloR 2.6.0 and edgeR 4.8.2 on the Stephenson COVID data
(113 donors, 4307 neighbourhoods).
A benchmark with permuted labels and planted depletion shows the pydeseq2
solver is better calibrated than edgeR (3.2 against 14.8 false calls at
SpatialFDR < 0.1 under permuted labels) but less powerful (TPR 0.31 against
0.50 with ten donors per group).
Cook's outlier handling, size factors, min_mu and fit_type did not change
this, so the pydeseq2 settings are left as they were.
@codecov-commenter

codecov-commenter commented Sep 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.57143% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.99%. Comparing base (be2cd41) to head (489ee21).

Files with missing lines Patch % Lines
src/pertpy/tools/_milo.py 78.57% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1109      +/-   ##
==========================================
+ Coverage   79.97%   79.99%   +0.01%     
==========================================
  Files          55       55              
  Lines        7536     7518      -18     
==========================================
- Hits         6027     6014      -13     
+ Misses       1509     1504       -5     
Files with missing lines Coverage Δ
src/pertpy/tools/_milo.py 78.59% <78.57%> (+0.17%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The pydeseq2 solver used to accept StatusCovid-StatusHealthy in ~Site+Status,
where the reference level Healthy has no coefficient; the contrast vector
rewrite rejected it. A term naming a reference level now weighs zero, which
is its value under treatment coding, so the old contrasts keep working for
the pydeseq2 solver and the mixed model.
@Zethson
Zethson merged commit c7e0f02 into main Sep 23, 2026
23 checks passed
@Zethson
Zethson deleted the milo-match-r branch September 23, 2026 12:50
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.

2 participants