Download eval_multi.py from htohfa/foto: direct link, hf CLI and curl.
- Browser
- Download file 14.8 kB
-
https://huggingface.co/spaces/htohfa/foto/resolve/main/eval_multi.py
- Command line
-
hf download hf://spaces/htohfa/foto/eval_multi.py
-
curl -L -o eval_multi.py https://huggingface.co/spaces/htohfa/foto/resolve/main/eval_multi.py
14.8 kB
| """Robustness eval: 5 query styles per figure instead of one paraphrase. | |
| For each sampled figure, one Haiku call produces five queries in different | |
| registers, from terse to vague to notation-flipped. Each style is evaluated | |
| separately, so the output shows how retrieval degrades as queries get more | |
| human: shorter, vaguer, differently notated. | |
| python eval_multi.py --indexes pilot_title_caption pilot_title_rewritten --n 300 | |
| Reuses helpers from eval_retrieval.py. Queries are cached in | |
| multi_eval_queries.jsonl, so reruns only redo the cheap ranking math. | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import random | |
| import re | |
| from pathlib import Path | |
| import duckdb | |
| import numpy as np | |
| from eval_retrieval import embed_queries, evaluate, load_index | |
| QUERY_MODEL = "claude-haiku-4-5-20251001" | |
| STYLES = ["terse", "casual", "vague", "detailed", "notation"] | |
| MULTI_PROMPT = """Here is a figure from an astrophysics paper: | |
| Title: {title} | |
| Caption: {caption} | |
| Five different researchers are trying to find this figure in a search tool. | |
| Write the query each would type. Do not reuse distinctive multi-word phrases | |
| from the caption. | |
| 1. "terse": 4-8 keywords, no sentence structure, the way people actually type into search boxes. | |
| 2. "casual": one short natural sentence. | |
| 3. "vague": the researcher only half-remembers it. Just the general topic and roughly what kind of plot it was. It's fine to be imprecise or slightly wrong. | |
| 4. "detailed": a precise, complete description of the science and what is plotted. | |
| 5. "notation": like casual, but write any symbols or jargon in a DIFFERENT convention than the caption uses (e.g. sigma_8 vs S8 vs "amplitude of matter fluctuations", spelled-out names vs acronyms). | |
| JSON only: | |
| {{"terse": "...", "casual": "...", "vague": "...", "detailed": "...", "notation": "..."}}""" | |
| def parse_json_obj(text: str) -> dict: | |
| text = re.sub(r"```(json)?", "", text) | |
| return json.loads(text[text.index("{"):text.rindex("}") + 1]) | |
| def generate_multi(samples: list[dict], cache_path: Path) -> dict: | |
| from anthropic import Anthropic | |
| client = None | |
| cache = {} | |
| if cache_path.exists(): | |
| for line in cache_path.open(): | |
| row = json.loads(line) | |
| cache[row["key"]] = row["queries"] | |
| with cache_path.open("a") as f: | |
| for i, s in enumerate(samples): | |
| key = f"{s['arxiv_id']}:{s['fig_idx']}" | |
| if key in cache: | |
| continue | |
| if client is None: | |
| client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) | |
| prompt = MULTI_PROMPT.format(title=s["title"], caption=s["caption"][:1200]) | |
| try: | |
| resp = client.messages.create( | |
| model=QUERY_MODEL, max_tokens=500, | |
| messages=[{"role": "user", "content": prompt}], | |
| ) | |
| queries = parse_json_obj(resp.content[0].text) | |
| if not all(st in queries and queries[st] for st in STYLES): | |
| raise ValueError("missing styles") | |
| except Exception as e: | |
| print(f" query gen failed for {key}: {e}") | |
| continue | |
| cache[key] = queries | |
| f.write(json.dumps({"key": key, "queries": queries}) + "\n") | |
| if (i + 1) % 50 == 0: | |
| print(f" {i+1}/{len(samples)} figures queried") | |
| return cache | |
| def generate_expansions(queries, style, n, cache_path): | |
| """LLM alternative phrasings, cached by (style, query).""" | |
| import os | |
| from anthropic import Anthropic | |
| from query_expansion import expand_query | |
| cache = {} | |
| if cache_path.exists(): | |
| for line in cache_path.open(): | |
| row = json.loads(line) | |
| cache[row["key"]] = row["variants"] | |
| client = None | |
| with cache_path.open("a") as f: | |
| for i, q in enumerate(queries): | |
| key = f"{style}:{q[:120]}" | |
| if key in cache: | |
| continue | |
| if client is None: | |
| client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) | |
| variants = expand_query(q, client, QUERY_MODEL, n=n) | |
| cache[key] = variants | |
| f.write(json.dumps({"key": key, "variants": variants}) + "\n") | |
| if (i + 1) % 50 == 0: | |
| print(f" expanded {i+1}/{len(queries)}") | |
| return [cache.get(f"{style}:{q[:120]}", []) for q in queries] | |
| def generate_author_suggestions(queries, style, cache_path): | |
| import os | |
| from anthropic import Anthropic | |
| from query_expansion import suggest_authors | |
| cache = {} | |
| if cache_path.exists(): | |
| for line in cache_path.open(): | |
| row = json.loads(line) | |
| cache[row["key"]] = row["authors"] | |
| client = None | |
| with cache_path.open("a") as f: | |
| for q in queries: | |
| key = f"{style}:{q[:120]}" | |
| if key in cache: | |
| continue | |
| if client is None: | |
| client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) | |
| names = suggest_authors(q, client, QUERY_MODEL) | |
| cache[key] = names | |
| f.write(json.dumps({"key": key, "authors": names}) + "\n") | |
| return [cache.get(f"{style}:{q[:120]}", []) for q in queries] | |
| def evaluate_retrieval(fi, queries, targets, k_list, variants_list=None, | |
| filter_lists=None, boost_lists=None, depth=200): | |
| """Rank of each target using the FigureIndex search path.""" | |
| ranks = [] | |
| paper_ranks = [] | |
| for qi, q in enumerate(queries): | |
| hits = fi.search_rows( | |
| q, k=depth, | |
| variants=variants_list[qi] if variants_list else None, | |
| filter_authors=filter_lists[qi] if filter_lists else None, | |
| boost_authors=boost_lists[qi] if boost_lists else None, | |
| depth=depth, | |
| ) | |
| tgt = targets[qi] | |
| rank = None | |
| p_rank = None | |
| for r, (aid, fidx) in enumerate(hits): | |
| if p_rank is None and aid == tgt[0]: | |
| p_rank = r | |
| if aid == tgt[0] and fidx == tgt[1]: | |
| rank = r | |
| break | |
| ranks.append(rank) | |
| paper_ranks.append(p_rank) | |
| return ranks, paper_ranks | |
| def evaluate_with_rerank(index, meta, info, queries, targets, reranker, | |
| doc_lookup, depth): | |
| Q = embed_queries(queries, info) | |
| _, ids = index.search(Q, depth) | |
| base_ranks = [] | |
| cand_lists = [] | |
| for qi, (tgt_id, tgt_fig) in enumerate(targets): | |
| cand = [int(i) for i in ids[qi] if i >= 0] | |
| cand_lists.append(cand) | |
| rank = None | |
| for r, idx in enumerate(cand): | |
| m = meta[idx] | |
| if m["arxiv_id"] == tgt_id and m["fig_idx"] == tgt_fig: | |
| rank = r | |
| break | |
| base_ranks.append(rank) | |
| if reranker is None: | |
| return base_ranks, None | |
| pairs = [] | |
| for qi, cand in enumerate(cand_lists): | |
| for idx in cand: | |
| m = meta[idx] | |
| doc = doc_lookup.get((m["arxiv_id"], m["fig_idx"]), m.get("caption", "")) | |
| pairs.append([queries[qi], doc]) | |
| scores = reranker.score(pairs) | |
| rr_ranks = [] | |
| pos = 0 | |
| for qi, cand in enumerate(cand_lists): | |
| s = scores[pos:pos + len(cand)] | |
| pos += len(cand) | |
| order = np.argsort(-s) | |
| tgt_id, tgt_fig = targets[qi] | |
| rank = None | |
| for r, oi in enumerate(order): | |
| m = meta[cand[oi]] | |
| if m["arxiv_id"] == tgt_id and m["fig_idx"] == tgt_fig: | |
| rank = r | |
| break | |
| rr_ranks.append(rank) | |
| return base_ranks, rr_ranks | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--slice", default="astro_captions.parquet") | |
| ap.add_argument("--index-dir", default="indexes") | |
| ap.add_argument("--indexes", nargs="+", | |
| default=["pilot_title_caption", "pilot_title_rewritten"]) | |
| ap.add_argument("--n", type=int, default=300) | |
| ap.add_argument("--seed", type=int, default=11) | |
| ap.add_argument("--expand", action="store_true", | |
| help="fuse the query with LLM-generated alternative phrasings") | |
| ap.add_argument("--expand-n", type=int, default=5) | |
| ap.add_argument("--authors", default="authors.parquet", | |
| help="author metadata parquet (from fetch_authors.py)") | |
| ap.add_argument("--author-filter", action="store_true", | |
| help="oracle author filter: restrict to papers sharing an " | |
| "author with the target, simulating a user who " | |
| "remembers one author name") | |
| ap.add_argument("--author-boost", action="store_true", | |
| help="soft boost using LLM-suggested authors (measured, not recommended)") | |
| ap.add_argument("--rerank", action="store_true", | |
| help="also rerank deep candidates with a local cross-encoder") | |
| ap.add_argument("--rerank-model", default="BAAI/bge-reranker-base") | |
| ap.add_argument("--rerank-depth", type=int, default=200) | |
| ap.add_argument("--reuse-queries", action="store_true", | |
| help="evaluate the figure set already in multi_eval_queries.jsonl " | |
| "(for scale comparisons against a different index)") | |
| args = ap.parse_args() | |
| base = Path(args.index_dir) | |
| first_index, first_meta, first_info = load_index(base, args.indexes[0]) | |
| con = duckdb.connect() | |
| papers = {r[0]: r for r in con.execute( | |
| f"SELECT arxiv_id, title, captions FROM read_parquet('{args.slice}')" | |
| ).fetchall()} | |
| if args.reuse_queries: | |
| picks = [] | |
| for line in Path("multi_eval_queries.jsonl").open(): | |
| arxiv_id, fig_idx = json.loads(line)["key"].rsplit(":", 1) | |
| picks.append((arxiv_id, int(fig_idx))) | |
| in_index = {(m["arxiv_id"], m["fig_idx"]) for m in first_meta} | |
| missing = [p for p in picks if p not in in_index] | |
| if missing: | |
| print(f" WARNING: {len(missing)} cached figures not in this index, skipping them") | |
| picks = [p for p in picks if p in in_index] | |
| else: | |
| rng = random.Random(args.seed) | |
| indexed = [(m["arxiv_id"], m["fig_idx"]) for m in first_meta if m["fig_idx"] >= 1] | |
| picks = rng.sample(indexed, min(args.n, len(indexed))) | |
| samples = [] | |
| for arxiv_id, fig_idx in picks: | |
| p = papers.get(arxiv_id) | |
| if not p or fig_idx > len(p[2]): | |
| continue | |
| samples.append({"arxiv_id": arxiv_id, "fig_idx": fig_idx, | |
| "title": p[1], "caption": p[2][fig_idx - 1]}) | |
| print(f"{len(samples)} eval figures, 5 query styles each") | |
| cache = generate_multi(samples, Path("multi_eval_queries.jsonl")) | |
| usable = [s for s in samples if f"{s['arxiv_id']}:{s['fig_idx']}" in cache] | |
| print(f"{len(usable)} figures with full query sets\n") | |
| reranker = None | |
| doc_lookup = {} | |
| if args.rerank: | |
| from embedders import LocalReranker | |
| from build_caption_index import clean_latex | |
| print(f"Loading reranker {args.rerank_model}...") | |
| reranker = LocalReranker(args.rerank_model) | |
| for arxiv_id, (aid, title, captions) in papers.items(): | |
| for fi, cap in enumerate(captions): | |
| doc_lookup[(arxiv_id, fi + 1)] = f"{title} | {clean_latex(cap)}" | |
| depth = args.rerank_depth | |
| use_search_path = args.expand or args.author_filter or args.author_boost | |
| fig_index = None | |
| author_lookup = {} | |
| if use_search_path: | |
| from retrieval import FigureIndex | |
| fig_index = FigureIndex(base / args.indexes[0], authors_path=args.authors) | |
| if not fig_index.authors_by_paper and (args.author_filter or args.author_boost): | |
| raise SystemExit(f"No author metadata found at {args.authors}. " | |
| f"Run fetch_authors.py first.") | |
| author_lookup = fig_index.authors_by_paper | |
| results = {} | |
| for name in args.indexes: | |
| index, meta, info = load_index(base, name) | |
| for style in STYLES: | |
| queries = [cache[f"{s['arxiv_id']}:{s['fig_idx']}"][style] for s in usable] | |
| targets = [(s["arxiv_id"], s["fig_idx"]) for s in usable] | |
| ranks, rr_ranks = evaluate_with_rerank(index, meta, info, queries, targets, | |
| reranker, doc_lookup, depth) | |
| n = len(ranks) | |
| rec = lambda rs, k: sum(1 for r in rs if r is not None and r < k) / n | |
| row = [rec(ranks, 1), rec(ranks, 5), rec(ranks, 20), | |
| rec(ranks, 50), rec(ranks, depth)] | |
| if rr_ranks is not None: | |
| row += [rec(rr_ranks, 1), rec(rr_ranks, 5), rec(rr_ranks, 20)] | |
| if use_search_path and name == args.indexes[0]: | |
| variants_list = None | |
| if args.expand: | |
| print(f" [{style}] expanding queries...") | |
| variants_list = generate_expansions( | |
| queries, style, args.expand_n, Path("expansion_cache.jsonl")) | |
| filter_lists = None | |
| if args.author_filter: | |
| filter_lists = [author_lookup.get(t[0], []) for t in targets] | |
| boost_lists = None | |
| if args.author_boost: | |
| print(f" [{style}] suggesting authors...") | |
| boost_lists = generate_author_suggestions( | |
| queries, style, Path("author_suggestion_cache.jsonl")) | |
| ex_ranks, _ = evaluate_retrieval( | |
| fig_index, queries, targets, None, | |
| variants_list=variants_list, filter_lists=filter_lists, | |
| boost_lists=boost_lists, depth=depth) | |
| row += [rec(ex_ranks, 1), rec(ex_ranks, 5), rec(ex_ranks, 20)] | |
| results[(name, style)] = row | |
| cols = ["R@1", "R@5", "R@20", "R@50", f"R@{depth}"] | |
| if args.rerank: | |
| cols += ["rr@1", "rr@5", "rr@20"] | |
| if use_search_path: | |
| tag = "ex" if args.expand else ("af" if args.author_filter else "ab") | |
| cols += [f"{tag}@1", f"{tag}@5", f"{tag}@20"] | |
| header = f"{'index':24s} {'style':10s} " + " ".join(f"{c:>7s}" for c in cols) | |
| lines = [header, "-" * len(header)] | |
| for name in args.indexes: | |
| for style in STYLES: | |
| vals = results[(name, style)] | |
| lines.append(f"{name:24s} {style:10s} " + " ".join(f"{v:7.3f}" for v in vals)) | |
| ncols = min(len(results[(name, s)]) for s in STYLES) | |
| avg = [sum(results[(name, s)][i] for s in STYLES) / len(STYLES) for i in range(ncols)] | |
| lines.append(f"{name:24s} {'MEAN':10s} " + " ".join(f"{v:7.3f}" for v in avg)) | |
| lines.append("") | |
| out = "\n".join(lines) | |
| print(out) | |
| Path("multi_eval_report.txt").write_text(out) | |
| print("Report written to multi_eval_report.txt") | |
| if __name__ == "__main__": | |
| main() | |