Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Open In Colab   Open in Kaggle

Tutorial 2: Contrastive learning for object recognition

Week 1, Day 2: Comparing Tasks

By Neuromatch Academy

Content creators: Andrew F. Luo, Leila Wehbe

Content reviewers: Samuele Bolotta, Yizhou Chen, RyeongKyung Yoon, Ruiyi Zhang, Lily Chamakura, Patrick Mineault, Alex Murphy

Production editors: Konstantine Tsafatinos, Ella Batty, Spiros Chavlis, Samuele Bolotta, Hlib Solodzhuk, Patrick Mineault, Alex Murphy


Tutorial Objectives

Estimated timing of tutorial: 40 minutes

By the end of this tutorial, participants will be able to:

  1. Understand why we want to do contrastive learning.

  2. Understand the losses used in contrastive learning.

  3. Train a network using contrastive learning on MNIST.


Setup

Install and import feedback gadget

Install and import feedback gadget

Source

Import dependencies

Source

Figure settings

Source

Helper functions

Source

Section 1: Building the model

Video

Youtube
Bilibili

What is contrastive learning?

Contrastive learning is a form of self-supervised learning (SSL). Contrastive learning seeks to map inputs to a high-dimensional space, bringing similar examples closer together and pushing dissimilar examples farther apart.

It may not be immediately obvious why you want to engage in contrastive learning. Can’t we just use a large 1000-class ImageNet-trained classifier to recognize every image? Contrastive learning proves useful when the number of classes is not known ahead of time. For example, if you wanted a network to recognize human faces, there are approximately 8 billion people on the planet, making it impractical to train a classification network with 8 billion output neurons. Instead, you can train a network to output a high-dimensional embedding for each image. With this approach, given a reference image of a person, the network can determine if a new photo is similar to or different from the reference image.

In this section, we will:

  • Construct a model that maps images to a high-dimensional space.

  • Visualize the geometric properties of the embedding prior to model training.

Constructing the model

We’ll now construct a fully connected artificial neural network for contrastive learning, built from residual blocks in the style of a ResNet. This will look much like a classification network, but without a classification head at the end. Instead, the network maps images to a high-dimensional space:

f(x)=zf(\mathbf{x}) = \mathbf{z}

where ff is the network, x\mathbf{x} is an input image and z\mathbf{z} is the embedding. z\mathbf{z} is a vector with dimension out_dim, normalized to have a norm of 1 (unit norm). Later, we will train the network such that similar images have similar embeddings and dissimilar images have dissimilar embeddings.

Building the model from residual blocks

We first define a residual block. This block contains a pre-normalization step and a leaky ReLU activation function to help with vanishing gradients, in addition to linear layers (fully connected layers with an identity function as the activation function). Residual networks tend to be easier to optimize than standard networks, because the residual connection allows for a passageway for gradients to flow down during backpropagation.

Now having defined our core ResidualBlock module, we build our network from a series of these residual blocks. We use PyTorch’s nn.Sequential module to chain the blocks together.

Notice that the output of the network is normalized using F.normalize so that each embedding has unit norm. Let’s now look at the geometry of images processed by an untrained network. We first load data from MNIST.

  0%|          | 0/9912422 [00:00<?, ?it/s]
100%|██████████| 9912422/9912422 [00:00<00:00, 135002293.28it/s]

  0%|          | 0/28881 [00:00<?, ?it/s]
100%|██████████| 28881/28881 [00:00<00:00, 9582761.95it/s]

  0%|          | 0/1648877 [00:00<?, ?it/s]
100%|██████████| 1648877/1648877 [00:00<00:00, 107266361.58it/s]

  0%|          | 0/4542 [00:00<?, ?it/s]
100%|██████████| 4542/4542 [00:00<00:00, 2259045.27it/s]

<Figure size 300x300 with 1 Axes>
7

Now we will create the model using the definition we wrote previously and move it to the desired device.

Using device: cpu

The geometry of the untrained network

Let’s visualize how close different examples are in the embedding space before training. We’ll first gather the embeddings from multiple images by passing them through the network.

Code exercise 1: Visualizing the cosine similarity of embeddings within and across classes before training

In this exercise, we’ll measure the cosine similarity between embeddings of images from the same class and across different classes. We’ll visualize the cosine similarity matrix to see if the network has learned to distinguish between different classes. The cosine similarity between two embedding vectors z1\mathbf{z}_1 and z2\mathbf{z}_2 with norm 1 is defined as:

