File size: 13,864 Bytes
45383d0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 | """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" # bump when VERIFY_PROMPT changes; keys cached per version
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")
# MUST match the overnight run so the image cache is reused
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")
# RAW captions (no LaTeX stripping) for the judges
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)")
# retrieval plans, identical to overnight run (bge prefixed)
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 = [] # endorsed non-target figures, for manual audit
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]
# verified ranking: accepted first by confidence, then
# retrieval order
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
# audit dump: target missed but judge endorsed the top
# candidate (recorded once, at tau=0.5 and full depth)
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()
|