TAI BUI
Back to curriculum
BuildPython

Image Generation — GANs

A GAN is two neural networks in a fixed game: one draws, one critiques, and both get better until the drawings fool the critic.

TIME: ~75 minutesPREREQUISITES: Phase 4 Lesson 03 (CNNs), Phase 3 Lesson 06 (Optimizers), Phase 3 Lesson 07 (Regularization)

Learning Objectives

  • Explain the minimax game between generator and discriminator and why the equilibrium corresponds to p_model = p_data
  • Implement a DCGAN in PyTorch and get it to generate coherent 32x32 synthetic images in under 60 lines
  • Stabilise GAN training with the three standard tricks: non-saturating loss, spectral norm, TTUR (two-timescale update rule)
  • Read training curves that distinguish healthy convergence from mode collapse, oscillation, and discriminator-wins-completely

The Problem

Classification teaches a network to map images to labels. Generation flips that: sample new images that look like they came from the same distribution as the training set. There's no "correct" output to diff against, only a distribution you're trying to mimic.

Standard loss functions can't do that job. MSE and cross-entropy have no way to score "did this sample come from the real distribution," so minimising per-pixel error just produces blurry averages, not realistic samples. The fix was to learn the loss itself: train a second network whose only job is telling real from fake, then use its judgement to push the generator toward samples that fool it.

GANs (Goodfellow et al., 2014) defined that framework. By 2018, StyleGAN was producing 1024x1024 faces indistinguishable from photographs. Diffusion models have since taken over on quality and controllability, but every trick that makes diffusion practical (normalisation choices, latent spaces, feature losses) was first worked out on GANs.

Warm-up0/3
1. In basic gradient descent, if the loss surface is nearly flat (gradient close to zero) at a network's current output, what happens to training?Warm-up
A loss curve plotted against a weight value, with a dot marking the network's current weights sitting on a long flat plateau, and a short arrow there labelled almost flat here, next to a steeper part of the same curve further along labelled steep here.
Illustrative loss curve: a flat plateau next to a steep drop, with the current weights on the flat part.
2. A standard CNN's max-pooling layer downsamples a feature map by keeping the largest value in each small window. Does pooling have any trainable weights of its own?Warm-up
A 4x4 grid of numbers, divided into four 2x2 windows outlined in orange, with an arrow to a 2x2 output grid holding the largest number from each window.
Illustrative: a worked 2x2, stride-2 max-pooling example on a small grid of numbers.
3. Picture two systems trained against each other: one produces counterfeit bills, the other tries to catch them. If the counterfeit-detector gets sharply better while the counterfeiter's skill stays the same, what happens to the counterfeiter's success rate?Warm-up
A flat line labelled counterfeiter skill stays the same, next to a rising line labelled detector sharpness gets sharply better, above a dashed box marked question mark, counterfeiter's success rate.
The counterfeiter's unchanging skill against the detector's rising sharpness; the resulting success rate is not shown.
Check: The Problem0/1
1. Why does minimising per-pixel error such as MSE fail to produce realistic generated samples?Explain
Three colored squares side by side, red, green and blue, each labelled a valid sample, with a small question mark beneath them standing for a single per-pixel target.
Illustrative: three equally valid outputs for the same input; what a single per-pixel target would look like is not shown.

The Concept

The two networks

The generator G takes a vector of noise z and outputs an image. The discriminator D takes an image and outputs a single scalar: the probability that the image is real.

Mermaid diagram

The game

G wants D to be wrong. D wants to be right. Formally:

min_G max_D  E_x[log D(x)] + E_z[log(1 - D(G(z)))]

Read right to left: D is maximising accuracy on real (log D(real)) and fake (log (1 - D(fake))) images, and G is minimising D's accuracy on fakes, since it wants D(G(z)) to be high.

Goodfellow proved that this minimax has a global equilibrium where p_G = p_data, D outputs 0.5 everywhere, and the Jensen-Shannon divergence (a symmetric measure of how far apart two probability distributions are) between generated and real distributions is zero. Getting there is the hard part.

Non-saturating loss

The form above is numerically unstable. Early in training, D(G(z)) is near zero for every fake, so log(1 - D(G(z))) has vanishing gradients with respect to G. The fix: flip G's loss.

