Learning From Data Nobody Labelled
The encoder, the linear probe, and the two baselines that decide everything
Weeks 12 and 13 generated data without labels. The images were the product. This week keeps the label-free training and throws the generated output away, because what is actually wanted is the encoder: a network that turns an input into a vector, trained on data nobody annotated, and then reused for a task where labels are expensive.
This is where the large models get their general ability. Nobody labelled the text a language model is pretrained on. The label was manufactured out of the data itself, by hiding part of it and asking the model to fill it in. The same trick applies to images, and this week builds three versions of it and measures all of them.
The setup
Twelve thousand MNIST images whose labels we will pretend not to have, and a held-out test set. Every experiment this week trains on the same pool and is scored the same way.
from torch import nn
from torch.utils.data import DataLoader, Subset, TensorDataset
from torchvision import datasets, transforms
tf = transforms.ToTensor()
train_all = datasets.MNIST('data', train=True, download=True, transform=tf)
test_all = datasets.MNIST('data', train=False, download=True, transform=tf)
def stack(dataset, n, start=0):
"""One pass, so images and labels stay together."""
xs = torch.stack([dataset[i][0] for i in range(start, start + n)])
ys = torch.tensor([dataset[i][1] for i in range(start, start + n)])
return xs, ys
# 12000 images whose labels we pretend not to have, and a held out set
unlab_x, unlab_y = stack(train_all, 12000)
test_x, test_y = stack(test_all, 2000)
pool = DataLoader(TensorDataset(unlab_x), batch_size=256, shuffle=True,
drop_last=True)
print('unlabelled pool %s' % (tuple(unlab_x.shape),))
print('test set %s' % (tuple(test_x.shape),))
print('batches per epoch %d' % len(pool))
test set (2000, 1, 28, 28)
batches per epoch 46
The encoder
One small convolutional network, used unchanged by every method below, so that differences in the results are differences between the methods rather than between architectures.
from torch import nn
from torch.utils.data import DataLoader, Subset, TensorDataset
from torchvision import datasets, transforms
tf = transforms.ToTensor()
train_all = datasets.MNIST('data', train=True, download=True, transform=tf)
test_all = datasets.MNIST('data', train=False, download=True, transform=tf)
def stack(dataset, n, start=0):
"""One pass, so images and labels stay together."""
xs = torch.stack([dataset[i][0] for i in range(start, start + n)])
ys = torch.tensor([dataset[i][1] for i in range(start, start + n)])
return xs, ys
# 12000 images whose labels we pretend not to have, and a held out set
unlab_x, unlab_y = stack(train_all, 12000)
test_x, test_y = stack(test_all, 2000)
pool = DataLoader(TensorDataset(unlab_x), batch_size=256, shuffle=True,
drop_last=True)
class Encoder(nn.Module):
"""The part we are trying to train without labels.
GroupNorm rather than BatchNorm, deliberately. This encoder gets
trained one way and evaluated another, often on batches of a very
different size, and BatchNorm's running statistics do not survive
that. Day 1 measures what it costs.
"""
def __init__(self, dim=64):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(1, 32, 3, padding=1), nn.GroupNorm(8, 32), nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.GroupNorm(8, 64), nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(64, 64, 3, padding=1), nn.GroupNorm(8, 64), nn.ReLU(),
nn.Flatten(),
nn.Linear(64 * 7 * 7, dim))
def forward(self, x):
return self.net(x)
torch.manual_seed(0)
enc = Encoder()
print(enc)
print()
print('%d parameters' % sum(p.numel() for p in enc.parameters()))
print('output shape %s' % (tuple(enc(unlab_x[:4]).shape),))
(net): Sequential(
(0): Conv2d(1, 32, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(1): GroupNorm(8, 32, eps=1e-05, affine=True)
(2): ReLU()
(3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(4): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(5): GroupNorm(8, 64, eps=1e-05, affine=True)
(6): ReLU()
(7): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(8): Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(9): GroupNorm(8, 64, eps=1e-05, affine=True)
(10): ReLU()
(11): Flatten(start_dim=1, end_dim=-1)
(12): Linear(in_features=3136, out_features=64, bias=True)
)
)
256832 parameters
output shape (4, 64)
One choice in there is deliberate and worth explaining: group normalisation rather than batch normalisation. This encoder gets trained under one regime and evaluated under another, often on batches of a completely different size, and week 4 spent a day on what BatchNorm does in that situation. Since the choice is easy to argue about and cheap to settle, here it is settled:
from torch import nn
from torch.utils.data import DataLoader, Subset, TensorDataset
from torchvision import datasets, transforms
tf = transforms.ToTensor()
train_all = datasets.MNIST('data', train=True, download=True, transform=tf)
test_all = datasets.MNIST('data', train=False, download=True, transform=tf)
def stack(dataset, n, start=0):
"""One pass, so images and labels stay together."""
xs = torch.stack([dataset[i][0] for i in range(start, start + n)])
ys = torch.tensor([dataset[i][1] for i in range(start, start + n)])
return xs, ys
# 12000 images whose labels we pretend not to have, and a held out set
unlab_x, unlab_y = stack(train_all, 12000)
test_x, test_y = stack(test_all, 2000)
pool = DataLoader(TensorDataset(unlab_x), batch_size=256, shuffle=True,
drop_last=True)
def build(norm):
"""The same encoder twice, differing only in the normalisation."""
def n(c):
return nn.BatchNorm2d(c) if norm == 'batch' else nn.GroupNorm(8, c)
return nn.Sequential(
nn.Conv2d(1, 32, 3, padding=1), n(32), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), n(64), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(64, 64, 3, padding=1), n(64), nn.ReLU(),
nn.Flatten(), nn.Linear(64 * 7 * 7, 64), nn.ReLU(),
nn.Linear(64, 10))
def compare(norm, steps=1500, seed=0):
torch.manual_seed(seed)
model = build(norm)
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
lf = nn.CrossEntropyLoss()
loader = DataLoader(TensorDataset(unlab_x, unlab_y), batch_size=128,
shuffle=True)
done = 0
while done < steps:
model.train()
for xb, yb in loader:
opt.zero_grad()
lf(model(xb), yb).backward()
opt.step()
done += 1
with torch.no_grad():
model.eval()
proper = (model(test_x).argmax(1) == test_y).float().mean()
# what the same weights score using each batch's own statistics
model.train()
batched = (model(test_x).argmax(1) == test_y).float().mean()
return float(proper), float(batched)
print('%-14s %18s %18s' % ('normalisation', 'running stats',
'batch stats'))
for norm in ['batch', 'group']:
proper, batched = compare(norm)
print('%-14s %18.4f %18.4f' % (norm, proper, batched))
batch 0.9870 0.9845
group 0.9860 0.9860
On this architecture they are the same to within a rounding error, and batch normalisation's two columns agree, so there is no drama to report. Group normalisation is kept anyway, on the grounds that it cannot develop a dependence on batch composition later, and it costs nothing to have that guarantee.
How everything will be scored
Two baselines matter, and skipping either one is how people convince themselves a pretext task worked when it did not. The first is training the whole encoder supervised on the same labels, which is what you would do if you never heard of any of this.
from torch import nn
from torch.utils.data import DataLoader, Subset, TensorDataset
from torchvision import datasets, transforms
tf = transforms.ToTensor()
train_all = datasets.MNIST('data', train=True, download=True, transform=tf)
test_all = datasets.MNIST('data', train=False, download=True, transform=tf)
def stack(dataset, n, start=0):
"""One pass, so images and labels stay together."""
xs = torch.stack([dataset[i][0] for i in range(start, start + n)])
ys = torch.tensor([dataset[i][1] for i in range(start, start + n)])
return xs, ys
# 12000 images whose labels we pretend not to have, and a held out set
unlab_x, unlab_y = stack(train_all, 12000)
test_x, test_y = stack(test_all, 2000)
pool = DataLoader(TensorDataset(unlab_x), batch_size=256, shuffle=True,
drop_last=True)
class Encoder(nn.Module):
"""The part we are trying to train without labels.
GroupNorm rather than BatchNorm, deliberately. This encoder gets
trained one way and evaluated another, often on batches of a very
different size, and BatchNorm's running statistics do not survive
that. Day 1 measures what it costs.
"""
def __init__(self, dim=64):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(1, 32, 3, padding=1), nn.GroupNorm(8, 32), nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.GroupNorm(8, 64), nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(64, 64, 3, padding=1), nn.GroupNorm(8, 64), nn.ReLU(),
nn.Flatten(),
nn.Linear(64 * 7 * 7, dim))
def forward(self, x):
return self.net(x)
@torch.no_grad()
def features(enc, x, batch=512):
enc.eval()
return torch.cat([enc(x[i:i + batch]) for i in range(0, len(x), batch)])
def linear_probe(enc, n_labels, epochs=300, seed=0):
"""Freeze the encoder, fit one linear layer on n_labels examples."""
torch.manual_seed(seed)
tr_x, tr_y = unlab_x[:n_labels], unlab_y[:n_labels]
ftr, fte = features(enc, tr_x), features(enc, test_x)
head = nn.Linear(ftr.shape[1], 10)
opt = torch.optim.AdamW(head.parameters(), lr=1e-2, weight_decay=1e-4)
lf = nn.CrossEntropyLoss()
for _ in range(epochs):
opt.zero_grad()
lf(head(ftr), tr_y).backward()
opt.step()
with torch.no_grad():
return (head(fte).argmax(1) == test_y).float().mean().item()
def supervised(n_labels, steps=1500, seed=0):
"""The same encoder trained from scratch on the same labels.
Budgeted in optimiser steps rather than epochs, so that 100 labels
and 12000 labels each get the same amount of training. A fixed epoch
count gives the small settings almost no steps and makes them look
far worse than they are.
"""
torch.manual_seed(seed)
model = nn.Sequential(Encoder(), nn.Linear(64, 10))
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
lf = nn.CrossEntropyLoss()
loader = DataLoader(TensorDataset(unlab_x[:n_labels],
unlab_y[:n_labels]),
batch_size=128, shuffle=True)
done = 0
while done < steps:
model.train()
for xb, yb in loader:
opt.zero_grad()
lf(model(xb), yb).backward()
opt.step()
done += 1
model.eval()
with torch.no_grad():
return (model(test_x).argmax(1) == test_y).float().mean().item()
print('%10s %14s' % ('labels', 'test accuracy'))
for n in [100, 500, 2000, 12000]:
print('%10d %14.4f' % (n, supervised(n)))
100 0.7320
500 0.9275
2000 0.9610
12000 0.9860
Budget in steps, not epochs
The first version of this comparison ran a fixed thirty epochs at every label count. With 100 labels that is 30 optimiser steps and with 12000 it is 2820, so the small settings were not underperforming for want of data, they were underperforming for want of training. Every run here gets 1500 steps regardless of how much data it has, which is the only version of the comparison that means anything.
The second baseline is the one that is almost always missing: the same encoder with its weights left exactly as initialised, never trained on anything at all.
from torch import nn
from torch.utils.data import DataLoader, Subset, TensorDataset
from torchvision import datasets, transforms
tf = transforms.ToTensor()
train_all = datasets.MNIST('data', train=True, download=True, transform=tf)
test_all = datasets.MNIST('data', train=False, download=True, transform=tf)
def stack(dataset, n, start=0):
"""One pass, so images and labels stay together."""
xs = torch.stack([dataset[i][0] for i in range(start, start + n)])
ys = torch.tensor([dataset[i][1] for i in range(start, start + n)])
return xs, ys
# 12000 images whose labels we pretend not to have, and a held out set
unlab_x, unlab_y = stack(train_all, 12000)
test_x, test_y = stack(test_all, 2000)
pool = DataLoader(TensorDataset(unlab_x), batch_size=256, shuffle=True,
drop_last=True)
class Encoder(nn.Module):
"""The part we are trying to train without labels.
GroupNorm rather than BatchNorm, deliberately. This encoder gets
trained one way and evaluated another, often on batches of a very
different size, and BatchNorm's running statistics do not survive
that. Day 1 measures what it costs.
"""
def __init__(self, dim=64):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(1, 32, 3, padding=1), nn.GroupNorm(8, 32), nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.GroupNorm(8, 64), nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(64, 64, 3, padding=1), nn.GroupNorm(8, 64), nn.ReLU(),
nn.Flatten(),
nn.Linear(64 * 7 * 7, dim))
def forward(self, x):
return self.net(x)
@torch.no_grad()
def features(enc, x, batch=512):
enc.eval()
return torch.cat([enc(x[i:i + batch]) for i in range(0, len(x), batch)])
def linear_probe(enc, n_labels, epochs=300, seed=0):
"""Freeze the encoder, fit one linear layer on n_labels examples."""
torch.manual_seed(seed)
tr_x, tr_y = unlab_x[:n_labels], unlab_y[:n_labels]
ftr, fte = features(enc, tr_x), features(enc, test_x)
head = nn.Linear(ftr.shape[1], 10)
opt = torch.optim.AdamW(head.parameters(), lr=1e-2, weight_decay=1e-4)
lf = nn.CrossEntropyLoss()
for _ in range(epochs):
opt.zero_grad()
lf(head(ftr), tr_y).backward()
opt.step()
with torch.no_grad():
return (head(fte).argmax(1) == test_y).float().mean().item()
def supervised(n_labels, steps=1500, seed=0):
"""The same encoder trained from scratch on the same labels.
Budgeted in optimiser steps rather than epochs, so that 100 labels
and 12000 labels each get the same amount of training. A fixed epoch
count gives the small settings almost no steps and makes them look
far worse than they are.
"""
torch.manual_seed(seed)
model = nn.Sequential(Encoder(), nn.Linear(64, 10))
opt = torch.optim.AdamW(model.parameters(), lr=1e-3)
lf = nn.CrossEntropyLoss()
loader = DataLoader(TensorDataset(unlab_x[:n_labels],
unlab_y[:n_labels]),
batch_size=128, shuffle=True)
done = 0
while done < steps:
model.train()
for xb, yb in loader:
opt.zero_grad()
lf(model(xb), yb).backward()
opt.step()
done += 1
model.eval()
with torch.no_grad():
return (model(test_x).argmax(1) == test_y).float().mean().item()
torch.manual_seed(0)
enc = Encoder()
print('%10s %14s' % ('labels', 'probe accuracy'))
for n in [100, 500, 2000, 12000]:
print('%10d %14.4f' % (n, linear_probe(enc, n)))
100 0.6340
500 0.7780
2000 0.8205
12000 0.8390
A random convolutional stack, with no training whatsoever, reaches 0.839 with a linear layer on top. That is not a quirk of MNIST. Random convolutions are a genuinely strong feature extractor, because a random filter bank still measures edges and textures at every position, and pooling still summarises them.