GANs — Generator vs Discriminator
Goodfellow's trick in 2014 was to skip density entirely: two networks, one making fakes and one catching them, trained against each other until the fakes are indistinguishable from real. It shouldn't work, and it often doesn't. When it does, the samples are very sharp for narrow domains.
Learning Objectives
- Write the GAN minimax objective and explain what the generator and the discriminator each optimize.
- Explain why the non-saturating generator loss gives G a usable gradient when D is confident.
- Read the toy run's D(real), D(fake) and mode counts to spot a discriminator that is winning or a collapsing generator.
- Match a failing GAN run to a likely cause and fix from the Pitfalls list.
The Problem
A generative adversarial network (GAN) trains two networks against each other. A classifier D(x) learns to tell real images from fakes, and a generator G(z) learns to fool D. The loss signal for G is whatever D currently thinks makes something look real. That signal shifts as G improves, so G chases a moving target. If both networks converge, G has learned the data distribution without anyone writing down log p(x).
Here the starting point is the VAE's blur. A VAE's MSE decoder loss is Bayes-optimal for the mean image, and the mean of many plausible digits is a fuzzy digit. You want a loss that rewards plausibility instead of pixel-wise proximity to any one target. Plausibility has no closed form, so you learn it. Goodfellow's idea was to let the classifier be that learned loss.
The math is a minimax game:
min_G max_D E_real[log D(x)] + E_fake[log(1 - D(G(z)))]
In 2026 GANs are no longer the SOTA generator, since diffusion and flow matching took that crown. StyleGAN 2/3 still rank among the sharpest face models, though, GAN discriminators serve as perceptual losses in diffusion training, and adversarial training powers the fast 1-step distillations (SDXL-Turbo, SD3-Turbo, LCM) that let you ship real-time diffusion.
The Concept
Generator G(z) maps a noise vector z ~ N(0, I) to a sample x̂. It's a decoder-shaped network (dense layers in the toy script, transposed convs in DCGAN-style image models).
Discriminator D(x) maps a sample to a scalar probability (or score). Real should score 1, fake 0.
Loss. Two alternating updates:
- Train
D:loss_D = -[ log D(x) + log(1 - D(G(z))) ]. Binary cross-entropy with real=1 and fake=0. - Train
G:loss_G = -log D(G(z)). This is the non-saturating form Goodfellow used. The originallog(1 - D(G(z)))saturates and kills gradients whenDis confident.
Training loop. One step of D, then one step of G, repeated.
Why it works. If G perfectly matches p_data, D can't do better than chance and outputs 0.5 everywhere, and G gets no more gradient. That's the equilibrium.
Why it breaks. Mode collapse: G finds one mode D can't classify and keeps producing it. Vanishing gradient: D learns too fast and log D saturates. Training instability: small changes to learning rates or batch sizes can tip the whole run.
Variants that made GANs work
| Year | Innovation | Fix |
|---|---|---|
| 2015 | DCGAN | Conv/deconv, batch norm, LeakyReLU, the first stable architecture. |
| 2017 | WGAN, WGAN-GP | Replace BCE with Wasserstein distance + gradient penalty. Fixes vanishing gradient. |
| 2017 | Spectral normalization | Lipschitz-bound the discriminator. Still used in 2026 discriminators. |
| 2018 | Progressive GAN | Train low-res first, add layers. First megapixel results. |
| 2019 | StyleGAN / StyleGAN2 | Mapping network + adaptive instance norm. State of the art for fixed-domain photorealism. |
| 2021 | StyleGAN3 | Alias-free, translation-equivariant, still the face gold standard in 2026. |
| 2022 | StyleGAN-XL | Conditional, class-aware, larger scale. |
| 2024 | R3GAN | Rebrands with stronger regularization; works on 1024² without tricks. |
Build It
code/main.py trains a tiny GAN on 1-D data drawn from a mixture of two Gaussians. Generator and discriminator are single-hidden-layer MLPs, and the forward pass, backward pass, and minimax loop are all written by hand. Every 100 steps the script prints the mean D(real) and D(fake) plus how many probe samples land in each mode, and it flags mode collapse when one mode is starved. It does not log losses or detect vanishing gradients, so you infer that failure from D(real) and D(fake) staying pinned.
Step 1: non-saturating loss
The vanilla Goodfellow loss log(1 - D(G(z))) goes to 0 when D classifies G's fake as fake with high confidence. G's gradient is basically zero at that point, so G can't improve. The non-saturating form -log D(G(z)) has the opposite asymptote: it blows up when D is confident, which gives G a strong signal.
main.py never computes this loss as a function. Excerpted from update_g, it starts from the analytic gradient directly:
# L = -log(p) with p = D(G(z)), so dL/dpre2 = -(1/p) * p*(1-p) = p - 1
dL_dpre2 = p - 1.0When D is confident the fake is fake, p is near 0 and this gradient is near -1, a strong push on G.
Step 2: one discriminator step per generator step
for step in range(1, 801):
# train D
reals = sample_real(batch, rng)
noise = sample_noise(batch, z_dim, rng)
fakes = [forward_g(z, G)[0] for z in noise]
update_d(reals, fakes, D, d_lr)
# train G
noise = sample_noise(batch, z_dim, rng) # fresh noise
update_g(noise, G, D, g_lr)update_g takes fresh noise and reruns G's forward pass itself, so its gradients are never stale.
Step 3: watch for mode collapse
if step % 100 == 0:
probe_fakes = [forward_g(z, G)[0][0] for z in sample_noise(400, z_dim, rng)]
mode_a = sum(1 for v in probe_fakes if v < 0)
mode_b = 400 - mode_a
if min(mode_a, mode_b) < 50:
print(" [!] mode collapse")The canonical symptom is that one of the two real modes stops being generated. The discriminator stops correcting it because it doesn't see that mode among the fakes.
Pitfalls
- Discriminator too strong. Cut D's learning rate by 2-5x, or add instance/layer noise. If
D(real)sits near 1 andD(fake)near 0 and stays there, G is dead. - Generator memorizes a mode. Add noise to D inputs, use a minibatch-discriminator layer, or switch to WGAN-GP.
- Batch norm leaking statistics. A real batch and a fake batch flowing through the same BN layer mix their statistics. Use instance norm or spectral norm instead (
main.pyhas no norm layers, so you won't see this one in the toy run). - Inception-score gaming. FID and IS are noisy at low sample counts. Use at least 10k samples at eval.
- One-shot sampling is a lie for conditional tasks. You still need CFG scales, truncation tricks, and re-sampling to get usable outputs.
Use It
The 2026 GAN stack:
| Situation | Pick |
|---|---|
| Photoreal human faces, fixed pose | StyleGAN3 (sharpest, smallest) |
| Anime / stylized faces | StyleGAN-XL or Stable Diffusion LoRA |
| Image-to-image translation | Pix2Pix / CycleGAN (Phase 8 · 04) or ControlNet (Phase 8 · 08) |
| Fast 1-step text-to-image | Adversarial distillation of diffusion (SDXL-Turbo, SD3-Turbo) |
| Perceptual loss inside a diffusion trainer | Small GAN discriminator on image crops |
| Anything multi-modal, open-ended | Don't. Use diffusion or flow matching |
GANs are sharp but narrow. Once your domain opens up (photos, arbitrary text prompts, video), switch to diffusion. The adversarial trick survives as a component (perceptual losses, distillation), not as a standalone generator.
Ship It
Save outputs/skill-gan-debugger.md. The skill takes a failing GAN run (loss curves, sample grid, dataset size) and outputs a ranked list of likely causes, one-line fixes, and a rerun protocol.
I'd run main.py once with the stock settings and read the D(real) and D(fake) columns before looking at the mode counts. Both are D's average output, printed every 100 steps over 800 steps, so you watch the contest instead of just the result. The [!] mode collapse flag fires when either mode drops below 50 of the 400 probe samples, and I'd treat it as a smoke alarm, not a diagnosis.
My bet is that exercise 1 teaches more than the stock run. The script starts with D's learning rate at half of G's (0.01 against 0.02), and the exercise tips that balance toward D. I'd want to see what that imbalance looks like in the printed columns before trusting the Pitfalls advice about cutting D's learning rate.
Keep in mind what the script leaves out. It has no batch norm, so that pitfall is something you read about here, not something main.py will show you, and the same goes for spectral norm. What it does give you is the non-saturating gradient written out by hand, p - 1 flowing back through D and then through G, and I'd read that function before reaching for any variant in the table.
Exercises
- Easy. Run
code/main.pywith the stock settings. Then inmain()changed_lrfrom0.01to5 * g_lrand rerun. WatchD(real),D(fake)and the mode counts: doesD(fake)get pinned near 0, and how quickly? - Medium. Replace the Goodfellow BCE loss with the WGAN loss:
loss_D = E[D(fake)] - E[D(real)],loss_G = -E[D(fake)], and clip D's weights to[-0.01, 0.01]. Is training more stable? Compare wall-clock convergence. - Hard. Extend the 1-D example to 2-D data (mixture of 8 Gaussians on a ring). Track how many of the 8 modes the generator captures at steps 1k, 5k, 10k. Implement minibatch discrimination and re-measure.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| Generator | "G" | Noise-to-sample network, G: z → x̂. |
| Discriminator | "D" | Classifier D: x → [0, 1], real vs fake. |
| Minimax | "The game" | min_G max_D of a joint objective. |
| Non-saturating loss | "The fix" | Use -log D(G(z)) for G instead of log(1 - D(G(z))). |
| Mode collapse | "G memorized one thing" | Generator produces few distinct outputs despite diverse data. |
| WGAN | "Wasserstein" | Replace BCE with Earth-Mover distance + gradient penalty; smoother gradient. |
| Spectral norm | "Lipschitz trick" | Constrain D's weight norms to bound its slope; stabilizes training. |
| StyleGAN | "The one that works" | Mapping network + AdaIN; best-in-class for faces, still in 2026. |
Production note: one-shot inference is GAN's lasting advantage
GANs no longer win on sample quality for open-domain generation, but they still win on inference cost. In production-inference terms, a GAN has:
- No prefill and decode stages. Generation is a single
G(z)forward pass, so TTFT ≈ total latency. - No KV-cache pressure. The only state is the weights. Batch size is bounded by activation memory, not cache.
- Trivial continuous batching. Each request takes the same fixed FLOPs, so a static batch at the server's target occupancy is usually optimal. No in-flight scheduler needed.
That's why GAN distillation (SDXL-Turbo, SD3-Turbo, ADD, LCM) is the dominant technique for fast text-to-image in 2026: it collapses a 20-50-step diffusion pipeline into 1-4 GAN-style forward passes while keeping the distribution of a diffusion base. The adversarial loss survives as a training-time knob for turning slow generators into fast ones.
Further Reading
- Goodfellow et al. (2014). Generative Adversarial Nets: the original GAN paper.
- Radford et al. (2015). Unsupervised Representation Learning with DCGAN: the first stable architecture.
- Arjovsky, Chintala, Bottou (2017). Wasserstein GAN: WGAN.
- Miyato et al. (2018). Spectral Normalization for GANs: SN.
- Karras et al. (2020). Analyzing and Improving the Image Quality of StyleGAN: StyleGAN2.
- Karras et al. (2021). Alias-Free Generative Adversarial Networks: StyleGAN3.
- Sauer et al. (2023). Adversarial Diffusion Distillation: SDXL-Turbo.
Code
- main.py (python, 192 lines)