feat: add likelihood ratio test (LRT) to DeseqStats#452
Open
pankev-in wants to merge 2 commits into
Open
Conversation
Implements R DESeq2's `DESeq(dds, test="LRT", reduced=...)` in PyDESeq2, resolving the long-standing request in scverse#176. `DeseqStats` gains a `test` argument (`"Wald"` or `"LRT"`) and a `reduced` argument. When `test="LRT"`, PyDESeq2 fits the nested reduced model with the same gene-wise dispersions as the full model and computes, per gene, stat = 2 * (loglik_full - loglik_reduced), p = chi2.sf(stat, df) with df = (#full coefficients - #reduced coefficients). The reported log2FoldChange and lfcSE still correspond to the requested contrast of the full model, matching R DESeq2. The reduced model may be given as a formula string (nested in the design) or as an explicit design matrix, so it supports custom reduced models with df > 1 (e.g. jointly testing a multi-level factor), unlike the earlier stalled attempt (scverse#178) which only removed a single term. Cooks outlier replacement is handled by refitting the reduced model on the same imputed counts as the full model (R's `counts(dds, replaced=TRUE)`). Validated numerically against R DESeq2 1.46.0: log fold changes match to ~1e-5 relative error and, for every gene with signal, statistics and p-values match to well under the existing test tolerance across single-factor (df=1), multi-factor (df=1 and df=2), and outlier-refit scenarios. R reference data is regenerable via tests/data/generate_lrt_reference.R. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI's `mypy -p pydeseq2` step (which is failing repo-wide on main due to a dependency-driven stubs regression) reported union-attr false positives on the anndata-typed `obsm`/`varm`/`X` accessors used by the new LRT code. Type the `design_matrix`/`LFC` attributes as DataFrame at assignment (they always are) and coerce the size-factor/dispersion Series with `np.asarray`. This makes the new LRT methods fully mypy-clean and, as a side effect, also clears pre-existing union-attr errors in `run_wald_test`, lowering the package's total mypy error count (228 -> 188) rather than adding to it. No runtime behaviour changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
|
Quick note on the failing CI: it's the The LRT tests pass locally (74/74 for the full suite). I also tidied up the typing in the code this PR touches, so the branch actually brings the total mypy count down from 228 to 188 instead of adding to it. I didn't want to fold a repo-wide typing/deps fix into a feature PR, but happy to open a separate one for the mypy step if that's helpful. Let me know how you'd prefer to handle it. |
Open
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
Adds the likelihood ratio test (LRT) to
DeseqStats, the Python equivalent of R DESeq2'sDESeq(dds, test="LRT", reduced=...). This resolves the long-standing request in #176 (and supersedes the stalled #178).DeseqStatsgains two arguments:test:"Wald"(default, unchanged behaviour) or"LRT";reduced: the nested reduced model for the LRT, as a formula string (e.g."~group") or an explicit design matrix (numpy array / DataFrame).Unlike #178 (which only ever removed a single coefficient, hard-coded
df=1, and droppedlfcSE), this supports arbitrary nested reduced models, includingdf > 1— e.g. jointly testing a multi-level factor or comparing nested models in general — which is the main reason to use the LRT over the Wald test.Closes #176.
The test
For each gene the statistic is
where
ℓis the negative-binomial log-likelihood at the maximum-likelihood fit of each model, both using the same gene-wise dispersions (estimated once from the full model, exactly as in DESeq2). Under H₀ that the extra full-model coefficients are zero,Λ ~ χ²withand
pvalue = chi2.sf(Λ, df). As in R DESeq2, the reportedlog2FoldChange/lfcSEstill correspond to the requestedcontrastof the full model, while the p-value reflects the model comparison. Cooks outlier replacement is honoured by refitting the reduced model on the same imputed counts as the full model (R'scounts(dds, replaced=TRUE)).Validation against R DESeq2 (the math, proven)
The implementation is validated numerically against R DESeq2 1.46.0 on the package's synthetic dataset, mirroring how the existing Wald reference data is produced. Reference data is regenerable via
tests/data/generate_lrt_reference.R.Example — full
~group + conditionvs reduced~group(df=1), PyDESeq2 vs R:Log fold changes agree to ~1e-6 relative error; statistics and p-values for genes with signal agree to ~1e-3. Across all validated scenarios, LFCs match R to ≤1e-5 and, for genes with signal, statistics/p-values match well within the suite's existing tolerance.
Tests
Added to
tests/test_pydeseq2.py(all passing; full suite 74/74 green):test_lrt_matches_r— single-factordf=1, multi-factordf=1, and jointdf=2, vs R;test_lrt_no_independent_filtering— isolates the raw LRT statistic/p-value vs R;test_lrt_with_outliers— Cooks outlier replacement + reduced-model refit vs R (and asserts the raw counts are not mutated);test_lrt_reduced_as_design_matrix— a reduced model given as a matrix equals the formula;test_lrt_statistic_definition— R-independent checks: statistic ≥ 0,pvalue == chi2.sf(stat, df), andΛ ≈ (Wald stat)²atdf=1;test_lrt_lfc_matches_wald— LRT reports the same LFC as the Wald test;test_lrt_input_validation— informative errors for missing/invalidreduced, non-nested reduced, unknowntest, etc.Docs: an LRT subsection is added to the minimal pipeline example, and the reference data is documented in
tests/data/README.md.Notes
testdefaults to"Wald", and the Wald path is unchanged (reducedmust beNonefor it).Inference.irlsroutine, so customInferencebackends work with no changes to the ABC.🤖 Generated with Claude Code