Thomas Piacentini
← Workshop

Cheat sheet

Build a VLM from Scratch

Original text · English

Patch embed, ViT encoder, MLP projector, cross-attention decoder, InfoNCE plus LM pretraining, R@K, EM and BLEU-4.

The VLM stack

197 tokensCLSmemory K, Vqueriesimage_emblogitsImage224x224x3clientPatchEmbedConv2d, 196 tokens + CLSprocessViT12 blocks, 12 heads,pre-LNmodelMLPProjectorlinear, GELU, linearprocessCaption idsmean-pooled text tableclientDecodercausal self +cross-attentionmodelLossesInfoNCE + LMprocess
Nodes and connections
NodeRoleKind
Image224x224x3client
PatchEmbedConv2d, 196 tokens + CLSprocess
ViT12 blocks, 12 heads, pre-LNmodel
MLPProjectorlinear, GELU, linearprocess
Caption idsmean-pooled text tableclient
Decodercausal self + cross-attentionmodel
LossesInfoNCE + LMprocess
FromToConnection
ImagePatchEmbed
PatchEmbedViT197 tokens
ViTMLPProjectorCLS
ViTDecodermemory K, V
Caption idsDecoderqueries
MLPProjectorLossesimage_emb
DecoderLosseslogits
Sources

Run each lesson

CommandWhat it does
python3 code/main.py (58)Tokens of shape (1, 197, 768). Unfold matches Conv2d
python3 code/main.py (59)Encodes to (1, 197, 768). About 86M parameters
python3 code/main.py (60)200 steps. Loss from about 1.07 to about 0.80
python3 code/main.py (61)Logits (2, 10, text_vocab), mask (10, 10), cache check
python3 code/main.py (62)50 steps. Contrastive near 2.77 to 2.4. LM 6.24 to 4.7
python3 code/main.py (63)Metrics before and after 50 training steps
python3 -m unittest code/test_main.pyRun the tests of one lesson
Sources

Build and encode

cfg = ViTConfig()224, 16, 768, depth 12, heads 12
encoder = VisionEncoder(cfg).eval()front end plus ViT stack
img = synthesize_image(seed=0)(1, 3, 224, 224) in [0, 1]
tokens, cls = encoder(img)(1, 197, 768) and (1, 768)
pos = sinusoidal_2d(grid_h, grid_w, dim)dim divisible by 4
ref = unfold_then_linear(img, weight, bias, cfg.patch_size)
nn.Conv2d(cfg.in_channels, cfg.hidden,
kernel_size=cfg.patch_size, stride=cfg.patch_size)
x = x + self.attn(self.ln1(x))pre-LN residual
x = x + self.ffn(self.ln2(x))GELU MLP, 4x wide
Sources

Align, fuse and pretrain

projector = MLPProjector(cfg.vision_hidden, cfg.projection_hidden, cfg.text_hidden)
freeze(encoder)requires_grad_(False)
loss = cosine_alignment_loss(image_emb, text_emb)mean of 1 - cos
mask = causal_mask(10)(10, 10) lower triangle
logits = decoder(text_ids, memory, use_cache=True)image K, V cached
contrast, sim = info_nce_loss(image_emb, text_emb, self.log_tau)
lm = lm_loss(logits, targets, padding_id=PAD_ID)PAD_ID is 0
total = contrast + cfg.lm_weight * lm
Sources

Attention in a VLM

FactorViT self-attentionDecoder self-attentionCross-attention
QueryImage tokensText tokensText tokens
Keys and valuesImage tokensText tokensImage memory tokens
MaskNo maskCausal (Nt, Nt)No mask
Output shape(B, 197, 768)(B, Nt, hidden)(B, Nt, hidden)
WhyEncoder-only and bidirectionalNo look-ahead in autoregressionWhole image visible
CacheNot usedNot usedImage K, V once per image
Sources

One pretraining step

  1. Sampler picks 16 pairs without replacement from the 200-pair corpus.
  2. Encoder returns memory tokens and the projected image embedding.
  3. Text side mean-pools embedded caption ids to text_emb.
  4. InfoNCE L2-normalizes both. Cross-entropy on rows and columns, then the mean.
  5. Decoder reads text_ids[:, :-1] and predicts text_ids[:, 1:].
  6. LM loss cross-entropy that ignores padding id 0.
  7. Adam steps on contrast + lm_weight * lm. Prints every 5 steps.
Sources

Shapes and numbers

