Source code for trinity.algorithm.advantage_fn.grpo_advantage

"""GRPO advantage computation
"""

import copy
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple

import torch

if TYPE_CHECKING:
    from verl import DataProto

from trinity.algorithm.advantage_fn.advantage_fn import AdvantageFn, GroupAdvantage
from trinity.common.experience import Experience, group_by
from trinity.utils.annotations import Deprecated
from trinity.utils.metrics import aggregate_metrics


[docs] @Deprecated class GRPOAdvantageFn(AdvantageFn): """GRPO advantage computation"""
[docs] def __init__( self, epsilon: float = 1e-6, ) -> None: self.epsilon = epsilon
def __call__( self, exps: "DataProto", **kwargs, ) -> Tuple["DataProto", Dict]: """ Compute advantage for GRPO, operating only on Outcome reward (with only one scalar reward for each response). Ref: https://github.com/volcengine/verl/blob/main/verl/trainer/ppo/core_algos.py token_level_rewards: `(torch.Tensor)` shape: (bs, response_length) eos_mask: `(torch.Tensor)` shape: (bs, response_length) scores: `(torch.Tensor)` shape: (bs, response_length) """ token_level_rewards = exps.batch["token_level_rewards"] eos_mask = exps.batch["response_mask"] index = exps.non_tensor_batch["uid"] epsilon = self.epsilon response_length = token_level_rewards.shape[-1] scores = token_level_rewards.sum(dim=-1) id2score = defaultdict(list) id2mean = {} id2std = {} with torch.no_grad(): bsz = scores.shape[0] for i in range(bsz): id2score[index[i]].append(scores[i]) for idx in id2score: if len(id2score[idx]) == 1: id2mean[idx] = torch.tensor(0.0) id2std[idx] = torch.tensor(1.0) elif len(id2score[idx]) > 1: id2mean[idx] = torch.mean(torch.tensor(id2score[idx], dtype=torch.float32)) id2std[idx] = torch.std(torch.tensor(id2score[idx], dtype=torch.float32)) else: raise ValueError(f"no score in prompt index: {idx}") for i in range(bsz): scores[i] = (scores[i] - id2mean[index[i]]) / (id2std[index[i]] + epsilon) scores = scores.unsqueeze(-1).tile([1, response_length]) * eos_mask exps.batch["advantages"] = scores exps.batch["returns"] = scores metrics = { # TODO: add meaningful metrics } return exps, metrics
[docs] @classmethod def default_args(cls) -> Dict: return { "epsilon": 1e-6, }
[docs] class GRPOGroupedAdvantage(GroupAdvantage): """An advantage class that calculates GRPO advantages."""
[docs] def __init__( self, epsilon: float = 1e-6, std_threshold: Optional[float] = None, duplicate_experiences: bool = False, rank_penalty: Optional[float] = None, std_cal_level: str = "group", # "group" or "batch" ) -> None: """Initialize the GRPO advantage function. Args: epsilon (float): A small value to avoid division by zero. std_threshold (Optional[float]): If provided, groups with a reward standard deviation equal or below this threshold will be skipped. duplicate_experiences (bool): If True, allows duplicate experiences to keep the original experience count. Only used when `std_threshold` is not None (https://hkunlp.github.io/blog/2025/Polaris). rank_penalty (Optional[float]): A penalty applied to the rank of rewards to correct for bias (https://arxiv.org/pdf/2506.02355). std_cal_level (str): The scope for calculating the reward standard deviation for normalization. Can be 'group' (default, std is calculated per group) or 'batch' (std is calculated across the entire batch). The mean is always calculated per group. Calculating the mean at the local (group) level and the standard deviation at the global (batch) level enables more robust reward shaping(https://arxiv.org/pdf/2508.08221v1). """ self.epsilon = epsilon self.std_threshold = std_threshold self.duplicate_experiences = duplicate_experiences self.rank_penalty = rank_penalty self.std_cal_level = std_cal_level if self.std_cal_level not in ["group", "batch"]: raise ValueError("std_cal_level must be either 'group' or 'batch'")
[docs] def group_experiences(self, exps): return group_by(exps, id_type="task")
[docs] def calculate_group_advantage( self, group_id: str, exps: List[Experience], precomputed_std: Optional[torch.Tensor] = None, ) -> Tuple[List[Experience], Dict]: metrics = {} with torch.no_grad(): if len(exps) == 1: group_reward_mean = torch.tensor( 0.0 ) # check this value (use exps[0].reward may be better) group_reward_std = torch.tensor(1.0) # set to 1.0 to avoid division by zero if self.std_threshold is not None: metrics["skipped_count"] = 1 exps.clear() # Clear experiences if only one experience else: rewards = torch.tensor([exp.reward for exp in exps], dtype=torch.float32) if self.rank_penalty is not None: # Correct bias by adjusting rewards based on their ranks old_log_probs = torch.tensor( [torch.mean(exp.logprobs, axis=-1) for exp in exps] ) group_ranks = torch.argsort(torch.argsort(old_log_probs)) group_ranks = group_ranks / len(group_ranks) rewards = rewards * (1 - group_ranks * self.rank_penalty) group_reward_mean = torch.mean(rewards) group_reward_std = torch.std(rewards) # If the reward standard deviation is below a threshold, skip the group if self.std_threshold is not None and group_reward_std <= self.std_threshold: metrics["skipped_count"] = len(exps) exps.clear() for exp in exps: if self.std_cal_level == "batch" and precomputed_std is not None: score = (exp.reward - group_reward_mean) / (precomputed_std + self.epsilon) else: score = (exp.reward - group_reward_mean) / (group_reward_std + self.epsilon) exp.advantages = score * exp.action_mask exp.returns = exp.advantages.clone() metrics["reward_mean"] = group_reward_mean.item() metrics["reward_std"] = group_reward_std.item() return exps, metrics
def _duplicate_experiences(self, exp_groups: Dict[str, List[Experience]]) -> List[Experience]: original_group_num = len(exp_groups) valid_groups = [group_exps for group_exps in exp_groups.values() if len(group_exps) > 0] skipped_group_num = original_group_num - len(valid_groups) if skipped_group_num == 0: return [exp for group in valid_groups for exp in group] elif skipped_group_num == original_group_num: # All groups are skipped, return an empty list return [] else: idx = torch.randint(0, len(valid_groups), (skipped_group_num,)).tolist() duplicated_groups = [copy.deepcopy(valid_groups[i]) for i in idx] duplicated_exps = [exp for group in duplicated_groups for exp in group] exps = [exp for group in valid_groups for exp in group] exps.extend(duplicated_exps) return exps
[docs] def process(self, exps): exp_groups = self.group_experiences(exps) metric_list = [] precomputed_std = None if self.std_cal_level == "batch": all_rewards = torch.tensor( [exp.reward for exp in exps], dtype=torch.float32 ) # All rewards in the batch if len(all_rewards) <= 1: precomputed_std = torch.tensor(1.0) else: precomputed_std = torch.std(all_rewards) for group_id, group_exps in exp_groups.items(): group_exps, group_metrics = self.calculate_group_advantage( group_id, group_exps, precomputed_std=precomputed_std ) metric_list.append(group_metrics) # Update the filtered_count metric filtered_count = sum(metric.pop("skipped_count", 0) for metric in metric_list) metrics = aggregate_metrics(metric_list, prefix="group_advantages") metrics["filtered_count"] = filtered_count if self.duplicate_experiences and self.std_threshold is not None: exps = self._duplicate_experiences(exp_groups) else: exps = [exp for group in exp_groups.values() for exp in group] # Flatten the list return exps, metrics
[docs] @classmethod def default_args(cls) -> dict: return { "epsilon": 1e-6, "std_threshold": None, "duplicate_experiences": False, "rank_penalty": None, "std_cal_level": "group", }