8. 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. It avoids
representation collapse via an EMA target encoder whose weights are
never touched by the optimizer.
The shipped path verifies the plumbing on a laptop: a small ViT for a few epochs on 32x32 images. It is 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. Its RMSNorms reduce in at least FP32 and keep FP64 as FP64 (see the precision note below the table). |
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. Keyword-only constructor: JEPAPredictor(*, embed_dim, n_patches, predictor_dim=None, n_layers=2, n_heads=2, ffn_mult=4). predictor_dim=None resolves to max(8, embed_dim // 2). |
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. All arguments are keyword-only. 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=...). The predictor must be a submodule of model.net; the first step refuses it otherwise. |
nnx.jepa_objective(target_encoder, predictor, mask_fn, *, ema_momentum=0.996, nonfinite="fail") |
The same loss as an objective (JEPAObjective, §5) for NNModel.train(..., objective=...) / Trainer.train(..., objective=...). The shared update engine owns the update, and the EMA target advances once per committed update. |
Precision. The ViTNN RMSNorms, like those in the ViTBlocks that
JEPAPredictor reuses, reduce in at least FP32 and keep FP64 as FP64. A
.double() encoder, its EMA target and the predictor therefore run forward,
backward and update_ema in FP64. The tests and float64_vit_predictor_step
in example 16 check this.
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 (
Devices, Losses, Nets,
NNModel, NNModelParams, NNOptimParams, NNSchedulerParams, NNTrainParams, NNViTParams, Optims,
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,
)
# The trainable ViT context encoder: Nets.VIT builds ViTNN from NNViTParams.
model = NNModel(
net_params=NNViTParams(
input_dim=3 * 32 * 32, output_dim=64, # in_channels * image_size**2; d_model
dropout_prob=0.0, image_size=32, patch_size=4,
d_model=64, n_layers=4, n_heads=4,
),
params=NNModelParams(net=Nets.VIT, device=Devices.CPU, loss=Losses.CROSS_ENTROPY),
)
# 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,
),
)
Storing and rebuilding the encoder. NNViTParams carries every ViTNN
setting into run.yaml and the checkpoints; NNRun.load returns them as the
run's net. NNModel.from_checkpoint rebuilds the encoder through
Nets.VIT with its trained weights, with no caller-side swap. The recipe above
registers the predictor under model.net so the optimizer trains it. This
puts the predictor's weights in the same checkpoint. Rebuild the encoder
without them:
from nnx import Checkpoints, NNCheckpoint
checkpoint = NNCheckpoint.load(run=run.id, type=Checkpoints.LAST)
encoder = NNModel.from_checkpoint(checkpoint, exclude_submodules=("_jepa_predictor",))
Every other weight still loads strictly, and a name the checkpoint does not
hold is refused. Without exclude_submodules, the error names the extra
submodules and the option of the entry point you called. The other two
rebuild paths take the same option:
from nnx.bundles import export_bundle, reconstruct_bundle
export_bundle(run.id, "jepa-bundle") # a run bundle of the checkpoint
encoder = reconstruct_bundle("jepa-bundle", exclude_submodules=("_jepa_predictor",)).model
model.save_pretrained("jepa-hub") # a Hub save of the trained model, predictor attached
encoder = NNModel.from_pretrained("jepa-hub", exclude_submodules=("_jepa_predictor",))
Unlike from_pretrained(..., strict=False), which also accepts missing
weights, exclude_submodules drops only the named submodules. An encoder
rebuilt from a bundle this way serves inference; it cannot
ReconstructedBundle.resume() the run, whose training state includes the
predictor.
The full example with an optional CIFAR-10 download lives in
examples/16_ijepa_image_plumbing.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). A parameter in the causal path would be one that
vision callers have to remember to flip. Instead, 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, which is enough for the demo. The mask is
shared across the batch so ViTNN's forward can stack the
kept patches into a rectangular tensor. For the 4-block
recipe, compose four calls inside a custom mask_fn. The only
hard constraint is that kept-counts per batch row must agree;
otherwise 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. The predictor must be a submodule of model.net,
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 byNNModel.trainwithPrecisionUnsupportedErrorwhen it resolves to FP16 on CUDA, before any step runs, because the JEPA step is full-precision only. Off CUDA the legacy flag falls back to full precision. AMP requires per-loss scaler bookkeeping that JEPA's custom step doesn't implement.NNOptimParams.accumulate_grad_batches != 1raisesValueErrorat the first step. The imperative JEPA step does not implement gradient accumulation. JEPA's reference recipe uses large batches instead. If you need accumulation (or mixed precision), train withjepa_objective(§5). Its EMA advances once per committed update.-
Resume-from-checkpoint restores the EMA target encoder too.
jepa_train_step_factoryreturns aJEPATrainStep, a checkpointable component namedjepa.target_encoder(Concepts §14.1). Every checkpoint's training state carries the target encoder's weights. A stateful warm resume puts them back, frozen and in eval mode.Four epochs and a 2 + 2 split with
resume_from_run_idend with identical encoder, predictor, target encoder, optimizer and scheduler state. Build the resumed session's step with the factory as usual; the restore overwrites the freshly copied target.The component is required. A current checkpoint written without it fails before anything is restored. A checkpoint written before component state existed resumes with a fresh target copy and a warning.
5. Objective mode¶
jepa_objective(target_encoder, predictor, mask_fn, ema_momentum=...)
describes the same latent-prediction loss as an objective
(Concepts §6.5):
from nnx import jepa_objective
objective = jepa_objective(target_encoder, predictor, mask_fn, ema_momentum=0.996)
model.train(params=NNTrainParams(..., optim=NNOptimParams(..., accumulate_grad_batches=4)), objective=objective)
-
Loss. One
"latent_mse"term per microbatch. It is the sum of squared errors between the predicted and target embeddings at the masked positions. The sum is normalized by the number of those elements (rows × target patches × embedding width). Microbatches with different target masks therefore combine as(sum_1 + sum_2) / (count_1 + count_2). For the same masks (a fixedmask_fn), uneven microbatches give exactly the full-batch update; a randommask_fndraws new masks per microbatch.The record carries the loss and
metrics={"latent_mse": ...}, with no classification fields. Masks are checked before any forward pass. They must be complementaryBoolTensor[n_patches]with at least one context and one target patch. -
Update. The shared engine owns backward, accumulation, mixed precision, clipping and the optimizer step, with the objective's non-finite policy (
nonfinite="fail"or"skip"). -
EMA, once per committed update. The target advances in the objective's
after_updatehook. This runs after the window's step, from the updated online weights, and before callbacks see the update event. It never runs per microbatch and never for a skipped window.ema_momentumis fixed in[0, 1).ema_updatescounts this run's EMA steps and continues across a stateful resume. ATrainerwith separate encoder and predictor optimizers still advances it once per committed update. -
Ownership, checked before any run is reserved. These rules apply:
- The predictor must be a submodule of
model.netwhose trainable parameters the run's optimizers own exactly once. - The target encoder must stay outside
model.net, share none of its parameters and be owned by no optimizer. - Every target parameter needs a same-named, same-shaped online one.
- Callbacks that change the net's topology (
checkpoint_transforms, e.g. QAT) are refused. The imperative step passed asobjective=is refused too.
A callback that renames the net's parameters without declaring it is not detected. An example is
torch.nn.utils.prune, which turnsweightintoweight_orig.update_emathen raises aKeyErrornaming the parameter at the next committed update. - The predictor must be a submodule of
-
Checkpoints and resume.
JEPAObjectiveis a checkpointable component namedjepa.objective. It holds the spec (loss, momentum), the predictor's reference, the EMA target's weights and the EMA counter. The reference is the predictor's name insidemodel.net; its weights are saved once, with the net, in LAST and inModelCheckpointsnapshots.Two epochs and one epoch plus a stateful resume end in the same encoder, predictor, target and counter. A saved state with a second predictor payload, another predictor reference or another spec is rejected before anything is restored.
examples/16_ijepa_image_plumbing.py --objective trains the demo this
way, and its bounded objective_mode() helper checks one EMA step per
committed update.