File size: 8,945 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
209
210
"""RePS-style preference suppression: dataset + bounded preference loss.

Replaces the EFUF targeted-NLL *gradient ascent* (``efuf_targeted_nll_loss``),
which is unbounded and unstable, with a reference-free, length-normalized
bidirectional preference objective in the spirit of RePS (arXiv:2505.20809)
and SimPO. The model is trained to PREFER a toilet-free caption (winning) over
a toilet-containing caption (losing) for the same image. Because the objective
is ``-log σ(β·(lp_w − lp_l) − γ)`` it saturates once the clean caption is
sufficiently preferred, so it cannot blow up the way NLL ascent does.

This acts at the OUTPUT (token distribution) level, complementing the per-layer
attention probe which suppresses at the activation level.

Preference pairs are mined from the EFUF synthetic data
(``pos_neg_synthetic_train.json``): each hallucination negative (score below the
CLIP threshold) carries a character-level ``position`` marking where the
hallucinated clause begins, so:
    winning (clean)      = sentence[:position]   (toilet-free prefix)
    losing (hallucinated)= sentence              (toilet clause appended)
The two share the same prefix; only the trailing hallucinated clause differs.
"""
from __future__ import annotations

import json as _json
import re
from pathlib import Path as _Path

import torch
import torch.nn.functional as F
from PIL import Image as _PILImage


def _build_object_regex(object_keywords) -> "re.Pattern":
    """Case-insensitive regex matching any object keyword (e.g. oven|stove|range)."""
    kws = object_keywords or ["toilet"]
    stems = sorted({re.escape(k.lower()) for k in kws}, key=len, reverse=True)
    return re.compile("|".join(stems), re.IGNORECASE)


class PreferenceDataset(torch.utils.data.Dataset):
    """(image, winning clean caption, losing hallucinated caption) triples.

    Mirrors the image-loading + tokenization conventions of
    ``finetune_adv_gen_resume.EFUFNegativeDataset`` so the same HF dataset
    (looked up by ``image_id``) supplies the images.
    """

    def __init__(
        self,
        processor,
        data_path: str,
        image_dir: str | None = None,
        dataset_id: str | None = None,
        hal_clip_thres: float = 23.0,
        min_position: int = 5,
        require_object_in_tail: bool = True,
        prompt: str = "Describe this image.",
        max_length: int = 1024,
        object_keywords: list[str] | None = None,
    ):
        obj_re = _build_object_regex(object_keywords)
        with open(data_path) as f:
            raw = _json.load(f)
        self.samples: list[dict] = []
        self._hf_lookup: dict | None = None
        self._image_dir: _Path | None = None

        if dataset_id:
            from datasets import load_dataset as _load_ds
            _ds_all = _load_ds(dataset_id)
            self._hf_lookup = {}
            for split in _ds_all:
                for item in _ds_all[split]:
                    self._hf_lookup[item["image_id"]] = item["image"]
            print(f"  PreferenceDataset: built HF image lookup with "
                  f"{len(self._hf_lookup)} entries from {dataset_id}")
        elif image_dir:
            self._image_dir = _Path(image_dir)

        for d in raw:
            if d["score"] >= hal_clip_thres:
                continue  # keep only genuine hallucinations
            pos = int(d["position"])
            if pos < min_position:
                continue
            sentence = d["sentence"]
            context = sentence[:pos]
            tail = sentence[pos:]
            if require_object_in_tail and not obj_re.search(tail):
                continue
            if obj_re.search(context):
                continue  # clean prefix must be object-free
            image_id = (
                d["image"].split("/")[-1]
                .replace(".jpg", "").replace(".png", "").replace(".jpeg", "")
            )
            if self._hf_lookup is not None:
                if image_id not in self._hf_lookup:
                    continue
            elif self._image_dir is not None:
                if not (self._image_dir / d["image"]).exists():
                    continue
            self.samples.append({
                "image_id": image_id,
                "win": context,        # clean caption
                "lose": sentence,      # hallucinated caption
            })

        self.processor = processor
        self.prompt = prompt
        self.max_length = max_length
        source = f"HF({dataset_id})" if dataset_id else f"local({image_dir})"
        print(f"PreferenceDataset: {len(self.samples)} preference pairs "
              f"(score < {hal_clip_thres}, position >= {min_position}) from {source}")

    def __len__(self):
        return len(self.samples)

    def _load_image(self, image_id: str):
        if self._hf_lookup is not None:
            return self._hf_lookup[image_id].convert("RGB")
        return _PILImage.open(str(self._image_dir / (image_id + ".jpg"))).convert("RGB")

    def _encode(self, image, response_text: str) -> dict:
        """Tokenize prefix+response with the image; mark response tokens (tail)."""
        prefix = f"<image>\nUSER: {self.prompt}\nASSISTANT: "
        full_text = prefix + response_text
        enc = self.processor(
            images=image, text=full_text, return_tensors="pt",
            padding="max_length", max_length=self.max_length, truncation=True,
        )
        input_ids = enc["input_ids"][0]
        attn = enc["attention_mask"][0]
        tok = self.processor.tokenizer
        n_full = len(tok.encode(full_text, add_special_tokens=True))
        n_prefix = len(tok.encode(prefix, add_special_tokens=True))
        resp_len = max(n_full - n_prefix, 0)
        S = input_ids.shape[0]
        real_len = int(attn.sum().item())
        resp_mask = torch.zeros(S, dtype=torch.bool)
        # Left-padded: real tokens occupy the tail; response is the last resp_len.
        resp_start = S - resp_len
        if 0 < resp_start < S and resp_len <= real_len:
            resp_mask[resp_start:] = True
        return {
            "pixel_values": enc["pixel_values"][0],
            "input_ids": input_ids,
            "attention_mask": attn,
            "response_mask": resp_mask,
        }

    def __getitem__(self, idx):
        s = self.samples[idx]
        image = self._load_image(s["image_id"])
        win = self._encode(image, s["win"])
        lose = self._encode(image, s["lose"])
        return {
            "win_pixel_values": win["pixel_values"],
            "win_input_ids": win["input_ids"],
            "win_attention_mask": win["attention_mask"],
            "win_response_mask": win["response_mask"],
            "lose_pixel_values": lose["pixel_values"],
            "lose_input_ids": lose["input_ids"],
            "lose_attention_mask": lose["attention_mask"],
            "lose_response_mask": lose["response_mask"],
        }


