Generative Adversarial Networks for Handwritten Digit Generation

    Abin Varghese
    Coffee AI
    abin[at]coffeeai[dot]co
    October 6, 2023
    Abstract

    I've always been fascinated by GANs. They don't just classify data, they create it. In this guide, I'll walk you through building a GAN that generates handwritten digits using the MNIST dataset. You'll see how two neural networks compete against each other, the generator trying to create realistic images and the discriminator trying to spot the fakes. By the end, you'll understand how adversarial training works and why GANs are so powerful for creative AI applications.

    Introduction

    Welcome to my exploration of Generative Adversarial Networks (GANs), where artificial intelligence meets creativity. I want to take you on a journey into the inner workings of GANs and show you their remarkable ability to generate stunningly realistic handwritten digit images.

    GANs represent a fundamental shift in machine learning. Rather than learning to classify or predict, they learn to create. This generative capability opens new frontiers in artificial intelligence, from data augmentation and content generation to artistic expression and beyond.

    From data preparation and model design to training and evaluation, I'll uncover the mechanics behind GANs and reveal the incredible potential these generative models hold. My implementation focuses on the MNIST dataset, providing a clear demonstration of how adversarial training enables neural networks to generate realistic synthetic data.

    Background: Understanding GANs

    The Adversarial Framework

    Generative Adversarial Networks consist of two neural networks engaged in a competitive game:

    • Generator: Creates synthetic data from random noise
    • Discriminator: Distinguishes real data from generated samples

    This adversarial process drives both networks to improve. The generator learns to create increasingly realistic samples, while the discriminator becomes better at detecting fakes. At equilibrium, the generator produces samples indistinguishable from real data.

    The MNIST Dataset

    I'm using the MNIST dataset of handwritten digits:

    • Training samples: 60,000 grayscale images
    • Image dimensions: 28×28 pixels
    • Classes: Digits 0-9
    • Format: Pixel values normalized to [0, 1]

    This dataset provides an ideal testbed for GAN development. It offers sufficient complexity while remaining computationally tractable.

    Data Preparation

    Loading and Transforming Data

    The first critical step involves preparing our MNIST dataset for GAN training. We implement a get_dl function to handle data transformation and loading:

    data_loading.py
    1def get_dl(batchsize):
    2 # Data Transformation: Converting images to Tensors
    3 train_transforms = transforms.Compose([transforms.ToTensor()])
    4 
    5 # Downloading the MNIST training and testing datasets and transforming them into Tensors
    6 train_data = MNIST(root='./train.', train=True, download=True, transform=train_transforms)
    7 test_data = MNIST(root='./test.', train=True, download=True, transform=train_transforms)
    8 
    9 # Data Loading: Creating DataLoader objects
    10 train_loader = DataLoader(train_data, batch_size=batchsize, shuffle=False, drop_last=True)
    11 test_loader = DataLoader(test_data, batch_size=batchsize, shuffle=False, drop_last=True)
    12 
    13 # Returning the prepared train and test data
    14 return train_loader, test_loader

    Data Processing Pipeline

    This method accomplishes several critical tasks:

    1. Data Transformation: We use transforms.Compose to create a transformation pipeline. The transforms.ToTensor() operation converts raw image data into PyTorch tensors, essential for neural network processing.

    2. Data Download and Transformation: The MNIST dataset is downloaded with training and testing datasets saved in separate directories. We apply transformations to convert these datasets into tensors.

    3. Data Loading: We utilize PyTorch's DataLoader class for efficient batch processing. Key parameters include:

      • batch_size: Number of samples per batch
      • shuffle=False: Maintains data order within batches
      • drop_last=True: Drops incomplete final batches to maintain consistent batch sizes
    4. Return Values: The function returns train_loader and test_loader objects for use during training and evaluation.

    With our data pipeline established, we're equipped to train our GAN to generate realistic handwritten digits.

    Model Architecture

    Generator Network Design

    The generator network transforms random noise into synthetic images. Our Generator class implements this transformation:

    generator.py
    1class Generator(nn.Module):
    2 def __init__(self, batch_size, input_dim):
    3 super().__init__()
    4 self.batch_size = batch_size
    5 self.input_dim = input_dim
    6 self.fc1 = nn.Linear(input_dim, 128)
    7 self.LRelu = nn.LeakyReLU()
    8 self.fc2 = nn.Linear(128, 1 * 28 * 28)
    9 self.tanh = nn.Tanh()
    10 
    11 def forward(self, x):
    12 # Layer 1: Fully Connected + Leaky ReLU Activation
    13 layer1 = self.LRelu(self.fc1(x))
    14 
    15 # Layer 2: Fully Connected + Tanh Activation
    16 layer2 = self.tanh(self.fc2(layer1))
    17 
    18 # Reshaping Output
    19 output = layer2.view(self.batch_size, 1, 28, 28)
    20 
    21 return output

    Generator Architecture Details

    Initialization: The constructor accepts two parameters:

    • batch_size: Number of samples generated per batch
    • input_dim: Dimensionality of the input noise vector (latent space)

    Network Layers:

    • self.fc1: First fully connected layer mapping input_dim → 128 dimensions, followed by Leaky ReLU activation
    • self.fc2: Second fully connected layer mapping 128 → 784 (28×28) dimensions, followed by Tanh activation

    Forward Propagation: The forward pass:

    1. Transforms input noise through fc1 with Leaky ReLU activation
    2. Maps to image space through fc2 with Tanh activation (output range [-1, 1])
    3. Reshapes output to match MNIST image dimensions [batch_size, 1, 28, 28]

    The generator progressively learns to map random noise to realistic digit images through adversarial training.

    Discriminator Network Design

    The discriminator evaluates whether images are real or generated. Our Discriminator class implements this binary classification:

    discriminator.py
    1class Discriminator(nn.Module):
    2 def __init__(self, batch_size):
    3 super().__init__()
    4 self.batch_size = batch_size
    5 self.fc1 = nn.Linear(1 * 28 * 28, 128)
    6 self.LRelu = nn.LeakyReLU()
    7 self.fc2 = nn.Linear(128, 1)
    8 self.SigmoidL = nn.Sigmoid()
    9 
    10 def forward(self, x):
    11 # Flattening the Input
    12 flat = x.view(self.batch_size, -1)
    13 
    14 # Layer 1: Fully Connected + Leaky ReLU Activation
    15 layer1 = self.LRelu(self.fc1(flat))
    16 
    17 # Layer 2: Fully Connected + Sigmoid Activation
    18 output = self.SigmoidL(self.fc2(layer1))
    19 
    20 # Reshaping Output
    21 return output.view(-1, 1).squeeze(1)

    Discriminator Architecture Details

    Initialization: The constructor accepts batch_size to process samples in batches.

    Network Layers:

    • self.fc1: First fully connected layer mapping 784 (flattened 28×28 image) → 128 dimensions with Leaky ReLU activation
    • self.fc2: Second fully connected layer mapping 128 → 1 dimension with Sigmoid activation (output range [0, 1])

    Forward Propagation: The forward pass:

    1. Flattens input images to 1D vectors
    2. Processes through fc1 with Leaky ReLU activation
    3. Maps to scalar confidence score through fc2 with Sigmoid activation
    4. Reshapes output to single scalar per sample

    The Sigmoid output represents the discriminator's confidence that the input is real (values near 1) versus fake (values near 0).

    Training Procedure

    The Training Loop

    The train_model function orchestrates the adversarial training process:

    training.py
    1def train_model(no_of_epochs, disc, gen, optimD, optimG, dataloaders, loss_fn, input_size, batch_size):
    2 """
    3 disc: Discriminator model
    4 gen: Generator model
    5 optimD: Optimizer for discriminator
    6 optimG: Optimizer for generator
    7 """
    8
    9 # Setting the device as CUDA or CPU
    10 device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
    11 real_label = 1 # Real label
    12 fake_label = 0 # Fake label
    13 
    14 # Running each epoch
    15 for epoch in range(no_of_epochs):
    16 print("Epoch {}/{}".format(epoch + 1, no_of_epochs))
    17 running_loss_D = 0
    18 running_loss_G = 0
    19 
    20 for phase in ["train"]:
    21 # Getting the input and labels from the dataloader
    22 for inputs, _ in dataloaders[phase]:
    23 inputs = inputs.to(device)
    24 
    25 # Converting labels into torch tensors with the proper size as per the batch size
    26 real_labels = torch.full((batch_size,), real_label, dtype=inputs.dtype, device=device)
    27 fake_labels = torch.full((batch_size,), fake_label, dtype=inputs.dtype, device=device)
    28 
    29 # Resetting the gradients for the optimizers
    30 optimD.zero_grad()
    31 optimG.zero_grad()
    32 
    33 # Output from the discriminator
    34 output = disc(inputs)
    35 
    36 # Discriminator real loss
    37 D_real_loss = loss_fn(output, real_labels)
    38 D_real_loss.backward()
    39 
    40 # Random torch tensor as noise data
    41 noise = torch.randn(batch_size, input_size, device=device)
    42 
    43 # Passing noise through the generator to get fake images
    44 fake = gen(noise)
    45 
    46 # Passing fake images through the discriminator with detaching (no gradient flow)
    47 output = disc(fake.detach())
    48 
    49 # Discriminator fake loss
    50 D_fake_loss = loss_fn(output, fake_labels)
    51 
    52 # Backpropagation for discriminator
    53 D_fake_loss.backward()
    54 
    55 # Total loss for discriminator
    56 disc_loss = D_real_loss + D_fake_loss
    57 running_loss_D += disc_loss.item()
    58 optimD.step()
    59 
    60 # Resetting the gradients for the generator optimizer
    61 optimG.zero_grad()
    62 
    63 # Passing fake images obtained from the generator to the discriminator
    64 output = disc(fake)
    65 
    66 # Generator loss by giving fake images as input but with real labels
    67 gen_loss = loss_fn(output, real_labels)
    68 
    69 # Backpropagation for generator
    70 gen_loss.backward()
    71 running_loss_G += gen_loss.item()
    72 optimG.step()
    73 
    74 # Displaying losses for each epoch
    75 print("Discriminator Loss: {:.4f}".format(running_loss_D))
    76 print("Generator Loss: {:.4f}".format(running_loss_G))

    Training Dynamics

    The training procedure implements the adversarial game:

    Device Selection: Automatically detects and utilizes GPU acceleration if available.

    Discriminator Training:

    1. Evaluate real images and compute loss against real labels
    2. Generate fake images from random noise
    3. Evaluate fake images (detached from generator graph) and compute loss against fake labels
    4. Backpropagate combined loss and update discriminator parameters

    Generator Training:

    1. Generate fake images from random noise
    2. Pass through discriminator (with gradient flow)
    3. Compute loss against real labels (fool the discriminator)
    4. Backpropagate and update generator parameters

    Key Insight: The generator is trained to maximize discriminator error by labeling fake images as real, while the discriminator learns to correctly classify both real and fake samples.

    Experimental Setup

    Configuration and Initialization

    We establish the complete training configuration:

    config.py
    1# Fixing seed for reproducibility
    2torch.manual_seed(4)
    3 
    4# Batch size for training
    5batch_size = 128
    6 
    7# Number of training epochs
    8no_of_epochs = 5
    9 
    10# Input size for the latent variable
    11input_size = 100
    12 
    13# Getting the train and test data loaders and organizing them in a dictionary
    14train_loader, test_loader = get_dl(batch_size)
    15dl = {"train": train_loader, "valid": test_loader}
    16 
    17# Discriminator model initialization
    18disc = Discriminator(batch_size)
    19 
    20# Generator model initialization
    21gen = Generator(batch_size, input_size)
    22 
    23# Optimizer for discriminator
    24optimD = torch.optim.Adam(disc.parameters(), lr=0.001, weight_decay=1e-05)
    25 
    26# Optimizer for generator
    27optimG = torch.optim.Adam(gen.parameters(), lr=0.001, weight_decay=1e-05)
    28 
    29# Binary cross-entropy loss function
    30loss_fn = torch.nn.BCELoss()
    31 
    32# Moving models to the selected device (CPU or GPU)
    33device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
    34disc.to(device)
    35gen.to(device)
    36 
    37# Training the GAN model
    38train_model(no_of_epochs, disc, gen, optimD, optimG, dl, loss_fn, input_size, batch_size)

    Hyperparameter Selection

    Reproducibility: Fixed random seed ensures consistent results across runs.

    Training Parameters:

    • Batch size: 128 samples
    • Epochs: 5 iterations through dataset
    • Latent dimension: 100-dimensional noise vectors

    Optimization:

    • Algorithm: Adam optimizer for both networks
    • Learning rate: 0.001
    • Weight decay: 1e-05 for regularization
    • Loss function: Binary cross-entropy

    These hyperparameters balance training stability with convergence speed, crucial for successful GAN training.

    Training Results

    The training process demonstrates progressive improvement:

    Epoch 1/5
    Discriminator Loss: 428.8399
    Generator Loss: 752.0623
    
    Epoch 2/5
    Discriminator Loss: 667.7617
    Generator Loss: 519.8400
    
    Epoch 3/5
    Discriminator Loss: 662.0597
    Generator Loss: 413.3630
    
    Epoch 4/5
    Discriminator Loss: 593.1331
    Generator Loss: 427.6587
    
    Epoch 5/5
    Discriminator Loss: 597.1973
    Generator Loss: 450.6230
    

    The loss trajectories indicate successful adversarial training, with both networks adapting to each other's improvements.

    Results and Visualization

    Generating Synthetic Digits

    We visualize the generator's output using the following code:

    visualization.py
    1import matplotlib.pyplot as plt
    2import torchvision
    3 
    4# Method to plot an image
    5def show_image(img):
    6 # Converting the image from a tensor to a NumPy array
    7 npimg = img.numpy()
    8 plt.imshow(np.transpose(npimg, (1, 2, 0)))
    9 
    10# Generating random noise
    11random_noise = torch.randn(128, input_size, device=device)
    12 
    13# Generating fake images from random noise using the generator
    14fake = gen(random_noise)
    15 
    16# Moving the generated images to the CPU for visualization
    17fake = fake.cpu()
    18 
    19# Plotting the fake images
    20fig, ax = plt.subplots(figsize=(15, 7))
    21show_image(torchvision.utils.make_grid(fake[0:50], 10, 5))
    22plt.show()

    Generated Samples

    The result displays 50 synthetic handwritten digits generated by our trained GAN:

    Generated Handwritten Digits
    Figure: 50 synthetic handwritten digits generated by the trained GAN model, demonstrating the network's ability to create realistic digit images from random noise

    Each image represents the generator's learned understanding of handwritten digit structure. The quality and diversity of these samples demonstrate successful adversarial training.

    Discussion

    Training Dynamics and Convergence

    Our implementation demonstrates several key aspects of GAN training:

    Loss Behavior: The discriminator and generator losses fluctuate throughout training, reflecting the adversarial dynamics. This instability is characteristic of GANs and indicates both networks are actively learning.

    Quality Progression: Generated samples improve significantly from random noise to recognizable digits, validating the adversarial training framework.

    Architectural Choices: The simple fully-connected architecture proves sufficient for MNIST, though more complex datasets would benefit from convolutional architectures.

    Challenges and Considerations

    Mode Collapse: GANs can suffer from mode collapse where the generator produces limited variety. Our results show reasonable diversity across digit classes.

    Training Stability: Balancing discriminator and generator learning rates is crucial. Too strong a discriminator prevents generator learning; too weak allows poor quality outputs.

    Evaluation Metrics: Unlike supervised learning, GANs lack clear objective metrics. Visual inspection and downstream task performance provide the primary evaluation methods.

    Conclusion

    I've shown you how to implement and train Generative Adversarial Networks for handwritten digit generation. Through systematic exploration of data preparation, model architecture, and training procedures, I've demonstrated how adversarial learning enables neural networks to generate realistic synthetic images.

    GANs represent a paradigm shift in artificial intelligence. They move beyond classification and prediction to genuine creation. From data augmentation and content generation to artistic expression, GANs demonstrate the creative potential of machine learning.

    The adversarial framework (two networks competing to improve) provides a powerful mechanism for unsupervised learning. As the field evolves, GANs continue pushing the boundaries of what's possible at the intersection of technology and imagination.

    The journey of GANs has only begun. Future developments in architecture design, training stability, and application domains promise even more remarkable creations. The extraordinary is becoming reality, one generated pixel at a time.