Learning from a teacher's whole distribution, and a negative result checked twice
The third technique changes the model rather than compressing it. Train a small network, but instead of showing it only the correct label, show it what a large trained network believes.
import os, time, copy
import torch
from torch import nn
from torch.utils.data import DataLoader, 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(ds, n):
xs = torch.stack([ds[i][0] for i in range(n)])
ys = torch.tensor([ds[i][1] for i in range(n)])
return xs, ys
xtr, ytr = stack(train_all, 12000)
xte, yte = stack(test_all, 2000)
def accuracy(model, x=None, y=None):
model.eval()
x = xte if x is None else x
y = yte if y is None else y
with torch.no_grad():
return (model(x).argmax(1) == y).float().mean().item()
def size_kb(model, path='tmp.pt'):
torch.save(model.state_dict(), path)
kb = os.path.getsize(path) / 1024
os.remove(path)
return kb
def latency_ms(model, n=200, batch=1):
"""Median time for one forward pass, which is what a server sees."""
model.eval()
x = xte[:batch]
times = []
with torch.no_grad():
for _ in range(10):
model(x)
for _ in range(n):
start = time.perf_counter()
model(x)
times.append((time.perf_counter() - start) * 1000)
times.sort()
return times[len(times) // 2]
def big():
"""The model we would like to ship but cannot afford to."""
return nn.Sequential(
nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(),
nn.Linear(64 * 7 * 7, 256), nn.ReLU(),
nn.Linear(256, 10))
def small():
"""Something we could actually run on a phone."""
return nn.Sequential(
nn.Flatten(),
nn.Linear(784, 32), nn.ReLU(),
nn.Linear(32, 10))
def train(model, epochs=6, lr=1e-3, seed=0, teacher=None, alpha=0.5,
temperature=4.0):
torch.manual_seed(seed)
opt = torch.optim.Adam(model.parameters(), lr=lr)
hard = nn.CrossEntropyLoss()
soft = nn.KLDivLoss(reduction='batchmean')
loader = DataLoader(TensorDataset(xtr, ytr), batch_size=128,
shuffle=True)
for _ in range(epochs):
model.train()
for xb, yb in loader:
out = model(xb)
loss = hard(out, yb)
if teacher is not None:
with torch.no_grad():
t = teacher(xb)
kd = soft(
nn.functional.log_softmax(out / temperature, dim=1),
nn.functional.softmax(t / temperature, dim=1))
loss = (1 - alpha) * loss + alpha * temperature ** 2 * kd
opt.zero_grad()
loss.backward()
opt.step()
model.eval()
return model
teacher = big()
teacher.load_state_dict(torch.load('teacher.pt'))
teacher.eval()
with torch.no_grad():
probs = nn.functional.softmax(teacher(xte[:1]) / 4.0, dim=1)[0]
print('true label %d' % yte[0])
print('the teacher, softened, says:')
for digit, p in sorted(enumerate(probs.tolist()), key=lambda t: -t[1])[:5]:
print(' %d %.4f' % (digit, p))
print()
print('the one-hot label says 1.0 for %d and 0.0 for everything else'
% yte[0])
true label 7
the teacher, softened, says:
7 0.8008
2 0.0750
3 0.0533
9 0.0221
8 0.0196
the one-hot label says 1.0 for 7 and 0.0 for everything else
That is the argument in one output. The label says this is a seven and says nothing else. The teacher says it is a seven, and that if it were not, a two or a three would be the next most reasonable readings, and a zero would be absurd. The claim is that the second set carries more information per example, so a small model can learn more from the same data.
Temperature, in distillation: Dividing logits by a number above one before the softmax, which flattens the distribution and makes the small probabilities visible. Without it the teacher's output is nearly one-hot and there is nothing extra to learn from. The loss is scaled by temperature squared to keep the gradient magnitude comparable.
Measured
import os, time, copy
import torch
from torch import nn
from torch.utils.data import DataLoader, 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(ds, n):
xs = torch.stack([ds[i][0] for i in range(n)])
ys = torch.tensor([ds[i][1] for i in range(n)])
return xs, ys
xtr, ytr = stack(train_all, 12000)
xte, yte = stack(test_all, 2000)
def accuracy(model, x=None, y=None):
model.eval()
x = xte if x is None else x
y = yte if y is None else y
with torch.no_grad():
return (model(x).argmax(1) == y).float().mean().item()
def size_kb(model, path='tmp.pt'):
torch.save(model.state_dict(), path)
kb = os.path.getsize(path) / 1024
os.remove(path)
return kb
def latency_ms(model, n=200, batch=1):
"""Median time for one forward pass, which is what a server sees."""
model.eval()
x = xte[:batch]
times = []
with torch.no_grad():
for _ in range(10):
model(x)
for _ in range(n):
start = time.perf_counter()
model(x)
times.append((time.perf_counter() - start) * 1000)
times.sort()
return times[len(times) // 2]
def big():
"""The model we would like to ship but cannot afford to."""
return nn.Sequential(
nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(),
nn.Linear(64 * 7 * 7, 256), nn.ReLU(),
nn.Linear(256, 10))
def small():
"""Something we could actually run on a phone."""
return nn.Sequential(
nn.Flatten(),
nn.Linear(784, 32), nn.ReLU(),
nn.Linear(32, 10))
def train(model, epochs=6, lr=1e-3, seed=0, teacher=None, alpha=0.5,
temperature=4.0):
torch.manual_seed(seed)
opt = torch.optim.Adam(model.parameters(), lr=lr)
hard = nn.CrossEntropyLoss()
soft = nn.KLDivLoss(reduction='batchmean')
loader = DataLoader(TensorDataset(xtr, ytr), batch_size=128,
shuffle=True)
for _ in range(epochs):
model.train()
for xb, yb in loader:
out = model(xb)
loss = hard(out, yb)
if teacher is not None:
with torch.no_grad():
t = teacher(xb)
kd = soft(
nn.functional.log_softmax(out / temperature, dim=1),
nn.functional.softmax(t / temperature, dim=1))
loss = (1 - alpha) * loss + alpha * temperature ** 2 * kd
opt.zero_grad()
loss.backward()
opt.step()
model.eval()
return model
teacher = big()
teacher.load_state_dict(torch.load('teacher.pt'))
teacher.eval()
print('%-30s %10s %10s %12s'
% ('', 'accuracy', 'size KB', 'latency ms'))
print('%-30s %10.4f %10.1f %12.3f'
% ('teacher', accuracy(teacher), size_kb(teacher),
latency_ms(teacher)))
alone = train(small(), seed=1)
print('%-30s %10.4f %10.1f %12.3f'
% ('student, labels only', accuracy(alone), size_kb(alone),
latency_ms(alone)))
taught = train(small(), seed=1, teacher=teacher)
print('%-30s %10.4f %10.1f %12.3f'
% ('student, distilled', accuracy(taught), size_kb(taught),
latency_ms(taught)))
accuracy size KB latency ms
teacher 0.9775 3223.5 0.271
student, labels only 0.8905 101.6 0.028
student, distilled 0.8730 101.6 0.031
The student is 32 times smaller and roughly 9 times faster, for about nine points of accuracy. That part is the real trade and it is often worth taking. But distillation made the student worse than training it on labels alone, which is the opposite of the entire point.
Checking that before believing it
One run at one setting is not enough to publish a negative result on, so here is a sweep over the mixing weight, at two seeds each.
import os, time, copy
import torch
from torch import nn
from torch.utils.data import DataLoader, 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(ds, n):
xs = torch.stack([ds[i][0] for i in range(n)])
ys = torch.tensor([ds[i][1] for i in range(n)])
return xs, ys
xtr, ytr = stack(train_all, 12000)
xte, yte = stack(test_all, 2000)
def accuracy(model, x=None, y=None):
model.eval()
x = xte if x is None else x
y = yte if y is None else y
with torch.no_grad():
return (model(x).argmax(1) == y).float().mean().item()
def size_kb(model, path='tmp.pt'):
torch.save(model.state_dict(), path)
kb = os.path.getsize(path) / 1024
os.remove(path)
return kb
def latency_ms(model, n=200, batch=1):
"""Median time for one forward pass, which is what a server sees."""
model.eval()
x = xte[:batch]
times = []
with torch.no_grad():
for _ in range(10):
model(x)
for _ in range(n):
start = time.perf_counter()
model(x)
times.append((time.perf_counter() - start) * 1000)
times.sort()
return times[len(times) // 2]
def big():
"""The model we would like to ship but cannot afford to."""
return nn.Sequential(
nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(),
nn.Linear(64 * 7 * 7, 256), nn.ReLU(),
nn.Linear(256, 10))
def small():
"""Something we could actually run on a phone."""
return nn.Sequential(
nn.Flatten(),
nn.Linear(784, 32), nn.ReLU(),
nn.Linear(32, 10))
def train(model, epochs=6, lr=1e-3, seed=0, teacher=None, alpha=0.5,
temperature=4.0):
torch.manual_seed(seed)
opt = torch.optim.Adam(model.parameters(), lr=lr)
hard = nn.CrossEntropyLoss()
soft = nn.KLDivLoss(reduction='batchmean')
loader = DataLoader(TensorDataset(xtr, ytr), batch_size=128,
shuffle=True)
for _ in range(epochs):
model.train()
for xb, yb in loader:
out = model(xb)
loss = hard(out, yb)
if teacher is not None:
with torch.no_grad():
t = teacher(xb)
kd = soft(
nn.functional.log_softmax(out / temperature, dim=1),
nn.functional.softmax(t / temperature, dim=1))
loss = (1 - alpha) * loss + alpha * temperature ** 2 * kd
opt.zero_grad()
loss.backward()
opt.step()
model.eval()
return model
teacher = big()
teacher.load_state_dict(torch.load('teacher.pt'))
teacher.eval()
print('alpha 0 is labels only, alpha 1 is the teacher only')
print('%8s %12s %12s' % ('alpha', 'seed 1', 'seed 2'))
for alpha in [0.0, 0.3, 0.5, 0.7, 0.9]:
accs = []
for seed in [1, 2]:
t = None if alpha == 0 else teacher
m = train(small(), seed=seed, teacher=t, alpha=alpha)
accs.append(accuracy(m))
print('%8.1f %12.4f %12.4f' % (alpha, accs[0], accs[1]))
alpha 0 is labels only, alpha 1 is the teacher only
alpha seed 1 seed 2
0.0 0.8905 0.8920
0.3 0.8835 0.8795
0.5 0.8835 0.8800
0.7 0.8835 0.8785
0.9 0.8820 0.8775
Consistent, monotonic, and in the wrong direction
Every alpha above zero is worse than alpha zero, at both seeds, and it gets worse the more weight the teacher is given. This is not noise and it is not one unlucky hyperparameter.
The most likely explanation is capacity. A 784 to 32 to 10 network has nowhere near enough room to represent the teacher's function, so asking it to match a full ten-way distribution spends its very limited capacity on relationships it cannot use, at the expense of the decision boundary it was actually going to be scored on. Distillation helps when the student is small but not hopeless.
The lesson is not that distillation does not work. It works well enough that most deployed small language models are made this way. The lesson is that it is a technique with a range of validity, and that the honest way to find out whether you are inside it is two training runs and a table.