Image-Text-to-Text
Transformers
Safetensors
qwen3_5
vllm
video
multimodal
reinforcement-learning
temporal-grounding
object-tracking
video-segmentation
visual-question-answering
spatial-reasoning
qwen3.5
conversational
Instructions to use OraRL/Video-ORA-9B with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use OraRL/Video-ORA-9B with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("image-text-to-text", model="OraRL/Video-ORA-9B") messages = [ { "role": "user", "content": [ {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/p-blog/candy.JPG"}, {"type": "text", "text": "What animal is on the candy?"} ] }, ] pipe(text=messages)# Load model directly from transformers import AutoProcessor, AutoModelForMultimodalLM processor = AutoProcessor.from_pretrained("OraRL/Video-ORA-9B") model = AutoModelForMultimodalLM.from_pretrained("OraRL/Video-ORA-9B", device_map="auto") messages = [ { "role": "user", "content": [ {"type": "image", "url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/p-blog/candy.JPG"}, {"type": "text", "text": "What animal is on the candy?"} ] }, ] inputs = processor.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", ).to(model.device) outputs = model.generate(**inputs, max_new_tokens=40) print(processor.decode(outputs[0][inputs["input_ids"].shape[-1]:])) - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use OraRL/Video-ORA-9B with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "OraRL/Video-ORA-9B" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "OraRL/Video-ORA-9B", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }'Use Docker
docker model run hf.co/OraRL/Video-ORA-9B
- SGLang
How to use OraRL/Video-ORA-9B with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "OraRL/Video-ORA-9B" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "OraRL/Video-ORA-9B", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "OraRL/Video-ORA-9B" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "OraRL/Video-ORA-9B", "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Describe this image in one sentence." }, { "type": "image_url", "image_url": { "url": "https://cdn.britannica.com/61/93061-050-99147DCE/Statue-of-Liberty-Island-New-York-Bay.jpg" } } ] } ] }' - Docker Model Runner
How to use OraRL/Video-ORA-9B with Docker Model Runner:
docker model run hf.co/OraRL/Video-ORA-9B
| # Copyright 2024 Bytedance Ltd. and/or its affiliates | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| """ | |
| PPO Trainer with Ray-based single controller. | |
| This trainer supports model-agonistic model initialization with huggingface. | |
| """ | |
| import importlib | |
| import json | |
| import math | |
| import os | |
| import time | |
| import uuid | |
| from collections import defaultdict, deque | |
| from copy import deepcopy | |
| from dataclasses import dataclass, field | |
| from enum import IntEnum, auto | |
| from typing import Any, Optional, Type | |
| import numpy as np | |
| import ray | |
| import torch | |
| from ray.experimental.tqdm_ray import tqdm | |
| from torchdata.stateful_dataloader import StatefulDataLoader | |
| from transformers import PreTrainedTokenizer, ProcessorMixin | |
| from ..protocol import DataProto, pad_dataproto_to_divisor, unpad_dataproto | |
| from ..single_controller.base import Worker | |
| from ..single_controller.ray import RayClassWithInitArgs, RayResourcePool, RayWorkerGroup | |
| from ..single_controller.ray.base import create_colocated_worker_cls | |
| from ..utils import torch_functional as VF | |
| from ..utils.checkpoint import CHECKPOINT_TRACKER, find_latest_ckpt, remove_obsolete_ckpt, thin_out_old_ckpts | |
| from ..utils.logger import Tracker | |
| from ..utils.multimodal_contract import validate_multi_modal_data_contract | |
| from ..utils.py_functional import convert_dict_to_str, timer, unflatten_dict | |
| from ..utils.seqlen_balancing import get_seqlen_balanced_partitions, log_seqlen_unbalance | |
| from ..workers.fsdp_workers import FSDPWorker | |
| from ..workers.reward import AutoRewardManager | |
| from .config import PPOConfig | |
| from .core_algos import ( | |
| AdvantageEstimator, | |
| FixedKLController, | |
| KLController, | |
| compute_advantage_return, | |
| compute_kl, | |
| get_kl_controller, | |
| ) | |
| from .orarl_post_selection import ( | |
| PostSelectionReference, | |
| balance_post_selection_group, | |
| ) | |
| from .orarl_selection import select_orarl_rollouts | |
| from .metrics import ( | |
| compute_data_metrics, | |
| compute_length_metrics, | |
| compute_throughout_metrics, | |
| compute_timing_metrics, | |
| reduce_metrics, | |
| ) | |
| from .orarl_config import ( | |
| is_orarl, | |
| validate_algorithm, | |
| ) | |
| def _disable_tqdm() -> bool: | |
| return os.getenv("VERL_DISABLE_TQDM", "0") == "1" | |
| def _print_step_summary_enabled() -> bool: | |
| return os.getenv("VERL_PRINT_STEP_SUMMARY", "1") == "1" | |
| def _skip_old_log_probs_enabled() -> bool: | |
| return os.getenv("VERL_SKIP_OLD_LOGPROBS", "0") == "1" | |
| class _NoOpProgress: | |
| def update(self, *args, **kwargs) -> None: | |
| pass | |
| def _fmt_metric(value: Any, precision: int = 4) -> str: | |
| if value is None: | |
| return "n/a" | |
| try: | |
| return f"{float(value):.{precision}f}" | |
| except (TypeError, ValueError): | |
| return str(value) | |
| def _fmt_duration(seconds: float) -> str: | |
| seconds = max(0, int(seconds)) | |
| hours, remainder = divmod(seconds, 3600) | |
| minutes, seconds = divmod(remainder, 60) | |
| if hours: | |
| return f"{hours}h{minutes:02d}m{seconds:02d}s" | |
| return f"{minutes}m{seconds:02d}s" | |
| class Role(IntEnum): | |
| """ | |
| To create more roles dynamically, you can subclass Role and add new members | |
| """ | |
| Actor = auto() | |
| Rollout = auto() | |
| ActorRollout = auto() | |
| Critic = auto() | |
| RefPolicy = auto() | |
| RewardModel = auto() | |
| ActorRolloutRef = auto() | |
| class ResourcePoolManager: | |
| """ | |
| Define a resource pool specification. Resource pool will be initialized first. | |
| """ | |
| resource_pool_spec: dict[str, list[int]] | |
| mapping: dict[Role, str] | |
| resource_pool_dict: dict[str, RayResourcePool] = field(default_factory=dict) | |
| def create_resource_pool(self): | |
| """Create ray resource pools for distributed training.""" | |
| for resource_pool_name, process_on_nodes in self.resource_pool_spec.items(): | |
| # max_colocate_count means the number of WorkerGroups (i.e. processes) in each RayResourcePool | |
| # For FSDP backend, we recommend using max_colocate_count=1 that merge all WorkerGroups into one. | |
| # For Megatron backend, we recommend using max_colocate_count>1 that can utilize different WorkerGroup for different models | |
| resource_pool = RayResourcePool( | |
| process_on_nodes=process_on_nodes, use_gpu=True, max_colocate_count=1, name_prefix=resource_pool_name | |
| ) | |
| self.resource_pool_dict[resource_pool_name] = resource_pool | |
| self._check_resource_available() | |
| def get_resource_pool(self, role: Role) -> RayResourcePool: | |
| """Get the resource pool of the worker.""" | |
| return self.resource_pool_dict[self.mapping[role]] | |
| def get_num_gpus(self) -> int: | |
| """Get the number of gpus in this cluster.""" | |
| return sum([n_gpus for process_on_nodes in self.resource_pool_spec.values() for n_gpus in process_on_nodes]) | |
| def _check_resource_available(self): | |
| """Check if the resource pool can be satisfied in this ray cluster.""" | |
| gpus_available = ray.available_resources().get("GPU", 0) | |
| gpus_required = self.get_num_gpus() | |
| if gpus_available < gpus_required: | |
| raise ValueError(f"Total available GPUs {gpus_available} is less than total desired GPUs {gpus_required}.") | |
| def _load_dotted_callable(spec: str): | |
| """Resolve a 'pkg.mod:attr' string into the callable it points to.""" | |
| if ":" not in spec: | |
| raise ValueError( | |
| f"Expected a 'module.path:function' style spec, got {spec!r}. " | |
| "Example: 'orarl.rewards:build_oracle_response_from_ground_truth'." | |
| ) | |
| module_path, attr = spec.split(":", 1) | |
| module = importlib.import_module(module_path) | |
| try: | |
| fn = getattr(module, attr) | |
| except AttributeError as err: | |
| raise AttributeError(f"Module {module_path!r} has no attribute {attr!r}.") from err | |
| if not callable(fn): | |
| raise TypeError(f"{spec!r} resolved to a non-callable object: {type(fn).__name__}") | |
| return fn | |
| def apply_kl_penalty(data: DataProto, kl_ctrl: KLController, kl_penalty="kl"): | |
| """Apply KL penalty to the token-level rewards.""" | |
| token_level_scores = data.batch["token_level_scores"] | |
| batch_size = data.batch.batch_size[0] | |
| response_mask = data.batch["response_mask"] | |
| # compute kl between ref_policy and current policy | |
| kld = compute_kl(data.batch["old_log_probs"], data.batch["ref_log_probs"], kl_penalty=kl_penalty) | |
| kld = kld * response_mask # (batch_size, response_length) | |
| data.batch["token_level_rewards"] = token_level_scores - kl_ctrl.kl_coef * kld | |
| current_kl = torch.mean(VF.masked_mean(kld, mask=response_mask, dim=-1)).item() | |
| metrics = {"actor/kl_penalty": current_kl, "actor/kl_coef": kl_ctrl.kl_coef} | |
| # According to https://github.com/huggingface/trl/blob/v0.11.0/trl/trainer/ppo_trainer.py#L880 | |
| kl_ctrl.update(current_kl=current_kl, n_steps=batch_size) | |
| return data, metrics | |
| def compute_advantage( | |
| data: DataProto, | |
| adv_estimator: AdvantageEstimator, | |
| gamma: float = 1.0, | |
| lam: float = 1.0, | |
| scale_rewards: bool = True, | |
| directional_gain: bool = False, | |
| directional_gain_gamma: float = 0.25, | |
| directional_gain_positive_only: bool = False, | |
| directional_gain_recenter: bool = False, | |
| ): | |
| """Compute advantage estimates for policy optimization. | |
| ``scale_rewards=False`` selects raw-centered GRPO advantages ``r-mean`` | |
| without per-group standard-deviation whitening. | |
| When ``is_oracle_row`` exists in ``data.non_tensor_batch`` the GRPO-family | |
| estimator keeps the oracle out of the baseline: the per-group mean is built | |
| over on-policy rollouts only, so a perfect-reward oracle cannot suppress a | |
| genuine on-policy winner. The standard deviation is also on-policy only, so | |
| the oracle cannot inflate it and shrink on-policy gradients. Has no effect | |
| for non-GRPO estimators or for runs without oracle rows. | |
| """ | |
| adv_inputs = { | |
| "token_level_rewards": data.batch["token_level_rewards"], | |
| "response_mask": data.batch["response_mask"], | |
| "index": data.non_tensor_batch["uid"], | |
| "gamma": gamma, | |
| "lam": lam, | |
| "scale_rewards": bool(scale_rewards), | |
| } | |
| if "values" in data.batch: | |
| adv_inputs["values"] = data.batch["values"] | |
| if "reward_baselines" in data.batch: | |
| adv_inputs["reward_baselines"] = data.batch["reward_baselines"] | |
| telemetry: dict[str, Any] = {} | |
| is_oracle_row = data.non_tensor_batch.get("is_oracle_row", None) | |
| if is_oracle_row is not None: | |
| adv_inputs["is_oracle_row"] = is_oracle_row | |
| adv_inputs["oracle_excluded_baseline"] = True | |
| if directional_gain: | |
| adv_inputs["directional_gain"] = True | |
| adv_inputs["directional_gain_gamma"] = float(directional_gain_gamma) | |
| adv_inputs["directional_gain_positive_only"] = bool( | |
| directional_gain_positive_only | |
| ) | |
| adv_inputs["directional_gain_recenter"] = bool(directional_gain_recenter) | |
| adv_inputs["_telemetry_out"] = telemetry | |
| advantages, returns = compute_advantage_return(adv_estimator, **adv_inputs) | |
| data.batch["advantages"] = advantages | |
| data.batch["returns"] = returns | |
| if telemetry: | |
| # Stash so the training loop can merge into per-step metrics. | |
| data.meta_info = dict(data.meta_info) | |
| data.meta_info["_advantage_telemetry"] = telemetry | |
| return data | |
| def compute_oracle_advantage_diagnostics( | |
| data: DataProto, | |
| eps: float = 1e-6, | |
| ) -> dict[str, float]: | |
| """Per-step diagnostics for the oracle-excluded advantage baseline. | |
| Returns ``orarl/*`` metrics quantifying how much the oracle row would | |
| contaminate a vanilla GRPO baseline and how much the oracle-excluded | |
| statistics recover. No-op without oracle rows (returns {}). | |
| Metrics emitted (per-batch averages over groups unless noted): | |
| Per-group statistics (reward space): | |
| - orarl/mu_op_mean mean of μ_op (on-policy mean) across groups | |
| - orarl/mu_g_mean mean of μ_g (full-group mean) | |
| - orarl/sigma_op_mean mean of σ_op | |
| - orarl/sigma_g_mean mean of σ_g | |
| - orarl/sigma_ratio_mean mean of σ_g / σ_op (>1 ↔ oracle far outside | |
| the on-policy reward distribution) | |
| - orarl/oracle_mean_contamination | |
| |μ_g - μ_op| / σ_op — how far the oracle would | |
| shift the baseline; high values flag the groups | |
| where excluding it matters most | |
| Recovery counts, computed against the current ``data.batch['advantages']``: | |
| - orarl/sign_flip_count on-policy rows whose advantage sign differs | |
| from what vanilla GRPO (μ_g, σ_g) would give — | |
| the rows the oracle-excluded baseline rescued | |
| - orarl/sign_flip_rate sign_flip_count / total_op_rows | |
| - orarl/groups_with_no_op_pos_baseline | |
| groups where vanilla GRPO would leave every | |
| on-policy advantage ≤ 0, so a sign-balanced | |
| selector would have no positive policy row | |
| Magnitudes (post-transform advantages): | |
| - orarl/oracle_adv_mag_mean / oracle_adv_mag_max |A_oracle| | |
| - orarl/op_adv_pos_mean mean |A_op| over on-policy positives | |
| - orarl/op_adv_neg_mean mean |A_op| over on-policy negatives | |
| Use these to confirm (a) |A_oracle| stays moderate (e.g. < 5) so PPO | |
| clipping does not eat the oracle signal, and (b) sign_flip_rate is | |
| non-trivial, which means the oracle-excluded baseline is doing real work. | |
| """ | |
| is_oracle_row_np = data.non_tensor_batch.get("is_oracle_row", None) | |
| if is_oracle_row_np is None: | |
| return {} | |
| response_mask = data.batch["response_mask"] | |
| token_level_rewards = data.batch["token_level_rewards"] | |
| advantages = data.batch["advantages"] | |
| scores = (token_level_rewards * response_mask).sum(dim=-1) # (bsz,) | |
| mask_sum = response_mask.sum(dim=-1).clamp(min=1) | |
| adv_signed = (advantages * response_mask).sum(dim=-1) / mask_sum # (bsz,) | |
| bsz = scores.shape[0] | |
| is_oracle = torch.tensor( | |
| [bool(is_oracle_row_np[i]) for i in range(bsz)], | |
| dtype=torch.bool, device=scores.device, | |
| ) | |
| uids = data.non_tensor_batch["uid"] | |
| uid_to_idx: dict[Any, list[int]] = defaultdict(list) | |
| for i, uid in enumerate(uids): | |
| uid_to_idx[uid].append(i) | |
| mu_op_acc, mu_g_acc = [], [] | |
| sigma_op_acc, sigma_g_acc = [], [] | |
| # Robust ratio aggregation: collect ratios ONLY from groups with non-trivial | |
| # sigma_op (above SIGMA_FLOOR), so a degenerate group with σ_op≈0 doesn't | |
| # blow up the per-batch average. Also report the unweighted mean(σ_g) / | |
| # mean(σ_op) as a stable companion. | |
| SIGMA_FLOOR = 1e-3 | |
| contam_acc, sigma_ratio_acc = [], [] | |
| degenerate_op_groups = 0 | |
| sign_flip = 0 | |
| total_op = 0 | |
| no_op_pos_groups = 0 | |
| n_groups = 0 | |
| oracle_mags, op_pos_mags, op_neg_mags = [], [], [] | |
| for indices in uid_to_idx.values(): | |
| if len(indices) < 2: | |
| continue | |
| idx_t = torch.tensor(indices, dtype=torch.long, device=scores.device) | |
| g_scores = scores.index_select(0, idx_t) | |
| g_adv = adv_signed.index_select(0, idx_t) | |
| g_oracle_mask = is_oracle.index_select(0, idx_t) | |
| op_mask = ~g_oracle_mask | |
| if op_mask.sum() < 1: | |
| continue | |
| op_scores = g_scores[op_mask] | |
| oracle_scores = g_scores[g_oracle_mask] | |
| mu_op = op_scores.mean() | |
| mu_g = g_scores.mean() | |
| sigma_op = (op_scores.std(unbiased=False) | |
| if op_scores.numel() > 1 else torch.zeros_like(mu_op)) | |
| sigma_g = g_scores.std(unbiased=False) | |
| mu_op_acc.append(mu_op.item()) | |
| mu_g_acc.append(mu_g.item()) | |
| sigma_op_v = float(sigma_op.item()) | |
| sigma_g_v = float(sigma_g.item()) | |
| sigma_op_acc.append(sigma_op_v) | |
| sigma_g_acc.append(sigma_g_v) | |
| if sigma_op_v < SIGMA_FLOOR: | |
| # σ_op too tiny → ratio/contamination divergent; skip from | |
| # per-group ratio averages but still count it. | |
| degenerate_op_groups += 1 | |
| else: | |
| contam_acc.append(abs(float(mu_g.item()) - float(mu_op.item())) / sigma_op_v) | |
| sigma_ratio_acc.append(sigma_g_v / sigma_op_v) | |
| # Hypothetical baseline advantages (always full-group μ, σ) for the | |
| # on-policy rows — used to count sign-flips vs the actual variant. | |
| baseline_op_adv = (op_scores - mu_g) / (sigma_g + eps) | |
| actual_op_adv = g_adv[op_mask] | |
| sign_flips_mask = ( | |
| ((actual_op_adv > 0) & (baseline_op_adv < 0)) | |
| | ((actual_op_adv < 0) & (baseline_op_adv > 0)) | |
| ) | |
| sign_flip += int(sign_flips_mask.sum().item()) | |
| total_op += int(op_mask.sum().item()) | |
| if (baseline_op_adv <= 0).all(): | |
| no_op_pos_groups += 1 | |
| op_abs = actual_op_adv.abs() | |
| op_pos_mags.extend(op_abs[actual_op_adv > 0].tolist()) | |
| op_neg_mags.extend(op_abs[actual_op_adv < 0].tolist()) | |
| if g_oracle_mask.any(): | |
| oracle_mags.extend(g_adv[g_oracle_mask].abs().tolist()) | |
| n_groups += 1 | |
| if n_groups == 0: | |
| return {} | |
| mean_sigma_op = sum(sigma_op_acc) / n_groups if sigma_op_acc else 0.0 | |
| mean_sigma_g = sum(sigma_g_acc) / n_groups if sigma_g_acc else 0.0 | |
| mean_mu_op = sum(mu_op_acc) / n_groups if mu_op_acc else 0.0 | |
| mean_mu_g = sum(mu_g_acc) / n_groups if mu_g_acc else 0.0 | |
| out: dict[str, float] = { | |
| "orarl/mu_op_mean": mean_mu_op, | |
| "orarl/mu_g_mean": mean_mu_g, | |
| "orarl/sigma_op_mean": mean_sigma_op, | |
| "orarl/sigma_g_mean": mean_sigma_g, | |
| # Two ratio summaries — different aggregation: | |
| # - *_per_group: average of per-group ratios (more sensitive but | |
| # requires non-degenerate σ_op; we already filter < SIGMA_FLOOR). | |
| # - *_of_means: ratio of per-batch averages (always finite, more | |
| # stable for swanlab plots; use this if per_group is noisy). | |
| "orarl/sigma_ratio_per_group": ( | |
| sum(sigma_ratio_acc) / max(len(sigma_ratio_acc), 1) | |
| if sigma_ratio_acc else 0.0 | |
| ), | |
| "orarl/sigma_ratio_of_means": ( | |
| mean_sigma_g / mean_sigma_op if mean_sigma_op > 0 else 0.0 | |
| ), | |
| "orarl/oracle_mean_contamination_per_group": ( | |
| sum(contam_acc) / max(len(contam_acc), 1) if contam_acc else 0.0 | |
| ), | |
| "orarl/oracle_mean_contamination_of_means": ( | |
| abs(mean_mu_g - mean_mu_op) / mean_sigma_op if mean_sigma_op > 0 else 0.0 | |
| ), | |
| "orarl/degenerate_op_groups": float(degenerate_op_groups), | |
| "orarl/sign_flip_count": float(sign_flip), | |
| "orarl/sign_flip_rate": sign_flip / max(total_op, 1), | |
| "orarl/groups_with_no_op_pos_baseline": float(no_op_pos_groups), | |
| "orarl/n_groups": float(n_groups), | |
| } | |
| if oracle_mags: | |
| out["orarl/oracle_adv_mag_mean"] = sum(oracle_mags) / len(oracle_mags) | |
| out["orarl/oracle_adv_mag_max"] = max(oracle_mags) | |
| if op_pos_mags: | |
| out["orarl/op_adv_pos_mean"] = sum(op_pos_mags) / len(op_pos_mags) | |
| out["orarl/op_adv_pos_count"] = float(len(op_pos_mags)) | |
| if op_neg_mags: | |
| out["orarl/op_adv_neg_mean"] = sum(op_neg_mags) / len(op_neg_mags) | |
| out["orarl/op_adv_neg_count"] = float(len(op_neg_mags)) | |
| return out | |
| def apply_detached_oracle_advantage( | |
| data: DataProto, | |
| scale: float = 1.0, | |
| beta: float = 1.0, | |
| directional_gain_gamma: float = 0.0, | |
| directional_gain_max: float = 4.0, | |
| match_best_ratio: float = 0.0, | |
| match_best_min: float = 0.5, | |
| match_best_max: float = 2.0, | |
| eps: float = 1e-6, | |
| ) -> dict[str, float]: | |
| """Overwrite each oracle row's advantage with a detached positive anchor. | |
| A_oracle_raw = scale * w_g * gain_g | |
| w_g = clip((r_oracle - mean_op) / (r_oracle + eps), 0, 1) ** beta | |
| gain_g = clip((σ_g / σ_op) ** directional_gain_gamma, ≤ directional_gain_max) | |
| = 1 (γ=0) | |
| Optional best-on-policy matching (``match_best_ratio > 0``) keeps the | |
| detached oracle row from dominating the contrastive policy rows: | |
| cap_g = clip(match_best_ratio * max(A_op positive), match_best_min, match_best_max) | |
| A_oracle = min(A_oracle_raw, cap_g) | |
| The cap comes from the already-normalized, post-gain on-policy advantages in | |
| the same group. When a group has no positive on-policy advantage, | |
| ``match_best_min`` acts as a small bootstrap anchor rather than suppressing | |
| the oracle to zero. | |
| ``r_oracle`` is the oracle row's reward and ``mean_op`` the group's on-policy | |
| mean reward (both summed ``token_level_scores``). Setting rather than scaling | |
| the advantage means the oracle never reaches the policy rows through the | |
| whitening denominator: combined with the on-policy-only σ, a high-reward | |
| oracle can no longer inflate σ and shrink on-policy gradients. | |
| The anchor stays positive with a magnitude that tracks how far on-policy is | |
| from the oracle along two axes: | |
| * reward gap ``w_g`` — →0 on solved groups, →1 on hard groups; | |
| * variance gap ``gain_g`` — the same oracle-gap factor ``(σ_g/σ_op)^γ`` | |
| that scales the on-policy toward-oracle push. A positive | |
| ``directional_gain_gamma`` puts the anchor on that same pre-cap scale. | |
| σ_g is the full-group std, σ_op the on-policy std; gain_g falls back to | |
| 1 on a degenerate σ_op group. | |
| Mutates ``data.batch['advantages']`` in place, oracle rows only. No-op | |
| without ``is_oracle_row``. Emits per-step and per-task telemetry. | |
| """ | |
| SIGMA_OP_FALLBACK_THRESHOLD: float = 1e-3 | |
| is_oracle_row_np = data.non_tensor_batch.get("is_oracle_row", None) | |
| if is_oracle_row_np is None: | |
| return {} | |
| # Prefer the pure reward (pre-KL) for gating; fall back to the KL-adjusted one. | |
| if "token_level_scores" in data.batch: | |
| reward_tok = data.batch["token_level_scores"] | |
| else: | |
| reward_tok = data.batch["token_level_rewards"] | |
| advantages = data.batch["advantages"] | |
| response_mask = data.batch["response_mask"] | |
| scores = (reward_tok * response_mask).sum(dim=-1) # (bsz,) | |
| bsz = scores.shape[0] | |
| device = scores.device | |
| is_oracle = torch.tensor( | |
| [bool(is_oracle_row_np[i]) for i in range(bsz)], dtype=torch.bool, device=device | |
| ) | |
| uids = data.non_tensor_batch["uid"] | |
| problem_types = data.non_tensor_batch.get("problem_type", None) | |
| uid_to_idx: dict[Any, list[int]] = defaultdict(list) | |
| for i, uid in enumerate(uids): | |
| uid_to_idx[uid].append(i) | |
| w_acc: list[float] = [] | |
| a_oracle_acc: list[float] = [] | |
| gain_acc: list[float] = [] | |
| raw_a_oracle_acc: list[float] = [] | |
| best_op_adv_acc: list[float] = [] | |
| cap_acc: list[float] = [] | |
| cap_applied = 0 | |
| per_task_w: dict[str, list[float]] = defaultdict(list) | |
| n_oracle_groups = 0 | |
| use_gain = directional_gain_gamma > 0.0 | |
| use_best_match = match_best_ratio > 0.0 | |
| for indices in uid_to_idx.values(): | |
| idx_t = torch.tensor(indices, dtype=torch.long, device=device) | |
| g_oracle = is_oracle.index_select(0, idx_t) | |
| if not bool(g_oracle.any()): | |
| continue | |
| op_mask = ~g_oracle | |
| if int(op_mask.sum().item()) < 1: | |
| continue | |
| g_scores = scores.index_select(0, idx_t) | |
| r_oracle = float(g_scores[g_oracle].max().item()) | |
| op_scores = g_scores[op_mask] | |
| mean_op = float(op_scores.mean().item()) | |
| w = (r_oracle - mean_op) / (r_oracle + eps) | |
| w = max(0.0, min(1.0, w)) | |
| if beta != 1.0: | |
| w = float(w ** beta) | |
| # Directional gain (σ_g/σ_op)^γ — the SAME factor that scales the | |
| # on-policy toward-oracle push, so the anchor rides on the same scale. | |
| gain = 1.0 | |
| if use_gain and int(op_mask.sum().item()) >= 2: | |
| sigma_op = float(op_scores.std().item()) | |
| sigma_g = float(g_scores.std().item()) if g_scores.numel() >= 2 else 0.0 | |
| if sigma_op >= SIGMA_OP_FALLBACK_THRESHOLD: | |
| gain = max( | |
| 1.0, | |
| min( | |
| (sigma_g / (sigma_op + eps)) ** directional_gain_gamma, | |
| directional_gain_max, | |
| ), | |
| ) | |
| raw_a_oracle = float(scale) * w * gain | |
| a_oracle = raw_a_oracle | |
| if use_best_match: | |
| group_advantages = advantages.index_select(0, idx_t) | |
| group_response_mask = response_mask.index_select(0, idx_t) | |
| op_advantages = group_advantages[op_mask] | |
| op_response_mask = group_response_mask[op_mask].to(group_advantages.dtype) | |
| op_lengths = op_response_mask.sum(dim=-1).clamp_min(1.0) | |
| op_sequence_advantages = (op_advantages * op_response_mask).sum(dim=-1) / op_lengths | |
| positive_advantages = op_sequence_advantages[op_sequence_advantages > 0] | |
| best_op_adv = ( | |
| float(positive_advantages.max().item()) | |
| if positive_advantages.numel() > 0 | |
| else 0.0 | |
| ) | |
| target_cap = ( | |
| float(match_best_ratio) * best_op_adv | |
| if best_op_adv > 0.0 | |
| else float(match_best_min) | |
| ) | |
| target_cap = max(float(match_best_min), min(float(match_best_max), target_cap)) | |
| a_oracle = min(raw_a_oracle, target_cap) | |
| best_op_adv_acc.append(best_op_adv) | |
| cap_acc.append(target_cap) | |
| cap_applied += int(a_oracle < raw_a_oracle) | |
| for j in indices: | |
| if bool(is_oracle[j]): | |
| advantages[j] = a_oracle * response_mask[j].to(advantages.dtype) | |
| w_acc.append(w) | |
| a_oracle_acc.append(a_oracle) | |
| raw_a_oracle_acc.append(raw_a_oracle) | |
| gain_acc.append(gain) | |
| n_oracle_groups += 1 | |
| if problem_types is not None: | |
| per_task_w[str(problem_types[indices[0]])].append(w) | |
| if n_oracle_groups == 0: | |
| return {} | |
| data.batch["advantages"] = advantages | |
| out: dict[str, float] = { | |
| "orarl/detached_w_mean": float(sum(w_acc) / len(w_acc)), | |
| "orarl/detached_a_oracle_mean": float(sum(a_oracle_acc) / len(a_oracle_acc)), | |
| "orarl/detached_a_oracle_raw_mean": float(sum(raw_a_oracle_acc) / len(raw_a_oracle_acc)), | |
| "orarl/detached_gain_mean": float(sum(gain_acc) / len(gain_acc)), | |
| "orarl/detached_groups": float(n_oracle_groups), | |
| } | |
| if use_best_match: | |
| out.update({ | |
| "orarl/detached_best_op_adv_mean": float( | |
| sum(best_op_adv_acc) / len(best_op_adv_acc) | |
| ), | |
| "orarl/detached_match_cap_mean": float(sum(cap_acc) / len(cap_acc)), | |
| "orarl/detached_match_cap_applied_frac": float(cap_applied) / float(n_oracle_groups), | |
| }) | |
| for task, ws in per_task_w.items(): | |
| if ws: | |
| out[f"orarl/detached_w_mean_by_task/{task}"] = float(sum(ws) / len(ws)) | |
| return out | |
| # Per-row scalar metric keys to mirror from reward_metrics into | |
| # `batch.non_tensor_batch` so downstream consumers can read them at the rollout | |
| # level. ``iou_raw`` is the un-shaped IoU, the only reward component strictly | |
| # aligned with the evaluation metric. | |
| _ROW_LEVEL_REWARD_KEYS_TO_PROPAGATE: tuple[str, ...] = ("iou_raw",) | |
| def _propagate_per_row_reward_metrics( | |
| batch: DataProto, | |
| reward_metrics: dict[str, list[float]], | |
| keys: tuple[str, ...] = _ROW_LEVEL_REWARD_KEYS_TO_PROPAGATE, | |
| ) -> None: | |
| """Write selected per-row reward components into ``batch.non_tensor_batch``. | |
| ``reward_metrics`` is a dict-of-lists in row order (length == bsz). For | |
| keys present in ``reward_metrics`` we materialize an ndarray on the | |
| batch's non_tensor_batch so subsequent batch slicing and selection | |
| naturally keep the per-row alignment. Idempotent (skips keys already | |
| present). | |
| """ | |
| bsz = len(batch) | |
| for key in keys: | |
| if key in batch.non_tensor_batch: | |
| continue | |
| values = reward_metrics.get(key) | |
| if values is None: | |
| continue | |
| if len(values) != bsz: | |
| continue | |
| arr = np.asarray(values, dtype=np.float32) | |
| # Replace NaN/Inf with 0 so downstream torch ops never explode. | |
| np.nan_to_num(arr, copy=False, nan=0.0, posinf=0.0, neginf=0.0) | |
| batch.non_tensor_batch[key] = arr | |
| def _sequence_advantage_scores( | |
| advantages: torch.Tensor, | |
| response_mask: torch.Tensor, | |
| ) -> torch.Tensor: | |
| """Collapse token advantages without making selection response-length biased. | |
| GRPO broadcasts one scalar advantage over every valid response token. A | |
| masked sum therefore multiplies that scalar by the response length and can | |
| rank a weaker, longer completion above a stronger, shorter completion. | |
| Masked mean recovers the original scalar and remains well-defined for | |
| estimators whose token advantages are not uniform. | |
| """ | |
| if advantages.shape != response_mask.shape: | |
| raise ValueError( | |
| "advantages and response_mask must have identical shapes, got " | |
| f"{tuple(advantages.shape)} and {tuple(response_mask.shape)}." | |
| ) | |
| return VF.masked_mean(advantages, response_mask, dim=-1) | |
| def build_orarl_post_selection_references( | |
| data: DataProto, | |
| ) -> dict[Any, PostSelectionReference]: | |
| """Snapshot all policy rows before OraRL selection drops any of them.""" | |
| required_batch_keys = ("advantages", "response_mask") | |
| missing = [key for key in required_batch_keys if key not in data.batch] | |
| if missing or "uid" not in data.non_tensor_batch: | |
| raise ValueError( | |
| "post-selection balance requires advantages, response_mask, and uid; " | |
| f"missing={missing + ([] if 'uid' in data.non_tensor_batch else ['uid'])}." | |
| ) | |
| if "token_level_scores" in data.batch: | |
| reward_tokens = data.batch["token_level_scores"] | |
| elif "token_level_rewards" in data.batch: | |
| reward_tokens = data.batch["token_level_rewards"] | |
| else: | |
| raise ValueError( | |
| "post-selection balance requires token_level_scores or token_level_rewards." | |
| ) | |
| advantages = data.batch["advantages"] | |
| response_mask = data.batch["response_mask"] | |
| sequence_advantages = _sequence_advantage_scores( | |
| advantages, | |
| response_mask, | |
| ).detach().float() | |
| sequence_rewards = ( | |
| reward_tokens * response_mask.to(reward_tokens.dtype) | |
| ).sum(dim=-1).detach().float() | |
| uids = np.asarray(data.non_tensor_batch["uid"], dtype=object) | |
| is_oracle = np.asarray( | |
| data.non_tensor_batch.get( | |
| "is_oracle_row", | |
| np.zeros(sequence_advantages.shape[0], dtype=bool), | |
| ), | |
| dtype=bool, | |
| ) | |
| if len(uids) != sequence_advantages.shape[0] or len(is_oracle) != len(uids): | |
| raise ValueError("uid and oracle flags must align with the batch rows.") | |
| uid_to_rows: dict[Any, list[int]] = defaultdict(list) | |
| for row, uid in enumerate(uids): | |
| uid_to_rows[uid].append(row) | |
| references: dict[Any, PostSelectionReference] = {} | |
| device = sequence_advantages.device | |
| for uid, rows in uid_to_rows.items(): | |
| op_rows = [row for row in rows if not bool(is_oracle[row])] | |
| if not op_rows: | |
| continue | |
| op_idx = torch.tensor(op_rows, dtype=torch.long, device=device) | |
| op_advantages = sequence_advantages.index_select(0, op_idx) | |
| op_rewards = sequence_rewards.index_select(0, op_idx) | |
| on_policy_rms = float(torch.sqrt(torch.mean(op_advantages.square())).item()) | |
| sigma_op = ( | |
| float(op_rewards.std().item()) | |
| if op_rewards.numel() >= 2 | |
| else 0.0 | |
| ) | |
| references[uid] = PostSelectionReference( | |
| on_policy_rms=on_policy_rms, | |
| sigma_op=sigma_op, | |
| on_policy_rows=len(op_rows), | |
| ) | |
| return references | |
| def apply_orarl_post_selection_advantage_balance( | |
| data: DataProto, | |
| references: dict[Any, PostSelectionReference], | |
| *, | |
| recenter: bool, | |
| rms_match: bool, | |
| rms_min_scale: float, | |
| ) -> dict[str, float]: | |
| """Correct each OraRL-selected group before the actor backward pass.""" | |
| if not recenter and not rms_match: | |
| return {} | |
| is_oracle_np = data.non_tensor_batch.get("is_oracle_row") | |
| if is_oracle_np is None: | |
| return { | |
| "orarl/post_selection_enabled": 1.0, | |
| "orarl/post_selection_recenter": float(recenter), | |
| "orarl/post_selection_rms_match": float(rms_match), | |
| "orarl/post_selection_groups": 0.0, | |
| "orarl/post_selection_groups_skipped": 0.0, | |
| } | |
| advantages = data.batch["advantages"] | |
| response_mask = data.batch["response_mask"] | |
| sequence_advantages = _sequence_advantage_scores( | |
| advantages, | |
| response_mask, | |
| ).detach().float() | |
| uids = np.asarray(data.non_tensor_batch["uid"], dtype=object) | |
| is_oracle = np.asarray(is_oracle_np, dtype=bool) | |
| if len(uids) != sequence_advantages.shape[0] or len(is_oracle) != len(uids): | |
| raise ValueError("uid and oracle flags must align with selected batch rows.") | |
| uid_to_rows: dict[Any, list[int]] = defaultdict(list) | |
| for row, uid in enumerate(uids): | |
| uid_to_rows[uid].append(row) | |
| metric_values: dict[str, list[float]] = defaultdict(list) | |
| processed_groups = 0 | |
| skipped_groups = 0 | |
| device = sequence_advantages.device | |
| for uid, rows in uid_to_rows.items(): | |
| reference = references.get(uid) | |
| group_oracle = np.asarray( | |
| [bool(is_oracle[row]) for row in rows], | |
| dtype=bool, | |
| ) | |
| oracle_rows = int(group_oracle.sum()) | |
| if oracle_rows > 1: | |
| raise ValueError( | |
| f"post-selection group {uid!r} contains {oracle_rows} oracle " | |
| "rows; expected at most one." | |
| ) | |
| no_actual_selection = ( | |
| reference is not None | |
| and reference.on_policy_rows > 0 | |
| and len(rows) >= reference.on_policy_rows + 1 | |
| ) | |
| if reference is None or oracle_rows == 0 or no_actual_selection: | |
| skipped_groups += 1 | |
| continue | |
| idx_t = torch.tensor(rows, dtype=torch.long, device=device) | |
| active_advantages = sequence_advantages.index_select(0, idx_t) | |
| active_is_oracle = torch.tensor( | |
| group_oracle, | |
| dtype=torch.bool, | |
| device=device, | |
| ) | |
| balanced, group_metrics = balance_post_selection_group( | |
| active_advantages, | |
| active_is_oracle, | |
| reference=reference, | |
| recenter=recenter, | |
| rms_match=rms_match, | |
| rms_min_scale=rms_min_scale, | |
| ) | |
| balanced_tokens = ( | |
| balanced.to(dtype=advantages.dtype).unsqueeze(-1) | |
| * response_mask.index_select(0, idx_t).to(advantages.dtype) | |
| ) | |
| advantages.index_copy_( | |
| 0, | |
| idx_t.to(device=advantages.device), | |
| balanced_tokens.to(device=advantages.device), | |
| ) | |
| for key, value in group_metrics.items(): | |
| metric_values[key].append(value) | |
| processed_groups += 1 | |
| data.batch["advantages"] = advantages | |
| metrics: dict[str, float] = { | |
| "orarl/post_selection_enabled": 1.0, | |
| "orarl/post_selection_recenter": float(recenter), | |
| "orarl/post_selection_rms_match": float(rms_match), | |
| "orarl/post_selection_groups": float(processed_groups), | |
| "orarl/post_selection_groups_skipped": float(skipped_groups), | |
| } | |
| for key, values in metric_values.items(): | |
| if values: | |
| metrics[f"orarl/post_selection_{key}"] = float( | |
| sum(values) / len(values) | |
| ) | |
| return metrics | |
| def _rollout_token_diversity_metrics( | |
| responses: torch.Tensor, | |
| response_mask: torch.Tensor, | |
| n_rollouts: int, | |
| ) -> dict[str, float]: | |
| """Measure whether an ``n``-sample rollout actually explores distinct text.""" | |
| if responses.shape != response_mask.shape: | |
| raise ValueError("responses and response_mask must have identical shapes.") | |
| if n_rollouts <= 1 or responses.shape[0] % n_rollouts != 0: | |
| return {} | |
| group_unique_fractions: list[float] = [] | |
| all_identical = 0 | |
| pairwise_agreements: list[float] = [] | |
| length_stds: list[float] = [] | |
| for start in range(0, responses.shape[0], n_rollouts): | |
| group_ids = responses[start : start + n_rollouts] | |
| group_mask = response_mask[start : start + n_rollouts].bool() | |
| sequences = [ | |
| tuple(group_ids[i][group_mask[i]].detach().cpu().tolist()) | |
| for i in range(n_rollouts) | |
| ] | |
| unique_count = len(set(sequences)) | |
| group_unique_fractions.append(unique_count / n_rollouts) | |
| all_identical += int(unique_count == 1) | |
| lengths = group_mask.sum(dim=-1).to(dtype=torch.float32) | |
| length_stds.append(float(lengths.std(unbiased=False).item())) | |
| for i in range(n_rollouts): | |
| for j in range(i + 1, n_rollouts): | |
| common = group_mask[i] & group_mask[j] | |
| if common.any(): | |
| agreement = (group_ids[i][common] == group_ids[j][common]).float().mean() | |
| pairwise_agreements.append(float(agreement.item())) | |
| num_groups = len(group_unique_fractions) | |
| return { | |
| "rollout/exact_unique_fraction": float(sum(group_unique_fractions) / num_groups), | |
| "rollout/all_identical_group_fraction": float(all_identical / num_groups), | |
| "rollout/pairwise_token_agreement_mean": float( | |
| sum(pairwise_agreements) / max(1, len(pairwise_agreements)) | |
| ), | |
| "rollout/response_length_std_mean": float(sum(length_stds) / num_groups), | |
| } | |
| def _on_policy_reward_diversity_metrics(data: DataProto) -> dict[str, float]: | |
| """Report useful reward spread per prompt, excluding injected oracle rows.""" | |
| if "token_level_scores" not in data.batch or "uid" not in data.non_tensor_batch: | |
| return {} | |
| scores = data.batch["token_level_scores"].sum(dim=-1).detach().cpu().float().tolist() | |
| uids = np.asarray(data.non_tensor_batch["uid"], dtype=object) | |
| is_oracle = np.asarray( | |
| data.non_tensor_batch.get("is_oracle_row", np.zeros(len(scores), dtype=bool)), | |
| dtype=bool, | |
| ) | |
| grouped: dict[Any, list[float]] = defaultdict(list) | |
| for uid, score, oracle_row in zip(uids, scores, is_oracle): | |
| if not oracle_row: | |
| grouped[uid].append(float(score)) | |
| stds: list[float] = [] | |
| unique_fractions: list[float] = [] | |
| for group_scores in grouped.values(): | |
| if len(group_scores) <= 1: | |
| continue | |
| score_tensor = torch.tensor(group_scores, dtype=torch.float32) | |
| stds.append(float(score_tensor.std(unbiased=False).item())) | |
| unique_fractions.append( | |
| len({round(score, 7) for score in group_scores}) / len(group_scores) | |
| ) | |
| if not stds: | |
| return {} | |
| return { | |
| "rollout/reward_group_std_mean": float(sum(stds) / len(stds)), | |
| "rollout/zero_reward_std_group_fraction": float( | |
| sum(std <= 1e-8 for std in stds) / len(stds) | |
| ), | |
| "rollout/reward_unique_fraction": float( | |
| sum(unique_fractions) / len(unique_fractions) | |
| ), | |
| } | |
| class RayPPOTrainer: | |
| """ | |
| Note that this trainer runs on the driver process on a single CPU/GPU node. | |
| """ | |
| def __init__( | |
| self, | |
| config: PPOConfig, | |
| tokenizer: PreTrainedTokenizer, | |
| processor: Optional[ProcessorMixin], | |
| train_dataloader: StatefulDataLoader, | |
| val_dataloader: StatefulDataLoader, | |
| role_worker_mapping: dict[Role, Type[Worker]], | |
| resource_pool_manager: ResourcePoolManager, | |
| ray_worker_group_cls: Type[RayWorkerGroup] = RayWorkerGroup, | |
| reward_fn: Optional[AutoRewardManager] = None, | |
| val_reward_fn: Optional[AutoRewardManager] = None, | |
| ): | |
| self.tokenizer = tokenizer | |
| self.processor = processor | |
| self.train_dataloader = train_dataloader | |
| self.val_dataloader = val_dataloader | |
| self.config = config | |
| self.reward_fn = reward_fn | |
| self.val_reward_fn = val_reward_fn | |
| self._is_orarl = is_orarl(config) | |
| validate_algorithm(config) | |
| self.val_reward_score = 0.0 | |
| self.best_val_reward_score = -1.0 | |
| self.best_global_step = None | |
| # Best-train ckpt tracking (separate from rolling save_limit window). | |
| # See TrainerConfig.keep_best_train_ckpt for rationale. The buffer is a | |
| # FIFO of recent metric values; we save when the smoothed value strictly | |
| # improves and we have already passed best_train_min_step. | |
| self._best_train_window: deque[float] = deque( | |
| maxlen=max(1, int(config.trainer.best_train_smooth_window)) | |
| ) | |
| self._best_train_score: float = float("-inf") | |
| self._best_train_step: int = 0 | |
| self.hybrid_engine = config.worker.hybrid_engine | |
| self.role_worker_mapping = role_worker_mapping | |
| self.resource_pool_manager = resource_pool_manager | |
| self.use_reward_model = Role.RewardModel in role_worker_mapping | |
| self.ray_worker_group_cls = ray_worker_group_cls | |
| # define KL control | |
| if config.algorithm.disable_kl: | |
| self.use_reference_policy = False | |
| self.kl_ctrl = FixedKLController(init_kl_coef=0.0) | |
| print("KL is disabled, no KL metrics will be logged. Please set `kl_coef=0` to log KL metrics.") | |
| else: | |
| self.use_reference_policy = True | |
| self.kl_ctrl = get_kl_controller(config.algorithm) | |
| if config.algorithm.adv_estimator == AdvantageEstimator.GAE: | |
| self.use_critic = True | |
| else: | |
| self.use_critic = False | |
| if config.algorithm.adv_estimator not in list(AdvantageEstimator): | |
| raise NotImplementedError(f"Unknown advantage estimator: {config.algorithm.adv_estimator}.") | |
| # generate_sequences uses DP_COMPUTE_PROTO, which splits the prompt | |
| # batch across every actor-rollout worker before rollout.n fan-out. | |
| # Validate the dataloader's actual batch size here instead of failing | |
| # after all Ray workers and rollout engines have been initialized. | |
| rollout_world_size = config.trainer.n_gpus_per_node * config.trainer.nnodes | |
| rollout_dispatch_batch_size = ( | |
| config.data.mini_rollout_batch_size | |
| if config.data.mini_rollout_batch_size is not None | |
| else config.data.rollout_batch_size | |
| ) | |
| if rollout_dispatch_batch_size % rollout_world_size != 0: | |
| batch_size_key = ( | |
| "data.mini_rollout_batch_size" | |
| if config.data.mini_rollout_batch_size is not None | |
| else "data.rollout_batch_size" | |
| ) | |
| raise ValueError( | |
| f"{batch_size_key}={rollout_dispatch_batch_size} must be divisible by " | |
| f"the actor-rollout world size {rollout_world_size} " | |
| f"(trainer.nnodes={config.trainer.nnodes} * " | |
| f"trainer.n_gpus_per_node={config.trainer.n_gpus_per_node}). " | |
| "The prompt batch is sharded before worker.rollout.n is applied." | |
| ) | |
| if config.data.rollout_batch_size % config.worker.actor.global_batch_size != 0: | |
| raise ValueError("Rollout batch size must be divisible by actor global batch size.") | |
| if ( | |
| config.data.rollout_batch_size * config.worker.rollout.n | |
| ) % config.worker.actor.micro_batch_size_per_device_for_experience != 0: | |
| raise ValueError( | |
| "Rollout batch size * rollout.n must be divisible by actor micro batch size for experience." | |
| ) | |
| if self.use_critic: | |
| if config.data.rollout_batch_size % config.worker.critic.global_batch_size != 0: | |
| raise ValueError("Rollout batch size must be divisible by critic global batch size.") | |
| if ( | |
| config.data.rollout_batch_size * config.worker.rollout.n | |
| ) % config.worker.critic.micro_batch_size_per_device_for_experience != 0: | |
| raise ValueError( | |
| "Rollout batch size * rollout.n must be divisible by critic micro batch size for experience." | |
| ) | |
| if ( | |
| config.algorithm.adv_estimator in (AdvantageEstimator.GRPO, AdvantageEstimator.RLOO) | |
| and config.worker.rollout.n == 1 | |
| ): | |
| raise ValueError("GRPO and RLOO algorithm need `config.worker.rollout.n > 1`.") | |
| _scale_rewards = bool(getattr(config.algorithm, "scale_rewards", True)) | |
| _adv_est = getattr( | |
| config.algorithm.adv_estimator, | |
| "value", | |
| config.algorithm.adv_estimator, | |
| ) | |
| if _adv_est == "grpo": | |
| if _scale_rewards: | |
| print("[GRPO] Scaled advantages ON: A=(r-mean)/(std+eps).") | |
| else: | |
| print("[GRPO] Raw-centered advantages ON: A=r-mean (no std whitening).") | |
| self._oracle_builder = None | |
| if self._is_orarl: | |
| if getattr(config.worker.rollout, "calculate_log_probs", False): | |
| raise ValueError( | |
| "OraRL oracle rows require worker.rollout.calculate_log_probs=false " | |
| "so old log probabilities are recomputed for their actual tokens." | |
| ) | |
| self._oracle_builder = _load_dotted_callable( | |
| config.algorithm.oracle_builder | |
| ) | |
| print( | |
| "[OraRL] enabled | append annotation-as-rollout | " | |
| f"directional_gain_gamma={config.algorithm.directional_gain_gamma} | " | |
| "detached oracle | strict sign-balanced selection | " | |
| "post-selection moment correction" | |
| ) | |
| if config.trainer.max_steps is not None: | |
| self.training_steps = config.trainer.max_steps | |
| elif config.data.mini_rollout_batch_size is not None: | |
| num_examples = len(train_dataloader) * config.data.mini_rollout_batch_size | |
| self.training_steps = num_examples // config.data.rollout_batch_size * config.trainer.total_epochs | |
| else: | |
| self.training_steps = len(train_dataloader) * config.trainer.total_epochs | |
| rollout_rows = config.data.rollout_batch_size * config.worker.rollout.n | |
| actor_global_batch_size = config.worker.actor.global_batch_size | |
| self.skip_old_log_probs = ( | |
| _skip_old_log_probs_enabled() | |
| and config.worker.actor.ppo_epochs == 1 | |
| and actor_global_batch_size in {config.data.rollout_batch_size, rollout_rows} | |
| ) | |
| if _skip_old_log_probs_enabled() and not self.skip_old_log_probs: | |
| print( | |
| "[trainer] VERL_SKIP_OLD_LOGPROBS=1 requested but disabled for this run: " | |
| "requires actor.ppo_epochs=1 and one actor global mini-batch per rollout step." | |
| ) | |
| elif self.skip_old_log_probs: | |
| print( | |
| "[trainer] VERL_SKIP_OLD_LOGPROBS=1: skipping FSDP old_log_probs recompute; " | |
| "actor update will use its first forward log_probs.detach() as old_log_probs. " | |
| "Reference-policy KL, if enabled, is still computed separately." | |
| ) | |
| config.worker.actor.optim.training_steps = self.training_steps | |
| config.worker.critic.optim.training_steps = self.training_steps | |
| print(f"Total training steps: {self.training_steps}") | |
| def init_workers(self) -> None: | |
| """Init resource pool and worker group""" | |
| self.resource_pool_manager.create_resource_pool() | |
| self.resource_pool_to_cls = {pool: {} for pool in self.resource_pool_manager.resource_pool_dict.values()} | |
| # create actor, rollout and ref | |
| if self.hybrid_engine: | |
| resource_pool = self.resource_pool_manager.get_resource_pool(Role.ActorRolloutRef) | |
| actor_rollout_ref_cls = RayClassWithInitArgs( | |
| cls=self.role_worker_mapping[Role.ActorRolloutRef], config=self.config.worker, role="actor_rollout_ref" | |
| ) | |
| self.resource_pool_to_cls[resource_pool]["actor_rollout_ref"] = actor_rollout_ref_cls | |
| else: | |
| raise NotImplementedError | |
| # create critic | |
| if self.use_critic: | |
| resource_pool = self.resource_pool_manager.get_resource_pool(Role.Critic) | |
| critic_cls = RayClassWithInitArgs( | |
| cls=self.role_worker_mapping[Role.Critic], config=self.config.worker, role="critic" | |
| ) | |
| self.resource_pool_to_cls[resource_pool]["critic"] = critic_cls | |
| # create a reward model if reward_fn is None | |
| if self.use_reward_model: | |
| # we create a RM here | |
| resource_pool = self.resource_pool_manager.get_resource_pool(Role.RewardModel) | |
| rm_cls = RayClassWithInitArgs( | |
| cls=self.role_worker_mapping[Role.RewardModel], config=self.config.worker, role="reward" | |
| ) | |
| self.resource_pool_to_cls[resource_pool]["rm"] = rm_cls | |
| # initialize WorkerGroup | |
| # NOTE: if you want to use a different resource pool for each role, which can support different parallel size, | |
| # you should not use `create_colocated_worker_cls`. Instead, directly pass different resource pool to different worker groups. | |
| # See https://github.com/volcengine/verl/blob/master/examples/ray/tutorial.ipynb for more information. | |
| all_wg: dict[str, FSDPWorker] = {} | |
| self.wg_dicts = [] | |
| for resource_pool, class_dict in self.resource_pool_to_cls.items(): | |
| worker_dict_cls = create_colocated_worker_cls(class_dict=class_dict) | |
| wg_dict = self.ray_worker_group_cls(resource_pool=resource_pool, ray_cls_with_init=worker_dict_cls) | |
| spawn_wg = wg_dict.spawn(prefix_set=class_dict.keys()) | |
| all_wg.update(spawn_wg) | |
| # keep the referece of WorkerDict to support ray >= 2.31. Ref: https://github.com/ray-project/ray/pull/45699 | |
| self.wg_dicts.append(wg_dict) | |
| if self.use_critic: | |
| self.critic_wg = all_wg["critic"] | |
| self.critic_wg.init_model() | |
| if self.use_reward_model: | |
| self.rm_wg = all_wg["rm"] | |
| self.rm_wg.init_model() | |
| # we should create rollout at the end so that vllm can have a better estimation of kv cache memory | |
| self.actor_rollout_ref_wg = all_wg["actor_rollout_ref"] | |
| self.actor_rollout_ref_wg.init_model() | |
| def _save_checkpoint(self) -> None: | |
| # path: {save_checkpoint_path}/global_step_{global_step}/{actor,critic} | |
| # Only advance best_global_step on steps that actually ran validation. | |
| # The condition must match the one guarding _validate() in the main loop. | |
| # Otherwise val_reward_score stays at its initial 0.0 while | |
| # best_val_reward_score starts at -1.0, so 0.0 > -1.0 always holds and | |
| # the first save is pinned as "best" forever. remove_obsolete_ckpt would | |
| # then protect the oldest checkpoint and evict recent ones instead — with | |
| # save_limit=2 the disk keeps the oldest plus the newest rather than the | |
| # two most recent. | |
| validation_ran_this_step = ( | |
| self.val_reward_fn is not None | |
| and self.config.trainer.val_freq > 0 | |
| and self.global_step % self.config.trainer.val_freq == 0 | |
| ) | |
| if validation_ran_this_step and self.val_reward_score > self.best_val_reward_score: | |
| self.best_val_reward_score = self.val_reward_score | |
| self.best_global_step = self.global_step | |
| remove_obsolete_ckpt( | |
| self.config.trainer.save_checkpoint_path, | |
| self.global_step, | |
| self.best_global_step, | |
| self.config.trainer.save_limit, | |
| ) | |
| folder_path = os.path.join(self.config.trainer.save_checkpoint_path, f"global_step_{self.global_step}") | |
| actor_path = os.path.join(folder_path, "actor") | |
| self.actor_rollout_ref_wg.save_checkpoint(actor_path, save_model_only=self.config.trainer.save_model_only) | |
| if self.use_critic: | |
| critic_path = os.path.join(folder_path, "critic") | |
| self.critic_wg.save_checkpoint(critic_path, save_model_only=self.config.trainer.save_model_only) | |
| dataloader_path = os.path.join(folder_path, "dataloader.pt") | |
| dataloader_state_dict = self.train_dataloader.state_dict() | |
| torch.save(dataloader_state_dict, dataloader_path) | |
| checkpointer_tracker_info = { | |
| "best_global_step": self.best_global_step, | |
| "best_val_reward_score": round(self.best_val_reward_score, 4), | |
| "last_global_step": self.global_step, | |
| "last_actor_path": os.path.abspath(actor_path), | |
| } | |
| checkpointer_tracker_path = os.path.join(self.config.trainer.save_checkpoint_path, CHECKPOINT_TRACKER) | |
| with open(checkpointer_tracker_path, "w") as f: | |
| json.dump(checkpointer_tracker_info, f, ensure_ascii=False, indent=2) | |
| # Keep optimizer/extra_state/dataloader only for the newest checkpoint and | |
| # thin older ones down to weights (huggingface/ + model_*.pt). | |
| if ( | |
| self.config.trainer.keep_optim_only_latest | |
| and not self.config.trainer.save_model_only | |
| ): | |
| thin_out_old_ckpts( | |
| self.config.trainer.save_checkpoint_path, | |
| keep_full_step=self.global_step, | |
| ) | |
| def _maybe_save_best_train_checkpoint(self, metrics: dict) -> None: | |
| """Save model-only ckpt when smoothed train metric hits a new best. | |
| Independent of save_freq / save_limit: | |
| - Lives under {save_checkpoint_path}/best_train/global_step_{N}/actor/ | |
| - Always model-only (no optim/dataloader): for downstream eval only. | |
| - Old best is removed atomically when a new best is found, so the | |
| best_train/ subdir holds at most ONE checkpoint. | |
| Decision rule: | |
| smoothed = mean of last `best_train_smooth_window` values of | |
| metrics[best_train_metric_key] | |
| if step >= best_train_min_step AND smoothed > self._best_train_score: | |
| save and update tracker | |
| """ | |
| cfg = self.config.trainer | |
| if not getattr(cfg, "keep_best_train_ckpt", False): | |
| return | |
| metric_val = metrics.get(cfg.best_train_metric_key) | |
| if metric_val is None: | |
| return | |
| try: | |
| metric_val = float(metric_val) | |
| except (TypeError, ValueError): | |
| return | |
| self._best_train_window.append(metric_val) | |
| if self.global_step < int(cfg.best_train_min_step): | |
| return | |
| if len(self._best_train_window) < self._best_train_window.maxlen: | |
| # Wait until the smoothing buffer is full, otherwise early steps | |
| # have an artificially small window and bias toward early peaks. | |
| return | |
| smoothed = sum(self._best_train_window) / len(self._best_train_window) | |
| if smoothed <= self._best_train_score: | |
| return | |
| # New best: save model-only into a side directory. | |
| prev_best_step = self._best_train_step | |
| prev_best_score = self._best_train_score | |
| self._best_train_score = smoothed | |
| self._best_train_step = self.global_step | |
| best_root = os.path.join(cfg.save_checkpoint_path, "best_train") | |
| os.makedirs(best_root, exist_ok=True) | |
| new_dir = os.path.join(best_root, f"global_step_{self.global_step}") | |
| new_actor = os.path.join(new_dir, "actor") | |
| # Save current step (model only). Force save_model_only=True regardless | |
| # of the global flag so this side ckpt stays small. | |
| self.actor_rollout_ref_wg.save_checkpoint(new_actor, save_model_only=True) | |
| if self.use_critic: | |
| new_critic = os.path.join(new_dir, "critic") | |
| self.critic_wg.save_checkpoint(new_critic, save_model_only=True) | |
| # Tracker file lets downstream eval scripts auto-discover the best step. | |
| tracker = { | |
| "best_train_step": self._best_train_step, | |
| "best_train_metric_key": cfg.best_train_metric_key, | |
| "best_train_smoothed_value": round(self._best_train_score, 6), | |
| "best_train_smooth_window": self._best_train_window.maxlen, | |
| "best_train_actor_path": os.path.abspath(new_actor), | |
| "previous_best_step": prev_best_step, | |
| "previous_best_smoothed_value": ( | |
| round(prev_best_score, 6) if prev_best_score != float("-inf") else None | |
| ), | |
| } | |
| with open(os.path.join(best_root, "best_train_tracker.json"), "w") as f: | |
| json.dump(tracker, f, ensure_ascii=False, indent=2) | |
| # Evict the previous best ckpt directory (we only keep one in best_train/). | |
| if prev_best_step and prev_best_step != self.global_step: | |
| old_dir = os.path.join(best_root, f"global_step_{prev_best_step}") | |
| if os.path.isdir(old_dir): | |
| import shutil | |
| try: | |
| shutil.rmtree(old_dir) | |
| except OSError as exc: | |
| print(f"[best-train ckpt] WARN: failed to remove {old_dir}: {exc}") | |
| prev_score_str = ( | |
| f"{prev_best_score:.4f}" if prev_best_score != float("-inf") else "n/a" | |
| ) | |
| print( | |
| f"[best-train ckpt] step={self.global_step} " | |
| f"smoothed_{cfg.best_train_metric_key}={self._best_train_score:.4f} " | |
| f"(prev best step={prev_best_step or 0}, value={prev_score_str}) " | |
| f"→ {new_actor}", | |
| flush=True, | |
| ) | |
| def _load_checkpoint(self) -> None: | |
| if self.config.trainer.load_checkpoint_path is not None: | |
| load_checkpoint_path = self.config.trainer.load_checkpoint_path | |
| elif self.config.trainer.find_last_checkpoint: | |
| load_checkpoint_path, tracker_info = find_latest_ckpt(self.config.trainer.save_checkpoint_path) | |
| if tracker_info is not None: | |
| self.best_val_reward_score = tracker_info.get("best_val_reward_score", 0.0) | |
| self.best_global_step = tracker_info.get("best_global_step", 0) | |
| else: | |
| load_checkpoint_path = None | |
| if load_checkpoint_path is None: | |
| return | |
| if "global_step_" not in load_checkpoint_path.strip(os.path.sep).split(os.path.sep)[-1]: | |
| raise ValueError("`load_checkpoint_path` should end with `global_step_*`.") | |
| print(f"Load from checkpoint: {load_checkpoint_path}.") | |
| self.global_step = int(load_checkpoint_path.strip(os.path.sep).split("global_step_")[-1]) | |
| actor_path = os.path.join(load_checkpoint_path, "actor") | |
| self.actor_rollout_ref_wg.load_checkpoint(actor_path) | |
| if self.use_critic: | |
| critic_path = os.path.join(load_checkpoint_path, "critic") | |
| self.critic_wg.load_checkpoint(critic_path) | |
| dataloader_path = os.path.join(load_checkpoint_path, "dataloader.pt") | |
| if os.path.exists(dataloader_path): | |
| dataloader_state_dict = torch.load(dataloader_path, weights_only=False) | |
| self.train_dataloader.load_state_dict(dataloader_state_dict) | |
| else: | |
| print(f"No dataloader state found at {dataloader_path}, will start from scratch.") | |
| def _assert_multimodal_contract(self, data: DataProto, stage: str) -> None: | |
| if "multi_modal_data" not in data.non_tensor_batch: | |
| return | |
| problem_ids = data.non_tensor_batch.get("problem_id", None) | |
| uids = data.non_tensor_batch.get("uid", None) | |
| for idx, multi_modal_data in enumerate(data.non_tensor_batch["multi_modal_data"]): | |
| try: | |
| validate_multi_modal_data_contract(multi_modal_data) | |
| except Exception as exc: | |
| problem_id = None if problem_ids is None else problem_ids[idx] | |
| uid = None if uids is None else uids[idx] | |
| raise ValueError( | |
| f"{stage}: invalid multi_modal_data at index={idx}, uid={uid}, problem_id={problem_id}: {exc}" | |
| ) from exc | |
| def _maybe_log_val_generations( | |
| self, | |
| inputs: list[str], | |
| outputs: list[str], | |
| labels: list[str], | |
| scores: list[float], | |
| problem_ids: list[Any], | |
| ) -> None: | |
| """Log a table of validation samples""" | |
| if self.config.trainer.val_generations_to_log <= 0: | |
| return | |
| # Create tuples of (input, output, label, score, problem_id) and sort by input text | |
| samples = list(zip(inputs, outputs, labels, scores, problem_ids)) | |
| samples.sort(key=lambda x: x[0]) # Sort by input text | |
| # Use fixed random seed for deterministic shuffling | |
| rng = np.random.RandomState(42) | |
| rng.shuffle(samples) | |
| samples = samples[: self.config.trainer.val_generations_to_log] | |
| self.logger.log_generation(samples, self.global_step) | |
| def _validate(self) -> dict[str, Any]: | |
| reward_tensor_lst = [] | |
| # Lists to collect samples for the table | |
| sample_inputs, sample_outputs, sample_labels, sample_scores, sample_problem_ids = [], [], [], [], [] | |
| reward_metrics_lst = defaultdict(list) | |
| length_metrics_lst = defaultdict(list) | |
| print("Start validation...") | |
| self.actor_rollout_ref_wg.prepare_rollout_engine() | |
| for batch_dict in self.val_dataloader: | |
| test_batch = DataProto.from_single_dict(batch_dict) | |
| test_gen_batch = test_batch.pop( | |
| batch_keys=["input_ids", "attention_mask", "position_ids"], | |
| non_tensor_batch_keys=["raw_prompt_ids", "multi_modal_data"], | |
| ) | |
| repeat_times = self.config.worker.rollout.val_override_config.get("n", 1) | |
| test_gen_batch.meta_info = self.config.worker.rollout.val_override_config | |
| test_gen_batch.meta_info["image_min_pixels"] = self.config.data.image_min_pixels | |
| test_gen_batch.meta_info["image_max_pixels"] = self.config.data.image_max_pixels | |
| test_gen_batch.meta_info["video_min_pixels"] = self.config.data.val_video_min_pixels | |
| test_gen_batch.meta_info["video_max_pixels"] = self.config.data.val_video_max_pixels | |
| test_gen_batch.meta_info["video_total_pixels"] = self.config.data.val_video_total_pixels | |
| test_gen_batch.meta_info["video_fps"] = self.config.data.val_video_fps | |
| test_gen_batch.meta_info["video_max_frames"] = self.config.data.val_video_max_frames | |
| self._assert_multimodal_contract(test_gen_batch, stage="validate") | |
| test_gen_batch, pad_size = pad_dataproto_to_divisor(test_gen_batch, self.actor_rollout_ref_wg.world_size) | |
| test_output_gen_batch = self.actor_rollout_ref_wg.generate_sequences(test_gen_batch) | |
| test_output_gen_batch = unpad_dataproto(test_output_gen_batch, pad_size=pad_size * repeat_times) | |
| # repeat to align with repeated responses in rollout | |
| test_batch = test_batch.repeat(repeat_times=repeat_times, interleave=True) | |
| test_batch = test_batch.union(test_output_gen_batch) | |
| # evaluate using reward_function | |
| # Only pass fields needed by reward, excluding large multi_modal_data to reduce serialization | |
| val_reward_batch = test_batch.select( | |
| batch_keys=["responses", "response_mask"], | |
| non_tensor_batch_keys=[k for k in test_batch.non_tensor_batch if k != "multi_modal_data"], | |
| ) | |
| reward_tensor, reward_metrics = ray.get(self.val_reward_fn.compute_reward.remote(val_reward_batch)) | |
| # store generations | |
| input_ids = test_batch.batch["prompts"] | |
| input_texts = [self.tokenizer.decode(ids, skip_special_tokens=True) for ids in input_ids] | |
| output_ids = test_batch.batch["responses"] | |
| output_texts = [self.tokenizer.decode(ids, skip_special_tokens=True) for ids in output_ids] | |
| scores = reward_tensor.sum(-1).cpu().tolist() | |
| sample_inputs.extend(input_texts) | |
| sample_outputs.extend(output_texts) | |
| sample_labels.extend(test_batch.non_tensor_batch["ground_truth"].tolist()) | |
| sample_scores.extend(scores) | |
| if "problem_id" in test_batch.non_tensor_batch: | |
| sample_problem_ids.extend(test_batch.non_tensor_batch["problem_id"].tolist()) | |
| else: | |
| sample_problem_ids.extend([None] * len(scores)) | |
| reward_tensor_lst.append(reward_tensor) | |
| for key, value in reward_metrics.items(): | |
| reward_metrics_lst[key].extend(value) | |
| for key, value in compute_length_metrics(test_batch).items(): | |
| length_metrics_lst[key].append(value) | |
| self.actor_rollout_ref_wg.release_rollout_engine() | |
| self._maybe_log_val_generations( | |
| sample_inputs, sample_outputs, sample_labels, sample_scores, sample_problem_ids | |
| ) | |
| if self.config.trainer.val_generations_to_log > 0 and sample_inputs: | |
| print("Sample problem_id:", sample_problem_ids[0]) | |
| print("Sample prompt (with template):", sample_inputs[0]) | |
| print("Sample response:", sample_outputs[0]) | |
| print("Sample ground_truth:", sample_labels[0]) | |
| print("Sample reward:", sample_scores[0]) | |
| self.val_reward_score = torch.cat(reward_tensor_lst, dim=0).sum(-1).mean().item() | |
| val_reward_metrics = {f"val/{key}_reward": value for key, value in reduce_metrics(reward_metrics_lst).items()} | |
| val_length_metrics = {f"val_{key}": value for key, value in reduce_metrics(length_metrics_lst).items()} | |
| print("Finish validation.") | |
| return {"val/reward_score": self.val_reward_score, **val_reward_metrics, **val_length_metrics} | |
| def _balance_batch(self, batch: DataProto, metrics: dict[str, Any], logging_prefix: str = "global_seqlen") -> None: | |
| """Reorder the data on single controller such that each dp rank gets similar total tokens""" | |
| attention_mask = batch.batch["attention_mask"] | |
| batch_size = attention_mask.shape[0] | |
| global_seqlen_lst = batch.batch["attention_mask"].view(batch_size, -1).sum(-1).tolist() # (train_batch_size,) | |
| world_size = self.actor_rollout_ref_wg.world_size | |
| global_partition_lst = get_seqlen_balanced_partitions( | |
| global_seqlen_lst, k_partitions=world_size, equal_size=True | |
| ) | |
| # reorder based on index. The data will be automatically equally partitioned by dispatch function | |
| global_idx = torch.tensor([j for partition in global_partition_lst for j in partition]) | |
| batch.reorder(global_idx) | |
| global_balance_stats = log_seqlen_unbalance( | |
| seqlen_list=global_seqlen_lst, partitions=global_partition_lst, prefix=logging_prefix | |
| ) | |
| metrics.update(global_balance_stats) | |
| def _inject_oracle_rollout_in_gen_output( | |
| self, | |
| gen_batch_output: DataProto, | |
| ground_truths: Optional[np.ndarray], | |
| extras: Optional[np.ndarray], | |
| n: int, | |
| replace_index: Optional[int] = None, | |
| ) -> int: | |
| """Build and mark annotation-derived oracle rollout rows.""" | |
| if not self.config.algorithm.oracle_injection or self._oracle_builder is None: | |
| return 0 | |
| if ground_truths is None or len(ground_truths) == 0: | |
| return 0 | |
| # `replace_index` override lets callers pin the target slot regardless of | |
| # the config default (used by the append path, which calls this with n=1 | |
| # and index 0 so every representative row becomes its group's oracle). | |
| if replace_index is None: | |
| replace_index = self.config.algorithm.oracle_replace_index | |
| if replace_index == -1: | |
| replace_index = n - 1 | |
| responses = gen_batch_output.batch["responses"].clone() | |
| response_length = responses.size(1) | |
| device = responses.device | |
| pad_token_id = self.tokenizer.pad_token_id | |
| eos_token_id = gen_batch_output.meta_info.get("eos_token_id", self.tokenizer.eos_token_id) | |
| # `get_response_mask` supports list[int]; single-eos tail append needs a scalar. | |
| eos_single = eos_token_id[0] if isinstance(eos_token_id, (list, tuple)) else eos_token_id | |
| is_oracle_row = np.zeros(responses.size(0), dtype=bool) | |
| replaced = 0 | |
| for i, oracle_raw in enumerate(ground_truths): | |
| if oracle_raw is None: | |
| continue | |
| extra = extras[i] if extras is not None and i < len(extras) else {} | |
| if extra is None: | |
| extra = {} | |
| try: | |
| oracle_text = self._oracle_builder(str(oracle_raw), extra) | |
| except Exception as err: | |
| print(f"[OraRL] oracle_builder failed at idx={i} annotation={oracle_raw!r}: {err}") | |
| continue | |
| if not isinstance(oracle_text, str) or len(oracle_text.strip()) == 0: | |
| continue | |
| oracle_token_ids = self.tokenizer.encode(oracle_text, add_special_tokens=False) | |
| # Ensure the oracle response terminates with EOS so response_mask covers it. | |
| if eos_single is not None and (len(oracle_token_ids) == 0 or oracle_token_ids[-1] != eos_single): | |
| oracle_token_ids = oracle_token_ids + [eos_single] | |
| if len(oracle_token_ids) > response_length: | |
| # Keep the tail so EOS survives; pad head is unusual, just truncate front. | |
| oracle_token_ids = oracle_token_ids[-response_length:] | |
| oracle_tokens = VF.pad_2d_list_to_length( | |
| [oracle_token_ids], | |
| pad_token_id, | |
| max_length=response_length, | |
| ).to(device) | |
| replace_idx = i * n + replace_index | |
| responses[replace_idx] = oracle_tokens.squeeze(0) | |
| is_oracle_row[replace_idx] = True | |
| replaced += 1 | |
| if replaced == 0: | |
| return 0 | |
| prompts = gen_batch_output.batch["prompts"] | |
| prompt_length = prompts.size(-1) | |
| attention_mask = gen_batch_output.batch["attention_mask"] | |
| position_ids = gen_batch_output.batch["position_ids"] | |
| prompt_attention_mask = attention_mask[..., :prompt_length] | |
| prompt_position_ids = position_ids[..., :prompt_length] | |
| response_mask = VF.get_response_mask(responses, eos_token_id=eos_token_id, dtype=prompt_attention_mask.dtype) | |
| sequence_ids = torch.cat([prompts, responses], dim=-1) | |
| batch_size = responses.size(0) | |
| delta_position_id = torch.arange(1, response_length + 1, device=position_ids.device) | |
| delta_position_id = delta_position_id.view(1, -1).expand(batch_size, -1) | |
| if prompt_position_ids.ndim == 3: # qwen2vl mrope: (batch_size, 4, seq_length) | |
| delta_position_id = delta_position_id.view(batch_size, 1, -1).expand( | |
| batch_size, prompt_position_ids.size(1), -1 | |
| ) | |
| response_position_ids = prompt_position_ids[..., -1:] + delta_position_id | |
| full_position_ids = torch.cat([prompt_position_ids, response_position_ids], dim=-1) | |
| full_attention_mask = torch.cat((prompt_attention_mask, response_mask), dim=-1) | |
| gen_batch_output.batch["responses"] = responses | |
| gen_batch_output.batch["input_ids"] = sequence_ids | |
| gen_batch_output.batch["response_mask"] = response_mask | |
| gen_batch_output.batch["position_ids"] = full_position_ids | |
| gen_batch_output.batch["attention_mask"] = full_attention_mask | |
| gen_batch_output.non_tensor_batch["is_oracle_row"] = is_oracle_row | |
| # When the rollout collected per-sequence mean logprobs, the values at | |
| # oracle-row positions still describe the ORIGINAL on-policy sample | |
| # (different tokens), so they are meaningless for ranking. Override them | |
| # with +inf so the oracle row always survives the probability pre-filter; | |
| # `selection_keep_oracle` force-keeps it in the reward-based stage. | |
| seq_lp = gen_batch_output.non_tensor_batch.get("seq_logprob_for_filter", None) | |
| if seq_lp is not None: | |
| seq_lp = np.asarray(seq_lp, dtype=np.float32, copy=True) | |
| seq_lp[is_oracle_row] = np.float32(np.inf) | |
| gen_batch_output.non_tensor_batch["seq_logprob_for_filter"] = seq_lp | |
| return replaced | |
| def _build_oracle_append_rows( | |
| self, | |
| gen_batch_output: DataProto, | |
| ground_truths: np.ndarray, | |
| extras: Optional[np.ndarray], | |
| n: int, | |
| ) -> Optional[DataProto]: | |
| """Build one extra oracle rollout per prompt for append mode. | |
| Uses the first on-policy rollout of every group as a template (it already | |
| carries the correct prompt tokens + multi_modal_data), then overwrites its | |
| response with the annotation-derived one by reusing | |
| ``_inject_oracle_rollout_in_gen_output`` with ``n=1`` and | |
| ``replace_index=0``, so every representative row becomes its group's | |
| oracle. | |
| The returned DataProto has ``len(ground_truths)`` rows (one per prompt), | |
| all flagged ``is_oracle_row=True``, with the SAME batch/non-tensor keys as | |
| ``gen_batch_output`` — so the caller can union it with the per-prompt | |
| metadata and ``DataProto.concat`` it onto the on-policy block, growing each | |
| group from ``n`` to ``n+1``. ``index_select`` copies the tensors, so | |
| overwriting the template never mutates the on-policy rows. | |
| Returns ``None`` if no oracle row could be built (empty batch or the | |
| builder returned nothing for every prompt), so the caller falls back to a | |
| plain GRPO group for this step. | |
| """ | |
| num_prompts = len(ground_truths) | |
| if num_prompts == 0: | |
| return None | |
| rep_idx = np.array([i * n for i in range(num_prompts)], dtype=np.int64) | |
| oracle_rows = gen_batch_output.index_select(rep_idx) | |
| replaced = self._inject_oracle_rollout_in_gen_output( | |
| gen_batch_output=oracle_rows, | |
| ground_truths=ground_truths, | |
| extras=extras, | |
| n=1, | |
| replace_index=0, | |
| ) | |
| if self._is_orarl and replaced != num_prompts: | |
| raise RuntimeError( | |
| "OraRL requires one valid oracle rollout per prompt, but the " | |
| f"configured builder produced {replaced} of {num_prompts}." | |
| ) | |
| if replaced == 0: | |
| return None | |
| return oracle_rows | |
| def _make_batch_data(self, metrics: dict[str, Any]) -> DataProto: | |
| print("Start generating batch...") | |
| try: | |
| batch_dict = next(self.data_iterator) | |
| except StopIteration: | |
| self.data_iterator = iter(self.train_dataloader) | |
| batch_dict = next(self.data_iterator) | |
| meta_info = { | |
| "image_min_pixels": self.config.data.image_min_pixels, | |
| "image_max_pixels": self.config.data.image_max_pixels, | |
| "video_min_pixels": self.config.data.video_min_pixels, | |
| "video_max_pixels": self.config.data.video_max_pixels, | |
| "video_total_pixels": self.config.data.video_total_pixels, | |
| "video_fps": self.config.data.video_fps, | |
| "video_max_frames": self.config.data.video_max_frames, | |
| } | |
| new_batch: DataProto = DataProto.from_single_dict(batch_dict, meta_info=meta_info) | |
| new_batch.non_tensor_batch["uid"] = np.array( | |
| [str(uuid.uuid4()) for _ in range(len(new_batch.batch))], dtype=object | |
| ) | |
| gen_batch = new_batch.pop( | |
| batch_keys=["input_ids", "attention_mask", "position_ids"], | |
| non_tensor_batch_keys=["raw_prompt_ids", "multi_modal_data"], | |
| meta_info_keys=[ | |
| "image_min_pixels", | |
| "image_max_pixels", | |
| "video_min_pixels", | |
| "video_max_pixels", | |
| "video_total_pixels", | |
| "video_fps", | |
| "video_max_frames", | |
| ], | |
| ) | |
| self._assert_multimodal_contract(gen_batch, stage="train") | |
| rollout_n = int(self.config.worker.rollout.n) | |
| rollout_backend = str(self.config.worker.rollout.name).lower() | |
| if rollout_backend in {"hf", "transformers"}: | |
| if rollout_n != self.actor_rollout_ref_wg.world_size: | |
| raise ValueError( | |
| "Official HF rollout requires rollout.n == actor world size so " | |
| "each rank contributes one completion per prompt; got " | |
| f"n={rollout_n}, world_size={self.actor_rollout_ref_wg.world_size}." | |
| ) | |
| # ONE_TO_ALL sends all unique prompts to every rank. Each rank runs | |
| # one HF generation per prompt; the custom collector transposes the | |
| # results into contiguous n-sized prompt groups. | |
| gen_batch_output = self.actor_rollout_ref_wg.generate_sequences_hf_official( | |
| gen_batch | |
| ) | |
| # The HF workers deliberately omit this large object from their | |
| # outputs. Restore the driver's original once, then repeat by n | |
| # below; this avoids sending 64 duplicate 448-frame tensors back | |
| # through Ray. | |
| multi_modal_data = gen_batch.non_tensor_batch.get("multi_modal_data") | |
| if multi_modal_data is not None: | |
| new_batch.non_tensor_batch["multi_modal_data"] = multi_modal_data | |
| else: | |
| gen_batch_output = self.actor_rollout_ref_wg.generate_sequences(gen_batch) | |
| expected_rollout_rows = len(new_batch) * rollout_n | |
| if len(gen_batch_output) != expected_rollout_rows: | |
| raise ValueError( | |
| "Rollout output row count does not match prompt-group semantics: " | |
| f"got {len(gen_batch_output)}, expected {len(new_batch)} * " | |
| f"{rollout_n} = {expected_rollout_rows}." | |
| ) | |
| metrics.update( | |
| _rollout_token_diversity_metrics( | |
| gen_batch_output.batch["responses"], | |
| gen_batch_output.batch["response_mask"], | |
| rollout_n, | |
| ) | |
| ) | |
| if self.config.algorithm.adv_estimator == "remax": | |
| gen_baseline_batch = deepcopy(gen_batch) | |
| gen_baseline_batch.meta_info["temperature"] = 0 | |
| gen_baseline_batch.meta_info["n"] = 1 | |
| gen_baseline_output = self.actor_rollout_ref_wg.generate_sequences(gen_baseline_batch) | |
| new_batch = new_batch.union(gen_baseline_output) | |
| remax_reward_batch = new_batch.select( | |
| batch_keys=["responses", "response_mask"], | |
| non_tensor_batch_keys=[k for k in new_batch.non_tensor_batch if k != "multi_modal_data"], | |
| ) | |
| reward_baseline_tensor, _ = ray.get(self.reward_fn.compute_reward.remote(remax_reward_batch)) | |
| reward_baseline_tensor = reward_baseline_tensor.sum(dim=-1) | |
| new_batch.pop(batch_keys=list(gen_baseline_output.batch.keys())) | |
| new_batch.batch["reward_baselines"] = reward_baseline_tensor | |
| del gen_baseline_batch, gen_baseline_output | |
| # Add an annotation-derived oracle response to each prompt's group. | |
| # * replace mode: overwrite one on-policy rollout in place; group stays n. | |
| # * append mode: keep all n on-policy rollouts and add the oracle as an | |
| # extra row (group becomes n+1). We build the oracle rows here but defer | |
| # the concat until the on-policy block has been assembled below. | |
| n = rollout_n | |
| oracle_mode = getattr(self.config.algorithm, "oracle_injection_mode", "replace") | |
| append_oracle = bool(self.config.algorithm.oracle_injection and oracle_mode == "append") | |
| oracle_append_rows: Optional[DataProto] = None | |
| if self.config.algorithm.oracle_injection: | |
| oracle_values = new_batch.non_tensor_batch.get("ground_truth", None) | |
| if oracle_values is None: | |
| raise KeyError( | |
| "algorithm.oracle_injection=True but `ground_truth` is missing from the " | |
| "batch non_tensor_batch. Check your dataset's answer_key." | |
| ) | |
| # Oracle builders need the task family to avoid inferring it from | |
| # ambiguous labels such as a single option letter. Keep lightweight | |
| # row metadata, but never duplicate media tensors into this side | |
| # channel. | |
| oracle_extra_keys = [ | |
| key | |
| for key in new_batch.non_tensor_batch | |
| if key not in {"ground_truth", "multi_modal_data", "raw_prompt_ids"} | |
| ] | |
| oracle_extras = np.asarray( | |
| [ | |
| { | |
| key: new_batch.non_tensor_batch[key][row] | |
| for key in oracle_extra_keys | |
| } | |
| for row in range(len(new_batch)) | |
| ], | |
| dtype=object, | |
| ) | |
| if append_oracle: | |
| oracle_append_rows = self._build_oracle_append_rows( | |
| gen_batch_output, | |
| oracle_values, | |
| oracle_extras, | |
| n, | |
| ) | |
| metrics["orarl/oracle/groups_injected"] = int( | |
| 0 if oracle_append_rows is None else len(oracle_append_rows) | |
| ) | |
| # Flag the on-policy block so `is_oracle_row` exists on every row | |
| # before the concat below (concat requires matching keys). | |
| gen_batch_output.non_tensor_batch["is_oracle_row"] = np.zeros( | |
| len(gen_batch_output), dtype=bool | |
| ) | |
| else: | |
| oracle_injected = self._inject_oracle_rollout_in_gen_output( | |
| gen_batch_output=gen_batch_output, | |
| ground_truths=oracle_values, | |
| extras=oracle_extras, | |
| n=n, | |
| ) | |
| metrics["orarl/oracle/groups_injected"] = int(oracle_injected) | |
| # On-policy block: repeat the per-prompt metadata n times and attach the | |
| # generated rollouts. `repeat` returns a fresh DataProto, so `new_batch` | |
| # (the un-repeated per-prompt metadata) stays intact for the oracle rows. | |
| op_batch = new_batch.repeat(repeat_times=n, interleave=True) | |
| op_batch = op_batch.union(gen_batch_output) | |
| if oracle_append_rows is not None: | |
| # Attach per-prompt metadata (uid/ground_truth/problem_type/...) to the | |
| # oracle rows so each carries its group's uid, then concat onto the | |
| # block. Everything downstream (advantage, selection, diagnostics) | |
| # groups by uid, so the oracle row need not be physically adjacent. | |
| oracle_full = new_batch.union(oracle_append_rows) | |
| batch_out = DataProto.concat([op_batch, oracle_full]) | |
| group_size = n + 1 | |
| else: | |
| batch_out = op_batch | |
| group_size = n | |
| return batch_out[: self.config.data.rollout_batch_size * group_size] | |
| def fit(self): | |
| """ | |
| The training loop of PPO. | |
| The driver process only need to call the compute functions of the worker group through RPC to construct the PPO dataflow. | |
| The light-weight advantage computation is done on the driver process. | |
| """ | |
| self.logger = Tracker(loggers=self.config.trainer.logger, config=self.config.to_dict()) | |
| self.global_step = 0 | |
| main_tqdm = _NoOpProgress() if _disable_tqdm() else tqdm( | |
| range(self.training_steps), | |
| desc="Running step", | |
| position=0, | |
| ) | |
| val_metrics: Optional[dict[str, Any]] = None | |
| # load checkpoint before doing anything | |
| self._load_checkpoint() | |
| main_tqdm.update(self.global_step) | |
| # perform validation before training | |
| # currently, we only support validation using the reward_function. | |
| if self.val_reward_fn is not None and self.config.trainer.val_before_train: | |
| val_metrics = self._validate() | |
| self.logger.log(data=val_metrics, step=self.global_step) | |
| if self.config.trainer.val_only: | |
| return | |
| self.data_iterator = iter(self.train_dataloader) | |
| train_start_time = time.time() | |
| while self.global_step < self.training_steps: | |
| self.global_step += 1 | |
| metrics, timing_raw = {}, {} | |
| with timer("step", timing_raw): | |
| # make a batch of data | |
| with timer("gen", timing_raw): | |
| self.actor_rollout_ref_wg.prepare_rollout_engine() | |
| batch = self._make_batch_data(metrics=metrics) | |
| self.actor_rollout_ref_wg.release_rollout_engine() | |
| # balance the number of valid tokens on each dp rank. | |
| # NOTE: this breaks the order of data inside the batch. | |
| # Please take care when you implement group based adv computation such as GRPO and rloo | |
| self._balance_batch(batch, metrics=metrics) | |
| # compute global valid tokens | |
| batch.meta_info["global_token_num"] = torch.sum(batch.batch["attention_mask"], dim=-1).tolist() | |
| # compute reward asynchronously so it can overlap with old-log-prob compute. | |
| if "token_level_scores" not in batch.batch: | |
| with timer("reward", timing_raw): | |
| reward_batch = batch.select( | |
| batch_keys=["responses", "response_mask"], | |
| non_tensor_batch_keys=[k for k in batch.non_tensor_batch if k != "multi_modal_data"], | |
| ) | |
| reward_ref = self.reward_fn.compute_reward.remote(reward_batch) | |
| # recompute old_log_probs | |
| with timer("old", timing_raw): | |
| # Actor update always needs the rollout temperature to compute response log-probs. | |
| batch.meta_info["temperature"] = self.config.worker.rollout.temperature | |
| if "rollout_log_probs" in batch.batch: | |
| # Bypass mode: reuse the per-token logprobs vLLM already | |
| # returned as old_log_probs. This skips one FSDP recompute | |
| # and keeps the first PPO mini-batch ratio away from a | |
| # constant 1, so clipping acts as a real trust region. | |
| # vLLM applies log-softmax to logits/temperature, matching | |
| # actor.compute_log_prob, so the values are interchangeable. | |
| rollout_lp = batch.batch.pop("rollout_log_probs") | |
| batch.batch["old_log_probs"] = rollout_lp.to(torch.float32) | |
| elif self.skip_old_log_probs: | |
| batch.meta_info["skip_old_log_probs"] = True | |
| metrics["actor/old_log_probs_skipped"] = 1.0 | |
| else: | |
| old_log_probs = self.actor_rollout_ref_wg.compute_log_probs(batch) | |
| batch = batch.union(old_log_probs) | |
| # compute ref_log_probs | |
| if self.use_reference_policy: | |
| with timer("ref", timing_raw): | |
| ref_log_probs = self.actor_rollout_ref_wg.compute_ref_log_probs(batch) | |
| batch = batch.union(ref_log_probs) | |
| # compute values | |
| if self.use_critic: | |
| with timer("values", timing_raw): | |
| values = self.critic_wg.compute_values(batch) | |
| batch = batch.union(values) | |
| with timer("adv", timing_raw): | |
| if "token_level_scores" not in batch.batch: | |
| # get token level scores asynchronously | |
| reward_tensor, reward_metrics = ray.get(reward_ref) | |
| batch.batch["token_level_scores"] = reward_tensor | |
| _propagate_per_row_reward_metrics(batch, reward_metrics) | |
| metrics.update(_on_policy_reward_diversity_metrics(batch)) | |
| reward_metrics = {f"reward/{k}": v for k, v in reduce_metrics(reward_metrics).items()} | |
| metrics.update(reward_metrics) | |
| # apply kl penalty if available | |
| if not self.config.algorithm.use_kl_loss and self.use_reference_policy: | |
| # apply kl penalty to reward | |
| batch, kl_metrics = apply_kl_penalty(batch, self.kl_ctrl, self.config.algorithm.kl_penalty) | |
| metrics.update(kl_metrics) | |
| else: | |
| batch.batch["token_level_rewards"] = batch.batch["token_level_scores"] | |
| # compute advantages, executed on the driver process | |
| batch = compute_advantage( | |
| batch, | |
| adv_estimator=self.config.algorithm.adv_estimator, | |
| gamma=self.config.algorithm.gamma, | |
| lam=self.config.algorithm.lam, | |
| scale_rewards=bool(self.config.algorithm.scale_rewards), | |
| directional_gain=bool( | |
| self.config.algorithm.directional_gain | |
| ), | |
| directional_gain_gamma=float( | |
| self.config.algorithm.directional_gain_gamma | |
| ), | |
| directional_gain_positive_only=bool( | |
| self.config.algorithm.directional_gain_positive_only | |
| ), | |
| directional_gain_recenter=bool( | |
| self.config.algorithm.directional_gain_recenter | |
| ), | |
| ) | |
| # Report the raw per-group oracle-gap statistics before any | |
| # anchoring rewrites the oracle advantage. No-op without | |
| # oracle rows. | |
| oracle_diag = compute_oracle_advantage_diagnostics(batch) | |
| if oracle_diag: | |
| metrics.update(oracle_diag) | |
| adv_telemetry = batch.meta_info.pop("_advantage_telemetry", None) | |
| if adv_telemetry: | |
| for k, v in adv_telemetry.items(): | |
| metrics[f"orarl/{k}"] = float(v) | |
| # OraRL stage 4: overwrite the oracle row with a detached | |
| # positive anchor so it never enters the group mean/std. | |
| if self.config.algorithm.detached_oracle_advantage: | |
| # The released recipe keeps the directional gain | |
| # policy-only, so the anchor is governed solely by its | |
| # adaptive cap. | |
| _det_gain_gamma = ( | |
| float(self.config.algorithm.directional_gain_gamma) | |
| if ( | |
| self.config.algorithm.directional_gain | |
| and self.config.algorithm.detached_oracle_use_directional_gain | |
| ) | |
| else 0.0 | |
| ) | |
| det_metrics = apply_detached_oracle_advantage( | |
| batch, | |
| scale=float( | |
| self.config.algorithm.detached_oracle_advantage_scale | |
| ), | |
| beta=float(self.config.algorithm.oracle_reward_gate_beta), | |
| directional_gain_gamma=_det_gain_gamma, | |
| match_best_ratio=float( | |
| self.config.algorithm.detached_oracle_match_best_ratio | |
| ), | |
| match_best_min=float( | |
| self.config.algorithm.detached_oracle_match_best_min | |
| ), | |
| match_best_max=float( | |
| self.config.algorithm.detached_oracle_match_best_max | |
| ), | |
| ) | |
| if det_metrics: | |
| metrics.update(det_metrics) | |
| # OraRL stage 5: retain the oracle plus a strict sign-balanced | |
| # subset of policy rows before the actor backward pass. | |
| if self._is_orarl and not self.use_critic: | |
| full_score = batch.batch["token_level_scores"].sum(-1) | |
| metrics["orarl/selection/full_batch_score_mean"] = float( | |
| full_score.mean().item() | |
| ) | |
| metrics["orarl/selection/full_batch_score_max"] = float( | |
| full_score.max().item() | |
| ) | |
| metrics["orarl/selection/full_batch_score_min"] = float( | |
| full_score.min().item() | |
| ) | |
| _post_recenter = bool( | |
| self.config.algorithm.post_selection_recenter | |
| ) | |
| _post_rms_match = bool( | |
| self.config.algorithm.post_selection_rms_match | |
| ) | |
| post_selection_references = ( | |
| build_orarl_post_selection_references(batch) | |
| if _post_recenter or _post_rms_match | |
| else {} | |
| ) | |
| with timer("orarl_selection", timing_raw): | |
| batch, selection_metrics = select_orarl_rollouts( | |
| batch, | |
| n_rollouts=self.config.worker.rollout.n, | |
| prune_ratio=self.config.algorithm.selection_prune_ratio, | |
| world_size=self.actor_rollout_ref_wg.world_size, | |
| positive_quota=self.config.algorithm.selection_positive_quota, | |
| negative_quota=self.config.algorithm.selection_negative_quota, | |
| ) | |
| metrics.update(selection_metrics) | |
| if _post_recenter or _post_rms_match: | |
| with timer("post_selection_balance", timing_raw): | |
| post_balance_metrics = ( | |
| apply_orarl_post_selection_advantage_balance( | |
| batch, | |
| post_selection_references, | |
| recenter=_post_recenter, | |
| rms_match=_post_rms_match, | |
| rms_min_scale=float( | |
| self.config.algorithm.post_selection_rms_min_scale | |
| ), | |
| ) | |
| ) | |
| metrics.update(post_balance_metrics) | |
| # Re-balance per-rank seqlen after pruning: the original | |
| # balance partitioned all G rows uniformly, but dropping the | |
| # low-|adv| rows skews the per-rank totals. Cheap, and keeps | |
| # the actor update from being bottlenecked on a single rank. | |
| if ( | |
| selection_metrics.get( | |
| "orarl/selection/dropped_rows", | |
| 0.0, | |
| ) | |
| > 0 | |
| ): | |
| self._balance_batch( | |
| batch, | |
| metrics=metrics, | |
| logging_prefix="orarl_selection_seqlen", | |
| ) | |
| # update critic | |
| if self.use_critic: | |
| with timer("update_critic", timing_raw): | |
| critic_output = self.critic_wg.update_critic(batch) | |
| critic_metrics = reduce_metrics(critic_output.non_tensor_batch) | |
| metrics.update(critic_metrics) | |
| # update actor | |
| if self.config.trainer.critic_warmup <= self.global_step: | |
| metrics["actor/backward_rows"] = float(len(batch)) | |
| metrics["actor/backward_rows_per_prompt"] = float( | |
| len(batch) / max(1, int(self.config.data.rollout_batch_size)) | |
| ) | |
| if self._is_orarl: | |
| metrics["actor/oracle_injection_enabled"] = 1.0 | |
| metrics["actor/orarl_selection_enabled"] = 1.0 | |
| with timer("update_actor", timing_raw): | |
| actor_output = self.actor_rollout_ref_wg.update_actor(batch) | |
| actor_metrics = reduce_metrics(actor_output.non_tensor_batch) | |
| metrics.update(actor_metrics) | |
| # validate | |
| if ( | |
| self.val_reward_fn is not None | |
| and self.config.trainer.val_freq > 0 | |
| and self.global_step % self.config.trainer.val_freq == 0 | |
| ): | |
| with timer("validation", timing_raw): | |
| val_metrics = self._validate() | |
| metrics.update(val_metrics) | |
| if self.config.trainer.save_freq > 0 and self.global_step % self.config.trainer.save_freq == 0: | |
| with timer("save_checkpoint", timing_raw): | |
| self._save_checkpoint() | |
| # collect metrics | |
| num_gpus = self.resource_pool_manager.get_num_gpus() | |
| metrics.update(compute_data_metrics(batch=batch, use_critic=self.use_critic)) | |
| metrics.update(compute_timing_metrics(batch=batch, timing_raw=timing_raw)) | |
| metrics.update(compute_throughout_metrics(batch=batch, timing_raw=timing_raw, num_gpus=num_gpus)) | |
| # Best-train ckpt: track every step (decoupled from save_freq) and | |
| # save model-only into a side dir when smoothed reward improves. | |
| with timer("save_best_train_checkpoint", timing_raw): | |
| self._maybe_save_best_train_checkpoint(metrics) | |
| if _print_step_summary_enabled(): | |
| step_time = metrics.get("timing_s/step") | |
| elapsed = time.time() - train_start_time | |
| avg_step_time = elapsed / max(1, self.global_step) | |
| remaining_steps = max(0, self.training_steps - self.global_step) | |
| eta = avg_step_time * remaining_steps | |
| print( | |
| "[TRAIN STEP] " | |
| f"{self.global_step}/{self.training_steps} " | |
| f"step_s={_fmt_metric(step_time, precision=1)} " | |
| f"elapsed={_fmt_duration(elapsed)} " | |
| f"eta={_fmt_duration(eta)}", | |
| flush=True, | |
| ) | |
| self.logger.log(data=metrics, step=self.global_step) | |
| main_tqdm.update() | |
| # perform validation after training (skip entirely when val_freq <= 0) | |
| if self.val_reward_fn is not None and self.config.trainer.val_freq > 0: | |
| if ( | |
| val_metrics is None | |
| or self.global_step % self.config.trainer.val_freq != 0 | |
| ): | |
| val_metrics = self._validate() | |
| self.logger.log(data=val_metrics, step=self.global_step) | |
| print(f"Final validation metrics:\n{convert_dict_to_str(unflatten_dict(val_metrics))}") | |
| if self.config.trainer.save_freq <= 0 or self.global_step % self.config.trainer.save_freq != 0: | |
| self._save_checkpoint() | |