File size: 7,917 Bytes
a2ffd07 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | """
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
# ---------------------------------------------------------------------------
# BLEU-2 helpers (no external deps; nltk used if available)
# ---------------------------------------------------------------------------
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:
# fallback: token-level F1 (unigram)
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."""
# Try bathroom/toilet first, then generic scene/object keys
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",
}
# ---------------------------------------------------------------------------
# Core computation
# ---------------------------------------------------------------------------
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":
# skip if only base present
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")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
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")
# Also compute BLEU for the base file's own edited captions (serves as self-check / LoRA result)
inputs: list[tuple[str, Path]] = []
if args.inputs:
for entry in args.inputs:
label, _, path = entry.partition(":")
inputs.append((label.strip(), Path(path.strip())))
# If the base file has an edited caption key too, add it automatically
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
# Print table
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()
|