Patches All the Way Down

An interactive course · built for interview mastery

Patches All the Way Down

Everything about Vision Transformers: the reasoning behind every design decision, a from-scratch PyTorch implementation, a pure-NumPy version with every gradient derived by hand, and the research lineage that followed — with live widgets and drills along the way.

Module 00

Start here

This course has one goal: after finishing it, you can walk to a whiteboard and rebuild a Vision Transformer from an empty file — in PyTorch or in raw NumPy — while explaining why every line is there. That combination (implementation fluency + design reasoning) is exactly what ML research interviews probe.

How to use it

Work through the modules in order. You said you know deep learning well but are rusty on attention, so modules 02–03 rebuild attention from first principles before anything vision-specific appears. Every module ends with a short quiz; your score and completion state are saved in this browser, so you can leave and come back. The interactive panels aren't decoration — each one exists because the concept it shows is a common interview probe (softmax temperature, quadratic cost, patch-size trade-offs, parameter counting).

Two rules that make this stick:

  • Type the code yourself. Reading module 05 is not the same as reproducing it. After each implementation module, close the page and rewrite the module from memory. The button on code blocks is for checking your version, not for skipping the typing.
  • Say the shapes out loud. Interviewers watch whether you track tensor shapes. Every code block here annotates shapes; module 04's shape-tracer drills them.

The route

01 motivates the whole enterprise (inductive bias, and why "just use a transformer" was a radical bet). 02–03 build scaled dot-product attention, multi-head attention, and the pre-norm transformer block. 04 assembles ViT itself — patch embedding, the [CLS] token, position embeddings — with a full parameter-count walkthrough. 05 is the complete PyTorch implementation; 06 re-derives it in NumPy including backprop through softmax, LayerNorm, and attention. 07 covers why ViTs are data-hungry and how they're actually trained. 08 collects the "interesting details" that separate good candidates from great ones. 09 traces DeiT → Swin → MAE → DINO. 10 is pure interview practice.

Every line of PyTorch and NumPy in this course was executed and verified before publishing: the ViT-B/16 build produces exactly 86,567,656 parameters, and the NumPy backward pass matches PyTorch autograd to 1e-16. You are not studying pseudocode.

Warm-up · calibrate yourself

An image is 224×224. With 16×16 patches, how many patch tokens does a ViT see?

224/16 = 14 patches per side, so 14 × 14 = 196 tokens (plus one [CLS] token → 197 in the sequence). If this felt automatic, great — the whole course keeps arithmetic like this at your fingertips.

Module 01

Why throw away convolutions?

ViT is best understood as a bet about inductive bias: bake less knowledge into the architecture, and let scale buy it back — with interest.

By 2020, a decade of computer vision rested on two assumptions hard-wired into CNNs: locality (nearby pixels are related, so use small kernels) and translation equivariance (a cat is a cat wherever it sits, so share weights across positions). These are inductive biases — prior knowledge the architecture enforces so the model doesn't need to learn it from data.

The ViT paper (Dosovitskiy et al., "An Image is Worth 16×16 Words", ICLR 2021) asked: what if we remove almost all of that? Cut the image into patches, treat the patches as a sequence of tokens, and feed them to a plain transformer encoder — nearly unchanged from the NLP transformer of Attention Is All You Need (2017). A transformer layer has no notion that patch 15 sits next to patch 16. Every token can attend to every other token from layer one; where things are is told to the model only through a learned position embedding.

Why this mattersInductive bias is a trade. When data is scarce, built-in priors are a gift: the model doesn't waste capacity rediscovering that images are local. When data is abundant, those same priors become a ceiling: the model cannot learn interactions the architecture forbids. A conv layer physically cannot relate two distant pixels in one step; attention can. ViT's wager was that at sufficient scale, learned bias beats designed bias.

The paper's central empirical result confirms the trade exactly. Trained only on ImageNet-1k (1.3M images), ViT loses to comparable ResNets — it overfits, because nothing stops it. Trained on ImageNet-21k (14M) it pulls even. Trained on JFT-300M (303M images, Google-internal) it wins decisively, and it keeps improving where CNNs saturate. And crucially, it was cheaper to train per unit of accuracy at scale, because dense matmuls over tokens map onto accelerators better than convolution pipelines.

CNN layer 1 layer 2 layer 3 receptive field grows layer by layer ViT · one attention layer every patch reaches every patch, layer 1
Fig 1 · The bias being removed. A 3×3 conv needs ~H/3 layers before opposite corners of an image can interact; self-attention connects any two patches in a single layer. Orange marks the paths available to one query location.

What ViT keeps, and the one place convolution survives

ViT is not "zero inductive bias." Three vision priors survive: the patchification itself assumes pixels arrive in a 2D grid and that a 16×16 tile is a sensible atomic unit; the position embeddings are learned per-location, so the model can rediscover 2D structure (and does — see module 08); and the whole model is applied to a fixed resolution. Also — a fact interviewers enjoy — the patch projection in every practical implementation is a convolution: a Conv2d with kernel = stride = patch size, which is mathematically identical to "slice and apply a shared Linear." Weight sharing across patches is a conv-style bias ViT quietly retains.

Interview angle"Why did ViT need JFT-300M when ResNets did fine on ImageNet?" is a classic opener. The answer they want: CNNs get locality and translation equivariance for free from the architecture; ViT must learn both from data, which costs samples. Bonus points for the follow-up: DeiT (module 09) later showed that heavy augmentation + distillation can substitute for most of that data, so the requirement was about supervision signal, not some magic in 300M images.

Checkpoint 01

Which inductive bias does a plain ViT encoder layer NOT have?

The encoder treats tokens as an unordered set (position info comes only from added embeddings); nothing prefers near over far. Weight sharing survives in the patch projection, and the 2D grid assumption lives in patchification.

Pre-trained on ImageNet-1k only, ViT vs. a comparable ResNet — who wins?

This is the paper's core finding: below ~14M images the missing priors hurt; at JFT-300M scale ViT overtakes and keeps scaling.

Module 02

Attention from first principles

Self-attention is a differentiable dictionary lookup: every token asks a question, every token offers an answer, and softmax decides how much of each answer to blend.

Forget images for two modules. You have a sequence of N vectors x₁ … x_N, each of dimension D, stacked into a matrix X of shape (N, D). We want each token's new representation to be built from the tokens that are relevant to it — where relevance is learned, not hard-coded.

Step 1 — three roles, three projections

Each token plays three roles at once, produced by three learned linear maps:

Q = XWQ,   K = XWK,   V = XWV   — each (N, d)

The retrieval metaphor is exact and worth saying in an interview: a query is what this token is looking for; a key is how a token advertises what it contains; a value is the content it hands over if selected. Why three separate matrices instead of comparing raw x vectors? Because relevance is asymmetric and task-dependent: "what a verb wants" ≠ "what a verb offers." Separate projections let the model place queries and keys in a space where dot product = usefulness, independent of what content gets transmitted.

Step 2 — similarity by dot product

S = QK / √d   — (N, N), Sij = how much token i cares about token j
Why divide by √d?Suppose the entries of q and k are independent with mean 0, variance 1. Their dot product is a sum of d such products, so it has mean 0 and variance d — standard deviation √d. With d = 64, raw scores routinely reach ±15. Softmax of scores that large is effectively one-hot, its gradients (which contain a factor p(1−p)) vanish, and training stalls. Dividing by √d restores unit variance regardless of head size. This derivation — two lines of variance algebra — is one of the most frequently asked whiteboard questions about transformers.

Step 3 — softmax rows into mixing weights

A = softmax(S)  row-wise,    out = AV   — (N, D): each row a convex blend of values

