E2E: Tiny Vision Transformer - End-to-End Implementations
Implement E2E: Tiny Vision Transformer in Python with NumPy. hard exercise with examples, hints, and instant feedback. No setup or signup required.
End-to-End Tiny Vision Transformer (ViT)
What you'll build
TinyViT: a Vision Transformer classifier with one pre-norm encoder block, including patch extraction, the [CLS] token, position embeddings, the loss and a training step.
Background
A Vision Transformer (Dosovitskiy et al., 2021) treats an image as a sequence of patches: cut it into P × P tiles, embed each tile linearly, prepend a learnable [CLS] token, add position embeddings, run a Transformer encoder, and classify from the final CLS token. With no convolutions, everything the model knows about 2-D layout comes from the patch order and the position embeddings.
Architecture
x (B, C, H, W)
│ patchify (B, N, P*P*C) N = (H/P) * (W/P)
│ @ W_patch + b_patch (B, N, D)
│ prepend CLS token (B, N+1, D)
│ + pos_emb (N+1, D) (only if use_pos_emb)
▼
t = t + MHA(LN1(t)) pre-norm attention with residual
t = t + MLP(LN2(t)) MLP: Linear(D, mlp_dim) -> GELU -> Linear(mlp_dim, D)
▼
cls = LN_f(t[:, 0]) (B, D) <- cls_features(x)
logits = cls @ W_head + b_head (B, num_classes)
Details
- Patch order: patches in row-major order over the
(H/P, W/P)grid; inside a patch, flatten as(p1, p2, c), i.e. einops'b c (h p1) (w p2) -> b (h w) (p1 p2 c)'. This matches a stride-Pconvolution. - MHA:
qkv = h @ W_qkv(no bias) split intoQ, K, Vof widthD; reshape tonum_headsheads of sizeD / num_heads;softmax(Q K^T / sqrt(d_head)) V; merge heads; output projection@ W_o(no bias). No mask: every token attends to every token. - LayerNorm: over the last dimension,
eps = 1e-5, with learnable gain (init 1) and bias (init 0). - GELU: tanh approximation,
0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3))). - Initialization: weights
~ N(0, 1/fan_in), biases 0, CLS token and position embeddings~ N(0, 0.02^2).
Why position embeddings matter
Self-attention is permutation-equivariant, and the CLS token attends to all patches with the same weights regardless of where they are. So without position embeddings, shuffling the patches of an image leaves the CLS output (and the prediction) unchanged. Position embeddings are the only source of spatial order.
Method contract
| Method | Returns |
|---|---|
TinyViT(img_size=8, patch_size=4, in_chans=1, dim=16, num_heads=2, mlp_dim=32, num_classes=2, use_pos_emb=True) | creates all parameters (initialization above) |
patchify(x) | (B, C, H, W) → (B, N, P·P·C) |
cls_features(x) | final-LayerNormed CLS token, (B, D) |
forward(x) | logits (B, num_classes) |
loss(x, y) | float, mean cross-entropy with integer labels y (B,) |
train_step(x, y, lr=0.1) | float, the loss before one plain SGD step |
train_step differs by track:
- NumPy track: update only the classification head (
W_head,b_head); the encoder stays frozen (a linear probe). Gradient:dlogits = (softmax(logits) − onehot(y)) / B,dW_head = clsᵀ @ dlogits,db_head = dlogits.sum(0). - PyTorch track: autograd updates every parameter.
In the NumPy track, parameter names and storage are up to you (the tests only use the methods above); the PyTorch starter names the patch embedding patch_embed.
Constraints
NumPy only, no loops over batch, heads or tokens.
Follow-up
- Attention over N patches costs
O(N²): what happens to the cost if you halve the patch size? - Why do ViTs need more data (or strong augmentation) than CNNs? (They lack the convolutional inductive bias of locality and translation equivariance.)
Examples
Input: TinyViT(img_size=8, patch_size=4, in_chans=1, dim=16, num_heads=2, num_classes=3).forward(np.random.randn(4, 1, 8, 8)) Output: logits of shape (4, 3)
4 patches of 4x4 pixels -> 5 tokens (with CLS) of width 16 -> CLS head.
Input: model.patchify(np.arange(32.).reshape(1, 2, 4, 4)) with patch_size=2 Output: shape (1, 4, 8); first patch [0, 16, 1, 17, 4, 20, 5, 21]
Order (p1, p2, c): pixel (0,0) of both channels, then pixel (0,1), then (1,0), then (1,1).
Practice in Python
import numpy as np
class TinyViT:
def __init__(self, img_size=8, patch_size=4, in_chans=1, dim=16, num_heads=2,
mlp_dim=32, num_classes=2, use_pos_emb=True):
"""
Create all parameters (weights ~ N(0, 1/fan_in), biases 0,
CLS token and position embeddings ~ N(0, 0.02^2), LayerNorm gains 1).
"""
self.patch_size = patch_size
self.num_heads = num_heads
self.use_pos_emb = use_pos_emb
# Your code here
def patchify(self, x):
"""(B, C, H, W) -> (B, N, P*P*C), patches row-major, flattened as (p1, p2, c)."""
# Your code here
pass
def cls_features(self, x):
"""Embed patches, prepend CLS, add pos-emb, one pre-norm block, final LN on CLS -> (B, D)."""
# Your code here
pass
def forward(self, x):
"""Logits (B, num_classes)."""
# Your code here
pass
def loss(self, x, y):
"""Mean cross-entropy (float)."""
# Your code here
pass
def train_step(self, x, y, lr=0.1):
"""One SGD step on the head (W_head, b_head); returns the loss before the step."""
# Your code here
pass
The interactive editor loads with Python and NumPy. Run your implementation against 7 tests, or use the progressive hints.