Next Step with GANs: Adding Control to Generate the Faces I Want

[Me] | Jul 31, 2025 min read

In my last blog, we built a Generative Adversarial Network (GAN) from scratch. It was super fun and a great learning experience to see a model generate (somewhat) realistic looking, albeit random, human faces.

But once the excitement wore off, I was left wondering. The GAN was a black box. I could hit a button and get a random face, but I had no way of telling it what kind of face to create. What if I wanted a person with blond hair? What if I wanted someone who was smiling?

That is what this post is about. This is the next part in my learning journey where I learned how to add a “steering wheel” to the GAN, and we will be turning our basic GAN into a Conditional GAN (CGAN) that will allow us to control the faces it generates.

This post is going to contain a mixture of the code and the ideas behind it. Hopefully that gives you some context for everything below as I figure this out from the perspective of me figuring this out. Let’s go ahead and get started!

The Big Idea: What Does it Mean for a GAN to be “Conditional”?

Original Image Source

Imagine you’re an artist who can paint any face, but someone walks up and says “paint me a person with blonde hair.” That’s essentially what a Conditional GAN (CGAN) does, it generates images based on specific instructions or labels.

Regular GAN vs Conditional GAN: The Key Difference

In a regular GAN, the Generator is like an artist painting random faces from imagination. You never know what you’ll get, maybe brown hair, maybe blonde, maybe a hat, maybe glasses. It’s completely unpredictable.

But what if we want control? What if we want to specifically generate a face with blonde hair, or a digit “7”, or a cat instead of a dog? That’s where Conditional GANs come in.

How Does a CGAN Work?

The magic happens by adding one extra ingredient to both networks: a label or condition. Think of it as giving both the Generator and Discriminator a “cheat sheet” about what the image should contain.

Here’s how each network changes:

The Generator’s New Job:

  • Before (Regular GAN): random_noise → Image (creates random image)
  • After (CGAN): random_noise + label → Image (creates specific image)
  • Example: random_noise + "Blonde_Hair" → Image of person with blonde hair

The Discriminator’s New Job:

  • Before (Regular GAN): Image → Real or Fake? (only checks authenticity)
  • After (CGAN): Image + label → Real or Fake? (checks both authenticity and correctness)
  • Example: Image + "Blonde_Hair" → Is this real AND does it actually show blonde hair?

Why This Simple Change is Powerful

By giving the Discriminator the label, it becomes much stricter. It’s no longer just asking “Is this image realistic?” but also “Does this image match what was requested?”

This forces the Generator to become smarter. It can’t just create any realistic image – it must create a realistic image that specifically matches the given condition. If asked for blonde hair, it better deliver blonde hair, or the Discriminator will reject it.

The result? We get controllable image generation where we can specify exactly what we want to create.

Step 0: Import Necessary Libraries and Packages

Let’s start by importing the necessary libraries and packages. We’ll need PyTorch for deep learning, torchvision for image data manipulation, and matplotlib for plotting. Also, we will be setting some hyperparameters for our GAN.

import torch
import torch.nn as nn

from torch.utils.data import Dataset
from torch.utils.data import DataLoader
from PIL import Image

from torchvision import transforms
import torchvision.utils as vutils
import torch.optim as optim

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import matplotlib.animation as animation
from IPython.display import HTML
import os
IMAGE_SIZE = 64

BATCH_SIZE = 1024
WORKERS = 4

NGPUS = 3

NUM_CHANNELS = 3
LATENT_DIM = 100

G_FEATURES = 64
D_FEATURES = 64

BETA1=0.5
LR=0.0002
NUM_EPOCHS=25

Step 1: Getting our Data Ready (with Labels!)

The main hurdle was the data. For this to run, we need data that has images and labels. Once again, I’m using the CelebA dataset, and it is nice that it comes with 40 attributes per image, such as, “Male”, “Smiling”, “Wavy_Hair”, etc. To load the data, I needed to write my own CelebADataset class in PyTorch because regular data loaders do not handle this image-and-label combination properly.

class CelebADataset(Dataset):
    def __init__(self, img_dir, attr_file, transform=None, selected_attrs=None):
        """
        Args:
            img_dir: Directory with images
            attr_file: Path to list_attr_celeba.txt
            transform: Image transforms
            selected_attrs: List of attribute names to use (if None, uses all)
        """
        self.img_dir = img_dir
        self.transform = transform
        
        # Read attribute file
        self.attr_df = pd.read_csv(attr_file, header=1)
        
        # Get attribute names from first row (which becomes column names)
        self.attr_names = list(self.attr_df.columns[1:])  # Skip filename column
        
        # Select specific attributes if provided
        if selected_attrs is not None:
            self.selected_attrs = selected_attrs
            # Verify all selected attributes exist
            missing_attrs = set(selected_attrs) - set(self.attr_names)
            if missing_attrs:
                raise ValueError(f"Attributes not found: {missing_attrs}")
        else:
            self.selected_attrs = self.attr_names
        
        self.filenames = self.attr_df.iloc[:, 0].values  # First column is filename
        
    def __len__(self):
        return len(self.attr_df)
    
    def __getitem__(self, idx):
        # Get image
        img_name = self.filenames[idx]
        img_path = os.path.join(self.img_dir, 'img_align_celeba', 'img_align_celeba', img_name)
        image = Image.open(img_path).convert('RGB')
        
        if self.transform:
            image = self.transform(image)
        
        # Get labels for selected attributes
        labels = []
        for attr in self.selected_attrs:
            # Convert from {-1, 1} to {0, 1}
            label = (self.attr_df.loc[idx, attr] + 1) // 2
            labels.append(label)

        labels = torch.tensor(labels, dtype=torch.float32)
        
        return (image, labels)
    
    def get_attr_names(self):
        """Return list of selected attribute names"""
        return self.selected_attrs