L_D = -E_x[log D(x)] - E_z[log(1 - D(G(z)))]
L_G = -E_z[log D(G(z))]                          # non-saturating

Now when D(G(z)) is near zero, G's loss is large and its gradient is informative. Every modern GAN trains with this variant.

DCGAN architecture rules

Radford, Metz, Chintala (2015) distilled years of failed experiments into five rules that make GAN training stable:

  1. Replace pooling with strided convs (both nets).
  2. Use batch norm in both generator and discriminator, except output of G and input of D.
  3. Remove fully connected layers on deeper architectures.
  4. G uses ReLU on all layers except output (tanh for output in [-1, 1]).
  5. D uses LeakyReLU (negative_slope=0.2) on all layers.

Every modern conv-based GAN (StyleGAN, BigGAN, GigaGAN) still starts from these rules and replaces pieces one at a time.

Failure modes and their signatures

  • Mode collapse: G finds one image that fools D and produces only that. Fix: add minibatch discrimination, spectral norm, or label-conditioning.
  • Discriminator wins: D becomes too strong too fast, and G's gradients vanish. Fix: smaller D, lower D learning rate, or apply label smoothing on the real labels.
  • Oscillation: the two nets trade wins without ever approaching equilibrium. Fix: TTUR (D learns faster than G by a factor of 2-4), or switch to Wasserstein loss.

Mermaid diagram

Evaluation

GANs have no ground truth, so how do you know they're working?

  • Sample inspection: look at 64 samples at the end of every epoch. Non-negotiable.
  • FID (Fréchet Inception Distance): distance between Inception-v3 feature distributions of real and generated sets. Lower is better, and it's the community standard.
  • Inception Score: older and more brittle. Prefer FID.
  • Precision/Recall for generative models: measures quality (precision) and coverage (recall) separately, which is more informative than FID alone.

For a small synthetic-data run, sample inspection is enough.

