Skip to content

← Flax NNX step 45 of 100

Hard Primitives

NNX Vision Transformer

Jump to the task

Why this matters

A vision transformer treats an image as a sequence of patch vectors. Attention lets each patch combine information from other patches. For classification, this exercise averages the final patch vectors and passes that average to a linear head.

You will build a small, freshly initialized model and return its logits. There is no training or checkpoint loading in this task. The model uses ReLU feed-forward layers and zero-initialized position embeddings; these choices define this exercise rather than a complete reproduction of a particular pretrained ViT.

Turn patches into vectors

Input image has shape (height, width, channels), without a batch axis. Use nnx.Conv with kernel size (P, P), stride P, and padding="VALID" to produce D features per patch.

This applies the same learned affine projection to every full patch. The kernel covers a patch's spatial positions and channels, while its output axis selects the feature. The convolution does not flip the kernel. Its output has shape (height // P, width // P, D). Incomplete patches at the bottom and right edges are dropped.

Here is a complete example with known weights. Each patch becomes its sum and its negated sum:

import jax.numpy as jnp
import numpy as np
from flax import nnx

image = jnp.arange(1., 9.).reshape(2, 4, 1)
conv = nnx.Conv(1, 2, kernel_size=(2, 2), strides=2,
                padding="VALID", rngs=nnx.Rngs(0))
conv.kernel[...] = jnp.broadcast_to(
    jnp.array([1., -1.]), (2, 2, 1, 2)
)
conv.bias[...] = jnp.zeros(2)
patches = conv(image)
sequence = patches.reshape(-1, 2)
np.testing.assert_allclose(sequence, [[14., -14.], [22., -22.]])
np.testing.assert_allclose(sequence.mean(axis=0), [18., -18.])

The full model reshapes the patch grid in the same row-major order, giving (N, D) where N = (height // P) * (width // P). Keep the D feature values of each patch together.

Add positions, then mix patches

Create pos_embed = nnx.Param(jnp.zeros((N, D))) and add pos_embed[...] to the patch sequence. Each row is a trainable position vector. At initialization all rows are zero, so they provide no location information yet. Training can make the rows different.

Each encoder block has two pre-normalized residual branches:

  1. x = x + attention(ln1(x)).
  2. x = x + ff2(jax.nn.relu(ff1(ln2(x)))).

Attention uses separate D-to-D query, key, value and output linear projections, all with biases. Split queries, keys and values into num_heads heads of width Dh = D // num_heads. Within each head, compute softmax(Q @ K.T / sqrt(Dh), axis=-1) @ V. Softmax runs over keys. Concatenate head outputs in their original order, then apply the output projection. Every patch can attend to every patch; there is no causal mask or dropout.

The feed-forward layers have widths D -> F -> D, with ReLU only between them. Both LayerNorms normalize the D features of each row, using epsilon=1e-6 and default trainable scale and bias.

Pool and classify

Apply a final LayerNorm to each patch row, then compute pooled = jnp.mean(x, axis=0). This averages patches and retains the D features. Finally, use nnx.Linear(D, K) to produce K logits. Return the logits directly, without softmax.

Normalize before pooling: LayerNorm is nonlinear, so normalizing the pooled vector generally gives a different result. Do not use the first patch as the classifier input; this model has no class token.

Without position information, self-attention reorders its outputs when its input rows are reordered. Averaging removes that ordering. Consequently, with the initial zero position embeddings, rearranging whole patches does not change this model's logits. The model still uses the arrangement of pixels within each patch through the convolution kernel. It is not free of architectural assumptions.

Problem

Write vit_forward(seed, image, patch_size, d_model, num_heads, d_ff, num_layers, num_classes) with NNX modules.

Cast the seed and size arguments to Python integers. Let P, D, F, L, and K denote patch size, model width, feed-forward width, block count, and class count. Assume positive sizes, L >= 0, P no larger than either image dimension, and D divisible by the positive head count.

Use one nnx.Rngs(int(seed)) throughout construction. Create these components in order to reproduce the initialized model:

  1. Patch convolution from image channels to D features.
  2. Zero position array of shape (N, D), wrapped in nnx.Param.
  3. L distinct encoder blocks in nnx.List. Within each block, create ln1, query projection, key projection, value projection, attention output projection, ln2, ff1, then ff2.
  4. Final LayerNorm with epsilon=1e-6, then the D-to-K head.

Use default NNX convolution and linear initializers, with biases enabled. Read parameter arrays with parameter[...]. Return a one-dimensional array of shape (K,).

With zero blocks, the model still embeds patches, normalizes each row, averages them and applies the head. Its output can depend on the image even without attention.

The diagram below compares allowed attention connections. This model uses the bidirectional pattern; the diagram does not show learned weights or this model's exact sequence length.

Loading visualization…