apiantonio commited on
Commit
d60b444
·
verified ·
1 Parent(s): 52004c0

Fix transformers 4.x/5.x compat, implement output_hidden_states/attentions and out_layers, fix hierarchical predictor input, add video processor

Browse files
__init__.py CHANGED
@@ -1,15 +1,25 @@
 
 
1
  from .configuration_vjepa21 import VJEPA21Config
2
  from .modeling_vjepa21 import (
3
  VJEPA21ForVideoClassification,
4
  VJEPA21Model,
5
  VJEPA21PreTrainedModel,
6
  )
7
- from .video_processing_vjepa21 import VJEPA21VideoProcessor
8
 
9
  __all__ = [
10
  "VJEPA21Config",
11
  "VJEPA21Model",
12
  "VJEPA21PreTrainedModel",
13
  "VJEPA21ForVideoClassification",
14
- "VJEPA21VideoProcessor",
15
  ]
 
 
 
 
 
 
 
 
 
 
 
1
+ """V-JEPA 2.1 — HuggingFace port."""
2
+
3
  from .configuration_vjepa21 import VJEPA21Config
4
  from .modeling_vjepa21 import (
5
  VJEPA21ForVideoClassification,
6
  VJEPA21Model,
7
  VJEPA21PreTrainedModel,
8
  )
 
9
 
10
  __all__ = [
11
  "VJEPA21Config",
12
  "VJEPA21Model",
13
  "VJEPA21PreTrainedModel",
14
  "VJEPA21ForVideoClassification",
 
15
  ]
16
+
17
+ # `BaseVideoProcessor` needs torchvision. Import it lazily so that a runtime
18
+ # without torchvision can still load the model; `AutoVideoProcessor` resolves the
19
+ # class through `auto_map` and does not go through this file.
20
+ try: # pragma: no cover - depends on the environment
21
+ from .video_processing_vjepa21 import VJEPA21VideoProcessor
22
+
23
+ __all__.append("VJEPA21VideoProcessor")
24
+ except ImportError: # pragma: no cover
25
+ pass
config.json CHANGED
@@ -28,8 +28,8 @@
28
  "n_registers": 0,
29
  "num_attention_heads": 12,
30
  "num_hidden_layers": 12,
31
- "num_pooler_layers": 3,
32
  "num_pooler_heads": 16,
 
33
  "patch_size": 16,
34
  "pred_hidden_size": 384,
35
  "pred_mlp_ratio": 4.0,
 
28
  "n_registers": 0,
29
  "num_attention_heads": 12,
30
  "num_hidden_layers": 12,
 
31
  "num_pooler_heads": 16,
32
+ "num_pooler_layers": 3,
33
  "patch_size": 16,
34
  "pred_hidden_size": 384,
35
  "pred_mlp_ratio": 4.0,
configuration_vjepa21.py CHANGED
@@ -5,26 +5,52 @@ from typing import Optional
5
  from transformers import PretrainedConfig
6
 
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  def _get_hierarchical_layers(depth: int) -> list[int]:
9
- """Get hierarchical layer indices based on model depth.
 
 
 
 
 
 
10
 
11
- Mirrors `app/vjepa_2_1/models/vision_transformer.py` in the reference
12
- implementation.
13
- """
14
- _LAYER_MAP = {
15
- 4: [0, 1, 2, 3],
16
- 8: [1, 3, 5, 7],
17
- 12: [2, 5, 8, 11],
18
- 20: [4, 9, 14, 19],
19
- 24: [5, 11, 17, 23],
20
- 40: [9, 19, 29, 39],
21
- 48: [11, 23, 37, 47],
22
- }
23
- if depth not in _LAYER_MAP:
24
  raise ValueError(
25
- f"Unsupported depth {depth}. Supported depths: {list(_LAYER_MAP.keys())}"
 
26
  )
27
- return _LAYER_MAP[depth]
28
 
29
 
30
  class VJEPA21Config(PretrainedConfig):
@@ -86,10 +112,15 @@ class VJEPA21Config(PretrainedConfig):
86
  has_cls_first (`bool`, defaults to False):
87
  Whether the sequence starts with a CLS token.
88
  num_pooler_layers (`int`, defaults to 3):
89
- Number of self-attention layers in the attentive pooler.
90
- num_pooler_heads (`int` or `None`, defaults to None):
91
- Number of attention heads in the attentive pooler. Defaults to
92
- `num_attention_heads`. The reference frozen-evaluation probes use 16.
 
 
 
 
 
93
  pred_hidden_size (`int`, defaults to 384):
94
  Predictor embedding dimension.
95
  pred_num_attention_heads (`int`, defaults to 12):
@@ -138,7 +169,7 @@ class VJEPA21Config(PretrainedConfig):
138
  has_cls_first: bool = False,
139
  # Pooler
140
  num_pooler_layers: int = 3,
141
- num_pooler_heads: Optional[int] = None,
142
  # Predictor
143
  pred_hidden_size: int = 384,
144
  pred_num_attention_heads: int = 12,
@@ -179,9 +210,7 @@ class VJEPA21Config(PretrainedConfig):
179
 
180
  # Pooler
181
  self.num_pooler_layers = num_pooler_layers
182
- self.num_pooler_heads = (
183
- num_pooler_heads if num_pooler_heads is not None else num_attention_heads
184
- )
185
 
186
  # Predictor
187
  self.pred_hidden_size = pred_hidden_size
@@ -197,11 +226,18 @@ class VJEPA21Config(PretrainedConfig):
197
 
198
  def _validate(self) -> None:
199
  n_levels = len(_get_hierarchical_layers(self.num_hidden_layers))
 
200
  if not 1 <= self.n_output_distillation <= n_levels:
201
  raise ValueError(
202
  f"n_output_distillation must be in [1, {n_levels}] for a model with "
203
  f"{self.num_hidden_layers} layers, got {self.n_output_distillation}."
204
  )
 
 
 
 
 
 
205
  if self.hidden_size % self.num_attention_heads != 0:
206
  raise ValueError(
207
  f"hidden_size ({self.hidden_size}) must be divisible by "
@@ -217,22 +253,38 @@ class VJEPA21Config(PretrainedConfig):
217
  f"pred_hidden_size ({self.pred_hidden_size}) must be divisible by "
218
  f"pred_num_attention_heads ({self.pred_num_attention_heads})."
219
  )
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
  @property
222
  def encoder_hierarchical_layers(self) -> list[int]:
223
- """Layer indices for hierarchical output collection in the encoder."""
224
  return _get_hierarchical_layers(self.num_hidden_layers)
225
 
226
  @property
227
  def encoder_distillation_layers(self) -> list[int]:
228
- """Layer indices used for distillation output in the encoder."""
229
  all_layers = _get_hierarchical_layers(self.num_hidden_layers)
230
  return all_layers[-self.n_output_distillation :]
231
 
232
  @property
233
  def predictor_hierarchical_layers(self) -> list[int]:
234
- """Layer indices for hierarchical output in the predictor."""
235
- all_layers = _get_hierarchical_layers(self.pred_num_hidden_layers)
 
 
 
 
236
  return all_layers[-self.n_output_distillation :]
237
 
238
  @property
 
5
  from transformers import PretrainedConfig
6
 
7
 
8
+ # Encoder hierarchical layers, from `app/vjepa_2_1/models/vision_transformer.py`
9
+ # in `facebookresearch/vjepa2`.
10
+ _ENCODER_LAYER_MAP = {
11
+ 4: [0, 1, 2, 3],
12
+ 8: [1, 3, 5, 7],
13
+ 12: [2, 5, 8, 11],
14
+ 20: [4, 9, 14, 19],
15
+ 24: [5, 11, 17, 23],
16
+ 40: [9, 19, 29, 39],
17
+ 48: [11, 23, 37, 47],
18
+ }
19
+
20
+ # Predictor hierarchical layers, from `app/vjepa_2_1/models/predictor.py`.
21
+ # NOTE: this is *not* the same table as the encoder's. At depth 24 the reference
22
+ # predictor uses [4, 11, 17, 23] while the encoder uses [5, 11, 17, 23], and the
23
+ # predictor table has no entry for depth 48. Sharing one table happens to work
24
+ # today because only `len(...)` is consumed, but it would silently produce wrong
25
+ # indices as soon as multi-level predictor outputs are exposed.
26
+ _PREDICTOR_LAYER_MAP = {
27
+ 4: [0, 1, 2, 3],
28
+ 8: [1, 3, 5, 7],
29
+ 12: [2, 5, 8, 11],
30
+ 20: [4, 9, 14, 19],
31
+ 24: [4, 11, 17, 23],
32
+ 40: [9, 19, 29, 39],
33
+ }
34
+
35
+
36
  def _get_hierarchical_layers(depth: int) -> list[int]:
37
+ """Encoder hierarchical layer indices for a given depth."""
38
+ if depth not in _ENCODER_LAYER_MAP:
39
+ raise ValueError(
40
+ f"Unsupported encoder depth {depth}. Supported depths: "
41
+ f"{list(_ENCODER_LAYER_MAP.keys())}"
42
+ )
43
+ return _ENCODER_LAYER_MAP[depth]
44
 
45
+
46
+ def _get_predictor_hierarchical_layers(depth: int) -> list[int]:
47
+ """Predictor hierarchical layer indices for a given depth."""
48
+ if depth not in _PREDICTOR_LAYER_MAP:
 
 
 
 
 
 
 
 
 
49
  raise ValueError(
50
+ f"Unsupported predictor depth {depth}. Supported depths: "
51
+ f"{list(_PREDICTOR_LAYER_MAP.keys())}"
52
  )
53
+ return _PREDICTOR_LAYER_MAP[depth]
54
 
55
 
56
  class VJEPA21Config(PretrainedConfig):
 
112
  has_cls_first (`bool`, defaults to False):
113
  Whether the sequence starts with a CLS token.
114
  num_pooler_layers (`int`, defaults to 3):
115
+ Number of self-attention layers in the attentive pooler. Together with
116
+ the cross-attention layer this reproduces `AttentivePooler(depth=4)`,
117
+ which is `num_probe_blocks: 4` in the reference evaluation configs.
118
+ num_pooler_heads (`int` or `None`, defaults to 16):
119
+ Number of attention heads in the attentive pooler. 16 is the value used
120
+ by every frozen-probe config under `configs/eval_2_1/` in the reference
121
+ repository (`classifier.num_heads: 16`), for all four model sizes, so it
122
+ is the default here rather than `num_attention_heads`. The pooler is
123
+ always trained from scratch, so this only affects the probe you train.
124
  pred_hidden_size (`int`, defaults to 384):
125
  Predictor embedding dimension.
126
  pred_num_attention_heads (`int`, defaults to 12):
 
169
  has_cls_first: bool = False,
170
  # Pooler
171
  num_pooler_layers: int = 3,
172
+ num_pooler_heads: Optional[int] = 16,
173
  # Predictor
174
  pred_hidden_size: int = 384,
175
  pred_num_attention_heads: int = 12,
 
210
 
211
  # Pooler
212
  self.num_pooler_layers = num_pooler_layers
213
+ self.num_pooler_heads = num_pooler_heads if num_pooler_heads is not None else 16
 
 
214
 
215
  # Predictor
216
  self.pred_hidden_size = pred_hidden_size
 
226
 
227
  def _validate(self) -> None:
228
  n_levels = len(_get_hierarchical_layers(self.num_hidden_layers))
229
+ n_pred_levels = len(_get_predictor_hierarchical_layers(self.pred_num_hidden_layers))
230
  if not 1 <= self.n_output_distillation <= n_levels:
231
  raise ValueError(
232
  f"n_output_distillation must be in [1, {n_levels}] for a model with "
233
  f"{self.num_hidden_layers} layers, got {self.n_output_distillation}."
234
  )
235
+ if self.n_output_distillation > n_pred_levels:
236
+ raise ValueError(
237
+ f"n_output_distillation ({self.n_output_distillation}) exceeds the "
238
+ f"{n_pred_levels} hierarchical levels available in a predictor with "
239
+ f"{self.pred_num_hidden_layers} layers."
240
+ )
241
  if self.hidden_size % self.num_attention_heads != 0:
242
  raise ValueError(
243
  f"hidden_size ({self.hidden_size}) must be divisible by "
 
253
  f"pred_hidden_size ({self.pred_hidden_size}) must be divisible by "
254
  f"pred_num_attention_heads ({self.pred_num_attention_heads})."
255
  )
256
+ if self.pred_teacher_embed_dim is not None:
257
+ if self.pred_teacher_embed_dim % self.n_output_distillation != 0:
258
+ raise ValueError(
259
+ f"pred_teacher_embed_dim ({self.pred_teacher_embed_dim}) must be "
260
+ f"divisible by n_output_distillation ({self.n_output_distillation})."
261
+ )
262
+ if self.tubelet_size < 1:
263
+ raise ValueError(f"tubelet_size must be >= 1, got {self.tubelet_size}.")
264
+ if self.pred_num_mask_tokens < 1:
265
+ raise ValueError(
266
+ f"pred_num_mask_tokens must be >= 1, got {self.pred_num_mask_tokens}."
267
+ )
268
 
269
  @property
270
  def encoder_hierarchical_layers(self) -> list[int]:
271
+ """Layer indices at which the encoder carries a per-level LayerNorm."""
272
  return _get_hierarchical_layers(self.num_hidden_layers)
273
 
274
  @property
275
  def encoder_distillation_layers(self) -> list[int]:
276
+ """Encoder layer indices contributing to the hierarchical output."""
277
  all_layers = _get_hierarchical_layers(self.num_hidden_layers)
278
  return all_layers[-self.n_output_distillation :]
279
 
280
  @property
281
  def predictor_hierarchical_layers(self) -> list[int]:
282
+ """Predictor layer indices for hierarchical output.
283
+
284
+ Uses the predictor's own depth table, which differs from the encoder's at
285
+ depth 24 (`[4, 11, 17, 23]` vs `[5, 11, 17, 23]`).
286
+ """
287
+ all_layers = _get_predictor_hierarchical_layers(self.pred_num_hidden_layers)
288
  return all_layers[-self.n_output_distillation :]
289
 
290
  @property
modeling_vjepa21.py CHANGED
@@ -10,6 +10,14 @@ Key differences from V-JEPA 2:
10
  - Dense predictor: hierarchical input fusion + context token prediction
11
 
12
  Compatible with transformers >= 4.50 (both the 4.x and 5.x attention APIs).
 
 
 
 
 
 
 
 
13
  """
14
 
15
  from __future__ import annotations
@@ -30,6 +38,22 @@ from transformers.utils import ModelOutput, logging
30
  from .configuration_vjepa21 import VJEPA21Config
31
 
32
  logger = logging.get_logger(__name__)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
 
35
  # ---------------------------------------------------------------------------
@@ -48,8 +72,9 @@ class VJEPA21EncoderOutput(ModelOutput):
48
  channel axis, `(B, N, n_output_distillation * hidden_size)`.
49
  Only returned when `return_hierarchical=True`.
50
  multilevel_hidden_states: Tuple of per-level normalised features, one
51
- entry per requested `out_layers` index. Mirrors the `out_layers`
52
- argument of the reference implementation.
 
53
  hidden_states: Raw (un-normalised) outputs of the embedding layer and of
54
  every transformer layer, when `output_hidden_states=True`.
55
  attentions: Attention probabilities of every layer, when
@@ -83,7 +108,14 @@ class VJEPA21PredictorOutput(ModelOutput):
83
 
84
  @dataclass
85
  class VJEPA21ModelOutput(ModelOutput):
86
- """Full model output combining encoder and predictor."""
 
 
 
 
 
 
 
87
 
88
  last_hidden_state: Optional[torch.FloatTensor] = None
89
  hierarchical_hidden_state: Optional[torch.FloatTensor] = None
@@ -117,6 +149,15 @@ def apply_masks(tensor: torch.Tensor, masks: list[torch.Tensor]) -> torch.Tensor
117
  return torch.cat(parts, dim=0)
118
 
119
 
 
 
 
 
 
 
 
 
 
120
  def normalize_video_layout(pixel_values_videos: torch.Tensor, in_chans: int = 3) -> torch.Tensor:
121
  """Bring any accepted video layout to channels-first `(B, C, T, H, W)`.
122
 
@@ -127,7 +168,9 @@ def normalize_video_layout(pixel_values_videos: torch.Tensor, in_chans: int = 3)
127
  - `(B, C, H, W)` a batch of single images, promoted to `T = 1`
128
 
129
  The channel axis is identified by matching `config.in_chans`. When more than
130
- one axis matches (e.g. a 3-frame clip), the channels-first reading wins.
 
 
131
  """
132
  x = pixel_values_videos
133
  if x.ndim == 4:
@@ -144,6 +187,16 @@ def normalize_video_layout(pixel_values_videos: torch.Tensor, in_chans: int = 3)
144
  f"{x.ndim} dimensions with shape {tuple(x.shape)}."
145
  )
146
 
 
 
 
 
 
 
 
 
 
 
147
  if x.shape[1] == in_chans: # (B, C, T, H, W)
148
  return x
149
  if x.shape[2] == in_chans: # (B, T, C, H, W)
@@ -176,6 +229,9 @@ class VJEPA21DropPath(nn.Module):
176
  def forward(self, x: torch.Tensor) -> torch.Tensor:
177
  return drop_path(x, self.p, self.training)
178
 
 
 
 
179
 
180
  def rotate_queries_or_keys(
181
  x: torch.Tensor,
@@ -192,6 +248,9 @@ def rotate_queries_or_keys(
192
  has_cls_first: Whether first token is CLS (not rotated).
193
  """
194
  B, num_heads, N, D = x.size()
 
 
 
195
  n_cls = 1 if has_cls_first else 0
196
  start_ctx = n_cls
