Skip to content

fix: correct binary logistic marker score direction - #4348

Open
dnncha wants to merge 3 commits into
scverse:mainfrom
dnncha:fix/binary-logreg-marker-direction
Open

fix: correct binary logistic marker score direction#4348
dnncha wants to merge 3 commits into
scverse:mainfrom
dnncha:fix/binary-logreg-marker-direction

Conversation

@dnncha

@dnncha dnncha commented Sep 8, 2026

Copy link
Copy Markdown

Binary LogisticRegression.coef_[0] points toward clf.classes_[1]. The current binary branch reports it under the first requested group without checking that group's encoded class. If the reported group has the lower category code, its marker ranking is reversed.

Orient the binary coefficient vector toward the reported group's class. Existing output shape and multiclass handling are preserved.

With a two-group synthetic matrix containing distinct A and B markers, the unmodified API ranks the B marker first under A when category order is A,B; reversing the categories similarly gives A's marker under B. An explicit target/reference comparison can also flip when only category order changes.

On Scanpy's released pbmc3k_processed() dataset, subset to 342 B cells and 1,144 CD4 T cells, run:

sc.tl.rank_genes_groups(
    adata, "louvain", groups=["B cells"], reference="CD4 T cells",
    method="logreg", max_iter=1000,
)

Using the default raw expression matrix, category order ["B cells", "CD4 T cells"] returns IL32, CD3D, S100A4, LDHB and CD3E at the top of the B-cell ranking. All top 20 have higher mean expression in CD4 T cells. Reversing category order returns HLA-DRA, CD74, HLA-DPB1, HLA-DRB1 and CD79A; all top 20 have higher mean expression in B cells. The first ordering's coefficients need to be negated. After this patch, both orders return the same B-cell ranking. The issue reproduces in releases 1.11.5 and 1.12.4 and current upstream base ec374022343eb7ef80bbe3139264e37552cb79b4.

Validation: 18 added public-API cases cover both category orders and target groups, default binary selection, nonconsecutive category codes, dense/sparse input, raw and layers. With the four existing logistic tests, unmodified source has 10 failures / 12 passes; patched source has 22 passes. Both ranking test files: 60 passed, 35 skipped, 1 expected failure, 32 passing subtests. These counts overlap. The full Scanpy suite was not run. Ruff lint and formatting checks pass.

Related older category-mapping discussions #273 and #2126 concern multiclass ordering; this patch addresses the binary coefficient orientation. No changed published conclusion or independent scientific review is claimed. Prepared with AI assistance. The report and downloadable reproduction scripts, results and execution records are available here.

@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.00%. Comparing base (ec37402) to head (08e3956).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4348      +/-   ##
==========================================
+ Coverage   81.98%   82.00%   +0.01%     
==========================================
  Files         134      134              
  Lines       13235    13237       +2     
==========================================
+ Hits        10851    10855       +4     
+ Misses       2384     2382       -2     
Flag Coverage Δ
hatch-test.low-vers 79.08% <100.00%> (+0.01%) ⬆️
hatch-test.pre 81.87% <100.00%> (+0.01%) ⬆️

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

Files with missing lines Coverage Δ
src/scanpy/tools/_rank_genes_groups.py 94.11% <100.00%> (+0.52%) ⬆️

@flying-sheep flying-sheep added this to the 1.12.5 milestone Sep 8, 2026

@flying-sheep flying-sheep left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks! some small nitpicks, otherwise this is a good fix!


@pytest.mark.parametrize("categories", [["A", "unused", "B"], ["B", "unused", "A"]])
@pytest.mark.parametrize("target", ["A", "B"])
@pytest.mark.parametrize("representation", ["dense", "sparse", "raw", "layer"])

@flying-sheep flying-sheep Sep 8, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please remove the representation parameter, the code paths are the same for dense, sparse, …

def test_binary_logreg_scores_point_toward_requested_group(
categories, target, representation
):
from anndata import AnnData

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

already imported in the module

Comment on lines 604 to +606
scores = scores_all[0]
if cat_code == clf.classes_[0]:
scores = -scores

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
scores = scores_all[0]
if cat_code == clf.classes_[0]:
scores = -scores
scores = scores_all[0] if cat_code == clf.classes_[1] else -scores_all[0]

@dnncha

dnncha commented Sep 8, 2026

Copy link
Copy Markdown
Author

Thanks!

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