TAI BUI
Back to curriculum
BuildPython

Vision Transformers (ViT)

Slice the image into patches, treat each patch as a word, and run a standard transformer on the sequence. No convolutions required.

TIME: ~45 minutesPREREQUISITES: Phase 7 Lesson 02 (Self-Attention), Phase 4 Lesson 04 (Image Classification)

Learning Objectives

  • Implement patch embedding, learned positional embedding, class token, and transformer encoder blocks from scratch to build a minimal ViT
  • Explain why ViT was thought to need massive pretraining data until DeiT and MAE proved otherwise
  • Compare ViT, Swin, and ConvNeXt on their architectural priors (none, local window attention, conv backbone)
  • Fine-tune a pretrained ViT on a small dataset using `timm` and the standard linear-probe / fine-tune recipe

The Problem

For a decade, convolution was computer vision. CNNs came with strong inductive biases built in, locality and translation equivariance, and nobody thought you could do without them. Then Dosovitskiy et al. (2020) showed that a plain transformer applied to flattened image patches, with no convolutional machinery at all, could match or beat the best CNNs at scale.

The catch was "at scale." ViT trained on ImageNet-1k alone lost to ResNet. Pretrain it on ImageNet-21k or JFT-300M first, then fine-tune on ImageNet-1k, and it won. The read at the time: transformers lack useful visual priors, but they can learn them given enough data. Subsequent work (DeiT, MAE, DINO) proved that wrong. With the right training recipe (strong augmentation, self-supervised pretraining, distillation), ViTs train fine on small data too.

By 2026, pure CNNs still hold the edge-device niche (ConvNeXt is the strongest), but transformers dominate everything else: segmentation (Mask2Former, SegFormer), detection (DETR, RT-DETR), multimodal (CLIP, SigLIP), video (VideoMAE, VJEPA). The ViT block structure — patch embed, class token, positional embedding, pre-LN transformer block — is the one worth knowing cold.

Warm-up0/3
1. In a transformer's self-attention layer, what does each token do with the other tokens in the sequence?Warm-up
Six word boxes in a row, 'sat' highlighted, with an arrow down to a dashed box with a question mark, captioned how 'sat' combines information from the rest of the sentence.
2. You have only a few thousand labelled photos for a new classification task. Why start from a model pretrained on a much larger dataset?Warm-up
A box labelled pretrained model, trained on a large dataset, an arrow toward a box labelled your task, a few thousand labelled photos, and a dashed question mark box between them.
3. A self-attention layer compares every token with every other token. If the sequence grows from 100 to 200 tokens, roughly how does the number of comparisons change?Warm-up
Two boxes, sequence length 100 tokens and sequence length 200 tokens, each above its own dashed question mark box labelled pairwise comparisons.
Check: The Problem0/1
1. Why did the original ViT need JFT-300M pretraining to beat ResNet, and why does DeiT not?Explain
A box labelled original ViT, pretrains on JFT-300M first, next to a box labelled DeiT, trains on ImageNet-1k alone, above a dashed question mark box asking why DeiT does not need the huge pretraining set.

The Concept

The pipeline

Mermaid diagram

Seven steps. Patches -> tokens -> attention -> classifier. Every variant (DeiT, Swin, ConvNeXt, MAE pretraining) changes one or two of the seven and leaves the rest alone.

Patch embedding

The first conv is the secret. Kernel size 16, stride 16, so a 224x224 image becomes a 14x14 grid of 16x16 patches, each projected to a 768-dim embedding. That single conv both patchifies and linearly projects.

Input:  (3, 224, 224)
Conv (3 -> 768, k=16, s=16, no padding):
Output: (768, 14, 14)
Flatten spatial: (196, 768)

196 patches = 196 tokens. Each token's feature dimension is 768 (ViT-B), 1024 (ViT-L), or 1280 (ViT-H).

Class token

A single learned vector prepended to the sequence:

tokens = [CLS; patch_1; patch_2; ...; patch_196]   shape (197, 768)

After N transformer blocks, the [CLS] output is the global image representation. Classification head reads only this one vector.

Positional embedding

Transformers have no built-in notion of spatial position. Add a learned vector to every token:

tokens = tokens + learned_pos_embedding   (also shape (197, 768))

The embedding is a parameter of the model; gradient-based training adapts it to 2D image structure. Sinusoidal 2D alternatives exist but are rarely used in practice.

Transformer encoder block

