Skip to content
Deep Learning with PyTorch
Generative Models 12 min read

Generative Adversarial Networks

Two networks locked in a forgery contest — train a GAN from a 1-D toy demo in your browser up to a DCGAN that draws handwritten digits.

Download notebook Open Google ColabIn Colab: File → Upload notebook → pick the downloaded file.

The autoencoder learned to compress and redraw data it had seen. A GAN goes further: it learns to draw data that never existed — faces, digits, artwork — by turning generation into a game between two networks. In this final lesson you'll build the adversarial intuition with a demo small enough to run in your browser, then scale the same idea up to a DCGAN that generates handwritten digits.

The counterfeiter and the detective

A GAN (generative adversarial network, Goodfellow et al., 2014) trains two networks against each other:

  • The generator G is a counterfeiter. It takes a random noise vector z and transforms it into a fake sample, G(z) — a fake image, say. It never sees real data directly.
  • The discriminator D is a detective. Given a sample, it outputs the probability that the sample is real. It trains on both real data (label 1) and the generator's fakes (label 0).

They improve because of each other. Early on, the fakes are garbage and the detective wins easily. But the detective's verdicts flow back through backpropagation as a training signal for the counterfeiter: "this fake was spotted because of these pixels." The counterfeiter adjusts, the fakes get better, the detective is forced to sharpen its criteria, and around it goes. At the theoretical equilibrium, the fakes are indistinguishable from real data and the detective is reduced to guessing — 50/50.

Formally the game is a minimax objective: min_G max_D E[log D(x)] + E[log(1 − D(G(z)))]. In plain words: the discriminator tunes its weights to maximize its accuracy — assign high D(x) to real samples and low D(G(z)) to fakes — while the generator tunes its weights to minimize that same score by making fakes the discriminator scores high. One value function, two players pulling in opposite directions.

The non-saturating trick

In practice the generator doesn't minimize log(1 − D(G(z))) — that gradient vanishes exactly when the generator is losing badly. Instead it maximizes log D(G(z)), which gives strong gradients when fakes are easily spotted. Same game, healthier learning signal — and it's what every implementation, including ours below, actually uses.

The adversarial game in 1-D, live

Images are too big to watch a GAN think, so let's shrink the problem until every moving part is visible. The "real data" is just numbers drawn from N(4, 0.5). The generator is the simplest possible one — it reshapes standard Gaussian noise as x = mu + sigma * z, so its only weights are mu and sigma. The discriminator is a tiny logistic regression on the features x and (quadratic features can perfectly separate two Gaussians). Both are trained with plain gradient steps, alternating — a real GAN loop, in numpy:

Python — runs in your browser

The generator starts producing numbers around 0 and, guided only by the discriminator's verdicts, marches its distribution over to sit on top of the real one. Look honestly at the final numbers, though: mu lands near 4 but sigma settles around 0.7 rather than 0.5, and D(fake) oscillates instead of resting at 0.5. Even in one dimension with four parameters, the game circles the equilibrium rather than settling on it — remember this when we discuss instability.

Scaling up: DCGAN on MNIST

For images, both players become convolutional networks — the DCGAN recipe. The generator runs a CNN in reverse: ConvTranspose2d layers upsample a noise vector into an image, doubling resolution at each step. The discriminator is an ordinary CNN classifier ending in a single logit. This needs a GPU — run it in the notebook on Colab.

import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
 
device = "cuda" if torch.cuda.is_available() else "cpu"
 
# scale images to [-1, 1] to match the generator's Tanh output
tfm = transforms.Compose([transforms.ToTensor(),
                          transforms.Normalize(0.5, 0.5)])
train_set = datasets.MNIST("data", train=True, download=True, transform=tfm)
loader = DataLoader(train_set, batch_size=128, shuffle=True)
 
z_dim = 100
 
generator = nn.Sequential(
    # (batch, 100, 1, 1) -> (batch, 128, 7, 7)
    nn.ConvTranspose2d(z_dim, 128, kernel_size=7, stride=1, padding=0),
    nn.BatchNorm2d(128), nn.ReLU(),
    # -> (batch, 64, 14, 14)
    nn.ConvTranspose2d(128, 64, kernel_size=4, stride=2, padding=1),
    nn.BatchNorm2d(64), nn.ReLU(),
    # -> (batch, 1, 28, 28)
    nn.ConvTranspose2d(64, 1, kernel_size=4, stride=2, padding=1),
    nn.Tanh(),
).to(device)
 
discriminator = nn.Sequential(
    # (batch, 1, 28, 28) -> (batch, 64, 14, 14)
    nn.Conv2d(1, 64, kernel_size=4, stride=2, padding=1),
    nn.LeakyReLU(0.2),
    # -> (batch, 128, 7, 7)
    nn.Conv2d(64, 128, kernel_size=4, stride=2, padding=1),
    nn.BatchNorm2d(128), nn.LeakyReLU(0.2),
    # -> (batch, 1, 1, 1) -> one logit per image
    nn.Conv2d(128, 1, kernel_size=7, stride=1, padding=0),
    nn.Flatten(),
).to(device)

