🎯 What You'll Learn
- Understand the GAN min-max objective and the Nash equilibrium goal
- Write the GAN training loop: alternating Discriminator and Generator updates
- Build a DCGAN (Deep Convolutional GAN) for image generation in PyTorch
- Diagnose and address common GAN failure modes: mode collapse, vanishing gradients, non-convergence
- Understand Conditional GAN (cGAN) for controlled generation
- Understand Progressive GANs and StyleGAN for high-resolution synthesis
- Evaluate GAN quality with FID (Fréchet Inception Distance)
Imagine a forger trying to fake paintings, and an expert art detective trying to spot fakes. The forger gets better by studying what mistakes the detective catches. The detective gets better by studying more real and fake paintings. This adversarial competition drives BOTH to improve. When the forger is so good that the detective can no longer tell real from fake — the forger has succeeded. That's exactly how GANs work. The Generator creates fake data, the Discriminator tries to catch it, and their competition produces astonishingly realistic outputs.
1 The Generative Modeling Problem
Most ML models we have studied are discriminative: they learn P(y|x) — given input x, predict label y. A cat/dog classifier, a spam filter, a sentiment analyzer. These models draw decision boundaries in feature space but don't model what the data itself looks like.
Generative models are different: they learn P(x) — the probability distribution over the data itself. Once you have a good generative model, you can sample new data points from it. For images, this means generating new images that look statistically identical to the training set. For molecules, this means generating new drug candidates. For text, this means generating coherent paragraphs.
Why Generative Modeling Is Hard
The space of natural images is astronomically large. A 64×64 RGB image has 64×64×3 = 12,288 pixel values, each 0–255. That is 256^12288 possible images — a number with nearly 30,000 digits. The vast majority of these are pure noise. Natural images occupy a tiny, complex, high-dimensional manifold within this space. Learning the distribution of this manifold — well enough to draw new samples from it — is one of the hardest problems in machine learning.
Approaches to Generative Modeling
| Approach | Key Idea | Strengths | Weaknesses |
|---|---|---|---|
| GAN | Adversarial game | Sharp images, fast sampling | Mode collapse, unstable training |
| VAE | Encode to latent, decode back | Stable, meaningful latent space | Blurry outputs |
| Diffusion | Learn to denoise | Best quality, mode coverage | Slow sampling (many steps) |
| Flow Models | Invertible transformations | Exact likelihood, invertible | Constrained architectures |
This lesson focuses on GANs. While diffusion models have largely superseded them for image quality, GANs remain widely used for fast generation, data augmentation, and as a foundation for understanding adversarial training principles that appear throughout deep learning.
2 The GAN Framework: Min-Max Game
A GAN consists of two neural networks trained in competition:
- Generator G: Takes a random noise vector z sampled from a simple distribution (typically N(0,1)) and outputs a fake sample G(z). The generator never sees real data — it learns only from the discriminator's feedback.
- Discriminator D: Takes a sample (either real from the dataset or fake from G) and outputs a scalar probability of being real. D(x) ≈ 1 means "this looks real"; D(G(z)) ≈ 0 means "this is clearly fake".
The diagram below is the single mental model to hold onto for the rest of this lesson: noise flows forward through G to produce a fake, both real and fake samples flow forward through D to produce a verdict, and then two separate gradient signals flow backward — one trains D to judge better, the other passes back through D (whose weights are frozen at that moment) into G, teaching G how to produce samples D would judge as real.
The adversarial loop. Forward pass (grey arrows): noise z → Generator → fake image; fake and real images → Discriminator → a real/fake score. Backward pass: the Discriminator's own classification loss updates only D's weights (red). Separately, the Generator's loss is computed from D's verdict on the fakes, and its gradient is back-propagated through D's (frozen) weights into G's weights (violet, dashed) — D never learns from this pass, it is only a conduit teaching G what fooled it and what didn't.
The Objective Function
The GAN training objective is a minimax game:
Breaking this apart: D wants to maximize this expression — maximize log D(x) (correctly classifying real samples as real) and maximize log(1 − D(G(z))) (correctly classifying fake samples as fake). G wants to minimize the same expression — specifically, minimize log(1 − D(G(z))), which means making D(G(z)) → 1 (fooling D into thinking generated samples are real).
The theoretical Nash equilibrium: G generates samples from exactly the real data distribution (indistinguishable from real), and D outputs exactly 0.5 for every sample (it can't do better than random guessing). In practice, GANs rarely reach this perfect equilibrium — training is more of an iterative approximation.
The theoretical G loss is log(1 − D(G(z))). But in early training, D is good and D(G(z)) ≈ 0, making log(1 − D(G(z))) ≈ log(1) = 0 — nearly constant with near-zero gradient. In practice, G is trained to maximize log(D(G(z))) instead (the "non-saturating" loss). These are theoretically equivalent at equilibrium but the non-saturating version provides stronger gradients early in training.
3 Training a GAN Step by Step
GAN training alternates between updating the Discriminator and the Generator. They are never updated simultaneously — each update is computed with the other network frozen.
The Training Loop
import torch
import torch.nn as nn
import torch.optim as optim
def train_gan_epoch(generator, discriminator, dataloader,
opt_G, opt_D, latent_dim, device):
"""
One epoch of GAN training.
Standard practice: update D once, then G once per batch.
"""
criterion = nn.BCEWithLogitsLoss() # numerically stable binary cross-entropy
for batch_idx, (real_imgs, _) in enumerate(dataloader):
batch_size = real_imgs.size(0)
real_imgs = real_imgs.to(device)
# Labels: real=1, fake=0 (smooth labels help stability)
real_labels = torch.ones(batch_size, 1, device=device) * 0.9 # label smoothing
fake_labels = torch.zeros(batch_size, 1, device=device) + 0.1
# ─────────────────────────────────────
# STEP 1: Train Discriminator
# ─────────────────────────────────────
discriminator.zero_grad()
# Real images loss: D should output ~1 for real images
d_real = discriminator(real_imgs)
loss_real = criterion(d_real, real_labels)
# Fake images loss: D should output ~0 for generated images
z = torch.randn(batch_size, latent_dim, device=device)
fake_imgs = generator(z).detach() # detach: don't backprop through G yet
d_fake = discriminator(fake_imgs)
loss_fake = criterion(d_fake, fake_labels)
loss_D = (loss_real + loss_fake) / 2
loss_D.backward()
opt_D.step()
# ─────────────────────────────────────
# STEP 2: Train Generator
# ─────────────────────────────────────
generator.zero_grad()
z = torch.randn(batch_size, latent_dim, device=device)
fake_imgs = generator(z)
d_fake_for_g = discriminator(fake_imgs)
# Generator wants D to output 1 for its fake images (fool D)
# Use real_labels here: G is trying to make D think fakes are real
loss_G = criterion(d_fake_for_g, torch.ones(batch_size, 1, device=device))
loss_G.backward()
opt_G.step()
if batch_idx % 100 == 0:
print(f" Batch {batch_idx:04d} | D loss: {loss_D.item():.4f} "
f"G loss: {loss_G.item():.4f} "
f"D(real): {d_real.mean().item():.3f} "
f"D(fake): {d_fake.mean().item():.3f}")
return loss_D.item(), loss_G.item()
The key diagnostic signals are D(real) and D(fake). Healthy training: D(real) ≈ 0.7–0.9 (D correctly identifies real images), D(fake) ≈ 0.1–0.4 (D partially fools, G is learning). Red flags: D(real) → 1.0 and D(fake) → 0.0 simultaneously (D is too powerful, G gets no gradient). D(real) ≈ D(fake) ≈ 0.5 already at epoch 1 (D is too weak, meaningless signal). G loss consistently increasing (mode collapse or training instability).
4 DCGAN: Deep Convolutional GAN
The original GAN used fully connected layers for both G and D — fine for MNIST but terrible for natural images. DCGAN (Radford et al., 2015) established architectural guidelines for stable GAN training on images that remain influential today.
DCGAN Design Rules
- Replace pooling with strided convolutions (in D) and transposed convolutions (in G)
- Use BatchNorm in both G and D (except G output layer and D input layer)
- Use ReLU in G for all layers except the output (use Tanh)
- Use LeakyReLU in D for all layers (slope 0.2)
- No fully connected layers except at the input (G) and output (D)
import torch
import torch.nn as nn
LATENT_DIM = 100 # noise vector dimension
IMAGE_SIZE = 64 # 64x64 output images
N_CHANNELS = 3 # RGB
N_FEATURES = 64 # base channel count (multiplied per stage)
class DCGANGenerator(nn.Module):
"""
DCGAN Generator: noise vector (100,) → image (3, 64, 64)
Uses transposed convolutions to progressively upsample:
4x4 → 8x8 → 16x16 → 32x32 → 64x64
"""
def __init__(self, latent_dim=LATENT_DIM, ngf=N_FEATURES):
super().__init__()
self.net = nn.Sequential(
# Input: (latent_dim,) → (ngf*8, 4, 4)
nn.ConvTranspose2d(latent_dim, ngf * 8, 4, 1, 0, bias=False),
nn.BatchNorm2d(ngf * 8),
nn.ReLU(True),
# (ngf*8, 4, 4) → (ngf*4, 8, 8)
nn.ConvTranspose2d(ngf * 8, ngf * 4, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf * 4),
nn.ReLU(True),
# (ngf*4, 8, 8) → (ngf*2, 16, 16)
nn.ConvTranspose2d(ngf * 4, ngf * 2, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf * 2),
nn.ReLU(True),
# (ngf*2, 16, 16) → (ngf, 32, 32)
nn.ConvTranspose2d(ngf * 2, ngf, 4, 2, 1, bias=False),
nn.BatchNorm2d(ngf),
nn.ReLU(True),
# (ngf, 32, 32) → (3, 64, 64)
nn.ConvTranspose2d(ngf, N_CHANNELS, 4, 2, 1, bias=False),
nn.Tanh(), # output in [-1, 1] — normalize your real images the same way!
)
def forward(self, z):
# z: (batch, latent_dim, 1, 1)
if z.dim() == 2:
z = z.unsqueeze(-1).unsqueeze(-1)
return self.net(z)
class DCGANDiscriminator(nn.Module):
"""
DCGAN Discriminator: image (3, 64, 64) → scalar probability of being real
Uses strided convolutions to progressively downsample:
64x64 → 32x32 → 16x16 → 8x8 → 4x4 → 1x1
"""
def __init__(self, ndf=N_FEATURES):
super().__init__()
self.net = nn.Sequential(
# Input: (3, 64, 64) → (ndf, 32, 32)
# No BatchNorm on first layer (per DCGAN paper)
nn.Conv2d(N_CHANNELS, ndf, 4, 2, 1, bias=False),
nn.LeakyReLU(0.2, inplace=True),
# (ndf, 32, 32) → (ndf*2, 16, 16)
nn.Conv2d(ndf, ndf * 2, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 2),
nn.LeakyReLU(0.2, inplace=True),
# (ndf*2, 16, 16) → (ndf*4, 8, 8)
nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 4),
nn.LeakyReLU(0.2, inplace=True),
# (ndf*4, 8, 8) → (ndf*8, 4, 4)
nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, bias=False),
nn.BatchNorm2d(ndf * 8),
nn.LeakyReLU(0.2, inplace=True),
# (ndf*8, 4, 4) → (1, 1, 1)
# No Sigmoid here — use BCEWithLogitsLoss for numerical stability
nn.Conv2d(ndf * 8, 1, 4, 1, 0, bias=False),
nn.Flatten(),
)
def forward(self, x):
return self.net(x)
# Instantiate and test
G = DCGANGenerator(LATENT_DIM)
D = DCGANDiscriminator()
z = torch.randn(8, LATENT_DIM)
fake_images = G(z)
d_scores = D(fake_images)
print(f"Noise shape: {z.shape}")
print(f"Generated images: {fake_images.shape}")
print(f"D output shape: {d_scores.shape}")
print(f"G parameters: {sum(p.numel() for p in G.parameters())/1e6:.2f}M")
print(f"D parameters: {sum(p.numel() for p in D.parameters())/1e6:.2f}M")
5 GAN Training Challenges and Solutions
GAN training is notoriously difficult. Understanding the failure modes is as important as understanding the architecture — you'll encounter them in practice.
Mode Collapse
Mode collapse occurs when the Generator learns to produce only one or a few types of samples that reliably fool the Discriminator. Imagine training a GAN on MNIST digits (0–9). If G notices that generating "6" always fools D, it stops exploring other digits and produces only "6" variations — 100% of its output is the same class. This is mode collapse: the model captures one "mode" of the distribution and ignores the rest. The GAN technically wins its game (D can't distinguish this fake 6 from a real 6) but fails at the goal of learning the full data distribution.
Vanishing Gradients for the Generator
If the Discriminator becomes too accurate too quickly, D(G(z)) ≈ 0 for all generated samples. The gradient of the BCE loss log(1 − D(G(z))) with respect to G's parameters becomes vanishingly small near 0 — the Generator receives essentially no gradient signal. It cannot improve. This is why training G with the non-saturating loss −log(D(G(z))) helps: its gradient is large when D(G(z)) is small.
Wasserstein GAN (WGAN)
WGAN (Arjovsky et al., 2017) replaces the binary cross-entropy loss with the Wasserstein distance — a smoother, more meaningful measure of distribution distance. Instead of a probability (clamped to [0,1]), the Discriminator (now called the "Critic") outputs an unbounded real number. The Critic is not trained to classify real/fake but to maximize the difference between its scores for real and fake samples. WGAN-GP adds a gradient penalty instead of weight clipping, producing more stable training.
import torch
import torch.nn as nn
def gradient_penalty(critic, real_imgs, fake_imgs, device):
"""WGAN-GP gradient penalty — enforces Lipschitz constraint on the critic."""
batch_size = real_imgs.size(0)
# Interpolate between real and fake
alpha = torch.rand(batch_size, 1, 1, 1, device=device)
interpolated = (alpha * real_imgs + (1 - alpha) * fake_imgs).requires_grad_(True)
# Critic score on interpolated samples
interp_score = critic(interpolated)
# Gradient of critic output with respect to interpolated input
grad = torch.autograd.grad(
outputs=interp_score,
inputs=interpolated,
grad_outputs=torch.ones_like(interp_score),
create_graph=True,
retain_graph=True,
)[0]
# Gradient norm — should be 1 for Lipschitz constraint
grad_norm = grad.view(batch_size, -1).norm(2, dim=1)
penalty = ((grad_norm - 1) ** 2).mean()
return penalty
def train_wgan_gp_step(generator, critic, real_imgs, opt_G, opt_C,
latent_dim, lambda_gp=10, n_critic=5, device='cpu'):
"""
WGAN-GP update step.
n_critic: number of critic updates per generator update (typically 5)
lambda_gp: weight of gradient penalty (typically 10)
"""
batch_size = real_imgs.size(0)
real_imgs = real_imgs.to(device)
# ── Train Critic (n_critic times) ──
for _ in range(n_critic):
z = torch.randn(batch_size, latent_dim, device=device)
fake_imgs = generator(z).detach()
critic.zero_grad()
# WGAN loss: maximize E[C(real)] - E[C(fake)]
# (minimize its negative)
c_real = critic(real_imgs).mean()
c_fake = critic(fake_imgs).mean()
gp = gradient_penalty(critic, real_imgs, fake_imgs, device)
loss_C = -(c_real - c_fake) + lambda_gp * gp
loss_C.backward()
opt_C.step()
# ── Train Generator (once) ──
generator.zero_grad()
z = torch.randn(batch_size, latent_dim, device=device)
fake_imgs = generator(z)
loss_G = -critic(fake_imgs).mean() # maximize C(fake) → fool critic
loss_G.backward()
opt_G.step()
return loss_C.item(), loss_G.item()
print("WGAN-GP: more stable training than standard GAN")
print("Key change: critic outputs unconstrained real numbers, not probabilities")
print("Gradient penalty: ensures the critic function is 1-Lipschitz")
Practical Stabilization Tricks
# Trick 1: Label Smoothing
# Instead of real=1.0, use real=0.9 (one-sided label smoothing)
# Prevents D from becoming over-confident, maintains gradient flow to G
real_labels = torch.full((batch_size, 1), 0.9, device=device) # not 1.0
# Trick 2: Add Noise to D Inputs
# Add small Gaussian noise to both real and fake images fed to D
# Blurs the decision boundary slightly, preventing D from memorizing
noise_std = 0.05
noisy_real = real_imgs + noise_std * torch.randn_like(real_imgs)
noisy_fake = fake_imgs + noise_std * torch.randn_like(fake_imgs)
# Trick 3: Spectral Normalization on D
# Normalize D's weight matrices by their spectral norm → bounds the Lipschitz constant
# from torch.nn.utils import spectral_norm
# self.conv1 = spectral_norm(nn.Conv2d(in_ch, out_ch, 3, padding=1))
# Trick 4: Different Learning Rates
# G typically: lr=2e-4, betas=(0.5, 0.999)
# D typically: lr=2e-4, betas=(0.5, 0.999) or slightly lower than G
opt_G = torch.optim.Adam(None, lr=2e-4, betas=(0.5, 0.999)) # β1=0.5, not 0.9
opt_D = torch.optim.Adam(None, lr=2e-4, betas=(0.5, 0.999))
# Why beta1=0.5? Standard 0.9 makes Adam accumulate gradient history too aggressively
# for GAN training — 0.5 is more responsive to recent gradient changes
What does a healthy training run actually look like on a loss curve? Unlike ordinary supervised training, neither loss should march steadily toward zero. Drag the slider to scrub through training and watch how D's loss climbs from an early advantage toward ln(2) ≈ 0.693 — the value at which D is reduced to a coin flip — while G's loss fluctuates around the same value instead of collapsing:
Synthetic but plausible loss curves: D starts with an easy advantage (low loss) then is pulled toward the ln(2) equilibrium as G improves; G's loss fluctuates around ln(2) rather than trending to zero.
In standard supervised learning, loss decreases over time. In GAN training, the losses oscillate throughout training — this is normal and expected. A decreasing G loss isn't necessarily good (it might mean D is too weak). A stable G loss near log(2) ≈ 0.693 and D loss near 0 is actually a healthy sign that D correctly classifies real/fake most of the time. Use visual inspection of generated samples as your primary quality metric, not loss values.
6 Conditional GAN (cGAN): Controlled Generation
A vanilla GAN generates samples randomly — you have no control over which kind of sample is generated. A Conditional GAN (cGAN) gives you control by conditioning both the Generator and the Discriminator on a class label (or any other conditioning signal).
How Conditioning Works
For the Generator: the class label y is embedded to a vector and concatenated with the noise vector z before generation. For the Discriminator: the class label is embedded and concatenated with the image features. Now D must not only ask "is this real?" but "is this a real example of class y?"
import torch
import torch.nn as nn
class ConditionalGenerator(nn.Module):
"""
cGAN Generator: (noise, label) → image
Conditions on class label via embedding concatenation.
"""
def __init__(self, latent_dim=100, num_classes=10, img_size=28, channels=1):
super().__init__()
self.img_size = img_size
self.channels = channels
# Learnable label embedding (one vector per class)
self.label_emb = nn.Embedding(num_classes, num_classes)
self.net = nn.Sequential(
nn.Linear(latent_dim + num_classes, 256),
nn.LeakyReLU(0.2, inplace=True),
nn.BatchNorm1d(256),
nn.Linear(256, 512),
nn.LeakyReLU(0.2, inplace=True),
nn.BatchNorm1d(512),
nn.Linear(512, 1024),
nn.LeakyReLU(0.2, inplace=True),
nn.BatchNorm1d(1024),
nn.Linear(1024, channels * img_size * img_size),
nn.Tanh(),
)
def forward(self, z, labels):
# Embed the label and concatenate with noise
label_emb = self.label_emb(labels) # (batch, num_classes)
gen_input = torch.cat([z, label_emb], dim=1)
img = self.net(gen_input)
return img.view(-1, self.channels, self.img_size, self.img_size)
class ConditionalDiscriminator(nn.Module):
"""
cGAN Discriminator: (image, label) → real/fake probability
"""
def __init__(self, num_classes=10, img_size=28, channels=1):
super().__init__()
self.img_size = img_size
self.label_emb = nn.Embedding(num_classes, num_classes)
self.net = nn.Sequential(
nn.Linear(channels * img_size * img_size + num_classes, 512),
nn.LeakyReLU(0.2, inplace=True),
nn.Linear(512, 256),
nn.LeakyReLU(0.2, inplace=True),
nn.Linear(256, 128),
nn.LeakyReLU(0.2, inplace=True),
nn.Linear(128, 1),
)
def forward(self, img, labels):
flat_img = img.view(img.size(0), -1)
label_emb = self.label_emb(labels)
d_input = torch.cat([flat_img, label_emb], dim=1)
return self.net(d_input)
# Test conditional generation
cG = ConditionalGenerator(latent_dim=100, num_classes=10)
cD = ConditionalDiscriminator(num_classes=10)
z = torch.randn(16, 100)
labels = torch.randint(0, 10, (16,)) # which digit to generate
fake_imgs = cG(z, labels)
d_scores = cD(fake_imgs, labels)
print(f"Generated images shape: {fake_imgs.shape}") # (16, 1, 28, 28)
print(f"Discriminator scores: {d_scores.shape}") # (16, 1)
# Generate a specific digit on demand:
z_digit7 = torch.randn(1, 100)
label_7 = torch.tensor([7])
generated_7 = cG(z_digit7, label_7)
print(f"Generated digit 7 image shape: {generated_7.shape}") # (1, 1, 28, 28)
Image-to-Image Translation: pix2pix
cGANs can also condition on images instead of class labels, enabling image-to-image translation. Pix2pix (Isola et al., 2017) is a cGAN where the condition is an input image: sketch → realistic photo, aerial map → street map, day → night scene, black-and-white → color. The Generator produces the translated image, and the Discriminator judges whether the translated image matches the conditioning image in a realistic way.
Adobe Photoshop's Content-Aware Fill uses a cGAN conditioned on the surrounding image context to fill in selected regions. NVIDIA's GauGAN (now Canvas) lets you draw a rough semantic map (blue = sky, green = grass, grey = rock) and a cGAN translates it into a photorealistic landscape. DALL·E's predecessor used a cGAN conditioned on text descriptions to generate images from text before diffusion models took over. Medical imaging synthesis: generate T2 MRI from T1 MRI to avoid the patient needing a second scan.
7 Progressive Growing and StyleGAN
Training a GAN directly on 1024×1024 images is extremely unstable. The discriminator easily distinguishes real from fake high-resolution images early in training, leaving the generator with vanishing gradients before it has learned anything useful.
Progressive Growing of GANs (ProGAN)
ProGAN (Karras et al., 2018) solves this with a curriculum: start training at 4×4 resolution, where both G and D are tiny and easy to train. Once stable, progressively add new convolutional layers to both G and D that double the resolution. At each transition, new layers are faded in gradually using a blending parameter α. By the time the model reaches 1024×1024, G and D have co-evolved through all intermediate scales and both are fully capable at the final resolution. ProGAN produced the most realistic synthetic faces seen at that time.
StyleGAN: Style-Based Generator
StyleGAN (Karras et al., 2019) introduced a fundamentally different Generator architecture. Instead of feeding noise directly to the first convolutional layer, it processes noise through a mapping network (8-layer MLP) to produce a style vector w. This style vector is then fed to each convolutional layer via learned affine transformations (AdaIN — Adaptive Instance Normalization). Each layer of G responds to the style vector at a different level of detail: early layers control coarse features (head shape, pose), middle layers control medium features (hair, facial features), late layers control fine features (freckles, pore texture). StyleGAN2 fixed some aliasing artifacts. The website thispersondoesnotexist.com uses StyleGAN2.
# StyleGAN-style AdaIN (Adaptive Instance Normalization) block
# The key mechanism: style vector controls per-channel mean and variance
import torch
import torch.nn as nn
class AdaIN(nn.Module):
"""
Adaptive Instance Normalization: applies style to feature maps.
Normalises feature map, then affine-transforms with style-derived params.
"""
def __init__(self, n_channels, style_dim):
super().__init__()
self.norm = nn.InstanceNorm2d(n_channels)
# Map style vector to per-channel scale (gamma) and shift (beta)
self.style_scale = nn.Linear(style_dim, n_channels)
self.style_bias = nn.Linear(style_dim, n_channels)
def forward(self, x, style):
# x: (B, C, H, W); style: (B, style_dim)
x_norm = self.norm(x)
gamma = self.style_scale(style).view(-1, x.size(1), 1, 1)
beta = self.style_bias(style).view(-1, x.size(1), 1, 1)
return gamma * x_norm + beta # modulate the feature map with style
# Demo
adain = AdaIN(n_channels=64, style_dim=512)
features = torch.randn(2, 64, 32, 32)
style = torch.randn(2, 512)
out = adain(features, style)
print(f"AdaIN input: {features.shape}") # (2, 64, 32, 32)
print(f"AdaIN output: {out.shape}") # (2, 64, 32, 32) — same shape, different style
StyleGAN separates the input noise from the style vector via the mapping network. The mapping network transforms the isotropic Gaussian noise into a "disentangled" latent space W, where different dimensions control different visual attributes. This means you can do arithmetic in W space: interpolate between two faces (smooth morphing), mix the fine style of one face with the coarse structure of another, or find directions in W that correspond to specific attributes (age, smile, hair color). This disentanglement makes StyleGAN far more controllable than earlier generators.
8 Evaluating GANs: FID Score
Training loss is not a reliable indicator of GAN quality. We need quantitative metrics that correlate with human judgment of image quality and diversity.
FID: Fréchet Inception Distance
FID (Heusel et al., 2017) works as follows: (1) Extract feature vectors from a pre-trained Inception network for both the real training images and the generated images. (2) Fit a multivariate Gaussian to each set of feature vectors, estimating mean (μ) and covariance (Σ) for each. (3) Compute the Fréchet distance between the two Gaussians:
Lower FID is better. A perfect generator (identical to real distribution) would have FID = 0. A generator producing pure noise would have FID in the thousands.
# FID computation using torch-fidelity (pip install torch-fidelity)
from torchvision.utils import save_image
import torch_fidelity
# Step 1: Save real images to a directory
# Step 2: Generate and save fake images to another directory
# Step 3: Compute FID
# Example with generated images saved to 'fake_images/' and real to 'real_images/'
# metrics = torch_fidelity.calculate_metrics(
# input1='real_images/',
# input2='fake_images/',
# cuda=True,
# isc=True, # also compute Inception Score
# fid=True, # compute FID
# verbose=True
# )
# print(f"FID: {metrics['frechet_inception_distance']:.2f}")
# print(f"IS: {metrics['inception_score_mean']:.2f}")
# Reference FID values for comparison:
reference_fids = {
"Random noise": 10000,
"Vanilla GAN (MNIST)": 25,
"DCGAN (CelebA 64x64)": 12,
"Progressive GAN (1024)": 8.0,
"StyleGAN2 (FFHQ 1024)": 2.8,
"StyleGAN3 (FFHQ)": 2.1,
"Real images (lower bound)": 0.0,
}
print(f"{'Model':<35} {'FID':>8}")
print("-" * 45)
for name, fid in reference_fids.items():
bar = '█' * max(1, int(50 * min(fid, 100) / 100))
print(f"{name:<35} {fid:>8.1f} {bar}")
FID measures the similarity between real and fake distributions as a whole — it rewards diversity (covering all modes) AND quality (realistic individual samples). However, FID is sensitive to the number of samples used (always use ≥ 50,000), the specific Inception version, and preprocessing. Two models can have the same FID with different failure modes: one might produce perfectly diverse but blurry images; another might produce photorealistic but limited-diversity images. Always use FID alongside visual inspection and, if possible, a human preference study.
Real-World Spotlight: GANs in Production and the Dark Side
Legitimate Applications
Face generation and synthetic data: NVIDIA's StyleGAN2 generates photorealistic faces of non-existent people. Companies like Generated.Photos sell synthetic faces for advertising where using real people's photos would require model releases. The privacy benefit: no real person can be identified from purely synthetic training data.
Medical imaging augmentation: A hospital has 200 examples of a rare cancer type — far too few to train a robust classifier. They train a conditional GAN on these 200 examples plus 5,000 normal cases, then generate 10,000 synthetic rare cancer images. The augmented dataset (200 real + 10,000 GAN) trains a classifier with 15% higher sensitivity than without GAN augmentation. This is one of the most valuable medical AI applications and is actively used in clinical research.
Super-resolution: SRGAN and ESRGAN upscale low-resolution images 4× with photorealistic texture detail. Streaming services use GAN-based super-resolution to deliver 4K apparent quality over 1080p bandwidth. Forensic image enhancement uses similar techniques.
# ESRGAN super-resolution with Real-ESRGAN (pip install realesrgan)
# from realesrgan import RealESRGANer
# from basicsr.archs.rrdbnet_arch import RRDBNet
# import cv2
# model = RRDBNet(num_in_ch=3, num_out_ch=3, scale=4)
# upsampler = RealESRGANer(scale=4, model_path='RealESRGAN_x4.pth', model=model)
# low_res = cv2.imread('low_res.jpg')
# high_res, _ = upsampler.enhance(low_res, outscale=4)
# cv2.imwrite('super_res.jpg', high_res)
print("Real-ESRGAN: 4x upscaling with GAN-generated photorealistic texture")
print("Input: 256x256 → Output: 1024x1024 with new detail")
The Dark Side: Deepfakes
The same technology that generates non-existent faces can also generate realistic videos of real people saying things they never said. Deepfake detection is now an active research area: Deepfake Detection Challenge (DFDC), FaceForensics++, and commercial deepfake detection APIs (Microsoft, Reality Defender). Current best detectors achieve ~90% accuracy on known deepfake methods but struggle with novel techniques. The arms race between generation and detection quality is ongoing.
MolGAN generates novel molecular graphs with desired properties (high binding affinity to a target protein, low toxicity, synthesisability). The GAN operates on graph representations — atoms as nodes, bonds as edges — rather than images. Trained on existing drug databases, it generates molecules in regions of chemical space not explored by existing drugs. Vertex Pharmaceuticals and Insilico Medicine have both reported GAN-discovered drug candidates entering clinical trials. This represents perhaps the most consequential application of GAN technology.
✍️ Practice Exercises
- Implement the full DCGAN training loop using the Generator and Discriminator from Section 4. Train on MNIST for 20 epochs. Save a grid of 64 generated images after each epoch using
torchvision.utils.save_image. Verify that images look recognisable by epoch 10. - Add label smoothing (real=0.9, fake=0.1) and Gaussian noise injection (std=0.05, annealed to 0 over 50 epochs) to the DCGAN training loop. Compare the final FID to training without these tricks on a 5,000-sample subset of CelebA.
- Implement the ConditionalGenerator from Section 6 and train it on MNIST. After training, generate 10 images conditioned on each digit (0–9) and display them in a 10×10 grid. Verify class conditioning: every row should show the same digit.
- Implement WGAN-GP from Section 5. Train it on CIFAR-10 for 50 epochs. Plot the Wasserstein distance (difference between critic scores for real and fake) over training. Compare stability to standard GAN training on the same dataset.
▶ Show Solution (Exercise 1 — DCGAN Training Loop)
import torch, torchvision
import torch.nn as nn
import torch.optim as optim
import torchvision.transforms as transforms
from torchvision.utils import save_image
import os
device = 'cuda' if torch.cuda.is_available() else 'cpu'
# Data
transform = transforms.Compose([
transforms.Resize(64),
transforms.CenterCrop(64),
transforms.ToTensor(),
transforms.Normalize([0.5], [0.5]),
])
dataset = torchvision.datasets.MNIST('.', download=True, transform=transform)
dataloader = torch.utils.data.DataLoader(dataset, batch_size=128, shuffle=True)
# Models (from Section 4, adjusted for 1-channel MNIST)
G = DCGANGenerator(latent_dim=100, ngf=32).to(device)
D = DCGANDiscriminator(ndf=32).to(device)
# (adjust N_CHANNELS=1 in the class definitions for MNIST)
opt_G = optim.Adam(G.parameters(), lr=2e-4, betas=(0.5, 0.999))
opt_D = optim.Adam(D.parameters(), lr=2e-4, betas=(0.5, 0.999))
criterion = nn.BCEWithLogitsLoss()
fixed_z = torch.randn(64, 100, device=device) # fixed noise for tracking progress
os.makedirs('gan_samples', exist_ok=True)
for epoch in range(20):
loss_D_total, loss_G_total, n_batches = 0, 0, 0
for real_imgs, _ in dataloader:
bs = real_imgs.size(0)
real_imgs = real_imgs.to(device)
# Train D
D.zero_grad()
z = torch.randn(bs, 100, device=device)
fake_imgs = G(z).detach()
loss_D = (criterion(D(real_imgs), torch.full((bs,1), 0.9, device=device)) +
criterion(D(fake_imgs), torch.zeros(bs, 1, device=device))) / 2
loss_D.backward(); opt_D.step()
# Train G
G.zero_grad()
z = torch.randn(bs, 100, device=device)
fake_imgs = G(z)
loss_G = criterion(D(fake_imgs), torch.ones(bs, 1, device=device))
loss_G.backward(); opt_G.step()
loss_D_total += loss_D.item(); loss_G_total += loss_G.item(); n_batches += 1
with torch.no_grad():
samples = G(fixed_z)
save_image(samples, f'gan_samples/epoch_{epoch:02d}.png',
nrow=8, normalize=True)
print(f"Epoch {epoch:02d} D={loss_D_total/n_batches:.4f} G={loss_G_total/n_batches:.4f}")
📚 Primary Source for This Lesson
Goodfellow et al. (2014) — "Generative Adversarial Networks"
The original paper that introduced the min-max adversarial game this lesson is built on. For the convolutional architecture used in practice, see Radford, Metz & Chintala (2015) "Unsupervised Representation Learning with Deep Convolutional GANs" (DCGAN).