Patches
224 / 16 gives a 14x14 grid of 196 patches of 768 values.
Attention cost
196 tokens give 38,416 scores per head per layer.
Projection
590,592 parameters for ViT-Base/16. CLS adds 768.
Head dim
768 / 12 heads gives 64.
Per block
about 7.1M. 12 blocks about 85M. Encoder about 86M.
Projector
about 1.3M for a 768 -> 1024 -> 512 head.
Cosine loss
0 when aligned, 2 when opposite.
Random R@K, N=50
R@1 0.02, R@5 0.10, R@10 0.20.
Sources

Evaluate the model

TaskCode
Build the eval suitebuild_eval_suite(seed, n_samples, vocab_size, max_len)
Image and text recalli2t, t2i = recall_at_k(sim, k)
VQA accuracyvqa_exact_match(predictions, references)
Caption scorebleu4(generated, references, smoothing=True)
All three metricsevaluate(model, suite)
Train between evalscontrast, lm, _ = model(imgs, ids)
Sources

Config defaults

SettingDefaultNote
image_size, patch_size224Patch 16, hidden 768
depth, heads12mlp_ratio 4.0, LayerNorm eps 1e-6
Align steps20032 pairs, lr 3e-4
projection_hidden1024text_hidden 512
Decoder hidden2568 heads, depth 4, 197 vision tokens
Pretrain batch_size1650 steps, lr 5e-4
lm_weight1.0Weight of the LM loss
init_log_taumath.log(1.0 / 0.07)Scale clamped to 1e-3 and 100.0
Sources

Errors and fixes

SymptomCauseFix
patch_size ... must divide image_sizeGrid is not wholePick a patch size that divides the image
spatial mismatch from PatchEmbedInput size differs from configResize to image_size
sinusoidal_2d dim must be divisible by 4Hidden size not a multiple of 4Use a hidden size divisible by 4
hidden ... not divisible by headsHead dim is not wholePick heads that divide hidden
could not load lesson 58 main.pyLesson folders movedKeep sibling lesson folders in place
causal mask shape ... does not matchMask is not (Nt, Nt)Build it with causal_mask(nt)
batch_size ... cannot exceed corpus sizeSampling without replacementLower batch size or add pairs
k ... not in [1, N]K is larger than the poolKeep k at or below N
Sources

Questions

Why patches, not pixels?
150,528 pixel tokens make attention unaffordable.
Why freeze the encoder at alignment?
Only the 1.3M projector has to learn.
Why late fusion?
Text stays clean. Image K, V run once per image.
Why learn the temperature?
Too small is noisy. Too large flattens the softmax.
Sources
Original source
---
id: vlm-from-scratch
title: Build a VLM from Scratch
code: BV
subtitle: Patch embed, ViT encoder, MLP projector, cross-attention decoder, InfoNCE plus LM pretraining, R@K, EM and BLEU-4.
hue: 25
sources: phases/19-capstone-projects/58-vision-encoder-patches, phases/19-capstone-projects/59-vit-transformer, phases/19-capstone-projects/60-projection-layer-modality-align, phases/19-capstone-projects/61-cross-attention-fusion, phases/19-capstone-projects/62-vision-language-pretraining, phases/19-capstone-projects/63-multimodal-eval
source_hash: 70009b06f92fcb5f
---

## The VLM stack
type: architecture
from: phases/19-capstone-projects/58-vision-encoder-patches/docs/en.md, phases/19-capstone-projects/59-vit-transformer/docs/en.md, phases/19-capstone-projects/60-projection-layer-modality-align/docs/en.md, phases/19-capstone-projects/61-cross-attention-fusion/docs/en.md, phases/19-capstone-projects/62-vision-language-pretraining/docs/en.md

```arch
node | img | 1,1 | Image | 224x224x3 | client
node | patch | 2,1 | PatchEmbed | Conv2d, 196 tokens + CLS | process
node | vit | 3,1 | ViT | 12 blocks, 12 heads, pre-LN | model
node | proj | 4,1 | MLPProjector | linear, GELU, linear | process
node | text | 1,2 | Caption ids | mean-pooled text table | client
node | dec | 3,2 | Decoder | causal self + cross-attention | model
node | loss | 4,2 | Losses | InfoNCE + LM | process
edge | img | patch |
edge | patch | vit | 197 tokens
edge | vit | proj | CLS
edge | vit | dec | memory K, V
edge | text | dec | queries
edge | proj | loss | image_emb
edge | dec | loss | logits
```

## Run each lesson
type: command-table
span: 2
from: phases/19-capstone-projects/58-vision-encoder-patches/docs/en.md, phases/19-capstone-projects/59-vit-transformer/docs/en.md, phases/19-capstone-projects/60-projection-layer-modality-align/docs/en.md, phases/19-capstone-projects/61-cross-attention-fusion/docs/en.md, phases/19-capstone-projects/62-vision-language-pretraining/docs/en.md, phases/19-capstone-projects/63-multimodal-eval/docs/en.md

