Search Tech Journey

Find topics, journeys and posts

back to blog
mlintermediate 120m read

DL S030 · Vision Transformer — Attention All the Way Down

Throw away every convolution and process an image as a sequence of patches with pure self-attention. When ViT wins vs when CNNs still rule.

🧠SoftwareM05 · Convnets + vision· Session 030 of 130 120 min

🎯 Implement a Vision Transformer stem — patch embedding, class token, positional embedding — and understand why replacing convolution with pure attention was a shocking result in 2020.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 30 / 80 · Module M05

The story

Story hook · the heresy that worked

In late 2020, a small team at Google Brain — Alexey Dosovitskiy, Lucas Beyer, Alexander Kolesnikov, Neil Houlsby and colleagues — quietly uploaded "An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale" to arXiv. The paper's central claim was heretical to the vision community:

"We show that this reliance on CNNs is not necessary and a pure transformer applied directly to sequences of image patches can perform very well on image classification tasks."

To appreciate how heretical that was, you have to understand what the vision field believed in 2020. Every serious classification, detection, or segmentation architecture used convolutions. Not by convention — because everyone was convinced they had to. The convolutional inductive bias (locality, translation equivariance, hierarchy) was considered a necessary prior for making vision work with feasible amounts of data and compute. Convnets had 30 years of theoretical justification (Fukushima 1980, LeCun 1989, Hubel & Wiesel 1962) and a decade of empirical dominance (AlexNet, VGG, ResNet, EfficientNet). "Just use attention" was the kind of proposal that a senior reviewer would reject in one sentence.

The ViT paper's headline results were split. On ImageNet-1k alone, ViT lost to a well-tuned ResNet. That was the expected outcome. But when the same ViT was pretrained on JFT-300M — Google's internal, never-released, 300-million-image dataset — and then fine-tuned on ImageNet-1k, it won by ~2% top-1. And more dramatically, ViT's accuracy kept climbing as they scaled data and model size, while ResNet's plateaued.

That scaling behaviour — the fact that ViT got hungrier and hungrier for data but rewarded you if you fed it — turned out to be the transformative insight. The 2020s in vision have been about removing inductive biases and replacing them with more data. The move from CNN to ViT is the same move as the move from RNN to Transformer in NLP, and for the same reason: attention scales; hand-crafted priors don't.

Within two years, the entire vision community had at least one ViT variant in production. DINOv2 (2023) made ViT the go-to backbone for dense prediction. Swin (2021) re-added a bit of the convolutional locality prior via windowed attention. ConvNeXt (2022) hit back with a modernised pure convnet and closed most of the gap. And Perceiver / Perceiver-IO (Jaegle et al. 2021) generalised the ViT idea to arbitrary modalities — audio, video, point clouds.

And there is a deeper unifying insight lurking here: ViT is not really "non-convolutional". The patch-embed layer at the front is literally nn.Conv2d(3, embed_dim, kernel_size=patch_size, stride=patch_size). It's a stride-16 convolution. The rest of the network is attention, but the first thing ViT does to a raw image is a very traditional convolutional projection. The border between CNN and Transformer is less a wall and more a spectrum. That is section 2 of this session.

Today we build the front end of a ViT from scratch, understand the three critical components (patch embedding, class token, positional embedding), and finish with the 2025 vision-language backbones that descended from this one paper.

The result that surprised everyone

In 2020, Google Brain published a paper titled "An Image is Worth 16x16 Words". Its claim: you don't need convolutions to do image classification. Take an image, chop it into 16×16 patches, flatten each patch into a vector, feed the sequence of vectors into a plain Transformer encoder — the same architecture used for NLP — and you get results competitive with ResNet.

At the time this was heretical. The entire field of computer vision had been built on the convolutional inductive bias: locality + translation equivariance were assumed to be necessary for images. ViT threw all of that out and worked anyway, provided you gave it enough data.

