Skip to content
ML to LLMs

Module 13: Deep-learning architectures 40 min10 min quick readDeep learning

GANs, generator versus discriminator

Train a generator and a discriminator against each other, derive the optimal discriminator and the non-saturating loss, and diagnose mode collapse and vanishing gradients.

Before this: Autoencoders, diffusion, and transfer learning, Backpropagation, Loss functions

Read on GitHub

Quick read: the intuition, interactive visualization, summary, and interview questions. The math, implementation, engineering, and knowledge-check sections are hidden.

Mental model. A forger and a detective train together. The forger (generator) turns random noise into fake samples. The detective (discriminator) looks at real and fake samples and says how likely each one is to be real. Every time the detective learns a new tell, the forger gets a gradient that says "move your fakes this way". If they reach a stalemate where the detective can only guess, the fakes are distributed like the real data.

You will learn to

  • Write the GAN minimax objective and the two alternating training steps, with shapes.
  • Derive the optimal discriminator D∗(x)=pdata/(pdata+pg)D^*(x) = p_{\text{data}}/(p_{\text{data}} + p_g) and explain the 0.5 equilibrium.
  • Compute why the original generator loss saturates and the non-saturating loss doesn't.
  • Implement a GAN with hand-written backprop and recognize mode collapse.
  • Explain where adversarial training lives on in 2026: perceptual and adversarial losses in autoencoders and fast distilled diffusion samplers.

Why it matters. GANs introduced a way to learn a generator without writing down a likelihood: train a classifier to tell real from fake and use its gradient as the training signal. Diffusion and flow matching now produce the best open-domain images, but the adversarial idea stayed: Stable Diffusion's autoencoder is trained with an adversarial loss, and 1 to 4 step distilled samplers like SDXL-Turbo use one. GAN failure modes (mode collapse, a too-strong discriminator) are also classic interview material because they test whether you understand gradients, not just architectures.

1. Intuition

No likelihood, just a critic. An autoencoder trained with squared error averages over plausible outputs, which makes images blurry: the mean of many sharp faces is a blurry face. A GAN never measures pixel distance. Its generator GG only learns from whether the discriminator DD was fooled. A sample that looks plausible is rewarded, even if it matches no particular training image.

Two networks, alternating steps.

  1. Train DD for one step as an ordinary binary classifier: real samples labeled 1, generated samples labeled 0.
  2. Train GG for one step to make DD say "real" on fresh fakes. The gradient flows through DD (frozen for this step) into GG.

What the gradient means. DD's output is a landscape over input space: high where it thinks real data lives. GG's update moves each fake sample uphill on that landscape. As GG improves, DD must redraw the landscape. At equilibrium DD outputs 0.5 everywhere the data lives, so there is no uphill left.

Why it breaks. The game has no guarantee of converging. Two common failures:

  • Vanishing gradient. If DD becomes too confident early, the original generator loss gives almost no gradient. The fix is the non-saturating loss (section 3).
  • Mode collapse. GG finds one region DD currently accepts and puts all its samples there. DD's gradient only acts where fakes are, so nothing pulls GG toward modes it never visits.

2. Visualization

Left: histograms of generator samples during training on synthetic two-mode data at -2 and +2. At step 0 the samples are a broad blob around 0; at step 300 they spread from -4.5 to 4.5; at step 1000 two peaks form near -2 and +2; at step 3000 they match the real data. Right: final generators. The normal run covers both modes; a hard-start run whose generator began near x = 8 puts all its samples on the +2 mode, which is mode collapse.

Synthetic data: a 50/50 mixture of Gaussians at −2-2 and +2+2 with standard deviation 0.4. Both networks are 2-hidden-layer MLPs with 32 units, trained with Adam in NumPy. In the hard-start run, GG's output bias begins at 8 and DD is pre-trained for 300 steps: the fakes walk to the nearest real mode and stay there.