The most important part here is getitem. For every image, it also creates a labels tensor from the attributes file. I did a little trick here to convert the labels from the dataset’s {-1, 1} format to a more standard {0, 1} format.

My Assumption: For this project, I decided to use all 40 attributes provided by CelebA. This might actually make it harder for the model, as some attributes could be confusing or rare. A good next step would be to pick a smaller, cleaner set of attributes and see if the results improve.

The rest of the data setup, like the transforms and DataLoader, is pretty much the same as in our first GAN project.

transform = transforms.Compose([
    transforms.Resize(IMAGE_SIZE),
    transforms.CenterCrop(IMAGE_SIZE),
    transforms.ToTensor(),
    transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
])

dataset = CelebADataset( './data/celeba', './data/celeba/list_attr_celeba.csv', transform=transform)

dataloader = torch.utils.data.DataLoader(dataset, batch_size=BATCH_SIZE,
                                         shuffle=True, num_workers=WORKERS)
NUM_CLASSES = len(dataset.get_attr_names())
device = torch.device("cuda:0" if (torch.cuda.is_available() and NGPUS > 0) else "cpu")

# Plot some training images
(real_batch, labels) = next(iter(dataloader))
plt.figure(figsize=(8,8))
plt.axis("off")
plt.title("Training Images")
plt.imshow(np.transpose(vutils.make_grid(real_batch.to(device)[:64], padding=2, normalize=True).cpu(),(1,2,0)))
plt.show()

png

Step 2: Upgrading the Generator and Discriminator

Now for the fun part: updating the models. The architecture is almost identical to our DCGAN from Part 1, with one tiny but critical change.

def weights_init(m):
    classname = m.__class__.__name__
    if classname.find('Conv') != -1:
        nn.init.normal_(m.weight.data, 0.0, 0.02)
    elif classname.find('BatchNorm') != -1:
        nn.init.normal_(m.weight.data, 1.0, 0.02)
        nn.init.constant_(m.bias.data, 0)

The Generator

Our old Generator took a 100-dimensional noise vector as input. The new one will take the noise vector plus the label vector. So if we have 40 attributes, the new input dimension is 100 (noise) + 40 (labels) = 140.

class Generator(nn.Module):
    def __init__(self):
        super(Generator, self).__init__()
        INPUT_DIM = LATENT_DIM + NUM_CLASSES
        self.model = nn.Sequential(
            # input is Z_DIM, going into a convolutional layer
            nn.ConvTranspose2d(INPUT_DIM, G_FEATURES * 8, 4, 1, 0),
            nn.BatchNorm2d(G_FEATURES * 8),
            nn.ReLU(True),

            # state size. (G_FEATURES * 8) x 4 x 4
            nn.ConvTranspose2d(G_FEATURES * 8, G_FEATURES * 4, 4, 2, 1),
            nn.BatchNorm2d(G_FEATURES * 4),
            nn.ReLU(True),

            # state size. (G_FEATURES * 4) x 8 x 8
            nn.ConvTranspose2d(G_FEATURES * 4, G_FEATURES * 2, 4, 2, 1),
            nn.BatchNorm2d(G_FEATURES * 2),
            nn.ReLU(True),

            # state size. (G_FEATURES * 2) x 16 x 16
            nn.ConvTranspose2d(G_FEATURES * 2, G_FEATURES, 4, 2, 1),
            nn.BatchNorm2d(G_FEATURES),
            nn.ReLU(True),

            # state size. (G_FEATURES) x 32 x 32
            nn.ConvTranspose2d(G_FEATURES, NUM_CHANNELS, 4, 2, 1),
            nn.Tanh()
        )

    def forward(self, z, labels):
        labels = labels.unsqueeze(2).unsqueeze(3)  # [batch_size, num_classes, 1, 1]
        
        # Concatenate noise and labels
        input_tensor = torch.cat([z, labels], dim=1)  # [batch_size, latent_dim + num_classes, 1, 1]
        return self.model(input_tensor)
        

That torch.cat line is the key. It’s how we inject our “condition” into the generation process.

netG = Generator().to(device)

