Buckets:
| #!/usr/bin/env python3 | |
| # /// script | |
| # requires-python = ">=3.11" | |
| # dependencies = [ | |
| # "datasets", | |
| # "huggingface_hub", | |
| # "iconclass", | |
| # "openai", | |
| # "pillow", | |
| # "sentence-transformers", | |
| # "numpy", | |
| # "tqdm", | |
| # ] | |
| # /// | |
| """Describe -> Retrieve: decouple VISION from TAXONOMY for Iconclass classification. | |
| THE HYPOTHESIS | |
| -------------- | |
| A thorough prior session found the fine-tuned Qwen3.5-4B is *capability-limited* | |
| on Iconclass: ~25% code recall, ~62-64% corrected H-F1, and that wall is invariant | |
| across reward tuning AND full-label re-SFT. The model can SEE artworks but can't | |
| reliably NAME the right Iconclass codes — the taxonomy is the bottleneck, not the | |
| vision. | |
| This script tests whether RETRIEVAL can supply the taxonomy knowledge the 4B lacks, | |
| with NO fine-tuning at all: | |
| 1. DESCRIBE — a strong VLM (Qwen3.6-35B-A3B via the HF router) writes a rich | |
| iconographic description of the image AND extracts discrete concepts | |
| (figures, scene, attributes, symbols, actions). This plays to the VLM's | |
| strength: looking at a picture and saying what's in it. | |
| 2. RETRIEVE — map that text -> candidate Iconclass codes via SEMANTIC SEARCH | |
| over the Iconclass tree (search_iconclass / build_search_index from | |
| grpo-tools/iconclass_tools.py). The Iconclass lib has no text->code search; | |
| this index is the only thing that supplies the "which code is this concept" | |
| knowledge. We retrieve per-concept and over the whole description, pool by | |
| similarity, and keep the top candidates. K is tunable. | |
| 3. CONFIRM — (optional) a binary VLM judge ("does this artwork depict X?") | |
| filters the retrieved candidates to lift precision (same judge pattern as | |
| eval_corrected.py). | |
| 4. SCORE — hierarchical-F1 + code precision/recall vs the FULL clean GT, | |
| using eval_sft.py's metric logic verbatim. | |
| WHY THIS IS A FAIR / CONTAMINATION-FREE COMPARISON | |
| -------------------------------------------------- | |
| We evaluate on `davanstrien/iconclass-vlm-brillfull` split `test` (full clean | |
| labels, contamination-safe held-out by filename hash). The fine-tuned 4B v3 scored | |
| 52.0% H-F1 on this same split — but that number is *contaminated-optimistic* | |
| because v3 trained on these very images. describe->retrieve uses NO training, so | |
| if it matches/beats ~52% it is clearly competitive, and we especially watch RECALL | |
| (the 4B's ~25% wall) to see if retrieval breaks through it. | |
| CONSTRAINTS | |
| ----------- | |
| Router (free tier) + CPU only, no GPU. The 35B cold-starts on the router (~2 min); | |
| warm up first (--probe). The semantic index build is ~75s one-time on CPU. | |
| USAGE | |
| ----- | |
| # reachability + index sanity, ~free | |
| uv run describe_retrieve.py --probe | |
| # small run to pick K / config | |
| uv run describe_retrieve.py --num-samples 12 --k 8 | |
| # full ~120-image eval, with judge confirm | |
| uv run describe_retrieve.py --num-samples 120 --k 8 --confirm | |
| # sweep several K in one go (no judge), pick the best | |
| uv run describe_retrieve.py --num-samples 60 --sweep-k 4,6,8,12,16 | |
| NOTE: if the project's internal uv index can't resolve deps, prepend | |
| ``--index-url https://pypi.org/simple/`` to ``uv run``. | |
| """ | |
| import argparse | |
| import asyncio | |
| import base64 | |
| import io | |
| import json | |
| import os | |
| import re | |
| import sys | |
| import time | |
| from dataclasses import dataclass, field | |
| from datasets import load_dataset | |
| from huggingface_hub import get_token | |
| from iconclass import init | |
| from openai import AsyncOpenAI | |
| # Bring in the semantic search over the Iconclass tree. This is the component | |
| # that supplies the taxonomy knowledge (text concept -> code) the 4B lacks. | |
| sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)))) | |
| sys.path.insert( | |
| 0, | |
| os.path.abspath( | |
| os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "grpo-tools") | |
| ), | |
| ) | |
| from iconclass_tools import build_search_index, search_iconclass # noqa: E402 | |
| ic = init() | |
| # ----------------------------------------------------------------------------- config | |
| DATASET = "davanstrien/iconclass-vlm-brillfull" | |
| SPLIT = "test" | |
| ROUTER_BASE_URL = "https://router.huggingface.co/v1" | |
| VLM_MODEL = "Qwen/Qwen3.6-35B-A3B" | |
| VLM_EXTRA_BODY = {"chat_template_kwargs": {"enable_thinking": False}} | |
| # 4B v3 reference on THIS split (contaminated-optimistic — trained on these images). | |
| FT_4B_V3_BRILLFULL_HF1 = 0.520 | |
| # ---- DESCRIBE prompt. Asks for a rich iconographic reading AND structured concepts. | |
| # The structured concepts become individual retrieval queries; the prose description | |
| # becomes a whole-image query. We push the VLM toward iconography (story/meaning, | |
| # attributes, symbols) because the 4B's error analysis showed "narrative blindness" | |
| # was its worst failure mode — exactly what a strong VLM should be able to supply. | |
| DESCRIBE_SYSTEM = ( | |
| "You are an expert art historian and iconographer. You look at an artwork and " | |
| "describe its CONTENT precisely: who/what is depicted, what is happening, the " | |
| "setting, and any attributes, symbols, gestures, or objects that identify " | |
| "people, stories, or themes. You name biblical, mythological, historical, and " | |
| "allegorical subjects when the visual evidence supports them." | |
| ) | |
| DESCRIBE_USER = ( | |
| "Describe the CONTENT of this artwork for iconographic cataloguing. " | |
| "Return ONLY a JSON object with these keys:\n" | |
| ' "description": a 2-4 sentence rich description of what is depicted ' | |
| "(figures, scene, action, setting, mood),\n" | |
| ' "figures": list of people/beings depicted (e.g. "Virgin Mary", "a kneeling ' | |
| 'man", "an angel", "a lion"),\n' | |
| ' "scene": short phrase naming the subject/story/theme if identifiable ' | |
| '(e.g. "the Crucifixion", "Annunciation", "a portrait", "a still life with ' | |
| 'fruit", "a landscape"),\n' | |
| ' "attributes": list of identifying objects/symbols/attributes (e.g. "a ' | |
| 'crown", "a sword", "a halo", "an open book", "grapes"),\n' | |
| ' "actions": list of poses/gestures/actions (e.g. "kneeling", "head turned ' | |
| 'right", "blessing", "holding a child").\n' | |
| "Only include what is visually supported. No prose outside the JSON." | |
| ) | |
| # ---- CONFIRM (binary judge) prompt — verbatim style from eval_corrected.py, which | |
| # was tuned in the project's judge prompt-engineering session. | |
| JUDGE_PROMPT_TEMPLATE = ( | |
| "Iconclass is a classification system for the content of art images. " | |
| "Artworks are assigned codes for the concepts they depict — objects, " | |
| "people, poses, scenes, themes.\n\n" | |
| "Does this artwork depict: {concept} ({code})\n\n" | |
| "Answer with a single word: YES or NO." | |
| ) | |
| # ===================================================================== | |
| # Metrics — inlined VERBATIM from eval_sft.py so numbers are directly | |
| # comparable to the documented SFT / GRPO / 4B-v3 results. | |
| # ===================================================================== | |
| def _is_valid_iconclass(code): | |
| try: | |
| base_code = code.split("(")[0].strip() | |
| _ = ic[base_code] | |
| return True | |
| except Exception: | |
| base_code = code.split("(")[0].strip() | |
| return _check_partial_validity(base_code) | |
| def _check_partial_validity(code): | |
| if not code: | |
| return False | |
| for i in range(len(code), 0, -1): | |
| try: | |
| _ = ic[code[:i]] | |
| return True | |
| except Exception: | |
| continue | |
| return False | |
| def _get_hierarchy_levels(code): | |
| """E.g. "25F23" -> ["2", "25", "25F", "25F2", "25F23"].""" | |
| if not code: | |
| return [] | |
| levels = [] | |
| if len(code) >= 1: | |
| levels.append(code[0]) | |
| if len(code) >= 2: | |
| levels.append(code[:2]) | |
| if len(code) >= 3: | |
| levels.append(code[:3]) | |
| for i in range(4, len(code) + 1): | |
| levels.append(code[:i]) | |
| return levels | |
| def _hierarchical_match_score(pred, truth): | |
| pred_clean = pred.split("(")[0].strip() if isinstance(pred, str) else "" | |
| truth_clean = truth.split("(")[0].strip() if isinstance(truth, str) else "" | |
| if pred_clean == truth_clean: | |
| return 1.0 | |
| pred_levels = _get_hierarchy_levels(pred_clean) | |
| truth_levels = _get_hierarchy_levels(truth_clean) | |
| common_levels = 0 | |
| for p, t in zip(pred_levels, truth_levels): | |
| if p == t: | |
| common_levels += 1 | |
| else: | |
| break | |
| if common_levels == 0: | |
| return 0.0 | |
| credit_by_level = [0.2, 0.4, 0.6, 0.8, 0.9, 0.95, 1.0] | |
| if common_levels >= len(credit_by_level): | |
| score = 1.0 | |
| else: | |
| score = credit_by_level[common_levels - 1] | |
| if len(pred_levels) != len(truth_levels): | |
| score *= 0.95 | |
| return score | |
| def _calculate_hierarchical_f1(predicted, ground_truth): | |
| if not predicted and not ground_truth: | |
| return 1.0 | |
| if not predicted or not ground_truth: | |
| return 0.0 | |
| precision_score = 0.0 | |
| for pred in predicted: | |
| best_match = max( | |
| (_hierarchical_match_score(pred, gt) for gt in ground_truth), | |
| default=0.0, | |
| ) | |
| precision_score += best_match | |
| precision = precision_score / len(predicted) if predicted else 0.0 | |
| recall_score = 0.0 | |
| for gt in ground_truth: | |
| best_match = max( | |
| (_hierarchical_match_score(pred, gt) for pred in predicted), | |
| default=0.0, | |
| ) | |
| recall_score += best_match | |
| recall = recall_score / len(ground_truth) if ground_truth else 0.0 | |
| if precision + recall > 0: | |
| return 2 * (precision * recall) / (precision + recall) | |
| return 0.0 | |
| def _hierarchical_precision_recall(predicted, ground_truth): | |
| """The hierarchical (partial-credit) precision & recall behind the F1. | |
| Reported alongside exact-set P/R because the 4B's documented "~25% recall" | |
| wall is the exact-set code recall; we report BOTH so the comparison is honest. | |
| """ | |
| if not predicted or not ground_truth: | |
| return 0.0, 0.0 | |
| precision_score = sum( | |
| max((_hierarchical_match_score(p, gt) for gt in ground_truth), default=0.0) | |
| for p in predicted | |
| ) | |
| recall_score = sum( | |
| max((_hierarchical_match_score(p, gt) for p in predicted), default=0.0) | |
| for gt in ground_truth | |
| ) | |
| precision = precision_score / len(predicted) | |
| recall = recall_score / len(ground_truth) | |
| return precision, recall | |
| def _code_precision_recall(predicted, ground_truth): | |
| """Exact-set code precision/recall (matches eval_sft.py).""" | |
| pred_set, gt_set = set(predicted), set(ground_truth) | |
| precision = len(pred_set & gt_set) / len(pred_set) if pred_set else 0.0 | |
| recall = len(pred_set & gt_set) / len(gt_set) if gt_set else 0.0 | |
| return precision, recall | |
| # ===================================================================== | |
| # Iconclass helpers | |
| # ===================================================================== | |
| def _code_description(code): | |
| """Decoded Iconclass text for a code (strips any (KEY)/(+xx) suffix).""" | |
| try: | |
| base = code.split("(")[0].strip() | |
| node = ic[base] | |
| desc = node() if callable(node) else str(node) | |
| return desc or None | |
| except Exception: | |
| return None | |
| # ===================================================================== | |
| # Image handling — verbatim from smoke_test_models.py / eval_corrected.py | |
| # ===================================================================== | |
| def image_to_data_url(img, max_size=768): | |
| if max(img.size) > max_size: | |
| r = max_size / max(img.size) | |
| from PIL import Image | |
| img = img.resize((int(img.size[0] * r), int(img.size[1] * r)), Image.LANCZOS) | |
| if img.mode != "RGB": | |
| img = img.convert("RGB") | |
| buf = io.BytesIO() | |
| img.save(buf, format="JPEG", quality=85) | |
| return f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}" | |
| def first_image(example): | |
| """brillfull rows carry a singular `image` and a list `images`; accept either.""" | |
| img = example.get("image") | |
| if img is not None: | |
| return img | |
| imgs = example.get("images", []) | |
| if not imgs: | |
| return None | |
| return imgs[0] if isinstance(imgs, list) else imgs | |
| def get_gt_codes(example): | |
| """brillfull rows carry full clean labels in `label` (list[str]).""" | |
| lab = example.get("label") | |
| if isinstance(lab, list): | |
| return [c for c in lab if isinstance(c, str) and c.strip()] | |
| return [] | |
| # ===================================================================== | |
| # STEP 1 — DESCRIBE (strong VLM) | |
| # ===================================================================== | |
| def _extract_json(text): | |
| """Pull the first JSON object out of a model reply (handles ```json fences).""" | |
| if not text: | |
| return None | |
| s = text.strip() | |
| if "```" in s: | |
| parts = s.split("```") | |
| for part in parts: | |
| p = part | |
| if p.lstrip().lower().startswith("json"): | |
| p = p.lstrip()[4:] | |
| p = p.strip() | |
| if p.startswith("{"): | |
| s = p | |
| break | |
| try: | |
| return json.loads(s) | |
| except (json.JSONDecodeError, TypeError): | |
| pass | |
| # last resort: greedy braces | |
| m = re.search(r"\{.*\}", text, re.DOTALL) | |
| if m: | |
| try: | |
| return json.loads(m.group(0)) | |
| except (json.JSONDecodeError, TypeError): | |
| return None | |
| return None | |
| class Description: | |
| description: str = "" | |
| figures: list = field(default_factory=list) | |
| scene: str = "" | |
| attributes: list = field(default_factory=list) | |
| actions: list = field(default_factory=list) | |
| raw: str = "" | |
| def concepts(self): | |
| """Discrete concept strings to use as individual retrieval queries. | |
| The scene phrase is repeated (weighted) because subject/story is the | |
| signal the 4B was worst at and the highest-value thing to retrieve on. | |
| """ | |
| out = [] | |
| if self.scene: | |
| out.append(self.scene) | |
| out.extend(c for c in self.figures if isinstance(c, str) and c.strip()) | |
| out.extend(c for c in self.attributes if isinstance(c, str) and c.strip()) | |
| out.extend(c for c in self.actions if isinstance(c, str) and c.strip()) | |
| # de-dup, preserve order | |
| seen, deduped = set(), [] | |
| for c in out: | |
| cs = c.strip() | |
| key = cs.lower() | |
| if cs and key not in seen: | |
| seen.add(key) | |
| deduped.append(cs) | |
| return deduped | |
| def parse_description(text): | |
| data = _extract_json(text) or {} | |
| return Description( | |
| description=str(data.get("description", "") or ""), | |
| figures=list(data.get("figures", []) or []), | |
| scene=str(data.get("scene", "") or ""), | |
| attributes=list(data.get("attributes", []) or []), | |
| actions=list(data.get("actions", []) or []), | |
| raw=text[:1500], | |
| ) | |
| async def describe_one(client, sem, image_url, max_tokens): | |
| async with sem: | |
| try: | |
| resp = await client.chat.completions.create( | |
| model=VLM_MODEL, | |
| messages=[ | |
| {"role": "system", "content": DESCRIBE_SYSTEM}, | |
| { | |
| "role": "user", | |
| "content": [ | |
| {"type": "image_url", "image_url": {"url": image_url}}, | |
| {"type": "text", "text": DESCRIBE_USER}, | |
| ], | |
| }, | |
| ], | |
| max_tokens=max_tokens, | |
| temperature=0.0, | |
| extra_body=VLM_EXTRA_BODY, | |
| ) | |
| raw = (resp.choices[0].message.content or "").strip() | |
| if not raw: | |
| rc = resp.choices[0].message.model_dump().get("reasoning_content", "") | |
| raw = rc or "" | |
| except Exception as e: | |
| return {"error": str(e)[:200]} | |
| return {"description": parse_description(raw)} | |
| # ===================================================================== | |
| # STEP 2 — RETRIEVE (semantic search -> candidate codes) | |
| # ===================================================================== | |
| def retrieve_candidates(desc: Description, k_per_query, k_whole, pool_keep): | |
| """Map a Description to candidate Iconclass codes via semantic search. | |
| Strategy: run one query per extracted concept (k_per_query hits each) PLUS a | |
| query over the whole prose description (k_whole hits). Pool all hits, keep the | |
| best similarity seen per code, then return the top `pool_keep` codes by score. | |
| Returns (ordered_codes, debug_hits) where debug_hits maps code -> best sim. | |
| """ | |
| pooled = {} # code -> best similarity | |
| def add(query, limit): | |
| if not query or not query.strip(): | |
| return | |
| try: | |
| hits = search_iconclass(query.strip(), limit=limit) | |
| except Exception: | |
| return | |
| for h in hits: | |
| code = h.get("code") | |
| if not code or code == "error": | |
| continue | |
| sim = float(h.get("similarity", 0.0)) | |
| if code not in pooled or sim > pooled[code]: | |
| pooled[code] = sim | |
| # whole-description query (broad context) | |
| add(desc.description, k_whole) | |
| # per-concept queries (precise, the main driver) | |
| for concept in desc.concepts(): | |
| add(concept, k_per_query) | |
| ordered = sorted(pooled.items(), key=lambda kv: kv[1], reverse=True) | |
| kept = [code for code, _ in ordered[:pool_keep]] | |
| return kept, dict(ordered[:pool_keep]) | |
| # ===================================================================== | |
| # STEP 3 — CONFIRM (binary VLM judge, optional) — eval_corrected.py pattern | |
| # ===================================================================== | |
| def parse_yes_no(raw): | |
| if not raw: | |
| return False | |
| s = raw.strip().upper() | |
| tokens = s.replace(",", " ").replace(".", " ").split() | |
| first = tokens[0] if tokens else "" | |
| if first.startswith("YES"): | |
| return True | |
| if first.startswith("NO"): | |
| return False | |
| return "YES" in s | |
| async def judge_one(client, sem, image_url, code, concept, max_tokens): | |
| prompt = JUDGE_PROMPT_TEMPLATE.format(concept=concept, code=code) | |
| async with sem: | |
| try: | |
| resp = await client.chat.completions.create( | |
| model=VLM_MODEL, | |
| messages=[ | |
| { | |
| "role": "user", | |
| "content": [ | |
| {"type": "image_url", "image_url": {"url": image_url}}, | |
| {"type": "text", "text": prompt}, | |
| ], | |
| } | |
| ], | |
| max_tokens=max_tokens, | |
| temperature=0.0, | |
| extra_body=VLM_EXTRA_BODY, | |
| ) | |
| raw = (resp.choices[0].message.content or "").strip() | |
| if not raw: | |
| rc = resp.choices[0].message.model_dump().get("reasoning_content", "") | |
| raw = rc or "" | |
| except Exception as e: | |
| raw = f"__error__: {e}" | |
| depicted = False if raw.startswith("__error__") else parse_yes_no(raw) | |
| return code, depicted | |
| # ===================================================================== | |
| # Per-sample record + pipeline | |
| # ===================================================================== | |
| class SampleRecord: | |
| index: int | |
| gt_codes: list | |
| image_url: str | |
| description: Description = None | |
| candidates: list = field(default_factory=list) # retrieved, pre-confirm | |
| candidate_scores: dict = field(default_factory=dict) | |
| confirmed: list = field(default_factory=list) # judge YES (post-confirm) | |
| error: str = None | |
| async def describe_all(records, concurrency, max_tokens): | |
| client = AsyncOpenAI(base_url=ROUTER_BASE_URL, api_key=get_token()) | |
| sem = asyncio.Semaphore(concurrency) | |
| tasks = [describe_one(client, sem, r.image_url, max_tokens) for r in records] | |
| # gather preserves order so results line up with `records`. | |
| results = await asyncio.gather(*tasks) | |
| for r, res in zip(records, results): | |
| if "error" in res: | |
| r.error = res["error"] | |
| else: | |
| r.description = res["description"] | |
| return client | |
| async def confirm_all(client, records, concurrency, max_tokens): | |
| """Run the binary judge over every (record, candidate) and keep the YESes.""" | |
| sem = asyncio.Semaphore(concurrency) | |
| tasks = [] | |
| owners = [] | |
| for r in records: | |
| if r.error or not r.candidates: | |
| continue | |
| for code in r.candidates: | |
| concept = _code_description(code) or code | |
| tasks.append(judge_one(client, sem, r.image_url, code, concept, max_tokens)) | |
| owners.append(r) | |
| if not tasks: | |
| return | |
| print(f" confirming {len(tasks)} candidate codes via binary judge…") | |
| results = await asyncio.gather(*tasks) | |
| for owner, (code, depicted) in zip(owners, results): | |
| if depicted: | |
| owner.confirmed.append(code) | |
| # ===================================================================== | |
| # Scoring / aggregation | |
| # ===================================================================== | |
| def score_predictions(records, use_confirmed): | |
| """Score each record's predicted codes vs GT. Returns list of metric dicts.""" | |
| rows = [] | |
| for r in records: | |
| if r.error: | |
| continue | |
| predicted = r.confirmed if use_confirmed else r.candidates | |
| h_f1 = _calculate_hierarchical_f1(predicted, r.gt_codes) | |
| h_p, h_r = _hierarchical_precision_recall(predicted, r.gt_codes) | |
| c_p, c_r = _code_precision_recall(predicted, r.gt_codes) | |
| rows.append( | |
| { | |
| "index": r.index, | |
| "n_pred": len(predicted), | |
| "n_gt": len(r.gt_codes), | |
| "hierarchical_f1": h_f1, | |
| "hier_precision": h_p, | |
| "hier_recall": h_r, | |
| "code_precision": c_p, | |
| "code_recall": c_r, | |
| "exact_match": set(predicted) == set(r.gt_codes), | |
| } | |
| ) | |
| return rows | |
| def aggregate(rows, label): | |
| n = len(rows) | |
| if n == 0: | |
| return {"label": label, "n": 0} | |
| def avg(k): | |
| return sum(r[k] for r in rows) / n | |
| return { | |
| "label": label, | |
| "n": n, | |
| "h_f1": avg("hierarchical_f1"), | |
| "hier_precision": avg("hier_precision"), | |
| "hier_recall": avg("hier_recall"), | |
| "code_precision": avg("code_precision"), | |
| "code_recall": avg("code_recall"), | |
| "exact": avg("exact_match"), | |
| "avg_pred": avg("n_pred"), | |
| "avg_gt": avg("n_gt"), | |
| } | |
| def print_summary(summaries): | |
| hdr = ( | |
| f"{'config':<26}{'n':>4}{'H-F1':>8}{'hierP':>8}{'hierR':>8}" | |
| f"{'codeP':>8}{'codeR':>8}{'exact':>7}{'#pred':>7}" | |
| ) | |
| print("\n" + "=" * len(hdr)) | |
| print("DESCRIBE -> RETRIEVE vs fine-tuned 4B v3 (brillfull-test)") | |
| print("=" * len(hdr)) | |
| print(hdr) | |
| print("-" * len(hdr)) | |
| for s in summaries: | |
| if s.get("n", 0) == 0: | |
| print(f"{s['label']:<26}{'—':>4} (no scored samples)") | |
| continue | |
| print( | |
| f"{s['label']:<26}{s['n']:>4}{s['h_f1']:>7.1%}{s['hier_precision']:>8.1%}" | |
| f"{s['hier_recall']:>8.1%}{s['code_precision']:>8.1%}" | |
| f"{s['code_recall']:>8.1%}{s['exact']:>7.1%}{s['avg_pred']:>7.1f}" | |
| ) | |
| print("-" * len(hdr)) | |
| print( | |
| f"{'fine-tuned 4B v3 (ref)':<26}{'~':>4}{FT_4B_V3_BRILLFULL_HF1:>7.1%}" | |
| f"{'—':>8}{'~25%':>8}{'—':>8}{'~25%':>8}{'—':>7}{'—':>7}" | |
| ) | |
| print(" (4B v3 H-F1 is contaminated-optimistic: it TRAINED on these images)") | |
| print("=" * len(hdr)) | |
| # ===================================================================== | |
| # Loading | |
| # ===================================================================== | |
| def load_records(num_samples, seed, first_n=False): | |
| print(f"Loading {DATASET} split={SPLIT}…") | |
| ds = load_dataset(DATASET, split=SPLIT) | |
| if first_n: | |
| # range(N) selection — matches eval_sft.py's ds.select(range(N)) so the | |
| # retrieval pool aligns by index with the fine-tuned 4B's predictions. | |
| idxs = list(range(min(num_samples, len(ds)))) | |
| else: | |
| # fixed-seed shuffle so the sample is reproducible and not just the first-N | |
| idxs = list(range(len(ds))) | |
| import random | |
| random.Random(seed).shuffle(idxs) | |
| idxs = idxs[:num_samples] | |
| records = [] | |
| for i in idxs: | |
| ex = ds[i] | |
| img = first_image(ex) | |
| gt = get_gt_codes(ex) | |
| if img is None or not gt: | |
| continue | |
| records.append( | |
| SampleRecord(index=i, gt_codes=gt, image_url=image_to_data_url(img)) | |
| ) | |
| print(f"Got {len(records)} usable samples (seed={seed}).") | |
| return records | |
| # ===================================================================== | |
| # Probe | |
| # ===================================================================== | |
| async def probe(seed): | |
| token = get_token() | |
| if not token: | |
| sys.exit("ERROR: no HF token (run `hf auth login`).") | |
| print("Building semantic search index (one-time, ~75s on CPU)…") | |
| t0 = time.time() | |
| build_search_index() | |
| print(f" index ready in {time.time() - t0:.1f}s") | |
| print("\nSemantic search sanity (text -> code):") | |
| for q in ["the crucifixion of Christ", "a pheasant in a landscape"]: | |
| hits = search_iconclass(q, limit=3) | |
| print(f" {q!r}:") | |
| for h in hits: | |
| print( | |
| f" {h['code']:<14} sim={h.get('similarity', 0):.3f} " | |
| f"{h['description'][:50]}" | |
| ) | |
| records = load_records(1, seed) | |
| if not records: | |
| sys.exit("No usable sample to probe.") | |
| r = records[0] | |
| client = AsyncOpenAI(base_url=ROUTER_BASE_URL, api_key=token) | |
| sem = asyncio.Semaphore(1) | |
| print(f"\nWarming up + describing 1 image (index {r.index})…") | |
| t0 = time.time() | |
| res = await describe_one(client, sem, r.image_url, 700) | |
| if "error" in res: | |
| sys.exit(f"VLM describe failed: {res['error']}") | |
| desc = res["description"] | |
| print(f" described in {time.time() - t0:.1f}s") | |
| print(f" scene: {desc.scene!r}") | |
| print(f" figures: {desc.figures}") | |
| print(f" attributes: {desc.attributes}") | |
| print(f" actions: {desc.actions}") | |
| print(f" description: {desc.description[:200]}") | |
| cands, scores = retrieve_candidates(desc, k_per_query=4, k_whole=8, pool_keep=8) | |
| print(f"\n retrieved {len(cands)} candidate codes:") | |
| for c in cands: | |
| print(f" {c:<16} sim={scores.get(c, 0):.3f} {_code_description(c)}") | |
| print(f"\n GT codes ({len(r.gt_codes)}): {r.gt_codes}") | |
| h_f1 = _calculate_hierarchical_f1(cands, r.gt_codes) | |
| _, h_r = _hierarchical_precision_recall(cands, r.gt_codes) | |
| print(f" -> H-F1={h_f1:.1%} hier-recall={h_r:.1%}") | |
| # ===================================================================== | |
| # Main | |
| # ===================================================================== | |
| def run_config(records, k_per_query, k_whole, pool_keep, label): | |
| """Retrieve (pre-confirm) for a given K config and score it.""" | |
| for r in records: | |
| if r.error: | |
| continue | |
| cands, scores = retrieve_candidates( | |
| r.description, k_per_query, k_whole, pool_keep | |
| ) | |
| r.candidates = cands | |
| r.candidate_scores = scores | |
| rows = score_predictions(records, use_confirmed=False) | |
| return aggregate(rows, label), rows | |
| def main(): | |
| sys.stdout.reconfigure(line_buffering=True) | |
| ap = argparse.ArgumentParser(description=__doc__) | |
| ap.add_argument("--num-samples", type=int, default=120) | |
| ap.add_argument("--seed", type=int, default=42) | |
| ap.add_argument( | |
| "--first-n", | |
| action="store_true", | |
| help="select range(N) (matches eval_sft.py) instead of a seed shuffle", | |
| ) | |
| ap.add_argument("--k", type=int, default=8, help="pool_keep: #codes kept/image") | |
| ap.add_argument( | |
| "--k-per-query", type=int, default=4, help="hits per extracted concept" | |
| ) | |
| ap.add_argument( | |
| "--k-whole", type=int, default=8, help="hits for the whole-description query" | |
| ) | |
| ap.add_argument( | |
| "--sweep-k", | |
| type=str, | |
| default=None, | |
| help="comma-separated pool_keep values to sweep, e.g. 4,6,8,12,16", | |
| ) | |
| ap.add_argument( | |
| "--confirm", | |
| action="store_true", | |
| help="run the binary VLM judge to confirm retrieved codes (lifts precision)", | |
| ) | |
| ap.add_argument("--describe-concurrency", type=int, default=8) | |
| ap.add_argument("--judge-concurrency", type=int, default=10) | |
| ap.add_argument("--describe-max-tokens", type=int, default=700) | |
| ap.add_argument("--judge-max-tokens", type=int, default=200) | |
| ap.add_argument("--probe", action="store_true") | |
| ap.add_argument("--output", type=str, default="describe_retrieve_results.json") | |
| args = ap.parse_args() | |
| if args.probe: | |
| asyncio.run(probe(args.seed)) | |
| return | |
| token = get_token() | |
| if not token: | |
| sys.exit("ERROR: no HF token (run `hf auth login`).") | |
| # Build the index up-front (one-time ~75s) so timing of the VLM stage is clean. | |
| print("Building semantic search index (one-time, ~75s on CPU)…") | |
| t0 = time.time() | |
| build_search_index() | |
| print(f" index ready in {time.time() - t0:.1f}s") | |
| records = load_records(args.num_samples, args.seed, first_n=args.first_n) | |
| if not records: | |
| sys.exit("No usable samples.") | |
| # ---- STEP 1: describe every image with the strong VLM. | |
| print(f"\n[1/3] Describing {len(records)} images with {VLM_MODEL}…") | |
| t0 = time.time() | |
| client = asyncio.run( | |
| describe_all(records, args.describe_concurrency, args.describe_max_tokens) | |
| ) | |
| n_desc = sum(1 for r in records if r.description is not None) | |
| n_err = sum(1 for r in records if r.error) | |
| print( | |
| f" described {n_desc}/{len(records)} ({n_err} errors) in {time.time() - t0:.1f}s" | |
| ) | |
| # ---- STEP 2: retrieve + score. Sweep K if requested, else single config. | |
| print("\n[2/3] Retrieving candidate codes via semantic search + scoring…") | |
| summaries = [] | |
| best = None | |
| if args.sweep_k: | |
| ks = [int(x) for x in args.sweep_k.split(",") if x.strip()] | |
| for k in ks: | |
| summ, _rows = run_config( | |
| records, args.k_per_query, args.k_whole, k, f"retrieve K={k}" | |
| ) | |
| summaries.append(summ) | |
| if best is None or summ.get("h_f1", -1) > best.get("h_f1", -1): | |
| best, best_k = summ, k | |
| print(f" best pool_keep K = {best_k} (H-F1={best['h_f1']:.1%})") | |
| pool_keep = best_k | |
| else: | |
| summ, _rows = run_config( | |
| records, args.k_per_query, args.k_whole, args.k, f"retrieve K={args.k}" | |
| ) | |
| summaries.append(summ) | |
| best, pool_keep = summ, args.k | |
| # ---- STEP 3: optional judge confirm (uses the best/chosen K candidates). | |
| confirm_summary = None | |
| if args.confirm: | |
| print("\n[3/3] Confirming retrieved codes with binary VLM judge…") | |
| # ensure candidates correspond to the chosen K | |
| for r in records: | |
| if r.error: | |
| continue | |
| cands, scores = retrieve_candidates( | |
| r.description, args.k_per_query, args.k_whole, pool_keep | |
| ) | |
| r.candidates, r.candidate_scores = cands, scores | |
| t0 = time.time() | |
| asyncio.run( | |
| confirm_all(client, records, args.judge_concurrency, args.judge_max_tokens) | |
| ) | |
| crows = score_predictions(records, use_confirmed=True) | |
| confirm_summary = aggregate(crows, f"retrieve+confirm K={pool_keep}") | |
| summaries.append(confirm_summary) | |
| print(f" confirm done in {time.time() - t0:.1f}s") | |
| else: | |
| print("\n[3/3] (skipped judge confirm — pass --confirm to enable)") | |
| # ---- Report. | |
| print_summary(summaries) | |
| # Verdict vs the 4B wall. | |
| headline = confirm_summary or best | |
| print("\nVERDICT (vs fine-tuned 4B v3 on the clean ruler):") | |
| df = headline["h_f1"] - FT_4B_V3_BRILLFULL_HF1 | |
| print( | |
| f" describe->retrieve H-F1 = {headline['h_f1']:.1%} " | |
| f"({'+' if df >= 0 else ''}{df:.1%} vs 4B v3's {FT_4B_V3_BRILLFULL_HF1:.0%})" | |
| ) | |
| print( | |
| f" hier-recall = {headline['hier_recall']:.1%}, " | |
| f"exact-set code recall = {headline['code_recall']:.1%} " | |
| f"(4B wall ≈ 25% exact-set recall)" | |
| ) | |
| # ---- Persist everything for inspection. | |
| out = { | |
| "summary": { | |
| "dataset": DATASET, | |
| "split": SPLIT, | |
| "vlm_model": VLM_MODEL, | |
| "n_samples": len(records), | |
| "seed": args.seed, | |
| "k_per_query": args.k_per_query, | |
| "k_whole": args.k_whole, | |
| "confirmed": bool(args.confirm), | |
| "ft_4b_v3_brillfull_hf1": FT_4B_V3_BRILLFULL_HF1, | |
| "configs": summaries, | |
| }, | |
| "records": [ | |
| { | |
| "index": r.index, | |
| "gt_codes": r.gt_codes, | |
| "scene": r.description.scene if r.description else None, | |
| "figures": r.description.figures if r.description else None, | |
| "attributes": r.description.attributes if r.description else None, | |
| "actions": r.description.actions if r.description else None, | |
| "description": r.description.description if r.description else None, | |
| "retrieved_codes": r.candidates, | |
| "retrieved_scores": { | |
| k: round(v, 4) for k, v in r.candidate_scores.items() | |
| }, | |
| "confirmed_codes": r.confirmed if args.confirm else None, | |
| "error": r.error, | |
| } | |
| for r in records | |
| ], | |
| } | |
| with open(args.output, "w") as f: | |
| json.dump(out, f, indent=2, default=str) | |
| print(f"\nSaved → {args.output}") | |
| if __name__ == "__main__": | |
| main() | |
Xet Storage Details
- Size:
- 34 kB
- Xet hash:
- d9f4147edf1c9c571767fdb653e816b9e688c434d734abeb8cfaf4279b589e53
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.