Each row of A is a probability distribution: non-negative, sums to 1. Row i of the output is a weighted average of all value vectors, weighted by how relevant each token is to token i. That's the whole mechanism. Note what it costs: S is an N×N matrix, so compute and memory are O(N²·d) — the fact that drives half the follow-up research in module 09.

Widget · why √d is load-bearing

Ten random attention scores for one query. Drag the head dimension: the left bars show raw dot-product scores growing like √d, the right bars show the resulting softmax. Watch the distribution collapse to one-hot when scores are unscaled — then flip scaling on.

Self-attention, seen whole

"Self" just means Q, K, V all come from the same sequence. The full einsum chain, with shapes:

attention in five lines — the kernel of everything
Q = X @ W_q                      # (N, D) @ (D, d)  -> (N, d)
K = X @ W_k                      # (N, d)
V = X @ W_v                      # (N, d)
A = softmax(Q @ K.T / sqrt(d))   # (N, N)   rows sum to 1
out = A @ V                      # (N, d)   each row: blend of values

Widget · a live attention matrix

Eight tokens, real Q·Kᵀ computed in your browser. Hover a cell to read it; hover a row label to see that query's full distribution. The temperature slider multiplies the scores before softmax — the same knob √d-scaling is quietly turning.

hover a cell…

attention weight (sequential ramp: light → dark = 0 → 1)

Interview angleThree probes appear constantly. (1) "Why softmax and not just normalize?" — softmax is differentiable, strictly positive (keeps every gradient path alive), and its exponential sharpens contrast so the model can approximate hard selection while staying smooth. (2) "What's the complexity?" — O(N²d) time, O(N²) attention-matrix memory per head; for ViT-B/16, N = 197, so the matrix is tiny — images at higher resolution are where N² starts to bite. (3) "Is attention permutation-invariant?" — permuting input tokens permutes outputs identically (equivariance); without position embeddings the model literally cannot tell whether an image's patches were shuffled.

Checkpoint 02

If q, k have i.i.d. entries with mean 0 and variance 1, the variance of q·k is:

Each of the d terms qᵢkᵢ has variance 1 and they're independent, so variances add: Var(q·k) = d. Hence dividing by √d gives variance 1.

What breaks when softmax inputs are very large in magnitude?

Softmax's Jacobian entries look like pᵢ(δᵢⱼ − pⱼ); when one p ≈ 1 and the rest ≈ 0, all entries ≈ 0. The rows always sum to 1 — that's never the failure.

Why separate W_Q and W_K rather than scoring with x_i · x_j directly?

x·x scoring forces symmetric relevance (i cares about j ⇔ j cares about i) and makes every token maximally attend to itself. Projections remove both constraints — and cost the same O(N²) as the score matrix anyway.

Module 03

Heads, blocks & residual streams

One attention pattern per layer is too few opinions. Multi-head attention runs several cheap attentions in parallel — then a block wraps attention and an MLP around a residual stream that gradients can flow through untouched.

Multi-head attention: parallel subspaces, same price

A single softmax produces a single mixing pattern per token — but a patch may simultaneously need "the other patches of this object," "the texture context," and "the global gist." So we split the model dimension D into H heads of size d_h = D/H (ViT-Base: D = 768, H = 12, d_h = 64). Each head gets its own Q, K, V projections into its 64-dim subspace, runs attention independently, and the H outputs are concatenated back to D and mixed by a final linear W_O.

headh = softmax(QhKh/√dh)Vh    MHA(X) = concat(head1…headH)WO
Why heads are (almost) freeTotal projection cost is identical to one big head: H heads × (D × d_h) parameters = D × D per projection either way. What you buy for the same FLOPs is H independent attention matrices — H different definitions of relevance per layer. What you give up: each head's dot products live in only 64 dimensions, so any single head is a lower-rank relevance judge. Empirically (and in the ViT paper's own analysis) different heads at the same layer learn wildly different ranges — some local, some global from layer 1 — see module 08.

In code, nobody loops over heads. The trick — worth performing smoothly on a whiteboard — is one fused projection to 3D followed by a reshape so the head axis sits beside the batch axis, making every head's attention a single batched matmul:

the reshape at the heart of every transformer implementation
qkv = self.qkv(x)                              # (B, N, D) -> (B, N, 3D)   one fused Linear
qkv = qkv.reshape(B, N, 3, H, D // H)          # split: which-of-qkv, head, head_dim
qkv = qkv.permute(2, 0, 3, 1, 4)               # (3, B, H, N, d_h)
q, k, v = qkv[0], qkv[1], qkv[2]               # each (B, H, N, d_h)
attn = (q @ k.transpose(-2, -1)) * self.scale  # (B, H, N, N)  batched over B*H
out  = (attn.softmax(-1) @ v)                  # (B, H, N, d_h)
out  = out.transpose(1, 2).reshape(B, N, D)    # concat heads back -> (B, N, D)

The MLP: where most of the parameters live

After attention mixes information across tokens, a two-layer MLP processes each token independently: expand D → 4D, apply GELU, project 4D → D. The 4× ratio is inherited from the original transformer and survives because it keeps working. Two-thirds of a ViT block's parameters sit here, not in attention — a fact that surprises people and therefore gets asked.

Why GELU, not ReLU?GELU(x) = x·Φ(x) (Φ = Gaussian CDF) is a smooth gate: instead of the hard keep/kill of ReLU it weights inputs by how large they are relative to noise. Its smoothness gives non-zero gradients around 0 and avoids dead units. It came to ViT via BERT — empirically better for transformers, and every major ViT keeps it (usually the tanh approximation, which is what module 06 differentiates by hand).

Pre-norm and the residual stream

The block wires these together with two residual connections and LayerNorms placed before each sublayer (pre-norm) — a deliberate departure from the 2017 post-norm transformer:

x ← x + MHA(LN(x))    x ← x + MLP(LN(x))
residual stream (B, N, D) — never resized, never normalized in place LN MHA + LN MLP 4× +
Fig 2 · One ViT encoder block (pre-norm). The branches read a normalized copy; the stream itself only ever receives additions. Twelve of these, stacked, are the entire body of ViT-Base.
Why pre-norm wonFollow the gradient. In pre-norm, the output is x plus branch terms, so ∂out/∂x contains an identity term through the untouched skip path: gradients reach layer 1 unattenuated no matter the depth. In post-norm, LayerNorm sits on the trunk — every block's gradient passes through a normalization Jacobian, and at depth this needed careful warmup to avoid divergence. Pre-norm transformers train stably without tricks, which is why ViT, GPT-2 onward, and essentially all modern stacks use it. Think of the stream as a shared memory that each sublayer reads (via LN) and writes into (via +).
Why LayerNorm, not BatchNorm?LN normalizes each token's D features independently — no batch statistics. That means no train/eval discrepancy, no dependence on batch size, and identical behavior for every sequence length. BatchNorm's running statistics are notoriously fragile for sequences and small per-device batches. (LN's affine scale γ and shift β are learned per-feature; module 06 derives its surprisingly elegant backward pass.)
Interview angleA favorite: "Where would you look for a ViT's parameters?" Per block: QKV projection 3D² + output projection D² ≈ 2.36M for D=768, MLP 8D² ≈ 4.72M, LayerNorms 4D ≈ 3k. So the MLP holds ~⅔ of block parameters, attention ~⅓, norms are noise. Also be ready for "what happens if you remove the residuals?" — deep signal propagation collapses; rank of representations degrades and gradients vanish; even a 12-block ViT becomes untrainable in practice.

Checkpoint 03

ViT-Base: D = 768, H = 12. Compared to one 768-dim head, using 12 heads of 64 dims changes projection parameter count how?

Each projection is still a D×D map overall (12 slices of 768×64). What changes is expressivity: 12 independent attention distributions instead of 1, each in a lower-rank subspace.

The main practical reason pre-norm replaced post-norm:

Same number of LNs (plus one final). The win is optimization: no normalization Jacobian on the trunk path.

Roughly what fraction of a ViT block's parameters are in the MLP?

MLP: 2·D·4D = 8D². Attention: 4D² (QKV + output proj). 8/12 ≈ ⅔ in the MLP.

Module 04

The ViT architecture

Everything vision-specific happens in the first three lines: cut, flatten, project, add position. From there it's a text transformer that never finds out it's looking at an image.

Patch embedding: images become sentences

Take an image (3, 224, 224), slice it into a 14×14 grid of 16×16 patches, flatten each patch to a 768-vector (16·16·3), and multiply by a learned (768, D) matrix. The result: 196 tokens of dimension D. A "word" is a 16×16 tile of pixels — hence the paper's title.

Why patches at all, and why 16?Pixel-level attention over 224² = 50,176 tokens would need a 2.5-billion-entry attention matrix per head per layer — O(N²) makes it impossible. Patches are a compromise: coarse enough that N stays small (196), fine enough to preserve spatial detail. Patch size is the resolution/compute dial: /32 → 49 tokens (cheap, coarse); /14 or /8 → more tokens, finer features, quadratically more attention compute. Modern models (DINOv2) favor /14 with high-res fine-tuning.

Widget · patch embedding explorer

A synthetic 224×224 image. Change the patch size and watch the token count and per-token flattened dimension respond; hover to pick out an individual patch and see exactly which token index it becomes.

hover the image…

Every real implementation does the slicing and projection in one op: nn.Conv2d(3, D, kernel_size=P, stride=P). With kernel = stride, windows never overlap, so the convolution is "shared Linear applied to each patch" — same math, one fused kernel. Being able to state this equivalence precisely is a small but reliable interview win.

The [CLS] token: a learned blank slate for the answer

Borrowed from BERT: prepend one learned D-dim vector to the 196 patch tokens. It participates in every attention layer like any token, and at the end the classification head reads only its final state. Sequence length becomes 197.

Why a whole extra token?You need to pool 196 token representations into one image representation. Averaging them (GAP) works but fixes the pooling scheme; a [CLS] token makes pooling learned — through attention, the token itself decides what to gather, layer by layer. It also gives the model a scratch register not tied to any image location. The honest footnote (paper, appendix): GAP works equally well if you retune the learning rate — [CLS] was kept partly to stay maximally faithful to the NLP transformer. Both remain in use today; DINOv2 uses [CLS] + patch tokens, many supervised pipelines use GAP.

Position embeddings: telling a set it's a grid

Attention is permutation-equivariant — shuffle the 196 patches and the outputs shuffle identically, so ViT would classify a jigsaw-scrambled image the same as the original. The fix: add a learned vector p_i to token i before the first block. One (197, D) table, trained end-to-end.

Why learned 1D, not sinusoidal or 2D-aware?The paper tried 1D learned, 2D learned (separate row/column embeddings), and relative variants: all within noise of each other. So they kept the simplest. The satisfying part — module 08 shows it — is that if you compute the cosine similarity of each position embedding with every other after training, the learned table has spontaneously organized into a 2D grid: nearby patches get similar embeddings, same-row/same-column structure emerges. The bias ViT was denied, it re-learned from data.

The whole machine

3×224×224 cut 196 × (768 px values) E CLS tokens (197, D) + pos embed (197, D) x + MHA(LN(x)) x + MLP(LN(x)) × 12 blocks LN → take CLS row Linear D → 1000 class logits learned pieces: · patch projection E (a P×P conv) · CLS vector · pos-embed table · 12 blocks (QKV, proj, MLP, LNs) · final LN + linear head
Fig 3 · ViT-B/16 end to end. Orange marks one patch's journey to one token, and the [CLS] token that ultimately carries the answer. Nothing after "+ pos embed" knows the input was an image.

Widget · shape tracer

The forward pass of ViT-B/16 on a batch of 32 images, one tensor at a time. Step through it until you can recite it — interviewers really do ask for exactly this recitation.

Widget · parameter counter

Pick a variant; the breakdown is computed live from the formulas (not a lookup table). ViT-B/16 lands on 86,567,656 — the exact number the course's verified PyTorch build prints.

Interview anglePractice the ViT-B/16 count until it's mechanical: patch embed 3·16²·768 + 768 = 590,592 · pos table 197·768 = 151,296 · [CLS] 768 · each block (3D²+3D) + (D²+D) + 8D²+5D + 4D = 7,087,872, ×12 = 85,054,464 · final LN 1,536 · head 769,000 → 86,567,656. Being able to reconstruct ~86M from first principles, out loud, is a disproportionately strong signal.

Checkpoint 04

Why is the patch projection implemented as Conv2d(kernel=P, stride=P)?

kernel = stride ⇒ each output position sees one disjoint patch ⇒ identical math to a shared Linear on flattened patches. Overlap would change the model (some hybrids do that deliberately).

Remove the position embeddings entirely. What can the trained model no longer do?

Self-attention is permutation-equivariant and [CLS] readout is permutation-invariant over patch tokens; without positional information ViT is a bag-of-patches model. (It still works surprisingly okay — texture carries a lot — but layout is gone.)

Same image, patch size halved (16 → 8). Sequence length and attention cost change by:

Tokens scale with (224/P)²: 196 → 784. The N×N attention matrix scales with N², so ~16×. This quadratic is why patch size is the main compute dial.

Module 05

PyTorch, from scratch

The complete model in ~90 lines, no timm, no shortcuts. Every block below was executed before publishing; the assembled model prints 86,567,656 parameters and a training loop drives its loss down.

Build order matters: leaf modules first, assembly last. That's also the order to use at a whiteboard, because each piece is independently testable — after writing Attention, you can already assert its output shape equals its input shape.

1 · Patch embedding

patch_embed.py
class PatchEmbed(nn.Module):
    """(B, 3, 224, 224) -> (B, 196, D): cut into P×P patches, project to D."""
    def __init__(self, img_size=224, patch_size=16, in_chans=3, embed_dim=768):
        super().__init__()
        self.num_patches = (img_size // patch_size) ** 2
        # kernel = stride = P  ==  'flatten each patch, apply shared Linear'
        self.proj = nn.Conv2d(in_chans, embed_dim,
                              kernel_size=patch_size, stride=patch_size)

    def forward(self, x):                    # (B, 3, 224, 224)
        x = self.proj(x)                     # (B, D, 14, 14)  one dot product per patch
        x = x.flatten(2)                     # (B, D, 196)     merge the two grid axes
        x = x.transpose(1, 2)                # (B, 196, D)     tokens-last-dim, like NLP
        return x

Line by line. flatten(2) merges dims 2 and 3 (the 14×14 grid) into 196 — row-major, so token order is "reading order." The transpose exists purely for convention: transformers everywhere expect (batch, sequence, features). No parameters beyond the conv's D·3·P² + D.

2 · Multi-head self-attention

attention.py
class Attention(nn.Module):
    def __init__(self, dim, num_heads=12, attn_drop=0., proj_drop=0.):
        super().__init__()
        assert dim % num_heads == 0          # heads must tile the model dim
        self.num_heads = num_heads
        self.head_dim = dim // num_heads
        self.scale = self.head_dim ** -0.5   # 1/sqrt(d_k): the variance fix

        self.qkv = nn.Linear(dim, dim * 3)   # fused Q,K,V — one matmul, not three
        self.attn_drop = nn.Dropout(attn_drop)
        self.proj = nn.Linear(dim, dim)      # W_O: re-mix concatenated heads
        self.proj_drop = nn.Dropout(proj_drop)

    def forward(self, x):                    # (B, N, D)
        B, N, D = x.shape
        qkv = self.qkv(x)                    # (B, N, 3D)
        qkv = qkv.reshape(B, N, 3, self.num_heads, self.head_dim)
        qkv = qkv.permute(2, 0, 3, 1, 4)     # (3, B, H, N, d_h)
        q, k, v = qkv[0], qkv[1], qkv[2]     # each (B, H, N, d_h)

        attn = (q @ k.transpose(-2, -1)) * self.scale   # (B, H, N, N)
        attn = attn.softmax(dim=-1)          # rows sum to 1, per head
        attn = self.attn_drop(attn)

        x = attn @ v                         # (B, H, N, d_h)
        x = x.transpose(1, 2).reshape(B, N, D)   # concat heads
        return self.proj_drop(self.proj(x))

Why the fused qkv? Three separate Linears would produce identical math; one (D, 3D) matmul is simply faster (one kernel launch, better GEMM shape) and is the idiom you'll see in timm, nanoGPT, and every serious codebase. Why scale before softmax, not after the matmul chain? It must hit the logits; scaling attention outputs later wouldn't fix softmax saturation. Note also transpose(1, 2) before the final reshape — forgetting it silently interleaves head outputs across tokens, a classic bug that still type-checks. (In production you'd let F.scaled_dot_product_attention pick a FlashAttention kernel — see module 08 — but interviews want this explicit version.)

3 · MLP and the block

block.py
class MLP(nn.Module):
    """Per-token: expand 4×, gate with GELU, project back."""
    def __init__(self, dim, hidden_dim, drop=0.):
        super().__init__()
        self.fc1 = nn.Linear(dim, hidden_dim)
        self.act = nn.GELU()
        self.fc2 = nn.Linear(hidden_dim, dim)
        self.drop = nn.Dropout(drop)

    def forward(self, x):
        x = self.drop(self.act(self.fc1(x)))
        x = self.drop(self.fc2(x))
        return x


class Block(nn.Module):
    """Pre-norm: the stream x is only ever added to."""
    def __init__(self, dim, num_heads, mlp_ratio=4., drop=0., attn_drop=0.):
        super().__init__()
        self.norm1 = nn.LayerNorm(dim, eps=1e-6)   # ViT uses eps=1e-6, not the 1e-5 default
        self.attn = Attention(dim, num_heads, attn_drop, drop)
        self.norm2 = nn.LayerNorm(dim, eps=1e-6)
        self.mlp = MLP(dim, int(dim * mlp_ratio), drop)

    def forward(self, x):
        x = x + self.attn(self.norm1(x))     # tokens talk to each other
        x = x + self.mlp(self.norm2(x))      # each token thinks alone
        return x

The two comments in Block.forward are the sentence to say in interviews: attention is the only cross-token operation in the entire network; everything else is per-token. That factorization — communicate, then compute — is the transformer.

4 · Assembly

vit.py
class VisionTransformer(nn.Module):
    def __init__(self, img_size=224, patch_size=16, in_chans=3,
                 num_classes=1000, embed_dim=768, depth=12,
                 num_heads=12, mlp_ratio=4., drop=0.):
        super().__init__()
        self.patch_embed = PatchEmbed(img_size, patch_size, in_chans, embed_dim)
        num_patches = self.patch_embed.num_patches           # 196

        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.pos_drop = nn.Dropout(drop)

        self.blocks = nn.ModuleList([
            Block(embed_dim, num_heads, mlp_ratio, drop) for _ in range(depth)
        ])
        self.norm = nn.LayerNorm(embed_dim, eps=1e-6)        # final LN (pre-norm needs it)
        self.head = nn.Linear(embed_dim, num_classes)

        nn.init.trunc_normal_(self.pos_embed, std=0.02)      # paper/timm init
        nn.init.trunc_normal_(self.cls_token, std=0.02)
        self.apply(self._init_weights)

    def _init_weights(self, m):
        if isinstance(m, nn.Linear):
            nn.init.trunc_normal_(m.weight, std=0.02)
            if m.bias is not None:
                nn.init.zeros_(m.bias)
        elif isinstance(m, nn.LayerNorm):
            nn.init.ones_(m.weight); nn.init.zeros_(m.bias)

    def forward(self, x):                    # (B, 3, 224, 224)
        B = x.shape[0]
        x = self.patch_embed(x)              # (B, 196, D)
        cls = self.cls_token.expand(B, -1, -1)   # broadcast, no copy per image
        x = torch.cat([cls, x], dim=1)       # (B, 197, D)
        x = self.pos_drop(x + self.pos_embed)    # position is *added*, not concatenated
        for blk in self.blocks:
            x = blk(x)
        x = self.norm(x)
        return self.head(x[:, 0])            # read the CLS row only -> (B, 1000)

Details that get probed. (1) expand vs repeat: expand creates a broadcast view — no memory copy; gradients from all B images still accumulate into the single shared [CLS] parameter. (2) Position embedding is added, not concatenated — it keeps D fixed and lets position and content share the same subspaces; concatenation would work but grows every weight matrix downstream. (3) Why a final norm? In pre-norm nets the stream itself is never normalized, so its magnitude grows with depth; the last LN tames it before the head reads. (4) trunc_normal_(std=0.02): small init keeps early attention logits small (near-uniform attention at start), which stabilizes the first steps.

5 · Prove it works

sanity.py — output shown as actually produced
model = VisionTransformer()                      # ViT-B/16 defaults
print(sum(p.numel() for p in model.parameters()))   # 86567656  ✓ canonical count

out = model(torch.randn(2, 3, 224, 224))
assert out.shape == (2, 1000)                    # ✓

# overfit sanity: a tiny ViT on one fixed batch — loss must collapse
tiny = VisionTransformer(img_size=32, patch_size=8, num_classes=10,
                         embed_dim=64, depth=2, num_heads=4)
opt = torch.optim.AdamW(tiny.parameters(), lr=1e-3)
xb, yb = torch.randn(8, 3, 32, 32), torch.randint(0, 10, (8,))
for _ in range(30):
    loss = F.cross_entropy(tiny(xb), yb)
    opt.zero_grad(); loss.backward(); opt.step()
# loss: 2.259 -> 0.411   ✓ the wiring is correct
The overfit-one-batch test is the single most useful debugging habit to mention in interviews: a correctly wired model must be able to memorize 8 samples. If it can't, the bug is in the graph (a detached tensor, a wrong reshape, a frozen parameter), not in the hyperparameters.

Checkpoint 05

You forget transpose(1, 2) before the final reshape in Attention. What happens?

(B, H, N, d_h) reshaped straight to (B, N, D) has the right element count, so it silently misassigns which token owns which features. Shape-compatible bugs are the dangerous ones — hence the overfit test.

Why must the final LayerNorm exist in a pre-norm ViT?

Every block adds into x without normalizing it. After 12 additions the stream's magnitude is depth-dependent; norm-then-read makes the head's input well-scaled. (Logits summing to one is softmax's job, at loss time.)

Module 06

NumPy: forward & backward, by hand

Autograd is a luxury. This module rebuilds ViT with every gradient derived on paper and checked against PyTorch to 10⁻¹⁶ — the deepest possible answer to "do you actually understand backprop?"

The architecture is the one you just built; what's new is the backward pass. Three derivations do all the work — softmax, LayerNorm, and attention — and everything else is the chain rule over matmuls. One convention first: for Y = XW + b, the three gradients every interviewer expects instantly:

∂L/∂X = G W    ∂L/∂W = XG    ∂L/∂b = Σrows G   where G = ∂L/∂Y

Memorize by shape: GWᵀ is the only product with X's shape; XᵀG the only one with W's. Shapes are the error-correcting code of backprop.

Derivation 1 — softmax

For p = softmax(z): ∂pᵢ/∂zⱼ = pᵢ(δᵢⱼ − pⱼ). Contract that Jacobian with the upstream gradient g and it collapses to a one-liner:

∂L/∂z = p ⊙ (g − ⟨g, p⟩)   — elementwise, minus a single scalar per row
softmax + backward
def softmax(x, axis=-1):
    x = x - x.max(axis=axis, keepdims=True)  # shift-invariance -> numerical stability
    e = np.exp(x)
    return e / e.sum(axis=axis, keepdims=True)

def softmax_grad(p, dp, axis=-1):
    # dL/dz = p * (dp - sum(dp * p)) : the full Jacobian, without materializing it
    return p * (dp - (dp * p).sum(axis=axis, keepdims=True))

Note the max-subtraction: softmax is invariant to shifting its input, and shifting by the max means the largest exponent is e⁰ = 1 — no overflow ever. Interviewers ask for exactly this trick.

Derivation 2 — LayerNorm

Forward: x̂ = (x − μ)/√(σ² + ε), y = γ·x̂ + β, with μ, σ² over the feature axis. The subtlety: x affects y through three routes — directly, through μ, and through σ². Collecting all three terms (the full derivation is a standard exercise; do it once on paper) gives a closed form worth memorizing:

∂L/∂x = 1√(σ²+ε) [ g − mean(g) − x̂ ⊙ mean(g ⊙ x̂) ]   g = g ⊙ γ
layernorm + backward
class LayerNorm:
    def __init__(self, dim, eps=1e-6):
        self.g = np.ones(dim); self.b = np.zeros(dim); self.eps = eps

    def forward(self, x):
        self.mu = x.mean(-1, keepdims=True)
        self.var = x.var(-1, keepdims=True)          # biased variance, like torch
        self.xhat = (x - self.mu) / np.sqrt(self.var + self.eps)
        return self.g * self.xhat + self.b

    def backward(self, dout):
        D = dout.shape[-1]
        self.dg = (dout * self.xhat).reshape(-1, D).sum(0)   # per-feature, over all tokens
        self.db = dout.reshape(-1, D).sum(0)
        dxhat = dout * self.g
        istd = 1.0 / np.sqrt(self.var + self.eps)
        return istd * (dxhat
                       - dxhat.mean(-1, keepdims=True)                    # via mu
                       - self.xhat * (dxhat * self.xhat).mean(-1, keepdims=True))  # via var

Read the return as: "the naive gradient, corrected so the output can't change the mean or the variance the layer just removed." The two subtracted means are exactly the projections onto the directions LayerNorm quotients out.

Derivation 3 — attention backward

Forward chain: S = QKᵀ/√d → A = softmax(S) → O = AV. Reverse each arrow, remembering the product rule at both matmuls:

∂L/∂A = GOV   ∂L/∂V = AGO   ∂L/∂S = softmax_grad(A, ∂L/∂A)/√d   ∂L/∂Q = (∂L/∂S)K   ∂L/∂K = (∂L/∂S)Q
attention backward — mirrors the forward, arrow by arrow
def backward(self, dout):                 # dout: (B, N, D)
    B, N, D = dout.shape
    d_out = self.proj.backward(dout)      # back through W_O
    d_out = d_out.reshape(B, N, self.H, self.dh).transpose(0, 2, 1, 3)  # un-concat heads

    dA = d_out @ self.v.transpose(0, 1, 3, 2)         # O = A V  (product rule, half 1)
    dV = self.A.transpose(0, 1, 3, 2) @ d_out         #          (product rule, half 2)
    dS = softmax_grad(self.A, dA) / np.sqrt(self.dh)  # through softmax, then the 1/√d
    dQ = dS @ self.k                                  # S = Q Kᵀ/√d
    dK = dS.transpose(0, 1, 3, 2) @ self.q

    dqkv = np.stack([dQ, dK, dV])                     # (3, B, H, N, d_h)
    dqkv = dqkv.transpose(1, 3, 0, 2, 4).reshape(B, N, 3 * D)  # exact inverse of fwd reshape
    return self.qkv.backward(dqkv)

Two things to internalize. Every transpose in backward is the inverse of one in forward — write the forward reshapes down and mirror them, don't improvise. And the residual connections in the block make backward almost embarrassingly simple: x + f(x) means the incoming gradient is copied to both paths and summed:

block backward — why residuals make deep nets trainable, in two lines
def backward(self, dout):
    dout = dout + self.ln2.backward(self.mlp.backward(dout))   # skip + branch
    dout = dout + self.ln1.backward(self.attn.backward(dout))  # skip + branch
    return dout   # note: dout reaches the previous block *undiminished* via the bare '+'

There is the vanishing-gradient story in executable form: whatever the branches contribute, the identity term guarantees the gradient that leaves this block is at least the gradient that entered it, unmodified. That's the sentence to say when asked "why do residuals help?"

Patchify without a conv, and the check that makes it true

patchify: pure reshape gymnastics
def patchify(self, x):                    # (B, C, H, W) -> (B, N, P*P*C)
    B, C, H, W = x.shape; p = self.patch
    x = x.reshape(B, C, H // p, p, W // p, p)   # factor each spatial axis into (grid, within)
    x = x.transpose(0, 2, 4, 3, 5, 1)           # (B, gh, gw, p, p, C): grid axes out front
    return x.reshape(B, self.N, p * p * C)      # flatten grid -> tokens, patch -> features
verification — actual output of the course's numpy build
loss  numpy 2.344725  torch 2.344725          # identical forward
  embed.W        max|Δgrad| = 4.4e-17          # every hand gradient vs autograd
  cls            max|Δgrad| = 5.6e-16
  pos            max|Δgrad| = 5.6e-16
  blk0.qkv.W     max|Δgrad| = 6.9e-17
  blk0.ln1.g     max|Δgrad| = 4.1e-18
  blk1.fc1.W     max|Δgrad| = 6.2e-17
  head.W         max|Δgrad| = 3.9e-16
all gradients match torch autograd — OK
step  0 loss 2.3447   ...   step 50 loss 0.1728   # plain SGD, pure numpy, it trains
Interview angleIf you can write softmax_grad and the LayerNorm backward cold, you are ahead of most candidates. The gradient-checking habit itself is also a talking point: compare against autograd (or finite differences: (L(θ+ε)−L(θ−ε))/2ε) on a tiny model in float64 — checking in float32 at real scale produces false alarms from rounding alone.

Checkpoint 06

Backprop through y = x + f(x): the gradient flowing to x is…

Addition's Jacobian w.r.t. each input is the identity — gradients copy, they don't split. The bare dy term is why depth stops being an optimization obstacle.

Why subtract the row max inside softmax?

exp(z−c) appears in both numerator and denominator, so any constant c cancels exactly. Choosing c = max makes the arithmetic safe without changing a single output value.

In ∂L/∂W = XᵀG for a Linear layer applied to (B·N, D) inputs, what does the matrix product implicitly do?

The contraction over the row axis is a sum over every (batch, token) that used W. "Shared parameter ⇒ summed gradient" is a rule worth saying explicitly; averaging happens only if the loss itself averages.

Module 07

Training recipes

Architecture is a third of the story. ViTs live or die by their training recipe — and the recipe's logic follows directly from the missing inductive biases of module 01.

The optimizer: AdamW, weight decay done right

Every serious ViT trains with AdamW. Two reasons it beats SGD+momentum here (while ResNets are happy with SGD): transformer gradients are far less isotropic — embedding tables, LayerNorm gains, and attention projections have wildly different curvature, and Adam's per-parameter scaling absorbs that; and the "W" matters: classic Adam entangles L2 regularization with the adaptive scaling (the penalty gets divided by √v̂, so high-variance weights are barely regularized). AdamW decouples decay — w ← w − lr·(update + λ·w) — restoring true shrinkage. ViT recipes use unusually large decay (0.1–0.3) as an extra regularizer, and exempt biases, LayerNorm parameters, [CLS], and position embeddings from it (decaying a bias or a norm gain toward zero fights their job, not overfitting).

The schedule: warmup, then cosine

Why warmup is non-optionalAt initialization Adam's second-moment estimate v̂ is built from a handful of noisy gradients, so early steps can be enormous in exactly the wrong directions; and attention starts near-uniform, so early gradients are both large and unrepresentative. A linear ramp over the first few thousand steps (ViT: 10k) lets the moment estimates stabilize before full-size steps. Skip it at LR 1e-3 and the loss reliably spikes or diverges in the first epochs. Afterwards, cosine decay to ~0 — smooth, hyperparameter-free, and near-universal since ViT.

Regularization scales opposite to data

The paper's key recipe table reads like a see-saw. Pre-training on JFT-300M: almost no regularization (no dropout, light decay) — 300M images regularize by existing. Training on ImageNet-1k alone: everything at once — RandAugment, Mixup, CutMix, random erasing, label smoothing 0.1, stochastic depth, dropout, heavy decay. The DeiT paper (module 09) is essentially the discovery of the exact augmentation cocktail that replaces 300M images.

Stochastic depth, the regularizer people forgetWith probability p (often ramped 0 → 0.1+ across depth), skip a block's entire branch for a sample: x + f(x) becomes just x. It's dropout at the resolution of whole residual branches — trains an implicit ensemble of depths, and it composes perfectly with pre-norm residual streams. Ask-me-anything favorite: at inference, scale f(x) by its keep probability (or do nothing, in the timm "residual scaling" variant).

Fine-tuning and the resolution trick

Standard practice: pre-train at 224, fine-tune at 384+. Higher resolution with the same patch size means more tokens (224→384: 196→576 patches) — and a position-embedding table of the wrong length. The fix is elegant and a guaranteed interview question: reshape the 196 learned embeddings back to their 14×14 grid, bicubically interpolate to 24×24, flatten to 576. This works precisely because (module 08) the learned table encodes a smooth 2D layout — smooth signals interpolate well. The [CLS] embedding is carried over unchanged, and a brief fine-tune lets the model adapt.

pos-embed interpolation — the one custom step in every ViT fine-tune
def interpolate_pos_embed(pos, new_grid):        # pos: (1, 1+N, D)
    cls_pe, patch_pe = pos[:, :1], pos[:, 1:]    # split off CLS
    g = int(patch_pe.shape[1] ** 0.5)            # old grid side, e.g. 14
    patch_pe = patch_pe.reshape(1, g, g, -1).permute(0, 3, 1, 2)   # (1, D, 14, 14)
    patch_pe = F.interpolate(patch_pe, size=new_grid,
                             mode="bicubic", align_corners=False)  # (1, D, 24, 24)
    patch_pe = patch_pe.permute(0, 2, 3, 1).reshape(1, new_grid**2, -1)
    return torch.cat([cls_pe, patch_pe], dim=1)  # (1, 1+576, D)

The reference recipes, condensed

SettingViT paper (JFT pre-train)DeiT (ImageNet-only)
OptimizerAdam β=(0.9, 0.999)AdamW
Base LR · schedule~8e-4 · warmup 10k + cosine (pre-train); SGD for fine-tune5e-4 × bs/512 · warmup 5 ep + cosine
Weight decay0.1 (high)0.05
Batch40961024
Epochs7–14 (JFT is huge)300
Augment / regminimal — data is the regularizerRandAugment, Mixup, CutMix, erasing, stoch. depth, label smoothing, repeated aug
Extra trickfine-tune at 384, interpolate pos-embeddistillation token from a CNN teacher
Interview angle"Your ViT diverges early in training — first three things you check?" Good answer: (1) warmup present and long enough; (2) LR scaled for the actual batch size (linear scaling rule) and pre-norm blocks confirmed (post-norm at depth diverges); (3) weight decay accidentally applied to LayerNorm/bias/pos-embed groups. Also know gradient clipping (global norm 1.0) is standard in ViT training and cheap insurance against loss spikes.

Checkpoint 07

Why does AdamW's decoupled decay matter more for transformers than Adam's L2?

That inverse-√v̂ interaction is the entire content of the AdamW paper, and ViT recipes lean on large decay values — which only behave as intended when decoupled.

Fine-tuning 224 → 384 with patch 16. What must change, and how?

Patch projection is per-patch and doesn't care how many patches there are; attention handles any N. Only the learned (1+196, D) table has the wrong length — and its emergent 2D smoothness is what makes interpolation legitimate.

Module 08

Details interviewers love

The findings, quirks, and systems facts that turn a correct answer into a memorable one.

1 · Position embeddings secretly learn the grid

Compute cosine similarity between the learned embedding of each patch position and every other. The result (ViT paper, Fig 7 center): each position is most similar to its 2D neighbors, with visible row and column bands — although training never told the model the tokens came from a grid. The removed inductive bias was re-derived from data. This is also, as module 07 showed, why bicubic interpolation of the table is sound.

2 · Attention distance: CNN-like from the bottom, global from the start

Define a head's "attention distance" as the average image-space distance between a query patch and the patches it attends to, weighted by attention — a receptive-field size for attention. The paper's measurement: in early layers, heads split — some are local (small distance, conv-like), others are already global. By mid-depth, nearly all heads are global. So ViT learns a local-to-global processing hierarchy where CNNs have it imposed — but keeps a few global shortcuts from layer one, which a CNN cannot have. With small datasets the local heads fail to emerge, which is one lens on why ViT under-performs there.

3 · O(N²) in practice, and FlashAttention

For ViT-B/16 at 224², N = 197 — the attention matrix is 197×197 ≈ 39k entries per head: trivial. The quadratic only bites at high resolution (1024² with patch 16 → N = 4096 → 16.8M entries per head per layer). Two responses coexist: change the architecture (Swin's windows, module 09) or change the kernel — FlashAttention computes exact softmax attention without ever materializing the N×N matrix in slow GPU memory, tiling the computation through on-chip SRAM with the online-softmax trick (a running max and running sum let you renormalize incrementally). Memory drops from O(N²) to O(N); the math is unchanged. The one-liner: "FlashAttention is an IO optimization, not an approximation."

4 · [CLS] vs global average pooling

Both work; the paper's appendix shows equal accuracy with separately tuned LRs — a nice example of hyperparameter coupling masquerading as an architecture effect. Practical modern note: for dense tasks (segmentation, depth) you use the patch tokens anyway, and models like DINOv2 expose both a [CLS] summary and patch-level features.

5 · Registers: the attention-sink discovery

"Vision Transformers Need Registers" (Darcet et al., 2023/24): large ViTs spontaneously repurpose a few low-information background patches as scratch storage — those tokens get abnormally high attention norms and pollute attention maps with spiky artifacts. The fix is almost comic: append a few extra learned tokens (registers) with no input and no output role, giving the model somewhere to put its global scratch data. Artifacts vanish, dense-task features improve. Lesson to quote: the [CLS] idea generalizes — transformers benefit from explicit "memory" tokens, and if you don't provide them the model improvises them out of your image.

6 · Robustness and what ViTs actually look at

ViTs lean less on high-frequency texture than CNNs of their era: they're notably more robust to patch-level occlusion and adversarial patches, and attention lets a single layer route around a masked region. But the cliché "CNNs = texture, ViTs = shape" oversimplifies — training recipe and augmentation move this dial more than architecture does (heavily-augmented CNNs also become shape-biased). Saying that nuance is exactly the kind of calibrated claim interviewers reward.

7 · Small numbers worth having memorized

ModelDDepthHeadsMLPParams
ViT-Ti/161921237685.7M
ViT-S/16384126153622M
ViT-B/167681212307286.6M
ViT-L/16102424164096307M
ViT-H/14128032165120632M

Also: head_dim is 64 in every variant (D/H = 64 throughout — a de-facto constant across transformers, big and small); sequence length 197 at 224/patch-16; ViT-L/16 fine-tuned from JFT hit 87.76% ImageNet top-1 in the original paper, and ViT-H/14 88.55%.

Checkpoint 08

FlashAttention speeds up exact attention primarily by…

It's IO-aware scheduling of the same math — bitwise-equivalent results (up to float order), O(N) memory. Approximation-based methods (Performer, Linformer) are a different family.

The "registers" finding shows that large ViTs, without extra tokens, will…

The model needs somewhere to store global computation; denied dedicated slots, it overwrites boring patches. Adding a few input-free register tokens removes the artifacts.

Measured "attention distance" in early ViT layers shows…

Early-layer heads span the local↔global range; depth pushes the population global. Learned hierarchy, plus day-one global shortcuts.

Module 09

The lineage: DeiT → Swin → MAE → DINO

Each successor answers one specific weakness of the original. Learn them as answers to questions, and you can reconstruct the whole timeline on demand.

DeiT (2021) — "must it really take 300M images?"

Data-efficient Image Transformers made ViT trainable on ImageNet-1k alone, in ~3 days on one 8-GPU node — no JFT. Two ingredients. First, the aggressive augmentation/regularization cocktail from module 07 (RandAugment, Mixup, CutMix, erasing, stochastic depth, repeated augmentation) — supplying synthetically the sample diversity that JFT supplied literally. Second, distillation through a token: alongside [CLS], append a learned distillation token whose head is trained to match a RegNet CNN teacher's predictions (hard-label distillation worked best). The CNN's inductive biases leak into the ViT through the distillation loss — the student gets convolutional priors without convolutional architecture. DeiT-B reached ~83.4% ImageNet top-1 with distillation; the "data hunger" objection to ViT died here.

Swin (2021) — "how do we afford dense prediction?"

Detection and segmentation need high-resolution feature maps, where global attention's O(N²) is unpayable (module 08's arithmetic). Swin restores the two CNN structural ideas ViT discarded, in transformer form. Windowed attention: compute self-attention only within 7×7-patch local windows — cost becomes linear in image size. Shifted windows: alternate layers offset the window grid by half a window, so information crosses window borders every other layer (otherwise windows would be permanently isolated islands). Hierarchy: stages of patch-merging (concatenate each 2×2 neighborhood of tokens, project down) halve resolution and double channels — a feature pyramid, /4 → /8 → /16 → /32, that drops straight into detection frameworks like a ResNet backbone. Swin also swaps learned absolute positions for relative position biases added to attention logits. Trade to articulate: Swin re-buys CNN-like efficiency by re-imposing locality — a deliberate step back from ViT's purity, and the price is that plain ViT scales better with data while Swin wins at dense tasks per FLOP.

MAE (2022) — "what's the BERT moment for vision?"

Masked Autoencoders answer: mask 75% of patches (BERT masks 15% of words — images are far more redundant, so the task must be made much harder to force semantic learning). The asymmetric design is the engineering jewel: the encoder (a plain ViT) sees only the 25% visible patches — no mask tokens — so pre-training compute drops ~4×; a lightweight decoder receives the encoded patches plus shared learnable [MASK] tokens with position embeddings and reconstructs raw pixels (per-patch-normalized, MSE on masked patches only). After pre-training, throw the decoder away. Self-supervised ViT-Huge fine-tuned to 87.8% ImageNet — proof that ViTs can pre-train from unlabeled pixels alone, and that the "needs labels at scale" era was over.

DINO (2021) & DINOv2 (2023) — "can features be useful without any fine-tuning?"

DINO is self-distillation: a student ViT sees aggressive local+global crops, a teacher — an exponential moving average of the student's own weights — sees mild global crops, and the student matches the teacher's soft output distribution (cross-entropy after centering + sharpening, the two tricks that prevent representational collapse). No labels anywhere. The astonishing emergent result: the [CLS] attention maps of the final layer segment objects — clean foreground masks nobody asked for — and k-NN classification on frozen features works nearly as well as linear probing. DINOv2 scaled the recipe (curated 142M-image dataset, ViT-g, plus an iBOT-style masked-token loss and engineering like KoLeo regularization) into general-purpose frozen backbones whose features transfer to depth, segmentation, and retrieval without fine-tuning — the closest vision has to a foundation-model feature extractor, and (with registers added) the standard baseline today.

ViT 2020 needs less data? dense tasks? no labels? frozen features? DeiT ’21aug + distill token Swin ’21shifted windows MAE ’22mask 75%, rebuild DINO ’21/v2 ’23self-distillation ImageNet-only ViTs work detection/segmentation backbones label-free pre-training, 4× cheaper foundation features, no fine-tune
Fig 4 · The lineage as questions. Each branch removes one objection to the 2020 original; together they explain why plain-ViT-plus-good-pre-training (MAE/DINOv2 style) is the modern default while Swin owns efficiency-critical dense pipelines.
Interview angleThe synthesis question is "which would you use today, and why?" A strong answer: frozen DINOv2 (with registers) for feature extraction and retrieval; plain ViT + MAE-style pre-training when you'll fine-tune end-to-end; Swin (or ConvNeXt) when serving cost at high resolution dominates; DeiT's recipe whenever you must train from scratch on modest data. Naming the constraint each choice answers is the point — not the model names.

Checkpoint 09

Why does MAE mask 75% when BERT masks 15%?

Redundancy sets the difficulty dial; the encoder-only-sees-visible-patches design turns the high ratio into a compute win simultaneously.

Remove the window shifting from Swin. What breaks?

Cost stays linear either way; the shift is purely the communication mechanism (patch merging between stages would still mix a little, but within-stage receptive fields freeze at window size).

In DINO, what prevents the student and teacher from collapsing to a constant output?

Centering fights one-dimension dominance, sharpening fights uniformity; balanced against each other (with the slow EMA teacher) they keep the output distribution informative with no labels and no negative pairs.

Module 10

Interview drills

Spaced repetition for the whole course. Read the question, answer out loud in full sentences, then open the card. If you hesitated, you haven't finished the module it points to.

Rapid-fire cards

M02Derive why attention scores are divided by √d.

For i.i.d. zero-mean unit-variance entries, q·k = Σqᵢkᵢ has variance d (variances of independent terms add). Std √d grows with head size; softmax of ±√d-scale logits saturates toward one-hot, and its Jacobian p(δ−p) then vanishes. Dividing by √d restores unit variance for any d, keeping softmax in its responsive regime.

M02Complexity of self-attention in sequence length N — time and memory, and where it bites for images.

Time O(N²·d) (score matrix + value mixing), memory O(N²) per head for the attention matrix (O(N) with FlashAttention). N = (H/P)²: at 224/16, N=197 — negligible; doubling resolution quadruples N and ×16s the matrix. High-res dense prediction is where it hurts — hence Swin's windows and FlashAttention.

M03Multi-head attention: what does it cost and what does it buy versus one big head?

Parameters and FLOPs are identical — H heads of dim D/H tile the same D×D projections. It buys H independent attention distributions per layer (different relevance criteria simultaneously); it costs per-head rank — each head compares in only D/H dimensions. Head dim is ~64 in virtually every deployed transformer.

M03Pre-norm vs post-norm — the gradient argument.

Pre-norm: out = x + f(LN(x)), so ∂out/∂x = I + (branch terms) — an unattenuated identity path from loss to layer 1; stable at depth without warmup gymnastics. Post-norm: out = LN(x + f(x)) puts a normalization Jacobian on the trunk itself, compounding per block. That is why ViT (and GPT-2 onward) are pre-norm, with one final LN to rescale the stream before readout.

M04Walk the shapes: batch 32 of 224×224 through ViT-B/16 to logits.

(32,3,224,224) → conv P16 → (32,768,14,14) → flatten+transpose → (32,196,768) → +[CLS] → (32,197,768) → +pos (1,197,768 broadcast) → 12 blocks, shape-invariant → final LN → take row 0 → (32,768) → head → (32,1000).

M04Count ViT-B/16's parameters from the config (768/12/12, patch 16, 1000 classes).

Patch embed 3·256·768+768 = 590,592. Pos 197·768 = 151,296; CLS 768. Block: QKV 768·2304+2304; proj 768²+768; MLP 768·3072+3072 + 3072·768+768; 2 LN = 4·768 → 7,087,872; ×12 = 85,054,464. Final LN 1,536; head 769,000. Total 86,567,656 ≈ 86.6M — two-thirds of each block in the MLP.

M04Why is patch embedding a Conv2d, and when is that equivalence exact?

Conv2d(C→D, kernel=P, stride=P): non-overlapping windows mean each output is one dot product between a flattened patch and shared weights — exactly flatten-then-Linear. Equivalence is exact iff kernel = stride (no overlap, no padding). One fused op, better kernels, same math.

M06Write softmax backward from memory.

dz = p * (dp − (dp*p).sum(-1, keepdims=True)). From the Jacobian pᵢ(δᵢⱼ−pⱼ): the diagonal gives p⊙dp, the rank-one part subtracts p·⟨dp,p⟩. Sanity: if dp is constant across the row, dz = 0 — softmax ignores uniform shifts.

M06Backprop through a residual y = x + f(x): what reaches x?

dy flows to x twice: identically through the skip (Jacobian I) and transformed through the branch — dx = dy + f′ᵀdy, summed, not split. The bare dy term is the vanishing-gradient cure: gradient at block 1 includes the loss gradient verbatim, regardless of depth.

M07Fine-tune 224 → 384: what breaks and what's the fix?

Token count changes (196 → 576), so the learned pos-embed table has the wrong length — everything else is length-agnostic. Fix: split off [CLS], reshape 196 → 14×14×D, bicubic-interpolate to 24×24, flatten back, re-append [CLS], fine-tune briefly. Valid because the learned table is 2D-smooth (it self-organized into a grid).

M07Which parameters are excluded from weight decay, and why?

Biases, LayerNorm γ/β, [CLS], position embeddings. Decay fights overfitting via shrinking effective capacity of multiplicative weight matrices; shrinking a bias or norm gain toward zero just distorts activations without regularizing anything meaningful. In code: two optimizer param-groups.

M08What are attention sinks/registers in large ViTs?

Trained large ViTs repurpose a few low-information patches as global scratch: outlier-norm tokens, spiky attention maps, degraded dense features. Adding a handful of learned, input-free register tokens gives that computation a home; artifacts disappear (DINOv2-with-registers is the standard modern config). Moral: transformers want explicit memory slots.

M09DeiT's distillation token — mechanism and why it beats plain logit distillation.

A second learned token alongside [CLS] with its own head trained on the CNN teacher's (hard) labels; at inference, average both heads. Through attention, the token can gather teacher-relevant evidence differently from [CLS], and the ViT inherits convolutional inductive bias through the loss instead of the architecture. Outperformed distilling into the [CLS] head directly.

M09Swin in three mechanisms.

(1) Attention within 7×7 windows → linear cost in image area. (2) Shift the window grid by half a window on alternate layers → cross-window communication. (3) Patch-merging stages (2×2 concat + project) → pyramid /4→/32 that plugs into detection heads. Plus relative position biases in the attention logits.

M09MAE's two asymmetries and what each buys.

Masking asymmetry: 75% hidden — high redundancy of images demands a hard task for semantic features. Compute asymmetry: heavyweight encoder sees only visible 25% (no mask tokens); tiny decoder handles masks+positions and reconstructs normalized pixels, then is discarded. Result: BERT-style self-supervision at ~4× lower pre-training cost.

Coding drills — do these cold, in order of difficulty

  • Drill 1. scaled_dot_product_attention(q, k, v) for shape (B, H, N, d), NumPy or PyTorch, with stable softmax. Target: under 4 minutes, no reference.
  • Drill 2. Full Attention module including the qkv-fusion reshape dance. The permute indices must come out right first try — narrate shapes as you type.
  • Drill 3. patchify as pure reshape/transpose (no conv, no loop), plus its exact inverse unpatchify. This is also MAE-interview territory.
  • Drill 4. Entire ViT forward in PyTorch from an empty file, then verify 86,567,656 on the B/16 config. Target: under 25 minutes.
  • Drill 5. NumPy LayerNorm forward + backward, gradient-checked against finite differences in float64.
  • Drill 6. Position-embedding interpolation for a resolution change (module 07's snippet) — from memory.
  • Drill 7 (boss level). NumPy attention backward, checked against autograd. When this matches to ~1e-16, you own this architecture.

Discussion questions to rehearse out loud

  • Design a ViT for 1024×1024 medical images on one A100 — walk through patch size, windowing vs FlashAttention, pos-embed strategy, and what you'd pre-train on given 50k labeled + 5M unlabeled scans. (Good answers combine modules 04, 07, 08, 09.)
  • Your ViT trains fine but attention maps are noisy spikes on background patches — what's happening and what are two fixes? (Registers; or train smaller/shorter — module 08.)
  • Argue both sides: "Swin's return to locality was the right call" vs "plain ViT + scale was the right call." (The 2023+ evidence — DINOv2, MAE-scaled plain ViTs, FlashAttention erasing much of the efficiency gap — favors plain ViT for representation learning; Swin persists in latency-bound dense pipelines.)
  • Why did BERT-style masked pre-training take two years longer to work in vision than in NLP? (Redundancy → needed 75% masking + pixel targets; also tokenization — words are discrete and semantic, patches aren't, which is why BEiT tried discrete visual tokens first and MAE showed raw pixels suffice with the right ratio.)
Suggested cycle to actually retain all this: day 1 modules 01–04 · day 2 modules 05–06 with drills 1–4 · day 3 modules 07–09 · day 4 drills 5–7 · then every few days, this module only. Re-derive, don't re-read.

Final checkpoint · the classic curveballs

A colleague replaces [CLS] readout with mean-pooling over patch tokens and accuracy drops 5 points. The most likely culprit?

A known trap: the ViT appendix shows GAP ≈ [CLS] with separately tuned LR. Attributing recipe effects to architecture is exactly the sloppiness interviews screen for.

At inference you feed a 448×448 image to a stock ViT-B/16 trained at 224, changing nothing. What happens mechanically?

Conv and attention are size-agnostic; the single length-dependent parameter is pos_embed (1, 197, D) vs 785 tokens. Hence interpolation as the canonical fix.

Batch 1, ViT-B at 1024² with patch 16 (N = 4097): the dominant memory consumer in a naive implementation is…

4097² ≈ 16.8M entries × 12 heads × 4 bytes ≈ 0.8GB per layer pre-softmax in fp32. This arithmetic — activations dominate at scale — motivates FlashAttention/windowing and is a favorite systems probe.

Where to go next

Primary sources, in reading order: Attention Is All You Need (Vaswani 2017) · An Image is Worth 16×16 Words (Dosovitskiy 2020) · Training data-efficient image transformers (Touvron 2021) · Swin Transformer (Liu 2021) · Masked Autoencoders Are Scalable Vision Learners (He 2021) · Emerging Properties in Self-Supervised Vision Transformers (Caron 2021) · DINOv2 (Oquab 2023) · Vision Transformers Need Registers (Darcet 2023). For code archaeology, read timm's vision_transformer.py — the field's de-facto reference implementation.

The other course on this page