Skip to content

Add tximport-style transcript-length normalization and pytximport support - #451

Open
gitbenlewis wants to merge 19 commits into
scverse:mainfrom
gitbenlewis:main
Open

gitbenlewis wants to merge 19 commits into
scverse:mainfrom
gitbenlewis:main

Conversation

@gitbenlewis

@gitbenlewis gitbenlewis commented Jul 16, 2026 •

Copy link
Copy Markdown

Reference Issue or PRs

Closes #305.
Closes #359.
Related to #412.

What does your PR implement? Be specific.

Adds opt-in DESeq2-style transcript-length normalization for dense estimated gene counts.

Pass average transcript lengths explicitly:

dds = DeseqDataSet(
    counts=estimated_counts,
    metadata=metadata,
    transcript_lengths=average_transcript_lengths,
    design="~condition",
)

For AnnData from pytximport:

dds = DeseqDataSet(
    adata=txi,
    transcript_lengths=txi.obsm["length"],
    design="~condition",
)

The implementation rounds estimated counts and combines transcript-length offsets with library-size normalization. Gene-specific factors are used throughout dispersion and LFC fitting, Wald tests, shrinkage, Cook’s outlier replacement/refitting, and VST. Both ratio and poscounts estimation are supported.

Correction requires explicit transcript_lengths; stored metadata does not activate it. Explicit correction of abundance-scaled counts is rejected, and unscaled pytximport input without opt-in produces a warning.

Usage examples are included in the documentation.

Validation

  • 43 transcript-length normalization tests pass locally.
  • Full local suite: 108 tests pass, including two download tests rerun with network access.
  • Committed DESeq2-generated fixtures check ratio and poscounts factors and normalized counts using the existing synthetic dataset. A small R regeneration script is included.
  • Additional manual DESeq2 comparisons checked zero counts, control genes, and downstream fitting on the synthetic dataset.
  • Lint, formatting, package build, and documentation checks pass.

Local tests used Python 3.12 and pandas 3.0.5. Current GitHub test CI with pandas 3.1.1 fails on deprecation warnings involving AnnData’s future.infer_string access and an existing rename(inplace=True) test call. These compatibility fixes are not included.

Current limitations

  • Iterative size-factor estimation is not supported with length offsets.
  • VST transformation of external counts with separate lengths is not supported.
  • pytximport’s obsm["length"] gene dimension must remain synchronized when genes are subset or reordered.
  • Committed R reference fixtures cover normalization; downstream tests do not yet provide full end-to-end R reference coverage.

Related work and acknowledgements

This work began with the generic transcript_lengths API and includes explicit pytximport input support following discussion in #412.

Thank you to @maltekuehl for the earlier implementation and design work in #412, and to @Zethson for encouraging this work. The tximport, DESeq2, and pytximport work is credited in the documentation.

@gitbenlewis

gitbenlewis commented Jul 29, 2026 •

Copy link
Copy Markdown
Author

Hi @BorisMuzellec — just checking in on PR #451 when you have a chance. All current checks are passing. Could you share an update on the review, or let me know if there’s anything else you’d like me to address? Thanks!
@grst

@sriramdevadas

Copy link
Copy Markdown

@Zethson, would it be useful if I verified this against R's DESeqDataSetFromTximport on a public salmon dataset and contributed the R reference outputs as fixtures in the repo's existing test format? Glad to do it, or to stay out of the way if you have it covered. (cc @gitbenlewis)

@gitbenlewis

gitbenlewis commented Sep 21, 2026 •

Copy link
Copy Markdown
Author

@Zethson, would it be useful if I verified this against R's DESeqDataSetFromTximport on a public salmon dataset and contributed the R reference outputs as fixtures in the repo's existing test format? Glad to do it, or to stay out of the way if you have it covered. (cc @gitbenlewis)

