← All tutorials

Contrastive Learning

On this page

1 Positives pull, negatives push 2 One batch becomes one loss 3 InfoNCE picks the true pair out of the candidates 4 Augmentations decide what is learned 5 The projection head is thrown away after training 6 A queue replaces the giant batch 7 SimCLR and MoCo in practice 8 References

Contrastive learning trains an encoder, the network that turns an image into a vector, its embedding. Every training step, each view in the batch answers one multiple-choice question: which candidate is its positive, the other view (randomly edited copy) of the same image? (Views are introduced in Self-Supervised Learning.) The loss rewards the right pick, which pulls each positive pair together and pushes other images apart. SimCLR does this with one big batch, and MoCo with a queue of past batches.

photoone imagetwo viewsrandom editsencoder fgives hlosspositive vs restsimilarityevery pairhead ggives zqueueMoCo onlySimCLR in six steps, plus MoCo's queue
Figure 1. Dashed, the queue of past negatives that MoCo adds to SimCLR.

1 Positives pull, negatives push

In each batch, the two views of one image are a positive pair and every other image is a negative. The view asking the question is the anchor.

anchorpositive, pulled innegatives,pushed aparteach embedding is scaled to length 1,so all of them sit on the circle
Figure 2. Green, the positive pulled in; red, the negatives pushed apart.

2 One batch becomes one loss

SimCLR (Chen et al. 2020) turns a batch of N images into one loss. It edits each image twice, embeds every view, and trains the encoder so each view picks its positive out of all the others. One encoder f serves every view, followed by a small projection head g.

Figure 3. A toy batch of N = 4; each step redraws the part of SimCLR it names.

3 InfoNCE picks the true pair out of the candidates

InfoNCE is the loss behind the multiple-choice question. An anchor faces M candidates, its positive plus M−1 negatives. InfoNCE (named after noise-contrastive estimation, from Contrastive Predictive Coding, van den Oord et al. 2018) scores every candidate, turns the scores into probabilities and applies cross entropy, the standard loss for picking the right answer from a set, with the positive as the right answer.

MethodAnchorPositiveCandidates M
CPC2018past speech, summarizedthe true next stepthe true next step plus random other steps
MoCo2020a viewits other viewthe positive plus 65,536 queued past views
SimCLR2020a viewits other viewthe other 2N−1 views
CLIP2021a photo, then a captionits caption, then its photoall N in the batch
Figure 4. One loss, four ways to fill the M candidates.
Each view scores every candidate, the scores become probabilities, and training minimizes minus the log of the positive’s probability, averaged over every view.
si,k = sim(zi, zk)τ
The score si,k compares anchor view i with candidate view k. Each embedding z has length 1, so sim is their cosine similarity: 1 when they point the same way, 0 at right angles, −1 when opposite. The temperature τ divides every score. A small τ stretches the gaps between scores, and the softmax on the next line turns stretched gaps into lopsided shares, so the closest wrong views take most of the push. A large τ flattens the gaps, and every negative is pushed about equally. CPC scored with a learned matrix instead and had no τ.
pi,j = exp(si,j)Σk∈C exp(si,k)
pi,j is the probability the model gives to view i’s positive j. exp raises e (about 2.718) to each score, so every term is positive and a higher score gets an exponentially larger one. C is the set of view i’s M candidates (∈ means in), and Σ adds the terms of all of them. Dividing the positive’s term by that sum makes the shares add to 1, so raising the positive’s share must lower every negative’s; that coupling is what pushes negatives away. The fraction is a softmax share; InfoNCE is minus its log, on the next line. In SimCLR, C is every view except i, so M = 2N−1; SimCLR calls the loss NT-Xent.
ℓi,j = −log pi,j
The loss ℓi,j of view i is minus the log of its positive’s share. It is near 0 when the positive wins clearly and grows without bound as pi,j falls toward 0, so the views the model gets most wrong pull hardest. It is cross entropy with the positive as the right class.
L = 12N Σi=12N ℓi,t(i)
Every view takes one turn as the anchor, so the loss L averages over all 2N views. t(i) is the positive of view i. Both views of a pair appear, so each pair is scored from both sides.

