litcoderr commited on
Commit
a596b0a
·
verified ·
1 Parent(s): de6b8c6

Publish PRISM weights and modeling code

Browse files
Files changed (9) hide show
  1. README.md +130 -0
  2. config.json +27 -0
  3. configuration_prism.py +54 -0
  4. ema.py +48 -0
  5. encoder.py +150 -0
  6. layers.py +128 -0
  7. model.safetensors +3 -0
  8. modeling_prism.py +454 -0
  9. 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
+ }