| """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 |
| FIG_K = 200 |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| 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") |
|
|
|
|
| |
|
|
| 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() |
|
|
| |
|
|
| 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() |
|
|
| |
|
|
| 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 |
| 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()) |
|
|