Files
ts-as-modality/src/tsmm/eval/qa_judge.py
T
张宗平 40ec960cf8 fix(m5): eval pipeline — anomaly_regions schema + nested JSON + pure-LLM baseline
Critical eval-pipeline bugs found while running the stage-2 model:

1. parse_anomaly_segments only recognized {"segments\:[[s,e]]}; the training
   data (data/instruct.py) emits {"anomaly_regions":[{"start","end"}]}.
   The model learned the correct format but scored 0 → VUS-PR=0. Now accepts
   both schemas + a balanced-brace JSON scanner (the flat regex could not
   match nested objects the model emits).
2. qa_judge._extract_json had the same nesting bug — reuse the scanner.
3. Generation truncated at 64-96 tokens (too short for stats JSON) → bumped
   to 192 in report + instruct_check.

Report enhancements:
- wire the pure-LLM baseline (series-as-text, no TS modality) so design §5.4
  'model vs pure LLM' is actually computed. Heavy predictors built/evaluated
  one at a time with GPU cleanup (single 12GB card).
- add QA-judge table across all 6 categories (design §6.5 问答均分).
- add --pure_llm / --max_per_cat flags.

Results (reports/eval-2026-06-30.md): model QA-judge mean 2.45 vs pure_llm
0.88 (2.8x); anomaly localization (VUS-PR) is the weak point — neither
model nor pure_llm localize well; TS-modality ablation change_rate 0.98.
2026-06-30 10:02:59 +00:00

202 lines
7.8 KiB
Python

"""QA judging (T5.3): rule track (JSON/number tolerance) + LLM-judge track (0-5).
- Rule track: objective, for anomaly/describe/forecast tasks. JSON region IoU,
numerical relative-tolerance match. Zero cost, fully reproducible.
- LLM-judge track: subjective, for root_cause/compare/event. Scores 0-5 via an
LLM (GPT-4o in production). Because this host has no network, the default
``backend="stub"`` produces a deterministic heuristic score (exact/substring
match → high; dissimilar → low) so the pipeline is runnable end-to-end offline;
swap to ``backend="openai"`` (or pass a callable) for real judging.
Design ref: §5.3 (双轨) and §6.2 (GPT-4o judge, eval-only).
"""
from __future__ import annotations
import json
import re
from typing import Any, Callable, Dict, List, Optional, Tuple
from .parse_answer import _extract_balanced_json, parse_anomaly_segments
_JSON_RE = re.compile(r"\{[^{}]*\}", re.DOTALL)
def _extract_json(pred: str) -> Optional[dict]:
if pred is None:
return None
# prefer balanced (nested-capable) extraction first
for blob in _extract_balanced_json(pred):
try:
obj = json.loads(blob)
if isinstance(obj, dict):
return obj
except json.JSONDecodeError:
continue
# fallback to flat blobs
for blob in _JSON_RE.findall(pred):
try:
return json.loads(blob)
except json.JSONDecodeError:
continue
return None
# ─── rule-track per-category scorers ──────────────────────────────────────
def rule_score_describe(pred: str, ref: dict, rtol: float = 0.05) -> Dict[str, Any]:
"""Compare numerical statistics with relative tolerance.
``ref`` is a dict of {stat_name: value}. A stat scores 1 if |pred-ref|/|ref|
<= rtol (or both zero). Overall score = fraction of matched stats.
"""
obj = _extract_json(pred)
if obj is None:
return {"score": 0.0, "parsed": False}
if not ref:
return {"score": 1.0 if not obj else 0.0, "parsed": True}
hits = 0
total = 0
for k, rv in ref.items():
total += 1
pv = obj.get(k)
if pv is None:
continue
try:
pv = float(pv); rv = float(rv)
except (TypeError, ValueError):
continue
if rv == 0 and pv == 0:
hits += 1
elif rv != 0 and abs(pv - rv) / abs(rv) <= rtol:
hits += 1
return {"score": hits / total, "parsed": True}
def rule_score_anomaly(pred: str, ref_segs: List[Tuple[int, int]], T: int) -> Dict[str, Any]:
"""Anomaly region IoU as the score."""
pred_segs, ok = parse_anomaly_segments(pred)
if not ok and not pred_segs:
return {"score": 0.0, "parsed": False}
# IoU over point sets
def to_set(segs):
s = set()
for a, b in segs:
a = max(0, a); b = min(T - 1, b)
s.update(range(a, b + 1))
return s
ps, rs = to_set(pred_segs), to_set(ref_segs)
if not ps and not rs:
return {"score": 1.0, "parsed": True}
inter = len(ps & rs)
union = len(ps | rs)
return {"score": inter / union if union else 0.0, "parsed": True}
def rule_score_forecast(pred: str, ref: List[float], rtol: float = 0.1) -> Dict[str, Any]:
obj = _extract_json(pred)
if obj is None:
return {"score": 0.0, "parsed": False}
vals = obj.get("forecast")
if not isinstance(vals, list):
return {"score": 0.0, "parsed": True}
try:
preds = [float(v) for v in vals]
except (TypeError, ValueError):
return {"score": 0.0, "parsed": True}
n = min(len(preds), len(ref))
if n == 0:
return {"score": 0.0, "parsed": True}
hits = 0
for p, r in zip(preds[:n], ref[:n]):
if r == 0 and p == 0:
hits += 1
elif r != 0 and abs(p - r) / abs(r) <= rtol:
hits += 1
return {"score": hits / len(ref) if ref else 0.0, "parsed": True}
# ─── rule-track dispatcher ──────────────────────────────────────────────────
class RuleJudge:
"""Dispatch to the right rule scorer by category."""
def judge(self, category: str, pred: str, **ref) -> Dict[str, Any]:
if category == "anomaly":
out = rule_score_anomaly(pred, ref.get("ref_segs", []), ref.get("T", 1))
elif category == "describe":
out = rule_score_describe(pred, ref.get("ref_answer", {}))
elif category == "forecast":
out = rule_score_forecast(pred, ref.get("ref_forecast", []))
else:
# root_cause / compare / event → not rule-checkable
return {"track": "skip", "score": None, "parsed": False}
return {"track": "rule", **out}
# ─── LLM-judge track (0-5) ──────────────────────────────────────────────────
class LLMJudge:
"""Score free-text answers 0-5.
``backend``:
- ``"stub"`` (default): deterministic offline heuristic (no network).
- ``"openai"``: real GPT-4o judge (requires OPENAI_API_KEY + network).
- a callable ``(question, ref, pred) -> {"score": float, "reason": str}``.
"""
def __init__(self, backend: str = "stub", model: str = "gpt-4o",
api_key: Optional[str] = None, base_url: Optional[str] = None) -> None:
self.backend = backend
self.model = model
self.api_key = api_key
self.base_url = base_url
def score(self, question: str, ref_answer: str, pred_answer: str) -> Dict[str, Any]:
if callable(self.backend):
return self.backend(question, ref_answer, pred_answer)
if self.backend == "stub":
return self._stub_score(question, ref_answer, pred_answer)
if self.backend == "openai":
return self._openai_score(question, ref_answer, pred_answer)
raise ValueError(f"unknown backend: {self.backend}")
def _stub_score(self, question, ref, pred) -> Dict[str, Any]:
# Offline heuristic: exact/substring/token-overlap similarity → 0-5.
ref = (ref or "").strip().lower()
pred = (pred or "").strip().lower()
if not ref and not pred:
return {"score": 5.0, "reason": "stub: both empty", "backend": "stub"}
if ref == pred:
return {"score": 5.0, "reason": "stub: exact match", "backend": "stub"}
rt, pt = set(ref.split()), set(pred.split())
if not rt:
return {"score": 1.0, "reason": "stub: empty ref", "backend": "stub"}
overlap = len(rt & pt) / len(rt)
score = round(5.0 * overlap, 1)
return {"score": score, "reason": f"stub: token-overlap={overlap:.2f}", "backend": "stub"}
def _openai_score(self, question, ref, pred) -> Dict[str, Any]: # pragma: no cover
import os
from openai import OpenAI
key = self.api_key or os.environ.get("OPENAI_API_KEY")
client = OpenAI(api_key=key, base_url=self.base_url)
prompt = (
"You are a strict grader. Score the model's answer 0-5.\n"
f"Question: {question}\nReference answer: {ref}\nModel answer: {pred}\n"
'Reply JSON: {"score": <0-5>, "reason": "..."}'
)
resp = client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
)
text = resp.choices[0].message.content
try:
obj = json.loads(text)
return {"score": float(obj["score"]), "reason": obj.get("reason", ""),
"backend": "openai"}
except (json.JSONDecodeError, KeyError):
return {"score": 0.0, "reason": f"parse-fail: {text[:100]}",
"backend": "openai"}