CLIP: contrastive vision-language pretraining
Train an image encoder and text encoder jointly with an InfoNCE contrastive loss on image-caption pairs; show how zero-shot classification emerges from cosine similarity.
Image classification systems traditionally require labeled datasets (image, class label pairs) to train. CLIP (Contrastive Language-Image Pretraining; Radford et al., OpenAI, 2021) sidesteps this by training on 400M image-caption pairs scraped from the internet[1]. The result: a model that can classify images into any category described in natural language -- zero-shot.
Architecture
CLIP trains two encoders jointly:
- Image encoder: a Vision Transformer (ViT) or ResNet that maps an image to an embedding .
- Text encoder: a Transformer encoder that maps a text string to an embedding .
Both embeddings are L2-normalized. Similarity is measured with the dot product (equivalent to cosine similarity after normalization).
Contrastive pretraining
For a batch of (image, caption) pairs, construct an similarity matrix:
The diagonal entries are positive pairs (matching image and caption). All off-diagonal entries are negative pairs (mismatched images and captions).
The InfoNCE loss is applied symmetrically (image-to-text and text-to-image):
where is a learned temperature (initialized to ).
Zero-shot classification
Given class names, CLIP classifies an image without any labeled examples:
- Construct a text prompt for each class: "a photo of a {class}."
- Embed all prompts: .
- Embed the image: .
- Compute cosine similarities: .
- Predict the class with the highest similarity: .
Prompt engineering matters significantly: "a photo of a {class}" outperforms just "{class}" because the training data contained many such captions. More elaborate prompts ("a photo of a {class}, a type of food") improve fine-grained classification.
Prompt ensembling: average embeddings over multiple prompt templates: "a photo of a {class}", "a blurry photo of a {class}", "art of a {class}", etc. This reduces sensitivity to any single template and typically improves accuracy.
Implementation (numpy)
import numpy as np
def softmax(x, axis=-1):
e = np.exp(x - x.max(axis=axis, keepdims=True))
return e / e.sum(axis=axis, keepdims=True)
def clip_loss(image_embs, text_embs, log_temp=np.log(1/0.07)):
"""
image_embs: (N, d) L2-normalized
text_embs: (N, d) L2-normalized
"""
temp = np.exp(log_temp)
logits = image_embs @ text_embs.T * temp # (N, N)
labels = np.arange(len(image_embs))
# Image-to-text loss
log_probs_i2t = np.log(softmax(logits, axis=1))
loss_i2t = -log_probs_i2t[labels, labels].mean()
# Text-to-image loss
log_probs_t2i = np.log(softmax(logits.T, axis=1))
loss_t2i = -log_probs_t2i[labels, labels].mean()
return (loss_i2t + loss_t2i) / 2
def zero_shot_classify(image_emb, class_names):
"""image_emb: (d,), class_names: list of strings. Returns predicted class index."""
# In practice: encode prompts with the text encoder
# Here: simulate with random embeddings (replace with real encoder)
rng = np.random.default_rng(42)
text_embs = np.array([rng.normal(0, 1, len(image_emb)) for _ in class_names])
text_embs /= np.linalg.norm(text_embs, axis=1, keepdims=True) # L2 normalize
sims = image_emb @ text_embs.T # (K,)
return class_names[sims.argmax()], softmax(sims)
# Demo
N, d = 8, 16
rng = np.random.default_rng(0)
img_embs = rng.normal(0, 1, (N, d))
txt_embs = img_embs + rng.normal(0, 0.5, (N, d)) # similar to images
img_embs /= np.linalg.norm(img_embs, axis=1, keepdims=True)
txt_embs /= np.linalg.norm(txt_embs, axis=1, keepdims=True)
loss = clip_loss(img_embs, txt_embs)
print(f"CLIP loss (paired): {loss:.4f}")
# Zero-shot classification demo
test_img = img_embs[0]
classes = ["cat", "dog", "bird", "fish"]
pred_cls, probs = zero_shot_classify(test_img, classes)
print(f"Predicted class: {pred_cls}")
print(f"Probabilities: {dict(zip(classes, probs.round(3)))}")What CLIP learns and where it fails
Learns: object categories, scenes, textures, styles, relationships -- essentially whatever the image-caption pairs contain.
Fails:
- Counting: "an image with exactly 3 dogs" -- CLIP does not count reliably.
- Negation: "an image without a tree" -- CLIP cannot reason about absence.
- Spatial relationships: "the cat is to the left of the dog" -- weak.
- Fine-grained: satellite imagery, medical scans, specialist domains rare in web captions.
CLIP as a backbone: CLIP image features are powerful representations for downstream tasks. Fine-tuning on a labeled dataset consistently outperforms supervised pretraining on ImageNet. CLIP embeddings serve as the visual front-end for vision-language models (LLaVA, BLIP-2)[2, 3].
Exercises
Exercise 1. A CLIP batch has image-caption pairs. How many entries does the similarity matrix have, how many are positive pairs, and how many are negative pairs? Generalize to arbitrary .
Solution
Step 1. is , so it has entries.
Step 2. The positive pairs are the diagonal entries , one per row: of them.
Step 3. Everything off-diagonal is a negative pair: . In general has entries, positives, and negatives, so each image is contrasted against wrong captions inside the batch. This is why CLIP wants a large batch: more in-batch negatives sharpen the contrastive signal.
Exercise 2. Take the smallest non-trivial batch, , with embeddings already L2-normalized and orthogonal: and . Using temperature , compute the symmetric InfoNCE loss by hand.
Solution
Step 1. The similarity matrix is , which here is the identity:
Step 2. With the logits equal . Row 0 of the image-to-text softmax has the positive at logit and the negative at logit , so the probability on the diagonal is
Step 3. By symmetry every row and column gives the same diagonal probability, so both the image-to-text and text-to-image terms equal , and so does their average:
The loss is positive even with perfectly matched, orthogonal pairs because a finite temperature never drives the off-diagonal probability fully to zero.
Exercise 3 (code). Implement the symmetric CLIP loss in numpy and verify the closed-form result from Exercise 2.
Solution
import numpy as np
def softmax(x, axis=-1):
e = np.exp(x - x.max(axis=axis, keepdims=True))
return e / e.sum(axis=axis, keepdims=True)
def clip_loss(img, txt, tau=1.0):
logits = (img @ txt.T) / tau # (N, N) similarity / temperature
n = len(img)
labels = np.arange(n)
l_i2t = -np.log(softmax(logits, axis=1))[labels, labels].mean()
l_t2i = -np.log(softmax(logits.T, axis=1))[labels, labels].mean()
return (l_i2t + l_t2i) / 2
img = np.array([[1.0, 0.0], [0.0, 1.0]]) # normalized, orthogonal
txt = img.copy() # text == image
loss = clip_loss(img, txt, tau=1.0)
expected = np.log(1 + np.exp(-1.0)) # -log(e/(e+1))
assert np.isclose(loss, expected)
print(round(loss, 4), "nats") # 0.3133The numpy loss matches the hand derivation to floating-point precision.
These aligned embeddings are exactly what the next lesson reuses: a vision-language model wires CLIP's frozen image encoder to an LLM so it can generate text, not just match it.
References
- [1]Radford, A., et al. (2021). "Learning transferable visual models from natural language supervision (CLIP)". ICML. arXiv:2103.00020↩
- [2]Liu, H., Li, C., Wu, Q., & Lee, Y. J. (2023). "Visual instruction tuning (LLaVA)". NeurIPS. arXiv:2304.08485↩
- [3]Li, J., Li, D., Savarese, S., & Hoi, S. (2023). "BLIP-2: bootstrapping language-image pre-training with frozen image encoders and large language models". ICML. arXiv:2301.12597↩