CoolFace
Datasetpublic

microsoft/XL-DocBench

XL-DocBench Evidence-grounded reasoning across hundreds or thousands of pages. Fully verified by 194 human experts. Hongchen Wei1,†,‡, Yuanzhe Wang2,†,‡, Bei Liu2,*, Yifan Yang2, Qi Dai2, Ruichun Ma2, Kai Qiu2, Yunsheng Li2, Dongdong Chen2, Chong Luo2, Zhenzhong Chen1, Baining Guo2 1Wuhan University   2Microsoft   †Equal contribution   ‡Work done during an internship at MSRA   *Project leader Project Page · Paper · Live Leaderboard… See the full description on the dataset page: https://huggingface.co/datasets/microsoft/XL-DocBench.

sourceHugging Faceotherupdated 20d agoView on Hugging Face
7likes932downloads
evaluate.py639 linesDownload Raw Back to code
1#!/usr/bin/env python32"""Evaluate XL-DocBench predictions.3 4This script is intentionally self-contained for public release. It computes the5deterministic metrics used in the benchmark tables: relaxed Accuracy,6token-level F1, and ANLS. It does not call any model or require private files.7 8Prediction JSONL format:9    {"question_id": "adubench_single_000001", "prediction": "..."}10 11The prediction field may also be named ``model_answer``, ``answer``,12``response``, or ``output``. JSON files are also accepted, including mappings13from question_id to answer or internal-style ``{"items": {...}}`` files.14"""15 16from __future__ import annotations17 18import argparse19import csv20import json21import re22import sys23from collections import defaultdict24from dataclasses import dataclass, field25from pathlib import Path26from typing import Any27 28ANSWER_FORMAT_MAP = {29    "Str": "entity",30    "Int": "numeric",31    "Float": "numeric",32    "None": "unanswerable",33    "Bool": "boolean",34    "Boolean": "boolean",35    "Percentage": "percentage",36}37 38PREDICTION_FIELDS = ("prediction", "model_answer", "answer", "response", "output")39QUESTION_ID_FIELDS = ("question_id", "global_qa_id", "global_id", "id")40SUCCESS_STATUSES = {"success", "ok", "completed"}41 42 43@dataclass44class MetricBucket:45    accuracy: list[float] = field(default_factory=list)46    token_f1: list[float] = field(default_factory=list)47    anls: list[float] = field(default_factory=list)48 49    def add(self, accuracy: float, token_f1: float, anls: float) -> None:50        self.accuracy.append(accuracy)51        self.token_f1.append(token_f1)52        self.anls.append(anls)53 54    def summary(self) -> dict[str, float | int]:55        return {56            "count": len(self.accuracy),57            "accuracy": average(self.accuracy),58            "token_f1": average(self.token_f1),59            "anls": average(self.anls),60        }61 62 63def average(values: list[float]) -> float:64    return round(sum(values) / len(values), 6) if values else 0.065 66 67def load_jsonl(path: Path) -> list[dict[str, Any]]:68    rows: list[dict[str, Any]] = []69    with path.open("r", encoding="utf-8") as handle:70        for line_number, line in enumerate(handle, start=1):71            line = line.strip()72            if not line:73                continue74            try:75                value = json.loads(line)76            except json.JSONDecodeError as exc:77                raise ValueError(f"Invalid JSON on {path}:{line_number}") from exc78            if not isinstance(value, dict):79                raise TypeError(f"Expected object on {path}:{line_number}")80            rows.append(value)81    return rows82 83 84def load_json_or_jsonl(path: Path) -> Any:85    if path.suffix.lower() == ".jsonl":86        return load_jsonl(path)87    with path.open("r", encoding="utf-8") as handle:88        return json.load(handle)89 90 91def get_question_id(row: dict[str, Any]) -> str:92    for field_name in QUESTION_ID_FIELDS:93        value = row.get(field_name)94        if value is not None and str(value).strip():95            return str(value).strip()96    return ""97 98 99def string_value(value: Any) -> str:100    if value is None:101        return ""102    if isinstance(value, (str, int, float, bool)):103        return str(value)104    return json.dumps(value, ensure_ascii=False, sort_keys=True)105 106 107def answer_payload(row: dict[str, Any]) -> dict[str, Any]:108    value = row.get("answer", {})109    return value if isinstance(value, dict) else {"value": value}110 111 112def gold_answer(row: dict[str, Any]) -> str:113    return string_value(answer_payload(row).get("value", ""))114 115 116def answer_format(row: dict[str, Any]) -> str:117    payload = answer_payload(row)118    raw_format = string_value(payload.get("format", "Str")) or "Str"119    verification_rule = string_value(payload.get("verification_rule", ""))120    if verification_rule == "choice_exact_match":121        return "single_choice"122    if verification_rule == "percentage_exact":123        return "percentage"124    if raw_format in ANSWER_FORMAT_MAP:125        return ANSWER_FORMAT_MAP[raw_format]126    if "numeric" in verification_rule or "tolerance" in verification_rule:127        return "numeric"128    return raw_format.lower()129 130 131def metadata(row: dict[str, Any]) -> dict[str, Any]:132    value = row.get("metadata", {})133    return value if isinstance(value, dict) else {}134 135 136def load_gold_records(gold_files: list[Path]) -> dict[str, dict[str, Any]]:137    if not gold_files:138        raise ValueError("At least one gold file is required")139 140    records: dict[str, dict[str, Any]] = {}141    for path in gold_files:142        for row in load_jsonl(path):143            question_id = get_question_id(row)144            if not question_id:145                raise ValueError(f"Missing question_id in {path}")146            if question_id in records:147                raise ValueError(f"Duplicate question_id in gold data: {question_id}")148            records[question_id] = row149    if not records:150        raise ValueError("No gold records found")151    return records152 153 154def extract_prediction(row: Any, prediction_field: str = "") -> str:155    if not isinstance(row, dict):156        return string_value(row)157 158    if prediction_field:159        if prediction_field not in row:160            raise ValueError(f"Prediction field not found: {prediction_field}")161        value = row[prediction_field]162        if isinstance(value, dict) and "value" in value:163            return string_value(value["value"])164        return string_value(value)165 166    for field_name in PREDICTION_FIELDS:167        if field_name not in row:168            continue169        value = row[field_name]170        if field_name == "answer" and isinstance(value, dict):171            return string_value(value.get("value", ""))172        return string_value(value)173    return ""174 175 176def load_predictions(177    path: Path, prediction_field: str = ""178) -> tuple[dict[str, str], dict[str, str]]:179    payload = load_json_or_jsonl(path)180    predictions: dict[str, str] = {}181    statuses: dict[str, str] = {}182 183    def add(question_id: str, value: Any) -> None:184        if not question_id:185            raise ValueError(f"Prediction row is missing a question id: {value!r}")186        if question_id in predictions:187            raise ValueError(f"Duplicate question_id in predictions: {question_id}")188        predictions[question_id] = extract_prediction(value, prediction_field)189        if isinstance(value, dict):190            statuses[question_id] = (191                string_value(value.get("status", "success")) or "success"192            )193        else:194            statuses[question_id] = "success"195 196    if isinstance(payload, list):197        for row in payload:198            if not isinstance(row, dict):199                raise TypeError("Prediction JSONL/list rows must be objects")200            add(get_question_id(row), row)201    elif isinstance(payload, dict) and isinstance(payload.get("items"), dict):202        for question_id, row in payload["items"].items():203            add(str(question_id), row)204    elif isinstance(payload, dict) and get_question_id(payload):205        add(get_question_id(payload), payload)206    elif isinstance(payload, dict):207        for question_id, row in payload.items():208            add(str(question_id), row)209    else:210        raise TypeError("Unsupported prediction file format")211 212    return predictions, statuses213 214 215def normalize_answer(text: str) -> str:216    text = text.strip().lower()217    for prefix in ("the answer is", "answer:", "answer is"):218        if text.startswith(prefix):219            text = text[len(prefix) :].strip()220    text = re.sub(r"[^\w\s\.\-\%]", "", text)221    text = re.sub(r"\b(a|an|the)\b", " ", text)222    return re.sub(r"\s+", " ", text).strip()223 224 225def extract_number(text: str) -> float | None:226    compact = text.replace(" ", "")227 228    power_match = re.search(229        r"(?P<base>[-+]?(?:\d+(?:\.\d+)?|\.\d+))\^(?P<exponent>[-+]?\d+)",230        compact,231    )232    if power_match:233        try:234            return float(power_match.group("base")) ** int(235                power_match.group("exponent")236            )237        except (OverflowError, ValueError):238            return None239 240    fraction_match = re.search(241        r"(?P<numerator>[-+]?(?:\d+(?:\.\d+)?|\.\d+))/(?P<denominator>[-+]?(?:\d+(?:\.\d+)?|\.\d+))",242        compact,243    )244    if fraction_match:245        try:246            denominator = float(fraction_match.group("denominator"))247            if denominator == 0:248                return None249            return float(fraction_match.group("numerator")) / denominator250        except ValueError:251            return None252 253    match = re.search(254        r"[-+]?(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:[.,]\d+)?|\.\d+)(?:[eE][-+]?\d+)?",255        compact,256    )257    if not match:258        return None259    token = match.group()260    if "," in token and "." not in token:261        integer, fractional = token.split(",", maxsplit=1)262        token = (263            f"{integer}.{fractional}"264            if len(fractional) <= 2265            else token.replace(",", "")266        )267    else:268        token = token.replace(",", "")269    try:270        return float(token)271    except ValueError:272        return None273 274 275def parse_boolean(text: str) -> bool | None:276    tokens = set(normalize_answer(text).split())277    positive = bool(tokens & {"yes", "true", "correct"})278    negative = bool(tokens & {"no", "false", "incorrect"})279    if positive == negative:280        return None281    return positive282 283 284def levenshtein_distance(left: str, right: str) -> int:285    if len(left) < len(right):286        return levenshtein_distance(right, left)287    if not right:288        return len(left)289 290    previous_row = list(range(len(right) + 1))291    for left_index, left_char in enumerate(left):292        current_row = [left_index + 1]293        for right_index, right_char in enumerate(right):294            substitution_cost = 0 if left_char == right_char else 1295            current_row.append(296                min(297                    current_row[right_index] + 1,298                    previous_row[right_index + 1] + 1,299                    previous_row[right_index] + substitution_cost,300                )301            )302        previous_row = current_row303    return previous_row[-1]304 305 306def normalized_levenshtein_similarity(prediction: str, gold: str) -> float:307    prediction = prediction.strip().lower()308    gold = gold.strip().lower()309    if not prediction and not gold:310        return 1.0311    if not prediction or not gold:312        return 0.0313    distance = levenshtein_distance(prediction, gold)314    return 1.0 - distance / max(len(prediction), len(gold))315 316 317def anls_score(prediction: str, gold: str, threshold: float = 0.5) -> float:318    similarity = normalized_levenshtein_similarity(prediction, gold)319    return similarity if similarity >= threshold else 0.0320 321 322def accuracy_score(prediction: str, gold: str, answer_type: str) -> float:323    prediction_norm = normalize_answer(prediction)324    gold_norm = normalize_answer(gold)325 326    if answer_type == "unanswerable":327        phrases = (328            "not answerable",329            "unanswerable",330            "cannot be determined",331            "cannot be answered",332            "not enough information",333        )334        return 1.0 if any(phrase in prediction_norm for phrase in phrases) else 0.0335 336    if answer_type == "boolean":337        prediction_bool = parse_boolean(prediction)338        gold_bool = parse_boolean(gold)339        return (340            1.0 if prediction_bool is not None and prediction_bool == gold_bool else 0.0341        )342 343    if answer_type in {"numeric", "percentage"}:344        prediction_number = extract_number(prediction)345        gold_number = extract_number(gold)346        if prediction_number is None or gold_number is None:347            return 0.0348        if gold_number == 0:349            return 1.0 if abs(prediction_number) < 1e-6 else 0.0350        relative_error = abs(prediction_number - gold_number) / abs(gold_number)351        return 1.0 if relative_error <= 0.05 else 0.0352 353    if answer_type == "single_choice":354        prediction_option = re.search(r"\b([A-D])\b", prediction.strip().upper())355        gold_option = re.search(r"\b([A-D])\b", gold.strip().upper())356        return (357            1.0358            if prediction_option359            and gold_option360            and prediction_option.group(1) == gold_option.group(1)361            else 0.0362        )363 364    if gold_norm and gold_norm in prediction_norm:365        return 1.0366    if normalized_levenshtein_similarity(prediction_norm, gold_norm) >= 0.8:367        return 1.0368    return 0.0369 370 371def token_f1_score(prediction: str, gold: str) -> float:372    prediction_tokens = set(normalize_answer(prediction).split())373    gold_tokens = set(normalize_answer(gold).split())374    if not gold_tokens:375        return 1.0 if not prediction_tokens else 0.0376    if not prediction_tokens:377        return 0.0378    overlap = prediction_tokens & gold_tokens379    if not overlap:380        return 0.0381    precision = len(overlap) / len(prediction_tokens)382    recall = len(overlap) / len(gold_tokens)383    return 2 * precision * recall / (precision + recall)384 385 386def add_breakdown(387    breakdowns: dict[str, dict[str, MetricBucket]],388    name: str,389    key: Any,390    accuracy: float,391    token_f1: float,392    anls: float,393) -> None:394    label = string_value(key) or "unknown"395    breakdowns[name][label].add(accuracy, token_f1, anls)396 397 398def evaluate(399    gold_records: dict[str, dict[str, Any]],400    predictions: dict[str, str],401    statuses: dict[str, str],402    ignore_missing: bool = False,403) -> dict[str, Any]:404    overall = MetricBucket()405    breakdowns: dict[str, dict[str, MetricBucket]] = {406        "split": defaultdict(MetricBucket),407        "domain": defaultdict(MetricBucket),408        "reasoning_type": defaultdict(MetricBucket),409        "answer_format": defaultdict(MetricBucket),410        "difficulty": defaultdict(MetricBucket),411        "doc_type": defaultdict(MetricBucket),412        "evidence_source": defaultdict(MetricBucket),413    }414    per_question: list[dict[str, Any]] = []415    missing_count = 0416 417    for question_id, row in gold_records.items():418        if question_id not in predictions:419            missing_count += 1420            if ignore_missing:421                continue422        prediction = predictions.get(question_id, "")423        status = statuses.get(question_id, "missing")424 425        gold = gold_answer(row)426        answer_type = answer_format(row)427        normalized_status = (428            status.strip().casefold().replace("-", "_").replace(" ", "_")429        )430        if normalized_status in SUCCESS_STATUSES:431            accuracy = accuracy_score(prediction, gold, answer_type)432            token_f1 = token_f1_score(prediction, gold)433            anls = anls_score(prediction, gold)434        else:435            accuracy = 0.0436            token_f1 = 0.0437            anls = 0.0438        overall.add(accuracy, token_f1, anls)439 440        row_metadata = metadata(row)441        split = string_value(row.get("task_type", "unknown"))442        add_breakdown(breakdowns, "split", split, accuracy, token_f1, anls)443        add_breakdown(444            breakdowns, "domain", row_metadata.get("domain"), accuracy, token_f1, anls445        )446        add_breakdown(447            breakdowns,448            "reasoning_type",449            row_metadata.get("reasoning_type"),450            accuracy,451            token_f1,452            anls,453        )454        add_breakdown(455            breakdowns,456            "answer_format",457            answer_payload(row).get("format"),458            accuracy,459            token_f1,460            anls,461        )462        add_breakdown(463            breakdowns,464            "difficulty",465            row_metadata.get("difficulty"),466            accuracy,467            token_f1,468            anls,469        )470        add_breakdown(471            breakdowns,472            "doc_type",473            row_metadata.get("doc_type"),474            accuracy,475            token_f1,476            anls,477        )478 479        evidence_sources = row_metadata.get("evidence_sources") or ["unknown"]480        if not isinstance(evidence_sources, list):481            evidence_sources = [evidence_sources]482        for evidence_source in evidence_sources:483            add_breakdown(484                breakdowns, "evidence_source", evidence_source, accuracy, token_f1, anls485            )486 487        per_question.append(488            {489                "question_id": question_id,490                "prediction": prediction,491                "gold_answer": gold,492                "answer_format": answer_payload(row).get("format", "Str"),493                "status": status,494                "accuracy": round(accuracy, 6),495                "token_f1": round(token_f1, 6),496                "anls": round(anls, 6),497                "split": split,498                "domain": row_metadata.get("domain", "unknown"),499                "reasoning_type": row_metadata.get("reasoning_type", "unknown"),500            }501        )502 503    extra_prediction_count = len(set(predictions) - set(gold_records))504    return {505        "gold_count": len(gold_records),506        "prediction_count": len(predictions),507        "evaluated_count": overall.summary()["count"],508        "missing_prediction_count": missing_count,509        "extra_prediction_count": extra_prediction_count,510        "overall": overall.summary(),511        "breakdowns": {512            name: {key: bucket.summary() for key, bucket in sorted(group.items())}513            for name, group in breakdowns.items()514        },515        "per_question": per_question,516    }517 518 519def write_per_question_csv(rows: list[dict[str, Any]], output_path: Path) -> None:520    output_path.parent.mkdir(parents=True, exist_ok=True)521    fieldnames = [522        "question_id",523        "prediction",524        "gold_answer",525        "answer_format",526        "status",527        "accuracy",528        "token_f1",529        "anls",530        "split",531        "domain",532        "reasoning_type",533    ]534    with output_path.open("w", encoding="utf-8", newline="") as handle:535        writer = csv.DictWriter(handle, fieldnames=fieldnames)536        writer.writeheader()537        writer.writerows(rows)538 539 540def default_data_dir() -> Path:541    script_dir = Path(__file__).resolve().parent542    for data_dir in (script_dir / "data", script_dir.parent / "data"):543        if data_dir.exists():544            return data_dir545    return script_dir.parent / "data"546 547 548def parse_args() -> argparse.Namespace:549    parser = argparse.ArgumentParser(description="Evaluate XL-DocBench predictions")550    parser.add_argument(551        "--predictions", required=True, type=Path, help="Prediction JSON/JSONL file"552    )553    parser.add_argument(554        "--data-dir",555        type=Path,556        default=default_data_dir(),557        help="Directory containing QA JSONL files",558    )559    parser.add_argument(560        "--gold-files",561        nargs="+",562        type=Path,563        default=None,564        help="Gold QA JSONL files; defaults to qa_single_doc and qa_cross_doc",565    )566    parser.add_argument(567        "--prediction-field", default="", help="Optional explicit prediction field name"568    )569    parser.add_argument(570        "--ignore-missing",571        action="store_true",572        help="Evaluate only questions present in the prediction file",573    )574    parser.add_argument(575        "--output", type=Path, default=None, help="Write JSON report to this path"576    )577    parser.add_argument(578        "--per-question-csv",579        type=Path,580        default=None,581        help="Optional per-question CSV output",582    )583    parser.add_argument(584        "--no-per-question-json",585        action="store_true",586        help="Omit per-question rows from the JSON report",587    )588    return parser.parse_args()589 590 591def main() -> None:592    args = parse_args()593    gold_files = args.gold_files594    if gold_files is None:595        gold_files = [596            args.data_dir / "qa_single_doc.jsonl",597            args.data_dir / "qa_cross_doc.jsonl",598        ]599 600    missing_gold_files = [str(path) for path in gold_files if not path.exists()]601    if missing_gold_files:602        raise FileNotFoundError(f"Gold file(s) not found: {missing_gold_files}")603 604    gold_records = load_gold_records(gold_files)605    predictions, statuses = load_predictions(args.predictions, args.prediction_field)606    report = evaluate(607        gold_records, predictions, statuses, ignore_missing=args.ignore_missing608    )609 610    if args.per_question_csv:611        write_per_question_csv(report["per_question"], args.per_question_csv)612    if args.no_per_question_json:613        report = {key: value for key, value in report.items() if key != "per_question"}614 615    if args.output:616        args.output.parent.mkdir(parents=True, exist_ok=True)617        args.output.write_text(618            json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"619        )620 621    overall = report["overall"]622    print("XL-DocBench evaluation")623    print(f"  gold questions:       {report['gold_count']}")624    print(f"  predictions:          {report['prediction_count']}")625    print(f"  evaluated:            {report['evaluated_count']}")626    print(f"  missing predictions:  {report['missing_prediction_count']}")627    print(f"  extra predictions:    {report['extra_prediction_count']}")628    print(f"  Accuracy:             {overall['accuracy'] * 100:.2f}")629    print(f"  Token F1:             {overall['token_f1'] * 100:.2f}")630    print(f"  ANLS:                 {overall['anls'] * 100:.2f}")631 632 633if __name__ == "__main__":634    try:635        main()636    except (OSError, TypeError, ValueError) as exc:637        print(f"ERROR: {exc}", file=sys.stderr)638        sys.exit(1)639