Lab 01 — ViT Patch Embedder + CLIP Contrastive Encoder + LLaVA Projector
Phase: 04 — Vision-Language Models Difficulty: ⭐⭐⭐☆☆ (the math is small; the fusion plumbing is what trips people) Time: 3–4 hours
A vision-language model is three machines bolted together: a Vision Transformer that turns pixels into tokens, a CLIP objective that teaches an image encoder and a text encoder to live in the same embedding space, and a connector that splices image tokens into an LLM's token stream so the language model can "read" the picture. This lab builds all three from scratch on nested lists —
patchify+patch_embed(ViT),cosine_similarity+clip_similarity_matrix+info_nce_loss+clip_zero_shot_classify(CLIP), andProjector+assemble_multimodal_sequence(LLaVA) — so the mechanism that lets GPT-4V, Claude, and LLaVA see is muscle memory, not magic.
What you build
patchify(image, patch_size)— ViT's tokenizer: chop anH×Wgrayscale image into non-overlappingP×Ppatches in raster order, each flattened to a vector; reject non-divisible sizes. An image becomes(H/P)·(W/P)tokens — "an image is worth 16×16 words."patch_embed(patches, W)— the linear projection that lifts each flat patch into ad-dim token, with an optional prepended learned[CLS]vector.cosine_similarity/l2_normalize— the similarity metric CLIP lives on, with the zero-norm guard interviewers love to probe.clip_similarity_matrix(image_embs, text_embs, temperature)— the temperature-scaled cosine logits: entry[i][j]is how much imageimatches textj, divided bytemperature.info_nce_loss(sim_matrix)— the symmetric InfoNCE loss (image→text + text→image cross-entropy, diagonal = positive), with the log-sum-exp trick so large logits never overflow. The soul of the lab: low when matched pairs sit on the diagonal, high when you shuffle.clip_zero_shot_classify(image_emb, class_text_embs)— classify by argmax cosine similarity to text prompts: no training, no fixed label set.Projector(in_dim, out_dim, hidden, seed)— LLaVA's connector: a seeded 2-layer GELU MLP that maps vision features into the LLM's token-embedding width.assemble_multimodal_sequence(text_token_embs, image_token_embs, idx)— splice the projected image tokens into the text stream at the<image>placeholder, yielding one fused sequence of lengthlen(text) - 1 + len(image).
Key concepts
| Concept | What to understand |
|---|---|
| Image → patches → tokens | a ViT treats P×P patches like words; (H/P)·(W/P) tokens enter a plain transformer |
| Patch embedding | a single linear layer projects flat patches to d_model; position + [CLS] are added on top |
| Shared embedding space | CLIP trains image and text encoders so a matching pair has high cosine similarity |
| Temperature | divides the logits; small τ sharpens the softmax the loss runs over |
| InfoNCE / in-batch negatives | the diagonal is the positive; every other pair in the batch is a free negative |
| Zero-shot via prompts | build the classifier from text ("a photo of a {class}") → argmax cosine — no fine-tune |
| Projection vs cross-attention | LLaVA feeds projected image tokens into the LLM; Flamingo cross-attends to them |
| Images inflate the token budget | each image is dozens–hundreds of tokens → bigger KV-cache (ties to Phase 00) |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers — your implementation |
solution.py | complete reference; python solution.py runs a worked example |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # the worked example
Success criteria
- All tests pass against your implementation (and
LAB_MODULE=solution). - You can explain why
patchifyof a4×4image withpatch_size=2gives 4 patches of length 4, and why a non-divisible size must raise. - You can state why
cosine_similarityis1for identical,-1for opposite,0for orthogonal vectors, and why a zero-norm input returns0instead of dividing by zero. - You can explain why
test_info_nce_low_when_pairs_matchedandtest_info_nce_high_when_pairs_shuffledare the whole point — the loss is low only when the diagonal (the matched pair) dominates its row and column. - You can explain why
info_nce_loss([[1000,0],[0,1000]])is ~0 and does not overflow (max-subtraction / log-sum-exp). - You can explain why
assemble_multimodal_sequencereturns lengthlen(text) - 1 + len(image)and where in the sequence the image tokens land. - Given any "how does an LLM see an image?" prompt you can sketch ViT → CLIP-style encoder → projector → LLM, and name the token-budget cost.
How this maps to the real stack
| The miniature | The production mechanism | Where to verify it |
|---|---|---|
patchify / patch_embed | ViT's Conv2d(kernel=stride=patch_size) patch embed; timm / HF ViTModel | HF transformers ViTModel; the ViT paper's "16×16 words" |
cosine_similarity / l2_normalize | CLIP normalizes embeddings to the unit sphere so a dot product is cosine | OpenCLIP / HF CLIPModel.get_image_features (L2-normed) |
clip_similarity_matrix + temperature | CLIP's logit_scale (a learned temperature) times the normalized embeddings | HF CLIPModel.logit_scale; the CLIP paper §2.3 |
info_nce_loss | the symmetric image/text contrastive loss; SigLIP swaps it for a sigmoid loss | OpenCLIP ClipLoss; CLIP & SigLIP papers |
clip_zero_shot_classify | zero-shot ImageNet via prompt ensembling — the result that made CLIP famous | OpenCLIP zero-shot eval scripts |
Projector | LLaVA's MLP vision→language connector (the part you actually train) | LLaVA repo build_vision_projector; the LLaVA-1.5 paper |
assemble_multimodal_sequence | LLaVA's prepare_inputs_labels_for_multimodal splicing image tokens at <image> | LLaVA llava_arch.py; HF LlavaForConditionalGeneration |
Limits of the miniature (be honest in the interview): real ViTs add learned position
embeddings and run a full transformer over the patch tokens — we stop at the linear patch
embed; CLIP trains the encoders for hours on hundreds of millions of pairs — we just score
fixed vectors; the projector here is randomly initialized, not trained (training it, with
the encoder frozen and LoRA on the LLM, is Phase 05); and we model grayscale images as
nested lists, so there is no 3-channel Conv2d, no interpolation of position embeddings for
new resolutions, and no O(T²) attention cost — the parts that make high-resolution VLMs
expensive.
Extensions (build these on real hardware)
- Add learned position embeddings and a
[CLS]token topatch_embed, then run a one-layer self-attention block over the patch tokens (reuse your Phase 02 attention) to make it a real (tiny) ViT. - Add a real temperature parameter that is learned (CLIP's
logit_scale) and show the gradient ofinfo_nce_lossw.r.t. it (reuse your Phase 03 autograd). - Swap InfoNCE for SigLIP's pairwise sigmoid loss and compare batch-size sensitivity.
- Implement Flamingo-style cross-attention fusion (image tokens as keys/values the LLM attends to) and contrast its token-budget cost with the LLaVA projection approach.
- Compute the KV-cache inflation: an image at
336×336with14×14patches is576tokens — plug that into your Phase 00kv_cache_bytesand show how many images blow the context budget. - Load a real
openai/clip-vit-base-patch32and check your zero-shot classification picks the same class on a handful of images.
Interview / resume
- Talking points: "How does an LLM actually see an image?" "What is contrastive pretraining and why does the temperature matter?" "Projection vs cross-attention fusion — when would you pick each?" "How does adding an image change the KV-cache and the cost?"
- Resume bullet: Built a from-scratch vision-language pipeline — ViT patch embedding, CLIP-style contrastive encoder with a numerically stable symmetric InfoNCE loss and zero-shot text-prompt classification, and a LLaVA-style MLP connector that fuses projected image tokens into an LLM's token stream — clarifying the exact mechanism behind multimodal models.