Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Imagine an automated monitoring bot posting a screenshot into your on-call incident channel. Across the top banner, bold red letters display "Deploy failed: Database connection timed out." Tucked into the bottom-right corner, in tiny 9-pixel gray text, sits a trace identifier: req_7f3a. Right beside it rests a blue Retry button.
Your multimodal incident responder has to accomplish two different jobs. First, it needs semantic scene understanding to recognize that the modal represents a critical deployment error. Second, it needs fine-grained character recognition to copy the exact identifier req_7f3a for an automated query. An image encoder might succeed on the error classification while completely mangling the identifier, even while a connected language model replies with grammatical confidence.
You already saw how self-attention routes information across a sequence of tokens. A Vision Transformer (ViT) uses transformer encoder blocks and changes where the sequence originates: non-overlapping image patches replace text tokens.[1]
Our running input is a 224 x 224 pixel crop of that incident notification modal. If you treated every RGB pixel as one sequence token, that crop would produce 224 x 224 = 50,176 tokens. Full self-attention would evaluate over 2.5 billion query-key pairs per head in each layer. Patching reduces that work. A fused attention kernel can avoid storing the full score matrix, but still has to evaluate the dense pairs.
Track three quantities separately throughout this chapter: how many patches an image yields, how many raw channel values each patch holds, and how wide its projected representation becomes. The worked examples below use synthetic arrays and explicit tensors to trace these mechanics step by step.
![The complete Vision Transformer patch embedding pipeline: a 4 by 4 image splits into four 2 by 2 patches, flattens into raster-order vectors, projects through a shared linear matrix E into dimension D, prepends a learnable [CLS] token, and adds 1D position embeddings.](/cdn/content-image/fundamentals/vision-transformers-image-encoders/illustrations/_generated/vit_patch_encoder_dark.png?v=e0c23c437799)
From pixels to patches
Start with a concrete spatial calculation. If each visual token spans a square patch of 16 x 16 pixels, how many tokens come out of our 224 x 224 input crop?
1224 / 16 = 14 patches per side
214 x 14 = 196 image patchesEach square patch contains 16 x 16 x 3 = 768 RGB channel values. That is the patch width before the model applies any learned parameters. In a real checkpoint, follow its image processor's resize, crop, RGB conversion, and normalization rules first. Here, "raw patch width" means the number of input values, not a requirement to feed unnormalized byte intensities.
ViT flattens each patch into a one-dimensional vector and multiplies it by a learned linear projection matrix to reach the model hidden dimension (referred to as d_model). In the standard ViT-Base architecture, . The matching number between 768 raw pixel values and 768 hidden units is a pure numerical coincidence: ViT-Large uses , mapping 768 raw pixel numbers to a 1024-dimensional vector. Keep those two dimensions distinct in your head.[1]
Patch width governs two separate budgets simultaneously. When you shrink patch width from 16 x 16 down to 8 x 8, the token count on that same 224 x 224 image leaps from 196 to 784. That quadruples the sequence length and multiplies dense pairwise attention comparisons by sixteen times, before counting any summary tokens.
For an arbitrary image of resolution with color channels and square patch width , assuming divides and :
1patch_count = (H / P) x (W / P)
2raw_patch_width = P x P x C
3projected_tokens shape = [patch_count, d_model]Changing alters the token count and the raw patch dimension, whereas changing d_model modifies the latent projection width. These are encoder design comparisons: an existing checkpoint's patch weights expect a particular input width. Switching its patch size requires a supported adaptation method and typically further training; changing one preprocessing number is insufficient.
Once projected, the transformer receives a sequence rather than a spatial grid:
1[patch_1, patch_2, ..., patch_196]This sequence matches the shape contracts of standard language encoders. The model doesn't process the image as a 2D matrix of pixels. It operates on an ordered list of embeddings, enabling self-attention to let any visual patch exchange information with every other visual patch.
Why flatten and project? A linear layer maps raw RGB intensities into a latent representation that transformer blocks can refine. Its parameters are shared across every spatial location: the exact same patch projection runs on the top-left banner as on the bottom-right button, just like a language model uses a single token-embedding matrix across all sequence positions.
There's a clean implementation equivalent. A 2D convolution with kernel size , stride , no padding, and output filters reads non-overlapping patches in a single operation. Each output filter corresponds to one projection dimension. A Conv2d(3, 768, kernel_size=16, stride=16) produces the exact same numerical outputs as manual flattening followed by matrix multiplication, provided weight ordering and bias match.[2]
Verify the shape transformation with a toy array. A 32 x 32 RGB image with 8 x 8 patches produces 16 patches of raw width 192, which project to width 256.
1import numpy as np
2
3def extract_patches(image: np.ndarray, patch_size: int = 16) -> np.ndarray:
4 """Split HxWxC image into (N_patches, patch_size*patch_size*C) flat patches."""
5 if image.ndim != 3 or type(patch_size) is not int or patch_size <= 0:
6 raise ValueError("expected HxWxC and a positive patch size")
7 h, w, c = image.shape
8 if min(h, w, c) == 0 or h % patch_size or w % patch_size:
9 raise ValueError("image must contain complete, nonempty patches")
10 patches = image.reshape(
11 h // patch_size, patch_size,
12 w // patch_size, patch_size, c
13 ).swapaxes(1, 2).reshape(-1, patch_size * patch_size * c)
14 return patches
15
16def patch_embed(patches: np.ndarray, proj_weight: np.ndarray) -> np.ndarray:
17 """Project (N, patch_width) patches with shared (d_model, patch_width) weights."""
18 return patches @ proj_weight.T
19
20rng = np.random.default_rng(0)
21numbered = np.arange(16).reshape(4, 4, 1)
22print("Raster patch rows:", extract_patches(numbered, 2).tolist())
23toy_image = rng.normal(size=(32, 32, 3)).astype(np.float32)
24patches = extract_patches(toy_image, patch_size=8)
25print("Patches shape:", patches.shape)
26
27d_model = 256
28proj = rng.normal(size=(d_model, 192)).astype(np.float32) * 0.02
29embedded = patch_embed(patches, proj)
30print("Embedded tokens shape:", embedded.shape)1Raster patch rows: [[0, 1, 4, 5], [2, 3, 6, 7], [8, 9, 12, 13], [10, 11, 14, 15]]
2Patches shape: (16, 192)
3Embedded tokens shape: (16, 256)Now compare manual patch flattening against an actual PyTorch convolution layer. Notice tensor conventions: NumPy uses channels-last (HWC) arrays, while PyTorch uses channels-first (BCHW) tensors. A raw shape match won't catch transposed channel dimensions, so verify numerical equality across all tokens.
1import torch
2
3torch.manual_seed(3)
4image = torch.arange(2 * 3 * 4 * 6, dtype=torch.float64).reshape(2, 3, 4, 6)
5patch_size = 2
6d_model = 5
7conv = torch.nn.Conv2d(3, d_model, patch_size, stride=patch_size).double()
8
9n, channels, height, width = image.shape
10grid_h, grid_w = height // patch_size, width // patch_size
11patches = (
12 image.reshape(n, channels, grid_h, patch_size, grid_w, patch_size)
13 .permute(0, 2, 4, 1, 3, 5)
14 .reshape(n, grid_h * grid_w, channels * patch_size * patch_size)
15)
16tokens = patches @ conv.weight.reshape(d_model, -1).T + conv.bias
17convolved = conv(image).flatten(2).transpose(1, 2)
18torch.testing.assert_close(tokens, convolved)
19wrong_order = patches.reshape(n, grid_h * grid_w, channels, 4)
20wrong_order = wrong_order.transpose(2, 3).reshape_as(patches)
21wrong_tokens = wrong_order @ conv.weight.reshape(d_model, -1).T + conv.bias
22print("Token shape:", tuple(tokens.shape))
23print("All convolution tokens match:", torch.allclose(tokens, convolved))
24print("Wrong channel order matches:", torch.allclose(wrong_tokens, convolved))1Token shape: (2, 6, 5)
2All convolution tokens match: True
3Wrong channel order matches: FalseConvolutional patch projection isn't a different model; it's the exact same linear mapping executed through standard 2D convolution primitives.
Flattening complete patches is lossless: reshape them back with the same ordering to recover the input. A projection can lose information whenever its matrix lacks full input rank, even if equals or exceeds the raw patch width. A visual token is not necessarily an averaged blur. Smaller patches give attention finer spatial granularity, which can help with localized details, but do not guarantee accurate character recognition.
Look at token counts and dense query-key pairs on a fixed 224 x 224 image. These counts exclude class and register tokens:
| Patch size | Tokens for 224 x 224 image | Raw RGB values per patch | Attention pairs per head |
|---|---|---|---|
| 32 x 32 | 49 | 3,072 | 2,401 |
| 16 x 16 | 196 | 768 | 38,416 |
| 14 x 14 | 256 | 588 | 65,536 |
| 8 x 8 | 784 | 192 | 614,656 |

