A complete GAN project โ building and training a DCGAN to generate new, realistic-looking images from random noise, a hands-on look at adversarial training's genuinely different training dynamics.
Problem Statement
Train a Generative Adversarial Network to generate new, plausible images resembling a training dataset (e.g. handwritten digits or simple faces), evaluated qualitatively by visual inspection of generated samples.
Dataset
MNIST (handwritten digits) is a good starting dataset for a first GAN โ simple enough that a DCGAN can produce visibly convincing results within a reasonable training time on modest hardware.
Architecture & Approach
DCGAN (Deep Convolutional GAN) uses a convolutional generator (transposed convolutions to upsample random noise into a full image) and a convolutional discriminator (a standard CNN classifying real vs generated), trained adversarially โ the generator trying to fool the discriminator, the discriminator trying not to be fooled.
Step-by-Step Build
import torch
import torch.nn as nn
class Generator(nn.Module):
def __init__(self, noise_dim=100):
super().__init__()
self.net = nn.Sequential(
nn.ConvTranspose2d(noise_dim, 256, 7, 1, 0), nn.BatchNorm2d(256), nn.ReLU(),
nn.ConvTranspose2d(256, 128, 4, 2, 1), nn.BatchNorm2d(128), nn.ReLU(),
nn.ConvTranspose2d(128, 1, 4, 2, 1), nn.Tanh() # output in [-1, 1], matching normalized images
)
def forward(self, z): return self.net(z.view(z.size(0), -1, 1, 1))
class Discriminator(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(1, 64, 4, 2, 1), nn.LeakyReLU(0.2),
nn.Conv2d(64, 128, 4, 2, 1), nn.BatchNorm2d(128), nn.LeakyReLU(0.2),
nn.Conv2d(128, 1, 7, 1, 0), nn.Sigmoid() # outputs a single "real vs fake" probability
)
def forward(self, x): return self.net(x).view(-1)
generator = Generator()
discriminator = Discriminator()
opt_g = torch.optim.Adam(generator.parameters(), lr=0.0002, betas=(0.5, 0.999))
opt_d = torch.optim.Adam(discriminator.parameters(), lr=0.0002, betas=(0.5, 0.999))
loss_fn = nn.BCELoss()
for epoch in range(20):
for real_images, _ in train_loader:
batch_size = real_images.size(0)
real_labels = torch.ones(batch_size)
fake_labels = torch.zeros(batch_size)
# --- Train Discriminator: distinguish real from fake ---
opt_d.zero_grad()
real_loss = loss_fn(discriminator(real_images), real_labels)
noise = torch.randn(batch_size, 100)
fake_images = generator(noise)
fake_loss = loss_fn(discriminator(fake_images.detach()), fake_labels) # detach: don't train G here
d_loss = real_loss + fake_loss
d_loss.backward()
opt_d.step()
# --- Train Generator: fool the discriminator ---
opt_g.zero_grad()
g_loss = loss_fn(discriminator(fake_images), real_labels) # G wants D to say "real"
g_loss.backward()
opt_g.step()
print(f"Epoch {epoch+1}: D_loss={d_loss.item():.4f}, G_loss={g_loss.item():.4f}")
Expected Results
After roughly 15-20 epochs on MNIST, generated digit images should become visually recognizable, if not perfectly sharp โ losses in GAN training are notoriously less directly interpretable than in standard supervised training, so visual inspection of generated samples throughout training matters more here than watching the loss curves alone.
Key Learnings & Extensions
- Notice
.detach()when training the discriminator on fake images โ this prevents gradients from flowing back into the generator during the discriminator's update step, keeping the two networks' training properly separated. - GAN training can be unstable โ if you observe mode collapse (the generator producing very similar-looking outputs regardless of input noise), this is a well-known, real GAN failure mode worth researching mitigations for.
- Extension: Try training a Conditional GAN instead, where the generator and discriminator both receive a class label, letting you control which digit gets generated.