foto / validation_and_analysis /benchmark_grid.py
Hurum Maksora Tohfa
validation tests
45383d0 unverified
Raw
History Blame Contribute Delete
15.5 kB
"""FOTO vs pathfinder vs Semantic Scholar over 5 query registers x 4 query forms (Table 4 data)."""
import os
os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
os.environ.setdefault("OMP_NUM_THREADS", "8")
import argparse, json, random, sys, time
from pathlib import Path
import numpy as np
import pandas as pd
import requests
from sentence_transformers import SentenceTransformer
from index_loader import load_caption_index
STYLES = ["terse", "casual", "vague", "detailed", "notation"]
HAIKU = "claude-haiku-4-5-20251001"
ANTHROPIC_URL = "https://api.anthropic.com/v1/messages"
S2_URL = "https://api.semanticscholar.org/graph/v1/paper/search"
OPENAI_EMB_URL = "https://api.openai.com/v1/embeddings"
PAPER_K = 50 # paper retrieval depth for baselines and paper_rank cap
FIG_K = 200 # caption retrieval depth for ours
# ----------------------------------------------------------------- LLM
def anthropic_call(prompt, max_tokens=2000, retries=4):
key = os.environ["ANTHROPIC_API_KEY"]
body = {"model": HAIKU, "max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}]}
headers = {"x-api-key": key, "anthropic-version": "2023-06-01",
"content-type": "application/json"}
for attempt in range(retries):
r = requests.post(ANTHROPIC_URL, headers=headers, json=body,
timeout=120)
if r.status_code == 200:
return "".join(b.get("text", "") for b in r.json()["content"])
if r.status_code in (429, 500, 529):
time.sleep(2 ** attempt * 2)
continue
raise RuntimeError(f"anthropic {r.status_code}: {r.text[:300]}")
raise RuntimeError("anthropic: retries exhausted")
def parse_json_block(text):
text = text.strip()
if text.startswith("```"):
text = text.split("```")[1]
if text.startswith("json"):
text = text[4:]
return json.loads(text.strip())
QUERY_PROMPT = """You are generating benchmark search queries for a figure \
retrieval system. Below is the title of an astronomy paper and the caption \
of one figure from it. Write five queries a researcher might type when \
looking for this exact figure, WITHOUT having the caption in front of them:
1. "terse": short expert phrasing, 4-10 words, standard jargon.
2. "casual": conversational, as if asking a colleague, one sentence.
3. "vague": the researcher half-remembers the figure. Keep it faithful to \
the figure's actual content but drop specifics (no survey names, no exact \
quantities); 5-15 words.
4. "detailed": two sentences describing axes, curves, and what is compared.
5. "notation": uses symbols/notation an expert would use (e.g. sigma_8, \
z~2, P(k)).
Do not copy phrases longer than 4 words from the caption. Respond with ONLY \
a JSON object: {{"terse": "...", "casual": "...", "vague": "...", \
"detailed": "...", "notation": "..."}}
Title: {title}
Caption: {caption}"""
VARIANTS_PROMPT = """You produce two variants of search queries used to \
retrieve one SPECIFIC scientific figure by matching its caption text.
For each query below produce:
"expanded": an expanded version under strict rules:
- Add only concrete, discriminative terms the target caption would \
plausibly contain, inferred strictly from what the query already says: \
standard synonyms, the conventional name of the plot type or statistic, \
standard notation for quantities the query names.
- Do NOT add survey names, instruments, wavelengths, redshifts, numeric \
values, or subfield context unless the query itself mentions or directly \
implies them.
- Do NOT generalize: the expansion must not match a broader class of \
figures than the original query does.
- Keep the original wording inside the expansion. Max 40 words.
"keywords": 3-8 keyword terms extracted from the query, space-separated, \
no stopwords, no sentence structure, no terms not present in or directly \
implied by the query.
Respond with ONLY a JSON array of {n} objects: \
[{{"i": 0, "expanded": "...", "keywords": "..."}}, ...] with "i" echoing \
the input index.
Queries:
{queries}"""
def generate_queries(title, caption):
out = parse_json_block(anthropic_call(
QUERY_PROMPT.format(title=title, caption=caption[:1500])))
return {s: str(out[s]) for s in STYLES}
def generate_variants(queries):
numbered = "\n".join(f"{i}. {q}" for i, q in enumerate(queries))
out = parse_json_block(anthropic_call(
VARIANTS_PROMPT.format(n=len(queries), queries=numbered)))
exp, kw = list(queries), list(queries)
for item in out:
i = int(item["i"])
if item.get("expanded"):
exp[i] = str(item["expanded"])
if item.get("keywords"):
kw[i] = str(item["keywords"])
return exp, kw
# ----------------------------------------------------------- external APIs
class S2Cache:
def __init__(self, path):
self.path = Path(path)
self.d = {}
if self.path.exists():
for line in self.path.open():
rec = json.loads(line)
self.d[rec["q"]] = rec["ids"]
self.f = self.path.open("a")
def get(self, q):
return self.d.get(q)
def put(self, q, ids):
self.d[q] = ids
self.f.write(json.dumps({"q": q, "ids": ids}) + "\n")
self.f.flush()
def s2_top(query, api_key, cache, limit=PAPER_K, throttle=1.5, retries=6):
"""Returns None iff the API never gave a valid response.
An empty list is a valid 'found nothing' outcome."""
hit = cache.get(query)
if hit is not None:
return hit
params = {"query": query, "fields": "externalIds", "limit": limit}
headers = {"x-api-key": api_key}
for attempt in range(retries):
r = requests.get(S2_URL, params=params, headers=headers, timeout=60)
time.sleep(throttle)
if r.status_code == 200:
ids = [(p.get("externalIds") or {}).get("ArXiv")
for p in r.json().get("data", []) or []]
ids = [i for i in ids if i]
cache.put(query, ids)
return ids
if r.status_code == 429:
time.sleep(10 * (attempt + 1))
continue
return None
return None
def openai_embed(texts, retries=4):
key = os.environ["OPENAI_API_KEY"]
headers = {"Authorization": f"Bearer {key}",
"content-type": "application/json"}
body = {"model": "text-embedding-3-small", "input": texts}
for attempt in range(retries):
r = requests.post(OPENAI_EMB_URL, headers=headers, json=body,
timeout=120)
if r.status_code == 200:
data = sorted(r.json()["data"], key=lambda d: d["index"])
v = np.array([d["embedding"] for d in data], dtype=np.float32)
return v / np.linalg.norm(v, axis=1, keepdims=True)
if r.status_code in (429, 500):
time.sleep(2 ** attempt * 2)
continue
raise RuntimeError(f"openai {r.status_code}: {r.text[:300]}")
raise RuntimeError("openai: retries exhausted")
# ------------------------------------------------------------------ main
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--index-dir", required=True)
ap.add_argument("--captions-parquet", default="astro_captions.parquet")
ap.add_argument("--pathfinder-emb", default=None)
ap.add_argument("--pathfinder-meta", default=None)
ap.add_argument("--n-figures", type=int, default=500)
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--out", default="results_grid")
ap.add_argument("--s2-forms", default="orig,expanded,keywords")
ap.add_argument("--s2-throttle", type=float, default=1.5)
args = ap.parse_args()
loaded, meta = load_caption_index(args.index_dir, args.captions_parquet)
if not meta.index.equals(pd.RangeIndex(len(meta))):
meta = meta.reset_index(drop=True)
print(f"index: {len(meta)} captions, {meta.arxiv_id.nunique()} papers")
do_pf = args.pathfinder_emb and args.pathfinder_meta
if do_pf:
pf_emb = np.load(args.pathfinder_emb)
pf_meta = pd.read_parquet(args.pathfinder_meta)
assert len(pf_meta) == len(pf_emb), "pathfinder corpus misaligned"
pf_id_set = set(pf_meta.arxiv_id.astype(str))
else:
print("WARNING: no pathfinder corpus given, skipping pathfinder")
s2_key = os.environ.get("SEMANTIC_SCHOLAR_API_KEY")
s2_forms = [f.strip() for f in args.s2_forms.split(",") if f.strip()]
if not s2_key:
print("WARNING: SEMANTIC_SCHOLAR_API_KEY unset, skipping s2")
s2_cache = S2Cache(f"{args.out}_s2_cache.jsonl") if s2_key else None
bge = SentenceTransformer("BAAI/bge-base-en-v1.5")
rng = random.Random(args.seed)
ok = np.flatnonzero(
meta.caption.str.len().ge(30).fillna(False).to_numpy()).tolist()
n_fig = min(args.n_figures, len(ok))
if n_fig < args.n_figures:
print(f"WARNING: only {len(ok)} usable captions, sampling {n_fig}")
sample = rng.sample(ok, n_fig)
out_jsonl = Path(f"{args.out}.jsonl")
done = set()
if out_jsonl.exists():
for line in out_jsonl.open():
rec = json.loads(line)
done.add((rec["arxiv_id"], rec["fig_index"]))
print(f"resuming: {len(done)} figures already done")
by_paper = meta.groupby("arxiv_id").indices
arxiv_ids = meta.arxiv_id.to_numpy()
# --------------------------------------------------------- scoring
def rank_of(seq, target):
for i, x in enumerate(seq):
if x == target:
return i + 1
return None
def fig_rank_in(ranked_rows, target_row):
ranked_rows = np.asarray(ranked_rows)
pos = np.where(ranked_rows == target_row)[0]
return int(pos[0]) + 1 if len(pos) else None
def distinct_paper_order(ranked_rows, cap=PAPER_K):
seen, order = set(), []
for pid in arxiv_ids[np.asarray(ranked_rows, dtype=int)]:
if pid not in seen:
seen.add(pid)
order.append(pid)
if len(order) >= cap:
break
return order
def ours_cell(qvec, target_row, target_paper):
_, ids = loaded.search(qvec, FIG_K)
top = ids[0]
return {"fig_rank": fig_rank_in(top, target_row),
"paper_rank": rank_of(distinct_paper_order(top),
target_paper)}
def ours_fused_cell(v_orig, v_exp, target_row, target_paper, k=FIG_K):
_, ids_a = loaded.search(v_orig, k)
_, ids_b = loaded.search(v_exp, k)
union = np.unique(np.concatenate([ids_a[0], ids_b[0]]))
vecs = loaded.vectors(union)
fused = np.maximum(vecs @ v_orig, vecs @ v_exp)
top = union[np.argsort(-fused)][:k]
return {"fig_rank": fig_rank_in(top, target_row),
"paper_rank": rank_of(distinct_paper_order(top),
target_paper)}
def rank_pool(query_vec, rows):
if len(rows) == 0:
return []
rows = np.asarray(rows)
sims = loaded.vectors(rows) @ query_vec
return list(rows[np.argsort(-sims)])
def pooled_cell(paper_order, rank_vec, target_row, target_paper):
pool = [r for pid in paper_order for r in by_paper.get(pid, [])]
ranked = rank_pool(rank_vec, pool)
return {"fig_rank": fig_rank_in(ranked, target_row),
"paper_rank": rank_of(paper_order, target_paper),
"pool_size": len(pool)}
def pf_paper_order(qvec_openai, k=PAPER_K):
sims = pf_emb @ qvec_openai
return pf_meta.iloc[np.argsort(-sims)[:k]].arxiv_id.tolist()
def pf_fused_paper_order(v_orig, v_exp, k=PAPER_K):
fused = np.maximum(pf_emb @ v_orig, pf_emb @ v_exp)
return pf_meta.iloc[np.argsort(-fused)[:k]].arxiv_id.tolist()
# ------------------------------------------------------------- loop
with out_jsonl.open("a") as fout:
n_done = 0
for row_i in sample:
row = meta.iloc[row_i]
key = (row.arxiv_id, int(row.fig_index))
if key in done:
continue
try:
qs = generate_queries(row.title, row.caption)
q_orig = [qs[s] for s in STYLES]
q_exp, q_kw = generate_variants(q_orig)
except Exception as e:
print(f"skip {key}: query gen failed ({e})")
continue
forms_text = {"orig": q_orig, "expanded": q_exp,
"keywords": q_kw}
forms_bge = {f: bge.encode(t, normalize_embeddings=True)
for f, t in forms_text.items()}
if do_pf:
try:
flat = openai_embed(q_orig + q_exp + q_kw)
except Exception as e:
print(f"skip {key}: openai embed failed ({e})")
continue
ns = len(STYLES)
forms_pf = {"orig": flat[:ns], "expanded": flat[ns:2 * ns],
"keywords": flat[2 * ns:]}
rec = {"arxiv_id": row.arxiv_id, "fig_index": int(row.fig_index),
"queries": qs,
"expanded": dict(zip(STYLES, q_exp)),
"keywords": dict(zip(STYLES, q_kw)),
"target_in_pf": bool(str(row.arxiv_id) in pf_id_set)
if do_pf else None,
"cells": {}}
for si, style in enumerate(STYLES):
cells = {}
for form in ("orig", "expanded", "keywords"):
v = forms_bge[form][si]
cells[f"ours/{form}"] = ours_cell(v, row_i, row.arxiv_id)
if do_pf:
order = pf_paper_order(forms_pf[form][si])
cells[f"pathfinder/{form}"] = pooled_cell(
order, v, row_i, row.arxiv_id)
if s2_key and form in s2_forms:
ids = s2_top(forms_text[form][si], s2_key, s2_cache,
throttle=args.s2_throttle)
if ids is None:
pass # API failure: cell absent
else:
c = pooled_cell(ids, v, row_i, row.arxiv_id)
c["n_arxiv_ids"] = len(ids)
cells[f"s2/{form}"] = c
cells["ours/fused"] = ours_fused_cell(
forms_bge["orig"][si], forms_bge["expanded"][si],
row_i, row.arxiv_id)
if do_pf:
order = pf_fused_paper_order(
forms_pf["orig"][si], forms_pf["expanded"][si])
cells["pathfinder/fused"] = pooled_cell(
order, forms_bge["orig"][si], row_i, row.arxiv_id)
rec["cells"][style] = cells
fout.write(json.dumps(rec) + "\n")
fout.flush()
n_done += 1
if n_done % 10 == 0:
print(f"{n_done} new figures done "
f"({n_done + len(done)}/{len(sample)} total)")
n_total = sum(1 for _ in out_jsonl.open())
print(f"\ndone: {n_total} figures in {out_jsonl}")
print(f"summarize with: python summarize.py {out_jsonl}")
if __name__ == "__main__":
sys.exit(main())