if (device.type == 'cuda') and (NGPUS > 1):
    netG = nn.DataParallel(netG, list(range(NGPUS)))

netG.apply(weights_init)

netG
DataParallel(
  (module): Generator(
    (model): Sequential(
      (0): ConvTranspose2d(140, 512, kernel_size=(4, 4), stride=(1, 1))
      (1): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (2): ReLU(inplace=True)
      (3): ConvTranspose2d(512, 256, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1))
      (4): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (5): ReLU(inplace=True)
      (6): ConvTranspose2d(256, 128, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1))
      (7): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (8): ReLU(inplace=True)
      (9): ConvTranspose2d(128, 64, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1))
      (10): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (11): ReLU(inplace=True)
      (12): ConvTranspose2d(64, 3, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1))
      (13): Tanh()
    )
  )
)

The Discriminator

The Discriminator gets a similar upgrade. It needs to look at an image and check if it matches the given labels. So, instead of taking just an image as input, it takes the image channels plus the number of labels.

We achieve this by reshaping the label vector into a “channel” and concatenating it with the image channels.

class Discriminator(nn.Module):
    def __init__(self):
        super(Discriminator, self).__init__()

        self.model = nn.Sequential(
            nn.Conv2d(NUM_CHANNELS + NUM_CLASSES, D_FEATURES, 4, 2, 1, bias=False),
            nn.LeakyReLU(0.2, inplace=True),

            nn.Conv2d(D_FEATURES, D_FEATURES * 2, 4, 2, 1, bias=False),
            nn.BatchNorm2d(D_FEATURES * 2),
            nn.LeakyReLU(0.2, inplace=True),

            nn.Conv2d(D_FEATURES * 2, D_FEATURES * 4, 4, 2, 1, bias=False),
            nn.BatchNorm2d(D_FEATURES * 4),
            nn.LeakyReLU(0.2, inplace=True),

            nn.Conv2d(D_FEATURES * 4, D_FEATURES * 8, 4, 2, 1, bias=False),
            nn.BatchNorm2d(D_FEATURES * 8),
            nn.LeakyReLU(0.2, inplace=True),

            nn.Conv2d(D_FEATURES * 8, 1, 4, 1, 0, bias=False),
            nn.Sigmoid(),
        )

    def forward(self, image, labels):
        batch_size = image.size(0)
        
        # Expand labels to match image spatial dimensions
        labels = labels.unsqueeze(2).unsqueeze(3)  # [batch_size, num_classes, 1, 1]
        labels = labels.expand(batch_size, -1, IMAGE_SIZE, IMAGE_SIZE)  # [batch_size, num_classes, 64, 64]
        
        # Concatenate image and labels
        input_tensor = torch.cat([image, labels], dim=1)  # [batch_size, num_channels + num_classes, 64, 64]
        
        return self.model(input_tensor).view(-1)
        

This was a part that took me a minute to get right. We can’t just attach a small label vector to a big image. We have to expand the labels so they have the same spatial dimensions as the image (64x64), effectively creating 40 extra “channels” that the discriminator can see.

netD = Discriminator().to(device)

if (device.type == 'cuda') and (NGPUS > 1):
    netD = nn.DataParallel(netD, list(range(NGPUS)))

netD.apply(weights_init)

netD
DataParallel(
  (module): Discriminator(
    (model): Sequential(
      (0): Conv2d(43, 64, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1), bias=False)
      (1): LeakyReLU(negative_slope=0.2, inplace=True)
      (2): Conv2d(64, 128, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1), bias=False)
      (3): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (4): LeakyReLU(negative_slope=0.2, inplace=True)
      (5): Conv2d(128, 256, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1), bias=False)
      (6): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (7): LeakyReLU(negative_slope=0.2, inplace=True)
      (8): Conv2d(256, 512, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1), bias=False)
      (9): BatchNorm2d(512, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
      (10): LeakyReLU(negative_slope=0.2, inplace=True)
      (11): Conv2d(512, 1, kernel_size=(4, 4), stride=(1, 1), bias=False)
      (12): Sigmoid()
    )
  )
)

Step 3: The Training Loop

The training loop follows the same two-step dance as before: first, we train the Discriminator, and then we train the Generator. The main difference is that we now have to pass the labels to the models every time.

One thing I changed from my first attempt was the real_label value.

criterion = nn.MSELoss()

fixed_noise = torch.randn(64, LATENT_DIM, 1, 1, device=device)

real_label = 0.9
fake_label = 0.

optimizerD = optim.Adam(netD.parameters(), lr=LR, betas=(BETA1, 0.999))
optimizerG = optim.Adam(netG.parameters(), lr=LR, betas=(BETA1, 0.999))

Using 0.9 instead of 1.0 for real images is a technique called label smoothing. I found that without it, my Discriminator would get too confident and its loss would drop to almost zero. When that happens, the Generator can’t learn anything. Label smoothing keeps the Discriminator a little “on its toes” and helps stabilize the training. It was a small change that made a big difference.

