Video Classification
Transformers
Safetensors
vjepa21
feature-extraction
video
vjepa
vjepa2
v-jepa-2.1
self-supervised
world-model
custom_code
Instructions to use apiantonio/vjepa2.1-vit-base-384 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use apiantonio/vjepa2.1-vit-base-384 with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("video-classification", model="apiantonio/vjepa2.1-vit-base-384", trust_remote_code=True)# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("apiantonio/vjepa2.1-vit-base-384", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
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 +12 -2
- config.json +1 -1
- configuration_vjepa21.py +80 -28
- modeling_vjepa21.py +247 -34
- tests/test_end_to_end_pipeline.py +91 -16
- tests/test_parity_official.py +326 -246
- tests/test_thesis_robustness.py +120 -50
- tests/test_vjepa21.py +257 -3
- video_processing_vjepa21.py +21 -4
__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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
"""
|
| 14 |
-
|
| 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:
|
|
|
|
| 26 |
)
|
| 27 |
-
return
|
| 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 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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] =
|
| 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
|
| 224 |
return _get_hierarchical_layers(self.num_hidden_layers)
|
| 225 |
|
| 226 |
@property
|
| 227 |
def encoder_distillation_layers(self) -> list[int]:
|
| 228 |
-
"""
|
| 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 |
-
"""
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 52 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
| 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=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 856 |
_, N_ctxt, _ = masked_states.shape
|
| 857 |
|
| 858 |
-
hidden_states, position_masks = self.embeddings(
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 1141 |
-
|
|
|
|
| 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
|
| 1206 |
-
|
| 1207 |
-
|
| 1208 |
-
|
| 1209 |
-
|
| 1210 |
-
target_mask
|
| 1211 |
-
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
**
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
# ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
processor = _processor()
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
The
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 37 |
-
|
| 38 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
out = {}
|
| 51 |
-
for k, v in
|
| 52 |
-
if k
|
| 53 |
-
out["embeddings.
|
| 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.")
|
| 58 |
elif k.startswith("patch_embed."):
|
| 59 |
-
out["embeddings.patch_embeddings." + k[len("patch_embed.")
|
| 60 |
elif k.startswith("norms_block."):
|
| 61 |
-
out[k] = v
|
| 62 |
elif k.startswith("blocks."):
|
| 63 |
-
|
| 64 |
-
idx, sub
|
| 65 |
-
|
| 66 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
return out
|
| 79 |
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
|
|
|
| 107 |
cfg = VJEPA21Config(
|
| 108 |
-
patch_size=
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
return ref, port
|
| 134 |
|
| 135 |
|
| 136 |
@torch.no_grad()
|
| 137 |
@pytest.mark.parametrize("n_distill", [1, 4])
|
| 138 |
-
def
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
-
expected = ref(x)
|
| 144 |
-
got = port(pixel_values_videos=x, skip_predictor=True).last_hidden_state
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
|
| 152 |
@torch.no_grad()
|
| 153 |
-
def
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
-
expected = ref(x)
|
| 159 |
-
got = port(pixel_values_videos=x, skip_predictor=True).last_hidden_state
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
|
| 166 |
@torch.no_grad()
|
| 167 |
@pytest.mark.parametrize("n_distill", [1, 4])
|
| 168 |
-
def
|
| 169 |
-
"""`out_layers`
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
expected = ref(x) # list of per-level normalised features
|
| 175 |
got = port(
|
| 176 |
-
pixel_values_videos=
|
| 177 |
).multilevel_hidden_states
|
| 178 |
-
|
| 179 |
assert len(expected) == len(got) == 4
|
| 180 |
-
for i, (
|
| 181 |
-
|
| 182 |
-
print(f"[
|
| 183 |
-
assert
|
| 184 |
|
| 185 |
|
| 186 |
@torch.no_grad()
|
| 187 |
-
def
|
| 188 |
-
"""
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
-
expected = ref(x)
|
| 193 |
-
got = port(pixel_values_videos=x, skip_predictor=True).last_hidden_state
|
| 194 |
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
|
| 200 |
-
# ---
|
|
|
|
|
|
|
| 201 |
|
| 202 |
|
| 203 |
-
def
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 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 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 265 |
|
| 266 |
-
|
| 267 |
got = port.predictor(z, [ctx], [tgt], mode="video")
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
assert d_pred < 1e-5 and d_ctx < 1e-5
|
| 274 |
|
| 275 |
|
| 276 |
@torch.no_grad()
|
| 277 |
-
def
|
| 278 |
-
"""
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
|
| 293 |
|
| 294 |
@torch.no_grad()
|
| 295 |
-
def
|
| 296 |
-
"""
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
|
|
|
|
|
|
| 321 |
|
| 322 |
-
|
|
|
|
| 323 |
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
print(f"[
|
| 330 |
-
|
| 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,
|
| 51 |
-
|
| 52 |
-
n_output_distillation=1, num_labels=5,
|
|
|
|
| 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 |
-
|
| 87 |
-
kernels pick tiling and reduction order as a function of the input
|
| 88 |
-
the same clip
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 140 |
|
| 141 |
-
|
| 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 |
-
|
| 156 |
-
|
| 157 |
-
assert cos > 0.9999
|
| 158 |
|
| 159 |
|
| 160 |
# --- 3. the central PEFT claim of the thesis ----------------------------------
|
| 161 |
-
# These
|
| 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
|
| 164 |
-
# a notebook runtime for the larger variants.
|
| 165 |
-
|
|
|
|
| 166 |
|
| 167 |
|
| 168 |
-
def
|
| 169 |
from peft import LoraConfig, get_peft_model
|
| 170 |
|
| 171 |
torch.manual_seed(0)
|
| 172 |
base = VJEPA21ForVideoClassification(config).eval()
|
| 173 |
-
|
| 174 |
-
r=r, lora_alpha=2 * r, lora_dropout=0.0,
|
| 175 |
-
target_modules=
|
| 176 |
-
modules_to_save=["classifier"],
|
| 177 |
)
|
| 178 |
-
peft_model = get_peft_model(base,
|
| 179 |
-
#
|
|
|
|
| 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 |
-
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
| 200 |
assert max_abs < 1e-4
|
| 201 |
|
| 202 |
|
| 203 |
@torch.no_grad()
|
| 204 |
-
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
| 215 |
f"residual adapter tensors: {len(leftover)}")
|
| 216 |
assert n_merged == n_base
|
| 217 |
assert not leftover
|
| 218 |
|
| 219 |
|
| 220 |
@torch.no_grad()
|
| 221 |
-
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
| 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
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|