ml-models / Model architectures / Vision Transformer core

Vision Transformer

The Vision Transformer (ViT) reads an image the way a language model reads a sentence: it cuts the image into 16 × 16 patches, turns each patch into a token with one shared linear map, adds a learned position to each token, and runs a plain transformer encoder. This page follows a 224 × 224 image to 197 tokens of width 768, shows why the patch embedding is exactly a convolution with stride equal to its kernel, and counts ViT-B/16's 86,567,656 parameters.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Patches
  5. Patch embedding as a strided convolution
  6. Class token and position embeddings
  7. The encoder
  8. The classification head
  9. Inductive bias and data
  10. Worked example
  11. Patchify by hand: the convolution and the linear layer agree
  12. Shape walk through ViT-B/16
  13. Counting ViT-B/16's parameters
  14. Resolution, patch size and attention cost
  15. Implementation
  16. Tradeoffs
  17. Connections
  18. Read first
  19. Read next
  20. References

Problem

Convolutional networks dominated vision for a decade because they build in two assumptions: nearby pixels matter together (locality), and a cat is a cat wherever it appears (translation equivariance). Meanwhile the transformer took over language with almost no built-in assumptions, and it kept getting better as data and models grew. Can the same architecture, unchanged, learn vision?

The obvious move fails. Treat every pixel as a token and a 224 × 224 image becomes 50,176 tokens. Self-attention compares every token with every other, so one head in one layer would compute 2,517,630,976 scores, around 2.5 billion. The question is how to turn an image into a sequence short enough for attention while keeping the architecture generic.

Intuition

Cut the picture into tiles, the way you might cut a photo into a 14 × 14 grid of squares. Each tile is small enough to describe with one vector, and there are only 196 of them. Treat each tile as a word: flatten its pixels, multiply by one learned matrix so every tile lands in the same 768-dimensional space, and you have a sentence of 196 tokens. Attention then lets any tile look at any other from the very first layer; a CNN needs many layers before a pixel in one corner can influence a pixel in the other.

Two pieces are borrowed from BERT's encoder-only design. Attention ignores order, so each token gets a learned position embedding that says which tile it came from. And one extra token, the class token, starts as a learned vector with no image content; it attends to all the patches in every layer, and its final state is what the classifier reads. It is an empty seat at the table whose job is to summarize the conversation.

A 224 by 224 image becomes 196 patch tokens plus a class token On the left, a square image divided by a 14 by 14 grid into 196 patches of 16 by 16 pixels; the first three patches of the top row are highlighted, as is the last patch at the bottom right. An arrow leads to a box labelled patch embedding, Conv2d with 3 input channels, 768 output channels, kernel 16 and stride 16, which maps each patch of 768 values to a vector of 768. A second arrow leads to a column of tokens: the class token first, then patch 1, patch 2, patch 3, a gap, and patch 196, each with a position embedding added. The column has shape 197 by 768. 224 × 224 × 3 pixels 14 × 14 patches of 16 × 16 × 3 patch embedding Conv2d(3, 768, 16, stride 16) 768 values → 768-d token [class] + pos[0] patch 1 + pos[1] patch 2 + pos[2] patch 3 + pos[3] patch 196 + pos[196] ⋮ [197, 768]

Mechanics

Patches

An image x of height H, width W and C channels is cut into non-overlapping P × P patches. There are N = HW / P² of them, and each flattens to a vector of P²C numbers. For ViT-B/16 on a 224 × 224 RGB image: N = (224 / 16)² = 14² = 196, and each patch holds 16 · 16 · 3 = 768 numbers. (That 768 matching the model width is a coincidence of the B/16 choice; ViT-L/16 has the same patches and width 1024.) The image side must be a multiple of P.

Patch embedding as a strided convolution

Each flattened patch is multiplied by one shared matrix E of shape [P²C, D] and a bias is added. That is a linear layer applied to every patch. It is also exactly a convolution with D output channels, kernel size P and stride P: with stride equal to the kernel, the windows tile the image without overlap, each output position sees exactly one patch, and every position uses the same weights. The conv weight [D, C, P, P] reshaped to [D, C·P·P] is Eᵀ, as long as the patch is flattened in the same channel, row, column order. Implementations use the convolution because it does the cutting, flattening and multiplying in one fused kernel.

