I-JEPA — Joint Embedding Predictive Architecture¶
NNx ships I-JEPA (Assran et al., CVPR 2023) as a TrainStepFn factory
alongside the other self-supervised paradigms. JEPA predicts in
latent space (no decoder, no pixel-reconstruction loss) and avoids
representation collapse via an EMA target encoder whose weights are
never touched by the optimizer.
The shipped path is sized for "verify-the-plumbing on a laptop, run a ViT-S for a few epochs on 32x32 images" — not a SOTA reproduction.
1. Public surface¶
| Symbol | Notes |
|---|---|
nnx.ViTNN |
Small Vision Transformer encoder. Patch-embed conv + learned pos embeds + CLS + N pre-norm blocks (RMSNorm + bidirectional MHA + SwiGLU). forward(x, mask=None) accepts an optional BoolTensor[B, n_patches] mask — True = keep — so masked patches never enter attention. |
nnx.ViTBlock |
Single pre-norm ViT block. Reused by JEPAPredictor. |
nnx.JEPAPredictor |
Small predictor module mapping (context_embeds, context_positions, target_positions) -> predicted_target_embeds. Uses its own (smaller) hidden width + position embeddings + a learned mask token. Constructor params: embed_dim, n_patches, predictor_dim=None, n_layers, n_heads, ffn_mult (default 4). |
nnx.build_target_encoder(source) |
Deep-copy source, freeze every param (requires_grad=False), pin to eval(). The factory function freezes again defensively. |
nnx.update_ema(source, target, momentum) |
In-place EMA update: target ← momentum · target + (1 - momentum) · source. Name-keyed against the target's params so a source with extra submodules (the typical "predictor under model.net" idiom) is fine. |
nnx.random_block_mask(n_patches, grid_size, …) |
Sample one rectangular block as the prediction target. Returns (context_mask, target_mask) 1-D BoolTensors. |
nnx.jepa_train_step_factory(target_encoder, predictor, mask_fn, *, ema_momentum=0.996) |
Returns a TrainStepFn for NNModel.train(..., train_step_fn=...). |
2. How a step runs¶
1. mask_fn(n_patches, device) -> (ctx_mask_1d, tgt_mask_1d)
2. context_embeds = model.net(x, mask=ctx_mask)
3. with torch.no_grad():
target_embeds = target_encoder(x)[:, target_positions, :]
4. predicted = predictor(context_embeds, ctx_positions, tgt_positions)
5. loss = MSE(predicted, target_embeds)
6. finalize_step(loss) # NaN guard + grad-clip + optimizer.step()
7. update_ema(model.net, target_encoder, ema_momentum)
3. Quickstart¶
import torch
from torch.utils.data import DataLoader, TensorDataset
from nnx import (
Activations, Devices, Losses, Nets,
NNModel, NNModelParams, NNOptimParams, NNParams, NNSchedulerParams, NNTrainParams, Optims,
ViTNN, JEPAPredictor,
build_target_encoder, jepa_train_step_factory, random_block_mask, set_seed,
)
set_seed(0)
# Synthetic 32x32 batch — JEPA is self-supervised, labels are ignored.
loader = DataLoader(
TensorDataset(torch.randn(64, 3, 32, 32), torch.zeros(64, dtype=torch.long)),
batch_size=8,
)
# NNModel with a placeholder NNParams; the real net is the ViT below.
model = NNModel(
net_params=NNParams(
input_dim=3 * 32 * 32, output_dim=64, hidden_dims=[64],
dropout_prob=0.0, activation=Activations.RELU,
),
params=NNModelParams(net=Nets.FEED_FWD, device=Devices.CPU, loss=Losses.CROSS_ENTROPY),
)
# Swap in the trainable ViT context encoder.
model.net = ViTNN(image_size=32, patch_size=4, in_channels=3,
d_model=64, n_layers=4, n_heads=4).to(model.device)
# EMA target + predictor (registered under model.net so the optimizer
# trains both encoder and predictor jointly).
target_encoder = build_target_encoder(model.net)
predictor = JEPAPredictor(embed_dim=model.net.d_model,
n_patches=model.net.n_patches,
predictor_dim=32, n_layers=2, n_heads=2)
model.net.add_module("_jepa_predictor", predictor)
# One random block per step, shared across the batch.
GRID = 32 // 4
def mask_fn(n_p, device):
return random_block_mask(n_patches=n_p, grid_size=GRID, device=device)
run = model.train(
params=NNTrainParams(
n_epochs=3, train_loader=loader,
optim=NNOptimParams(name=Optims.ADAM, max_lr=5e-4,
momentum=(0.9, 0.999), weight_decay=1e-4),
scheduler=NNSchedulerParams(min_lr=1e-7, factor=0.5,
patience=2, cooldown=1, threshold=1e-3),
),
train_step_fn=jepa_train_step_factory(
target_encoder=target_encoder,
predictor=predictor,
mask_fn=mask_fn,
ema_momentum=0.996,
),
)
The full example with an optional CIFAR-10 download lives in
examples/16_ijepa_cifar10.py.
4. Design notes¶
4.1. Why a separate ViTNN instead of reusing TransformerNN¶
TransformerNN (the LM path; see docs/lm.md) is a decoder-only LM stack. Its
MultiHeadCausalAttention hard-codes a causal mask + RoPE on Q/K;
both are wrong for vision tokens (bidirectional attention, learned
absolute positions). Rather than carve a parameter into the causal
path that vision callers would have to remember to flip, ViTNN
ships a sibling _MultiHeadSelfAttention and reuses the parts that
generalize (RMSNorm, SwiGLU).
4.2. Why a single block mask per step¶
The reference I-JEPA samples 4 target blocks per image. The shipped
random_block_mask samples 1 — enough for the demo and forced to
share the mask across the batch so ViTNN's forward can stack the
kept patches into a rectangular tensor. Users who want the 4-block
recipe can compose four calls inside a custom mask_fn; the only
hard constraint is that kept-counts per batch row must agree (or
the encoder will raise).
4.3. EMA update is name-keyed, not positional¶
update_ema walks target.named_parameters() and looks each name
up on the source. Composing the predictor as a submodule of
model.net is the canonical idiom so a single optimizer picks up
both encoder and predictor params; the predictor's params then
appear on source.named_parameters() but not on the target, and
the EMA correctly leaves them alone. Mismatches the other direction
(target param missing on source) raise — that's a real bug.
4.4. Loss reporting¶
JEPA has no classification metric. The returned
NNEvaluationDataPoint reports the L2 loss in both .loss and
.error so BEST checkpoint tracking and ReduceLROnPlateau have
a signal to lock onto. The other classification fields stay zero.
4.5. Sharp edges¶
NNModelParams.mixed_precision=Trueis rejected by the factory'sfinalize_stepcall — like every paradigm step factory — because AMP requires per-loss scaler bookkeeping that JEPA's custom step doesn't implement.NNOptimParams.accumulate_grad_batches != 1is also rejected for the same reason. JEPA's reference recipe uses large batches rather than accumulation; if you need accumulation, write a custom step that callsupdate_emaonly at the cycle boundary.- Resume-from-checkpoint only restores
model.net— the EMA target encoder is not persisted on the standard checkpoint path. Usetorch.save(target_encoder.state_dict(), ...)alongside the NNx checkpoint if you need exact-resume continuity.