The snippet computes L for one toy batch, where z1 and z2 hold the unit-length embeddings of the same N images’ two views, row for row.

import torch
import torch.nn.functional as F

N, D, TAU = 4, 128, 0.1
z1 = F.normalize(torch.randn(N, D), dim=1)
z2 = F.normalize(torch.randn(N, D), dim=1)

z = torch.cat([z1, z2])                  # [2N, D]
s = z @ z.T / TAU                        # [2N, 2N]
is_self = torch.eye(2 * N, dtype=torch.bool)
s = s.masked_fill(is_self, -torch.inf)   # never pick itself
t = torch.arange(2 * N).roll(N)          # positive of view i
L = F.cross_entropy(s, t)
  1. 1Unit-length embeddings z of the two views of one toy batch, Section 3 eq line 1.
  2. 2Scores si,k divided by τ, Section 3 eq line 1.
  3. 3Removes each view’s own score, leaving the set C of Section 3 eq line 2.
  4. 4Positive index t(i) and cross entropy averaged over all 2N views, Section 3 eq lines 2 to 4.

Figure 5. Arrow length is each negative’s share of the push.

3.1 More candidates raise how much a loss can show

More candidates make the question harder, so a low loss says more about how much the two views share. Guessing at random among M candidates gives the positive probability 1/M, a loss of log M; a perfect model drives it toward 0.

A low loss proves that the two views share information, up to a limit set by the number of candidates.
I(v1; v2) ≥ log M − L
The mutual information I counts how much seeing view v1 tells you about its positive v2, in nats (information counted with the natural log); ≥ means at least. The right side is a floor that rises as the loss L falls. L never falls below 0, so the floor can never pass log M. So a loss can show at most log M nats of shared information, and only more candidates raise that bound, one reason large batches and queues help (CPC paper, sec. 2.3).
log M, the highest bound a loss can showSimCLR, batch 256, M = 5116.2SimCLR, batch 4096, M = 8,1919.0CLIP, batch 32,768, M = 32,76810.4MoCo queue, M = 65,53711.1036912nats
Figure 6. The highest bound each setting can show, in nats.

A loss stuck at log M means every candidate scores the same, the signature of collapse (every image gets the same embedding).

4 Augmentations decide what is learned

SimCLR edits every view with a random crop, color distortion and blur. Crops of one photo share the same mix of colors, so with crops alone the network matches color histograms (how often each color appears) and ignores shapes. Adding color distortion blocks that shortcut.

Crop plus one more edit, accuracy+ color56.3+ edge filter46.0+ noise39.9+ blur35.0+ cutout33.9+ rotate30.2extra crop only, 33.1color with no extra crop, 18.8
Figure 7. ResNet-50, 100 epochs, probe top-1 (SimCLR Fig. 5); each bar adds one edit to one view only, the other view stays unedited.

The snippet builds SimCLR’s edit pipeline in torchvision, and each call of augment draws fresh random edits.

import torch
import torchvision.transforms as T

SIZE, STRENGTH = 224, 1.0
BLUR_K = 23                     # odd, about 10% of SIZE
jit = 0.8 * STRENGTH
color = T.ColorJitter(jit, jit, jit, 0.2 * STRENGTH)
blur = T.GaussianBlur(BLUR_K)
augment = T.Compose([
    T.RandomResizedCrop(SIZE),
    T.RandomHorizontalFlip(),
    T.RandomApply([color], p=0.8),
    T.RandomGrayscale(p=0.2),
    T.RandomApply([blur], p=0.5),
    T.ToTensor(),
])

img = T.ToPILImage()(torch.rand(3, 256, 256))   # toy photo
view1, view2 = augment(img), augment(img)
  1. 1Edit settings for crop size, color strength and blur size, the strength that Figure 8’s slider scales; raising STRENGTH up to its default 1.0 lifts SimCLR from 59.6% to 63.2% while supervised falls from 77.0% to 75.7% (SimCLR Table 1).
  2. 2The SimCLR edits in order (crop, flip, color, gray, blur), the edits of Figure 7.
  3. 3Two calls on one photo give a positive pair, view 1 and view 2 in Figure 8.

