Files
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

347 lines
12 KiB
Python

#!/usr/bin/env python3
"""Eval Qwen3-VL-4B (base or SFT-LoRA) on test set at a given compression level.
Reports three metrics:
- exact_match : predicted.lower() == golden.lower()
- char_accuracy : character-level fuzzy match (weak, kept for backcompat)
- llm_judge_accuracy : GPT-4.1 grade using the SimpleQA-style grader template
(A=correct / B=incorrect / C=not_attempted)
Environment:
OPENAI_API_KEY, OPENAI_BASE_URL must be set. Source .env at repo root.
"""
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
# Reused from train_contrastors.py — SimpleQA-style grader, returns A/B/C.
_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 _resolve_image_path(ex: dict, images_root: str) -> str:
"""chunk_path is relative to the dataset root (e.g. images/shard_000/...).
images_root is the directory that contains the `images/` subtree (compressed or original)."""
rel = ex["chunk_path"]
if os.path.isabs(rel):
return rel
return os.path.join(images_root, rel)
def run_inference(
model,
processor,
examples,
images_root: str,
device: str,
desc: str,
max_new_tokens: int = 128,
enable_thinking: bool = False,
):
"""Run VQA inference on a list of examples; returns list of (golden, predicted) pairs."""
results = []
for ex in tqdm(examples, desc=desc):
img_path = _resolve_image_path(ex, images_root)
if not os.path.exists(img_path):
results.append(
{
"query": ex["query"],
"golden": ex["answer"].strip(),
"predicted": "",
"image_missing": True,
}
)
continue
messages = [
{
"role": "user",
"content": [
{"type": "image", "image": f"file://{img_path}"},
{"type": "text", "text": ex["query"]},
],
}
]
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": ex["query"],
"golden": ex["answer"].strip(),
"predicted": pred,
"chunk_path": ex["chunk_path"],
}
)
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):
"""Grade predictions with GPT-4.1. Returns list of (bool correct, raw grade)."""
from openai import OpenAI
client = OpenAI() # uses OPENAI_API_KEY + OPENAI_BASE_URL from env
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()
# A = CORRECT
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,
help="Path to LoRA adapter checkpoint (optional). If unset, use base model.",
)
p.add_argument(
"--dataset-dir",
default="/scratch/users/zwcolin/cxr_embeds/external_data/screenshot-training-natural-filtered-v2",
help="Directory containing <split>_hn_with_answer.jsonl",
)
p.add_argument("--split", default="test", choices=["train", "eval", "test"])
p.add_argument(
"--images-root",
default=None,
help="Directory that contains the images/ subtree. "
"Default: --dataset-dir (uncompressed). For compressed eval, pass "
"/scratch/users/zwcolin/cxr_embeds/sft_data/compressed_Nx",
)
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",
help="Enable <think></think> mode. Auto-bumps max-new-tokens to 512 if still default.",
)
p.add_argument("--device", default="cuda:0")
p.add_argument(
"--judge",
action="store_true",
default=True,
help="Run GPT-4.1 judge (default on).",
)
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,
help="Run label (e.g. 'base_0x', 'sft_3x'). Used in output filename.",
)
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. "
"Run `source .env` (repo root) or pass --no-judge.",
file=sys.stderr,
)
sys.exit(1)
images_root = args.images_root or args.dataset_dir
# Load model (+ optional adapter)
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)
# Load examples
jsonl = os.path.join(args.dataset_dir, f"{args.split}_hn_with_answer.jsonl")
with open(jsonl) as f:
examples = [json.loads(line) for line in f]
if args.n_examples > 0:
examples = examples[: args.n_examples]
print(f"Loaded {len(examples)} examples from {jsonl}")
print(f"Images root: {images_root}")
# Auto-bump max-new-tokens for thinking mode
if args.thinking and args.max_new_tokens < 512:
args.max_new_tokens = 512
print("Thinking enabled: bumped max_new_tokens to 512")
# Inference
results = run_inference(
model,
processor,
examples,
images_root,
args.device,
desc=f"eval[{args.tag}]",
max_new_tokens=args.max_new_tokens,
enable_thinking=args.thinking,
)
# Compute metrics
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']})"
)
# Save
Path(args.output_dir).mkdir(parents=True, exist_ok=True)
out = {
"tag": args.tag,
"model": args.model,
"adapter": args.adapter,
"dataset_dir": args.dataset_dir,
"images_root": images_root,
"split": args.split,
"n_examples": len(results),
"judge_model": args.judge_model if args.judge else None,
"metrics": metrics,
"results": results,
}
fname = f"eval_{args.tag}_{args.split}_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()