Introduction to Generative AI

Generative Adversarial Networks (GANs)


You have a network that turns 100 random numbers into a 64×64 image. Now write the loss function that makes it produce realistic faces.

Stop and actually try. This is harder than it looks, and the difficulty is the entire reason GANs exist.

Squared error against what? The generator produced a face from noise. There is no ground-truth image it was supposed to match — that is the whole point of generation. Pairing each output with a random real face and minimising pixel error just drives every output towards the dataset average, a grey oval.

Hand-written realism metrics? You could reward high-frequency detail to punish blur. The generator will find that adding fine-grained noise scores brilliantly, and you get static. You could add a symmetry term, a skin-tone histogram term, an edge-density term. Every one of them gets gamed, because the generator optimises exactly what you wrote and nothing else, and none of your terms says "face".

Ask a human? Correct, and useless — gradient descent needs millions of evaluations per training run.

The problem is that "looks real" is not a formula anyone can write. So do not write it. Train a second network to be the loss function. That network's job is to distinguish real images from generated ones — a plain binary classification problem, which neural networks are excellent at. Whatever it uses to tell them apart is, by construction, a difference between your output and reality. Its gradient points at that difference. Follow it.

A GAN's real innovation is not two networks competing. It is that the loss function is learned rather than specified, so it adapts to whatever flaw the generator has right now.

One step of the two-player gameDraw 100random numbersGeneratormakes an imageCriticscores realagainst fakeUpdatethe criticUpdate thegeneratorThe original generator loss saturates exactly when it is losing, which the non-saturating fix repairs.
There is no loss curve to watch here — a falling generator loss can equally mean the critic has collapsed.

The game

Two networks, opposite objectives.

  • Generator GG: takes noise z∼N(0,I)z \sim \mathcal{N}(0, I), outputs an image G(z)G(z). Wants the discriminator to call its output real.
  • Discriminator DD: takes an image, outputs a probability that it came from the real dataset. Wants to be right.

Written as one objective:

min⁡Gmax⁡D  V(D,G)=Ex∼pdata[log⁡D(x)]+Ez∼pz[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 \sim p_z}\big[\log\big(1 - D(G(z))\big)\big]

Read it one term at a time. The first term is large when DD assigns high probability to real images. The second is large when DD assigns low probability to generated ones. DD maximises both. GG appears only in the second term and pushes it the other way — it wants D(G(z))D(G(z)) near 1.

There is a genuinely elegant result underneath. Hold GG fixed and the optimal discriminator is

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

Substitute that back in and the generator's objective becomes the Jensen–Shannon divergence between the real and generated distributions, minimised exactly when pg=pdatap_g = p_{\text{data}}. So at the theoretical optimum the generator has matched the data distribution and the discriminator, unable to do better than guessing, outputs 0.5 everywhere.

That is the theory. In practice you never reach it, for reasons that occupy the rest of this lesson.

Why the original loss stalls, and the one-line fix

Early in training the generator is terrible and the discriminator spots it instantly, so D(G(z))≈0.01D(G(z)) \approx 0.01. Look at what the generator's gradient does there.

Its loss is log⁡(1−D(G(z)))\log(1 - D(G(z))), and

∂∂Dlog⁡(1−D)=−11−D\frac{\partial}{\partial D}\log(1 - D) = \frac{-1}{1 - D}

At D=0.01D = 0.01 that is −1.01-1.01. The generator is failing as badly as it possibly can and receives a gradient of magnitude one. The function log⁡(1−D)\log(1-D) is nearly flat near D=0D = 0 — it has saturated. The worse the generator does, the less it learns.

The fix is to have the generator maximise log⁡D(G(z))\log D(G(z)) instead of minimising log⁡(1−D(G(z)))\log(1 - D(G(z))). Same direction of preference, completely different gradient:

∂∂D(−log⁡D)=−1D\frac{\partial}{\partial D}\big(-\log D\big) = \frac{-1}{D}

At D=0.01D = 0.01 that is −100-100. A hundred times stronger, precisely when the generator most needs a strong signal.

D(G(z))D(G(z))Generator statusGradient, saturating formGradient, non-saturating form
0.01Easily caught1.01100
0.1Poor1.1110
0.5Fooling DD half the time2.02.0
0.9Winning101.11

The two forms agree at 0.5 and behave oppositely at the extremes. The non-saturating version gives the loudest signal when the generator is worst, which is what you want; the original gives it when the generator is already winning, which is useless. Every practical GAN uses the non-saturating loss. It is one line of code and the difference between training and not training.

Loss variants