sim(z1,z2)=z1⋅z2\text{sim}(\mathbf{z}_1, \mathbf{z}_2) = \mathbf{z}_1 \cdot \mathbf{z}_2

where ⋅\cdot denotes the dot product. The cosine similarity ranges from -1 to 1, where:

  • 1 indicates the vectors are identical

  • 0 indicates that the vectors are orthogonal

  • -1 indicates that the vectors are diametrically opposed.

---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
Cell In[10], line 8
      4     embeddings[i] = test_embeddings_untrained[test_labels_untrained == i]
      6 ############################################################
      7 # Fill in this code to compute cosine similarity matrix within the class.
----> 8 raise NotImplementedError("Student exercise: calculate cosine similarity.")
      9 ############################################################
     10 
     11 # Within class cosine similarity:
     12 for i in range(10):

NotImplementedError: Student exercise: calculate cosine similarity.

Click for solution

Example output:

Solution hint

Reflection

What do you observe in the cosine similarity matrix? Are the embeddings from the same digit class more similar than embeddings of different classes? Why might that be?

Section 2: Training the model and visualizing feature similarity

Let’s train the network to pull elements of the same class together and push elements of different classes apart. We’ll use a contrastive loss function to do this.

The contrastive loss function

Our goal is to train the model to push embeddings for similar images closer together and dissimilar embeddings from unrelated images further away from each other. We can achieve this by minimizing a contrastive loss function.

Triplet loss minimization

Let’s first consider a single anchor image whose embedding is za\mathbf{z}_a. We want to compare this against a set of embeddings for different images zk\mathbf{z}_k, where k∈[0…K]k \in [0 \ldots K]. We have a single positive image z0\mathbf{z}_0 of the same class as the anchor image, while the negative images z[1…K]\mathbf{z}_{[1 \ldots K]} are images of other classes. We want to minimize the distance between the anchor image and the positive image while maximizing the distance between the anchor image and the negative images.

A classic way to do this is via the InfoNCE loss function, which is widely used in contrastive learning, for example in OpenAI’s CLIP. This loss is defined as:

La=−log⁡(exp⁡(za⋅z0/τ)∑k=0Kexp⁡(za⋅zk/τ))\mathcal{L}_a = -\log \left( \frac{\exp(\mathbf{z}_a \cdot \mathbf{z}_{0} / \tau)}{\sum_{k=0}^{K} \exp(\mathbf{z}_a \cdot \mathbf{z}_k / \tau)} \right)

Here τ\tau is a temperature parameter that controls the sharpness of the distribution. You can think of it as a cross-entropy loss with a single pseudo-class corresponding to similar labels and the negative pairs corresponding to different labels.

Decoupled contrastive learning

InfoNCE typically requires substantial batch sizes—commonly 128 or larger—to perform optimally. The need for large batch sizes stems from the necessity for diverse negative samples in the batch to effectively learn the contrasts. However, large batch sizes can be impractical in resource-constrained settings or when data availability is limited.

To address this, we will implement a modified version of InfoNCE as described in the “Decoupled Contrastive Learning” paper. This variant adapts the loss to be more suitable for smaller batch sizes by modifying the denominator of the InfoNCE formula. Specifically, it removes the positive example from the denominator, which reduces the computational demand and stabilizes training when fewer examples are available. This adjustment not only makes the loss function more flexible, but also maintains robustness in learning discriminative features even with smaller batch sizes.

La=−log⁡(exp⁡(za⋅z0/τ)∑k=1Kexp⁡(za⋅zk/τ))\mathcal{L}_a = -\log \left( \frac{\exp(\mathbf{z}_a \cdot \mathbf{z}_{0} / \tau)}{\sum_{k=1}^{K} \exp(\mathbf{z}_a \cdot \mathbf{z}_k / \tau)} \right)

Batching and numerical stability

In practice, we will sample an entire batch of images at a time. We will then compute the average decoupled contrastive loss for every positive pair of images (images that have the same label). Thus:

Lbatch=1Npositive pairs∑positive pairs (i, j)−log⁡(exp⁡(zi⋅zj/τ)∑k∈Negative(i)exp⁡(zi⋅zk/τ))\mathcal{L}_{\text{batch}} = \frac{1}{N_{\text{positive pairs}}} \sum_{\text{positive pairs (i, j)}} -\log \left( \frac{\exp(\mathbf{z}_i \cdot \mathbf{z}_{j} / \tau)}{\sum_{k \in \text{Negative(i)}} \exp(\mathbf{z}_i \cdot \mathbf{z}_k / \tau)} \right)

