Skip to content

[fix] Exclude padding from the Plackett-Luce normalizer in ListMLE/PListMLE losses#3827

Merged
tomaarsen merged 3 commits into
huggingface:mainfrom
Incheonkirin:fix-listmle-padding-normalizer
Jun 17, 2026
Merged

[fix] Exclude padding from the Plackett-Luce normalizer in ListMLE/PListMLE losses#3827
tomaarsen merged 3 commits into
huggingface:mainfrom
Incheonkirin:fix-listmle-padding-normalizer

Conversation

@Incheonkirin

Copy link
Copy Markdown
Contributor

Summary

PListMLELoss (and ListMLELoss, which subclasses it) scatter a list's logits into a
(batch_size, max_docs) matrix and pad shorter lists with a logit of 1e-16. The Plackett-Luce
log-likelihood uses a reverse cumulative sum of scores = logits.exp() as the per-position
normalizer. Because exp(1e-16) ~= 1, every padded slot enters that normalizer with roughly unit
mass, inflating the denominator of every real document's log-probability. The padding mask is only
applied after the cumulative sum (log_probs[~mask] = 0.0), so it removes the padded positions'
own terms but not their pollution of the real positions' normalizers.

As a result, a query's loss and gradients depend on max_docs — i.e. on how many documents the
longest other query in the batch happens to have. For a single list (real labels [2, 1],
logits [1.5, 0.3]):

padding width max_docs loss grad
2 (no padding) 0.2633 [-0.2315, +0.2315]
3 (1 pad) 0.9759 [-0.3440, -0.2280]
4 (2 pads) 1.4671
6 (4 pads) 2.1627

The same list's loss grows with padding width and the second document's gradient flips sign, so a
variable-length-list batch (the common case for reranker training) trains on a corrupted signal.

This excludes padded entries from the normalizer by zeroing their scores before the cumulative sum,
using a mask in the same (sorted) order as the logits:

scores = sorted_logits.exp().masked_fill(~sorted_mask, 0.0)

This matches how reference ListMLE implementations handle padding (e.g. allRank sets padded
predictions to -inf before the exp, which is the same as zero mass in the normalizer). For a list
with no padding the change is a no-op, so existing single-length behavior is unchanged.

Tests

Added a deterministic padding-invariance regression test for ListMLELoss and PListMLELoss,
parametrized over respect_input_order (True/False): it batches a 2-document query with a
3-document query and asserts the batched loss equals the mean of the two per-query losses. It fails
on current main and passes with this change:

pytest tests/cross_encoder/losses/test_listwise_padding.py -q
# main:  4 failed
# fix:   4 passed

Incheonkirin and others added 3 commits June 17, 2026 18:41
…istMLE losses

Padded list positions are filled with a logit of 1e-16, so exp(1e-16) ~= 1 and they
enter the reverse-cumsum normalizer with ~unit mass. The mask was applied only after
the cumsum, so a query's loss and gradients depended on the batch's padding width
(loss inflates, and a document's gradient can flip sign). Zero the padded scores
before the cumsum using the sorted-order mask; no-op for single-length lists.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes an error in the Plackett–Luce normalizer used by PListMLELoss / ListMLELoss where padded entries contributed non-zero mass to the cumulative-sum denominator, making the loss/gradients depend on max_docs (i.e., other queries’ list lengths in the batch).

Changes:

  • Exclude padded positions from the Plackett–Luce normalizer by masking exp(logits) before the reverse cumulative sum.
  • Add a regression test asserting padding-invariance for both PListMLELoss and ListMLELoss, across respect_input_order=True/False.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
sentence_transformers/cross_encoder/losses/plist_mle.py Masks padded entries out of the Plackett–Luce normalizer to make listwise losses invariant to batch padding width.
tests/cross_encoder/losses/test_misc.py Adds a padding-invariance regression test for the affected listwise losses.
Comments suppressed due to low confidence (2)

tests/cross_encoder/losses/test_misc.py:68

  • This test compares losses across multiple forward passes; if the underlying HF model is in train mode, dropout will make the per-query and batched computations consume different random masks and can cause flaky failures unrelated to padding invariance. Put the model into eval mode (and optionally run under no_grad) for a deterministic regression test.
    tests/cross_encoder/losses/test_misc.py:62
  • For consistency with the other tests in this file (and to avoid line-length/lint issues), format the test signature across multiple lines and add a return type annotation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@tomaarsen

Copy link
Copy Markdown
Member

Hello!

Thanks for tracking this down and for the clean padding-invariance test! I wanted to see how much it actually moves the needle in real training, so I finetuned a few microsoft/MiniLM-L12-H384-uncased rerankers on MS MARCO v1.1 (1 epoch, identical seed/config), fix vs. no-fix, for both affected losses.

NanoBEIR R100 mean ndcg@10 (best checkpoint):

Loss no fix with this PR
ListMLE ~0.39 0.529
PListMLE 0.514 0.525

(Small note: The ListMLE 'no fix' was based on older ST/transformers versions, I didn't rerun that baseline)
The size of the effect lines up neatly with how much each loss leans on the padded (bottom) positions. It's strictly ≥ no-fix everywhere, and a genuine correctness win for ListMLE. Models + full training logs:

I also pushed two tiny follow-ups to your branch: trimmed the inline comment and folded the regression test into tests/cross_encoder/losses/test_misc.py (trying to avoid one-off test files). Merging when everything is green.

  • Tom Aarsen

@tomaarsen
tomaarsen enabled auto-merge (squash) June 17, 2026 13:47
@Incheonkirin

Incheonkirin commented Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Nice, bigger ListMLE bump than I'd have guessed! Thanks for benchmarking both and publishing the runs 😄 test_misc.py works for me

@tomaarsen
tomaarsen disabled auto-merge June 17, 2026 14:37
@tomaarsen
tomaarsen merged commit 7763e94 into huggingface:main Jun 17, 2026
17 of 18 checks passed
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.

3 participants