# Adapt from https://github.com/PeterGriffinJin/Search-R1/blob/ceee7b89655ed52f205b9beb98e1190c3eedcfb0/verl/utils/reward_score/qa_em_format.py
# Copyright 2024 Bytedance Ltd. and/or its affiliates
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import random
import re
import string
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 em_check(prediction, golden_answers):
if isinstance(golden_answers, str):
golden_answers = [golden_answers]
normalized_prediction = normalize_answer(prediction)
score = 0
for golden_answer in golden_answers:
golden_answer = normalize_answer(golden_answer)
if golden_answer == normalized_prediction:
score = 1
break
return score
def is_valid_sequence(text):
# Find the position of "<|im_start|>assistant" with potential whitespace
assistant_pattern = r"<\|im_start\|>assistant\s*"
assistant_match = re.search(assistant_pattern, text)
if not assistant_match:
return False, "Missing assistant marker"
# Extract the content after the assistant marker
start_pos = assistant_match.end()
content = text[start_pos:]
# Check for balanced tags
tags_to_check = ["think", "search", "information", "answer"]
for tag in tags_to_check:
opening_count = len(re.findall(f"<{tag}>", content))
closing_count = len(re.findall(f"{tag}>", content))
if opening_count != closing_count:
return False, f"Mismatch in {tag} tags: {opening_count} opening vs {closing_count} closing tags"
# Now check for proper sequence pattern and no extraneous content
# 1. First split the content by any tags we recognize
split_pattern = r"(?(?:think|search|information|answer)>)"
parts = re.split(split_pattern, content)
# 2. Keep track of the current position in the expected sequence
state = "start" # start -> think -> search -> information -> think -> ... -> answer -> end
# 3. Check each part
for _i, part in enumerate(parts):
# Skip empty parts
if not part.strip():
continue
# Check if this is a tag
if re.match(r"?(?:think|search|information|answer)>", part):
# This is a tag, check if it's valid in the current state
if part == "" and state in ["start", "information"]:
state = "in_think"
elif part == "" and state == "in_think":
state = "after_think"
elif part == "" and state == "after_think":
state = "in_search"
elif part == "" and state == "in_search":
state = "after_search"
elif part == "" and state == "after_search":
state = "in_information"
elif part == "" and state == "in_information":
state = "information"
elif part == "" and state == "after_think":
state = "in_answer"
elif part == "" and state == "in_answer":
state = "end"
else:
return False, f"Unexpected tag {part} in state {state}"
else:
# This is content, check if it's valid in the current state
if state in ["in_think", "in_search", "in_information", "in_answer"]:
# Content is allowed inside tags
pass
elif state in ["start", "after_think", "after_search", "information"]:
# Only whitespace is allowed between tags
if part.strip():
return False, f"Unexpected content '{part.strip()}' between tags (state: {state})"
else:
return False, f"Unexpected content in state {state}"
# Check final state
if state != "end":
return False, f"Incomplete sequence, ended in state {state}"
return True, "Valid sequence format"
def extract_solution(solution_str):
"""Extract the equation from the solution string."""
answer_pattern = r"(.*?)"
match = re.finditer(answer_pattern, solution_str, re.DOTALL)
matches = list(match)
# If there are 0 or exactly 1 matches, return None
if len(matches) <= 1:
return None
# If there are 2 or more matches, return the last one
return matches[-1].group(1).strip()
def extract_information_blocks(text: str) -> list[str]:
pattern = r"(.*?)"
matches = re.findall(pattern, text, re.DOTALL)
return [match.strip() for match in matches]
def is_retrieval_correct(text: str, golden_answers: list[str]) -> list[str]:
seqs = extract_information_blocks(text)
for seq in seqs:
for golden_answer in golden_answers:
if normalize_answer(golden_answer) in normalize_answer(seq):
return True
return False
def compute_score_em(
solution_str,
ground_truth,
method="strict",
structure_format_score=0,
final_format_score=0,
retrieval_score=0,
format_score=0,
score=1.0,
):
"""The scoring function for exact match (EM).
Args:
solution_str: the solution text
ground_truth: the ground truth
method: the method to extract the solution, choices are 'strict' and 'flexible'
format_score: the score for the format
score: the score for the correct answer
"""
is_valid_format, _ = is_valid_sequence(solution_str)
retrieval_correct = False
if is_valid_format:
retrieval_correct = is_retrieval_correct(solution_str, ground_truth["target"])
answer = extract_solution(solution_str=solution_str)
do_print = random.randint(1, 64) == 1
if do_print:
print("--------------------------------")
print(f"Golden answers: {ground_truth['target']}")
print(f"Extracted answer: {answer}")
print(f"Solution string: {solution_str}")
if answer is None:
if is_valid_format:
if retrieval_correct:
return structure_format_score + retrieval_score # 0.3
else:
return structure_format_score # 0.2
else:
return 0
else:
if em_check(answer, ground_truth["target"]):
if is_valid_format:
return score # 1
else:
return score - structure_format_score # 0.8
elif is_valid_format:
if retrieval_correct:
return structure_format_score + retrieval_score # 0.3
else:
return structure_format_score # 0.2
else:
return final_format_score # 0.1