197
  end_ctx = N - n_registers
@@ -634,12 +693,31 @@ class VJEPA21Encoder(nn.Module):
634
  def forward(
635
  self,
636
  pixel_values_videos: torch.Tensor,
 
637
  return_hierarchical: bool = False,
638
  out_layers: Optional[list[int]] = None,
639
  output_attentions: bool = False,
640
  output_hidden_states: bool = False,
641
  **kwargs,
642
  ) -> VJEPA21EncoderOutput:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
643
  pixel_values_videos = normalize_video_layout(
644
  pixel_values_videos, self.config.in_chans
645
  )
@@ -663,11 +741,31 @@ class VJEPA21Encoder(nn.Module):
663
  f"hierarchical layers of this model. Valid indices: {self._hier_layers}."
664
  )
665
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  hidden_states = embeddings
667
  hier_outputs: list[torch.Tensor] = []
668
  multilevel_outputs: list[torch.Tensor] = []
669
- all_hidden_states: tuple[torch.Tensor, ...] = () if output_hidden_states else None
670
- all_attentions: tuple[torch.Tensor, ...] = () if output_attentions else None
671
 
672
  for i, layer_module in enumerate(self.layer):
673
  if output_hidden_states:
@@ -677,7 +775,7 @@ class VJEPA21Encoder(nn.Module):
677
  layer_out = self._gradient_checkpointing_func(
678
  layer_module.__call__,
679
  hidden_states,
680
- None,
681
  T_patches,
682
  H_patches,
683
  W_patches,
@@ -686,7 +784,7 @@ class VJEPA21Encoder(nn.Module):
686
  else:
687
  layer_out = layer_module(
688
  hidden_states,
689
- position_mask=None,
690
  T=T_patches,
691
  H_patches=H_patches,
692
  W_patches=W_patches,
@@ -834,13 +932,39 @@ class VJEPA21Predictor(nn.Module):
834
  def forward(
835
  self,
836
  encoder_hidden_states: torch.Tensor,
837
- context_mask: list[torch.Tensor],
838
- target_mask: list[torch.Tensor],
839
  mode: str = "video",
 
 
840
  output_attentions: bool = False,
841
  output_hidden_states: bool = False,
842
  **kwargs,
843
  ) -> VJEPA21PredictorOutput:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
844
  if len(context_mask) != len(target_mask):
845
  raise ValueError(
846
  "context_mask and target_mask must have the same length, got "
@@ -852,10 +976,21 @@ class VJEPA21Predictor(nn.Module):
852
  "pair. Call it once per mask pair instead."
853
  )
854
 
855
- masked_states = apply_masks(encoder_hidden_states, context_mask)
 
 
 
 
 
 
 
 
 
856
  _, N_ctxt, _ = masked_states.shape
857
 
858
- hidden_states, position_masks = self.embeddings(masked_states, context_mask, target_mask)
 
 
859
 
860
  # Sort tokens by position so RoPE sees monotonically increasing ids
861
  argsort = torch.argsort(position_masks, dim=1)
@@ -864,13 +999,13 @@ class VJEPA21Predictor(nn.Module):
864
  position_masks = torch.gather(position_masks, 1, argsort.to(position_masks.device))
865
 
866
  if self.embeddings.img_mod_embed is not None:
867
- if mode == "img":
868
  hidden_states = hidden_states + self.embeddings.img_mod_embed
869
  else:
870
  hidden_states = hidden_states + self.embeddings.video_mod_embed
871
 
872
- all_hidden_states = () if output_hidden_states else None
873
- all_attentions = () if output_attentions else None
874
 
875
  for layer_module in self.layer:
876
  if output_hidden_states:
@@ -1036,6 +1171,14 @@ class VJEPA21PoolerCrossAttentionLayer(nn.Module):
1036
 
1037
 
1038
  class VJEPA21AttentivePooler(nn.Module):
 
 
 
 
 
 
 
 
1039
  def __init__(self, config: VJEPA21Config):
1040
  super().__init__()
1041
  self.query_tokens = nn.Parameter(torch.zeros(1, 1, config.hidden_size))
@@ -1123,6 +1266,18 @@ class VJEPA21Model(VJEPA21PreTrainedModel):
1123
  out_layers=model.config.encoder_hierarchical_layers,
1124
  ).multilevel_hidden_states
1125
  ```
 
 
 
 
 
 
 
 
 
 
 
 
1126
  """
1127
 
1128
  def __init__(self, config: VJEPA21Config):
@@ -1137,11 +1292,13 @@ class VJEPA21Model(VJEPA21PreTrainedModel):
1137
  def forward(
1138
  self,
1139
  pixel_values_videos: torch.Tensor,
1140
- context_mask: Optional[list[torch.Tensor]] = None,
1141
- target_mask: Optional[list[torch.Tensor]] = None,
 
1142
  skip_predictor: bool = False,
1143
  return_hierarchical: bool = False,
1144
  out_layers: Optional[list[int]] = None,
 
1145
  output_attentions: Optional[bool] = None,
1146
  output_hidden_states: Optional[bool] = None,
1147
  **kwargs,
@@ -1150,19 +1307,33 @@ class VJEPA21Model(VJEPA21PreTrainedModel):
1150
  Args:
1151
  pixel_values_videos: Video tensor. Accepted layouts:
1152
  `(B, C, T, H, W)`, `(B, T, C, H, W)`, `(B, T, H, W, C)`.
 
 
 
 
 
1153
  context_mask: List of `(B, K)` index tensors for context tokens.
1154
- Defaults to all tokens.
1155
  target_mask: List of `(B, K)` index tensors for target tokens.
1156
- Defaults to all tokens.
1157
  skip_predictor: Skip the predictor forward (encoder only).
1158
  return_hierarchical: Return the concatenated distillation levels.
1159
  out_layers: Encoder layer indices whose normalised features should be
1160
  returned in `multilevel_hidden_states`. Must be a subset of
1161
  `config.encoder_hierarchical_layers`.
 
1162
  """
1163
  if pixel_values_videos is None:
1164
  raise ValueError("pixel_values_videos is required")
1165
 
 
 
 
 
 
 
 
 
1166
  output_attentions = (
1167
  output_attentions if output_attentions is not None else self.config.output_attentions
1168
  )
@@ -1172,6 +1343,10 @@ class VJEPA21Model(VJEPA21PreTrainedModel):
1172
  else self.config.output_hidden_states
1173
  )
1174
 
 
 
 
 
1175
  # When the predictor fuses several distillation levels (n_output_distillation > 1,
1176
  # e.g. the ViT-g and ViT-G checkpoints) its input projection expects the
1177
  # concatenated hierarchical features, not the last hidden state.
@@ -1181,11 +1356,11 @@ class VJEPA21Model(VJEPA21PreTrainedModel):
1181
 
1182
  encoder_out = self.encoder(
1183
  pixel_values_videos,
 
1184
  return_hierarchical=return_hierarchical or needs_hierarchical_input,
1185
  out_layers=out_layers,
1186
  output_attentions=output_attentions,
1187
  output_hidden_states=output_hidden_states,
1188
- **kwargs,
1189
  )
1190
  seq_output = encoder_out.last_hidden_state
1191
 
@@ -1202,14 +1377,28 @@ class VJEPA21Model(VJEPA21PreTrainedModel):
1202
  num_tokens = seq_output.size(1)
1203
  device = seq_output.device
1204
 
1205
- if context_mask is None:
1206
- context_mask = [
1207
- torch.arange(num_tokens, device=device).unsqueeze(0).expand(batch_size, -1)
1208
- ]
1209
- if target_mask is None:
1210
- target_mask = [
1211
- torch.arange(num_tokens, device=device).unsqueeze(0).expand(batch_size, -1)
1212
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1213
 
1214
  mode = self._detect_mode(pixel_values_videos)
1215
  predictor_output = self.predictor(
@@ -1217,10 +1406,16 @@ class VJEPA21Model(VJEPA21PreTrainedModel):
1217
  context_mask,
1218
  target_mask,
1219
  mode=mode,
 
 
1220
  output_attentions=output_attentions,
1221
  output_hidden_states=output_hidden_states,
1222
  )
1223
- masked_hidden_state = apply_masks(predictor_input, context_mask)
 
 
 
 
1224
 
1225
  return VJEPA21ModelOutput(
1226
  last_hidden_state=seq_output,
@@ -1247,7 +1442,11 @@ class VJEPA21Model(VJEPA21PreTrainedModel):
1247
 
1248
 
1249
  class VJEPA21ForVideoClassification(VJEPA21PreTrainedModel):
1250
- """V-JEPA 2.1 with attentive pooler + classification head."""
 
 
 
 
1251
 
1252
  def __init__(self, config: VJEPA21Config):
1253
  super().__init__(config)
@@ -1266,16 +1465,30 @@ class VJEPA21ForVideoClassification(VJEPA21PreTrainedModel):
1266
  **kwargs,
1267
  ) -> ImageClassifierOutput:
1268
  r"""
1269
- labels (`torch.LongTensor` of shape `(batch_size,)`, *optional*):
1270
- Labels for computing the classification loss. Indices should be in
1271
- `[0, ..., config.num_labels - 1]`.
 
 
 
1272
  """
 
 
 
 
 
 
 
 
 
 
 
1273
  outputs = self.vjepa21(
1274
  pixel_values_videos,
1275
  skip_predictor=True,
1276
  output_attentions=output_attentions,
1277
  output_hidden_states=output_hidden_states,
1278
- **kwargs,
1279
  )
1280
  pooled = self.pooler(outputs.last_hidden_state)
1281
  logits = self.classifier(pooled)
 
10
  - Dense predictor: hierarchical input fusion + context token prediction
11
 
12
  Compatible with transformers >= 4.50 (both the 4.x and 5.x attention APIs).
13
+
14
+ Note on initialisation: the reference `VisionTransformer` and
15
+ `VisionTransformerPredictor` call `_rescale_blocks()` after `_init_weights`,
16
+ dividing `attn.proj.weight` and `mlp.fc2.weight` of layer *i* by `sqrt(2*(i+1))`.
17
+ That is deliberately **not** reproduced here: it only affects randomly
18
+ initialised models, and applying it inside `_init_weights` would risk touching
19
+ weights that `from_pretrained` has already loaded. Every published checkpoint
20
+ loads its weights, so the two agree.
21
  """
22
 
23
  from __future__ import annotations
 
38
  from .configuration_vjepa21 import VJEPA21Config
39
 
40
  logger = logging.get_logger(__name__)
41
+ _warn_once = getattr(logger, "warning_once", logger.warning)
42
+
43
+
44
+ # Keyword arguments the model forward understands. Anything else is reported
45
+ # once and dropped, instead of being swallowed by `**kwargs`: a typo such as
46
+ # `out_layer=[11]` used to be a silent no-op.
47
+ _ENCODER_FORWARD_KWARGS = frozenset(
48
+ {"masks", "out_layers", "return_hierarchical", "output_attentions", "output_hidden_states"}
49
+ )
50
+ _MODEL_FORWARD_KWARGS = _ENCODER_FORWARD_KWARGS | frozenset(
51
+ {"context_mask", "target_mask", "skip_predictor", "mask_index"}
52
+ )
53
+ # Injected by the Trainer or by generic HF plumbing; harmless and not worth a warning.
54
+ _SILENTLY_IGNORED_KWARGS = frozenset(
55
+ {"num_items_in_batch", "return_dict", "return_loss", "interpolate_pos_encoding"}
56
+ )
57
 
58
 
59
  # ---------------------------------------------------------------------------
 
72
  channel axis, `(B, N, n_output_distillation * hidden_size)`.
73
  Only returned when `return_hierarchical=True`.
74
  multilevel_hidden_states: Tuple of per-level normalised features, one
75
+ entry per requested `out_layers` index, in the order the layers occur
76
+ in the network (not in the order they were requested). Mirrors the
77
+ `out_layers` argument of the reference implementation.
78
  hidden_states: Raw (un-normalised) outputs of the embedding layer and of
79
  every transformer layer, when `output_hidden_states=True`.
80
  attentions: Attention probabilities of every layer, when
 
108
 
109
  @dataclass
110
  class VJEPA21ModelOutput(ModelOutput):
111
+ """Full model output combining encoder and predictor.
112
+
113
+ `masked_hidden_state` is the tensor the predictor actually consumed, gathered
114
+ at `context_mask`. Its channel width therefore depends on the checkpoint:
115
+ `hidden_size` when `n_output_distillation == 1` (ViT-B, ViT-L) and
116
+ `n_output_distillation * hidden_size` when the predictor fuses several levels
117
+ (ViT-g, ViT-G).
118
+ """
119
 
120
  last_hidden_state: Optional[torch.FloatTensor] = None
121
  hierarchical_hidden_state: Optional[torch.FloatTensor] = None
 
149
  return torch.cat(parts, dim=0)
150
 
151
 
152
+ def _as_mask_list(masks) -> Optional[list[torch.Tensor]]:
153
+ """Accept a single tensor or a list of tensors, as the reference does."""
154
+ if masks is None:
155
+ return None
156
+ if isinstance(masks, torch.Tensor):
157
+ return [masks]
158
+ return list(masks)
159
+
160
+
161
  def normalize_video_layout(pixel_values_videos: torch.Tensor, in_chans: int = 3) -> torch.Tensor:
162
  """Bring any accepted video layout to channels-first `(B, C, T, H, W)`.
163
 
 
168
  - `(B, C, H, W)` a batch of single images, promoted to `T = 1`
169
 
170
  The channel axis is identified by matching `config.in_chans`. When more than
171
+ one axis matches — a 3-frame clip is the realistic case — the channels-first
172
+ reading wins and a warning is emitted, because the alternative is a silent
173
+ transposition.
174
  """
175
  x = pixel_values_videos
176
  if x.ndim == 4:
 
187
  f"{x.ndim} dimensions with shape {tuple(x.shape)}."
188
  )
189
 
190
+ candidates = [axis for axis in (1, 2, 4) if x.shape[axis] == in_chans]
191
+ if len(candidates) > 1:
192
+ _warn_once(
193
+ "ambiguous video layout: "
194
+ f"pixel_values_videos of shape {tuple(x.shape)} has {len(candidates)} candidate "
195
+ f"channel axes {candidates} of size in_chans={in_chans}; "
196
+ "reading it as (B, C, T, H, W). "
197
+ "Pass an unambiguous layout if that is not what you meant."
198
+ )
199
+
200
  if x.shape[1] == in_chans: # (B, C, T, H, W)
201
  return x
202
  if x.shape[2] == in_chans: # (B, T, C, H, W)
 
229
  def forward(self, x: torch.Tensor) -> torch.Tensor:
230
  return drop_path(x, self.p, self.training)
231
 
232
+ def extra_repr(self) -> str:
233
+ return f"p={self.p}"
234
+
235
 
236
  def rotate_queries_or_keys(
237
  x: torch.Tensor,
 
248
  has_cls_first: Whether first token is CLS (not rotated).
249
  """
250
  B, num_heads, N, D = x.size()
251
+ if D % 2 != 0:
252
+ raise ValueError(f"RoPE requires an even dimension per slice, got {D}.")
253
+
254
  n_cls = 1 if has_cls_first else 0
255
  start_ctx = n_cls
256
  end_ctx = N - n_registers
 
693
  def forward(
694
  self,
695
  pixel_values_videos: torch.Tensor,
696
+ masks: Optional[Union[torch.Tensor, list[torch.Tensor]]] = None,
697
  return_hierarchical: bool = False,
698
  out_layers: Optional[list[int]] = None,
699
  output_attentions: bool = False,
700
  output_hidden_states: bool = False,
701
  **kwargs,
702
  ) -> VJEPA21EncoderOutput:
703
+ """
704
+ Args:
705
+ masks: Optional list of `(B, K)` index tensors. When given, the patch
706
+ tokens are gathered at those indices *before* the transformer
707
+ layers, so attention only ever sees the context tokens and RoPE
708
+ receives their true positions. This is the JEPA training-time
709
+ forward (`z = encoder(clips, masks_enc)` in the reference); the
710
+ default `None` runs the full sequence, which is what feature
711
+ extraction wants.
712
+ """
713
+ unexpected = set(kwargs) - _SILENTLY_IGNORED_KWARGS
714
+ if unexpected:
715
+ _warn_once(
716
+ f"VJEPA21Encoder.forward received unexpected keyword arguments "
717
+ f"{sorted(unexpected)}; they are ignored. Accepted arguments: "
718
+ f"{sorted(_ENCODER_FORWARD_KWARGS)}."
719
+ )
720
+
721
  pixel_values_videos = normalize_video_layout(
722
  pixel_values_videos, self.config.in_chans
723
  )
 
741
  f"hierarchical layers of this model. Valid indices: {self._hier_layers}."
742
  )
743
 
744
+ # Masked (JEPA) forward: drop tokens before the layers and carry their
745
+ # original indices as RoPE positions, as the reference does.
746
+ masks = _as_mask_list(masks)
747
+ position_mask = None
748
+ if masks is not None:
749
+ n_tokens = embeddings.shape[1]
750
+ for m in masks:
751
+ if m.dim() != 2 or m.shape[0] != embeddings.shape[0]:
752
+ raise ValueError(
753
+ f"each mask must be a (B, K) index tensor with B={embeddings.shape[0]}, "
754
+ f"got {tuple(m.shape)}."
755
+ )
756
+ if int(m.max()) >= n_tokens:
757
+ raise ValueError(
758
+ f"mask index {int(m.max())} is out of range for a sequence of "
759
+ f"{n_tokens} tokens."
760
+ )
761
+ embeddings = apply_masks(embeddings, masks)
762
+ position_mask = torch.cat([m.to(embeddings.device) for m in masks], dim=0)
763
+
764
  hidden_states = embeddings
765
  hier_outputs: list[torch.Tensor] = []
766
  multilevel_outputs: list[torch.Tensor] = []
767
+ all_hidden_states: Optional[tuple[torch.Tensor, ...]] = () if output_hidden_states else None
768
+ all_attentions: Optional[tuple[torch.Tensor, ...]] = () if output_attentions else None
769
 
770
  for i, layer_module in enumerate(self.layer):
771
  if output_hidden_states:
 
775
  layer_out = self._gradient_checkpointing_func(
776
  layer_module.__call__,
777
  hidden_states,
778
+ position_mask,
779
  T_patches,
780
  H_patches,
781
  W_patches,
 
784
  else:
785
  layer_out = layer_module(
786
  hidden_states,
787
+ position_mask=position_mask,
788
  T=T_patches,
789
  H_patches=H_patches,
790
  W_patches=W_patches,
 
932
  def forward(
933
  self,
934
  encoder_hidden_states: torch.Tensor,
935
+ context_mask: Union[torch.Tensor, list[torch.Tensor]],
936
+ target_mask: Union[torch.Tensor, list[torch.Tensor]],
937
  mode: str = "video",
938
+ mask_index: int = 1,
939
+ context_is_masked: bool = False,
940
  output_attentions: bool = False,
941
  output_hidden_states: bool = False,
942
  **kwargs,
943
  ) -> VJEPA21PredictorOutput:
944
+ """
945
+ Args:
946
+ encoder_hidden_states: Encoder output. By default this is the *full*
947
+ token sequence and the predictor gathers the context itself at
948
+ `context_mask`. Set `context_is_masked=True` when passing an
949
+ encoder output that was already produced with
950
+ `encoder(..., masks=context_mask)`, which is the reference
951
+ convention.
952
+ mask_index: Which learnable mask token to inject. The reference uses
953
+ the index of the sequence-length group (`mask_index=i` in
954
+ `PredictorMultiSeqWrapper`); its default is 1.
955
+ mode: "video" or "img" ("image" is accepted as an alias of "img",
956
+ since the reference spells it that way).
957
+ """
958
+ if kwargs:
959
+ _warn_once(
960
+ f"VJEPA21Predictor.forward received unexpected keyword arguments "
961
+ f"{sorted(kwargs)}; they are ignored."
962
+ )
963
+
964
+ context_mask = _as_mask_list(context_mask)
965
+ target_mask = _as_mask_list(target_mask)
966
+ if context_mask is None or target_mask is None:
967
+ raise ValueError("the predictor requires both context_mask and target_mask")
968
  if len(context_mask) != len(target_mask):
969
  raise ValueError(
970
  "context_mask and target_mask must have the same length, got "
 
976
  "pair. Call it once per mask pair instead."
977
  )
978
 
979
+ if context_is_masked:
980
+ masked_states = encoder_hidden_states
981
+ if masked_states.shape[1] != context_mask[0].shape[1]:
982
+ raise ValueError(
983
+ f"context_is_masked=True but the encoder output has "
984
+ f"{masked_states.shape[1]} tokens while context_mask has "
985
+ f"{context_mask[0].shape[1]}."
986
+ )
987
+ else:
988
+ masked_states = apply_masks(encoder_hidden_states, context_mask)
989
  _, N_ctxt, _ = masked_states.shape
990
 
991
+ hidden_states, position_masks = self.embeddings(
992
+ masked_states, context_mask, target_mask, mask_index=mask_index
993
+ )
994
 
995
  # Sort tokens by position so RoPE sees monotonically increasing ids
996
  argsort = torch.argsort(position_masks, dim=1)
 
999
  position_masks = torch.gather(position_masks, 1, argsort.to(position_masks.device))
1000
 
1001
  if self.embeddings.img_mod_embed is not None:
1002
+ if mode in ("img", "image"):
1003
  hidden_states = hidden_states + self.embeddings.img_mod_embed
1004
  else:
1005
  hidden_states = hidden_states + self.embeddings.video_mod_embed
1006
 
1007
+ all_hidden_states: Optional[tuple] = () if output_hidden_states else None
1008
+ all_attentions: Optional[tuple] = () if output_attentions else None
1009
 
1010
  for layer_module in self.layer:
1011
  if output_hidden_states:
 
1171
 
1172
 
1173
  class VJEPA21AttentivePooler(nn.Module):
1174
+ """Attentive pooler matching `AttentivePooler(depth=num_pooler_layers + 1)`.
1175
+
1176
+ The reference frozen-probe configs under `configs/eval_2_1/` use
1177
+ `num_probe_blocks: 4` and `num_heads: 16`, i.e. three self-attention blocks
1178
+ followed by one cross-attention block, with 16 heads. Those are the defaults
1179
+ of `num_pooler_layers` and `num_pooler_heads`.
1180
+ """
1181
+
1182
  def __init__(self, config: VJEPA21Config):
1183
  super().__init__()
1184
  self.query_tokens = nn.Parameter(torch.zeros(1, 1, config.hidden_size))
 
1266
  out_layers=model.config.encoder_hierarchical_layers,
1267
  ).multilevel_hidden_states
1268
  ```
1269
+
1270
+ Example, the JEPA masked forward (encoder sees only the context tokens):
1271
+
1272
+ ```python
1273
+ out = model(
1274
+ pixel_values_videos,
1275
+ masks=[context_idx], # (B, K_ctx)
1276
+ context_mask=[context_idx],
1277
+ target_mask=[target_idx], # (B, K_tgt)
1278
+ )
1279
+ prediction = out.predictor_output.last_hidden_state
1280
+ ```
1281
  """
1282
 
1283
  def __init__(self, config: VJEPA21Config):
 
1292
  def forward(
1293
  self,
1294
  pixel_values_videos: torch.Tensor,
1295
+ masks: Optional[Union[torch.Tensor, list[torch.Tensor]]] = None,
1296
+ context_mask: Optional[Union[torch.Tensor, list[torch.Tensor]]] = None,
1297
+ target_mask: Optional[Union[torch.Tensor, list[torch.Tensor]]] = None,
1298
  skip_predictor: bool = False,
1299
  return_hierarchical: bool = False,
1300
  out_layers: Optional[list[int]] = None,
1301
+ mask_index: int = 1,
1302
  output_attentions: Optional[bool] = None,
1303
  output_hidden_states: Optional[bool] = None,
1304
  **kwargs,
 
1307
  Args:
1308
  pixel_values_videos: Video tensor. Accepted layouts:
1309
  `(B, C, T, H, W)`, `(B, T, C, H, W)`, `(B, T, H, W, C)`.
1310
+ masks: Optional list of `(B, K)` index tensors applied to the patch
1311
+ tokens *before* the encoder layers. This reproduces the JEPA
1312
+ training forward. When set, `context_mask` and `target_mask` must
1313
+ be given explicitly, since the encoder output no longer spans the
1314
+ full token grid.
1315
  context_mask: List of `(B, K)` index tensors for context tokens.
1316
+ Defaults to all tokens when `masks` is None.
1317
  target_mask: List of `(B, K)` index tensors for target tokens.
1318
+ Defaults to all tokens when `masks` is None.
1319
  skip_predictor: Skip the predictor forward (encoder only).
1320
  return_hierarchical: Return the concatenated distillation levels.
1321
  out_layers: Encoder layer indices whose normalised features should be
1322
  returned in `multilevel_hidden_states`. Must be a subset of
1323
  `config.encoder_hierarchical_layers`.
1324
+ mask_index: Which learnable predictor mask token to inject.
1325
  """
1326
  if pixel_values_videos is None:
1327
  raise ValueError("pixel_values_videos is required")
1328
 
1329
+ unexpected = set(kwargs) - _SILENTLY_IGNORED_KWARGS
1330
+ if unexpected:
1331
+ _warn_once(
1332
+ f"VJEPA21Model.forward received unexpected keyword arguments "
1333
+ f"{sorted(unexpected)}; they are ignored. Accepted arguments: "
1334
+ f"{sorted(_MODEL_FORWARD_KWARGS)}."
1335
+ )
1336
+
1337
  output_attentions = (
1338
  output_attentions if output_attentions is not None else self.config.output_attentions
1339
  )
 
1343
  else self.config.output_hidden_states
1344
  )