img_list = []
G_losses = []
D_losses = []
iters = 0

# Create fixed noise and labels for consistent evaluation
fixed_noise = torch.randn(64, LATENT_DIM, 1, 1, device=device)
fixed_labels = torch.randint(0, 2, (64, NUM_CLASSES), device=device).float()

print("Starting Training Loop...")
for epoch in range(NUM_EPOCHS):
    for i, data in enumerate(dataloader):
        
        ############################
        # (1) Update D network: maximize log(D(x,y)) + log(1 - D(G(z,y),y))
        ###########################
        ## Train with all-real batch
        netD.zero_grad()
        # Format batch - now includes labels
        real_cpu = data[0].to(device)
        real_labels = data[1].to(device)  # Get labels from dataset
        b_size = real_cpu.size(0)
        label = torch.full((b_size,), real_label, dtype=torch.float, device=device)
        
        # Forward pass real batch through D with labels
        output = netD(real_cpu, real_labels)
        # Calculate loss on all-real batch
        errD_real = criterion(output, label)
        # Calculate gradients for D in backward pass
        errD_real.backward()
        D_x = output.mean().item()

        ## Train with all-fake batch
        # Generate batch of latent vectors and random labels
        noise = torch.randn(b_size, LATENT_DIM, 1, 1, device=device)

        fake_labels = real_labels  # Same labels as real batch

        # Generate fake image batch with G using labels
        fake = netG(noise, fake_labels)
        label.fill_(fake_label)
        
        # Classify all fake batch with D using same labels
        output = netD(fake.detach(), fake_labels)
        # Calculate D's loss on the all-fake batch
        errD_fake = criterion(output, label)
        # Calculate the gradients for this batch
        errD_fake.backward()
        D_G_z1 = output.mean().item()
        
        # Compute error of D as sum over the fake and the real batches
        errD = errD_real + errD_fake
        # Update D
        optimizerD.step()

        ############################
        # (2) Update G network: maximize log(D(G(z,y),y))
        ###########################
        netG.zero_grad()
        label.fill_(real_label)  # fake labels are real for generator cost
        
        # Since we just updated D, perform another forward pass of all-fake batch through D
        output = netD(fake, fake_labels)
        # Calculate G's loss based on this output
        errG = criterion(output, label)
        # Calculate gradients for G
        errG.backward()
        D_G_z2 = output.mean().item()
        # Update G
        optimizerG.step()

        # Output training stats
        if i % 50 == 0:
            print('[%d/%d][%d/%d]\tLoss_D: %.4f\tLoss_G: %.4f\tD(x): %.4f\tD(G(z)): %.4f / %.4f'
                  % (epoch, NUM_EPOCHS, i, len(dataloader),
                     errD.item(), errG.item(), D_x, D_G_z1, D_G_z2))

        # Save Losses for plotting later
        G_losses.append(errG.item())
        D_losses.append(errD.item())

        # Check how the generator is doing by saving G's output on fixed_noise and fixed_labels
        if (iters % 500 == 0) or ((epoch == NUM_EPOCHS-1) and (i == len(dataloader)-1)):
            with torch.no_grad():
                fake = netG(fixed_noise, fixed_labels).detach().cpu()
            img_list.append(vutils.make_grid(fake, padding=2, normalize=True))

        iters += 1
