Ch. 16
Multimodal Transformers
ViT, CLIP contrastive loss, LLaVA-style projectors, Stable Diffusion conditioning — modalities sharing a residual stream.
A vision transformer sees an image the way a language model sees a paragraph. Cut the image into 16-by-16 patches, Squashing a multi-dimensional grid of numbers into a single long list.Full glossary → each patch into a vector, add a position A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary →, hand the resulting sequence to the same nn.Module you trained on Tiny Shakespeare. The model does not know it is looking at pixels. It just attends to tokens. This is the trick that holds the rest of the chapter together: once you accept that "A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary →" is a more general concept than "subword", every multimodal architecture in the last five years stops being a new thing and starts being a way to project your A form of information, like text, images, or audio.Full glossary → of choice into the same The main running tally of information that flows through a deep model, with each layer reading from it and adding its bit back in.Full glossary →. CLIP projects images and captions into a shared 512-dimensional space and trains the projections to agree. Stable Diffusion's UNet has the text encoder's outputs reach into its noise-prediction layers through cross-A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →. LLaVA glues a CLIP vision tower to a Llama by training one MLP. The architectures are not radically different. The training data and the loss are where the work happens.
FIG 16.1 · Learning outcomes
By the end of this chapter you will be able to:
- Implement a Vision Transformer (ViT) end-to-end in ≤80 lines of PyTorch, including the patch embedding, the
[CLS]token, and 2D positional encoding. - Explain why CLIP's contrastive loss is just symmetric softmax cross-entropy on a similarity matrix, and write the 15-line training step yourself.
- Use CLIP for zero-shot image classification on a small custom dataset and explain why it works without any fine-tuning.
- Describe the three families of vision-language fusion — input concatenation, prefix tuning, and cross-attention — with one production model per family (Fuyu, LLaVA, Flamingo).
- Trace how a text prompt reaches the noise-prediction step in Stable Diffusion via CLIP and UNet cross-attention.
- Make an informed choice between Q-Former, MLP projector, and Perceiver resampler for a new multimodal model, based on parameter budget and data scale.
- Identify three specific safety failures unique to multimodal models (typographic attacks, image prompt injection, cross-modal jailbreaks) and which papers documented them.
FIG 16.2 · What you need first
- Ch 15 — Transformers from Scratch — you need attention, multi-head attention, residual stream, LayerNorm, the whole stack. ViT is the same stack with a different tokenizer. This chapter takes another chapter as a hard prereq.
- Ch 12 — CNNs — you need to remember what a convolution does and what "receptive field" means, because the comparison ViT-vs-CNN runs through this entire chapter.
- Ch 14 — NLP with RNNs + Attention — cross-attention is introduced there. We use it heavily here.
- external — Géron Ch 14 §image-classification — if you want the CV baseline (CNNs, ResNet) before you read about ViT replacing them.
If you can read the GPT class and explain every line, you have enough. Most of this chapter is "swap the tokenizer, keep the transformer".
FIG 16.3.1
ViT: image patches as tokens
In 2020, Dosovitskiy et al. shipped "An Image Is Worth 16x16 Words" and the title is the paper. Take an image of shape (3, 224, 224). Cut it into 14×14 non-overlapping patches of size (3, 16, 16). Squashing a multi-dimensional grid of numbers into a single long list.Full glossary → each patch to a 768-dimensional vector. You now have 196 tokens of d_model=768. Prepend a learned [CLS] A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → — an extra A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → with no pixels behind it, whose only job is to accumulate a summary of the whole image as it flows up the stack, so you classify off its final vector (the trick BERT, the Reading a sequence both forward and backward so each spot has context from both sides.Full glossary → text model, used for sentence classification). Add a learned positional embedding to each of the 197 positions. Feed this sequence into a stack of 12 transformer encoder blocks — the same another chapter block you built, minus the A block that stops a model from peeking at later positions, so it only sees what came before.Full glossary →, so every token attends to every other token instead of only to its past — identical to the ones in BERT. Read the final [CLS] token's embedding, project it to your number of classes, train with A loss that measures how far a model's predicted chances are from the true answer.Full glossary → on ImageNet. That is ViT-B/16.
Two things are surprising about this once you accept it works. First, the "Cutting an image into small tiles and turning each tile into a single bundle of numbers.Full glossary →" can be implemented as a single 2D A small grid of numbers that slides across an image, at each spot multiplying its numbers by the patch underneath and adding them into one number.Full glossary → with How many pixels a sliding filter jumps with each step across an image.Full glossary → equal to patch size: nn.Conv2d(3, 768, kernel_size=16, stride=16). The Conv2d is mathematically equivalent to "flatten each patch and apply a linear projection". This is the only conv in the whole network. Second, ViT does worse than ResNet on ImageNet at small data scales. It needs JFT-300M (300 million images) to overtake ResNet. With less data, the A built-in assumption that nudges a model toward certain kinds of patterns.Full glossary → of convolutions (locality, translation equivariance) wins. With enough data, the transformer just learns the A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → from scratch. This is the lesson that generalizes across modalities: transformers replace inductive bias with data.
ViT patch embedding (Conv2d == flatten + linear) vs. from scratch
DL primitiveprocessor = ViTImageProcessor.from_pretrained('google/vit-base-patch16-224')
model = ViTModel.from_pretrained('google/vit-base-patch16-224')
inputs = processor(images=img, return_tensors='pt')
hidden = model(**inputs).last_hidden_state # (1, 197, 768): CLS + 196 patch tokens
# the patchifier underneath is literally:
# nn.Conv2d(3, 768, kernel_size=16, stride=16) then flatten(2).transpose(1, 2)self.patch_embed = nn.Conv2d(1, d_model, kernel_size=patch_size, stride=patch_size)
self.cls_token = nn.Parameter(torch.zeros(1, 1, d_model))
self.pos_embed = nn.Parameter(torch.zeros(1, 17, d_model)) # 16 patches + 1 CLS
...
patches = self.patch_embed(img) # (B, d_model, 4, 4)
patches = patches.flatten(2).transpose(1, 2) # (B, 16, d_model)
cls = self.cls_token.expand(B, 1, -1)
x = torch.cat([cls, patches], dim=1) # (B, 17, d_model)
x = x + self.pos_embed # learned 1D positions
for blk in self.blocks:
x = blk(x)
x = self.ln(x[:, 0]) # read the CLS token
x = self.proj(x) # (B, embed_dim)from scratch: lab/solution.py: TinyImageEncoder.__init__/forward
- 1
ViT's embeddings.patch_embeddings.projection (a Conv2d(3,768,16,16))self.patch_embed = nn.Conv2d(1, d_model, kernel_size=patch_size, stride=patch_size) -- stride==kernel makes it a per-patch linear map - 2
flatten + transpose to (B, n_patches, d) inside ViTEmbeddingspatches.flatten(2).transpose(1, 2): (B, d, H/P, W/P) -> (B, n_patches, d) - 3
ViT's prepended learned cls_tokenself.cls_token expanded to batch and torch.cat'd at position 0 - 4
ViT's learned position_embeddings added to all 197 tokensx = x + self.pos_embed (shape (1, 17, d_model), 16 patches + CLS) - 5
ViTForImageClassification reads last_hidden_state[:, 0] for the headx = self.ln(x[:, 0]) then self.proj(...) -- classify off the CLS token
What the one call hides
- The Conv2d-with-stride-equal-to-kernel IS a patch flatten + shared linear projection; the library never tells you the 'conv' is really a linear layer applied independently to each patch.
- HuggingFace ViT bakes in mean/std normalization and resize inside the ViTImageProcessor; the scratch path assumes you already have a (B,1,28,28) tensor in range.
- The library uses interpolatable learned position embeddings and handles resolution mismatch (interpolate_pos_encoding); the fixed (1,17,d) scratch pos_embed will silently break at any other patch count.
- cls_token and pos_embed are initialized with truncated/normal std=0.02 in both (nn.init.normal_(..., std=0.02) in scratch); the library hides this init choice.
- Real ViT applies dropout on embeddings; the tiny scratch version omits embedding dropout.
- Gotcha: Patch count is fixed by pos_embed length: feed a different image size and the addition x + pos_embed shape-mismatches (or worse, the library silently interpolates and degrades).
- Gotcha: in_channels must match (scratch uses 1 for MNIST grayscale; google/vit-base expects 3-channel RGB) -- a beginner who reuses the processor on grayscale gets wrong channels.
- Gotcha: The [CLS] token has no pixels behind it; forgetting to prepend it (or reading the wrong index) means you classify off a patch instead of the global summary.
- Gotcha: ViTImageProcessor's default resize to 224 and ImageNet normalization will silently mangle inputs that are already preprocessed or in a different range.
For real vision work load a pretrained ViT/DINOv2 backbone from transformers or timm -- nobody trains ViT patch embeddings from scratch (it needs 300M+ images to beat a CNN); the scratch version exists to internalize that 'image patch embedding' is a strided conv that equals flatten+linear, and that the rest of ViT is just the maskless transformer encoder.
On the job: You load a pretrained backbone and reuse its patch embedding; the from-scratch knowledge pays off when you adapt input channels (grayscale, depth, multi-spectral), change patch size, or interpolate position embeddings to a new resolution.
The Block class is the same one. ViT is decoder-shaped without the causal mask. Note: no triangular mask here. Every patch attends to every other patch. Vision is non-Generating text one piece at a time, where each new piece is chosen based on everything written so far.Full glossary →.
FIG 16.3.2
Positional encoding for 2D: do you need 2D, or does 1D work?
Patches have a 2D grid structure. Position (3, 5) is one row above (4, 5) and one column right of (3, 4). The original ViT throws this information away: it uses a 1D learned position A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary →, one per patch index in row-major order. The model is supposed to figure out the 2D structure from data.
It does. But several follow-up papers show you can do better.
Sinusoidal 2D. Pre-compute a fixed Extra information added to each input that depends only on where it sits in the order.Full glossary → that is the concatenation of sin/cos features for the row index and sin/cos features for the column index. SimpleViT (the cleanup of original ViT by some of the same authors) uses this and trains faster.
A way of telling a model where each token sits by twisting its number bundle a little more for each later position.Full glossary → on 2D. Apply rotary embeddings independently along the row and column axes. Common in DiT (the diffusion transformer that powers Sora's predecessors) and in NaViT.
No position encoding. Some recent ViT variants drop the position encoding entirely, on the basis that the A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → pattern itself can encode locality if you let it. This works at scale but trains slower.
The empirical finding from the ViT-22B paper and others: positional encoding matters less than you would expect. The model recovers locality from data, especially with enough patches. But for small data, 2D positional information helps.
FIG 16.3.3
DINO and self-supervised vision: ViT without labels
ViT trained on ImageNet (1.3M labels) is fine. ViT trained on JFT-300M (300M labels) is better. ViT trained on JFT-3B is better still. But labels do not scale to billions. The interesting question is whether you can train a competitive ViT with zero labels.
DINO (Caron et al. 2021) showed you can. The recipe:
- Take two random crops of the same image, one large (the global view), one small (the local view).
- Pass each through a teacher network and a student network. Both are ViT.
- The teacher's weights are an exponential moving average of the student's. No A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → updates flow to the teacher.
- The student's outputs (after a small MLP head and A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary →) should match the teacher's outputs for the same image. Loss: A loss that measures how far a model's predicted chances are from the true answer.Full glossary → between the two distributions.
- To prevent collapse (all images get the same output), the teacher's outputs are centered by subtracting a running mean and sharpened with a low softmax A dial that controls how much a model gambles on unlikely words versus sticking to the most likely one.Full glossary →. This combination is the part that nobody understood properly for two years.
The surprise: after training DINO, the [CLS] A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary →'s A calculation where each word gets a score for how related it is to every other word, then blends in the others according to those scores.Full glossary → map on a held-out image segments objects. Foreground vs background. Cars vs people. No Labeling every single pixel in an image with what it belongs to.Full glossary → labels were used. The A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → found object boundaries because that is what makes the two-crop matching work.
DINOv2 (Oquab et al. 2023) scales this up with bigger ViTs and a curated 142M-image dataset, and the embeddings transfer to dense prediction tasks (depth, segmentation) competitively with supervised models. This is the part of vision-transformer research that quietly replaced ImageNet pretraining for most downstream tasks.
Library path:
import torch
# Load a pretrained DINOv2 backbone — no head, just features
backbone = torch.hub.load("facebookresearch/dinov2", "dinov2_vitb14")
backbone.eval()
# Embed an image
img = torch.randn(1, 3, 224, 224)
with torch.no_grad():
features = backbone(img) # (1, 768) — global feature for the imageYou can use those features directly in a kNN classifier and get ~80% on ImageNet without ever training a linear layer. The kNN-on-DINO-features A simple reference method you compare against to see whether a fancier approach is actually worth it.Full glossary → is what every new self-supervised method now compares against.
FIG 16.3.4
DETR: object detection as set prediction
Before ViT, "transformer for vision" mostly meant attaching a transformer to a CNN The grid of responses you get after sliding a filter over an image, bright where the pattern was found.Full glossary → and doing classification. DETR (Carion et al. 2020) was the paper that made a transformer the whole detector and broke the dependence on hand-tuned NMS and anchor boxes — the two staples of the older CNN detector stack: anchor boxes are a fixed grid of candidate box shapes the model scores and refines, and NMS (Cleaning up object detection by keeping only the most confident box when several boxes cover the same thing.Full glossary →) is the post-processing pass that, when several boxes fire on one object, keeps the highest-confidence one and drops the rest by overlap.
The architecture: a ResNet backbone extracts a feature map of shape (C, H/32, W/32). Squashing a multi-dimensional grid of numbers into a single long list.Full glossary → to a sequence of length . Feed into a transformer encoder. The decoder takes learned "object queries" (typically ) and cross-attends to the encoded features. Each query outputs one (bbox, class) prediction. Loss: bipartite matching between predictions and ground-truth boxes, then per-match L1 and classification losses.
The trick is the bipartite matching. With queries and (say) 5 ground-truth objects, you solve a assignment problem at training time using the Hungarian algorithm. Each ground-truth gets one query. The other 95 queries are trained to predict "no object". This eliminates NMS because the model is now trained to directly predict the set, not a redundant set of candidates.
DETR trains slowly (500 epochs to The point where the wrongness score stops dropping and levels off, so more training doesn't help.Full glossary → on COCO). Deformable DETR (Zhu et al. 2021) fixed this with sparse A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → on a sparse set of sampling points, reducing it to ~50 epochs. The lineage continues through DETR-style detectors (DINO, RT-DETR) which are now competitive with YOLOv8 and similar at real-time Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →.
FIG 16.3.5
CLIP: joint image-text embedding
CLIP is the most influential multimodal architecture of the past five years. Built by Radford et al. at OpenAI in 2021, the recipe is comically simple:
- Scrape 400 million (image, caption) pairs from the web.
- Train an image encoder (ViT or ResNet variant) and a text encoder (small transformer).
- For a A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → of pairs, compute image embeddings and text embeddings . Both L2-normalized.
- Compute the similarity matrix , where is a learned A dial that controls how much a model gambles on unlikely words versus sticking to the most likely one.Full glossary →.
- Loss: symmetric A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary → A loss that measures how far a model's predicted chances are from the true answer.Full glossary →. The image-to-text loss is cross-entropy where the correct label for row is column . The text-to-image loss is cross-entropy on the transposed matrix.
That is the entire training objective. There is no caption generation, no masked language modeling, no auxiliary loss. Two encoders, a dot product, a symmetric cross-entropy. Train for a long time on a lot of data.
What you get: an A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → space where "a photo of a cat" and an image of a cat land at nearby points. The downstream consequence is that you can do zero-shot classification. For ImageNet, encode the prompt "a photo of a {class}" for each of the 1000 classes, encode your test image, find the closest class embedding by A score for whether two sets of numbers are pointing the same direction, even if one is bigger overall.Full glossary → (the dot product after L2-normalizing both vectors, so it measures direction agreement and ignores magnitude). CLIP scores ~76% top-1 on ImageNet this way, comparable to a fully supervised ResNet-50.
CLIP contrastive loss vs. from scratch
DL glueloss_fn = open_clip.loss.ClipLoss()
# image_features, text_features: (N, d) L2-normalized; logit_scale = log_temp.exp()
loss = loss_fn(image_features, text_features, logit_scale)
# equivalently, the whole thing is just:
# logits = logit_scale * image_features @ text_features.T
# labels = torch.arange(N)
# loss = 0.5 * (F.cross_entropy(logits, labels) + F.cross_entropy(logits.T, labels))img_feat = self.image_encoder(images) # (B, d), L2-normalized
txt_feat = self.text_encoder(texts) # (B, d), L2-normalized
logits = self.log_temperature.exp() * img_feat @ txt_feat.T # (B, B)
B = logits.shape[0]
labels = torch.arange(B, device=logits.device)
loss = 0.5 * (F.cross_entropy(logits, labels)
+ F.cross_entropy(logits.T, labels))
return loss, logits
# log_temperature init = 2.65 (= log(1/0.07)), clamped to [0, 4.6] each step
# in train_step: model.log_temperature.clamp_(0.0, 4.6)from scratch: lab/solution.py: TinyCLIP.forward
- 1
ClipLoss()(image_features, text_features, logit_scale)the whole forward: build the (B,B) similarity matrix, take symmetric CE, return scalar loss - 2
logit_scale (passed in, = model.logit_scale.exp())self.log_temperature.exp() multiplying the dot products; init 2.65 -> scale e^2.65 ~ 14.2, i.e. temperature tau ~ 0.07 - 3
the matmul image_features @ text_features.T inside the lossimg_feat @ txt_feat.T producing the (B,B) cosine-similarity matrix (features already L2-normed) - 4
labels = arange(N) with the diagonal as ground truthlabels = torch.arange(B): row i's correct match is column i (the matching pair) - 5
0.5*(CE(logits) + CE(logits.T)) symmetrization0.5 * (F.cross_entropy(logits, labels) + F.cross_entropy(logits.T, labels)) = image->text plus text->image - 6
open_clip clamps logit_scale to <= log(100)train_step's model.log_temperature.clamp_(0.0, 4.6) (log(100) ~ 4.605)
What the one call hides
- The L2-normalization of both feature sets is assumed already done by the encoders (open_clip's encode_image/encode_text and the scratch encoders both call F.normalize); without it the dot product is dominated by magnitude and the loss stalls near log(N).
- open_clip's ClipLoss gathers features across all GPUs (all_gather) so the effective batch (= number of negatives) is global, not per-device; the scratch version only has the local batch as negatives.
- logit_scale lives as a learnable nn.Parameter initialized to log(1/0.07) and is clamped to <= log(100) every step inside the real training loop, exactly mirrored by the scratch clamp_(0.0, 4.6).
- The 'correct label is the diagonal' assumption silently treats every off-diagonal pair as a negative, including accidental false negatives (two cats both captioned 'a cat') -- neither path dedupes.
- F.cross_entropy folds log-softmax + NLL together with a numerically stable log-sum-exp; the scratch code leans on this same F.cross_entropy rather than hand-rolling softmax.
- Gotcha: Forgetting to L2-normalize features before the matmul: loss flatlines near -log(1/N) and zero-shot accuracy sits at chance.
- Gotcha: Not clamping logit_scale: temperature collapses toward 0, the softmax becomes one-hot, gradients vanish and training silently stops.
- Gotcha: Assuming a small clean batch is fine: CLIP's signal comes from many negatives (OpenAI used batch 32768); a tiny batch gives a weak, noisy gradient even with correct code.
- Gotcha: open_clip's loss expects the unscaled, normalized features plus a separate logit_scale arg -- passing pre-scaled logits double-applies the temperature.
Use open_clip's ClipLoss in any real multi-GPU run (it handles cross-device feature gathering and caching); the 8-line scratch loss exists to prove the entire CLIP objective is just symmetric softmax cross-entropy with the diagonal as the label, and to let you fork it (e.g. SigLIP's sigmoid loss) when the stock loss isn't what you want.
On the job: You usually call ClipLoss, but you hand-write this exact symmetric-CE block whenever you change the objective -- adding a hard-negative term, swapping to SigLIP's pairwise sigmoid, or fusing a third modality.
That is CLIP's training loss in 8 lines. The diagonal of the similarity matrix is the "correct match" for each row and each column, and cross-entropy says "the diagonal should be highest". The temperature is initialized at 0.07 and learned. Models with sharper similarities (high logit_scale) generalize better, up to a point, after which they collapse.
FIG 16.3.6
Contrastive loss as InfoNCE: why the symmetric softmax works
The CLIP loss is a specific instance of InfoNCE (Information Noise Contrastive Estimation), the workhorse of self-Teaching a model from examples where you already know the right answer for each one.Full glossary →. The InfoNCE objective for one positive pair and negatives is:
This is precisely the A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary → A loss that measures how far a model's predicted chances are from the true answer.Full glossary → you computed in clip_loss. The numerator is the diagonal entry of the similarity matrix. The denominator is the row sum. CLIP uses every other row's image as a negative for the current row's text and vice versa, so each A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → of pairs gives negatives per positive. Larger batch = more negatives = better A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → signal. CLIP was trained at batch size 32,768. This is one reason multimodal pretraining is expensive: it is bottlenecked by negative-sample quality, which scales with batch size, which scales with GPU memory.
InfoNCE has a beautiful information-theoretic justification: its negative is a lower bound on the mutual information — the number of bits that knowing one view (the image) tells you about the other (its caption), zero when they are independent. So minimizing the loss (it is a negative-log term, like the cross-entropy) raises that bound, pushing up the shared information between the two views, which is a sensible thing to want. See 18-lilian-weng/2021-05-31-contrastive §infonce for the proof.
Practical caveat: if your batch contains semantically similar pairs (two different images of cats with caption "a cat"), they will appear as false negatives. CLIP-style training is robust to this because of the sheer volume of data, but if you train on a small clean dataset, you may need to deduplicate or use a hard-negative mining strategy. SimCLR-style augmentations (random crops, color jitter) are a different way to generate "obvious" negatives.
FIG 16.3.7
CLIP zero-shot classification: prompts as classifiers
You have a pretrained CLIP. You have a list of class names you want to classify into. You have no labeled training data for those classes. What do you do?
# Pseudo-code; see the library path above for the full version
class_names = ["cat", "dog", "car", "plane"]
prompts = [f"a photo of a {c}" for c in class_names]
text_features = clip.encode_text(tokenize(prompts)) # (4, d), normalized
def classify(image):
img_feat = clip.encode_image(preprocess(image))
img_feat = img_feat / img_feat.norm(dim=-1, keepdim=True)
sims = img_feat @ text_features.T # (1, 4)
return class_names[sims.argmax().item()]The text encoder is now your classifier. Each class is represented by a 512-dimensional vector (the average of the text embeddings of however many prompts you chose). You can ensemble multiple prompts per class ("a photo of a {c}", "a sketch of a {c}", "a painting of a {c}") and average the resulting features to get a more robust class vector. This is called "prompt ensembling" and is what the OpenAI CLIP paper used to push ImageNet zero-shot from 73% to 76%.
This is the moment most people realize CLIP changed something. You can classify into any category nameable in English without training a model. You can describe a novel concept in a sentence and the model will find images that match. The space of useful classifiers became open-ended.
FIG 16.3.8
The three families of vision-language fusion
When you want a model that takes both an image and text and generates text (caption, answer to a question, dialogue), there are three architectural patterns. Each one is a different answer to the question: how do the image tokens reach the language model's The main running tally of information that flows through a deep model, with each layer reading from it and adding its bit back in.Full glossary →?
Family 1: Concatenation as prefix tokens. Embed the image as a sequence of tokens (with a ViT or a CLIP image encoder), project them into the LM's A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → space, prepend them to the text tokens. The LM treats them as a long prefix. The classic example is Frozen (Tsimpoukelli et al. 2021) and the modern example is Fuyu-8B (Adept, 2023), which uses ViT-style patch tokens projected by a single linear layer with no separate vision tower. Fuyu is the maximally simple multimodal architecture: it has only a language model, and the image is treated as exotic tokens.
Family 2: Cross-A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → into LM layers. Keep the LM frozen. Add new cross-attention layers between the LM's blocks that read from a separate vision encoder's outputs. Train only the new cross-attention layers. The classic example is Flamingo (Alayrac et al. 2022). The cross-attention layers are inserted with gating (a learned tanh gate initialized at zero, so at initialization the new layer is a no-op and the LM is preserved). The vision-encoder outputs are first compressed by a Perceiver resampler — a small transformer with a fixed number of learned query vectors — that maps an arbitrary-length sequence of image features down to a fixed 64 visual tokens per image. This is what lets Flamingo handle interleaved sequences of images and text.
Family 3: Frozen vision encoder + projector + frozen LM. A simple MLP (or two-layer projector) maps image features into the LM's embedding space, then the LM sees them as prefix tokens (so it's a hybrid of Family 1's interface and a separate vision tower). The vision encoder is pretrained (typically CLIP-ViT) and frozen. The LM is pretrained (typically Llama or Mistral) and either frozen or A cheap way to fine-tune by training small add-on pieces while leaving the big original model frozen.Full glossary →-tuned — LoRA (Low-Rank Adaptation) freezes the pretrained weights and trains only small low-rank matrices injected into the attention layers, cutting trainable parameters by ~90% so you adapt the LM without paying for a full fine-tune. Only the projector is trained from scratch on image-caption data. LLaVA (Liu et al. 2023) is the canonical example. It is the cheapest credible multimodal architecture: you can train LLaVA-1.5 on 8 A100s in a day. It is also the most copied: Qwen-VL, MiniGPT-4, InstructBLIP, and the entire long tail of open multimodal models all use some variant of "frozen vision + projector + LM".
Choosing between them, very roughly:
- Lots of paired data + lots of compute: Family 1 (concatenation) or Family 3 (projector) trained end-to-end. Most production multimodal LMs in 2024.
- Limited data, want to preserve LM: Family 2 (cross-attention) with frozen LM. Flamingo, Otter.
- No compute, just want VLM: Family 3 with everything frozen except a tiny projector. LLaVA's original recipe.
FIG 16.3.9
LLaVA-style projectors: when "just an MLP" is enough
LLaVA is the model most people will start with when building their own multimodal system, so it deserves its own section. The architecture:
- A pretrained CLIP ViT-L/14 image encoder. Frozen. Outputs a sequence of 256 patch features (after global average Shrinking an image grid by replacing each small patch with a single summary number.Full glossary → is not applied) of dimension 1024.
- A 2-layer MLP projector. Input dim 1024, hidden dim 4096, output dim 4096 (the LM's A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → dim). Trained from scratch. GELU activation between the layers.
- A pretrained Llama / Vicuna / Mistral LM, e.g. Llama-2-7B-Chat. Either frozen or A cheap way to fine-tune by training small add-on pieces while leaving the big original model frozen.Full glossary →-tuned.
At Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →, you tokenize the prompt with a special <image> placeholder. Encode the image with CLIP, project with the MLP, get 256 image tokens. Replace <image> in the embedding sequence with those 256 tokens. The LM then sees a sequence of [text-tokens-before, 256-image-tokens, text-tokens-after] and generates the response autoregressively.
Training is in two stages.
Stage 1 — One piece of information about an example that the model looks at when making a guess.Full glossary → alignment. Train only the MLP projector on 558K image-caption pairs (LAION-CC-SBU subset). The vision encoder and LM are both frozen. The MLP learns to project CLIP features into the LM's embedding space. About 4 hours on 8 A100s.
Stage 2 — instruction tuning. Train the MLP and the LM (with LoRA, typically) on 158K image-text instruction pairs generated by GPT-4 from image captions and bounding boxes. About 8 hours on 8 A100s.
The result is a model that takes an image and a question and produces a coherent answer. It is not state-of-the-art on benchmarks. It is good enough to be useful, and most importantly it is reproducible on a small lab's compute budget.
# Pseudo-code for LLaVA's forward pass
def llava_forward(image, text_tokens, image_placeholder_id):
img_feats = clip_vit(image) # (1, 256, 1024)
img_embeds = mlp_projector(img_feats) # (1, 256, 4096)
text_embeds = llm.embed(text_tokens) # (1, T, 4096)
# Replace the placeholder with image embeds
embeds = splice_image_into_text(
text_embeds, img_embeds, image_placeholder_id
) # (1, T + 256 - 1, 4096)
return llm.forward_from_embeds(embeds)The deeper lesson: a 2-layer MLP is enough projection capacity if the vision encoder is already aligned with language (which CLIP's pretraining does for free). When the vision encoder is not aligned (e.g. DINO features that have never seen text), you need a heavier projector — often a Q-Former.
Q-Former (BLIP-2; Li et al. 2023) is a small transformer that takes a fixed set of learned query vectors and cross-attends to the vision encoder's outputs. It serves as a learned compression + alignment layer between vision and language. With Q-Former, you can use a non-aligned vision encoder (e.g. EVA-CLIP, or DINO) and still get good text generation. The cost is a heavier model (the Q-Former has its own parameters and Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary →). BLIP-2 with a Q-Former and FlanT5 was state-of-the-art VLM in mid-2023 before LLaVA simplified the approach.
FIG 16.3.10
Flamingo and the perceiver resampler
Flamingo's design solves a problem LLaVA-style models duck: how do you handle multiple images interleaved with text in a single context? "Here's image A, here's image B, which is bigger?" requires the model to attend to both images and reason about them together.
The Flamingo trick is the Perceiver resampler. The vision encoder produces a variable-length sequence of features per image (sometimes a long sequence, e.g. for video). The Perceiver resampler takes those features and compresses them to a fixed number of A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → tokens (64 in the original Flamingo) using cross-A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → with learned query vectors.
import torch
import torch.nn as nn
class PerceiverResampler(nn.Module):
def __init__(self, d_model: int, n_latents: int = 64, n_layers: int = 6):
super().__init__()
self.latents = nn.Parameter(torch.randn(n_latents, d_model))
self.layers = nn.ModuleList([
PerceiverBlock(d_model) for _ in range(n_layers)
])
def forward(self, x: torch.Tensor) -> torch.Tensor:
# x: (B, T_visual, d_model) — the encoder's output, variable length
# Expand learned latents to batch
latents = self.latents.unsqueeze(0).expand(x.shape[0], -1, -1)
for layer in self.layers:
latents = layer(latents, x) # cross-attend latents -> x, plus FFN
return latents # (B, n_latents, d_model) — fixed lengthEach PerceiverBlock does cross-attention (queries from latents, keys+values from ) plus A calculation where each word gets a score for how related it is to every other word, then blends in the others according to those scores.Full glossary → among the latents plus an FFN. After the resampler, every image becomes exactly 64 tokens regardless of resolution, video length, or anything else. This is what makes Flamingo's interleaved-context training work: each image slot has predictable cost.
Flamingo's cross-attention layers (the gated layers inserted between Llama blocks) then attend to the latent visual tokens, not to the raw vision-encoder features. Two-stage compression. The masking is also designed so each text A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → only sees the visual tokens of the last preceding image in the interleaved sequence — a constraint that turned out to work better than allowing global attention across all preceding images.
The downside of Flamingo: the cross-attention layers add ~10% parameters to a frozen LM, and the gating-initialized-at-zero trick means early in training the visual signal is suppressed and the model basically ignores images until the gate opens. This makes training data-hungry. Flamingo was trained on 43M webpages with interleaved images (M3W) plus standard image-text pair datasets. Most groups can't reproduce this.
The Perceiver/Perceiver IO architecture (Jaegle et al. 2021) generalizes the resampler idea: a fixed-size latent A spot in a model where its width is squeezed small on purpose, forcing the information to compress before it continues.Full glossary → that you can cross-attend to from any A form of information, like text, images, or audio.Full glossary →. Used in DeepMind's RT-2 robotics model and several recent multimodal architectures. Worth knowing as a pattern even if you never use Flamingo specifically.
FIG 16.3.11
BLIP-2 and CoCa: combining captioning loss with contrastive
CLIP's contrastive loss is great for retrieval and zero-shot classification. It is bad for generation — CLIP can't write a caption. The next generation of multimodal models add a captioning loss to the contrastive loss.
CoCa (Contrastive Captioner; Yu & Wang et al. 2022) is the textbook example. The architecture is a unimodal text decoder (causal A calculation where each word gets a score for how related it is to every other word, then blends in the others according to those scores.Full glossary → only) on the bottom, a multimodal text decoder (cross-attends to image features) on top. Total loss is a weighted sum:
The contrastive loss aligns image and text representations like CLIP. The captioning loss makes the model predict the next text A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → given the image. The shared encoder produces representations that work for both retrieval and generation.
BLIP-2 (Li et al. 2023) does something similar but with the Q-Former as the bridge. Two-stage training:
- Vision-language representation learning: train Q-Former with three losses: image-text contrastive (like CLIP), image-text matching (binary classifier on whether image-text pair is matched), and image-grounded text generation.
- Vision-to-language generative learning: freeze the vision encoder and the LM (typically FlanT5 or OPT), train only the Q-Former to produce features that the LM can use for text generation.
The output: a model that does retrieval, classification, captioning, and VQA all reasonably well, at modest training cost.
The takeaway pattern: contrastive loss alone gives you alignment. Captioning loss alone gives you generation. Combining them gives you both, and the joint training doesn't hurt either task much. Modern multimodal pretraining recipes (PaLI, CoCa, BLIP-2, InternVL) all do some flavor of this.
FIG 16.3.12
Stable Diffusion's text conditioning: cross-attention as the only fusion point
Image generation from text is structurally different from VLMs. The model produces pixels, not tokens. But the multimodal trick is the same: cross-A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → reaches from one A form of information, like text, images, or audio.Full glossary → into another.
Stable Diffusion's pipeline:
- Text encoder. A frozen CLIP text encoder (ViT-L for SD 1.x, OpenCLIP H/14 for SD 2.x) maps the prompt to a sequence of 77 A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → embeddings of dim 768 or 1024.
- VAE encoder. A pretrained A model that squeezes images down to tiny codes and can rebuild images back from those codes.Full glossary → (VAE) — an autoencoder whose A spot in a model where its width is squeezed small on purpose, forcing the information to compress before it continues.Full glossary → compresses an image down to a small A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → code you can later decode back into pixels (covered properly in another chapter) — compresses a 512×512 RGB image to a 64×64×4 latent: a low-resolution, channels-deep A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → that stands in for the full image, so all the heavy work happens on 64×64×4 numbers instead of 512×512×3 pixels. (At Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →, you start from random noise of this shape; the VAE is only used to decode at the end.)
- UNet. The denoiser. Takes (noisy latent, timestep, text embeddings). Outputs predicted noise. This is the heart of diffusion: training adds Gaussian noise to a latent in small steps, and the UNet learns to predict the noise that was added so you can subtract it; at generation time you start from pure noise and run the UNet ~50 times, each pass predicting and removing a slice of noise so the latent edges toward a clean image (the diffusion mechanics are another chapter). This is the network that runs 50 times during inference, once per denoising step.
- VAE decoder. Takes the final denoised latent and produces the 512×512 image.
The text reaches the UNet through cross-attention layers interleaved with the convolutional ResNet blocks. The UNet at each spatial resolution applies:
x = conv_block(x) # spatial conv, no text
x = cross_attention(q=x, k=text_emb, v=text_emb) # text reaches in here
x = self_attention(x) # spatial self-attentionThe cross-attention's queries come from the image latents (flattened across spatial axes); the keys and values come from the text embeddings. The attention pattern says "this spatial position should pay attention to these text tokens". After many forward passes through these layers, the noise prediction is conditioned on the text.
The "text encoder choice matters" finding (from the Imagen paper) is important here: Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → the text encoder gives a bigger generation-quality lift than scaling the UNet. SD 2.x switched to a larger OpenCLIP for exactly this reason. Modern models (SDXL, Stable Cascade) use even bigger text encoders, sometimes two of them concatenated.
The structural lesson: cross-attention is the universal fusion primitive across multimodal architectures. Family 2 above uses it to fuse vision into LMs. Stable Diffusion uses it to fuse text into image generation. The signature pattern is Q ← modality A, K, V ← modality B. Once you know to look for it, you see it everywhere.
FIG 16.3.13
DALL-E vs Stable Diffusion: two paths to text-to-image
DALL-E (the original, 2021) and Stable Diffusion (2022) reached similar results from different architectural starting points. The contrast is instructive.
DALL-E (v1) is a single Generating text one piece at a time, where each new piece is chosen based on everything written so far.Full glossary → transformer over a joint A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → sequence: text tokens followed by image tokens. The image tokens come from a discrete VAE (dVAE) that encodes a 256×256 image to a 32×32 grid of 8192-way categorical tokens (1024 image tokens total). The transformer (12B params in the original) is trained to predict the next token given the previous. Sample: type a prompt, generate 1024 image tokens autoregressively, decode with the dVAE.
Stable Diffusion is a denoising diffusion model on continuous A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → representations, conditioned on text via cross-A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →. No Chopping text into small pieces and giving each piece a number, because models can only work with numbers.Full glossary → of images. No autoregressive sampling. Iterative denoising of a continuous A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → instead.
DALL-E's autoregressive image generation is slow at Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → (1024 tokens of sequential generation per image) and the discrete VAE creates a hard quality ceiling (you can't generate finer detail than the dVAE codebook supports). Stable Diffusion's diffusion-on-latents is fast (40-50 steps, each doing one UNet Running an example through the model from start to finish to get a guess, which is really just a chain of multiply-and-add steps.Full glossary →), and the continuous latents have no codebook limit, but training requires a complex schedule and the model is harder to fine-tune cleanly.
DALL-E 2 and DALL-E 3 switched to diffusion. The autoregressive line lived on in Parti (Google, 2022) and Muse (Google, 2023, with parallel non-autoregressive masked generation). The diffusion line continued through SDXL, Stable Cascade, FLUX, and so on. As of writing this chapter, diffusion is dominant for image generation. Autoregressive is competitive for video (where the sequential structure maps onto frames) and is making a comeback via flow matching and consistency models that combine diffusion's quality with faster sampling.
The lesson is that the A form of information, like text, images, or audio.Full glossary → of the output (continuous pixels vs discrete tokens) and the generation strategy (autoregressive vs diffusion vs masked) are largely separable design choices. You can mix them: discrete-token diffusion exists (D3PM), continuous-pixel autoregression has been tried (Image GPT), and so on.
FIG 16.3.14
Multimodal mech-interp: what features does CLIP learn?
The Distill "Multimodal Neurons" paper (Goh et al. 2021) opened the inside of CLIP and found something striking: individual neurons in the image encoder respond to concepts, not just visual features. There is a "Spider-Man neuron" that fires on photographs of Spider-Man, drawings of Spider-Man, the word "spider" written in text inside an image, and Halloween costumes. Same neuron, multiple modalities of evidence.
The interpretation: CLIP's contrastive training pressure makes the image encoder learn features that are aligned with linguistic concepts. Anything you can name in a caption tends to become a One piece of information about an example that the model looks at when making a guess.Full glossary →, because that's what makes the contrastive loss low. This is mechanistically similar to what Anthropic's sparse autoencoder work later found in language model residual streams: the model encodes a sparse dictionary of concepts, and many of them are interpretable.
The famous failure mode discovered in the same paper: typographic attacks. The Spider-Man neuron fires on the text "Spider-Man" written on a piece of paper. So you can fool CLIP into misclassifying an apple as an iPod by writing "iPod" on the apple. The model reads text in images, treats the text as evidence about content, and so a hostile label written on an object overrides the actual visual evidence. This is not a bug — it is what the training data taught the model to do, because in the LAION corpus, text in images is often a strong signal about content (book covers, store signs, etc.). It is just exploitable.
More recent work has scaled this up. Sparse autoencoders on multimodal models (Anthropic's 2024 Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary →-monosemanticity paper trained SAEs on Claude 3 Sonnet, which is multimodal) found features that activate on both visual and textual evidence of the same concept: "Golden Gate Bridge" features fire on photographs of the bridge and on text mentioning it. The features are conceptual, not A form of information, like text, images, or audio.Full glossary →-specific. This is the same finding as the 2021 multimodal neurons paper, scaled up.
FIG 16.3.15
Practical recipe: which architecture for which problem
Brutal summary of when to use what, given the modeling landscape as of 2025-2026:
- Image classification with a fixed label set, lots of labels: fine-tuned ViT or supervised CLIP-style model. ConvNeXt is also competitive if you don't have the compute for a ViT.
- Image classification with an open label set, no labels: zero-shot CLIP. Use OpenCLIP-ViT-L/14 or bigger.
- Finding every object in an image and drawing a labeled box around each one.Full glossary →: DETR-family (RT-DETR, DINO) for the modern stack. YOLO if you need real-time edge Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →.
- Semantic Labeling every single pixel in an image with what it belongs to.Full glossary →: SAM-family (Segment Anything) for promptable segmentation. Mask2Former-on-ViT for fixed-The fixed set of all chunks a model is allowed to read or produce.Full glossary → segmentation.
- Image features for retrieval / kNN / downstream tasks: DINOv2 if labels are unavailable, CLIP if you also need text alignment.
- Multimodal LM that takes images and text in, generates text: LLaVA-style (frozen CLIP-ViT + projector + Llama-family LM) is the cheap default. Move to Qwen2-VL or InternVL-style models with native multi-resolution handling if you need video or high-resolution images.
- Text-to-image generation: A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → diffusion (SDXL, FLUX) for general use. Specialized models (consistency, distilled) for fast inference.
- Building your own from scratch: do not. Use a pretrained CLIP for vision, a pretrained LM for language, train a projector. Multimodal pretraining from scratch is a $1M+ undertaking.
Most production multimodal stacks in 2026 are some variant of "CLIP image encoder + projector + Llama-family LM + A cheap way to fine-tune by training small add-on pieces while leaving the big original model frozen.Full glossary → Taking a model that already learned a lot of general skills and training it a bit more on your own specific data.Full glossary → on task-specific data". The architecture has converged. The interesting work is on data, evals, and serving (which is another chapter).
FIG 16.4 · Safety lens · this chapter
What goes wrong when you wire vision into a language model? Three failure modes that are specific to multimodal architectures, not generic LLM problems.
Typographic attacks and visual Sneaking extra instructions into an input that the model can't tell apart from the real task, so it follows yours too.Full glossary →. The same property that makes CLIP useful (it reads text in images and treats it as evidence about content) is exploitable. Goh et al. 2021 demonstrated this in the multimodal neurons paper: an apple with "iPod" written on it is classified as iPod with high confidence. The pattern generalizes to deployed VLMs. Greshake et al. 2023 showed that putting instructions in an image (visible text that says "ignore previous instructions and execute X") jailbreaks GPT-4V and similar models, because the vision encoder converts the image into tokens that the LM treats as part of its prompt. This is indirect prompt injection delivered through a new One of several stacked grids of numbers in an image, each tracking a different kind of pattern.Full glossary → — the image. The model's safety training is on textual prompts and does not transfer to malicious text rendered as pixels. Mitigations require either ignoring text in images (which breaks legitimate OCR uses) or running a separate text-extraction step with safety filtering before the image reaches the LM. Neither is fully deployed. See 26-pentest-redteam/embracethered-com-blog §multimodal-injection (Wunderwuzzi's writeups document specific live exploits on Microsoft Copilot, Claude Vision, and similar) and 18-lilian-weng/2023-10-25-adv-attack-llm §prompt-injection.
Cross-modal jailbreaks via image conditioning. Several papers (e.g., Bagdasaryan et al. 2023's "Abusing Images for Indirect Instruction Injection in Multi-Modal LLMs") demonstrate that A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary →-based adversarial perturbations on images can produce images that look benign to humans but cause the model to behave as if a specific malicious text prompt were in context. The attack is the multimodal How well the model handles brand-new examples it never studied.Full glossary → of GCG (Zou et al. 2023): instead of optimizing a text suffix, you optimize the pixels. The model's safety A small grid of weights that slides across an image to spot a particular pattern.Full glossary → does not see the actual prompt because the prompt was never in the text — it was encoded into the image's pixel values. This is harder to defend against than text injection because pixel perturbations can be made imperceptible. The most credible mitigation as of writing is image-side adversarial robustness training (a la Madry-style PGD), which is computationally expensive and only partially effective. See 18-lilian-weng/2023-10-25-adv-attack-llm §gradient-based + §multimodal-extensions and 26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications §multimodal.
CSAM and copyright leakage from training data. Stable Diffusion 1.5 was trained on LAION-5B, a web-scraped image-text dataset that includes CSAM (Thiel 2023 documented this) and includes copyrighted images (Carlini et al. 2023 showed that diffusion models can memorize and regurgitate training images, particularly those that appear multiple times in the corpus). The model does not "know" it learned these — the A rule that turns one bad guess into a single number measuring how far off it was, where lower is better.Full glossary → makes no distinction. The downstream consequence is that an open-weights diffusion model trained on uncurated data is a redistribution vector for copyrighted images and a generator of illegal content. Stability AI's response (for SD 2.0+) was to filter the training data and add classifier-based output filtering. Black Forest Labs (FLUX) and others have taken stricter approaches. This is a class of problem that does not exist for text-only models in the same form because text training data is rarely "illegal to possess" in the way images can be. See 01-explorables/distill-multimodal-neurons §dataset-issues (briefly) and the LAION-5B follow-ups in 26-pentest-redteam/.
What habits to adopt when you build multimodal code:
- Run an OCR step on uploaded images and check it against safety filters before passing to your VLM. Most production deployments now do this. If a user uploads an image with "ignore previous instructions" in it, you want to catch that before it reaches your LM.
- Cache image encodings, not raw images. This reduces the attack surface for adversarial image attacks: an attacker who can replace the cached encoding can attack you, but the broader internet's adversarial images don't reach your encoder repeatedly.
- Log the image A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → alongside the text response. During incident response, you want to be able to look at the embedding distribution to detect anomalies. Many adversarial images produce embeddings that look statistically out-of-distribution.
FIG 16.5 · Under the hood
The library call, and the lines it hides
You don't have to choose between “use the library” and “build it from scratch.” Here is the one library call, the exact lines it stands in for, and when to reach for which on the job.
Scaled dot-product multi-head self-attention vs. from scratch
DL primitive# Option A: the fused kernel for the core operation
out = F.scaled_dot_product_attention(q, k, v) # q,k,v: (B, n_heads, T, d_k)
# Option B: the full module (does qkv projection + heads + output proj)
mha = nn.MultiheadAttention(d_model, n_heads, bias=False, batch_first=True)
out, _ = mha(x, x, x) # self-attention, no causal mask -> bidirectional (ViT-style)B, T, C = x.shape
qkv = self.qkv(x) # (B, T, 3C), one fused Linear, bias=False
q, k, v = qkv.split(C, dim=-1)
q = q.view(B, T, self.n_heads, self.d_k).transpose(1, 2) # (B, h, T, d_k)
k = k.view(B, T, self.n_heads, self.d_k).transpose(1, 2)
v = v.view(B, T, self.n_heads, self.d_k).transpose(1, 2)
scores = q @ k.transpose(-2, -1) / math.sqrt(self.d_k) # (B, h, T, T)
attn = F.softmax(scores, dim=-1) # rows sum to 1
return self.out((attn @ v).transpose(1, 2).contiguous().view(B, T, C))from scratch: lab/solution.py: TransformerBlock._attn
- 1
internal q@k.T scaling inside scaled_dot_product_attentionscores = q @ k.transpose(-2,-1) / math.sqrt(self.d_k) -- the 1/sqrt(d_k) scale - 2
internal softmax(scores) of the fused kernelattn = F.softmax(scores, dim=-1) over the key axis - 3
internal attn @ v of the fused kernel(attn @ v) -> the weighted sum of value vectors - 4
nn.MultiheadAttention's in_proj_weightself.qkv = nn.Linear(d_model, 3*d_model, bias=False) then split into q,k,v - 5
nn.MultiheadAttention's head reshaping.view(B, T, n_heads, d_k).transpose(1, 2) -- splitting C into (h, d_k) and moving heads to dim 1 - 6
nn.MultiheadAttention's out_projself.out(... .transpose(1,2).contiguous().view(B, T, C)) -- merge heads then project
What the one call hides
- scaled_dot_product_attention silently dispatches to a fused FlashAttention/memory-efficient kernel when shapes/dtype allow, never materializing the (T,T) matrix -- the scratch code always builds it.
- nn.MultiheadAttention bundles the qkv projection, head split/merge, and output projection into one call, so the explicit .view/.transpose plumbing is invisible.
- The 1/sqrt(d_k) scaling (to keep dot-product variance ~1 before softmax) is applied internally by both library paths; a beginner never sees that it exists.
- No causal mask is applied here (ViT/CLIP encoders are bidirectional); the library default is also no mask, but it accepts is_causal/attn_mask which the scratch path would need an explicit added term for.
- nn.MultiheadAttention adds bias by default (bias=True); the scratch block deliberately uses bias=False on both qkv and out, matching ViT convention.
- Gotcha: nn.MultiheadAttention defaults to batch_first=False (expects (T, B, C)); passing (B, T, C) without batch_first=True silently transposes your meaning.
- Gotcha: The library's d_model must be divisible by n_heads or it raises -- same assert as the scratch d_model % n_heads == 0.
- Gotcha: scaled_dot_product_attention takes already-projected, already-head-split q/k/v; it does NOT do the Linear projections -- forgetting that gives you attention over raw embeddings.
- Gotcha: Using nn.MultiheadAttention's averaged attention weights for interpretability is misleading -- it averages across heads by default (need_weights/average_attn_weights).
In production, prefer F.scaled_dot_product_attention (or an attention library) for the fused, memory-efficient kernel, and nn.MultiheadAttention when you want the whole projection-plus-heads module; the hand-rolled version is purely to see that attention is three matmuls and a softmax.
On the job: You rarely re-implement the core kernel, but you do write the qkv-projection / head-reshape / mask plumbing around F.scaled_dot_product_attention whenever you build a variant the stock module won't expose cleanly -- cross-attention, ALiBi/RoPE, sliding-window or KV-cache decoding.
CLIP zero-shot classification (text as classifier) vs. from scratch
DL gluemodel, _, preprocess = open_clip.create_model_and_transforms('ViT-B-32', pretrained='laion2b_s34b_b79k')
tokenizer = open_clip.get_tokenizer('ViT-B-32')
texts = [f'a photo of a {c}' for c in class_names]
with torch.no_grad():
img_feat = model.encode_image(preprocess(img).unsqueeze(0))
txt_feat = model.encode_text(tokenizer(texts))
img_feat = img_feat / img_feat.norm(dim=-1, keepdim=True)
txt_feat = txt_feat / txt_feat.norm(dim=-1, keepdim=True)
pred = (img_feat @ txt_feat.T).argmax(dim=-1)@torch.no_grad()
def zero_shot_predict(model, image, class_captions):
img_feat = model.image_encoder(image) # (B, embed), L2-normalized
txt_feat = model.text_encoder(class_captions)# (C, embed), L2-normalized
sims = img_feat @ txt_feat.T # (B, C) cosine similarities
return sims.argmax(dim=-1) # closest class caption per imagefrom scratch: lab/solution.py: zero_shot_predict
- 1
model.encode_image(preprocess(img))model.image_encoder(image) -> the L2-normalized image embedding - 2
model.encode_text(tokenizer(['a photo of a {c}', ...]))model.text_encoder(class_captions) -> one normalized vector per class caption - 3
img_feat @ txt_feat.T (cosine sim because both normalized)sims = img_feat @ txt_feat.T -- the (B, C) similarity matrix - 4
(...).argmax(dim=-1) to pick the classreturn sims.argmax(dim=-1) -- nearest class caption is the prediction - 5
no .fit() / no training -- the text encoder IS the classifierno gradient step anywhere in the function; classifier weights = caption embeddings
What the one call hides
- The prompt template ('a photo of a {c}') is itself a tuned hyperparameter -- OpenAI's prompt ensembling (averaging multiple templates per class) is what pushed ImageNet zero-shot from 73% to 76%; the bare scratch call uses one template.
- Both feature sets must be L2-normalized for the dot product to be cosine similarity; open_clip leaves normalization to you (note the explicit /norm in the library snippet) -- the scratch encoders fold F.normalize in.
- Temperature/logit_scale is irrelevant for argmax (a positive monotone scale), so zero_shot_predict drops it; but for calibrated probabilities you must multiply by logit_scale and softmax, which the library exposes via model.logit_scale.
- The class vector is just the text embedding -- there is no learned linear head, no bias, no class-frequency prior, unlike a softmax classifier from supervised training.
- open_clip's tokenizer truncates/pads captions to a fixed context length (77 for CLIP); the scratch char-tokenizer pads/truncates to TEXT_MAX_LEN=30.
- Gotcha: Skipping L2-normalization makes argmax track feature magnitude instead of direction -> garbage predictions even with a perfectly trained model.
- Gotcha: Class names the text encoder never saw at training (fine-grained species, rare proper nouns) classify poorly -- zero-shot only works for nameable, web-frequent concepts.
- Gotcha: Forgetting model.eval()/torch.no_grad() leaves dropout/grad on; the scratch fn is decorated @torch.no_grad() but the library call needs it added explicitly.
- Gotcha: The prediction is only as good as the prompt wording; a bad template ('{c}' vs 'a photo of a {c}') can swing accuracy by several points with zero code change.
Reach for a pretrained open_clip / HuggingFace CLIP for any real zero-shot task -- you will not out-train LAION-2B; the scratch zero_shot_predict shows the punchline that a CLIP classifier is just 'embed image, embed each class caption, take the argmax cosine similarity' with no .fit() in sight.
On the job: You write this exact embed-and-argmax routine constantly -- it's how you turn any CLIP-style encoder into a label-free classifier or a retrieval index, and where you add prompt ensembling, a learned bias/temperature, or a different distance metric.
Pre-norm transformer encoder block vs. from scratch
DL primitiveblock = nn.TransformerEncoderLayer(
d_model=64, nhead=4, dim_feedforward=4*64,
activation='gelu', norm_first=True, batch_first=True, dropout=0.0,
)
x = block(x) # (B, T, d_model) -> (B, T, d_model), bidirectional (no mask)self.ln1 = nn.LayerNorm(d_model)
self.ln2 = nn.LayerNorm(d_model)
self.ffn = nn.Sequential(
nn.Linear(d_model, 4 * d_model),
nn.GELU(),
nn.Linear(4 * d_model, d_model),
)
...
def forward(self, x):
x = x + self._attn(self.ln1(x)) # pre-norm + attention + residual
x = x + self.ffn(self.ln2(x)) # pre-norm + FFN + residual
return xfrom scratch: lab/solution.py: TransformerBlock
- 1
norm_first=True (pre-norm)self.ln1(x) BEFORE attention and self.ln2(x) BEFORE the FFN, not after - 2
the self_attn submodule of TransformerEncoderLayerself._attn(...) -- the multi-head scaled-dot-product block - 3
the residual add around attention (x = x + dropout(attn))x = x + self._attn(self.ln1(x)) - 4
dim_feedforward=4*d_model with the two Linears + activationnn.Sequential(Linear(d, 4d), GELU(), Linear(4d, d)) - 5
activation='gelu'nn.GELU() between the FFN's two linears - 6
the residual add around the FFNx = x + self.ffn(self.ln2(x))
What the one call hides
- nn.TransformerEncoderLayer applies dropout after attention, after the FFN, and inside the FFN; the scratch block has zero dropout, so behavior differs unless you pass dropout=0.0.
- The library defaults to norm_first=False (post-norm, the original 2017 design); modern ViT/GPT use pre-norm, which you must opt into -- the scratch block is pre-norm by construction.
- TransformerEncoderLayer's internal MultiheadAttention adds bias on its projections by default; the scratch block uses bias=False qkv/out (ViT convention), a silent numerical difference.
- The library applies its own weight init (xavier on the in/out projections); the scratch block leaves init to PyTorch defaults on its Linears.
- Default activation is ReLU in nn.TransformerEncoderLayer -- you must pass activation='gelu' to match the scratch GELU FFN.
- Gotcha: norm_first defaults to False: leave it and you get post-norm, which trains differently (and worse for deep stacks) than the pre-norm scratch block.
- Gotcha: batch_first defaults to False -> the layer expects (T, B, C); forgetting batch_first=True silently mis-shapes your sequence.
- Gotcha: Default activation is 'relu' and default dropout is 0.1; both differ from the scratch block (GELU, no dropout) and quietly change results.
- Gotcha: nn.TransformerEncoderLayer is a single layer -- you still need nn.TransformerEncoder (or a ModuleList loop, as the scratch code does) to stack n_layers.
Use nn.TransformerEncoderLayer/Encoder (or a pretrained backbone) in production for the fused, well-initialized, dropout-correct implementation; the scratch TransformerBlock exists so you can see a layer is exactly 'pre-norm -> attention -> residual -> pre-norm -> MLP -> residual', and build the non-standard variants (cross-attention, gated layers a la Flamingo) the stock layer won't give you.
On the job: You stack nn.TransformerEncoderLayer for vanilla encoders, but you re-derive this forward by hand whenever the architecture deviates -- adding cross-attention to another modality, gating residuals, or inserting adapters/LoRA between the sublayers.
FIG 16.6 · Chapter notebook
Build this chapter with your own hands
A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.
What you'll build
- A Vision Transformer from scratch: a PatchEmbedding that is one Conv2d, a learned [CLS] token, learned position embeddings, and the same encoder Block you wrote in Ch 15. You prove the conv-as-patch-projection equivalence with an assert, then run a randn smoke test after every module.
- A tiny ViT trained on FashionMNIST, complete with a deliberate broken training loop (gradients never zeroed) that you diagnose from the loss curve and fix.
- CLIP's contrastive loss from eight lines, derived as symmetric softmax cross-entropy on a similarity matrix, checked against F.cross_entropy and recovered on synthetic ground-truth-aligned embeddings.
- A zero-shot classifier built out of a frozen embedding space, and the cross-attention fusion primitive (Q <- modality A, K,V <- modality B) that wires text into every multimodal model.
~6 min on CPU · 101 cells · 11 checked exercises · runs in Colab
FIG 16.7 · Going further
29-practice-engineering/lucidrains-vitPhil Wang's ViT-PyTorch repo. Implements every ViT variant published. Read after this chapter to see how the patterns recombine.
01-explorables/distill-multimodal-neuronsthe canonical paper on CLIP's internal features. Critical for understanding why typographic attacks work.
18-lilian-weng/2022-06-09-vlmLilian Weng's exhaustive taxonomy of vision-language models. Covers VisualBERT, SimVLM, Frozen, Flamingo, CoCa, BLIP. If you want to know about a VLM published before mid-2022, look here first.
18-lilian-weng/2021-05-31-contrastivethe InfoNCE deep-dive. Reading this clears up why CLIP's loss is what it is and connects it to SimCLR, MoCo, BYOL, and the rest of the self-supervised vision literature.
01-explorables/jalammar-illustrated-stable-diffusionthe friendliest introduction to diffusion + text conditioning. Read alongside the LDM paper for full context.
22-anthropic-recent/2024-scaling-monosemanticity-index §multimodal-featuresAnthropic's SAE work on Claude 3 Sonnet (multimodal). Features are conceptual, not modality-specific. Mechanistically validates the 2021 Distill paper.
26-pentest-redteam/embracethered-com-blog §multimodalWunderwuzzi's live exploit writeups on commercial multimodal systems. Concrete examples of visual prompt injection in the wild.
04-stanford/cs231n(lecture on transformers in vision, if available) — Fei-Fei Li's group's perspective on ViT vs CNN.
FIG 16.8 · What this enables
Chapters you can now read, with the connecting idea written out.
KV cache and quantization techniques apply to multimodal models, but with a twist: image tokens are typically many more than text tokens (256 per image vs ~10-20 text tokens per turn), so KV cache for VLMs is dominated by visual context. Knowing the architecture matters for what's possible.
Now that you've seen how CLIP conditions Stable Diffusion's UNet, you can read the diffusion math knowing where the text fits in.
Modern computer-use agents (Claude Computer Use, GPT-4V agent demos) are multimodal VLMs that take screenshots as input. The architecture from this chapter is the agent's perception layer.
Sparse autoencoders on multimodal models reveal cross-modal features. The pipeline assumes you understand how text and vision share a residual stream.
Visual prompt injection, typographic attacks, and adversarial image attacks are deep dive territory. This chapter introduces them; another chapter makes them practice.
FIG 16.9 · 28 sources
- 01-explorables/distill-multimodal-neurons
- 01-explorables/distill-augmented-rnns
- 01-explorables/jalammar-illustrated-stable-diffusion
- 01-explorables/jalammar-illustrated-retrieval-transformer
- 01-explorables/jalammar-illustrated-transformer
- 03-curricula/hf-cv-course-unit0
- 04-stanford/cs231n-* (where available)
- 05-safety/neelnanda-mechanistic-interpretability-glossary
- 06-practice/lilianweng-posts-2023-01-10-inference-optimization
- 14-arena-notebooks/chapter1-part2-intro-to-mech-interp
- 16-d2l-sections/chapter_attention-mechanisms-and-transformers__vision-transformer
- 16-d2l-sections/chapter_attention-mechanisms-and-transformers__multihead-attention
- 16-d2l-sections/chapter_computer-vision__rcnn
- 18-lilian-weng/2019-11-10-self-supervised
- 18-lilian-weng/2021-05-31-contrastive
- 18-lilian-weng/2021-07-11-diffusion-models
- 18-lilian-weng/2022-06-09-vlm
- 18-lilian-weng/2023-10-25-adv-attack-llm
- 18-lilian-weng/2024-04-12-diffusion-video
- 22-anthropic-recent/2024-scaling-monosemanticity-index
- 24-founder-blogs/huyenchip-huyenchip-com-2023-10-10-multimodal-html
- 24-founder-blogs/raschka-understanding-large-language-models
- 26-pentest-redteam/embracethered-com-blog
- 26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications
- 29-practice-engineering/lucidrains-vit
- 29-practice-engineering/lucidrains-dalle
- 29-practice-engineering/lucidrains-imagen
- 29-practice-engineering/lucidrains-flash-attention