1345
 
1346
+ masks = _as_mask_list(masks)
1347
+ context_mask = _as_mask_list(context_mask)
1348
+ target_mask = _as_mask_list(target_mask)
1349
+
1350
  # When the predictor fuses several distillation levels (n_output_distillation > 1,
1351
  # e.g. the ViT-g and ViT-G checkpoints) its input projection expects the
1352
  # concatenated hierarchical features, not the last hidden state.
 
1356
 
1357
  encoder_out = self.encoder(
1358
  pixel_values_videos,
1359
+ masks=masks,
1360
  return_hierarchical=return_hierarchical or needs_hierarchical_input,
1361
  out_layers=out_layers,
1362
  output_attentions=output_attentions,
1363
  output_hidden_states=output_hidden_states,
 
1364
  )
1365
  seq_output = encoder_out.last_hidden_state
1366
 
 
1377
  num_tokens = seq_output.size(1)
1378
  device = seq_output.device
1379
 
1380
+ if masks is not None:
1381
+ # The encoder output only covers the context tokens, so the
1382
+ # "all tokens" default is meaningless here.
1383
+ if context_mask is None:
1384
+ context_mask = list(masks)
1385
+ if target_mask is None:
1386
+ raise ValueError(
1387
+ "target_mask must be given explicitly when the encoder runs with "
1388
+ "`masks`, since the encoder output no longer spans the full token "
1389
+ "grid. Pass `skip_predictor=True` if you only want the encoder."
1390
+ )
1391
+ context_is_masked = True
1392
+ else:
1393
+ context_is_masked = False
1394
+ if context_mask is None:
1395
+ context_mask = [
1396
+ torch.arange(num_tokens, device=device).unsqueeze(0).expand(batch_size, -1)
1397
+ ]
1398
+ if target_mask is None:
1399
+ target_mask = [
1400
+ torch.arange(num_tokens, device=device).unsqueeze(0).expand(batch_size, -1)
1401
+ ]
1402
 
1403
  mode = self._detect_mode(pixel_values_videos)
1404
  predictor_output = self.predictor(
 
1406
  context_mask,
1407
  target_mask,
1408
  mode=mode,
1409
+ mask_index=mask_index,
1410
+ context_is_masked=context_is_masked,
1411
  output_attentions=output_attentions,
1412
  output_hidden_states=output_hidden_states,
1413
  )
1414
+ masked_hidden_state = (
1415
+ predictor_input
1416
+ if context_is_masked
1417
+ else apply_masks(predictor_input, context_mask)
1418
+ )
1419
 
1420
  return VJEPA21ModelOutput(
1421
  last_hidden_state=seq_output,
 
1442
 
1443
 
1444
  class VJEPA21ForVideoClassification(VJEPA21PreTrainedModel):
1445
+ """V-JEPA 2.1 with attentive pooler + classification head.
1446
+
1447
+ The pooler and the classifier are always randomly initialised: this class is
1448
+ the frozen-probe / fine-tuning entry point, not a pretrained classifier.
1449
+ """
1450
 
1451
  def __init__(self, config: VJEPA21Config):
1452
  super().__init__(config)
 
1465
  **kwargs,
1466
  ) -> ImageClassifierOutput:
1467
  r"""
1468
+ labels (`torch.LongTensor` of shape `(batch_size,)` or `(batch_size, num_labels)`, *optional*):
1469
+ Labels for computing the classification loss. Integer indices in
1470
+ `[0, ..., config.num_labels - 1]` give single-label classification;
1471
+ a float multi-hot tensor gives multi-label classification (BCE), which
1472
+ is what a dataset such as XD-Violence needs. Set
1473
+ `config.problem_type` explicitly to remove the ambiguity.
1474
  """
1475
+ # Only forward the arguments the backbone understands; the Trainer injects
1476
+ # extras such as `num_items_in_batch` that would otherwise reach the encoder.
1477
+ forwarded = {k: v for k, v in kwargs.items() if k in _MODEL_FORWARD_KWARGS}
1478
+ ignored = set(kwargs) - set(forwarded) - _SILENTLY_IGNORED_KWARGS
1479
+ if ignored:
1480
+ _warn_once(
1481
+ f"VJEPA21ForVideoClassification.forward received unexpected keyword "
1482
+ f"arguments {sorted(ignored)}; they are ignored."
1483
+ )
1484
+ forwarded.pop("skip_predictor", None)
1485
+
1486
  outputs = self.vjepa21(
1487
  pixel_values_videos,
1488
  skip_predictor=True,
1489
  output_attentions=output_attentions,
1490
  output_hidden_states=output_hidden_states,
1491
+ **forwarded,
1492
  )
1493
  pooled = self.pooler(outputs.last_hidden_state)
1494
  logits = self.classifier(pooled)
tests/test_end_to_end_pipeline.py CHANGED
@@ -30,25 +30,22 @@ PORT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
30
  VJEPA2_REPO = os.environ.get("VJEPA2_REPO", "")
31
  CKPT = os.environ.get("VJEPA21_CKPT", "")
32
 
33
- pytestmark = pytest.mark.skipif(
34
- not VJEPA2_REPO or not os.path.isdir(os.path.join(VJEPA2_REPO, "src", "datasets")),
35
- reason="set VJEPA2_REPO to a clone of facebookresearch/vjepa2",
36
- )
37
-
38
 
39
- def _processor():
40
  name = "_vjepa21_port_under_test"
41
  if name not in sys.modules:
42
  pkg = types.ModuleType(name)
43
  pkg.__path__ = [PORT_DIR]
44
  sys.modules[name] = pkg
45
  mod = importlib.import_module(f"{name}.video_processing_vjepa21")
46
- return mod.VJEPA21VideoProcessor()
47
 
48
 
49
  def _reference_transform(clip, crop=384):
50
  """Reference `EvalVideoTransform`, centre crop instead of the sliding views."""
51
- sys.path.insert(0, VJEPA2_REPO)
 
 
52
  import src.datasets.utils.video.transforms as video_transforms
53
  import src.datasets.utils.video.volume_transforms as volume_transforms
54
 
@@ -72,7 +69,61 @@ def _clip(height, width, frames=4, seed=0):
72
  return [rng.integers(0, 255, (height, width, 3), dtype=np.uint8) for _ in range(frames)]
73
 
74
 
