初始化项目,由ModelHub XC社区提供模型
Model: ayh015/myLightningOPD Source: Original Platform
This commit is contained in:
3
slime/rollout/__init__.py
Normal file
3
slime/rollout/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
BIN
slime/rollout/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
slime/rollout/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
slime/rollout/__pycache__/base_types.cpython-312.pyc
Normal file
BIN
slime/rollout/__pycache__/base_types.cpython-312.pyc
Normal file
Binary file not shown.
BIN
slime/rollout/__pycache__/data_source.cpython-312.pyc
Normal file
BIN
slime/rollout/__pycache__/data_source.cpython-312.pyc
Normal file
Binary file not shown.
BIN
slime/rollout/__pycache__/on_policy_distillation.cpython-312.pyc
Normal file
BIN
slime/rollout/__pycache__/on_policy_distillation.cpython-312.pyc
Normal file
Binary file not shown.
BIN
slime/rollout/__pycache__/sglang_rollout.cpython-312.pyc
Normal file
BIN
slime/rollout/__pycache__/sglang_rollout.cpython-312.pyc
Normal file
Binary file not shown.
29
slime/rollout/base_types.py
Normal file
29
slime/rollout/base_types.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from slime.utils.types import Sample
|
||||
|
||||
|
||||
@dataclass
|
||||
class RolloutFnTrainOutput:
|
||||
samples: list[list[Sample]]
|
||||
metrics: dict[str, Any] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RolloutFnEvalOutput:
|
||||
data: dict[str, dict[str, Any]]
|
||||
metrics: dict[str, Any] = None
|
||||
|
||||
|
||||
def call_rollout_fn(fn, *args, evaluation: bool, **kwargs):
|
||||
output = fn(*args, **kwargs, evaluation=evaluation)
|
||||
|
||||
# compatibility for legacy version
|
||||
if not isinstance(output, (RolloutFnTrainOutput, RolloutFnEvalOutput)):
|
||||
output = RolloutFnEvalOutput(data=output) if evaluation else RolloutFnTrainOutput(samples=output)
|
||||
|
||||
return output
|
||||
230
slime/rollout/data_source.py
Normal file
230
slime/rollout/data_source.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import abc
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
from slime.utils.data import create_dataset
|
||||
from slime.utils.misc import load_function
|
||||
from slime.utils.processing_utils import load_processor, load_tokenizer
|
||||
from slime.utils.types import Sample
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DataSource(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def get_samples(self, num_samples: int) -> list[list[Sample]]:
|
||||
"""
|
||||
Return num_samples samples
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def add_samples(self, samples: list[list[Sample]]):
|
||||
"""
|
||||
Add samples to the data source
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def save(self, rollout_id):
|
||||
"""
|
||||
Save the state of the data source
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def load(self, rollout_id=None):
|
||||
"""
|
||||
Load the state of the data source
|
||||
"""
|
||||
|
||||
|
||||
# TODO may further refactor data-loading part later
|
||||
class RolloutDataSource(DataSource):
|
||||
def __init__(self, args):
|
||||
self.args = args
|
||||
|
||||
self.epoch_id = 0
|
||||
self.sample_group_index = 0
|
||||
self.sample_index = 0
|
||||
self.sample_offset = 0
|
||||
# TODO remove this
|
||||
self.metadata = {}
|
||||
|
||||
if args.rollout_global_dataset:
|
||||
tokenizer = load_tokenizer(args.hf_checkpoint, trust_remote_code=True)
|
||||
processor = load_processor(args.hf_checkpoint, trust_remote_code=True)
|
||||
|
||||
# TODO move (during the refactor)
|
||||
if (d := args.dump_details) is not None:
|
||||
tokenizer.save_pretrained(Path(d) / "tokenizer")
|
||||
if processor:
|
||||
processor.save_pretrained(Path(d) / "processor")
|
||||
|
||||
self.dataset = create_dataset(
|
||||
args.prompt_data,
|
||||
tokenizer=tokenizer,
|
||||
processor=processor,
|
||||
max_length=args.rollout_max_prompt_len,
|
||||
prompt_key=args.input_key,
|
||||
multimodal_keys=args.multimodal_keys,
|
||||
label_key=args.label_key,
|
||||
metadata_key=args.metadata_key,
|
||||
tool_key=args.tool_key,
|
||||
apply_chat_template=args.apply_chat_template,
|
||||
apply_chat_template_kwargs=args.apply_chat_template_kwargs,
|
||||
seed=args.rollout_seed,
|
||||
)
|
||||
if self.args.rollout_shuffle:
|
||||
self.dataset.shuffle(self.epoch_id)
|
||||
else:
|
||||
self.dataset = None
|
||||
|
||||
def get_samples(self, num_samples):
|
||||
# TODO further improve code
|
||||
if self.dataset is not None:
|
||||
if self.sample_offset + num_samples <= len(self.dataset):
|
||||
prompt_samples = self.dataset.samples[self.sample_offset : self.sample_offset + num_samples]
|
||||
self.sample_offset += num_samples
|
||||
else:
|
||||
prompt_samples = self.dataset.samples[self.sample_offset :]
|
||||
num_samples -= len(prompt_samples)
|
||||
self.epoch_id += 1
|
||||
if self.args.rollout_shuffle:
|
||||
self.dataset.shuffle(self.epoch_id)
|
||||
prompt_samples += self.dataset.samples[:num_samples]
|
||||
self.sample_offset = num_samples
|
||||
else:
|
||||
prompt_samples = [Sample() for _ in range(num_samples)]
|
||||
|
||||
samples = []
|
||||
for prompt_sample in prompt_samples:
|
||||
group = []
|
||||
for _ in range(self.args.n_samples_per_prompt):
|
||||
sample = copy.deepcopy(prompt_sample)
|
||||
sample.group_index = self.sample_group_index
|
||||
sample.index = self.sample_index
|
||||
self.sample_index += 1
|
||||
group.append(sample)
|
||||
self.sample_group_index += 1
|
||||
samples.append(group)
|
||||
return samples
|
||||
|
||||
def add_samples(self, samples: list[list[Sample]]):
|
||||
raise RuntimeError(f"Cannot add samples to {self.__class__.__name__}. This is a read-only data source.")
|
||||
|
||||
def save(self, rollout_id):
|
||||
if not self.args.rollout_global_dataset:
|
||||
return
|
||||
|
||||
state_dict = {
|
||||
"sample_offset": self.sample_offset,
|
||||
"epoch_id": self.epoch_id,
|
||||
"sample_group_index": self.sample_group_index,
|
||||
"sample_index": self.sample_index,
|
||||
"metadata": self.metadata,
|
||||
# Save wandb_run_id for resume support
|
||||
"wandb_run_id": getattr(self.args, "wandb_run_id", None),
|
||||
}
|
||||
path = os.path.join(self.args.save, f"rollout/global_dataset_state_dict_{rollout_id}.pt")
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
torch.save(state_dict, path)
|
||||
|
||||
def load(self, rollout_id=None):
|
||||
if not self.args.rollout_global_dataset:
|
||||
return
|
||||
|
||||
if self.args.load is None:
|
||||
return
|
||||
|
||||
path = os.path.join(self.args.load, f"rollout/global_dataset_state_dict_{rollout_id}.pt")
|
||||
if not os.path.exists(path):
|
||||
logger.info(f"Checkpoint {path} does not exist.")
|
||||
return
|
||||
|
||||
logger.info(f"load metadata from {path}")
|
||||
logger.info(f"load metadata: {self.metadata}")
|
||||
state_dict = torch.load(path)
|
||||
self.sample_offset = state_dict.get("sample_offset", 0)
|
||||
self.epoch_id = state_dict.get("epoch_id", 0)
|
||||
self.sample_group_index = state_dict.get("sample_group_index", 0)
|
||||
self.sample_index = state_dict.get("sample_index", 0)
|
||||
self.metadata = state_dict.get("metadata", {})
|
||||
|
||||
# Load wandb_run_id for resume support (only if not already set)
|
||||
if not getattr(self.args, "wandb_run_id", None):
|
||||
loaded_wandb_run_id = state_dict.get("wandb_run_id")
|
||||
if loaded_wandb_run_id:
|
||||
self.args.wandb_run_id = loaded_wandb_run_id
|
||||
logger.info(f"Loaded wandb_run_id from checkpoint: {loaded_wandb_run_id}")
|
||||
|
||||
if self.args.rollout_global_dataset and self.args.rollout_shuffle:
|
||||
self.dataset.shuffle(self.epoch_id)
|
||||
|
||||
|
||||
class RolloutDataSourceWithBuffer(RolloutDataSource):
|
||||
def __init__(self, args):
|
||||
super().__init__(args)
|
||||
self.buffer = []
|
||||
if self.args.buffer_filter_path is None:
|
||||
self.buffer_filter = pop_first
|
||||
else:
|
||||
self.buffer_filter = load_function(self.args.buffer_filter_path)
|
||||
|
||||
def get_samples(self, num_samples: int) -> list[list[Sample]]:
|
||||
"""
|
||||
Return num_samples samples
|
||||
"""
|
||||
|
||||
samples = self._get_samples_from_buffer(num_samples)
|
||||
num_samples -= len(samples)
|
||||
|
||||
if num_samples == 0:
|
||||
return samples
|
||||
|
||||
samples += super().get_samples(num_samples=num_samples)
|
||||
return samples
|
||||
|
||||
def _get_samples_from_buffer(self, num_samples: int) -> list[list[Sample]]:
|
||||
if len(self.buffer) == 0 or num_samples == 0:
|
||||
return []
|
||||
|
||||
samples = self.buffer_filter(self.args, None, self.buffer, num_samples)
|
||||
return samples
|
||||
|
||||
def add_samples(self, samples: list[list[Sample]]):
|
||||
"""
|
||||
Add a sample group to buffer.
|
||||
"""
|
||||
if not samples:
|
||||
return
|
||||
assert isinstance(samples, list), f"samples must be a list, got {type(samples)}"
|
||||
assert isinstance(samples[0], list), f"the elements of samples must be list, got {type(samples[0])}"
|
||||
for i in range(0, len(samples)):
|
||||
assert (
|
||||
len(samples[i]) == self.args.n_samples_per_prompt
|
||||
), f"the length of the elements of samples must be equal to n_samples_per_prompt, got {len(samples[i])} != {self.args.n_samples_per_prompt}"
|
||||
group = samples[i] # type: ignore
|
||||
self.buffer.append(group)
|
||||
|
||||
# TODO remove
|
||||
def update_metadata(self, metadata: dict):
|
||||
self.metadata.update(metadata)
|
||||
|
||||
# TODO remove
|
||||
def get_metadata(self):
|
||||
return self.metadata
|
||||
|
||||
def get_buffer_length(self):
|
||||
return len(self.buffer)
|
||||
|
||||
|
||||
def pop_first(args, rollout_id, buffer: list[list[Sample]], num_samples: int) -> list[list[Sample]]:
|
||||
num_to_pop = min(len(buffer), num_samples)
|
||||
samples = buffer[:num_to_pop]
|
||||
del buffer[:num_to_pop]
|
||||
return samples
|
||||
3
slime/rollout/filter_hub/__init__.py
Normal file
3
slime/rollout/filter_hub/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
BIN
slime/rollout/filter_hub/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
slime/rollout/filter_hub/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
slime/rollout/filter_hub/__pycache__/base_types.cpython-312.pyc
Normal file
BIN
slime/rollout/filter_hub/__pycache__/base_types.cpython-312.pyc
Normal file
Binary file not shown.
10
slime/rollout/filter_hub/base_types.py
Normal file
10
slime/rollout/filter_hub/base_types.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class DynamicFilterOutput:
|
||||
keep: bool
|
||||
reason: str | None = None
|
||||
18
slime/rollout/filter_hub/dynamic_sampling_filters.py
Normal file
18
slime/rollout/filter_hub/dynamic_sampling_filters.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import torch
|
||||
|
||||
from slime.rollout.filter_hub.base_types import DynamicFilterOutput
|
||||
from slime.utils.types import Sample
|
||||
|
||||
__all__ = ["check_reward_nonzero_std"]
|
||||
|
||||
|
||||
def check_reward_nonzero_std(args, samples: list[Sample], **kwargs):
|
||||
rewards = [sample.get_reward_value(args) for sample in samples]
|
||||
keep = torch.tensor(rewards, dtype=torch.float).std() > 0.0
|
||||
return DynamicFilterOutput(
|
||||
keep=keep,
|
||||
reason=None if keep else f"zero_std_{round(rewards[0], 1)}",
|
||||
)
|
||||
72
slime/rollout/on_policy_distillation.py
Normal file
72
slime/rollout/on_policy_distillation.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import aiohttp
|
||||
import torch
|
||||
|
||||
from slime.utils.processing_utils import encode_image_for_rollout_engine
|
||||
from slime.utils.types import Sample
|
||||
|
||||
|
||||
async def reward_func(args, sample, **kwargs):
|
||||
# For Lightning OPD: teacher log-probs are pre-computed in metadata,
|
||||
# no teacher server call needed. Return a sentinel so post_process_rewards knows.
|
||||
metadata = sample.metadata or {}
|
||||
if metadata.get("is_lightning_opd", False) or metadata.get("is_offline_opd", False):
|
||||
return {"lightning_opd": True}
|
||||
|
||||
payload = {
|
||||
"input_ids": sample.tokens,
|
||||
"sampling_params": {
|
||||
"temperature": 0,
|
||||
"max_new_tokens": 0,
|
||||
"skip_special_tokens": False,
|
||||
},
|
||||
"return_logprob": True,
|
||||
"logprob_start_len": 0,
|
||||
}
|
||||
|
||||
if sample.multimodal_inputs and sample.multimodal_inputs.get("images"):
|
||||
image_data = sample.multimodal_inputs["images"]
|
||||
payload["image_data"] = [encode_image_for_rollout_engine(image) for image in image_data]
|
||||
|
||||
session_kwargs = {}
|
||||
async with aiohttp.ClientSession(**session_kwargs) as session:
|
||||
async with session.post(args.rm_url, json=payload) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
|
||||
def post_process_rewards(args, samples: list[Sample], **kwargs):
|
||||
"""Process rewards from teacher model and extract teacher log probabilities.
|
||||
|
||||
This function:
|
||||
1. Extracts teacher log-probs from the reward response (which contains sglang's logprob output)
|
||||
2. Trims them to match the response length
|
||||
3. Stores them in sample.teacher_log_probs for OPD KL penalty computation
|
||||
4. Returns scalar rewards (0.0 for pure distillation) compatible with GRPO/PPO
|
||||
|
||||
For Lightning OPD, teacher log-probs are pre-computed in the parquet
|
||||
metadata instead of being fetched from a teacher server at runtime.
|
||||
"""
|
||||
raw_rewards = [sample.get_reward_value(args) for sample in samples]
|
||||
response_lengths = [sample.response_length for sample in samples]
|
||||
|
||||
for i, (sample, reward) in enumerate(zip(samples, raw_rewards)):
|
||||
metadata = sample.metadata or {}
|
||||
if isinstance(reward, dict) and reward.get("lightning_opd"):
|
||||
# Lightning OPD: teacher log-probs are pre-computed in metadata
|
||||
pre_teacher_lp = metadata.get("teacher_log_probs", [])
|
||||
sample.teacher_log_probs = torch.tensor(
|
||||
[float(x) for x in pre_teacher_lp], dtype=torch.float32
|
||||
)
|
||||
else:
|
||||
# Online OPD: extract teacher log-probs from sglang response
|
||||
t_log_probs = torch.tensor(
|
||||
[item[0] for item in reward["meta_info"]["input_token_logprobs"][1:]],
|
||||
dtype=torch.float32,
|
||||
)
|
||||
sample.teacher_log_probs = t_log_probs[-response_lengths[i]:]
|
||||
|
||||
scalar_rewards = [0.0] * len(samples)
|
||||
return scalar_rewards, scalar_rewards
|
||||
83
slime/rollout/rm_hub/__init__.py
Normal file
83
slime/rollout/rm_hub/__init__.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
|
||||
import aiohttp
|
||||
|
||||
from slime.utils.misc import load_function
|
||||
from slime.utils.types import Sample
|
||||
|
||||
from .deepscaler import get_deepscaler_rule_based_reward
|
||||
from .f1 import f1_score
|
||||
from .gpqa import compute_gpqa_reward
|
||||
from .math_dapo_utils import compute_score as compute_score_dapo
|
||||
from .math_utils import extract_answer as extract_boxed_answer
|
||||
from .math_utils import grade_answer_verl
|
||||
|
||||
|
||||
async def remote_rm(args, sample: Sample):
|
||||
payload = {
|
||||
"prompt": sample.prompt,
|
||||
"response": sample.response,
|
||||
"label": sample.label,
|
||||
}
|
||||
session_kwargs = {}
|
||||
async with aiohttp.ClientSession(**session_kwargs) as session:
|
||||
async with session.post(args.rm_url, json=payload) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
|
||||
async def async_rm(args, sample: Sample, **kwargs):
|
||||
if args.custom_rm_path is not None:
|
||||
rm_function = load_function(args.custom_rm_path)
|
||||
return await rm_function(args, sample, **kwargs)
|
||||
|
||||
metadata = sample.metadata if isinstance(sample.metadata, dict) else {}
|
||||
rm_type = (metadata.get("rm_type") or args.rm_type or "").strip()
|
||||
response = sample.response
|
||||
label = sample.label
|
||||
if rm_type.startswith("boxed_"):
|
||||
response = extract_boxed_answer(response) or ""
|
||||
rm_type = rm_type[len("boxed_") :]
|
||||
|
||||
# This function is intended for remote or time-consuming reward model evaluation.
|
||||
# Implement the actual logic as needed.
|
||||
if rm_type == "remote_rm":
|
||||
return await remote_rm(args, sample)
|
||||
elif rm_type == "deepscaler":
|
||||
return get_deepscaler_rule_based_reward(response, label)
|
||||
elif rm_type == "dapo":
|
||||
return compute_score_dapo(response, label)
|
||||
elif rm_type == "math":
|
||||
return 1 if grade_answer_verl(response, label) else 0
|
||||
elif rm_type == "f1":
|
||||
return f1_score(response, label)[0]
|
||||
elif rm_type == "gpqa":
|
||||
return compute_gpqa_reward(response, label, metadata=metadata)
|
||||
elif rm_type == "ifbench":
|
||||
from .ifbench import compute_ifbench_reward
|
||||
|
||||
return compute_ifbench_reward(response, label, metadata=metadata)
|
||||
elif rm_type == "random":
|
||||
return random.randint(0, 1)
|
||||
elif rm_type:
|
||||
raise NotImplementedError(f"Rule-based RM for {rm_type} is not implemented.")
|
||||
else:
|
||||
raise NotImplementedError("Rule-based RM type is not specified.")
|
||||
|
||||
|
||||
async def batched_async_rm(
|
||||
args,
|
||||
samples: list[Sample],
|
||||
**kwargs,
|
||||
) -> list[int | float]:
|
||||
if args.custom_rm_path is not None:
|
||||
# Ensure the custom reward function is implemented in batch mode
|
||||
rm_function = load_function(args.custom_rm_path)
|
||||
return await rm_function(args, samples, **kwargs)
|
||||
tasks = [async_rm(args, sample, **kwargs) for sample in samples]
|
||||
rewards = await asyncio.gather(*tasks)
|
||||
return rewards
|
||||
BIN
slime/rollout/rm_hub/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
slime/rollout/rm_hub/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
slime/rollout/rm_hub/__pycache__/deepscaler.cpython-312.pyc
Normal file
BIN
slime/rollout/rm_hub/__pycache__/deepscaler.cpython-312.pyc
Normal file
Binary file not shown.
BIN
slime/rollout/rm_hub/__pycache__/f1.cpython-312.pyc
Normal file
BIN
slime/rollout/rm_hub/__pycache__/f1.cpython-312.pyc
Normal file
Binary file not shown.
BIN
slime/rollout/rm_hub/__pycache__/gpqa.cpython-312.pyc
Normal file
BIN
slime/rollout/rm_hub/__pycache__/gpqa.cpython-312.pyc
Normal file
Binary file not shown.
BIN
slime/rollout/rm_hub/__pycache__/math_dapo_utils.cpython-312.pyc
Normal file
BIN
slime/rollout/rm_hub/__pycache__/math_dapo_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
slime/rollout/rm_hub/__pycache__/math_utils.cpython-312.pyc
Normal file
BIN
slime/rollout/rm_hub/__pycache__/math_utils.cpython-312.pyc
Normal file
Binary file not shown.
45
slime/rollout/rm_hub/deepscaler.py
Normal file
45
slime/rollout/rm_hub/deepscaler.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .math_utils import extract_answer, grade_answer_mathd, grade_answer_sympy
|
||||
|
||||
|
||||
def get_deepscaler_rule_based_reward(response, label):
|
||||
if "</think>" in response:
|
||||
model_solution = response.split("</think>")[-1]
|
||||
elif "###Response" in response:
|
||||
model_solution = response.split("###Response")[1]
|
||||
else:
|
||||
return 0
|
||||
|
||||
model_answer = extract_answer(model_solution)
|
||||
if model_answer is None:
|
||||
return 0
|
||||
if label == "":
|
||||
return 0
|
||||
|
||||
# Convert single answer to list for uniform processing
|
||||
assert isinstance(label, (str, float, int))
|
||||
ground_truths = [label]
|
||||
|
||||
# Process each ground truth
|
||||
processed_ground_truths = []
|
||||
for truth in ground_truths:
|
||||
truth = str(truth)
|
||||
if "\\boxed" in truth:
|
||||
processed_truth = extract_answer(truth)
|
||||
if processed_truth is not None:
|
||||
processed_ground_truths.append(processed_truth)
|
||||
else:
|
||||
processed_ground_truths.append(truth)
|
||||
|
||||
if not processed_ground_truths:
|
||||
return 0
|
||||
|
||||
# Check against all possible correct answers
|
||||
for ground_truth in processed_ground_truths:
|
||||
is_correct = grade_answer_mathd(model_answer, ground_truth) or grade_answer_sympy(model_answer, ground_truth)
|
||||
if is_correct:
|
||||
return 1
|
||||
|
||||
return 0
|
||||
50
slime/rollout/rm_hub/f1.py
Normal file
50
slime/rollout/rm_hub/f1.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import re
|
||||
import string
|
||||
from collections import Counter
|
||||
|
||||
|
||||
def normalize_answer(s):
|
||||
|
||||
def remove_articles(text):
|
||||
return re.sub(r"\b(a|an|the)\b", " ", text)
|
||||
|
||||
def white_space_fix(text):
|
||||
return " ".join(text.split())
|
||||
|
||||
def remove_punc(text):
|
||||
exclude = set(string.punctuation)
|
||||
return "".join(ch for ch in text if ch not in exclude)
|
||||
|
||||
def lower(text):
|
||||
return text.lower()
|
||||
|
||||
return white_space_fix(remove_articles(remove_punc(lower(s))))
|
||||
|
||||
|
||||
def f1_score(prediction, ground_truth):
|
||||
ZERO_METRIC = (0, 0, 0)
|
||||
|
||||
if prediction is None:
|
||||
return ZERO_METRIC
|
||||
|
||||
normalized_prediction = normalize_answer(prediction)
|
||||
normalized_ground_truth = normalize_answer(ground_truth)
|
||||
|
||||
if normalized_prediction in ["yes", "no", "noanswer"] and normalized_prediction != normalized_ground_truth:
|
||||
return ZERO_METRIC
|
||||
if normalized_ground_truth in ["yes", "no", "noanswer"] and normalized_prediction != normalized_ground_truth:
|
||||
return ZERO_METRIC
|
||||
|
||||
prediction_tokens = normalized_prediction.split()
|
||||
ground_truth_tokens = normalized_ground_truth.split()
|
||||
common = Counter(prediction_tokens) & Counter(ground_truth_tokens)
|
||||
num_same = sum(common.values())
|
||||
if num_same == 0:
|
||||
return ZERO_METRIC
|
||||
precision = 1.0 * num_same / len(prediction_tokens)
|
||||
recall = 1.0 * num_same / len(ground_truth_tokens)
|
||||
f1 = (2 * precision * recall) / (precision + recall)
|
||||
return f1, precision, recall
|
||||
132
slime/rollout/rm_hub/gpqa.py
Normal file
132
slime/rollout/rm_hub/gpqa.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import re
|
||||
import string
|
||||
from collections.abc import Iterable
|
||||
|
||||
DEFAULT_VALID_LETTERS = list(string.ascii_uppercase[:8])
|
||||
|
||||
|
||||
def _strip_chain_of_thought(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
if "</think>" in text:
|
||||
return text.rsplit("</think>", 1)[-1]
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _normalize_text(text: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
|
||||
|
||||
|
||||
def _extract_letter_from_response(response: str, valid_letters: Iterable[str]) -> str | None:
|
||||
"""
|
||||
Best-effort extraction of the selected option letter from the model response.
|
||||
"""
|
||||
if not response:
|
||||
return None
|
||||
|
||||
text = _strip_chain_of_thought(response)
|
||||
patterns = [
|
||||
r"(?:answer|option|choice)\s*(?:is|:)?\s*([A-Z])",
|
||||
r"([A-Z])\s*(?:is\s*(?:the)?\s*correct)",
|
||||
r"final\s*(?:answer|option)\s*(?:is|:)?\s*([A-Z])",
|
||||
]
|
||||
|
||||
valid_letters = {letter.upper() for letter in valid_letters}
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, flags=re.IGNORECASE)
|
||||
if match:
|
||||
letter = match.group(1).upper()
|
||||
if letter in valid_letters:
|
||||
return letter
|
||||
|
||||
# Fallback: last standalone capital letter that is valid.
|
||||
candidates = re.findall(r"\b([A-Z])\b", text)
|
||||
for letter in reversed(candidates):
|
||||
letter = letter.upper()
|
||||
if letter in valid_letters:
|
||||
return letter
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def compute_gpqa_reward(response: str, label, metadata: dict | None = None) -> float:
|
||||
"""Rule-based scorer for GPQA-style multiple-choice evaluation."""
|
||||
if response is None:
|
||||
return 0.0
|
||||
|
||||
metadata = metadata or {}
|
||||
|
||||
choices = metadata.get("choices")
|
||||
if isinstance(choices, dict):
|
||||
choices = list(choices.values())
|
||||
elif choices is not None:
|
||||
choices = list(choices)
|
||||
|
||||
valid_letters = metadata.get("valid_letters")
|
||||
if valid_letters:
|
||||
valid_letters = [str(letter).upper() for letter in valid_letters]
|
||||
elif choices:
|
||||
valid_letters = list(string.ascii_uppercase[: len(choices)])
|
||||
else:
|
||||
valid_letters = DEFAULT_VALID_LETTERS
|
||||
|
||||
correct_letter = metadata.get("correct_letter")
|
||||
if isinstance(correct_letter, str):
|
||||
correct_letter = correct_letter.strip().upper()
|
||||
else:
|
||||
correct_letter = None
|
||||
|
||||
label_text = None
|
||||
if isinstance(label, str):
|
||||
label_text = label.strip()
|
||||
if len(label_text) == 1 and label_text.upper() in valid_letters and not correct_letter:
|
||||
correct_letter = label_text.upper()
|
||||
elif isinstance(label, (int, float)):
|
||||
idx = int(label)
|
||||
if 0 <= idx < len(valid_letters):
|
||||
correct_letter = valid_letters[idx]
|
||||
|
||||
if not correct_letter and choices and label_text:
|
||||
normalized_label = _normalize_text(label_text)
|
||||
for idx, choice in enumerate(choices):
|
||||
if _normalize_text(str(choice)) == normalized_label:
|
||||
correct_letter = valid_letters[idx]
|
||||
metadata.setdefault("correct_answer", choice)
|
||||
break
|
||||
|
||||
extracted_letter = _extract_letter_from_response(response, valid_letters)
|
||||
if extracted_letter and correct_letter:
|
||||
return 1.0 if extracted_letter == correct_letter else 0.0
|
||||
|
||||
candidate_answers = []
|
||||
if correct_letter and choices:
|
||||
try:
|
||||
idx = valid_letters.index(correct_letter)
|
||||
except ValueError:
|
||||
idx = None
|
||||
if idx is not None and idx < len(choices):
|
||||
candidate_answers.append(str(choices[idx]))
|
||||
|
||||
for key in ("correct_answer", "answer_text"):
|
||||
value = metadata.get(key)
|
||||
if value:
|
||||
candidate_answers.append(str(value))
|
||||
|
||||
if label_text:
|
||||
candidate_answers.append(label_text)
|
||||
|
||||
normalized_targets = {_normalize_text(text) for text in candidate_answers if text}
|
||||
normalized_response = _normalize_text(_strip_chain_of_thought(response))
|
||||
for target in normalized_targets:
|
||||
if target and target in normalized_response:
|
||||
return 1.0
|
||||
|
||||
if extracted_letter and not correct_letter and label_text:
|
||||
return 1.0 if extracted_letter == label_text.strip().upper() else 0.0
|
||||
|
||||
return 0.0
|
||||
173
slime/rollout/rm_hub/ifbench.py
Normal file
173
slime/rollout/rm_hub/ifbench.py
Normal file
@@ -0,0 +1,173 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WORKSPACE_ROOT = Path(__file__).resolve().parents[3]
|
||||
_WORKSPACE_PARENT = _WORKSPACE_ROOT.parent
|
||||
_LOCAL_IFBENCH_REQUIREMENTS = _WORKSPACE_ROOT / "examples" / "eval_multi_task" / "requirements_ifbench.txt"
|
||||
|
||||
|
||||
def _ensure_ifbench_repo() -> Path:
|
||||
"""Clone IFBench repo if needed and ensure it is available on sys.path."""
|
||||
|
||||
repo_path = _WORKSPACE_PARENT / "IFBench"
|
||||
|
||||
if not repo_path.exists():
|
||||
clone_cmd = ["git", "clone", "https://github.com/allenai/IFBench.git", str(repo_path)]
|
||||
try:
|
||||
subprocess.run(clone_cmd, check=True, capture_output=True)
|
||||
except Exception as exc:
|
||||
raise ImportError(
|
||||
"Unable to automatically clone IFBench. Please clone "
|
||||
"https://github.com/allenai/IFBench.git into the repo root."
|
||||
) from exc
|
||||
|
||||
repo_str = str(repo_path)
|
||||
if repo_str not in sys.path:
|
||||
sys.path.insert(0, repo_str)
|
||||
|
||||
current_pythonpath = os.environ.get("PYTHONPATH")
|
||||
if current_pythonpath is None:
|
||||
os.environ["PYTHONPATH"] = repo_str
|
||||
elif repo_str not in current_pythonpath.split(os.pathsep):
|
||||
os.environ["PYTHONPATH"] = os.pathsep.join([repo_str, current_pythonpath])
|
||||
|
||||
return repo_path
|
||||
|
||||
|
||||
def _ensure_ifbench_dependencies(repo_path: Path) -> None:
|
||||
"""Install IFBench requirements the first time the module is imported."""
|
||||
|
||||
requirements_file = _LOCAL_IFBENCH_REQUIREMENTS
|
||||
|
||||
if not requirements_file.exists():
|
||||
logger.debug("Local IFBench requirements file not found at %s; skipping install.", requirements_file)
|
||||
return
|
||||
|
||||
sentinel = repo_path / ".deps_installed"
|
||||
if sentinel.exists():
|
||||
return
|
||||
|
||||
install_cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements_file)]
|
||||
try:
|
||||
subprocess.run(install_cmd, check=True)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to install IFBench dependencies automatically: %s", exc)
|
||||
else:
|
||||
sentinel.write_text("installed\n")
|
||||
|
||||
|
||||
def _load_evaluation_lib():
|
||||
repo_path = _ensure_ifbench_repo()
|
||||
try:
|
||||
return importlib.import_module("evaluation_lib")
|
||||
except ImportError:
|
||||
_ensure_ifbench_dependencies(repo_path)
|
||||
return importlib.import_module("evaluation_lib")
|
||||
|
||||
|
||||
evaluation_lib = _load_evaluation_lib()
|
||||
InputExample = evaluation_lib.InputExample
|
||||
|
||||
|
||||
JsonDict = dict[str, Any]
|
||||
KwargsDict = dict[str, str | int | float | None]
|
||||
|
||||
|
||||
def _normalize_instruction_ids(raw_ids: Sequence[Any]) -> list[str]:
|
||||
"""Ensure instruction identifiers are clean strings."""
|
||||
|
||||
normalized: list[str] = []
|
||||
for entry in raw_ids or []:
|
||||
if entry is None:
|
||||
continue
|
||||
text = str(entry).strip()
|
||||
if not text:
|
||||
continue
|
||||
normalized.append(text)
|
||||
return normalized
|
||||
|
||||
|
||||
def _coerce_kwargs_list(
|
||||
raw_kwargs: Any,
|
||||
num_instructions: int,
|
||||
) -> list[KwargsDict]:
|
||||
"""Convert stored kwargs into the list structure expected by IFBench."""
|
||||
|
||||
if isinstance(raw_kwargs, list):
|
||||
processed: list[KwargsDict] = []
|
||||
for entry in raw_kwargs:
|
||||
if isinstance(entry, dict):
|
||||
processed.append(dict(entry))
|
||||
else:
|
||||
processed.append({})
|
||||
elif isinstance(raw_kwargs, dict):
|
||||
processed = [dict(raw_kwargs) for _ in range(num_instructions)]
|
||||
else:
|
||||
processed = [{} for _ in range(num_instructions)]
|
||||
|
||||
if len(processed) < num_instructions:
|
||||
tail = processed[-1] if processed else {}
|
||||
processed.extend([dict(tail) for _ in range(num_instructions - len(processed))])
|
||||
elif len(processed) > num_instructions:
|
||||
processed = processed[:num_instructions]
|
||||
|
||||
# Remove explicit None values to match official preprocessing.
|
||||
sanitized: list[KwargsDict] = []
|
||||
for entry in processed:
|
||||
sanitized.append({k: v for k, v in entry.items() if v is not None})
|
||||
return sanitized
|
||||
|
||||
|
||||
def _build_input_example(metadata: JsonDict) -> InputExample | None:
|
||||
instruction_ids = _normalize_instruction_ids(metadata.get("instruction_id_list") or [])
|
||||
if not instruction_ids:
|
||||
logger.debug("Missing instruction identifiers in metadata: %s", metadata)
|
||||
return None
|
||||
|
||||
prompt_text = metadata.get("prompt_text")
|
||||
if prompt_text is None:
|
||||
prompt_text = ""
|
||||
else:
|
||||
prompt_text = str(prompt_text)
|
||||
|
||||
raw_kwargs = metadata.get("kwargs")
|
||||
kwargs_list = _coerce_kwargs_list(raw_kwargs, len(instruction_ids))
|
||||
|
||||
return InputExample(
|
||||
key=int(metadata.get("record_id") or 0),
|
||||
instruction_id_list=instruction_ids,
|
||||
prompt=prompt_text,
|
||||
kwargs=kwargs_list,
|
||||
)
|
||||
|
||||
|
||||
def compute_ifbench_reward(response: str, label: Any, metadata: JsonDict | None = None) -> float:
|
||||
"""Score a model response using the official IFBench rules."""
|
||||
|
||||
if metadata is None:
|
||||
logger.debug("No metadata provided for IFBench scoring.")
|
||||
return 0.0
|
||||
|
||||
if response is None:
|
||||
return 0.0
|
||||
|
||||
inp = _build_input_example(metadata)
|
||||
if inp is None:
|
||||
return 0.0
|
||||
|
||||
prompt_to_response = {inp.prompt: str(response or "")}
|
||||
output = evaluation_lib.test_instruction_following_strict(inp, prompt_to_response)
|
||||
return 1.0 if output.follow_all_instructions else 0.0
|
||||
292
slime/rollout/rm_hub/math_dapo_utils.py
Normal file
292
slime/rollout/rm_hub/math_dapo_utils.py
Normal file
@@ -0,0 +1,292 @@
|
||||
# Copyright 2024 Bytedance Ltd. and/or its affiliates
|
||||
# Copyright 2022 EleutherAI and the HuggingFace Inc. team. All rights reserved.
|
||||
# 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.
|
||||
# Adapted from https://github.com/EleutherAI/lm-evaluation-harness/blob/main/lm_eval/tasks/hendrycks_math/utils.py
|
||||
|
||||
import re
|
||||
import signal
|
||||
|
||||
|
||||
def last_boxed_only_string(string: str) -> str | None:
|
||||
"""Extract the last LaTeX boxed expression from a string.
|
||||
|
||||
Args:
|
||||
string: Input string containing LaTeX code
|
||||
|
||||
Returns:
|
||||
The last boxed expression or None if not found
|
||||
"""
|
||||
idx = string.rfind("\\boxed{")
|
||||
if idx < 0:
|
||||
return None
|
||||
|
||||
i = idx
|
||||
right_brace_idx = None
|
||||
num_left_braces_open = 0
|
||||
|
||||
while i < len(string):
|
||||
if string[i] == "{":
|
||||
num_left_braces_open += 1
|
||||
if string[i] == "}":
|
||||
num_left_braces_open -= 1
|
||||
if num_left_braces_open == 0:
|
||||
right_brace_idx = i
|
||||
break
|
||||
i += 1
|
||||
|
||||
return string[idx : right_brace_idx + 1] if right_brace_idx is not None else None
|
||||
|
||||
|
||||
def remove_boxed(s: str) -> str:
|
||||
"""Remove the LaTeX boxed command from a string.
|
||||
|
||||
Args:
|
||||
s: String with format "\\boxed{content}"
|
||||
|
||||
Returns:
|
||||
The content inside the boxed command
|
||||
"""
|
||||
left = "\\boxed{"
|
||||
assert s[: len(left)] == left, f"box error: {s}"
|
||||
assert s[-1] == "}", f"box error: {s}"
|
||||
return s[len(left) : -1]
|
||||
|
||||
|
||||
class timeout:
|
||||
|
||||
def __init__(self, seconds=1, error_message="Timeout"):
|
||||
self.seconds = seconds
|
||||
self.error_message = error_message
|
||||
|
||||
def handle_timeout(self, signum, frame):
|
||||
raise TimeoutError(self.error_message)
|
||||
|
||||
def __enter__(self):
|
||||
signal.signal(signal.SIGALRM, self.handle_timeout)
|
||||
signal.alarm(self.seconds)
|
||||
|
||||
def __exit__(self, type, value, traceback):
|
||||
signal.alarm(0)
|
||||
|
||||
|
||||
# Constants for normalization
|
||||
SUBSTITUTIONS = [
|
||||
("an ", ""),
|
||||
("a ", ""),
|
||||
(".$", "$"),
|
||||
("\\$", ""),
|
||||
(r"\ ", ""),
|
||||
(" ", ""),
|
||||
("mbox", "text"),
|
||||
(",\\text{and}", ","),
|
||||
("\\text{and}", ","),
|
||||
("\\text{m}", "\\text{}"),
|
||||
]
|
||||
|
||||
REMOVED_EXPRESSIONS = [
|
||||
"square",
|
||||
"ways",
|
||||
"integers",
|
||||
"dollars",
|
||||
"mph",
|
||||
"inches",
|
||||
"hours",
|
||||
"km",
|
||||
"units",
|
||||
"\\ldots",
|
||||
"sue",
|
||||
"points",
|
||||
"feet",
|
||||
"minutes",
|
||||
"digits",
|
||||
"cents",
|
||||
"degrees",
|
||||
"cm",
|
||||
"gm",
|
||||
"pounds",
|
||||
"meters",
|
||||
"meals",
|
||||
"edges",
|
||||
"students",
|
||||
"childrentickets",
|
||||
"multiples",
|
||||
"\\text{s}",
|
||||
"\\text{.}",
|
||||
"\\text{\ns}",
|
||||
"\\text{}^2",
|
||||
"\\text{}^3",
|
||||
"\\text{\n}",
|
||||
"\\text{}",
|
||||
r"\mathrm{th}",
|
||||
r"^\circ",
|
||||
r"^{\circ}",
|
||||
r"\;",
|
||||
r",\!",
|
||||
"{,}",
|
||||
'"',
|
||||
"\\dots",
|
||||
"<|im_end|>",
|
||||
"<|endoftext|>",
|
||||
]
|
||||
|
||||
|
||||
def normalize_final_answer(final_answer: str) -> str:
|
||||
"""Normalize a final answer to a quantitative reasoning question.
|
||||
|
||||
Args:
|
||||
final_answer: The answer string to normalize
|
||||
|
||||
Returns:
|
||||
Normalized answer string
|
||||
"""
|
||||
final_answer = str(final_answer)
|
||||
final_answer = final_answer.split("=")[-1]
|
||||
|
||||
# Apply substitutions and removals
|
||||
for before, after in SUBSTITUTIONS:
|
||||
final_answer = final_answer.replace(before, after)
|
||||
for expr in REMOVED_EXPRESSIONS:
|
||||
final_answer = final_answer.replace(expr, "")
|
||||
|
||||
# Extract and normalize LaTeX math
|
||||
final_answer = re.sub(r"(.*?)(\$)(.*?)(\$)(.*)", "$\\3$", final_answer)
|
||||
final_answer = re.sub(r"(\\text\{)(.*?)(\})", "\\2", final_answer)
|
||||
final_answer = re.sub(r"(\\textbf\{)(.*?)(\})", "\\2", final_answer)
|
||||
final_answer = re.sub(r"(\\overline\{)(.*?)(\})", "\\2", final_answer)
|
||||
final_answer = re.sub(r"(\\boxed\{)(.*)(\})", "\\2", final_answer)
|
||||
|
||||
# Normalize shorthand TeX:
|
||||
# \fracab -> \frac{a}{b}
|
||||
# \frac{abc}{bef} -> \frac{abc}{bef}
|
||||
# \fracabc -> \frac{a}{b}c
|
||||
# \sqrta -> \sqrt{a}
|
||||
# \sqrtab -> sqrt{a}b
|
||||
final_answer = re.sub(r"(frac)([^{])(.)", "frac{\\2}{\\3}", final_answer)
|
||||
final_answer = re.sub(r"(sqrt)([^{])", "sqrt{\\2}", final_answer)
|
||||
final_answer = final_answer.replace("$", "")
|
||||
|
||||
# Normalize numbers
|
||||
if final_answer.replace(",", "").isdigit():
|
||||
final_answer = final_answer.replace(",", "")
|
||||
|
||||
return final_answer.strip()
|
||||
|
||||
|
||||
def is_correct_minerva(
|
||||
solution_str: str, gt: str, gt_need_extract: bool = False, answer_pattern: str = r"(?i)Answer\s*:\s*([^\n]+)"
|
||||
) -> tuple[bool, str]:
|
||||
"""Check if the solution is correct according to Minerva criteria.
|
||||
|
||||
Args:
|
||||
solution_str: The solution string to check
|
||||
gt: The ground truth answer
|
||||
gt_need_extract: Whether the ground truth needs extraction
|
||||
answer_pattern: Regex pattern to extract the answer
|
||||
|
||||
Returns:
|
||||
Tuple of (is_correct, normalized_prediction)
|
||||
"""
|
||||
# Extract answer from solution
|
||||
match = re.findall(answer_pattern, solution_str)
|
||||
extracted_answer = match[-1] if match else "[INVALID]"
|
||||
pred = normalize_final_answer(extracted_answer)
|
||||
|
||||
# Process ground truth
|
||||
if gt_need_extract:
|
||||
gt = normalize_final_answer(remove_boxed(last_boxed_only_string(gt)))
|
||||
else:
|
||||
gt = normalize_final_answer(gt)
|
||||
|
||||
gt = str(int(float(gt))) # in dapo, all answers are integers
|
||||
|
||||
return (pred == gt), pred
|
||||
|
||||
|
||||
def is_correct_strict_box(pred: str, gt: str, pause_tokens_index: list[int] | None = None) -> tuple[int, str | None]:
|
||||
"""Check if the prediction is correct using strict boxed answer criteria.
|
||||
|
||||
Args:
|
||||
pred: The prediction string
|
||||
gt: The ground truth answer
|
||||
pause_tokens_index: Indices of pause tokens
|
||||
|
||||
Returns:
|
||||
Tuple of (score, extracted_prediction)
|
||||
"""
|
||||
# Extract the relevant part of the prediction
|
||||
if pause_tokens_index is not None:
|
||||
assert len(pause_tokens_index) == 4
|
||||
pred = pred[pause_tokens_index[-1] - 100 :]
|
||||
else:
|
||||
pred = pred[-100:]
|
||||
|
||||
# Extract and check the boxed answer
|
||||
boxed_pred = last_boxed_only_string(pred)
|
||||
extracted_pred = remove_boxed(boxed_pred) if boxed_pred is not None else None
|
||||
|
||||
return 1 if (extracted_pred == gt) else -1, extracted_pred
|
||||
|
||||
|
||||
def verify(
|
||||
solution_str: str, answer: str, strict_box_verify: bool = False, pause_tokens_index: list[int] | None = None
|
||||
) -> bool:
|
||||
"""Verify if the solution is correct.
|
||||
|
||||
Args:
|
||||
solution_str: The solution string to verify
|
||||
answer: The ground truth answer
|
||||
strict_box_verify: Whether to use strict box verification
|
||||
pause_tokens_index: Indices of pause tokens
|
||||
|
||||
Returns:
|
||||
True if the solution is correct, False otherwise
|
||||
"""
|
||||
if strict_box_verify:
|
||||
correct, pred = is_correct_strict_box(solution_str, answer, pause_tokens_index)
|
||||
return correct == 1, pred
|
||||
|
||||
correct, pred = is_correct_minerva(solution_str, answer)
|
||||
return correct, pred
|
||||
|
||||
|
||||
def compute_score(
|
||||
solution_str: str,
|
||||
ground_truth: str,
|
||||
strict_box_verify: bool = False,
|
||||
pause_tokens_index: list[int] | None = None,
|
||||
) -> float:
|
||||
"""Compute the reward score for a solution.
|
||||
|
||||
Args:
|
||||
solution_str: The solution string
|
||||
ground_truth: The ground truth answer
|
||||
config: Configuration object containing reward model settings
|
||||
pause_tokens_index: Indices of pause tokens
|
||||
|
||||
Returns:
|
||||
Reward score (1.0 for correct, -1.0 for incorrect)
|
||||
"""
|
||||
# Limit solution length for efficiency
|
||||
solution_str = solution_str[-300:] # The longest answer in MATH-500 has 159 characters
|
||||
|
||||
# Verify the solution
|
||||
correct, pred = verify(solution_str, ground_truth, strict_box_verify, pause_tokens_index)
|
||||
|
||||
reward = 1.0 if correct else -1.0
|
||||
acc = correct
|
||||
|
||||
return {
|
||||
"score": reward,
|
||||
"acc": acc,
|
||||
"pred": pred,
|
||||
}
|
||||
491
slime/rollout/rm_hub/math_utils.py
Normal file
491
slime/rollout/rm_hub/math_utils.py
Normal file
@@ -0,0 +1,491 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# from https://github.com/agentica-project/deepscaler/blob/e6080ccd974eb64bd3430f0b36108244a6fee330/deepscaler/rewards/math_utils/utils.py
|
||||
"""
|
||||
Answer checker API that uses sympy to simplify expressions and check for equality.
|
||||
|
||||
Call grade_answer(given_answer: str, ground_truth: str).
|
||||
"""
|
||||
import re
|
||||
|
||||
import sympy
|
||||
from pylatexenc import latex2text
|
||||
from sympy.parsing import sympy_parser
|
||||
|
||||
|
||||
# Dan Hendrycks' code
|
||||
def mathd_normalize_answer(answer: str | None) -> str | None:
|
||||
if answer is None:
|
||||
return None
|
||||
answer = answer.strip()
|
||||
try:
|
||||
# Remove enclosing `\text{}`.
|
||||
m = re.search("^\\\\text\{(?P<text>.+?)\}$", answer)
|
||||
if m is not None:
|
||||
answer = m.group("text").strip()
|
||||
return _strip_string(answer)
|
||||
except Exception:
|
||||
return answer
|
||||
|
||||
|
||||
def _strip_string(string):
|
||||
def _fix_fracs(string):
|
||||
substrs = string.split("\\frac")
|
||||
new_str = substrs[0]
|
||||
if len(substrs) > 1:
|
||||
substrs = substrs[1:]
|
||||
for substr in substrs:
|
||||
new_str += "\\frac"
|
||||
if substr[0] == "{":
|
||||
new_str += substr
|
||||
else:
|
||||
try:
|
||||
assert len(substr) >= 2
|
||||
except Exception:
|
||||
return string
|
||||
a = substr[0]
|
||||
b = substr[1]
|
||||
if b != "{":
|
||||
if len(substr) > 2:
|
||||
post_substr = substr[2:]
|
||||
new_str += "{" + a + "}{" + b + "}" + post_substr
|
||||
else:
|
||||
new_str += "{" + a + "}{" + b + "}"
|
||||
else:
|
||||
if len(substr) > 2:
|
||||
post_substr = substr[2:]
|
||||
new_str += "{" + a + "}" + b + post_substr
|
||||
else:
|
||||
new_str += "{" + a + "}" + b
|
||||
string = new_str
|
||||
return string
|
||||
|
||||
def _fix_a_slash_b(string):
|
||||
if len(string.split("/")) != 2:
|
||||
return string
|
||||
a = string.split("/")[0]
|
||||
b = string.split("/")[1]
|
||||
try:
|
||||
a = int(a)
|
||||
b = int(b)
|
||||
assert string == f"{a}/{b}"
|
||||
new_string = "\\frac{" + str(a) + "}{" + str(b) + "}"
|
||||
return new_string
|
||||
except Exception:
|
||||
return string
|
||||
|
||||
def _remove_right_units(string):
|
||||
# "\\text{ " only ever occurs (at least in the val set) when describing units
|
||||
if "\\text{ " in string:
|
||||
splits = string.split("\\text{ ")
|
||||
assert len(splits) == 2
|
||||
return splits[0]
|
||||
else:
|
||||
return string
|
||||
|
||||
def _fix_sqrt(string):
|
||||
if "\\sqrt" not in string:
|
||||
return string
|
||||
splits = string.split("\\sqrt")
|
||||
new_string = splits[0]
|
||||
for split in splits[1:]:
|
||||
if split[0] != "{":
|
||||
a = split[0]
|
||||
new_substr = "\\sqrt{" + a + "}" + split[1:]
|
||||
else:
|
||||
new_substr = "\\sqrt" + split
|
||||
new_string += new_substr
|
||||
return new_string
|
||||
|
||||
# linebreaks
|
||||
string = string.replace("\n", "")
|
||||
|
||||
# remove inverse spaces
|
||||
string = string.replace("\\!", "")
|
||||
|
||||
# replace \\ with \
|
||||
string = string.replace("\\\\", "\\")
|
||||
|
||||
# replace tfrac and dfrac with frac
|
||||
string = string.replace("tfrac", "frac")
|
||||
string = string.replace("dfrac", "frac")
|
||||
|
||||
# remove \left and \right
|
||||
string = string.replace("\\left", "")
|
||||
string = string.replace("\\right", "")
|
||||
|
||||
# Remove circ (degrees)
|
||||
string = string.replace("^{\\circ}", "")
|
||||
string = string.replace("^\\circ", "")
|
||||
|
||||
# remove dollar signs
|
||||
string = string.replace("\\$", "")
|
||||
|
||||
# remove units (on the right)
|
||||
string = _remove_right_units(string)
|
||||
|
||||
# remove percentage
|
||||
string = string.replace("\\%", "")
|
||||
string = string.replace("\%", "")
|
||||
|
||||
# " 0." equivalent to " ." and "{0." equivalent to "{." Alternatively, add "0" if "." is the start of the string
|
||||
string = string.replace(" .", " 0.")
|
||||
string = string.replace("{.", "{0.")
|
||||
# if empty, return empty string
|
||||
if len(string) == 0:
|
||||
return string
|
||||
if string[0] == ".":
|
||||
string = "0" + string
|
||||
|
||||
# to consider: get rid of e.g. "k = " or "q = " at beginning
|
||||
if len(string.split("=")) == 2:
|
||||
if len(string.split("=")[0]) <= 2:
|
||||
string = string.split("=")[1]
|
||||
|
||||
# fix sqrt3 --> sqrt{3}
|
||||
string = _fix_sqrt(string)
|
||||
|
||||
# remove spaces
|
||||
string = string.replace(" ", "")
|
||||
|
||||
# \frac1b or \frac12 --> \frac{1}{b} and \frac{1}{2}, etc. Even works with \frac1{72} (but not \frac{72}1). Also does a/b --> \\frac{a}{b}
|
||||
string = _fix_fracs(string)
|
||||
|
||||
# manually change 0.5 --> \frac{1}{2}
|
||||
if string == "0.5":
|
||||
string = "\\frac{1}{2}"
|
||||
|
||||
# NOTE: X/Y changed to \frac{X}{Y} in dataset, but in simple cases fix in case the model output is X/Y
|
||||
string = _fix_a_slash_b(string)
|
||||
|
||||
return string
|
||||
|
||||
|
||||
# sympy might hang -- we don't care about trying to be lenient in these cases
|
||||
BAD_SUBSTRINGS = ["^{", "^("]
|
||||
BAD_REGEXES = ["\^[0-9]+\^", "\^[0-9][0-9]+"]
|
||||
TUPLE_CHARS = "()[]"
|
||||
|
||||
|
||||
def _sympy_parse(expr: str):
|
||||
"""Parses an expression with sympy."""
|
||||
py_expr = expr.replace("^", "**")
|
||||
return sympy_parser.parse_expr(
|
||||
py_expr,
|
||||
transformations=(sympy_parser.standard_transformations + (sympy_parser.implicit_multiplication_application,)),
|
||||
)
|
||||
|
||||
|
||||
def _parse_latex(expr: str) -> str:
|
||||
"""Attempts to parse latex to an expression sympy can read."""
|
||||
expr = expr.replace("\\tfrac", "\\frac")
|
||||
expr = expr.replace("\\dfrac", "\\frac")
|
||||
expr = expr.replace("\\frac", " \\frac") # Play nice with mixed numbers.
|
||||
expr = latex2text.LatexNodes2Text().latex_to_text(expr)
|
||||
|
||||
# Replace the specific characters that this parser uses.
|
||||
expr = expr.replace("√", "sqrt")
|
||||
expr = expr.replace("π", "pi")
|
||||
expr = expr.replace("∞", "inf")
|
||||
expr = expr.replace("∪", "U")
|
||||
expr = expr.replace("·", "*")
|
||||
expr = expr.replace("×", "*")
|
||||
|
||||
return expr.strip()
|
||||
|
||||
|
||||
def _is_float(num: str) -> bool:
|
||||
try:
|
||||
float(num)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_int(x: float) -> bool:
|
||||
try:
|
||||
return abs(x - int(round(x))) <= 1e-7
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_frac(expr: str) -> bool:
|
||||
return bool(re.search(r"^-?[0-9]+.?/0*[1-9][0-9]*.?$", expr))
|
||||
|
||||
|
||||
def _str_is_int(x: str) -> bool:
|
||||
try:
|
||||
x = _strip_properly_formatted_commas(x)
|
||||
x = float(x)
|
||||
return abs(x - int(round(x))) <= 1e-7
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _str_to_int(x: str) -> int:
|
||||
x = x.replace(",", "")
|
||||
x = float(x)
|
||||
return int(x)
|
||||
|
||||
|
||||
def _inject_implicit_mixed_number(step: str):
|
||||
"""
|
||||
Automatically make a mixed number evalable
|
||||
e.g. 7 3/4 => 7+3/4
|
||||
"""
|
||||
p1 = re.compile("([0-9]) +([0-9])")
|
||||
step = p1.sub("\\1+\\2", step) ## implicit mults
|
||||
return step
|
||||
|
||||
|
||||
def _strip_properly_formatted_commas(expr: str):
|
||||
# We want to be careful because we don't want to strip tuple commas
|
||||
p1 = re.compile("(\d)(,)(\d\d\d)($|\D)")
|
||||
while True:
|
||||
next_expr = p1.sub("\\1\\3\\4", expr)
|
||||
if next_expr == expr:
|
||||
break
|
||||
expr = next_expr
|
||||
return next_expr
|
||||
|
||||
|
||||
def _normalize(expr: str) -> str:
|
||||
"""Normalize answer expressions."""
|
||||
if expr is None:
|
||||
return None
|
||||
|
||||
# Remove enclosing `\text{}`.
|
||||
m = re.search("^\\\\text\{(?P<text>.+?)\}$", expr)
|
||||
if m is not None:
|
||||
expr = m.group("text")
|
||||
|
||||
expr = expr.replace("\\%", "%")
|
||||
expr = expr.replace("\\$", "$")
|
||||
expr = expr.replace("$", "")
|
||||
expr = expr.replace("%", "")
|
||||
expr = expr.replace(" or ", " , ")
|
||||
expr = expr.replace(" and ", " , ")
|
||||
|
||||
expr = expr.replace("million", "*10^6")
|
||||
expr = expr.replace("billion", "*10^9")
|
||||
expr = expr.replace("trillion", "*10^12")
|
||||
|
||||
for unit in [
|
||||
"degree",
|
||||
"cm",
|
||||
"centimeter",
|
||||
"meter",
|
||||
"mile",
|
||||
"second",
|
||||
"minute",
|
||||
"hour",
|
||||
"day",
|
||||
"week",
|
||||
"month",
|
||||
"year",
|
||||
"foot",
|
||||
"feet",
|
||||
"inch",
|
||||
"yard",
|
||||
]:
|
||||
expr = re.sub(f"{unit}(es)?(s)? *(\^[0-9]+)?", "", expr)
|
||||
expr = re.sub("\^ *\\\\circ", "", expr)
|
||||
|
||||
if len(expr) > 0 and expr[0] == "{" and expr[-1] == "}":
|
||||
expr = expr[1:-1]
|
||||
|
||||
expr = re.sub(",\\\\! *", "", expr)
|
||||
if _is_float(expr) and _is_int(float(expr)):
|
||||
expr = str(int(round(float(expr))))
|
||||
if "\\" in expr:
|
||||
try:
|
||||
expr = _parse_latex(expr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# edge case with mixed numbers and negative signs
|
||||
expr = re.sub("- *", "-", expr)
|
||||
|
||||
expr = _inject_implicit_mixed_number(expr)
|
||||
expr = expr.replace(" ", "")
|
||||
|
||||
# if we somehow still have latex braces here, just drop them
|
||||
expr = expr.replace("{", "")
|
||||
expr = expr.replace("}", "")
|
||||
|
||||
# don't be case sensitive for text answers
|
||||
expr = expr.lower()
|
||||
|
||||
if _str_is_int(expr):
|
||||
expr = str(_str_to_int(expr))
|
||||
|
||||
return expr
|
||||
|
||||
|
||||
def count_unknown_letters_in_expr(expr: str):
|
||||
expr = expr.replace("sqrt", "")
|
||||
expr = expr.replace("frac", "")
|
||||
letters_in_expr = set([x for x in expr if x.isalpha()])
|
||||
return len(letters_in_expr)
|
||||
|
||||
|
||||
def should_allow_eval(expr: str):
|
||||
# we don't want to try parsing unknown text or functions of more than two variables
|
||||
if count_unknown_letters_in_expr(expr) > 2:
|
||||
return False
|
||||
|
||||
for bad_string in BAD_SUBSTRINGS:
|
||||
if bad_string in expr:
|
||||
return False
|
||||
|
||||
for bad_regex in BAD_REGEXES:
|
||||
if re.search(bad_regex, expr) is not None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def are_equal_under_sympy(ground_truth_normalized: str, given_normalized: str):
|
||||
are_equal = False
|
||||
try:
|
||||
expr = f"({ground_truth_normalized})-({given_normalized})"
|
||||
if should_allow_eval(expr):
|
||||
sympy_diff = _sympy_parse(expr)
|
||||
simplified = sympy.simplify(sympy_diff)
|
||||
if simplified == 0:
|
||||
are_equal = True
|
||||
except Exception:
|
||||
pass
|
||||
return are_equal
|
||||
|
||||
|
||||
def split_tuple(expr: str):
|
||||
"""
|
||||
Split the elements in a tuple/interval, while handling well-formatted commas in large numbers
|
||||
"""
|
||||
expr = _strip_properly_formatted_commas(expr)
|
||||
if len(expr) == 0:
|
||||
return []
|
||||
if (
|
||||
len(expr) > 2
|
||||
and expr[0] in TUPLE_CHARS
|
||||
and expr[-1] in TUPLE_CHARS
|
||||
and all([ch not in expr[1:-1] for ch in TUPLE_CHARS])
|
||||
):
|
||||
elems = [elem.strip() for elem in expr[1:-1].split(",")]
|
||||
else:
|
||||
elems = [expr]
|
||||
return elems
|
||||
|
||||
|
||||
def last_boxed_only_string(string):
|
||||
idx = string.rfind("\\boxed")
|
||||
if idx < 0:
|
||||
idx = string.rfind("\\fbox")
|
||||
if idx < 0:
|
||||
return None
|
||||
|
||||
i = idx
|
||||
right_brace_idx = None
|
||||
num_left_braces_open = 0
|
||||
while i < len(string):
|
||||
if string[i] == "{":
|
||||
num_left_braces_open += 1
|
||||
if string[i] == "}":
|
||||
num_left_braces_open -= 1
|
||||
if num_left_braces_open == 0:
|
||||
right_brace_idx = i
|
||||
break
|
||||
i += 1
|
||||
|
||||
if right_brace_idx is None:
|
||||
retval = None
|
||||
else:
|
||||
retval = string[idx : right_brace_idx + 1]
|
||||
|
||||
return retval
|
||||
|
||||
|
||||
def remove_boxed(s):
|
||||
left = "\\boxed{"
|
||||
try:
|
||||
assert s[: len(left)] == left
|
||||
assert s[-1] == "}"
|
||||
return s[len(left) : -1]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def extract_boxed_answer(solution: str) -> str:
|
||||
"""Extract the answer from inside a LaTeX \\boxed{} command"""
|
||||
solution = last_boxed_only_string(solution)
|
||||
solution = remove_boxed(solution)
|
||||
return solution
|
||||
|
||||
|
||||
def grade_answer_sympy(given_answer: str, ground_truth: str) -> bool:
|
||||
ground_truth_normalized = _normalize(ground_truth)
|
||||
given_normalized = _normalize(given_answer)
|
||||
|
||||
if ground_truth_normalized is None:
|
||||
return False
|
||||
|
||||
if ground_truth_normalized == given_normalized:
|
||||
return True
|
||||
|
||||
if len(given_normalized) == 0:
|
||||
return False
|
||||
|
||||
ground_truth_elems = split_tuple(ground_truth_normalized)
|
||||
given_elems = split_tuple(given_normalized)
|
||||
|
||||
if len(ground_truth_elems) > 1 and (
|
||||
ground_truth_normalized[0] != given_normalized[0] or ground_truth_normalized[-1] != given_normalized[-1]
|
||||
):
|
||||
is_correct = False
|
||||
elif len(ground_truth_elems) != len(given_elems):
|
||||
is_correct = False
|
||||
else:
|
||||
for ground_truth_elem, given_elem in zip(ground_truth_elems, given_elems, strict=False):
|
||||
if _is_frac(ground_truth_elem) and _is_frac(given_elem):
|
||||
# if fractions aren't reduced, then shouldn't be marked as correct
|
||||
# so, we don't want to allow sympy.simplify in this case
|
||||
is_correct = ground_truth_elem == given_elem
|
||||
elif _str_is_int(ground_truth_elem) != _str_is_int(given_elem):
|
||||
# if the ground truth answer is an integer, we require the given answer to be a strict match (no sympy.simplify)
|
||||
is_correct = False
|
||||
else:
|
||||
is_correct = are_equal_under_sympy(ground_truth_elem, given_elem)
|
||||
if not is_correct:
|
||||
break
|
||||
|
||||
return is_correct
|
||||
|
||||
|
||||
def grade_answer_mathd(given_answer: str, ground_truth: str) -> bool:
|
||||
ground_truth_normalized_mathd = mathd_normalize_answer(ground_truth)
|
||||
given_answer_normalized_mathd = mathd_normalize_answer(given_answer)
|
||||
|
||||
# be at least as lenient as mathd
|
||||
if ground_truth_normalized_mathd == given_answer_normalized_mathd:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_answer(passage: str) -> str:
|
||||
if "\\boxed" in passage:
|
||||
return extract_boxed_answer(passage)
|
||||
return None
|
||||
|
||||
|
||||
def grade_answer_verl(solution_str, ground_truth):
|
||||
if not ground_truth:
|
||||
return False
|
||||
ground_truth = str(ground_truth)
|
||||
if "\\boxed" in ground_truth:
|
||||
ground_truth = extract_answer(ground_truth)
|
||||
given_answer = extract_answer(solution_str)
|
||||
if given_answer is None:
|
||||
return False
|
||||
return grade_answer_mathd(given_answer, ground_truth) or grade_answer_sympy(given_answer, ground_truth)
|
||||
675
slime/rollout/sglang_rollout.py
Normal file
675
slime/rollout/sglang_rollout.py
Normal file
@@ -0,0 +1,675 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
from argparse import Namespace
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pybase64
|
||||
import torch
|
||||
import sglang_router
|
||||
from packaging.version import parse
|
||||
from tqdm import tqdm
|
||||
|
||||
from slime.rollout.base_types import RolloutFnEvalOutput, RolloutFnTrainOutput
|
||||
from slime.rollout.filter_hub.base_types import DynamicFilterOutput
|
||||
from slime.utils.async_utils import run
|
||||
from slime.utils.data import Dataset
|
||||
from slime.utils.eval_config import EvalDatasetConfig
|
||||
from slime.utils.http_utils import get, post
|
||||
from slime.utils.mask_utils import get_response_lengths, MultiTurnLossMaskGenerator
|
||||
from slime.utils.misc import SingletonMeta, load_function
|
||||
from slime.utils.processing_utils import encode_image_for_rollout_engine, load_processor, load_tokenizer
|
||||
from slime.utils.types import Sample
|
||||
|
||||
from .rm_hub import async_rm, batched_async_rm
|
||||
|
||||
__all__ = ["generate_rollout"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GenerateState(metaclass=SingletonMeta):
|
||||
"""
|
||||
The global state for the generation process.
|
||||
"""
|
||||
|
||||
def __init__(self, args: Namespace) -> None:
|
||||
# persistent state for the generation process
|
||||
self.args = args
|
||||
self.tokenizer = load_tokenizer(args.hf_checkpoint, trust_remote_code=True)
|
||||
self.processor = load_processor(args.hf_checkpoint, trust_remote_code=True)
|
||||
|
||||
num_engines = args.rollout_num_gpus // args.rollout_num_gpus_per_engine
|
||||
self.semaphore = asyncio.Semaphore(
|
||||
args.sglang_server_concurrency * num_engines if num_engines > 0 else 1
|
||||
)
|
||||
self.sampling_params: dict[str, Any] = dict(
|
||||
temperature=args.rollout_temperature,
|
||||
top_p=args.rollout_top_p,
|
||||
top_k=args.rollout_top_k,
|
||||
max_new_tokens=args.rollout_max_response_len,
|
||||
stop=args.rollout_stop,
|
||||
stop_token_ids=args.rollout_stop_token_ids,
|
||||
skip_special_tokens=args.rollout_skip_special_tokens,
|
||||
no_stop_trim=False, # Changed to remove stop tokens from rollout output
|
||||
spaces_between_special_tokens=False,
|
||||
)
|
||||
|
||||
if getattr(args, "sglang_enable_deterministic_inference", False):
|
||||
sampling_seed_base = args.rollout_seed
|
||||
self.group_sampling_seeds = [sampling_seed_base + i for i in range(args.n_samples_per_prompt)]
|
||||
|
||||
self.reset()
|
||||
|
||||
def reset(self) -> None:
|
||||
self.remaining_batch_size = 0
|
||||
self.pendings = set()
|
||||
self.aborted = False
|
||||
self.current_rollout_id = 0
|
||||
|
||||
def submit_generate_tasks(self, samples: list[list[Sample]]) -> None:
|
||||
for group in samples:
|
||||
self.pendings.add(
|
||||
asyncio.create_task(
|
||||
# submit a group of samples as a single task.
|
||||
generate_and_rm_group(
|
||||
self.args,
|
||||
group,
|
||||
sampling_params=self.sampling_params.copy(),
|
||||
evaluation=False,
|
||||
)
|
||||
)
|
||||
)
|
||||
self.remaining_batch_size += len(samples)
|
||||
|
||||
|
||||
|
||||
def _is_lightning_opd(sample: Sample) -> bool:
|
||||
"""Check if sample is a Lightning OPD sample (pre-computed response + teacher logprobs)."""
|
||||
metadata = sample.metadata or {}
|
||||
return metadata.get("is_lightning_opd", False) or metadata.get("is_offline_opd", False)
|
||||
|
||||
|
||||
def _handle_lightning_opd_sample(sample: Sample, state: "GenerateState") -> Sample:
|
||||
"""Handle Lightning OPD samples: response tokens are pre-computed in parquet metadata.
|
||||
|
||||
Expected metadata fields:
|
||||
response_tokens: list[int] pre-tokenized response token IDs
|
||||
loss_mask: list[int] 1 for each response token to compute loss on
|
||||
response: str decoded response text (used by verifiable reward)
|
||||
|
||||
The sample's prompt tokens are prepended to form the full sequence sent to the teacher
|
||||
server for logprob computation. The training model then computes student logprobs on
|
||||
the same sequence during each forward pass, so the OPD advantage
|
||||
log P_teacher - log P_πt is still computed against the *current* policy.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
metadata = sample.metadata or {}
|
||||
|
||||
# Prompt may be a raw string (common case) or already tokenized list[int].
|
||||
prompt = sample.prompt
|
||||
if isinstance(prompt, str):
|
||||
prompt = state.tokenizer.encode(prompt, add_special_tokens=False)
|
||||
elif isinstance(prompt, np.ndarray):
|
||||
prompt = prompt.tolist()
|
||||
|
||||
response_tokens = metadata.get("response_tokens", [])
|
||||
if isinstance(response_tokens, np.ndarray):
|
||||
response_tokens = response_tokens.tolist()
|
||||
|
||||
loss_mask = metadata.get("loss_mask", [1] * len(response_tokens))
|
||||
if isinstance(loss_mask, np.ndarray):
|
||||
loss_mask = loss_mask.tolist()
|
||||
|
||||
# Full sequence = prompt tokens + response tokens (RM needs full context for logprobs)
|
||||
# Force Python int conversion: parquet pyarrow backend can produce numpy.int64 elements
|
||||
# that survive list() but fail JSON serialization when sent to the teacher server.
|
||||
sample.tokens = [int(x) for x in prompt] + [int(x) for x in response_tokens]
|
||||
sample.loss_mask = [int(x) for x in loss_mask]
|
||||
sample.response_length = int(sum(loss_mask))
|
||||
sample.response = metadata.get("response", "")
|
||||
sample.status = Sample.Status.COMPLETED
|
||||
|
||||
# Load pre-computed student (pi_ref) log-probs for importance weight tracking.
|
||||
# These are produced by data_curation/add_student_logprobs.py and stored as
|
||||
# metadata["student_log_probs"]. When present they are passed through as
|
||||
# rollout_log_probs so that loss.py can compute w = pi_theta / pi_ref.
|
||||
student_log_probs = metadata.get("student_log_probs")
|
||||
if student_log_probs is not None:
|
||||
sample.rollout_log_probs = [float(x) for x in student_log_probs]
|
||||
|
||||
return sample
|
||||
|
||||
|
||||
async def generate(args: Namespace, sample: Sample, sampling_params: dict[str, Any]) -> Sample:
|
||||
"""Generate using traditional SGLang router with token-based workflow"""
|
||||
if args.ci_test:
|
||||
assert isinstance(sample.prompt, str)
|
||||
|
||||
state = GenerateState(args)
|
||||
url = f"http://{args.sglang_router_ip}:{args.sglang_router_port}/generate"
|
||||
|
||||
assert (
|
||||
sample.status == Sample.Status.PENDING or sample.status == Sample.Status.ABORTED
|
||||
), f"Sample status is {sample.status}"
|
||||
|
||||
# Handle Lightning OPD samples: response tokens are pre-computed in metadata, skip sglang.
|
||||
# The RM call (teacher logprob computation) still runs normally after this.
|
||||
if _is_lightning_opd(sample):
|
||||
return _handle_lightning_opd_sample(sample, state)
|
||||
|
||||
if state.processor:
|
||||
processor_output = state.processor(text=sample.prompt, **sample.multimodal_inputs)
|
||||
prompt_ids = processor_output["input_ids"][0]
|
||||
sample.multimodal_train_inputs = {
|
||||
k: v for k, v in processor_output.items() if k not in ["input_ids", "attention_mask"]
|
||||
} or None
|
||||
else:
|
||||
prompt_ids = state.tokenizer.encode(sample.prompt, add_special_tokens=False)
|
||||
|
||||
if len(sample.response) > 0:
|
||||
sampling_params["max_new_tokens"] -= len(sample.tokens) - len(prompt_ids)
|
||||
|
||||
assert (
|
||||
sampling_params["max_new_tokens"] >= 0
|
||||
), f"max_new_tokens: {sampling_params['max_new_tokens']} should not be less than 0"
|
||||
if sampling_params["max_new_tokens"] == 0:
|
||||
sample.status = Sample.Status.TRUNCATED
|
||||
return sample
|
||||
|
||||
# Prepare payload for sglang server
|
||||
payload = {
|
||||
"sampling_params": sampling_params,
|
||||
"return_logprob": True,
|
||||
}
|
||||
|
||||
if args.use_rollout_routing_replay:
|
||||
payload["return_routed_experts"] = True
|
||||
|
||||
if sample.multimodal_inputs and sample.multimodal_inputs["images"]:
|
||||
image_data = sample.multimodal_inputs["images"]
|
||||
payload["image_data"] = [encode_image_for_rollout_engine(image) for image in image_data]
|
||||
|
||||
# Use existing tokens for multi-turn or tokenize the new prompt
|
||||
if len(sample.response) > 0:
|
||||
payload["input_ids"] = sample.tokens
|
||||
else:
|
||||
payload["input_ids"] = prompt_ids
|
||||
if not sample.tokens: # Initialize sample.tokens for the first turn
|
||||
sample.tokens = prompt_ids
|
||||
|
||||
output = await post(url, payload)
|
||||
|
||||
# Extract new response tokens
|
||||
|
||||
if args.use_slime_router and "RadixTreeMiddleware" in args.slime_router_middleware_paths:
|
||||
assert not args.partial_rollout, "Currently partial rollout is not supported when using slime router"
|
||||
retrieve_url = f"http://{args.sglang_router_ip}:{args.sglang_router_port}/retrieve_from_text"
|
||||
retrieve_payload = {"text": sample.prompt + output["text"], "return_logp": True}
|
||||
retrieve_output = await post(retrieve_url, retrieve_payload)
|
||||
sample.tokens = retrieve_output["tokens"]
|
||||
sample.response += output["text"]
|
||||
sample.loss_mask = retrieve_output["loss_mask"]
|
||||
sample.response_length = get_response_lengths([sample.loss_mask])[0]
|
||||
sample.loss_mask = sample.loss_mask[-sample.response_length :]
|
||||
sample.rollout_log_probs = retrieve_output["rollout_logp"][-sample.response_length :]
|
||||
# Notice: currently cannot get the spec info from radix router output.
|
||||
else:
|
||||
if "output_token_logprobs" in output["meta_info"]:
|
||||
new_response_tokens = [item[1] for item in output["meta_info"]["output_token_logprobs"]]
|
||||
new_response_log_probs = [item[0] for item in output["meta_info"]["output_token_logprobs"]]
|
||||
else:
|
||||
new_response_tokens, new_response_log_probs = [], []
|
||||
|
||||
# Update sample with tokens directly - avoiding re-tokenization
|
||||
sample.tokens = sample.tokens + new_response_tokens
|
||||
sample.response_length += len(new_response_tokens)
|
||||
sample.response += output["text"]
|
||||
|
||||
if sample.rollout_log_probs is None:
|
||||
sample.rollout_log_probs = []
|
||||
sample.rollout_log_probs += new_response_log_probs
|
||||
|
||||
if args.sglang_speculative_algorithm:
|
||||
# cannot directly use spec info from sglang because of partial rollout.
|
||||
sample.spec_info.add(
|
||||
meta_info=output["meta_info"],
|
||||
response_length=sample.response_length,
|
||||
)
|
||||
|
||||
if "weight_version" in output["meta_info"]:
|
||||
sample.weight_versions.append(output["meta_info"]["weight_version"])
|
||||
|
||||
if "routed_experts" in output["meta_info"]:
|
||||
sample.rollout_routed_experts = np.frombuffer(
|
||||
pybase64.b64decode(output["meta_info"]["routed_experts"].encode("ascii")),
|
||||
dtype=np.int32,
|
||||
).reshape(
|
||||
len(sample.tokens) - 1,
|
||||
args.num_layers,
|
||||
args.moe_router_topk,
|
||||
)
|
||||
|
||||
match output["meta_info"]["finish_reason"]["type"]:
|
||||
case "length":
|
||||
sample.status = Sample.Status.TRUNCATED
|
||||
case "abort":
|
||||
sample.status = Sample.Status.ABORTED
|
||||
case "stop":
|
||||
sample.status = Sample.Status.COMPLETED
|
||||
|
||||
return sample
|
||||
|
||||
|
||||
async def generate_and_rm(
|
||||
args: Namespace,
|
||||
sample: Sample | list[Sample],
|
||||
sampling_params: dict[str, Any],
|
||||
evaluation: bool = False,
|
||||
) -> Sample | list[Sample]:
|
||||
# mask previous off-policy generation for partial rollout
|
||||
if args.partial_rollout and args.mask_offpolicy_in_partial_rollout and sample.response_length > 0:
|
||||
sample.loss_mask = [0] * sample.response_length
|
||||
|
||||
# For samples with existing response, check if they're complete
|
||||
if sample.status == Sample.Status.COMPLETED or sample.status == Sample.Status.TRUNCATED:
|
||||
assert sample.response is not None
|
||||
if not args.group_rm:
|
||||
assert sample.reward is not None
|
||||
return sample
|
||||
|
||||
state = GenerateState(args)
|
||||
is_lightning_opd = _is_lightning_opd(sample)
|
||||
|
||||
# generate (skip for Lightning OPD samples whose tokens are pre-computed)
|
||||
if is_lightning_opd:
|
||||
sample = _handle_lightning_opd_sample(sample, state)
|
||||
else:
|
||||
async with state.semaphore:
|
||||
if state.aborted:
|
||||
sample.status = Sample.Status.ABORTED
|
||||
return sample
|
||||
|
||||
if args.custom_generate_function_path is not None:
|
||||
custom_generate_func = load_function(args.custom_generate_function_path)
|
||||
sample = await custom_generate_func(args, sample, sampling_params)
|
||||
else:
|
||||
sample = await generate(args, sample, sampling_params)
|
||||
|
||||
# for the rm that need the whole group, we will not do the rm here
|
||||
if args.group_rm:
|
||||
return sample
|
||||
|
||||
# multi samples
|
||||
if isinstance(sample, list):
|
||||
samples = sample
|
||||
if any([sample.status == Sample.Status.ABORTED for sample in samples]):
|
||||
return samples
|
||||
|
||||
# for multi agent system, the reward of some sample is calculated during generation.
|
||||
samples_need_reward = [sample for sample in samples if sample.reward is None]
|
||||
rewards = await batched_async_rm(args, samples_need_reward)
|
||||
for sample, reward in zip(samples_need_reward, rewards, strict=False):
|
||||
sample.reward = reward
|
||||
return samples
|
||||
else:
|
||||
if sample.status == Sample.Status.ABORTED:
|
||||
return sample
|
||||
# for multi-turn environment, a reward could be assigned to the agent.
|
||||
if sample.reward is None:
|
||||
sample.reward = await async_rm(args, sample)
|
||||
|
||||
return sample
|
||||
|
||||
|
||||
async def generate_and_rm_group(
|
||||
args: Namespace, group: list[Sample], sampling_params: dict[str, Any], evaluation: bool = False
|
||||
) -> list[Sample]:
|
||||
state = GenerateState(args)
|
||||
|
||||
if state.aborted:
|
||||
return group
|
||||
|
||||
tasks = []
|
||||
for idx, sample in enumerate(group):
|
||||
current_sampling_params = sampling_params.copy()
|
||||
if getattr(args, "sglang_enable_deterministic_inference", False):
|
||||
seed = state.group_sampling_seeds[idx]
|
||||
current_sampling_params["sampling_seed"] = seed
|
||||
tasks.append(
|
||||
asyncio.create_task(generate_and_rm(args, sample, current_sampling_params, evaluation=evaluation))
|
||||
)
|
||||
|
||||
group = await asyncio.gather(*tasks)
|
||||
|
||||
# for the rm that need the whole group, we will do the rm here
|
||||
if not state.aborted and args.group_rm:
|
||||
rewards = await batched_async_rm(args, group)
|
||||
for sample, reward in zip(group, rewards, strict=False):
|
||||
sample.reward = reward
|
||||
|
||||
return group
|
||||
|
||||
|
||||
async def abort(args: Namespace, rollout_id: int) -> list[list[Sample]]:
|
||||
aborted_samples = []
|
||||
|
||||
state = GenerateState(args)
|
||||
assert not state.aborted
|
||||
state.aborted = True
|
||||
|
||||
# No rollout engines → no router, no pending tasks; nothing to abort.
|
||||
if not args.rollout_num_gpus:
|
||||
return aborted_samples
|
||||
|
||||
if parse(sglang_router.__version__) <= parse("0.2.1") or args.use_slime_router:
|
||||
response = await get(f"http://{args.sglang_router_ip}:{args.sglang_router_port}/list_workers")
|
||||
urls = response["urls"]
|
||||
else:
|
||||
response = await get(f"http://{args.sglang_router_ip}:{args.sglang_router_port}/workers")
|
||||
urls = [worker["url"] for worker in response["workers"]]
|
||||
|
||||
logger.info(f"Abort request for {urls}")
|
||||
await asyncio.gather(*[post(f"{url}/abort_request", {"abort_all": True}) for url in urls])
|
||||
|
||||
# make sure all the pending tasks are finished
|
||||
count = 0
|
||||
while state.pendings:
|
||||
done, state.pendings = await asyncio.wait(state.pendings, return_when=asyncio.FIRST_COMPLETED)
|
||||
|
||||
if not args.partial_rollout:
|
||||
continue
|
||||
|
||||
# for partial rollout, collect the partial samples into the data buffer
|
||||
for task in done:
|
||||
group = task.result()
|
||||
for sample in group:
|
||||
if sample.response and "start_rollout_id" not in sample.metadata:
|
||||
sample.metadata["start_rollout_id"] = rollout_id
|
||||
aborted_samples.append(group)
|
||||
count += len(group)
|
||||
|
||||
if args.partial_rollout:
|
||||
logger.info(f"Collected {count} partial samples into the data buffer")
|
||||
|
||||
return aborted_samples
|
||||
|
||||
|
||||
async def generate_rollout_async(
|
||||
args: Namespace, rollout_id: int, data_source: Callable[[int], list[list[Sample]]]
|
||||
) -> tuple[RolloutFnTrainOutput, list[list[Sample]]]:
|
||||
"""An example to implement the generate_rollout function for an rule based rm rollout generation.
|
||||
|
||||
Args:
|
||||
args: the whole args
|
||||
rollout_id: int, the id of the rollout, used for deterministic data generation
|
||||
data_source: the data source to fetch
|
||||
|
||||
Returns:
|
||||
tuple[RolloutFnTrainOutput, list[list[Sample]]]:
|
||||
- data: a list of groups of samples generated by the rollout, length equals `rollout_batch_size`
|
||||
- aborted_samples: any partial groups collected during abort when partial_rollout is enabled
|
||||
"""
|
||||
assert args.rollout_global_dataset
|
||||
|
||||
state = GenerateState(args)
|
||||
state.current_rollout_id = rollout_id
|
||||
|
||||
# instantiate data filters
|
||||
dynamic_filter = (
|
||||
load_function(args.dynamic_sampling_filter_path) if args.dynamic_sampling_filter_path is not None else None
|
||||
)
|
||||
|
||||
metric_gatherer = _MetricGatherer()
|
||||
|
||||
# target_data_size is the total number of valid samples to get
|
||||
target_data_size = args.rollout_batch_size
|
||||
|
||||
data = []
|
||||
all_data = []
|
||||
do_print = True
|
||||
pbar = tqdm(total=target_data_size * args.n_samples_per_prompt, desc="Rollout generation")
|
||||
while len(data) < target_data_size:
|
||||
while state.remaining_batch_size < target_data_size:
|
||||
# get samples from the buffer and submit the generation requests.
|
||||
samples = data_source(args.over_sampling_batch_size)
|
||||
state.submit_generate_tasks(samples)
|
||||
|
||||
# wait for the generation to finish
|
||||
done, state.pendings = await asyncio.wait(state.pendings, return_when=asyncio.FIRST_COMPLETED)
|
||||
for task in done:
|
||||
group: list[Sample] = task.result()
|
||||
|
||||
if do_print:
|
||||
sample = group[0][0] if isinstance(group[0], list) else group[0]
|
||||
logger.info(
|
||||
f"First rollout sample: {[str(sample.prompt) + sample.response]}, label: {sample.label}, reward: {sample.reward}",
|
||||
)
|
||||
do_print = False
|
||||
|
||||
assert len(group) == args.n_samples_per_prompt
|
||||
all_data.append(group)
|
||||
dynamic_filter_output = _call_dynamic_filter(dynamic_filter, args, group)
|
||||
if not dynamic_filter_output.keep:
|
||||
metric_gatherer.on_dynamic_filter_drop(reason=dynamic_filter_output.reason)
|
||||
state.remaining_batch_size -= 1
|
||||
continue
|
||||
|
||||
# add the samples to the data
|
||||
# NOTE: here we have not stored all the unused samples back to the data buffer.
|
||||
if len(data) < target_data_size:
|
||||
data.append(group)
|
||||
pbar.update(len(group))
|
||||
|
||||
pbar.close()
|
||||
sample = data[-1][0][0] if isinstance(data[-1][0], list) else data[-1][0]
|
||||
logger.info(
|
||||
f"Finish rollout: {[str(sample.prompt) + sample.response]}, label: {sample.label}, reward: {sample.reward}",
|
||||
)
|
||||
|
||||
# there are still some unfinished requests, abort them
|
||||
aborted_samples = await abort(args, rollout_id)
|
||||
|
||||
assert len(data) == args.rollout_batch_size, f"Got {len(data)} samples, expected {args.rollout_batch_size}"
|
||||
data = sorted(data, key=lambda group: group[0][0].index if isinstance(group[0], list) else group[0].index)
|
||||
all_samples = sorted(data, key=lambda group: group[0][0].index if isinstance(group[0], list) else group[0].index)
|
||||
|
||||
# reset the global state to prevent effects on the next rollout or eval.
|
||||
state.reset()
|
||||
if args.rollout_sample_filter_path is not None:
|
||||
filter_func = load_function(args.rollout_sample_filter_path)
|
||||
filter_func(args, data)
|
||||
|
||||
# There can be circumstances where users want to process all samples including filtered ones.
|
||||
if args.rollout_all_samples_process_path is not None:
|
||||
process_func = load_function(args.rollout_all_samples_process_path)
|
||||
process_func(args, all_samples, data_source)
|
||||
|
||||
return RolloutFnTrainOutput(samples=data, metrics=metric_gatherer.collect()), aborted_samples
|
||||
|
||||
|
||||
def _call_dynamic_filter(fn, *args, **kwargs):
|
||||
if fn is None:
|
||||
return DynamicFilterOutput(keep=True)
|
||||
|
||||
output = fn(*args, **kwargs)
|
||||
|
||||
# compatibility for legacy version
|
||||
if not isinstance(output, DynamicFilterOutput):
|
||||
output = DynamicFilterOutput(keep=output)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
class _MetricGatherer:
|
||||
def __init__(self):
|
||||
self._dynamic_filter_drop_reason_count = defaultdict(lambda: 0)
|
||||
|
||||
def on_dynamic_filter_drop(self, reason: str | None):
|
||||
if not reason:
|
||||
return
|
||||
self._dynamic_filter_drop_reason_count[reason] += 1
|
||||
|
||||
def collect(self):
|
||||
return {
|
||||
f"rollout/dynamic_filter/drop_{reason}": count
|
||||
for reason, count in self._dynamic_filter_drop_reason_count.items()
|
||||
}
|
||||
|
||||
|
||||
EVAL_PROMPT_DATASET = {}
|
||||
|
||||
|
||||
async def eval_rollout(args: Namespace, rollout_id: int) -> tuple[dict[str, dict[str, list[Any]]], list[list[Sample]]]:
|
||||
assert not args.group_rm, "Group RM is not supported for eval rollout"
|
||||
|
||||
coros = []
|
||||
for dataset_cfg in getattr(args, "eval_datasets", []) or []:
|
||||
coros.append(eval_rollout_single_dataset(args, rollout_id, dataset_cfg))
|
||||
results_list = await asyncio.gather(*coros)
|
||||
results = {}
|
||||
for r in results_list:
|
||||
results.update(r)
|
||||
return RolloutFnEvalOutput(data=results), []
|
||||
|
||||
|
||||
async def eval_rollout_single_dataset(
|
||||
args: Namespace, rollout_id: int, dataset_cfg: EvalDatasetConfig
|
||||
) -> dict[str, dict[str, list[Any]]]:
|
||||
"""An example to implement the eval_rollout function for an rule based rm rollout generation.
|
||||
|
||||
Args:
|
||||
args: the whole args
|
||||
rollout_id: int, the id of the rollout, used for deterministic data generation
|
||||
dataset_cfg: configuration of the dataset
|
||||
"""
|
||||
assert not args.group_rm, "Group RM is not supported for eval rollout"
|
||||
|
||||
global EVAL_PROMPT_DATASET
|
||||
|
||||
cache_key = dataset_cfg.cache_key + (args.hf_checkpoint, args.apply_chat_template)
|
||||
if cache_key not in EVAL_PROMPT_DATASET:
|
||||
tokenizer = load_tokenizer(args.hf_checkpoint, trust_remote_code=True)
|
||||
processor = load_processor(args.hf_checkpoint, trust_remote_code=True)
|
||||
EVAL_PROMPT_DATASET[cache_key] = Dataset(
|
||||
path=dataset_cfg.path,
|
||||
tokenizer=tokenizer,
|
||||
processor=processor,
|
||||
max_length=args.eval_max_prompt_len,
|
||||
prompt_key=dataset_cfg.input_key,
|
||||
label_key=dataset_cfg.label_key,
|
||||
multimodal_keys=args.multimodal_keys,
|
||||
metadata_key=dataset_cfg.metadata_key,
|
||||
tool_key=dataset_cfg.tool_key,
|
||||
apply_chat_template=args.apply_chat_template,
|
||||
apply_chat_template_kwargs=args.apply_chat_template_kwargs,
|
||||
)
|
||||
dataset = EVAL_PROMPT_DATASET[cache_key]
|
||||
|
||||
base_sampling_params = dict(
|
||||
temperature=dataset_cfg.temperature,
|
||||
top_p=dataset_cfg.top_p,
|
||||
top_k=dataset_cfg.top_k,
|
||||
max_new_tokens=dataset_cfg.max_response_len,
|
||||
stop=args.rollout_stop,
|
||||
stop_token_ids=args.rollout_stop_token_ids,
|
||||
skip_special_tokens=args.rollout_skip_special_tokens,
|
||||
no_stop_trim=False, # Changed to remove stop tokens from rollout output
|
||||
spaces_between_special_tokens=False,
|
||||
)
|
||||
|
||||
tasks = []
|
||||
# do multiple samples for eval prompts
|
||||
sample_index = 0
|
||||
for _i, prompt_sample in enumerate(dataset.samples):
|
||||
for j in range(dataset_cfg.n_samples_per_eval_prompt):
|
||||
# use the same prompt for multiple samples
|
||||
sample = copy.deepcopy(prompt_sample)
|
||||
sample.index = sample_index
|
||||
sample_index += 1
|
||||
sample.metadata = dataset_cfg.inject_metadata(getattr(sample, "metadata", None))
|
||||
sampling_params = base_sampling_params
|
||||
if getattr(args, "sglang_enable_deterministic_inference", False):
|
||||
sampling_params = base_sampling_params.copy()
|
||||
sampling_params["sampling_seed"] = args.rollout_seed + j
|
||||
tasks.append(
|
||||
asyncio.create_task(
|
||||
generate_and_rm(
|
||||
args,
|
||||
sample,
|
||||
sampling_params=sampling_params,
|
||||
evaluation=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
data = []
|
||||
do_print = True
|
||||
pbar = tqdm(total=len(tasks), desc="Rollout generation", disable=not do_print)
|
||||
for coro in asyncio.as_completed(tasks):
|
||||
sample = await coro
|
||||
if do_print:
|
||||
logger.info(
|
||||
"eval_rollout_single_dataset example data: "
|
||||
f"{[str(sample.prompt) + sample.response]} "
|
||||
f"reward={sample.reward}"
|
||||
)
|
||||
do_print = False
|
||||
if isinstance(sample, list):
|
||||
data.extend(sample)
|
||||
else:
|
||||
data.append(sample)
|
||||
pbar.update(1)
|
||||
pbar.close()
|
||||
|
||||
data.sort(key=lambda sample: sample.index)
|
||||
|
||||
reward_key = args.eval_reward_key or args.reward_key
|
||||
return {
|
||||
dataset_cfg.name: {
|
||||
"rewards": [sample.reward if not reward_key else sample.reward[reward_key] for sample in data],
|
||||
"truncated": [sample.status == Sample.Status.TRUNCATED for sample in data],
|
||||
"samples": data,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# TODO remove this temp function
|
||||
def generate_rollout(
|
||||
args: Namespace, rollout_id: int, data_buffer: Any, evaluation: bool = False
|
||||
) -> RolloutFnTrainOutput | RolloutFnEvalOutput:
|
||||
"""An example to implement the generate_rollout function for an rule based rm rollout generation.
|
||||
|
||||
Args:
|
||||
args: the whole args
|
||||
rollout_id: int, the id of the rollout, used for deterministic data generation
|
||||
data_buffer: the data buffer to store the generated samples
|
||||
evaluation: bool, whether the rollout is for evaluation or not
|
||||
|
||||
Returns:
|
||||
list[list[Sample]]: a list of list of samples generated by the rollout
|
||||
"""
|
||||
output, aborted_samples = generate_abortable_samples(
|
||||
args, rollout_id, data_buffer.get_samples, evaluation=evaluation
|
||||
)
|
||||
data_buffer.add_samples(aborted_samples)
|
||||
return output
|
||||
|
||||
|
||||
def generate_abortable_samples(
|
||||
args: Namespace,
|
||||
rollout_id: int,
|
||||
data_source: Callable[[int], list[list[Sample]]],
|
||||
evaluation: bool = False,
|
||||
) -> tuple[Any, list[list[Sample]]]:
|
||||
assert args.rollout_global_dataset
|
||||
if evaluation:
|
||||
return run(eval_rollout(args, rollout_id))
|
||||
return run(generate_rollout_async(args, rollout_id, data_source))
|
||||
15
slime/rollout/sleep_rollout.py
Normal file
15
slime/rollout/sleep_rollout.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sleep(args, rollout_id, data_source, evaluation=False):
|
||||
count = 0
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
count += 1
|
||||
logger.info(f"rollout sleep for {count} hours")
|
||||
Reference in New Issue
Block a user