[GRPO] Fix entropy bonus normalization inconsistency across loss types - #6648
[GRPO] Fix entropy bonus normalization inconsistency across loss types#6648YaseenBashaT wants to merge 4 commits into
Conversation
The entropy bonus term used a different normalizer for cispo/dapo/vespo (a global token count) than every other loss type (mean over active tokens, scaled by gradient accumulation steps). Since the documented objective is L = L_policy - entropy_coef * H, the bonus should not depend on how a given loss type normalizes its policy term. Unify the computation to always take the mean entropy over the tokens the bonus acts on, scaled only by the gradient-accumulation factor. Extend test_entropy_bonus_scale to also parametrize over top_entropy_quantile, so the fix is verified both with and without entropy-based token gating.
There was a problem hiding this comment.
Pull request overview
Unifies GRPO entropy bonus normalization so the entropy regularizer is computed as a mean over the tokens it acts on (via effective_mask) and scaled only by gradient accumulation, removing the prior loss_type-specific normalization behavior for cispo/dapo/vespo.
Changes:
- Simplifies entropy bonus computation in
GRPOTrainer._compute_lossby removing theloss_typebranch and always normalizing byeffective_mask.sum(). - Updates
test_entropy_bonus_scaleto also exercise entropy masking by parametrizingtop_entropy_quantilewith1.0and0.2.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
trl/trainer/grpo_trainer.py |
Removes loss_type-dependent entropy bonus normalization and applies a single mean-over-effective_mask formula scaled by gradient accumulation. |
tests/test_grpo_trainer.py |
Extends the entropy scaling regression test to cover cases where top_entropy_quantile < 1.0 activates entropy masking. |
Suppressed comments (1)
tests/test_grpo_trainer.py:1845
- With
top_entropy_quantile < 1.0, the loss’s entropy regularizer is computed overeffective_mask(filtered tokens), but this test still divides by the loggedentropymetric, which in GRPOTrainer is computed withglobal_masked_mean(entropies)using the unfilteredmask. That makescontrib / entropy == entropy_coefgenerally false under entropy masking, so this parametrization is likely to fail or not test the intended invariant. Consider either logging an “effective entropy” metric matchingeffective_mask(and using that here), or adjusting the test to compute the same entropy quantity used in the loss.
@pytest.mark.parametrize("top_entropy_quantile", [1.0, 0.2])
@pytest.mark.parametrize("loss_type", ["grpo", "dr_grpo", "dapo", "luspo"])
def test_entropy_bonus_scale(self, loss_type, top_entropy_quantile):
# Regression test: the entropy bonus is the mean per-token entropy H for every loss type (documented
# objective L = L_policy - entropy_coef * H), so it must not inherit any loss-type-specific policy
# normalization. A previous "unified" formula divided H by a global token count for the
# cispo/dapo/vespo family, making the bonus ~1/sequence_length too small; conversely, scaling the
# bonus like the dr_grpo (fixed budget) or luspo (sequence-weighted) policy term would also be wrong.
# With gradient_accumulation_steps=1 the per-step entropy contribution to the loss is
# contrib = policy_loss - loss = entropy_coef * entropy_loss, so contrib / entropy must equal
# entropy_coef for all loss types.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
world_entropy (used by the adaptive controller) is a true window-global token-weighted mean: total entropy sum divided by total token count across the whole accumulation window. entropy_loss instead accumulates as an average of per-micro-batch means. These only match exactly when every micro-batch in the window has the same number of active tokens, which isn't guaranteed once completion lengths vary or top_entropy_quantile filters tokens. The previous comment claimed the two values match; this corrects the wording to note they can differ, without changing any behavior. Per Copilot's review comment on this PR. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update. |
|
I investigated this against #6140, where the current two-branch split comes from. Conclusion: the bug is real and the fix is the right direction — and it does not contradict #6140. Both rest on the same principle (the bonus is What the branch was actually doing. It was not applying policy normalization. It existed because Where it breaks. In #6140, What settles the direction is #6140's own documentation and controller, not the general principle:
The buggy branch therefore desyncs the optimized quantity from the gated one — the same failure mode #6140 rejected the Dr. GRPO rescaling for. This PR restores that invariant. Verified locally. Pre-fix, only Three things before merging:
|
… fixed entropy_coef Comparing contrib/entropy to entropy_coef is only exact when effective_mask == mask (top_entropy_quantile == 1.0). Under filtering, the logged entropy metric is H_full while the bonus uses H_eff, so the old assertion only held because this tiny model's entropy happens to be ~uniform across tokens. Assert what the fix actually restores instead: every loss type's ratio agrees with every other's, at the same quantile. Per @albertvillanova's review on huggingface#6648.
|
Thanks for going this deep on it, seriously appreciate the effort here. You're right on the 4.8x vs 10x, that was just sloppy math on my part in the original writeup, fixed it. Also agree on the test, comparing against a fixed entropy_coef only worked because this tiny model's entropy is basically flat across every token, so it was passing for the wrong reason. Switched it to check that every loss type lands on the same ratio as grpo instead, which is the thing that's actually supposed to hold. Added a note in the description calling out the cispo/dapo/vespo averaging change on purpose so it doesn't look like a random regression later, and flagged that this should merge after #6654 rather than just rebasing on it. And yeah, good catch that Copilot's suppressed comment was actually right, I'd glossed over that one. |
|
#6654 is merged ( The new One thing left before I approve: please merge git fetch upstream main
git merge upstream/main
git pushI trial-merged it locally and it applies cleanly — no conflicts, despite both PRs touching the area right around This matters beyond staying current: your new Once it's merged and CI is green I'll approve. |
|
Done, merged main in (not rebased) and pushed. Ran test_entropy_bonus_scale and test_luspo_loss_ignores_padding on the merge myself too, all 4 pass, matches what you found on your trial merge. |
Description
Current behavior:
cispo/dapo/vespodivide the entropy bonus by aglobal token count borrowed from the policy-loss normalizer, while every
other loss type divides by the tokens that actually contributed
(
effective_mask.sum()). These only agree when nothing gets filtered out.Once
top_entropy_quantile < 1.0drops most tokens, the global countdoesn't shrink with it, so the bonus gets silently crushed for those three
loss types — measured ~4.8x smaller at
top_entropy_quantile=0.2in oneworked example (ratio ρ ≈ top_entropy_quantile ≈ 0.208; a 10x crush would
need
top_entropy_quantile≈0.1, not 0.2 — correcting the earlier estimatein this description).
This also contradicts what the docs already promise
(
docs/source/grpo_trainer.md, "Entropy regularization" section):Changes: Drop the per-
loss_typebranch. The entropy term now alwaysdivides by
effective_mask.sum(), then by the gradient-accumulationfactor. One formula, and it now matches what the docs already said.
Accepted regression (intentional):
cispo/dapo/vespopreviouslygot an exact token-weighted global average of entropy across the whole
gradient-accumulation window, because the old denominator summed real
token counts across every accumulated micro-batch. After this change they
get an unweighted mean of per-micro-batch, per-rank means instead — the
same aggregation every other loss type already used. This is deliberate:
uniform treatment across loss types is what the docs promise, and the two
aggregations only diverge meaningfully when token counts vary a lot across
micro-batches/ranks.
Merge note: please merge this after #6654, not just rebase on top of
it — the entropy mask is one of several things that broadcast
per_token_lossto(B, T)forluspo(see #6654), and this PR's newluspo@top_entropy_quantile=0.2case depends on that fix being in tobe meaningful.
Test plan
Before
test_entropy_bonus_scaleonly ran withtop_entropy_quantile=1.0— theone setting where the bug can't show up, since nothing gets filtered.
After
Added
top_entropy_quantile=0.2to the same test's parametrization, acrossall 4 tested
loss_types. Also changed the assertion itself: comparingcontrib / entropyto a fixedentropy_coefis only exact attop_entropy_quantile=1.0(whereeffective_mask == mask, so H_eff ==H_full). Under filtering it's
entropy_coef * H_eff / H_full, and thelogged
entropymetric is H_full (global_masked_meanover the fullcompletion mask), not H_eff — so asserting the ratio equals
entropy_coefat
top_entropy_quantile=0.2was fixture-lucky: it only held because thistiny, near-untrained model's entropy is ~uniform across tokens
(H_eff/H_full ≈ 1.0), which isn't true in general. The test now asserts
the actual invariant instead: every loss type's ratio must agree with
every other's, at the same quantile.
Verified in both directions: reverted just the trainer hunk (restoring the
old per-
loss_typebranching) and reran.top_entropy_quantile=0.2failsas expected (
1 failed, 1 passed) — that's the case that actuallyexercises the bug.
top_entropy_quantile=1.0still passes even on thereverted code, which is correct and not a gap in the test: at quantile
1.0 nothing is filtered, so the old and new formulas coincide by
construction, matching the "Before" note above that this is the one
setting where the bug can't show up. Restored the fix afterward and
confirmed both cases pass (
2 passed). Full suite (145 passed, 41skipped, 0 failed) was run before this test was rewritten to its current
parametrization; the rewrite collapsed what used to be 8 separate
parametrized cases into 2 (each now looping over all 4 loss types
internally), so that count is no longer the right one to cite.
test_entropy_bonus_scaleitself: 2 passed, 0 failed, directly re-runafter the rewrite.
Before submitting
AI writing disclosure
Who can review?
Anyone in the community is free to review the PR once the tests have passed.
y
Note
Medium Risk
Changes training loss math for cispo/dapo/vespo (especially with top_entropy_quantile < 1), which can shift exploration strength in production GRPO runs.
Overview
Unifies GRPO entropy regularization so
cispo/dapo/vespono longer scale the bonus with the policy loss’s global token normalizer. The entropy term is always the mean overeffective_masktokens (includingtop_entropy_quantilefiltering), divided only by the gradient-accumulation factor—matching documented behavior thatentropy_coefmeans the same for everyloss_type.Regression test updates:
test_entropy_bonus_scalenow runs attop_entropy_quantile1.0 and 0.2, trains each ofgrpo/dr_grpo/dapo/luspoin one loop, and checks that(policy_loss - loss) / entropyis consistent across loss types rather than equalingentropy_coef(which only lines up with logged entropy when no quantile filtering).Reviewed by Cursor Bugbot for commit 3643cdd. Bugbot is set up for automated code reviews on this repo. Configure here.