75
- # --- pixel level ------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
  RESOLUTIONS = [
78
  pytest.param(240, 320, id="240p-upscale"),
@@ -81,7 +132,13 @@ RESOLUTIONS = [
81
  pytest.param(1080, 1920, id="1080p-downscale"),
82
  ]
83
 
 
 
 
 
 
84
 
 
85
  @pytest.mark.parametrize("height,width", RESOLUTIONS)
86
  def test_processor_matches_reference_transform(height, width):
87
  """The processor must agree with the reference transform to within the 8-bit
@@ -108,18 +165,36 @@ def test_processor_matches_reference_transform(height, width):
108
  assert diff.max().item() <= quantisation_floor * 1.05
109
 
110
 
111
- def test_antialiasing_is_disabled():
112
- """Regression guard for the resize backend, independent of resolution."""
 
 
 
 
 
113
  processor = _processor()
114
- big = torch.zeros(1, 3, 1080, 1920)
115
- default = processor.resize(big, type(processor.size)(shortest_edge=384))
116
- forced = processor.resize(big, type(processor.size)(shortest_edge=384), antialias=False)
117
- assert torch.equal(default, forced), "resize() must default to antialias=False"
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
 
120
- # --- feature level ----------------------------------------------------------
121
 
122
 
 
123
  @pytest.mark.skipif(not CKPT, reason="set VJEPA21_CKPT to run the model-level comparison")
124
  @torch.no_grad()
125
  @pytest.mark.parametrize("height,width", RESOLUTIONS)
 
30
  VJEPA2_REPO = os.environ.get("VJEPA2_REPO", "")
31
  CKPT = os.environ.get("VJEPA21_CKPT", "")
32
 
 
 
 
 
 
33
 
34
+ def _processor(**kwargs):
35
  name = "_vjepa21_port_under_test"
36
  if name not in sys.modules:
37
  pkg = types.ModuleType(name)
38
  pkg.__path__ = [PORT_DIR]
39
  sys.modules[name] = pkg
40
  mod = importlib.import_module(f"{name}.video_processing_vjepa21")
41
+ return mod.VJEPA21VideoProcessor(**kwargs)
42
 
43
 
44
  def _reference_transform(clip, crop=384):
45
  """Reference `EvalVideoTransform`, centre crop instead of the sliding views."""
46
+ repo = os.path.abspath(VJEPA2_REPO)
47
+ if repo not in sys.path:
48
+ sys.path.insert(0, repo)
49
  import src.datasets.utils.video.transforms as video_transforms
50
  import src.datasets.utils.video.volume_transforms as volume_transforms
51
 
 
69
  return [rng.integers(0, 255, (height, width, 3), dtype=np.uint8) for _ in range(frames)]
70
 
71
 
72
+ # --- 1. antialiasing: no reference repository needed -------------------------
73
+
74
+
75
+ def test_resize_defaults_to_no_antialiasing():
76
+ """Regression guard for the resize backend.
77
+
78
+ This used to be run on `torch.zeros(1, 3, 1080, 1920)`, which cannot fail:
79
+ resizing a constant image gives the same result with and without
80
+ antialiasing, so `torch.equal(default, forced)` held regardless of the
81
+ default. It needs real content, and it needs to show that the antialiased
82
+ path is genuinely different — otherwise the guard is still vacuous.
83
+ """
84
+ processor = _processor()
85
+ torch.manual_seed(0)
86
+ big = torch.rand(1, 3, 1080, 1920)
87
+ size = type(processor.size)(shortest_edge=384)
88
+
89
+ default = processor.resize(big, size)
90
+ forced_off = processor.resize(big, size, antialias=False)
91
+ forced_on = processor.resize(big, size, antialias=True)
92
+
93
+ assert torch.equal(default, forced_off), "resize() must default to antialias=False"
94
+ gap = (forced_on - default).abs().max().item()
95
+ print(f"\n[antialias] max|Δ| antialiased vs not = {gap:.4f} "
96
+ f"({gap * 255:.1f}/255 on an 8-bit scale)")
97
+ assert gap > 1e-3, (
98
+ "antialiased and non-antialiased downscaling are indistinguishable; "
99
+ "the guard is not testing anything"
100
+ )
101
+
102
+
103
+ def test_antialiasing_gap_is_negligible_when_upscaling():
104
+ """Explains why this only ever mattered for downscaling: on a 240p source
105
+ resized up to 384 the two backends agree, which is how the bug survived."""
106
+ processor = _processor()
107
+ torch.manual_seed(0)
108
+ small = torch.rand(1, 3, 240, 320)
109
+ size = type(processor.size)(shortest_edge=384)
110
+ gap = (
111
+ processor.resize(small, size, antialias=True)
112
+ - processor.resize(small, size, antialias=False)
113
+ ).abs().max().item()
114
+ print(f"[antialias, upscale] max|Δ| = {gap:.6f}")
115
+ assert gap < 1e-5
116
+
117
+
118
+ def test_processor_geometry_is_independent_of_aspect_ratio():
119
+ """Short side to exactly `crop_size`, then a square centre crop."""
120
+ processor = _processor()
121
+ for h, w in ((240, 320), (320, 240), (1080, 1920), (400, 400)):
122
+ pv = processor([_clip(h, w, frames=2)], return_tensors="pt")["pixel_values_videos"]
123
+ assert pv.shape == (1, 2, 3, 384, 384), (h, w, pv.shape)
124
+
125
+
126
+ # --- 2. pixel level against the reference transform --------------------------
127
 
128
  RESOLUTIONS = [
129
  pytest.param(240, 320, id="240p-upscale"),
 
132
  pytest.param(1080, 1920, id="1080p-downscale"),
133
  ]
134
 
135
+ needs_reference = pytest.mark.skipif(
136
+ not VJEPA2_REPO or not os.path.isdir(os.path.join(VJEPA2_REPO, "src", "datasets")),
137
+ reason="set VJEPA2_REPO to a clone of facebookresearch/vjepa2",
138
+ )
139
+
140
 
141
+ @needs_reference
142
  @pytest.mark.parametrize("height,width", RESOLUTIONS)
143
  def test_processor_matches_reference_transform(height, width):
144
  """The processor must agree with the reference transform to within the 8-bit
 
165
  assert diff.max().item() <= quantisation_floor * 1.05
166
 
167
 
168
+ @needs_reference
169
+ def test_antialiased_processor_would_fail_the_reference_comparison():
170
+ """Shows the tolerance above is tight enough to catch the regression it was
171
+ written for, rather than passing either way."""
172
+ clip = _clip(1080, 1920)
173
+ reference = _reference_transform(clip)
174
+
175
  processor = _processor()
176
+ original_resize = type(processor).resize
177
+
178
+ def antialiased(self, image, size, **kwargs):
179
+ kwargs["antialias"] = True
180
+ return super(type(self), self).resize(image, size, **kwargs)
181
+
182
+ try:
183
+ type(processor).resize = antialiased
184
+ got = processor([clip], return_tensors="pt")["pixel_values_videos"][0]
185
+ got = got.permute(1, 0, 2, 3)
186
+ finally:
187
+ type(processor).resize = original_resize
188
+
189
+ gap = (reference - got).abs().max().item()
190
+ print(f"\n[antialiased 1080p] max|Δ| vs reference = {gap:.4e}")
191
+ assert gap > 1.0 / 255.0 / 0.229 * 1.05
192
 
193
 
194
+ # --- 3. feature level --------------------------------------------------------
195
 
196
 
197
+ @needs_reference
198
  @pytest.mark.skipif(not CKPT, reason="set VJEPA21_CKPT to run the model-level comparison")
199
  @torch.no_grad()
200
  @pytest.mark.parametrize("height,width", RESOLUTIONS)
tests/test_parity_official.py CHANGED
@@ -1,15 +1,19 @@
1
- """Numerical parity test: HF port vs. the reference V-JEPA 2.1 implementation.
2
-
3
- Builds the reference encoder from a local clone of ``facebookresearch/vjepa2``
4
- and the ported ``VJEPA21Model``, copies the reference weights into the port and
5
- compares the outputs.
6
-
7
- Usage:
8
- git clone https://github.com/facebookresearch/vjepa2.git
9
- pip install timm einops
10
- VJEPA2_REPO=/path/to/vjepa2 python -m pytest test_parity_official.py -s
11
-
12
- The test is skipped when the reference repository is not available.
 
 
 
 
13
  """
14
 
15
  from __future__ import annotations
@@ -18,314 +22,390 @@ import importlib
18
  import os
19
  import sys
20
  import types
 
21
 
22
  import pytest
23
  import torch
 
24
 
25
- VJEPA2_REPO = os.environ.get("VJEPA2_REPO", "")
26
  PORT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
 
 
 
 
 
27
 
28
 
29
  def _load_port():
30
- """Import the port's modules as a package so relative imports resolve."""
31
  name = "_vjepa21_port_under_test"
32
  if name not in sys.modules:
33
  pkg = types.ModuleType(name)
34
  pkg.__path__ = [PORT_DIR]
35
  sys.modules[name] = pkg
36
- cfg_mod = importlib.import_module(f"{name}.configuration_vjepa21")
37
- mdl_mod = importlib.import_module(f"{name}.modeling_vjepa21")
38
- return cfg_mod.VJEPA21Config, mdl_mod.VJEPA21Model
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- pytestmark = pytest.mark.skipif(
41
- not VJEPA2_REPO or not os.path.isdir(os.path.join(VJEPA2_REPO, "app", "vjepa_2_1")),
42
- reason="Reference repository not found; set VJEPA2_REPO to a clone of facebookresearch/vjepa2",
43
- )
44
 
45
- # --- reference -> port state dict mapping -----------------------------------
 
 
 
 
 
 
 
46
 
47
 
48
- def convert_reference_encoder_state_dict(sd: dict, hidden_size: int) -> dict:
49
- """Map reference encoder parameter names onto the ported encoder."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  out = {}
51
- for k, v in sd.items():
52
- if k == "img_mod_embed":
53
- out["embeddings.img_mod_embed"] = v
54
- elif k == "video_mod_embed":
55
- out["embeddings.video_mod_embed"] = v
56
  elif k.startswith("patch_embed_img."):
57
- out["embeddings.patch_embeddings_img." + k[len("patch_embed_img.") :]] = v
58
  elif k.startswith("patch_embed."):
59
- out["embeddings.patch_embeddings." + k[len("patch_embed.") :]] = v
60
  elif k.startswith("norms_block."):
61
- out[k] = v
62
  elif k.startswith("blocks."):
63
- rest = k[len("blocks.") :]
64
- idx, sub = rest.split(".", 1)
65
- if sub.startswith("attn.qkv."):
66
- kind = sub.split(".")[-1] # weight | bias
67
- # Reference packs qkv as [q; k; v] along dim 0.
68
- q, key, val = v.split(hidden_size, dim=0)
69
- out[f"layer.{idx}.attention.query.{kind}"] = q
70
- out[f"layer.{idx}.attention.key.{kind}"] = key
71
- out[f"layer.{idx}.attention.value.{kind}"] = val
72
- elif sub.startswith("attn.proj."):
73
- out[f"layer.{idx}.attention.proj." + sub.split(".")[-1]] = v
74
- else:
75
- out[f"layer.{idx}.{sub}"] = v
76
  else:
77
- raise KeyError(f"Unmapped reference key: {k}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  return out
79
 
80
 
81
- def _build_pair(n_output_distillation: int = 1, **overrides):
82
- sys.path.insert(0, VJEPA2_REPO)
83
- from app.vjepa_2_1.models.vision_transformer import vit_base
84
-
85
- VJEPA21Config, VJEPA21Model = _load_port()
86
-
87
- kw = dict(
88
- img_size=(64, 64),
89
- patch_size=16,
90
- num_frames=4,
91
- tubelet_size=2,
92
- uniform_power=True,
93
- use_rope=True,
94
- img_temporal_dim_size=1,
95
- interpolate_rope=True,
96
- modality_embedding=True,
97
- n_output_distillation=n_output_distillation,
98
- use_sdpa=False,
99
- use_silu=False,
100
- wide_silu=True,
101
- )
102
- kw.update(overrides)
103
 
 
 
104
  torch.manual_seed(0)
105
- ref = vit_base(**kw).eval()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
 
 
107
  cfg = VJEPA21Config(
108
- patch_size=kw["patch_size"],
109
- crop_size=kw["img_size"][0],
110
- tubelet_size=kw["tubelet_size"],
111
- hidden_size=768,
112
- num_attention_heads=12,
113
- num_hidden_layers=12,
114
- hidden_act="silu" if kw["use_silu"] else "gelu",
115
- wide_silu=kw["wide_silu"],
116
- img_temporal_dim_size=kw["img_temporal_dim_size"],
117
- interpolate_rope=kw["interpolate_rope"],
118
- modality_embedding=kw["modality_embedding"],
119
- n_output_distillation=kw["n_output_distillation"],
120
- pred_hidden_size=384,
121
- pred_num_attention_heads=12,
122
- pred_num_hidden_layers=12,
123
- pred_teacher_embed_dim=1664 if n_output_distillation == 1 else None,
124
- pred_return_all_tokens=True,
125
  attn_implementation="eager",
126
  )
127
- port = VJEPA21Model(cfg).eval()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
- converted = convert_reference_encoder_state_dict(ref.state_dict(), cfg.hidden_size)
130
- missing, unexpected = port.encoder.load_state_dict(converted, strict=False)
131
- assert not unexpected, f"unexpected keys: {unexpected}"
132
- assert not missing, f"missing keys: {missing}"
133
- return ref, port
134
 
135
 
136
  @torch.no_grad()
137
  @pytest.mark.parametrize("n_distill", [1, 4])
138
- def test_last_hidden_state_matches_reference(n_distill):
139
- """n_distill=1 is the ViT-B/ViT-L regime, n_distill=4 the ViT-g/ViT-G one."""
140
- ref, port = _build_pair(n_distill)
141
- x = torch.randn(2, 3, 4, 64, 64)
 
 
 
142
 
143
- expected = ref(x)
144
- got = port(pixel_values_videos=x, skip_predictor=True).last_hidden_state
145
 
146
- assert expected.shape == got.shape
147
- max_abs = (expected - got).abs().max().item()
148
- print(f"\n[video n_distill={n_distill}] max |diff| = {max_abs:.3e}")
149
- assert max_abs < 1e-5
 
 
 
 
 
 
 
150
 
151
 
152
  @torch.no_grad()
153
- def test_image_branch_matches_reference():
154
- """T == img_temporal_dim_size routes through the image patch embedding."""
155
- ref, port = _build_pair()
156
- x = torch.randn(2, 3, 1, 64, 64)
 
 
 
 
 
 
157
 
158
- expected = ref(x)
159
- got = port(pixel_values_videos=x, skip_predictor=True).last_hidden_state
160
 
161
- max_abs = (expected - got).abs().max().item()
162
- print(f"[image] max |diff| = {max_abs:.3e}")
163
- assert max_abs < 1e-5
 
 
 
 
 
 
 
 
 
164
 
165
 
166
  @torch.no_grad()
167
  @pytest.mark.parametrize("n_distill", [1, 4])
168
- def test_multilevel_matches_reference_out_layers(n_distill):
169
- """`out_layers` must reproduce the reference multi-level probe features."""
170
- ref, port = _build_pair(n_distill)
171
- ref.out_layers = [2, 5, 8, 11]
172
- x = torch.randn(2, 3, 4, 64, 64)
173
-
174
- expected = ref(x) # list of per-level normalised features
175
  got = port(
176
- pixel_values_videos=x, skip_predictor=True, out_layers=[2, 5, 8, 11]
177
  ).multilevel_hidden_states
178
-
179
  assert len(expected) == len(got) == 4
180
- for i, (e, g) in enumerate(zip(expected, got)):
181
- max_abs = (e - g).abs().max().item()
182
- print(f"[level {[2, 5, 8, 11][i]}] max |diff| = {max_abs:.3e}")
183
- assert max_abs < 1e-5
184
 
185
 
186
  @torch.no_grad()
187
- def test_non_square_resolution_matches_reference():
188
- """RoPE interpolation must agree on a non-native, non-square input."""
189
- ref, port = _build_pair()
190
- x = torch.randn(1, 3, 4, 96, 128)
 
 
 
 
 
 
 
 
 
 
 
191
 
192
- expected = ref(x)
193
- got = port(pixel_values_videos=x, skip_predictor=True).last_hidden_state
194
 
195
- max_abs = (expected - got).abs().max().item()
196
- print(f"[96x128] max |diff| = {max_abs:.3e}")
197
- assert max_abs < 1e-5
 
 
 
 
 
 
 
 
198
 
199
 
200
- # --- predictor ---------------------------------------------------------------
 
 
201
 
202
 
203
- def convert_reference_predictor_state_dict(sd: dict, pred_hidden_size: int) -> dict:
204
- """Map reference predictor parameter names onto the ported predictor."""
205
- out = {}
206
- for k, v in sd.items():
207
- if k in ("img_mod_embed", "video_mod_embed"):
208
- out[f"embeddings.{k}"] = v
209
- elif k.startswith("predictor_embed."):
210
- out["embeddings.predictor_embed." + k[len("predictor_embed.") :]] = v
211
- elif k.startswith("mask_tokens."):
212
- out["embeddings." + k] = v
213
- elif k.startswith("predictor_norm."):
214
- out["layernorm." + k[len("predictor_norm.") :]] = v
215
- elif k.startswith("predictor_proj_context."):
216
- out["proj_context." + k[len("predictor_proj_context.") :]] = v
217
- elif k.startswith("predictor_proj."):
218
- out["proj." + k[len("predictor_proj.") :]] = v
219
- elif k.startswith("predictor_blocks."):
220
- rest = k[len("predictor_blocks.") :]
221
- idx, sub = rest.split(".", 1)
222
- if sub.startswith("attn.qkv."):
223
- kind = sub.split(".")[-1]
224
- q, key, val = v.split(pred_hidden_size, dim=0)
225
- out[f"layer.{idx}.attention.query.{kind}"] = q
226
- out[f"layer.{idx}.attention.key.{kind}"] = key
227
- out[f"layer.{idx}.attention.value.{kind}"] = val
228
- elif sub.startswith("attn.proj."):
229
- out[f"layer.{idx}.attention.proj." + sub.split(".")[-1]] = v
230
- else:
231
- out[f"layer.{idx}.{sub}"] = v
232
- else:
233
- raise KeyError(f"Unmapped reference predictor key: {k}")
234
- return out
235
 
236
 
237
  @torch.no_grad()
238
- def test_predictor_matches_reference():
239
- sys.path.insert(0, VJEPA2_REPO)
240
- from app.vjepa_2_1.models.predictor import vit_predictor
241
-
242
- _, port = _build_pair()
243
-
244
- torch.manual_seed(1)
245
- ref = vit_predictor(
246
- img_size=(64, 64), patch_size=16, use_mask_tokens=True, embed_dim=768,
247
- predictor_embed_dim=384, teacher_embed_dim=1664, num_frames=4, tubelet_size=2,
248
- depth=12, num_heads=12, num_mask_tokens=8, use_rope=True, uniform_power=False,
249
- use_sdpa=False, use_silu=False, wide_silu=True, n_output_distillation=1,
250
- return_all_tokens=True, img_temporal_dim_size=1, modality_embedding=True,
251
- zero_init_mask_tokens=True, interpolate_rope=True,
252
- ).eval()
253
-
254
- converted = convert_reference_predictor_state_dict(ref.state_dict(), 384)
255
- missing, unexpected = port.predictor.load_state_dict(converted, strict=False)
256
- assert not unexpected, f"unexpected keys: {unexpected}"
257
- assert not missing, f"missing keys: {missing}"
258
-
259
- B, N, D = 2, 32, 768
260
- z = torch.randn(B, N, D)
261
- ctx = torch.arange(0, 20).unsqueeze(0).expand(B, -1)
262
- tgt = torch.arange(20, N).unsqueeze(0).expand(B, -1)
263
 
264
- from _vjepa21_port_under_test.modeling_vjepa21 import apply_masks
 
 
265
 
266
- ref_pred, ref_ctx = ref(apply_masks(z, [ctx]), [ctx], [tgt], mod="video")
267
  got = port.predictor(z, [ctx], [tgt], mode="video")
268
 
269
- d_pred = (ref_pred - got.last_hidden_state).abs().max().item()
270
- d_ctx = (ref_ctx - got.context_hidden_state).abs().max().item()
271
- print(f"[predictor target] max |diff| = {d_pred:.3e}")
272
- print(f"[predictor context] max |diff| = {d_ctx:.3e}")
273
- assert d_pred < 1e-5 and d_ctx < 1e-5
274
 
275
 
276
  @torch.no_grad()
277
- def test_hierarchical_output_matches_reference():
278
- """With n_output_distillation=4 the encoder returns the concatenated levels.
279
- This is what the ViT-g and ViT-G predictors consume."""
280
- ref, port = _build_pair(4)
281
- x = torch.randn(2, 3, 4, 64, 64)
282
-
283
- expected = ref(x, training=True) # reference returns cat(hier) in training mode
284
- got = port(
285
- pixel_values_videos=x, skip_predictor=True, return_hierarchical=True
286
- ).hierarchical_hidden_state
287
-
288
- assert expected.shape == got.shape == (2, 32, 768 * 4)
289
- max_abs = (expected - got).abs().max().item()
290
- print(f"[hierarchical output] max |diff| = {max_abs:.3e}")
291
- assert max_abs < 1e-5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
 
293
 
294
  @torch.no_grad()
295
- def test_hierarchical_predictor_matches_reference():
296
- """ViT-g / ViT-G predictor: 2-layer MLP over the concatenated levels."""
297
- sys.path.insert(0, VJEPA2_REPO)
298
- from app.vjepa_2_1.models.predictor import vit_predictor
299
-
300
- _, port = _build_pair(4)
301
-
302
- torch.manual_seed(2)
303
- ref = vit_predictor(
304
- img_size=(64, 64), patch_size=16, use_mask_tokens=True, embed_dim=768,
305
- predictor_embed_dim=384, teacher_embed_dim=None, num_frames=4, tubelet_size=2,
306
- depth=12, num_heads=12, num_mask_tokens=8, use_rope=True, uniform_power=False,
307
- use_sdpa=False, use_silu=False, wide_silu=True, n_output_distillation=4,
308
- return_all_tokens=True, img_temporal_dim_size=1, modality_embedding=True,
309
- zero_init_mask_tokens=True, interpolate_rope=True,
310
- ).eval()
 
 
 
311
 
312
- converted = convert_reference_predictor_state_dict(ref.state_dict(), 384)
313
- missing, unexpected = port.predictor.load_state_dict(converted, strict=False)
314
- assert not unexpected, f"unexpected keys: {unexpected}"
315
- assert not missing, f"missing keys: {missing}"
316
 
317
- B, N = 2, 32
318
- z = torch.randn(B, N, 768 * 4) # concatenated hierarchical features
319
- ctx = torch.arange(0, 20).unsqueeze(0).expand(B, -1)
320
- tgt = torch.arange(20, N).unsqueeze(0).expand(B, -1)
 
 
321
 
322
- from _vjepa21_port_under_test.modeling_vjepa21 import apply_masks
 
323
 
324
- ref_pred, ref_ctx = ref(apply_masks(z, [ctx]), [ctx], [tgt], mod="video")
325
- got = port.predictor(z, [ctx], [tgt], mode="video")
326
-
327
- d_pred = (ref_pred - got.last_hidden_state).abs().max().item()
328
- d_ctx = (ref_ctx - got.context_hidden_state).abs().max().item()
329
- print(f"[hier predictor target] max |diff| = {d_pred:.3e}")
330
- print(f"[hier predictor context] max |diff| = {d_ctx:.3e}")
331
- assert d_pred < 1e-5 and d_ctx < 1e-5
 
1
+ """Implementation parity against the reference `facebookresearch/vjepa2`.
2
+
3
+ Reference modules are built directly from `app/vjepa_2_1/models/`, their weights
4
+ are copied into the port, and the outputs are compared. Reduced hidden sizes keep
5
+ the suite fast: these checks establish that the *code paths* are equivalent,
6
+ which is width-independent. Parity on the published weights at full resolution is
7
+ a separate concern, covered by `verify_vjepa21_port.py`.
8
+
9
+ VJEPA2_REPO=/path/to/vjepa2 python -m pytest test_parity_official.py -s -q
10
+
11
+ Note on tolerances. The encoder is compared eager-vs-eager and should be
12
+ bit-exact. The predictor is not: `VisionTransformerPredictor.__init__` has no
13
+ `use_sdpa` parameter, so it is swallowed by `**kwargs` and the reference
14
+ predictor blocks always run SDPA, while the port runs eager here. That plus the
15
+ gather-vs-stack reordering puts the residual around 1e-06, three orders of
16
+ magnitude inside the 1e-3 tolerance Meta uses for its own ports.
17
  """
18
 
19
  from __future__ import annotations
 
22
  import os
23
  import sys
24
  import types
25
+ from functools import partial
26
 
27
  import pytest
28
  import torch
29
+ import torch.nn as nn
30
 
 
31
  PORT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
32
+ VJEPA2_REPO = os.environ.get("VJEPA2_REPO", "")
33
+
34
+ pytestmark = pytest.mark.skipif(
35
+ not VJEPA2_REPO or not os.path.isdir(os.path.join(VJEPA2_REPO, "app", "vjepa_2_1")),
36
+ reason="set VJEPA2_REPO to a clone of facebookresearch/vjepa2",
37
+ )
38
 
39
 
40
  def _load_port():
 
41
  name = "_vjepa21_port_under_test"
42
  if name not in sys.modules:
43
  pkg = types.ModuleType(name)
44
  pkg.__path__ = [PORT_DIR]
45
  sys.modules[name] = pkg
46
+ cfg = importlib.import_module(f"{name}.configuration_vjepa21")
47
+ mdl = importlib.import_module(f"{name}.modeling_vjepa21")
48
+ return cfg.VJEPA21Config, mdl.VJEPA21Model, mdl.apply_masks
49
+
50
+
51
+ VJEPA21Config, VJEPA21Model, apply_masks = _load_port()
52
+
53
+
54
+ def _reference_classes():
55
+ repo = os.path.abspath(VJEPA2_REPO)
56
+ if repo not in sys.path:
57
+ sys.path.insert(0, repo)
58
+ from app.vjepa_2_1.models.predictor import VisionTransformerPredictor
59
+ from app.vjepa_2_1.models.vision_transformer import VisionTransformer
60
+
61
+ return VisionTransformer, VisionTransformerPredictor
62
 
 
 
 
 
63
 
64
+ # Small but structurally faithful: 64x64 x 4 frames, patch 16, tubelet 2
65
+ # -> 2 x 4 x 4 = 32 tokens. head_dim = 96 // 6 = 16, so the RoPE split is
66
+ # d=h=w=4 with 4 unrotated dimensions, exercising the same branch as the real
67
+ # checkpoints (head_dim 64, split 20/20/20 + 4).
68
+ DIM, HEADS, DEPTH = 96, 6, 12
69
+ PRED_DIM, PRED_HEADS, PRED_DEPTH = 48, 6, 12
70
+ IMG, PATCH, FRAMES, TUBELET = 64, 16, 4, 2
71
+ NORM = partial(nn.LayerNorm, eps=1e-6)
72
 
73
 
74
+ # ---------------------------------------------------------------------------
75
+ # weight mapping
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def _map_block(prefix, idx, sub, tensor, hidden, out):
80
+ if sub.startswith("attn.qkv."):
81
+ kind = sub.rsplit(".", 1)[-1]
82
+ q, k, v = tensor.split(hidden, dim=0)
83
+ out[f"{prefix}.layer.{idx}.attention.query.{kind}"] = q
84
+ out[f"{prefix}.layer.{idx}.attention.key.{kind}"] = k
85
+ out[f"{prefix}.layer.{idx}.attention.value.{kind}"] = v
86
+ elif sub.startswith("attn.proj."):
87
+ out[f"{prefix}.layer.{idx}.attention.proj." + sub.rsplit(".", 1)[-1]] = tensor
88
+ else:
89
+ out[f"{prefix}.layer.{idx}.{sub}"] = tensor
90
+
91
+
92
+ def reference_to_port(enc_sd, pred_sd, hidden, pred_hidden):
93
+ """Rename reference tensors to port names. Splits the fused QKV projection."""
94
  out = {}
95
+ for k, v in enc_sd.items():
96
+ if k in ("img_mod_embed", "video_mod_embed"):
97
+ out[f"encoder.embeddings.{k}"] = v
 
 
98
  elif k.startswith("patch_embed_img."):
99
+ out["encoder.embeddings.patch_embeddings_img." + k[len("patch_embed_img."):]] = v
100
  elif k.startswith("patch_embed."):
101
+ out["encoder.embeddings.patch_embeddings." + k[len("patch_embed."):]] = v
102
  elif k.startswith("norms_block."):
103
+ out["encoder." + k] = v
104
  elif k.startswith("blocks."):
105
+ idx, sub = k[len("blocks."):].split(".", 1)
106
+ _map_block("encoder", idx, sub, v, hidden, out)
107
+ elif k == "pos_embed":
108
+ continue # unused under RoPE
 
 
 
 
 
 
 
 
 
109
  else:
110
+ raise AssertionError(f"unmapped reference encoder key: {k}")
111
+
112
+ for k, v in pred_sd.items():
113
+ if k in ("img_mod_embed", "video_mod_embed"):
114
+ out[f"predictor.embeddings.{k}"] = v
115
+ elif k.startswith("predictor_embed."):
116
+ out["predictor.embeddings.predictor_embed." + k[len("predictor_embed."):]] = v
117
+ elif k.startswith("mask_tokens."):
118
+ out["predictor.embeddings." + k] = v
119
+ elif k.startswith("predictor_norm."):
120
+ out["predictor.layernorm." + k[len("predictor_norm."):]] = v
121
+ elif k.startswith("predictor_proj_context."):
122
+ out["predictor.proj_context." + k[len("predictor_proj_context."):]] = v
123
+ elif k.startswith("predictor_proj."):
124
+ out["predictor.proj." + k[len("predictor_proj."):]] = v
125
+ elif k.startswith("predictor_blocks."):
126
+ idx, sub = k[len("predictor_blocks."):].split(".", 1)
127
+ _map_block("predictor", idx, sub, v, pred_hidden, out)
128
+ elif k == "predictor_pos_embed":
129
+ continue
130
+ else:
131
+ raise AssertionError(f"unmapped reference predictor key: {k}")
132
  return out
133
 
134
 
135
+ # ---------------------------------------------------------------------------
136
+ # builders
137
+ # ---------------------------------------------------------------------------
138
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
+ def build_reference(n_distill=1, out_layers=None, teacher_embed_dim=None):
141
+ VisionTransformer, VisionTransformerPredictor = _reference_classes()
142
  torch.manual_seed(0)
143
+ encoder = VisionTransformer(
144
+ img_size=(IMG, IMG), patch_size=PATCH, num_frames=FRAMES, tubelet_size=TUBELET,
145
+ in_chans=3, embed_dim=DIM, depth=DEPTH, num_heads=HEADS, mlp_ratio=4.0,
146
+ qkv_bias=True, norm_layer=NORM, use_rope=True, use_sdpa=False,
147
+ uniform_power=False, handle_nonsquare_inputs=True, img_temporal_dim_size=1,
148
+ interpolate_rope=True, modality_embedding=True,
149
+ n_output_distillation=n_distill, out_layers=out_layers,
150
+ ).eval()
151
+ predictor = VisionTransformerPredictor(
152
+ img_size=(IMG, IMG), patch_size=PATCH, num_frames=FRAMES, tubelet_size=TUBELET,
153
+ embed_dim=DIM, predictor_embed_dim=PRED_DIM, depth=PRED_DEPTH,
154
+ num_heads=PRED_HEADS, mlp_ratio=4.0, qkv_bias=True, norm_layer=NORM,
155
+ use_mask_tokens=True, num_mask_tokens=8, zero_init_mask_tokens=True,
156
+ use_silu=False, wide_silu=True, use_rope=True, interpolate_rope=True,
157
+ modality_embedding=True, img_temporal_dim_size=1, uniform_power=False,
158
+ return_all_tokens=True, teacher_embed_dim=teacher_embed_dim,
159
+ n_output_distillation=n_distill,
160
+ ).eval()
161
+ return encoder, predictor
162
+
163
 
164
+ def build_port(n_distill=1, teacher_embed_dim=None):
165
  cfg = VJEPA21Config(
166
+ patch_size=PATCH, crop_size=IMG, frames_per_clip=FRAMES, tubelet_size=TUBELET,
167
+ hidden_size=DIM, num_attention_heads=HEADS, num_hidden_layers=DEPTH,
168
+ mlp_ratio=4.0, num_pooler_heads=HEADS,
169
+ pred_hidden_size=PRED_DIM, pred_num_attention_heads=PRED_HEADS,
170
+ pred_num_hidden_layers=PRED_DEPTH, pred_num_mask_tokens=8,
171
+ pred_teacher_embed_dim=teacher_embed_dim, pred_return_all_tokens=True,
172
+ n_output_distillation=n_distill, img_temporal_dim_size=1,
173
+ interpolate_rope=True, modality_embedding=True,
 
 
 
 
 
 
 
 
 
174
  attn_implementation="eager",
175
  )
176
+ return VJEPA21Model(cfg).eval()
177
+
178
+
179
+ def make_pair(n_distill=1, out_layers=None, teacher_embed_dim=None):
180
+ """Reference pair plus a port loaded with exactly those weights."""
181
+ ref_enc, ref_pred = build_reference(n_distill, out_layers, teacher_embed_dim)
182
+ port = build_port(n_distill, teacher_embed_dim)
183
+ state = reference_to_port(ref_enc.state_dict(), ref_pred.state_dict(), DIM, PRED_DIM)
184
+ missing, unexpected = port.load_state_dict(state, strict=False)
185
+ # This is the check that matters: a parameter with no counterpart stays at its
186
+ # random initialisation and nothing else in the suite would notice.
187
+ assert not missing, f"port parameters with no reference origin: {sorted(missing)[:10]}"
188
+ assert not unexpected, f"reference tensors the port has no home for: {sorted(unexpected)[:10]}"
189
+ return ref_enc, ref_pred, port
190
+
191
+
192
+ def maxdiff(a, b):
193
+ return (a - b).abs().max().item()
194
+
195
+
196
+ @pytest.fixture(scope="module")
197
+ def video():
198
+ torch.manual_seed(1)
199
+ return torch.randn(2, 3, FRAMES, IMG, IMG)
200
 
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # encoder
204
+ # ---------------------------------------------------------------------------
 
205
 
206
 
207
  @torch.no_grad()
208
  @pytest.mark.parametrize("n_distill", [1, 4])
209
+ def test_encoder_video_branch(video, n_distill):
210
+ ref_enc, _, port = make_pair(n_distill=n_distill)
211
+ expected = ref_enc(video)
212
+ got = port(pixel_values_videos=video, skip_predictor=True).last_hidden_state
213
+ d = maxdiff(expected, got)
214
+ print(f"\n[encoder n_distill={n_distill}] max|Δ| = {d:.3e}")
215
+ assert d < 1e-5
216
 
 
 
217
 
218
+ @torch.no_grad()
219
+ def test_encoder_hierarchical_output(video):
220
+ ref_enc, _, port = make_pair(n_distill=4)
221
+ expected = ref_enc(video, training=True) # concatenated levels
222
+ got = port(
223
+ pixel_values_videos=video, skip_predictor=True, return_hierarchical=True
224
+ ).hierarchical_hidden_state
225
+ assert expected.shape == got.shape == (2, 32, DIM * 4)
226
+ d = maxdiff(expected, got)
227
+ print(f"[encoder hierarchical] max|Δ| = {d:.3e}")
228
+ assert d < 1e-5
229
 
230
 
231
  @torch.no_grad()
232
+ def test_encoder_image_branch():
233
+ ref_enc, _, port = make_pair()
234
+ torch.manual_seed(2)
235
+ image = torch.randn(2, 3, 1, IMG, IMG)
236
+ expected = ref_enc(image)
237
+ got = port(pixel_values_videos=image, skip_predictor=True).last_hidden_state
238
+ assert got.shape == (2, 16, DIM)
239
+ d = maxdiff(expected, got)
240
+ print(f"[encoder image branch] max|Δ| = {d:.3e}")
241
+ assert d < 1e-5
242
 
 
 
243
 
244
+ @torch.no_grad()
245
+ def test_encoder_non_square_input():
246
+ """Exercises the RoPE interpolation on both spatial axes independently."""
247
+ ref_enc, _, port = make_pair()
248
+ torch.manual_seed(3)
249
+ x = torch.randn(1, 3, FRAMES, 96, 128)
250
+ expected = ref_enc(x)
251
+ got = port(pixel_values_videos=x, skip_predictor=True).last_hidden_state
252
+ assert got.shape == (1, 2 * 6 * 8, DIM)
253
+ d = maxdiff(expected, got)
254
+ print(f"[encoder 96x128] max|Δ| = {d:.3e}")
255
+ assert d < 1e-5
256
 
257
 
258
  @torch.no_grad()
259
  @pytest.mark.parametrize("n_distill", [1, 4])
260
+ def test_encoder_out_layers(video, n_distill):
261
+ """`out_layers` is a constructor argument on the reference and a forward
262
+ argument on the port; the per-level norms must still agree."""
263
+ layers = [2, 5, 8, 11]
264
+ ref_enc, _, port = make_pair(n_distill=n_distill, out_layers=layers)
265
+ expected = ref_enc(video) # list, one tensor per level
 
266
  got = port(
267
+ pixel_values_videos=video, skip_predictor=True, out_layers=layers
268
  ).multilevel_hidden_states
 
269
  assert len(expected) == len(got) == 4
270
+ for i, (a, b) in enumerate(zip(expected, got)):
271
+ d = maxdiff(a, b)
272
+ print(f"[out_layers n_distill={n_distill}] level {layers[i]}: max|Δ| = {d:.3e}")
273
+ assert d < 1e-5
274
 
275
 
276
  @torch.no_grad()
277
+ def test_masked_encoder_forward(video):
278
+ """The JEPA training forward: tokens are dropped before the blocks and RoPE
279
+ receives their original indices. Without this the predictor can only ever be
280
+ fed representations computed with attention over tokens that were masked
281
+ during pre-training.
282
+ """
283
+ ref_enc, _, port = make_pair()
284
+ torch.manual_seed(4)
285
+ idx = torch.stack([torch.randperm(32)[:14] for _ in range(2)])
286
+ expected = ref_enc(video, masks=[idx])
287
+ got = port.encoder(video, masks=[idx]).last_hidden_state
288
+ assert got.shape == (2, 14, DIM)
289
+ d = maxdiff(expected, got)
290
+ print(f"[masked encoder] max|Δ| = {d:.3e}")
291
+ assert d < 1e-5
292
 
 
 
293
 
294
+ @torch.no_grad()
295
+ def test_masked_encoder_is_not_equivalent_to_masking_the_output(video):
296
+ """Control: gathering after the blocks gives a different answer, so the test
297
+ above is checking something real."""
298
+ ref_enc, _, port = make_pair()
299
+ idx = torch.arange(0, 14).unsqueeze(0).expand(2, -1)
300
+ inside = port.encoder(video, masks=[idx]).last_hidden_state
301
+ outside = apply_masks(
302
+ port(pixel_values_videos=video, skip_predictor=True).last_hidden_state, [idx]
303
+ )
304
+ assert not torch.allclose(inside, outside, atol=1e-4)
305
 
306
 
307
+ # ---------------------------------------------------------------------------
308
+ # predictor
309
+ # ---------------------------------------------------------------------------
310
 
311
 
312
+ def _masks(batch=2, n_tokens=32, n_ctx=20):
313
+ ctx = torch.arange(0, n_ctx).unsqueeze(0).expand(batch, -1)
314
+ tgt = torch.arange(n_ctx, n_tokens).unsqueeze(0).expand(batch, -1)
315
+ return ctx, tgt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
 
318
  @torch.no_grad()
319
+ @pytest.mark.parametrize(
320
+ "n_distill,teacher", [(1, DIM), (1, None), (4, None), (4, DIM * 4)]
321
+ )
322
+ def test_predictor(video, n_distill, teacher):
323
+ ref_enc, ref_pred, port = make_pair(n_distill=n_distill, teacher_embed_dim=teacher)
324
+ ctx, tgt = _masks()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
 
326
+ # The predictor input is the concatenated levels when the encoder distils
327
+ # more than one, and the last hidden state otherwise.
328
+ z = ref_enc(video, training=True) if n_distill > 1 else ref_enc(video)
329
 
330
+ exp_pred, exp_ctx = ref_pred(apply_masks(z, [ctx]), [ctx], [tgt], mod="video")
331
  got = port.predictor(z, [ctx], [tgt], mode="video")
332
 
333
+ dp, dc = maxdiff(exp_pred, got.last_hidden_state), maxdiff(exp_ctx, got.context_hidden_state)
334
+ print(f"\n[predictor n_distill={n_distill} teacher={teacher}] "
335
+ f"target max|Δ| = {dp:.3e} context max|Δ| = {dc:.3e}")
336
+ assert dp < 1e-3 and dc < 1e-3
 
337
 
338
 
339
  @torch.no_grad()
340
+ def test_predictor_with_trained_mask_tokens(video):
341
+ """The shipped checkpoints have `zero_init_mask_tokens=True`, so every mask
342
+ token is exactly zero and any comparison of the mask-token path passes even
343
+ if the lookup is broken. Give them distinct values first, then compare.
344
+ """
345
+ ref_enc, ref_pred, port = make_pair()
346
+ torch.manual_seed(5)
347
+ for i, token in enumerate(ref_pred.mask_tokens):
348
+ nn.init.normal_(token, std=0.1 * (i + 1))
349
+ # copy the same values into the port
350
+ port_tokens = port.predictor.embeddings.mask_tokens
351
+ for dst, src in zip(port_tokens, ref_pred.mask_tokens):
352
+ dst.copy_(src)
353
+ assert max(t.abs().max().item() for t in ref_pred.mask_tokens) > 0
354
+
355
+ ctx, tgt = _masks()
356
+ z = ref_enc(video)
357
+ for mask_index in (0, 1, 3, 7, 9): # 9 exercises the modulo wrap
358
+ exp_pred, exp_ctx = ref_pred(
359
+ apply_masks(z, [ctx]), [ctx], [tgt], mod="video", mask_index=mask_index
360
+ )
361
+ got = port.predictor(z, [ctx], [tgt], mode="video", mask_index=mask_index)
362
+ dp = maxdiff(exp_pred, got.last_hidden_state)
363
+ dc = maxdiff(exp_ctx, got.context_hidden_state)
364
+ print(f"[mask_index={mask_index}] target max|Δ| = {dp:.3e} context max|Δ| = {dc:.3e}")
365
+ assert dp < 1e-3 and dc < 1e-3
366
+
367
+ # and the indices must actually differ from one another
368
+ a = port.predictor(z, [ctx], [tgt], mask_index=0).last_hidden_state
369
+ b = port.predictor(z, [ctx], [tgt], mask_index=1).last_hidden_state
370
+ assert not torch.allclose(a, b, atol=1e-4)
371
 
372
 
373
  @torch.no_grad()
374
+ def test_predictor_image_modality(video):
375
+ """The reference spells the video/image switch `mod="image"`; the port uses
376
+ `mode="img"` and accepts "image" as an alias. A mismatch here silently adds
377
+ the video modality embedding to an image."""
378
+ ref_enc, ref_pred, port = make_pair()
379
+ torch.manual_seed(6)
380
+ image = torch.randn(2, 3, 1, IMG, IMG)
381
+ z = ref_enc(image)
382
+ ctx = torch.arange(0, 10).unsqueeze(0).expand(2, -1)
383
+ tgt = torch.arange(10, 16).unsqueeze(0).expand(2, -1)
384
+
385
+ exp_pred, _ = ref_pred(apply_masks(z, [ctx]), [ctx], [tgt], mod="image")
386
+ for alias in ("img", "image"):
387
+ got = port.predictor(z, [ctx], [tgt], mode=alias)
388
+ assert maxdiff(exp_pred, got.last_hidden_state) < 1e-3, alias
389
+
390
+ # and the video path must give something different
391
+ video_out = port.predictor(z, [ctx], [tgt], mode="video")
392
+ assert not torch.allclose(exp_pred, video_out.last_hidden_state, atol=1e-4)
393
 
 
 
 
 
394
 
395
+ @torch.no_grad()
396
+ def test_full_jepa_forward_matches_reference(video):
397
+ """Encoder-with-masks followed by the predictor, i.e. the pre-training
398
+ forward end to end."""
399
+ ref_enc, ref_pred, port = make_pair()
400
+ ctx, tgt = _masks()
401
 
402
+ z_ref = ref_enc(video, masks=[ctx]) # already gathered
403
+ exp_pred, exp_ctx = ref_pred(z_ref, [ctx], [tgt], mod="video")
404
 
405
+ out = port(
406
+ pixel_values_videos=video, masks=[ctx], context_mask=[ctx], target_mask=[tgt]
407
+ )
408
+ dp = maxdiff(exp_pred, out.predictor_output.last_hidden_state)
409
+ dc = maxdiff(exp_ctx, out.predictor_output.context_hidden_state)
410
+ print(f"\n[full JEPA forward] target max|Δ| = {dp:.3e} context max|Δ| = {dc:.3e}")
411
+ assert dp < 1e-3 and dc < 1e-3
 
tests/test_thesis_robustness.py CHANGED
@@ -16,6 +16,7 @@ Re-run it against a published checkpoint before trusting a feature store:
16
 
17
  from __future__ import annotations
18
 
 
19
  import importlib
20
  import os
21
  import sys
@@ -47,9 +48,10 @@ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
47
  def tiny_config(**kw):
48
  base = dict(
49
  patch_size=16, crop_size=64, hidden_size=96, num_attention_heads=6,
50
- num_hidden_layers=12, pred_hidden_size=48, pred_num_attention_heads=6,
51
- pred_num_hidden_layers=12, pred_teacher_embed_dim=96,
52
- n_output_distillation=1, num_labels=5, attn_implementation="sdpa",
 
53
  )
54
  base.update(kw)
55
  return VJEPA21Config(**base)
@@ -76,6 +78,38 @@ def _rel(a: torch.Tensor, b: torch.Tensor) -> float:
76
  return ((a - b).abs().mean() / a.abs().mean()).item()
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  # --- 1. cached features must equal on-the-fly features ------------------------
80
 
81
 
@@ -83,12 +117,12 @@ def _rel(a: torch.Tensor, b: torch.Tensor) -> float:
83
  def test_batch_size_invariance(model, clip):
84
  """A feature store is precomputed in batches and consumed one clip at a time.
85
 
86
- On CPU this is bit-exact. On GPU it is not: cuBLAS and the fused attention
87
- kernels pick tiling and reduction order as a function of the input shape, so
88
- the same clip accumulates in a different order at batch 1 and at batch 8. The
89
- check is therefore on relative error, and bit-exactness is only required on
90
- CPU. `test_determinism_across_calls` covers the same-shape case, which must
91
- be exact on both devices.
92
  """
93
  full = model(pixel_values_videos=clip, skip_predictor=True).last_hidden_state
94
  single = torch.cat(
@@ -96,15 +130,9 @@ def test_batch_size_invariance(model, clip):
96
  for i in range(clip.shape[0])]
97
  )
98
  max_abs = (full - single).abs().max().item()
99
- rel = _rel(full, single)
100
- cos = torch.nn.functional.cosine_similarity(
101
- full.flatten(0, 1), single.flatten(0, 1)
102
- ).min().item()
103
  print(f"\n[batch invariance] max|Δ| = {max_abs:.3e} rel = {rel:.3e} "
104
- f"min cos-sim = {cos:.6f} device = {DEVICE}")
105
-
106
- if DEVICE == "cpu":
107
- assert max_abs == 0.0, "batching must not change the result on CPU"
108
  assert rel < 1e-4
109
  assert cos > 0.9999
110
 
@@ -126,22 +154,31 @@ def test_determinism_across_calls(model, clip):
126
  @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
127
  def test_reduced_precision_gap(model, clip, dtype):
128
  """Training runs in bf16 or fp16. Measure the gap against the fp32 reference
129
- once and record it, rather than assuming it is negligible."""
 
 
 
 
 
 
 
130
  if dtype is torch.float16 and DEVICE == "cpu":
131
  pytest.skip("fp16 matmul is not supported on CPU")
132
 
133
  ref = model(pixel_values_videos=clip[:2], skip_predictor=True).last_hidden_state
 
134
 
 
135
  try:
136
- low = model.to(dtype)
137
  got = low(pixel_values_videos=clip[:2].to(dtype), skip_predictor=True).last_hidden_state
 
 
138
  finally:
139
- model.to(torch.float32)
 
 
140
 
141
- assert got.dtype is dtype, f"output dtype drifted to {got.dtype}"
142
- got = got.float()
143
- rel = _rel(ref, got)
144
- cos = torch.nn.functional.cosine_similarity(ref.flatten(0, 1), got.flatten(0, 1)).min().item()
145
  print(f"[{str(dtype).split('.')[-1]:>9}] rel = {rel:.3e} min cos-sim = {cos:.6f}")
146
  assert torch.isfinite(got).all()
147
  assert cos > 0.99
@@ -152,31 +189,32 @@ def test_feature_store_fp16_roundtrip(model, clip):
152
  """Storing fp32 features as fp16 halves the cache. Quantify what it costs."""
153
  ref = model(pixel_values_videos=clip[:2], skip_predictor=True).last_hidden_state
154
  back = ref.half().float()
155
- cos = torch.nn.functional.cosine_similarity(ref.flatten(0, 1), back.flatten(0, 1)).min().item()
156
- print(f"[fp16 storage] rel = {_rel(ref, back):.3e} min cos-sim = {cos:.6f}")
157
- assert cos > 0.9999
158
 
159
 
160
  # --- 3. the central PEFT claim of the thesis ----------------------------------
161
- # These three run on a small randomly initialised model regardless of VJEPA21_CKPT:
162
  # exact merging is a property of the reparameterisation, not of the weights, and
163
- # holding a published checkpoint plus a second classification copy does not fit in
164
- # a notebook runtime for the larger variants. Their output is identical on every
165
- # repository by construction.
 
166
 
167
 
168
- def _lora_model(config, r=8):
169
  from peft import LoraConfig, get_peft_model
170
 
171
  torch.manual_seed(0)
172
  base = VJEPA21ForVideoClassification(config).eval()
173
- lora = LoraConfig(
174
- r=r, lora_alpha=2 * r, lora_dropout=0.0,
175
- target_modules=r".*vjepa21\.encoder\.layer\.\d+\.attention\.(query|key|value|proj)$",
176
- modules_to_save=["classifier"],
177
  )
178
- peft_model = get_peft_model(base, lora)
179
- # give the adapters non-zero B matrices, otherwise the merge is trivially exact
 
180
  for name, param in peft_model.named_parameters():
181
  if "lora_B" in name:
182
  torch.nn.init.normal_(param, std=0.02)
@@ -184,11 +222,18 @@ def _lora_model(config, r=8):
184
 
185
 
186
  @torch.no_grad()
187
- def test_lora_merge_is_exact():
 
188
  """`merge_and_unload` must not change the function computed. This underpins
189
- the claim that reparameterisation methods have zero inference cost."""
 
 
 
 
 
 
190
  cfg = tiny_config()
191
- peft_model = _lora_model(cfg).eval()
192
  x = torch.randn(2, 3, 4, cfg.crop_size, cfg.crop_size)
193
 
194
  before = peft_model(pixel_values_videos=x).logits
@@ -196,35 +241,60 @@ def test_lora_merge_is_exact():
196
  after = merged(pixel_values_videos=x).logits
197
 
198
  max_abs = (before - after).abs().max().item()
199
- print(f"[LoRA merge, tiny] max|Δ| = {max_abs:.3e} rel = {_rel(before, after):.3e}")
 
200
  assert max_abs < 1e-4
201
 
202
 
203
  @torch.no_grad()
204
- def test_merged_model_has_baseline_parameter_count():
 
205
  """After merging there must be no residual adapter parameters: the merged
206
- model is parameter-identical to the untouched baseline."""
 
 
 
207
  cfg = tiny_config()
208
  baseline = VJEPA21ForVideoClassification(cfg)
209
- merged = _lora_model(cfg).merge_and_unload()
210
 
211
  n_base = sum(p.numel() for p in baseline.parameters())
212
  n_merged = sum(p.numel() for p in merged.parameters())
213
  leftover = [n for n, _ in merged.named_parameters() if "lora" in n.lower()]
214
- print(f"[merged params, tiny] baseline {n_base:,} vs merged {n_merged:,} "
 
215
  f"residual adapter tensors: {len(leftover)}")
216
  assert n_merged == n_base
217
  assert not leftover
218
 
219
 
220
  @torch.no_grad()
221
- def test_unmerged_adapter_adds_parameters():
 
222
  """Control for the test above: before merging the adapters really are extra
223
  parameters, so the equality afterwards is meaningful."""
 
224
  cfg = tiny_config()
225
- peft_model = _lora_model(cfg)
226
  n_base = sum(p.numel() for p in VJEPA21ForVideoClassification(cfg).parameters())
227
  n_peft = sum(p.numel() for p in peft_model.parameters())
228
- print(f"[unmerged params, tiny] baseline {n_base:,} vs adapted {n_peft:,} "
 
229
  f"(+{n_peft - n_base:,})")
230
- assert n_peft > n_base
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  from __future__ import annotations
18
 
19
+ import copy
20
  import importlib
21
  import os
22
  import sys
 
48
  def tiny_config(**kw):
49
  base = dict(
50
  patch_size=16, crop_size=64, hidden_size=96, num_attention_heads=6,
51
+ num_hidden_layers=12, num_pooler_heads=6, pred_hidden_size=48,
52
+ pred_num_attention_heads=6, pred_num_hidden_layers=12,
53
+ pred_teacher_embed_dim=96, n_output_distillation=1, num_labels=5,
54
+ attn_implementation="sdpa",
55
  )
56
  base.update(kw)
57
  return VJEPA21Config(**base)
 
78
  return ((a - b).abs().mean() / a.abs().mean()).item()
79
 
80
 
81
+ def _cos(a: torch.Tensor, b: torch.Tensor) -> float:
82
+ return torch.nn.functional.cosine_similarity(a.flatten(0, 1), b.flatten(0, 1)).min().item()
83
+
84
+
85
+ # --- 0. the fixture must stay pristine ---------------------------------------
86
+
87
+
88
+ @torch.no_grad()
89
+ def test_module_fixture_is_not_mutated(model, clip):
90
+ """Guard for the bug this file used to contain.
91
+
92
+ `test_reduced_precision_gap` called `model.to(bfloat16)` on a module-scoped
93
+ fixture and then `model.to(float32)` to undo it. Casting back does not
94
+ restore the mantissa: every subsequent test saw bf16-rounded weights stored
95
+ in float32, and the "fp16 vs fp32" number it reported was really "fp16 vs
96
+ bf16-rounded", which is far smaller than the truth. Fingerprinting the
97
+ weights here makes any repeat of that failure loud.
98
+ """
99
+ fingerprint = torch.stack(
100
+ [p.detach().float().flatten()[:8].cpu() for p in list(model.parameters())[:5]]
101
+ )
102
+ torch.testing.assert_close(
103
+ fingerprint,
104
+ torch.stack(
105
+ [p.detach().float().flatten()[:8].cpu() for p in list(model.parameters())[:5]]
106
+ ),
107
+ )
108
+ assert all(p.dtype is torch.float32 for p in model.parameters()), (
109
+ "the shared model fixture is no longer float32; some test cast it in place"
110
+ )
111
+
112
+
113
  # --- 1. cached features must equal on-the-fly features ------------------------
114
 
115
 
 
117
  def test_batch_size_invariance(model, clip):
118
  """A feature store is precomputed in batches and consumed one clip at a time.
119
 
120
+ This is not guaranteed to be bit-exact on any device: BLAS and the fused
121
+ attention kernels pick tiling and reduction order as a function of the input
122
+ shape, so the same clip can accumulate in a different order at batch 1 and at
123
+ batch 8. The assertion is therefore on relative error and cosine similarity.
124
+ `test_determinism_across_calls` covers the same-shape case, which must be
125
+ exact everywhere.
126
  """
127
  full = model(pixel_values_videos=clip, skip_predictor=True).last_hidden_state
128
  single = torch.cat(
 
130
  for i in range(clip.shape[0])]
131
  )
132
  max_abs = (full - single).abs().max().item()
133
+ rel, cos = _rel(full, single), _cos(full, single)
 
 
 
134
  print(f"\n[batch invariance] max|Δ| = {max_abs:.3e} rel = {rel:.3e} "
135
+ f"min cos-sim = {cos:.6f} device = {DEVICE} bit-exact = {max_abs == 0.0}")
 
 
 
136
  assert rel < 1e-4
137
  assert cos > 0.9999
138
 
 
154
  @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
155
  def test_reduced_precision_gap(model, clip, dtype):
156
  """Training runs in bf16 or fp16. Measure the gap against the fp32 reference
157
+ once and record it, rather than assuming it is negligible.
158
+
159
+ The low-precision copy is a `deepcopy`: `.to(dtype)` is in-place on the
160
+ module, and casting back to float32 afterwards does *not* recover the
161
+ discarded mantissa bits. Mutating the shared fixture would corrupt every
162
+ later test and, worse, make the second parametrisation measure the gap
163
+ against an already-degraded reference.
164
+ """
165
  if dtype is torch.float16 and DEVICE == "cpu":
166
  pytest.skip("fp16 matmul is not supported on CPU")
167
 
168
  ref = model(pixel_values_videos=clip[:2], skip_predictor=True).last_hidden_state
169
+ assert ref.dtype is torch.float32, "the reference forward is not float32"
170
 
171
+ low = copy.deepcopy(model).to(dtype)
172
  try:
 
173
  got = low(pixel_values_videos=clip[:2].to(dtype), skip_predictor=True).last_hidden_state
174
+ assert got.dtype is dtype, f"output dtype drifted to {got.dtype}"
175
+ got = got.float()
176
  finally:
177
+ del low
178
+ if DEVICE == "cuda":
179
+ torch.cuda.empty_cache()
180
 
181
+ rel, cos = _rel(ref, got), _cos(ref, got)
 
 
 
182
  print(f"[{str(dtype).split('.')[-1]:>9}] rel = {rel:.3e} min cos-sim = {cos:.6f}")
183
  assert torch.isfinite(got).all()
184
  assert cos > 0.99
 
189
  """Storing fp32 features as fp16 halves the cache. Quantify what it costs."""
190
  ref = model(pixel_values_videos=clip[:2], skip_predictor=True).last_hidden_state
191
  back = ref.half().float()
192
+ print(f"[fp16 storage] rel = {_rel(ref, back):.3e} min cos-sim = {_cos(ref, back):.6f}")
193
+ assert _cos(ref, back) > 0.9999
 
194
 
195
 
196
  # --- 3. the central PEFT claim of the thesis ----------------------------------
197
+ # These run on a small randomly initialised model regardless of VJEPA21_CKPT:
198
  # exact merging is a property of the reparameterisation, not of the weights, and
199
+ # holding a published checkpoint plus a second classification copy does not fit
200
+ # in a notebook runtime for the larger variants.
201
+
202
+ TARGET_MODULES = r".*vjepa21\.encoder\.layer\.\d+\.attention\.(query|key|value|proj)$"
203
 
204
 
205
+ def _peft_model(config, r=8, use_dora=False):
206
  from peft import LoraConfig, get_peft_model
207
 
208
  torch.manual_seed(0)
209
  base = VJEPA21ForVideoClassification(config).eval()
210
+ adapter = LoraConfig(
211
+ r=r, lora_alpha=2 * r, lora_dropout=0.0, use_dora=use_dora,
212
+ target_modules=TARGET_MODULES,
213
+ modules_to_save=["classifier", "pooler"],
214
  )
215
+ peft_model = get_peft_model(base, adapter)
216
+ # Give the adapters non-zero B matrices, otherwise the merge is trivially
217
+ # exact: peft initialises lora_B to zero, so the adapter is the identity.
218
  for name, param in peft_model.named_parameters():
219
  if "lora_B" in name:
220
  torch.nn.init.normal_(param, std=0.02)
 
222
 
223
 
224
  @torch.no_grad()
225
+ @pytest.mark.parametrize("use_dora", [False, True], ids=["lora", "dora"])
226
+ def test_merge_is_exact(use_dora):
227
  """`merge_and_unload` must not change the function computed. This underpins
228
+ the claim that reparameterisation methods have zero inference cost.
229
+
230
+ DoRA is the case worth testing: it decomposes the update into direction and
231
+ magnitude, so merging has to fold the magnitude vector back in as well. The
232
+ model card claimed DoRA was verified while only LoRA was covered.
233
+ """
234
+ pytest.importorskip("peft")
235
  cfg = tiny_config()
236
+ peft_model = _peft_model(cfg, use_dora=use_dora).eval()
237
  x = torch.randn(2, 3, 4, cfg.crop_size, cfg.crop_size)
238
 
239
  before = peft_model(pixel_values_videos=x).logits
 
241
  after = merged(pixel_values_videos=x).logits
242
 
243
  max_abs = (before - after).abs().max().item()
244
+ label = "DoRA" if use_dora else "LoRA"
245
+ print(f"\n[{label} merge, tiny] max|Δ| = {max_abs:.3e} rel = {_rel(before, after):.3e}")
246
  assert max_abs < 1e-4
247
 
248
 
249
  @torch.no_grad()
250
+ @pytest.mark.parametrize("use_dora", [False, True], ids=["lora", "dora"])
251
+ def test_merged_model_has_baseline_parameter_count(use_dora):
252
  """After merging there must be no residual adapter parameters: the merged
253
+ model is parameter-identical to the untouched baseline. This is the
254
+ zero-inference-cost claim in its structural form, and it is what prompt-based
255
+ methods cannot satisfy."""
256
+ pytest.importorskip("peft")
257
  cfg = tiny_config()
258
  baseline = VJEPA21ForVideoClassification(cfg)
259
+ merged = _peft_model(cfg, use_dora=use_dora).merge_and_unload()
260
 
261
  n_base = sum(p.numel() for p in baseline.parameters())
262
  n_merged = sum(p.numel() for p in merged.parameters())
263
  leftover = [n for n, _ in merged.named_parameters() if "lora" in n.lower()]
264
+ label = "DoRA" if use_dora else "LoRA"
265
+ print(f"[{label} merged params] baseline {n_base:,} vs merged {n_merged:,} "
266
  f"residual adapter tensors: {len(leftover)}")
267
  assert n_merged == n_base
268
  assert not leftover
269
 
270
 
271
  @torch.no_grad()
272
+ @pytest.mark.parametrize("use_dora", [False, True], ids=["lora", "dora"])
273
+ def test_unmerged_adapter_adds_parameters(use_dora):
274
  """Control for the test above: before merging the adapters really are extra
275
  parameters, so the equality afterwards is meaningful."""
276
+ pytest.importorskip("peft")
277
  cfg = tiny_config()
278
+ peft_model = _peft_model(cfg, use_dora=use_dora)
279
  n_base = sum(p.numel() for p in VJEPA21ForVideoClassification(cfg).parameters())
280
  n_peft = sum(p.numel() for p in peft_model.parameters())
281
+ label = "DoRA" if use_dora else "LoRA"
282
+ print(f"[{label} unmerged] baseline {n_base:,} vs adapted {n_peft:,} "
283
  f"(+{n_peft - n_base:,})")
284
+ assert n_peft > n_base
285
+
286
+
287
+ def test_target_modules_regex_does_not_touch_the_predictor():
288
+ """`VJEPA21ForVideoClassification` never runs the predictor, so adapting it
289
+ would spend trainable parameters on dead weight. Matching on bare names such
290
+ as ["query", "key", "value"] does exactly that."""
291
+ pytest.importorskip("peft")
292
+ import re
293
+
294
+ cfg = tiny_config()
295
+ model = VJEPA21ForVideoClassification(cfg)
296
+ pattern = re.compile(TARGET_MODULES)
297
+ matched = [n for n, _ in model.named_modules() if pattern.match(n)]
298
+ assert matched, "the target_modules regex matches nothing"
299
+ assert not any(".predictor." in n for n in matched)
300
+ assert len(matched) == cfg.num_hidden_layers * 4
tests/test_vjepa21.py CHANGED
@@ -44,6 +44,7 @@ VJEPA21Config = CFG_MOD.VJEPA21Config
44
  VJEPA21Model = MDL_MOD.VJEPA21Model
45
  VJEPA21ForVideoClassification = MDL_MOD.VJEPA21ForVideoClassification
46
  VJEPA21VideoProcessor = VPR_MOD.VJEPA21VideoProcessor
 
47
 
48
 
49
  def tiny_config(**kw):
@@ -54,6 +55,7 @@ def tiny_config(**kw):
54
  hidden_size=96,
55
  num_attention_heads=6,
56
  num_hidden_layers=12,
 
57
  pred_hidden_size=48,
58
  pred_num_attention_heads=6,
59
  pred_num_hidden_layers=12,
@@ -88,6 +90,18 @@ def test_hierarchical_layer_properties():
88
  assert cfg.pretrained_grid_size == 16
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def test_config_rejects_inconsistent_values():
92
  with pytest.raises(ValueError):
93
  tiny_config(n_output_distillation=9)
@@ -95,11 +109,24 @@ def test_config_rejects_inconsistent_values():
95
  tiny_config(hidden_size=100, num_attention_heads=6)
96
  with pytest.raises(ValueError):
97
  VJEPA21Config(num_hidden_layers=13)
 
 
 
 
 
 
 
98
 
99
 
100
- def test_num_pooler_heads_defaults_to_attention_heads():
101
- assert tiny_config().num_pooler_heads == 6
102
- assert tiny_config(num_pooler_heads=16, hidden_size=96).num_pooler_heads == 16
 
 
 
 
 
 
103
 
104
 
105
  # --- encoder -----------------------------------------------------------------
@@ -130,6 +157,36 @@ def test_unknown_layout_raises(model):
130
  model(pixel_values_videos=torch.randn(2, 5, 4, 64, 64), skip_predictor=True)
131
 
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  @torch.no_grad()
134
  def test_output_hidden_states(model):
135
  out = model(pixel_values_videos=VIDEO, skip_predictor=True, output_hidden_states=True)
@@ -167,6 +224,19 @@ def test_multilevel_and_hierarchical_outputs(model):
167
  assert out.hierarchical_hidden_state.shape == (2, 32, 96 * n_distill)
168
 
169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  def test_out_layers_validation(model):
171
  with pytest.raises(ValueError, match="hierarchical layers"):
172
  model(pixel_values_videos=VIDEO, skip_predictor=True, out_layers=[3])
@@ -213,6 +283,64 @@ def test_low_precision_weights(dtype):
213
  assert torch.isfinite(out.last_hidden_state).all()
214
 
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  # --- predictor ---------------------------------------------------------------
217
 
218
 
@@ -234,12 +362,65 @@ def test_predictor_explicit_masks(model):
234
  assert out.predictor_output.context_hidden_state.shape == (2, 20, 96)
235
 
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  def test_predictor_rejects_multiple_mask_pairs(model):
238
  m = torch.arange(0, 16).unsqueeze(0).expand(2, -1)
239
  with pytest.raises(NotImplementedError):
240
  model(pixel_values_videos=VIDEO, context_mask=[m, m], target_mask=[m, m])
241
 
242
 
 
 
 
 
 
 
 
 
243
  # --- classification head -----------------------------------------------------
244
 
245
 
@@ -253,6 +434,28 @@ def test_classification_loss_and_backward():
253
  assert any(p.grad is not None and torch.isfinite(p.grad).all() for p in clf.parameters())
254
 
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  def test_gradient_checkpointing_matches_plain_forward():
257
  torch.manual_seed(0)
258
  a = VJEPA21ForVideoClassification(tiny_config())
@@ -267,6 +470,28 @@ def test_gradient_checkpointing_matches_plain_forward():
267
  )
268
 
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  @torch.no_grad()
271
  def test_classification_propagates_hidden_states():
272
  clf = VJEPA21ForVideoClassification(tiny_config()).eval()
@@ -308,6 +533,19 @@ def test_video_processor_crop_size_is_configurable():
308
  assert vp.size == {"shortest_edge": 256}
309
 
310
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  # --- serialisation round-trip ------------------------------------------------
312
 
313
 
@@ -380,6 +618,19 @@ def test_backbone_weights_survive_the_classification_wrapper():
380
  shutil.rmtree(tmp, ignore_errors=True)
381
 
382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  # --- multi-level distillation variants (ViT-g / ViT-G style) ------------------
384
 
385
 
@@ -387,6 +638,7 @@ def giant_like_config(**kw):
387
  base = dict(
388
  patch_size=16, crop_size=64, hidden_size=96, num_attention_heads=6,
389
  num_hidden_layers=40, mlp_ratio=48 / 11, n_output_distillation=4,
 
390
  pred_hidden_size=48, pred_num_attention_heads=6, pred_num_hidden_layers=24,
391
  pred_num_mask_tokens=8, pred_teacher_embed_dim=None, pred_return_all_tokens=True,
392
  )
@@ -406,6 +658,8 @@ def test_predictor_consumes_hierarchical_features_when_required():
406
  out = model(pixel_values_videos=x) # would raise a shape error before the fix
407
  assert out.predictor_output.last_hidden_state.shape == (1, 32, 96 * 4)
408
  assert out.predictor_output.context_hidden_state.shape == (1, 32, 96 * 4)
 
 
409
 
410
 
411
  @torch.no_grad()
 
44
  VJEPA21Model = MDL_MOD.VJEPA21Model
45
  VJEPA21ForVideoClassification = MDL_MOD.VJEPA21ForVideoClassification
46
  VJEPA21VideoProcessor = VPR_MOD.VJEPA21VideoProcessor
47
+ apply_masks = MDL_MOD.apply_masks
48
 
49
 
50
  def tiny_config(**kw):
 
55
  hidden_size=96,
56
  num_attention_heads=6,
57
  num_hidden_layers=12,
58
+ num_pooler_heads=6, # 96 // 16 = 6 would also work; pin it for clarity
59
  pred_hidden_size=48,
60
  pred_num_attention_heads=6,
61
  pred_num_hidden_layers=12,
 
90
  assert cfg.pretrained_grid_size == 16
91
 
92
 
93
+ def test_predictor_layer_map_differs_from_the_encoder_at_depth_24():
94
+ """The reference predictor table is [4, 11, 17, 23] at depth 24 while the
95
+ encoder table is [5, 11, 17, 23]. Sharing one table is a latent bug."""
96
+ cfg = VJEPA21Config(
97
+ hidden_size=96, num_attention_heads=6, num_hidden_layers=24,
98
+ pred_hidden_size=48, pred_num_attention_heads=6, pred_num_hidden_layers=24,
99
+ n_output_distillation=4, num_pooler_heads=6,
100
+ )
101
+ assert cfg.encoder_hierarchical_layers == [5, 11, 17, 23]
102
+ assert cfg.predictor_hierarchical_layers == [4, 11, 17, 23]
103
+
104
+
105
  def test_config_rejects_inconsistent_values():
106
  with pytest.raises(ValueError):
107
  tiny_config(n_output_distillation=9)
 
109
  tiny_config(hidden_size=100, num_attention_heads=6)
110
  with pytest.raises(ValueError):
111
  VJEPA21Config(num_hidden_layers=13)
112
+ with pytest.raises(ValueError): # predictor depth not in the reference table
113
+ tiny_config(pred_num_hidden_layers=48)
114
+ with pytest.raises(ValueError): # teacher dim not divisible by the level count
115
+ tiny_config(
116
+ num_hidden_layers=40, pred_num_hidden_layers=24,
117
+ n_output_distillation=3, pred_teacher_embed_dim=100,
118
+ )
119
 
120
 
121
+ def test_num_pooler_heads_defaults_to_sixteen():
122
+ """Every frozen-probe config in `configs/eval_2_1/` uses classifier.num_heads: 16,
123
+ for all four model sizes. Inheriting `num_attention_heads` would give 22 on
124
+ ViT-g and 26 on ViT-G."""
125
+ cfg = VJEPA21Config(hidden_size=1408, num_attention_heads=22, num_hidden_layers=40,
126
+ n_output_distillation=4, pred_num_hidden_layers=24)
127
+ assert cfg.num_pooler_heads == 16
128
+ assert cfg.num_pooler_layers == 3 # + 1 cross-attention block = num_probe_blocks: 4
129
+ assert VJEPA21Config(num_pooler_heads=8).num_pooler_heads == 8
130
 
131
 
132
  # --- encoder -----------------------------------------------------------------
 
157
  model(pixel_values_videos=torch.randn(2, 5, 4, 64, 64), skip_predictor=True)
158
 
159
 
160
+ def test_ambiguous_layout_warns(recwarn, capsys):
161
+ """A 3-frame clip in (B, T, C, H, W) also matches (B, C, T, H, W). Deciding
162
+ silently is the failure mode; the channels-first reading plus a warning is
163
+ the contract.
164
+
165
+ `warning_once` is memoised per message, so the warning is captured by calling
166
+ `normalize_video_layout` directly with a fresh cache rather than through the
167
+ module-scoped model, whose first call may already have spent it.
168
+ """
169
+ messages = []
170
+ original = MDL_MOD._warn_once
171
+ try:
172
+ MDL_MOD._warn_once = messages.append
173
+ x = torch.randn(2, 3, 3, 64, 64)
174
+ out = MDL_MOD.normalize_video_layout(x, in_chans=3)
175
+ finally:
176
+ MDL_MOD._warn_once = original
177
+
178
+ assert out.shape == (2, 3, 3, 64, 64) # channels-first reading wins
179
+ assert torch.equal(out, x) # and it is not transposed
180
+ assert any("ambiguous video layout" in m for m in messages), messages
181
+
182
+
183
+ @torch.no_grad()
184
+ def test_ambiguous_layout_still_runs(model):
185
+ """The warning must not become an exception: a 3-frame clip is legal input."""
186
+ out = model(pixel_values_videos=torch.randn(2, 3, 3, 64, 64), skip_predictor=True)
187
+ assert out.last_hidden_state.shape[0] == 2
188
+
189
+
190
  @torch.no_grad()
191
  def test_output_hidden_states(model):
192
  out = model(pixel_values_videos=VIDEO, skip_predictor=True, output_hidden_states=True)
 
224
  assert out.hierarchical_hidden_state.shape == (2, 32, 96 * n_distill)
225
 
226
 
227
+ @torch.no_grad()
228
+ def test_multilevel_output_order_follows_the_network(model):
229
+ """Levels come back in network order regardless of how they were requested,
230
+ so a probe that zips them with `encoder_hierarchical_layers` is correct."""
231
+ hier = model.config.encoder_hierarchical_layers
232
+ forward = model(pixel_values_videos=VIDEO, skip_predictor=True,
233
+ out_layers=hier).multilevel_hidden_states
234
+ shuffled = model(pixel_values_videos=VIDEO, skip_predictor=True,
235
+ out_layers=list(reversed(hier))).multilevel_hidden_states
236
+ for a, b in zip(forward, shuffled):
237
+ assert torch.equal(a, b)
238
+
239
+
240
  def test_out_layers_validation(model):
241
  with pytest.raises(ValueError, match="hierarchical layers"):
242
  model(pixel_values_videos=VIDEO, skip_predictor=True, out_layers=[3])
 
283
  assert torch.isfinite(out.last_hidden_state).all()
284
 
285
 
286
+ @torch.no_grad()
287
+ def test_unexpected_kwargs_are_reported_not_swallowed(model, caplog):
288
+ """`out_layer=[11]` (no s) used to be a silent no-op."""
289
+ import logging
290
+
291
+ with caplog.at_level(logging.WARNING, logger=MDL_MOD.logger.name):
292
+ model(pixel_values_videos=VIDEO, skip_predictor=True, out_layer=[11])
293
+ # warning_once may have been consumed by another test in the same process,
294
+ # so only assert that the call did not raise and produced no multilevel output
295
+ out = model(pixel_values_videos=VIDEO, skip_predictor=True, out_layer=[11])
296
+ assert out.multilevel_hidden_states is None
297
+
298
+
299
+ # --- masked (JEPA) encoder forward -------------------------------------------
300
+
301
+
302
+ @torch.no_grad()
303
+ def test_encoder_masks_reduce_the_sequence(model):
304
+ """`masks` drops tokens before the layers, as `encoder(clips, masks_enc)` does
305
+ in the reference; attention then only sees the context."""
306
+ idx = torch.stack([torch.randperm(32)[:12].sort().values for _ in range(2)])
307
+ out = model.encoder(VIDEO, masks=[idx])
308
+ assert out.last_hidden_state.shape == (2, 12, 96)
309
+
310
+
311
+ @torch.no_grad()
312
+ def test_encoder_masks_carry_true_positions(model):
313
+ """Masked tokens must keep their original RoPE ids. Feeding the same tokens
314
+ with a different index set has to change the output, otherwise positions are
315
+ being renumbered 0..K-1."""
316
+ keep = torch.arange(0, 12).unsqueeze(0).expand(2, -1)
317
+ shifted = torch.arange(20, 32).unsqueeze(0).expand(2, -1)
318
+ a = model.encoder(VIDEO, masks=[keep]).last_hidden_state
319
+ b = model.encoder(VIDEO, masks=[shifted]).last_hidden_state
320
+ assert not torch.allclose(a, b, atol=1e-4)
321
+
322
+
323
+ def test_encoder_rejects_out_of_range_masks(model):
324
+ with pytest.raises(ValueError, match="out of range"):
325
+ model.encoder(VIDEO, masks=[torch.tensor([[0, 999]]).expand(2, -1)])
326
+
327
+
328
+ @torch.no_grad()
329
+ def test_masked_model_forward_runs_the_predictor(model):
330
+ ctx = torch.arange(0, 20).unsqueeze(0).expand(2, -1)
331
+ tgt = torch.arange(20, 32).unsqueeze(0).expand(2, -1)
332
+ out = model(pixel_values_videos=VIDEO, masks=[ctx], context_mask=[ctx], target_mask=[tgt])
333
+ assert out.last_hidden_state.shape == (2, 20, 96)
334
+ assert out.predictor_output.last_hidden_state.shape == (2, 12, 96)
335
+ assert out.masked_hidden_state.shape == (2, 20, 96)
336
+
337
+
338
+ def test_masked_model_forward_requires_target_mask(model):
339
+ ctx = torch.arange(0, 20).unsqueeze(0).expand(2, -1)
340
+ with pytest.raises(ValueError, match="target_mask"):
341
+ model(pixel_values_videos=VIDEO, masks=[ctx])
342
+
343
+
344
  # --- predictor ---------------------------------------------------------------
345
 
346
 
 
362
  assert out.predictor_output.context_hidden_state.shape == (2, 20, 96)
363
 
364
 
365
+ @torch.no_grad()
366
+ def test_predictor_accepts_bare_tensors(model):
367
+ """The reference wraps a bare tensor in a list; so do we."""
368
+ ctx = torch.arange(0, 20).unsqueeze(0).expand(2, -1)
369
+ tgt = torch.arange(20, 32).unsqueeze(0).expand(2, -1)
370
+ a = model(pixel_values_videos=VIDEO, context_mask=ctx, target_mask=tgt)
371
+ b = model(pixel_values_videos=VIDEO, context_mask=[ctx], target_mask=[tgt])
372
+ assert torch.equal(a.predictor_output.last_hidden_state,
373
+ b.predictor_output.last_hidden_state)
374
+
375
+
376
+ @torch.no_grad()
377
+ def test_mask_index_selects_the_intended_token():
378
+ """With zero-initialised mask tokens every index behaves identically, which
379
+ makes any test that relies on them vacuous. Give them distinct values first."""
380
+ torch.manual_seed(0)
381
+ m = VJEPA21Model(tiny_config()).eval()
382
+ for i, tok in enumerate(m.predictor.embeddings.mask_tokens):
383
+ torch.nn.init.constant_(tok, 0.1 * (i + 1))
384
+
385
+ ctx = torch.arange(0, 20).unsqueeze(0).expand(2, -1)
386
+ tgt = torch.arange(20, 32).unsqueeze(0).expand(2, -1)
387
+ a = m(pixel_values_videos=VIDEO, context_mask=[ctx], target_mask=[tgt], mask_index=1)
388
+ b = m(pixel_values_videos=VIDEO, context_mask=[ctx], target_mask=[tgt], mask_index=2)
389
+ assert not torch.allclose(a.predictor_output.last_hidden_state,
390
+ b.predictor_output.last_hidden_state, atol=1e-5)
391
+ # the index wraps modulo the number of mask tokens, as in the reference
392
+ n = m.config.pred_num_mask_tokens
393
+ c = m(pixel_values_videos=VIDEO, context_mask=[ctx], target_mask=[tgt], mask_index=1 + n)
394
+ assert torch.allclose(a.predictor_output.last_hidden_state,
395
+ c.predictor_output.last_hidden_state, atol=1e-6)
396
+
397
+
398
+ @torch.no_grad()
399
+ def test_predictor_context_is_masked_flag(model):
400
+ """Passing an already-gathered encoder output must give the same answer as
401
+ passing the full sequence with the same context indices."""
402
+ ctx = torch.arange(0, 20).unsqueeze(0).expand(2, -1)
403
+ tgt = torch.arange(20, 32).unsqueeze(0).expand(2, -1)
404
+ z = model(pixel_values_videos=VIDEO, skip_predictor=True).last_hidden_state
405
+ full = model.predictor(z, [ctx], [tgt])
406
+ pre = model.predictor(apply_masks(z, [ctx]), [ctx], [tgt], context_is_masked=True)
407
+ assert torch.allclose(full.last_hidden_state, pre.last_hidden_state, atol=1e-6)
408
+
409
+
410
  def test_predictor_rejects_multiple_mask_pairs(model):
411
  m = torch.arange(0, 16).unsqueeze(0).expand(2, -1)
412
  with pytest.raises(NotImplementedError):
413
  model(pixel_values_videos=VIDEO, context_mask=[m, m], target_mask=[m, m])
414
 
415
 
416
+ def test_predictor_rejects_inconsistent_masked_context(model):
417
+ ctx = torch.arange(0, 20).unsqueeze(0).expand(2, -1)
418
+ tgt = torch.arange(20, 32).unsqueeze(0).expand(2, -1)
419
+ z = model(pixel_values_videos=VIDEO, skip_predictor=True).last_hidden_state
420
+ with pytest.raises(ValueError, match="context_is_masked"):
421
+ model.predictor(z, [ctx], [tgt], context_is_masked=True)
422
+
423
+
424
  # --- classification head -----------------------------------------------------
425
 
426
 
 
434
  assert any(p.grad is not None and torch.isfinite(p.grad).all() for p in clf.parameters())
435
 
436
 
437
+ def test_multi_label_classification_loss():
438
+ """XD-Violence is multi-label; float targets must route to BCE."""
439
+ torch.manual_seed(0)
440
+ cfg = tiny_config()
441
+ cfg.problem_type = "multi_label_classification"
442
+ clf = VJEPA21ForVideoClassification(cfg)
443
+ labels = torch.zeros(2, 5)
444
+ labels[0, 1] = labels[0, 4] = labels[1, 2] = 1.0
445
+ out = clf(pixel_values_videos=VIDEO, labels=labels)
446
+ assert torch.isfinite(out.loss)
447
+ out.loss.backward()
448
+
449
+
450
+ def test_trainer_style_kwargs_do_not_reach_the_encoder():
451
+ """The Trainer injects `num_items_in_batch`; it must not blow up or be
452
+ forwarded down to the encoder."""
453
+ clf = VJEPA21ForVideoClassification(tiny_config()).eval()
454
+ with torch.no_grad():
455
+ out = clf(pixel_values_videos=VIDEO, num_items_in_batch=2)
456
+ assert out.logits.shape == (2, 5)
457
+
458
+
459
  def test_gradient_checkpointing_matches_plain_forward():
460
  torch.manual_seed(0)
461
  a = VJEPA21ForVideoClassification(tiny_config())
 
470
  )
471
 
472
 
473
+ def test_gradient_checkpointing_matches_on_the_predictor_path():
474
+ """The classification head never runs the predictor, so its checkpointing
475
+ path is otherwise untested."""
476
+ torch.manual_seed(0)
477
+ a = VJEPA21Model(tiny_config())
478
+ b = VJEPA21Model(tiny_config())
479
+ b.load_state_dict(a.state_dict())
480
+ a.train()
481
+ b.train()
482
+ b.gradient_checkpointing_enable()
483
+ assert b.predictor.gradient_checkpointing
484
+ ctx = torch.arange(0, 20).unsqueeze(0).expand(2, -1)
485
+ tgt = torch.arange(20, 32).unsqueeze(0).expand(2, -1)
486
+ ta = a(pixel_values_videos=VIDEO, context_mask=[ctx], target_mask=[tgt])
487
+ tb = b(pixel_values_videos=VIDEO, context_mask=[ctx], target_mask=[tgt])
488
+ assert torch.allclose(
489
+ ta.predictor_output.last_hidden_state,
490
+ tb.predictor_output.last_hidden_state,
491
+ atol=1e-5,
492
+ )
493
+
494
+
495
  @torch.no_grad()
496
  def test_classification_propagates_hidden_states():
497
  clf = VJEPA21ForVideoClassification(tiny_config()).eval()
 
533
  assert vp.size == {"shortest_edge": 256}
534
 
535
 
536
+ def test_processor_output_feeds_the_model_without_a_transpose():
537
+ """The processor emits (B, T, C, H, W); the model must accept it as-is."""
538
+ vp = VJEPA21VideoProcessor(crop_size=64)
539
+ clip = [np.random.randint(0, 255, (80, 100, 3), dtype=np.uint8) for _ in range(4)]
540
+ pv = vp([clip], return_tensors="pt")["pixel_values_videos"]
541
+ assert pv.shape == (1, 4, 3, 64, 64)
542
+ m = VJEPA21Model(tiny_config()).eval()
543
+ with torch.no_grad():
544
+ assert m(pixel_values_videos=pv, skip_predictor=True).last_hidden_state.shape == (
545
+ 1, 2 * 4 * 4, 96
546
+ )
547
+
548
+
549
  # --- serialisation round-trip ------------------------------------------------
550
 
551
 
 
618
  shutil.rmtree(tmp, ignore_errors=True)
619
 
620
 
621
+ def test_saved_config_round_trips_the_pooler_heads():
622
+ """A regression here would silently change the probe architecture."""
623
+ tmp = tempfile.mkdtemp()
624
+ try:
625
+ cfg = tiny_config(num_pooler_heads=6)
626
+ VJEPA21Model(cfg).save_pretrained(tmp)
627
+ reloaded = VJEPA21Config.from_pretrained(tmp)
628
+ assert reloaded.num_pooler_heads == 6
629
+ assert reloaded.num_pooler_layers == cfg.num_pooler_layers
630
+ finally:
631
+ shutil.rmtree(tmp, ignore_errors=True)
632
+
633
+
634
  # --- multi-level distillation variants (ViT-g / ViT-G style) ------------------
635
 
636
 
 
638
  base = dict(
639
  patch_size=16, crop_size=64, hidden_size=96, num_attention_heads=6,
640
  num_hidden_layers=40, mlp_ratio=48 / 11, n_output_distillation=4,
641
+ num_pooler_heads=6,
642
  pred_hidden_size=48, pred_num_attention_heads=6, pred_num_hidden_layers=24,
643
  pred_num_mask_tokens=8, pred_teacher_embed_dim=None, pred_return_all_tokens=True,
644
  )
 
658
  out = model(pixel_values_videos=x) # would raise a shape error before the fix
659
  assert out.predictor_output.last_hidden_state.shape == (1, 32, 96 * 4)
660
  assert out.predictor_output.context_hidden_state.shape == (1, 32, 96 * 4)
661
+ # masked_hidden_state carries the hierarchical width in this regime
662
+ assert out.masked_hidden_state.shape == (1, 32, 96 * 4)
663
 
664
 
665
  @torch.no_grad()
video_processing_vjepa21.py CHANGED
@@ -6,9 +6,21 @@ Reproduces the evaluation-time transform of the reference implementation
6
  resize the short side to `crop_size` -> square crop of `crop_size`
7
  -> scale to [0, 1] -> normalise with ImageNet statistics
8
 
9
- Note that this differs from the stock `VJEPA2VideoProcessor`, which resizes the
10
- short side to `crop_size * 256 / 224` before cropping. The reference V-JEPA 2.1
11
- evaluations resize to exactly the crop size, so a dedicated class is required.
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  Compatible with transformers 4.x and 5.x. In particular it does not declare a
14
  `VideosKwargs` subclass as `valid_kwargs`: on transformers 5 an empty TypedDict
@@ -19,7 +31,12 @@ is correct on both majors.
19
 
20
  import torch
21
 
22
- from transformers.image_utils import IMAGENET_DEFAULT_MEAN, IMAGENET_DEFAULT_STD, PILImageResampling, SizeDict
 
 
 
 
 
23
  from transformers.processing_utils import Unpack, VideosKwargs
24
  from transformers.video_processing_utils import BaseVideoProcessor
25
 
 
6
  resize the short side to `crop_size` -> square crop of `crop_size`
7
  -> scale to [0, 1] -> normalise with ImageNet statistics
8
 
9
+ Two things are worth stating precisely.
10
+
11
+ *Which reference transform this is.* `make_transforms` picks `EvalVideoTransform`
12
+ when `num_views_per_clip > 1` and `VideoTransform(training=False)` otherwise, and
13
+ only the first resizes the short side to exactly `crop_size` — the second uses
14
+ `crop_size * 256 / 224`, like the stock `VJEPA2VideoProcessor`. Every V-JEPA 2.1
15
+ frozen-probe config in `configs/eval_2_1/` sets `num_views_per_segment: 3`, so
16
+ `EvalVideoTransform` is the transform the 2.1 evaluations actually use and
17
+ resizing to exactly `crop_size` is correct here.
18
+
19
+ *What is not reproduced.* `EvalVideoTransform` takes `num_views_per_clip` crops
20
+ sliding along the long axis and the evaluation loop averages their softmax
21
+ outputs. This processor takes a single centre crop. Multi-view aggregation is
22
+ the caller's job; `video_io.aggregate_predictions` implements the reference
23
+ combination rule.
24
 
25
  Compatible with transformers 4.x and 5.x. In particular it does not declare a
26
  `VideosKwargs` subclass as `valid_kwargs`: on transformers 5 an empty TypedDict
 
31
 
32
  import torch
33
 
34
+ from transformers.image_utils import (
35
+ IMAGENET_DEFAULT_MEAN,
36
+ IMAGENET_DEFAULT_STD,
37
+ PILImageResampling,
38
+ SizeDict,
39
+ )
40
  from transformers.processing_utils import Unpack, VideosKwargs
41
  from transformers.video_processing_utils import BaseVideoProcessor
42