Files
composable-evidence-states-…/eval/benchmark_extractor.py
T
2026-10-01 22:44:07 +08:00

164 lines
5.8 KiB
Python

from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
import torch
import torch.nn.functional as F
import yaml
from torch.utils.data import DataLoader
from data.dataset import EvidenceDataset, collate_evidence
from data.schema import FACT_TYPE
from eval.evaluate_slots import evaluate
from losses.alignment_loss import matched_states
from losses.matching_loss import hungarian_matching_loss
from models.evidence_model import EvidenceExtractorModel
def recall_at_one(
queries: torch.Tensor,
query_labels: torch.Tensor,
candidates: torch.Tensor,
candidate_labels: torch.Tensor,
) -> float:
if not len(queries) or not len(candidates):
return 0.0
similarity = F.normalize(queries, dim=-1) @ F.normalize(candidates, dim=-1).T
retrieved = candidate_labels[similarity.argmax(dim=1)]
return float((retrieved == query_labels).all(-1).float().mean())
def hard_negative_recall_at_one(
queries: torch.Tensor,
query_labels: torch.Tensor,
candidates: torch.Tensor,
candidate_labels: torch.Tensor,
) -> float:
scores = []
normalized_candidates = F.normalize(candidates, dim=-1)
for query, label in zip(F.normalize(queries, dim=-1), query_labels):
exact = (candidate_labels == label).all(-1)
field_distance = (candidate_labels[:, 1:4] != label[1:4]).sum(-1)
hard = (
(candidate_labels[:, 0] == FACT_TYPE)
& (field_distance == 1)
& (candidate_labels[:, 4:] == label[4:]).all(-1)
)
pool = exact | hard
if exact.any() and hard.any():
selected_labels = candidate_labels[pool]
nearest = (query @ normalized_candidates[pool].T).argmax()
scores.append(float((selected_labels[nearest] == label).all()))
return sum(scores) / max(1, len(scores))
@torch.no_grad()
def collect_fact_states(
model: EvidenceExtractorModel, loader: DataLoader, device: torch.device
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
visual_vectors = []
visual_labels = []
text_vectors = []
text_labels = []
model.eval()
for batch in loader:
outputs = model(batch["images"].to(device), batch["rules_text"])
visual_match = hungarian_matching_loss(outputs["visual"], batch["visual_targets"])
text_match = hungarian_matching_loss(outputs["text"], batch["text_targets"])
visual, visual_target = matched_states(
outputs["visual"]["states"],
batch["visual_targets"],
visual_match.assignments,
FACT_TYPE,
)
text, text_target = matched_states(
outputs["text"]["states"],
batch["text_targets"],
text_match.assignments,
FACT_TYPE,
)
visual_vectors.append(visual.cpu())
visual_labels.append(visual_target.cpu())
text_vectors.append(text.cpu())
text_labels.append(text_target.cpu())
return (
torch.cat(visual_vectors),
torch.cat(visual_labels),
torch.cat(text_vectors),
torch.cat(text_labels),
)
def make_loader(config: dict[str, Any], split: str) -> DataLoader:
dataset = EvidenceDataset(
Path(config["data"]["root"]) / f"{split}.jsonl", config["data"]["image_size"]
)
return DataLoader(
dataset,
batch_size=config["training"]["batch_size"],
collate_fn=collate_evidence,
)
@torch.no_grad()
def benchmark(
model: EvidenceExtractorModel, config: dict[str, Any], device: torch.device
) -> dict[str, Any]:
iid_loader = make_loader(config, "test_id")
iid = evaluate(model, iid_loader, device)
visual, visual_labels, text, text_labels = collect_fact_states(model, iid_loader, device)
ood = evaluate(model, make_loader(config, "test_ood"), device)
metrics = {
"text_fact_rule_f1": iid["text_all"]["f1"],
"visual_fact_f1": iid["visual_fact"]["f1"],
"cross_modal_recall_at_1": recall_at_one(
text, text_labels, visual, visual_labels
),
"hard_negative_recall_at_1": hard_negative_recall_at_one(
text, text_labels, visual, visual_labels
),
"ood_fact_f1": ood["visual_fact"]["f1"],
"null_slot_accuracy": iid["null_slot_accuracy"],
}
thresholds = config["evaluation"]["gates"]
gates = {
name: {"value": value, "threshold": thresholds[name], "passed": value >= thresholds[name]}
for name, value in metrics.items()
}
return {
"metrics": metrics,
"gates": gates,
"passed": all(gate["passed"] for gate in gates.values()),
"details": {"test_id": iid, "test_ood": ood},
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--config", type=Path, default=Path("configs/mvp.yaml"))
parser.add_argument("--checkpoint", type=Path, required=True)
parser.add_argument("--backbone", choices=("tiny", "clip"), default=None)
parser.add_argument("--output", type=Path, default=None)
parser.add_argument("--enforce-gates", action="store_true")
args = parser.parse_args()
config = yaml.safe_load(args.config.read_text(encoding="utf-8"))
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = EvidenceExtractorModel(config, backbone_name=args.backbone).to(device)
checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=True)
model.load_state_dict(checkpoint["model"])
result = benchmark(model, config, device)
rendered = json.dumps(result, indent=2)
print(rendered)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(rendered + "\n", encoding="utf-8")
if args.enforce_gates and not result["passed"]:
raise SystemExit(1)
if __name__ == "__main__":
main()