Contrastive learning trains an image encoder without labels by making it play a matching game. Take a photograph, crop it twice at random, and distort the colors of each crop. Feed both crops, along with crops of many other photographs, through the same network. Train the network so that the two crops of one photo land close together in its output space, and crops of different photos land far apart. The pairs come from how the crops were made, so nobody has to label anything.
In 2020 this recipe made pretraining without labels a serious rival to pretraining with them. Train a ResNet-50 this way on ImageNet's 1.28 million photos, without their labels. Then freeze it and fit only a linear classifier on its outputs, using the labels for that last step alone. The classifier names the right class for 69.3 percent of test images (Chen et al. 2020a). The same network trained end to end on all the labels gets about 76 percent. This section builds the two methods that defined the approach, SimCLR and MoCo, and the lessons from them that every later method in the chapter keeps.
The loss these methods use is InfoNCE, which §7.2 built for text retrieval. A batch holds B matching pairs, such as questions and their answers. Each question scores all B answers with a cosine similarity divided by a temperatureτ, a small positive number that sharpens the scores. The loss is a softmax cross-entropy over those scores, with the question's own answer as the correct class. In plain words, each question must pick its own answer out of the batch, and every other answer is a wrong option to push away.
One experiment in the SimCLR paper makes a good puzzle. Make the color distortion in the training augmentations stronger, and the contrastive model gets better, from 59.6 to 63.2 percent. Train an ordinary supervised ResNet-50 with the same augmentations, and it gets worse, from 77.0 to 75.7 percent. Why would contrastive learning want an augmentation that supervised learning dislikes? By the end of the section you will be able to answer that exactly. On the way we adapt InfoNCE to two views of every photo, find the exact lowest value the adapted loss can reach, and derive the one new term in its gradient. The rest of the theory, the mutual-information bound and the role of the temperature, carries over from §7.2, and we recap it where the image experiments need it. We check each part of the recipe against the published experiments. Then we build MoCo, which makes the same loss work with small batches, and work out how stale its stored negatives are.
Two views of one photograph
Here is the setup in plain words. Take a photo and transform it at random twice, for example with two different crops and two different color changes. Each result is called a view. Run both views through the same network. Train the network so that the two views of one photo get similar outputs, and views of different photos do not.
§28.1 called this the joint-embedding template and showed its trap. If all you ask is that two views agree, a network that sends every image to the same vector agrees perfectly and has learned nothing. That failure is called collapse. Contrastive methods prevent it with negatives, views of other photos that the loss pushes away. The method of Chen et al. (2020a), called SimCLR, fills each part of the template with the simplest choice that works, and the field uses its names for the parts.
In plain words, the network gets two distorted copies of each photo and must embed them so that each copy can pick out its twin among all the other views. The encoder is a ResNet-50, a convolutional network of 50 layers built from residual blocks (§0.8). It ends by averaging its feature maps over all positions, and it has about 24 million parameters below its classifier.
Two notes on notation. The SimCLR paper numbers the views of image k as 2k−1 and 2k. We use i and i+B, as most implementations do, because then the index of a view's twin is one line of code. And Chapter 27 wrote a ViT's pooled image vector as z. The self-supervised literature calls that vector h and keeps z for the projection head's output, and so do we.
A representation is graded with the protocols of §28.1, and the main one is the linear probe. Freeze the encoder and compute h for every labeled ImageNet training image. Train only a linear classifier on those features. Report its top-1 accuracy, the fraction of test images whose highest-scoring class is the right one. ImageNet here means the standard 1.28 million training photos in 1,000 classes. Every percentage in this section is that number unless it says otherwise.
The loss over 2B views
SimCLR's loss is InfoNCE with one change. In §7.2 the questions and the answers were two separate sets of vectors. Here there is one set, the 2B views, and each view plays both parts. The view that is asking is the anchor, its twin is the positive, and every other view is a negative. That one change forces four details.
Every view is an anchor. Its candidates are the other 2B−1 views of the batch, one positive and 2B−2 negatives (both views of each of the other B−1 images).
A view's cosine with itself is always 1. It must be removed from the softmax, or it would win every row.
Each positive pair is scored in both directions, once with each view as the anchor.
The negatives cost nothing extra, because they are the other anchors. A batch of 4,096 images gives each view 8,190 negatives.
In plain words, each of the 2B views plays a guessing game with 2B−1 options ("which of the other views is my twin?"), and the loss is the average surprise at the right answer. The name lists the three ingredients, normalized embeddings, a temperature, and a cross-entropy. The loss is older than the name. The same softmax over one positive and many negatives appears in Sohn (2016), Wu et al. (2018), and van den Oord et al. (2018). SimCLR's contribution was the recipe around it.
Interviewers often ask you to write this loss from memory. People slip in two places, the mask on the diagonal and the index of the positive. Here is the loss on four views, small enough to check by hand.
Here is the loss in NumPy, line by line as in the definition, checked on the hand example.
Python · cell 1
Where the loss starts and how low it can go
Two numbers frame every NT-Xent training curve. The first is where training starts, and §7.2 already found it for InfoNCE. (Losses here use the natural logarithm, so they are measured in nats.) A random encoder gives nearly equal logits, so each row's softmax is close to uniform and the loss is about the log of the number of candidates. Here each anchor has 2B−1 candidates, so ℓi≈log(2B−1). That is log511=6.24 at B=256 and log8191=9.01 at B=4096.
The second number is the lowest the loss can ever go. §7.2 showed that at τ=1 it cannot reach zero, using an ideal encoder with every negative at cosine −1. No encoder can do that, because many vectors cannot all point away from each other. With all 2B views in one set we can find the exact floor. Cosines lie between −1 and 1, so every logit lies between −1/τ and 1/τ, and the floor depends on τ.
∂ Proof of the NT-Xent lower bound, by Jensen's inequality twice
Write cij=z^i⊤z^j, let pi=cii+ be anchor i's positive cosine, and let N(i) be its n=2B−2 negatives. Dividing the softmax by the positive's term, as in the hand example,
ℓi=log(1+j∈N(i)∑e(cij−pi)/τ).
Step 1, average the negatives. The exponential is convex, so by Jensen's inequality n1∑jexj≥exˉ for the mean xˉ. With cˉi the mean negative cosine of anchor i, this gives ℓi≥φ((cˉi−pi)/τ) with φ(u)=log(1+neu).
Step 2, average the anchors. The function φ is increasing and convex (its second derivative is neu/(1+neu)2>0), so by Jensen again 2B1∑iφ(ui)≥φ(uˉ), and
L≥φ(τ1[2B1i∑cˉi−P]),P=2B1i∑pi.
Step 3, the negatives cannot all be far. Expand ∑kz^k2=∑i∑jcij≥0. The diagonal contributes 2B and the positive pairs contribute ∑ipi=2BP, so the negative cosines sum to at least −2B−2BP. There are 2B⋅n of them, so
2B1i∑cˉi≥−n1+P.
Step 4, put it together. The argument of φ is at least τ1[−n1+P−P], which decreases as P grows, and P≤1. At P=1 it equals −τ1(1+n2)=−(B−1)τB, and since φ is increasing this gives the bound. Equality needs every inequality to be tight. Step 4 needs P=1 (twins coincide), Step 3 needs ∑kz^k=0, and the two Jensen steps need every negative cosine to be equal. B unit vectors with equal pairwise cosines summing to zero form a regular simplex, with cosine −1/(B−1), and such a simplex spans B−1 dimensions. □
In plain words, the temperature sets a floor under the loss. The loss can approach zero only when e1/τ is much larger than the number of negatives, roughly when τ is well below 1/log(2B). At B=4096 that threshold is 0.11.
Python · cell 2
At τ=1 the random encoder sits at 6.25, right at log(2B−1). At τ=0.1 it starts higher, at 6.74. Random directions in many dimensions are nearly orthogonal, and their cosines spread by about 1/128=0.088 around zero (§1.3). A small temperature magnifies that spread tenfold. A softmax that is confidently wrong costs more than a uniform one, as §7.2 showed. The simplex meets the bound to six decimals, so the bound cannot be improved.
This tells you what a healthy training curve looks like. A loss that starts far from log(2B−1) points to a bug in the mask or the indexing. A loss that stalls near the floor at a large τ is not a bug at all.
The widget shows the full 2B×2B matrix of cosines for a batch of four images. Click a row to see its softmax, the probability the anchor gives each candidate. The lower bars split the negatives' probability among them. Each negative's share is also its share of the gradient that pushes the negatives away, as the next part shows.
Where does each row's probability go in NT-Xent, and what does the temperature change?
Eight views, two of each of four images, and their cosines. Click a row to see its softmax.
Loading interactive figure…
The gradient pushes every view twice
The gradient tells you what the loss does to the embeddings, and "implement NT-Xent with its backward pass" is a standard interview question. §7.2 derived the InfoNCE gradient as expected minus observed, a pull toward the positive and a push away from the softmax-weighted average of the candidates. NT-Xent differs from it in one way that trips people up. There the questions and the answers were different matrices, so each received one gradient term. Here the same matrix Z^ sits on both sides of S=Z^Z^⊤/τ. So every view receives a gradient twice. It gets one as an anchor, from its own row, and another as a candidate, from every other anchor's row.
Recall the rule for a softmax cross-entropy from §0.2. The gradient with respect to the logits is the predicted probabilities minus the one-hot target. Applied to every row at once, that gives the theorem.
∂ Proof of the NT-Xent gradient
Row i of the loss is a softmax cross-entropy over the entries Sij, j=i, with target i+. By the "predicted minus true" rule its gradient is (Aij−Πij)/(2B), the 1/(2B) coming from the mean. The masked diagonal entry is the constant −∞, so it has no gradient, and Aii=Πii=0 makes the formula hold there too.
Now Sij=z^i⊤z^j/τ depends on z^k when k=i and when k=j. Write G=∂L/∂S. The chain rule gives
∂z^k∂L=τ1j∑Gkjz^j+τ1i∑Gikz^i,
which is row k of (G+G⊤)Z^/τ. The positive-pair relation is symmetric (i++=i), so Π⊤=Π, which gives the last form. □
In plain words, a gradient step pulls view k toward its twin twice, once through its own row and once through its twin's row. It pushes view k away from the softmax-weighted average of the candidates in its own row. And it pushes view k away from every other anchor that mistook it for that anchor's twin, in proportion to the probability that anchor gave it.
Forgetting the transpose term is the most common bug. The code still trains, slowly and wrongly, so always check a hand-written gradient against finite differences. One check is free. Every row of A−Π sums to zero, because each softmax row sums to one.
Interview
NT-Xent loss and gradient over 2B views, no loops
Implement nt_xent(Z, tau) and return (loss, dZ).
The input Z has shape (2B, d) and holds unnormalized embeddings. Rows i and i + B are the two views of image i.
Normalize the rows, then compute LNT-Xent as defined above. Leave each view's similarity with itself out of its softmax, take the positive of row i to be row (i + B) % (2B), and average over all 2B anchors.
dZ is the gradient of the loss with respect to the unnormalized Z.
Use no Python loops over rows, and keep everything finite at tau = 0.001.
import numpy as np
def nt_xent(Z, tau):
"""Z: (2B, d), rows i and i + B are two views of image i. Returns (loss, dZ)."""
raise NotImplementedError
Tests, hints, verified solutions, and AI help, with Pro at $7.99 a month.
What the augmentations must destroy
Now the puzzle from the start. The loss asks the network to tell which views came from the same photograph. It does not care how the network tells. The network will find and use any cue that two crops of one photo share and crops of different photos do not. The content of the photo is such a cue, which is what we want. But so is its color distribution.
A beach photo is mostly blue and sand, and a forest photo is mostly green. Two random crops of one photo have nearly the same color histogram, the fraction of pixels at each intensity in each color channel. Crops of different photos do not. SimCLR's Figure 6 shows this. Histograms of different crops of one image look alike and differ across images, until color distortion is added. So a network can solve the contrastive task by matching histograms, which takes no understanding of shapes at all.
Supervised learning has no such shortcut. The labels define its task, and a color histogram does not predict "golden retriever" well enough to get far. There, color distortion only removes information that is sometimes useful, so it hurts a little. In contrastive learning the augmentations define the task. They say which differences between two inputs should be ignored. If color is not among them, color becomes the answer. That resolves the opening puzzle.
The supervised augmentations of §27.4 are not part of this policy. RandAugment applies random image operations tuned for classifiers, and Mixup and CutMix blend two images and their labels, which here do not exist. The evidence for this policy comes from SimCLR's own experiments. No single transformation is enough on its own. Cropping plus color distortion is the strongest pair. Blur adds a little (63.2 to 64.5 percent at 100 epochs), and so does flipping (removing it drops 64.5 to 63.4).
The widget makes two views of one synthetic photo with the crop, flip, color jitter, and grayscale steps. It then plays the matching game 128 times with color alone. Each time a view's twin hides among one fresh view of each of 8 photos, and the widget counts how often the twin has the closest color histogram.
What do two augmented views of one photo still share?
Two random crops of one synthetic photo, each with SimCLR's color jitter of strength s.
Loading interactive figure…
Warm-up
The color-histogram shortcut
Measure the shortcut yourself. The setup provides 60 synthetic photographs and a function that crops them.
IMAGES is a list of 60 arrays, each 24×24×3 with values in [0,1].
make_views(jitter, seed) returns two lists of 60 views, A and B, where A[i] and B[i] are two crops of photo i. Crops have different sizes and are not resized.
Implement two functions.
color_histogram(view, bins) returns a vector of length 3 * bins. It holds the histogram of each color channel over bins equal bins on [0,1], concatenated in the order red, green, blue. A value of exactly 1 goes in the last bin. Divide each channel's counts by the number of pixels, so each channel's histogram sums to 1.
histogram_match_accuracy(views_a, views_b, bins) returns the fraction of indices i for which views_b[i] is the view in views_b closest to views_a[i], by L1 distance between histograms.
Then compare the accuracy with and without jitter.
import numpy as np
def color_histogram(view, bins):
"""view: (h, w, 3) in [0, 1]. Returns (3 * bins,), each channel's histogram summing to 1."""
raise NotImplementedError
def histogram_match_accuracy(views_a, views_b, bins):
"""Fraction of i whose nearest neighbor (L1) of views_a[i] among views_b is views_b[i]."""
Tests, hints, verified solutions, and AI help, with Pro at $7.99 a month.
The histogram shortcut is not hypothetical, and we can watch a contrastive learner take it. The next cell builds a tiny world of "photos", each described by 16 numbers. Eight are content features, the photo's class (one of 4) plus details of that particular photo. The other eight are color features, the photo's own palette, unrelated to its class. A crop changes the content features a lot, since different crops show different parts. Without jitter, the color features barely change between views.
The encoder is a linear map down to only dz=4 dimensions, so it must choose what to keep. We train it with NT-Xent and the gradient derived above. Then we freeze it and fit two linear probes, one for the class and one for the color features.
Python · cell 3
The second half of that result is a warning. Jitter made the features blind to color, and for some tasks color is the signal, as with flowers, birds, ripe fruit, or lesions in medical images. Ericsson et al. (2021) compared 13 self-supervised models on 40 downstream tasks (§28.1). They found that the best ones keep less color information than supervised models do.
Tian et al. (2020) turned the lesson into a principle they call InfoMin. Good views should share as little information as possible beyond what the downstream task needs, since the encoder may use anything the views share. They showed that stronger augmentation lowers the information shared between views and raises downstream accuracy, up to a point, and they reached 73.0 percent with a ResNet-50.
The projection head, and why it is thrown away
SimCLR computes the loss on z=gθ(h) but evaluates h, the layer below. Why have a head at all, and why throw it away after training?
Start with a simple fact. A head that is only a linear map cannot help a linear probe directly.
So if a head helps, it must help by changing what the encoder learns. The experiments say it does, a lot. SimCLR proposed an explanation and tested it. The loss asks z to be invariant to the augmentations, so the head learns to throw away whatever they change, such as color, orientation, and the exact crop. The layer below, h, does not have to, and keeps more.
To test this, SimCLR trained small MLPs to predict which transformation had been applied, once from h and once from z. For this test the pretraining augmentations also included rotations, noise, and edge filtering.
The head is a buffer that absorbs the invariance, so the representation underneath stays rich. In practice, always probe the encoder's output and never the head's. An interviewer who asks "why throw the head away?" wants this answer, with the rotation numbers as evidence.
Temperature and normalization
§7.2 showed that the temperature decides which negatives get the push. A small τ concentrates the gradient on the few negatives closest to the anchor, the hard negatives. A large τ spreads it evenly over all of them. SimCLR measured what that does to image features, and the floor above explains one end of the curve.
Read the table from both ends. At τ=1 the softmax cannot become confident, because of the floor. So every negative keeps pushing about equally, and the hard ones get no special attention. At τ=0.05 almost all the push goes to the few nearest negatives. Many of those are false negatives, other photos of the same class, and the loss pushes them away hardest (the pitfall of §7.2).
The unnormalized runs are the shortcut lesson again. Without normalization the network can sharpen its softmax just by making its vectors longer, since a longer vector acts like a smaller temperature. That raises the contrastive accuracy without improving the features.
The best temperature is not universal. MoCo, built later in this section, used 0.07. MoCo v2 found 0.2 better once it added an MLP head. MoCo v3's ResNet-50 recipe uses τ=1 with an extra prediction head and reaches 73.8 percent. Tune τ together with the rest of the recipe, and expect an interviewer to ask what goes wrong at each extreme.
Big batches and the LARS optimizer
More negatives make a harder game. They also raise a ceiling. §7.2 showed that InfoNCE with N candidates can certify at most logN nats of information shared by the two views, here log(2B−1). SimCLR deliberately keeps no store of old embeddings, so its negatives come only from the batch. It measured what batch size buys.
Big batches help most when training is short. The gap mostly closes with more epochs, because each image then meets many more negatives over the course of training.
A batch of 4,096 at learning rate 4.8 is unstable with plain momentum SGD (§0.6), so SimCLR uses LARS (You et al. 2017) at every batch size. The idea is that different layers need different step sizes. The ratio of a layer's gradient norm to its weight norm varies wildly between layers and over training. LARS rescales each layer's step to a fixed fraction of that layer's own weight norm, with a per-layer factor called the trust ratio.
In plain words, at SimCLR's settings each layer moves by at most 4.8×0.001=0.48 percent of its own norm per step, before momentum, whether its gradient is tiny or huge. With momentum μ=0.9 the buffer adds up as many as 1/(1−μ)=10 such steps, so about 5 percent. That is what makes a learning rate of 4.8 safe. A layer with small weights and large gradients, which plain SGD at that rate would blow up, gets a proportionally small step.
Core
LARS with exclusions
Implement lars_step(params, grads, bufs, lr, wd, trust, momentum, exclude), one LARS step exactly as in the definition above.
params, grads, and bufs are dicts from parameter names to NumPy arrays, where bufs holds the momentum buffers.
exclude is a set of names, the biases and normalization parameters.
For an array not in exclude, use the trust ratio ηtrust∥w∥/(∥g∥+λ∥w∥). Use a ratio of 1 if ∥w∥=0 or the denominator is 0.
For an excluded array, use plain momentum SGD without weight decay.
Norms are taken over the whole array. Return (new_params, new_bufs) as new dicts, and leave the inputs unchanged.
import numpy as np
def lars_step(params, grads, bufs, lr, wd, trust, momentum, exclude):
"""params, grads, bufs: dicts name -> array. exclude: set of names. Returns (new_params, new_bufs)."""
raise NotImplementedError
Tests, hints, verified solutions, and AI help, with Pro at $7.99 a month.
BatchNorm can leak the pairing
The last ingredient of SimCLR is the one that silently breaks contrastive learning when you spread training over many devices. Recall from §0.7 that BatchNorm, in training mode, normalizes each feature with the mean and variance of the current batch. Training on many devices usually uses data parallelism. Each device holds a copy of the model and processes its own slice of the batch, so it normally computes those statistics over its own slice only.
There are three fixes.
Global BatchNorm, used by SimCLR, computes the mean and variance over all devices together (torch.nn.SyncBatchNorm in PyTorch).
Shuffled BatchNorm, used by MoCo, applies to the second encoder that MoCo uses to make its keys (built below). It shuffles the batch before splitting it across devices for that encoder, and puts the outputs back in order afterward. So the two views of an image are normalized with statistics from different slices.
No batch statistics.Hénaff et al. (2019) replaced BatchNorm with LayerNorm, which normalizes each example on its own.
In an interview, "my contrastive accuracy is near 100 percent and my probe is bad" should make you think of this pitfall first.
The negatives must also be shared across devices. Each device collects the embeddings of the whole global batch with an all-gather, an operation that gives every device the tensors of every other device. Then it computes the loss for its own anchors. The gather must pass gradients back to the device that produced each embedding, or every device's negatives become constants. The collectives themselves are in §10.2. §7.2 shows how to compute a giant contrastive batch in chunks with exactly the full-batch gradient, a trick called gradient caching.
Here is how the pieces look in PyTorch. The block is shown, not run, because it needs torchvision and several processes.
MoCo, a dictionary larger than the batch
SimCLR's negatives cost a huge batch. He et al. (2020) found a way to get many negatives with a small one. They looked at contrastive learning as a dictionary lookup. The query is one view of an image. The keys are encoded views of many images. The loss asks the query to find its own key, the one made from the other view of the same image.
Their hypothesis was that good features need a dictionary with two properties. It should be large, so there are many negatives. And it should be consistent, with all keys made by nearly the same encoder, so that comparing the query with them is fair. The two other mechanisms of the time each gave up one of the two.
In the end-to-end mechanism, which SimCLR later scaled up, the dictionary is the batch. It is consistent, but its size is the batch size, which memory limits.
The memory bank of Wu et al. (2018), met in §28.1, stores one feature for every image in the dataset. It is large. But each stored feature was computed when its image was last seen, up to a whole epoch earlier, by an encoder that has changed since.
MoCo keeps both properties with two pieces. A queue holds the keys of recent batches, and a slowly moving copy of the encoder makes them.
In plain words, the queue makes the number of negatives independent of the batch size, and the slow key encoder keeps the queue's keys comparable with each other. Each query sees one fresh positive and 65,536 negatives, while backpropagation runs through only the 256 queries.
Three details matter in practice. The positive comes from the current batch and is not in the queue. MoCo v1 uses only the queue as negatives, not the other keys of the batch. And the keys get no gradient, so the key encoder's forward pass runs under no_grad. We rename two of the paper's symbols to avoid clashes. Its queue length K becomes Kq, since K is a number of classes in this book, and its key-encoder parameters θk become ξ.
The exponential moving average is the same one §0.6 used to average weights for evaluation, with m in the role of that section's β. Here it produces the encoder that makes the targets. The non-contrastive methods of §28.3 and the self-distillation of §28.4 use it the same way. Three numbers describe it.
For m=0.999 the window is 1,000 steps, the lag 999 steps, and the half-life 693 steps. In practice this means a key encoder that averages roughly the last thousand versions of the query encoder, so it changes very little from one step to the next.
How old are the keys in the queue
Now we can put a number on MoCo's consistency argument. At step t the query encoder has parameters θt. The positive key was just computed with ξt. The keys added j steps ago were computed with ξt−j, for j=1,…,Kq/B. How far behind θt is each of them?
In plain words, every key, the positive included, comes from an encoder that trails the query encoder by the lag m/(1−m). Keys that sat in the queue for j steps trail by j more. When the lag is large compared with the queue's length in batches, all keys are about equally old, and the dictionary is consistent.
Python · cell 4
The widget draws the age of every key used in one step, its lag behind the query encoder, as a bar. The queue holds MoCo's 65,536 keys, so you can see how momentum and batch size change the spread. A dashed line carries the positive's age across the queue, so each negative's extra age shows above it.
How old are MoCo's keys, and how consistent are they?
A queue of 65,536 keys, newest left. Bar height is age, the steps a key's encoder trails the query encoder.
Loading interactive figure…
With a batch of 1,024 the queue holds only 64 batches, so the oldest key lags 64+999=1,063 steps and the ratio falls to 1.06. With MoCo's defaults the ratio (256+lag)/lag stays below 1.5 only when the lag exceeds 512 steps, which needs m>512/513≈0.998.
The transfer result mattered more than the linear probe. MoCo's linear accuracy, 60.6, was below SimCLR's 69.3, which came months later and used 1,000 epochs. But MoCo showed self-supervised pretraining beating supervised pretraining at the tasks most practitioners cared about, finding and outlining objects in photos, in seven detection and segmentation tasks.
Interview
One MoCo step
Implement moco_step(q, k, queue, ptr, theta, xi, m, tau), one training step of MoCo without the encoders. The inputs are these.
q and k are (B, d) arrays of unit-length queries and their positive keys. Use them as given, without renormalizing.
queue is (K_q, d), with K_q a multiple of B, and ptr is the index of the oldest batch in the queue.
theta and xi are flat arrays holding the parameters of the query encoder (already updated by this step's optimizer) and of the key encoder.
Return (loss, dq, new_queue, new_ptr, new_xi).
loss is the mean over the batch of Lq. The logits are [q.k+, q.queue^T] / tau, with the positive in column 0 and only the queue as negatives.
dq is the gradient of loss with respect to q. The keys are constants.
After computing the loss, write the batch's keys into rows ptr to ptr + B of a copy of the queue, and advance the pointer by B modulo K_q.
new_xi is the momentum update of the key encoder.
Compute everything stably.
import numpy as np
def moco_step(q, k, queue, ptr, theta, xi, m, tau):
"""q, k: (B, d) unit. queue: (K_q, d). Returns (loss, dq, new_queue, new_ptr, new_xi)."""
raise NotImplementedError
Tests, hints, verified solutions, and AI help, with Pro at $7.99 a month.
Research
How consistent is a queue?
Make the staleness theorem of this section concrete, and check it by simulation.
key_lag(m, K_q, B) returns (pos, neg, ratio). Here pos is the positive key's mean lag m/(1−m). The array neg holds the lags of the Kq/B batches in the queue, with entry j - 1 for the batch added j steps ago. And ratio is neg[-1] / pos, or np.inf when pos is 0.
simulate_lag(m, n_slots, steps, noise, rng, D=8) simulates MoCo's two encoders. Start with theta = xi = zeros(D) and a unit drift direction v = ones(D) / sqrt(D). At each of steps steps, first move the query encoder, theta = theta + v + noise * rng.normal(size=D). Then apply the momentum update to xi, and record xi as the encoder of that step's keys. After the last step, return an array of n_slots + 1 measured lags. Entry j is the projection (θT−ξT−j)⊤v for the keys encoded j steps before the end, so entry 0 is the positive key.
With noise = 0 and enough steps, the simulation should reproduce the closed form.
import numpy as np
def key_lag(m, K_q, B):
"""Returns (positive lag, array of the K_q // B negative-slot lags, oldest / positive ratio)."""
raise NotImplementedError
def simulate_lag(m, n_slots, steps, noise, rng, D=8):
"""Returns n_slots + 1 measured lags, entry 0 for the positive key."""
Tests, hints, verified solutions, and AI help, with Pro at $7.99 a month.
MoCo v2, MoCo v3, and unstable contrastive ViTs
SimCLR and MoCo appeared three months apart, and each had something the other lacked. SimCLR had the MLP head and strong augmentation. MoCo had the queue that made small batches work. Within weeks the MoCo authors combined them.
MoCo v3 (Chen et al. 2021) asked what happens with Vision Transformers, and the answer reshaped the recipe. A Vision Transformer, or ViT, cuts an image into square patches, turns each patch into a vector with one linear layer, and runs a Transformer over those vectors (§27.2). At a batch of 4,096 the queue's extra negatives no longer helped, so MoCo v3 dropped the queue and uses the other keys of the batch as negatives. It kept the momentum encoder.
Its momentum ablation tells the staleness story from the other side. For ViT-B trained for 300 epochs, momentum m=0,0.9,0.99,0.999 gives 74.3, 75.6, 76.5, and 75.0. Without a queue, m=0 no longer fails, because every key comes from the current batch and one encoder. The momentum encoder still adds 2.2 points.
The real finding of MoCo v3 was instability. Self-supervised ViTs trained with large batches and learning rates showed sudden dips in accuracy during training. The dips recovered partly and left the final number a point or two lower. Run-to-run variation was only 0.1 to 0.3 points, so the damage was easy to miss.
The authors measured k-NN accuracy every few tens of iterations to see the dips. Each dip followed a spike in the gradient norm. The spike appeared first in the patch projection, the first layer of the ViT that turns image patches into vectors (§27.1), and tens of iterations later in the last layers.
The authors were careful about what this shows. Freezing the first layer narrows the set of solutions and changes no architecture, so the problem is in optimization, not in capacity. The first layer is only the easiest place to intervene, since it is the one layer that is not a Transformer block, and they called for a more basic fix. Later work supplied several. These include a lower learning rate and longer warmup, normalizing the queries and keys inside attention (QK-norm, §5.7), and the stability measures of DINOv2 in §28.4. The practical lesson is to monitor training at high resolution, because instability can hide inside a final number that looks fine.
In PyTorch
Production NT-Xent is four lines. Normalize, mask the diagonal with masked_fill, and call F.cross_entropy with targets (i+B)mod2B, which handles the stable log-softmax and the mean. The block checks autograd against the closed-form gradient of the theorem.
Then it runs a few steps of MoCo's two moving parts, the momentum update under torch.no_grad() and a ring-buffer queue. The loss rises over the first steps. The queue starts with random keys, which are easy negatives, and real keys replace them.
🔥 In PyTorchfor a machine with PyTorch
Where contrastive learning falls short
Contrastive learning set the template for self-supervised vision, and its weaknesses set the agenda for the rest of the chapter. There are four.
Negatives cost a big batch or a queue. SimCLR needs thousands of images per step, with all-gathers and LARS. MoCo needs a second encoder and a queue whose keys must be kept consistent. Either way, the method's quality depends on how many negatives you can afford.
Some negatives are not negative. Every other image in the batch is pushed away, including images of the same class. ImageNet has 1,000 roughly balanced classes, so each negative shares the anchor's class with probability about 1/1000. That is about 8 of SimCLR's 8,190 negatives per anchor at batch 4,096, and about 66 of MoCo's 65,536. At small τ these false negatives get the largest pushes.
The embeddings can still partly collapse. Negatives stop every image from mapping to one point. They do not stop the embeddings from filling only a low-dimensional subspace. Strong augmentation is one cause. Along a direction where the augmentation varies more than the data does, the encoder's weights shrink, as they did on the color features in our simulation under strong jitter. When that happens along many directions, the embeddings fill fewer dimensions. §28.3 names this dimensional collapse, shows how to measure it, and explains its causes.
The features are global. NT-Xent compares one vector per image. So the encoder is rewarded for what identifies the whole image, not for where things are in it. That shows up as weaker dense features, the per-patch features used for segmentation and depth, than the masked methods of §28.5 give.
54.0 (58.0 reimplemented with InfoNCE and 65,536 negatives)
SimCLR
the other views in the batch
2B−2=8,190
4,096
the same network, with gradients
global BatchNorm
69.3 (1,000 epochs)
MoCo v1
a queue of past keys
65,536
256
momentum, m=0.999
shuffled BatchNorm
60.6 (200 epochs)
MoCo v2
a queue, plus the MLP head and blur
65,536
256
momentum, m=0.999
shuffled BatchNorm
71.1 (800 epochs)
MoCo v3
the other view's keys in the batch
B−1=4,095 per direction
4,096
momentum, m=0.99 for ViT
global BatchNorm (SyncBN), only in the heads for ViT
73.8 (800 epochs); ViT-B 76.7
These comparisons mix training lengths and architectures, so read them as a history of recipes rather than a controlled experiment.
The same loss reappears across the book. CLIP trains it across two kinds of data, an image encoder against a text encoder, with a learned temperature (§30.2). And wav2vec 2.0 uses a contrastive loss to pick the true quantized speech frame among distractors (§35.4).
Read more
Chen, Kornblith, Norouzi & Hinton (2020a). A Simple Framework for Contrastive Learning of Visual Representations. The SimCLR paper. Its ablations of augmentation (Table 1, Figures 5 and 6), the projection head (Table 3), the loss and temperature (Tables 4 and 5), and batch size (Table B.1) are the evidence behind most of this section.
He, Fan, Wu, Xie & Girshick (2020). Momentum Contrast for Unsupervised Visual Representation Learning. MoCo. Section 3 explains the dictionary view, the queue, the momentum update, and shuffled BatchNorm, and Section 4 has the momentum and mechanism ablations and the transfer results.
Chen, Fan, Girshick & He (2020c). Improved Baselines with Momentum Contrastive Learning. MoCo v2, a three-page note that isolates the MLP head, the temperature, and blur, with the memory and time cost of each mechanism.
🎬 Momentum Contrast for Unsupervised Visual Representation Learning · ComputerVisionFoundation VideosThe MoCo authors' CVPR 2020 presentation of the dictionary view, the queue, and the momentum encoder.🎬 Contrastive Learning with SimCLR V1/V2 and Some Intriguing Properties · COMPUTER VISION TALKSTing Chen, SimCLR's first author, explains the framework, its ablations, and what changed in SimCLR v2.🎬 09P – Contrastive joint embedding methods (JEMs) for self-supervised learning (SSL) · Alfredo Canziani (冷在)The NYU Deep Learning practicum on contrastive joint-embedding methods, a careful walk through the losses and the collapse problem they solve.