Standard. Multi-head self-attention, MLP, residual connections, pre-LayerNorm.

x = x + MSA(LN(x))
x = x + MLP(LN(x))

MLP is two-layer with GELU: Linear(d -> 4d) -> GELU -> Linear(4d -> d)

ViT-B/16 stacks 12 of these blocks, each with 12 attention heads, totalling 86M parameters.

Why pre-LN

Early transformers used post-LN (x = LN(x + sublayer(x))) and struggled to train past 6-8 layers without warmup. Pre-LN (x = x + sublayer(LN(x))) trains deeper networks stably without warmup. Every ViT and every modern LLM uses pre-LN.

Patch size trade-off

  • 16x16 patches -> 196 tokens, standard.
  • 32x32 patches -> 49 tokens, faster but lower resolution.
  • 8x8 patches -> 784 tokens, finer but O(n^2) attention cost scales badly.

Bigger patches = fewer tokens = faster but less spatial detail. SwinV2 uses 4x4 patches in hierarchical windows.

DeiT's recipe for training ViT on ImageNet-1k

The original ViT needed JFT-300M to beat CNNs. DeiT (Touvron et al., 2020) trained ViT-B to 81.8% top-1 on ImageNet-1k alone with four changes:

  1. Heavy augmentation: RandAugment, Mixup, CutMix, Random Erasing.
  2. Stochastic depth (drop entire blocks at random during training).
  3. Repeated augmentation (same image sampled 3 times per batch).
  4. Distillation from a CNN teacher (optional, lifts accuracy further).

Every modern ViT training recipe descends from DeiT.

Swin vs ConvNeXt

  • Swin (Liu et al., 2021): window-based attention. Each block attends within a local window; alternating blocks shift the window to mix information across windows, bringing back a CNN-like locality prior without giving up the attention operator.
  • ConvNeXt (Liu et al., 2022): a redesigned CNN matching Swin's architecture choices, borrowing depthwise convolutions and inverted bottlenecks from the MobileNet/EfficientNet lineage plus LayerNorm and GELU from transformers. It showed the gap was never "attention vs convolution": it was modern training recipe plus architecture.

In 2026, ConvNeXt-V2 and Swin-V2 are both production-grade; the right choice depends on your inference stack (ConvNeXt compiles better for edge) and pretraining corpus.

MAE pretraining

Masked Autoencoder (He et al., 2022): mask 75% of patches at random, train the encoder to process only the visible 25%, train a small decoder to reconstruct the masked patches from the encoder's output. After pretraining, discard the decoder and fine-tune the encoder.

MAE makes ViT trainable on ImageNet-1k alone, hits SOTA, and is the current default self-supervised recipe.

