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