Learning Without Labels
Rebuilding the input through a bottleneck, and why the bottleneck matters
Everything so far has needed labels. An autoencoder needs none: it is trained to reproduce its own input through a narrow middle, and what it learns on the way is a compressed representation of the data.
The idea
from torch import nn
from torch.utils.data import DataLoader, Subset
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)
train_set = Subset(train_all, range(8000))
val_set = Subset(test_all, range(2000))
train_loader = DataLoader(train_set, batch_size=128, shuffle=True)
val_loader = DataLoader(val_set, batch_size=512)
RAMP = ' .:-=+*#%@'
def show(img, step=2):
"""Print a 28 by 28 image as characters."""
t = img.detach().reshape(28, 28)
t = (t - t.min()) / (t.max() - t.min() + 1e-9)
for row in t[::step]:
print(''.join(RAMP[min(9, int(v * 9.999))] for v in row.tolist()))
class AutoEncoder(nn.Module):
def __init__(self, latent=16):
super().__init__()
self.encoder = nn.Sequential(
nn.Flatten(),
nn.Linear(784, 256), nn.ReLU(),
nn.Linear(256, 64), nn.ReLU(),
nn.Linear(64, latent))
self.decoder = nn.Sequential(
nn.Linear(latent, 64), nn.ReLU(),
nn.Linear(64, 256), nn.ReLU(),
nn.Linear(256, 784), nn.Sigmoid())
def forward(self, x):
return self.decoder(self.encoder(x))
def train_ae(model, epochs=8, lr=1e-3, noise=0.0, loader=None):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr)
loss_fn = nn.MSELoss()
for _ in range(epochs):
model.train()
for xb, _ in (loader or train_loader):
target = xb.flatten(1)
inp = xb if noise == 0 else (xb + noise * torch.randn_like(xb)
).clamp(0, 1)
opt.zero_grad()
loss_fn(model(inp), target).backward()
opt.step()
model.eval()
total = seen = 0
with torch.no_grad():
for xb, _ in val_loader:
total += loss_fn(model(xb), xb.flatten(1)).item() * len(xb)
seen += len(xb)
return total / seen
torch.manual_seed(0)
model = AutoEncoder(latent=16)
x = next(iter(val_loader))[0][:4]
code = model.encoder(x)
print('input ', tuple(x.shape), '= %d numbers per image' % (28 * 28))
print('code ', tuple(code.shape), '= 16 numbers per image')
print('output ', tuple(model(x).shape))
print('\ncompression %.0f to 1' % (784 / 16))
print('parameters %d' % sum(p.numel() for p in model.parameters()))
code (4, 16) = 16 numbers per image
output (4, 784)
compression 49 to 1
parameters 437664
Training it, with no labels anywhere
from torch import nn
from torch.utils.data import DataLoader, Subset
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)
train_set = Subset(train_all, range(8000))
val_set = Subset(test_all, range(2000))
train_loader = DataLoader(train_set, batch_size=128, shuffle=True)
val_loader = DataLoader(val_set, batch_size=512)
RAMP = ' .:-=+*#%@'
def show(img, step=2):
"""Print a 28 by 28 image as characters."""
t = img.detach().reshape(28, 28)
t = (t - t.min()) / (t.max() - t.min() + 1e-9)
for row in t[::step]:
print(''.join(RAMP[min(9, int(v * 9.999))] for v in row.tolist()))
class AutoEncoder(nn.Module):
def __init__(self, latent=16):
super().__init__()
self.encoder = nn.Sequential(
nn.Flatten(),
nn.Linear(784, 256), nn.ReLU(),
nn.Linear(256, 64), nn.ReLU(),
nn.Linear(64, latent))
self.decoder = nn.Sequential(
nn.Linear(latent, 64), nn.ReLU(),
nn.Linear(64, 256), nn.ReLU(),
nn.Linear(256, 784), nn.Sigmoid())
def forward(self, x):
return self.decoder(self.encoder(x))
def train_ae(model, epochs=8, lr=1e-3, noise=0.0, loader=None):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr)
loss_fn = nn.MSELoss()
for _ in range(epochs):
model.train()
for xb, _ in (loader or train_loader):
target = xb.flatten(1)
inp = xb if noise == 0 else (xb + noise * torch.randn_like(xb)
).clamp(0, 1)
opt.zero_grad()
loss_fn(model(inp), target).backward()
opt.step()
model.eval()
total = seen = 0
with torch.no_grad():
for xb, _ in val_loader:
total += loss_fn(model(xb), xb.flatten(1)).item() * len(xb)
seen += len(xb)
return total / seen
torch.manual_seed(0)
model = AutoEncoder(latent=16)
print('validation reconstruction error %.5f' % train_ae(model))
x, _ = next(iter(val_loader))
with torch.no_grad():
out = model(x[:1])
print('\noriginal:')
show(x[0])
print('\nrebuilt from 16 numbers:')
show(out[0])
original:
%@@@@@########*:
: :::: @@=
@@-
:@@:
%@:
:@@:
#@%.
.%@=
:@@@:
=@%
rebuilt from 16 numbers:
.:-+*##+#*##%@%%*:
.::=**+==:-:-+%@%:
.. .+#%=
.=%#-
.+%%-
*@%.
.%@@:
-*#*:
.=*#*-
Note what the loss function never saw
The target is the input. There is no label in that training loop at all, which means it will run on any pile of unlabelled data you have. That is the whole appeal, and it is the thread running through this week and the next two: labels are expensive, raw data is not, and a great deal can be learned from raw data alone.
How narrow can the middle be?
from torch import nn
from torch.utils.data import DataLoader, Subset
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)
train_set = Subset(train_all, range(8000))
val_set = Subset(test_all, range(2000))
train_loader = DataLoader(train_set, batch_size=128, shuffle=True)
val_loader = DataLoader(val_set, batch_size=512)
RAMP = ' .:-=+*#%@'
def show(img, step=2):
"""Print a 28 by 28 image as characters."""
t = img.detach().reshape(28, 28)
t = (t - t.min()) / (t.max() - t.min() + 1e-9)
for row in t[::step]:
print(''.join(RAMP[min(9, int(v * 9.999))] for v in row.tolist()))
class AutoEncoder(nn.Module):
def __init__(self, latent=16):
super().__init__()
self.encoder = nn.Sequential(
nn.Flatten(),
nn.Linear(784, 256), nn.ReLU(),
nn.Linear(256, 64), nn.ReLU(),
nn.Linear(64, latent))
self.decoder = nn.Sequential(
nn.Linear(latent, 64), nn.ReLU(),
nn.Linear(64, 256), nn.ReLU(),
nn.Linear(256, 784), nn.Sigmoid())
def forward(self, x):
return self.decoder(self.encoder(x))
def train_ae(model, epochs=8, lr=1e-3, noise=0.0, loader=None):
torch.manual_seed(0)
opt = torch.optim.AdamW(model.parameters(), lr=lr)
loss_fn = nn.MSELoss()
for _ in range(epochs):
model.train()
for xb, _ in (loader or train_loader):
target = xb.flatten(1)
inp = xb if noise == 0 else (xb + noise * torch.randn_like(xb)
).clamp(0, 1)
opt.zero_grad()
loss_fn(model(inp), target).backward()
opt.step()
model.eval()
total = seen = 0
with torch.no_grad():
for xb, _ in val_loader:
total += loss_fn(model(xb), xb.flatten(1)).item() * len(xb)
seen += len(xb)
return total / seen
print('%8s %10s %16s' % ('latent', 'params', 'reconstruction'))
for latent in [2, 8, 32, 128]:
torch.manual_seed(0)
model = AutoEncoder(latent=latent)
err = train_ae(model, epochs=6)
print('%8d %10d %16.5f'
% (latent, sum(p.numel() for p in model.parameters()), err))
2 435858 0.05140
8 436632 0.03442
32 439728 0.03465
128 452112 0.03403
The error falls sharply from two dimensions to eight and then stops moving. Below eight the bottleneck is genuinely the constraint; above it, something else is, and on this architecture and this budget the extra width buys nothing. Finding that knee is worth doing once, because everything past it is capacity you are paying for and not using.
A bottleneck that is not a bottleneck teaches nothing
Widen the middle until it is as large as the input and the model can learn the identity function, which reconstructs perfectly and has learned nothing whatsoever about the data. The constraint is not an inconvenience to be minimised, it is the entire mechanism. If your reconstruction error is suspiciously low, check that the code is actually narrower than the input once you count every channel.