Multimodal AI 11 Aug 2026

Scaling Contrastive Learning: Understanding SigLIP

#Language-Image Pre-training #Contrastive Learning #Sigmoid Loss #Computer Vision #Natural Language Processing #Zero-shot Learning #Representation Learning #Scaling Laws

Scaling Contrastive Learning: Understanding SigLIP

In the world of Vision-Language Pre-training (VLP), CLIP (Contrastive Language-Image Pre-training) set the gold standard. However, CLIP comes with a hidden cost: a massive dependency on batch size and a memory-intensive softmax operation.

Enter SigLIP. By rethinking the fundamental loss function of contrastive learning, SigLIP decouples the relationship between batch size and memory, enabling more efficient scaling and better performance on limited hardware.

In this post, we will dive deep into the intuition, the mathematics, and a production-ready PyTorch implementation of the Sigmoid Loss for Language-Image Pre-training.


The Core Intuition: From Softmax to Sigmoid

To understand SigLIP, we first have to understand the "bottleneck" in standard CLIP.

The CLIP Approach (Global Softmax)

CLIP treats contrastive learning as a multi-class classification problem. For a given image, the model asks: "Which of these $N$ text descriptions in the current batch is the correct one?"

To answer this, CLIP uses a global softmax across the entire batch. This requires calculating a probability distribution, meaning every image embedding must be compared against every text embedding in a single, monolithic operation. As you scale the batch size ($B$), the memory requirements for the similarity matrix grow quadratically ($B^2$), creating a massive memory bottleneck.

The SigLIP Approach (Pairwise Sigmoid)

SigLIP shifts the paradigm. Instead of one multi-class problem, it treats contrastive learning as a series of independent binary classification tasks.

For every possible image-text pair $(i, j)$, SigLIP asks: "Do these two belong together? Yes or No?"

By using a sigmoid loss, the model evaluates each pair independently. This decoupling is a game-changer: it allows for "chunked" implementation. Devices can swap small subsets of embeddings to compute the loss without ever needing to materialize the full $B \times B$ matrix in memory.


The Technical Architecture

The Mathematical Framework

The transition from softmax to sigmoid is captured in the loss function. Instead of normalizing across a row or column, SigLIP optimizes the following:

$$\mathcal{L} = -\sum_{i,j} \log \sigma(z_{ij} \cdot (t \cdot \text{sim}(I_i, T_j) + b))$$

Where:

  • $z_{ij}$ is the label: $1$ if $i=j$ (positive pair), and $-1$ if $i \neq j$ (negative pair).
  • $\text{sim}(I_i, T_j)$ is the cosine similarity between image and text embeddings.
  • $t$ and $b$ are learnable parameters (temperature and bias) that allow the model to adapt the decision boundary.

The Pipeline Flow

The following diagram illustrates how data flows from raw input to the final weight update:

flowchart TD subgraph Input_Stage ["Input Stage"] ImgData["Image Batch (B, D_in)"] TxtData["Text Batch (B, D_in)"] end subgraph Encoder_Stage ["Dual Encoder Architecture"] ImgEnc["Image Encoder (Linear/ViT)"] TxtEnc["Text Encoder (Linear/BERT)"] ImgData --> ImgEnc TxtData --> TxtEnc ImgEmb["Image Embeddings (B, D_emb)"] TxtEmb["Text Embeddings (B, D_emb)"] ImgEnc --> ImgEmb TxtEnc --> TxtEmb end subgraph SigLIP_Loss_Pipeline ["SigLIP Loss Pipeline"] L2Norm["L2 Normalization (Unit Hypersphere)"] SimMat["Pairwise Similarity Matrix (B x B)"] ScaleBias["Scale & Bias Transformation: (z_i * z_j^T) * exp(t') + b"] Labels["Label Generation (Diagonal=1, Off-Diagonal=-1)"] SigmoidOp["Pairwise Sigmoid Loss: -log_sigmoid(labels * logits)"] FinalLoss["Mean Loss (Scalar)"] ImgEmb --> L2Norm TxtEmb --> L2Norm L2Norm --> SimMat SimMat --> ScaleBias ScaleBias --> SigmoidOp Labels --> SigmoidOp SigmoidOp --> FinalLoss end subgraph Optimization ["Optimization Loop"] Grads["Backpropagation (Gradients)"] Update["Update Weights (Encoders + t', b)"] FinalLoss --> Grads Grads --> Update Update -.-> ImgEnc Update -.-> TxtEnc Update -.-> ScaleBias end %% Styling style Input_Stage fill:#f9f,stroke:#333,stroke-width:2px style Encoder_Stage fill:#e1f5fe,stroke:#01579b,stroke-width:2px style SigLIP_Loss_Pipeline fill:#fff3e0,stroke:#e65100,stroke-width:2px style Optimization fill:#f1f8e9,stroke:#33691e,stroke-width:2px