Thanks! I have a separate PyDESeq2–R DESeq2 parity-testing repository, where I’ve added real-Salmon coverage using six public GEUVADIS samples imported independently through R tximport and pytximport. Import and normalization checks pass, and both PyDESeq2 input interfaces agree.
There are some remaining numerical parity differences that appear unrelated to this PR’s import and normalization changes, with details in the diagnosis document. I’ve started addressing these on a separate dispersion-parity branch. It contains partial improvements; full parity is not yet achieved. I suggest keeping that work in a separate PR.
The R reference outputs are now included as fixtures in the external repository. An independent check and help integrating them into PyDESeq2’s existing test format would still be very welcome.

@Zethson merge conflicts resolved : )

@codecov-commenter

codecov-commenter commented Sep 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.19028% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.77%. Comparing base (2ca3b74) to head (c742803).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/pydeseq2/dds.py 99.01% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #451      +/-   ##
==========================================
+ Coverage   86.05%   89.77%   +3.72%     
==========================================
  Files          15       15              
  Lines        1276     1486     +210     
==========================================
+ Hits         1098     1334     +236     
+ Misses        178      152      -26     
Files with missing lines Coverage Δ
src/pydeseq2/default_inference.py 97.82% <100.00%> (+1.72%) ⬆️
src/pydeseq2/dispersions.py 96.93% <100.00%> (+0.09%) ⬆️
src/pydeseq2/ds.py 90.14% <100.00%> (+0.14%) ⬆️
src/pydeseq2/inference.py 100.00% <100.00%> (ø)
src/pydeseq2/misc.py 90.90% <100.00%> (+4.24%) ⬆️
src/pydeseq2/dds.py 92.71% <99.01%> (+5.09%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gitbenlewis

Copy link
Copy Markdown
Author

@BorisMuzellec, could you review this when you have a chance? I’ve resolved the conflicts with main and fixed the CI failures. @Zethson, if another reviewer would be better suited, could you help route this? Thanks!

@Zethson

Zethson commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Sorry, @BorisMuzellec is the right person to review this. I can see @maltekuehl potentially help out - I think it's potentially also in your interest?

@maltekuehl
maltekuehl self-requested a review November 4, 2026 19:55

@maltekuehl maltekuehl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First of all, thank you for working on this. I have left comments across the files.

As with many PRs in the LLM-age, this one seems to be mixing many different bug fixes, features and other changes at the same time, making review somewhat difficult. > 1000 LOC tests don't help that, either.

Could you please split it up into different PRs?

  1. a minimal implementation of opt-in transcript-length normalization
  2. sparse data support
  3. unrelated bug fixes

When it comes to tests and the preserve fit part, I believe sizeable chunks can probably also be dropped entirely, unless there are good reasons to keep them. Same goes for docstring or README re-formatting, please keep the diff to what is actually needed at a time.

Happy to review a more focussed PR again!

Comment thread .github/workflows/test.yaml Outdated
Comment on lines +93 to +101
uv run --no-project --isolated --python 3.12 \
--with-editable . \
--with 'numpy==2.2.0' \
--with 'scipy==1.15.0' \
--with 'anndata==0.11.0' \
--with 'pandas==2.3.0' \
--with 'zarr<3' \
--with 'pytest==8.4.0' \
--with 'coverage>=7.10' \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a specific reason for these overrides? We should aim to follow SPEC0 with PyDESeq2 and 3.12 is outside the inclusion window now.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A dependency and Python version update should perhaps be a separate PR to align with SPEC0.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the overrides and restored the upstream test workflow. Python and dependency-version changes are outside the scope of this PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A dependency and Python version update should perhaps be a separate PR to align with SPEC0.

Agreed. I’ve removed those changes from this PR so that any version-policy update can be reviewed separately.

Comment thread src/pydeseq2/dds.py Outdated
warnings.simplefilter("ignore", FutureWarning)


def _rounded_counts(counts: Any) -> Any:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest moving this out of dds.py to misc.py since it is not dataset related. Also, is there a good reason to use Any here instead of a union type?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With sparse support removed, the helper was unnecessary. I removed it and inlined the two calls using counts.round() and np.rint().

Comment thread src/pydeseq2/dds.py Outdated
Comment on lines +90 to +94
design
Model design.
Can be either a pandas DataFrame representing a design matrix, or a formulaic formula in the format ``'x + z'`` or ``'~x+z'``.
If a design matrix is provided, DeseqStats built from this DeseqDataSet will only support contrasts in the form of numeric vectors.
Model design. Can be either a pandas DataFrame representing a design matrix, or
a formulaic formula in the format ``'x + z'`` or ``'~x+z'``.
If a design matrix is provided, DeseqStats built from this DeseqDataSet will
only support contrasts in the form of numeric vectors.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we keep out purely cosmetic docstring changes for the initial PR to get a better understanding of what actually changed? Or is this a mismatch in the linter/formatter?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted the purely cosmetic docstring changes in c742803

assert not hasattr(dds, attr)


def test_transcript_length_normalization_matches_deseq2_formula():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test feels circular and repetitive, no? Instead of proving a correct implementation of the original formula by repeating the formula in the test, I would suggest comparing against a fixture generated with DESeq2 to prove concordance and to prevent drift. E.g., some of the typical GEUVADIS test data or a subsample thereof. This should also eliminate the need for some of the other functions in this test file and reduce LOCs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the formula-based expectations with DESeq2-generated fixtures for ratio and poscounts normalization factors and normalized counts in edd40f7. These reuse the repository’s existing synthetic dataset, with a small R regeneration script included.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1187 on a single test file seems excessive. Are there things you could use @pytest.mark.parametrize for? And instead of testing correctness of every single part, it is often easier to test end-to-end against a fixture to detect drift.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Especially when working with LLMs, please provide strict guardrails on what needs a test and how. Integration tests, correctness tests and end-to-end tests are much preferable over implementation and unit tests, generally.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reduced the file from 1,187 to 493 lines across bb8b50d, 0e9dfc4, edd40f7, and b162f02 by removing sparse tests, parametrizing repeated cases, and consolidating pipeline checks. The independent R fixtures cover normalization; downstream checks remain Python integration tests rather than full end-to-end R comparisons.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed redundant assertions and consolidated the integration checks in b162f02, alongside the independent normalization fixtures added in edd40f7. The remaining moments test uses small explicit inputs and hand-calculated expectations rather than repeating the implementation.

Comment thread src/pydeseq2/inference.py Outdated
from scipy.sparse import sparray
from scipy.sparse import spmatrix

type CountMatrix = np.ndarray | spmatrix | sparray

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we should consider moving types to a _types file that can be imported from so that we don't run the risk of circular imports between the consumers of shared types in the future.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the added shared matrix types along with sparse support in dfd7217, so those cross-module type dependencies are no longer part of this PR.

Comment thread src/pydeseq2/dds.py
)
has_transcript_lengths = has_transcript_lengths or has_pytximport_fields
counts_from_abundance = adata.uns.get("counts_from_abundance")
if counts_from_abundance is not None and has_transcript_lengths:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using pytximport with counts_from_abundance="length_scaled_tpm" is perfectly fine to use with PyDESeq2 out-of-the-box and it carries .obsm["length"], so raising here breaks existing, desired workflows. It should be up to the user which workflow they want to use, we should not force them into the DESeqDataSetFromTximport workflow.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without an opt-in argument, we would also silently change the output for users where counts_from_abundance is None. In that case, I think a warning would be fair though, since using output from that configuration just like that with PyDESeq2 is not recommended.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Length correction now requires an explicit transcript_lengths= argument following 0e9dfc4. Stored length metadata alone no longer activates correction or causes scaled inputs to be rejected. The error applies only when explicit correction is combined with abundance-scaled counts; ordinary integer-count validation remains unchanged.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added explicit opt-in in 0e9dfc4 and the suggested warning in b162f02. When counts_from_abundance is explicitly None and no lengths are supplied, the constructor warns without enabling correction. Inputs without that metadata key and abundance-scaled inputs do not receive the warning.

