mjev-spatial / src /spatial_jev /unified.py
multimodalart's picture
multimodalart HF Staff
Upload folder using huggingface_hub
85300f6 verified
Raw History Blame Contribute Delete
18.2 kB
"""One shared classifier with scalar interval decisions and three-step pointing."""
import json
import random
from pathlib import Path
import numpy as np
import torch
from PIL import Image
from torch import nn
from .hierarchy import (POINT_NAMES, ROOT_BOX, child_box, decode_point, decode_scalar,
point_path, roi_visible, scalar_fine_bins, scalar_interval, scalar_path,
visual_token_boxes)
from .schema import compile_prompt
from .numeric_tasks import codebook_for, numeric_prompt
def causal_visual_mask(length, visual_types, stages, query_start=0):
"""Causal text plus bidirectional vision ONLY within the same reveal stage.
Native Molmo2's all-image bidirectionality would leak future GT-selected crops.
Old prefix queries are not recomputed during incremental inference.
"""
keys = torch.arange(length)
queries = keys[query_start:]
allowed = keys[None] <= queries[:, None]
visual_types = torch.as_tensor(visual_types, dtype=torch.bool)
stages = torch.as_tensor(stages, dtype=torch.long)
allowed |= (visual_types[query_start:, None] & visual_types[None]
& (stages[query_start:, None] == stages[None]))
return torch.zeros((1, 1, len(queries), length)).masked_fill(~allowed[None, None], -torch.inf)
def apply_roi_mask(mask, visual_positions, boxes, roi, start=0, end=None):
keep = roi_visible(boxes, roi)
if not keep.any():
raise ValueError("ROI has no visual support")
blocked = torch.as_tensor(np.asarray(visual_positions)[~keep], dtype=torch.long)
mask[:, :, start:end, blocked] = -torch.inf
return int(keep.sum())
def move_inputs(inputs, device):
return {key: value.to(device=device, dtype=torch.bfloat16)
if value.is_floating_point() else value.to(device) for key, value in inputs.items()}
def collate_training(examples, pad_id):
"""Pad independent examples without cross-example attention or image-pooling offsets."""
length = max(p["inputs"]["input_ids"].shape[1] for p in examples)
batch = len(examples)
ids = torch.full((batch, length), pad_id, dtype=torch.long)
mask = torch.full((batch, 1, length, length), -torch.inf)
positions, counts, targets, weights = [], [], [], []
for index, packed in enumerate(examples):
n = packed["inputs"]["input_ids"].shape[1]
ids[index, :n] = packed["inputs"]["input_ids"][0]
mask[index, :, :n, :n] = packed["inputs"]["attention_mask"][0]
padding = torch.arange(n, length)
mask[index, 0, padding, padding] = 0
positions.extend((index, int(p)) for p in packed["positions"])
counts.extend(packed["counts"].tolist())
targets.extend(packed["targets"].tolist())
weights.extend([1 / len(packed["targets"])] * len(packed["targets"]))
inputs = {"input_ids": ids, "attention_mask": mask}
for key in ("pixel_values", "image_token_pooling", "image_grids", "image_num_crops"):
# Native pooling indices are local to each image; the model adds offsets itself.
inputs[key] = torch.cat([p["inputs"][key] for p in examples], dim=0)
return {"inputs": inputs, "positions": torch.tensor(positions), "counts": torch.tensor(counts),
"targets": torch.tensor(targets), "loss_weights": torch.tensor(weights) / batch}
class SharedClassifier(nn.Module):
def __init__(self, hidden_size, max_choices=32):
super().__init__()
self.norm = nn.LayerNorm(hidden_size)
self.linear = nn.Linear(hidden_size, max_choices)
def forward(self, hidden, counts):
with torch.autocast(device_type=hidden.device.type, enabled=False):
logits = self.linear(self.norm(hidden.float()))
invalid = torch.arange(logits.shape[-1], device=logits.device)[None] >= counts[:, None]
return logits.masked_fill(invalid, -torch.inf)
class UnifiedSpatialModel(nn.Module):
def __init__(self, backbone, classifier):
super().__init__()
self.backbone, self.classifier = backbone, classifier
def forward(self, inputs, positions, counts):
out = self.backbone(**inputs, use_cache=False)
hidden = (out.last_hidden_state[positions[:, 0], positions[:, 1]] if positions.ndim == 2
else out.last_hidden_state[0, positions])
return self.classifier(hidden, counts)
def build_unified_model(config, checkpoint=None, trainable=True):
from peft import LoraConfig, PeftModel, get_peft_model
from transformers import AutoModelForImageTextToText
full = AutoModelForImageTextToText.from_pretrained(
config["base_model"], trust_remote_code=True, local_files_only=True,
dtype=torch.bfloat16, attn_implementation="sdpa")
backbone, hidden = full.model, full.config.text_config.hidden_size
del full
if checkpoint:
backbone = PeftModel.from_pretrained(backbone, str(Path(checkpoint) / "adapter"),
is_trainable=trainable)
else:
backbone = get_peft_model(backbone, LoraConfig(
r=config["lora_rank"], lora_alpha=config["lora_alpha"],
lora_dropout=config["lora_dropout"], target_modules=config["lora_targets"], bias="none"))
classifier = SharedClassifier(hidden, config["max_choices"])
if checkpoint:
from safetensors.torch import load_file
classifier.load_state_dict(load_file(str(Path(checkpoint) / "classifier.safetensors")))
if trainable and config.get("gradient_checkpointing", True):
backbone.get_base_model().gradient_checkpointing_enable(
gradient_checkpointing_kwargs={"use_reentrant": False})
model = UnifiedSpatialModel(backbone, classifier)
if any(p.requires_grad for n, p in backbone.named_parameters() if "vision_backbone" in n):
raise ValueError("Visual encoder/projector must be frozen in this ablation")
if not trainable:
model.requires_grad_(False).eval()
return model
def save_unified_checkpoint(model, processor, config, output, step, optimizer=None):
from safetensors.torch import save_file
output = Path(output)
output.mkdir(parents=True, exist_ok=False)
model.backbone.save_pretrained(output / "adapter", safe_serialization=True)
save_file({k: v.detach().cpu().contiguous() for k, v in model.classifier.state_dict().items()},
str(output / "classifier.safetensors"))
processor.save_pretrained(output / "processor")
(output / "config.json").write_text(json.dumps(config, indent=2) + "\n")
if optimizer:
torch.save({"step": step, "optimizer": optimizer.state_dict()}, output / "optimizer.pt")
(output / "checkpoint.json").write_text(json.dumps({
"step": step, "format": "base_plus_lora_plus_one_classifier",
"point_variant": config["point_variant"], "base_revision": config["base_revision"],
"calibrated": False}, indent=2) + "\n")
class UnifiedBuilder:
def __init__(self, processor, config):
self.processor, self.config = processor, config
self.codebook = config["scalar_codebook"]
self.variant = config["point_variant"]
if self.variant not in ("single_image", "crop_refill", "roi_mask"):
raise ValueError("Unknown point ablation")
self.patch_id = processor.tokenizer.convert_tokens_to_ids("<im_patch>")
self.header = processor.tokenizer.encode("<|im_start|>assistant\n", add_special_tokens=False)
def render(self, text, image_count):
content = [{"type": "image"} for _ in range(image_count)]
content.append({"type": "text", "text": text})
# Render each reveal separately. Rendering all messages at once hoists future crops.
return self.processor.apply_chat_template([{"role": "user", "content": content}],
tokenize=False, add_generation_prompt=True)
@staticmethod
def load_images(inp):
images = []
for media in inp["media"]:
with Image.open(media["uri"]) as image:
images.append(image.convert("RGB"))
return images
@staticmethod
def crop(original, box):
w, h = original.size
extent = (box[0] * w, box[1] * h, box[2] * w, box[3] * h)
size = (max(1, round(extent[2] - extent[0])), max(1, round(extent[3] - extent[1])))
return original.transform(size, Image.Transform.EXTENT, extent, Image.Resampling.BILINEAR)
def point_prompt(self, inp, box, level):
bounds = ", ".join(f"{x:.6f}" for x in box)
options = "\n".join(f"{i+1}. {name}" for i, name in enumerate(POINT_NAMES))
return (f"Request: {inp['question']}\nChoose one valid point satisfying the request. "
f"Localization step {level+1} of 3. The current rectangle in the ORIGINAL image "
f"is (left, top, right, bottom) = ({bounds}), normalized to [0, 1]. "
"The origin is top-left, x increases rightward and y downward. "
"Any image appended after the original is a zoomed view of its current rectangle. "
"Divide the CURRENT rectangle into three equal rows and three equal columns. "
f"Which region contains the point?\nOptions:\n{options}\nSelect exactly one option.")
def scalar_prompt(self, inp, root=None):
cb = codebook_for(inp, self.codebook)
if cb.get("unit", "m") != "m":
return numeric_prompt(inp, root, cb)
if root is None:
options = ["exactly zero meters"]
options += [f"{lo:g} to {hi:g} meters" for lo, hi in zip(cb["edges_m"], cb["edges_m"][1:])]
options[1] = f"greater than zero and less than {cb['edges_m'][1]:g} meters"
options += [f"greater than {cb['edges_m'][-1]:g} meters"]
instruction = "Select the range containing the nonnegative measurement."
else:
lo, hi = scalar_interval(root, cb)
fine = scalar_fine_bins(root, cb)
step = (hi - lo) / fine
options = [f"{lo+i*step:.6g} to {lo+(i+1)*step:.6g} meters"
for i in range(fine)]
instruction = f"Refine the previously selected range {lo:g} to {hi:g} meters."
text = (f"Question: {inp['question']}\n{instruction} "
"Ranges include the lower endpoint and exclude the upper endpoint, "
"except that the maximum finite endpoint is included. Exact zero has its own class."
"\nOptions:\n" + "\n".join(f"{i+1}. {s}" for i, s in enumerate(options))
+ "\nSelect exactly one option.")
return text, len(options)
def initial(self, inp, seed):
task, mapping = inp["answer_space"]["kind"], []
if task == "choice":
order = list(range(len(inp["answer_space"]["options"])))
if not self.config.get("preserve_option_order", False):
random.Random(seed).shuffle(order)
text, mapping = compile_prompt(inp, order)
count = len(mapping)
elif task == "scalar":
text, count = self.scalar_prompt(inp)
else:
text, count = self.point_prompt(inp, ROOT_BOX, 0), 9
return self.render(text, len(inp["media"])), count, mapping
def encode(self, text, images, metadata=False, continuation=False):
kwargs = {"text": [text], "return_tensors": "pt", "padding": False,
"return_mm_token_type_ids": True}
if images:
kwargs.update(images=images, return_pointing_metadata=metadata)
encoded = self.processor(**kwargs)
meta = encoded.pop("metadata", None)
if continuation:
bos = self.processor.tokenizer.bos_token_id or self.processor.tokenizer.eos_token_id
if encoded["input_ids"][0, 0].item() != bos:
raise ValueError("Unexpected continuation BOS")
for key in ("input_ids", "attention_mask", "token_type_ids"):
encoded[key] = encoded[key][:, 1:]
return dict(encoded), meta
def geometry(self, inputs, metadata):
positions = (inputs["input_ids"][0] == self.patch_id).nonzero().flatten().numpy()
boxes = visual_token_boxes(metadata, inputs["image_grids"].tolist(),
inputs["image_num_crops"].tolist(), inputs["pixel_values"].shape[1])
if len(positions) != len(boxes):
raise ValueError("Patch positions and geometric supports differ")
return positions, boxes
def training_example(self, row, seed):
inp, task = row["input"], row["input"]["answer_space"]["kind"]
text, count, mapping = self.initial(inp, seed)
images = self.load_images(inp)
original = images[0]
if task == "choice":
path = [mapping.index(row["target"]["option_id"])]
elif task == "scalar":
path = scalar_path(row["target"]["value"], self.codebook)
else:
path = point_path(row["target"]["points"][0])
counts, boxes, box = [count], [ROOT_BOX], ROOT_BOX
for stage in range(1, len(path)):
if task == "point":
box = child_box(box, path[stage - 1])
question, count = self.point_prompt(inp, box, stage), 9
extra = [self.crop(original, box)] if self.variant == "crop_refill" else []
else:
question, count = self.scalar_prompt(inp, path[0])
extra = []
text += f"{path[stage-1]+1}<|im_end|>\n" + self.render(question, len(extra))
images.extend(extra)
counts.append(count)
boxes.append(box)
needs_geometry = task == "point" and self.variant == "roi_mask"
inputs, metadata = self.encode(text, images, metadata=needs_geometry)
ids = inputs["input_ids"][0].tolist()
positions = [i + len(self.header) - 1 for i in range(len(ids) - len(self.header) + 1)
if ids[i:i+len(self.header)] == self.header]
if len(positions) != len(path):
raise ValueError("Decision marker count mismatch")
if len(ids) > self.config["max_sequence_length"]:
raise ValueError("Sequence budget exceeded")
types = inputs.pop("token_type_ids")[0]
stages = torch.zeros(len(ids), dtype=torch.long)
for stage in range(1, len(path)):
stages[positions[stage-1]+1:positions[stage]+1] = stage
mask = causal_visual_mask(len(ids), types, stages)
if needs_geometry:
visual_positions, supports = self.geometry(inputs, metadata)
for stage in range(1, len(path)):
apply_roi_mask(mask, visual_positions, supports, boxes[stage],
positions[stage-1]+1, positions[stage]+1)
inputs["attention_mask"] = mask
return {"inputs": inputs, "positions": torch.tensor(positions),
"counts": torch.tensor(counts), "targets": torch.tensor(path),
"mapping": mapping, "boxes": boxes}
@torch.inference_mode()
def predict_unified(model, builder, inp, device, seed, forced_path=None):
"""No targets are accepted. forced_path is solely for cache-equivalence integration tests."""
task = inp["answer_space"]["kind"]
images = builder.load_images(inp)
original = images[0]
text, count, mapping = builder.initial(inp, seed)
need_geometry = task == "point" and builder.variant == "roi_mask"
encoded, meta = builder.encode(text, images, metadata=need_geometry)
geom = builder.geometry(encoded, meta) if need_geometry else None
all_types, all_stages = [], []
path, logits_trace, support_counts = [], [], []
box, cache, previous_length, total_input_tokens = ROOT_BOX, None, 0, 0
for stage in range(3 if task == "point" else 2 if task == "scalar" else 1):
types = encoded.pop("token_type_ids")[0].tolist()
all_types.extend(types)
all_stages.extend([stage] * len(types))
total_input_tokens += encoded["input_ids"].shape[1]
if total_input_tokens > builder.config["max_sequence_length"]:
raise ValueError("Rollout sequence budget exceeded")
mask = causal_visual_mask(total_input_tokens, all_types, all_stages, previous_length)
if need_geometry and stage > 0:
support_counts.append(apply_roi_mask(mask, *geom, box))
encoded["attention_mask"] = mask
inputs = move_inputs(encoded, device)
inputs["cache_position"] = torch.arange(previous_length, total_input_tokens, device=device)
output = model.backbone(**inputs, past_key_values=cache, use_cache=True)
logits = model.classifier(output.last_hidden_state[:, -1],
torch.tensor([count], device=device))
logits_trace.append(logits[0, :count].float().cpu().tolist())
choice = int(logits.argmax(-1).item()) if forced_path is None else forced_path[stage]
path.append(choice)
cache, previous_length = output.past_key_values, total_input_tokens
if task == "choice":
break
if task == "scalar":
if stage == 1 or choice in (0, len(builder.codebook["edges_m"])):
break
question, count = builder.scalar_prompt(inp, choice)
extra = []
else:
box = child_box(box, choice)
if stage == 2:
break
question, count = builder.point_prompt(inp, box, stage + 1), 9
extra = [builder.crop(original, box)] if builder.variant == "crop_refill" else []
text = f"{choice+1}<|im_end|>\n" + builder.render(question, len(extra))
encoded, _ = builder.encode(text, extra, continuation=True)
if task == "choice":
prediction = mapping[path[0]]
elif task == "scalar":
prediction = decode_scalar(path, builder.codebook)
else:
prediction = decode_point(path)
return {"prediction": prediction, "path": path, "logits": logits_trace,
"mapping": mapping, "input_tokens": total_input_tokens,
"roi_support_counts": support_counts}