CNN Backbones and Pretraining

Contrastive learning for images

Contrastive learning trains a backbone with no labels by showing it two altered copies of the same photo and asking it to tell them apart from everybody else's.

Read these first

On this page 8
  1. Why anybody wanted this
  2. The task it is given
  3. The trap that swallowed early attempts
  4. Why it changed things
  5. Where you have already seen this
  6. The honest part
  7. Remember this
  8. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

Contrastive learning teaches a network without labels. It shows the network two altered copies of one photo, and asks it to match them.

Think about your water bottle in a school bag rack. Twenty bottles, most of them the same model.

You can still pick yours out in poor light, from an angle, half hidden behind another bag. You learned what stays the same about your bottle, and what makes it different from the others.

Nobody taught you that with a label. You worked it out from seeing your bottle many times, in many conditions.

Why anybody wanted this

Labelling images is slow and expensive. ImageNet took years of human effort to label about one and a quarter million photos.

Meanwhile the internet holds billions of unlabelled photos. Your company probably holds millions more with nothing written on them.

The question was straightforward. Can a network learn useful vision from photos alone, with nobody typing what is in them?

The task it is given

Take a photo. Make two altered copies: crop one part, flip it, shift the colours, blur it a little. Do the same to a second copy, differently.

The network sees a pile of copies from many photos. It has to say which two came from one original.

   photo A  ->  copy A1 (cropped, flipped)
            ->  copy A2 (recoloured, blurred)

   photo B  ->  copy B1
            ->  copy B2

   task: given A1, pick A2 out of the whole pile

To do this, the network has to work out what survives cropping and recolouring. What survives is content. So it learns to describe content, without anybody naming it.

The two copies of one photo are called a positive pair. Everything else in the pile is a negative.

The trap that swallowed early attempts

There is a cheap way to cheat. Describe every photo with the exact same answer. Then the two copies always match perfectly.

This is called collapse, and it is a real failure, not a theoretical worry. The network scores brilliantly and has learned nothing at all.

The negatives are what prevent it. If everything is described identically, then everything matches everything, and the network is punished. It has to keep different photos apart while pulling copies of the same photo together.

Collapse is demonstrated with running code further down this page. It is worth seeing, because the failure looks like success from the loss value alone.

Why it changed things

By 2020 this approach produced backbones that matched supervised training on ImageNet, using no labels for the pretraining stage.

That is a big claim, and it held up. It also fed directly into the image and text models you use now. Matching a photo to its caption uses the same machinery.

Where you have already seen this

  • CLIP, which powers text-to-image search, is trained contrastively on photos and captions.
  • Duplicate photo detection in a gallery app.
  • Product visual search, where the same item appears in very different photos.

The honest part

The augmentations are not a detail. They are the method.

If you never change colour, the network can describe a photo by its average colour. Cropping alone will not stop it. Choosing which changes to apply is choosing what the network is allowed to ignore, and it is task-specific. Medical images and satellite images need different choices than holiday photos.

Read that twice. Most disappointing results in this area come from copying augmentations from a paper about a different kind of image.

Remember this

  • Two altered copies of one photo are a positive pair; every other photo is a negative.
  • Negatives exist to prevent collapse, where every photo gets the same description.
  • The choice of augmentations decides what the model learns to ignore.

What to learn next

Developer — Code and libraries.

Setup

bash
pip install "torch==2.5.1"

Run against PyTorch 2.5.1 on CPU. The first script is instant; the second takes about thirteen seconds.

The loss, on six embeddings you can see

nt_xent.py
import math
import torch
import torch.nn.functional as F


def nt_xent(z, temperature=0.5):
    """z holds 2N rows: rows 0..N-1 are view A, rows N..2N-1 are view B of the same images."""
    z = F.normalize(z, dim=1)                 # every embedding onto the unit circle
    n = z.shape[0] // 2
    sim = z @ z.t() / temperature             # cosine similarity of every pair
    sim.fill_diagonal_(-1e9)                  # an embedding must not match itself
    target = torch.cat([torch.arange(n, 2 * n), torch.arange(0, n)])
    return F.cross_entropy(sim, target)


# three images, two views each, laid out by hand in two dimensions
good = torch.tensor([
    [1.0,  0.1], [-0.1, 1.0], [-1.0, -0.1],    # view A of images 1, 2, 3
    [1.0, -0.1], [ 0.1, 1.0], [-1.0,  0.1],    # view B of the same three
])
scrambled = good[[3, 4, 5, 1, 2, 0]]           # same points, wrong pairings