The convolution returns [b, D, 14, 14]: a 14 × 14 grid of 768-vectors. flatten(2) merges the two grid axes into [b, D, 196] in row-major order (patch 1 is the top-left, patch 14 the top-right, patch 15 starts the second row), and transpose(1, 2) gives the sequence layout [b, 196, D] the encoder expects.

Class token and position embeddings

z0 = [xclass; xp1E; xp2E; …; xpNE] + Epos,   E ∈ ℝ(P²C)×D,   Epos ∈ ℝ(N+1)×D

The class token xclass is one learned vector of size D, copied for every image in the batch and prepended, so the sequence has N + 1 = 197 tokens. Position embeddings are a learned table with one row per token position, added (not concatenated) to the tokens. ViT uses plain 1-D learned positions: the paper found that 2-D-aware and relative variants gave no measurable gain, and the learned rows end up encoding the 2-D layout on their own (each position's embedding is most similar to those in its own row and column). Without them, attention would see a bag of patches and could not tell a face from a scrambled face.

Because the table has a fixed number of rows, a model trained at 224 × 224 cannot directly read a 384 × 384 image, which has 24 × 24 = 576 patches. The standard fix keeps the class-token row, reshapes the 196 patch rows into their 14 × 14 grid, resizes that grid to 24 × 24 with 2-D bicubic interpolation, and flattens it back. Fine-tuning at a higher resolution than pre-training is common, so this interpolation is part of every ViT checkpoint loader.

The encoder

The encoder is the standard transformer encoder in its pre-norm form (LayerNorm before each sublayer, residual around it), with GELU in the MLP. For layers ℓ = 1 … L:

z′ℓ = MSA(LN(zℓ−1)) + zℓ−1,    zℓ = MLP(LN(z′ℓ)) + z′ℓ

MSA is multi-head self-attention with 12 heads of width 64, with no causal mask: every patch may attend to every other patch and to the class token. The MLP is Linear(768, 3072) → GELU → Linear(3072, 768). The shape stays [b, 197, 768] through all 12 layers. In PyTorch terms one layer is nn.TransformerEncoderLayer(768, 12, 3072, activation="gelu", norm_first=True, batch_first=True), and the stack is nn.TransformerEncoder(layer, 12); the paper's models use LayerNorm ε = 10⁻⁶ rather than PyTorch's default 10⁻⁵.

y = Linear(LN(zL0))   ∈ ℝK

After the last layer a final LayerNorm is applied and only token 0, the class token, is kept: [b, 197, 768] → [b, 768]. A linear head maps it to K class logits, trained with cross-entropy. During pre-training the paper used an MLP head with one hidden layer; for fine-tuning it is replaced by a single zero-initialized linear layer sized for the new task. Averaging all patch tokens (global average pooling) instead of reading the class token works as well once the learning rate is tuned for it, and many later models do that.

Inductive bias and data

The only image-specific choices in ViT are the patch cut and the position-embedding resize. Everything else, including which patches relate to which, must be learned. That is a weaker prior than a CNN's, and it shows: trained on ImageNet-1k (1.3M images) alone, ViT-B lagged comparable ResNets. Pre-trained on ImageNet-21k (14M images) or JFT-300M and then fine-tuned, ViT matched or beat them at lower pre-training compute. DeiT (Touvron et al., 2021) later closed the gap on ImageNet-1k alone with heavy augmentation, regularization and distillation from a CNN teacher through an extra distillation token. Early layers still learn local filters, because the data reward them; the architecture just does not force them.

ComponentShape/ParamsMeaningSource
nn.Conv2dConv2d(3, 768, kernel_size=16, stride=16); weight [768, 3, 16, 16], bias [768]The patch embedding: one shared linear map per non-overlapping patch.conv.py 388–565
Conv2d.__init__stride=1, padding=0, dilation=1, groups=1, bias=TrueDefaults; ViT overrides only the stride.512–544
Conv2d._conv_forward[b, 3, 224, 224] → [b, 768, 14, 14]Calls F.conv2d with the weight, bias and stride.546–562
nn.Flattenstart_dim=1, end_dim=-1 by default; ViT needs Flatten(2)Merges the 14 × 14 grid into 196 positions, row-major.flatten.py 12–62
class tokennn.Parameter [1, 1, 768]Learned summary token, expanded over the batch and prepended.Dosovitskiy et al. (2020), eq. 1
position embeddingsnn.Parameter [1, 197, 768]One learned row per position, added to the tokens.Dosovitskiy et al. (2020), eq. 1
nn.TransformerEncoderLayerd_model, nhead, dim_feedforward=2048, dropout=0.1, layer_norm_eps=1e-5, norm_first=FalseOne encoder block; ViT sets 768, 12, 3072, GELU, norm_first=True.transformer.py 663–983
pre-norm branchx = x + _sa_block(norm1(x)); x = x + _ff_block(norm2(x))LayerNorm inside each residual branch.946–958
_sa_block / _ff_block[b, 197, 768] → [b, 197, 768]Self-attention and MLP sublayers with dropout.961–983
nn.TransformerEncodernum_layers deep copies of the layer, optional final normThe stack; pass a LayerNorm as norm for ViT's final LN.transformer.py 320–555
nn.MultiheadAttentionin_proj_weight [2304, 768], out_proj 768 → 768The 12-head attention inside each layer.activation.py 1090–1573
nn.GELUapproximate="none"The MLP nonlinearity.activation.py 778–823
nn.LayerNormweight and bias [768]; ε = 10⁻⁶ in ViTPre-norms and the final norm.normalization.py 105–236
nn.Linear head[b, 768] → [b, 1000]Class logits from the class token.linear.py 53–140
The ViT encoder block repeated 12 times, then the class token goes to the head Top row, left to right: the token sequence z zero of shape b by 197 by 768; inside a dashed frame labelled times 12, a LayerNorm, multi-head self-attention with 12 heads, an addition joined by a residual line that skips the first two boxes, another LayerNorm, an MLP from 768 to 3072 to 768 with GELU, and a second addition with its own residual line. A line leads down to the bottom row: a final LayerNorm, selecting token 0 to give shape b by 768, a linear head from 768 to 1000, and logits of shape b by 1000. × 12 layers z₀ [b,197,768] LayerNorm MSA 12 heads LayerNorm MLP + GELU 768→3072→768 + + residual LayerNorm final take token 0 [b, 768] Linear head 768 → 1000 logits [b, 1000] the other 196 tokens are discarded by the classifier

Worked example

All numbers below come from tested helpers in ml_models_docs.py (vit_patchify, vit_conv, vit_shape_walk, vit_params), written in plain Python so the page needs no ML package. The parameter counts match the published totals for torchvision's ViT models.

Patchify by hand: the convolution and the linear layer agree

A one-channel 4 × 4 image, patch size 2, and a model width of D = 2. The image has four 2 × 2 patches; each flattens row by row to four numbers.

Image rowPixels
01 2 0 1
13 4 1 0
20 0 2 2
31 0 2 3

The embedding has two output channels. Channel 0 has weights [1, 0, 0, -1] (top-left minus bottom-right, a tiny diagonal-edge detector) and bias 1; channel 1 has weights [1, 1, 1, 1] (the patch sum) and bias 0. As a convolution these are a [2, 1, 2, 2] weight with stride 2; as a linear layer they are the rows of a [2, 4] matrix.

PatchGrid cellFlattenedLinear: W·p + bConv2d output at that cell
1(0, 0)[1, 2, 3, 4][-2, 10][-2, 10]
2(0, 1)[0, 1, 1, 0][1, 2][1, 2]
3(1, 0)[0, 0, 1, 0][1, 1][1, 1]
4(1, 1)[2, 2, 2, 3][0, 9][0, 9]

Patch 1 is [1, 2, 3, 4]: channel 0 gives 1 · 1 − 1 · 4 + 1 = -2, channel 1 gives 1 + 2 + 3 + 4 = 10. The two columns agree on every patch, which is the whole claim: a stride-P, kernel-P convolution is a linear layer on flattened patches. With stride 1 instead, the same kernel would slide over overlapping windows and produce a 3 × 3 grid, 9 tokens instead of 4: that is an ordinary CNN layer, not a patch embedding.

Shape walk through ViT-B/16

A batch of 8 RGB images at 224 × 224, with D = 768, 12 heads, MLP width 3072 and 12 layers:

StepShape
image[8, 3, 224, 224]
Conv2d(kernel = stride = 16)[8, 768, 14, 14]
flatten(2)[8, 768, 196]
transpose(1, 2)[8, 196, 768]
prepend class token[8, 197, 768]
add position embeddings[8, 197, 768]
per-head attention scores[8, 12, 197, 197]
MLP hidden[8, 197, 3072]
12 encoder layers[8, 197, 768]
take token 0[8, 768]
head[8, 1000]

The attention-score and MLP-hidden rows are the largest intermediate tensors in a layer: 12 attention maps of 197 × 197 = 38,809 scores each, and the MLP's 3072-wide hidden layer. Every layer starts and ends at [8, 197, 768].

Counting ViT-B/16's parameters

PartArithmeticParameters
patch embedding3 · 16 · 16 · 768 + 768590,592
class token768768
position embeddings197 · 768151,296
  attention in-projection (Q, K, V)768 · 2304 + 23041,771,776
  attention out-projection768 · 768 + 768590,592
  two LayerNorms2 · (768 + 768)3,072
  MLP up768 · 3072 + 30722,362,368
  MLP down3072 · 768 + 7682,360,064
one encoder layersum of the five rows above7,087,872
12 layers12 · 7,087,87285,054,464
final LayerNorm768 + 7681,536
head768 · 1000 + 1000769,000
total86,567,656

98.3% of the parameters sit in the encoder layers, and two thirds of each layer is the MLP. The patch embedding, class token and positions together are under 1%. The image size enters only through the position table, so the same weights serve any resolution once that table is interpolated.

The same helper on the rest of the family:

ModelPatchDLayersHeadsMLPTokens at 224Parameters
ViT-Ti/16161921237681975,717,416
ViT-S/1616384126153619722,050,664
ViT-B/16167681212307219786,567,656
ViT-B/3232768121230725088,224,232
ViT-L/1616102424164096197304,326,632
ViT-H/1414128032165120257632,045,800

ViT-B/32 has more parameters than ViT-B/16 (its patch embedding is 4× larger) but runs on a quarter as many tokens, so it is several times cheaper per image. Patch size trades accuracy for compute almost independently of model size.

Resolution, patch size and attention cost

ImagePatchGridTokensScores per head per layervs 224/16
224327 × 7502,5000.06×
2241614 × 1419738,8091.00×
2241416 × 1625766,0491.70×
3841624 × 24577332,9298.58×
5121632 × 3210251,050,62527.07×

Fine-tuning ViT-B/16 at 384 × 384 raises the sequence from 197 to 577 tokens: the position table is interpolated from 14 × 14 to 24 × 24, and every attention map grows about 8.6×. The MLP and projections grow only linearly (577 / 197 ≈ 2.9×), so at high resolution attention becomes the dominant cost, which is what windowed designs like Swin and memory-efficient kernels like FlashAttention address.

How many tokens does ViT-H/14 see on a 224 × 224 image, and what happens with a 230 × 230 image?

224 / 14 = 16, so 16² + 1 = 257 tokens. 230 is not a multiple of 14: a stride-14 convolution would silently drop the last 6 pixel rows and columns, so the image is resized or cropped to a multiple of the patch size first.

Implementation

A complete ViT in PyTorch, built only from the modules in the table above (dropout and initialization details left at their defaults):

import torch
from torch import nn


class ViT(nn.Module):
    def __init__(self, image=224, patch=16, dim=768, depth=12, heads=12, mlp=3072, classes=1000):
        super().__init__()
        tokens = (image // patch) ** 2 + 1
        self.embed = nn.Conv2d(3, dim, kernel_size=patch, stride=patch)
        self.flatten = nn.Flatten(2)
        self.cls = nn.Parameter(torch.zeros(1, 1, dim))
        self.pos = nn.Parameter(torch.randn(1, tokens, dim) * 0.02)
        layer = nn.TransformerEncoderLayer(dim, heads, mlp, dropout=0.0, activation="gelu",
                                           layer_norm_eps=1e-6, batch_first=True, norm_first=True)
        self.encoder = nn.TransformerEncoder(layer, depth, norm=nn.LayerNorm(dim, eps=1e-6),
                                             enable_nested_tensor=False)
        self.head = nn.Linear(dim, classes)

    def forward(self, x):                                # [b, 3, 224, 224]
        x = self.flatten(self.embed(x)).transpose(1, 2)  # [b, 196, 768]
        cls = self.cls.expand(x.shape[0], -1, -1)        # [b, 1, 768]
        x = torch.cat([cls, x], dim=1) + self.pos        # [b, 197, 768]
        x = self.encoder(x)                              # [b, 197, 768], final LayerNorm included
        return self.head(x[:, 0])                        # [b, 1000]


model = ViT()
print(sum(p.numel() for p in model.parameters()))       # 86567656
print(model(torch.randn(8, 3, 224, 224)).shape)          # torch.Size([8, 1000])

The count matches the table because nn.MultiheadAttention packs Q, K and V into one [2304, 768] in_proj_weight, exactly the paper's layout. To load a checkpoint at a new resolution, interpolate the position table before loading:

def resize_positions(pos, new_side):
    cls, grid = pos[:, :1], pos[:, 1:]
    side = int(grid.shape[1] ** 0.5)
    grid = grid.reshape(1, side, side, -1).permute(0, 3, 1, 2)
    grid = nn.functional.interpolate(grid, size=(new_side, new_side), mode="bicubic", align_corners=False)
    return torch.cat([cls, grid.permute(0, 2, 3, 1).reshape(1, new_side * new_side, -1)], dim=1)

In practice, torchvision's vit_b_16 and the timm library provide pretrained weights, the same structure with their own encoder block classes, and the position resize built into their loaders.

Tradeoffs

  • Data appetite. With few built-in priors ViT needs large pre-training sets (or strong augmentation and distillation, as in DeiT) to beat a CNN; on small datasets from scratch a ResNet usually wins.
  • Quadratic tokens. Halving the patch size quadruples the tokens and multiplies attention cost by about 16. Dense tasks (detection, segmentation) want small patches and high resolution, so they use hierarchical or windowed variants (Swin) or plain ViTs with windowed attention in most layers (ViTDet).
  • Global from layer one. Every patch can attend to every other immediately, which helps with long-range structure and makes ViTs more robust to some occlusions and patch shuffles than CNNs, but costs compute that local convolutions would not.
  • Fixed positions. Learned absolute positions tie the model to a grid size; interpolation works but is a mismatch until fine-tuned. Relative or rotary positions avoid that at some complexity.
  • Uniform architecture. One token width at every depth makes ViT simple to scale, shard and pair with text encoders (CLIP), and to pre-train by masking patches (MAE); a CNN's pyramid gives multi-scale features for free.
  • Patch boundaries. Non-overlapping patches cut objects at arbitrary lines; small convolutional stems before patching (hybrid models) smooth this and often help optimization.
Shuffle the 196 patch tokens after the position embeddings have been added. Does the class token's output change?

No. Without a mask, self-attention and the per-token MLP are permutation-equivariant, so shuffling the tokens only shuffles the outputs, and the class token sees the same set. Order matters only through the position embeddings; shuffling the patches before adding them does change the output.

ViT-B/16 at 384 has how many parameters?

86,859,496: only the position table changes, growing by (577 − 197) · 768 = 291,840.

Connections

Read first

Read next

References