Fix loss alignment and Trainer token counting for encoder decoder models#46903
Conversation
c07b1f4 to
b3fe54f
Compare
| loss = self.loss_function( | ||
| logits=logits, labels=labels, vocab_size=self.config.text_config.vocab_size, **kwargs | ||
| ) |
There was a problem hiding this comment.
we can pass shift_labels=labels into loss-fn to not sift it twice
There was a problem hiding this comment.
Updated — kept self.loss_function and passed shift_labels=labels, so this avoids the second shift while preserving the existing loss path / num_items_in_batch handling.
| def test_training_loss_no_double_shift(self): | ||
| # forward right-shifts input_ids into decoder_input_ids, so the loss must be a plain CE | ||
| # against labels (no second shift). Regression test for the ForCausalLMLoss double-shift. | ||
| from torch.nn import CrossEntropyLoss |
There was a problem hiding this comment.
yeah, we defi need a test. Can you make it general for all models when config.is_encoder_decoder=True, and move under test_modeling_common.py
We'd want the test to run on all existing and upcoming models, this is an "easy to miss" bug so should be tested
There was a problem hiding this comment.
I prototyped the common test locally in test_modeling_common.py behind config.is_encoder_decoder. It passes Bart/MBart/LED/Pegasus and catches the same issue in Florence2, but Florence2 is already tracked in #46898 / #46897.
The current common harness also skips PPFormulaNet because its shared test inputs use decoder labels of length 1, so removing the PPFormulaNet-specific test would lose coverage. A robust common test needs non-degenerate labels and decoder length >= 2, and would be red on Florence2 until #46898 lands.
Would you prefer I keep this PR scoped to the PPFormulaNet fix + regression test and add the common test as a follow-up, or expand this PR to include the common-test design and handle the failures it exposes?
There was a problem hiding this comment.
I will merge the florence today, no problem on that one, Can you push the common test and fix any other models, except florence. Lets fix it all at once that many tiny PRs
There was a problem hiding this comment.
Done — pushed the common test (test_encoder_decoder_loss_no_double_shift in test_modeling_common.py) and fixed CohereASR, the only other affected model besides PPFormulaNet/Florence2. Full summary is in the top-level PR comment.
| def test_training_loss_no_double_shift(self): | ||
| # forward right-shifts input_ids into decoder_input_ids, so the loss must be a plain CE | ||
| # against labels (no second shift). Regression test for the ForCausalLMLoss double-shift. | ||
| from torch.nn import CrossEntropyLoss |
There was a problem hiding this comment.
import at the begonning of file
There was a problem hiding this comment.
Done — removed the local CrossEntropyLoss import after switching the model fix back to self.loss_function.
|
Updated the fix to use Tests
|
fa9249d to
7e42355
Compare
|
Done — pushed the generic common test and the other affected model requested here. Common test. Added Other affected models. A static scan for the pattern (right-shift into Validation (local):
|
| try: | ||
| config, inputs_dict = self.model_tester.prepare_config_and_inputs_for_common() | ||
| except ImportError as error: | ||
| self.skipTest(reason=f"Could not prepare inputs: {str(error).strip()}") |
There was a problem hiding this comment.
why try/except, we should be fine building config once outside the loop
There was a problem hiding this comment.
Removed both try/except blocks. A class based applicability check runs first. Then prepare_config_and_inputs_for_common() runs once outside the model class loop, and the config is copied for each model class. Preparation and forward errors now fail the test normally.
| input_tensor = next( | ||
| (value for value in inputs.values() if isinstance(value, torch.Tensor) and value.ndim > 0), None | ||
| ) | ||
| if input_tensor is None: | ||
| continue |
There was a problem hiding this comment.
doesn't really look good, I suppose you wanted to infer batch size. We can try self.model_tester.batch_size
There was a problem hiding this comment.
Done. Labels now use self.model_tester.batch_size directly. The tensor based batch size inference was removed.
| if vocab_size is None or vocab_size < 4: | ||
| continue |
There was a problem hiding this comment.
we can delete this block, testers with small vocab size shouldn't exit and if they do, they'll have to use bigger vocab
There was a problem hiding this comment.
Done. I removed the vocabulary size skip, so small vocabulary testers no longer exit early.
| batch_size, seq_len = labels.shape[0], max(2, labels.shape[-1]) | ||
| set_seed(42) | ||
| labels = torch.randint(3, vocab_size, (batch_size, seq_len), device=torch_device) | ||
| inputs["labels"] = labels |
There was a problem hiding this comment.
also weird, why do we prepare labels and then pop to prepare again, doesn't prepare_for_class already give correct labels since Seq2Seq is a common class
There was a problem hiding this comment.
I changed this to return_labels=False, so labels are no longer prepared and then replaced. Fresh labels are created once with a sequence length of at least 2, which keeps the aligned and shifted loss comparison meaningful. The tester provided decoder_input_ids and decoder_attention_mask are removed so the model exercises its target to decoder input shift path. FSMT and PPFormulaNet use their explicit model specific paths.
| try: | ||
| with torch.no_grad(): | ||
| output = model(**inputs) | ||
| except (TypeError, RuntimeError, ValueError, IndexError): | ||
| # model needs inputs this generic path does not supply -> not applicable | ||
| continue |
There was a problem hiding this comment.
same, not try/except , it hides any errors
There was a problem hiding this comment.
Removed. There is no try/except around the forward call anymore. Only explicit applicability skips remain, so unexpected model errors fail the test.
|
@OmkumarSolanki hey, any progress here or wouold you like me to wrap it up, would be great to merge this sson |
|
Sorry, I completely missed these - thanks for the reminder. Will try to get to the review comments today. |
7e42355 to
b486454
Compare
|
Thanks for the thorough review, @zucchini-nlp. I reworked the PR and force pushed it as one commit rebased onto the current main branch. The main changes are:
Local validation results:
I replied to the inline comments with the specific changes. Please let me know if you would like anything else adjusted. |
zucchini-nlp
left a comment
There was a problem hiding this comment.
Nice! Can you explain why we have similar tests with tiny differences per model?
| # `self.loss_function` so grad-accumulation `num_items_in_batch` normalization is honored. | ||
| loss = self.loss_function( | ||
| logits=logits, | ||
| labels=labels, |
There was a problem hiding this comment.
nit: is labels are already shifted (i.e. shift_labels), lets explicitly pass labels=None and shift_labels=labels
There was a problem hiding this comment.
Done, in Moonshine and PPFormulaNet. Both now pass labels=None with the aligned targets as shift_labels.
One thing to note on the exact form. The value has to come out of kwargs first. If a caller passes its own shift_labels and the model also passes one by keyword, the call raises TypeError: transformers.loss.loss_utils.ForCausalLMLoss() got multiple values for keyword argument 'shift_labels'. In the previous revision of this PR the pop also sat after the inner model call, so a caller supplied value leaked down into it. Both are handled by popping at the top of forward.
Both modulars updated and the three modeling files regenerated.
| self.assertTrue(torch.allclose(out.loss, aligned_ce(out.logits, lbl))) | ||
| self.assertFalse(torch.allclose(out.loss, double_shift_ce(out.logits, lbl))) | ||
|
|
||
| def test_num_items_in_batch_scales_training_loss(self): |
There was a problem hiding this comment.
not following here, are we adding a new test for only Moonshine? can you explain why only this model?
There was a problem hiding this comment.
Folded into the existing test_training_loss_no_double_shift instead of a second test. It now runs over the plain and the padded labels.
It is Moonshine only because Moonshine is the one model here whose alignment was already correct on current main, after #46784. It computed the loss with a plain nn.CrossEntropyLoss, which ignores num_items_in_batch, so it returned the same value for any count. The generic alignment test cannot see that, and the Trainer fix does not help either: the Trainer passes a count, but the count has no effect while the model computes a plain mean. Everything else in this PR was already on self.loss_function.
| test_resize_embeddings = False | ||
| is_encoder_decoder = True | ||
|
|
||
| def test_trainer_counts_all_labels_and_aligned_loss(self): |
There was a problem hiding this comment.
same q here, this one tests num_items_in_batch with trainer. Why we test differently each model?
There was a problem hiding this comment.
Removed, with the unused Trainer, TrainingArguments and tempfile imports. That file matches main again.
You are right that it was a third version of the same check. Trainer counting is not specific to PPFormulaNet and is covered once on Bart in test_num_items_in_batch_encoder_decoder. Alignment is covered by the common test.
One assertion in it was not a duplicate, so I moved it into the Moonshine loss test rather than lose it: that a caller supplied shift_labels is used as given, which is the reason for the pop above.
| if config.model_type == "dia": | ||
| # dia routes through ForMaskedLMLoss and flattens its codebook/channel streams into the batch | ||
| # dimension, so its `(batch * num_channels, seq, vocab)` logits never align with 2D `labels` | ||
| self.skipTest(reason="dia flattens codebook channels into the batch dim; logits do not match 2D `labels`") | ||
| if config.model_type == "prophetnet": | ||
| # prophetnet computes NLL over `ngram` future-token prediction streams (with label smoothing), | ||
| # not a single cross-entropy over `output.logits` | ||
| self.skipTest(reason="prophetnet's loss is NLL over n-gram streams, not one cross-entropy over the logits") |
There was a problem hiding this comment.
can you skip directly in DIa/ProphetNet testers with @unittest.skip
There was a problem hiding this comment.
Done. Both are @unittest.skip overrides of the test in DiaModelTest and ProphetNetModelTest, and both model_type checks are out of the common test.
| if config.model_type == "fsmt": | ||
| # fsmt derives `decoder_input_ids` from `input_ids` (the encoder source), not `labels`; | ||
| # supply aligned decoder inputs built from `labels` so the loss is a per-token CE against them. | ||
| inputs["decoder_input_ids"] = model.prepare_decoder_input_ids_from_labels(labels) |
There was a problem hiding this comment.
not really following here as well, can you explain? TBH not a fan of adding too many conditions in a test for each model type
There was a problem hiding this comment.
All of them are gone. There is no model_type branch left in the test.
They came from letting each model build its own decoder_input_ids, which meant the test had to know how each one derives them, plus an initializer_factor reset because the T5 testers make the logits numerically uniform and the aligned and shifted loss values stop being distinguishable.
The test now builds the right shifted decoder inputs itself and passes them in, so every model runs the same path. The first token is the model's top level decoder_start_token_id when that is set and inside the vocabulary, and 0 otherwise, which several testers need since they leave it unset. That token changes the logits, but it cannot change which cross entropy the reported loss matches, so the choice is not load bearing. The range check is defensive: it keeps an id past the tester vocabulary from failing in an embedding lookup before the assertions run.
The oracle compares gradients rather than loss values. It differentiates the reported loss, the aligned cross entropy and the shifted cross entropy with respect to output.logits and asserts the reported one matches the aligned gradient and not the shifted one. Gradients keep the target positions, so they stay decisive where the loss values collapse. On the T5 tester config the logits have std 4.2e-6, both losses come out at 4.595120 and differ by 4.8e-7 so torch.allclose cannot separate them, while the gradients differ by 1.3e-2. That is what removed the initializer_factor handling.
What the test gives up is checking how each model builds its decoder inputs. It now only checks that the loss does not shift targets that are already aligned, which is the bug it is guarding.
Mutation checked both ways. Reverting the PPFormulaNet fix fails the test, and injecting a double shift into T5ForConditionalGeneration fails it too, which the old value based oracle could only do with the initializer_factor override in place. The sweep is still 38 passed, 568 skipped.
Encoder-decoder LM heads right-shift their targets into decoder_input_ids, so their logits are already position-aligned with `labels`. Two related bugs mishandle that alignment: - PPFormulaNet (and, pre-huggingface#46784, Moonshine) routed `labels` through ForCausalLMLoss without `shift_labels`, so the loss shifted a second time and the model trained against labels[..., 1:]. - Trainer._loss_shifts_labels counted num_items_in_batch over labels[..., 1:] for every ForCausalLMLoss head. Encoder-decoder heads have a real target at every non-`-100` position, so this under-counts targets (one position per sequence) and over-scales the gradient-accumulation loss. Changes: - trainer.py: _loss_shifts_labels additionally requires config.is_encoder_decoder to be false, so encoder-decoder heads count every valid label. Decoder-only heads are unchanged; Csm (decoder-only) is unaffected. - pp_formulanet: compute the loss via self.loss_function with labels=None and the aligned targets passed as shift_labels. shift_labels is popped from kwargs before the inner model call, so a caller-supplied value is honored, does not collide with the derived one, and does not leak into the inner model. - moonshine / moonshine_streaming: same form, replacing a plain CrossEntropyLoss that ignored num_items_in_batch. Tests: - tests/test_modeling_common.py: generic guard test_encoder_decoder_loss_no_double_shift. It supplies the right-shifted decoder_input_ids itself and compares gradients of the reported loss w.r.t. output.logits against the aligned and the shifted cross-entropy, which stays decisive on testers whose initialization makes the logits near-uniform. No per-model branches. - Dia and ProphetNet skip it via @unittest.skip in their own testers. - tests/trainer/test_trainer.py: Bart encoder-decoder num_items_in_batch count. - The Moonshine loss test also covers num_items_in_batch scaling and a caller-supplied shift_labels. Fixes huggingface#46901
b486454 to
e964019
Compare
|
[For maintainers] Suggested jobs to run (before merge) run-slow: dia, moonshine, moonshine_streaming, pp_formulanet, prophetnet |
|
Thanks for the review, and for the patience across the three rounds. Pushed the cleanup.
The common guard now uses one model independent setup and oracle, with no model specific branches. Local results, rebased on rg --files tests/models -g 'test_modeling*.py' -0 | \
xargs -0 env PYTHONPATH=src python -m pytest \
-k test_encoder_decoder_loss_no_double_shift -q
# 38 passed, 568 skipped, 38 subtests passed
python -m pytest tests/trainer/test_trainer.py::TrainerGradientAccumulationTest
# 8 passed, 1 skipped (the skip is require_torch_multi_accelerator)
python -m pytest tests/models/pp_formulanet/test_modeling_pp_formulanet.py
# 113 passed, 147 skipped, 686 subtests passed
python -m pytest tests/models/moonshine/test_modeling_moonshine.py \
tests/models/moonshine_streaming/test_modeling_moonshine_streaming.py
# 195 passed, 237 skipped, 2672 subtests passed
python utils/check_modular_conversion.py --files \
src/transformers/models/moonshine/modular_moonshine.py \
src/transformers/models/moonshine_streaming/modular_moonshine_streaming.py \
src/transformers/models/pp_formulanet/modular_pp_formulanet.py
# clean
make typing # 2 checks passed
make style # 4 checks passed
git diff --check # cleanMutation checked three ways: reverting the PPFormulaNet fix fails the common test; injecting a double shift into |
CI recapDashboard: View test results in Grafana |
zucchini-nlp
left a comment
There was a problem hiding this comment.
Thanks for iterating and lets merge now
|
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. |
What does this PR do?
Encoder decoder models build
decoder_input_idsby right shifting the target withshift_tokens_right, so the logits already line up withlabels. Two related bugs get thisalignment wrong.
Double shifted training loss. When such a model computes its loss through
self.loss_function(which uses
ForCausalLMLoss) without passingshift_labels, the labels are shifted a secondtime to
labels[..., 1:]. The model then trains againstlabels[..., 1:]instead oflabels,which is an off by one. PPFormulaNet had this bug, and Moonshine had it before Fix Moonshine training-loss double-shift (train against labels, not labels[..., 1:]) #46784.
Trainer token undercount.
Trainer._loss_shifts_labelsassumed that anyForCausalLMLossheadcounts
num_items_in_batchoverlabels[..., 1:]. For encoder decoder heads every label that isnot
-100is a real target, so this counts one target position too few per sequence and scalesthe gradient accumulation loss up. For example, if 8 targets are counted as 6, the loss is
multiplied by about 8/6, which is roughly 1.33x.
Changes
trainer.py._loss_shifts_labelsnow also requiresconfig.is_encoder_decoderto be false.Decoder only heads keep the old behavior, and encoder decoder heads count every valid label. This
corrects the
num_items_in_batchnormalization for PPFormulaNet and, through the Moonshine changebelow, for Moonshine and MoonshineStreaming. It also corrects Florence2 and CohereASR, whose model
fixes already landed upstream, so those files do not need any further change here.
pp_formulanet. The loss now goes throughself.loss_functionwithlabels=Noneand the alignedtargets passed as
shift_labels.shift_labelsis popped fromkwargsbefore the inner model call,so a caller supplied value is honoured, does not collide with the derived one, and does not leak into
the inner model. The edit is in
modular_pp_formulanet.pyand themodelingfile is regenerated.moonshineandmoonshine_streaming. These now use the same form instead of a plainCrossEntropyLossthat ignorednum_items_in_batch. Bothmodelingfiles are regenerated from themodulars and the unused import is removed.
Only the training loss changes, and only when
labelsare passed. Logits andgenerate()areunchanged, and
-100stays ignored.This is the existing coordinated PR for this work. The wider scope was approved by @zucchini-nlp in
this comment: #46903 (comment). No
duplicate PR is being opened.
Fixes #46901.
Test
A new generic test
test_encoder_decoder_loss_no_double_shiftintests/test_modeling_common.pycovers
ModelTesterMixinsequence to sequence language model heads whoseforwardowns the loss. Itfeeds fresh labels of length at least 2, supplies
decoder_input_idsbuilt by right shifting thoselabels, and asserts that the gradient of the reported loss with respect to
output.logitsmatches thegradient of a cross entropy against
labelsand not againstlabels[..., 1:]. The gradient comparisonstays decisive on testers whose initialization makes the logits near uniform. There are no per model
branches in it. Dia and ProphetNet skip it in their own testers, since Dia flattens codebook channels
into the batch dimension and ProphetNet computes loss over n gram prediction streams. Trainer counting
is covered by a Bart encoder decoder test in
tests/trainer/test_trainer.py, and Moonshine's move ontothe shared loss is covered in its own loss test. The existing
test_num_items_in_batch_causal_lmandtest_num_items_in_batch_non_causal_lmstill pass.Commands run locally, rebased on
origin/mainbb3ffb9:Note
AI assistance was used while preparing this change; I have reviewed every line and run the tests.