Try it (predict first, then run the script and check):

  1. In the normal run, D(real)D(\text{real}) and D(fake)D(\text{fake}) stay near 0.50 the whole time. Predict whether that means DD is useless, and what GG would do if DD stopped training.
  2. In the hard start, predict which mode the generator lands on and why it doesn't spread to the other one.
  3. Change g_start=8.0 to g_start=-8.0. Predict which mode wins now.
  4. Call train(1, non_saturating=False) from the normal start. Predict whether it still learns, then do the same with the hard start.

3. The math

Symbols

SymbolMeaningShape
xxa real sample[d][d] (here d=1d = 1)
zznoise input to the generator, z∼N(0,I)z \sim \mathcal{N}(0, I)[dz][d_z] (here 4)
Gθ(z)G_\theta(z)generator; outputs a fake sample x^\hat{x}[d][d]
Dψ(x)D_\psi(x)discriminator; probability that xx is real, σ(a)\sigma(a) of a logit aascalar in (0,1)(0, 1)
pdata,pgp_{\text{data}}, p_gdensity of real data and of generator samplesfunctions of xx
V(D,G)V(D, G)the game valuescalar

The minimax game

min⁡Gmax⁡D  V(D,G)=Ex∼pdata[log⁡D(x)]+Ez[log⁡(1−D(G(z)))]\min_G \max_D \; V(D, G) = \mathbb{E}_{x \sim p_{\text{data}}}\big[\log D(x)\big] + \mathbb{E}_{z}\big[\log\big(1 - D(G(z))\big)\big]

The DD step maximizes VV, which is the same as minimizing binary cross-entropy with real = 1 and fake = 0:

LD=−log⁡D(x)−log⁡(1−D(G(z)))L_D = -\log D(x) - \log\big(1 - D(G(z))\big)

The optimal discriminator

For a fixed GG, maximize pdata(x)log⁡D(x)+pg(x)log⁡(1−D(x))p_{\text{data}}(x) \log D(x) + p_g(x) \log(1 - D(x)) at each point xx. Setting the derivative to 0, pdata/D−pg/(1−D)=0p_{\text{data}}/D - p_g/(1 - D) = 0, gives

D∗(x)=pdata(x)pdata(x)+pg(x)D^*(x) = \frac{p_{\text{data}}(x)}{p_{\text{data}}(x) + p_g(x)}

Substituting D∗D^* back in gives V=−log⁡4+2 JSD(pdata ∥ pg)V = -\log 4 + 2\,\text{JSD}(p_{\text{data}} \,\|\, p_g), where JSD is the Jensen-Shannon divergence. It is minimized, at −log⁡4-\log 4, exactly when pg=pdatap_g = p_{\text{data}}, and then D∗(x)=0.5D^*(x) = 0.5 everywhere.

Saturating versus non-saturating generator loss

The original generator loss is log⁡(1−D(G(z)))\log(1 - D(G(z))), to be minimized. The fix that Goodfellow et al. proposed in the same paper is to maximize log⁡D(G(z))\log D(G(z)) instead:

LGsat=log⁡(1−D(G(z))),LGNS=−log⁡D(G(z))L_G^{\text{sat}} = \log\big(1 - D(G(z))\big), \qquad L_G^{\text{NS}} = -\log D(G(z))

With D=σ(a)D = \sigma(a), the gradients with respect to the logit aa are

∂LGsat∂a=−D,∂LGNS∂a=−(1−D)\frac{\partial L_G^{\text{sat}}}{\partial a} = -D, \qquad \frac{\partial L_G^{\text{NS}}}{\partial a} = -(1 - D)

Worked example: a confident discriminator

Early in training DD easily rejects a fake: D(G(z))=0.01D(G(z)) = 0.01, logit a=ln⁡(0.01/0.99)=−4.595a = \ln(0.01/0.99) = -4.595.

  • Saturating: ∂L/∂a=−0.01\partial L / \partial a = -0.01.
  • Non-saturating: ∂L/∂a=−(1−0.01)=−0.99\partial L / \partial a = -(1 - 0.01) = -0.99.

Both point the same way, but the non-saturating gradient is 0.99/0.01=990.99 / 0.01 = 99 times larger, exactly when GG most needs to move.

Worked example: one discriminator loss

