Skip to content

Validate reward model tokenizer matches policy in XPOTrainer/NashMDTrainer - #6955

Open
amanyagami wants to merge 3 commits into
huggingface:mainfrom
amanyagami:fix/xpo-nashmd-reward-tokenizer-mismatch
Open

Validate reward model tokenizer matches policy in XPOTrainer/NashMDTrainer#6955
amanyagami wants to merge 3 commits into
huggingface:mainfrom
amanyagami:fix/xpo-nashmd-reward-tokenizer-mismatch

Conversation

@amanyagami

@amanyagami amanyagami commented Aug 28, 2026

Copy link
Copy Markdown

What does this PR do?

XPOTrainer and NashMDTrainer (both subclasses of experimental.online_dpo.OnlineDPOTrainer) score completions
with the get_reward() helper, which runs the reward model directly on the policy-tokenized input_ids:

# xpo_trainer.py / nash_md_trainer.py
_, model_scores, _ = get_reward(
    self.reward_funcs, model_data["input_ids"], self.processing_class.pad_token_id, context_length
)

Unlike OnlineDPOTrainer._calculate_rewards_from_functions, which decodes completions with the policy's tokenizer
and re-tokenizes them per reward function with reward_processing_class before scoring, XPOTrainer and
NashMDTrainer never decode/re-tokenize: they feed the policy's token IDs straight into the reward model's forward
pass. That's only correct if the reward model shares the exact same vocabulary (token -> ID mapping) as the policy
model — otherwise the reward model scores the wrong tokens, which either silently produces meaningless rewards or
crashes deep inside an embedding lookup with an unhelpful IndexError.

On top of that:

  • XPOTrainer accepts and documents reward_processing_classes, but never actually uses it anywhere in
    _compute_rewards — passing a reward tokenizer that differs from the policy's just wasn't a way to avoid the
    bug, and there was no signal to the user that it silently does nothing.
  • NashMDTrainer doesn't accept reward_processing_classes at all, and hardcodes
    reward_processing_classes=processing_class in its call to super().__init__(), so it always feeds the
    policy's tokenizer to the (potentially different) reward model, with no way to override it and no validation that
    this is actually safe.

Why this fix (see issue for the 3 options considered)

The issue lists three options: (1) implement full decode/re-tokenize per reward model like OnlineDPOTrainer does,
(2) add validation + document the shared-vocab constraint, or (3) something else. I went with (2):

_compute_rewards in both trainers uses get_reward(), a single-model helper built around raw query_responses
token IDs (used for computing sequence lengths, building the attention mask from pad_token_id, etc.) — not
OnlineDPOTrainer's per-reward-function decode/apply-chat-template/re-tokenize path. Rewriting _compute_rewards
to decode and re-tokenize would touch the padding/masking/EOS-detection logic in both trainers and is a much larger,
higher-risk diff for two experimental trainers I can't validate on GPU. Since the issue explicitly allows the
safer, minimal option when a full rewrite is too large, I added a validation check + documentation instead of
reimplementing the reward pipeline.

Changes

  • Added validate_reward_processing_class_shares_vocab() to trl/experimental/utils.py: given a reward function,
    its processing class, and the policy's processing class, it's a no-op for non-model reward functions and raises a
    clear ValueError if a model-based reward function's tokenizer vocabulary doesn't match the policy's.
  • XPOTrainer.__init__: call the new validation after resolving self.reward_funcs to a single model. Documented
    the shared-vocab constraint in the reward_processing_classes docstring entry.
  • NashMDTrainer.__init__: added the missing reward_processing_class argument (singular, since NashMD only
    supports one reward model) so callers can now point it at the reward model's own tokenizer. When not provided, it
    still defaults to processing_class, preserving the existing default behavior exactly. Also call the new
    validation, and documented the constraint in the docstring.

Why this doesn't break existing behavior