LossDiscriminator outputPropertyCost
Non-saturating (standard)Probability via sigmoidFixes the early-training stallStill unstable mid-training
Least-squares (LSGAN)Unbounded scorePenalises samples far from the boundary even when correctly classified; smoother gradientsMarginal quality gain
Wasserstein (WGAN)Unbounded "critic" scoreLoss correlates with sample quality, so the curve is finally informativeRequires a Lipschitz constraint
WGAN-GPUnbounded scoreEnforces the constraint with a gradient penalty rather than weight clippingExtra backward pass per step
HingeUnbounded scoreSimple, stable, standard in large image GANsNone notable

The Wasserstein family is worth understanding for one reason beyond stability. With the standard loss, the discriminator's loss tells you nothing about sample quality — it measures how the contest is going, not how good the images are, so a falling loss can accompany worsening output. The Wasserstein critic's value approximates an actual distance between the distributions, so it goes down as samples improve. That turns a blind training run into a monitored one.

Architecture

The generator maps a low-dimensional vector to a full image, expanding spatial size while contracting channels. The discriminator does the reverse. To keep the code short, this version makes 32×32 colour images; one more stride-2 layer in each network gives the 64×64 of the opening example.

Python
import torch.nn as nnclass Generator(nn.Module):    def __init__(self, z_dim=100, ch=64):        super().__init__()        self.net = nn.Sequential(            # z: (B, 100, 1, 1) -> 4x4            nn.ConvTranspose2d(z_dim, ch*8, 4, 1, 0, bias=False),            nn.BatchNorm2d(ch*8), nn.ReLU(True),            # 4x4 -> 8x8            nn.ConvTranspose2d(ch*8, ch*4, 4, 2, 1, bias=False),            nn.BatchNorm2d(ch*4), nn.ReLU(True),            # 8x8 -> 16x16            nn.ConvTranspose2d(ch*4, ch*2, 4, 2, 1, bias=False),            nn.BatchNorm2d(ch*2), nn.ReLU(True),            # 16x16 -> 32x32            nn.ConvTranspose2d(ch*2, 3, 4, 2, 1, bias=False),            nn.Tanh(),                       # outputs in [-1, 1]        )    def forward(self, z):        return self.net(z)class Discriminator(nn.Module):    def __init__(self, ch=64):        super().__init__()        self.net = nn.Sequential(            nn.Conv2d(3, ch, 4, 2, 1, bias=False),            nn.LeakyReLU(0.2, inplace=True),          # no norm on the first layer            nn.Conv2d(ch, ch*2, 4, 2, 1, bias=False),            nn.BatchNorm2d(ch*2), nn.LeakyReLU(0.2, inplace=True),            nn.Conv2d(ch*2, ch*4, 4, 2, 1, bias=False),            nn.BatchNorm2d(ch*4), nn.LeakyReLU(0.2, inplace=True),            nn.Conv2d(ch*4, 1, 4, 1, 0, bias=False),  # single score        )    def forward(self, x):        return self.net(x).view(-1)

Several choices in there are not stylistic:

  • Tanh on the generator output, images scaled to [−1,1][-1,1]. The ranges must match or the discriminator separates real from fake on brightness alone and learns nothing useful.
  • LeakyReLU in the discriminator. Plain ReLU zeroes the gradient for every negative activation, and the generator's only learning signal passes back through this network. Dead units in the discriminator mean no signal for the generator.
  • No normalisation on the discriminator's first layer. It would erase the input statistics that distinguish real from generated data.
  • Kernel 4, stride 2, padding 1. This combination halves or doubles resolution exactly, avoiding the checkerboard artefacts that appear when kernel size is not divisible by stride.

The training loop

Python
import torch, torch.nn.functional as Fopt_d = torch.optim.Adam(D.parameters(), lr=2e-4, betas=(0.5, 0.999))opt_g = torch.optim.Adam(G.parameters(), lr=2e-4, betas=(0.5, 0.999))for real in loader:    b = real.size(0)    # ---- discriminator ----    z    = torch.randn(b, z_dim, 1, 1, device=dev)    fake = G(z)    d_real = D(real)    d_fake = D(fake.detach())          # detach: no generator update here    loss_d = (F.binary_cross_entropy_with_logits(d_real, torch.full_like(d_real, 0.9))            + F.binary_cross_entropy_with_logits(d_fake, torch.zeros_like(d_fake)))    opt_d.zero_grad(); loss_d.backward(); opt_d.step()    # ---- generator (non-saturating) ----    d_fake = D(fake)                   # re-score, gradients flow to G    loss_g = F.binary_cross_entropy_with_logits(d_fake, torch.ones_like(d_fake))    opt_g.zero_grad(); loss_g.backward(); opt_g.step()

Two lines carry outsized weight. fake.detach() stops the discriminator's update from also modifying the generator — omit it and you are training the generator to help the discriminator catch it, which is exactly backwards. And the target of 0.9 instead of 1.0 is one-sided label smoothing: it stops the discriminator becoming absolutely certain, which keeps its gradients from vanishing. The betas of (0.5,0.999)(0.5, 0.999) rather than the usual (0.9,0.999)(0.9, 0.999) reduce momentum, because in a two-player game the landscape shifts under you and stale momentum overshoots.

The two failures

Mode collapse

Train on a dataset of ten digit classes and find the generator producing only 3s and 8s. Every sample is sharp. Every sample is convincing. Coverage is 20%.

The cause is directly visible in the objective. The generator is scored on whether each individual sample fools the discriminator. Nothing in V(D,G)V(D, G) asks whether the samples, taken together, resemble the dataset. If the generator finds one output the discriminator cannot reject, producing that output every time is an optimal strategy. It has won the game as written.

What follows is a chase: the discriminator eventually learns "too many 3s here", so the generator jumps to producing only 7s, then only 1s, cycling forever without ever covering everything at once.

RemedyMechanism
Minibatch discriminationLet DD see relationships within a batch, so it can reject a batch that lacks variety
Unrolled GANGG optimises against DD's future response, removing the payoff for a short-lived jump
WGAN-GPThe Wasserstein distance penalises missing mass, unlike Jensen–Shannon
Two discriminators, or one with more capacityHarder to fool with a single trick
Experience replay of past fakesStops the generator revisiting a mode DD has already forgotten

Instability

There is no loss being minimised. There is an equilibrium between two networks that both keep moving, and equilibria can be orbited rather than reached. The characteristic symptom is a run that improves for 40 epochs and then, over two epochs, degenerates into noise.

The usual proximate cause is an imbalance. If the discriminator becomes far stronger than the generator, it rejects everything with near-total confidence, D(G(z))→0D(G(z)) \to 0, and even the non-saturating gradient becomes a signal pointing everywhere at once. If the generator gets far ahead, the discriminator stops being an informative critic and the generator drifts.

Practical levers, roughly in the order to try them: spectral normalisation on the discriminator, which is the single most reliable stabiliser available; a lower discriminator learning rate; different update ratios such as two discriminator steps per generator step; gradient penalty; and keeping an exponential moving average of the generator's weights for sampling, which smooths out the oscillation even when training itself is bumpy.

Save checkpoints often and evaluate them. GAN training does not converge to a best model at the end — the best model is usually somewhere in the middle of the run.

Evaluating output

You cannot use the loss. A GAN's loss measures the state of a contest, not the quality of images. Falling generator loss is compatible with worsening samples.

MetricMeasuresDirectionLimitation
Inception ScoreConfident classification plus class varietyHigher betterBlind to mode collapse within a class; ignores the real data entirely
Fréchet Inception DistanceDistance between real and generated feature distributionsLower betterNeeds 10k+ samples; assumes Gaussian features
Precision / Recall for generative modelsSeparates realism from coverageBoth higherMore work to compute
Human preference studyWhat you actually care about—Slow and expensive

FID is the default. Its virtue is catching mode collapse — a generator producing beautiful images of one class has generated features clustered far too tightly, and the distance to the real distribution rises even as individual samples look flawless. Report the sample count alongside the number; FID computed on 1,000 samples is not comparable to FID on 50,000.

GAN or VAE

Both map noise to data in a single forward pass. The difference is what they optimise, and every practical distinction follows from that.

GANVAE
ObjectiveFool a learned criticMaximise a lower bound on likelihood
Sample sharpnessHigh — nothing averagesBlurry — averaging is built in
Mode coverageUnreliableGood; the likelihood term punishes ignoring data
TrainingCan fail outrightEssentially always converges
Encoder for real inputsNone by defaultYes, free
Can score how likely an input isNoApproximately
Loss curve is informativeNo, unless WassersteinYes

Sharpness versus coverage, and stability versus quality. Those are the trades.

What this means when you build something

Before starting a GAN project, price in the failure mode that has no equivalent elsewhere: a full training run can end with nothing usable. Not a mediocre model — a collapsed one. If your schedule cannot absorb that, use an architecture with a monotone loss.

If you do proceed, three decisions do most of the work. Use a known-good recipe rather than designing from scratch; the published architectures encode years of hard-won detail about normalisation placement and kernel sizes. Add spectral normalisation to the discriminator from the start — it costs almost nothing and prevents the most common divergence. Compute FID every few epochs on a fixed noise batch and keep the best checkpoint, because the final checkpoint is frequently not the best one.

Where GANs remain genuinely the right answer is real-time generation. One forward pass, milliseconds, on modest hardware. For interactive editing, live style transfer, super-resolution in a video pipeline, or anything running on a device, that single-pass property is decisive — and it is why the adversarial idea survives inside modern systems even where the pure GAN has been displaced, most often as a component that sharpens or accelerates a model trained some other way.