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.
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⁻⁵.
The classification head
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.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
nn.Conv2d | Conv2d(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=True | Defaults; 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.Flatten | start_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 token | nn.Parameter [1, 1, 768] | Learned summary token, expanded over the batch and prepended. | Dosovitskiy et al. (2020), eq. 1 |
| position embeddings | nn.Parameter [1, 197, 768] | One learned row per position, added to the tokens. | Dosovitskiy et al. (2020), eq. 1 |
nn.TransformerEncoderLayer | d_model, nhead, dim_feedforward=2048, dropout=0.1, layer_norm_eps=1e-5, norm_first=False | One encoder block; ViT sets 768, 12, 3072, GELU, norm_first=True. | transformer.py 663–983 |
| pre-norm branch | x = 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.TransformerEncoder | num_layers deep copies of the layer, optional final norm | The stack; pass a LayerNorm as norm for ViT's final LN. | transformer.py 320–555 |
nn.MultiheadAttention | in_proj_weight [2304, 768], out_proj 768 → 768 | The 12-head attention inside each layer. | activation.py 1090–1573 |
nn.GELU | approximate="none" | The MLP nonlinearity. | activation.py 778–823 |
nn.LayerNorm | weight and bias [768]; ε = 10⁻⁶ in ViT | Pre-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 |
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 row | Pixels |
|---|---|
| 0 | 1 2 0 1 |
| 1 | 3 4 1 0 |
| 2 | 0 0 2 2 |
| 3 | 1 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.
| Patch | Grid cell | Flattened | Linear: W·p + b | Conv2d 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:
| Step | Shape |
|---|---|
| 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
| Part | Arithmetic | Parameters |
|---|---|---|
| patch embedding | 3 · 16 · 16 · 768 + 768 | 590,592 |
| class token | 768 | 768 |
| position embeddings | 197 · 768 | 151,296 |
| attention in-projection (Q, K, V) | 768 · 2304 + 2304 | 1,771,776 |
| attention out-projection | 768 · 768 + 768 | 590,592 |
| two LayerNorms | 2 · (768 + 768) | 3,072 |
| MLP up | 768 · 3072 + 3072 | 2,362,368 |
| MLP down | 3072 · 768 + 768 | 2,360,064 |
| one encoder layer | sum of the five rows above | 7,087,872 |
| 12 layers | 12 · 7,087,872 | 85,054,464 |
| final LayerNorm | 768 + 768 | 1,536 |
| head | 768 · 1000 + 1000 | 769,000 |
| total | 86,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:
| Model | Patch | D | Layers | Heads | MLP | Tokens at 224 | Parameters |
|---|---|---|---|---|---|---|---|
| ViT-Ti/16 | 16 | 192 | 12 | 3 | 768 | 197 | 5,717,416 |
| ViT-S/16 | 16 | 384 | 12 | 6 | 1536 | 197 | 22,050,664 |
| ViT-B/16 | 16 | 768 | 12 | 12 | 3072 | 197 | 86,567,656 |
| ViT-B/32 | 32 | 768 | 12 | 12 | 3072 | 50 | 88,224,232 |
| ViT-L/16 | 16 | 1024 | 24 | 16 | 4096 | 197 | 304,326,632 |
| ViT-H/14 | 14 | 1280 | 32 | 16 | 5120 | 257 | 632,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
| Image | Patch | Grid | Tokens | Scores per head per layer | vs 224/16 |
|---|---|---|---|---|---|
| 224 | 32 | 7 × 7 | 50 | 2,500 | 0.06× |
| 224 | 16 | 14 × 14 | 197 | 38,809 | 1.00× |
| 224 | 14 | 16 × 16 | 257 | 66,049 | 1.70× |
| 384 | 16 | 24 × 24 | 577 | 332,929 | 8.58× |
| 512 | 16 | 32 × 32 | 1025 | 1,050,625 | 27.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
- The Transformer core
- Convolutional networks core
Read next
- Multimodal models advanced
References
- Dosovitskiy et al. An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale. ICLR 2021. arXiv:2010.11929
- Vaswani et al. Attention Is All You Need. NeurIPS 2017. arXiv:1706.03762
- Devlin et al. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. NAACL 2019. arXiv:1810.04805
- Touvron et al. Training data-efficient image transformers & distillation through attention (DeiT). ICML 2021. arXiv:2012.12877
- Liu et al. Swin Transformer: Hierarchical Vision Transformer using Shifted Windows. ICCV 2021. arXiv:2103.14030
- He et al. Masked Autoencoders Are Scalable Vision Learners. CVPR 2022. arXiv:2111.06377
- Hendrycks and Gimpel. Gaussian Error Linear Units (GELUs). 2016. arXiv:1606.08415
- PyTorch v2.15.0:
Conv2d,Flatten,TransformerEncoderLayer,TransformerEncoder,MultiheadAttention.