Multimodal Alignment & Contrastive Learning (CLIP)
Derive the InfoNCE symmetric contrastive loss from scratch, explain dual-encoder vector alignment, explore SigLIP sigmoid loss, and implement zero-shot multimodal retrieval in Python and Rust.
Traditional artificial intelligence systems treat sensory modalities in strict isolation: Computer Vision models classify spatial pixel matrices, Natural Language Processing models process discrete text token sequences, and Speech architectures analyze acoustic frequency spectrograms.
Multimodal AI bridges these heterogeneous perception streams into a single unified geometric vector space. The breakthrough architecture enabling open-vocabulary zero-shot perception is OpenAI's Contrastive Language-Image Pre-training (CLIP).
Maximizes cosine similarity between matching image-text pairs while repelling non-matching negative pairs across the batch.
1. The Multimodal Vector Alignment Problem#
Given an image and a text caption , single-modality encoders output features residing in completely distinct coordinate systems:
Because and inhabit separate vector spaces, Euclidean distance or inner products between and are mathematically undefined.
To resolve this, CLIP introduces linear projection layers and that map both representations into a shared latent embedding space of dimension :
Both vectors are -normalized to lie on the unit hypersphere . Their semantic compatibility is measured by the cosine similarity:
2. Derivation of the InfoNCE Contrastive Loss#
Consider a mini-batch of paired image-text examples:
There are positive matching pairs on the diagonal: . The remaining off-diagonal pairs for serve as negative distractors.
Let denote the cosine similarity between image and text . Let denote a learnable temperature parameter controlling the sharpness of the probability distribution.
Step 1: Image-to-Text Classification Loss ()#
For image , we formulate a categorical distribution over all candidate text captions in the batch using the Softmax function:
The ground-truth label is the matching diagonal caption . The categorical cross-entropy loss for image is:
Averaging across all images in the mini-batch yields:
Step 2: Text-to-Image Classification Loss ()#
Symmetrically, for text caption , we predict which of the candidate images corresponds to it:
The cross-entropy loss averaged across all texts is:
Step 3: Total Symmetric InfoNCE Objective#
The total CLIP loss is the arithmetic mean of the two directional contrastive objectives:
3. Alternative Alignment: SigLIP (Sigmoid Loss)#
In 2023, Xiaohua Zhai et al. introduced SigLIP (Sigmoid Loss for Language-Image Pre-training).
Standard Softmax InfoNCE requires global denominator normalization across all items in the batch (). In multi-GPU distributed data parallel setups, this requires expensive All-Gather collective communications.
SigLIP reframes multimodal contrastive learning as independent binary classification problems using the sigmoid function :
where if (positive match) and if (negative pair), with learnable temperature and bias .
Because every cell in the similarity matrix is evaluated independently without batch-wide normalization, SigLIP scales to arbitrarily large batch sizes with reduced communication overhead.
4. Zero-Shot Classification Mechanics#
Once trained, CLIP operates as an open-vocabulary classifier without requiring any fine-tuning on downstream datasets:
5. From-Scratch Implementation: CLIP Contrastive Engine & Zero-Shot Retrieval#
We implement the complete CLIP dual-encoder embedding projection, cosine similarity matrix computation, symmetric InfoNCE loss, and zero-shot retrieval ranking from scratch in Python and Rust without external libraries.
Python Implementation (Pure Standard Library)#
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103""" CLIP Dual-Encoder Embedding Alignment & Symmetric InfoNCE Loss from scratch using only the Python standard library. """ import math from typing import List, Tuple def l2_normalize(vec: List[float]) -> List[float]: norm = math.sqrt(sum(x * x for x in vec)) if norm < 1e-12: return vec[:] return [x / norm for x in vec] def dot_product(v1: List[float], v2: List[float]) -> float: return sum(a * b for a, b in zip(v1, v2)) def matmul_vec(matrix: List[List[float]], vec: List[float]) -> List[float]: # matrix: [out_dim, in_dim], vec: [in_dim] return [dot_product(row, vec) for row in matrix] class SimpleCLIPAlignment: def __init__(self, vis_dim: int, text_dim: int, embed_dim: int, temperature: float = 0.07): self.vis_dim = vis_dim self.text_dim = text_dim self.embed_dim = embed_dim self.temperature = temperature # Projection matrices: W_vis [embed_dim, vis_dim], W_text [embed_dim, text_dim] # Initialized with simple deterministic scaling scale_v = 1.0 / math.sqrt(vis_dim) self.w_vis = [[((i + j) % 7 - 3) * scale_v * 0.1 for j in range(vis_dim)] for i in range(embed_dim)] scale_t = 1.0 / math.sqrt(text_dim) self.w_text = [[((i * j) % 5 - 2) * scale_t * 0.1 for j in range(text_dim)] for i in range(embed_dim)] def project_image(self, raw_vis: List[float]) -> List[float]: projected = matmul_vec(self.w_vis, raw_vis) return l2_normalize(projected) def project_text(self, raw_text: List[float]) -> List[float]: projected = matmul_vec(self.w_text, raw_text) return l2_normalize(projected) def compute_infonce_loss(self, img_embeds: List[List[float]], txt_embeds: List[List[float]]) -> float: N = len(img_embeds) # Compute cosine similarity matrix S: [N, N] sim_matrix = [[dot_product(img_embeds[i], txt_embeds[j]) for j in range(N)] for i in range(N)] # Image-to-Text Cross Entropy loss_i2t = 0.0 for i in range(N): scaled_logits = [sim_matrix[i][j] / self.temperature for j in range(N)] max_logit = max(scaled_logits) exp_sum = sum(math.exp(l - max_logit) for l in scaled_logits) log_prob = (scaled_logits[i] - max_logit) - math.log(exp_sum) loss_i2t += -log_prob loss_i2t /= N # Text-to-Image Cross Entropy loss_t2i = 0.0 for j in range(N): scaled_logits = [sim_matrix[i][j] / self.temperature for i in range(N)] max_logit = max(scaled_logits) exp_sum = sum(math.exp(l - max_logit) for l in scaled_logits) log_prob = (scaled_logits[j] - max_logit) - math.log(exp_sum) loss_t2i += -log_prob loss_t2i /= N return 0.5 * (loss_i2t + loss_t2i) def zero_shot_classify(self, img_embed: List[float], class_text_embeds: List[List[float]]) -> List[float]: # Compute similarities with all class embeddings logits = [dot_product(img_embed, class_emb) / self.temperature for class_emb in class_text_embeds] max_l = max(logits) exps = [math.exp(l - max_l) for l in logits] sum_exp = sum(exps) return [e / sum_exp for e in exps] if __name__ == "__main__": clip = SimpleCLIPAlignment(vis_dim=4, text_dim=4, embed_dim=3, temperature=0.1) # Batch of 3 paired synthetic features raw_images = [ [1.0, 0.5, -0.2, 0.8], # dog image [-0.5, 1.2, 0.3, -0.1], # sports car image [0.2, -0.8, 1.5, 0.4] # sunset beach image ] raw_texts = [ [0.9, 0.4, -0.1, 0.7], # "a photo of a dog" [-0.4, 1.1, 0.2, -0.2], # "a photo of a sports car" [0.1, -0.7, 1.4, 0.5] # "a photo of a sunset beach" ] img_emb = [clip.project_image(img) for img in raw_images] txt_emb = [clip.project_text(txt) for txt in raw_texts] loss = clip.compute_infonce_loss(img_emb, txt_emb) print(f"InfoNCE Loss for batch: {loss:.4f}") # Zero-shot classification test for Image 0 (dog) probs = clip.zero_shot_classify(img_emb[0], txt_emb) for c_idx, p in enumerate(probs): print(f"Class {c_idx} Probability: {p * 100:.2f}%")
Rust Implementation (Pure Standard Library)#
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129// Multimodal Contrastive Alignment & Symmetric InfoNCE Loss // Implemented strictly from scratch in pure Rust std. fn l2_normalize(vec: &[f64]) -> Vec<f64> { let norm_sq: f64 = vec.iter().map(|&x| x * x).sum(); let norm = norm_sq.sqrt(); if norm < 1e-12 { vec.to_vec() } else { vec.iter().map(|&x| x / norm).collect() } } fn dot_product(a: &[f64], b: &[f64]) -> f64 { a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum() } struct SimpleCLIP { embed_dim: usize, temperature: f64, w_vis: Vec<Vec<f64>>, // [embed_dim x vis_dim] w_text: Vec<Vec<f64>>, // [embed_dim x text_dim] } impl SimpleCLIP { fn new(vis_dim: usize, text_dim: usize, embed_dim: usize, temperature: f64) -> Self { let scale_v = 1.0 / (vis_dim as f64).sqrt(); let mut w_vis = vec![vec![0.0; vis_dim]; embed_dim]; for i in 0..embed_dim { for j in 0..vis_dim { w_vis[i][j] = (((i + j) % 7) as f64 - 3.0) * scale_v * 0.1; } } let scale_t = 1.0 / (text_dim as f64).sqrt(); let mut w_text = vec![vec![0.0; text_dim]; embed_dim]; for i in 0..embed_dim { for j in 0..text_dim { w_text[i][j] = (((i * j) % 5) as f64 - 2.0) * scale_t * 0.1; } } Self { embed_dim, temperature, w_vis, w_text, } } fn project_image(&self, raw: &[f64]) -> Vec<f64> { let proj: Vec<f64> = self.w_vis.iter().map(|row| dot_product(row, raw)).collect(); l2_normalize(&proj) } fn project_text(&self, raw: &[f64]) -> Vec<f64> { let proj: Vec<f64> = self.w_text.iter().map(|row| dot_product(row, raw)).collect(); l2_normalize(&proj) } fn compute_loss(&self, img_embeds: &[Vec<f64>], txt_embeds: &[Vec<f64>]) -> f64 { let n = img_embeds.len(); let mut sim = vec![vec![0.0; n]; n]; for i in 0..n { for j in 0..n { sim[i][j] = dot_product(&img_embeds[i], &txt_embeds[j]); } } // Image to Text Loss let mut loss_i2t = 0.0; for i in 0..n { let scaled: Vec<f64> = (0..n).map(|j| sim[i][j] / self.temperature).collect(); let max_val = scaled.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let sum_exp: f64 = scaled.iter().map(|&x| (x - max_val).exp()).sum(); let log_prob = (scaled[i] - max_val) - sum_exp.ln(); loss_i2t -= log_prob; } loss_i2t /= n as f64; // Text to Image Loss let mut loss_t2i = 0.0; for j in 0..n { let scaled: Vec<f64> = (0..n).map(|i| sim[i][j] / self.temperature).collect(); let max_val = scaled.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let sum_exp: f64 = scaled.iter().map(|&x| (x - max_val).exp()).sum(); let log_prob = (scaled[j] - max_val) - sum_exp.ln(); loss_t2i -= log_prob; } loss_t2i /= n as f64; 0.5 * (loss_i2t + loss_t2i) } fn zero_shot_classify(&self, img_emb: &[f64], candidate_texts: &[Vec<f64>]) -> Vec<f64> { let logits: Vec<f64> = candidate_texts.iter().map(|txt| dot_product(img_emb, txt) / self.temperature).collect(); let max_val = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let exps: Vec<f64> = logits.iter().map(|&x| (x - max_val).exp()).collect(); let sum_exp: f64 = exps.iter().sum(); exps.iter().map(|&e| e / sum_exp).collect() } } fn main() { let clip = SimpleCLIP::new(4, 4, 3, 0.1); let raw_images = vec![ vec![1.0, 0.5, -0.2, 0.8], vec![-0.5, 1.2, 0.3, -0.1], vec![0.2, -0.8, 1.5, 0.4], ]; let raw_texts = vec![ vec![0.9, 0.4, -0.1, 0.7], vec![-0.4, 1.1, 0.2, -0.2], vec![0.1, -0.7, 1.4, 0.5], ]; let img_embeds: Vec<Vec<f64>> = raw_images.iter().map(|img| clip.project_image(img)).collect(); let txt_embeds: Vec<Vec<f64>> = raw_texts.iter().map(|txt| clip.project_text(txt)).collect(); let loss = clip.compute_loss(&img_embeds, &txt_embeds); println!("InfoNCE Loss (Rust std): {:.4}", loss); let probs = clip.zero_shot_classify(&img_embeds[0], &txt_embeds); for (i, p) in probs.iter().enumerate() { println!("Class {} Prediction: {:.2}%", i, p * 100.0); } }
6. Architectural Comparison: Multimodal Paradigms#
| Paradigm | Architecture | Alignment Level | Computational Efficiency | Downstream Flexibility |
|---|---|---|---|---|
| Dual Encoder (CLIP) | Independent Vision & Text Encoders + Dot Product | Global representation vectors | Extremely fast: embeddings pre-computable for instant index search | Strong zero-shot classification and text-to-image retrieval |
| Cross-Attention Resampler (Flamingo) | Frozen Vision Encoder + Perceiver Resampler into LLM | Fine-grained patch embeddings conditioned via cross-attention | Moderate: requires full forward pass through large decoder | Multi-image interleaved in-context conversational reasoning |
| Linear Projection Adapter (LLaVA) | Vision Encoder + 2-layer MLP projection directly into token space | Patches mapped directly as visual tokens | High throughput: visual tokens treated identically to words | Document OCR, visual question answering, spatial grounding |
| Independent Sigmoid (SigLIP) | Dual Encoder with pairwise Sigmoid classification | Global vectors without batch-wide normalization | Scalable to ultra-large batch sizes with zero multi-GPU all-gather latency | Open-vocabulary vision-language tasks at Google scale |
7. Knowledge Check#
Question
question: "Why must image and text feature embeddings be L2-normalized before computing cosine similarity in CLIP?"
options:
- "(Recommended) To constrain all vectors to the unit hypersphere S^(d-1), preventing vector magnitudes from artificially dominating the dot product similarity score"
- "To eliminate the need for matrix multiplication"
- "To convert floating point numbers to integers"
- "To prevent the text encoder from learning grammar"
explanation: "L2 normalization ensures that ||z_I|| = ||z_T|| = 1. Consequently, z_I . z_T equals the cosine of the angle between them, bounded in [-1, 1]. Without normalization, encoders could arbitrarily increase vector norms to manipulate the Softmax probability distribution."
:::
Question
question: "What primary distributed systems advantage does SigLIP offer over standard InfoNCE CLIP?"
options:
- "(Recommended) SigLIP decomposes contrastive learning into independent pairwise binary sigmoid classifications, eliminating the global Softmax denominator that requires multi-GPU All-Gather communication"
- "SigLIP eliminates the need for a vision encoder"
- "SigLIP replaces attention with convolution"
- "SigLIP requires zero negative examples"
explanation: "Standard InfoNCE Softmax requires summing exp(s_ik / tau) across all batch elements k, which demands expensive distributed all-gather synchronization across GPU ranks. SigLIP evaluates each cell with a sigmoid loss independently, removing batch-wide synchronization barriers."
:::