| Command | What it does |
|---|---|
| `python3 code/main.py` (58) | Tokens of shape `(1, 197, 768)`. Unfold matches Conv2d |
| `python3 code/main.py` (59) | Encodes to `(1, 197, 768)`. About 86M parameters |
| `python3 code/main.py` (60) | 200 steps. Loss from about 1.07 to about 0.80 |
| `python3 code/main.py` (61) | Logits `(2, 10, text_vocab)`, mask `(10, 10)`, cache check |
| `python3 code/main.py` (62) | 50 steps. Contrastive near 2.77 to 2.4. LM 6.24 to 4.7 |
| `python3 code/main.py` (63) | Metrics before and after 50 training steps |
| `python3 -m unittest code/test_main.py` | Run the tests of one lesson |

## Build and encode
type: annotated-code
lang: python
from: phases/19-capstone-projects/58-vision-encoder-patches/code/main.py, phases/19-capstone-projects/59-vit-transformer/code/main.py

```python
cfg = ViTConfig()                         # 224, 16, 768, depth 12, heads 12
encoder = VisionEncoder(cfg).eval()       # front end plus ViT stack
img = synthesize_image(seed=0)            # (1, 3, 224, 224) in [0, 1]
tokens, cls = encoder(img)                # (1, 197, 768) and (1, 768)
pos = sinusoidal_2d(grid_h, grid_w, dim)  # dim divisible by 4
ref = unfold_then_linear(img, weight, bias, cfg.patch_size)
nn.Conv2d(cfg.in_channels, cfg.hidden,
          kernel_size=cfg.patch_size, stride=cfg.patch_size)
x = x + self.attn(self.ln1(x))            # pre-LN residual
x = x + self.ffn(self.ln2(x))             # GELU MLP, 4x wide
```

## Align, fuse and pretrain
type: annotated-code
lang: python
from: phases/19-capstone-projects/60-projection-layer-modality-align/code/main.py, phases/19-capstone-projects/61-cross-attention-fusion/code/main.py, phases/19-capstone-projects/62-vision-language-pretraining/code/main.py

```python
projector = MLPProjector(cfg.vision_hidden, cfg.projection_hidden, cfg.text_hidden)
freeze(encoder)                                     # requires_grad_(False)
loss = cosine_alignment_loss(image_emb, text_emb)   # mean of 1 - cos
mask = causal_mask(10)                              # (10, 10) lower triangle
logits = decoder(text_ids, memory, use_cache=True)  # image K, V cached
contrast, sim = info_nce_loss(image_emb, text_emb, self.log_tau)
lm = lm_loss(logits, targets, padding_id=PAD_ID)    # PAD_ID is 0
total = contrast + cfg.lm_weight * lm
```

## Attention in a VLM
type: comparison
from: phases/19-capstone-projects/59-vit-transformer/docs/en.md, phases/19-capstone-projects/61-cross-attention-fusion/docs/en.md

| Factor | ViT self-attention | Decoder self-attention | Cross-attention |
|---|---|---|---|
| Query | Image tokens | Text tokens | Text tokens |
| Keys and values | Image tokens | Text tokens | Image memory tokens |
| Mask | No mask | Causal `(Nt, Nt)` | No mask |
| Output shape | `(B, 197, 768)` | `(B, Nt, hidden)` | `(B, Nt, hidden)` |
| Why | Encoder-only and bidirectional | No look-ahead in autoregression | Whole image visible |
| Cache | Not used | Not used | Image K, V once per image |

## One pretraining step
type: flow
from: phases/19-capstone-projects/62-vision-language-pretraining/code/main.py, phases/19-capstone-projects/62-vision-language-pretraining/docs/en.md

1. **Sampler**: picks 16 pairs without replacement from the 200-pair corpus.
2. **Encoder**: returns memory tokens and the projected image embedding.
3. **Text side**: mean-pools embedded caption ids to `text_emb`.
4. **InfoNCE**: L2-normalizes both. Cross-entropy on rows and columns, then the mean.
5. **Decoder**: reads `text_ids[:, :-1]` and predicts `text_ids[:, 1:]`.
6. **LM loss**: cross-entropy that ignores padding id 0.
7. **Adam**: steps on `contrast + lm_weight * lm`. Prints every 5 steps.

## Shapes and numbers
type: concepts
from: phases/19-capstone-projects/58-vision-encoder-patches/docs/en.md, phases/19-capstone-projects/59-vit-transformer/docs/en.md, phases/19-capstone-projects/60-projection-layer-modality-align/docs/en.md, phases/19-capstone-projects/63-multimodal-eval/docs/en.md

