Cem13's picture
Release Kodama Core with weights, runtime, attribution, and evaluation
c7893fa verified
Raw History Blame Contribute Delete
27 kB
"""Multimodal prototype: typed questions about IMAGES (optionally with a text state).
from jevlike.mm import MultimodalSystemOne
m = MultimodalSystemOne.load("checkpoints/jevlike-mm-proto")
m.predict(None, {"kind": Choice("What type of document is this?", ["invoice", "letter", "resume"]),
"signed": Noul("The page is handwritten")}, image="scan.png")
Design (research prototype, see reports/mm_proto.md):
* **Vision**: a frozen SigLIP (default google/siglip-base-patch16-224). Its 14x14 patch grid is
average-pooled to 7x7 and SigLIP's pooled (attention-pooling head) embedding is prepended, giving
``n_img_tokens`` = 50 vectors. A trainable 2-layer MLP projector maps them into ModernBERT's
token-embedding space (plus a learned per-slot position vector).
* **Input**: image tokens are spliced into the sequence right after [CLS] through
``inputs_embeds``, framed by learned [IMG_START] / [IMG_END] vectors::
[CLS] [IMG_START] <50 image tokens> [IMG_END] <state text> [SEP] <type tag> <instr> [SEP] [MASK] key: text ...
The question part, the option markers, the decision head, the marker scorer and the escalate
head are the text model's (jevlike/model.py), unchanged.
* **Similarity prior** (``sim_prior``): SigLIP's own image-text score for every option (option text
for choice/score, the statement for noul, scored at the ``true`` marker) is turned into a few
features (raw logit, sigmoid, z-score / softmax / gap-to-max within the question, question type)
and added, through a zero-initialized MLP, to that option's [MASK] embedding. This hands the
language side SigLIP's zero-shot knowledge of label names it has never been trained on; the
encoder still reads the instructions and options and decides. Training drops the prior for a
fraction of questions (``sim_dropout``), so ``use_sim=False`` is a meaningful ablation.
* **Text-only inputs** go through exactly the text path (no image tokens, no prior), so the
checkpoint directory is also a valid text ``SystemOne`` checkpoint: ``model.safetensors`` holds
the DecisionModel, ``mm.safetensors`` + ``mm_config.json`` the multimodal parts; SigLIP weights
come from ``mm_config.vision`` (HF id / path) or ``vision.safetensors`` if present.
Limitations: 224x224 input (small document text is unreadable), one image per question, long
states are truncated (no chunking) when an image is present.
"""
from __future__ import annotations
import json
import math
import os
import re
import time
from dataclasses import asdict, dataclass, field
from typing import Callable, Mapping, Sequence
import numpy as np
import torch
import torch.nn.functional as F
from safetensors.torch import load_file, save_file
from torch import nn
from jevlike.calibrate import temperature
from jevlike.model import DecisionModel
from jevlike.predict import SystemOne
from jevlike.serialize import TYPE_IDS, Encoded, Serializer, TokenBudgetBatchSampler, collate
from jevlike.types import QTYPES, PublicQuestion, Question, Record
MM_CONFIG, MM_WEIGHTS, VISION_WEIGHTS = "mm_config.json", "mm.safetensors", "vision.safetensors"
N_SIM_FEATS = 6 + len(QTYPES)
IMG_OFFSET = 1 # image block starts right after [CLS]
Questions = Mapping[str, "PublicQuestion | Question"]
ImageLike = "PIL.Image.Image | str | os.PathLike | np.ndarray | None"
@dataclass
class MMConfig:
vision: str = "google/siglip-base-patch16-224"
vision_config: dict = field(default_factory=dict) # SiglipConfig.to_dict(); makes the architecture self-contained
image_size: int = 224
pool: int = 2 # average-pool the patch grid by this factor (14x14 -> 7x7)
use_pooled: bool = True # prepend SigLIP's pooled (MAP-head) embedding as one more token
proj_hidden: int = 2048
sim_prior: bool = True
sim_dropout: float = 0.3 # training: fraction of image questions whose prior is withheld
sim_template: str = "{}"
text_max_len: int = 64 # SigLIP text length (SigLIP expects padding to 64)
@property
def grid(self) -> int:
vc = self.vision_config.get("vision_config", {})
return (vc.get("image_size", self.image_size) // vc.get("patch_size", 16)) // self.pool
@property
def n_img_tokens(self) -> int:
return self.grid ** 2 + int(self.use_pooled)
@property
def n_slots(self) -> int: # [IMG_START] + tokens + [IMG_END]
return self.n_img_tokens + 2
@classmethod
def load(cls, path: str) -> "MMConfig":
with open(os.path.join(path, MM_CONFIG)) as f:
d = json.load(f)
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
# ---------------------------------------------------------------- images & option prompts
def load_pixels(image, size: int = 224) -> torch.Tensor:
"""PIL image / path / HxWx3 uint8 array -> [3, size, size] float tensor, SigLIP preprocessing
(squash-resize bicubic, scale to [-1, 1])."""
from PIL import Image
if isinstance(image, (str, os.PathLike)):
image = Image.open(image)
elif isinstance(image, np.ndarray):
image = Image.fromarray(image)
image = image.convert("RGB")
if image.size != (size, size):
image = image.resize((size, size), Image.BICUBIC)
x = torch.from_numpy(np.asarray(image, dtype=np.float32).copy()).permute(2, 0, 1) / 255.0
return (x - 0.5) / 0.5
_OPAQUE = re.compile(r"^([A-Za-z]|\d+|(opt_|option_|class_|label_|c|cat_|choice_|id_)\d+|[A-Z]{2,4})$")
def option_prompts(q: Question) -> list[str | None]:
"""The text SigLIP scores for each marker: option text (readable key + text) for choice/score,
the statement at the ``true`` marker for noul (``false`` gets none)."""
if q.type == "noul":
return [None, q.instructions.strip()]
out = []
for o in q.options:
key, text = o.key.strip(), o.text.strip()
if q.type == "score" or _OPAQUE.match(key):
out.append(text or key)
else:
k = key.replace("_", " ")
out.append(f"{k}: {text}" if text and text.lower() != k.lower() else k)
return out
class MMSerializer:
"""Text serializer + image slots: image items get ``n_slots`` placeholder positions after [CLS]
(their text is serialized with ``max_len - n_slots``); markers are shifted accordingly."""
def __init__(self, tokenizer, max_len: int, head_max_len: int, n_slots: int):
self.text = Serializer(tokenizer, max_len, head_max_len)
img_len = max_len - n_slots
self.img = Serializer(tokenizer, img_len, min(head_max_len, img_len - 64))
self.n_slots, self.pad = n_slots, self.text.pad
def encode_many(self, items: Sequence[tuple[str, Question, bool]]) -> list[Encoded]:
out: list[Encoded | None] = [None] * len(items)
for has_img, ser in ((False, self.text), (True, self.img)):
idx = [i for i, it in enumerate(items) if bool(it[2]) == has_img]
if not idx:
continue
for i, e in zip(idx, ser.encode_many([(items[i][0] or "", items[i][1]) for i in idx])):
if has_img:
e = Encoded([e.input_ids[0]] + [self.pad] * self.n_slots + e.input_ids[1:],
[m + self.n_slots for m in e.markers], e.qtype, e.trunc)
out[i] = e
return out # type: ignore[return-value]
def mm_collate(encs: Sequence[Encoded], labels, pad_id: int, image_of: Sequence[int], pixels: Sequence[torch.Tensor],
prompts: Sequence[Sequence[str | None]] | None, use_sim: Sequence[bool] | None = None) -> dict:
"""``serialize.collate`` + image fields.
image_of[i]: index into ``pixels`` for row i, or -1 (text-only row). prompts[i]: per-marker SigLIP
prompt (or None); use_sim[i]: False withholds the prior for row i. Adds ``pixel_values`` [N, 3, S, S],
``image_rows`` / ``image_index`` [R], ``sim_strings`` (list, unique) and ``sim_marker_text`` [M]
(index into sim_strings, -1 = none)."""
b = collate(encs, labels, pad_id)
used = sorted({j for j in image_of if j >= 0})
remap = {j: k for k, j in enumerate(used)}
rows = [i for i, j in enumerate(image_of) if j >= 0]
b["image_rows"] = torch.tensor(rows, dtype=torch.long)
b["image_index"] = torch.tensor([remap[image_of[i]] for i in rows], dtype=torch.long)
b["pixel_values"] = torch.stack([pixels[j] for j in used]) if used else torch.zeros(0, 3, 1, 1)
strings: dict[str, int] = {}
mt: list[int] = []
for i, e in enumerate(encs):
ps = prompts[i] if prompts is not None and image_of[i] >= 0 and (use_sim is None or use_sim[i]) else None
for r in range(len(e.markers)):
s = ps[r] if ps is not None else None
mt.append(strings.setdefault(s, len(strings)) if s else -1)
b["sim_strings"] = list(strings)
b["sim_marker_text"] = torch.tensor(mt, dtype=torch.long)
return b
# ---------------------------------------------------------------- model
class MultimodalDecisionModel(nn.Module):
def __init__(self, dm: DecisionModel, mcfg: MMConfig, siglip: nn.Module | None = None,
text_tokenize: Callable[[list[str]], torch.Tensor] | None = None):
super().__init__()
from transformers import SiglipConfig, SiglipModel
if siglip is None: # architecture only (weights loaded by the caller)
siglip = SiglipModel(SiglipConfig.from_dict(mcfg.vision_config))
self.dm, self.mcfg = dm, mcfg
mcfg.vision_config = siglip.config.to_dict()
self.siglip = siglip.eval().requires_grad_(False)
self.text_tokenize = text_tokenize
Dv, D = siglip.config.vision_config.hidden_size, dm.backbone.config.hidden_size
n = mcfg.n_img_tokens
self.proj = nn.Sequential(nn.LayerNorm(Dv), nn.Linear(Dv, mcfg.proj_hidden), nn.GELU(),
nn.Linear(mcfg.proj_hidden, D))
self.img_pos = nn.Parameter(torch.randn(n, D) * 0.02)
self.img_start = nn.Parameter(torch.randn(D) * 0.02)
self.img_end = nn.Parameter(torch.randn(D) * 0.02)
self.sim_mlp = nn.Sequential(nn.Linear(N_SIM_FEATS, 256), nn.GELU(), nn.Linear(256, D))
nn.init.zeros_(self.sim_mlp[-1].weight) # starts as "no prior"
nn.init.zeros_(self.sim_mlp[-1].bias)
self._text_cache: dict[str, torch.Tensor] = {}
# the frozen SigLIP never trains (and stays in eval mode)
def train(self, mode: bool = True):
super().train(mode)
self.siglip.eval()
return self
def mm_parameters(self) -> list[tuple[str, nn.Parameter]]:
return [(n, p) for n, p in self.named_parameters() if not n.startswith(("dm.", "siglip."))]
@property
def device(self) -> torch.device:
return self.img_pos.device
# -------------------------------------------------------- SigLIP side
@torch.no_grad()
def image_features(self, pixel_values: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
"""-> (tokens [N, n_img_tokens, Dv], normalized image embedding [N, Dv])."""
vm = self.siglip.vision_model
dtype = next(vm.parameters()).dtype
out = vm(pixel_values=pixel_values.to(self.device, dtype))
h = out.last_hidden_state
N, P, Dv = h.shape
g = int(math.isqrt(P))
grid = F.avg_pool2d(h.transpose(1, 2).reshape(N, Dv, g, g).float(), self.mcfg.pool)
toks = grid.flatten(2).transpose(1, 2)
pooled = out.pooler_output.float()
if self.mcfg.use_pooled:
toks = torch.cat([pooled[:, None], toks], 1)
return toks, F.normalize(pooled, dim=-1)
@torch.no_grad()
def text_embeds(self, strings: Sequence[str]) -> torch.Tensor:
"""Normalized SigLIP text embeddings [U, Dv] (cached per string)."""
if not strings:
return torch.zeros(0, self.siglip.config.text_config.hidden_size, device=self.device)
todo = [s for s in dict.fromkeys(strings) if s not in self._text_cache]
for i in range(0, len(todo), 256):
chunk = todo[i:i + 256]
ids = self.text_tokenize([self.mcfg.sim_template.format(s) for s in chunk]).to(self.device)
emb = F.normalize(self.siglip.text_model(input_ids=ids).pooler_output.float(), dim=-1)
for s, e in zip(chunk, emb):
self._text_cache[s] = e
if len(self._text_cache) > 200_000:
self._text_cache.clear()
return torch.stack([self._text_cache[s] for s in strings])
def sim_features(self, b: dict, img_emb: torch.Tensor, B: int, K: int) -> tuple[torch.Tensor, torch.Tensor]:
"""Per-marker prior features. Returns (marker positions into marker_idx, features [m, N_SIM_FEATS])."""
mt = b["sim_marker_text"]
sel = (mt >= 0).nonzero().flatten()
if len(sel) == 0:
return sel, torch.zeros(0, N_SIM_FEATS, device=self.device)
txt = self.text_embeds(b["sim_strings"])
row_img = torch.full((B,), -1, dtype=torch.long, device=self.device)
row_img[b["image_rows"]] = b["image_index"]
seg, rank = b["marker_seg"][sel], b["marker_rank"][sel]
cos = (img_emb[row_img[seg]] * txt[mt[sel]]).sum(-1)
sl = self.siglip
logit = cos * sl.logit_scale.float().exp() + sl.logit_bias.float()
# within-question statistics over the markers that have a prompt
M = torch.full((B, K), float("nan"), device=self.device).index_put((seg, rank), logit)
valid = ~M.isnan()
cnt = valid.sum(-1).clamp(min=1)
Mz = M.nan_to_num(0.0)
mean = Mz.sum(-1) / cnt
std = (((Mz - mean[:, None]) ** 2) * valid).sum(-1).div(cnt).sqrt().clamp(min=1e-3)
mx = M.masked_fill(~valid, -1e9).max(-1).values
soft = M.masked_fill(~valid, -1e9).softmax(-1)
qt = b["qtype"][seg]
multi = (cnt[seg] > 1).float()
feats = torch.stack([
logit / 10, torch.sigmoid(logit),
multi * (logit - mean[seg]) / std[seg], multi * soft[seg, rank], multi * (logit - mx[seg]) / 10,
torch.ones_like(logit)], -1)
feats = torch.cat([feats, F.one_hot(qt, len(QTYPES)).float()], -1)
return sel, feats
# -------------------------------------------------------- forward
def embed(self, b: dict) -> torch.Tensor:
ids = b["input_ids"]
emb = self.dm.backbone.get_input_embeddings()(ids)
B, T, D = emb.shape
rows = b.get("image_rows")
if rows is None or len(rows) == 0:
return emb
toks, img_emb = self.image_features(b["pixel_values"])
v = (self.proj(toks) + self.img_pos).to(emb.dtype)
n = self.mcfg.n_img_tokens
emb = emb.clone()
emb[rows, IMG_OFFSET] = self.img_start.to(emb.dtype)
emb[rows, IMG_OFFSET + 1:IMG_OFFSET + 1 + n] = v[b["image_index"]]
emb[rows, IMG_OFFSET + 1 + n] = self.img_end.to(emb.dtype)
if self.mcfg.sim_prior and "sim_marker_text" in b:
K = int(b["n_options"].max())
sel, feats = self.sim_features(b, img_emb, B, K)
if len(sel):
add = self.sim_mlp(feats).to(emb.dtype)
emb = emb.reshape(B * T, D).index_add(0, b["marker_idx"][sel], add).reshape(B, T, D)
return emb
def forward(self, **b) -> dict[str, torch.Tensor]:
rows = b.get("image_rows")
if rows is None or len(rows) == 0: # pure text batch: exactly the text model
return self.dm(**b)
return self.dm(**b, inputs_embeds=self.embed(b))
# -------------------------------------------------------- io
def save_pretrained(self, path: str, tokenizer=None, save_vision: bool = False) -> None:
"""model.safetensors / config.json = the DecisionModel (a valid text checkpoint);
mm.safetensors / mm_config.json = projector, image slots, prior MLP."""
self.dm.save_pretrained(path, tokenizer)
with open(os.path.join(path, MM_CONFIG), "w") as f:
json.dump(asdict(self.mcfg), f, indent=1, default=str)
save_file({n: p.detach().contiguous().cpu() for n, p in self.mm_parameters()}, os.path.join(path, MM_WEIGHTS))
if save_vision:
save_file({k: v.detach().contiguous().cpu() for k, v in self.siglip.state_dict().items()},
os.path.join(path, VISION_WEIGHTS))
@classmethod
def from_pretrained(cls, path: str, device: str | torch.device = "cpu", vision: str | None = None,
text_tokenize=None) -> "MultimodalDecisionModel":
device = torch.device(device)
dm = DecisionModel.from_pretrained(path, device)
mcfg = MMConfig.load(path)
siglip, tt = load_siglip(path if os.path.exists(os.path.join(path, VISION_WEIGHTS)) else (vision or mcfg.vision),
mcfg, device)
model = cls(dm, mcfg, siglip, text_tokenize or tt)
sd = load_file(os.path.join(path, MM_WEIGHTS))
missing = model.load_state_dict(sd, strict=False)
bad = [k for k in missing.missing_keys if not k.startswith(("dm.", "siglip."))]
assert not bad and not missing.unexpected_keys, (bad, missing.unexpected_keys)
return model.to(device)
@classmethod
def from_text_checkpoint(cls, path: str, mcfg: MMConfig | None = None, device="cpu") -> "MultimodalDecisionModel":
"""New multimodal model around a trained text checkpoint (fresh projector / prior MLP)."""
device = torch.device(device)
mcfg = mcfg or MMConfig()
siglip, tt = load_siglip(mcfg.vision, mcfg, device)
return cls(DecisionModel.from_pretrained(path, device), mcfg, siglip, tt).to(device)
def load_siglip(src: str, mcfg: MMConfig, device: torch.device):
"""SigLIP model (frozen; bf16 on CUDA) + a text tokenize function. ``src`` is an HF id / dir, or a
checkpoint dir holding vision.safetensors (+ mm_config.json with the architecture)."""
from transformers import AutoTokenizer, SiglipConfig, SiglipModel
if os.path.exists(os.path.join(src, VISION_WEIGHTS)):
model = SiglipModel(SiglipConfig.from_dict(mcfg.vision_config))
model.load_state_dict(load_file(os.path.join(src, VISION_WEIGHTS)))
tok_src = mcfg.vision
else:
model = SiglipModel.from_pretrained(src)
tok_src = src
if device.type == "cuda":
model = model.to(torch.bfloat16)
model = model.to(device).eval().requires_grad_(False)
try:
tok = AutoTokenizer.from_pretrained(tok_src)
except Exception: # noqa: BLE001 - tiny test configs have no tokenizer; caller passes text_tokenize
tok = None
def tokenize(strings: list[str]) -> torch.Tensor:
return tok(strings, padding="max_length", truncation=True, max_length=mcfg.text_max_len,
return_tensors="pt")["input_ids"]
return model, (tokenize if tok is not None else None)
# ---------------------------------------------------------------- data (training / eval)
def read_mm_jsonl(path: str, root: str = ".") -> list[tuple[Record, str | None, dict]]:
"""Record JSONL with an optional extra "image" (path, relative to ``root``) -> (record, image path, raw dict)."""
out = []
with open(path, encoding="utf-8") as f:
for line in f:
if line.strip():
d = json.loads(line)
img = d.get("image")
out.append((Record.from_json(line), os.path.join(root, img) if img else None, d))
return out
class MMDataset(torch.utils.data.Dataset):
"""Pre-encoded (record, image) items; images are decoded in ``collate`` (DataLoader workers)."""
def __init__(self, items: Sequence[tuple[Record, str | None]], ser: MMSerializer, image_size: int = 224,
sim_dropout: float = 0.0, seed: int = 0):
self.records = [r for r, _ in items]
self.images = [img for _, img in items]
self.encs = ser.encode_many([(r.state, r.question, img is not None) for r, img in items])
self.prompts = [option_prompts(r.question) for r in self.records]
self.labels = [r.label for r in self.records]
self.lengths = np.array([len(e) for e in self.encs])
self.pad_id, self.size, self.sim_dropout = ser.pad, image_size, sim_dropout
self.rng = np.random.default_rng(seed)
def __len__(self) -> int:
return len(self.encs)
def __getitem__(self, i: int) -> int:
return i
def collate(self, idx: Sequence[int]) -> dict:
paths = list(dict.fromkeys(self.images[i] for i in idx if self.images[i]))
pix = [load_pixels(p, self.size) for p in paths]
where = {p: k for k, p in enumerate(paths)}
image_of = [where[self.images[i]] if self.images[i] else -1 for i in idx]
use_sim = (self.rng.random(len(idx)) >= self.sim_dropout).tolist() if self.sim_dropout else None
b = mm_collate([self.encs[i] for i in idx], [self.labels[i] for i in idx], self.pad_id, image_of, pix,
[self.prompts[i] for i in idx], use_sim)
b["index"] = torch.tensor(list(idx))
return b
def to_device(b: dict, device) -> dict:
out = {k: (v.to(device, non_blocking=True) if isinstance(v, torch.Tensor) else v) for k, v in b.items()}
out["k_max"] = int(b["n_options"].max())
return out
@torch.no_grad()
def collect(model: MultimodalDecisionModel, ds: MMDataset, device, max_tokens: int = 8192) -> dict:
"""Raw per-question outputs over a dataset (the train.collect contract, so calibrate.fit and
train.summarize work on it)."""
model.eval()
sampler = TokenBudgetBatchSampler(ds.lengths, max_tokens=max_tokens, max_batch=128, shuffle=False)
logits: list = [None] * len(ds)
esc = torch.zeros(len(ds))
for idx in sampler:
b = to_device(ds.collate(idx), device)
with torch.autocast(device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"):
out = model(**b)
lg, e = out["logits"].float().cpu(), out["escalate_logit"].float().cpu()
for j, i in enumerate(idx):
logits[i] = lg[j, :len(ds.encs[i].markers)]
esc[idx] = e
return {"logits": logits, "escalate_logit": esc, "labels": torch.tensor(ds.labels),
"qtype": torch.tensor([e.qtype for e in ds.encs]),
"n_options": torch.tensor([len(e.markers) for e in ds.encs]),
"source": [r.source for r in ds.records]}
# ---------------------------------------------------------------- public API
class MultimodalSystemOne(SystemOne):
"""``SystemOne`` plus images. ``predict(state_or_None, questions, image=...)`` returns the same
answer dicts as ``SystemOne.predict``. Calls without any image take the text path unchanged
(including ``long_state="chunk"``)."""
def __init__(self, model: MultimodalDecisionModel, tokenizer, calibration: dict | None, device: torch.device,
**kw):
super().__init__(model.dm, tokenizer, calibration, device, **kw)
self.mm = model.eval()
cfg = model.dm.cfg
self.mser = MMSerializer(tokenizer, cfg.max_len, cfg.head_max_len, model.mcfg.n_slots)
@classmethod
def load(cls, path: str, device: str | torch.device | None = None, vision: str | None = None,
**kw) -> "MultimodalSystemOne":
from transformers import AutoTokenizer
device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
cal_path = os.path.join(path, "calibration.json")
cal = json.load(open(cal_path)) if os.path.exists(cal_path) else None
model = MultimodalDecisionModel.from_pretrained(path, device, vision)
return cls(model, AutoTokenizer.from_pretrained(path), cal, device, **kw)
def predict(self, state: str | None, questions: Questions, image: ImageLike = None, # type: ignore[override]
calibrated: bool = True, use_sim: bool = True, **kw) -> dict:
return self.predict_batch([(state, questions, image)], calibrated=calibrated, use_sim=use_sim, **kw)[0]
@torch.inference_mode()
def predict_batch(self, items: Sequence[tuple], calibrated: bool = True, use_sim: bool = True, # type: ignore[override]
**kw) -> list[dict]:
"""items: (state, questions) or (state, questions, image); state may be None/"" with an image."""
items = [tuple(it) + (None,) * (3 - len(it)) for it in items]
if all(img is None for _, _, img in items):
return super().predict_batch([(s or "", qs) for s, qs, _ in items], calibrated=calibrated, **kw)
t0 = time.perf_counter()
pixels, image_of_item = [], []
for _, _, img in items:
if img is None:
image_of_item.append(-1)
else:
image_of_item.append(len(pixels))
pixels.append(img if isinstance(img, torch.Tensor) else load_pixels(img, self.mm.mcfg.image_size))
flat: list[tuple[int, str, Question]] = []
for i, (_, qs, _) in enumerate(items):
for name, q in qs.items():
q = q if isinstance(q, Question) else Question.from_public(q)
try:
q.validate()
except AssertionError as e:
raise ValueError(f"question {name!r}: {e}") from None
flat.append((i, name, q))
encs = self.mser.encode_many([(items[i][0] or "", q, image_of_item[i] >= 0) for i, _, q in flat])
prompts = [option_prompts(q) for _, _, q in flat]
logits: list[torch.Tensor] = [None] * len(encs) # type: ignore[list-item]
esc = torch.zeros(len(encs))
for idx in self.batches([len(e) for e in encs]) if encs else []:
b = mm_collate([encs[j] for j in idx], None, self.ser.pad, [image_of_item[flat[j][0]] for j in idx],
pixels, [prompts[j] for j in idx], [use_sim] * len(idx))
b = to_device(b, self.device)
with torch.autocast(self.device.type, dtype=torch.bfloat16, enabled=self.device.type == "cuda"):
out = self.mm(**b)
lg, e = out["logits"].float().cpu(), out["escalate_logit"].float().cpu()
for r, j in enumerate(idx):
logits[j] = lg[r, :len(encs[j].markers)]
esc[idx] = e
cal = self.cal if calibrated else None
if cal:
esc = cal["escalate"]["a"] * esc + cal["escalate"]["b"]
p_wrong = 1 - torch.sigmoid(esc)
results = [{"answers": {}, "model": self.name, "latency_ms": 0.0} for _ in items]
for j, (i, name, q) in enumerate(flat):
p = (logits[j] / temperature(cal, q.type, q.n_options)).softmax(-1)
results[i]["answers"][name] = self._answer(q, p, float(p_wrong[j]))
ms = (time.perf_counter() - t0) * 1000
for r in results:
r["latency_ms"] = ms
return results