That last clause is the key. On ImageNet-1k alone, ViT loses to a well-tuned ResNet. On JFT-300M (Google's internal 300M-image dataset), ViT wins by ~2%. The lesson: the convolutional inductive bias saves data, and if you have enough data you don't need the bias.

Today we build the front end of a ViT — the part that turns an image into a sequence — and understand its three components: patch embedding, class token, positional embedding. The Transformer encoder that sits on top is the same one we'll build from scratch in Module M07.

You will be able to
  • Explain the ViT recipe end-to-end: patchify → linear project → add class token → add positional embedding → Transformer encoder → classify from class token.
  • Implement patch embedding two ways (Unfold + Linear, or equivalent Conv2d with kernel=stride=patch_size).
  • Understand why the class token is needed and how it works.
  • Compare CNN vs ViT inductive biases in one sentence each.
  • Know the data regime where ViT beats CNN and vice versa.

Prerequisites

  • S025–S029 (all of M05 — you need the CNN baseline to appreciate what ViT gives up).
  • We'll use nn.TransformerEncoderLayer as a black box today; the internals get built from scratch in S036–S039.


1 · The recipe in one picture

image (B, 3, 224, 224) 1. cut into 14×14 = 196 patches of size 16×16×3 patches (B, 196, 768) # 16·16·3 = 768 numbers per patch 2. linear project each patch to embedding dim D=768 (identity here since it already is) patch_embeddings (B, 196, 768) 3. prepend a learnable [CLS] token tokens (B, 197, 768) 4. add learnable positional embeddings tokens + pos (B, 197, 768) 5. Transformer encoder (L layers of multi-head self-attention + FFN) output (B, 197, 768) 6. take just the [CLS] token, feed to a Linear head logits (B, num_classes)

That's the whole model. No convolution. No pooling. Everything is matmul.


2 · Patch embedding — two ways to write the same thing

The patchify step reshapes (B, 3, 224, 224) into (B, 196, 768). There are two idiomatic implementations, and it turns out they are identical.

2.1 The obvious way: unfold + linear

import torch
import torch.nn as nn
 
class PatchEmbeddingUnfold(nn.Module):
    def __init__(self, img_size=224, patch_size=16, in_ch=3, embed_dim=768):
        super().__init__()
        self.patch_size = patch_size
        self.num_patches = (img_size // patch_size) ** 2   # 196
        self.proj = nn.Linear(patch_size * patch_size * in_ch, embed_dim)
 
    def forward(self, x):
        B, C, H, W = x.shape
        p = self.patch_size
        # unfold spatial dims into patches
        x = x.unfold(2, p, p).unfold(3, p, p)   # (B, C, H/p, W/p, p, p)
        x = x.permute(0, 2, 3, 1, 4, 5)          # (B, H/p, W/p, C, p, p)
        x = x.reshape(B, self.num_patches, -1)    # (B, 196, C·p·p)
        return self.proj(x)                       # (B, 196, embed_dim)

2.2 The clever way: a Conv2d with kernel = stride = patch_size

class PatchEmbeddingConv(nn.Module):
    def __init__(self, img_size=224, patch_size=16, in_ch=3, embed_dim=768):
        super().__init__()
        self.num_patches = (img_size // patch_size) ** 2
        self.proj = nn.Conv2d(in_ch, embed_dim,
                              kernel_size=patch_size, stride=patch_size)
 
    def forward(self, x):
        # (B, C, H, W) -> (B, embed_dim, H/p, W/p)
        x = self.proj(x)
        # (B, embed_dim, 14, 14) -> (B, 196, embed_dim)
        return x.flatten(2).transpose(1, 2)

Why are they equivalent? A Conv2d(3, 768, kernel=16, stride=16) applies 768 different 16×16×3 kernels at each of the 14×14 non-overlapping positions. That's exactly what "flatten each 16×16×3 patch and multiply by a 768×768 matrix" does — the 3072 weights per output channel are the 768 rows of the linear projection.

Use the Conv2d version in real code. It's faster (dispatched to cuDNN) and simpler to read.


3 · The class token — where does the prediction come from?

You now have a sequence of 196 patch embeddings, each (768,). What single vector do you feed to the classifier at the end?

Three options historically considered:

  1. Average all 196. Works okay, ~0.5% worse than the alternative.
  2. Take the first patch's embedding. Bad — that patch is a fixed spatial location.
  3. Prepend a learnable "[CLS]" token. The winner.

The class token is a single trainable (1, D) vector prepended to every sequence:

self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
nn.init.trunc_normal_(self.cls_token, std=0.02)
 
def forward(self, x):
    B = x.size(0)
    cls = self.cls_token.expand(B, -1, -1)   # (B, 1, D)
    x = torch.cat([cls, x], dim=1)            # (B, 197, D)
    ...

The idea: through self-attention, the CLS token pulls information from every patch token. Its final embedding is a learned "summary" of the whole image, purpose-built for classification. The last layer feeds only output[:, 0] (the CLS position) to the classification head, ignoring the other 196 patch outputs.

The analogy
🌍 Real world
💻 Code world

4 · Positional embedding — because attention is permutation-invariant

Self-attention is order-agnostic — permute the input tokens and you get the same permuted output. That's fine for a set, but an image is a grid: patch (0, 0) is in the top-left corner and patch (13, 13) is in the bottom-right. The network needs to know this.

ViT's fix: add a learnable position embedding to every token.

self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, embed_dim))   # +1 for CLS
nn.init.trunc_normal_(self.pos_embed, std=0.02)
 
def forward(self, x):
    # ... after cls prepend, x is (B, 197, D)
    x = x + self.pos_embed
    return self.dropout(x)

Note the design choices:

  • Learnable, not fixed. The original Transformer (Attention Is All You Need) used sinusoidal positional encodings. ViT went learnable because empirically it works about the same, and lets the network specialize per position. We'll compare both in S038.
  • Added, not concatenated. Additive positional info integrates naturally with the token embeddings.
  • Same dropout everywhere. Positional embeddings are dropout'd along with tokens.

4.1 The interpolation trick (for fine-tuning to different resolutions)

If you pretrain at 224×224 (196 patches + CLS = 197 positional embeddings) and want to fine-tune at 384×384 (576 patches + CLS = 577), the positional embedding table has the wrong number of rows. Standard fix: bilinear-interpolate the 14×14 grid of position embeddings to a 24×24 grid before initializing the new model. Practically all ViT fine-tuning pipelines do this transparently.


5 · Putting it together — a minimal ViT

class ViT(nn.Module):
    def __init__(self, img_size=224, patch_size=16, in_ch=3,
                 embed_dim=768, depth=12, num_heads=12,
                 mlp_ratio=4, num_classes=1000, dropout=0.1):
        super().__init__()
        self.patch_embed = PatchEmbeddingConv(img_size, patch_size, in_ch, embed_dim)
        num_patches = self.patch_embed.num_patches
 
        self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
        self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, embed_dim))
        self.dropout   = nn.Dropout(dropout)
 
        encoder_layer = nn.TransformerEncoderLayer(
            d_model=embed_dim, nhead=num_heads,
            dim_feedforward=int(embed_dim * mlp_ratio),
            dropout=dropout, activation="gelu",
            batch_first=True, norm_first=True,
        )
        self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=depth)
        self.norm = nn.LayerNorm(embed_dim)
        self.head = nn.Linear(embed_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_embed(x)                         # (B, N, D)
        cls = self.cls_token.expand(x.size(0), -1, -1)
        x = torch.cat([cls, x], dim=1)                  # (B, N+1, D)
        x = self.dropout(x + self.pos_embed)
        x = self.encoder(x)                             # (B, N+1, D)
        x = self.norm(x[:, 0])                          # CLS token only
        return self.head(x)                             # (B, num_classes)

Parameter count for ViT-Base (D=768, depth=12, heads=12): ~86M. Compare with ResNet-50 at 25M. ViT is 3× bigger.

Two things worth flagging:

  • norm_first=True. Pre-norm variant (LayerNorm before attention, not after). Trains more stably; standard since ~2020.
  • activation="gelu". Not ReLU. GELU is smoother, marginally better in language and vision transformers. Every LLM uses it.
Try itFeel where ViT's 86M parameters actually live

Instantiate the ViT above and print a parameter breakdown by named module:

model = ViT(embed_dim=768, depth=12, num_heads=12, num_classes=1000)
for name, module in model.named_children():
    n = sum(p.numel() for p in module.parameters())
    print(f"{name:15s}  {n/1e6:7.2f}M")
total = sum(p.numel() for p in model.parameters())
print(f"total          {total/1e6:7.2f}M")

Before you run: guess which module holds the most. Most people say patch_embed (the giant Conv2d) or head (the 768→1000 classifier). The right answer is encoder — the twelve transformer blocks together are ~85M of the 86M total. Each block is roughly 4 × D² (attention QKVO) plus 2 × D × 4D (MLP), so ~7M per block × 12 = ~85M. Now try halving depth to 6: the model drops to ~44M, and — crucially — needs more data per parameter to learn, not less. That inverted intuition is the whole story of "ViT wins with data, loses without."

💡 Hint · Break the total down by module and stare at the numbers before scrolling.

6 · Why ViT wins with data, loses without

The ViT paper's central figure shows accuracy vs. pretraining dataset size:

  • ImageNet-1k (1.3M images): ResNet > ViT
  • ImageNet-21k (14M images): ResNet ≈ ViT
  • JFT-300M (300M images): ViT > ResNet by ~2%

Why? The convolutional inductive bias (locality, translation equivariance) is like free training data for a CNN — the architecture already assumes what a CNN would otherwise have to learn from examples. ViT has to learn all those properties from data. With enough data, ViT catches up and surpasses; below that threshold, CNNs use their prior more efficiently.

The inductive bias / data tradeoff

    7 · What came after ViT (context, not required today)

    • DeiT (2021): trained ViT-Base to competitive ImageNet-1k accuracy using clever distillation + augmentation. Killed the "ViT needs JFT" narrative.
    • Swin Transformer (2021): re-introduced locality via windowed attention. Hybrid of ViT and hierarchical CNN. Won multiple 2021 benchmarks.
    • DINOv2 (2023): self-supervised ViT that produces features rivaling supervised models. The current default backbone for "I need general-purpose visual features".
    • SAM (2023): ViT-based promptable segmentation. Foundation model era for vision.

    Every one of these builds on the same architectural skeleton we wrote today.


    War stories

    War story The positional embedding I forgot to add

    First time I implemented ViT from scratch I forgot the x + self.pos_embed line. Model trained; accuracy stalled at ~40% on ImageNet-1k. Because self-attention is permutation-invariant, the network was making predictions that were literally indifferent to which corner of the image had the cat. Textbook bug. Add the positional embedding before dropout, add it after concatenating CLS.

    War story Fine-tuning at higher resolution without interpolating pos_embed

    Pretrained ViT at 224, tried to fine-tune at 384 without interpolating the positional embedding table. The forward pass silently succeeded (because I'd padded with zeros instead of erroring), but the model saw all the extra patches as "position unknown" and its accuracy dropped to random on those regions. Fix: always call interpolate_pos_embed from the timm/HF loading utilities, or write your own bilinear interp of the 14×14 → 24×24 grid.



    8 · The 2025 pulse · vision-language backbones eat everything

    By 2023 the ViT story had already outgrown pure image classification. The interesting question stopped being "does ViT beat ResNet on ImageNet?" and became "can a single ViT learn a joint representation of images and text, so that everything downstream — classification, retrieval, captioning, VQA, robotics — falls out for free?"

    Here's the current lineage you should know for 2025.

    Foundation models that are just ViTs, trained self-supervised at massive scale:

    • DINOv2 (Oquab et al., Meta AI 2023) — ViT-g/14 trained self-supervised (teacher-student distillation + iBOT masked-image prediction + Sinkhorn-Knopp centering) on 142M curated images. Frozen features rival supervised backbones on almost every downstream benchmark. Current best default for dense-prediction transfer.
    • ViT-22B (Dehghani et al., Google 2023) — 22 billion parameters. Demonstrated that the transformer scaling laws that work for LLMs also work for vision at extreme scales. Not open, but the paper is essential reading for the recipe.
    • Hiera (Ryali et al., FAIR 2023) — stripped-down hierarchical ViT (like Swin) pretrained with MAE. Faster and simpler than Swin, matches or beats it on every benchmark.
    • EVA-02 (Fang et al. 2023) — combines MAE pretraining with CLIP-based feature distillation. Strong on both classification and segmentation.

    Vision-language backbones (image + text jointly trained):

    The 2025 practical decision tree for "what backbone do I pick?":

    Your task2025 default
    Pure classification, medium dataFine-tuned ConvNeXt V2 or DeiT-III (both work, ConvNeXt is faster)
    Dense prediction (seg, depth, det), any dataDINOv2 frozen features + task head
    Multimodal (VQA, captioning, retrieval)SigLIP-2 or Perception Encoder
    On-device, latency-criticalMobileNetV4 or FastViT
    You're building an LLM with vision (VLM)SigLIP-2 encoder → projector → LLM. That's the standard recipe.

    The Vision Transformer, seven years on: what started as a heretical claim in 2020 — "convolutions aren't necessary" — has evolved into the more nuanced modern truth: convolutions aren't necessary if you have enough data, but even when they're not necessary they're often the right first layer for computational reasons. Every SigLIP-2 encoder, every DINOv2 model, every ViT-22B still starts with Conv2d(3, D, kernel=stride=16) for the patch embed. The line between conv and attention is a technical detail; the real story is that flexible attention layers on top of a small conv stem now underpin nearly every state-of-the-art vision system in 2025.

    That's the end of Module M05. You now have the entire modern vision toolkit in your head: convolution (S025), the architectural lineage from LeNet to ConvNeXt (S026), the training recipe that beats the architecture (S027), transfer learning and self-supervised pretraining (S028), the augmentation stack that adds 5% top-1 for free (S029), and the ViT recipe that unified images and language into one representation (this session).

    Next up: we swap image patches for word tokens and start building the language side of the story.

    Further reading · ViT and beyond in 2025


    Retention scaffold

    Vision Transformer · click to reveal · click to reveal
    ★ = stretch question

    One-line summary (write it in your own words): ______________________________________

    Spaced review: re-derive the ViT forward pass end-to-end in 24 hours — patchify → embed → CLS → pos → encoder → classify — without opening the file. On day 7, load timm.create_model('vit_base_patch16_224', pretrained=True), inspect .patch_embed, and confirm it's a stride-16 Conv2d.

    Next session (S031): Word2Vec — swap image patches for word tokens. The trick that made every modern LLM possible: represent every word as a learnable dense vector.

    Sticky note:

    Patch → Linear → [CLS + Pos] → Encoder → Classify from CLS. Patch embed = stride-P Conv2d. ViT wins with data, loses without. DINOv2 + SigLIP-2 are the 2025 defaults.


    Legacy recall drills

    Retention scaffold

    Recall

    1. In one sentence, what is ViT?

    Split an image into non-overlapping patches, treat each patch as a token, and run a plain Transformer encoder over the resulting sequence to classify from a learned CLS token.

    2. Why can PatchEmbedding be implemented as a Conv2d?

    Because "linear-project each 16×16×3 patch to 768 dims" is mathematically identical to "convolve with 768 kernels of size 16×16×3 at stride 16" — same weights, same outputs, just written in convolution notation.

    3. Why do we need positional embeddings but a plain CNN doesn't?

    Self-attention is permutation-invariant: reordering the input tokens reorders the outputs identically. A CNN, by contrast, has spatial position baked into where each weight lives. ViT has to inject position explicitly.

    4. What is the CLS token and what happens to it at the output?

    A single learnable vector prepended to the patch sequence. Through self-attention it accumulates a summary of the whole image. Only its final embedding is passed to the classifier.

    5. When does ViT beat CNNs, and when do CNNs win?

    ViT wins with very large pretraining datasets (>~100M images). CNNs win with smaller datasets because their locality bias is like free training data.

    Stretch

    Implement PatchEmbeddingUnfold and PatchEmbeddingConv and verify they produce the same output up to weight initialization. Then load a pretrained timm ViT and inspect its patch_embed layer — it will be nn.Conv2d, confirming the equivalence.

    In your own words

    Explain the inductive-bias / data tradeoff between CNNs and ViTs in 3 sentences.

    Spaced review

    • S025 (convolution) — ViT's patch embed is one big stride-16 conv. Understanding this is understanding why the two paradigms aren't as far apart as they seem.
    • S021 (LayerNorm vs BatchNorm) — ViT uses LayerNorm exclusively; there's no BN. We'll revisit why in S036–S038.

    Next session

    S031 — Word2Vec. We swap image patches for word tokens and start building up the language side of the story. The core trick — represent every word as a learnable dense vector — is what makes modern NLP possible.

    Bring back tomorrow

    • ViT recipe: patch → linear project → CLS + pos embed → Transformer encoder → classify from CLS.
    • Patch embed = Conv2d(kernel=stride=patch_size).
    • ViT wins with data, loses without.

    Previous: ← DL S029 · Next: DL S031 →