Week 3 was a Vision Transformer built from scratch. The whole idea fits in one move at the very start, and you can see it working on a model small enough to train while this page loads.
Author
Rosh Beed
Published
September 18, 2026
Week 3: take the transformer, which was designed for text, and point it at images. No convolutions.
The awkward part is that a transformer eats a sequence of tokens, and an image isn’t one. It’s a grid of pixels with no natural order and far too many of them — 28×28 is 784 pixels, and attention costs grow with the square of the sequence length. On a real photograph you’d be computing attention over hundreds of thousands of positions.
The Vision Transformer paper [1] solves this in one move, in the first layer, and everything after it is the ordinary transformer with nothing changed: cut the image into fixed-size squares and treat each square as a word.
Show the code
import syssys.path.insert(0, "..")import numpy as npimport torchimport torch.nn as nnfrom huggingface_hub import hf_hub_downloadREVISION ="f705fed08827ff6c36e3b5329495c943a5e544e8"path = hf_hub_download("roshbeed/ai-residency-blog-data", "mnist/mnist-small.npz", repo_type="dataset", revision=REVISION)data = np.load(path)x_train = torch.from_numpy(data["x_train"]).float() /255.0y_train = torch.from_numpy(data["y_train"]).long()x_test = torch.from_numpy(data["x_test"]).float() /255.0y_test = torch.from_numpy(data["y_test"]).long()PATCH =7PATCHES = (28// PATCH) **2def patchify(images):"""28x28 -> 16 patches of 7x7, flattened. This is the whole trick.""" batch = images.shape[0] tiles = images.unfold(1, PATCH, PATCH).unfold(2, PATCH, PATCH)return tiles.reshape(batch, PATCHES, PATCH * PATCH)baseline = torch.bincount(y_test).max().item() /len(y_test)print(f"{len(x_train):,} training digits, {len(x_test):,} test digits")print(f"each image becomes {PATCHES} tokens of {PATCH * PATCH} numbers")print(f"always guessing the most common digit: {baseline:.4f}")
6,000 training digits, 1,500 test digits
each image becomes 16 tokens of 49 numbers
always guessing the most common digit: 0.1173
Show the code
import matplotlib.pyplot as pltfrom _style import COLOURS, MUTEDdigit = x_train[1]tiles = patchify(digit.unsqueeze(0))[0].reshape(4, 4, PATCH, PATCH)fig = plt.figure(figsize=(7.2, 3.6))outer = fig.add_gridspec(1, 2, wspace=0.15)whole = fig.add_subplot(outer[0])whole.imshow(digit, cmap="gray_r")whole.set_title("the image", fontsize=10, color=MUTED, pad=8)whole.axis("off")inner = outer[1].subgridspec(4, 4, wspace=0.18, hspace=0.18)for r inrange(4):for c inrange(4): ax = fig.add_subplot(inner[r, c]) ax.imshow(tiles[r, c], cmap="gray_r", vmin=0, vmax=1) ax.set_xticks([]); ax.set_yticks([])for s in ax.spines.values(): s.set_color("#cfd4da")if r ==0and c ==1: ax.set_title("sixteen tokens", fontsize=10, color=MUTED, pad=8, loc="left")
Figure 1: One digit as the model sees it: sixteen 7x7 patches, which become sixteen tokens in a sequence.
Each patch gets projected to a vector by a single Linear, and from there the model has a sequence of 16 vectors — structurally identical to a sentence of 16 words. A [CLS] token is stuck on the front, exactly as BERT does, and its final representation is what the classifier reads.
The project version wrote the attention, the encoder block and the positional encodings by hand, with no torch.nn.Transformer anywhere. Here I use the built-in attention, because this post is about the patching idea rather than the arithmetic inside a head.
Drawn out, with the shapes the tensors actually take:
Show the code
from _arch import diagramdiagram(VisionTransformer(), input_shape=(1, 28, 28), style="flow")
Figure 2: The whole model, left to right. Each patch is projected to 64 numbers, two identical encoder blocks run over the 17 tokens, and the head reads position 0. The tall blue-and-green pairs are the feed-forward expansion to 256 inside each block; the outlines spanning the top are the residual connections.
Show the code
def train(use_positions, epochs=15, batch=128, seed=0): torch.manual_seed(seed) model = VisionTransformer(use_positions) optimiser = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=0.01) generator = torch.Generator().manual_seed(seed) accuracies = []for _ inrange(epochs): perm = torch.randperm(len(x_train), generator=generator)for i inrange(0, len(perm) - batch, batch): b = perm[i:i + batch] loss = nn.functional.cross_entropy(model(x_train[b])[0], y_train[b]) optimiser.zero_grad() loss.backward() optimiser.step()with torch.no_grad(): accuracies.append((model(x_test)[0].argmax(1) == y_test).float().mean().item())return model, accuraciesmodel, with_positions = train(use_positions=True)print(f"{sum(p.numel() for p in model.parameters()):,} parameters")print(f"best test accuracy: {max(with_positions):.4f} (baseline {baseline:.4f})")
104,970 parameters
best test accuracy: 0.9393 (baseline 0.1173)
About a hundred thousand parameters, six thousand training images, and a few seconds. The real project got 91.78% on the full MNIST test set after one epoch; this gets to roughly the same place on a fiftieth of the data.
What the position embeddings are for
Self-attention has no idea where anything is. It computes how much each token should attend to each other token, and that calculation is permutation invariant — shuffle the tokens and you get the same set of outputs back, shuffled. Without something to break that symmetry, a Vision Transformer sees a bag of patches, not a picture.
That is what the learned position embeddings do, and you can measure exactly what they are worth by removing them.
Show the code
_, without_positions = train(use_positions=False)print(f"with position embeddings: {max(with_positions):.4f}")print(f"without position embeddings: {max(without_positions):.4f}")print(f"difference: {max(with_positions) -max(without_positions):+.4f}")
with position embeddings: 0.9393
without position embeddings: 0.8693
difference: +0.0700
Figure 3: The same model trained with and without position embeddings. Without them the model can still recognise which patches are present, just not where.
It still works without them, which surprised me at first — and then made sense. A bag of 7×7 patches carries a lot about which digit it is, because a 0 and a 1 contain visibly different patches regardless of arrangement. The position embeddings are worth the last several points, which is where the distinctions that depend on layout live.
What the CLS token looks at
Since the classifier reads only position 0, the attention weights out of that position say which patches the model is actually using.
Figure 4: Attention from the [CLS] token in the final block, for four test digits. Brighter patches are the ones the classification actually rests on.
The attention concentrates on the patches with ink in them and largely ignores the empty corners, which is the behaviour you’d want and nobody specified. Nothing in the loss says “look at the strokes” — it falls out of training the classifier.
I’d be careful about reading much more than that into it. Attention maps are suggestive, not explanatory, and with two layers and sixteen patches this one is very coarse.
The part that transfers
The reason this architecture mattered isn’t MNIST accuracy — a small CNN beats it easily at this scale, and the ViT paper is explicit that transformers only overtake convolutional networks with a lot of data behind them.
It’s that once an image is a sequence of tokens, it is the same kind of object as a sentence. That’s what makes it possible to feed images and text to one model, which is where week 4 goes.
The full project, with the attention and encoder blocks written from scratch, is on GitHub.
[1] Dosovitskiy et al. An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale. ICLR 2021.