Check: The Concept0/17
1. What does the generator in a GAN take as input and produce as output?Define
A box labelled noise z, 64-dim vector, an arrow into a box labelled G, and an arrow into a box labelled image, 3 colour channels.
What goes into the generator and what comes out of it.
2. What is the discriminator in a GAN?Define
A box labelled image, real or generated, an arrow into a box labelled D, and an arrow into a box labelled scalar, in the range 0 to 1.
What goes into the discriminator and the kind of value it outputs.
3. In the GAN minimax objective, which network minimises the objective and which maximises it?Define
The formula E_x[log D(x)] plus E_z[log(1 - D(G(z)))], above two boxes labelled G and D, each updating its weights, and a dashed box standing for each network's role in the objective.
The GAN objective written out, with each network's role in it left open.
4. What is the non-saturating generator loss?Define
The formula log(1 - D(G(z))) in a code box, plotted against D(G(z)) from 0 to 1, next to a dashed box marked question mark, used instead.
The original generator loss term over the full range of D(G(z)); the expression that replaces it is not shown.
5. Which layers do the DCGAN architecture rules use in place of pooling?Define
Four boxes in a row, image, feature map, feature map and scalar, with each connecting arrow marked with a question mark, and a note that every step shrinks the feature map and no pooling layer appears anywhere.
The discriminator's chain from image to scalar, with the layer that does each step left unresolved.
6. Beyond replacing pooling with strided convolutions, which other DCGAN architecture rule does the lesson list?Define
A numbered list of five DCGAN rules, with rule one written out as replacing pooling with strided convs and rules two to five each shown as a question mark.
The lesson's five DCGAN architecture rules, with only the one the stem already names written out.
7. You train a GAN and notice G produces almost identical samples regardless of the input noise. What is this failure called and what does it mean?Define
A 4 by 4 grid of 16 generated samples, every one an orange circle of almost the same size, color and position, varying only by a tiny jitter.
Illustrative: 16 samples from a trained generator that all look nearly identical, matching the stem's description.
8. What does TTUR (two-timescale update rule) change about GAN training?Define
A box labelled G with opt_g and lr_G and a box labelled D with opt_d and lr_D, above a dashed box marked question mark, what TTUR changes.
The two optimizers in the lesson's training script, one per network; what the two-timescale rule changes about them is not shown.
9. What does FID (Fréchet Inception Distance) measure?Define
A box for the real training images and a box for images sampled from the generator, above a dashed box marked question mark, what FID reports about them.
The two image sets an FID score compares; what the score itself reports is not shown.
10. In the minimax objective, why does G want D(G(z)) to be high?Explain
A chain of boxes: z into G into fake image into D, ending in a small dashed box marked question mark, with a note that this score is D(G(z)), the number G's own loss is built from.
The pipeline from noise to D's score on the fake it produces; the value of that score, and which way G wants it to move, are not shown.
11. Why does GAN training use the non-saturating generator loss -log(D(G(z))) instead of log(1 - D(G(z)))?Explain
The two generator-loss expressions from the stem in a code box, the second one highlighted as the form training actually runs, above a dashed box marked question mark, why training swaps the first for the second.
The two loss forms the stem compares, both acting on D(G(z)); the reason for the swap is not shown.
12. What characterizes the global equilibrium of the GAN minimax game that Goodfellow proved?Explain
A real image and a fake from G both feeding into a box labelled D, whose two outputs are written as D(real) equals question mark and D(fake) equals question mark.
D scoring a real image and a fake once training has reached the equilibrium; the scores themselves are not shown.
13. How does oscillation differ from mode collapse and discriminator-wins, and what are its fixes?Explain
Two loss curves, D loss and G loss, weaving back and forth and repeatedly crossing each other over epochs without settling toward a shared value.
Illustrative: D's and G's loss curves repeatedly crossing over epochs, neither one settling.
14. Besides FID and Inception Score, precision/recall for generative models is an evaluation approach. What does it measure that a single FID number does not?Explain
A badge labelled FID, one number, next to two boxes labelled precision and recall, each with an unresolved value.
The single FID number the stem names, next to precision and recall with their meanings left open.
15. A colleague reports a GAN with D loss near zero and G loss increasing over training. What is happening?Analyze
A loss curve for D dropping to near 0 and staying there, next to a loss curve for G that keeps climbing, over the same epochs.
Illustrative: the D loss near zero and the rising G loss the colleague describes, exactly as given in the stem.
16. Your GAN's discriminator and generator losses keep trading the lead epoch after epoch, never settling. Which technique, giving the discriminator a faster learning rate than the generator, addresses this?Analyze
D loss and G loss crossing back and forth repeatedly across four epochs, neither one pulling ahead for long.
Illustrative: D's and G's losses trading the lead epoch after epoch, matching the stem's description.
17. You train the DCGAN on 2000 synthetic circles as a quick experiment. Which evaluation is enough for this run?Design
A strip of five training images, each a coloured circle on a dark square, above a box for the DCGAN trained on them and a box for its samples noting there is nothing to diff them against, next to a dashed box marked question mark, evaluation that is enough here.
Illustrative training images for the 2000-circle run, with the generator it trains and the samples it produces; which evaluation is enough is not shown.

Build It

Step 1: Generator

A small DCGAN generator that takes 64-dim noise and produces a 32x32 image.

python
import torch
import torch.nn as nn
 
class Generator(nn.Module):
    def __init__(self, z_dim=64, img_channels=3, feat=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.ConvTranspose2d(z_dim, feat * 4, kernel_size=4, stride=1, padding=0, bias=False),
            nn.BatchNorm2d(feat * 4),
            nn.ReLU(inplace=True),
            nn.ConvTranspose2d(feat * 4, feat * 2, kernel_size=4, stride=2, padding=1, bias=False),
            nn.BatchNorm2d(feat * 2),
            nn.ReLU(inplace=True),
            nn.ConvTranspose2d(feat * 2, feat, kernel_size=4, stride=2, padding=1, bias=False),
            nn.BatchNorm2d(feat),
            nn.ReLU(inplace=True),
            nn.ConvTranspose2d(feat, img_channels, kernel_size=4, stride=2, padding=1, bias=False),
            nn.Tanh(),
        )
 
    def forward(self, z):
        return self.net(z.view(z.size(0), -1, 1, 1))

Four transposed convs, each with kernel_size=4, stride=2, padding=1 so they cleanly double spatial size. Output activations in [-1, 1] via tanh.

Step 2: Discriminator

Mirror of the generator. LeakyReLU, strided convs, ends with a scalar logit.