Weak edits teach little, because matching pixels is enough. Strong edits force the model to match content, until the crops share no scene and the two views no longer show the same thing. Edits that keep the label fight overfitting the same way they do in Data Augmentation for Wearables.

image, two crops
view 1
view 2
pixels agree
scene shared

Figure 8. Pixels agree falls first; scene shared falls only for strong crops.

5 The projection head is thrown away after training

The encoder f outputs h, the embedding you keep. A small projection head g, an MLP (two linear layers with a ReLU, which zeroes negative values, between them), maps h to z, and the loss reads only z. SimCLR throws g away after training. The head learns to drop details the loss ignores, such as color and orientation, so h still holds them.

encoderfhhead g2 layers, ReLUzlosslinear probeg is thrown awayafter traininga probe on h beats z by over 10 points
Figure 9. g absorbs what the loss ignores, so the probe reads h (SimCLR Fig. 8).

6 A queue replaces the giant batch

MoCo (Momentum Contrast, He et al. 2020) gets many negatives from a small batch by keeping recent batches' embeddings in a queue. SimCLR's negatives come from the batch, so it trains at a batch of 4096 (Figure 6 shows the lower ceiling at batch 256). In MoCo the anchor, the view that gets gradients, is called the query; its other view is the positive key, and 65,536 queued keys are the negatives. The loss is the same InfoNCE with M = 65,537 candidates, at τ = 0.07.

view 1view 2query encodergradient descentkey encodermoving averageno gradientsquerykeyqueue of 65,536 keys from past batchesnewest inoldest outpull the query and its own key togetherpush the query from every queued key
Figure 10. Each step, new keys enter and the oldest batch leaves.
In the momentum update, the key encoder, which gets no gradients, trails the query encoder as an exponential moving average (EMA).
θk ← m·θk + (1−m)·θq
The key encoder’s weights θk (theta) are replaced (←) by a blend after every step. The momentum m is the share of its old weights it keeps. The rest comes from the query encoder’s weights θq, the ones gradient descent trains. With m near 1 the key encoder changes only a little each step, so keys queued many batches ago still match the current encoder; MoCo used m = 0.999, and copying every step (m = 0) did not converge. Soft target updates in deep RL (DDPG and many DQN variants) use the same moving-average copy, with step size 1 − m.

7 SimCLR and MoCo in practice

This snippet runs one SimCLR training step, where both views pass through the encoder f and the head g of Section 5 and the loss reads only z.

import torch
import torch.nn as nn
import torch.nn.functional as F

N, D, TAU = 256, 128, 0.1
f = nn.Sequential(nn.Flatten(), nn.Linear(3 * 32 * 32, 512), nn.ReLU())
g = nn.Sequential(nn.Linear(512, 512), nn.ReLU(), nn.Linear(512, D))
params = list(f.parameters()) + list(g.parameters())
opt = torch.optim.SGD(params, lr=0.3, momentum=0.9, weight_decay=1e-6)

x1, x2 = torch.rand(N, 3, 32, 32), torch.rand(N, 3, 32, 32)
z = F.normalize(g(f(torch.cat([x1, x2]))), dim=1)   # [2N, D]
s = z @ z.T / TAU
s = s.masked_fill(torch.eye(2 * N, dtype=torch.bool), -torch.inf)
t = torch.arange(2 * N).roll(N)
loss = F.cross_entropy(s, t)
opt.zero_grad()
loss.backward()
opt.step()
  1. 1Encoder f, head g and one optimizer for both, the two boxes of Figure 3 step 2; SimCLR’s optimizer LARS is not in PyTorch, so SGD stands in.
  2. 2Both views through f then g, giving unit-length z, Figure 3 steps 1 and 2.
  3. 3NT-Xent on all 2N views, Section 3 eq lines 1 to 4.
  4. 4One gradient step on f and g, Figure 3 step 5.