print(f"views agree     : loss {nt_xent(good).item():.4f}")
print(f"views scrambled : loss {nt_xent(scrambled).item():.4f}")
print(f"pure chance     : loss {math.log(2 * 3 - 1):.4f}   (= ln(2N-1) with N=3)")

print("\ntemperature changes how harshly near-misses are punished")
for t in (0.05, 0.1, 0.5, 1.0, 10.0):
    print(f"  temperature {t:5.2f} -> loss {nt_xent(good, t).item():.4f}")

print("\nchance loss grows with batch size, so the task gets harder as N grows")
for n in (8, 64, 256, 4096):
    print(f"  N = {n:5d} -> ln(2N-1) = {math.log(2 * n - 1):.3f}")

print("\ncollapse scores well on positives and terribly on this loss")
collapsed = torch.ones(6, 2)
print(f"  every embedding identical -> loss {nt_xent(collapsed).item():.4f}")
print(f"  cosine of the positive pair -> {F.cosine_similarity(collapsed[:3], collapsed[3:]).mean():.4f}")
Output
views agree     : loss 0.3440
views scrambled : loss 3.2219
pure chance     : loss 1.6094   (= ln(2N-1) with N=3)

temperature changes how harshly near-misses are punished
  temperature  0.05 -> loss 0.0000
  temperature  0.10 -> loss 0.0003
  temperature  0.50 -> loss 0.3440
  temperature  1.00 -> loss 0.7813
  temperature 10.00 -> loss 1.5068

chance loss grows with batch size, so the task gets harder as N grows
  N =     8 -> ln(2N-1) = 2.708
  N =    64 -> ln(2N-1) = 4.844
  N =   256 -> ln(2N-1) = 6.236
  N =  4096 -> ln(2N-1) = 9.011

collapse scores well on positives and terribly on this loss
  every embedding identical -> loss 1.6094
  cosine of the positive pair -> 1.0000

The most useful line is the last block. A collapsed encoder scores a perfect 1.0000 on positive similarity and lands at exactly the chance loss, 1.6094. Positive similarity alone is not a health check. The contrastive loss is, because collapse cannot beat chance on it.

Chance depends on batch size, so raw loss values are not comparable across runs. A loss of 5.0 is terrible at N=8 and good at N=4096. Always report loss against ln(2N-1).

Temperature is not a minor knob. At 0.05 the loss reads 0.0000 on embeddings that are visibly imperfect. A low temperature sharpens the softmax so hard negatives dominate the gradient; too low and everything looks solved. SimCLR uses 0.1 to 0.5 depending on the setting.

Now watch collapse happen for real

collapse_demo.py
import torch
import torch.nn as nn
import torch.nn.functional as F


def make_batch(n, g):
    """Three shapes on a 16x16 canvas: a bar, a column and a diagonal."""
    y = torch.randint(0, 3, (n,), generator=g)
    x = torch.zeros(n, 1, 16, 16)
    for i, k in enumerate(y.tolist()):
        r, c = torch.randint(2, 10, (2,), generator=g).tolist()
        if k == 0:
            x[i, 0, r, c:c + 6] = 1.0
        elif k == 1:
            x[i, 0, r:r + 6, c] = 1.0
        else:
            for d in range(6):
                x[i, 0, r + d, c + d] = 1.0
    return x, y


def augment(x, g):
    """Two random views of the same picture: shift, brightness, noise."""
    n = x.shape[0]
    out = torch.empty_like(x)
    shifts = torch.randint(-3, 4, (n, 2), generator=g)
    for i in range(n):
        out[i] = torch.roll(x[i], shifts=tuple(shifts[i].tolist()), dims=(1, 2))
    out = out * (0.5 + torch.rand(n, 1, 1, 1, generator=g))
    return out + 0.15 * torch.randn(out.shape, generator=g)


class Encoder(nn.Module):
    def __init__(self):
        super().__init__()
        self.body = nn.Sequential(
            nn.Conv2d(1, 16, 3, stride=2, padding=1), nn.ReLU(),
            nn.Conv2d(16, 32, 3, stride=2, padding=1), nn.ReLU(),
            nn.AdaptiveAvgPool2d(1), nn.Flatten())
        self.head = nn.Sequential(nn.Linear(32, 32), nn.ReLU(), nn.Linear(32, 16))

    def forward(self, x):
        h = self.body(x)
        return h, self.head(h)         # features, and the projection the loss uses