python
class Discriminator(nn.Module):
    def __init__(self, img_channels=3, feat=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(img_channels, feat, kernel_size=4, stride=2, padding=1),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(feat, feat * 2, kernel_size=4, stride=2, padding=1, bias=False),
            nn.BatchNorm2d(feat * 2),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(feat * 2, feat * 4, kernel_size=4, stride=2, padding=1, bias=False),
            nn.BatchNorm2d(feat * 4),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(feat * 4, 1, kernel_size=4, stride=1, padding=0),
        )
 
    def forward(self, x):
        return self.net(x).view(-1)

The last conv reduces a 4x4 feature map to 1x1. Output is a single scalar per image; apply sigmoid only during loss computation.

Step 3: Training step

Alternate: update D once, then G once, every batch.

python
import torch.nn.functional as F
 
def train_step(G, D, real, z, opt_g, opt_d, device):
    real = real.to(device)
    bs = real.size(0)
 
    # D step
    opt_d.zero_grad()
    d_real = D(real)
    d_fake = D(G(z).detach())
    loss_d = (F.binary_cross_entropy_with_logits(d_real, torch.ones_like(d_real))
              + F.binary_cross_entropy_with_logits(d_fake, torch.zeros_like(d_fake)))
    loss_d.backward()
    opt_d.step()
 
    # G step
    opt_g.zero_grad()
    d_fake = D(G(z))
    loss_g = F.binary_cross_entropy_with_logits(d_fake, torch.ones_like(d_fake))
    loss_g.backward()
    opt_g.step()
 
    return loss_d.item(), loss_g.item()

G(z).detach() in the D step is critical: we don't want gradients flowing into G during its update. Forgetting that is the classic beginner bug.

Step 4: Full training loop on synthetic shapes

python
from torch.utils.data import DataLoader, TensorDataset
import numpy as np
 
def synthetic_images(num=2000, size=32, seed=0):
    rng = np.random.default_rng(seed)
    imgs = np.zeros((num, 3, size, size), dtype=np.float32) - 1.0
    for i in range(num):
        r = rng.uniform(6, 12)
        cx, cy = rng.uniform(r, size - r, size=2)
        yy, xx = np.meshgrid(np.arange(size), np.arange(size), indexing="ij")
        mask = (xx - cx) ** 2 + (yy - cy) ** 2 < r ** 2
        color = rng.uniform(-0.5, 1.0, size=3)
        for c in range(3):
            imgs[i, c][mask] = color[c]
    return torch.from_numpy(imgs)
 
device = "cuda" if torch.cuda.is_available() else "cpu"
data = synthetic_images()
loader = DataLoader(TensorDataset(data), batch_size=64, shuffle=True)
 
G = Generator(z_dim=64, img_channels=3, feat=32).to(device)
D = Discriminator(img_channels=3, feat=32).to(device)
opt_g = torch.optim.Adam(G.parameters(), lr=2e-4, betas=(0.5, 0.999))
opt_d = torch.optim.Adam(D.parameters(), lr=2e-4, betas=(0.5, 0.999))
 
for epoch in range(10):
    for (batch,) in loader:
        z = torch.randn(batch.size(0), 64, device=device)
        ld, lg = train_step(G, D, batch, z, opt_g, opt_d, device)
    print(f"epoch {epoch}  D {ld:.3f}  G {lg:.3f}")

Adam(lr=2e-4, betas=(0.5, 0.999)) is the DCGAN default. The low beta1 keeps the momentum term from stabilising the adversarial game too much.

Step 5: Sampling

python
@torch.no_grad()
def sample(G, n=16, z_dim=64, device="cpu"):
    G.eval()
    z = torch.randn(n, z_dim, device=device)
    imgs = G(z)
    imgs = (imgs + 1) / 2
    return imgs.clamp(0, 1)

Always switch to eval mode before sampling. For DCGAN this matters because batch norm running stats are used instead of the batch's stats.

Step 6: Spectral normalisation