Check: The Concept0/20
1. What is patch embedding in a ViT?Define
A 224x224 image drawn as a 14x14 grid of 16x16 patches, an arrow to a box reading token embeddings, 196 tokens, dim 768, and a dashed question mark box asking how each patch becomes one.
2. What is the class token in a ViT?Define
A token sequence starting with a highlighted box labelled CLS followed by patch tokens p1 through p5 and an ellipsis, noted as 197 tokens total, with a dashed question mark box asking what the CLS output is used for after the last block.
3. What is a ViT's positional embedding?Define
A box labelled tokens with shape 197 by 768, a plus sign, a box labelled pos_embed with the same shape, an equals sign, a box labelled encoder input, and a line asking what kind of thing pos_embed is.
4. What does pre-LN mean in a transformer block?Define
A transformer sublayer drawn with its residual skip and a plus for the sum, an empty dashed slot A on the sublayer's input and an empty dashed slot B after the sum, and a line asking which slot pre-LN means.
5. What is multi-head attention?Define
A box labelled attention block, num_heads = 12, an arrow to a dashed question mark box, with text asking how the 12 heads interact.
6. What does the name ViT-B/16 specify?Define
The name ViT dash B slash 16 broken into three parts, with the B and the 16 each replaced by a dashed question mark box asking what that part specifies.
7. What is DeiT?Define
A box labelled original ViT, needs JFT-300M pretraining, next to a dashed question mark box asking what is different about DeiT.
8. What is MAE (Masked Autoencoder) pretraining?Define
A full patch grid, a dashed question mark box labelled mask some share, an arrow to an encoder box noted as seeing the visible patches, then a decoder box, with a question asking whether the decoder is kept or thrown away after pretraining.
9. Besides heavy augmentation, which changes made up DeiT's recipe for training ViT-B on ImageNet-1k alone?Define
A box labelled heavy augmentation, given in the stem, above three dashed question mark boxes standing for the other three unnamed changes in DeiT's recipe.
10. A ViT is built with no positional embedding at all. What is the core problem?Explain
A 3x3 grid of plain patch tiles with an arrow to a dashed question mark box, under a note that this ViT is built without the line that adds a positional embedding, asking what the core problem is.
11. Why does the patch-embedding conv use a stride equal to its kernel size?Explain
A 224x224 image square with the conv kernel's first 16x16 position highlighted in the top-left corner at true relative scale, a card listing the input size, kernel size and stride, and a dashed question mark box asking why the stride equals the kernel size.
12. What happens to the token count and cost as ViT patch size shrinks from 32x32 to 16x16 to 8x8 on the same image?Explain
Three boxes labelled 32x32 patches, 16x16 patches and 8x8 patches, each above its own dashed question mark box, with a caption asking for the token count at each size and which way attention cost moves.
13. Pre-LayerNorm (x + sublayer(LN(x))) versus post-LayerNorm (LN(x + sublayer(x))): which do modern transformers use, and why?Explain
A code box showing the pre-LN formula x = x + sublayer(LN(x)) and the post-LN formula x = LN(x + sublayer(x)), above a dashed question mark box asking which one modern transformers use and why.
14. What does Swin's window-based attention do?Explain
A feature map drawn as a 4x4 arrangement of windows for one Swin block, next to a dashed question mark box asking what Swin's attention does with those windows.
15. What does ConvNeXt demonstrate about ViT, Swin, and CNNs?Explain
A box labelled Swin, attention, local windows, next to a box labelled ConvNeXt, conv, same recipe and design choices, above a dashed question mark box asking how their results compare.
16. A ViT uses 16x16 patches on a 112x112 image. How many tokens does the transformer process, counting the class token?Apply
A 112x112 image square with a single 16x16 patch highlighted in the top-left corner to scale, a setup card listing the image size, the patch size and the one prepended class token, and a dashed question mark box asking how many tokens reach the transformer.
17. Switching a ViT from 16x16 to 8x8 patches on a 224x224 image makes training far slower, though the patch conv is tiny. What explains this?Analyze
The same 224x224 image drawn twice, tiled into 16x16 patches on the left and into 8x8 patches on the right, with a note that the patch conv itself stays tiny either way and a question asking why training gets far slower.
18. A team fine-tunes a checkpoint trained at 224x224 with 16x16 patches on 384x384 images using their own loader. It loads, but accuracy is poor. What is the most likely cause?Analyze
A box labelled checkpoint, trained at 224x224 with patch 16, an arrow to a box labelled fine-tuning run, 384x384 images with patch 16, a note that the team's own loader raised no error but accuracy is poor, and a dashed question mark box asking for the most likely cause.
19. You have a large pile of unlabelled images and want a strong ViT without a huge labelled set. Which plan fits?Design
A box labelled unlabelled images, a large pile, no labels, next to a box labelled goal, strong ViT without a huge labelled set, above a dashed question mark box asking which training plan fits.
20. You must ship an image classifier on an edge device with a pretrained backbone and a fixed inference stack. Which backbone family fits best?Design
A box labelled edge device, fixed inference stack, next to a dashed question mark box asking which backbone family fits best.

Build It

Step 1: Patch embedding

python
import torch
import torch.nn as nn
 