This snippet runs one MoCo training step, then moves the key encoder toward the query encoder by EMA (exponential moving average, Section 6 eq line 1) and puts the batch’s keys in the queue.

import copy
import torch
import torch.nn as nn
import torch.nn.functional as F

D, K, M_EMA, TAU = 128, 65536, 0.999, 0.07
enc_q = nn.Sequential(nn.Flatten(), nn.Linear(3 * 32 * 32, D))
enc_k = copy.deepcopy(enc_q).requires_grad_(False)
queue = F.normalize(torch.randn(K, D), dim=1)
opt = torch.optim.SGD(enc_q.parameters(), lr=0.03, momentum=0.9, weight_decay=1e-4)

x1, x2 = torch.rand(256, 3, 32, 32), torch.rand(256, 3, 32, 32)
q = F.normalize(enc_q(x1), dim=1)              # queries, [256, D]
with torch.no_grad():
    k = F.normalize(enc_k(x2), dim=1)          # keys, no gradient
pos = (q * k).sum(dim=1, keepdim=True)         # [256, 1]
neg = q @ queue.T                              # [256, K]
logits = torch.cat([pos, neg], dim=1) / TAU
labels = torch.zeros(len(q), dtype=torch.long) # positive is column 0
loss = F.cross_entropy(logits, labels)
opt.zero_grad()
loss.backward()
opt.step()

with torch.no_grad():
    for p_k, p_q in zip(enc_k.parameters(), enc_q.parameters()):
        p_k.mul_(M_EMA).add_(p_q, alpha=1 - M_EMA)
    queue = torch.cat([k, queue[:-len(k)]])    # newest in, oldest out
  1. 1Query encoder, key encoder copy, queue of K keys and optimizer, the parts of Figure 10.
  2. 2Toy views x1 and x2, as augment in Section 4 makes them, give query q and key k, the two encoders in Figure 10.
  3. 3InfoNCE with the queue as negatives, the loss of Section 3 at τ = 0.07.
  4. 4Gradient step on the query encoder only, the gradient side of Figure 10.
  5. 5Key encoder update, Section 6 eq line 1.
  6. 6Newest keys in, oldest out, the queue in Figure 10.
SimCLRMoCo
Actsevery step, on the batchevery step; 256 keys in, the oldest 256 out
Keepsonly the weightsthe queue and the key encoder
SettingsLARS (a large-batch optimizer), 100 to 1000 epochsSGD, lr 0.03, 200 epochs
Costabout 1.5 hours per 100 epochs on 128 TPU v3 coresabout 53 hours on 8 GPUs; the queue holds 65,536 × 128 numbers, about 34 MB
Codegoogle-research/simclrfacebookresearch/moco
Gotchassmall batches mean few negatives; BatchNorm (which normalizes each feature with its batch’s mean and variance) must use statistics from all devices (Global BN)m = 0 does not converge; a query and its key on one GPU share BatchNorm statistics and can match by them, so MoCo shuffles samples across GPUs for the keys
Knob, defaultRaise itLower it
τ divides every score si,k; 0.1 SimCLR, 0.07 MoCoshares flatten; very high, every negative is pushed about equallythe push gathers on the closest negatives; near 0, only the single closest
N, the batch, gives each view 2N − 2 negatives in C; 4096 SimCLRmore negatives, a higher bound log M, better accuracy, more memoryfewer negatives and lower accuracy; at N = 1 there are none
K, the queue, adds K negatives per query; 65,536 MoCoaccuracy rises (MoCo Fig. 3); costs memory, 128 numbers per keyback toward a batch of negatives; at 0 there are none
m blends the key encoder each step; 0.999 MoCosteadier keys; at 1 the key encoder never changesqueued keys disagree; m = 0.9 gives 55.2%, against 59.0% at 0.999, and 0 fails (MoCo, K = 4096)
D, the size of z; 128 SimCLRresults stay similar at any size (SimCLR Fig. 8); probe h either way

From SimCLR sec. 2.2 and Table 5, and MoCo Algorithm 1 and App. A.9.

Image-Text Contrastive Learning (CLIP) comes next, where the same loss runs in both directions between photos and their captions.

8 References