Comment thread src/pydeseq2/dds.py Outdated
Comment on lines +264 to +267
if adata.isbacked:
raise ValueError(
"DeseqDataSet requires an in-memory AnnData object. Call "
"adata.to_memory() before initialization."

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to have that here and just here and to include it in this PR? Is this a condition specific to functionality introduced in this PR or just something general?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a general restriction rather than a requirement of length normalization. Removed the guard and its dedicated rejection test in b162f02. The guide retains the recommendation to load backed data into memory first.

Comment thread .github/workflows/test.yaml Outdated
Comment on lines +103 to +104
tests/test_transcript_length_normalization.py \
tests/test_stats.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it running just these files?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the file selection and restored the upstream workflow in dfd7217, including its normal test-suite invocation.

Comment thread src/pydeseq2/dds.py Outdated
Comment on lines +425 to +438
if preserve_inherited_fit and adata is not None:
source_design = adata.obsm.get("design_matrix")
normalization_control_mask = self._make_control_mask(control_genes)
fit_settings = (
("fit_type", fit_type),
("min_mu", min_mu),
("min_disp", min_disp),
("max_disp", np.maximum(max_disp, self.n_obs)),
("refit_cooks", refit_cooks),
("min_replicates", min_replicates),
("beta_tol", beta_tol),
)
preserve_inherited_fit = (
isinstance(source_design, pd.DataFrame)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain this entire block up to L505 here, how it relates to the PR, why it's needed and the reasons behind the implementation? Without diving deep into DESeq2 again, I cannot follow what the purpose is.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the inherited-fit reuse logic in 0e9dfc4. The remaining cleanup prevents copied size factors, dispersions, and LFCs from surviving a new normalization choice. It preserves user metadata while clearing fit-derived results, so subsequent fitting cannot silently reuse stale values. Tests cover switching between ordinary and length normalization, including construction from exported AnnData.

@gitbenlewis

Copy link
Copy Markdown
Author

First of all, thank you for working on this. I have left comments across the files.

As with many PRs in the LLM-age, this one seems to be mixing many different bug fixes, features and other changes at the same time, making review somewhat difficult. > 1000 LOC tests don't help that, either.

Could you please split it up into different PRs?

  1. a minimal implementation of opt-in transcript-length normalization
  2. sparse data support
  3. unrelated bug fixes

When it comes to tests and the preserve fit part, I believe sizeable chunks can probably also be dropped entirely, unless there are good reasons to keep them. Same goes for docstring or README re-formatting, please keep the diff to what is actually needed at a time.

Happy to review a more focussed PR again!

@maltekuehl . thank you for taking the time to review this.

I appreciate the detailed feedback and will carefully work through each comment.

I agree that the PR has grown beyond its original scope and become difficult to review. I'll start working on spliting this up into the 3 separate PRs you suggested.

Thanks agian for offering to review a more focused version!

Restore upstream count handling, CI configuration, and compatibility
behavior while retaining dense transcript-length normalization.

Sparse test cleanup and explicit opt-in behavior remain pending.
Remove sparse-only tests and helpers, and use dense inputs in mixed
normalization tests. Drop the assertion tied to the reverted VST fix.
Require transcript_lengths to enable length correction.
Remove automatic metadata detection and inherited-fit reuse.
Update tests and documentation for explicit input and fresh fitting.
Replace ratio and poscounts formula tests with R-generated references
using upstream synthetic data. Include a small regeneration script.
Inline rounding, remove the backed-input guard, and consolodate tests.
Warn on unscaled input without opt-in and move the guide into docs

This branch has not been deployed

No deployments
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.

Plans to add DESeqDataSetFromTximport? Add support for sample-/gene-dependent normalization factors (e.g., length offsets from pytximport)

5 participants