- **Patches**: 224 / 16 gives a 14x14 grid of 196 patches of 768 values.
- **Attention cost**: 196 tokens give 38,416 scores per head per layer.
- **Projection**: 590,592 parameters for ViT-Base/16. CLS adds 768.
- **Head dim**: 768 / 12 heads gives 64.
- **Per block**: about 7.1M. 12 blocks about 85M. Encoder about 86M.
- **Projector**: about 1.3M for a `768 -> 1024 -> 512` head.
- **Cosine loss**: 0 when aligned, 2 when opposite.
- **Random R@K, N=50**: R@1 0.02, R@5 0.10, R@10 0.20.

## Evaluate the model
type: task-table
span: 2
from: phases/19-capstone-projects/63-multimodal-eval/code/main.py

| Task | Code |
|---|---|
| Build the eval suite | `build_eval_suite(seed, n_samples, vocab_size, max_len)` |
| Image and text recall | `i2t, t2i = recall_at_k(sim, k)` |
| VQA accuracy | `vqa_exact_match(predictions, references)` |
| Caption score | `bleu4(generated, references, smoothing=True)` |
| All three metrics | `evaluate(model, suite)` |
| Train between evals | `contrast, lm, _ = model(imgs, ids)` |

## Config defaults
type: defaults
from: phases/19-capstone-projects/58-vision-encoder-patches/code/main.py, phases/19-capstone-projects/59-vit-transformer/code/main.py, phases/19-capstone-projects/60-projection-layer-modality-align/code/main.py, phases/19-capstone-projects/61-cross-attention-fusion/code/main.py, phases/19-capstone-projects/62-vision-language-pretraining/code/main.py

| Setting | Default | Note |
|---|---|---|
| `image_size`, `patch_size` | 224 | Patch 16, hidden 768 |
| `depth`, `heads` | 12 | `mlp_ratio` 4.0, LayerNorm eps 1e-6 |
| Align `steps` | 200 | 32 pairs, lr 3e-4 |
| `projection_hidden` | 1024 | `text_hidden` 512 |
| Decoder `hidden` | 256 | 8 heads, depth 4, 197 vision tokens |
| Pretrain `batch_size` | 16 | 50 steps, lr 5e-4 |
| `lm_weight` | 1.0 | Weight of the LM loss |
| `init_log_tau` | math.log(1.0 / 0.07) | Scale clamped to 1e-3 and 100.0 |

## Errors and fixes
type: troubleshooting
from: phases/19-capstone-projects/58-vision-encoder-patches/code/main.py, phases/19-capstone-projects/59-vit-transformer/code/main.py, phases/19-capstone-projects/60-projection-layer-modality-align/code/main.py, phases/19-capstone-projects/61-cross-attention-fusion/code/main.py, phases/19-capstone-projects/62-vision-language-pretraining/code/main.py, phases/19-capstone-projects/63-multimodal-eval/code/main.py

| Symptom | Cause | Fix |
|---|---|---|
| `patch_size ... must divide image_size` | Grid is not whole | Pick a patch size that divides the image |
| `spatial mismatch` from `PatchEmbed` | Input size differs from config | Resize to `image_size` |
| `sinusoidal_2d dim must be divisible by 4` | Hidden size not a multiple of 4 | Use a hidden size divisible by 4 |
| `hidden ... not divisible by heads` | Head dim is not whole | Pick heads that divide hidden |
| `could not load lesson 58 main.py` | Lesson folders moved | Keep sibling lesson folders in place |
| `causal mask shape ... does not match` | Mask is not `(Nt, Nt)` | Build it with `causal_mask(nt)` |
| `batch_size ... cannot exceed corpus size` | Sampling without replacement | Lower batch size or add pairs |
| `k ... not in [1, N]` | K is larger than the pool | Keep k at or below N |

## Questions
type: qa
from: phases/19-capstone-projects/58-vision-encoder-patches/docs/en.md, phases/19-capstone-projects/59-vit-transformer/docs/en.md, phases/19-capstone-projects/60-projection-layer-modality-align/docs/en.md, phases/19-capstone-projects/61-cross-attention-fusion/docs/en.md, phases/19-capstone-projects/62-vision-language-pretraining/docs/en.md

- **Why patches, not pixels?** 150,528 pixel tokens make attention unaffordable.
- **Why freeze the encoder at alignment?** Only the 1.3M projector has to learn.
- **Why late fusion?** Text stays clean. Image K, V run once per image.
- **Why learn the temperature?** Too small is noisy. Too large flattens the softmax.
Code and artifacts 1
What you take away

Saved in your notebook on this device.

Sources and limits

Reading checkpoints are self-reported. Test results are recorded separately and apply to the executed cases.

AI Engineering from Scratch · 3f9715c · MIT

Download this file ↓