refactor(algorithms): standardize advantage-estimator results - #3512
tianyi-zhang-02 wants to merge 12 commits into
Conversation
7cae64c to
ab220c1
Compare
|
Rebased onto current main and pushed a follow-up commit. Removing the
Verified by running Worth flagging one thing I noticed while doing this: the |
|
Fixed something I should have caught before opening this: the protocol was defined and then referenced nowhere, and two estimators didn't satisfy it — Latest push makes it real:
On the test: |
yuki-97
left a comment
There was a problem hiding this comment.
hi @tianyi-zhang-02 , sorry for late reply and thanks for so many contributions! will assign reviewers.
@bg51717 to review
|
Small follow-up on my last push. The note explaining why Moved to Nothing about the behaviour changes; the docstring in a PR about documenting the estimator contract just shouldn't be the part that's wrong. |
3442920 to
78b8224
Compare
bg51717
left a comment
There was a problem hiding this comment.
Thanks for the refactor. Unifying the estimator return type and removing the last_metrics side channel makes the call sites clearer. I left a few small comments.
| raise ValueError( | ||
| "GAE needs per-token value estimates; enable the critic so that " | ||
| "'values' is present in the training batch, or switch " | ||
| "grpo.adv_estimator.name to an estimator that needs no value model." |
There was a problem hiding this comment.
Should this be ppo.adv_estimator.name? GAE is constructed from PPO's adv_estimator config.
There was a problem hiding this comment.
You are right — GAE is built in ppo.py::_create_advantage_estimator from master_config.ppo["adv_estimator"], so grpo.adv_estimator.name sends the reader to a key that does not control it. Fixed to ppo.adv_estimator.name.
| def _create_advantage_estimator(master_config: MasterConfig) -> AdvantageEstimator: | ||
| """Create and return an advantage estimator based on configuration. | ||
|
|
||
| PPO's training loop consumes a `(advantages, returns)` pair from a |
There was a problem hiding this comment.
This still describes the old tuple return contract. Could we update it ?
There was a problem hiding this comment.
Fixed. It now says that both supported estimators return an AdvantageResult, that gae populates returns while raw_reward leaves it None, and that the loop writes train_data["returns"] only when it is present.
I got this wrong once on the way: my first rewrite claimed both populate returns. RawRewardAdvantageEstimator returns AdvantageResult(adv) with returns left None, which is exactly why the loop still guards with if result.returns is not None.
| rewards, | ||
| mask, | ||
| repeated_batch, | ||
| repeated_batch=None, |
There was a problem hiding this comment.
@yuki-97, could you take a quick look at the API design here? The PR uses an AdvantageResult dataclass for the common return value and an AdvantageEstimator Protocol instead of a shared base class. To conform to this Protocol, GDPOAdvantageEstimator, which requires repeated_batch, now gives that argument a default value of None and checks at runtime whether a valid value was actually provided. I'm not sure whether this is a reasonable trade-off.
There was a problem hiding this comment.
Fair question — the fake default is the part I like least too.
Here is why it is there. Both loops call compute_advantage with the union of what every estimator might need, and lean on **kwargs for the rest. So the real invariant is "may accept extra arguments, may not require any beyond prompt_ids/rewards/mask". repeated_batch and values were the only two breaking it. A protocol that two of six estimators fail is not enforcing much.
It did buy one concrete thing. ppo.py adds values only if "values" in train_data. So GAE without a critic was reachable, and it failed with a bare TypeError about a missing positional argument. Now it tells you to enable the critic. That is strictly better than the failure it replaced.
Three ways to go:
- Leave it. Uniform signature, explicit error, docstring says it is required.
- Drop the Protocol, keep
AdvantageResult. Signatures stay honest, and the dataclass is the part carrying its weight anyway. This is what the PR was two pushes ago. - Pass one typed context object instead of loose kwargs. Then every estimator takes one argument honestly. I would want this long-term, but it touches every estimator and both loops, so it should be its own PR.
If the fake default is the sticking point, I would rather do 2 here and propose 3 separately. Your call :)
|
Also merged current main in, which surfaced a real break worth flagging: the async PPO loop added since this branch was cut still had the tuple branching this PR removes elsewhere — if isinstance(result, tuple):
advantages, returns = result
else:
advantages, returns = result, NoneAn That is the second time a new |
|
@bg51717 @yuki-97 — new information that bears on the API question at #3773 ( That is option 3 from my earlier reply — "pass one typed object instead of loose lookups" — applied to the config side. So the direction question looks answered: typed objects, not dicts. Which makes the return side of this PR ( It also means the two will conflict. Both rewrite the estimator On the |
The estimators shared no declared interface: compute_advantage returned a
bare tensor from GRPO/GDPO/Reinforce++/OPD but a (advantages, returns)
tuple from RawReward/GAE, so ppo.py had to branch on isinstance(result,
tuple), and OPD published its logging metrics by assigning
self.last_metrics, which grpo.py read back through a hasattr() probe that
only existed because the other estimators lack the attribute.
Introduce AdvantageResult(advantages, returns=None, metrics={}) as the
single return type and an AdvantageEstimator Protocol describing the
contract. Migrate all six estimators and the five call sites; the tuple
branch in ppo.py and the hasattr probe in grpo.py both disappear.
Declared as a Protocol rather than a base class so estimators defined
outside nemo_rl satisfy it structurally, matching how dataset classes are
already pluggable by dotted path.
Behavior is unchanged: only the wrapper moves. GAE's private _compute_gae
still returns its tuple, since three call sites inside the estimator
destructure it.
Addresses NVIDIA-NeMo#2909
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Removing the isinstance(result, tuple) branch in ppo.py means every compute_advantage double has to return an AdvantageResult too. Five were missed, four of them in tests added to main after this branch was cut: - tests/unit/algorithms/test_ppo.py DummyAdvantageEstimator returned a bare tuple, so test_ppo_train_excludes_overlong_samples_from_advantage and test_ppo_train_wires_logprob_mask_to_advantage_training_and_metrics failed with AttributeError: tuple object has no attribute advantages. - tests/unit/algorithms/test_grpo.py had three MagicMock return_values set to bare tensors. - tests/unit/single_controller/test_train_pump.py _FakeAdvEstimator returned a bare tensor; that file was not touched by this branch at all. Verified by running test_ppo.py, test_grpo.py and test_advantage_estimator.py against origin/main and against this branch: identical results, no failures introduced. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
The AdvantageResult docstring said 'returns' is populated by GAE and the raw-reward estimator. Only GAE populates it -- RawRewardAdvantageEstimator returns AdvantageResult(adv) with returns left None, three lines away in the same file. The six public compute_advantage docstrings also still described the old return types (a bare tensor, or a tuple). Ruff's pydocstyle rules check that a Returns section exists, never that it is accurate, so none of this was caught. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
The protocol was defined but referenced nowhere, and two estimators did not satisfy it: GDPO required ``repeated_batch`` and GAE required ``values``, both as positional parameters outside the contract. A protocol nothing is checked against is documentation that can go stale silently. - Declare the contract's parameters keyword-only, which is how both loops actually call it: each passes the union of what all estimators need and relies on ``**kwargs`` to absorb the rest. - Give GDPO's ``repeated_batch`` and GAE's ``values`` keyword defaults with explicit errors. Both are still required in practice; the defaults only stop the signatures from demanding more than the shared contract. GAE previously raised a bare TypeError when ``values`` was absent from the batch (ppo.py adds it conditionally), which now says to enable the critic instead. - Annotate both ``_create_advantage_estimator`` functions with the protocol. ``runtime_checkable`` only checks that ``compute_advantage`` exists, which every estimator passed even with an incompatible signature, so the test inspects the signature: no estimator may require anything outside the contract. It fails if either default is removed. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
The note explaining why ``values`` carries a default landed in ``_compute_gae``'s Args block, which has neither the default nor the check it describes -- ``values: torch.Tensor`` there is still required, and the guard lives in ``compute_advantage`` above it, so "checked explicitly below" pointed the wrong way as well as at the wrong function. Moves it to ``compute_advantage``, which had no Args block at all, and matches the wording to the equivalent note on GDPO's ``repeated_batch``. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
…rn contract Both from review. The GAE guard told the reader to change grpo.adv_estimator.name, but GAE is built by ppo.py's _create_advantage_estimator from master_config.ppo['adv_estimator']. That function's docstring also still described the pre-AdvantageResult tuple return. Restates it as it now works: gae populates returns, raw_reward leaves it None, and the loop writes train_data['returns'] only when present. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
The async PPO loop that landed on main since this branch was cut still had the
tuple branching this PR removes elsewhere:
if isinstance(result, tuple):
advantages, returns = result
else:
advantages, returns = result, None
An AdvantageResult is not a tuple, so it took the else branch and passed the
dataclass itself to torch.masked_select --
test_ppo_train_critic_warmup_reuses_generation_until_policy_update fails with
"argument 'input' must be Tensor, not AdvantageResult". Matches the sync path
above it.
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
…kens guard The rebase onto main left the new AdvantageResult call sitting *after* main's guarded block instead of replacing it, so the estimator ran twice and ran even when the sequence mask had removed the whole chunk -- which is exactly what test_advantage_stage_skips_estimator_when_seq_mask_removes_whole_chunk asserts must not happen. Also migrates the four estimator doubles main added since this PR opened (NVIDIA-NeMo#3768's OPD ones plus the GAE-like tuple) onto AdvantageResult, which is the point of the contract: a double that still returns a bare tensor or a tuple is modelling an interface that no longer exists. Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com> Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
6fa1956 to
dd2ebe9
Compare
Two gaps that only show once this branch is merged with anything else:
- nine controller stubs predate _teacher_logprobs_required, which main's
NVIDIA-NeMo#3768 made _advantage_stage read unconditionally;
- the estimator double returns a bare tensor, which NVIDIA-NeMo#3512 replaces with
AdvantageResult.
The double now resolves AdvantageResult through the module rather than
importing it, so it returns whichever shape the checkout actually has and this
file does not depend on a name that exists only on the other branch.
Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com>
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
…t has NVIDIA-NeMo#3512 replaces the bare-tensor return with AdvantageResult. The double now resolves that class through the module instead of importing it, so it returns a tensor on this branch and an AdvantageResult once NVIDIA-NeMo#3512 lands, and this file does not depend on a name that exists only there. An earlier version of this commit also added _teacher_logprobs_required to nine controller stubs. main already sets it in seven places and the suite is green without the other two, so that was duplicate work that collided with NVIDIA-NeMo#3512 on the same lines -- dropped. Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com> Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
…t has NVIDIA-NeMo#3512 replaces the bare-tensor return with AdvantageResult. The double now resolves that class through the module instead of importing it, so it returns a tensor on this branch and an AdvantageResult once NVIDIA-NeMo#3512 lands, and this file does not depend on a name that exists only there. An earlier version of this commit also added _teacher_logprobs_required to nine controller stubs. main already sets it in seven places and the suite is green without the other two, so that was duplicate work that collided with NVIDIA-NeMo#3512 on the same lines -- dropped. Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com> Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Two gaps in this PR's own parity claim. grpo.py logs advantages at :3471 and clips at :3487; grpo_sync.py at :903 then :915. Both report pre-clip. This PR clipped first, so SC's advantages/mean|max|min would have meant something no other driver's does. Clip after the log; the data-plane write is unchanged. The clip guard's 'not self._is_ppo' half was untested, because the PPO test handed the ppo block a GRPO-shaped stub carrying advantage_clip_low -- a field real PPOConfig does not declare. Deleting the clause left every test green. PPOConfig is extra="allow", so the clause is exactly what stops a user-set ppo.advantage_clip_low from running GRPO clipping on a PPO run; there is a test for that now, and the PPO stub matches the real config. Also drop the importlib lookup for AdvantageResult: it does not exist on main, NVIDIA-NeMo#3512 is unmerged, and if that branch ever returned the wrapper the production code at single_controller.py:2028 would break anyway. The guide said setup rejects reward_scaling. It does not any more. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Signed-off-by: Tianyi Zhang <zhangtianyi975@gmail.com> # Conflicts: # tests/unit/single_controller/test_single_controller_actor.py
Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
Retain the unified AdvantageResult while removing the Protocol that forced heterogeneous estimators into an artificial three-argument signature. Restore GDPO's repeated_batch and GAE's values as honest required inputs, drop the now-vacuous signature tests, and update the data-plane example for the result wrapper. Signed-off-by: Tianyi Zhang <123608656+tianyi-zhang-02@users.noreply.github.com>
|
Follow-up on the API concern from your review, on final SHA
cc @bg51717 — this directly resolves the trade-off you flagged without keeping a runtime-only signature contract. |
What does this PR do?
Standardizes all six advantage estimators on one explicit result type:
GRPO, synchronous GRPO, PPO, and SingleController now consume named fields instead of branching on tensors versus tuples. OPD metrics travel in the result instead of through the
last_metricsside channel.The earlier
AdvantageEstimatorProtocol and its fakeNonedefaults are removed.GDPOAdvantageEstimator.repeated_batchandGeneralizedAdvantageEstimator.valuesremain honestly required, while the typed estimator configs merged in #3773 stay unchanged.Addresses #2909.
Validation
Final SHA:
6eb502a06122c27eb4763645a40a5175467f4427, fast-forwarded from the existing PR head and merged with currentmain(ccbcd4cc5).Environment: Runpod Secure Cloud,
nvcr.io/nvidia/nemo-rl:v0.7.0, Python3.13.14, PyTorch2.11.0+cu130; tests ran CPU-only because this refactor changes result/caller contracts rather than GPU kernels.