Implementation in PyTorch

Below is a complete implementation. I have included a SigLIPLoss module and a toy DualEncoder to demonstrate how the loss aligns random vectors in a shared embedding space.

PYTHON
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, Dataset

class SigLIPLoss(nn.Module):
    """
    Implementation of the Sigmoid Loss for Language-Image Pre-training (SigLIP).
    Replaces global softmax with pairwise binary classification.
    """
    def __init__(self, t_prime=0.0, b=0.0):
        super().__init__()
        # t_prime and b are learnable parameters
        # t = exp(t_prime) ensures temperature remains positive
        self.t_prime = nn.Parameter(torch.tensor(t_prime))
        self.b = nn.Parameter(torch.tensor(b))

    def forward(self, img_emb, txt_emb):
        # 1. L2 Normalize embeddings to project them onto a unit hypersphere
        z_img = F.normalize(img_emb, p=2, dim=-1) 
        z_txt = F.normalize(txt_emb, p=2, dim=-1) 

        # 2. Compute pairwise similarity matrix
        # logits[i, j] = dot(z_img[i], z_txt[j]) * exp(t_prime) + b
        t = torch.exp(self.t_prime)
        logits = torch.matmul(z_img, z_txt.T) * t + self.b 

        # 3. Create labels: 1 for diagonal (positive), -1 for off-diagonal (negative)
        n = img_emb.shape[0]
        labels = 2 * torch.eye(n, device=img_emb.device) - 1 

        # 4. Compute Sigmoid Loss
        # We use logsigmoid for numerical stability. 
        # labels * logits flips the sign for negatives, treating it as binary classification.
        loss = -F.logsigmoid(labels * logits).sum() / n
        
        return loss

class SimpleDualEncoder(nn.Module):
    def __init__(self, input_dim, embed_dim):
        super().__init__()
        self.image_encoder = nn.Linear(input_dim, embed_dim)
        self.text_encoder = nn.Linear(input_dim, embed_dim)

    def forward(self, img_data, txt_data):
        return self.image_encoder(img_data), self.text_encoder(txt_data)

class MockImageTextDataset(Dataset):
    def __init__(self, num_samples=1000, dim=128):
        self.num_samples = num_samples
        self.dim = dim
        self.base_vectors = torch.randn(num_samples, dim)
        
    def __len__(self):
        return self.num_samples

    def __getitem__(self, idx):
        # Create pairs by adding slight noise to the same base vector
        img = self.base_vectors[idx] + torch.randn(self.dim) * 0.1
        txt = self.base_vectors[idx] + torch.randn(self.dim) * 0.1
        return img, txt

# --- Execution ---
if __name__ == '__main__':
    # Hyperparameters
    BATCH_SIZE, EMBED_DIM, INPUT_DIM, EPOCHS = 64, 128, 128, 20
    
    dataset = MockImageTextDataset(num_samples=2000, dim=INPUT_DIM)
    dataloader = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True)

    model = SimpleDualEncoder(INPUT_DIM, EMBED_DIM)
    criterion = SigLIPLoss()
    optimizer = torch.optim.Adam(list(model.parameters()) + list(criterion.parameters()), lr=1e-3)

    model.train()
    for epoch in range(EPOCHS):
        total_loss = 0
        for img_batch, txt_batch in dataloader:
            optimizer.zero_grad()
            img_emb, txt_emb = model(img_batch, txt_batch)
            loss = criterion(img_emb, txt_emb)
            loss.backward()
            optimizer.step()
            total_loss += loss.item()
        
        if (epoch + 1) % 5 == 0:
            print(f"Epoch [{epoch+1}/{EPOCHS}] | Avg Loss: {total_loss/len(dataloader):.4f}")

    # Evaluation
    model.eval()
    with torch.no_grad():
        test_img, test_txt = dataset[0]
        img_emb, txt_emb = model(test_img.unsqueeze(0), test_txt.unsqueeze(0))
        similarity = torch.matmul(F.normalize(img_emb, dim=-1), F.normalize(txt_emb, dim=-1).T).item()
        print(f"\nFinal Test Pair Cosine Similarity: {similarity:.4f}")

Key Takeaways for Engineers

  1. Memory Efficiency: By moving from a global softmax to a pairwise sigmoid, SigLIP removes the need to normalize across the batch, enabling the use of "chunked" gradients.
  2. Scaling: This architecture is designed for extreme batch sizes, which is critical for the performance of foundation models.
  3. Learnable Parameters: Don't forget the $t$ (temperature) and $b$ (bias). These are not hyperparameters to be tuned by hand but are learned by the model to optimize the sigmoid boundary.
  4. Numerical Stability: When implementing this, always use logsigmoid or BCEWithLogitsLoss rather than applying a sigmoid followed by a log to avoid underflow/overflow.