dp-zp-agent/tools/graphrag/evaluation/benchmark_v4.py

374 lines
15 KiB
Python

"""Predeclare a broad source-grounded holdout, without reading retrieval outputs.
All profiles have already been exposed in v1-v3. This is a NEW QUERY-GROUP
holdout over the same corpus, explicitly not an unseen-person experiment.
Historic cases remain DEV; paraphrases of the same logical task stay together.
"""
from __future__ import annotations
import argparse
import copy
import json
import random
import re
from collections import Counter
from pathlib import Path
from .benchmark_v2 import (
BASE,
academic_binding,
academics,
audit_dataset,
fingerprint,
independent_pool,
make_case,
validate_case,
write_json,
)
from .source_oracle import load_source_oracle, norm
SEED = 20261025
QUOTAS = {
"shared_keyword_pair": 40,
"same_academic_year_pair": 40,
"same_start_year_pair": 32,
"person_title_year": 24,
"person_keyword_title": 32,
"document_author_category_multi_hop": 24,
"person_multiple_categories": 24,
"candidate_set_aggregation": 32,
"conjunctive_disambiguation": 40,
"negative_lookup": 32,
}
def logical_group(case: dict) -> str:
"""Normalize task/subjects/constraints; never make paraphrases independent."""
graph = case["graph"]
category = case["category"]
names = sorted(norm(row["value"]) for row in graph["start_entities"] if row["type"] == "person")
bindings = graph.get("expected_bindings", [])
keywords = sorted({norm(row["keyword"]) for row in bindings if row.get("keyword")})
years = sorted({int(value) for value in re.findall(r"\b\d{4}\b", case["question"])})
# A shared-year query has no requested year; its complete answer is derived.
selectors = {"keywords": keywords, "requested_years": years}
if category == "document_author_category_multi_hop":
selectors["categories"] = sorted(
{norm(row["category"]) for row in bindings if row.get("category")}
)
if graph.get("expected_no_answer"):
selectors["negative_constraints"] = {
key: norm(value) if isinstance(value, str) else value
for key, value in graph.get("negative_constraints", {}).items()
}
return fingerprint({"category": category, "subjects": names, "selectors": selectors})
def paraphrase(case: dict, variant: int) -> str:
names = [e["value"] for e in case["graph"]["start_entities"]]
category = case["category"]
rows = case["graph"]["expected_bindings"]
year = next(iter(re.findall(r"\b20\d{2}\b", case["question"])), "")
tag = next((r["keyword"] for r in rows if r.get("keyword")), "")
if variant == 0 or category == "negative_lookup":
return case["question"]
if category == "shared_keyword_pair":
return (
f"Majú osoby {names[0]} a {names[1]} spoločné kľúčové slovo {tag}?"
if variant == 1
else f"Pre osoby {names[0]} a {names[1]} over rovnaké kľúčové slovo {tag}."
)
if category == "same_academic_year_pair":
return (
f"Ktoré spoločné roky prác majú osoby {names[0]} a {names[1]}?"
if variant == 1
else f"Porovnaj rovnaké roky práce pri osobách {names[0]} a {names[1]}."
)
if category == "same_start_year_pair":
return f"Aký spoločný rok začiatku štúdia majú osoby {names[0]} a {names[1]}?"
if category == "person_multiple_categories":
return f"Aké všetky kategórie majú dokumenty osoby {names[0]}?"
if category == "document_author_category_multi_hop":
return f'Ktorý autor dokumentu je pri osobe {names[0]} spojený s kategóriou {rows[0]["category"]}?'
if category == "person_title_year":
# Preserve an explicit project selector, if present in the source query.
label = (
"diplomového projektu" if "diplomoveho projektu" in norm(case["question"]) else "práce"
)
return f"Uveď názov {label} osoby {names[0]} a rok práce pre rok {year}."
if category == "person_keyword_title":
return f"Uveď názov práce osoby {names[0]} v roku {year} s kľúčovým slovom {tag}."
if category == "candidate_set_aggregation":
return (
f'Z kandidátov {", ".join(names)} vyber osoby s prácou v roku {year}. Aký je ich počet?'
)
if category == "conjunctive_disambiguation":
return f"Ktorá z osôb {names[0]} a {names[1]} spĺňa kľúčové slovo {tag} a rok práce {year}?"
return case["question"]
def build_pool(oracle: dict) -> dict[str, list[dict]]:
names = sorted(oracle["people"], key=norm)
pool = independent_pool(oracle, names)
negatives = []
# Realistic absent combinations as well as unknown entities. Positive cases
# make up 90% of TEST; no easy-negative inflation of the headline score.
for index, name in enumerate(names):
other_tags = sorted(
{
tag
for p in oracle["people"].values()
for tag in p["tags"]
if norm(tag) not in {norm(t) for t in oracle["people"][name]["tags"]}
},
key=norm,
)
if other_tags:
tag = other_tags[index % len(other_tags)]
negatives.append(
make_case(
"",
"test",
"negative_lookup",
f"Aký názov práce má osoba {name} s kľúčovým slovom {tag}?",
[name],
oracle,
[],
no_answer=True,
reason="Requested literal tag is absent from this source document family.",
negative_constraints={"keyword": tag},
)
)
observed = {r["year"] for r in academics(oracle, name)}
absent_year = next(y for y in range(2018, 2027) if y not in observed)
negatives.append(
make_case(
"",
"test",
"negative_lookup",
f"Aký názov práce má osoba {name} v roku {absent_year}?",
[name],
oracle,
[],
no_answer=True,
reason="Requested academic year is absent from the original profile.",
negative_constraints={"year": absent_year},
)
)
for first, last in [
("Jozef", "Neznámy Overovací"),
("Petra", "Neznáma Overovacia"),
("Juraj", "Nevyskytujúci Skúšobný"),
("Lucia", "Chýbajúca Vzorová"),
("Adam", "Nedostupný Kontrolný"),
("Eva", "Neprítomná Kontrolná"),
]:
name = f"{first} {last}"
negatives.append(
make_case(
"",
"test",
"negative_lookup",
f"Aký názov práce má osoba {name}?",
[name],
oracle,
[],
no_answer=True,
reason="Full name absent from all original profiles.",
negative_constraints={"unknown_person": name},
)
)
pool["negative_lookup"] = negatives
return pool
def refresh_titles(case: dict, oracle: dict) -> dict:
"""Adjudicate title gold from raw sources before looking at TEST predictions."""
if (
case["category"] not in {"person_title_year", "person_keyword_title"}
or not case["should_answer"]
):
return copy.deepcopy(case)
name = case["graph"]["start_entities"][0]["value"]
years = [int(v) for v in re.findall(r"\b20\d{2}\b", case["question"])]
tag = next(
(b.get("keyword") for b in case["graph"]["expected_bindings"] if b.get("keyword")), None
)
text = norm(case["question"])
kind = next(
(
kind
for phrase, kind in [
("diplomoveho projektu", "diploma_project"),
("diplomovej prace", "diploma_thesis"),
("bakalarskej prace", "bachelor_thesis"),
]
if phrase in text
),
None,
)
records = [
r
for r in academics(oracle, name)
if r.get("title")
and (not years or r["year"] in years)
and (kind is None or r["academic_type"] == kind)
]
bindings = [academic_binding(name, r, title=True, keyword=tag) for r in records]
return make_case(
case["id"],
case["split"],
case["category"],
case["question"],
[name],
oracle,
bindings,
no_answer=not bindings,
reason="No explicit title for the requested academic record in original Markdown.",
negative_constraints={
"title_required": True,
**({"year": years[-1]} if years else {}),
**({"keyword": tag} if tag else {}),
**({"academic_type": kind} if kind else {}),
},
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=BASE / "graphrag_questions_v4.json")
parser.add_argument("--artifacts", type=Path, default=BASE / "results/v4_preparation")
parser.add_argument("--seed", type=int, default=SEED)
parser.add_argument("--extra-exposed", type=Path, action="append", default=[])
args = parser.parse_args()
if args.output.exists() or args.artifacts.exists():
parser.error("Refusing to overwrite a declared benchmark or its preparation artifacts.")
oracle = load_source_oracle()
historic = json.loads((BASE / "graphrag_questions_v3.json").read_text())
for path in args.extra_exposed:
historic.extend(json.loads(path.read_text()))
old_questions = {norm(q["question"]) for q in historic}
exposed = {logical_group(q) for q in historic}
adjudications = []
for index, original in enumerate(historic):
corrected = refresh_titles(original, oracle)
if corrected["graph"]["expected_bindings"] != original["graph"]["expected_bindings"]:
adjudications.append(
{
"id": original["id"],
"split": "dev",
"old_bindings": original["graph"]["expected_bindings"],
"new_bindings": corrected["graph"]["expected_bindings"],
"source_evidence": corrected["source_evidence"],
}
)
historic[index] = corrected
exposed.update(logical_group(q) for q in historic)
dev = []
for index, original in enumerate(historic):
case = copy.deepcopy(original)
case["split"] = "dev"
case["id"] = f"regression-{index + 1:04d}"
case["graph"]["partition_group"] = logical_group(case)
dev.append(case)
rng = random.Random(args.seed)
holdout = []
capacities = {}
for category, cases in build_pool(oracle).items():
unique = {
logical_group(c): c
for c in cases
if logical_group(c) not in exposed and norm(c["question"]) not in old_questions
}
candidates = sorted(unique.values(), key=lambda q: logical_group(q))
rng.shuffle(candidates)
quota = QUOTAS[category]
capacities[category] = len(candidates)
if len(candidates) < quota:
raise RuntimeError(
f"Insufficient unseen logical groups for {category}: {len(candidates)} < {quota}"
)
# Random selection is committed before running either split. Never
# select or remove a TEST question based on a retrieval outcome.
for index, case in enumerate(candidates[:quota]):
case = refresh_titles(case, oracle)
case["split"] = "test"
case["id"] = f"wide-{len(holdout) + 1:03d}"
case["question"] = paraphrase(case, index % 3)
case["graph"]["partition_group"] = logical_group(case)
holdout.append(case)
for index, case in enumerate(
candidates[quota : quota + min(12, max(0, len(candidates) - quota))]
):
case = refresh_titles(case, oracle)
case["split"] = "dev"
case["id"] = f"wide-dev-{len(dev) + 1:04d}"
case["question"] = paraphrase(case, index % 3)
case["graph"]["partition_group"] = logical_group(case)
dev.append(case)
questions = dev + holdout
problems = {q["id"]: validate_case(q, oracle) for q in questions if validate_case(q, oracle)}
if problems:
raise RuntimeError(f"Source oracle rejected cases: {problems}")
groups = {
split: {q["graph"]["partition_group"] for q in questions if q["split"] == split}
for split in ["dev", "test"]
}
assert not groups["dev"] & groups["test"]
assert not {norm(q["question"]) for q in dev} & {norm(q["question"]) for q in holdout}
write_json(args.output, questions)
audit = audit_dataset(questions, oracle, args.output)
audit["partition_policy"] = {
"method": "disjoint logical query groups",
"shared_source_profiles": True,
"group_function": "benchmark_v4.logical_group",
"seed": args.seed,
"previously_exposed_groups": sorted(exposed),
"limitations": [
"Known source corpus, persons, vocabulary and template families.",
"Fresh questions/fact combinations; not an unseen-person or LLM-answer benchmark.",
],
}
write_json(args.artifacts / "source_audit.json", audit)
write_json(
args.artifacts / "source_adjudications.json",
{
"reason": "Raw-source review before any new TEST predictions; preserve historic datasets unchanged.",
"questions": adjudications,
},
)
write_json(
args.artifacts / "partition.json",
{
**audit["partition_policy"],
"dataset_sha256": audit["dataset_sha256"],
"dev_count": len(dev),
"test_count": len(holdout),
"available_unseen_groups": capacities,
"test_categories": dict(Counter(q["category"] for q in holdout)),
"test_positive_count": sum(q["should_answer"] for q in holdout),
"test_negative_count": sum(not q["should_answer"] for q in holdout),
"dev_groups": sorted(groups["dev"]),
"test_groups": sorted(groups["test"]),
"test_profile_count": len({p for q in holdout for p in q["graph"]["source_profiles"]}),
"source_corpus": oracle["source_corpus"],
},
)
print(
json.dumps(
{
"dev": len(dev),
"test": len(holdout),
"source_issues": 0,
"available_unseen_groups": capacities,
},
ensure_ascii=False,
indent=2,
)
)
if __name__ == "__main__":
main()