class PatchEmbedding(nn.Module):
    def __init__(self, in_channels=3, patch_size=16, dim=192, image_size=64):
        super().__init__()
        assert image_size % patch_size == 0
        self.proj = nn.Conv2d(in_channels, dim, kernel_size=patch_size, stride=patch_size)
        num_patches = (image_size // patch_size) ** 2
        self.num_patches = num_patches
 
    def forward(self, x):
        x = self.proj(x)
        return x.flatten(2).transpose(1, 2)

One conv, one flatten, one transpose. That is the entire image-to-tokens step.

Step 2: Transformer block

Pre-LN, multi-head self-attention, MLP with GELU, residual connections.

python
class Block(nn.Module):
    def __init__(self, dim, num_heads, mlp_ratio=4, dropout=0.0):
        super().__init__()
        self.ln1 = nn.LayerNorm(dim)
        self.attn = nn.MultiheadAttention(dim, num_heads, dropout=dropout, batch_first=True)
        self.ln2 = nn.LayerNorm(dim)
        self.mlp = nn.Sequential(
            nn.Linear(dim, dim * mlp_ratio),
            nn.GELU(),
            nn.Dropout(dropout),
            nn.Linear(dim * mlp_ratio, dim),
            nn.Dropout(dropout),
        )
 
    def forward(self, x):
        a, _ = self.attn(self.ln1(x), self.ln1(x), self.ln1(x), need_weights=False)
        x = x + a
        x = x + self.mlp(self.ln2(x))
        return x

nn.MultiheadAttention handles the splitting into heads, the scaled dot-product, and the output projection. batch_first=True so shapes are (N, seq, dim).

Step 3: The ViT

python
class ViT(nn.Module):
    def __init__(self, image_size=64, patch_size=16, in_channels=3,
                 num_classes=10, dim=192, depth=6, num_heads=3, mlp_ratio=4):
        super().__init__()
        self.patch = PatchEmbedding(in_channels, patch_size, dim, image_size)
        num_patches = self.patch.num_patches
        self.cls_token = nn.Parameter(torch.zeros(1, 1, dim))
        self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, dim))
        self.blocks = nn.ModuleList([
            Block(dim, num_heads, mlp_ratio) for _ in range(depth)
        ])
        self.ln = nn.LayerNorm(dim)
        self.head = nn.Linear(dim, num_classes)
        nn.init.trunc_normal_(self.pos_embed, std=0.02)
        nn.init.trunc_normal_(self.cls_token, std=0.02)
 
    def forward(self, x):
        x = self.patch(x)
        cls = self.cls_token.expand(x.size(0), -1, -1)
        x = torch.cat([cls, x], dim=1)
        x = x + self.pos_embed
        for blk in self.blocks:
            x = blk(x)
        x = self.ln(x[:, 0])
        return self.head(x)
 
vit = ViT(image_size=64, patch_size=16, num_classes=10, dim=192, depth=6, num_heads=3)
x = torch.randn(2, 3, 64, 64)
print(f"output: {vit(x).shape}")
print(f"params: {sum(p.numel() for p in vit.parameters()):,}")

About 2.8M parameters: a tiny ViT, tractable on CPU. Real ViT-B is 86M, same class definition, just dim=768, depth=12, num_heads=12.

Step 4: Sanity check on a single image

python
logits = vit(torch.randn(1, 3, 64, 64))
print(f"logits: {logits}")
print(f"probs:  {logits.softmax(-1)}")

Should run without error. Probabilities sum to 1.

Check: Build It0/4
1. In the tiny ViT, PatchEmbedding uses image_size=64 and patch_size=16 with dim=192. For an input of shape (2, 3, 64, 64), what shape does forward return?Apply
The PatchEmbedding forward method: a conv, then flatten(2), then transpose(1,2), with the final returned shape left as a question mark, for image_size=64, patch_size=16, dim=192.
2. In the tiny ViT forward pass with a batch of 2 images of 64x64, what shape is x right after the class token is concatenated?Apply
Three lines of ViT.forward: the patch embedding on a (2, 3, 64, 64) input, the class token expanded to the batch, and the torch.cat call with its resulting shape left as a question mark, under a note giving image_size 64, patch_size 16 and dim 192.
3. At the end of ViT.forward, the code runs self.ln(x[:, 0]) and passes it to self.head. Which vector is being classified?Apply
The seven lines of ViT.forward in order, from the patch embedding through the concatenation, the positional embedding and the block loop, with the final return line highlighted and a question asking which vector reaches self.head.
4. A colleague writes each block as x = self.ln1(x + a) then x = self.ln2(x + self.mlp(x)), stacks 12 of them, and trains without warmup. Training is unstable. What did they build?Analyze
A code box showing x = self.ln1(x + a) then x = self.ln2(x + self.mlp(x)), stacked 12 times and trained without warmup, above a dashed question mark box asking what pattern this is and whether it is stable at 12 layers.

Use It

timm ships every ViT variant with pretrained ImageNet weights, one line:

python
import timm
 
model = timm.create_model("vit_base_patch16_224", pretrained=True, num_classes=10)

timm is the production default for vision transformers in 2026: ViT, DeiT, Swin, Swin-V2, ConvNeXt, ConvNeXt-V2, MaxViT, MViT, EfficientFormer, and dozens more, all under the same API.

