← CLIP Series Reading Guide
Deep Learning · Architectures · CLIP (Contrastive Language–Image Pre-training) Chapter 02 / 4

2. Inside CLIP's Figure 3 Pseudocode: Linear Projections, $\ell_2$ Hypersphere Normalization, Learnable Temperature $\exp(t)$ & Symmetric Cross-Entropy

Figure 3 of the OpenAI CLIP paper (Radford et al., 2021) is one of the most famous code snippets in modern deep learning: in just $15$ lines of NumPy-style pseudocode, it specifies the entire forward and loss computation of CLIP. Our second set of handwritten OneNote notes (from Karandeep Singh's line-by-line breakdown of OpenAI CLIP) dissects every variable, matrix multiplication, normalization axis, and cross-entropy call in Figure 3. In this chapter, we walk through those exact notes, prove why each operation is mathematically necessary, and trace a complete $3 \times 3$ numerical batch by hand.

Six-stage tensor pipeline tracing CLIP Figure 3: (1) Aligned Images I[N,H,W,C] and Texts T[N,L], (2) Backbone features I_f[N,d_i] and T_f[N,d_t], (3) Linear projections W_i and W_t into [N,d_e], (4) L2 normalization onto the unit hypersphere, (5) Scaled pairwise cosine logits, and (6) Symmetric cross-entropy loss with labels = np.arange(n).
Figure 1: Complete tensor shape and mathematical pipeline of CLIP's Figure 3 implementation across the image branch, text branch, scaled cosine logit matrix, and bidirectional cross-entropy loss.

1. The Annotated Figure 3 Pseudocode from Our Study Notes

Below is our annotated OneNote sheet showing Figure 3 from Radford et al. (2021) alongside handwritten callouts for the linear projection matrices, row-wise $\ell_2$ normalization, the $N \times N$ logit matrix, ground-truth index vector labels = np.arange(n), and the averaged bidirectional loss:

Handwritten OneNote notes titled Karandeep Singh OpenAI CLIP showing Figure 3 NumPy pseudocode with red callout arrows explaining W_i and W_t projections, I_f and T_f feature extraction, l2_normalize distribution alignment, logits = np.dot(I_e, T_e.T) * np.exp(t), labels = np.arange(n) identity matrix diagonal, and symmetric cross-entropy loss.
Figure 2 (Study Notes — Figure 3 Dissection): Annotated OpenAI CLIP NumPy pseudocode tracing feature extraction, projection into a shared embedding dimension, $\ell_2$ normalization, temperature-scaled cosine logits, and symmetric cross-entropy.

For reference, here is the exact verbatim Figure 3 pseudocode from Radford et al. (2021):

# image_encoder - ResNet or Vision Transformer
# text_encoder  - CBOW or Text Transformer
# I[n, h, w, c] - minibatch of aligned images
# T[n, l]       - minibatch of aligned texts
# W_i[d_i, d_e] - learned proj of image to embed
# W_t[d_t, d_e] - learned proj of text to embed
# t             - learned temperature parameter

# extract feature representations of each modality
I_f = image_encoder(I) #[n, d_i]
T_f = text_encoder(T)  #[n, d_t]

# joint multimodal embedding [n, d_e]
I_e = l2_normalize(np.dot(I_f, W_i), axis=1)
T_e = l2_normalize(np.dot(T_f, W_t), axis=1)

# scaled pairwise cosine similarities [n, n]
logits = np.dot(I_e, T_e.T) * np.exp(t)

# symmetric loss function
labels = np.arange(n)
loss_i = cross_entropy_loss(logits, labels, axis=0)
loss_t = cross_entropy_loss(logits, labels, axis=1)
loss   = (loss_i + loss_t)/2

2. Line-by-Line Mathematical & Engineering Dissection

Step 1 & 2: Unaligned Modality Features (I_f, T_f) and Linear Projections (W_i, W_t)

The image encoder and text encoder are completely separate architectures with different internal widths:

You cannot take the dot product of a $768$-dimensional vector and a $512$-dimensional vector! As our handwritten red callout notes: W_i[d_i, d_e] and W_t[d_t, d_e] bring text and image to the same embedding space $\mathbb{R}^{d_e}$ (e.g., $d_e = 512$ for ViT-B/32 or $d_e = 768$ for ViT-L/14):

$$\mathbf{U}_i = \mathbf{I}_f W_i \in \mathbb{R}^{n \times d_e}, \qquad \mathbf{U}_t = \mathbf{T}_f W_t \in \mathbb{R}^{n \times d_e}.$$

Why did OpenAI use a pure linear projection $W_i, W_t$ instead of a 2-layer non-linear MLP projection head like SimCLR and MoCo v2? Radford et al. tested non-linear MLP projection heads and found that while non-linear heads help self-supervised image-only models hide augmentation invariance quirks, in massive multimodal pre-training a simple linear projection performs identically while keeping zero-shot linear algebra clean and fast.

Step 3: Why Row-Wise $\ell_2$ Normalization (axis=1) Is Critical

I_e = l2_normalize(np.dot(I_f, W_i), axis=1)
T_e = l2_normalize(np.dot(T_f, W_t), axis=1)

In our handwritten notes, we wrote: "We normalize to bring image & text embeddings to similar distribution, because text encoder might have different distributions than image encoder." Specifically, dividing each row vector $\mathbf{u} \in \mathbb{R}^{d_e}$ by its Euclidean norm $\|\mathbf{u}\|_2 = \sqrt{\sum_{k=1}^{d_e} u_k^2}$ along axis=1 enforces two critical guarantees:

  1. Scale & Distribution Invariance Across Modalities: A 12-layer causal Text Transformer and a 50-layer convolutional ResNet have completely different activation variances. Projecting every row onto the unit hypersphere $\mathbb{S}^{d_e-1} = \{\mathbf{v} \in \mathbb{R}^{d_e} : \|\mathbf{v}\|_2 = 1\}$ turns the raw inner product $\mathbf{I}_e^{(i)} \cdot \mathbf{T}_e^{(j)}$ into exact cosine similarity:
    $$\mathbf{I}_e^{(i)} \cdot \mathbf{T}_e^{(j)} = \frac{\mathbf{u}_i^\top \mathbf{v}_j}{\|\mathbf{u}_i\|_2\,\|\mathbf{v}_j\|_2} = \cos(\theta_{ij}) \in [-1, +1].$$
  2. Preventing the Norm-Explosion Shortcut: In cross-entropy loss, if embeddings were unnormalized, the optimizer could drive the cross-entropy loss toward zero simply by multiplying $W_i$ and $W_t$ by $100\times$ without improving the angular alignment $\theta_{ij}$ between matched pairs! Bounding $\cos(\theta_{ij}) \in [-1, 1]$ forces all learning into the angles between vectors, while delegating logit sharpness to a single controlled scalar $\exp(t)$.

Step 4: Scaled Pairwise Cosine Similarities & Learnable Log-Temperature t

logits = np.dot(I_e, T_e.T) * np.exp(t)

Why is np.dot(I_e, T_e.T) multiplied by np.exp(t)?

Step 5: Why labels = np.arange(n) and Why Average axis=0 & axis=1?

labels = np.arange(n)
loss_i = cross_entropy_loss(logits, labels, axis=0)
loss_t = cross_entropy_loss(logits, labels, axis=1)
loss   = (loss_i + loss_t)/2

Look at the diagram at the bottom of our handwritten OneNote sheet (Figure 2):

3. Complete Worked $3 \times 3$ Numerical Batch Trace

Let $n = 3$, embedding dimension $d_e = 2$, and learned temperature scale $\exp(t) = 10.0$ ($\tau = 0.10$). Suppose after linear projection and row-wise $\ell_2$ normalization, our unit embeddings are:

$$\mathbf{I}_e = \begin{bmatrix} 1.0 & 0.0 \\ 0.0 & 1.0 \\ -0.6 & -0.8 \end{bmatrix}, \qquad \mathbf{T}_e = \begin{bmatrix} 0.96 & 0.28 \\ 0.28 & 0.96 \\ -0.8 & -0.6 \end{bmatrix}.$$

Multiplying $\mathbf{S} = \mathbf{I}_e \mathbf{T}_e^\top$ and scaling by $\exp(t) = 10.0$ yields the $3 \times 3$ logit matrix:

$$\mathbf{S} = \begin{bmatrix} +0.960 & +0.280 & -0.800 \\ +0.280 & +0.960 & -0.600 \\ -0.800 & -0.936 & +0.960 \end{bmatrix} \quad\Longrightarrow\quad \text{logits} = 10\,\mathbf{S} = \begin{bmatrix} \mathbf{+9.60} & +2.80 & -8.00 \\ +2.80 & \mathbf{+9.60} & -6.00 \\ -8.00 & -9.36 & \mathbf{+9.60} \end{bmatrix}.$$
Step-by-step calculation of Image-to-Text (row softmax) and Text-to-Image (column softmax) cross-entropy losses for the $3 \times 3$ batch with labels = [0, 1, 2].
Index $k$ Ground-Truth Target Row Logits $\text{logits}[k, :]$ ($I_k \to T$) Row Softmax $P(T_k \mid I_k)$ & NLL Col Logits $\text{logits}[:, k]$ ($T_k \to I$) Col Softmax $P(I_k \mid T_k)$ & NLL
$k = 0$ labels[0] = 0 $[+9.60,\; +2.80,\; -8.00]$ $P_{00} = 0.9989,\; -\ln P_{00} = \mathbf{0.0011}$ $[+9.60,\; +2.80,\; -8.00]^\top$ $P_{00} = 0.9989,\; -\ln P_{00} = \mathbf{0.0011}$
$k = 1$ labels[1] = 1 $[+2.80,\; +9.60,\; -6.00]$ $P_{11} = 0.9989,\; -\ln P_{11} = \mathbf{0.0011}$ $[+2.80,\; +9.60,\; -9.36]^\top$ $P_{11} = 0.9989,\; -\ln P_{11} = \mathbf{0.0011}$
$k = 2$ labels[2] = 2 $[-8.00,\; -9.36,\; +9.60]$ $P_{22} \approx 1.0000,\; -\ln P_{22} = \mathbf{0.0000}$ $[-8.00,\; -6.00,\; +9.60]^\top$ $P_{22} \approx 1.0000,\; -\ln P_{22} = \mathbf{0.0000}$

4. Complete Runnable NumPy & PyTorch Implementation of Figure 3

import numpy as np

def l2_normalize(x: np.ndarray, axis: int = 1, eps: float = 1e-12) -> np.ndarray:
    norm = np.linalg.norm(x, ord=2, axis=axis, keepdims=True)
    return x / np.maximum(norm, eps)

def cross_entropy_loss(logits: np.ndarray, labels: np.ndarray, axis: int = 1) -> float:
    """Numerically stable log-softmax + NLL along the specified class axis."""
    if axis == 0:
        logits = logits.T  # transpose so classes are along axis=1 (columns)
    shifted = logits - np.max(logits, axis=1, keepdims=True)
    log_probs = shifted - np.log(np.sum(np.exp(shifted), axis=1, keepdims=True))
    n = logits.shape[0]
    return float(-np.mean(log_probs[np.arange(n), labels]))

# Verify our 3x3 worked numerical trace
I_e = np.array([[1.0, 0.0], [0.0, 1.0], [-0.6, -0.8]])
T_e = np.array([[0.96, 0.28], [0.28, 0.96], [-0.8, -0.6]])
t = np.log(10.0)  # exp(t) = 10.0

# Clamping log-temperature as in official OpenAI CLIP: exp(t) <= 100
t_clamped = np.minimum(t, np.log(100.0))
logits = np.dot(I_e, T_e.T) * np.exp(t_clamped)

labels = np.arange(len(I_e))
loss_i = cross_entropy_loss(logits, labels, axis=0)
loss_t = cross_entropy_loss(logits, labels, axis=1)
loss = (loss_i + loss_t) / 2.0

print("Logits matrix:\n", np.round(logits, 2))
print(f"loss_i = {loss_i:.6f}, loss_t = {loss_t:.6f}, symmetric loss = {loss:.6f}")
assert round(loss, 4) == 0.0007