"""Daily-Omni multiple-choice accuracy scoring for vLLM-Omni bench serve. Compares model ``generated_text`` to dataset ``Answer`` (A/B/C/D). **Alignment with open-source** (`Lliar-liar/Daily-Omni` ``test_model/.../testmodel.py``): - Answer extraction defaults to the same rules as ``extract_choice_letter`` (strip after an ``assistant`` marker, then leading ``A``–``D``, else ``\\b[A-D]\\b``, else a CJK-safe non-letter-boundary pass). Set env ``DAILY_OMNI_EXTRACT_MODE=relaxed`` to use the older vLLM-Omni heuristics (last ``answer:``, tail scan, etc.). - Overall accuracy comparable to the official script uses **successful HTTP responses only** as the denominator (their ``valid_questions = total - failed`` excludes inference / I/O skips). We also report ``daily_omni_accuracy_incl_http_fail`` where each failed request counts as a wrong answer in the denominator (stricter throughput-bench view). - **By video length:** mirrors upstream ``--- Accuracy by Video Duration ---`` for ``30s`` / ``60s`` (``qa.json`` ``video_duration``): ``daily_omni_per_duration*`` metrics and a printed block. - **By video category:** mirrors ``--- Accuracy by Video Category ---`` using ``video_category`` from ``qa.json`` (``daily_omni_per_category*``; empty category is bucketed as ``unknown``). - **Correctness:** uses the same ``evaluate_answer`` rule as upstream (truthy extracted letter vs raw ``Answer`` string, both ``strip().upper()``). Rows with empty ``Answer`` are skipped (``no_gold``), matching missing-field skips in the official loop. """ from __future__ import annotations import os import re from typing import Any from vllm.benchmarks.lib.endpoint_request_func import RequestFuncOutput from vllm_omni.benchmarks.data_modules.daily_omni_dataset import DailyOmniSampleRequest _VALID = frozenset("ABCD") # Official ``testmodel.py`` buckets (``qa.json`` ``video_duration``). DAILY_OMNI_DURATION_KEYS: tuple[str, ...] = ("30s", "60s") def extract_choice_letter_official(text: str | None) -> str | None: """Port of Daily-Omni ``extract_choice_letter`` (first A–D, assistant-tail semantics).""" if not text: return None raw = str(text).strip() if not raw: return None match = re.search(r"assistant\s*([\s\S]*)$", raw, flags=re.IGNORECASE) candidate = match.group(1).strip() if match else raw direct = re.match(r"(?i)^\s*([A-D])(?:[\s\.\)::]|$)", candidate) if direct: return direct.group(1).upper() fallback = re.search(r"\b([A-D])\b", candidate.upper()) if fallback: return fallback.group(1) # ``\b`` is ASCII/Latin-word-centric; CJK (e.g. "选B", "答案:B") has no boundary before B. loose = list( re.finditer( r"(?:[^A-Za-z]|^)([A-D])(?:[^A-Za-z]|$)", candidate, flags=re.IGNORECASE, ) ) if loose: return loose[-1].group(1).upper() return None def evaluate_answer_official(model_answer: str | None, correct_answer: str) -> bool: """Port of Daily-Omni ``evaluate_answer`` (strict string match after strip/upper).""" if not model_answer: return False return model_answer.strip().upper() == (correct_answer or "").strip().upper() def normalize_gold_answer(gold: str) -> str | None: """Best-effort single letter from ``Answer`` (for ``gold_normalized`` in saved items only).""" g = (gold or "").strip().upper() if len(g) == 1 and g in _VALID: return g m = re.search(r"([ABCD])\b", g) if m: return m.group(1).upper() return None def _extract_predicted_choice_relaxed(text: str) -> str | None: """Legacy vLLM-Omni heuristics (last ``answer:`` patterns, tail scan).""" if not text or not str(text).strip(): return None t = str(text).strip() strong_patterns = [ r"(?i)\*\*answer\*\*\s*[::]?\s*\(?([ABCD])\)?", r"(?i)\banswer\s*[::]?\s*\(?([ABCD])\)?", r"(?i)\bfinal\s+answer\s*[::]?\s*\(?([ABCD])\)?", r"(?i)\bcorrect\s+(?:answer|option)\s*[::]?\s*\(?([ABCD])\)?", r"(?i)\bthe\s+(?:correct\s+)?option\s+(?:is|would\s+be)\s*\(?([ABCD])\)?", r"(?i)\bI\s+(?:would\s+)?(?:choose|select|pick)\s*\(?([ABCD])\)?", ] last_letter: str | None = None for pat in strong_patterns: for m in re.finditer(pat, t): last_letter = m.group(1).upper() if last_letter: return last_letter # Weaker phrases: first match can be spurious; still prefer last occurrence. weak_patterns = [ r"(?i)\boption\s*[::]?\s*\(?([ABCD])\)?", r"(?i)\bchoice\s*[::]?\s*\(?([ABCD])\)?", ] for pat in weak_patterns: for m in re.finditer(pat, t): last_letter = m.group(1).upper() if last_letter: return last_letter paren = list(re.finditer(r"\(([ABCD])\)", t)) if paren: return paren[-1].group(1).upper() # First line sometimes is just "B" or "B." — allow if whole output is short one_line = t.split("\n", 1)[0].strip() if len(t) < 120 and len(one_line) <= 6: m0 = re.match(r"^([ABCD])\s*[.:\)]?\s*$", one_line, re.I) if m0: return m0.group(1).upper() # Tail-only: avoids matching echoed "A. ..." option blocks at the start tail_len = min(500, len(t)) tail = t[-tail_len:] # ``\b`` after the letter avoids "Because"/"Definitely" false positives m = re.search(r"(?:^|[^\w])([ABCD])\b", tail, re.I) if m: return m.group(1).upper() return None def extract_predicted_choice(text: str | None) -> str | None: """Parse model output to A–D (official Daily-Omni rules by default).""" if not text or not str(text).strip(): return None mode = os.environ.get("DAILY_OMNI_EXTRACT_MODE", "official").strip().lower() if mode in ("relaxed", "heuristic", "legacy"): return _extract_predicted_choice_relaxed(str(text)) return extract_choice_letter_official(text) def compute_daily_omni_accuracy_metrics( input_requests: list[Any], outputs: list[RequestFuncOutput], *, include_per_item: bool = False, ) -> dict[str, Any] | None: """If all requests are :class:`DailyOmniSampleRequest`, compute accuracy stats. Rows with empty ``Answer`` (after strip) are skipped as ``no_gold``, like upstream missing ``correct_answer``. **Denominators:** The open-source script excludes items that hit inference / I/O failures from ``valid_questions``; we mirror that with ``daily_omni_accuracy`` (= correct / successful responses). Failed HTTP requests are also tracked and used in ``daily_omni_accuracy_incl_http_fail`` (each failure counts as incorrect in the denominator). """ if not input_requests or len(input_requests) != len(outputs): return None if not all(isinstance(r, DailyOmniSampleRequest) for r in input_requests): return None # total / correct: all rows with gold (incl. HTTP fail in total) # total_ok / correct_ok: successful HTTP only (GitHub-style per-type denominator) per_task: dict[str, dict[str, int]] = {} per_category: dict[str, dict[str, int]] = {} per_duration: dict[str, dict[str, int]] = { k: {"correct": 0, "total": 0, "correct_ok": 0, "total_ok": 0} for k in DAILY_OMNI_DURATION_KEYS } items: list[dict[str, Any]] = [] correct = 0 evaluated = 0 no_gold = 0 request_failed = 0 parse_failed = 0 # success but could not extract A–D for req, out in zip(input_requests, outputs, strict=True): assert isinstance(req, DailyOmniSampleRequest) gold_raw = (req.daily_omni_gold_answer or "").strip() gold_norm = normalize_gold_answer(req.daily_omni_gold_answer) tt = (req.daily_omni_task_type or "unknown").strip() or "unknown" dur_key = (req.daily_omni_video_duration or "").strip() dur_active = dur_key in per_duration cat_key = (req.daily_omni_video_category or "").strip() or "unknown" if tt not in per_task: per_task[tt] = {"correct": 0, "total": 0, "correct_ok": 0, "total_ok": 0} if cat_key not in per_category: per_category[cat_key] = {"correct": 0, "total": 0, "correct_ok": 0, "total_ok": 0} if not gold_raw: no_gold += 1 items.append( { "request_id": req.request_id, "skipped": True, "reason": "no_gold", "task_type": tt, "video_id": req.daily_omni_video_id, "video_duration": dur_key or None, "video_category": cat_key if cat_key != "unknown" else None, } ) continue if not out.success: request_failed += 1 evaluated += 1 per_task[tt]["total"] += 1 per_category[cat_key]["total"] += 1 if dur_active: per_duration[dur_key]["total"] += 1 # GitHub: failed inference not in valid_questions — do not increment total_ok items.append( { "request_id": req.request_id, "gold": gold_raw, "gold_normalized": gold_norm, "predicted": None, "correct": False, "task_type": tt, "video_id": req.daily_omni_video_id, "video_duration": dur_key or None, "video_category": cat_key if cat_key != "unknown" else None, "error": (out.error or "")[:500], } ) continue pred = extract_predicted_choice(out.generated_text) evaluated += 1 per_task[tt]["total"] += 1 per_task[tt]["total_ok"] += 1 per_category[cat_key]["total"] += 1 per_category[cat_key]["total_ok"] += 1 if dur_active: per_duration[dur_key]["total"] += 1 per_duration[dur_key]["total_ok"] += 1 if pred is None: parse_failed += 1 is_correct = evaluate_answer_official(pred, req.daily_omni_gold_answer) if is_correct: correct += 1 per_task[tt]["correct"] += 1 per_task[tt]["correct_ok"] += 1 per_category[cat_key]["correct"] += 1 per_category[cat_key]["correct_ok"] += 1 if dur_active: per_duration[dur_key]["correct"] += 1 per_duration[dur_key]["correct_ok"] += 1 items.append( { "request_id": req.request_id, "gold": gold_raw, "gold_normalized": gold_norm, "predicted": pred, "correct": is_correct, "parse_failed": pred is None, "task_type": tt, "video_id": req.daily_omni_video_id, "video_duration": dur_key or None, "video_category": cat_key if cat_key != "unknown" else None, } ) evaluated_ok = evaluated - request_failed accuracy_github = (correct / evaluated_ok) if evaluated_ok else None accuracy_incl_fail = (correct / evaluated) if evaluated else None per_task_accuracy: dict[str, float | None] = {} per_task_accuracy_github: dict[str, float | None] = {} for name, st in per_task.items(): tot = st["total"] per_task_accuracy[name] = (st["correct"] / tot) if tot else None tok = st["total_ok"] per_task_accuracy_github[name] = (st["correct_ok"] / tok) if tok else None per_category_accuracy: dict[str, float | None] = {} per_category_accuracy_github: dict[str, float | None] = {} for name, st in per_category.items(): tot = st["total"] per_category_accuracy[name] = (st["correct"] / tot) if tot else None tok = st["total_ok"] per_category_accuracy_github[name] = (st["correct_ok"] / tok) if tok else None per_duration_accuracy: dict[str, float | None] = {} per_duration_accuracy_github: dict[str, float | None] = {} for name, st in per_duration.items(): tot = st["total"] per_duration_accuracy[name] = (st["correct"] / tot) if tot else None tok = st["total_ok"] per_duration_accuracy_github[name] = (st["correct_ok"] / tok) if tok else None out: dict[str, Any] = { # Comparable to GitHub testmodel.py: correct / successful inferences "daily_omni_accuracy": accuracy_github, "daily_omni_accuracy_incl_http_fail": accuracy_incl_fail, "daily_omni_correct": correct, "daily_omni_evaluated": evaluated, "daily_omni_evaluated_ok": evaluated_ok, "daily_omni_no_gold": no_gold, "daily_omni_request_failed": request_failed, "daily_omni_parse_failed": parse_failed, "daily_omni_per_task": {k: dict(v) for k, v in per_task.items()}, "daily_omni_per_task_accuracy": per_task_accuracy, "daily_omni_per_task_accuracy_github_style": per_task_accuracy_github, "daily_omni_per_category": {k: dict(v) for k, v in per_category.items()}, "daily_omni_per_category_accuracy": per_category_accuracy, "daily_omni_per_category_accuracy_github_style": per_category_accuracy_github, "daily_omni_per_duration": {k: dict(v) for k, v in per_duration.items()}, "daily_omni_per_duration_accuracy": per_duration_accuracy, "daily_omni_per_duration_accuracy_github_style": per_duration_accuracy_github, } if include_per_item: out["daily_omni_eval_items"] = items return out def print_daily_omni_accuracy_summary(metrics: dict[str, Any]) -> None: """Pretty-print accuracy block (stdout).""" acc = metrics.get("daily_omni_accuracy") acc_fail = metrics.get("daily_omni_accuracy_incl_http_fail") if acc is None and acc_fail is None and metrics.get("daily_omni_evaluated", 0) == 0: return print("{s:{c}^{n}}".format(s=" Daily-Omni accuracy (MCQ) ", n=50, c="=")) ok = int(metrics.get("daily_omni_evaluated_ok", 0) or 0) cor = int(metrics.get("daily_omni_correct", 0) or 0) if ok > 0 and acc is not None: print(f"Overall Accuracy: {cor}/{ok} = {acc:.2%}") elif int(metrics.get("daily_omni_evaluated", 0) or 0) > 0: print("Overall Accuracy: 0/0 = N/A (no successful HTTP responses)") print( "{:<40} {:<10}".format( "Submitted (gold present):", metrics.get("daily_omni_evaluated", 0), ) ) print( "{:<40} {:<10}".format( "Successful HTTP (GitHub denom.):", metrics.get("daily_omni_evaluated_ok", 0), ) ) print("{:<40} {:<10}".format("Correct:", metrics.get("daily_omni_correct", 0))) if acc is not None: print("{:<40} {:<10.4f}".format("Accuracy (ratio, same as above):", acc)) if acc_fail is not None and metrics.get("daily_omni_request_failed", 0): print( "{:<40} {:<10.4f}".format( "Accuracy (incl. HTTP as wrong):", acc_fail, ) ) print("{:<40} {:<10}".format("Skipped (no gold):", metrics.get("daily_omni_no_gold", 0))) print( "{:<40} {:<10}".format( "HTTP failed (excl. from GitHub acc.):", metrics.get("daily_omni_request_failed", 0), ) ) print( "{:<40} {:<10}".format( "Parsed OK but no A–D found:", metrics.get("daily_omni_parse_failed", 0), ) ) pt = metrics.get("daily_omni_per_task") or {} pta = metrics.get("daily_omni_per_task_accuracy_github_style") or {} if pta: print("\n--- Accuracy by QA Type ---") for name in sorted(pta.keys()): a = pta[name] st = pt.get(name) or {} tok = int(st.get("total_ok", 0) or 0) cok = int(st.get("correct_ok", 0) or 0) if tok and a is not None: print(f"{name}: {cok}/{tok} = {a:.2%}") else: print(f"{name}: 0/0 = N/A") pc = metrics.get("daily_omni_per_category") or {} ptc = metrics.get("daily_omni_per_category_accuracy_github_style") or {} if ptc: print("\n--- Accuracy by Video Category ---") for name in sorted(ptc.keys()): a = ptc[name] st = pc.get(name) or {} tok = int(st.get("total_ok", 0) or 0) cok = int(st.get("correct_ok", 0) or 0) if tok and a is not None: print(f"{name}: {cok}/{tok} = {a:.2%}") else: print(f"{name}: 0/0 = N/A") pdf = metrics.get("daily_omni_per_duration_accuracy_github_style") or {} if pdf: print("\n--- Accuracy by Video Duration ---") for name in DAILY_OMNI_DURATION_KEYS: a = pdf.get(name) st = (metrics.get("daily_omni_per_duration") or {}).get(name) or {} tok = int(st.get("total_ok", 0) or 0) cor = int(st.get("correct_ok", 0) or 0) if tok and a is not None: print(f"{name} Duration: {cor}/{tok} = {a:.2%}") else: print(f"{name} Duration: 0/0 = N/A") print("=" * 50)