def sequence_logprob(
    logits: torch.Tensor,          # (B, S_full, V) — model output
    input_ids: torch.Tensor,       # (B, S_text)
    response_mask: torch.Tensor,   # (B, S_text) bool — True at response tokens
    n_image_patches: int,
) -> torch.Tensor:
    """Length-normalized mean log-prob of the response tokens, per row (B,).

    Alignment mirrors ``efuf_targeted_nll_loss``: LLaVA expands the single
    ``<image>`` token into ``n_image_patches`` embeddings, so we front-pad the
    text tensors by ``n_image_patches - 1`` to match the full logits length,
    then apply the causal shift (logit at j predicts token j+1).
    """
    B, S_full, V = logits.shape
    text_offset = n_image_patches - 1
    labels_full = F.pad(input_ids.long(), (text_offset, 0), value=-100)   # (B, S_full)
    resp_full = F.pad(response_mask, (text_offset, 0), value=False)       # (B, S_full)
    labels_full = labels_full.masked_fill(~resp_full, -100)

    logp = F.log_softmax(logits[:, :-1, :].float(), dim=-1)               # (B, S_full-1, V)
    tgt = labels_full[:, 1:]                                              # (B, S_full-1)
    valid = tgt != -100
    gathered = logp.gather(-1, tgt.clamp(min=0).unsqueeze(-1)).squeeze(-1)  # (B, S_full-1)
    gathered = gathered * valid.float()
    counts = valid.float().sum(dim=-1).clamp(min=1.0)
    return gathered.sum(dim=-1) / counts                                  # (B,)


def reps_preference_loss(
    lp_win: torch.Tensor,    # (B,) length-normalized logprob of clean caption
    lp_lose: torch.Tensor,   # (B,) length-normalized logprob of hallucinated caption
    beta: float = 2.0,
    gamma: float = 0.5,
) -> torch.Tensor:
    """Reference-free SimPO/RePS preference loss: prefer clean over hallucinated.

    L = -mean[ log σ(β·(lp_win − lp_lose) − γ) ]. Bounded and saturating, so it
    is stable to optimize (unlike unbounded NLL gradient ascent).
    """
    margin = beta * (lp_win - lp_lose) - gamma
    return -F.logsigmoid(margin).mean()