minimind/trainer/search_shortqa_reward.py

144 lines
4.8 KiB
Python

import json
import re
import string
from collections import Counter
from typing import Any, Dict, Iterable, List, Tuple
def normalize_text(text: Any) -> str:
text = str(text or "").lower().strip()
text = re.sub(r"\s+", " ", text)
return text.strip(string.punctuation + ",。!?;:“”‘’()【】《》、")
def tokenize_text(text: Any) -> List[str]:
text = normalize_text(text)
zh_chars = re.findall(r"[\u4e00-\u9fff]", text)
words = re.findall(r"[a-z0-9]+", text)
return zh_chars + words
def token_f1(prediction: str, gold_answers: Iterable[str]) -> float:
pred_tokens = tokenize_text(prediction)
if not pred_tokens:
return 0.0
best = 0.0
for answer in gold_answers:
gold_tokens = tokenize_text(answer)
if not gold_tokens:
continue
common = Counter(pred_tokens) & Counter(gold_tokens)
overlap = sum(common.values())
if overlap == 0:
continue
precision = overlap / len(pred_tokens)
recall = overlap / len(gold_tokens)
best = max(best, 2 * precision * recall / (precision + recall))
return best
def exact_match(prediction: str, gold_answers: Iterable[str]) -> float:
pred = normalize_text(prediction)
return float(any(pred == normalize_text(answer) for answer in gold_answers))
def parse_search_response(text: str) -> Tuple[str, List[str], bool]:
text = str(text or "").strip()
json_text = text
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if fenced:
json_text = fenced.group(1)
else:
match = re.search(r"\{.*\}", text, re.DOTALL)
if match:
json_text = match.group(0)
try:
data = json.loads(json_text)
answer = str(data.get("answer", "")).strip()
citations = data.get("citations", [])
if isinstance(citations, str):
citations = [citations]
citations = [str(x) for x in citations]
return answer, citations, True
except Exception:
return text, [], False
def repetition_penalty(text: str, n=3, cap=0.4) -> float:
toks = tokenize_text(text)
if len(toks) < n:
return 0.0
grams = [tuple(toks[i:i + n]) for i in range(len(toks) - n + 1)]
return min(cap, (len(grams) - len(set(grams))) / max(len(grams), 1))
def citation_metrics(citations: List[str], gold_doc_ids: Iterable[str], valid_doc_ids: Iterable[str]) -> Dict[str, float]:
pred = [str(x) for x in citations]
gold = set(str(x) for x in gold_doc_ids)
valid = set(str(x) for x in valid_doc_ids)
if not pred:
return {"citation_precision": 0.0, "citation_recall": 0.0, "citation_valid": 0.0}
valid_rate = sum(1 for x in pred if x in valid) / len(pred)
if not gold:
return {"citation_precision": valid_rate, "citation_recall": 1.0, "citation_valid": valid_rate}
hit = sum(1 for x in pred if x in gold)
return {
"citation_precision": hit / len(pred),
"citation_recall": hit / len(gold),
"citation_valid": valid_rate,
}
def score_search_shortqa_response(
response: str,
answers: Iterable[str],
gold_doc_ids: Iterable[str],
contexts: List[Dict[str, Any]],
weights=None,
) -> Tuple[float, Dict[str, float]]:
weights = weights or {
"answer_f1": 0.45,
"exact_match": 0.20,
"citation": 0.20,
"format": 0.10,
"brevity": 0.05,
}
answer, citations, is_json = parse_search_response(response)
valid_doc_ids = [doc.get("id") for doc in contexts]
cite = citation_metrics(citations, gold_doc_ids, valid_doc_ids)
answer_f1 = token_f1(answer, answers)
em = exact_match(answer, answers)
format_score = 1.0 if is_json and isinstance(citations, list) and answer else 0.0
answer_len = len(answer)
brevity = 1.0 if 1 <= answer_len <= 80 else max(0.0, 1.0 - abs(answer_len - 80) / 200)
repeat = repetition_penalty(answer)
citation_score = 0.5 * cite["citation_precision"] + 0.3 * cite["citation_recall"] + 0.2 * cite["citation_valid"]
total = (
weights["answer_f1"] * answer_f1
+ weights["exact_match"] * em
+ weights["citation"] * citation_score
+ weights["format"] * format_score
+ weights["brevity"] * brevity
- 0.20 * repeat
)
parts = {
"reward_total": total,
"answer_f1": answer_f1,
"exact_match": em,
"citation_score": citation_score,
"format_score": format_score,
"brevity_score": brevity,
"repetition_penalty": repeat,
}
parts.update(cite)
return float(total), parts
def average_metrics(metrics: List[Dict[str, float]]) -> Dict[str, float]:
if not metrics:
return {}
keys = sorted({key for item in metrics for key in item})
return {key: sum(float(item.get(key, 0.0)) for item in metrics) / len(metrics) for key in keys}