Starting Training Loop...
[0/25][0/198]	Loss_D: 0.5548	Loss_G: 0.6298	D(x): 0.6188	D(G(z)): 0.5915 / 0.1263
[0/25][50/198]	Loss_D: 0.1808	Loss_G: 0.8100	D(x): 0.9874	D(G(z)): 0.3594 / 0.0000
[0/25][100/198]	Loss_D: 0.1790	Loss_G: 0.7655	D(x): 0.7100	D(G(z)): 0.0423 / 0.0284
[0/25][150/198]	Loss_D: 0.3752	Loss_G: 0.6216	D(x): 0.4401	D(G(z)): 0.1199 / 0.1314
[1/25][0/198]	Loss_D: 0.5194	Loss_G: 0.4903	D(x): 0.4323	D(G(z)): 0.4491 / 0.2143
[1/25][50/198]	Loss_D: 0.3804	Loss_G: 0.5375	D(x): 0.5811	D(G(z)): 0.4466 / 0.1824
[1/25][100/198]	Loss_D: 0.4227	Loss_G: 0.3413	D(x): 0.4031	D(G(z)): 0.3460 / 0.3280
[1/25][150/198]	Loss_D: 0.3687	Loss_G: 0.3532	D(x): 0.5201	D(G(z)): 0.4413 / 0.3188
[2/25][0/198]	Loss_D: 0.4082	Loss_G: 0.3306	D(x): 0.4556	D(G(z)): 0.4370 / 0.3337
[2/25][50/198]	Loss_D: 0.4182	Loss_G: 0.2900	D(x): 0.4841	D(G(z)): 0.4722 / 0.3717
[2/25][100/198]	Loss_D: 0.3522	Loss_G: 0.3134	D(x): 0.4837	D(G(z)): 0.3842 / 0.3506
[2/25][150/198]	Loss_D: 0.4197	Loss_G: 0.2599	D(x): 0.4356	D(G(z)): 0.4403 / 0.3931
[3/25][0/198]	Loss_D: 0.3831	Loss_G: 0.2772	D(x): 0.4497	D(G(z)): 0.3829 / 0.3815
[3/25][50/198]	Loss_D: 0.3687	Loss_G: 0.3366	D(x): 0.5001	D(G(z)): 0.4389 / 0.3268
[3/25][100/198]	Loss_D: 0.4180	Loss_G: 0.2940	D(x): 0.4171	D(G(z)): 0.4043 / 0.3638
[3/25][150/198]	Loss_D: 0.4190	Loss_G: 0.3367	D(x): 0.4586	D(G(z)): 0.4324 / 0.3286
[4/25][0/198]	Loss_D: 0.3844	Loss_G: 0.3450	D(x): 0.4472	D(G(z)): 0.3854 / 0.3214
[4/25][50/198]	Loss_D: 0.2833	Loss_G: 0.4206	D(x): 0.6479	D(G(z)): 0.4346 / 0.2627
[4/25][100/198]	Loss_D: 0.3699	Loss_G: 0.3184	D(x): 0.4324	D(G(z)): 0.3434 / 0.3446
[4/25][150/198]	Loss_D: 0.3708	Loss_G: 0.2806	D(x): 0.3976	D(G(z)): 0.3044 / 0.3832
[5/25][0/198]	Loss_D: 0.3039	Loss_G: 0.3596	D(x): 0.4451	D(G(z)): 0.2384 / 0.3127
[5/25][50/198]	Loss_D: 0.3305	Loss_G: 0.5955	D(x): 0.6131	D(G(z)): 0.4605 / 0.1310
[5/25][100/198]	Loss_D: 0.4005	Loss_G: 0.4844	D(x): 0.4716	D(G(z)): 0.4101 / 0.2104
[5/25][150/198]	Loss_D: 0.2596	Loss_G: 0.4403	D(x): 0.4593	D(G(z)): 0.1425 / 0.2472
[6/25][0/198]	Loss_D: 0.4552	Loss_G: 0.7116	D(x): 0.2656	D(G(z)): 0.0351 / 0.0592
[6/25][50/198]	Loss_D: 0.2847	Loss_G: 0.5172	D(x): 0.4478	D(G(z)): 0.1523 / 0.1892
[6/25][100/198]	Loss_D: 0.3290	Loss_G: 0.6304	D(x): 0.6707	D(G(z)): 0.4616 / 0.1108
[6/25][150/198]	Loss_D: 0.1372	Loss_G: 0.5680	D(x): 0.7281	D(G(z)): 0.2057 / 0.1552
[7/25][0/198]	Loss_D: 0.2285	Loss_G: 0.5808	D(x): 0.6125	D(G(z)): 0.3062 / 0.1428
[7/25][50/198]	Loss_D: 0.2587	Loss_G: 0.7436	D(x): 0.6193	D(G(z)): 0.3157 / 0.0387
[7/25][100/198]	Loss_D: 0.2663	Loss_G: 0.6504	D(x): 0.6728	D(G(z)): 0.3438 / 0.0983
[7/25][150/198]	Loss_D: 0.2602	Loss_G: 0.5599	D(x): 0.4814	D(G(z)): 0.1856 / 0.1573
[8/25][0/198]	Loss_D: 0.3013	Loss_G: 0.5708	D(x): 0.5326	D(G(z)): 0.3159 / 0.1510
[8/25][50/198]	Loss_D: 0.3113	Loss_G: 0.6135	D(x): 0.4050	D(G(z)): 0.0868 / 0.1237
[8/25][100/198]	Loss_D: 0.3152	Loss_G: 0.5322	D(x): 0.4028	D(G(z)): 0.0954 / 0.1863
[8/25][150/198]	Loss_D: 0.2476	Loss_G: 0.5371	D(x): 0.5250	D(G(z)): 0.2420 / 0.1750
[9/25][0/198]	Loss_D: 0.3133	Loss_G: 0.5324	D(x): 0.5167	D(G(z)): 0.3467 / 0.1752
[9/25][50/198]	Loss_D: 0.1989	Loss_G: 0.6866	D(x): 0.6827	D(G(z)): 0.2949 / 0.0733
[9/25][100/198]	Loss_D: 0.3015	Loss_G: 0.5611	D(x): 0.5725	D(G(z)): 0.3654 / 0.1579
[9/25][150/198]	Loss_D: 0.3582	Loss_G: 0.4736	D(x): 0.3792	D(G(z)): 0.2115 / 0.2240
[10/25][0/198]	Loss_D: 0.3318	Loss_G: 0.5620	D(x): 0.3783	D(G(z)): 0.1114 / 0.1583
[10/25][50/198]	Loss_D: 0.2871	Loss_G: 0.5357	D(x): 0.5163	D(G(z)): 0.2817 / 0.1759
[10/25][100/198]	Loss_D: 0.3057	Loss_G: 0.5523	D(x): 0.5422	D(G(z)): 0.3488 / 0.1619
[10/25][150/198]	Loss_D: 0.2332	Loss_G: 0.4727	D(x): 0.4871	D(G(z)): 0.1461 / 0.2283
[11/25][0/198]	Loss_D: 0.3315	Loss_G: 0.5373	D(x): 0.5725	D(G(z)): 0.4154 / 0.1746
[11/25][50/198]	Loss_D: 0.3466	Loss_G: 0.6669	D(x): 0.6648	D(G(z)): 0.4997 / 0.0853
[11/25][100/198]	Loss_D: 0.2058	Loss_G: 0.6242	D(x): 0.6104	D(G(z)): 0.2693 / 0.1131
[11/25][150/198]	Loss_D: 0.2437	Loss_G: 0.7064	D(x): 0.7182	D(G(z)): 0.3983 / 0.0607
[12/25][0/198]	Loss_D: 0.4349	Loss_G: 0.6460	D(x): 0.6818	D(G(z)): 0.5901 / 0.0984
[12/25][50/198]	Loss_D: 0.3156	Loss_G: 0.4433	D(x): 0.5119	D(G(z)): 0.3382 / 0.2441
[12/25][100/198]	Loss_D: 0.2529	Loss_G: 0.4618	D(x): 0.6252	D(G(z)): 0.3423 / 0.2365
[12/25][150/198]	Loss_D: 0.3400	Loss_G: 0.6874	D(x): 0.7105	D(G(z)): 0.5018 / 0.0726
[13/25][0/198]	Loss_D: 0.2812	Loss_G: 0.5398	D(x): 0.6766	D(G(z)): 0.4291 / 0.1719
[13/25][50/198]	Loss_D: 0.2640	Loss_G: 0.5811	D(x): 0.6374	D(G(z)): 0.3848 / 0.1424
[13/25][100/198]	Loss_D: 0.2322	Loss_G: 0.4822	D(x): 0.5514	D(G(z)): 0.2538 / 0.2135
[13/25][150/198]	Loss_D: 0.2773	Loss_G: 0.4896	D(x): 0.6415	D(G(z)): 0.3956 / 0.2082
[14/25][0/198]	Loss_D: 0.2477	Loss_G: 0.4474	D(x): 0.5264	D(G(z)): 0.2527 / 0.2409
[14/25][50/198]	Loss_D: 0.3354	Loss_G: 0.6856	D(x): 0.7321	D(G(z)): 0.5115 / 0.0735
[14/25][100/198]	Loss_D: 0.2738	Loss_G: 0.4555	D(x): 0.5301	D(G(z)): 0.2828 / 0.2355
[14/25][150/198]	Loss_D: 0.2905	Loss_G: 0.5539	D(x): 0.5802	D(G(z)): 0.3535 / 0.1615
[15/25][0/198]	Loss_D: 0.3590	Loss_G: 0.3664	D(x): 0.3489	D(G(z)): 0.1441 / 0.3118
[15/25][50/198]	Loss_D: 0.2833	Loss_G: 0.5638	D(x): 0.6282	D(G(z)): 0.4042 / 0.1541
[15/25][100/198]	Loss_D: 0.2815	Loss_G: 0.5606	D(x): 0.6720	D(G(z)): 0.4106 / 0.1597
[15/25][150/198]	Loss_D: 0.2727	Loss_G: 0.6405	D(x): 0.7255	D(G(z)): 0.4426 / 0.1030
[16/25][0/198]	Loss_D: 0.2790	Loss_G: 0.6204	D(x): 0.7457	D(G(z)): 0.4591 / 0.1155
[16/25][50/198]	Loss_D: 0.2553	Loss_G: 0.6144	D(x): 0.7042	D(G(z)): 0.4116 / 0.1202
[16/25][100/198]	Loss_D: 0.2657	Loss_G: 0.3804	D(x): 0.4525	D(G(z)): 0.1448 / 0.2986
[16/25][150/198]	Loss_D: 0.3851	Loss_G: 0.4424	D(x): 0.3122	D(G(z)): 0.0890 / 0.2485
[17/25][0/198]	Loss_D: 0.2217	Loss_G: 0.4680	D(x): 0.5673	D(G(z)): 0.2330 / 0.2264
[17/25][50/198]	Loss_D: 0.2785	Loss_G: 0.6119	D(x): 0.6932	D(G(z)): 0.4330 / 0.1209
[17/25][100/198]	Loss_D: 0.2658	Loss_G: 0.2630	D(x): 0.4561	D(G(z)): 0.1784 / 0.4087
[17/25][150/198]	Loss_D: 0.3141	Loss_G: 0.4150	D(x): 0.4070	D(G(z)): 0.1393 / 0.2690
[18/25][0/198]	Loss_D: 0.2006	Loss_G: 0.4670	D(x): 0.6964	D(G(z)): 0.3424 / 0.2256
[18/25][50/198]	Loss_D: 0.2667	Loss_G: 0.3068	D(x): 0.4676	D(G(z)): 0.2068 / 0.3673
[18/25][100/198]	Loss_D: 0.2906	Loss_G: 0.5292	D(x): 0.5966	D(G(z)): 0.3759 / 0.1795
[18/25][150/198]	Loss_D: 0.2480	Loss_G: 0.4546	D(x): 0.5725	D(G(z)): 0.2770 / 0.2387
[19/25][0/198]	Loss_D: 0.2968	Loss_G: 0.4267	D(x): 0.5268	D(G(z)): 0.3221 / 0.2568
[19/25][50/198]	Loss_D: 0.2799	Loss_G: 0.5682	D(x): 0.7214	D(G(z)): 0.4454 / 0.1536
[19/25][100/198]	Loss_D: 0.4610	Loss_G: 0.3794	D(x): 0.2415	D(G(z)): 0.0806 / 0.3036
[19/25][150/198]	Loss_D: 0.3381	Loss_G: 0.6886	D(x): 0.7557	D(G(z)): 0.5301 / 0.0717
[20/25][0/198]	Loss_D: 0.3057	Loss_G: 0.6645	D(x): 0.7232	D(G(z)): 0.4872 / 0.0866
[20/25][50/198]	Loss_D: 0.3350	Loss_G: 0.6268	D(x): 0.7073	D(G(z)): 0.5102 / 0.1104
[20/25][100/198]	Loss_D: 0.3390	Loss_G: 0.2664	D(x): 0.3618	D(G(z)): 0.1487 / 0.4062
[20/25][150/198]	Loss_D: 0.3207	Loss_G: 0.5489	D(x): 0.6260	D(G(z)): 0.4507 / 0.1636
[21/25][0/198]	Loss_D: 0.2503	Loss_G: 0.3562	D(x): 0.5342	D(G(z)): 0.2858 / 0.3162
[21/25][50/198]	Loss_D: 0.2671	Loss_G: 0.4787	D(x): 0.6738	D(G(z)): 0.4126 / 0.2146
[21/25][100/198]	Loss_D: 0.2303	Loss_G: 0.4942	D(x): 0.6277	D(G(z)): 0.3481 / 0.2028
[21/25][150/198]	Loss_D: 0.2607	Loss_G: 0.3622	D(x): 0.5170	D(G(z)): 0.2724 / 0.3121
[22/25][0/198]	Loss_D: 0.2895	Loss_G: 0.5622	D(x): 0.7069	D(G(z)): 0.4613 / 0.1550
[22/25][50/198]	Loss_D: 0.2580	Loss_G: 0.3352	D(x): 0.4940	D(G(z)): 0.2463 / 0.3362
[22/25][100/198]	Loss_D: 0.2568	Loss_G: 0.4272	D(x): 0.6030	D(G(z)): 0.3629 / 0.2547
[22/25][150/198]	Loss_D: 0.3016	Loss_G: 0.5320	D(x): 0.6750	D(G(z)): 0.4485 / 0.1772
[23/25][0/198]	Loss_D: 0.3133	Loss_G: 0.5806	D(x): 0.6933	D(G(z)): 0.4803 / 0.1418
[23/25][50/198]	Loss_D: 0.2653	Loss_G: 0.5013	D(x): 0.6780	D(G(z)): 0.4198 / 0.2033
[23/25][100/198]	Loss_D: 0.3809	Loss_G: 0.3425	D(x): 0.3154	D(G(z)): 0.1303 / 0.3294
[23/25][150/198]	Loss_D: 0.2750	Loss_G: 0.3751	D(x): 0.4778	D(G(z)): 0.2488 / 0.3004
[24/25][0/198]	Loss_D: 0.2799	Loss_G: 0.5095	D(x): 0.6901	D(G(z)): 0.4421 / 0.1912
[24/25][50/198]	Loss_D: 0.2783	Loss_G: 0.5286	D(x): 0.6833	D(G(z)): 0.4378 / 0.1840
[24/25][100/198]	Loss_D: 0.2347	Loss_G: 0.5505	D(x): 0.6749	D(G(z)): 0.3857 / 0.1625
[24/25][150/198]	Loss_D: 0.3297	Loss_G: 0.5733	D(x): 0.6996	D(G(z)): 0.5026 / 0.1465
plt.figure(figsize=(10,5))
plt.title("Generator and Discriminator Loss During Training")
plt.plot(G_losses,label="G")
plt.plot(D_losses,label="D")
plt.xlabel("iterations")
plt.ylabel("Loss")
plt.legend()
plt.show()