The training loop alternates the two players every batch. Note the .detach() when training the discriminator (no generator gradients wanted) and the flipped labels when training the generator:

criterion = nn.BCEWithLogitsLoss()
opt_d = torch.optim.Adam(discriminator.parameters(), lr=2e-4, betas=(0.5, 0.999))
opt_g = torch.optim.Adam(generator.parameters(), lr=2e-4, betas=(0.5, 0.999))
 
fixed_z = torch.randn(64, z_dim, 1, 1, device=device)  # to watch progress
 
for epoch in range(25):
    for real, _ in loader:
        real = real.to(device)
        bs = len(real)
        ones, zeros = torch.ones(bs, 1, device=device), torch.zeros(bs, 1, device=device)
 
        # --- 1. discriminator step ---
        z = torch.randn(bs, z_dim, 1, 1, device=device)
        fake = generator(z)
        loss_d = (criterion(discriminator(real), ones) +
                  criterion(discriminator(fake.detach()), zeros))
        opt_d.zero_grad(); loss_d.backward(); opt_d.step()
 
        # --- 2. generator step: make D say 'real' on fakes ---
        loss_g = criterion(discriminator(fake), ones)
        opt_g.zero_grad(); loss_g.backward(); opt_g.step()
 
    print(f"epoch {epoch + 1}: loss_D={loss_d.item():.3f} loss_G={loss_g.item():.3f}")
 
    if (epoch + 1) % 5 == 0:            # sample a grid every few epochs
        from torchvision.utils import make_grid
        import matplotlib.pyplot as plt
        with torch.no_grad():
            samples = generator(fixed_z).cpu() * 0.5 + 0.5   # back to [0, 1]
        plt.figure(figsize=(6, 6))
        plt.imshow(make_grid(samples, nrow=8).permute(1, 2, 0), cmap="gray")
        plt.axis("off"); plt.title(f"epoch {epoch + 1}")
        plt.show()

Because fixed_z never changes, the periodic grids show the same 64 noise vectors maturing from static, to blobs, to recognizable digits over about 25 epochs. Every digit in the final grid is a drawing that exists nowhere in MNIST.

Why GAN training is famously unstable

Unlike every loss you've minimized so far, a GAN has no single number going reliably down — it's two losses chasing each other, and the "landscape" moves whenever either player does. The classic failure modes:

  • Mode collapse. The generator finds one output that reliably fools the discriminator and produces only that — a GAN that draws convincing 1s and nothing else. Diversity dies because the objective never explicitly demands it. (Our 1-D demo showed a cousin of this: on two-mode data, a too-simple generator drifts to one mode or smears across both.)
  • Non-convergence. The players can circle each other forever — generator adapts, discriminator re-adapts, losses oscillate — without approaching equilibrium, exactly like the residual wobble in the 1-D demo.
  • Imbalance. A discriminator that wins too hard gives the generator near-zero gradients; one that's too weak gives it meaningless guidance.

The battle-tested tricks, most from the DCGAN paper, and already baked into the code above: learning rate 2e-4 with Adam betas (0.5, 0.999) (the lower momentum term damps the oscillation), BatchNorm in both networks, LeakyReLU in the discriminator, Tanh output with inputs normalized to [-1, 1], and strided convolutions instead of pooling. Later research added better objectives (Wasserstein loss, spectral normalization) attacking the same instabilities.

GANs today

Honest modern context: for image generation, diffusion models — the engines behind Stable Diffusion and friends — have largely displaced GANs. They optimize a plain denoising objective (an idea you already met in the denoising autoencoder!), which sidesteps the two-player instability and covers modes much more reliably. But GANs remain in real use where speed matters — a GAN generates in one forward pass versus a diffusion model's many denoising steps — for super-resolution, image-to-image translation, and as adversarial components: many state-of-the-art systems still bolt on a discriminator as an extra "does this look real?" loss. The adversarial idea outlived the architecture.

Check your understanding

5 questions · free
  1. Q1.What does the generator take as input, and what does it never see?

  2. Q2.At the theoretical equilibrium of the GAN game, what does the discriminator output?

  3. Q3.Why is fake.detach() used when computing the discriminator's loss?

  4. Q4.A trained GAN produces excellent images — but almost every sample is the same digit 1. What is this failure called?

  5. Q5.Which is a standard stability recipe for training DCGANs?

Exercise: Watch a generator struggle with two modes

In the 1-D PyRunner above, make the real data bimodal: half the samples from N(0, 0.5) and half from N(6, 0.5). Rerun the training and look at the final histogram and the learned mu and sigma. Where did the generator put its probability mass, and why is this a miniature version of mode collapse? Write two sentences explaining what the generator would need in order to fix it.

That's a wrap — not just on generative models, but on the entire deep learning course. You started with a single neuron and ended by training two networks to out-scheme each other into drawing digits from pure noise: backpropagation, CNNs, transfer learning, RNNs, embeddings, autoencoders, and GANs are all in your toolkit now. Congratulations on finishing! When you're ready for more, head back to the course catalog and pick your next adventure.