Two Networks, One Game
Replacing a pixel loss with an opponent, and the first samples
Week 12 ended with a complaint. The variational autoencoder could generate digits from noise, but they came out soft, and the reason was the loss: averaging over every plausible digit is what minimises a pixel-wise error, and the average of several plausible digits is a blur. No amount of extra capacity fixes a loss that rewards hedging.
So replace the loss. Instead of scoring an output against a target pixel by pixel, train a second network whose only job is to tell real images from generated ones, and score the generator by whether it fools that network. An average of two digits does not fool anybody, so hedging stops paying.
The two networks
Neither is unusual on its own. The generator is a stack that widens from a small random vector up to 784 numbers, ending in Tanh because the images have been scaled to the range minus one to one. The discriminator is an ordinary binary classifier that happens to be looking at images.
from torch import nn
from torch.utils.data import DataLoader, Subset
from torchvision import datasets, transforms
tf = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((0.5,), (0.5,))])
train_all = datasets.MNIST('data', train=True, download=True, transform=tf)
train_set = Subset(train_all, range(8000))
loader = DataLoader(train_set, batch_size=128, shuffle=True, drop_last=True)
RAMP = ' .:-=+*#%@'
def show(flat, step=2):
"""Print one flattened image, scaled from [-1, 1] back to characters."""
t = (flat.detach().reshape(28, 28) + 1) / 2
for row in t[::step]:
print(''.join(RAMP[min(9, max(0, int(v * 9.999)))]
for v in row.tolist()))
class Gen(nn.Module):
def __init__(self, zdim=32):
super().__init__()
self.zdim = zdim
self.net = nn.Sequential(
nn.Linear(zdim, 128), nn.LeakyReLU(0.2),
nn.Linear(128, 256), nn.BatchNorm1d(256), nn.LeakyReLU(0.2),
nn.Linear(256, 784), nn.Tanh())
def forward(self, z):
return self.net(z)
class Disc(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(784, 256), nn.LeakyReLU(0.2),
nn.Linear(256, 128), nn.LeakyReLU(0.2),
nn.Linear(128, 1))
def forward(self, x):
return self.net(x.flatten(1))
torch.manual_seed(0)
G, D = Gen(), Disc()
G.eval()
with torch.no_grad():
fake = G(torch.randn(2, 32))
print('the discriminator scores this noise at %.3f'
% D(fake).mean())
show(fake[0])
=++==++==+=++===+++==++++==+
+==+++=+++=+=++++++===++=+++
++++++===*+===+=++==++=+++++
+=+====+++++=+=====++++++===
++=+=+=+=++=+====+=++++===++
+=++===+==++=+==+=+==+=+==+=
+++==+++==++==++==+++-=++==+
======++=++=+=+==+++=+==+=++
===++=++++=+=++===++=-=====+
=====+==++++++=+=====+++==++
==++==+-+++=+-==++=++==+=+=+
+=++=-++==+++*++=+==+==-+++=
==++=+==++==++==+==++=++=+==
===+=+===++*==+=+===+====++=
Static, and a discriminator that has not been trained either, sitting near zero because it has no opinion yet. A score of zero is the boundary: the network outputs a logit, so positive means it thinks the image is real and negative means it does not.
The game
Each batch runs two updates, in this order:
- Show the discriminator a batch of real images labelled one and a batch of generated images labelled zero, and take one step on its parameters. The generated images are
detach()ed, because this step must not change the generator. - Generate a fresh batch, score it with the discriminator, and take one step on the generator's parameters in the direction that makes the discriminator call those images real. The discriminator is not updated here even though the gradient passes straight through it.
The detach is not an optimisation
Leaving it out means the discriminator step also updates the generator, in the direction that makes its own output easier to detect. The run does not crash and the losses look plausible. It simply never produces anything, which is the worst kind of bug to have in a model that is expected to look bad for the first few epochs anyway.
from torch import nn
from torch.utils.data import DataLoader, Subset
from torchvision import datasets, transforms
tf = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((0.5,), (0.5,))])
train_all = datasets.MNIST('data', train=True, download=True, transform=tf)
train_set = Subset(train_all, range(8000))
loader = DataLoader(train_set, batch_size=128, shuffle=True, drop_last=True)
RAMP = ' .:-=+*#%@'
def show(flat, step=2):
"""Print one flattened image, scaled from [-1, 1] back to characters."""
t = (flat.detach().reshape(28, 28) + 1) / 2
for row in t[::step]:
print(''.join(RAMP[min(9, max(0, int(v * 9.999)))]
for v in row.tolist()))
class Gen(nn.Module):
def __init__(self, zdim=32):
super().__init__()
self.zdim = zdim
self.net = nn.Sequential(
nn.Linear(zdim, 128), nn.LeakyReLU(0.2),
nn.Linear(128, 256), nn.BatchNorm1d(256), nn.LeakyReLU(0.2),
nn.Linear(256, 784), nn.Tanh())
def forward(self, z):
return self.net(z)
class Disc(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(784, 256), nn.LeakyReLU(0.2),
nn.Linear(256, 128), nn.LeakyReLU(0.2),
nn.Linear(128, 1))
def forward(self, x):
return self.net(x.flatten(1))
bce = nn.BCEWithLogitsLoss()
def train_gan(epochs=30, zdim=32, d_steps=1, saturating=False,
lr_g=2e-4, lr_d=2e-4, seed=0, trace_every=0):
torch.manual_seed(seed)
G, D = Gen(zdim), Disc()
og = torch.optim.Adam(G.parameters(), lr=lr_g, betas=(0.5, 0.999))
od = torch.optim.Adam(D.parameters(), lr=lr_d, betas=(0.5, 0.999))
for ep in range(1, epochs + 1):
dsum = gsum = acc = n = 0.0
for xb, _ in loader:
real = xb.flatten(1)
ones = torch.ones(len(real), 1)
zeros = torch.zeros(len(real), 1)
for _ in range(d_steps):
fake = G(torch.randn(len(real), zdim)).detach()
loss_d = bce(D(real), ones) + bce(D(fake), zeros)
od.zero_grad()
loss_d.backward()
od.step()
fake = G(torch.randn(len(real), zdim))
score = D(fake)
loss_g = -bce(score, zeros) if saturating else bce(score, ones)
og.zero_grad()
loss_g.backward()
og.step()
dsum += loss_d.item()
gsum += loss_g.item()
acc += ((D(real) > 0).float().mean().item()
+ (score < 0).float().mean().item()) / 2
n += 1
if trace_every and ep % trace_every == 0:
print('%6d %12.3f %12.3f %14.2f'
% (ep, dsum / n, gsum / n, acc / n))
G.eval()
return G, D
print('%6s %12s %12s %14s' % ('epoch', 'D loss', 'G loss', 'D accuracy'))
G, D = train_gan(epochs=30, trace_every=5)
torch.manual_seed(7)
with torch.no_grad():
for img in G(torch.randn(3, 32)):
show(img)
print()
5 1.189 0.833 0.87
10 1.153 0.984 0.79
15 1.084 1.157 0.81
20 1.085 1.123 0.78
25 1.006 1.209 0.80
30 1.007 1.230 0.80
. .- .
. + . : .
-. . %%.: .
. +@@@@@@@@@@%.
..#@@. +%.
- :*# .**@++.
: :+. .-%%@%+: .
.-: =@@%*+##--
+=-=*@%...:@%# :
= + . *=-.:
.. . : :#@@% +
- *:%@%@@@@@@@% *
.::@=:=
: .
.
-
. . :: .
. . . - . . +
#+@@==.:.:
--+-%- . .
.: -.. .
+ =#:=
+ .*%@@=
. .=#%@@@= +
*%@@@: . .
: .:*: .
. ..
= -
.
. -. .
.##@=--=:
---%=#=.: --
..: :#. :-.
--@*+-: +
- %%-#..
:-==
. . ..=#++.-%##
:.%%@#:.*@#:
-=%@+*-
:
Thirty epochs on eight thousand images, from a generator that is four dense layers. What comes out has the thickness and curvature of handwriting, closed loops in roughly the right places, and a dark background it was never told about. What it does not have is a particular digit you would confidently name. That is the honest description of this budget, and it is still a long way from the noise the same network produced a few blocks ago, with no target image anywhere in the loss.