A drop-in replacement for BN in the discriminator that guarantees the network is 1-Lipschitz (its output can't shift faster than a fixed rate as the input shifts). Fixes most "D wins too hard" failures.

python
from torch.nn.utils import spectral_norm
 
def build_sn_discriminator(img_channels=3, feat=64):
    return nn.Sequential(
        spectral_norm(nn.Conv2d(img_channels, feat, 4, 2, 1)),
        nn.LeakyReLU(0.2, inplace=True),
        spectral_norm(nn.Conv2d(feat, feat * 2, 4, 2, 1)),
        nn.LeakyReLU(0.2, inplace=True),
        spectral_norm(nn.Conv2d(feat * 2, feat * 4, 4, 2, 1)),
        nn.LeakyReLU(0.2, inplace=True),
        spectral_norm(nn.Conv2d(feat * 4, 1, 4, 1, 0)),
    )

Swap Discriminator for build_sn_discriminator() and you often won't need the TTUR trick. Spectral norm is the easiest single stability upgrade you can apply.

Check: Build It0/11
1. What does spectral normalisation do to the layers of the discriminator?Define
The opening of the lesson's spectral-norm discriminator, with the first conv layer wrapped in spectral_norm highlighted and the rest of the stack elided, above a dashed box marked question mark, property guaranteed.
Spectral norm wrapping the discriminator's conv layers, taken from the lesson's own code; what property it guarantees is not stated.
2. Why should the generator be switched to eval mode before sampling in a DCGAN?Explain
The lesson's sample function, with the line G.eval() highlighted, above a dashed box marked question mark, what eval() changes.
The exact line that switches the generator to eval mode before sampling; what that changes is not stated.
3. Why do DCGAN training scripts use Adam with betas=(0.5, 0.999) instead of a default beta1 of 0.9?Explain
Both optimizer lines from the lesson's training script, betas set to 0.5 and 0.999, above a small dashed box marked question mark, why 0.5 instead of PyTorch's default 0.9.
The exact betas the lesson's optimizers use; the reason for the lower first value is not stated.
4. In the lesson's training step, why does the discriminator update call `G(z).detach()` instead of just `G(z)`?Explain
The D step of train_step, with the line d_fake = D(G(z).detach()) highlighted, above a dashed box marked question mark, why .detach() here.
The exact line where the lesson calls .detach() on G's output during the discriminator's update; the reason is not stated.
5. With z_dim=64 and a batch of 8 noise vectors, what is the output shape of the lesson's Generator.forward?Apply
A chain of five boxes starting at (8,64,1,1), each following box marked with a question mark, with the four arrows between them labelled with their kernel, stride and padding: k4 s1 p0, then k4 s2 p1 three times.
The generator's four transposed-conv layers and their kernel, stride and padding; every size after the input is left unresolved.
6. The discriminator receives a 32x32 image. What spatial size reaches its final Conv2d (kernel 4, stride 1, padding 0)?Apply
A chain of four boxes starting at 32x32, each later box marked with a question mark, with the three arrows between them labelled k4, s2, p1, and a note that a fourth conv follows after this chain.
The discriminator's first three strided convs; the size each one produces is left unresolved.
7. In the training script, feat=32. How many output channels does the discriminator's second Conv2d produce?Apply
Four boxes labelled conv 1 to conv 4, each with its output channel count shown as a question mark, the second one highlighted, and a note that the training script builds the discriminator with feat equal to 32.
The discriminator's four conv layers with feat fixed at 32, and every output channel count left unresolved.
8. In train_step, the G step computes binary_cross_entropy_with_logits(d_fake, torch.ones_like(d_fake)). Which generator loss does this implement?Apply
The G step of train_step, with the binary_cross_entropy_with_logits call against torch.ones_like(d_fake) highlighted, above a dashed box marked question mark, which loss formula is this.
The exact G-step code from the lesson's training function; which loss formula it computes is not stated.
9. The generator outputs a pixel value of 0.0 after tanh. What value does sample() return for that pixel?Apply
The sample function's rescaling line, (imgs + 1) / 2, above a dashed box marked question mark, pixel 0.0 maps to.
The exact rescaling line sample() applies to the generator's tanh output; the result for an input of 0.0 is not shown.
10. Your discriminator keeps winning too hard, so you swap the batch-norm discriminator for the spectral-norm one. Which extra trick does the lesson say you often no longer need?Analyze
A box for the batch-norm discriminator with an arrow into a box for the spectral-norm discriminator, above a dashed box marked question mark, extra trick often no longer needed.
Swapping the discriminator's normalisation from batch norm to spectral norm; what extra trick that swap often makes unnecessary is not stated.
11. Your from-scratch GAN produces garbage and you have spent days on the loss math. According to the lesson, what should you check first?Analyze
Four tiles of grey noise standing for the generator's current samples, above a dashed box labelled loss math, re-checked for days, next to a dashed box marked question mark, what else is left to check.
Illustrative: garbage samples after days spent on the loss math; what else might be the actual bug is not shown.

Use It

For serious generation, use pretrained weights or switch to diffusion. Two standard libraries:

  • torch_fidelity computes FID / IS on your generator without writing custom eval code.
  • pytorch-gan-zoo (legacy) and StudioGAN ship tested implementations of DCGAN, WGAN-GP, SN-GAN, StyleGAN, and BigGAN.

In 2026, GANs are still the best choice for real-time image generation (latency under 10 ms), style transfer, and image-to-image translation with precise control (Pix2Pix, CycleGAN). Diffusion wins on photorealism and text conditioning.

Check: Use It0/1
1. A product needs sketch-to-photo translation with precise control and under 10 ms latency per image. Which approach fits the lesson's guidance?Design
Two badges, latency under 10 milliseconds and precise control, sketch to photo, above a dashed box marked question mark, which approach fits both.
The two requirements the product states; which approach fits them is not shown.

Ship It

This lesson produces:

  • outputs/prompt-gan-training-triage.md: a prompt that reads a training curve description and picks the failure mode (mode collapse, D-wins, oscillation) plus the single recommended fix.
  • outputs/skill-dcgan-scaffold.md: a skill that writes a DCGAN scaffold from z_dim, target image_size, and num_channels, including a training loop and sample saver.
Author's Note

The DCGAN rules read like a checklist you can skim past, and that's exactly why so many from-scratch GAN attempts fail in the first afternoon. I've watched people burn a week hunting for a subtle bug in their loss math when the real problem was a missing .detach() call letting gradients leak into the generator during the discriminator's update, or a stray max-pool layer that should have been a strided conv. Before touching the loss function, check the five architecture rules and the training loop mechanics first. That's where almost every early GAN failure actually lives.

Diffusion has won on raw image quality, and I don't expect that to reverse. But GANs still earn their place when you need a sample in single-digit milliseconds, or precise pixel-level control over a translation (turn this sketch into this photo, exactly, not approximately). Learn this game and you'll recognise its shape everywhere else: adversarial losses show up in speech vocoders, in imitation learning for RL, and in half the discriminator-guided tricks now bolted onto diffusion samplers.

Exercises

  1. (Easy) Train the DCGAN above on the synthetic circle dataset and save a grid of 16 samples at the end of each epoch. By which epoch do the generated circles become clearly circular?
  2. (Medium) Replace the discriminator's batch norm with spectral norm. Train both versions side by side. Which one converges faster? Which one has lower variance across three seeds?
  3. (Hard) Implement a conditional DCGAN: feed the class label into both G and D (concat one-hot to the noise in G, concat a class embedding channel in D). Train on the synthetic "circles vs squares" dataset from lesson 7 and show that class conditioning works by sampling with specific labels.

Key Terms

TermWhat people sayWhat it actually means
Generator (G)"The draws-stuff net"Maps noise to images; trained to fool the discriminator
Discriminator (D)"The critic"Binary classifier; trained to distinguish real from generated images
Minimax"The game"min over G, max over D of an adversarial loss; equilibrium is p_G = p_data
Non-saturating loss"The numerically sane version"G's loss is -log(D(G(z))) instead of log(1 - D(G(z))) to avoid vanishing gradients early in training
Mode collapse"Generator makes one thing"G produces only a small subset of the data distribution; fix with SN, minibatch discrimination, or larger batch
TTUR"Two learning rates"D learns faster than G, typically by a factor of 2-4; stabilises training
Spectral norm"1-Lipschitz layer"A weight-normalisation that bounds each layer's Lipschitz constant; stops D from becoming arbitrarily steep
FID"Fréchet Inception Distance"Distance between Inception-v3 feature distributions of real and generated sets; the standard evaluation metric

Further Reading