The standard recipe for a small dataset is two phases, in order. First, freeze the pretrained backbone and train only the new head (a linear probe) for a few epochs, so the classifier calibrates to your classes without disturbing the pretrained features. Second, unfreeze the whole network and fine-tune end to end at a learning rate 10-100x smaller than the probe phase used, letting the backbone adjust slightly to your data. Skip straight to full fine-tuning with a freshly initialized head, and the large, random early gradients from that head flow back through the whole network and degrade the pretrained weights before it has calibrated.

For multimodal work (image plus text), transformers ships CLIP, SigLIP, BLIP-2, LLaVA, and the image encoder in every one of them is a ViT variant.

Check: Use It0/2
1. In the small-dataset recipe, why is the full fine-tuning phase run at a learning rate 10-100x smaller than the linear-probe phase?Explain
A box labelled phase 1, linear probe, backbone frozen, learning rate r, next to a box labelled phase 2, full fine-tune, backbone unfrozen, learning rate unresolved, with a note that phase 2 uses a rate 10 to 100 times smaller.
2. You fine-tune a pretrained ViT-B by unfreezing every layer immediately and training with a freshly initialized classifier head. Accuracy ends up worse than a plain linear probe. What does the standard recipe say went wrong?Analyze
A box labelled classifier head, freshly initialised, next to a box labelled pretrained ViT-B backbone, unfrozen from step 1, a note that there is no linear-probe phase so both train together from the first batch, and a dashed question mark box over the question why the result is worse than a plain linear probe.

Ship It

This lesson produces:

  • outputs/prompt-vit-vs-cnn-picker.md - a prompt that picks between a ViT, a ConvNeXt, or a Swin based on dataset size, compute, and inference stack.
  • outputs/skill-vit-patch-and-pos-embed-inspector.md - a skill that verifies a ViT's patch embedding and positional embedding shapes match the model's expected sequence length, catching the most common porting bugs.
Author's Note

The bug I've hit more than any other with ViTs is a shape mismatch on the positional embedding. Pretrained weights are usually trained at 224x224 with a 16x16 patch, which locks in 196 patch tokens plus the class token, 197 total. Fine-tune at a different resolution and that count changes, but the checkpoint's positional embedding tensor doesn't know that. It still ships shaped for the original grid.

Load it naively and you get one of two outcomes: a shape error that at least fails loudly, or worse, a model that loads fine and just quietly underperforms because half its position information is garbage. The standard fix is to bicubically interpolate the positional embedding grid to the new patch count before loading the rest of the checkpoint. timm does this for you automatically. Roll your own loader, or port weights between frameworks by hand, and this is exactly where it bites. Check the shape first.

Exercises

  1. (Easy) Print the shapes of every intermediate tensor for a forward pass through the tiny ViT above. Confirm: input (N, 3, 64, 64) -> patches (N, 16, 192) -> with CLS (N, 17, 192) -> classifier input (N, 192) -> output (N, num_classes).
  2. (Medium) Fine-tune a pretrained timm ViT-S/16 on the synthetic-CIFAR dataset from Lesson 4. Compare against ResNet-18 fine-tuning on the same data. Report training time and final accuracy.
  3. (Hard) Implement MAE pretraining for the tiny ViT: mask 75% of patches, train the encoder + a small decoder to reconstruct the masked patches. Evaluate linear-probe accuracy on the synthetic data before and after pretraining.

Key Terms

TermWhat people sayWhat it actually means
Patch embedding"The first conv"A conv with kernel size = stride = patch size; turns the image into a grid of token embeddings
Class token"[CLS]"A learned vector prepended to the token sequence; its final output is the global image representation
Positional embedding"Learned pos"A learned vector added to every token so the transformer knows where each patch came from
Pre-LN"LayerNorm before sublayer"The stable transformer variant: x + sublayer(LN(x)) instead of LN(x + sublayer(x))
Multi-head attention"Parallel attention"Standard transformer attention split into num_heads independent subspaces, concatenated afterwards
ViT-B/16"Base, patch 16"The canonical size: dim=768, depth=12, heads=12, patch_size=16, image=224; ~86M params
DeiT"Data-efficient ViT"ViT trained on ImageNet-1k alone with strong augmentation; proved large pretraining datasets are not strictly required
MAE"Masked autoencoder"Self-supervised pretraining: mask 75% of patches, reconstruct; the dominant ViT pretraining recipe

Further Reading