ProCreations's picture
download
raw
8.36 kB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Utilities for cross-encoder / external-service reranking evaluation.
The helpers here mirror the standalone BGE/EasyRec scripts the user provided,
but are adapted to the repository's data structures and constants so they can
be reused across different evaluators.
"""
from __future__ import annotations
import math
import random
from collections import defaultdict
from dataclasses import dataclass
from typing import Dict, Iterable, List, Sequence, Set, Tuple
import numpy as np
from agent_rec.config import pos_topk_for_qid
from agent_rec.data import stratified_train_valid_split
@dataclass
class EvalItem:
qid: str
qtext: str
cand_ids: List[str]
doc_texts: List[str]
rel_set: Set[str]
def build_agent_text_cache(all_agents: Dict[str, dict], tools: Dict[str, dict]) -> Dict[str, str]:
"""
Build a textual representation for each agent: `<model> || tool desc`.
The same concatenation is used by the original BGE/EasyRec scripts, so
keeping it here ensures score parity when migrating to this codebase.
"""
def _tool_text(tn: str) -> str:
t = tools.get(tn, {}) or {}
return f"{tn} {t.get('description', '')}".strip()
cache: Dict[str, str] = {}
for aid, a in all_agents.items():
a = a or {}
mname = (a.get("M", {}) or {}).get("name", "") or ""
tlst = (a.get("T", {}) or {}).get("tools", []) or []
parts = [mname]
if tlst:
tool_parts = [_tool_text(tn) for tn in tlst]
parts.append(" || " + " | ".join(tool_parts))
cache[aid] = "".join(parts).strip(" |")
return cache
def select_eval_qids(
qids_in_rank: List[str],
*,
seed: int,
valid_ratio: float = 0.2,
qid_to_part: Dict[str, str] | None = None,
stratified: bool = True,
) -> List[str]:
"""
Pick eval qids in a deterministic way.
If `qid_to_part` is provided (default for our datasets) and `stratified` is
True, this mirrors the training scripts' part-aware split so evaluation
uses the same distribution as the traditional models. Otherwise, it falls
back to a simple global shuffle.
"""
if valid_ratio <= 0:
return list(qids_in_rank)
if stratified and qid_to_part:
_, valid_qids = stratified_train_valid_split(
list(qids_in_rank), qid_to_part=qid_to_part, valid_ratio=valid_ratio, seed=seed
)
return valid_qids
rng = random.Random(seed)
eval_qids = list(qids_in_rank)
rng.shuffle(eval_qids)
n_valid = int(len(eval_qids) * valid_ratio)
return eval_qids[:n_valid]
def sample_qids_by_part(
qids: Sequence[str],
*,
qid_to_part: Dict[str, str] | None,
per_part: int,
seed: int,
) -> List[str]:
"""Sample a fixed number of qids from each dataset part.
Args:
qids: Candidate qids to sample from.
qid_to_part: Mapping from qid to part name.
per_part: Number of qids to sample per part; 0 or negative disables sampling.
seed: Seed for deterministic sampling.
"""
if per_part <= 0 or not qid_to_part:
return list(qids)
rng = random.Random(seed)
by_part: Dict[str, List[str]] = defaultdict(list)
for qid in qids:
part = qid_to_part.get(qid, "unknown")
by_part[part].append(qid)
sampled: List[str] = []
for part, part_qids in by_part.items():
if len(part_qids) <= per_part:
sampled.extend(part_qids)
else:
sampled.extend(rng.sample(part_qids, per_part))
return sampled
def _negatives_via_sampling(
*,
rel_set: Set[str],
a_ids_arr: np.ndarray,
need_neg: int,
np_rng: np.random.Generator,
oversample_mult: float = 2.0,
) -> List[str]:
"""
Draw negatives by random sampling over all agent IDs instead of building
an explicit `all_agents - rel_set` pool. This keeps memory overhead low
and matches the optimized approach used in the EasyRec threaded script.
"""
neg_list: List[str] = []
if need_neg <= 0:
return neg_list
target = max(int(need_neg * oversample_mult), 1)
while len(neg_list) < need_neg:
idx = np_rng.integers(0, len(a_ids_arr), size=target, endpoint=False)
for aid in a_ids_arr[idx]:
if aid not in rel_set and aid not in neg_list:
neg_list.append(aid)
if len(neg_list) >= need_neg:
break
if len(neg_list) < need_neg:
target = max(int((need_neg - len(neg_list)) * oversample_mult), 1)
return neg_list[:need_neg]
def prepare_eval_items(
*,
eval_qids: Iterable[str],
all_questions: Dict[str, dict],
all_agents: Dict[str, dict],
tools: Dict[str, dict],
all_rankings: Dict[str, List[str]],
a_ids: List[str],
seed: int,
cand_size: int = 1000,
pos_topk: int | None = None,
qid_to_part: Dict[str, str] | None = None,
agent_text_cache: Dict[str, str] | None = None,
) -> List[EvalItem]:
"""
Construct evaluation items with positives injected and random negatives.
"""
assert cand_size > 0
a_ids_arr = np.asarray(a_ids)
a_set = set(a_ids)
np_rng = np.random.default_rng(seed)
if agent_text_cache is None:
agent_text_cache = build_agent_text_cache(all_agents, tools)
items: List[EvalItem] = []
for qid in eval_qids:
k = pos_topk if pos_topk is not None else pos_topk_for_qid(qid, qid_to_part)
gt_all = [aid for aid in (all_rankings.get(qid, []) or []) if aid in a_set]
gt = gt_all[:k]
if not gt:
continue
rel_set = set(gt)
need_neg = max(0, cand_size - len(gt))
neg_list = _negatives_via_sampling(
rel_set=rel_set, a_ids_arr=a_ids_arr, need_neg=need_neg, np_rng=np_rng
)
cand_ids = gt + neg_list
qtext = (all_questions.get(qid, {}) or {}).get("input", "") or ""
doc_texts = [agent_text_cache.get(aid, "") for aid in cand_ids]
items.append(
EvalItem(
qid=qid,
qtext=qtext,
cand_ids=cand_ids,
doc_texts=doc_texts,
rel_set=rel_set,
)
)
return items
def metric_template(ks: Sequence[int]) -> Dict[int, Dict[str, float]]:
return {k: {"P": 0.0, "R": 0.0, "F1": 0.0, "Hit": 0.0, "nDCG": 0.0, "MRR": 0.0} for k in ks}
def metrics_from_hits(bin_hits: List[int], rel_size: int, ks: Sequence[int]) -> Dict[int, Dict[str, float]]:
out: Dict[int, Dict[str, float]] = {}
rel_size = max(rel_size, 1)
for k in ks:
topk_hits = bin_hits[:k]
Hk = sum(topk_hits)
P = Hk / float(k)
R = Hk / float(rel_size)
F1 = (2 * P * R) / (P + R) if (P + R) > 0 else 0.0
Hit = 1.0 if Hk > 0 else 0.0
dcg = 0.0
for i, h in enumerate(topk_hits):
if h:
dcg += 1.0 / math.log2(i + 2.0)
ideal = min(rel_size, k)
idcg = sum(1.0 / math.log2(i + 2.0) for i in range(ideal)) if ideal > 0 else 0.0
nDCG = (dcg / idcg) if idcg > 0 else 0.0
rr = 0.0
for i, h in enumerate(topk_hits):
if h:
rr = 1.0 / float(i + 1)
break
out[k] = {"P": P, "R": R, "F1": F1, "Hit": Hit, "nDCG": nDCG, "MRR": rr}
return out
def accumulate_metrics(
agg: Dict[int, Dict[str, float]], metrics: Dict[int, Dict[str, float]], ks: Sequence[int]
) -> None:
for k in ks:
for m, v in metrics[k].items():
agg[k][m] += v
def finalize_metrics(agg: Dict[int, Dict[str, float]], count: int, ks: Sequence[int]) -> Dict[int, Dict[str, float]]:
if count == 0:
return metric_template(ks)
out = metric_template(ks)
for k in ks:
for m in agg[k]:
out[k][m] = agg[k][m] / float(count)
return out
def topk_hits_from_scores(
scores: np.ndarray, cand_ids: List[str], rel_set: Set[str], ks: Sequence[int]
) -> Tuple[List[str], List[int]]:
if scores.size == 0 or len(cand_ids) == 0:
return [], []
max_k = max(ks)
order = np.argsort(-scores)[:max_k]
pred_ids = [cand_ids[i] for i in order]
bin_hits = [1 if aid in rel_set else 0 for aid in pred_ids]
return pred_ids, bin_hits

Xet Storage Details

Size:
8.36 kB
·
Xet hash:
77fcacf0e4bd6989c09bcd968b1161cc5cef8600cb7aec7e1b45500ebb129192

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.