DINO and self-distillation
DINO trains a network to copy a slow-moving average of itself, and two small corrections stop that idea from collapsing into a single useless answer.
- 20 min read
- 3 reading levels
- Updated
Read these first
On this page 7
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
DINO trains a network by asking it to agree with a slow-moving average of its own recent self.
Think about learning a song by recording yourself. One take is shaky. Now play back the smoothed average of your last ten takes. It sounds steadier than any single one of them.
Sing along with that average and you improve. Your better singing then goes into the average, which gets better again.
That is DINO. There is a student, which is the network being trained. There is also a teacher, a running average of the student's recent weights. The student is trained to match the teacher.
Why this sounds impossible
Nothing outside is supplying an answer. The network is copying itself. It seems like it should learn nothing at all, or lock onto one answer and stop.
Copying yourself really can fail that way, and it does. The failure is called collapse. The network gives every picture the identical answer, matches its teacher perfectly, and learns nothing.
Two small corrections prevent it, and they pull in opposite directions.
The two corrections
Spreading. The teacher keeps track of its own average answer and subtracts it. If it has been leaning towards one option, that option is pushed back down. No single answer can win by default.
Think of an examiner who notices they have given everyone a B. They force themselves to use the whole range.
Committing. The teacher is also made more decisive. A near-tie is turned into a confident choice instead of a shrug.
Think of the same examiner refusing to award half marks. Every student gets a definite grade.
Spreading alone gives you an examiner who awards every grade equally at random. Committing alone gives you one who awards an A to everybody. Together they give you real grading.
spreading only -> every answer used, none of them meaningful
committing only -> one answer used, confidently, for everything
both together -> a small number of answers, used consistentlyRead that twice. It is the heart of the method, and the running code further down shows all four outcomes.
What it discovers on its own
The striking finding was what these networks paid attention to. Nobody drew any outlines for them. Yet their internal attention traces the object in the picture, cleanly separating a dog from a lawn.
Object outlines came for free, from a network that was only trying to agree with itself.
In the toy experiment below, the same thing happens in miniature. It is given eight slots to use, and three kinds of picture. It uses exactly three, and every picture of a kind lands in the same slot. No labels were involved at any point.
Where you have already seen this
- Search by image, where you upload a photo and get similar ones.
- Medical and satellite imaging models built where labels are scarce and expensive.
- DINOv2 and DINOv3 are the general-purpose vision models many teams now start from instead of training their own.
The honest part
The two corrections have to be balanced, and the balance is delicate. Push the committing dial slightly too far and the whole thing collapses. The last row of the measured table below shows it.
There is also no complete theory explaining why this works. There are good arguments and strong measurements, and there is no proof. That is a fair description of a lot of this field.
Remember this
- The teacher is a slow average of the student, and the student learns to match the teacher.
- Spreading and committing pull in opposite directions and are both needed.
- Object outlines emerge without anyone ever drawing one.
What to learn next
- Choosing a backbone — turning all of this into one decision.
- Knowledge distillation — the teacher-student idea in its usual form.
- Contrastive learning for images — the other route to label-free features.
Developer — Code and libraries.
Setup
pip install "torch==2.5.1"The first script runs against PyTorch 2.5.1 on CPU and takes about a minute. The second needs transformers and downloads about 90 MB of weights.
Build the loop and break it on purpose
import copy
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
K = 8 # prototypes: slots the head can point at
TAU_S = 0.1 # student temperature
EMA, CENTER_M = 0.99, 0.9 # teacher momentum, centre momentum
def make_batch(n, g):
"""Three textures, and the labels are only ever used to score the result."""
y = torch.randint(0, 3, (n,), generator=g)
phase = torch.randint(0, 4, (n, 2), generator=g)
rows = torch.arange(16).view(16, 1).expand(16, 16)
cols = torch.arange(16).view(1, 16).expand(16, 16)
x = torch.zeros(n, 1, 16, 16)
for i, k in enumerate(y.tolist()):
a, b = phase[i].tolist()
if k == 0:
x[i, 0] = (((rows + a) // 2) % 2).float()
elif k == 1:
x[i, 0] = (((cols + b) // 2) % 2).float()
else:
x[i, 0] = ((((rows + a) // 2) + ((cols + b) // 2)) % 2).float()
return x, y
def augment(x, g):
n = x.shape[0]
out = torch.empty_like(x)
shifts = torch.randint(0, 16, (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.6 + 0.6 * torch.rand(n, 1, 1, 1, generator=g))
return out + 0.1 * torch.randn(out.shape, generator=g)
class Net(nn.Module):
"""Small convolutional trunk, then cosine similarity against K prototypes."""
def __init__(self):
super().__init__()
self.trunk = nn.Sequential(
nn.Conv2d(1, 16, 3, 2, 1), nn.ReLU(),
nn.Conv2d(16, 32, 3, 2, 1), nn.ReLU(),
nn.AdaptiveAvgPool2d(1), nn.Flatten(),
nn.Linear(32, 64), nn.GELU(), nn.Linear(64, 32))
self.prototypes = nn.Parameter(torch.randn(K, 32))
def forward(self, x):
z = F.normalize(self.trunk(x), dim=1)
return z @ F.normalize(self.prototypes, dim=1).t()
def run(use_centering, tau_t, steps=600):
torch.manual_seed(0)
g = torch.Generator().manual_seed(0)
student = Net()
teacher = copy.deepcopy(student)
for p in teacher.parameters():
p.requires_grad_(False)
centre = torch.zeros(K)
opt = torch.optim.Adam(student.parameters(), lr=3e-3)
for _ in range(steps):
x, _ = make_batch(64, g)
v1, v2 = augment(x, g), augment(x, g)
with torch.no_grad():
t1, t2 = teacher(v1), teacher(v2)
c = centre if use_centering else torch.zeros(K)
p1 = F.softmax((t1 - c) / tau_t, dim=1) # centre, then sharpen
p2 = F.softmax((t2 - c) / tau_t, dim=1)
s1, s2 = student(v1), student(v2)
loss = -0.5 * ((p2 * F.log_softmax(s1 / TAU_S, 1)).sum(1).mean()
+ (p1 * F.log_softmax(s2 / TAU_S, 1)).sum(1).mean())
opt.zero_grad()
loss.backward()
opt.step()
with torch.no_grad():
centre = CENTER_M * centre + (1 - CENTER_M) * torch.cat([t1, t2]).mean(0)
for tp, sp in zip(teacher.parameters(), student.parameters()):
tp.mul_(EMA).add_(sp.detach(), alpha=1 - EMA) # teacher trails the student
x, y = make_batch(600, torch.Generator().manual_seed(123))
with torch.no_grad():
p = F.softmax((teacher(x) - (centre if use_centering else 0)) / tau_t, dim=1)
avg = p.mean(0)
spread = -(avg * (avg + 1e-12).log()).sum().item()
per_image = -(p * (p + 1e-12).log()).sum(1).mean().item()
used = int((avg > 0.01).sum())
assign = p.argmax(1)
purity = sum((y[assign == k].mode().values == y[assign == k]).float().sum().item()
for k in range(K) if (assign == k).any()) / len(y)
return spread, per_image, used, purity
print(f"spread entropy: {math.log(K):.3f} means all {K} prototypes shared equally, "
f"{math.log(3):.3f} means 3 of them, 0 means total collapse")
print(f"{'setting':34s} {'spread':>7s} {'per-image':>10s} {'used':>5s} {'purity':>7s}")
for centering, tau_t, name in [
(False, 0.02, "sharpening only, no centring"),
(True, 0.40, "centring, almost no sharpening"),
(True, 0.02, "centring + sharpening"),
(True, 0.005, "centring + far too much sharpening")]:
spread, per_image, used, purity = run(centering, tau_t)
print(f"{name:34s} {spread:7.3f} {per_image:10.3f} {used:5d} {purity:7.3f}")spread entropy: 2.079 means all 8 prototypes shared equally, 1.099 means 3 of them, 0 means total collapse setting spread per-image used purity sharpening only, no centring 0.000 0.000 1 0.390 centring, almost no sharpening 2.079 2.079 8 0.717 centring + sharpening 1.087 0.000 3 1.000 centring + far too much sharpening 0.001 0.001 1 0.390
The exact numbers depend on PyTorch's random initialisation and will shift slightly on another version. The pattern across the four rows will not.
Reading the table, row by row
Row 1, no centring: complete collapse. One prototype used out of eight, spread entropy exactly 0.000. Every image gets the same answer. Purity of 0.390 is what you get by putting everything in one bucket, since the largest true class holds about that share.
Row 2, centring without sharpening: the opposite failure. All eight prototypes used, and per-image entropy is also 2.079, meaning every image gets a flat, undecided distribution. The examiner is spreading marks evenly and committing to nothing. Purity of 0.717 comes from noise in a near-tie.
Row 3, both: it works, and the numbers are worth staring at. Spread entropy is 1.087, and $\ln 3 = 1.099$. The method chose to use three prototypes out of eight, without being told there are three classes. Per-image entropy is 0.000, so every assignment is confident. Purity is 1.000: every image of a kind landed in the same prototype, with no labels used in training at all.
Row 4, too much sharpening: collapse returns. Dropping the teacher temperature from 0.02 to 0.005 destroys everything. The two corrections are in tension, and sharpening can overwhelm centring. This is why the real method warms the teacher temperature up over the first epochs rather than starting at its final value.
The teacher update is three lines and easy to get wrong. tp.mul_(EMA).add_(sp.detach(), alpha=1-EMA) runs under no_grad, and the teacher's parameters have requires_grad_(False). If gradients flow into the teacher, the loss has a trivial solution and you collapse immediately.
Now look at what the real thing produces
DINOv2 is the same idea, trained by Meta on a very large curated image collection. This script downloads about 90 MB and runs on CPU.
import torch
import torch.nn.functional as F
from transformers import AutoImageProcessor, AutoModel, AutoConfig
torch.manual_seed(0)
def shape_image(kind, dx=0, dy=0, bright=1.0):
"""A 224x224 RGB picture holding one plain shape on a grey ground."""
img = torch.full((3, 224, 224), 0.45)
ys = torch.arange(224).view(224, 1) - 112 - dy
xs = torch.arange(224).view(1, 224) - 112 - dx
if kind == "disc":
mask = (ys ** 2 + xs ** 2) < 70 ** 2
colour = torch.tensor([0.85, 0.15, 0.15])
elif kind == "square":
mask = (ys.abs() < 60) & (xs.abs() < 60)
colour = torch.tensor([0.15, 0.30, 0.85])
else: # a cross
mask = ((ys.abs() < 20) & (xs.abs() < 75)) | ((ys.abs() < 75) & (xs.abs() < 20))
colour = torch.tensor([0.15, 0.65, 0.25])
img = torch.where(mask.unsqueeze(0), colour.view(3, 1, 1), img)
return (img * bright).clamp(0, 1)
kinds = ["disc", "square", "cross"]
views, labels = [], []
for k in kinds:
views.append(shape_image(k, 0, 0, 1.0))
labels.append(k + " A")
views.append(shape_image(k, 45, -35, 0.75)) # moved and dimmed
labels.append(k + " B")
batch = torch.stack(views)
name = "facebook/dinov2-small"
processor = AutoImageProcessor.from_pretrained(name)
mean = torch.tensor(processor.image_mean).view(1, 3, 1, 1)
std = torch.tensor(processor.image_std).view(1, 3, 1, 1)
pixels = (batch - mean) / std
pretrained = AutoModel.from_pretrained(name).eval()
random_init = AutoModel.from_config(AutoConfig.from_pretrained(name)).eval()
print("model:", name)
print("parameters:", f"{sum(p.numel() for p in pretrained.parameters()):,}")
with torch.no_grad():
out = pretrained(pixel_values=pixels)
print("last_hidden_state:", tuple(out.last_hidden_state.shape),
"= 1 CLS token + 256 patch tokens of 384 numbers")
def sim_matrix(model):
with torch.no_grad():
cls = model(pixel_values=pixels).last_hidden_state[:, 0]
z = F.normalize(cls, dim=1)
return z @ z.t()
for title, model in [("pretrained DINOv2", pretrained), ("same network, random weights", random_init)]:
s = sim_matrix(model)
print(f"\ncosine similarity between whole-image embeddings, {title}")
print(" " + "".join(f"{l:>10s}" for l in labels))
for i, l in enumerate(labels):
print(f"{l:>12s}" + "".join(f"{s[i, j].item():10.3f}" for j in range(6)))
same = torch.tensor([s[0, 1], s[2, 3], s[4, 5]]).mean()
idx = [(0, 2), (0, 4), (2, 4), (1, 3), (1, 5), (3, 5)]
diff = torch.tensor([s[i, j] for i, j in idx]).mean()
print(f" two views of the same shape : {same.item():.3f}")
print(f" two different shapes : {diff.item():.3f}")
print(f" gap : {same.item() - diff.item():+.3f}")model: facebook/dinov2-small
parameters: 22,056,576
last_hidden_state: (6, 257, 384) = 1 CLS token + 256 patch tokens of 384 numbers
cosine similarity between whole-image embeddings, pretrained DINOv2
disc A disc B square A square B cross A cross B
disc A 1.000 0.977 0.760 0.719 0.684 0.696
disc B 0.977 1.000 0.775 0.744 0.698 0.717
square A 0.760 0.775 1.000 0.973 0.804 0.835
square B 0.719 0.744 0.973 1.000 0.769 0.821
cross A 0.684 0.698 0.804 0.769 1.000 0.977
cross B 0.696 0.717 0.835 0.821 0.977 1.000
two views of the same shape : 0.976
two different shapes : 0.755
gap : +0.221
cosine similarity between whole-image embeddings, same network, random weights
disc A disc B square A square B cross A cross B
disc A 1.000 0.306 0.713 0.349 0.600 0.202
disc B 0.306 1.000 0.238 0.847 0.225 0.875
square A 0.713 0.238 1.000 0.550 0.873 0.376
square B 0.349 0.847 0.550 1.000 0.519 0.951
cross A 0.600 0.225 0.873 0.519 1.000 0.450
cross B 0.202 0.875 0.376 0.951 0.450 1.000
two views of the same shape : 0.435
two different shapes : 0.810
gap : -0.374Reading the similarity matrices
The pretrained gap is positive and the random gap is negative. That sign flip is the entire result. Pretrained features group by what the shape is. Random features group by something else.
Look at what the random network grouped instead. In its matrix, disc B, square B and cross B are highly similar to each other, at 0.847, 0.875 and 0.951. Each is dissimilar to its own A view. The B views share a shift and a brightness reduction. An untrained network keys on brightness and position, which are exactly the nuisances the augmentations were designed to make the model ignore.
Pretraining did not add information about these shapes. DINOv2 never saw a synthetic disc during training. What it acquired was a general habit: describe content, ignore brightness and position. That habit transfers to shapes it has never met, which is what makes a pretrained backbone worth starting from.
257 tokens, not 1. One CLS token summarising the image, plus 256 patch tokens, one per 16x16 patch of a 224x224 input. Use the CLS token for classification and retrieval; use the patch tokens for segmentation and detection.
Common mistakes
Letting gradients into the teacher. Set requires_grad_(False) on the teacher and do the update under torch.no_grad(). Without both, the loss collapses on the first few steps.
Starting the teacher temperature at its final value. DINO warms it from about 0.04 upward over the first epochs. Row 4 of the table shows what excessive sharpening does.
Using the same augmentation for both views. They must be drawn independently, as in contrastive learning.
Skipping the processor's normalisation. DINOv2 expects ImageNet mean and standard deviation. Feeding raw 0-to-1 pixels runs without error and degrades every similarity in the matrix.
Assuming DINOv3 will download like DINOv2. The DINOv3 checkpoints on the Hugging Face Hub are gated: you must accept the licence terms and be logged in. The code shape is the same, and the model identifiers look like facebook/dinov3-vits16-pretrain-lvd1689m.
Try it yourself
In the toy script, set K = 3, so the number of slots matches the number of classes. Check that the assignment stays clean. Then set K = 64 and see how many slots it actually uses. In the DINOv2 script, add a fourth shape and confirm that the block structure of the matrix grows with it.
What to learn next
- Choosing a backbone — turning all of this into one decision.
- Knowledge distillation — the teacher-student idea in its usual form.
- Contrastive learning for images — the other route to label-free features.
Researcher — Mathematics and papers.
The objective
Caron, Touvron, Misra, Jégou, Mairal, Bojanowski and Joulin (2021), Emerging Properties in Self-Supervised Vision Transformers, ICCV, arxiv.org/abs/2104.14294.
A student network $g_{\theta_s}$ and a teacher $g_{\theta_t}$ share an architecture. Both produce a distribution over $K$ prototypes:
$$ P_s(x)^{(i)} = \frac{\exp\bigl(g_{\theta_s}(x)^{(i)}/\tau_s\bigr)}{\sum_{k=1}^{K}\exp\bigl(g_{\theta_s}(x)^{(k)}/\tau_s\bigr)} $$
with the teacher's version using $\tau_t < \tau_s$ and a centred input, $g_{\theta_t}(x) - c$. The loss is cross-entropy over pairs of views, minimised over $\theta_s$ only:
$$ \min_{\theta_s} \sum_{x' \in {x_1^g, x_2^g}} \; \sum_{x \in V,\; x \neq x'} H\bigl(P_t(x'),\, P_s(x)\bigr) $$
$V$ is the set of crops: two global crops covering more than 50% of the image, plus several local crops covering less than 50%. Global crops go through the teacher only; all crops go through the student. This asymmetry, called multi-crop and taken from SwAV, is what forces local-to-global correspondence.
The teacher is an exponential moving average of the student, $\theta_t \leftarrow \lambda \theta_t + (1-\lambda)\theta_s$, with $\lambda$ following a cosine schedule from 0.996 to 1.
The centre updates as $c \leftarrow m c + (1-m)\frac{1}{B}\sum_{i} g_{\theta_t}(x_i)$.
The head is an MLP followed by $\ell_2$ normalisation and a weight-normalised linear layer to $K = 65536$ prototypes, so the logits are cosine similarities. The developer block's prototypes parameter with explicit normalisation is the same construction in miniature.
Reported: 78.3% top-1 under k-NN evaluation with a small ViT, and 80.1% under linear evaluation with ViT-Base.
Why centring and sharpening are both required
The paper's own analysis is the cleanest available account. Write the teacher's output entropy in two ways:
- Centring prevents any one prototype from dominating, since a growing logit is subtracted away by the running mean. On its own it pushes the output towards the uniform distribution.
- Sharpening with a low $\tau_t$ pushes the output towards a one-hot distribution. On its own it permits collapse onto a single prototype.
Their opposing effects mean each avoids the other's failure mode, and the paper shows the collapse behaviour by ablating one at a time and plotting the entropy of the mean output alongside the mean per-sample entropy. The table in the developer block is a direct miniature of that experiment, including the recovery of collapse at very low $\tau_t$.
Note what centring is not: it is a first-order statistic only, a running mean over the batch. It does not enforce equipartition the way SwAV's Sinkhorn-Knopp step does. It is cheaper and weaker, and it works because sharpening keeps it honest.
The emergent property in the title
Attention maps from the last block of a DINO-trained ViT, thresholded, segment the main object without any segmentation supervision. Supervised ViTs and convolutional networks do not show this as cleanly. The paper reports competitive unsupervised video object segmentation from these maps directly.
This is the observation that made the paper influential, more than the accuracy numbers. It is also the observation that carried through to DINOv2 and DINOv3, both of which are valued mainly for their dense per-patch features.
The line since
iBOT (Zhou et al., 2021) adds a masked-image-modelling term to the DINO objective, using the teacher as the target for masked patch tokens. It is the direct bridge between this lesson and masked image modelling.
Oquab et al. (2023), DINOv2: Learning Robust Visual Features without Supervision, arxiv.org/abs/2304.07193, scale the iBOT-plus-DINO objective to a 1-billion-parameter ViT trained on a curated dataset assembled by an automatic retrieval pipeline, then distil smaller models from it. The distilled models outperform OpenCLIP on most image-level and pixel-level benchmarks. The paper's real contribution is arguably the data pipeline: curation by nearest-neighbour retrieval against a set of seed datasets, plus deduplication, rather than any new loss.
Darcet et al. (2024), Vision Transformers Need Registers, identified high-norm artefact tokens in the attention maps of large trained ViTs, including DINOv2, and fixed them with a small number of extra learnable tokens. The Hugging Face DINOv3 configuration exposes num_register_tokens, and the released small model uses 4.
Siméoni et al. (2025), DINOv3, arxiv.org/abs/2508.10104, scale further and address a failure that appears only at long schedules: dense patch features degrade over training even as image-level metrics improve. Their fix, Gram anchoring, computes the Gram matrix of pairwise patch-feature similarities for both student and teacher and adds a loss pushing the student's towards the teacher's. This constrains the structure of the patch features rather than their values, so the model can keep improving globally without losing spatial coherence.
The release includes ViT variants and ConvNeXt variants distilled from the largest model, with Hugging Face identifiers of the form facebook/dinov3-vits16-pretrain-lvd1689m, facebook/dinov3-vit7b16-pretrain-lvd1689m and facebook/dinov3-convnext-tiny-pretrain-lvd1689m. The checkpoints are gated and require accepting the licence, so the developer block above uses DINOv2 instead.
Relationship to knowledge distillation
Ordinary knowledge distillation transfers from a large trained teacher to a small student. Here the teacher is neither larger nor previously trained; it is the student's own past, and its only advantage is temporal averaging.
Polyak averaging of weights is a long-established variance-reduction technique, and the teacher in this scheme is exactly that. The mechanism is that an average over recent iterates is a better model than any single iterate, so following it provides a target slightly ahead of the student. Whether this fully explains why the scheme avoids collapse is not settled. Tian, Chen and Ganguli (2021), Understanding Self-Supervised Learning Dynamics without Contrastive Pairs, give a partial analysis for BYOL-style methods, showing the predictor and the momentum encoder together produce a dynamic that suppresses collapse directions. Nobody has a complete account.
Practical guidance
Unless your images are far from natural photographs, do not train one of these yourself. A distilled DINOv2 or DINOv3 checkpoint, frozen, with a linear head on top, is a strong baseline that costs an afternoon. The value of understanding the training loop is in diagnosing it when you do need to adapt it, and in knowing which released checkpoint to reach for.
What to learn next
- Choosing a backbone — turning all of this into one decision.
- Knowledge distillation — the teacher-student idea in its usual form.
- Contrastive learning for images — the other route to label-free features.