Learn image representations with I-JEPA
The earlier notebooks train models that produce something: an image or text. This one trains an encoder whose only product is a vector for each image, and the goal is that similar images get similar vectors. We use I-JEPA (Assran et al., 2023). The encoder sees part of an image and has to predict the encoder’s own embeddings of the hidden parts. It never reconstructs pixels, and it needs no hand-made augmentations such as crops and colour jitter.
After training we check the vectors in three ways: a linear probe and a k-nearest-neighbour probe that try to read the flower species out of them, and a look at each query image’s nearest neighbours.
The notebook expects one NVIDIA GPU.
Install
Section titled “Install”%pip install -q "dew-ml[cuda13,streaming,interop] @ git+https://github.com/AshishKumar4/dew"Settings
Section titled “Settings”The images are 64x64 and the encoder cuts them into 8x8 patches, an 8 by 8 grid of 64 tokens. NUM_TARGET_BLOCKS and BLOCK_SCALE set the hidden regions: four blocks, each covering 15 to 20% of the image. The flower labels run from 1 to 102, so the probes use 103 classes.
import os
IMAGE_SIZE = 64PATCH_SIZE = 8GRID = (IMAGE_SIZE // PATCH_SIZE, IMAGE_SIZE // PATCH_SIZE)BATCH_SIZE = 128STEPS = 4000LEARNING_RATE = 5e-4EMB_FEATURES = 192NUM_LAYERS = 6NUM_HEADS = 3NUM_TARGET_BLOCKS = 4BLOCK_SCALE = (0.15, 0.2)CLASSES = 103DATA_FILE = "data/06-flowers.parquet"RUN_DIR = "runs/06-jepa"SEED = 0
# The tutorial test sets DEW_TUTORIAL_SMOKE=1 to run every cell in minutes on a CPU.if os.environ.get("DEW_TUTORIAL_SMOKE") == "1": BATCH_SIZE, STEPS = 16, 4from pathlib import Path
import jaximport matplotlib.pyplot as pltimport numpy as np
print(jax.devices())The data
Section titled “The data”We use the Hugging Face copy of Oxford Flowers again, this time for its species labels. That copy is sorted by species, and Dew holds out validation images from the head of the dataset, so the held-out set would be only two or three species. We shuffle the dataset once and save a local Parquet copy; HFImages reads it through the parquet loader of datasets.
augmentation="none" turns off the random flips: in I-JEPA the variation comes from the masks. val_batches=8 holds out the first 1,024 shuffled images. The encoder never trains on them, and the probes score only them.
import datasets
Path(DATA_FILE).parent.mkdir(parents=True, exist_ok=True)flowers = datasets.load_dataset("pranked03/flowers-blip-captions", split="train")flowers.shuffle(seed=SEED).to_parquet(DATA_FILE)print(flowers)from dew.data import DataPartition, HFImages, HFOptions, Loading
data = HFImages( name="parquet", options=HFOptions(data_files=DATA_FILE), image_size=IMAGE_SIZE, augmentation="none", val_batches=8, loading=Loading(workers=0, threads=16, read_buffer=64),).load(batch=BATCH_SIZE)
val_labels = np.concatenate([batch["label"] for batch in data.val(DataPartition())])print("training images:", data.records, "| held-out images:", len(val_labels), "| species in the held-out set:", len(np.unique(val_labels)))The mask
Section titled “The mask”Each training image gets NUM_TARGET_BLOCKS rectangles of patches to predict, with a random size and aspect ratio, and the context the encoder sees is a random subset of the remaining patches. multi_block_mask works out the block sizes once for the grid, so every mask has the same number of tokens and the training step compiles once.
Below is one sampled mask: . is context the encoder sees, # is a patch it has to predict, and - is dropped.
from dew.objectives.jepa import multi_block_mask
mask = multi_block_mask(GRID, num_targets=NUM_TARGET_BLOCKS, scale=BLOCK_SCALE)print("context tokens:", mask.num_context, "| target blocks:", mask.num_targets, "of", mask.block_area, "tokens each")
context_idx, target_idx = mask.sample(jax.random.key(SEED), 1)view = np.full(GRID[0] * GRID[1], "-")view[np.asarray(context_idx[0])] = "."view[np.asarray(target_idx).reshape(-1)] = "#"print("\n".join(" ".join(row) for row in view.reshape(GRID)))Encoder, predictor and objective
Section titled “Encoder, predictor and objective”I-JEPA has three networks:
- the context encoder, a ViT that sees only the context patches;
- the target encoder, which sees the whole image and produces the embeddings to predict;
- the predictor, a narrower transformer that takes the context embeddings plus the positions of the hidden patches and guesses the target encoder’s embeddings there.
The target encoder is not trained. It is an exponential moving average of the context encoder, so the targets improve as the encoder improves. JepaObjective puts the three together and asks the trainer to keep that average, which grows from a momentum of 0.996 to 1 over momentum_steps.
The loss is the squared distance between the predicted and the target embeddings. The objective also reports repr_std, the spread of the embeddings across a batch. If it falls towards zero the encoder is giving every image the same vector, which is called collapse.
from dew import Field, modelsfrom dew.objectives.jepa import JepaObjective
encoder = models.build("jepa_encoder", patch_size=PATCH_SIZE, emb_features=EMB_FEATURES, num_layers=NUM_LAYERS, num_heads=NUM_HEADS, dtype="bfloat16", attention_impl="auto")predictor = models.build("jepa_predictor", grid=GRID, emb_features=EMB_FEATURES, predictor_features=EMB_FEATURES // 2, num_layers=NUM_LAYERS // 2, num_heads=NUM_HEADS, dtype="bfloat16", attention_impl="auto")objective = JepaObjective(encoder, predictor, mask=mask, sample=Field("image", (IMAGE_SIZE, IMAGE_SIZE, 3)), momentum_steps=STEPS)
variables = jax.eval_shape(objective.init, jax.random.key(0))for name, tree in variables["params"].items(): print(f"{name}: {sum(x.size for x in jax.tree_util.tree_leaves(tree)) / 1e6:.2f}M parameters")Training
Section titled “Training”import optaxfrom dew import Checkpoints, LocalTracker, Trainer
trainer = Trainer(objective, optax.adamw(LEARNING_RATE), key=jax.random.key(SEED), checkpoints=Checkpoints(RUN_DIR), tracker=LocalTracker(f"{RUN_DIR}/tracking"))state = trainer.fit(data, steps=STEPS, log_every=500, checkpoint_every=STEPS)import json
rows = [json.loads(line) for line in open(f"{RUN_DIR}/tracking/scalars.jsonl")]rows = [row for row in rows if "train/loss" in row["scalars"]]steps = [row["step"] for row in rows]figure, (left, right) = plt.subplots(1, 2, figsize=(10, 3))left.plot(steps, [row["scalars"]["train/loss"] for row in rows], marker=".")left.set(xlabel="step", title="loss")right.plot(steps, [row["scalars"]["train/repr_std"] for row in rows], marker=".")right.set(xlabel="step", title="repr_std")plt.show()Probes on the held-out images
Section titled “Probes on the held-out images”objective.evaluate embeds a batch with the target encoder (the EMA weights) and averages each image’s patch embeddings into one vector. We collect those vectors for all 1,024 held-out images.
Each probe fits on half of the vectors and scores the other half. The linear probe is logistic regression; the k-NN probe gives each image the majority species of its 20 nearest neighbours. Both scores mean little alone, because a probe with more dimensions than images can fit almost anything. So we also run both probes with the labels shuffled. The gap between the real and the shuffled score is how much species information the vectors carry. Chance on 102 species is about 1%.
from dew.objectives.base import Stepfrom dew.objectives.jepa import representation_healthfrom dew.objectives.jepa.probes import knn_probe_accuracy, linear_probe_accuracy
features, labels, held_out = [], [], []for batch in data.val(DataPartition()): scored = objective.evaluate(state.params, batch, Step(step=state.step, key=jax.random.key(1), ema=state.averaged)) features.append(np.asarray(scored.features)) labels.append(np.asarray(scored.labels)) held_out.append(batch["image"])features, labels, held_out = np.concatenate(features), np.concatenate(labels), np.concatenate(held_out)print("held-out embeddings:", features.shape)
health = representation_health(features)print(f"repr_std {float(health['repr_std']):.3f} | repr_cov_offdiag {float(health['repr_cov_offdiag']):.4f}")
shuffled = np.random.default_rng(0).permutation(labels)for name, probe in (("linear probe", linear_probe_accuracy), ("k-NN probe", knn_probe_accuracy)): real = float(probe(features, labels, CLASSES)) control = float(probe(features, shuffled, CLASSES)) print(f"{name}: {real:.3f} with the real labels, {control:.3f} with shuffled labels")Nearest neighbours
Section titled “Nearest neighbours”The probes give one number each. We can also look at the vectors directly. Each row below is one held-out query image (left) and its five nearest held-out images by cosine similarity, with their species labels.
normalised = features / np.linalg.norm(features, axis=-1, keepdims=True)queries = [0, 1, 2, 3, 4, 5]figure, axes = plt.subplots(len(queries), 6, figsize=(7, len(queries) * 1.25))for row, query in enumerate(queries): neighbours = np.argsort(-(normalised @ normalised[query]))[1:6] for column, index in enumerate([query, *neighbours]): axis = axes[row, column] axis.imshow(held_out[index]) axis.set_title(f"{'query ' if column == 0 else ''}{labels[index]}", fontsize=7) axis.axis("off")plt.tight_layout()plt.show()Keeping the encoder
Section titled “Keeping the encoder”What we keep from a JEPA run is the EMA copy of the context encoder, without the predictor. save_params writes it as a safetensors file and load_params reads it back. objective.encode embeds images in [-1, 1] with a given set of encoder weights.
from dew.interop import load_params, save_params
encoder_params = state.averaged["params"]["context_encoder"]save_params(encoder_params, f"{RUN_DIR}/encoder.safetensors")reloaded = load_params(f"{RUN_DIR}/encoder.safetensors")again = objective.encode(reloaded, held_out[:8].astype(np.float32) / 127.5 - 1)print("reloaded encoder output:", np.asarray(again).shape)Where to go next
Section titled “Where to go next”The I-JEPA paper trains a ViT-H/16 for 300 epochs on ImageNet; the knobs are the ones in the settings cell. recipes/jepa/train.py runs the same objective from the command line, and the jepa_video_encoder model with a factorized predictor does the same job on video clips.