288 lines
9.7 KiB
Python
288 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Eval Qwen3-VL-4B on multi-image retrieval-style test data.
|
|
|
|
Input: ShareGPT JSON produced by prepare_sft_data_multiimage.py
|
|
(each example has 6 images + 1 query + 1 golden answer).
|
|
|
|
Metrics: exact_match / char_accuracy / llm_judge_accuracy (GPT-4.1).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
|
|
import torch
|
|
from tqdm import tqdm
|
|
from transformers import Qwen3VLForConditionalGeneration, AutoProcessor
|
|
from qwen_vl_utils import process_vision_info
|
|
|
|
|
|
_GRADER_TEMPLATE = """Your job is to look at a question, a gold target, and a predicted answer, and then assign a grade of either ["CORRECT", "INCORRECT", "NOT_ATTEMPTED"].
|
|
Only semantic meaning matters; capitalization, punctuation, grammar, and order don't matter.
|
|
Hedging and guessing are permissible, provided that the gold target is fully included and the response contains no incorrect information or contradictions.
|
|
For numerical answers the predicted answer must be correct to the last significant figure in the gold answer. The gold target may contain more information than the question; the predicted answer only needs to contain what the question asks.
|
|
Do not punish typos in names if it is clearly the same name.
|
|
|
|
Here is a new example. Simply reply with either CORRECT, INCORRECT, NOT ATTEMPTED. Don't apologize or correct yourself; we are just grading the answer.
|
|
```
|
|
Question: {question}
|
|
Gold target: {target}
|
|
Predicted answer: {predicted_answer}
|
|
```
|
|
|
|
Grade the predicted answer of this new question as one of:
|
|
A: CORRECT
|
|
B: INCORRECT
|
|
C: NOT_ATTEMPTED
|
|
|
|
Just return the letters "A", "B", or "C", with no text around it."""
|
|
|
|
|
|
def strip_image_tokens(s: str) -> str:
|
|
return s.replace("<image>", "").lstrip("\n ")
|
|
|
|
|
|
def run_inference(
|
|
model, processor, examples, device, desc, max_new_tokens=128, enable_thinking=False
|
|
):
|
|
results = []
|
|
for ex in tqdm(examples, desc=desc):
|
|
images = ex.get("images", [])
|
|
# Verify all images exist
|
|
missing = [p for p in images if not os.path.exists(p)]
|
|
if missing:
|
|
results.append(
|
|
{
|
|
"query": ex.get("_query", ""),
|
|
"golden": ex.get("_golden", ""),
|
|
"predicted": "",
|
|
"image_missing": True,
|
|
"missing_paths": missing,
|
|
}
|
|
)
|
|
continue
|
|
|
|
user_msg = next(m for m in ex["messages"] if m["role"] == "user")
|
|
assistant_msg = next(m for m in ex["messages"] if m["role"] == "assistant")
|
|
query = strip_image_tokens(user_msg["content"])
|
|
golden = assistant_msg["content"].strip()
|
|
|
|
content = [{"type": "image", "image": f"file://{p}"} for p in images]
|
|
content.append({"type": "text", "text": query})
|
|
messages = [{"role": "user", "content": content}]
|
|
|
|
text = processor.apply_chat_template(
|
|
messages,
|
|
tokenize=False,
|
|
add_generation_prompt=True,
|
|
enable_thinking=enable_thinking,
|
|
)
|
|
image_inputs, video_inputs = process_vision_info(messages)
|
|
inputs = processor(
|
|
text=[text],
|
|
images=image_inputs,
|
|
videos=video_inputs,
|
|
padding=True,
|
|
return_tensors="pt",
|
|
).to(device)
|
|
|
|
with torch.no_grad():
|
|
out_ids = model.generate(
|
|
**inputs, max_new_tokens=max_new_tokens, do_sample=False
|
|
)
|
|
gen_ids = out_ids[0][inputs.input_ids.shape[1] :]
|
|
pred = processor.decode(gen_ids, skip_special_tokens=True).strip()
|
|
|
|
results.append(
|
|
{
|
|
"query": query,
|
|
"golden": golden,
|
|
"predicted": pred,
|
|
"n_images": len(images),
|
|
"gold_pos": ex.get("_gold_pos"),
|
|
"gold_in_top6_pos": ex.get("_gold_in_top6_pos"),
|
|
}
|
|
)
|
|
return results
|
|
|
|
|
|
def compute_em_char(results):
|
|
correct_em = 0
|
|
char_correct = 0
|
|
char_total = 0
|
|
scored = 0
|
|
for r in results:
|
|
if r.get("image_missing"):
|
|
continue
|
|
scored += 1
|
|
pred = r["predicted"].lower()
|
|
gold = r["golden"].lower()
|
|
if pred == gold:
|
|
correct_em += 1
|
|
if gold or pred:
|
|
matches = sum(1 for a, b in zip(pred, gold) if a == b)
|
|
char_correct += matches
|
|
char_total += max(len(pred), len(gold))
|
|
return {
|
|
"exact_match": correct_em / scored if scored else 0.0,
|
|
"char_accuracy": char_correct / char_total if char_total else 0.0,
|
|
"scored": scored,
|
|
}
|
|
|
|
|
|
def grade_with_gpt(results, model: str, concurrency: int = 16):
|
|
from openai import OpenAI
|
|
|
|
client = OpenAI()
|
|
|
|
def _grade(idx, r):
|
|
try:
|
|
resp = client.chat.completions.create(
|
|
model=model,
|
|
messages=[
|
|
{
|
|
"role": "user",
|
|
"content": _GRADER_TEMPLATE.format(
|
|
question=r["query"],
|
|
target=r["golden"],
|
|
predicted_answer=r["predicted"] or "(no answer)",
|
|
),
|
|
}
|
|
],
|
|
max_tokens=5,
|
|
temperature=0,
|
|
)
|
|
grade = resp.choices[0].message.content.strip()
|
|
is_correct = bool(re.match(r"^\s*A\b", grade))
|
|
return idx, is_correct, grade
|
|
except Exception as e:
|
|
return idx, False, f"ERR:{e}"
|
|
|
|
scored = [(i, r) for i, r in enumerate(results) if not r.get("image_missing")]
|
|
n = len(scored)
|
|
verdicts = [("", False)] * len(results)
|
|
correct = 0
|
|
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
|
futures = [pool.submit(_grade, i, r) for i, r in scored]
|
|
for fut in tqdm(as_completed(futures), total=n, desc="GPT judge"):
|
|
idx, is_correct, grade = fut.result()
|
|
verdicts[idx] = (grade, is_correct)
|
|
if is_correct:
|
|
correct += 1
|
|
|
|
for i, (grade, is_correct) in enumerate(verdicts):
|
|
results[i]["judge_grade"] = grade
|
|
results[i]["judge_correct"] = is_correct
|
|
return {
|
|
"llm_judge_accuracy": correct / n if n else 0.0,
|
|
"llm_judge_correct": correct,
|
|
"llm_judge_total": n,
|
|
}
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument("--model", default="Qwen/Qwen3-VL-4B-Instruct")
|
|
p.add_argument("--adapter", default=None)
|
|
p.add_argument(
|
|
"--test-json",
|
|
required=True,
|
|
help="Path to <dataset_dir>/test.json from prepare_sft_data_multiimage.py",
|
|
)
|
|
p.add_argument("--n-examples", type=int, default=500)
|
|
p.add_argument("--max-new-tokens", type=int, default=128)
|
|
p.add_argument("--thinking", action="store_true")
|
|
p.add_argument("--device", default="cuda:0")
|
|
p.add_argument("--judge", action="store_true", default=True)
|
|
p.add_argument("--no-judge", dest="judge", action="store_false")
|
|
p.add_argument("--judge-model", default="gpt-4.1-2025-04-14")
|
|
p.add_argument("--judge-concurrency", type=int, default=16)
|
|
p.add_argument("--tag", required=True)
|
|
p.add_argument(
|
|
"--output-dir",
|
|
default="/scratch/users/zwcolin/cxr_embeds/cxr_embedding/sft/eval_out",
|
|
)
|
|
args = p.parse_args()
|
|
|
|
if args.judge and not os.environ.get("OPENAI_API_KEY"):
|
|
print("ERROR: --judge set but OPENAI_API_KEY not in env.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(f"Loading base model: {args.model}")
|
|
model = Qwen3VLForConditionalGeneration.from_pretrained(
|
|
args.model,
|
|
torch_dtype=torch.bfloat16,
|
|
device_map=args.device,
|
|
)
|
|
if args.adapter:
|
|
from peft import PeftModel
|
|
|
|
print(f"Loading LoRA adapter: {args.adapter}")
|
|
model = PeftModel.from_pretrained(model, args.adapter)
|
|
model = model.merge_and_unload()
|
|
model.eval()
|
|
|
|
processor = AutoProcessor.from_pretrained(args.model)
|
|
|
|
with open(args.test_json) as f:
|
|
examples = json.load(f)
|
|
if args.n_examples > 0:
|
|
examples = examples[: args.n_examples]
|
|
print(f"Loaded {len(examples)} multi-image examples")
|
|
|
|
if args.thinking and args.max_new_tokens < 512:
|
|
args.max_new_tokens = 512
|
|
|
|
results = run_inference(
|
|
model,
|
|
processor,
|
|
examples,
|
|
args.device,
|
|
desc=f"eval[{args.tag}]",
|
|
max_new_tokens=args.max_new_tokens,
|
|
enable_thinking=args.thinking,
|
|
)
|
|
|
|
metrics = compute_em_char(results)
|
|
print(f"\n=== {args.tag} ===")
|
|
print(f" scored: {metrics['scored']} / {len(results)}")
|
|
print(f" exact_match: {metrics['exact_match']:.4f}")
|
|
print(f" char_accuracy: {metrics['char_accuracy']:.4f}")
|
|
|
|
if args.judge:
|
|
judge_metrics = grade_with_gpt(
|
|
results, args.judge_model, args.judge_concurrency
|
|
)
|
|
metrics.update(judge_metrics)
|
|
print(
|
|
f" llm_judge: {metrics['llm_judge_accuracy']:.4f} "
|
|
f"({metrics['llm_judge_correct']}/{metrics['llm_judge_total']})"
|
|
)
|
|
|
|
Path(args.output_dir).mkdir(parents=True, exist_ok=True)
|
|
out = {
|
|
"tag": args.tag,
|
|
"model": args.model,
|
|
"adapter": args.adapter,
|
|
"test_json": args.test_json,
|
|
"n_examples": len(results),
|
|
"judge_model": args.judge_model if args.judge else None,
|
|
"metrics": metrics,
|
|
"results": results,
|
|
}
|
|
fname = f"eval_{args.tag}_multiimage_n{len(results)}.json"
|
|
fpath = os.path.join(args.output_dir, fname)
|
|
with open(fpath, "w") as f:
|
|
json.dump(out, f, ensure_ascii=False, indent=2)
|
|
print(f"\nSaved: {fpath}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|