At fixed head count and feature width, the all-to-all attention products cost work. The fourth-power term explains the steep increase. Materializing scores also needs quadratic space; fused attention avoids that intermediate, and windowed attention changes which pairs are evaluated.
Calculate the exact query-key pairs across settings:
1def patch_tokens(image_size: int, patch_size: int) -> int:
2 if any(type(value) is not int or value <= 0 for value in (image_size, patch_size)):
3 raise ValueError("image and patch sizes must be positive integers")
4 if image_size % patch_size:
5 raise ValueError("image must split into complete patches")
6 patches_per_side = image_size // patch_size
7 return patches_per_side**2
8
9def attention_pairs(tokens: int) -> int:
10 return tokens**2
11
12pixel_tokens = 224 * 224
13tokens_16 = patch_tokens(224, 16)
14tokens_14 = patch_tokens(224, 14)
15tokens_8 = patch_tokens(224, 8)
16
17print(pixel_tokens, tokens_16, tokens_14, tokens_8)
18print("pixel vs P=16 pair ratio:", attention_pairs(pixel_tokens) // attention_pairs(tokens_16))
19print("P=8 vs P=16 pair ratio:", round(attention_pairs(tokens_8) / attention_pairs(tokens_16), 1))
20print("16-patch count correct:", tokens_16 == 196)
21print("8-patch count correct:", tokens_8 == 784)150176 196 256 784
2pixel vs P=16 pair ratio: 65536
3P=8 vs P=16 pair ratio: 16.0
416-patch count correct: True
58-patch count correct: TruePixel-level attention generates 65,536 times more attention comparisons than 16 x 16 patching on this single image crop. Grouping pixels into patches is what makes transformer-scale attention computationally feasible on images.
Why does switching from 16×16 patches to 8×8 patches create 16× as many patch-to-patch attention pairs?
Answer
Halving patch width doubles patches along height and width, producing more tokens (). Dense self-attention calculates an all-pairs dot product matrix of size , so a increase in tokens causes a increase in attention comparisons ().
Patching reduces the dense attention budget. Next, give those tokens a way to represent where each patch came from on the screen.
Position information
Two screenshots can contain the same patches arranged differently. A model that sees only an unordered bag cannot distinguish those arrangements from token order, even if patch contents offer clues about the likely scene.
Without position information or an order-dependent mask, deterministic self-attention is permutation-equivariant: shuffling tokens shuffles the output representations the same way. Mean pooling or attention with a fixed query is then invariant to that shuffle. This is the setting below, with dropout disabled. In a class-token encoder, keep the class token fixed and permute only patches: its summary remains unchanged.
Original ViT adds a learned one-dimensional position embedding table .[1] Using zero-based patch coordinates, row 0, column 1 is patch index 1. With the class token at sequence slot 0, that patch occupies sequence slot 2 and receives .
Before passing tokens into the encoder, ViT also prepends a learnable classification token ([CLS]), designated . Its output state after the final layer becomes the aggregate image representation:
1z_0 = [x_class; x_p^1 E; x_p^2 E; ...; x_p^N E] + E_posThe resulting input sequence has length and width .
Test how adding position embeddings breaks permutation symmetry. In this experiment, two patch vectors swap locations. Without position embeddings, attention against a fixed query yields an identical summary. With position embeddings added, the two arrangements produce distinct representations:
1import numpy as np
2
3def attend(query: np.ndarray, tokens: np.ndarray) -> np.ndarray:
4 scores = tokens @ query / np.sqrt(tokens.shape[1])
5 scores = scores - scores.max()
6 weights = np.exp(scores) / np.exp(scores).sum()
7 return weights @ tokens
8
9query = np.array([1.0, 0.0])
10patches = np.array([[2.0, 0.1], [0.2, 1.5]])
11swapped = patches[::-1]
12positions = np.array([[0.0, 0.6], [0.5, 0.0]])
13
14without_position = np.allclose(attend(query, patches), attend(query, swapped))
15with_position = np.allclose(
16 attend(query, patches + positions),
17 attend(query, swapped + positions),
18)
19print("Same summary after swap without positions:", without_position)
20print("Same summary after swap with positions:", with_position)
21print("Original positioned summary:", np.round(attend(query, patches + positions), 3).tolist())
22print("Swapped positioned summary:", np.round(attend(query, swapped + positions), 3).tolist())1Same summary after swap without positions: True
2Same summary after swap with positions: False
3Original positioned summary: [1.629, 0.928]
4Swapped positioned summary: [2.122, 0.429]Track the unbatched tensors for ViT-B/16 at 224-pixel resolution:
| Stage | Shape | Description |
|---|---|---|
| Input image | [3, 224, 224] | Preprocessed RGB tensor |
| Flattened patches | [196, 768] | 196 non-overlapping patches |
| Projected tokens | [196, 768] | Linear projection into model hidden dimension |
| Encoder input | [197, 768] | Prepended [CLS] token plus learned position embeddings |
| Encoded tokens | [197, 768] | Contextualized representation after transformer blocks |
![In a two-patch fixed-query example, swapping patches without position information leaves the summary [1.606, 0.406] unchanged. Adding slot vectors produces summaries [1.629, 0.928] and [2.122, 0.429] for the two layouts. Learned tables can be resized; Qwen2.5-VL instead uses 2D rotary positions.](/cdn/content-image/fundamentals/vision-transformers-image-encoders/illustrations/_generated/position_embeddings_dark.png?v=78ac2aff9bb5)
Resolution scaling and 2D position embeddings
A rigid 1D learned table creates an engineering challenge when input resolution changes. Moving from 224 x 224 to 256 x 256 with 16 x 16 patches expands sequence length from 196 to 256 patch tokens. The pretrained 1D table only has vectors for indices 0 through 196.
Original ViT uses 2D grid interpolation during higher-resolution fine-tuning.[1] Separate the [CLS] position vector, reshape 196 patch-position vectors into a grid, resize to , flatten, and reattach [CLS]. The paper does not specify bicubic interpolation. Google's reference implementation uses scipy.ndimage.zoom(..., order=1), a linear interpolation method.[3] Other implementations use bicubic; match the checkpoint's method and coordinate settings.
Observe the shape mismatch before any interpolation:
1import numpy as np
2
3def sequence_length(image_size: int, patch_size: int, cls_token: bool = True) -> int:
4 if any(type(value) is not int or value <= 0 for value in (image_size, patch_size)):
5 raise ValueError("image and patch sizes must be positive integers")
6 if type(cls_token) is not bool or image_size % patch_size:
7 raise ValueError("expected a Boolean class-token flag and complete patches")
8 patch_count = (image_size // patch_size) ** 2
9 return patch_count + int(cls_token)
10
11trained_positions = np.zeros((sequence_length(224, 16), 768))
12higher_resolution_tokens = np.zeros((sequence_length(256, 16), 768))
13
14print("trained slots:", trained_positions.shape[0])
15print("new slots:", higher_resolution_tokens.shape[0])
16print("needs resized position table:", trained_positions.shape != higher_resolution_tokens.shape)1trained slots: 197
2new slots: 257
3needs resized position table: TrueTry the resize itself on a rectangular grid. This synthetic example chooses PyTorch bicubic interpolation with align_corners=False, not Google's original method.[4] The three feature channels are interpolated separately, and the class position is never treated as a grid cell:
1import torch
2import torch.nn.functional as F
3
4def resize_patch_positions(positions, old_grid, new_grid):
5 """Resize [1, 1+H*W, D] positions while preserving the class slot."""
6 dimensions = (*old_grid, *new_grid)
7 if len(old_grid) != 2 or len(new_grid) != 2 or any(
8 type(value) is not int or value <= 0 for value in dimensions
9 ):
10 raise ValueError("expected two positive integer grid dimensions each")
11 old_h, old_w = old_grid
12 if positions.ndim != 3 or positions.shape[0] != 1 or positions.shape[-1] == 0:
13 raise ValueError("expected one position table with nonempty feature width")
14 if positions.shape[1] != 1 + old_h * old_w:
15 raise ValueError("old grid does not match the patch positions")
16 cls = positions[:, :1]
17 grid = positions[:, 1:].reshape(1, old_h, old_w, -1).permute(0, 3, 1, 2)
18 resized = F.interpolate(grid, size=new_grid, mode="bicubic", align_corners=False)
19 patches = resized.permute(0, 2, 3, 1).reshape(1, new_grid[0] * new_grid[1], -1)
20 return torch.cat((cls, patches), dim=1)
21
22positions = torch.arange(197 * 3, dtype=torch.float64).reshape(1, 197, 3)
23resized = resize_patch_positions(positions, (14, 14), (16, 20))
24print("Original / resized:", tuple(positions.shape), tuple(resized.shape))
25print("Class position preserved:", torch.equal(positions[:, :1], resized[:, :1]))1Original / resized: (1, 197, 3) (1, 321, 3)
2Class position preserved: TrueInterpolation supplies a correctly shaped table; it does not train the model to read the higher-resolution screenshot. That requires checking the checkpoint's supported resolutions and evaluating its outputs.
Some backbones, including Qwen2.5-VL, use 2D Rotary Position Embeddings (2D-RoPE).[5][6] Rather than adding a vector to each token, they rotate queries and keys using row and column coordinates. One simple construction divides head width into two equal groups of paired dimensions: half rotate with row , half with column . This construction requires divisible by four; axis allocation and dimension ordering can differ between implementations.
For fixed unrotated and , their rotated dot product contains the relative row and column offsets. The score still depends on those content vectors, too. Rotary positions avoid resizing a learned absolute table, but do not remove token limits or guarantee extrapolation quality. Qwen2.5-VL still resizes height and width to multiples of 28 and merges neighboring patches before its LLM.[5]
What breaks if a ViT receives patch embeddings without position information?
Answer
With no position information or order-dependent mask, deterministic self-attention is permutation-equivariant. Permuting patches permutes their outputs and leaves a permutation-invariant readout unchanged. The model can inspect patterns inside a patch, but token order alone supplies no patch-grid location.
Encoder attention
ViT stacks standard transformer encoder blocks: multi-head self-attention and position-wise feed-forward networks, wrapped with residual connections and LayerNorm.
Original ViT uses full bidirectional self-attention without a causal mask. Any patch can attend to any other, so the banner patch can use evidence from the Retry button in the first layer. Other vision encoders restrict visibility: Qwen2.5-VL uses local windows in most layers and full attention in four of its 32 vision layers.[5] Bidirectional does not necessarily mean globally unrestricted.
Compare that bidirectional attention with a language decoder causal mask. In vision understanding, later raster patches aren't "future" steps in time; they're spatial neighbors that can provide contextual evidence immediately:
1import numpy as np
2
3def softmax(scores: np.ndarray) -> np.ndarray:
4 scores = scores - scores.max(axis=-1, keepdims=True)
5 probs = np.exp(scores)
6 return probs / probs.sum(axis=-1, keepdims=True)
7
8tokens = np.array([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]])
9scores = tokens @ tokens.T / np.sqrt(tokens.shape[1])
10full_weights = softmax(scores)
11causal_scores = np.where(np.triu(np.ones_like(scores), k=1) == 1, -np.inf, scores)
12causal_weights = softmax(causal_scores)
13
14print("Vision patch 0 can read later patch 1:", full_weights[0, 1] > 0)
15print("Causal token 0 can read later token 1:", causal_weights[0, 1] > 0)
16print("Vision row sums to one:", np.isclose(full_weights[0].sum(), 1.0))1Vision patch 0 can read later patch 1: True
2Causal token 0 can read later token 1: False
3Vision row sums to one: TrueFull bidirectional attention allows distant screen regions to exchange evidence without waiting for successive local layers.
The ViT architecture in full
The end-to-end dataflow of a Vision Transformer follows a structured sequence:

ViT employs Pre-LN transformer blocks, applying LayerNorm before each sublayer and adding residuals afterward:[1]
- Patch embedding: linear projection of flattened patches from to
- Prepend a learnable
[CLS]token at sequence slot 0 - Add learned sequence position embeddings
- Pass through blocks with the same architecture and separate parameters:
- Apply final LayerNorm
- Readout: extract the
[CLS]token for image-level tasks, or keep all patch tokens for dense vision-language handoff
For ViT-B/16, the model hidden size is , with layers and 12 attention heads. Each head operates on dimension . Queries and keys have shape [B, 12, 197, 64], forming attention score matrices of shape [B, 12, 197, 197]. The position-wise MLP expands hidden width by to 3072, then projects back to 768.
Inductive biases and pretraining scale
Compare a ViT with a convolutional network such as ResNet.[7] Their different inductive biases influence what training has to learn.
An inductive bias represents the architectural assumptions baked directly into a model's wiring:
- Locality: A small convolution kernel connects nearby pixels. Successive layers expand the receptive field; dilation, larger kernels, or global pooling can also connect distant regions. Downsampling is not required for receptive-field growth.
- Translation equivariance: On an ideal grid, stride-one convolution commutes with translation: . Finite image boundaries and padding qualify this property, and strided sampling generally preserves it only for shifts aligned with the stride. It is not an exact guarantee for every shifted screenshot.
Original ViT has weaker image-specific biases than a conventional CNN, but still groups neighboring pixels into patches and uses their 2D grid when resizing position embeddings. Full attention permits long-range connections immediately; that permission does not mean the model has learned useful spatial reasoning.
In the original paper's training setup, ViTs trained on ImageNet-1K (about 1.3 million images) without strong regularization underperformed comparable ResNets. Larger-scale pretraining on ImageNet-21k or JFT improved transfer.[1] This is an experimental result, not a rule that ViTs must lose on smaller datasets.
DeiT subsequently trained competitive ViTs using ImageNet alone, with a stronger training recipe and an optional distillation token.[8] ConvNeXt showed competitive convolutional backbones under updated designs and training.[9] Architecture, pretraining, regularization, and task all matter. For a small private screenshot set, compare pretrained encoders and smaller baselines on held-out examples before committing to a large model from scratch.
In a ViT-B/16 classifier with a [CLS] token, why does the sequence length become 197 instead of 196?
Answer
A 224×224 image with 16×16 patches yields patch tokens. Prepending one learned [CLS] token at slot 0 to aggregate global image context brings the total sequence length to tokens.
CLIP and image-text training
A trained ViT produces visual embeddings. To match our screenshot against a query like "deployment error with database timeout", we can train image and text representations in a shared space. This aligns their global readouts, rather than every image patch with every language token.
CLIP (Contrastive Language-Image Pre-training) accomplishes this using contrastive learning across a dual-encoder architecture.[10]
Original CLIP (2021) pairs an image encoder with a text encoder and trains on 400 million image-text pairs collected from the web.[10] It evaluates both modified ResNet and ViT image encoders. Its text transformer uses a causal mask and reads the end-of-text token; its ViT image encoder uses the class token. Learned projections map those readouts into a common width , which can differ from either encoder's hidden width.

Given a training batch of paired images and captions:
- The vision encoder and its output projection produce image vectors , then L2-normalize them: .
- The text encoder and its output projection produce caption vectors , then L2-normalize them: .
- Compute the cosine similarity matrix scaled by a learned logit scale parameter :
- Diagonal entries () are the supplied positive pairs. The off-diagonal entries are assigned negative labels. Those labels are a training convention, not proof that every other caption is semantically wrong.
The training objective is the symmetric InfoNCE loss,[11][10] composed of image-to-text and text-to-image cross-entropy terms:
Row softmax trains each image to pick its matching caption among options; column softmax trains each caption to pick its matching image.
Suppose two screenshots both show a database timeout and both captions say "deployment failed." The diagonal-only objective treats each cross-pair as negative despite its plausible meaning. Duplicate captions cannot be distinguished by a deterministic text encoder. Batch curation and objectives supporting multiple positives can address this mismatch; increasing batch size alone does not resolve it.

Calculate the symmetric InfoNCE loss on three synthetic normalized pairs:
1import numpy as np
2
3images = np.eye(3)
4texts = np.array([[1.0, 0.2, 0.0], [0.1, 1.0, 0.2], [0.0, 0.3, 1.0]])
5texts /= np.linalg.norm(texts, axis=1, keepdims=True)
6logits = 3.0 * images @ texts.T
7
8def matching_loss(scores):
9 shifted = scores - scores.max(axis=1, keepdims=True)
10 log_probs = shifted - np.log(np.exp(shifted).sum(axis=1, keepdims=True))
11 return -np.diag(log_probs).mean()
12
13row_loss = matching_loss(logits)
14column_loss = matching_loss(logits.T)
15print("Logits:", np.round(logits, 3).tolist())
16print("Row / column loss:", round(row_loss, 3), round(column_loss, 3))
17print("Symmetric loss:", round((row_loss + column_loss) / 2, 3))1Logits: [[2.942, 0.293, 0.0], [0.588, 2.928, 0.862], [0.0, 0.586, 2.873]]
2Row / column loss: 0.155 0.156
3Symmetric loss: 0.155Zero-shot open-vocabulary classification
Because CLIP projects images and natural language into the same embedding space, it performs zero-shot open-vocabulary classification without needing a fixed, closed-set classification head.
To classify our incident screenshot across arbitrary categories:
- Formulate prompt templates for candidate classes:
"a screenshot of a deploy failed error"versus"a screenshot of a successful deployment banner". - Pass the candidate strings through the text encoder to obtain normalized candidate vectors .
- Compute the cosine similarity between the image embedding and each candidate text vector.
- Select the class with the highest similarity score.
Prompt ensembling can improve zero-shot accuracy, as it did on the original paper's benchmarks.[10] Encode several appropriate prompt variants per class, normalize each vector, average within that class, and normalize the average again. Test the templates on your domain: a photograph template is not automatically suitable for a terminal screenshot.
The following vectors are invented for the arithmetic; the prompt strings are labels, not inputs to a real CLIP checkpoint:
1import numpy as np
2
3def normalize(rows: np.ndarray) -> np.ndarray:
4 return rows / np.linalg.norm(rows, axis=-1, keepdims=True)
5
6image = normalize(np.array([[0.95, 0.20, 0.05]]))
7prompts = ["a deploy failed toast", "a deploy succeeded banner"]
8text = normalize(np.array([[0.90, 0.25, 0.05], [0.05, 0.95, 0.10]]))
9logit_scale = 5.0
10
11logits = logit_scale * (image @ text.T)[0]
12probabilities = np.exp(logits - logits.max())
13probabilities = probabilities / probabilities.sum()
14
15print("Ranked prompt:", prompts[int(np.argmax(probabilities))])
16print("Candidate probabilities:", np.round(probabilities, 3).tolist())
17duplicated = np.exp(np.array([logits[0], logits[0], logits[1]]) - logits.max())
18duplicated /= duplicated.sum()
19print("After duplicating first candidate:", np.round(duplicated, 3).tolist())1Ranked prompt: a deploy failed toast
2Candidate probabilities: [0.976, 0.024]
3After duplicating first candidate: [0.494, 0.494, 0.012]Why did 0.976 become 0.494 without changing the image? Softmax distributes mass among the supplied candidates, so the duplicated prompt splits its share. These are candidate-relative probabilities, not a calibrated probability that a deployment failed. A positive common logit scale changes their sharpness without changing the similarity ranking. Neither ranking nor a sharp softmax verifies req_7f3a.
You compare a toast-screenshot embedding against text labels like "deploy failed toast" and "deploy succeeded banner." What does CLIP return, and what does it not return?
Answer
CLIP returns image-text similarity scores. It does not itself generate a transcript, output a verified trace ID or bounding box, or execute remediation. Its representations can contain text information, and candidate scoring can support OCR-like classification; this two-prompt ranking does not establish exact character recognition.
The ranking favors the deployment-failure label under the embedding model. For the next job, copying the identifier, you need a separate procedure that produces and checks a string: for example, an OCR tool or a generative VLM tested on exact IDs. To generate an explanation conditioned on the image, connect visual features to a language decoder.
From image encoder to VLM
Many generative Vision-Language Models (VLMs) connect a pretrained vision encoder to an autoregressive Large Language Model (LLM).
Original LLaVA (2023) uses a CLIP ViT-L/14 encoder and a learned linear projector.[12] It extracts patch-grid hidden states from a selected encoder layer, rather than CLIP's final projected global vector. For the 224-pixel CLIP checkpoint, there are patch features of width 1024; the class token is separate.[13] The LLM width depends on the chosen language checkpoint, so label it rather than assuming 4096.
![Diagram showing Image 224x224, CLIP ViT-L/14, Patch states [256, 1024], and Linear projector.](/cdn/content-image/fundamentals/vision-transformers-image-encoders/diagrams/_generated/content_diagram_2_dark.png?v=7460c08aa502)
Keep three counts separate: encoder patch states, bridge outputs, and visual slots actually inserted into LLM self-attention. The bridge determines how they relate:
| Bridge | What happens to visual features? | Where does the LLM use them? |
|---|---|---|
| Token-wise linear or MLP projector | Changes each feature's width while preserving count | A prefix-style model inserts them as sequence slots. Original LLaVA uses a linear bridge. |
| Perceiver Resampler | Learned queries compress a variable feature grid into a fixed number of latents | Flamingo uses 64 latents as memory for added gated cross-attention blocks, not 64 ordinary self-attention prefix slots.[14] Its original encoder is an NFNet CNN. |
| Spatial feature merger | Packs each neighboring 2×2 group into a wider vector, then projects it; count falls 4× | Qwen2.5-VL inserts the merged outputs into its LLM sequence.[5] Packing is a rearrangement; the learned projection can lose information. |
A token-wise projector changes width, not count. A toy linear bridge from vision width 8 to language width 16 preserves the four input slots:
1import numpy as np
2
3rng = np.random.default_rng(5)
4visual_features = rng.normal(size=(1, 4, 8))
5projector = rng.normal(size=(8, 16))
6llm_visual_inputs = visual_features @ projector
7text_token_count = 3
8
9print("Vision features:", visual_features.shape)
10print("Projected features:", llm_visual_inputs.shape)
11print("Slots before generation:", llm_visual_inputs.shape[1] + text_token_count)1Vision features: (1, 4, 8)
2Projected features: (1, 4, 16)
3Slots before generation: 7Token budget economics in VLMs
In a prefix-style VLM, each inserted visual input occupies a context window slot. Full attention over the prefix has quadratic pairwise work, while retained self-attention KV cache grows linearly with sequence length. One newly decoded token attends to the retained keys with linear lookback work. A cross-attention design such as Flamingo has separate visual memory and a different accounting boundary.
First calculate patch counts for , before any merger:
1At 224 x 224 with P = 14: (224/14)^2 = 256 visual tokens
2At 448 x 448 with P = 14: (448/14)^2 = 1,024 visual tokens
3At 896 x 896 with P = 14: (896/14)^2 = 4,096 visual tokensIf a prefix-style bridge passes every patch through unchanged, four images at consume 4,096 visual slots, before text and any image delimiters. A 2×2 merger instead produces 256 features per image, or 1,024 total. Crops, tiling, and extra global views can increase the input count again.
Predict the storage change when one image grows from 224 to 448 pixels per side. Use a hypothetical 32-layer decoder with eight KV heads, 128-wide keys and values, two bytes per element, and 512 text tokens. Retain all prefix positions, with one patch per visual slot and no auxiliary image tokens:
1raw KV bytes = 2 (K and V) × layers × retained tokens × KV heads × head width × bytes1layers, kv_heads, head_width, element_bytes = 32, 8, 128, 2
2text_tokens = 512
3
4def raw_kv_mib(retained_tokens):
5 if type(retained_tokens) is not int or retained_tokens <= 0:
6 raise ValueError("retained token count must be a positive integer")
7 return 2 * layers * retained_tokens * kv_heads * head_width * element_bytes / 2**20
8
9for image_side in (224, 448, 896):
10 visual_slots = (image_side // 14) ** 2
11 prefix_slots = text_tokens + visual_slots
12 print(image_side, "visual / prefix:", visual_slots, prefix_slots,
13 "raw KV MiB:", raw_kv_mib(prefix_slots))
14
15print("Extra raw KV per retained token, MiB:", raw_kv_mib(1))1224 visual / prefix: 256 768 raw KV MiB: 96.0
2448 visual / prefix: 1024 1536 raw KV MiB: 192.0
3896 visual / prefix: 4096 4608 raw KV MiB: 576.0
4Extra raw KV per retained token, MiB: 0.125Four times the visual slots only doubles the first two total prefix lengths because the 512 text slots stay fixed. The raw cache doubles too; it does not quadruple with that doubled prefix. This is payload accounting for one request, not total GPU memory: it excludes model weights, activations, allocator overhead, and cache quantization metadata. Sliding windows or other retention policies also change the cache count.
Calculate patch-feature counts for a separate toy encoder:
1def patch_feature_slots(image_size: int, patch_size: int, images: int = 1) -> int:
2 if any(type(value) is not int or value <= 0 for value in (image_size, patch_size, images)):
3 raise ValueError("sizes and image count must be positive integers")
4 if image_size % patch_size:
5 raise ValueError("image must split into complete patches")
6 per_image = (image_size // patch_size) ** 2
7 return images * per_image
8
9base = patch_feature_slots(224, 16, images=1)
10two_images = patch_feature_slots(224, 16, images=2)
11high_res = patch_feature_slots(448, 16, images=1)
12
13print(base, two_images, high_res)
14print("single-image patch features correct:", base == 196)
15print("two-image patch features correct:", two_images == 392)
16print("high-res patch features correct:", high_res == 784)1196 392 784
2single-image patch features correct: True
3two-image patch features correct: True
4high-res patch features correct: TruePixel resizing, changing patch granularity, and merging encoded features are different operations. Pixel downsampling can erase character strokes before encoding; patch flattening itself does not. A learned feature merger can lose details later. Locate that boundary before trying to fix the language decoder.
| Concern | Production trade-off |
|---|---|
| Context budget | Passing 1,024 patch states individually uses 1,024 prefix slots. A merger reduces that count but may lose information. Neither preserves layout automatically; positional handling and training matter. |
| Detail preservation | Resizing a 672-pixel-wide screenshot to width 224 with the same aspect ratio shrinks 9-pixel-high text to about 3 pixels. A native 224-pixel crop does not make that shrink. Inspect the processed crop and compare OCR or VLM strings with labeled IDs. Cropping offers more evidence, not guaranteed correctness. |
| Multi-turn chat | Reuse cached visual embeddings when the image, crop, processor, and encoder are unchanged. That saves vision passes. Reusing the decoder's prefix KV is a separate optimization requiring a matching prefix; an embedding cache alone does not save all LLM work. |
Encoder variants change supervision and token budgets
Different vision backbones modify the training loss, resolution handling, or slot usage to optimize specific downstream tasks.
SigLIP: independent pair losses
CLIP normalizes each image's scores over captions and each caption's scores over images. Distributed implementations commonly gather remote embeddings to include cross-device negatives. The choice of communication collective is an implementation decision, not a mathematical requirement of softmax; a denominator can be accumulated across chunks.
SigLIP (Sigmoid Loss for Language-Image Pre-training) replaces matrix-wide softmax normalization with independent binary sigmoid losses on every pair:[15]
Here is a learned scale and is a learned bias. With diagonal positives and negatives, the bias helps represent that imbalance. The original paper sums all pair losses and divides by , not :
Independent pair terms make chunked evaluation simpler: a chunk needs no row or column softmax denominator. SigLIP's paper exchanges negative embeddings between neighboring devices using collective permutes.[15] This reduces peak memory and changes communication; it does not decouple the workers or eliminate cross-device traffic. Gradient synchronization also remains. If you discard remote negatives instead, you have changed the training pairs, not obtained the same loss for free.
1import numpy as np
2
3logits = np.array([[2.0, -1.0], [-0.4, 1.7]])
4labels = 2 * np.eye(2) - 1
5pair_losses = np.logaddexp(0.0, -labels * logits)
6
7print("Positive losses:", np.round(np.diag(pair_losses), 3).tolist())
8print("Negative losses:", np.round(pair_losses[labels < 0], 3).tolist())
9print("Mean pair loss:", round(float(pair_losses.mean()), 3))
10print("SigLIP batch normalization:", round(float(pair_losses.sum() / len(logits)), 3))1Positive losses: [0.127, 0.168]
2Negative losses: [0.313, 0.513]
3Mean pair loss: 0.28
4SigLIP batch normalization: 0.56The mean over four pairs is 0.28; the paper's sum divided by two examples is 0.56. Using the pair mean multiplies the objective and its gradients by relative to that normalization. Weighting or sampling negatives also changes the objective. Independent terms do not mean independent training decisions.
Variable resolution and dense features
Many fixed-resolution checkpoints expect a square input, but the ViT architecture itself does not require square images. Check the processor: stretching a dashboard into a square distorts characters; resizing and center-cropping can instead drop the identifier near an edge.
SigLIP 2 (2025) adds captioning and localization pretraining, followed by self-distillation and masked teacher-feature prediction, to the image-text sigmoid objective.[16] Its NaFlex variant largely preserves aspect ratio while resizing to patch-compatible dimensions under a chosen maximum token budget. It still interpolates a learned position grid, pads shorter sequences, and masks the padding. "Native aspect ratio" does not mean preserving every original pixel or removing context limits.
SigLIP 2 uses multi-head attention pooling for global readouts rather than assuming ViT's class-token readout.[16] The auxiliary captioning decoder serves training and is not included in the released encoder pair. A captioning training objective therefore does not make that checkpoint a generative captioner. Check actual auxiliary slots before adding a class token to your budget.
DINOv2 demonstrates self-supervised learning without paired text labels.[17] In its main pretraining setup, a student learns image-level representations and predicts teacher outputs for masked patches; the teacher sees those patches unmasked and is updated by an exponential moving average. This predicts feature distributions, not reconstructed RGB pixels. The paper evaluates segmentation and depth using readouts trained on the patch features. A backbone's feature tensor alone is not a depth map or a text-aligned classifier.
DINOv3 (released August 2025) extends this line with Gram anchoring: a teacher reference constrains relationships among patch features to counter the dense-feature degradation observed during long training.[18] Its family includes both ViT and ConvNeXt backbones. For our screenshot, compare exact-ID and layout-task results under the same crop and token budget; a strong result on natural-image dense prediction does not establish reliable UI transcription.
Some checkpoints introduce register tokens.[19] The paper observes high-norm background tokens in several tested ViTs and interprets them as slots repurposed for internal computations. Training with extra learned registers, commonly four in its experiments, removes the observed outliers and smooths feature maps in those models. This is a tested mechanism, not a universal guarantee about every ViT or every background artifact. Registers add encoder slots; whether the LLM receives them depends on the bridge's feature selection.
Does removing CLIP's batch-wide softmax make SigLIP independent of batch composition?
Answer
No. Each pair contributes an independent logistic term, but sample choice, positive/negative ratio, false negatives, and normalization still affect gradients. A chunked implementation can reduce peak memory while still exchanging remote features. Removing a softmax denominator does not eliminate communication or make batch curation irrelevant.
Trace an encoder boundary
When architecting or debugging vision-language systems:
- Calculate patch count, raw patch width, and projected tensor shapes from input resolution and patch width .
- Account for quadratic self-attention scaling before shrinking patch size to avoid memory exhaustion.
- Verify the checkpoint's position mechanism: additive tables, rotary queries and keys, or another spatial design.
- Resize learned patch-position grids with the expected interpolation settings, or use the supported rotary coordinates. Keep class and register slots separate.
- Distinguish CLIP ranking from verified transcription. Count encoder states, bridge outputs, inserted prefix slots, and retained KV separately.
An encoder processes a 224×224 image with 16×16 patches, one class token, and four registers. Its bridge passes only patch features through a linear projector. How many encoder slots and LLM visual slots are there?
Answer
There are image patches. Adding one [CLS] token and four register tokens produces encoder slots. Slicing out only the patch tokens removes the five auxiliary slots, so the linear projector receives and outputs exactly 196 visual tokens into the LLM context window.
Your VLM correctly identifies the "Deploy failed" error status but copies the request ID req_7f3a as req_7838. How do you isolate the error?
Answer
Inspect the actual resized or cropped encoder input. Compare native identifier crops, OCR, and the VLM against labeled exact strings, including an "unreadable" outcome. Better results with the crop implicate preprocessing or resolution; a legible processed crop with persistent errors points toward the encoder, bridge, or decoder. Never use guessed IDs as verification targets.