Files
vllm-omni/vllm_omni/benchmarks/data_modules/daily_omni_eval.py
T

417 lines
17 KiB
Python
Raw Normal View History

"""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)