Here ii corresponds to the index of a single image, and Negative(i)\text{Negative(i)} is the set of indices of all the negative images corresponding to anchor image ii (all the images with labels that are different from the anchor image’s label).

To prevent the exponential from overflowing, we’ll subtract the maximum value from the dot products before exponentiating. This is a common trick to improve numerical stability, but not of direct importance to understanding contrastive learning. Don’t worry if the technical details are unclear.

Code exercise 2: The decoupled contrastive learning loss function

Let’s first complete the implementation of the Decoupled Contrastive Learning (DCL) loss function to better grasp how it separates positive and negative pairs. The total training time might take around 5 minutes.

However, before we do that, we need to go over the concept of a matching-pair and how this works in our use case.

Matching and Non-Matching Pairs

The idea behind decoupled contrastive learning is to be able to use fewer samples in each batch, so in a sense we need to make it a purer learning problem, to increase the learning signal. In our MNIST dataset, we have 10 different numbers that are each their own class label. Imagine in our training batch that we have multiple instances of the number “7” and and for a different MNIST image of a “7” is being calculated. Because we randomly sampled other images of the same digit, if they are present in the denominator, we are effectively treating them as negatives to be pushed away from the other pair of images that represent “7”. We would be better to make sure we exclude in the negatives all other randomly sampled instances where the class label is the same. By doing that, we make the problem easier because there is no visual similarity between items in any positive / negative pair, used in contrastive learning. This is the logic for what we’re trying to achieve in the code below.

Using device: cpu
Is the network in training mode? True
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
Cell In[11], line 136
    133 label_pos_neg = get_all_pairs_indices(train_label)
    135 # Compute the loss using the decoupled contrastive learning loss function
--> 136 final_loss = torch.mean(pair_based_loss(similarities, label_pos_neg, dcl_loss))
    138 # Compute gradients from the loss
    139 final_loss.backward()

Cell In[11], line 63, in pair_based_loss(similarities, indices_tuple, lossfunc)
     61 if len(a2) > 0:
     62     neg_pair = similarities[a2, n]  # Extract negative pairs
---> 63 return lossfunc(pos_pair, neg_pair, indices_tuple)

Cell In[11], line 7, in dcl_loss(pos_pairs, neg_pairs, indices_tuple, temperature)
      1 def dcl_loss(pos_pairs, neg_pairs, indices_tuple, temperature=0.07):
      2     ############################################################
      3     # First question: Think about how you can ensure that non-matching pairs do
      4     # not contribute to the denominator in the loss calculation. You need to set their
      5     # values to a large negative number to effectively exclude them from the
      6     # exponential computation. Second question: Look at the numerator in the formula!
----> 7     raise NotImplementedError("Student exercise: complete DCL loss function.")
      8     ############################################################
      9     """
     10     Computes the Decoupled Contrastive Learning loss.
     11 
     12     Returns:
     13         torch.Tensor: The computed loss value.
     14     """

NotImplementedError: Student exercise: complete DCL loss function.

Great, we have a trained network! Let’s collect the embeddings from the model.

Feature extraction done!

Visualizing the cosine similarity after training

Let’s start by double-checking that the network has learned to distinguish between different classes. We’ll measure the cosine similarity between embeddings of images within and across classes. We’ll visualize the cosine similarity matrix to see if the network has learned to distinguish between different classes.

<Figure size 800x600 with 2 Axes>

We see that the network has rapidly learned to distinguish between different classes and cluster similar examples together, despite not being trained to classify images directly.

Visualizing the geometry of the embeddings before and after training

Let’s use t-SNE to visualize the geometry of the embeddings before and after training.

t-SNE transformation in progress... This may take a minute
t-SNE embeddings shape: (10000, 128)

Similarly, for the trained network.

t-SNE transformation in progress... This may take a minute
t-SNE embeddings shape: (10000, 2)

Now plot the distribution of features before and after training.

<Figure size 800x450 with 2 Axes>

Notice how training has pulled examples with similar labels together and pushed examples with different labels apart.

Using the network to identify nearest neighbours from the train set

