| """ |
| Compute BLEU-2 (edited vs base) per category from cached captions.json files. |
| |
| Uses a captions.json that contains base_caption as the reference source, |
| then joins any other captions.json on (image_id, prompt) to get the |
| edited captions. No model inference needed. |
| |
| Usage: |
| python bleu_from_cache.py \ |
| --base_captions adv_outputs/run_20260421_191124/lora_adapter/captions.json \ |
| --inputs \ |
| "LoRA:lora-baseline/runs/run_20260430_135824/step_500/captions.json" \ |
| "Nullu:Nullu/output/edited_model/LLaVA-7B-top4-0-32-bathroom_toilet/captions.json" \ |
| "EFUF-ep5:EFUF/efuf/checkpoints/.../eval_epoch_005/captions.json" |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import math |
| import sys |
| from collections import defaultdict |
| from pathlib import Path |
|
|
|
|
| |
| |
| |
|
|
| def _make_scorer(): |
| try: |
| from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction |
| _smooth = SmoothingFunction().method1 |
| def score(ref: str, hyp: str) -> float: |
| r, h = ref.lower().split(), hyp.lower().split() |
| if not r or not h: |
| return float("nan") |
| return sentence_bleu([r], h, weights=(0.5, 0.5), smoothing_function=_smooth) |
| except ImportError: |
| def score(ref: str, hyp: str) -> float: |
| |
| r, h = set(ref.lower().split()), set(hyp.lower().split()) |
| if not r or not h: |
| return float("nan") |
| inter = len(r & h) |
| p, rec = inter / len(h), inter / len(r) |
| return 2 * p * rec / (p + rec) if (p + rec) > 0 else 0.0 |
| return score |
|
|
|
|
| def _detect_caption_key(record: dict) -> str | None: |
| for key in ("lora_caption", "edited_caption", "EFUF_caption", "base_caption"): |
| if key in record and record[key]: |
| return key |
| return None |
|
|
|
|
| def _category_from_flags(rec: dict) -> str: |
| """Derive canonical category name from boolean flags, independent of display naming.""" |
| |
| scene_keys = [k for k in rec if k not in ("index", "image_id", "prompt", "category") and |
| not k.endswith("_caption") and not k.endswith("_mentions_object") and |
| isinstance(rec[k], int)] |
| if "bathroom" in rec and "toilet" in rec: |
| sc, ob = int(rec["bathroom"]), int(rec["toilet"]) |
| elif len(scene_keys) == 2: |
| sc, ob = int(rec[scene_keys[0]]), int(rec[scene_keys[1]]) |
| else: |
| return rec.get("category", "unknown") |
|
|
| if sc == 0 and ob == 1: |
| return "non_scene_with_object" |
| if sc == 1 and ob == 0: |
| return "scene_no_object" |
| if sc == 1 and ob == 1: |
| return "scene_with_object" |
| return "neither" |
|
|
|
|
| CAT_LABELS = { |
| "non_scene_with_object": "non_scene_w_object", |
| "scene_no_object": "scene_no_object (suppression target — lower BLEU expected)", |
| "scene_with_object": "scene_with_object (key quality metric)", |
| "neither": "neither", |
| } |
|
|
|
|
| |
| |
| |
|
|
| def compute_bleu(base_lookup: dict, edited_records: list, scorer) -> dict[str, list[float]]: |
| """Return dict of category -> list of BLEU scores.""" |
| by_cat: dict[str, list[float]] = defaultdict(list) |
| missing = 0 |
| for rec in edited_records: |
| key = (rec["image_id"], rec["prompt"]) |
| base_rec = base_lookup.get(key) |
| if base_rec is None: |
| missing += 1 |
| continue |
| base_cap = base_rec.get("base_caption", "") |
| edited_key = _detect_caption_key(rec) |
| if not edited_key or edited_key == "base_caption": |
| |
| continue |
| edited_cap = rec[edited_key] |
| cat = _category_from_flags(rec) |
| s = scorer(base_cap, edited_cap) |
| if not math.isnan(s): |
| by_cat[cat].append(s) |
| if missing: |
| print(f" [warn] {missing} records had no matching base caption", file=sys.stderr) |
| return dict(by_cat) |
|
|
|
|
| def mean(vals: list[float]) -> float: |
| return sum(vals) / len(vals) if vals else float("nan") |
|
|
|
|
| |
| |
| |
|
|
| def parse_args(): |
| p = argparse.ArgumentParser(description="Compute BLEU-2 from cached captions.json files.") |
| p.add_argument( |
| "--base_captions", |
| default="adv_outputs/run_20260421_191124/lora_adapter/captions.json", |
| help="captions.json that contains base_caption field.", |
| ) |
| p.add_argument( |
| "--inputs", nargs="+", metavar="LABEL:PATH", |
| help="One or more 'Label:path/to/captions.json' entries.", |
| ) |
| p.add_argument("--output_json", default=None, help="Optional path to save results as JSON.") |
| return p.parse_args() |
|
|
|
|
| def main(): |
| args = parse_args() |
| scorer = _make_scorer() |
|
|
| base_path = Path(args.base_captions) |
| print(f"Loading base captions from: {base_path}") |
| with open(base_path) as f: |
| base_data = json.load(f) |
| base_lookup = {(r["image_id"], r["prompt"]): r for r in base_data} |
| print(f" {len(base_lookup)} (image_id, prompt) pairs\n") |
|
|
| |
| inputs: list[tuple[str, Path]] = [] |
| if args.inputs: |
| for entry in args.inputs: |
| label, _, path = entry.partition(":") |
| inputs.append((label.strip(), Path(path.strip()))) |
|
|
| |
| sample = base_data[0] |
| base_edited_key = _detect_caption_key({k: v for k, v in sample.items() if k != "base_caption"}) |
| if base_edited_key and base_edited_key != "base_caption": |
| inputs = [(f"[base file] {base_edited_key}", base_path)] + inputs |
|
|
| all_results: dict[str, dict] = {} |
| cat_order = ["non_scene_with_object", "scene_no_object", "scene_with_object", "neither"] |
|
|
| for label, path in inputs: |
| print(f"Processing: {label}") |
| with open(path) as f: |
| data = json.load(f) |
| by_cat = compute_bleu(base_lookup, data, scorer) |
| row = {cat: mean(by_cat.get(cat, [])) for cat in cat_order} |
| all_results[label] = row |
|
|
| |
| col_w = max(len(l) for l, _ in inputs) + 2 if inputs else 30 |
| col_w = max(col_w, 20) |
| cats_display = [ |
| ("non_scene_with_object", "non_scene_w_obj"), |
| ("scene_no_object", "scene_no_obj "), |
| ("scene_with_object", "scene_with_obj "), |
| ] |
|
|
| header = f"\n{'Method':<{col_w}}" + "".join(f" {c[1]}" for c in cats_display) |
| print(header) |
| print("-" * len(header)) |
| for label, row in all_results.items(): |
| line = f"{label:<{col_w}}" |
| for cat, _ in cats_display: |
| v = row.get(cat, float("nan")) |
| line += f" {v:>15.4f}" if not math.isnan(v) else f" {'n/a':>15}" |
| print(line) |
|
|
| print() |
| print("Note: scene_no_object = hallucination target (lower BLEU is EXPECTED for good suppression).") |
| print(" scene_with_object and non_scene_w_obj = quality metrics (higher = less collateral damage).") |
|
|
| if args.output_json: |
| out = {label: {cat: (None if math.isnan(v) else v) for cat, v in row.items()} |
| for label, row in all_results.items()} |
| with open(args.output_json, "w") as f: |
| json.dump(out, f, indent=2) |
| print(f"\nSaved to {args.output_json}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|