For both trainers, when reward_processing_class(es) isn't explicitly overridden with something incompatible, the
validation either compares a tokenizer to itself (NashMDTrainer's unchanged default) or compares the
auto-loaded reward-model tokenizer to the policy tokenizer, which only raises when they were already silently
mismatched (previously broken, just with a worse failure mode later). A genuinely matching setup is unaffected.

Tests

Added regression tests in tests/experimental/test_xpo_trainer.py and tests/experimental/test_nash_md_trainer.py:

  • test_reward_processing_class_vocab_mismatch_raises (both trainers): passing a reward tokenizer with a different
    vocabulary (a tiny GPT-2 tokenizer against a Qwen2 policy) raises ValueError mentioning "vocabulary".
  • test_reward_processing_class_matching_vocab_is_accepted (XPO) / test_reward_processing_class_can_be_passed_explicitly
    (NashMD): explicitly passing a matching processing class is accepted and stored.
  • test_reward_processing_class_defaults_to_processing_class (NashMD): omitting reward_processing_class still
    defaults to processing_class, confirming the previous default behavior is preserved.

I confirmed all 5 new tests fail on main (2 with TypeError: unexpected keyword argument for NashMD, since the
parameter didn't exist; the 2 mismatch tests fail because nothing validates the mismatch) and pass with the fix.

Test output (after fix)

Full suite for both trainers (includes the existing training/PEFT tests, which run actual tiny-model training steps
on CPU, in addition to the 5 new reward-tokenizer tests):

$ python -m pytest tests/experimental/test_xpo_trainer.py tests/experimental/test_nash_md_trainer.py -q
..........................                                               [100%]
26 passed, 1 warning in 334.71s (0:05:34)

Just the new reward-tokenizer tests:

$ python -m pytest tests/experimental/test_xpo_trainer.py tests/experimental/test_nash_md_trainer.py -k "reward_processing_class" -v
tests/experimental/test_xpo_trainer.py::TestXPOTrainer::test_reward_processing_class_vocab_mismatch_raises PASSED
tests/experimental/test_xpo_trainer.py::TestXPOTrainer::test_reward_processing_class_matching_vocab_is_accepted PASSED
tests/experimental/test_nash_md_trainer.py::TestNashMDTrainer::test_reward_processing_class_vocab_mismatch_raises PASSED
tests/experimental/test_nash_md_trainer.py::TestNashMDTrainer::test_reward_processing_class_can_be_passed_explicitly PASSED
tests/experimental/test_nash_md_trainer.py::TestNashMDTrainer::test_reward_processing_class_defaults_to_processing_class PASSED
5 passed in 16.22s

I confirmed these 5 fail on main (git stash the source changes, keep the tests): 2 with TypeError: unexpected keyword argument 'reward_processing_class' for NashMDTrainer (the parameter didn't exist), and the 2 mismatch
tests with no exception raised at all (nothing validated the mismatch).

pre-commit run --files trl/experimental/utils.py trl/experimental/xpo/xpo_trainer.py trl/experimental/nash_md/nash_md_trainer.py tests/experimental/test_xpo_trainer.py tests/experimental/test_nash_md_trainer.py
passes (ruff check, ruff format, doc-builder style check all green).

Fixes #6951

Before submitting

  • This PR fixes a typo or improves the docs (you can dismiss the other checks if that's the case).
  • Did you read the contributor guideline, Pull Request section?
  • Was this discussed/approved via a GitHub issue? Please add a link to it if that's the case.
  • Did you make sure to update the documentation with your changes?
  • Did you write any new necessary tests?

AI writing disclosure

We welcome the use of AI tools to help with contributions. For transparency and to help us improve our review process, please indicate the level of AI involvement in this PR.

  • No AI usage: the PR was written entirely by a human.
  • AI-assisted: some parts were suggested or improved by AI, but the PR was written and reviewed by a human.
  • AI-generated: the PR was mostly or fully generated by an AI tool.

Who can review?

Anyone in the community is free to review the PR once the tests have passed. Feel free to tag members/contributors who may be interested in your PR.


Note

Low Risk
Changes are limited to experimental trainers: init-time validation and docs, with no change to reward scoring logic beyond rejecting already-invalid tokenizer pairings.

Overview
Fixes #6951 by failing fast when XPOTrainer and NashMDTrainer would score completions with policy token IDs on a reward model whose vocabulary does not match the policy tokenizer (unlike OnlineDPOTrainer, these trainers use get_reward() on raw input_ids without decode/re-tokenize).

Adds validate_reward_processing_class_shares_vocab() in trl/experimental/utils.py: for model-based reward functions it compares get_vocab() on policy and reward sides, unwrapping ProcessorMixin to .tokenizer on both sides. XPOTrainer and NashMDTrainer call it after super().__init__(), using accelerator.unwrap_model() on the reward model so the check still runs when the reward model is wrapped by DDP/DeepSpeed.

NashMDTrainer gains optional reward_processing_class (default remains processing_class via reward_processing_class or processing_class). Docstrings document the shared-vocabulary requirement for reward_processing_classes / reward_processing_class.

Regression tests cover vocab mismatch ValueError, matching vocab acceptance, NashMD defaults, DDP-wrapped reward models (patched Accelerator.prepare_model), and ProcessorMixin paths in test_utils.py.

Reviewed by Cursor Bugbot for commit 786e110. Bugbot is set up for automated code reviews on this repo. Configure here.

…ainer

XPOTrainer and NashMDTrainer score completions via get_reward(), which
runs the reward model directly on the *policy*-tokenized input_ids,
unlike OnlineDPOTrainer, which decodes and re-tokenizes completions
per reward function. This is only correct if the reward model shares
the exact same vocabulary as the policy model; otherwise the reward
model scores the wrong tokens, silently producing meaningless rewards
or crashing with a cryptic embedding out-of-range error.

XPOTrainer already accepted and documented reward_processing_classes,
but never used it anywhere, with no indication that it did nothing.
NashMDTrainer didn't accept it at all and hardcoded
reward_processing_classes=processing_class in its call to
OnlineDPOTrainer.__init__, forcing the policy tokenizer onto every
reward model with no way to override it and no validation.

Implementing full decode/re-tokenize like OnlineDPOTrainer would
require reworking the padding/masking/EOS-detection logic that
get_reward() shares between both trainers -- a much larger, harder to
validate change for two experimental trainers. Instead, add
validate_reward_processing_class_shares_vocab() (trl/experimental/utils.py)
and call it from both trainers' __init__ after the reward model is
resolved, raising an informative ValueError on a vocabulary mismatch
instead of letting it fail later. Also add the missing
reward_processing_class argument to NashMDTrainer (defaulting to
processing_class, preserving its previous behavior when omitted), and
document the shared-vocab constraint in both docstrings.

Fixes huggingface#6951

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 84ddbd8. Configure here.

Comment thread trl/experimental/utils.py
Comment thread trl/experimental/utils.py Outdated
… unwrap ProcessorMixin symmetrically

Two real bugs in the initial fix, both caught by automated review on PR huggingface#6955:

1. self.reward_funcs can already be wrapped (DeepSpeed engine / DDP) by
   Accelerator.prepare_model inside super().__init__() by the time the vocab
   check runs in XPOTrainer/NashMDTrainer's own __init__. A wrapper is not a
   PreTrainedModel, so validate_reward_processing_class_shares_vocab's guard
   silently skipped the check under exactly the training setups (DeepSpeed,
   multi-process DDP) where catching a mismatch matters most. Fixed by
   unwrapping with self.accelerator.unwrap_model() before the check, at both
   call sites. Regression tests reproduce this with a real single-process
   DistributedDataParallel wrapping (not a hand-rolled stand-in, since
   accelerate's extract_model_from_parallel only recognizes real wrapper
   types) -- confirmed to fail without the unwrap and pass with it.

2. The vocab check unwrapped a ProcessorMixin on the policy side but not the
   reward side, so NashMDTrainer defaulting reward_processing_class to a
   ProcessorMixin processing_class (VLM policies) raised AttributeError from
   get_vocab() instead of comparing vocabularies -- even for the same
   processor object on both sides. Fixed by unwrapping reward_processing_class
   the same way. Regression tests cover both the matching-vocab (must not
   raise) and mismatched-vocab (must still raise ValueError, not
   AttributeError) cases directly against the helper.

Fixes the two issues raised by Cursor Bugbot on 84ddbd8.
@amanyagami

Copy link
Copy Markdown
Author

Both issues flagged by Cursor Bugbot are fixed in fbfba76:

  1. Validation skipped after model wrappingself.reward_funcs can already be wrapped (DeepSpeed engine / DDP) by Accelerator.prepare_model inside super().__init__() by the time the check runs. Fixed by unwrapping with self.accelerator.unwrap_model() at both call sites before the isinstance(reward_func, PreTrainedModel) check. Added a regression test that wraps the reward model in a real single-process DistributedDataParallel (accelerate's extract_model_from_parallel only recognizes real wrapper types, not a hand-rolled stand-in) and confirms the check still fires.

  2. Reward processor vocab lookup crashes — the helper now unwraps a ProcessorMixin on the reward side the same way it already did on the policy side, so NashMDTrainer defaulting reward_processing_class to a ProcessorMixin processing_class (VLM policies) no longer raises AttributeError from get_vocab(). Added direct unit tests for both the matching-vocab (must not raise) and mismatched-vocab (must still raise ValueError) cases.

Both new regression tests were confirmed to fail without their respective fix and pass with it. Full tests/experimental/test_xpo_trainer.py + test_nash_md_trainer.py + test_utils.py suite: 34 passed, 1 pre-existing unrelated skip. ruff check/ruff format --check clean.

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.

XPOTrainer accepts reward_processing_classes but never uses it, and NashMDTrainer does not accept it at all

1 participant