How can we actually use a contrastive learning network for a downstream task like classification? Some options include:

  • Fine-tuning: Add a linear classification layer on top of the embedding. Fine-tune the model end-to-end for the downstream supervised task.

  • Nearest neighbour search: Given a test anchor image, find the most similar image in the train set. Apply the same label to the test image as the most similar image in the train set.

Here, we will use a nearest neighbour search to find the most similar image in the train set.

<Figure size 800x600 with 2 Axes>

In this one case, this nearest neighbor scheme works well. Let’s measure the overall accuracy over the entire test set.

Mean accuracy: 0.9478

Not too bad for a simple nearest neighbour search! Contrastive learning has allowed us to learn a useful embedding space for recognizing digits.

Keep in mind, however, that a nearest neighbour search can be impractical in real applications, as we’d need to keep the embeddings of all the train examples in memory. It’s often more effective to fine-tune the network on the downstream task. This allows the network to learn task-specific features that may not be captured by the contrastive learning objective, and it can be more computationally efficient.

How is contrastive learning used in practice?

Nearly all vision foundation models, such as DINO, DINOv2, CLIP, and their derivatives (including OpenCLIP and EVA-CLIP) are trained using contrastive losses. DINO and DINOv2 are trained solely on images, while CLIP is trained on a combination of images and text.

When only images are used, the contrastive learning loss is applied to augmentations of the same image. These augmentations can include crops, flips, and rotations, and this approach is referred to as a “pretext task.” Typically, augmentations of the same image are treated as instances where the embeddings should be the same. For example, a network should recognize a photo of you and a photo of you flipped, with altered brightness, noise added, or converted to black and white, as representing the same person.

A pretext task to classify puppies

Photos by JimboMack66, CC-BY 2.0

When images and text are paired together, such that an image and a correct text description together make a positive pair, as in CLIP, the training data consist of images and their corresponding captions. For example, the caption “A photo of a dog” might be paired with a picture of a blue heeler puppy. These captions are typically scraped from online sources and collected into datasets like LAION-2B, COYO-700M, and CommonCrawl. Although these captions are often of varying quality, the sheer volume of data helps to mitigate this issue.

When the positive pairs are of two different input modalities e.g. (image,text), (audio,text), we call this multimodal contrastive learning. This approach typically employs a dual encoder system: one for text and one for images. The network is trained using a loss function that minimizes the distance between the correct text-image pairs while maximizing the distance between incorrect pairs. For example, the caption “A photo of a dog” should have embeddings close to the image of the blue heeler puppy and far from other non-dog images, such as an image of a cat. To compute the “distance” of the embeddings, methods such as normalized dot-product (cosine similarity), angular distance (Universal Sentence Encoder), Euclidean distance, or squared Euclidean distance are often used.

Discussion point

We have argued that judging similarity is an inherent capability of both human brains and artificial intelligence systems. We’ve covered many ways to implement contrastive learning in AI; can you speculate if and how contrastive learning might be implemented in the human brain?


The Big Picture

In this tutorial, we’ve covered contrastive learning, a self-supervised technique that works well in situations where the number of classes is large or undefined. This method teaches a model to recognize similarity, not by traditional classification, but by learning to distinguish between ‘similar’ and ‘dissimilar’ directly through embeddings. We discussed the significance of generating embeddings that bring data points of the same class closer together while pushing different classes apart, which is particularly valuable in complex recognition tasks like identifying faces among billions of possibilities.

Through practical exercises with the MNIST dataset, we’ve seen how contrastive learning can be implemented. The session highlighted the intuitive appeal of contrastive learning: learning by comparison, which is a natural way for both humans and machines to understand the world.

Let’s bring this back to the overall goal of the overall theme of the course, generalization. So far, we’ve looked at how tasks are defined by cost functions, specifications of different losses. Some tasks require models to learn representations that are not task-specific but can be very task general in large parts, and specialised (task-specific) in other parts. For example, most of a CNN architecture might specialise in learning features present in the real world: shapes, colors, lines. Contrastive learning is a way we can efficiently make use of the large amounts of unlabeled data in the world. It’s also a task that, over large datasets, results in models learning very rich, general representations.

Take a moment to think about all the ways that learning via contrastive learning might give a network (human or artificial) a rich set of representations. We’ll soon get to studying how to measure representations and across multiple systems and their geometry. But first, let’s address another method to learn tasks that’s a little bit different to what we’ve seen before: Reinforcement Learning.