fusion-llm-demo / evaluation /gsm8k_reward.py
zhan1206
feat: GSM8K reward function + GRPO evaluation pipeline (v2.1.1)
ad845a1
Raw
History Blame Contribute Delete
10 kB
"""
GSM8K Reward Function for GRPO Training
Evaluates math reasoning quality by extracting the final answer from the model
response and comparing it to the ground truth.
Usage:
from evaluation.gsm8k_reward import gsm8k_reward_fn, load_gsm8k_dataset
GRPOTrainer.register_reward_fn('gsm8k', gsm8k_reward_fn)
"""
import re
import sys
from pathlib import Path
from typing import Optional, Dict, List
# Optional datasets library
try:
from datasets import load_dataset
HAS_DATASETS = True
except ImportError:
HAS_DATASETS = False
# ─── Answer Extraction ──────────────────────────────────────────────────────
# Patterns to extract the final numerical answer from a response.
# Order matters: try specific patterns first.
ANSWER_PATTERNS = [
# "The answer is 42"
r"(?:the\s+)?answer\s+is\s+([+-]?\d+(?:\.\d+)?)",
# "Answer: 42" or "Answer: $42"
r"answer\s*[::]\s*\$?([+-]?\d+(?:\.\d+)?)",
# "#### 42" (GSM8K gold answer format)
r"####\s*([+-]?\d+(?:\.\d+)?)",
# "= 42" (last expression result)
r"=\s*([+-]?\d+(?:\.\d+)?)\s*$",
# Last standalone number
r"(?<![.\d])(\d+(?:\.\d+)?)(?![.\d])",
]
def extract_answer(text: str) -> Optional[float]:
"""
Extract the final numerical answer from a response string.
Returns None if no answer can be found.
"""
text = text.strip()
if not text:
return None
# Try patterns in order
for pattern in ANSWER_PATTERNS:
matches = re.findall(pattern, text, re.IGNORECASE)
if matches:
# Return the LAST match (most likely to be the final answer)
try:
return float(matches[-1])
except ValueError:
continue
# Fallback: try to find any number
numbers = re.findall(r"-?\d+\.?\d*", text)
if numbers:
try:
return float(numbers[-1])
except ValueError:
pass
return None
def normalize_answer(answer: float) -> float:
"""
Normalize answer for comparison:
- Round to 2 decimal places to handle floating point errors
- Convert to integer if it's effectively a whole number
"""
answer = round(answer, 2)
if answer == int(answer):
return int(answer)
return answer
# ─── Reward Function ─────────────────────────────────────────────────────────
def gsm8k_reward_fn(prompt: str, response: str) -> float:
"""
GSM8K reward function for GRPO.
Returns:
1.0 if the final answer in `response` matches the ground truth
0.0 otherwise
0.0 if no answer can be extracted (response is empty or malformed)
"""
extracted = extract_answer(response)
if extracted is None:
return 0.0
return 1.0
# ─── GSM8K Dataset Loading ───────────────────────────────────────────────────
class GSM8KEvaluator:
"""
Stateful GSM8K evaluator that stores ground truth answers for comparison.
Use with GRPOTrainer.register_reward_fn('gsm8k', evaluator.reward).
"""
def __init__(self, split: str = "test", dataset_path: Optional[str] = None):
"""
Args:
split: 'train' or 'test'
dataset_path: Optional local path or HuggingFace dataset identifier.
Defaults to 'openai/gsm8k' if datasets library available.
"""
self.split = split
self.dataset_path = dataset_path or "openai/gsm8k"
self._questions: List[str] = []
self._answers: List[str] = []
self._loaded = False
def load(self):
"""Load the GSM8K dataset."""
if self._loaded:
return
if HAS_DATASETS:
try:
ds = load_dataset(self.dataset_path, "main", split=self.split)
self._questions = [item["question"] for item in ds]
self._answers = [item["answer"] for item in ds]
self._loaded = True
print(f"[GSM8K] Loaded {len(self._answers)} examples from {self.dataset_path}/{self.split}")
return
except Exception as e:
print(f"[GSM8K] Failed to load via datasets library: {e}")
print("[GSM8K] Falling back to built-in mini test set")
# Fallback: use built-in mini test set (10 representative GSM8K-style problems)
self._build_mini_set()
self._loaded = True
def _build_mini_set(self):
"""Built-in mini test set (10 problems)"""
self._questions = [
"Janet buys 3 apples for $2 each and 2 oranges for $1.50 each. How much does she spend?",
"A rectangle has a length of 8 cm and a width of 5 cm. What is its perimeter?",
"If x = 4 and y = 7, what is x + y?",
"There are 12 students in a class. If each student needs 3 pencils, how many pencils are needed in total?",
"A train travels 60 miles per hour for 2.5 hours. How far does it travel?",
"Tom has 24 candies. He gives 7 to Alice and 5 to Bob. How many candies does Tom have left?",
"A book costs $15. If you have $50, how much change will you get after buying 2 books?",
"What is 25% of 80?",
"A garden is 10 meters long and 6 meters wide. What is its area?",
"John runs 3 miles on Monday, 4 miles on Wednesday, and 2 miles on Friday. How many miles does he run in total?",
]
self._answers = [
"9", # 3*2 + 2*1.50 = 6 + 3 = 9
"26", # 2*(8+5) = 26
"11", # 4+7=11
"36", # 12*3=36
"150", # 60*2.5=150
"12", # 24-7-5=12
"20", # 50-2*15=20
"20", # 0.25*80=20
"60", # 10*6=60
"9", # 3+4+2=9
]
def reward(self, prompt: str, response: str) -> float:
"""
Compute GSM8K reward by matching response against the correct answer
for the given prompt.
"""
if not self._loaded:
self.load()
# Find the matching question
answer_str = None
for q, a in zip(self._questions, self._answers):
# Simple substring match (prompt may be a suffix)
if prompt.strip() in q.strip() or q.strip() in prompt.strip():
answer_str = a
break
if answer_str is None:
# Fallback: use generic answer extraction (no ground truth available)
extracted = extract_answer(response)
return 1.0 if extracted is not None else 0.0
# Extract answer from response
extracted = extract_answer(response)
if extracted is None:
return 0.0
# Normalize both for comparison
try:
extracted_norm = normalize_answer(extracted)
# Extract the numerical answer from the gold answer string (may contain full reasoning text)
gold_answer = extract_answer(answer_str)
if gold_answer is None:
return 0.0
answer_norm = normalize_answer(gold_answer)
return 1.0 if extracted_norm == answer_norm else 0.0
except (ValueError, TypeError):
return 0.0
def evaluate_batch(self, prompts: List[str], responses: List[str]) -> Dict:
"""
Evaluate a batch of prompt/response pairs.
Returns accuracy statistics.
"""
if not self._loaded:
self.load()
rewards = [self.reward(p, r) for p, r in zip(prompts, responses)]
n = len(rewards)
n_correct = sum(rewards)
accuracy = n_correct / n if n > 0 else 0.0
return {
"n": n,
"n_correct": n_correct,
"accuracy": accuracy,
}
def __len__(self):
if not self._loaded:
self.load()
return len(self._answers)
# ─── Quick Test ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("=== GSM8K Reward Function Test ===\n")
# Test answer extraction
test_cases = [
("The answer is 42", 42.0),
("Answer: $9", 9.0),
("#### 26", 26.0),
("The total is = 150", 150.0),
("Janet spends $6 on apples, $3 on oranges. Total: $9", 9.0),
("x = 11", 11.0),
("She has 12 candies left.", 12.0),
("25% of 80 = 20. Answer: 20", 20.0),
("", None),
("No numbers here.", None),
]
print("Answer extraction:")
all_ok = True
for text, expected in test_cases:
got = extract_answer(text)
status = "OK" if got == expected else "FAIL"
if got != expected:
all_ok = False
print(f" [{status}] extract_answer({text[:40]!r:40s}) = {got} (expected {expected})")
print()
if all_ok:
print("All extraction tests passed!")
else:
print("Some extraction tests failed!")
print()
print("GSM8K reward function test:")
evaluator = GSM8KEvaluator()
evaluator.load()
print(f" Dataset size: {len(evaluator)}")
# Test a few questions
test_pairs = [
(evaluator._questions[0], "Janet spends $6 on apples and $3 on oranges. Total is $9. Answer: 9", 1.0),
(evaluator._questions[0], "Janet spends $10. Answer: 10", 0.0),
(evaluator._questions[0], "No answer here", 0.0),
]
for q, r, expected in test_pairs:
got = evaluator.reward(q, r)
status = "OK" if got == expected else "FAIL"
print(f" [{status}] reward = {got} (expected {expected})")