| """Verification benchmark: vision (caption+figure) vs caption-only text, Sonnet + DeepSeek judges.""" |
|
|
| import os |
| os.environ.setdefault("OPENBLAS_NUM_THREADS", "1") |
| os.environ.setdefault("OMP_NUM_THREADS", "8") |
|
|
| import argparse, base64, io, json, random, re, time |
| from collections import defaultdict |
| from pathlib import Path |
|
|
| import numpy as np |
| import pandas as pd |
|
|
| from index_loader import load_caption_index |
| from embedders import LocalEmbedder |
|
|
| SONNET = "claude-sonnet-4-6" |
| TAUS = [0.0, 0.5, 0.8] |
| PROMPT_VER = "p2" |
|
|
| VERIFY_PROMPT = """A researcher is searching for a published scientific \ |
| figure with this query: |
| |
| "{query}" |
| |
| {materials_line} |
| |
| Caption: {caption} |
| |
| Judge the candidate against what the query actually specifies. If the query \ |
| is broad or vague, any figure genuinely showing what it describes counts as \ |
| a match; do not demand details the query never mentions. If the query \ |
| specifies particulars (quantities, axes, plot type, what is compared), the \ |
| figure must show them. Science match is the dominant factor: a figure on \ |
| the wrong scientific topic should never score above 0.4. |
| |
| Respond JSON only: |
| {{"match": true|false, "confidence": 0.0-1.0, \ |
| "what_is_plotted": "<one sentence>", "reason": "<one sentence>"}}""" |
|
|
| MATERIALS_VISION = "Below is the candidate figure image together with its caption." |
| MATERIALS_TEXT = "Only the figure's caption is available (no image)." |
|
|
|
|
| from fetch_figures import fetch_images, safe_name |
|
|
|
|
| def image_b64(path, max_dim=1024): |
| from PIL import Image |
| im = Image.open(path).convert("RGB") |
| im.thumbnail((max_dim, max_dim)) |
| buf = io.BytesIO() |
| im.save(buf, format="JPEG", quality=80) |
| return base64.standard_b64encode(buf.getvalue()).decode() |
|
|
|
|
| def parse_verdict(text): |
| obj = json.loads(text[text.index("{"):text.rindex("}") + 1]) |
| return {"match": bool(obj.get("match")), |
| "confidence": float(obj.get("confidence", 0.0)), |
| "reason": str(obj.get("reason", ""))[:200]} |
|
|
|
|
| class Cache: |
| def __init__(self, path): |
| self.path = Path(path) |
| self.d = {} |
| if self.path.exists(): |
| for line in self.path.open(): |
| r = json.loads(line) |
| self.d[r["key"]] = r["verdict"] |
| self.f = self.path.open("a") |
|
|
| def get(self, key): |
| return self.d.get(key) |
|
|
| def put(self, key, verdict): |
| self.d[key] = verdict |
| self.f.write(json.dumps({"key": key, "verdict": verdict}) + "\n") |
| self.f.flush() |
|
|
|
|
| class AnthropicJudge: |
| def __init__(self, model): |
| from anthropic import Anthropic |
| self.client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) |
| self.model = model |
|
|
| def judge(self, prompt, img_path=None, retries=5): |
| content = [] |
| if img_path is not None: |
| content.append({"type": "image", "source": { |
| "type": "base64", "media_type": "image/jpeg", |
| "data": image_b64(img_path)}}) |
| content.append({"type": "text", "text": prompt}) |
| for attempt in range(retries): |
| try: |
| resp = self.client.messages.create( |
| model=self.model, max_tokens=250, |
| messages=[{"role": "user", "content": content}]) |
| return parse_verdict(resp.content[0].text) |
| except Exception as e: |
| if attempt == retries - 1: |
| print(f" judge failed: {str(e)[:100]}") |
| return None |
| time.sleep(5 * (attempt + 1)) |
|
|
|
|
| class DeepSeekJudge: |
| """Text-only. OpenAI-compatible endpoint.""" |
|
|
| def __init__(self, model): |
| from openai import OpenAI |
| self.client = OpenAI(api_key=os.environ["DEEPSEEK_API_KEY"], |
| base_url="https://api.deepseek.com") |
| self.model = model |
|
|
| def judge(self, prompt, img_path=None, retries=5): |
| for attempt in range(retries): |
| try: |
| resp = self.client.chat.completions.create( |
| model=self.model, max_tokens=250, |
| messages=[{"role": "user", "content": prompt}]) |
| return parse_verdict(resp.choices[0].message.content) |
| except Exception as e: |
| if attempt == retries - 1: |
| print(f" deepseek failed: {str(e)[:100]}") |
| return None |
| time.sleep(5 * (attempt + 1)) |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--results", default="results_grid.jsonl") |
| ap.add_argument("--index-bge", |
| default="indexes/title_caption_bge-base-en-v1.5") |
| ap.add_argument("--captions-parquet", default="astro_captions.parquet") |
| ap.add_argument("--image-dir", default="vision_images") |
| ap.add_argument("--out", default="verify2") |
| |
| ap.add_argument("--registers", default="detailed,vague") |
| ap.add_argument("--n-per-register", type=int, default=50) |
| ap.add_argument("--verify-k", type=int, default=50) |
| ap.add_argument("--vision-seed", type=int, default=123) |
| ap.add_argument("--deepseek-model", default="deepseek-chat", |
| help="check this matches your DeepSeek account's model id") |
| args = ap.parse_args() |
|
|
| records = [json.loads(l) for l in open(args.results)] |
| registers = [r.strip() for r in args.registers.split(",")] |
| rng = random.Random(args.vision_seed) |
| sample = rng.sample(records, min(args.n_per_register * 2, len(records))) |
| tasks = [(reg, rec) for reg in registers |
| for rec in sample[:args.n_per_register]] |
| print(f"{len(tasks)} queries " |
| f"({args.n_per_register} figures x {registers})") |
|
|
| loaded, meta = load_caption_index(args.index_bge, args.captions_parquet) |
| if not meta.index.equals(pd.RangeIndex(len(meta))): |
| meta = meta.reset_index(drop=True) |
| bge = LocalEmbedder("BAAI/bge-base-en-v1.5") |
|
|
| |
| import duckdb |
| captions = {} |
| for aid, caps in duckdb.connect().execute( |
| f"SELECT arxiv_id, captions FROM " |
| f"read_parquet('{args.captions_parquet}')").fetchall(): |
| for i, cap in enumerate(caps or []): |
| captions[(aid, i + 1)] = cap or "" |
| max_cap = max((len(c) for c in captions.values() if c), default=0) |
| if max_cap <= 200: |
| raise SystemExit("ABORT: captions look truncated; check the slice.") |
| print(f"raw captions loaded (max {max_cap} chars)") |
|
|
| |
| plans = [] |
| for reg, rec in tasks: |
| q = rec["queries"][reg] |
| v = bge.embed([q], is_query=True)[0] |
| _, ids = loaded.search(v, args.verify_k) |
| cands = [(meta.iloc[int(i)].arxiv_id, int(meta.iloc[int(i)].fig_index)) |
| for i in ids[0]] |
| target = (rec["arxiv_id"], int(rec["fig_index"])) |
| plans.append({"register": reg, "query": q, "target": target, |
| "cands": cands, |
| "ret_rank": cands.index(target) + 1 |
| if target in cands else None}) |
|
|
| image_dir = Path(args.image_dir) |
| fetch_images({c for p in plans for c in p["cands"]}, image_dir) |
| have_img = {c for p in plans for c in p["cands"] |
| if (image_dir / safe_name(*c)).exists()} |
| all_c = {c for p in plans for c in p["cands"]} |
| print(f"{len(have_img)}/{len(all_c)} candidate images on disk " |
| f"(missing ones are skipped in the vision condition)") |
|
|
| conditions = {"vision_sonnet": (AnthropicJudge(SONNET), True), |
| "text_sonnet": (AnthropicJudge(SONNET), False)} |
| if os.environ.get("DEEPSEEK_API_KEY"): |
| conditions["text_deepseek"] = ( |
| DeepSeekJudge(args.deepseek_model), False) |
| else: |
| print("WARNING: DEEPSEEK_API_KEY unset, skipping text_deepseek") |
|
|
| cache = Cache(f"{args.out}_cache.jsonl") |
| depths = sorted({d for d in [20, args.verify_k] if d <= args.verify_k}) |
| results = [] |
| alternatives = [] |
|
|
| for n, p in enumerate(plans): |
| for cond, (judge, uses_image) in conditions.items(): |
| verdicts = {} |
| for c in p["cands"]: |
| key = f"{PROMPT_VER}:{cond}:{p['register']}:" \ |
| f"{p['target'][0]}:{p['target'][1]}:{c[0]}:{c[1]}" |
| v = cache.get(key) |
| if v is None: |
| img = image_dir / safe_name(*c) if uses_image else None |
| if uses_image and not img.exists(): |
| continue |
| prompt = VERIFY_PROMPT.format( |
| query=p["query"], |
| materials_line=MATERIALS_VISION if uses_image |
| else MATERIALS_TEXT, |
| caption=captions.get(c, "")[:1500]) |
| v = judge.judge(prompt, img_path=img) |
| if v is not None: |
| cache.put(key, v) |
| if v is not None: |
| verdicts[c] = v |
|
|
| for tau in TAUS: |
| def accept(c): |
| v = verdicts.get(c) |
| return bool(v and v["match"] and v["confidence"] >= tau) |
| for d in depths: |
| cands_d = p["cands"][:d] |
| |
| |
| order = sorted( |
| range(len(cands_d)), |
| key=lambda i: (0 if accept(cands_d[i]) else 1, |
| -(verdicts.get(cands_d[i]) or |
| {"confidence": 0})["confidence"], i)) |
| vrank = None |
| for r, i in enumerate(order, 1): |
| if cands_d[i] == p["target"]: |
| vrank = r |
| break |
| top = cands_d[order[0]] if order else None |
| rr = p["ret_rank"] if (p["ret_rank"] is not None |
| and p["ret_rank"] <= d) else None |
| |
| |
| if (tau == 0.5 and d == depths[-1] and rr is None |
| and top is not None and accept(top)): |
| v = verdicts.get(top) or {} |
| alternatives.append({ |
| "register": p["register"], "cond": cond, |
| "query": p["query"], |
| "cand_arxiv": top[0], "cand_fig": top[1], |
| "confidence": v.get("confidence"), |
| "reason": v.get("reason", ""), |
| "caption_snippet": |
| captions.get(top, "")[:300], |
| "image": str(image_dir / safe_name(*top)), |
| }) |
| results.append({ |
| "register": p["register"], "cond": cond, |
| "tau": tau, "depth": d, |
| "target_retrieved": rr is not None, |
| "ret_rank": rr, "ver_rank": vrank, |
| "top_accepted": accept(top) if top else False, |
| "any_accepted": any(accept(c) for c in cands_d), |
| "real_accepted": accept(p["target"]) |
| if p["target"] in cands_d else None, |
| }) |
| if (n + 1) % 10 == 0: |
| print(f"{n + 1}/{len(plans)} queries judged") |
|
|
| df = pd.DataFrame(results) |
| df.to_csv(f"{args.out}_raw.csv", index=False) |
| if alternatives: |
| pd.DataFrame(alternatives).to_csv( |
| f"{args.out}_alternatives.csv", index=False) |
|
|
| print("\n--- Verification summary ---") |
| rows = [] |
| for (reg, cond, tau, d), g in df.groupby( |
| ["register", "cond", "tau", "depth"]): |
| got = g[g.target_retrieved] |
| miss = g[~g.target_retrieved] |
| real = got[got.real_accepted.notna()] |
| rows.append({ |
| "register": reg, "cond": cond, "tau": tau, "depth": d, |
| "n": len(g), |
| "tgt_in_topd": round(g.target_retrieved.mean(), 3), |
| "ret_R@1": round((got.ret_rank == 1).mean(), 3) |
| if len(got) else None, |
| "ver_R@1": round((got.ver_rank == 1).mean(), 3) |
| if len(got) else None, |
| "ver_R@5": round((got.ver_rank <= 5).mean(), 3) |
| if len(got) else None, |
| "real_accept%": round(real.real_accepted.mean(), 3) |
| if len(real) else None, |
| "alt_accept": round(miss.top_accepted.mean(), 3) |
| if len(miss) else None, |
| "abstain": round((~miss.any_accepted).mean(), 3) |
| if len(miss) else None, |
| }) |
| summary = pd.DataFrame(rows).sort_values( |
| ["register", "cond", "tau", "depth"]) |
| summary.to_csv(f"{args.out}_summary.csv", index=False) |
| with pd.option_context("display.max_rows", None, "display.width", 200): |
| print(summary.to_string(index=False)) |
| print(f"\nwrote {args.out}_raw.csv, {args.out}_summary.csv") |
| print("real_accept% = judge accepts the true figure when it sees it. " |
| "alt_accept = when the designated target was NOT retrieved, the " |
| "judge endorsed some other figure: for detailed queries this is " |
| "close to a false-positive rate; for vague queries the endorsed " |
| "figure may legitimately satisfy the query -- audit the dump in " |
| f"{args.out}_alternatives.csv to split those cases.") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|