def nt_xent(z, temperature=0.5):
    z = F.normalize(z, dim=1)
    n = z.shape[0] // 2
    sim = z @ z.t() / temperature
    sim.fill_diagonal_(-1e9)
    target = torch.cat([torch.arange(n, 2 * n), torch.arange(0, n)])
    return F.cross_entropy(sim, target)


def positive_only(z):
    """Pull the two views together and do nothing else. Watch what happens."""
    n = z.shape[0] // 2
    return -F.cosine_similarity(z[:n], z[n:]).mean()


def diagnose(enc):
    x, _ = make_batch(300, torch.Generator().manual_seed(99))
    with torch.no_grad():
        feats, proj = enc(x)
    p = F.normalize(proj, dim=1)
    off = p @ p.t()
    mean_cos = ((off.sum() - off.diag().sum()) / (300 * 299)).item()
    return feats.std(dim=0).mean().item(), mean_cos


for name, loss_fn in [("positive pairs only", positive_only), ("NT-Xent with negatives", nt_xent)]:
    torch.manual_seed(0)
    g = torch.Generator().manual_seed(0)
    enc = Encoder()
    s0, c0 = diagnose(enc)
    opt = torch.optim.Adam(enc.parameters(), lr=3e-3)
    for step in range(300):
        x, _ = make_batch(64, g)
        views = torch.cat([augment(x, g), augment(x, g)])
        _, z = enc(views)
        loss = loss_fn(z)
        opt.zero_grad()
        loss.backward()
        opt.step()
    s1, c1 = diagnose(enc)
    print(f"{name:24s} before: feature spread {s0:.4f}, cosine between different images {c0:+.4f}")
    print(f"{'':24s} after : feature spread {s1:.4f}, cosine between different images {c1:+.4f}"
          f"   final loss {loss.item():.3f}")
Output
positive pairs only      before: feature spread 0.0040, cosine between different images +0.9999
                         after : feature spread 0.0011, cosine between different images +1.0000   final loss -1.000
NT-Xent with negatives   before: feature spread 0.0040, cosine between different images +0.9999
                         after : feature spread 0.0251, cosine between different images +0.0004   final loss 3.836

The exact figures depend on PyTorch's random initialisation and will shift slightly on another version or platform. The two outcomes will not.

Reading the collapse output

The positive-only run reaches a loss of exactly -1.000, which is the best score possible. It got there by mapping every image to the same point: the average cosine between different images is 1.0000. A perfect score on a worthless model.

The feature spread fell rather than rose. From 0.0040 to 0.0011. The encoder actively worked to remove variation, because variation was the only thing that could hurt its objective.

The NT-Xent run pushed different images apart. Cosine between different images fell from 0.9999 to 0.0004, meaning embeddings are close to orthogonal. Feature spread rose six-fold. That spread is what a downstream classifier will use.

Both runs started at 0.9999. An untrained encoder ending in global average pooling is already close to collapsed. Contrastive training is not preventing a collapse that would otherwise never happen. It is climbing out of one.

Common mistakes

Judging progress by the positive similarity. It reaches 1.0 in the failure case. Track a spread or uniformity statistic instead, exactly as diagnose does.

Using a small batch with no memory bank. With N=32, chance loss is 4.14 and there are 62 negatives per anchor. SimCLR needed batches in the thousands. MoCo exists precisely so you do not need them, by keeping a queue of embeddings from previous batches.

Evaluating on the projection head output. SimCLR found that the layer before the projection head transfers better. Throw the head away after pretraining. Use body, not head, downstream.

Applying the same augmentation to both views. They must be sampled independently. Sharing a random seed between views makes the task trivial and teaches nothing.

Copying ImageNet augmentations onto a different domain. Colour jitter is central for natural photos. On greyscale X-rays it does close to nothing, and a horizontal flip may destroy the meaning of a chest scan.

Try it yourself

Delete the augment call and pass the same tensor as both views. The loss will drop quickly and the model will have learned an identity check rather than anything about content. Then keep the augmentations but remove the shift, leaving only brightness, and see how much of the spread survives.

What to learn next

Researcher — Mathematics and papers.

The objective

The NT-Xent loss of Chen, Kornblith, Norouzi and Hinton (2020), A Simple Framework for Contrastive Learning of Visual Representations, ICML, arxiv.org/abs/2002.05709, is, for a positive pair $(i,j)$ in a batch of $2N$ views:

$$ \ell_{i,j} = -\log \frac{\exp\bigl(\mathrm{sim}(z_i, z_j)/\tau\bigr)}{\sum_{k=1}^{2N} \mathbb{1}_{[k \neq i]} \exp\bigl(\mathrm{sim}(z_i, z_k)/\tau\bigr)} $$

$z$ are $\ell_2$-normalised projection outputs, $\mathrm{sim}$ is cosine similarity, $\tau$ is the temperature, and $\mathbb{1}$ excludes the anchor from its own denominator. This is InfoNCE with in-batch negatives, and it lower-bounds the mutual information between views, though Tschannen et al. (2020) show the bound is loose enough that the mutual-information framing does not explain the method's success.

SimCLR's ablations identified three components as decisive: composition of augmentations, particularly random crop plus colour distortion together; a learnable non-linear projection head between the representation and the loss; and large batches with long training. Reported: 76.5% top-1 under linear evaluation with a ResNet-50, a 7% improvement on the previous state of the art and matching supervised ResNet-50, and 85.8% top-5 when fine-tuned on 1% of labels.

Why the projection head helps

The loss makes its input invariant to the augmentations. Colour information, for instance, is discarded because colour jitter is one of the augmentations. That invariance is useful for the pretext task and harmful for downstream tasks where colour matters.

Putting a small MLP between the representation and the loss lets the head absorb the invariance while the representation keeps more information. This is why linear probes are run on the pre-head features. The effect is large and reproducible, and it is one of the few clean architectural findings in this literature.

The family, and what each fixed

MethodYearMechanism against collapseNotable property
MoCo2019Explicit negatives from a queue, momentum encoderDecouples negative count from batch size
SimCLR2020In-batch negativesNeeds very large batches
BYOL2020Predictor plus momentum target, no negatives74.3% linear probe with ResNet-50
SwAV2020Online clustering with equipartition constraintMulti-crop augmentation
SimSiam2020Stop-gradient aloneNo negatives, no momentum, no large batch
Barlow Twins2021Cross-correlation matrix driven to identityRedundancy reduction, not instance discrimination

He, Fan, Wu, Xie and Girshick (2019), Momentum Contrast, arxiv.org/abs/1911.05722, maintain a queue of encoded keys from previous batches, encoded by a slowly-updated momentum encoder. The momentum is required because the queue holds embeddings produced by older weights; without slow updates, the keys are inconsistent with each other.

Grill et al. (2020), Bootstrap Your Own Latent, arxiv.org/abs/2006.07733, remove negatives entirely and still avoid collapse, reaching 74.3% top-1 with a ResNet-50 and 79.6% with a larger one. Chen and He (2020), Exploring Simple Siamese Representation Learning, arxiv.org/abs/2011.10566, then strip away the momentum encoder too and show that the stop-gradient is the essential ingredient, with the asymmetry between the two branches doing the work that negatives were assumed to do.

That result is worth sitting with. The textbook story, that negatives are what prevent collapse, is incomplete. Asymmetry between two views of the same network is another route to the same end, and it is not fully explained.

Alignment and uniformity

Wang and Isola (2020), Understanding Contrastive Representation Learning through Alignment and Uniformity on the Hypersphere, ICML, arxiv.org/abs/2005.10242, decompose the contrastive objective into two properties on the unit sphere: alignment, the closeness of positive pairs, and uniformity, how evenly the induced feature distribution covers the sphere. They show the contrastive loss optimises both in the infinite-negative limit, and give directly optimisable metrics for each.

This is the theory behind the diagnostic in the developer block. Positive similarity measures alignment alone, which collapse maximises. The mean cosine between different images is a crude uniformity measure, and only both together describe a healthy encoder.

Practical guidance

  • Report linear-probe accuracy and k-NN accuracy on a held-out labelled set, not the pretraining loss. Loss values are not comparable across batch sizes because chance is $\ln(2N-1)$.
  • Track a uniformity statistic during pretraining. Collapse can occur hundreds of epochs in, after a learning-rate change.
  • Design augmentations from the invariances you actually want. This is domain knowledge, and it is the highest-leverage decision in the whole pipeline.
  • For most applied work today, starting from a released self-supervised checkpoint such as DINOv2 or DINOv3 dominates pretraining your own, unless your domain is far from natural images.

What to learn next