Skip to content

feat: sample score_genes controls per gene - #4373

Open
Sizerta wants to merge 2 commits into
scverse:mainfrom
Sizerta:score-genes-per-gene-controls
Open

Sizerta wants to merge 2 commits into
scverse:mainfrom
Sizerta:score-genes-per-gene-controls

Conversation

@Sizerta

@Sizerta Sizerta commented Sep 21, 2026 •

Copy link
Copy Markdown

Closes #3845

What does this PR do?

score_genes currently samples ctrl_size reference genes once for each expression bin represented in gene_list.

This can give different genes in gene_list very different influence over the reference set. For example, if six genes from the gene set fall into one expression bin and only one gene falls into another, the current implementation samples the same number of controls from both bins. The bin containing six genes is therefore under-represented relative to the number of genes it is meant to match.

This PR adds a new ctrl_per_gene option. When enabled, score_genes samples a matched control set for every gene in gene_list and combines those control sets so that each gene contributes equally to the reference mean, following the per-gene matching described in Tirosh et al. (2016).

The existing behavior is preserved under the ScanpyV1 preset:

  • ScanpyV1: ctrl_per_gene=False
  • ScanpyV2Preview: ctrl_per_gene=True

This follows the same preset-based approach Scanpy uses for other behavior changes planned for 2.0, so existing V1 results are not silently changed.

Implementation

The main changes are:

  • Add ctrl_per_gene to the score_genes API and ScoreGenesPreset.
  • Preserve the individual control sets generated for each target gene instead of only keeping their union.
  • Compute weights for the union of control genes so that the mean of the weighted reference is equivalent to averaging the matched control means for each target gene.
  • Add a sparse/dense weighted mean implementation that continues to ignore NaNs.
  • Add the corresponding preset values for ScanpyV1 and ScanpyV2Preview.

When ctrl_per_gene=False, the existing scoring path is retained.

Testing

I added tests covering:

  • the null case from Implementation of sc.tl.score_genes doesn't match reference #3845, where the current per-bin approach produces a systematic bias;
  • the exact per-gene weighting, checked against an independent hand calculation;
  • both values of ctrl_as_ref;
  • both dense and sparse input;
  • NaN handling for dense and sparse input;
  • the preset values.

The focused test suite passes:

hatch test tests/test_score_genes.py
35 passed

The existing preset-selection test also passes:

hatch test tests/test_settings.py::test_preset_scanpy_v2_preview_checks_deps
1 passed

I also compared the unmodified and patched implementations directly under the ScanpyV1 preset. For the tested dense/sparse, ctrl_as_ref=True/False, and NaN-containing cases, the resulting score arrays were byte-for-byte identical.

Reproducing #3845

I also reproduced the issue independently with a scaled synthetic dataset preserving the structure of the example in #3845: many genes from the target set in one expression level and only a few in other levels, with no intended biological signal.

The current per-bin implementation produced:

mean score:        -19.83
fraction negative: 99.98%

With ctrl_per_gene=True:

mean score:        -0.053
fraction negative: 52.3%

The latter is much closer to the expected null behavior, with the score distribution centered around zero rather than being systematically shifted negative.

Notes

The new implementation follows the per-gene matching described in Tirosh et al. (2016). It does not attempt to reproduce every detail of Seurat's AddModuleScore implementation, in particular its handling of duplicate control genes.

@codecov

codecov Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.36842% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 81.96%. Comparing base (82db34e) to head (ee159b5).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/scanpy/tools/_score_genes.py 97.29% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4373      +/-   ##
==========================================
+ Coverage   81.92%   81.96%   +0.03%     
==========================================
  Files         134      134              
  Lines       13027    13059      +32     
==========================================
+ Hits        10673    10704      +31     
- Misses       2354     2355       +1     
Flag Coverage Δ
hatch-test.low-vers 78.84% <97.36%> (+0.04%) ⬆️
hatch-test.pre 81.83% <97.36%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/scanpy/_settings/presets.py 93.15% <100.00%> (+0.04%) ⬆️
src/scanpy/tools/_score_genes.py 89.36% <97.29%> (+2.08%) ⬆️

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.

Implementation of sc.tl.score_genes doesn't match reference

1 participant