ImageNet-1k’s 1.28M labels cost around $10M. Self-supervised vision learns the features from the unlabelled pile instead: SimCLR pulls two views together, DINO distils an EMA teacher into its student, MAE hides 75% of the patches and paints them back. Seven chapters and seven labs take the three families apart — the InfoNCE loss, the batch-as-negatives arithmetic, centring and sharpening, the 75%-vs-15% mask argument, and the linear probe that puts the frozen features to work on your 10k labels.
SimCLR pulls two augmented views of one image together and pushes every other image away. DINO trains a student to predict an EMA teacher's output — no negatives at all. MAE hides 75% of patches and reconstructs the pixels. All three are trained on unlabelled images and produce an encoder you freeze or fine-tune.
frozen linear probes: SimCLR ≈71 · DINO ≈77 · MAE ≈76 · DINOv2 ≈8602 / THE BATCH IS THE SUPERVISION
No labels, but plenty of negatives.
InfoNCE asks one view to find its partner among all 2N − 1 candidates in the batch: batch 32 gives 62 negatives per positive, 512 gives 1,022, 4096 gives 8,190. More negatives, sharper signal. A constant encoder scores exactly ln(2N − 1) — 4.14 at batch 32, 9.01 at 4096 — which is why the loss is read against that floor.
L = −log( exp(sim(zᵢ,zⱼ)/τ) / Σₖ exp(sim(zᵢ,zₖ)/τ) ) · τ = 0.07–0.503 / GUARDS AND RATIOS
Every family needs a collapse guard.
Contrastive: the negatives. DINO: centring (subtract the per-dim EMA mean) plus sharpening (teacher temperature 0.04 → 0.07). MAE: the 75% mask itself — at 15% a blur solves the task, which is why text can mask 15% and images cannot. Augmentations define the invariances: crop and colour jitter say what the encoder must ignore.
Self-supervised pretraining is a made-up task whose only non-trivial solution is real visual features — pull two views together (SimCLR), predict a lagged copy of yourself (DINO), or paint the 75% of patches you were not allowed to see (MAE). The labels arrive only at the end, on a head with a few thousand parameters.
By the end you will be able to write InfoNCE from memory and say why batch 32 starves it, compute the negative count and the ln(2N − 1) floor for any batch, explain centring and sharpening as two halves of one anti-collapse mechanism, justify MAE’s 75% against BERT’s 15% with the entropy argument, and choose between a frozen DINOv2 probe and an MAE fine-tune for your own label budget.
01
THREE FAMILIES, ONE IDEA
Labels cost millions. Pictures cost nothing.
ImageNet-1k is 1.28M labelled images, and that label set is estimated to have cost around $10M. Self-supervised pretraining throws the labels out of the expensive part: learn features from unlabelled pictures first, then fine-tune on whatever small labelled set you can afford.
Supervised ImageNet has 1.28M labelled images; at the commonly quoted annotation estimate that works out to roughly 1.28M labelled images for about $10M, or $7.81 per image. Medical imaging and industrial inspection datasets are tinier and cost more per label, because a radiologist or a specialist has to look at every one. Meanwhile the world is drowning in pictures nobody labelled: YouTube frames, web crawls, factory webcam footage, satellite sweeps. The pretraining corpora are named for their scale: JFT-300M (300M images), LAION-5B (5B image–text pairs), and DINOv2’s 142M-image curated set — three to four orders of magnitude more pictures than any label budget reaches. The question that reshaped vision from 2018 onward: can a model learn visual features from the cheap data, and only use labels at the end?
The conceptual trick is that the pretext task — the thing the model is trained to do — does not have to be the downstream task. Predicting the colour of a grayscale image, classifying the rotation someone applied, masking patches and reconstructing them: all were tried. What matters is not whether the task is useful, but whether solving it requires useful features. Three families scaled:
FAMILY MODELS WHAT IT OPTIMISES
contrastive SimCLR, MoCo, CLIP pull two views of one image together,
push every other image away
teacher–student DINO, BYOL, iBOT student predicts the teacher's output
for another view; teacher = EMA of student
masked reconstruction MAE, BEiT, SimMIM hide 75% of patches, reconstruct the
missing pixels from the visible 25%
All three are trained on unlabelled images and produce an encoder you then freeze (for a linear probe) or fine-tune (for real). The supervised recipe — collect labels, train end to end — is not replaced; it is moved to the end of the pipeline, where a 10k-image labelled set is enough.
The anchor numbers for this lesson, all on ImageNet-1k with a frozen encoder and one linear classifier on top: SimCLR (ResNet-50) ≈71% in 2020, DINO (ViT-S/16) ≈77% in 2021, MAE (ViT-L/16) ≈76% in 2022, DINOv2 (ViT-g/14) ≈86% in 2023. The last one is a purely self-supervised model whose frozen features rival fully supervised ones — trained on 142M curated images and zero labels.
Three families, side by side
Pick a family to read its objective, its negatives, its collapse guard and its compute; then flip the guard off and watch what the family would collapse into without it. The green strip is the piece that keeps the representation alive.
familycollapse guard
family CONTRASTIVE · SimCLR · MoCo · CLIP
loss InfoNCE = −log( exp(sim(zᵢ,zⱼ)/τ) / Σₖ exp(sim(zᵢ,zₖ)/τ) )
τ 0.1 in SimCLR; lower is sharper and wants more negatives
views 2N per batch: 2 × 4,096 = 8,192 embeddings, 8,190 negatives each
guard the negatives — the batch is the supervision; no negatives, no push
without every image maps to the same point and the loss sticks at ln(2N − 1) — all answers look equally right
The three families are three answers to one failure mode: a model that maps everything to the same vector is useless. Contrastive learning keeps representations apart with negatives; DINO centres and sharpens the teacher; MAE makes the task hard enough that only real features solve it.
Quick check
Why is it fine for the pretraining task (reconstruct patches, match views) to be completely different from the downstream task (classify flowers)?
02
CONTRASTIVE
Two views of one image. Everyone else is a negative.
SimCLR’s idea fits in one sentence: take an image, augment it twice, and train the encoder so the two views land close together while every other image in the batch stays far away. No labels, no decoder — just a softmax that must pick the right partner.
The pipeline is four steps. Sample an image. Apply two independent random augmentations (two views). Push both through the same encoder and a small projection head. Minimise a loss that says “these two embeddings are the same picture” and “these two embeddings are different pictures”. The encoder and projection head are trained; the projection head is thrown away afterwards and only the encoder is kept.
The loss is InfoNCE. For an anchor view z_i with positive partner z_j, it is a softmax over cosine similarities divided by a temperature τ, where the positive is the target class and every other view is a distractor:
L(i, j) = −log( exp(sim(z_i, z_j) / τ)
───────────────────────────────────── )
Σ_{k ≠ i} exp(sim(z_i, z_k) / τ)
sim = cosine similarity between L2-normalised embeddings
τ = temperature: 0.1 in SimCLR, quoted range 0.07–0.5
k = every view in the batch except the anchor itself
Read it as a multiple-choice exam: the anchor asks “which of these 2N − 1 views is my partner?”, the softmax gives a probability to each candidate, and the loss is the negative log of the probability it assigned to the right answer. A perfect encoder puts all probability on the positive and scores 0. A constant encoder spreads probability evenly and scores ln(2N − 1) — the exam with all answers equally plausible.
Worked numeric check — one anchor, three candidates
Take a microscope-sized batch: 2 images, so 4 views. Suppose the encoder says the positive pair has cosine similarity 0.8, and the two other pairs sit at 0.2. With τ = 0.5 the logits are 1.6, 0.4 and 0.4:
positive logit 0.8 / 0.5 = 1.600
negative logits 0.2 / 0.5 = 0.400 (two of them)
softmax denominator e^1.6 + e^0.4 + e^0.4 = 4.953 + 1.492 + 1.492 = 7.937
P(positive) 4.953 / 7.937 = 0.624
loss −log(0.624) = 0.471
now drop τ to 0.07, the sharp end of the range:
positive logit 0.8 / 0.07 = 11.429
negative logits 0.2 / 0.07 = 2.857
P(positive) 0.9996 → loss 0.0004
and make the negative hard — cosine 0.7 instead of 0.2, τ = 0.07:
positive logit 11.429
negative logit 10.000
P(positive) 0.8067 → loss 0.2147 ← a 0.1 similarity gap now costs 0.21 nats
Two things to carry forward: a smaller τ makes the softmax sharper, so a clean positive is rewarded almost perfectly; and the same sharpness makes any negative that looks like the positive expensive. That is what “hard negatives” means, and it is why the number of negatives has to be large — see the next chapter.
InfoNCE from scratch — the whole loss in nine linespython
import torch
import torch.nn.functional as F
def info_nce(z1, z2, tau=0.1):
"""z1, z2: (N, D) L2-normalised embeddings of paired views."""
N, D = z1.shape
z = torch.cat([z1, z2], dim=0) # (2N, D): view 1s then view 2s
sim = z @ z.T / tau # (2N, 2N) cosine similarity / tau
mask = torch.eye(2 * N, dtype=torch.bool, device=z.device)
sim = sim.masked_fill(mask, float("-inf")) # a view is not its own answer
targets = torch.cat([torch.arange(N, 2 * N), torch.arange(0, N)]).to(z.device)
return F.cross_entropy(sim, targets) # softmax with the positive as target# sanity check from the source:
z1 = F.normalize(torch.randn(16, 32), dim=-1)
z2 = z1.clone()
print(info_nce(z1, z2, tau=0.1).item()) # identical pairs → near 0
z_random = F.normalize(torch.randn(16, 32), dim=-1)
print(info_nce(z1, z_random, tau=0.1).item()) # random pairs → ≈4.75 at τ=0.1# ln(2N−1) = ln(31) ≈ 3.43 is the constant-encoder floor: identical# embeddings make every candidate equally plausible, while random ones# sit above it because their similarities are spread out.
torch.eye masks the diagonal so a view cannot be its own target; targets[i] = i + N for the first half and i − N for the second. L2-normalise before calling — the function assumes unit vectors.
The InfoNCE playground
A batch of embeddings, two views each. The loss is a softmax: pick the positive out of all 2N − 1 candidates. Move the temperature, the batch size or how similar the positives are, and watch the loss and the similarity matrix react.
batch size (images)
batch 8 images → 16 views → 14 negatives per positive
τ 0.10 (SimCLR default 0.1; range quoted 0.07–0.5)
anchor view 1 of image 0, loss 0.2598
positive cosine 0.526 · strongest negative 0.266
batch mean 0.9038
constant floor ln(2N−1) = 2.7081 (what a constant encoder scores)
top-1 match 88% of anchors have their positive as nearest neighbour
the arithmetic: for every anchor the loss is −log( exp(sᵢⱼ/τ) / Σₖ exp(sᵢₖ/τ) ).
A small τ makes the softmax sharper: it rewards a clear positive but punishes
any negative that happens to look similar.
This is the real loss, computed on toy embeddings — not a trained network. The mechanism is the point: the batch is the label, and the denominator is where the negatives live.
03
BATCH SIZE IS THE NEGATIVE COUNT
512 works. 32 fails.
In supervised learning a batch is just a gradient estimate. In contrastive learning the batch is the supervision: every other view in it is a negative the positive must be ranked against. Shrink the batch and you starve the softmax of distractors.
Count the negatives. A batch of N images produces 2N views — each with its own positive partner — and for every anchor the denominator sums over the other 2N − 1 candidates, one of which is the positive. So each positive competes against 2N − 2 negatives:
batch N views 2N negatives 2N − 2 constant-encoder floor ln(2N − 1)
32 64 62 4.143
256 512 510 6.236
512 1,024 1,022 6.931 ← the "512 works" line
1,024 2,048 2,046 7.624
4,096 8,192 8,190 9.011 ← SimCLR's headline batch
similarity matrix at batch 4096: 8,192² × 4 B = 268 MB of fp32 scores
per batch — the negatives are paid for in memory.
Why does the count matter so much? Because the loss is a softmax: it asks the anchor to find its partner among all candidates. With 62 negatives, a mediocre embedding can already win most of the exams — the gradient signal is weak and noisy. With 8,190, the positive has to beat thousands of candidates, so the encoder is pushed to separate images that genuinely differ. SimCLR’s own ablation shows top-1 accuracy climbing steeply from batch 256 to 4096; at batch 32 the loss still decreases, but the features barely improve.
The fix when your GPU cannot hold a big batch is MoCo’s momentum queue: keep the features of the last 65,536 images in a queue, use them as negatives, and decouple the negative count from the batch size. A 256-image batch plus the queue gives 65,792 negatives — the memory cost of the queue (65,536 × 128 dims × 4 B = 32 MiB) is tiny next to a 4096-image batch of activations.
Why a constant encoder scores exactly ln(2N − 1)
Suppose the encoder ignores its input and returns the same unit vector for every view. Every cosine similarity is then 1, so every logit is 1/τ — identical for the positive and for all 2N − 2 negatives. The softmax has no reason to prefer the target:
P(positive) = exp(1/τ) / ( (2N − 1) · exp(1/τ) ) = 1 / (2N − 1)
loss = −log( 1 / (2N − 1) ) = log(2N − 1)
at N = 32: log(63) = 4.143
at N = 512: log(1023) = 6.931
at N = 4096: log(8191) = 9.011
The temperature cancels out: a constant encoder scores the same
whatever τ is. That is the floor — training must push the loss
below it, and a batch of 32 makes the floor itself shallow.
This is also the number to watch in a training log. A contrastive run whose loss sits at ln(2N − 1) has learned nothing; one that goes to zero too fast has found a shortcut. The healthy curve passes through a few nats and keeps falling.
The batch is the negative count
InfoNCE ranks a positive against every other view in the batch, so the batch size is not an efficiency dial — it is how much supervision the loss gets. Slide it and watch the arithmetic: negatives, the constant-encoder floor, and the memory of the similarity matrix.
BATCH 512 → 1,024 VIEWS → 1,022 NEGATIVES PER POSITIVE
16
32
64
128
256
512
1024
2048
4096
Bars are √-scaled so 62 stays visible next to 8,190. Stops are powers of two; SimCLR’s default is 4096.
batch 512 images × 2 views = 1,024 embeddings
negatives 1,022 per positive (2N − 2)
constant floor ln(2N − 1) = 6.930 ← loss of a constant encoder
similarity 1,024² = 1,048,576 scores × 4 B = 4.0 MiB
backward every positive pulls against 1,022 negatives at once
MoCo alternative batch 256 + a queue of 65,536 past features = 65,792 negatives
queue memory 65,536 × 128 dims × 4 B = 32.0 MiB
BATCH 512 · WORKABLE
512–1024 negatives per positive is where SimCLR-style contrastive learning starts to work. SimCLR's own ablation shows a steep accuracy climb from 256 to 4096; below ~256 the benefit is marginal.
batch 512 images
views 1,024 = 512 × 2 augmentations
negatives 1,022 per positive
floor 6.930 = ln(1,023)
matrix 1,048,576 fp32 scores = 4.0 MiB
verdict workable
SimCLR's ladder: batch 256 (510 negatives) → 4096 (8,190).
The 2020 paper's ablation reports the accuracy climbing
steeply across that whole range, which is why batch size
became a headline number in contrastive learning.
Without the memory: MoCo keeps a queue of the last
65,536 features, so a 256-batch run still scores
every positive against 65,792 negatives — the negative
count no longer has to fit in one batch.
The honest caveat the paper adds: negatives must also be *diverse*. A queue of stale features works because it lags the encoder, but a batch of near-duplicate images gives you fewer useful negatives than its size suggests.
Quick check
SimCLR needs batches of 512–8192 while supervised ImageNet training is happy at 256. Why?
04
TEACHER–STUDENT
A teacher that is just an average of the student.
DINO drops negatives entirely. A student network sees one view and predicts a teacher’s output for the other; the teacher is not trained — it is an exponential moving average of the student’s own weights. The trick is keeping that echo from collapsing into a constant.
Two networks, same architecture. The student has gradients; the teacher is a copy whose weights are updated by a slow exponential moving average of the student’s:
teacher weights θ_t ← m · θ_t + (1 − m) · θ_s m = 0.996
the half-life is real arithmetic: after k steps the initial teacher
contributes m^k of its weight, and m^173 = 0.996^173 ≈ 0.5.
So the teacher is always a smoothed 173-steps-ago version of the
student — close enough to give a stable target, old enough to
give a better one.
Each image produces two augmented views. View 1 goes to the student, view 2 to the teacher, and the student’s output is trained to match the teacher’s — then the roles swap. There is no negative anywhere: the teacher is the target. But a target that is an average of the model itself invites the laziest solution, which is to output the same vector for everything. DINO’s pretraining loss without guards is minimised by collapse.
Two guards prevent it, and both act on the teacher’s output. Centring keeps a running mean of the teacher’s output per dimension and subtracts it: if one dimension starts to win every image, its mean grows and the subtraction cancels its advantage. Sharpening divides the teacher logits by a small temperature (0.04 early on, then 0.07), which makes the teacher’s distribution peaked. Centring alone would make the output uniform; sharpening alone would let one dimension dominate. Together they produce a distribution that is flat across dimensions, peaked per image — exactly what you want the student to match.
The DINO head in miniature — centring and sharpening visible in codepython
class DinoHead(torch.nn.Module):
"""Toy head from the lesson source; real DINO uses a deeper MLP."""def __init__(self, in_dim=64, out_dim=128, momentum=0.9):
super().__init__()
self.proj = torch.nn.Linear(in_dim, out_dim)
self.register_buffer("centre", torch.zeros(out_dim))
self.momentum = momentum
def student(self, x, temp=0.1):
return F.log_softmax(self.proj(x) / temp, dim=-1)
def teacher(self, x, temp=0.04):
out = self.proj(x)
# subtract the running centre, then sharpen with a low temperaturereturn F.softmax((out - self.centre) / temp, dim=-1).detach()
@torch.no_grad()
def update_centre(self, teacher_out): # momentum 0.9
self.centre.mul_(self.momentum).add_(teacher_out.mean(dim=0), alpha=1 - self.momentum)
Teacher outputs are detached: no gradient flows into the teacher — only the EMA update. The centre buffer is not a parameter; it is a statistic of the teacher's own output, so it adapts as the student moves.
DINO’s two collapse guards
DINO has no negatives. Two cheap tricks keep the teacher honest: centring subtracts the per-dimension running mean, and sharpening divides by a small teacher temperature. Turn each one off and watch the output collapse — one dimension dominating, or everything going flat.
Largest dim mean = how much probability mass the busiest output dimension takes across all images (collapse is above ~0.15); mean entropy = how peaked each image’s distribution is (uniform collapse is above ~0.93 of 1.000).
HEALTHY
healthy — peaked per image (entropy 0.078) and spread across dimensions (top dim mean 11.0%)
teacher weights w_t ← m·w_t + (1 − m)·w_s, m = 0.996
half-life 172.9 steps — after that, half the old teacher is gone
student log_softmax(proj(view 1) / 0.1) ← sharp student
teacher softmax((proj(view 2) − centre) / 0.04) ← centred + sharpened
centre c ← 0.9·c + (1 − 0.9)·mean(teacher output over the batch)
loss cross-entropy(student, teacher) on both directions; no negatives anywhere
top dimensions by mean mass: dim 5 → 11.0% · dim 2 → 9.4% · dim 1 → 8.3%
guards
centring ON — subtract the per-dim EMA mean
sharpening ON — teacher ÷ 0.04
momentum 0.996 → centre/weight half-life 172.9 steps
max dim mean 0.110 (uniform = 0.063)
entropy 0.078 of 1.000
status healthy
DINO pretrained on ImageNet-1k with no labels, then a linear
probe on frozen features reached ~77% top-1 — within a
couple of points of the same architecture trained fully
supervised. DINOv2 then scaled the recipe to 142M images.
The numbers come from a deterministic toy head, not a trained network: 64 fixed feature vectors through a random projection. What transfers is the failure mode — the same collapse DINO’s centring and sharpening are designed to prevent.
Quick check
What stops DINO from collapsing to a constant output?
05
MASKED RECONSTRUCTION
Hide 75% of the picture. Draw the rest from memory.
MAE takes a ViT, masks 75% of the patches at random, shows the encoder only the visible quarter, and trains a small decoder to reconstruct the missing pixels. Then it throws the decoder away — the encoder is the product.
The arithmetic first, because it explains the speed. A 224×224 image at 16px patches is 196 patches; mask 75% and 147 patches are hidden, 49 stay visible. The encoder — the big ViT — only ever sees those 49 plus the [CLS] token: 50 tokens instead of 197, so its attention and MLP cost is roughly a quarter of the full image. The 147 mask tokens are inserted only in the decoder, which is deliberately small: 8 transformer blocks, 512 dimensions wide, versus the encoder’s 12 blocks at 768. The loss is mean squared error on the masked patches only — 147 × 16 × 16 × 3 = 112,896 pixel values scored per image.
That asymmetry is the key design choice: the decoder never sees the task that matters; it exists so the encoder can be cheap. Because the encoder skips the masked tokens entirely, MAE pretrains about 3× faster than BEiT, which pushes all 197 tokens through the encoder. After pretraining, the decoder is discarded. The encoder is the feature extractor that gets fine-tuned or probed.
And the ratio? MAE’s paper sweeps 50%, 75% and 90% and finds 75% the sweet spot for both fine-tuning and linear probing. That number is not arbitrary. Natural language has high entropy per token: BERT can mask 15% of words and each blank still has many plausible completions, so the model must understand the sentence. Image patches have low entropy: neighbouring pixels are so correlated that a masked 16×16 patch is almost determined by the patches around it. Mask 15% of an image and a blur reproduces it — no semantics required. You have to mask aggressively until local extrapolation fails, and the encoder is forced to represent the object rather than the texture.
MAE masking — the whole pretext task is one functionpython
def random_mask_indices(num_patches, mask_ratio=0.75, seed=0):
"""Return the visible and masked patch indices for one image."""
g = torch.Generator().manual_seed(seed)
n_keep = int(num_patches * (1 - mask_ratio)) # 196 * 0.25 = 49
perm = torch.randperm(num_patches, generator=g)
visible = perm[:n_keep]
masked = perm[n_keep:]
return visible.sort().values, masked.sort().values
visible, masked = random_mask_indices(196, mask_ratio=0.75)
print(f"visible: {len(visible)} / 196") # 49
print(f"masked: {len(masked)} / 196") # 147# the encoder sees the visible patches only:# shuffle the 49 visible patches (MAE adds no mask tokens to the encoder),# prepend [CLS] -> 50 tokens, run the big ViT.# the decoder gets:# 50 encoder tokens + 147 learned mask tokens, un-shuffled to 196 positions,# then a small 8-block / 512-dim transformer predicts the pixels.# loss = MSE(reconstructed, original) on the 147 masked patches only.
The source keeps the visible patches in their original order for a readable demo; MAE actually shuffles them so the encoder cannot cheat by knowing where in the grid it is looking — position comes back at the decoder.
Mask ratio: difficulty vs information
Slide the MAE mask ratio from 15% to 90%. The left grid is all the encoder ever sees; the right grid fills the blanks with a local baseline (average the nearest visible patches) and reports its real mean error. The curve is why 75% is not arbitrary.
presets
patches 196 at 16×16 on 224² (14 × 14)
visible 49 patches → 50 tokens with [CLS]
masked 147 patches → the decoder's problem
loss MSE on the masked 147 patches only
error local baseline mean |ΔRGB| = 33.1/255
guessability 0.00 (a labelled teaching model)
information 0.25 of the pixels visible
at 15% 167 visible, 29 blanks — a blur fills them in,
and the encoder learns almost nothing semantic.
at 75% 49 visible, 147 blanks — local extrapolation fails,
so the encoder must represent the object.
at 90% 20 visible, 176 blanks — underdetermined: even a
semantic model cannot recover the missing patches.
BERT masks 15% of text tokens because a masked word still has many plausible completions. Image patches do not: a 16×16 patch is almost predicted by its neighbours, so the ratio has to go up until the task stops being a blur.
Quick check
MAE masks 75% of patches; BERT masks 15% of tokens. Why the difference?
06
AUGMENTATION IS THE SIGNAL
The augmentations decide what the model refuses to see.
A positive pair is only useful if the two views differ in a way the encoder can learn to ignore. Random crop says “where the object sits does not matter”; colour jitter says “its colours do not matter”. Choose them carelessly and you teach the model to ignore your labels.
Everything self-supervised is defined by its augmentations, because they are the only source of variation in the data. A contrastive pair is the same image under two random draws of the augmentation policy, so the policy is a statement about which variations should map to the same feature. SimCLR’s pipeline is four transformations, and each one buys a specific invariance:
RandomResizedCrop(96, scale=(0.2, 1.0)) crop 20–100% of the area,
resized back to 96×96
→ position + scale invariance: the object can be anywhere,
any size, and must still embed the same way
RandomHorizontalFlip() mirror with p = 0.5
→ left/right invariance
ColorJitter(0.4, 0.4, 0.4, 0.1) brightness, contrast,
saturation ±0.4, hue ±0.1
→ appearance invariance: lighting, camera, white balance
RandomGrayscale(p=0.2) drop colour one time in five
→ forces shape and texture to carry the signal
The pair is then declared positive by fiat: same image, two draws,
no label consulted. Everything the encoder learns about similarity
comes from this one assumption.
Strength is a dial with two failure modes. Turn it down and the positive pair is nearly identical, so the model can win the exam with a trivial solution and the features barely change. Turn it up and the two views share so little that the positive is indistinguishable from a negative — the task becomes noise, or worse, the encoder learns to be invariant to exactly the thing your downstream task needs. That second failure is the one that costs real projects.
The two-view pipeline — the source's Step 1, unchangedpython
import torch
import torchvision.transforms as T
two_view_train = lambda: T.Compose([
T.RandomResizedCrop(96, scale=(0.2, 1.0)),
T.RandomHorizontalFlip(),
T.ColorJitter(0.4, 0.4, 0.4, 0.1),
T.RandomGrayscale(p=0.2),
T.ToTensor(),
])
class TwoViewDataset(torch.utils.data.Dataset):
def __init__(self, base):
self.base = base
self.aug = two_view_train()
def __len__(self):
return len(self.base)
def __getitem__(self, i):
img, _ = self.base[i] # the label is read and discarded
v1 = self.aug(img) # draw 1
v2 = self.aug(img) # draw 2 — independentreturn v1, v2
# Every __getitem__ returns two augmented views of one image.# The label in base[i] is never used: that is the whole point.
Each __getitem__ call draws both views independently, so across epochs a given image appears under different crops and colours. Real SimCLR uses 224² crops and a projector MLP; the source uses 96² to keep the demo runnable on a laptop.
Augmentations are the signal
Two views of the same image are only useful if they differ in a way the encoder can learn to ignore — and only if they still share what matters. Toggle each augmentation and watch the two views rewrite themselves.
augmentations
view 1 crop 61% at (0.28, 0.37) · grayscale
brightness ×0.72 · contrast ×0.97 · saturation ×0.95
view 2 crop 39% at (0.55, 0.49) · grayscale
brightness ×0.93 · contrast ×0.93 · saturation ×1.14
crop IoU 0.327 — how much of the image both views still see
colour mean |ΔRGB| 16.4/255 between the two views
the pair is positive because it is the same image with two
independently sampled augmentations — no label was consulted.
The crop window is sampled from 20–100% of the area, so the
two views can miss each other almost completely (IoU near 0)
while still being declared the same picture.
The source’s pipeline is exactly this: RandomResizedCrop(96, scale=(0.2, 1.0)), RandomHorizontalFlip, ColorJitter(0.4, 0.4, 0.4, 0.1), RandomGrayscale(p=0.2). The strength is a design choice — too weak and the task is trivial, too strong and you train a model that ignores your labels.
07
PUT FROZEN FEATURES TO WORK
Freeze the encoder. Spend labels on one small head.
The standard evaluation of a self-supervised model is a linear probe: freeze the encoder, train one linear classifier on top, report top-1. It isolates feature quality from fine-tuning dynamics — and it is also the cheapest thing you can run on your own data.
The recipe is three lines: run images through the frozen encoder, store the feature vectors (a DINOv2-B CLS token is 768 numbers), and fit Linear(768 → num_classes) on your labels. No backbone gradients, no augmentations needed, minutes on a CPU for a small set. The published anchors show what that buys, all on ImageNet-1k:
frozen encoder, one linear head top-1
SimCLR ResNet-50 (2020) ≈71
DINO ViT-S/16 (2021) ≈77
MAE ViT-L/16 (2022) ≈76
DINOv2 ViT-g/14 (2023) ≈86
same features, full fine-tune instead of a probe:
MAE ViT-B/16 linear 68.0 → fine-tuned 83.6
MAE ViT-L/16 linear ≈76 → fine-tuned 85.9
DINOv2 was pretrained on 142M curated unlabelled images; the
~1.1B-parameter ViT-g/14 reaches ≈86 with a *frozen* encoder —
within a couple of points of fully supervised models.
The 15.6-point gap between MAE ViT-B’s probe (68.0) and its fine-tune (83.6) is the honest caveat of the whole field: frozen features are good, but a linear classifier can only use the directions already present in the feature space. Fine-tuning reshapes the features for the task, which is why it wins when labels are plentiful — and why a probe can win when they are scarce and the fine-tune starts overfitting. The board below walks that crossover across label budgets.
Two more things frozen features do with no training at all. Retrieval: embed every image in a gallery, embed the query, rank by cosine similarity — that is image search, duplicate detection and “more like this” without a single label. k-NN classification: embed the labelled set, embed the test image, vote among its nearest neighbours; within a few points of a linear probe and zero training. Frozen DINOv2 features also feed depth, segmentation and correspondence heads (they are the backbone in 2020s dense-prediction stacks), because the patch tokens carry dense spatial information, not just the CLS summary.
Use It — a DINOv2 checkpoint as a feature extractorpython
import torch
from transformers import AutoImageProcessor, AutoModel
processor = AutoImageProcessor.from_pretrained("facebook/dinov2-base")
model = AutoModel.from_pretrained("facebook/dinov2-base")
model.eval()
with torch.no_grad():
inputs = processor(images=[pil_image], return_tensors="pt")
outputs = model(**inputs)
embedding = outputs.last_hidden_state[:, 0] # CLS token, 768 dims# zero-shot retrieval: cosine similarity against a gallery of embeddings# linear probe: LogisticRegression().fit(gallery_embeddings, labels)# dense tasks: outputs.last_hidden_state[:, 1:] are the patch tokens# MAE-style fine-tuning instead? The timm repo ships every MAE checkpoint:# model = timm.create_model("vit_base_patch16_224.mae", pretrained=True)
The image processor handles resize/normalise to the resolution and statistics the checkpoint was trained with. Getting that contract wrong is the most common way a good frozen model produces bad features (Phase 4, Lesson 01).
Frozen features vs fine-tuning, per label budget
Self-supervised pretraining gives you a frozen encoder. A linear probe on top measures the features; fine-tuning the whole encoder is what you ship when you have labels to spend. Move the budget and watch the two lines trade places.
ILLUSTRATIVE CURVES · CALIBRATED TO MAE ViT-B/16’S PUBLISHED ANCHORS
linear probe
full fine-tune
random features
Bars span 40–90% top-1 so the differences stay readable. The random baseline is the 26% you get from a frozen encoder with no training at all — the floor every other row has to beat.
labels 10,000
linear probe 49.4% (frozen encoder + one Linear layer)
fine-tune 44.0% (every weight moves, lr 10–100× smaller)
gap -5.4 points ← probe ahead at this budget
illustrative crossover ~19,000 labels
annotation at ~$10M for 1.28M labels ≈ $10M total, $7.81/label
your budget's labels ≈ $78k of annotation
unlabelled images used for pretraining 142M (DINOv2) at $0 of labelling
· start with the frozen encoder and a linear probe: it is the cheapest run, it trains in minutes, and it tells you whether the pretrained features already separate your classes.
· the illustrative curves put the probe ahead of full fine-tuning at this budget (49.4 vs 44.0) — with few labels, fine-tuning's millions of free parameters overfit before they help. That low-shot crossover is a published pattern, not a law; measure it.
· if the probe is close to the accuracy you need, spend your money on more *unlabelled* data instead of more labels.
labelled budgetrows shown
budget a small labelled set you could actually collect
published linear probes (frozen encoder, ImageNet-1k):
71% SimCLR ResNet-50 (contrastive, 2020)
77% DINO ViT-S/16 (teacher–student, 2021)
76% MAE ViT-L/16 (masked reconstruction, 2022)
86% DINOv2 ViT-g/14 (teacher–student (scaled), 2023)
what frozen features are good for, with zero extra labels:
· image retrieval — embed a gallery, rank by cosine similarity
· k-NN classification — vote among nearest neighbours
· dense tasks — depth, segmentation and correspondence heads
· bounding boxes and masks from a frozen backbone
The probe protocol: freeze the encoder, train only
Linear(d_features → num_classes) on the labels, report top-1.
The curves are a teaching model — shape and crossover follow the SSL literature, endpoints are pinned to MAE ViT-B/16’s 68.0 / 83.6 at 1.28M labels. Your own crossover will move with dataset size and similarity to the pretraining data.
08
CHECK YOURSELF
Five questions. Then the terms worth keeping.
The batch-size question and the collapse question are the two that separate “I have heard of SimCLR” from “I could debug a contrastive run”. The 75%-vs-15% question is the one people get wrong because they memorise the number instead of the reason.
0 / 5 answered · 0 correct
01Why does SimCLR need batch sizes of 512–8192 while supervised ImageNet training works with batch 256?
02What prevents DINO from collapsing to a constant output?
03MAE masks 75% of patches. BERT masks 15% of tokens. Why the difference?
04After self-supervised pretraining, a 'linear probe' evaluation trains what?
05Why does MAE use an asymmetric encoder–decoder design?
Key terms, demystified
Click a card to swap the lazy description for what it actually means.
Exercises from the lesson
Three problems with real numbers: sweep τ and watch the loss respond, break a DINO head by removing the centre buffer, and pretrain MAE on CIFAR-100 and compare linear probes at 10, 50 and 200 epochs. Try first; a worked answer is one click away.
Easy — Verify that the InfoNCE loss drops when you decrease temperature for well-aligned embeddings and rises (away from the constant-encoder floor) when you decrease it for random embeddings. Produce a plot of τ ∈ [0.05, 0.07, 0.1, 0.2, 0.5] versus loss for both cases.Show one worked answer
L2-normalise two sets of 16 paired embeddings (dim 32): one where positives really are close (alignment ≈0.9) and one where the 'positive' is random. Run the lesson's info_nce for each τ and print both losses. With a fixed seed the table looks like this — aligned pair (cos ≈ 0.9), random pair (cos ≈ 0): τ = 0.05 → 0.0001 / 7.669; τ = 0.07 → 0.0013 / 5.903; τ = 0.1 → 0.0163 / 4.750; τ = 0.2 → 0.410 / 3.773; τ = 0.5 → 1.855 / 3.481. Three things to read off it. (1) For aligned pairs the loss falls as τ falls: colder softmax puts more probability on the positive — 0.0001 at τ = 0.05. (2) For random pairs the loss rises as τ falls: the softmax tries to pick one of 31 candidates whose similarities are all near zero but not equal, and a colder softmax amplifies that spread. ln(31) = 3.434 is the floor for a constant encoder — identical embeddings, every candidate equally plausible — while the random encoder already measures 4.750 at τ = 0.1, above it, and climbs further as τ falls. (3) At τ = 0.5 the two curves are far apart but the aligned loss is no longer tiny (1.855), because a warm softmax spreads probability over the negatives. The practical reading: a small τ needs a big batch (many negatives) and a positive that is genuinely closer than the negatives; that is why SimCLR uses τ = 0.1 at batch 4096, not batch 32.
Medium — Implement a DINO-style centre buffer and show that without it the student collapses to a constant vector within a few epochs.Show one worked answer
Build a toy head: a linear projection from 64-dim features to 16 or 128 outputs, a centre buffer initialised to zeros, a student log_softmax at τ = 0.1 and a teacher softmax at τ = 0.04 over (logits − centre); update the centre after every batch with c ← 0.9·c + 0.1·mean(teacher output over the batch), and update the teacher weights with θ_t ← 0.996·θ_t + 0.004·θ_s. Run both directions (student on view 1 vs teacher on view 2, then swap) with a cross-entropy loss. With centring on, the diagnostic numbers the lesson's collapse lab computes are: largest per-dimension mean of the teacher's output ≈0.11 (uniform is 1/16 = 0.0625) and mean normalised entropy ≈0.08 — distribution flat across dimensions, peaked per image. Turn centring off and the same head gives largest dim mean ≈0.63: one dimension absorbs most of the probability mass on every image, and the student's best strategy is to predict that dimension blindly — the loss falls fast while the representation dies. Watch two numbers per epoch: the max per-dimension mean should stay under ~0.15 and per-sample entropy should stay low; if the max dim mean climbs past ~0.2 or entropy goes to ~1.0, you are collapsing and no amount of training will fix it.
Hard — Train MAE on CIFAR-100 with the TinyUNet from Phase 4, Lesson 10 as the backbone. Report linear-probe accuracy at 10, 50 and 200 pretraining epochs, and show that a MAE-pretrained linear probe beats a from-scratch supervised probe on the same 1,000-image subset.Show one worked answer
Do the shapes before touching code. CIFAR-100 is 32×32 pixels; with 4×4 patches that is 8×8 = 64 patches. Mask 75%: 48 masked, 16 visible, so the encoder sees 16 + 1 [CLS] = 17 tokens instead of 65, and the MSE scores 48 × 4 × 4 × 3 = 2,304 pixel values per image — 49× fewer than ViT-B's 112,896 on 224², which is why this fits on one GPU. Pretrain with AdamW, a small learning rate and random resized crops, then throw the reconstruction head away. For the evaluation, freeze the encoder and fit a single linear layer on top of the pooled features using three labelled subsets: 1,000, 10,000 and 50,000 images of CIFAR-100. Report three numbers per pretraining checkpoint (10/50/200 epochs) and compare against two baselines: (a) a linear probe on the same frozen randomly-initialised backbone and (b) a small CNN trained from scratch supervised on the same 1,000 images. The expected pattern from the MAE paper, at miniature scale: the random-features probe barely beats chance (you should see roughly 10–20% with good pooling), the from-scratch supervised model fits the 1,000 images and then overfits, and the MAE-pretrained probe improves with pretraining epochs before plateauing — beating the from-scratch probe on 1,000 images and closing on supervised training as labels grow. If your pretrained probe never beats the random-features probe, check three things in order: the mask ratio is actually 75%, the encoder never sees masked patches, and the linear probe uses the frozen features (no gradients into the backbone).
Terms this lesson borrows from later lessons (or outside)
You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.
vision transformer — The backbone all three families pretrain: 196 patches + [CLS], self-attention, the position table. Self-supervision only changes the objective, not the architecture. Phase 4, Lesson 14.
image classification — The downstream task the linear probe measures: cross-entropy over classes, top-1 accuracy, the same evaluation as a supervised ResNet. Phase 4, Lesson 04.
transfer learning & fine-tuning — The ladder this lesson's last chapter walks — frozen probe → last block → full fine-tune — and why fine-tuning needs a learning rate 10–100× smaller than training from scratch. Phase 4, Lesson 05.
data augmentation — RandomResizedCrop, flip and colour jitter, which this lesson repurposes as the *only* source of supervision in contrastive pretraining. In supervised training they are regularisation; here they define the invariances. Phase 4, Lesson 04.
momentum — The 0.996 EMA decay behind DINO's teacher and MoCo's queue; the same idea as momentum in an optimizer, applied to weights and features instead of gradients. Phase 3, Lesson 06.
cosine similarity — The metric inside InfoNCE: dot product of L2-normalised embeddings, in [−1, 1], temperature-scaled into logits. Phase 1, Lesson 14 covers the metric; here it becomes the loss's geometry.
KEEP GOING
A picture is a start. Practice is the rest.
This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.
Lesson text adapted from AI Engineering from Scratch (Phase 04, Lesson 17) and the Math Foundations Notebook reference build. The seven labs — the InfoNCE playground, the three-family comparator, the batch-size board, the DINO collapse board, the masking lab, the augmentation lab and the linear-probe board — are original to this page, as is the annotation arithmetic ($10M ÷ 1,281,167 ≈ $7.81 per ImageNet label), the negative counts (62 / 1,022 / 8,190) with the constant-encoder floor ln(2N − 1) and its derivation, the worked one-anchor losses (0.471 at τ = 0.5; 0.2147 for a hard negative at τ = 0.07), the DINO EMA half-life (0.996¹⁷³ ≈ 0.5), MAE's patch arithmetic (49 visible, 147 masked, 50 encoder tokens, 112,896 scored pixel values) and its CIFAR-100 variant (64 patches → 48 masked, 17 tokens, 2,304 values), the 268 MB similarity matrix at batch 4096 and MoCo's 32 MiB queue of 65,536 features, the augmentation-invariance trap, and the illustrative probe-vs-fine-tune curves calibrated to MAE ViT-B/16's published 68.0 / 83.6 anchors. Every number shown is computed live by the labs or verified by hand in the prose.