png

fig = plt.figure(figsize=(8,8))
plt.axis("off")
ims = [[plt.imshow(np.transpose(i,(1,2,0)), animated=True)] for i in img_list]
ani = animation.ArtistAnimation(fig, ims, interval=1000, repeat_delay=1000, blit=True)

HTML(ani.to_jshtml())

png

# Grab a batch of real images from the dataloader
real_batch = next(iter(dataloader))

# Plot the real images
plt.figure(figsize=(15,15))
plt.subplot(1,2,1)
plt.axis("off")
plt.title("Real Images")
plt.imshow(np.transpose(vutils.make_grid(real_batch[0].to(device)[:64], padding=5, normalize=True).cpu(),(1,2,0)))

# Plot the fake images from the last epoch
plt.subplot(1,2,2)
plt.axis("off")
plt.title("Fake Images")
plt.imshow(np.transpose(img_list[-1],(1,2,0)))
plt.show()

png

The Results: Did It Work?

After training for 25 epochs, it was time to see if we could actually control the output. I wrote a small function to generate faces with specific attributes.

def generate_custom_face(netG, device, **attributes):
    """
    Generate face with custom attributes
    Usage: generate_custom_face(netG, device, Male=1, Smiling=1, Blond_Hair=1, Young=1)
    """
    netG.eval()
    
    # Attribute name to index mapping
    attr_map = {
        '5_o_Clock_Shadow': 0, 'Arched_Eyebrows': 1, 'Attractive': 2, 'Bags_Under_Eyes': 3,
        'Bald': 4, 'Bangs': 5, 'Big_Lips': 6, 'Big_Nose': 7, 'Black_Hair': 8, 'Blond_Hair': 9,
        'Blurry': 10, 'Brown_Hair': 11, 'Bushy_Eyebrows': 12, 'Chubby': 13, 'Double_Chin': 14,
        'Eyeglasses': 15, 'Goatee': 16, 'Gray_Hair': 17, 'Heavy_Makeup': 18, 'High_Cheekbones': 19,
        'Male': 20, 'Mouth_Slightly_Open': 21, 'Mustache': 22, 'Narrow_Eyes': 23, 'No_Beard': 24,
        'Oval_Face': 25, 'Pale_Skin': 26, 'Pointy_Nose': 27, 'Receding_Hairline': 28, 'Rosy_Cheeks': 29,
        'Sideburns': 30, 'Smiling': 31, 'Straight_Hair': 32, 'Wavy_Hair': 33, 'Wearing_Earrings': 34,
        'Wearing_Hat': 35, 'Wearing_Lipstick': 36, 'Wearing_Necklace': 37, 'Wearing_Necktie': 38, 'Young': 39
    }
    
    # Create label vector
    label = torch.zeros(40)
    for attr_name, value in attributes.items():
        if attr_name in attr_map:
            label[attr_map[attr_name]] = value
    
    label = label[:40].unsqueeze(0).to(device)
    noise = torch.randn(1, LATENT_DIM, 1, 1, device=device)
    
    with torch.no_grad():
        fake_image = netG(noise, label)
    
    return fake_image

