# -*- coding: utf-8 -*-
"""
Standalone evaluation of an AnomSeer/Qwen2.5-VL model on the Time-RA RATs-Uni test
set, reporting the **same metrics** as Time-RA / ITFormer for direct comparison:
num_samples, num_valid, num_invalid, valid_rate,
binary_{accuracy,precision_macro,recall_macro,f1_macro},
type_{accuracy,precision_macro,recall_macro,f1_macro},
thought_rouge_l, thought_bleu
The metric computation (`compute_rats40k_metrics`, `_rouge_l`, `_bleu`) is copied
verbatim from /mnt/share01/sqk/ITFormer/inference_rats40k.py so the numbers line up
exactly (15-class macro over labels 0-14, invalid predictions counted as -1, etc.).
Inference is done with vLLM. ``--model`` is a HF model directory:
* the base Qwen2.5-VL (zero-shot baseline), or
* a LoRA checkpoint already merged to HF (see tools/merge_lora_ckpt.py).
Usage
-----
python eval_rats_uni.py \
--model /mnt/share01/sqk/models/Qwen2.5-VL-3B-Instruct \
--data /mnt/share01/sqk/datasets/RATs40K/RATs-Uni-TSImage_Reason.json \
--out ./eval_results/rats_uni_zeroshot.json \
--tp 2
"""
import os
import io
import re
import json
import argparse
from typing import Any, Dict, List, Optional
from PIL import Image
# Reuse the exact training prompt + taxonomy so eval matches training.
from multimodal_data_processing.rats_uni import build_prompt, _resolve_figure_path
# ---------------------------------------------------------------------------
# Metric helpers — copied verbatim from ITFormer/inference_rats40k.py so the
# reported numbers are computed identically (do not "improve" these).
# ---------------------------------------------------------------------------
ACTION_ID_MAP: Dict[int, str] = {
0: "Normal Sequence", 1: "Point Anomaly", 2: "Periodic Change Anomaly",
3: "Trend Change Anomaly", 4: "Change Point Anomaly", 5: "Distributional Change Anomaly",
6: "Amplitude Anomaly", 7: "Pattern Change Anomaly", 8: "Sparse Anomaly",
9: "Repeated Value Anomaly", 10: "Sudden Flatline Anomaly", 11: "Drift Anomaly",
12: "Sudden Spike Anomaly", 13: "Continuous Segment Anomaly", 14: "Nonlinear Pattern Anomaly",
}
from sklearn.metrics import accuracy_score, f1_score, precision_score, recall_score
try:
from rouge_score import rouge_scorer as _rs_mod
_ROUGE = True
except ImportError:
_ROUGE = False
try:
from nltk.translate.bleu_score import SmoothingFunction, corpus_bleu as _cb
_NLTK = True
except ImportError:
_NLTK = False
def _rouge_l(preds: List[str], refs: List[str]) -> float:
if not preds or not _ROUGE:
return 0.0
scorer = _rs_mod.RougeScorer(["rougeL"], use_stemmer=False)
return sum(scorer.score(p, r)["rougeL"].fmeasure for p, r in zip(preds, refs)) / len(preds)
def _bleu(preds: List[str], refs: List[str]) -> float:
if not preds or not _NLTK:
return 0.0
try:
return float(_cb([[r.split()] for r in refs], [p.split() for p in preds],
smoothing_function=SmoothingFunction().method1))
except Exception:
return 0.0
def compute_rats40k_metrics(results: List[Dict[str, Any]]) -> Dict[str, float]:
"""Compute all evaluation metrics from a list of per-sample result dicts."""
gt_type, pred_type, gt_thoughts, pred_thoughts = [], [], [], []
invalid = 0
for r in results:
pred_id = r.get("pred_action_id")
gt_id = r.get("gt_action_id")
if gt_id is None:
invalid += 1
continue
gt_id = int(gt_id)
gt_type.append(gt_id)
if pred_id is None:
invalid += 1
pred_type.append(-1)
else:
pred_type.append(int(pred_id))
gt_thoughts.append(str(r.get("gt_thought", "") or ""))
pred_thoughts.append(str(r.get("pred_thought", "") or ""))
metrics: Dict[str, Any] = {
"num_samples": len(results),
"num_valid": len(results) - invalid,
"num_invalid": invalid,
"valid_rate": (len(results) - invalid) / len(results) if results else 0.0,
}
if not gt_type:
for k in ("binary_accuracy", "binary_precision_macro", "binary_recall_macro",
"binary_f1_macro", "type_accuracy", "type_precision_macro",
"type_recall_macro", "type_f1_macro", "thought_rouge_l", "thought_bleu"):
metrics[k] = 0.0
return metrics
gt_bin = [0 if x == 0 else 1 for x in gt_type]
pred_bin = [0 if x == 0 else (1 if x > 0 else -1) for x in pred_type]
metrics.update({
"binary_accuracy": accuracy_score(gt_bin, pred_bin),
"binary_precision_macro": precision_score(gt_bin, pred_bin, labels=[0, 1], average="macro", zero_division=0),
"binary_recall_macro": recall_score(gt_bin, pred_bin, labels=[0, 1], average="macro", zero_division=0),
"binary_f1_macro": f1_score(gt_bin, pred_bin, labels=[0, 1], average="macro", zero_division=0),
"type_accuracy": accuracy_score(gt_type, pred_type),
"type_precision_macro": precision_score(gt_type, pred_type, labels=list(ACTION_ID_MAP), average="macro", zero_division=0),
"type_recall_macro": recall_score(gt_type, pred_type, labels=list(ACTION_ID_MAP), average="macro", zero_division=0),
"type_f1_macro": f1_score(gt_type, pred_type, labels=list(ACTION_ID_MAP), average="macro", zero_division=0),
})
metrics["thought_rouge_l"] = _rouge_l(pred_thoughts, gt_thoughts)
metrics["thought_bleu"] = _bleu(pred_thoughts, gt_thoughts)
return metrics
def print_metrics(metrics: Dict[str, Any]) -> None:
print("\n" + "=" * 50)
print("RATs-Uni Evaluation Results")
print("=" * 50)
for k, v in metrics.items():
print(f" {k:<28} {v:.4f}" if isinstance(v, float) else f" {k:<28} {v}")
# ---------------------------------------------------------------------------
# Response parsing (AnomSeer output format: ... ...)
# ---------------------------------------------------------------------------
_ID_BY_KEY = {re.sub(r"[^a-z0-9]+", "", n.lower()): i for i, n in ACTION_ID_MAP.items()}
_ALIASES = {
"normal": 0, "point": 1, "periodic": 2, "trend": 3, "changepoint": 4,
"distributional": 5, "amplitude": 6, "pattern": 7, "sparse": 8, "repeated": 9,
"flatline": 10, "drift": 11, "spike": 12, "continuous": 13, "nonlinear": 14,
}
def _norm(t: str) -> str:
return re.sub(r"[^a-z0-9]+", "", str(t).lower())
def parse_pred_action_id(text: str) -> Optional[int]:
"""Parse ... -> action id; falls back to id/alias/substring."""
m = re.search(r"(.*?)", text, re.DOTALL | re.IGNORECASE)
cand = m.group(1).strip() if m else text
# exact integer id when the field is essentially just a number
mid = re.search(r"\b(\d{1,2})\b", cand)
if mid and re.fullmatch(r"[^a-zA-Z]*\d{1,2}[^a-zA-Z]*", cand):
if int(mid.group(1)) in ACTION_ID_MAP:
return int(mid.group(1))
key = _norm(cand)
if key in _ID_BY_KEY:
return _ID_BY_KEY[key]
if key in _ALIASES:
return _ALIASES[key]
best = None
for i, name in ACTION_ID_MAP.items():
if _norm(name) and _norm(name) in key:
if best is None or len(name) > len(ACTION_ID_MAP[best]):
best = i
if best is not None:
return best
if mid and int(mid.group(1)) in ACTION_ID_MAP:
return int(mid.group(1))
return None
def parse_pred_thought(text: str) -> str:
"""Extract the ... reasoning (fallback: text minus the tag)."""
m = re.search(r"(.*?)", text, re.DOTALL | re.IGNORECASE)
if m:
return m.group(1).strip()
return re.sub(r".*?", "", text, flags=re.DOTALL | re.IGNORECASE).strip()
# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------
def load_test_samples(json_path: str, split: str, data_root: str,
max_samples: Optional[int]) -> List[dict]:
with open(json_path) as f:
data = json.load(f)
records = data[split]
keys = sorted(records.keys(), key=lambda k: int(k) if str(k).isdigit() else k)
samples = []
for key in keys:
if max_samples is not None and len(samples) >= max_samples:
break
e = records[key]
if e is None:
continue
img_path = _resolve_figure_path(data_root, e.get("FigurePath", ""))
if not os.path.isfile(img_path):
continue
samples.append({
"index": key,
"length": len(e.get("Observation") or []),
"source": e.get("Source", "unknown"),
"image_path": img_path,
"gt_action_id": e.get("ActionID"),
"gt_thought": e.get("Thought", "") or "",
})
return samples
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
ap = argparse.ArgumentParser(description="Eval an AnomSeer/Qwen2.5-VL model on RATs-Uni (Time-RA metrics).")
ap.add_argument("--model", required=True, help="HF model dir (base, or a merged-LoRA model).")
ap.add_argument("--data", default="/mnt/share01/sqk/datasets/RATs40K/RATs-Uni-TSImage_Reason.json")
ap.add_argument("--data_root", default=None, help="Figure root (defaults to the JSON's dir).")
ap.add_argument("--split", default="TSAD_test")
ap.add_argument("--out", default="./eval_results/rats_uni_metrics.json")
ap.add_argument("--tp", type=int, default=2, help="vLLM tensor-parallel size.")
ap.add_argument("--gpu_mem_util", type=float, default=0.85)
ap.add_argument("--max_model_len", type=int, default=2048)
ap.add_argument("--max_tokens", type=int, default=512)
ap.add_argument("--max_samples", type=int, default=None)
ap.add_argument("--save_predictions", action="store_true", help="Also dump per-sample predictions.")
args = ap.parse_args()
data_root = args.data_root or os.path.dirname(os.path.abspath(args.data))
samples = load_test_samples(args.data, args.split, data_root, args.max_samples)
print(f"Loaded {len(samples)} test samples from {args.split}")
from vllm import LLM, SamplingParams
from transformers import AutoProcessor
processor = AutoProcessor.from_pretrained(args.model, trust_remote_code=True)
llm = LLM(
model=args.model,
tensor_parallel_size=args.tp,
gpu_memory_utilization=args.gpu_mem_util,
max_model_len=args.max_model_len,
limit_mm_per_prompt={"image": 1},
seed=0,
trust_remote_code=True,
)
sampling = SamplingParams(temperature=0.0, max_tokens=args.max_tokens) # greedy
# Build vLLM inputs (chat template inserts the vision tokens; image via multi_modal_data).
vllm_inputs, metas = [], []
for s in samples:
text = build_prompt(s["length"], s["source"])
text = re.sub(r"^\s*", "", text) # the chat template handles the image placeholder
messages = [{"role": "user", "content": [
{"type": "image"},
{"type": "text", "text": text},
]}]
prompt = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
try:
image = Image.open(s["image_path"]).convert("RGB")
except Exception as exc: # noqa: BLE001
print(f"[WARN] bad image {s['image_path']}: {exc}")
continue
vllm_inputs.append({"prompt": prompt, "multi_modal_data": {"image": image}})
metas.append(s)
print(f"Generating for {len(vllm_inputs)} prompts (greedy)...")
outputs = llm.generate(vllm_inputs, sampling)
results, predictions = [], []
for s, out in zip(metas, outputs):
resp = out.outputs[0].text
pred_id = parse_pred_action_id(resp)
pred_thought = parse_pred_thought(resp)
results.append({
"gt_action_id": s["gt_action_id"],
"pred_action_id": pred_id,
"gt_thought": s["gt_thought"],
"pred_thought": pred_thought,
})
if args.save_predictions:
predictions.append({**{k: s[k] for k in ("index", "source", "gt_action_id")},
"pred_action_id": pred_id, "response": resp})
metrics = compute_rats40k_metrics(results)
print_metrics(metrics)
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
with open(args.out, "w") as f:
json.dump(metrics, f, indent=2)
print(f"\nSaved metrics -> {args.out}")
if args.save_predictions:
pred_path = os.path.splitext(args.out)[0] + "_predictions.json"
with open(pred_path, "w") as f:
json.dump(predictions, f, indent=2, ensure_ascii=False)
print(f"Saved predictions -> {pred_path}")
if __name__ == "__main__":
main()