Feature Extraction
Transformers
Safetensors
prism
video
representation-learning
view-invariant
cross-view
egocentric
egoexo4d
emnlp2026
custom_code
Instructions to use litcoderr/prism with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use litcoderr/prism with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("feature-extraction", model="litcoderr/prism", trust_remote_code=True)# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("litcoderr/prism", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Publish PRISM weights and modeling code
Browse files- README.md +130 -0
- config.json +27 -0
- configuration_prism.py +54 -0
- ema.py +48 -0
- encoder.py +150 -0
- layers.py +128 -0
- model.safetensors +3 -0
- modeling_prism.py +454 -0
- predictor.py +106 -0
README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
library_name: transformers
|
| 4 |
+
pipeline_tag: feature-extraction
|
| 5 |
+
base_model:
|
| 6 |
+
- google/siglip2-so400m-patch14-384
|
| 7 |
+
- Qwen/Qwen3-Embedding-0.6B
|
| 8 |
+
tags:
|
| 9 |
+
- video
|
| 10 |
+
- representation-learning
|
| 11 |
+
- view-invariant
|
| 12 |
+
- cross-view
|
| 13 |
+
- egocentric
|
| 14 |
+
- egoexo4d
|
| 15 |
+
- emnlp2026
|
| 16 |
+
---
|
| 17 |
+
|
| 18 |
+
# PRISM — EgoExo4D
|
| 19 |
+
|
| 20 |
+
**P**redictive **R**ecomposition v**I**a **S**emantic Latent Deco**M**position for
|
| 21 |
+
View-invariant Video Representation Learning — **EMNLP 2026 (Main Conference)**.
|
| 22 |
+
|
| 23 |
+
[Paper][paper] · [Project page][project] · [Code][code]
|
| 24 |
+
|
| 25 |
+
PRISM encodes a video clip into a **view-invariant** embedding: cosine similarity between
|
| 26 |
+
two PRISM embeddings measures *what is happening*, not *where the camera is*. It gets there
|
| 27 |
+
by refusing to produce a single embedding — each clip is decomposed into a view-invariant
|
| 28 |
+
latent `z_vi` and a view-variant latent `z_vv`, and the split is enforced by recomposing
|
| 29 |
+
across clips (`z_vi` of clip A with `z_vv` of clip B) and aligning the result with the
|
| 30 |
+
matching recomposed language description. Semantics that leaked between the two streams
|
| 31 |
+
cannot survive that swap.
|
| 32 |
+
|
| 33 |
+
This checkpoint is trained on **EgoExo4D**.
|
| 34 |
+
|
| 35 |
+
## Usage
|
| 36 |
+
|
| 37 |
+
```python
|
| 38 |
+
import torch
|
| 39 |
+
from transformers import AutoModel, AutoImageProcessor
|
| 40 |
+
|
| 41 |
+
model = AutoModel.from_pretrained(
|
| 42 |
+
"litcoderr/prism", trust_remote_code=True, dtype=torch.bfloat16
|
| 43 |
+
).eval().cuda()
|
| 44 |
+
proc = AutoImageProcessor.from_pretrained(model.config.vision_backbone_name)
|
| 45 |
+
|
| 46 |
+
frames = [...] # list[PIL.Image], sampled at 4 fps
|
| 47 |
+
pixel_values = proc(images=frames, return_tensors="pt").pixel_values[None]
|
| 48 |
+
pixel_values = pixel_values.to("cuda", torch.bfloat16) # (1, T, 3, 384, 384)
|
| 49 |
+
valid_mask = torch.ones(pixel_values.shape[:2], dtype=torch.bool, device="cuda")
|
| 50 |
+
|
| 51 |
+
emb = model.encode(pixel_values, valid_mask) # (1, 512) — L2-normalized
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
| | |
|
| 55 |
+
|:---|:---|
|
| 56 |
+
| `model.encode(pixel_values, valid_mask)` | `(B, 512)` L2-normalized clip embedding — mean-pooled `z_vi` over valid frames |
|
| 57 |
+
| `model.encode_streams(pixel_values, valid_mask)` | `{"z_vi_seq", "z_vv_seq"}`, each `(B, T, 512)` per-frame |
|
| 58 |
+
|
| 59 |
+
**Inputs.** `pixel_values` is `(B, T, 3, 384, 384)`, frames sampled at 4 fps, up to
|
| 60 |
+
`T = 128` (32 s), preprocessed by the SigLIP2 image processor. `valid_mask` is `(B, T)`
|
| 61 |
+
bool marking real frames in a padded batch. Both encode paths run under `torch.no_grad()`
|
| 62 |
+
and use the EMA target encoder θ̄.
|
| 63 |
+
|
| 64 |
+
For batched encoding of a clip manifest, see `scripts/encode.sh` in the [code repo][code].
|
| 65 |
+
|
| 66 |
+
## Results
|
| 67 |
+
|
| 68 |
+
Cross-view semantic alignment (R@5 / top-1 / avg):
|
| 69 |
+
|
| 70 |
+
| Method | <sub>EgoExo4D</sub><br>ego→exo | <br>exo→ego | <br>avg | <br>Recog. top-1 | <sub>EgoExoLearn</sub><br>Assoc. avg |
|
| 71 |
+
|:---|:--:|:--:|:--:|:--:|:--:|
|
| 72 |
+
| SigLIP2 | 35.1 | 19.7 | 27.4 | 13.9 | 26.6 |
|
| 73 |
+
| ViewpointRosetta <sub>*prior SoTA*</sub> | 58.1 | 47.2 | 52.7 | 34.5 | 32.3 |
|
| 74 |
+
| **PRISM** | **75.9** | **50.3** | **63.1** | **41.9** | **43.9** |
|
| 75 |
+
|
| 76 |
+
Zero-shot transfer to AE2 (never trained on AE2 videos) beats AE2-trained models on
|
| 77 |
+
temporal ordering — Kendall's τ **0.601** vs 0.562, phase progression **0.647** vs 0.480.
|
| 78 |
+
Full tables and ablations are in the [paper][paper].
|
| 79 |
+
|
| 80 |
+
## Architecture
|
| 81 |
+
|
| 82 |
+
| Component | |
|
| 83 |
+
|:---|:---|
|
| 84 |
+
| Vision backbone | `google/siglip2-so400m-patch14-384` — **frozen**, not stored here |
|
| 85 |
+
| Text backbone | `Qwen/Qwen3-Embedding-0.6B` — **frozen**, not stored here |
|
| 86 |
+
| Decompositional Encoder θ | 4-layer Q-Former (2 queries → `z_vi`, `z_vv`) + 12-layer causal temporal stack per stream |
|
| 87 |
+
| Compositional Latent Predictor φ | 4-layer causal transformer over `concat(z_vv, z_vi)`, with `cls_head` / `vi_head` / `vv_head` |
|
| 88 |
+
| Target encoder θ̄ | EMA of θ, decay 0.998 |
|
| 89 |
+
| Embedding dim | 512 |
|
| 90 |
+
|
| 91 |
+
This repo holds **trained weights only** (0.97 GB, fp32): θ, φ, θ̄, and the logit scale. The
|
| 92 |
+
two backbones are re-downloaded from the Hub when the model is constructed, so nothing about
|
| 93 |
+
them is duplicated here.
|
| 94 |
+
|
| 95 |
+
## Training
|
| 96 |
+
|
| 97 |
+
EgoExo4D, 6 epochs, batch 4 × 7 GPUs (DDP), constant-with-warmup lr 7e-5 (10% warmup),
|
| 98 |
+
weight decay 0.01, grad-clip 1.0, bf16, seed 42. Video sampled at 4 fps, clips capped at
|
| 99 |
+
32 s / 128 frames, 384×384. Objective `L = 1.0 · L_decomp + 0.5 · L_temp`, InfoNCE
|
| 100 |
+
all-gathered across ranks, sliding-shift augmentation on.
|
| 101 |
+
|
| 102 |
+
Per-clip view-invariant / view-variant captions are a *provided input*; recomposed captions
|
| 103 |
+
are generated online by a local vLLM server running `Qwen/Qwen3-1.7B`. Full recipe in the
|
| 104 |
+
[code repo][code].
|
| 105 |
+
|
| 106 |
+
## Limitations
|
| 107 |
+
|
| 108 |
+
Trained on EgoExo4D — skill-centric, mostly indoor, ego/exo camera pairs. Domains far from
|
| 109 |
+
that distribution are untested. Clips longer than 32 s are truncated to the first 128
|
| 110 |
+
sampled frames. The training pipeline depends on decoupled view-invariant / view-variant
|
| 111 |
+
captions, which most datasets do not ship.
|
| 112 |
+
|
| 113 |
+
## Citation
|
| 114 |
+
|
| 115 |
+
```bibtex
|
| 116 |
+
@inproceedings{chee2026prism,
|
| 117 |
+
title = {{PRISM}: Predictive Recomposition via Semantic Latent Decomposition
|
| 118 |
+
for View-invariant Video Representation Learning},
|
| 119 |
+
author = {Chee, Youngchae and Lee, Hosu and Park, Sungjune and Kim, Junho and Ro, Yong Man},
|
| 120 |
+
booktitle = {Proceedings of the 2026 Conference on Empirical Methods in Natural Language Processing (EMNLP)},
|
| 121 |
+
year = {2026}
|
| 122 |
+
}
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
MIT licensed. The frozen backbones keep their own licenses.
|
| 126 |
+
|
| 127 |
+
<!-- swap these when the links go live -->
|
| 128 |
+
[paper]: https://github.com/litcoderr/prism
|
| 129 |
+
[project]: https://github.com/litcoderr/prism
|
| 130 |
+
[code]: https://github.com/litcoderr/prism
|
config.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architectures": [
|
| 3 |
+
"PRISMModel"
|
| 4 |
+
],
|
| 5 |
+
"auto_map": {
|
| 6 |
+
"AutoConfig": "configuration_prism.PRISMConfig",
|
| 7 |
+
"AutoModel": "modeling_prism.PRISMModel"
|
| 8 |
+
},
|
| 9 |
+
"d_z": 512,
|
| 10 |
+
"ema_decay": 0.998,
|
| 11 |
+
"infonce_all_gather": true,
|
| 12 |
+
"lambda_decomp": 1.0,
|
| 13 |
+
"lambda_temp": 0.5,
|
| 14 |
+
"logit_scale_init": 2.6592,
|
| 15 |
+
"max_frames": 128,
|
| 16 |
+
"mlp_ratio": 4.0,
|
| 17 |
+
"model_type": "prism",
|
| 18 |
+
"num_heads": 8,
|
| 19 |
+
"predictor_depth": 4,
|
| 20 |
+
"qformer_depth": 4,
|
| 21 |
+
"sliding_shift_aug": true,
|
| 22 |
+
"temporal_depth": 12,
|
| 23 |
+
"text_backbone_name": "Qwen/Qwen3-Embedding-0.6B",
|
| 24 |
+
"transformers_version": "4.57.6",
|
| 25 |
+
"use_ema": true,
|
| 26 |
+
"vision_backbone_name": "google/siglip2-so400m-patch14-384"
|
| 27 |
+
}
|
configuration_prism.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PRISM configuration.
|
| 2 |
+
|
| 3 |
+
Defaults reproduce the released checkpoint (SigLIP2-so400m / Qwen3-Embedding-0.6B).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from transformers import PretrainedConfig
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class PRISMConfig(PretrainedConfig):
|
| 12 |
+
model_type = "prism"
|
| 13 |
+
|
| 14 |
+
def __init__(
|
| 15 |
+
self,
|
| 16 |
+
# ---- frozen backbones (loaded from the Hub by name) ----
|
| 17 |
+
vision_backbone_name: str = "google/siglip2-so400m-patch14-384",
|
| 18 |
+
text_backbone_name: str = "Qwen/Qwen3-Embedding-0.6B",
|
| 19 |
+
# ---- architecture ----
|
| 20 |
+
d_z: int = 512,
|
| 21 |
+
qformer_depth: int = 4,
|
| 22 |
+
temporal_depth: int = 12,
|
| 23 |
+
predictor_depth: int = 4,
|
| 24 |
+
num_heads: int = 8,
|
| 25 |
+
mlp_ratio: float = 4.0,
|
| 26 |
+
max_frames: int = 128,
|
| 27 |
+
logit_scale_init: float = 2.6592,
|
| 28 |
+
# ---- training objective ----
|
| 29 |
+
lambda_decomp: float = 1.0,
|
| 30 |
+
lambda_temp: float = 0.5,
|
| 31 |
+
infonce_all_gather: bool = True,
|
| 32 |
+
sliding_shift_aug: bool = True,
|
| 33 |
+
# ---- EMA target encoder ----
|
| 34 |
+
use_ema: bool = True,
|
| 35 |
+
ema_decay: float = 0.998,
|
| 36 |
+
**kwargs,
|
| 37 |
+
):
|
| 38 |
+
super().__init__(**kwargs)
|
| 39 |
+
self.vision_backbone_name = vision_backbone_name
|
| 40 |
+
self.text_backbone_name = text_backbone_name
|
| 41 |
+
self.d_z = d_z
|
| 42 |
+
self.qformer_depth = qformer_depth
|
| 43 |
+
self.temporal_depth = temporal_depth
|
| 44 |
+
self.predictor_depth = predictor_depth
|
| 45 |
+
self.num_heads = num_heads
|
| 46 |
+
self.mlp_ratio = mlp_ratio
|
| 47 |
+
self.max_frames = max_frames
|
| 48 |
+
self.logit_scale_init = logit_scale_init
|
| 49 |
+
self.lambda_decomp = lambda_decomp
|
| 50 |
+
self.lambda_temp = lambda_temp
|
| 51 |
+
self.infonce_all_gather = infonce_all_gather
|
| 52 |
+
self.sliding_shift_aug = sliding_shift_aug
|
| 53 |
+
self.use_ema = use_ema
|
| 54 |
+
self.ema_decay = ema_decay
|
ema.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""EMA target encoder helpers.
|
| 2 |
+
|
| 3 |
+
The target encoder ``θ̄`` is a deep copy of the (trainable) Decompositional
|
| 4 |
+
Encoder ``θ``. It receives no gradient and is updated in place after every
|
| 5 |
+
optimizer step:
|
| 6 |
+
|
| 7 |
+
θ̄ ← α · θ̄ + (1 - α) · θ
|
| 8 |
+
|
| 9 |
+
It supplies the (stable) prediction targets for the temporal objective
|
| 10 |
+
``L_temp`` and is the encoder used at inference (see the paper, §3.3).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import copy
|
| 16 |
+
|
| 17 |
+
import torch
|
| 18 |
+
from torch import nn
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def make_ema_copy(module: nn.Module) -> nn.Module:
|
| 22 |
+
"""Deep-copy ``module`` for EMA use: ``requires_grad=False``, eval, fp32."""
|
| 23 |
+
ema = copy.deepcopy(module)
|
| 24 |
+
for p in ema.parameters():
|
| 25 |
+
p.requires_grad = False
|
| 26 |
+
p.data = p.data.float()
|
| 27 |
+
ema.eval()
|
| 28 |
+
return ema
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@torch.no_grad()
|
| 32 |
+
def update_ema(ema_module: nn.Module, online_module: nn.Module, decay: float) -> None:
|
| 33 |
+
"""In place ``θ̄ ← decay·θ̄ + (1-decay)·θ`` over matching parameters.
|
| 34 |
+
|
| 35 |
+
Online params may be bf16/fp16/fp32 (mixed-precision keeps fp32 masters);
|
| 36 |
+
EMA params stay fp32 for numerical stability across many steps. Buffers
|
| 37 |
+
(e.g. sinusoidal positional embeddings) are not trained and not updated.
|
| 38 |
+
"""
|
| 39 |
+
for p_ema, p in zip(ema_module.parameters(), online_module.parameters()):
|
| 40 |
+
p_ema.data.mul_(decay).add_(p.data.float(), alpha=1.0 - decay)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@torch.no_grad()
|
| 44 |
+
def sync_ema_from_online(ema_module: nn.Module, online_module: nn.Module) -> None:
|
| 45 |
+
"""Copy online → EMA (fp32). Used to warm-start the target encoder when a
|
| 46 |
+
checkpoint lacks EMA weights."""
|
| 47 |
+
for p_ema, p in zip(ema_module.parameters(), online_module.parameters()):
|
| 48 |
+
p_ema.data.copy_(p.data.float())
|
encoder.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Decompositional Encoder θ.
|
| 2 |
+
|
| 3 |
+
Decomposes a video into a view-invariant stream ``z_vi`` and a view-variant
|
| 4 |
+
stream ``z_vv`` (paper §3.1). Two sub-modules:
|
| 5 |
+
|
| 6 |
+
- ``QFormer`` : per-frame BLIP-2-style Q-Former with two learned
|
| 7 |
+
queries (Q_vi, Q_vv) that attend to a single
|
| 8 |
+
frame's frozen patch tokens.
|
| 9 |
+
- ``CausalTemporalEncoder`` : two parallel causal temporal streams (one per
|
| 10 |
+
factor). Within a stream, frame ``t`` attends to
|
| 11 |
+
frames ``≤ t``; the streams never attend to each
|
| 12 |
+
other (cross-factor mixing is deferred to φ).
|
| 13 |
+
|
| 14 |
+
``forward(patches) -> (z_vi, z_vv)`` with each ``(B, T, d_z)``.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
from torch import nn
|
| 21 |
+
|
| 22 |
+
from .layers import QFormerBlock, TemporalBlock, build_sin_pos_embed, causal_mask
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class QFormer(nn.Module):
|
| 26 |
+
"""Per-frame Q-Former with N=2 queries: query 0 → z_vi, query 1 → z_vv."""
|
| 27 |
+
|
| 28 |
+
def __init__(
|
| 29 |
+
self,
|
| 30 |
+
d_z: int = 512,
|
| 31 |
+
d_kv: int = 1024,
|
| 32 |
+
depth: int = 4,
|
| 33 |
+
num_heads: int = 8,
|
| 34 |
+
mlp_ratio: float = 4.0,
|
| 35 |
+
):
|
| 36 |
+
super().__init__()
|
| 37 |
+
self.d_z = d_z
|
| 38 |
+
self.d_kv = d_kv
|
| 39 |
+
self.queries = nn.Parameter(torch.zeros(1, 2, d_z))
|
| 40 |
+
nn.init.normal_(self.queries, std=0.02)
|
| 41 |
+
self.blocks = nn.ModuleList(
|
| 42 |
+
[QFormerBlock(d_z, d_kv, num_heads, mlp_ratio) for _ in range(depth)]
|
| 43 |
+
)
|
| 44 |
+
self.final_norm = nn.LayerNorm(d_z)
|
| 45 |
+
|
| 46 |
+
def forward(self, patches: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 47 |
+
"""patches: ``(B, T, P, d_kv)`` → ``(z_vi, z_vv)`` each ``(B, T, d_z)``."""
|
| 48 |
+
B, T, P, d_kv = patches.shape
|
| 49 |
+
assert d_kv == self.d_kv, f"expected d_kv={self.d_kv}, got {d_kv}"
|
| 50 |
+
kv = patches.reshape(B * T, P, d_kv)
|
| 51 |
+
q = self.queries.expand(B * T, -1, -1).contiguous()
|
| 52 |
+
for block in self.blocks:
|
| 53 |
+
q = block(q, kv)
|
| 54 |
+
q = self.final_norm(q)
|
| 55 |
+
z_vi = q[:, 0, :].reshape(B, T, self.d_z) # query 0 → view-invariant
|
| 56 |
+
z_vv = q[:, 1, :].reshape(B, T, self.d_z) # query 1 → view-variant
|
| 57 |
+
return z_vi, z_vv
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class CausalTemporalEncoder(nn.Module):
|
| 61 |
+
"""Two parallel causal temporal streams over z_vi and z_vv.
|
| 62 |
+
|
| 63 |
+
Each stream has its own (non-shared) stack of ``TemporalBlock``s. A shared
|
| 64 |
+
sinusoidal positional embedding is added before the blocks; a boolean
|
| 65 |
+
``key_padding_mask`` ``(B, T)`` blocks padded frames in both streams.
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
def __init__(
|
| 69 |
+
self,
|
| 70 |
+
d_z: int = 512,
|
| 71 |
+
max_frames: int = 128,
|
| 72 |
+
depth: int = 12,
|
| 73 |
+
num_heads: int = 8,
|
| 74 |
+
mlp_ratio: float = 4.0,
|
| 75 |
+
):
|
| 76 |
+
super().__init__()
|
| 77 |
+
self.d_z = d_z
|
| 78 |
+
self.max_frames = max_frames
|
| 79 |
+
self.register_buffer(
|
| 80 |
+
"pos_embed", build_sin_pos_embed(max_frames, d_z), persistent=False
|
| 81 |
+
)
|
| 82 |
+
self.blocks_vi = nn.ModuleList(
|
| 83 |
+
[TemporalBlock(d_z, num_heads, mlp_ratio) for _ in range(depth)]
|
| 84 |
+
)
|
| 85 |
+
self.blocks_vv = nn.ModuleList(
|
| 86 |
+
[TemporalBlock(d_z, num_heads, mlp_ratio) for _ in range(depth)]
|
| 87 |
+
)
|
| 88 |
+
self.norm_vi = nn.LayerNorm(d_z)
|
| 89 |
+
self.norm_vv = nn.LayerNorm(d_z)
|
| 90 |
+
|
| 91 |
+
def _pos_embed(self, T: int, device, dtype) -> torch.Tensor:
|
| 92 |
+
# Training never exceeds max_frames; T > max_frames only happens when
|
| 93 |
+
# encoding a full native-fps video longer than the cap, where we extend
|
| 94 |
+
# the (deterministic) sinusoidal PE on the fly.
|
| 95 |
+
if T <= self.max_frames:
|
| 96 |
+
return self.pos_embed[:, :T, :]
|
| 97 |
+
return build_sin_pos_embed(T, self.d_z).to(device=device, dtype=dtype)
|
| 98 |
+
|
| 99 |
+
def forward(
|
| 100 |
+
self,
|
| 101 |
+
z_vi_seq: torch.Tensor,
|
| 102 |
+
z_vv_seq: torch.Tensor,
|
| 103 |
+
key_padding_mask: torch.Tensor | None = None,
|
| 104 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 105 |
+
"""z_vi_seq, z_vv_seq: ``(B, T, d_z)``; ``key_padding_mask`` True = padded."""
|
| 106 |
+
B, T, _ = z_vi_seq.shape
|
| 107 |
+
pos = self._pos_embed(T, z_vi_seq.device, z_vi_seq.dtype)
|
| 108 |
+
zi = z_vi_seq + pos
|
| 109 |
+
zv = z_vv_seq + pos
|
| 110 |
+
attn_mask = causal_mask(T, device=z_vi_seq.device)
|
| 111 |
+
for blk in self.blocks_vi:
|
| 112 |
+
zi = blk(zi, attn_mask=attn_mask, key_padding_mask=key_padding_mask)
|
| 113 |
+
for blk in self.blocks_vv:
|
| 114 |
+
zv = blk(zv, attn_mask=attn_mask, key_padding_mask=key_padding_mask)
|
| 115 |
+
return self.norm_vi(zi), self.norm_vv(zv)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class DecompositionalEncoder(nn.Module):
|
| 119 |
+
"""θ: video patches → (z_vi, z_vv).
|
| 120 |
+
|
| 121 |
+
Composes the per-frame ``QFormer`` with the ``CausalTemporalEncoder``.
|
| 122 |
+
"""
|
| 123 |
+
|
| 124 |
+
def __init__(
|
| 125 |
+
self,
|
| 126 |
+
d_z: int = 512,
|
| 127 |
+
d_kv: int = 1024,
|
| 128 |
+
qformer_depth: int = 4,
|
| 129 |
+
temporal_depth: int = 12,
|
| 130 |
+
num_heads: int = 8,
|
| 131 |
+
mlp_ratio: float = 4.0,
|
| 132 |
+
max_frames: int = 128,
|
| 133 |
+
):
|
| 134 |
+
super().__init__()
|
| 135 |
+
self.qformer = QFormer(
|
| 136 |
+
d_z=d_z, d_kv=d_kv, depth=qformer_depth,
|
| 137 |
+
num_heads=num_heads, mlp_ratio=mlp_ratio,
|
| 138 |
+
)
|
| 139 |
+
self.temporal = CausalTemporalEncoder(
|
| 140 |
+
d_z=d_z, max_frames=max_frames, depth=temporal_depth,
|
| 141 |
+
num_heads=num_heads, mlp_ratio=mlp_ratio,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
def forward(
|
| 145 |
+
self,
|
| 146 |
+
patches: torch.Tensor,
|
| 147 |
+
key_padding_mask: torch.Tensor | None = None,
|
| 148 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 149 |
+
z_vi_pre, z_vv_pre = self.qformer(patches)
|
| 150 |
+
return self.temporal(z_vi_pre, z_vv_pre, key_padding_mask=key_padding_mask)
|
layers.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared building blocks for PRISM.
|
| 2 |
+
|
| 3 |
+
- ``QFormerBlock`` : one block of the per-frame Q-Former (self-attn over the
|
| 4 |
+
queries → cross-attn into the frozen patch tokens → FFN). Used by the
|
| 5 |
+
Decompositional Encoder's frame-level stage.
|
| 6 |
+
- ``TemporalBlock`` : a pre-LN transformer block used by both the temporal
|
| 7 |
+
stage of the Decompositional Encoder (causal) and the Compositional
|
| 8 |
+
Latent Predictor.
|
| 9 |
+
- ``build_sin_pos_embed`` / ``causal_mask`` : positional-embedding and masking
|
| 10 |
+
helpers.
|
| 11 |
+
|
| 12 |
+
PyTorch's ``MultiheadAttention`` boolean masks follow the convention
|
| 13 |
+
``True == blocked`` for both ``attn_mask`` and ``key_padding_mask``.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import math
|
| 19 |
+
|
| 20 |
+
import torch
|
| 21 |
+
from torch import nn
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def build_sin_pos_embed(num_positions: int, dim: int) -> torch.Tensor:
|
| 25 |
+
"""1-D sinusoidal positional embedding, shape ``(1, num_positions, dim)``."""
|
| 26 |
+
pe = torch.zeros(num_positions, dim)
|
| 27 |
+
pos = torch.arange(0, num_positions, dtype=torch.float32).unsqueeze(1)
|
| 28 |
+
div_term = torch.exp(
|
| 29 |
+
torch.arange(0, dim, 2, dtype=torch.float32) * (-math.log(10000.0) / dim)
|
| 30 |
+
)
|
| 31 |
+
pe[:, 0::2] = torch.sin(pos * div_term)
|
| 32 |
+
pe[:, 1::2] = torch.cos(pos * div_term)
|
| 33 |
+
return pe.unsqueeze(0)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def causal_mask(length: int, device: torch.device | None = None) -> torch.Tensor:
|
| 37 |
+
"""Boolean causal mask ``(L, L)``; ``True`` blocks future positions."""
|
| 38 |
+
return torch.triu(
|
| 39 |
+
torch.ones(length, length, dtype=torch.bool, device=device), diagonal=1
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class QFormerBlock(nn.Module):
|
| 44 |
+
"""BLIP-2-style block: query self-attn → query→patch cross-attn → FFN."""
|
| 45 |
+
|
| 46 |
+
def __init__(
|
| 47 |
+
self,
|
| 48 |
+
d_z: int,
|
| 49 |
+
d_kv: int,
|
| 50 |
+
num_heads: int = 8,
|
| 51 |
+
mlp_ratio: float = 4.0,
|
| 52 |
+
attn_drop: float = 0.0,
|
| 53 |
+
proj_drop: float = 0.0,
|
| 54 |
+
):
|
| 55 |
+
super().__init__()
|
| 56 |
+
self.norm1 = nn.LayerNorm(d_z)
|
| 57 |
+
self.self_attn = nn.MultiheadAttention(
|
| 58 |
+
embed_dim=d_z, num_heads=num_heads, dropout=attn_drop, batch_first=True
|
| 59 |
+
)
|
| 60 |
+
self.norm2_q = nn.LayerNorm(d_z)
|
| 61 |
+
self.norm2_kv = nn.LayerNorm(d_kv)
|
| 62 |
+
self.cross_attn = nn.MultiheadAttention(
|
| 63 |
+
embed_dim=d_z,
|
| 64 |
+
num_heads=num_heads,
|
| 65 |
+
dropout=attn_drop,
|
| 66 |
+
kdim=d_kv,
|
| 67 |
+
vdim=d_kv,
|
| 68 |
+
batch_first=True,
|
| 69 |
+
)
|
| 70 |
+
self.norm3 = nn.LayerNorm(d_z)
|
| 71 |
+
hidden = int(d_z * mlp_ratio)
|
| 72 |
+
self.mlp = nn.Sequential(
|
| 73 |
+
nn.Linear(d_z, hidden),
|
| 74 |
+
nn.GELU(),
|
| 75 |
+
nn.Dropout(proj_drop),
|
| 76 |
+
nn.Linear(hidden, d_z),
|
| 77 |
+
nn.Dropout(proj_drop),
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
def forward(self, q: torch.Tensor, kv: torch.Tensor) -> torch.Tensor:
|
| 81 |
+
h = self.norm1(q)
|
| 82 |
+
sa, _ = self.self_attn(h, h, h, need_weights=False)
|
| 83 |
+
q = q + sa
|
| 84 |
+
hq = self.norm2_q(q)
|
| 85 |
+
hkv = self.norm2_kv(kv)
|
| 86 |
+
ca, _ = self.cross_attn(hq, hkv, hkv, need_weights=False)
|
| 87 |
+
q = q + ca
|
| 88 |
+
q = q + self.mlp(self.norm3(q))
|
| 89 |
+
return q
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class TemporalBlock(nn.Module):
|
| 93 |
+
"""Pre-LN transformer block (self-attn + FFN).
|
| 94 |
+
|
| 95 |
+
Accepts a boolean ``attn_mask`` ``(L, L)`` (True = blocked) and a
|
| 96 |
+
``key_padding_mask`` ``(B, L)`` (True = padded) on every forward.
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
def __init__(self, d_in: int, num_heads: int = 8, mlp_ratio: float = 4.0):
|
| 100 |
+
super().__init__()
|
| 101 |
+
self.norm1 = nn.LayerNorm(d_in)
|
| 102 |
+
self.attn = nn.MultiheadAttention(
|
| 103 |
+
embed_dim=d_in, num_heads=num_heads, batch_first=True
|
| 104 |
+
)
|
| 105 |
+
self.norm2 = nn.LayerNorm(d_in)
|
| 106 |
+
hidden = int(d_in * mlp_ratio)
|
| 107 |
+
self.mlp = nn.Sequential(
|
| 108 |
+
nn.Linear(d_in, hidden),
|
| 109 |
+
nn.GELU(),
|
| 110 |
+
nn.Linear(hidden, d_in),
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
def forward(
|
| 114 |
+
self,
|
| 115 |
+
x: torch.Tensor,
|
| 116 |
+
attn_mask: torch.Tensor | None = None,
|
| 117 |
+
key_padding_mask: torch.Tensor | None = None,
|
| 118 |
+
) -> torch.Tensor:
|
| 119 |
+
h = self.norm1(x)
|
| 120 |
+
a, _ = self.attn(
|
| 121 |
+
h, h, h,
|
| 122 |
+
need_weights=False,
|
| 123 |
+
attn_mask=attn_mask,
|
| 124 |
+
key_padding_mask=key_padding_mask,
|
| 125 |
+
)
|
| 126 |
+
x = x + a
|
| 127 |
+
x = x + self.mlp(self.norm2(x))
|
| 128 |
+
return x
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d542adf5895ba690a825a920408faa97b7c4fba9876868db8ffc8bd38d6465bd
|
| 3 |
+
size 970913516
|
modeling_prism.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PRISM model.
|
| 2 |
+
|
| 3 |
+
Frozen SigLIP2 vision + frozen Qwen3-Embedding text + trainable Decompositional
|
| 4 |
+
Encoder θ + Compositional Latent Predictor φ + EMA target encoder θ̄.
|
| 5 |
+
|
| 6 |
+
One training ``forward`` returns:
|
| 7 |
+
|
| 8 |
+
- ``loss_decomp`` : symmetric InfoNCE between the compositional latent
|
| 9 |
+
``s = φ(z_vv^B, z_vi^A)`` and the recomposed text embedding
|
| 10 |
+
``e = Qwen3Embedding(compose(T_vi^A, T_vv^B))``, over the batch
|
| 11 |
+
(with DDP all-gather of ``s`` / ``e`` / the valid-pair mask). [paper §3.2]
|
| 12 |
+
- ``loss_temp_vi`` / ``loss_temp_vv`` : ``1 - cos(ẑ_t, z̄_{t+1})`` for each
|
| 13 |
+
stream, where the target ``z̄`` comes from the EMA encoder θ̄. [paper §3.3]
|
| 14 |
+
- ``loss = λ_decomp · loss_decomp + λ_temp · ½(loss_temp_vi + loss_temp_vv)``.
|
| 15 |
+
|
| 16 |
+
Cross-pairing (one clip's view-variant stream with another clip's view-invariant
|
| 17 |
+
stream) is done inside ``forward`` via a cyclic shift of the batch: the
|
| 18 |
+
view-variant stream comes from clip ``i``, the view-invariant stream from clip
|
| 19 |
+
``(i+1) mod B``. The trainer builds the matching recomposed caption with the
|
| 20 |
+
same convention.
|
| 21 |
+
|
| 22 |
+
At inference, ``encode`` returns an L2-normalized clip embedding (mean-pooled
|
| 23 |
+
``z_vi`` over valid frames) from the EMA encoder.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import logging
|
| 29 |
+
from dataclasses import dataclass
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
import torch
|
| 33 |
+
import torch.distributed as dist
|
| 34 |
+
import torch.nn.functional as F
|
| 35 |
+
from torch import nn
|
| 36 |
+
from transformers import AutoModel, PreTrainedModel
|
| 37 |
+
|
| 38 |
+
logger = logging.getLogger(__name__)
|
| 39 |
+
|
| 40 |
+
from .configuration_prism import PRISMConfig
|
| 41 |
+
from .ema import make_ema_copy, sync_ema_from_online, update_ema
|
| 42 |
+
from .encoder import DecompositionalEncoder
|
| 43 |
+
# Unused here, but kept as a direct import: when this file is served as Hub remote
|
| 44 |
+
# code, transformers ships only the relative imports named in *this* module, so
|
| 45 |
+
# ``layers`` (used by encoder/predictor) has to be visible from here.
|
| 46 |
+
from .layers import QFormerBlock, TemporalBlock # noqa: F401
|
| 47 |
+
from .predictor import CompositionalPredictor
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class PRISMOutput:
|
| 52 |
+
loss: torch.Tensor
|
| 53 |
+
loss_decomp: torch.Tensor
|
| 54 |
+
loss_temp_vi: torch.Tensor
|
| 55 |
+
loss_temp_vv: torch.Tensor
|
| 56 |
+
n_valid_pairs: int
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ---------------------------------------------------------------------------
|
| 60 |
+
# Loss / distributed helpers
|
| 61 |
+
# ---------------------------------------------------------------------------
|
| 62 |
+
def _symmetric_infonce(
|
| 63 |
+
a: torch.Tensor, b: torch.Tensor, logit_scale: torch.Tensor
|
| 64 |
+
) -> torch.Tensor:
|
| 65 |
+
"""CLIP-style symmetric InfoNCE on L2-normalized features."""
|
| 66 |
+
a = F.normalize(a, dim=-1)
|
| 67 |
+
b = F.normalize(b, dim=-1)
|
| 68 |
+
scale = logit_scale.exp().clamp(max=100.0)
|
| 69 |
+
logits = scale * a @ b.t()
|
| 70 |
+
labels = torch.arange(a.shape[0], device=a.device)
|
| 71 |
+
return 0.5 * (F.cross_entropy(logits, labels) + F.cross_entropy(logits.t(), labels))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _all_gather_with_grad(x: torch.Tensor) -> torch.Tensor:
|
| 75 |
+
"""CLIP-style all-gather: concat across ranks; own-rank slot keeps gradient.
|
| 76 |
+
|
| 77 |
+
Other ranks' tensors are detached for this rank's backward; DDP's gradient
|
| 78 |
+
all-reduce then distributes the gradient across ranks, making it equivalent
|
| 79 |
+
to a single forward over the full ``B * world_size`` batch. Single-process
|
| 80 |
+
→ returns ``x`` unchanged.
|
| 81 |
+
"""
|
| 82 |
+
if not dist.is_available() or not dist.is_initialized():
|
| 83 |
+
return x
|
| 84 |
+
world_size = dist.get_world_size()
|
| 85 |
+
if world_size == 1:
|
| 86 |
+
return x
|
| 87 |
+
rank = dist.get_rank()
|
| 88 |
+
gathered = [torch.empty_like(x) for _ in range(world_size)]
|
| 89 |
+
dist.all_gather(gathered, x.contiguous())
|
| 90 |
+
gathered[rank] = x # own-rank slot keeps grad
|
| 91 |
+
return torch.cat(gathered, dim=0)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _all_gather_bool(x: torch.Tensor) -> torch.Tensor:
|
| 95 |
+
"""Plain all-gather for boolean masks (no gradient)."""
|
| 96 |
+
if not dist.is_available() or not dist.is_initialized():
|
| 97 |
+
return x
|
| 98 |
+
world_size = dist.get_world_size()
|
| 99 |
+
if world_size == 1:
|
| 100 |
+
return x
|
| 101 |
+
gathered = [torch.empty_like(x) for _ in range(world_size)]
|
| 102 |
+
dist.all_gather(gathered, x.contiguous())
|
| 103 |
+
return torch.cat(gathered, dim=0)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _sample_shift_plan(
|
| 107 |
+
valid_a: torch.Tensor, valid_b: torch.Tensor, T: int
|
| 108 |
+
) -> dict:
|
| 109 |
+
"""Sliding-shift augmentation plan.
|
| 110 |
+
|
| 111 |
+
For each sample, place the shorter clip's valid frames at a random offset
|
| 112 |
+
within the longer clip's valid range. Returns gather indices and post-shift
|
| 113 |
+
valid masks for both sides; apply identically to online and EMA tensors so
|
| 114 |
+
predictor input and temporal target stay time-aligned.
|
| 115 |
+
"""
|
| 116 |
+
B = valid_a.shape[0]
|
| 117 |
+
device = valid_a.device
|
| 118 |
+
t_a = valid_a.int().sum(dim=1)
|
| 119 |
+
t_b = valid_b.int().sum(dim=1)
|
| 120 |
+
max_off_a = torch.clamp(t_b - t_a, min=0)
|
| 121 |
+
max_off_b = torch.clamp(t_a - t_b, min=0)
|
| 122 |
+
|
| 123 |
+
rand = torch.rand(B, 2, device=device)
|
| 124 |
+
offset_a = (rand[:, 0] * (max_off_a.float() + 1.0)).long().clamp(max=max_off_a)
|
| 125 |
+
offset_b = (rand[:, 1] * (max_off_b.float() + 1.0)).long().clamp(max=max_off_b)
|
| 126 |
+
|
| 127 |
+
arange_T = torch.arange(T, device=device).unsqueeze(0).expand(B, -1)
|
| 128 |
+
src_idx_a = arange_T - offset_a.unsqueeze(1)
|
| 129 |
+
src_idx_b = arange_T - offset_b.unsqueeze(1)
|
| 130 |
+
new_valid_a = (src_idx_a >= 0) & (src_idx_a < t_a.unsqueeze(1))
|
| 131 |
+
new_valid_b = (src_idx_b >= 0) & (src_idx_b < t_b.unsqueeze(1))
|
| 132 |
+
return {
|
| 133 |
+
"src_idx_a": src_idx_a.clamp(0, T - 1),
|
| 134 |
+
"src_idx_b": src_idx_b.clamp(0, T - 1),
|
| 135 |
+
"new_valid_a": new_valid_a,
|
| 136 |
+
"new_valid_b": new_valid_b,
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _apply_shift(
|
| 141 |
+
z: torch.Tensor, src_idx: torch.Tensor, valid: torch.Tensor
|
| 142 |
+
) -> torch.Tensor:
|
| 143 |
+
"""Gather ``z[B, T, D]`` along T via ``src_idx[B, T]``; zero invalid positions."""
|
| 144 |
+
gather_idx = src_idx.unsqueeze(-1).expand(-1, -1, z.shape[-1])
|
| 145 |
+
return torch.gather(z, dim=1, index=gather_idx) * valid.unsqueeze(-1).to(z.dtype)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# ---------------------------------------------------------------------------
|
| 149 |
+
# Checkpoint resolution (local directory or Hugging Face Hub repo)
|
| 150 |
+
# ---------------------------------------------------------------------------
|
| 151 |
+
_WEIGHTS_NAME = "model.safetensors"
|
| 152 |
+
_HUB_KWARGS = ("revision", "cache_dir", "token", "force_download", "local_files_only", "proxies")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _resolve_weights(path_or_repo: str, **hub_kwargs) -> str:
|
| 156 |
+
"""Path to the checkpoint's weights: a local directory, else a Hub repo id."""
|
| 157 |
+
local = Path(path_or_repo) / _WEIGHTS_NAME
|
| 158 |
+
if local.is_file():
|
| 159 |
+
return str(local)
|
| 160 |
+
from huggingface_hub import hf_hub_download
|
| 161 |
+
|
| 162 |
+
return hf_hub_download(repo_id=str(path_or_repo), filename=_WEIGHTS_NAME, **hub_kwargs)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ---------------------------------------------------------------------------
|
| 166 |
+
# Model
|
| 167 |
+
# ---------------------------------------------------------------------------
|
| 168 |
+
class PRISMModel(PreTrainedModel):
|
| 169 |
+
config_class = PRISMConfig
|
| 170 |
+
base_model_prefix = "prism"
|
| 171 |
+
# Frozen backbones are reloaded from the Hub in __init__ and excluded from
|
| 172 |
+
# the saved checkpoint (see ``state_dict``); silence the load-time warning.
|
| 173 |
+
_keys_to_ignore_on_load_missing = [r"^vision_model\.", r"^text_model\."]
|
| 174 |
+
supports_gradient_checkpointing = False
|
| 175 |
+
|
| 176 |
+
def __init__(self, config: PRISMConfig):
|
| 177 |
+
super().__init__(config)
|
| 178 |
+
|
| 179 |
+
# ---- Frozen vision tower ----
|
| 180 |
+
# CLIP keeps a CLS token in last_hidden_state; SigLIP / SigLIP2 do not.
|
| 181 |
+
# SigLIP2 weights use the SigLIP v1 architecture, so SiglipVisionModel
|
| 182 |
+
# handles both checkpoint families.
|
| 183 |
+
if "siglip" in config.vision_backbone_name.lower():
|
| 184 |
+
from transformers import SiglipVisionModel
|
| 185 |
+
self.vision_model = SiglipVisionModel.from_pretrained(config.vision_backbone_name)
|
| 186 |
+
self._vision_has_cls = False
|
| 187 |
+
else:
|
| 188 |
+
from transformers import CLIPVisionModel
|
| 189 |
+
self.vision_model = CLIPVisionModel.from_pretrained(config.vision_backbone_name)
|
| 190 |
+
self._vision_has_cls = True
|
| 191 |
+
d_v = int(self.vision_model.config.hidden_size)
|
| 192 |
+
|
| 193 |
+
# ---- Frozen Qwen3-Embedding text tower ----
|
| 194 |
+
self.text_model = AutoModel.from_pretrained(config.text_backbone_name)
|
| 195 |
+
d_t = int(self.text_model.config.hidden_size)
|
| 196 |
+
|
| 197 |
+
for p in self.vision_model.parameters():
|
| 198 |
+
p.requires_grad = False
|
| 199 |
+
for p in self.text_model.parameters():
|
| 200 |
+
p.requires_grad = False
|
| 201 |
+
self.vision_model.eval()
|
| 202 |
+
self.text_model.eval()
|
| 203 |
+
|
| 204 |
+
self.d_v = d_v
|
| 205 |
+
self.d_t = d_t
|
| 206 |
+
|
| 207 |
+
# ---- Trainable: Decompositional Encoder θ + Compositional Predictor φ ----
|
| 208 |
+
self.encoder = DecompositionalEncoder(
|
| 209 |
+
d_z=config.d_z, d_kv=d_v,
|
| 210 |
+
qformer_depth=config.qformer_depth, temporal_depth=config.temporal_depth,
|
| 211 |
+
num_heads=config.num_heads, mlp_ratio=config.mlp_ratio,
|
| 212 |
+
max_frames=config.max_frames,
|
| 213 |
+
)
|
| 214 |
+
self.predictor = CompositionalPredictor(
|
| 215 |
+
d_z=config.d_z, d_t=d_t, max_frames=config.max_frames,
|
| 216 |
+
depth=config.predictor_depth, num_heads=config.num_heads,
|
| 217 |
+
mlp_ratio=config.mlp_ratio,
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# ---- EMA target encoder θ̄ ----
|
| 221 |
+
if config.use_ema:
|
| 222 |
+
self.target_encoder = make_ema_copy(self.encoder)
|
| 223 |
+
|
| 224 |
+
self.logit_scale = nn.Parameter(torch.tensor(config.logit_scale_init))
|
| 225 |
+
|
| 226 |
+
# -- keep trainable defaults; do not re-init the from-Hub backbones --
|
| 227 |
+
def _init_weights(self, module): # noqa: D401
|
| 228 |
+
pass
|
| 229 |
+
|
| 230 |
+
@classmethod
|
| 231 |
+
def from_pretrained(cls, pretrained_model_name_or_path, *model_args, **kwargs):
|
| 232 |
+
"""Load a weights-only PRISM checkpoint from a local directory or the Hub.
|
| 233 |
+
|
| 234 |
+
The frozen vision / text backbones are not stored in the checkpoint;
|
| 235 |
+
they are rebuilt from the Hub in ``__init__``. Only the trained weights
|
| 236 |
+
(encoder θ, predictor φ, target encoder θ̄, logit scale) are loaded. The
|
| 237 |
+
``dtype`` / ``torch_dtype`` kwarg is honored; other HF loading kwargs
|
| 238 |
+
(device_map, sharding, ...) are not needed for this single-file ckpt.
|
| 239 |
+
"""
|
| 240 |
+
from safetensors.torch import load_file
|
| 241 |
+
|
| 242 |
+
hub_kwargs = {k: kwargs.pop(k) for k in _HUB_KWARGS if kwargs.get(k) is not None}
|
| 243 |
+
config = kwargs.pop("config", None)
|
| 244 |
+
if not isinstance(config, PRISMConfig):
|
| 245 |
+
config = PRISMConfig.from_pretrained(pretrained_model_name_or_path, **hub_kwargs)
|
| 246 |
+
dtype = kwargs.pop("torch_dtype", None) or kwargs.pop("dtype", None)
|
| 247 |
+
|
| 248 |
+
model = cls(config) # backbones materialized from the Hub
|
| 249 |
+
state = load_file(_resolve_weights(pretrained_model_name_or_path, **hub_kwargs))
|
| 250 |
+
missing, unexpected = model.load_state_dict(state, strict=False)
|
| 251 |
+
bad_missing = [m for m in missing if not m.startswith(("vision_model.", "text_model."))]
|
| 252 |
+
if bad_missing:
|
| 253 |
+
logger.warning(f"missing non-backbone keys: {bad_missing[:8]}")
|
| 254 |
+
if unexpected:
|
| 255 |
+
logger.warning(f"unexpected keys: {unexpected[:8]}")
|
| 256 |
+
# Warm-start the target encoder if a checkpoint predates EMA weights.
|
| 257 |
+
if config.use_ema and any(k.startswith("target_encoder.") for k in bad_missing):
|
| 258 |
+
model.sync_ema_from_online()
|
| 259 |
+
if dtype is not None:
|
| 260 |
+
model = model.to(dtype)
|
| 261 |
+
return model
|
| 262 |
+
|
| 263 |
+
def train(self, mode: bool = True):
|
| 264 |
+
"""Keep frozen backbones (and the EMA target encoder) in eval mode."""
|
| 265 |
+
super().train(mode)
|
| 266 |
+
self.vision_model.eval()
|
| 267 |
+
self.text_model.eval()
|
| 268 |
+
if getattr(self.config, "use_ema", False):
|
| 269 |
+
self.target_encoder.eval()
|
| 270 |
+
return self
|
| 271 |
+
|
| 272 |
+
def state_dict(self, *args, **kwargs):
|
| 273 |
+
"""Exclude the frozen, from-Hub backbones from saved checkpoints."""
|
| 274 |
+
sd = super().state_dict(*args, **kwargs)
|
| 275 |
+
return type(sd)(
|
| 276 |
+
(k, v) for k, v in sd.items()
|
| 277 |
+
if not k.startswith(("vision_model.", "text_model."))
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
# ------------------------------------------------------------------
|
| 281 |
+
# Frozen backbone helpers
|
| 282 |
+
# ------------------------------------------------------------------
|
| 283 |
+
@torch.no_grad()
|
| 284 |
+
def _encode_video(self, pixel_values: torch.Tensor) -> torch.Tensor:
|
| 285 |
+
"""``(B, T, 3, H, W)`` → patch tokens ``(B, T, P, d_v)`` (CLS dropped for CLIP)."""
|
| 286 |
+
B, T = pixel_values.shape[:2]
|
| 287 |
+
x = pixel_values.reshape(B * T, *pixel_values.shape[2:])
|
| 288 |
+
seq = self.vision_model(pixel_values=x).last_hidden_state
|
| 289 |
+
if self._vision_has_cls:
|
| 290 |
+
seq = seq[:, 1:, :]
|
| 291 |
+
return seq.reshape(B, T, seq.shape[1], self.d_v)
|
| 292 |
+
|
| 293 |
+
@torch.no_grad()
|
| 294 |
+
def _encode_text(
|
| 295 |
+
self, input_ids: torch.Tensor, attention_mask: torch.Tensor
|
| 296 |
+
) -> torch.Tensor:
|
| 297 |
+
"""Qwen3-Embedding: last-token pool over a right-padded batch → L2-normed ``(N, d_t)``."""
|
| 298 |
+
last_hidden = self.text_model(
|
| 299 |
+
input_ids=input_ids, attention_mask=attention_mask
|
| 300 |
+
).last_hidden_state
|
| 301 |
+
last_idx = (attention_mask.sum(dim=1) - 1).clamp(min=0)
|
| 302 |
+
rows = torch.arange(last_hidden.shape[0], device=last_hidden.device)
|
| 303 |
+
return F.normalize(last_hidden[rows, last_idx], dim=-1)
|
| 304 |
+
|
| 305 |
+
# ------------------------------------------------------------------
|
| 306 |
+
# EMA hooks (called by the trainer after each optimizer step)
|
| 307 |
+
# ------------------------------------------------------------------
|
| 308 |
+
@torch.no_grad()
|
| 309 |
+
def update_ema(self) -> None:
|
| 310 |
+
if getattr(self.config, "use_ema", False):
|
| 311 |
+
update_ema(self.target_encoder, self.encoder, self.config.ema_decay)
|
| 312 |
+
|
| 313 |
+
@torch.no_grad()
|
| 314 |
+
def sync_ema_from_online(self) -> None:
|
| 315 |
+
if getattr(self.config, "use_ema", False):
|
| 316 |
+
sync_ema_from_online(self.target_encoder, self.encoder)
|
| 317 |
+
|
| 318 |
+
# ------------------------------------------------------------------
|
| 319 |
+
# Inference
|
| 320 |
+
# ------------------------------------------------------------------
|
| 321 |
+
@torch.no_grad()
|
| 322 |
+
def encode_streams(
|
| 323 |
+
self, pixel_values: torch.Tensor, valid_mask: torch.Tensor | None = None
|
| 324 |
+
) -> dict:
|
| 325 |
+
"""Run θ̄ (or θ if ``use_ema=False``) → ``{z_vi_seq, z_vv_seq}`` each ``(B, T, d_z)``."""
|
| 326 |
+
patches = self._encode_video(pixel_values)
|
| 327 |
+
kpm = None if valid_mask is None else (~valid_mask)
|
| 328 |
+
enc = self.target_encoder if getattr(self.config, "use_ema", False) else self.encoder
|
| 329 |
+
z_vi, z_vv = enc(patches, key_padding_mask=kpm)
|
| 330 |
+
return {"z_vi_seq": z_vi, "z_vv_seq": z_vv}
|
| 331 |
+
|
| 332 |
+
@torch.no_grad()
|
| 333 |
+
def encode(
|
| 334 |
+
self, pixel_values: torch.Tensor, valid_mask: torch.Tensor | None = None
|
| 335 |
+
) -> torch.Tensor:
|
| 336 |
+
"""Clip embedding: L2-normalized mean-pool of ``z_vi`` over valid frames → ``(B, d_z)``."""
|
| 337 |
+
z_vi = self.encode_streams(pixel_values, valid_mask)["z_vi_seq"].float()
|
| 338 |
+
if valid_mask is None:
|
| 339 |
+
valid = torch.ones(z_vi.shape[:2], device=z_vi.device, dtype=z_vi.dtype)
|
| 340 |
+
else:
|
| 341 |
+
valid = valid_mask.to(z_vi.dtype)
|
| 342 |
+
denom = valid.sum(dim=1, keepdim=True).clamp(min=1.0)
|
| 343 |
+
emb = (z_vi * valid.unsqueeze(-1)).sum(dim=1) / denom
|
| 344 |
+
return F.normalize(emb, dim=-1)
|
| 345 |
+
|
| 346 |
+
# ------------------------------------------------------------------
|
| 347 |
+
# Training forward
|
| 348 |
+
# ------------------------------------------------------------------
|
| 349 |
+
def forward(
|
| 350 |
+
self,
|
| 351 |
+
pixel_values: torch.Tensor,
|
| 352 |
+
valid_mask: torch.Tensor,
|
| 353 |
+
composed_input_ids: torch.Tensor,
|
| 354 |
+
composed_attention_mask: torch.Tensor,
|
| 355 |
+
valid_pair_mask: torch.Tensor | None = None,
|
| 356 |
+
) -> PRISMOutput:
|
| 357 |
+
"""
|
| 358 |
+
pixel_values: ``(B, T, 3, H, W)`` — padded to T in the collator.
|
| 359 |
+
valid_mask: ``(B, T)`` bool, True = real frame.
|
| 360 |
+
composed_input_ids: ``(B, L)`` — tokenized recomposed caption per pair.
|
| 361 |
+
composed_attention_mask: ``(B, L)``.
|
| 362 |
+
valid_pair_mask: ``(B,)`` bool — True if the composer succeeded.
|
| 363 |
+
"""
|
| 364 |
+
B = pixel_values.shape[0]
|
| 365 |
+
device = pixel_values.device
|
| 366 |
+
use_ema = getattr(self.config, "use_ema", False)
|
| 367 |
+
|
| 368 |
+
# ---- Frozen encoders ----
|
| 369 |
+
patches = self._encode_video(pixel_values) # (B, T, P, d_v)
|
| 370 |
+
T = patches.shape[1]
|
| 371 |
+
e_text = self._encode_text(composed_input_ids, composed_attention_mask) # (B, d_t)
|
| 372 |
+
|
| 373 |
+
# ---- Decompositional encoder θ (and EMA θ̄ for temporal targets) ----
|
| 374 |
+
kpm = ~valid_mask
|
| 375 |
+
z_vi_seq, z_vv_seq = self.encoder(patches, key_padding_mask=kpm)
|
| 376 |
+
if use_ema:
|
| 377 |
+
with torch.no_grad():
|
| 378 |
+
z_vi_seq_ema, z_vv_seq_ema = self.target_encoder(patches, key_padding_mask=kpm)
|
| 379 |
+
|
| 380 |
+
# ---- Cross-pairing: view-variant from clip i, view-invariant from (i+1) ----
|
| 381 |
+
shift = torch.roll(torch.arange(B, device=device), shifts=-1, dims=0)
|
| 382 |
+
z_vv = z_vv_seq # view-variant (clip i)
|
| 383 |
+
z_vi = z_vi_seq[shift] # view-invariant (clip i+1)
|
| 384 |
+
valid_vv = valid_mask
|
| 385 |
+
valid_vi = valid_mask[shift]
|
| 386 |
+
if use_ema:
|
| 387 |
+
z_vv_ema = z_vv_seq_ema
|
| 388 |
+
z_vi_ema = z_vi_seq_ema[shift]
|
| 389 |
+
|
| 390 |
+
# ---- Sliding-shift augmentation (training only) ----
|
| 391 |
+
if self.training and getattr(self.config, "sliding_shift_aug", True):
|
| 392 |
+
plan = _sample_shift_plan(valid_vv, valid_vi, T)
|
| 393 |
+
z_vv = _apply_shift(z_vv, plan["src_idx_a"], plan["new_valid_a"])
|
| 394 |
+
z_vi = _apply_shift(z_vi, plan["src_idx_b"], plan["new_valid_b"])
|
| 395 |
+
if use_ema:
|
| 396 |
+
z_vv_ema = _apply_shift(z_vv_ema, plan["src_idx_a"], plan["new_valid_a"])
|
| 397 |
+
z_vi_ema = _apply_shift(z_vi_ema, plan["src_idx_b"], plan["new_valid_b"])
|
| 398 |
+
valid_vv = plan["new_valid_a"]
|
| 399 |
+
valid_vi = plan["new_valid_b"]
|
| 400 |
+
|
| 401 |
+
# ---- Compositional predictor φ ----
|
| 402 |
+
out = self.predictor(z_vv, z_vi, valid_vv=valid_vv, valid_vi=valid_vi)
|
| 403 |
+
s = out["s"] # (B, d_t) compositional latent
|
| 404 |
+
z_vi_pred = out["z_vi_pred"] # (B, T, d_z) vi next-frame head
|
| 405 |
+
z_vv_pred = out["z_vv_pred"] # (B, T, d_z) vv next-frame head
|
| 406 |
+
pair_valid = out["pair_valid"] # (B, T)
|
| 407 |
+
|
| 408 |
+
# ---- L_decomp: InfoNCE(s, e_text) over valid pairs ----
|
| 409 |
+
if valid_pair_mask is None:
|
| 410 |
+
valid_pair_mask = torch.ones(B, dtype=torch.bool, device=device)
|
| 411 |
+
valid_pair_mask = valid_pair_mask & pair_valid.any(dim=1)
|
| 412 |
+
|
| 413 |
+
if self.config.infonce_all_gather:
|
| 414 |
+
s_g = _all_gather_with_grad(s)
|
| 415 |
+
e_g = _all_gather_with_grad(e_text)
|
| 416 |
+
valid_g = _all_gather_bool(valid_pair_mask)
|
| 417 |
+
else:
|
| 418 |
+
s_g, e_g, valid_g = s, e_text, valid_pair_mask
|
| 419 |
+
|
| 420 |
+
valid_idx = valid_g.nonzero(as_tuple=True)[0]
|
| 421 |
+
n_valid = int(valid_idx.numel())
|
| 422 |
+
if n_valid >= 2:
|
| 423 |
+
loss_decomp = _symmetric_infonce(s_g[valid_idx], e_g[valid_idx], self.logit_scale)
|
| 424 |
+
else:
|
| 425 |
+
loss_decomp = torch.zeros((), device=device)
|
| 426 |
+
|
| 427 |
+
# ---- L_temp: 1 - cos(prediction at t, EMA target at t+1), per stream ----
|
| 428 |
+
if T >= 2:
|
| 429 |
+
if use_ema:
|
| 430 |
+
tgt_vi = z_vi_ema[:, 1:T, :]
|
| 431 |
+
tgt_vv = z_vv_ema[:, 1:T, :]
|
| 432 |
+
else:
|
| 433 |
+
tgt_vi = z_vi.detach()[:, 1:T, :]
|
| 434 |
+
tgt_vv = z_vv.detach()[:, 1:T, :]
|
| 435 |
+
valid_next_vi = valid_vi[:, :T - 1] & valid_vi[:, 1:T]
|
| 436 |
+
valid_next_vv = valid_vv[:, :T - 1] & valid_vv[:, 1:T]
|
| 437 |
+
err_vi = 1.0 - F.cosine_similarity(z_vi_pred[:, :T - 1, :], tgt_vi, dim=-1)
|
| 438 |
+
err_vv = 1.0 - F.cosine_similarity(z_vv_pred[:, :T - 1, :], tgt_vv, dim=-1)
|
| 439 |
+
loss_temp_vi = err_vi[valid_next_vi].mean() if valid_next_vi.any() else torch.zeros((), device=device)
|
| 440 |
+
loss_temp_vv = err_vv[valid_next_vv].mean() if valid_next_vv.any() else torch.zeros((), device=device)
|
| 441 |
+
else:
|
| 442 |
+
loss_temp_vi = torch.zeros((), device=device)
|
| 443 |
+
loss_temp_vv = torch.zeros((), device=device)
|
| 444 |
+
|
| 445 |
+
loss_temp = 0.5 * (loss_temp_vi + loss_temp_vv)
|
| 446 |
+
loss = self.config.lambda_decomp * loss_decomp + self.config.lambda_temp * loss_temp
|
| 447 |
+
|
| 448 |
+
return PRISMOutput(
|
| 449 |
+
loss=loss,
|
| 450 |
+
loss_decomp=loss_decomp.detach(),
|
| 451 |
+
loss_temp_vi=loss_temp_vi.detach(),
|
| 452 |
+
loss_temp_vv=loss_temp_vv.detach(),
|
| 453 |
+
n_valid_pairs=n_valid,
|
| 454 |
+
)
|
predictor.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compositional Latent Predictor φ.
|
| 2 |
+
|
| 3 |
+
Takes the view-variant stream of one clip and the view-invariant stream of
|
| 4 |
+
another, fuses them per time step, and runs a small causal transformer
|
| 5 |
+
(paper §3.1–3.3). One token per frame::
|
| 6 |
+
|
| 7 |
+
token_t = concat(z_vv_t, z_vi_t) ∈ R^{2·d_z}
|
| 8 |
+
|
| 9 |
+
with a standard causal mask (frame ``t`` attends to ``≤ t``) and a key-padding
|
| 10 |
+
mask blocking frames where either stream is padded.
|
| 11 |
+
|
| 12 |
+
Three linear heads on the final hidden state ``x ∈ R^{B×T×2·d_z}``:
|
| 13 |
+
|
| 14 |
+
- ``cls_head`` (2·d_z → d_t): at the last valid pair-frame → the compositional
|
| 15 |
+
semantic latent ``s`` aligned to language (``L_decomp``).
|
| 16 |
+
- ``vi_head`` (2·d_z → d_z): per t → ``ẑ_vi`` predicting the view-invariant
|
| 17 |
+
input stream's next frame (``L_temp``).
|
| 18 |
+
- ``vv_head`` (2·d_z → d_z): per t → ``ẑ_vv`` predicting the view-variant
|
| 19 |
+
input stream's next frame (``L_temp``).
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import torch
|
| 25 |
+
from torch import nn
|
| 26 |
+
|
| 27 |
+
from .layers import TemporalBlock, build_sin_pos_embed, causal_mask
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class CompositionalPredictor(nn.Module):
|
| 31 |
+
def __init__(
|
| 32 |
+
self,
|
| 33 |
+
d_z: int = 512,
|
| 34 |
+
d_t: int = 1024,
|
| 35 |
+
max_frames: int = 128,
|
| 36 |
+
depth: int = 4,
|
| 37 |
+
num_heads: int = 8,
|
| 38 |
+
mlp_ratio: float = 4.0,
|
| 39 |
+
):
|
| 40 |
+
super().__init__()
|
| 41 |
+
self.d_z = d_z
|
| 42 |
+
self.d_t = d_t
|
| 43 |
+
self.max_frames = max_frames
|
| 44 |
+
self.d_in = 2 * d_z # channel-concat [z_vv ‖ z_vi]
|
| 45 |
+
|
| 46 |
+
self.register_buffer(
|
| 47 |
+
"pos_embed", build_sin_pos_embed(max_frames, self.d_in), persistent=False
|
| 48 |
+
)
|
| 49 |
+
self.input_norm = nn.LayerNorm(self.d_in)
|
| 50 |
+
self.blocks = nn.ModuleList(
|
| 51 |
+
[TemporalBlock(self.d_in, num_heads, mlp_ratio) for _ in range(depth)]
|
| 52 |
+
)
|
| 53 |
+
self.final_norm = nn.LayerNorm(self.d_in)
|
| 54 |
+
|
| 55 |
+
self.cls_head = nn.Linear(self.d_in, d_t) # → s (compositional latent)
|
| 56 |
+
self.vi_head = nn.Linear(self.d_in, d_z) # → ẑ_vi (next-frame, V-I)
|
| 57 |
+
self.vv_head = nn.Linear(self.d_in, d_z) # → ẑ_vv (next-frame, V-V)
|
| 58 |
+
|
| 59 |
+
def forward(
|
| 60 |
+
self,
|
| 61 |
+
z_vv: torch.Tensor,
|
| 62 |
+
z_vi: torch.Tensor,
|
| 63 |
+
valid_vv: torch.Tensor,
|
| 64 |
+
valid_vi: torch.Tensor,
|
| 65 |
+
) -> dict:
|
| 66 |
+
"""
|
| 67 |
+
z_vv, z_vi: ``(B, T, d_z)`` — view-variant / view-invariant streams,
|
| 68 |
+
generally sourced from two different clips.
|
| 69 |
+
valid_vv, valid_vi: ``(B, T)`` bool — True where the frame is real.
|
| 70 |
+
|
| 71 |
+
Returns dict:
|
| 72 |
+
s: ``(B, d_t)`` cls head at the last valid pair-frame.
|
| 73 |
+
z_vi_pred: ``(B, T, d_z)`` vi head per t (predicts z_vi_{t+1}).
|
| 74 |
+
z_vv_pred: ``(B, T, d_z)`` vv head per t (predicts z_vv_{t+1}).
|
| 75 |
+
pair_valid: ``(B, T)`` valid_vv & valid_vi.
|
| 76 |
+
"""
|
| 77 |
+
B, T, _ = z_vv.shape
|
| 78 |
+
device = z_vv.device
|
| 79 |
+
assert T <= self.max_frames, f"T={T} > max_frames={self.max_frames}"
|
| 80 |
+
assert z_vi.shape == z_vv.shape, "z_vv and z_vi must match shape"
|
| 81 |
+
|
| 82 |
+
x = torch.cat([z_vv, z_vi], dim=-1) # (B, T, 2·d_z)
|
| 83 |
+
x = x + self.pos_embed[:, :T, :].to(dtype=x.dtype)
|
| 84 |
+
x = self.input_norm(x)
|
| 85 |
+
|
| 86 |
+
attn_mask = causal_mask(T, device=device) # (T, T) True = blocked
|
| 87 |
+
pair_valid = valid_vv & valid_vi # (B, T)
|
| 88 |
+
key_padding_mask = ~pair_valid # True = blocked
|
| 89 |
+
for blk in self.blocks:
|
| 90 |
+
x = blk(x, attn_mask=attn_mask, key_padding_mask=key_padding_mask)
|
| 91 |
+
x = self.final_norm(x) # (B, T, 2·d_z)
|
| 92 |
+
|
| 93 |
+
# cls head at the last valid pair-frame per sample. (Samples with no
|
| 94 |
+
# valid pair-frame are filtered out of the loss downstream.)
|
| 95 |
+
arange_T = torch.arange(T, device=device).unsqueeze(0).expand(B, -1)
|
| 96 |
+
scored = arange_T.where(pair_valid, torch.full_like(arange_T, -1))
|
| 97 |
+
last_valid = scored.max(dim=1).values.clamp(min=0) # (B,)
|
| 98 |
+
last_tokens = x[torch.arange(B, device=device), last_valid, :]
|
| 99 |
+
s = self.cls_head(last_tokens) # (B, d_t)
|
| 100 |
+
|
| 101 |
+
return {
|
| 102 |
+
"s": s,
|
| 103 |
+
"z_vi_pred": self.vi_head(x), # (B, T, d_z)
|
| 104 |
+
"z_vv_pred": self.vv_head(x), # (B, T, d_z)
|
| 105 |
+
"pair_valid": pair_valid,
|
| 106 |
+
}
|