prism / modeling_prism.py
litcoderr's picture
Publish PRISM weights and modeling code
a596b0a verified
Raw
History Blame Contribute Delete
20.2 kB
"""PRISM model.
Frozen SigLIP2 vision + frozen Qwen3-Embedding text + trainable Decompositional
Encoder θ + Compositional Latent Predictor φ + EMA target encoder θ̄.
One training ``forward`` returns:
- ``loss_decomp`` : symmetric InfoNCE between the compositional latent
``s = φ(z_vv^B, z_vi^A)`` and the recomposed text embedding
``e = Qwen3Embedding(compose(T_vi^A, T_vv^B))``, over the batch
(with DDP all-gather of ``s`` / ``e`` / the valid-pair mask). [paper §3.2]
- ``loss_temp_vi`` / ``loss_temp_vv`` : ``1 - cos(ẑ_t, z̄_{t+1})`` for each
stream, where the target ``z̄`` comes from the EMA encoder θ̄. [paper §3.3]
- ``loss = λ_decomp · loss_decomp + λ_temp · ½(loss_temp_vi + loss_temp_vv)``.
Cross-pairing (one clip's view-variant stream with another clip's view-invariant
stream) is done inside ``forward`` via a cyclic shift of the batch: the
view-variant stream comes from clip ``i``, the view-invariant stream from clip
``(i+1) mod B``. The trainer builds the matching recomposed caption with the
same convention.
At inference, ``encode`` returns an L2-normalized clip embedding (mean-pooled
``z_vi`` over valid frames) from the EMA encoder.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
import torch
import torch.distributed as dist
import torch.nn.functional as F
from torch import nn
from transformers import AutoModel, PreTrainedModel
logger = logging.getLogger(__name__)
from .configuration_prism import PRISMConfig
from .ema import make_ema_copy, sync_ema_from_online, update_ema
from .encoder import DecompositionalEncoder
# Unused here, but kept as a direct import: when this file is served as Hub remote
# code, transformers ships only the relative imports named in *this* module, so
# ``layers`` (used by encoder/predictor) has to be visible from here.
from .layers import QFormerBlock, TemporalBlock # noqa: F401
from .predictor import CompositionalPredictor
@dataclass
class PRISMOutput:
loss: torch.Tensor
loss_decomp: torch.Tensor
loss_temp_vi: torch.Tensor
loss_temp_vv: torch.Tensor
n_valid_pairs: int
# ---------------------------------------------------------------------------
# Loss / distributed helpers
# ---------------------------------------------------------------------------
def _symmetric_infonce(
a: torch.Tensor, b: torch.Tensor, logit_scale: torch.Tensor
) -> torch.Tensor:
"""CLIP-style symmetric InfoNCE on L2-normalized features."""
a = F.normalize(a, dim=-1)
b = F.normalize(b, dim=-1)
scale = logit_scale.exp().clamp(max=100.0)
logits = scale * a @ b.t()
labels = torch.arange(a.shape[0], device=a.device)
return 0.5 * (F.cross_entropy(logits, labels) + F.cross_entropy(logits.t(), labels))
def _all_gather_with_grad(x: torch.Tensor) -> torch.Tensor:
"""CLIP-style all-gather: concat across ranks; own-rank slot keeps gradient.
Other ranks' tensors are detached for this rank's backward; DDP's gradient
all-reduce then distributes the gradient across ranks, making it equivalent
to a single forward over the full ``B * world_size`` batch. Single-process
→ returns ``x`` unchanged.
"""
if not dist.is_available() or not dist.is_initialized():
return x
world_size = dist.get_world_size()
if world_size == 1:
return x
rank = dist.get_rank()
gathered = [torch.empty_like(x) for _ in range(world_size)]
dist.all_gather(gathered, x.contiguous())
gathered[rank] = x # own-rank slot keeps grad
return torch.cat(gathered, dim=0)
def _all_gather_bool(x: torch.Tensor) -> torch.Tensor:
"""Plain all-gather for boolean masks (no gradient)."""
if not dist.is_available() or not dist.is_initialized():
return x
world_size = dist.get_world_size()
if world_size == 1:
return x
gathered = [torch.empty_like(x) for _ in range(world_size)]
dist.all_gather(gathered, x.contiguous())
return torch.cat(gathered, dim=0)
def _sample_shift_plan(
valid_a: torch.Tensor, valid_b: torch.Tensor, T: int
) -> dict:
"""Sliding-shift augmentation plan.
For each sample, place the shorter clip's valid frames at a random offset
within the longer clip's valid range. Returns gather indices and post-shift
valid masks for both sides; apply identically to online and EMA tensors so
predictor input and temporal target stay time-aligned.
"""
B = valid_a.shape[0]
device = valid_a.device
t_a = valid_a.int().sum(dim=1)
t_b = valid_b.int().sum(dim=1)
max_off_a = torch.clamp(t_b - t_a, min=0)
max_off_b = torch.clamp(t_a - t_b, min=0)
rand = torch.rand(B, 2, device=device)
offset_a = (rand[:, 0] * (max_off_a.float() + 1.0)).long().clamp(max=max_off_a)
offset_b = (rand[:, 1] * (max_off_b.float() + 1.0)).long().clamp(max=max_off_b)
arange_T = torch.arange(T, device=device).unsqueeze(0).expand(B, -1)
src_idx_a = arange_T - offset_a.unsqueeze(1)
src_idx_b = arange_T - offset_b.unsqueeze(1)
new_valid_a = (src_idx_a >= 0) & (src_idx_a < t_a.unsqueeze(1))
new_valid_b = (src_idx_b >= 0) & (src_idx_b < t_b.unsqueeze(1))
return {
"src_idx_a": src_idx_a.clamp(0, T - 1),
"src_idx_b": src_idx_b.clamp(0, T - 1),
"new_valid_a": new_valid_a,
"new_valid_b": new_valid_b,
}
def _apply_shift(
z: torch.Tensor, src_idx: torch.Tensor, valid: torch.Tensor
) -> torch.Tensor:
"""Gather ``z[B, T, D]`` along T via ``src_idx[B, T]``; zero invalid positions."""
gather_idx = src_idx.unsqueeze(-1).expand(-1, -1, z.shape[-1])
return torch.gather(z, dim=1, index=gather_idx) * valid.unsqueeze(-1).to(z.dtype)
# ---------------------------------------------------------------------------
# Checkpoint resolution (local directory or Hugging Face Hub repo)
# ---------------------------------------------------------------------------
_WEIGHTS_NAME = "model.safetensors"
_HUB_KWARGS = ("revision", "cache_dir", "token", "force_download", "local_files_only", "proxies")
def _resolve_weights(path_or_repo: str, **hub_kwargs) -> str:
"""Path to the checkpoint's weights: a local directory, else a Hub repo id."""
local = Path(path_or_repo) / _WEIGHTS_NAME
if local.is_file():
return str(local)
from huggingface_hub import hf_hub_download
return hf_hub_download(repo_id=str(path_or_repo), filename=_WEIGHTS_NAME, **hub_kwargs)
# ---------------------------------------------------------------------------
# Model
# ---------------------------------------------------------------------------
class PRISMModel(PreTrainedModel):
config_class = PRISMConfig
base_model_prefix = "prism"
# Frozen backbones are reloaded from the Hub in __init__ and excluded from
# the saved checkpoint (see ``state_dict``); silence the load-time warning.
_keys_to_ignore_on_load_missing = [r"^vision_model\.", r"^text_model\."]
supports_gradient_checkpointing = False
def __init__(self, config: PRISMConfig):
super().__init__(config)
# ---- Frozen vision tower ----
# CLIP keeps a CLS token in last_hidden_state; SigLIP / SigLIP2 do not.
# SigLIP2 weights use the SigLIP v1 architecture, so SiglipVisionModel
# handles both checkpoint families.
if "siglip" in config.vision_backbone_name.lower():
from transformers import SiglipVisionModel
self.vision_model = SiglipVisionModel.from_pretrained(config.vision_backbone_name)
self._vision_has_cls = False
else:
from transformers import CLIPVisionModel
self.vision_model = CLIPVisionModel.from_pretrained(config.vision_backbone_name)
self._vision_has_cls = True
d_v = int(self.vision_model.config.hidden_size)
# ---- Frozen Qwen3-Embedding text tower ----
self.text_model = AutoModel.from_pretrained(config.text_backbone_name)
d_t = int(self.text_model.config.hidden_size)
for p in self.vision_model.parameters():
p.requires_grad = False
for p in self.text_model.parameters():
p.requires_grad = False
self.vision_model.eval()
self.text_model.eval()
self.d_v = d_v
self.d_t = d_t
# ---- Trainable: Decompositional Encoder θ + Compositional Predictor φ ----
self.encoder = DecompositionalEncoder(
d_z=config.d_z, d_kv=d_v,
qformer_depth=config.qformer_depth, temporal_depth=config.temporal_depth,
num_heads=config.num_heads, mlp_ratio=config.mlp_ratio,
max_frames=config.max_frames,
)
self.predictor = CompositionalPredictor(
d_z=config.d_z, d_t=d_t, max_frames=config.max_frames,
depth=config.predictor_depth, num_heads=config.num_heads,
mlp_ratio=config.mlp_ratio,
)
# ---- EMA target encoder θ̄ ----
if config.use_ema:
self.target_encoder = make_ema_copy(self.encoder)
self.logit_scale = nn.Parameter(torch.tensor(config.logit_scale_init))
# -- keep trainable defaults; do not re-init the from-Hub backbones --
def _init_weights(self, module): # noqa: D401
pass
@classmethod
def from_pretrained(cls, pretrained_model_name_or_path, *model_args, **kwargs):
"""Load a weights-only PRISM checkpoint from a local directory or the Hub.
The frozen vision / text backbones are not stored in the checkpoint;
they are rebuilt from the Hub in ``__init__``. Only the trained weights
(encoder θ, predictor φ, target encoder θ̄, logit scale) are loaded. The
``dtype`` / ``torch_dtype`` kwarg is honored; other HF loading kwargs
(device_map, sharding, ...) are not needed for this single-file ckpt.
"""
from safetensors.torch import load_file
hub_kwargs = {k: kwargs.pop(k) for k in _HUB_KWARGS if kwargs.get(k) is not None}
config = kwargs.pop("config", None)
if not isinstance(config, PRISMConfig):
config = PRISMConfig.from_pretrained(pretrained_model_name_or_path, **hub_kwargs)
dtype = kwargs.pop("torch_dtype", None) or kwargs.pop("dtype", None)
model = cls(config) # backbones materialized from the Hub
state = load_file(_resolve_weights(pretrained_model_name_or_path, **hub_kwargs))
missing, unexpected = model.load_state_dict(state, strict=False)
bad_missing = [m for m in missing if not m.startswith(("vision_model.", "text_model."))]
if bad_missing:
logger.warning(f"missing non-backbone keys: {bad_missing[:8]}")
if unexpected:
logger.warning(f"unexpected keys: {unexpected[:8]}")
# Warm-start the target encoder if a checkpoint predates EMA weights.
if config.use_ema and any(k.startswith("target_encoder.") for k in bad_missing):
model.sync_ema_from_online()
if dtype is not None:
model = model.to(dtype)
return model
def train(self, mode: bool = True):
"""Keep frozen backbones (and the EMA target encoder) in eval mode."""
super().train(mode)
self.vision_model.eval()
self.text_model.eval()
if getattr(self.config, "use_ema", False):
self.target_encoder.eval()
return self
def state_dict(self, *args, **kwargs):
"""Exclude the frozen, from-Hub backbones from saved checkpoints."""
sd = super().state_dict(*args, **kwargs)
return type(sd)(
(k, v) for k, v in sd.items()
if not k.startswith(("vision_model.", "text_model."))
)
# ------------------------------------------------------------------
# Frozen backbone helpers
# ------------------------------------------------------------------
@torch.no_grad()
def _encode_video(self, pixel_values: torch.Tensor) -> torch.Tensor:
"""``(B, T, 3, H, W)`` → patch tokens ``(B, T, P, d_v)`` (CLS dropped for CLIP)."""
B, T = pixel_values.shape[:2]
x = pixel_values.reshape(B * T, *pixel_values.shape[2:])
seq = self.vision_model(pixel_values=x).last_hidden_state
if self._vision_has_cls:
seq = seq[:, 1:, :]
return seq.reshape(B, T, seq.shape[1], self.d_v)
@torch.no_grad()
def _encode_text(
self, input_ids: torch.Tensor, attention_mask: torch.Tensor
) -> torch.Tensor:
"""Qwen3-Embedding: last-token pool over a right-padded batch → L2-normed ``(N, d_t)``."""
last_hidden = self.text_model(
input_ids=input_ids, attention_mask=attention_mask
).last_hidden_state
last_idx = (attention_mask.sum(dim=1) - 1).clamp(min=0)
rows = torch.arange(last_hidden.shape[0], device=last_hidden.device)
return F.normalize(last_hidden[rows, last_idx], dim=-1)
# ------------------------------------------------------------------
# EMA hooks (called by the trainer after each optimizer step)
# ------------------------------------------------------------------
@torch.no_grad()
def update_ema(self) -> None:
if getattr(self.config, "use_ema", False):
update_ema(self.target_encoder, self.encoder, self.config.ema_decay)
@torch.no_grad()
def sync_ema_from_online(self) -> None:
if getattr(self.config, "use_ema", False):
sync_ema_from_online(self.target_encoder, self.encoder)
# ------------------------------------------------------------------
# Inference
# ------------------------------------------------------------------
@torch.no_grad()
def encode_streams(
self, pixel_values: torch.Tensor, valid_mask: torch.Tensor | None = None
) -> dict:
"""Run θ̄ (or θ if ``use_ema=False``) → ``{z_vi_seq, z_vv_seq}`` each ``(B, T, d_z)``."""
patches = self._encode_video(pixel_values)
kpm = None if valid_mask is None else (~valid_mask)
enc = self.target_encoder if getattr(self.config, "use_ema", False) else self.encoder
z_vi, z_vv = enc(patches, key_padding_mask=kpm)
return {"z_vi_seq": z_vi, "z_vv_seq": z_vv}
@torch.no_grad()
def encode(
self, pixel_values: torch.Tensor, valid_mask: torch.Tensor | None = None
) -> torch.Tensor:
"""Clip embedding: L2-normalized mean-pool of ``z_vi`` over valid frames → ``(B, d_z)``."""
z_vi = self.encode_streams(pixel_values, valid_mask)["z_vi_seq"].float()
if valid_mask is None:
valid = torch.ones(z_vi.shape[:2], device=z_vi.device, dtype=z_vi.dtype)
else:
valid = valid_mask.to(z_vi.dtype)
denom = valid.sum(dim=1, keepdim=True).clamp(min=1.0)
emb = (z_vi * valid.unsqueeze(-1)).sum(dim=1) / denom
return F.normalize(emb, dim=-1)
# ------------------------------------------------------------------
# Training forward
# ------------------------------------------------------------------
def forward(
self,
pixel_values: torch.Tensor,
valid_mask: torch.Tensor,
composed_input_ids: torch.Tensor,
composed_attention_mask: torch.Tensor,
valid_pair_mask: torch.Tensor | None = None,
) -> PRISMOutput:
"""
pixel_values: ``(B, T, 3, H, W)`` — padded to T in the collator.
valid_mask: ``(B, T)`` bool, True = real frame.
composed_input_ids: ``(B, L)`` — tokenized recomposed caption per pair.
composed_attention_mask: ``(B, L)``.
valid_pair_mask: ``(B,)`` bool — True if the composer succeeded.
"""
B = pixel_values.shape[0]
device = pixel_values.device
use_ema = getattr(self.config, "use_ema", False)
# ---- Frozen encoders ----
patches = self._encode_video(pixel_values) # (B, T, P, d_v)
T = patches.shape[1]
e_text = self._encode_text(composed_input_ids, composed_attention_mask) # (B, d_t)
# ---- Decompositional encoder θ (and EMA θ̄ for temporal targets) ----
kpm = ~valid_mask
z_vi_seq, z_vv_seq = self.encoder(patches, key_padding_mask=kpm)
if use_ema:
with torch.no_grad():
z_vi_seq_ema, z_vv_seq_ema = self.target_encoder(patches, key_padding_mask=kpm)
# ---- Cross-pairing: view-variant from clip i, view-invariant from (i+1) ----
shift = torch.roll(torch.arange(B, device=device), shifts=-1, dims=0)
z_vv = z_vv_seq # view-variant (clip i)
z_vi = z_vi_seq[shift] # view-invariant (clip i+1)
valid_vv = valid_mask
valid_vi = valid_mask[shift]
if use_ema:
z_vv_ema = z_vv_seq_ema
z_vi_ema = z_vi_seq_ema[shift]
# ---- Sliding-shift augmentation (training only) ----
if self.training and getattr(self.config, "sliding_shift_aug", True):
plan = _sample_shift_plan(valid_vv, valid_vi, T)
z_vv = _apply_shift(z_vv, plan["src_idx_a"], plan["new_valid_a"])
z_vi = _apply_shift(z_vi, plan["src_idx_b"], plan["new_valid_b"])
if use_ema:
z_vv_ema = _apply_shift(z_vv_ema, plan["src_idx_a"], plan["new_valid_a"])
z_vi_ema = _apply_shift(z_vi_ema, plan["src_idx_b"], plan["new_valid_b"])
valid_vv = plan["new_valid_a"]
valid_vi = plan["new_valid_b"]
# ---- Compositional predictor φ ----
out = self.predictor(z_vv, z_vi, valid_vv=valid_vv, valid_vi=valid_vi)
s = out["s"] # (B, d_t) compositional latent
z_vi_pred = out["z_vi_pred"] # (B, T, d_z) vi next-frame head
z_vv_pred = out["z_vv_pred"] # (B, T, d_z) vv next-frame head
pair_valid = out["pair_valid"] # (B, T)
# ---- L_decomp: InfoNCE(s, e_text) over valid pairs ----
if valid_pair_mask is None:
valid_pair_mask = torch.ones(B, dtype=torch.bool, device=device)
valid_pair_mask = valid_pair_mask & pair_valid.any(dim=1)
if self.config.infonce_all_gather:
s_g = _all_gather_with_grad(s)
e_g = _all_gather_with_grad(e_text)
valid_g = _all_gather_bool(valid_pair_mask)
else:
s_g, e_g, valid_g = s, e_text, valid_pair_mask
valid_idx = valid_g.nonzero(as_tuple=True)[0]
n_valid = int(valid_idx.numel())
if n_valid >= 2:
loss_decomp = _symmetric_infonce(s_g[valid_idx], e_g[valid_idx], self.logit_scale)
else:
loss_decomp = torch.zeros((), device=device)
# ---- L_temp: 1 - cos(prediction at t, EMA target at t+1), per stream ----
if T >= 2:
if use_ema:
tgt_vi = z_vi_ema[:, 1:T, :]
tgt_vv = z_vv_ema[:, 1:T, :]
else:
tgt_vi = z_vi.detach()[:, 1:T, :]
tgt_vv = z_vv.detach()[:, 1:T, :]
valid_next_vi = valid_vi[:, :T - 1] & valid_vi[:, 1:T]
valid_next_vv = valid_vv[:, :T - 1] & valid_vv[:, 1:T]
err_vi = 1.0 - F.cosine_similarity(z_vi_pred[:, :T - 1, :], tgt_vi, dim=-1)
err_vv = 1.0 - F.cosine_similarity(z_vv_pred[:, :T - 1, :], tgt_vv, dim=-1)
loss_temp_vi = err_vi[valid_next_vi].mean() if valid_next_vi.any() else torch.zeros((), device=device)
loss_temp_vv = err_vv[valid_next_vv].mean() if valid_next_vv.any() else torch.zeros((), device=device)
else:
loss_temp_vi = torch.zeros((), device=device)
loss_temp_vv = torch.zeros((), device=device)
loss_temp = 0.5 * (loss_temp_vi + loss_temp_vv)
loss = self.config.lambda_decomp * loss_decomp + self.config.lambda_temp * loss_temp
return PRISMOutput(
loss=loss,
loss_decomp=loss_decomp.detach(),
loss_temp_vi=loss_temp_vi.detach(),
loss_temp_vv=loss_temp_vv.detach(),
n_valid_pairs=n_valid,
)