# Let's ask for a person who is Smiling, has Blond Hair, and is Wearing Lipstick
generate_conditions = {'Blond_Hair': 1, 'Smiling': 1, 'Wearing_Lipstick': 1}

IMG_NUM = 9
gen_images = []
for i in range(IMG_NUM):
    image = generate_custom_face(netG, device, **generate_conditions)
    image = image[0]
    gen_images.append(image.cpu())

plt.axis("off")
plt.title("Generated Images")
gen_grid = vutils.make_grid(gen_images, padding=2, normalize=True, nrow=3)
plt.imshow(np.transpose(gen_grid, (1, 2, 0)))
plt.tight_layout()
plt.show()

And here are the results:

png

It worked! The generated faces aren’t perfect, and some look a bit strange, but they clearly have the attributes we asked for. The model learned the connection between the labels and the visual features. Success!

Final Thoughts on This Step

This project was a great next step in my deep learning journey. Converting the GAN to a CGAN really helped me understand how we can guide and control these powerful models.

Key Learnings

My biggest takeaway is that the core concepts are often simpler than they seem. The idea of concatenating the label with the input is straightforward, but it’s what enables this whole new level of control. Sometimes the most elegant solutions in machine learning are the simplest ones.

Beyond the technical implementation, this project taught me several important lessons:

Controllability vs Creativity Trade-off: While CGANs give us precise control over outputs, I noticed they can sometimes produce less diverse results compared to vanilla GANs. The conditioning acts as both a guide and a constraint.

Label Quality Matters: The quality of your generated images is directly tied to the quality and consistency of your training labels. Garbage in, garbage out applies strongly here.

Architecture Flexibility: Adding conditions doesn’t just work for images - this same principle extends to text generation, audio synthesis, and other domains. It’s a fundamental technique for making generative models practical.

Looking Ahead

This foundation opens doors to more advanced conditional generation techniques like StyleGAN conditioning, text-to-image models, and even controllable video generation. The core principle of “give the model context about what you want” is everywhere in modern AI.

Thanks for following along! Let me know if you have any questions or suggestions - I’d love to hear about your own experiments with conditional generation.

comments powered by Disqus