@@ -131,8 +131,6 @@ def union_gen_batch_via_task_id(tasks, batch: DataProto, gen_batch_output: DataP
131131 task_id_counter [tid ] += 1
132132 else :
133133 task_id_counter [tid ] = 1
134- current_id = task_id_counter [tid ]
135- gen_batch_output .non_tensor_batch ['rollout_ids' ][i ] = f"T{ tid } R{ current_id } "
136134 logger .info (f'task_id_counter: { task_id_counter } ' )
137135 return gen_batch_output
138136
@@ -166,6 +164,117 @@ def import_or_export_data_proto(batch: DataProto, direction: str = "export", fil
166164 else :
167165 raise ValueError (f"direction must be 'import' or 'export', got '{ direction } '" )
168166
167+ def compute_grpo_episode_level_outcome_advantage (
168+ token_level_rewards : torch .Tensor ,
169+ response_mask : torch .Tensor ,
170+ index : np .ndarray ,
171+ episode_index : np .ndarray ,
172+ norm_adv_by_std_in_grpo : bool = True ,
173+ epsilon : float = 1e-6 ,
174+ ) -> tuple [torch .Tensor , torch .Tensor ]:
175+ """GRPO outcome advantage with the baseline computed at *episode* scope.
176+
177+ Mirrors ``verl.trainer.ppo.core_algos.compute_grpo_outcome_advantage`` but,
178+ instead of treating every sample equally when forming the per-task (``uid``)
179+ baseline, it first reduces every episode (``episode_uuids``) to its mean
180+ scalar reward and then computes the task baseline mean/std over those
181+ per-episode means. This way an episode that produced many samples does not
182+ dominate the baseline of an episode that produced few.
183+
184+ Example (matches the documented behaviour):
185+ task T -> episode 1 (2 samples, reward 1) + episode 2 (1 sample, reward 0)
186+ sample scope baseline = (1 + 1 + 0) / 3 = 0.667
187+ episode scope baseline = (mean[1, 1] + mean[0]) / 2 = (1 + 0) / 2 = 0.5
188+
189+ Args:
190+ token_level_rewards: (bsz, response_length) reward tensor.
191+ response_mask: (bsz, response_length) mask of trainable response tokens.
192+ index: per-sample task id (``non_tensor_batch["uid"]``).
193+ episode_index: per-sample episode id (``non_tensor_batch["episode_uuids"]``).
194+ norm_adv_by_std_in_grpo: divide the centred reward by the (episode-level)
195+ group std when True, otherwise only subtract the group mean.
196+ epsilon: numerical-stability term added to the std denominator.
197+
198+ Returns:
199+ (advantages, returns) - both (bsz, response_length); identical, as in GRPO.
200+ """
201+ scores = (token_level_rewards * response_mask ).sum (dim = - 1 ) # (bsz,) scalar reward
202+ bsz = scores .shape [0 ]
203+
204+ # 1) reduce each episode to its mean scalar reward
205+ episode_score_sum : dict = defaultdict (float )
206+ episode_score_cnt : dict = defaultdict (int )
207+ for i in range (bsz ):
208+ ep = episode_index [i ]
209+ episode_score_sum [ep ] += scores [i ].item ()
210+ episode_score_cnt [ep ] += 1
211+ episode_mean = {ep : episode_score_sum [ep ] / episode_score_cnt [ep ] for ep in episode_score_sum }
212+
213+ # 2) collect, per task, the set of distinct episodes it produced
214+ task2episodes : dict = defaultdict (dict ) # use dict as ordered set
215+ for i in range (bsz ):
216+ task2episodes [index [i ]][episode_index [i ]] = None
217+
218+ # 3) per-task baseline = mean/std over the per-episode means.
219+ # Single-episode tasks are degenerate -> follow verl's convention
220+ # (mean=0, std=1) so the advantage reduces to the raw score.
221+ task_mean : dict = {}
222+ task_std : dict = {}
223+ for task , episodes in task2episodes .items ():
224+ vals = torch .tensor ([episode_mean [ep ] for ep in episodes ], dtype = torch .float32 )
225+ if vals .numel () == 1 :
226+ task_mean [task ] = torch .tensor (0.0 )
227+ task_std [task ] = torch .tensor (1.0 )
228+ else :
229+ task_mean [task ] = vals .mean ()
230+ task_std [task ] = vals .std ()
231+
232+ # 4) centre (and optionally normalise) every sample against its task baseline
233+ adv = scores .clone ()
234+ for i in range (bsz ):
235+ task = index [i ]
236+ if norm_adv_by_std_in_grpo :
237+ adv [i ] = (scores [i ] - task_mean [task ]) / (task_std [task ] + epsilon )
238+ else :
239+ adv [i ] = scores [i ] - task_mean [task ]
240+
241+ adv = adv .unsqueeze (- 1 ) * response_mask
242+ return adv , adv
243+
244+
245+ def compute_episode_level_loss_weight (data : DataProto ) -> torch .Tensor :
246+ """Per-token loss weight that makes every episode contribute equally.
247+
248+ Each sample belonging to an episode (same ``non_tensor_batch["episode_uuids"]``)
249+ that produced ``N`` samples receives weight ``1 / N``. The weights of all
250+ samples of one episode therefore sum to 1, so an episode that emitted many
251+ samples does not contribute more to the loss than one that emitted few.
252+
253+ The weight is broadcast across the response dimension so it has the **same
254+ shape as ``advantages``** ((bsz, response_length)); this lets it multiply
255+ both the per-token policy-gradient term and the per-token KL term directly.
256+
257+ Returns:
258+ A (bsz, response_length) tensor (matching ``data.batch["advantages"]``
259+ dtype/device) of per-token loss weights, constant along the response
260+ dimension for a given sample.
261+ """
262+ episode_index = data .non_tensor_batch ["episode_uuids" ]
263+ bsz = len (episode_index )
264+ episode_count : dict = defaultdict (int )
265+ for ep in episode_index :
266+ episode_count [ep ] += 1
267+ advantages = data .batch ["advantages" ] # (bsz, response_length)
268+ per_sample = torch .tensor (
269+ [1.0 / episode_count [episode_index [i ]] for i in range (bsz )],
270+ dtype = advantages .dtype ,
271+ device = advantages .device ,
272+ )
273+ # broadcast per-sample weight to the same shape as advantages
274+ weights = per_sample .view (- 1 , 1 ) * torch .ones_like (advantages )
275+ return weights
276+
277+
169278def compute_advantage (
170279 data : DataProto ,
171280 adv_estimator : AdvantageEstimator ,
@@ -174,6 +283,7 @@ def compute_advantage(
174283 num_repeat : int = 1 ,
175284 norm_adv_by_std_in_grpo : bool = True ,
176285 config : Optional [AlgoConfig ] = None ,
286+ advantage_estimation_episode_level : bool = False ,
177287) -> DataProto :
178288 """Compute advantage estimates for policy optimization.
179289
@@ -189,13 +299,21 @@ def compute_advantage(
189299 norm_adv_by_std_in_grpo (bool, optional): Whether to normalize advantages by standard deviation in
190300 GRPO. Defaults to True.
191301 config (dict, optional): Configuration dictionary for algorithm settings. Defaults to None.
302+ advantage_estimation_episode_level (bool, optional): When True (and using the GRPO estimator),
303+ the GRPO baseline is computed at episode scope instead of sample scope so every episode
304+ contributes equally regardless of how many samples it produced. Defaults to False.
192305
193306 Returns:
194307 DataProto: The updated data with computed advantages and returns.
195308 """
196309 # Back-compatible with trainers that do not compute response mask in fit
197310 if "response_mask" not in data .batch .keys ():
198311 data .batch ["response_mask" ] = compute_response_mask (data )
312+ if advantage_estimation_episode_level and adv_estimator != AdvantageEstimator .GRPO :
313+ raise NotImplementedError (
314+ "ajet.trainer_common.advantage_estimation_episode_level is only "
315+ f"supported with the GRPO advantage estimator, got { adv_estimator } ."
316+ )
199317 # prepare response group
200318 if adv_estimator == AdvantageEstimator .GAE :
201319 # Compute advantages and returns using Generalized Advantage Estimation (GAE)
@@ -222,13 +340,30 @@ def compute_advantage(
222340 response_length = grpo_calculation_mask .size (1 )
223341 # This mask is the one intended for GRPO
224342 grpo_calculation_mask = data .batch ["loss_mask" ][:, - response_length :]
225- # Call compute_grpo_outcome_advantage with parameters matching its definition
226- advantages , returns = core_algos .compute_grpo_outcome_advantage (
227- token_level_rewards = data .batch ["token_level_rewards" ],
228- response_mask = grpo_calculation_mask ,
229- index = data .non_tensor_batch ["uid" ],
230- norm_adv_by_std_in_grpo = norm_adv_by_std_in_grpo ,
231- )
343+ if advantage_estimation_episode_level :
344+ # Episode-scope baseline: every episode contributes equally to the
345+ # per-task baseline regardless of how many samples it produced.
346+ if "episode_uuids" not in data .non_tensor_batch :
347+ raise KeyError (
348+ "advantage_estimation_episode_level is enabled but "
349+ "non_tensor_batch['episode_uuids'] is missing; cannot identify "
350+ "same-episode samples."
351+ )
352+ advantages , returns = compute_grpo_episode_level_outcome_advantage (
353+ token_level_rewards = data .batch ["token_level_rewards" ],
354+ response_mask = grpo_calculation_mask ,
355+ index = data .non_tensor_batch ["uid" ],
356+ episode_index = data .non_tensor_batch ["episode_uuids" ],
357+ norm_adv_by_std_in_grpo = norm_adv_by_std_in_grpo ,
358+ )
359+ else :
360+ # Call compute_grpo_outcome_advantage with parameters matching its definition
361+ advantages , returns = core_algos .compute_grpo_outcome_advantage (
362+ token_level_rewards = data .batch ["token_level_rewards" ],
363+ response_mask = grpo_calculation_mask ,
364+ index = data .non_tensor_batch ["uid" ],
365+ norm_adv_by_std_in_grpo = norm_adv_by_std_in_grpo ,
366+ )
232367 data .batch ["advantages" ] = advantages
233368 data .batch ["returns" ] = returns
234369 else :
@@ -692,6 +827,13 @@ def fit(self): # noqa: C901
692827 "norm_adv_by_std_in_grpo" , True
693828 ) # GRPO adv normalization factor
694829
830+ # [AJET] episode-scope advantage baseline (disabled by default)
831+ advantage_estimation_episode_level = bool (
832+ self .config .ajet .trainer_common .get (
833+ "advantage_estimation_episode_level" , False
834+ )
835+ )
836+
695837 batch = compute_advantage (
696838 batch ,
697839 adv_estimator = self .config .algorithm .adv_estimator ,
@@ -700,8 +842,26 @@ def fit(self): # noqa: C901
700842 num_repeat = self .config .ajet .rollout .num_repeat ,
701843 norm_adv_by_std_in_grpo = norm_adv_by_std_in_grpo ,
702844 config = self .config .algorithm ,
845+ advantage_estimation_episode_level = advantage_estimation_episode_level ,
703846 )
704847
848+ # [AJET] per-sample loss weight that makes every episode
849+ # contribute equally to the policy-gradient update
850+ # (disabled by default). Consumed in
851+ # AjetDataParallelPPOActor.update_policy.
852+ if bool (
853+ self .config .ajet .trainer_common .get (
854+ "loss_weight_normalization_episode_level" , False
855+ )
856+ ):
857+ if "episode_uuids" not in batch .non_tensor_batch :
858+ raise KeyError (
859+ "loss_weight_normalization_episode_level is enabled but "
860+ "non_tensor_batch['episode_uuids'] is missing; cannot "
861+ "identify same-episode samples."
862+ )
863+ batch .batch ["loss_weight" ] = compute_episode_level_loss_weight (batch )
864+
705865 # update critic
706866 if self .use_critic :
707867 with marked_timer ("update_critic" , timing_raw , color = "pink" ):
0 commit comments