D(x)=0.8D(x) = 0.8 on a real sample and D(G(z))=0.3D(G(z)) = 0.3 on a fake.

LD=−ln⁡0.8−ln⁡(1−0.3)=0.2231+0.3567=0.5798L_D = -\ln 0.8 - \ln(1 - 0.3) = 0.2231 + 0.3567 = 0.5798

The non-saturating generator loss on that fake is −ln⁡0.3=1.204-\ln 0.3 = 1.204. At equilibrium, with D=0.5D = 0.5 everywhere, LD=−2ln⁡0.5=1.386=ln⁡4L_D = -2\ln 0.5 = 1.386 = \ln 4.

Worked example: optimal D at one point

At some xx, pdata(x)=0.3p_{\text{data}}(x) = 0.3 and pg(x)=0.1p_g(x) = 0.1: D∗(x)=0.3/(0.3+0.1)=0.75D^*(x) = 0.3 / (0.3 + 0.1) = 0.75. GG is under-producing samples here, and the generator's gradient will push fakes toward this xx.

4. Implementation

The full script is code/13-deep-architectures/gan_scratch.py (NumPy only, about 4 seconds on a laptop CPU).

Build it from scratch

Step 1: the data and a small MLP. Real data is a two-mode mixture, shape [n, 1]. Both networks are the same MLP class: LeakyReLU hidden layers (no dead units for the generator's gradient to get stuck in) and a linear output. GG maps [n, 4] -> [n, 1]; DD maps [n, 1] -> [n, 1] logits.

python
def sample_real(rng, n):
    return (np.where(rng.random(n) < 0.5, -2.0, 2.0) + 0.4 * rng.standard_normal(n))[:, None]

    def forward(self, x):
        p = self.p
        a1 = x @ p["W1"] + p["b1"]
        h1 = np.where(a1 > 0, a1, 0.2 * a1)
        a2 = h1 @ p["W2"] + p["b2"]
        h2 = np.where(a2 > 0, a2, 0.2 * a2)
        self.cache = (x, a1, h1, a2, h2)
        return h2 @ p["W3"] + p["b3"]

Predict: what range of values can GG's output take, and why doesn't DD's forward end in a sigmoid?

Step 2: backprop that also returns the input gradient. The generator's update needs ∂L/∂x^\partial L / \partial \hat{x}, the gradient with respect to DD's input. So backward returns parameter gradients and d1 @ W1.T, the gradient flowing out of the first layer.

python
    def backward(self, d_out):
        x, a1, h1, a2, h2 = self.cache
        p = self.p
        g = {"W3": h2.T @ d_out, "b3": d_out.sum(0)}
        d2 = (d_out @ p["W3"].T) * np.where(a2 > 0, 1, 0.2)
        g["W2"], g["b2"] = h1.T @ d2, d2.sum(0)
        d1 = (d2 @ p["W2"].T) * np.where(a1 > 0, 1, 0.2)
        g["W1"], g["b1"] = x.T @ d1, d1.sum(0)
        return g, d1 @ p["W1"].T                  # parameter grads, input grad

Predict: the input gradient has which shape for a batch of 64 one-dimensional fakes?

Step 3: the discriminator step. Ordinary logistic regression on top of the MLP: stack real and fake, label 1 and 0. The gradient of binary cross-entropy with respect to the logit is sigmoid(logit) - label.

python
def d_step(D, real, fake, lr):
    x = np.vstack([real, fake])
    y = np.vstack([np.ones_like(real), np.zeros_like(fake)])
    p = sigmoid(D.forward(x))
    g, _ = D.backward((p - y) / len(x))
    D.adam(g, lr)
    return p[: len(real)].mean(), p[len(real):].mean()

Predict: in this step, does any gradient reach GG's weights?

Step 4: the generator step. Fresh noise, a forward pass through GG then DD, the logit gradient from section 3, back through DD to its input, then back through GG. Only GG's optimizer steps; DD's weights are untouched.

python
def g_step(G, D, z, lr, non_saturating=True):
    fake = G.forward(z)
    p = sigmoid(D.forward(fake))
    # non-saturating: minimise -log D(G(z))   -> dL/dlogit = p - 1
    # saturating:     minimise log(1 - D(G(z))) -> dL/dlogit = -p
    d_logit = (p - 1) if non_saturating else -p
    _, d_fake = D.backward(d_logit / len(z))      # gradient flows through D to its input...
    g, _ = G.backward(d_fake)                    # ...then into G (D is NOT updated)
    G.adam(g, lr)

Predict: if you accidentally also called D.adam on these gradients, which way would DD move?

Step 5: the loop and a mode check. One DD step, then one GG step, 3,000 times. Adam uses β1=0.5\beta_1 = 0.5 (a common GAN default since DCGAN), since a lot of momentum makes the chase oscillate. The mode check counts samples within 1 of each mode.

python
        real = sample_real(rng, 64)
        fake = G.forward(rng.standard_normal((64, 4)))
        d_real, d_fake = d_step(D, real, fake, lr=1e-3)
        g_step(G, D, rng.standard_normal((64, 4)), lr=1e-3, non_saturating=non_saturating)

def coverage(xs):
    left = np.mean(np.abs(xs + 2) < 1)
    right = np.mean(np.abs(xs - 2) < 1)
    return left, right

Predict: a generator that outputs exactly +2+2 every time. What does coverage return, and what would a single-number "sample quality" metric say about it?

Use the library

In PyTorch, autograd handles both backward passes. The two details to get right are fake.detach() in the DD step (so DD's loss doesn't update GG) and using the "real" label in the GG step (the non-saturating loss).

python
import torch, torch.nn as nn, torch.nn.functional as F

mlp = lambda i, o: nn.Sequential(nn.Linear(i, 32), nn.LeakyReLU(0.2), nn.Linear(32, 32), nn.LeakyReLU(0.2), nn.Linear(32, o))
G, D = mlp(4, 1), mlp(1, 1)
opt_g = torch.optim.Adam(G.parameters(), lr=1e-3, betas=(0.5, 0.999))
opt_d = torch.optim.Adam(D.parameters(), lr=1e-3, betas=(0.5, 0.999))
bce = F.binary_cross_entropy_with_logits                    # sigmoid + BCE, numerically stable

for real in loader:                                         # real: [B, 1]
    fake = G(torch.randn(len(real), 4))
    d_loss = bce(D(real), torch.ones_like(real)) + bce(D(fake.detach()), torch.zeros_like(real))
    opt_d.zero_grad(); d_loss.backward(); opt_d.step()
    g_loss = bce(D(fake), torch.ones_like(real))            # non-saturating: -log D(G(z))
    opt_g.zero_grad(); g_loss.backward(); opt_g.step()

For images, GG and DD become convolutional networks (DCGAN: strided convolutions, batch norm in GG, LeakyReLU in DD), and stabilizers like spectral normalization in DD or a gradient penalty are standard.

Run it

bash
uv run code/13-deep-architectures/gan_scratch.py

Key lines of output:

text
saturating     loss log(1 - D): d/dlogit = -D       = -0.010
non-saturating loss -log D:     d/dlogit = -(1 - D) = -0.990  (99x larger)

== Non-saturating GAN, 3000 steps ==
 step | D(real) | D(fake)
    1 |    0.52 |    0.51
 3000 |    0.50 |    0.50
mode coverage: 0.45 near -2, 0.51 near +2 (data: 0.50 / 0.50 minus tails)

== Hard start: G's samples begin near x = 8, D pre-trained 300 steps, 1500 steps ==
seed | D(fake) at start | near -2 | near +2
   0 |            0.014 |    0.00 |    1.00

DD hovers near 0.5 because GG keeps up with it on this easy 1-D problem; the useful signal is in the small, shifting slopes of DD, not its average. All three hard-start seeds collapse onto the +2+2 mode.

5. Engineering

Where GANs stand in 2026. For open-domain text-to-image, video, and audio, diffusion and flow-matching models are the default: they train stably and cover the whole distribution. GANs remain strong in narrow domains (faces, a single product category, super-resolution) where their one-pass sampling is fast. The adversarial loss is everywhere:

  • Autoencoders for latent diffusion. Stable Diffusion's autoencoder is trained with reconstruction, perceptual (LPIPS), and patch-based adversarial losses so decoded images are sharp (latent diffusion).
  • Fast distilled samplers. SDXL-Turbo uses Adversarial Diffusion Distillation (an adversarial loss plus score distillation from a teacher) to sample in 1 to 4 steps; SD3-Turbo uses a latent variant. Not every fast sampler is adversarial: Latent Consistency Models use consistency distillation.

Stabilizers that matter. Non-saturating loss always. Adam with β1\beta_1 of 0 to 0.5. Spectral normalization on DD (bounds its slope). Wasserstein loss with gradient penalty (WGAN-GP) when DD overpowers GG. Two time-scale learning rates (TTUR: DD's learning rate higher than GG's). An exponential moving average of GG's weights for sampling. Data augmentation applied to both real and fake inputs of DD when data is small.

Evaluation. Losses don't tell you sample quality: they're a game, not a target. Use FID (Fréchet distance between Inception features of real and generated sets; lower is better) on enough samples (it is biased at small sample counts, so compare at a fixed count such as 50,000), precision and recall for generative models (quality versus coverage separately), and look at sample grids. Mode collapse shows up as low recall and repeated samples.

Compute. Sampling is one forward pass of GG, so latency is low and fixed. Training is two networks, each step, and usually needs careful tuning per dataset.

Watch out

A falling generator loss does not mean better samples. If DD's accuracy goes to 100% and GG's loss rises steadily, GG is getting no useful gradient; if DD's outputs sit at 0.5 but samples look identical, you have mode collapse. Always look at samples and a coverage metric.

Common mistakes

  • Forgetting detach() on fakes in the DD step, so DD's loss also trains GG in the wrong direction.
  • Reusing the DD-step fakes for the GG step after DD has updated, which gives stale gradients. Generate fresh fakes or recompute.
  • Using the saturating loss log⁡(1−D(G(z)))\log(1 - D(G(z))).
  • Letting batch norm see a batch of only real or only fake samples in DD and leak batch statistics; prefer spectral norm or layer norm in DD.
  • Judging progress from loss curves instead of samples, FID, and coverage.

6. Knowledge check

Quiz: GANs, generator versus discriminator

Question 1 of 10
Multiple choiceeasy

What does the discriminator step of GAN training optimize?

Practice exercise. At a point xx, pdata(x)=0.05p_{\text{data}}(x) = 0.05 and pg(x)=0.20p_g(x) = 0.20. What is D∗(x)D^*(x)? If a fake sits at this xx and DD outputs D∗D^*, what are the saturating and non-saturating gradients with respect to the logit?

Solution

D∗(x)=0.05/(0.05+0.20)=0.2D^*(x) = 0.05 / (0.05 + 0.20) = 0.2. GG is over-producing here. Saturating: ∂L/∂a=−D=−0.2\partial L/\partial a = -D = -0.2. Non-saturating: −(1−D)=−0.8-(1 - D) = -0.8, four times larger. Both push the logit up (making the sample look more real), which in practice moves the fake toward regions where DD is higher, away from this over-crowded spot.

Implementation challenge. Extend the script to 2-D data: 8 Gaussians on a ring of radius 2. Count how many of the 8 modes have at least 5% of samples after 1,000, 3,000, and 6,000 steps. Then add minibatch standard deviation (append the batch's average standard deviation as an extra feature to DD's input) and compare.

Hint

sample_real becomes angle 2πk/82\pi k/8 for random kk, plus small Gaussian noise; DD takes [n, 2], GG outputs [n, 2]. For the minibatch feature, compute s = x.std(0).mean() and concatenate a column filled with s. Its gradient flows to every sample in the batch, so the backward pass needs a small extra term. If that's fiddly, stop the gradient through s first and see whether it still helps DD spot collapsed batches.

Summary

  • A GAN trains GG to fool DD and DD to separate real from fake; GG learns from the gradient of DD, never from a likelihood.
  • For fixed GG, D∗=pdata/(pdata+pg)D^* = p_{\text{data}}/(p_{\text{data}} + p_g); at the global optimum pg=pdatap_g = p_{\text{data}} and D∗=0.5D^* = 0.5.
  • Use the non-saturating loss −log⁡D(G(z))-\log D(G(z)); its logit gradient −(1−D)-(1-D) is large exactly when DD is confident.
  • Mode collapse happens because DD only shapes gradients where fakes already are; watch coverage and samples, not loss.
  • In 2026 adversarial losses live on inside autoencoders and fast distilled diffusion samplers; diffusion and flow matching are the default generators.

Next: Latent diffusion and Stable Diffusion

Related: Autoencoders, diffusion, and transfer learning · Flow matching and rectified flows · Loss functions · Backpropagation

Go deeper, code-first (AI Engineering from Scratch): Conditional GANs & Pix2Pix

Parts of this lesson are adapted from AI Engineering from Scratch by Rohit Ghumare (MIT license, notice): GANs — Generator vs Discriminator.

Interview angle

Walk me through how a GAN trains. What is each network optimizing?

There are two networks. The generator maps noise z to a sample. The discriminator maps a sample to the probability that it's real. Training alternates. In the D step, D is an ordinary binary classifier: real samples labeled 1, generated samples labeled 0, minimizing binary cross-entropy, with the fakes detached so G isn't updated. In the G step, fresh fakes go through G and then D, and the loss is minus log D of G of z, which is minimized when D says real. The gradient flows back through D, whose weights are frozen for that step, into G. Formally it's a minimax game: for fixed G, the best D is p_data over p_data plus p_g, and plugging that in gives minus log 4 plus twice the Jensen-Shannon divergence, minimized when the generator's distribution equals the data's, where D outputs 0.5.

Why does the original generator loss saturate, and what does the non-saturating loss change?

The original game has G minimize log of one minus D of G of z. With D as a sigmoid of a logit a, the gradient with respect to a is minus D. Early in training, D easily rejects fakes, so D of G of z is near zero, and the gradient is near zero too: G learns almost nothing exactly when it's worst. The non-saturating loss instead minimizes minus log D of G of z. Its gradient with respect to the logit is minus one plus D, which is close to minus one when D is confident. At D equal to 0.01, the gradients are minus 0.01 versus minus 0.99, a 99 times difference, in the same direction. Both losses have the same fixed point, so you lose nothing in theory and gain a usable signal in practice. Goodfellow's original paper already recommended it.

Your GAN's samples all look nearly identical, but the losses look stable. Diagnose and fix.

That's mode collapse. Stable losses don't contradict it: G found a region D currently accepts and D's average output sits near 0.5. It happens because D only shapes gradients where fakes are; nothing pulls G toward modes it never visits, and G can hop between modes as D catches up. Confirm it with a coverage measure: precision and recall for generative models, FID at a fixed sample count, nearest-neighbor checks, or for toy data, counting samples per mode. Fixes, roughly in order: make D see batch-level statistics (minibatch standard deviation), stabilize D with spectral normalization or a gradient penalty, switch to a Wasserstein-style loss, lower G's learning rate or use two time-scale updates, average G's weights, and add augmentation if data is small. If coverage matters more than one-step speed, consider diffusion or flow matching instead.

GAN, diffusion, or flow matching for a product that must generate 512 by 512 product photos in under 50 ms on one GPU?

The constraint is latency: 50 milliseconds rules out a 30-step diffusion sampler at that resolution on typical hardware, since each step is a full network pass. Options. A GAN in a narrow domain (one product category, fixed backgrounds) gives a single forward pass and can be very sharp, but training is fiddly and coverage of rare variants is weaker. Or train a diffusion or flow-matching model, which is more stable and covers the distribution, then distill it to 1 to 4 steps with adversarial diffusion distillation or consistency distillation, so the adversarial loss is used as a distillation tool. I'd prototype the distilled latent model first, measure latency per step and FID plus human ratings against the GAN, and pick on measured quality and coverage at the 50 ms budget, not on reputation.