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()