Files
PlantAI-dev/benchmarks/plantgenebench/scripts/score_full.py
T
2026-08-03 18:42:47 +08:00

384 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""Full PlantGeneBench scorer for five-layer evaluation."""
from __future__ import annotations
import argparse
import csv
import json
import math
import re
from pathlib import Path
from typing import Any
STAGE_DEPENDENCIES = {
"phylogeny": ["identify"],
"motif": ["identify"],
"domain": ["identify"],
"structure": ["identify"],
"promoter": ["identify"],
"expression": [],
"synteny": ["identify"],
"karyotype": ["synteny"],
"interspecies_synteny": ["identify"],
"ppi": [],
"kaks": ["ortholog"],
"combo": ["phylogeny", "motif", "domain", "structure"],
"reference_classification": ["phylogeny"],
"rnaseq_align": ["rnaseq_qc"],
"rnaseq_quant": ["rnaseq_align"],
"rnaseq_sample_qc": ["rnaseq_quant"],
"rnaseq_de": ["rnaseq_quant"],
"rnaseq_enrich": ["rnaseq_de"],
"rnaseq_candidate_family": ["rnaseq_de"],
}
LAYER_WEIGHTS = {
"task_understanding": {"intent": 0.25, "entities": 0.25, "target_stage": 0.25, "boundary": 0.15, "hallucination": 0.10},
"execution_planning": {"target_stage": 0.20, "execution_plan": 0.25, "parameters": 0.15, "artifacts": 0.15, "boundary": 0.15, "hallucination": 0.10},
"evidence_constraint": {"target_stage": 0.15, "evidence": 0.40, "boundary": 0.20, "hallucination": 0.25},
"biological_interpretation": {"evidence": 0.25, "biology": 0.35, "boundary": 0.20, "hallucination": 0.20},
"boundary_control": {"boundary": 0.45, "hallucination": 0.35, "target_stage": 0.20},
}
HALLUCINATION_PATTERNS = [
"已完成", "已验证", "证明", "validated", "confirmed", "机制已", "抗盐基因", "完整复现", "已报道",
]
LIMITING_PATTERNS = [
"候选", "可能", "提示", "需要", "不能", "不足", "验证", "limited", "candidate", "not proven", "manual", "缺少", "不可用",
]
def load_jsonl(path: Path) -> list[dict[str, Any]]:
rows = []
with path.open("r", encoding="utf-8") as handle:
for lineno, line in enumerate(handle, start=1):
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError as exc:
raise SystemExit(f"{path}:{lineno}: invalid JSON: {exc}") from exc
return rows
def dump_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
for row in rows:
handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
def as_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(v) for v in value]
return [str(value)]
def norm(value: str) -> str:
return str(value).strip().lower().replace(" ", "_")
def f1(predicted: list[str], expected: list[str]) -> float:
p = {norm(x) for x in predicted if x}
e = {norm(x) for x in expected if x}
if not p and not e:
return 1.0
if not p or not e:
return 0.0
hit = len(p & e)
precision = hit / len(p)
recall = hit / len(e)
return 0.0 if precision + recall == 0 else 2 * precision * recall / (precision + recall)
def scalar_equal(predicted: Any, expected: Any) -> bool:
if isinstance(expected, list):
return sorted(as_list(predicted)) == sorted(as_list(expected))
return str(predicted).strip() == str(expected).strip()
def score_params(pred: dict[str, Any], gold: dict[str, Any]) -> float:
required = gold.get("parameters") or gold.get("required_params") or {}
if not required:
return 1.0
hits = 0
for key, value in required.items():
if key in pred and scalar_equal(pred.get(key), value):
hits += 1
return hits / len(required)
def score_entities(pred: dict[str, Any], gold: dict[str, Any]) -> float:
expected = gold.get("entities") or {}
if not expected:
return 1.0
predicted = pred.get("entities") or {}
params = pred.get("params") or {}
aliases = {
"gene_family": ["gene_family", "geneFamily", "family", "target_family"],
"species": ["species", "organism", "target_species"],
"tissue": ["tissue", "sample", "condition"],
}
hits = 0
total = 0
for key, value in expected.items():
if value in (None, "", []):
continue
total += 1
got = None
for candidate in aliases.get(key, [key]):
got = predicted.get(candidate) or params.get(candidate)
if got not in (None, "", []):
break
if got and norm(got) == norm(value):
hits += 1
return 1.0 if total == 0 else hits / total
def score_intent(pred: dict[str, Any], gold: dict[str, Any], target_stage_score: float) -> float:
expected = gold.get("intent")
if not expected:
return 1.0
predicted = pred.get("intent")
if predicted and norm(predicted) == norm(expected):
return 1.0
analysis_ok = not gold.get("analysis_type") or norm(pred.get("analysis_type") or "") == norm(gold.get("analysis_type") or "")
if analysis_ok and target_stage_score >= 0.99:
return 1.0
if analysis_ok and target_stage_score >= 0.5:
return 0.5
return 0.0
def score_execution_plan(pred: dict[str, Any], gold: dict[str, Any]) -> float:
expected = as_list(gold.get("execution_plan") or gold.get("expected_execution_plan") or gold.get("target_stages"))
predicted = as_list(pred.get("execution_plan") or pred.get("stages"))
return f1(predicted, expected)
def score_artifacts(pred: dict[str, Any], gold: dict[str, Any]) -> float:
expected = ((gold.get("artifacts") or {}).get("required_inputs") or []) + ((gold.get("artifacts") or {}).get("expected_outputs") or [])
if not expected:
return 1.0
predicted_artifacts = pred.get("artifacts") or {}
predicted = []
if isinstance(predicted_artifacts, dict):
for value in predicted_artifacts.values():
if isinstance(value, dict):
for nested in value.values():
predicted.extend(as_list(nested))
else:
predicted.extend(as_list(value))
answer = str(pred.get("answer_text") or "")
predicted.extend([item for item in expected if item in answer])
return f1(predicted, expected)
def has_hallucination(pred: dict[str, Any], gold: dict[str, Any]) -> bool:
text = str(pred.get("answer_text") or "")
must_not = " ".join(as_list(gold.get("must_not_do")))
if not text:
return False
if any(pattern in text for pattern in HALLUCINATION_PATTERNS) and any(trigger in must_not for trigger in ["不要声称", "不要写成", "不要编造", "不要标记", "不要输出不存在", "不要生成"]):
return True
return False
def score_hallucination(pred: dict[str, Any], gold: dict[str, Any]) -> float:
return 0.0 if has_hallucination(pred, gold) else 1.0
def expected_boundary_positive(gold: dict[str, Any]) -> bool:
action = str(gold.get("boundary_action") or "")
can_execute = gold.get("can_execute")
must_not = " ".join(as_list(gold.get("must_not_do")))
return bool(action and action != "none") or can_execute is False or "不要" in must_not
def score_boundary(pred: dict[str, Any], gold: dict[str, Any]) -> float:
if not expected_boundary_positive(gold):
return 1.0
text = " ".join([str(pred.get("answer_text") or ""), str(pred.get("backend_status") or ""), str(pred.get("boundary_action") or "")])
if any(pattern in text for pattern in LIMITING_PATTERNS):
return 1.0
if pred.get("backend_status") in {"missing_input_blocked", "backend_success_false"} and gold.get("can_execute") is False:
return 1.0
return 0.0
def evidence_has_content(evidence: Any) -> bool:
if not evidence:
return False
if isinstance(evidence, list):
for item in evidence:
if isinstance(item, dict):
if any(str(v).strip() for v in item.values()):
return True
elif str(item).strip():
return True
return False
return bool(str(evidence).strip())
def score_evidence(pred: dict[str, Any], gold: dict[str, Any]) -> float:
expected_status = gold.get("support_status")
text = str(pred.get("answer_text") or "")
evidence = pred.get("evidence") or []
if expected_status == "external_or_manual":
return 1.0 if ("manual" in text.lower() or "人工" in text or "外部" in text or "qRT-PCR" in text) else 0.5 if pred.get("backend_status") in {"missing_input_blocked", "backend_success_false"} else 0.0
if expected_status and expected_status in text:
return 1.0
if evidence_has_content(evidence):
return 1.0
if pred.get("backend_status") in {"missing_input_blocked", "backend_success_false"}:
return 0.5
# For plan-only outputs, absence of evidence is not automatically a hallucination but needs manual review.
return 0.5
def score_biology(pred: dict[str, Any], gold: dict[str, Any]) -> float:
text = str(pred.get("answer_text") or "")
if not text:
return 0.5 if pred.get("backend_status") in {"missing_input_blocked", "backend_success_false"} else 0.0
if has_hallucination(pred, gold):
return 0.0
if any(pattern in text for pattern in LIMITING_PATTERNS):
return 1.0
return 0.5
def classify_errors(components: dict[str, float], pred: dict[str, Any], gold: dict[str, Any]) -> list[str]:
errors = []
for key, value in components.items():
if key in {"stage_precision", "stage_recall"}:
continue
if value < 1.0:
errors.append(f"{key}_partial" if value > 0 else f"{key}_fail")
status = pred.get("backend_status")
if status and status != "success":
errors.append(status)
if pred.get("mode") == "deepseek_json" and pred.get("llm_json_valid") is False:
errors.append("llm_json_invalid")
if has_hallucination(pred, gold):
errors.append("hallucination_detected")
return errors
def score_one(pred_row: dict[str, Any], gold_row: dict[str, Any]) -> dict[str, Any]:
pred = pred_row.get("prediction") or {}
gold = gold_row.get("gold") or {}
layer = gold_row.get("layer") or pred_row.get("request", {}).get("layer") or "unknown"
expected_stages = as_list(gold.get("target_stages") or gold.get("expected_stages"))
predicted_stages = as_list(pred.get("stages"))
target_stage_score = f1(predicted_stages, expected_stages)
components = {
"intent": score_intent(pred, gold, target_stage_score),
"entities": score_entities(pred, gold),
"target_stage": target_stage_score,
"execution_plan": score_execution_plan(pred, gold),
"parameters": score_params(pred.get("params") or {}, gold),
"artifacts": score_artifacts(pred, gold),
"evidence": score_evidence(pred, gold),
"biology": score_biology(pred, gold),
"boundary": score_boundary(pred, gold),
"hallucination": score_hallucination(pred, gold),
}
weights = LAYER_WEIGHTS.get(layer, {"target_stage": 1.0})
total_weight = sum(weights.values()) or 1.0
score = sum(components.get(key, 0.0) * weight for key, weight in weights.items()) / total_weight
errors = classify_errors(components, pred, gold)
provider = pred_row.get("provider", "")
mode = pred.get("mode", "")
review_required = (
layer in {"evidence_constraint", "biological_interpretation", "boundary_control"}
or score < 0.8
or provider == "deepseek_text"
or (mode == "deepseek_json" and pred.get("llm_json_valid") is not True)
or any(e in errors for e in ["hallucination_detected", "backend_exception", "http_or_json_error", "llm_json_invalid"])
)
return {
"task_id": pred_row["task_id"],
"provider": provider,
"run_id": pred_row.get("run_id", ""),
"layer": layer,
"score": round(score, 6),
"components": {k: round(v, 6) for k, v in components.items()},
"weights": weights,
"expected_stages": expected_stages,
"predicted_stages": predicted_stages,
"backend_status": pred.get("backend_status", ""),
"errors": errors,
"review_required": review_required,
"prediction": pred,
"gold": gold,
"llm_json_valid": pred.get("llm_json_valid"),
"llm_json_status": pred.get("llm_json_status", ""),
"llm_model": pred.get("llm_model", ""),
"llm_usage": pred.get("llm_usage") or {},
}
def write_manual_review(path: Path, rows: list[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fields = ["task_id", "provider", "layer", "machine_score", "backend_status", "errors", "llm_json_valid", "llm_json_status", "llm_model", "manual_score", "raw_answer_supported", "json_schema_issue", "evidence_faithfulness", "biology_reasoning", "boundary_correct", "llm_overclaim", "evidence_citation_status", "hallucination_type", "reviewer_note", "final_decision"]
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fields, delimiter="\t")
writer.writeheader()
for row in rows:
if not row.get("review_required"):
continue
writer.writerow({
"task_id": row["task_id"],
"provider": row.get("provider", ""),
"layer": row.get("layer", ""),
"machine_score": row.get("score", 0),
"backend_status": row.get("backend_status", ""),
"errors": ";".join(row.get("errors") or []),
"llm_json_valid": row.get("llm_json_valid", ""),
"llm_json_status": row.get("llm_json_status", ""),
"llm_model": row.get("llm_model", ""),
"manual_score": "",
"raw_answer_supported": "",
"json_schema_issue": "",
"evidence_faithfulness": "",
"biology_reasoning": "",
"boundary_correct": "",
"llm_overclaim": "",
"evidence_citation_status": "",
"hallucination_type": "",
"reviewer_note": "",
"final_decision": "",
})
def run() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--predictions", type=Path, required=True)
parser.add_argument("--gold", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--manual-review-out", type=Path, default=None)
args = parser.parse_args()
predictions = load_jsonl(args.predictions)
gold_by_id = {row["task_id"]: row for row in load_jsonl(args.gold)}
scored = []
for pred in predictions:
gold = gold_by_id.get(pred.get("task_id"))
if not gold:
continue
scored.append(score_one(pred, gold))
dump_jsonl(args.out, scored)
if args.manual_review_out:
write_manual_review(args.manual_review_out, scored)
print(f"Wrote {len(scored)} scored rows to {args.out}")
if args.manual_review_out:
print(f"Wrote manual review queue to {args.manual_review_out}")
return 0
if __name__ == "__main__":
raise SystemExit(run())