Pixels2GenAI
Path iii Generative
M 12 · 12.2.3 · conceptual

12.2.3 Conditional VAEs

Add label conditioning to a VAE by concatenating one-hot vectors into both encoder and decoder inputs; train it on MNIST and generate digits on demand.

Duration30–35 min
Levelintermediate
Load3 core concepts
PrereqsLessons 12.2.1 and 12.2.2, basic PyTorch
Fig. 1 One hundred CVAE-generated digits. Each row is conditioned on a specific digit class (0 through 9); each column is a different random latent vector. The labels control identity; the latent controls style.

Overview

The VAE from the previous two lessons can generate samples, but it can’t generate specific samples. Sampling z ~ N(0, I) and decoding gives you something the model considers plausible — which abstract pattern, which digit, which face, you don’t get to say. For artistic work that’s often fine. For anything that needs targeted output it isn’t.

The Conditional VAE [1] solves this with a structural change that is, mechanically, almost embarrassingly simple: concatenate the desired output label onto the encoder input and onto the decoder input, then train the same way. The encoder learns to compress away the label information (since it’s already provided); the decoder learns to use the label as an explicit control knob. Once trained, you ask for digit 7 by passing [0,0,0,0,0,0,0,1,0,0] to the decoder alongside any latent vector.

This lesson trains a CVAE on the MNIST handwritten-digit dataset [2] — the canonical sanity check for any new generative architecture — and uses it to demonstrate the core property: same latent, different label, different output.

Learning objectives

  1. Explain why a vanilla VAE cannot perform targeted generation and what the CVAE adds to fix it.
  2. Build the one-hot label concatenation pattern into both encoder and decoder.
  3. Train a CVAE on MNIST and verify that the label controls digit identity while the latent controls within-class style.
  4. Recognise the “label leakage” failure mode and predict when it shows up.

Quick start — sketch the architecture

Forty lines: encoder + decoder + a generate(label) method. The full training script in Exercise 1 wraps an MNIST DataLoader and an optimiser around the same class.

python · quick-start.py
import torch
import torch.nn as nn

class CVAE(nn.Module):
    def __init__(self, latent_dim=16, num_classes=10):
        super().__init__()
        self.latent_dim = latent_dim
        self.num_classes = num_classes

        # Encoder: image (784) + label (10) -> latent distribution params
        self.encoder = nn.Sequential(
            nn.Linear(784 + num_classes, 256), nn.ReLU(),
            nn.Linear(256, 256), nn.ReLU(),
        )
        self.fc_mu     = nn.Linear(256, latent_dim)
        self.fc_logvar = nn.Linear(256, latent_dim)

        # Decoder: latent (16) + label (10) -> image (784)
        self.decoder = nn.Sequential(
            nn.Linear(latent_dim + num_classes, 256), nn.ReLU(),
            nn.Linear(256, 256), nn.ReLU(),
            nn.Linear(256, 784), nn.Sigmoid(),
        )

    def encode(self, x, y):  return (lambda h: (self.fc_mu(h), self.fc_logvar(h)))(self.encoder(torch.cat([x, y], dim=1)))
    def decode(self, z, y):  return self.decoder(torch.cat([z, y], dim=1))

    def generate(self, labels, n_each=1):
        with torch.no_grad():
            n = len(labels) * n_each
            y = torch.zeros(n, self.num_classes)
            for i, lbl in enumerate(labels):
                y[i*n_each:(i+1)*n_each, lbl] = 1
            z = torch.randn(n, self.latent_dim)
            return self.decode(z, y)

m = CVAE()
print(f"Generated a '7': shape {m.generate([7]).shape}")

Run it with random (untrained) weights and you get noise — the architecture is there but the model knows nothing yet.

Core concepts

Concept 1 — Why vanilla VAEs can’t take requests

In a standard VAE [3], sampling proceeds in two steps: draw z ~ N(0, I), decode x̂ = decoder(z). There is no input slot for “what kind of sample I want.” If you train on a mix of MNIST digits, the encoder learns to map all ten classes into one shared latent space, the decoder learns to handle that whole space, and at generation time every random z collapses to whatever the decoder considers most plausible at that point.

You can cluster the latent space afterwards and use the centroids — that gets you something — but it’s brittle. The clusters drift between training runs, the boundaries between classes are fuzzy, and any new class requires retraining the clustering pipeline. The simpler fix is to tell the model what you want during training, and again during generation. That’s what conditioning is.

The cleanest place to inject the condition is at the input layer. The encoder sees concat(image, label); the decoder sees concat(latent, label). The model can use the label however it wants — including ignoring it — but training pressure pushes it to use the label correctly because the reconstruction loss demands a class-appropriate output for each class-labelled input. Doersch’s 2016 tutorial [4] frames the architectural choice in the broader context of variational inference; Goodfellow, Bengio and Courville [5] treat it as a special case of a general “conditioning on auxiliary information” pattern that extends to text-to-image and beyond.

Fig. 2 CVAE architecture. The label `y` enters at two places: appended to the image at encoder input, and appended to the latent vector at decoder input. The reparameterisation trick from Lesson 12.2.1 is unchanged.

Concept 2 — One-hot labels and the concatenation pattern

The label is a categorical variable, but neural networks want vectors. The translation is the one-hot encoding: label 7 becomes [0,0,0,0,0,0,0,1,0,0] for ten classes. PyTorch’s F.one_hot(labels, num_classes).float() does it in one call.

Once the label is a vector, the conditioning is two lines:

python · conditioning
def encode(self, x, labels_onehot):
    combined = torch.cat([x, labels_onehot], dim=1)   # 784 + 10 = 794
    h = self.encoder_layers(combined)
    return self.fc_mu(h), self.fc_logvar(h)

def decode(self, z, labels_onehot):
    combined = torch.cat([z, labels_onehot], dim=1)   # 16 + 10 = 26
    return self.decoder_layers(combined)

The dim=1 matters — it’s the feature dimension, not the batch dimension. Concatenating along dim=0 would silently double your batch size and produce a cryptic shape mismatch a few lines later.

There are alternatives. Embedding lookups replace one-hot vectors with learned dense vectors and scale better when the number of classes is large. FiLM layers [6] feed the condition through a small network that produces multiplicative and additive modulations applied to every layer’s activations — more powerful, more parameters. Cross-attention is what modern text-to-image models like Stable Diffusion use. The concatenation approach in this lesson is the simplest of the family; everything more sophisticated is built on the same conceptual move (give the model the condition; let it learn to use it).

Concept 3 — Training, generation, and what each piece controls

Training is the standard VAE loop with labels threaded through. Same loss (reconstruction + KL), same reparameterisation, same optimiser.

python · training step
def train_step(model, images, labels, optimizer):
    optimizer.zero_grad()
    y = F.one_hot(labels, model.num_classes).float()

    mu, logvar = model.encode(images, y)
    z = model.reparameterize(mu, logvar)
    recon = model.decode(z, y)

    recon_loss = F.binary_cross_entropy(recon, images, reduction='sum')
    kl_loss = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())
    loss = recon_loss + kl_loss
    loss.backward(); optimizer.step()
    return loss.item()

Generation flips the script. Drop the encoder, sample z ~ N(0, I), decode with whatever label you want:

python · conditional generation
def generate(model, digit, n=10):
    model.eval()
    with torch.no_grad():
        y = torch.zeros(n, 10); y[:, digit] = 1
        z = torch.randn(n, model.latent_dim)
        return model.decode(z, y)

The interesting demonstration of what conditioning bought you is to fix z and sweep y across all ten classes — same noise, different label. You should get ten visually different digits that nonetheless share something stylistic (stroke thickness, slant angle, vertical position) because the latent vector is encoding those style attributes while the label handles the identity.

Fig. 3 The conditioning split made visible. Top row: same `z`, different `y` — identity changes, style (thickness, tilt) holds. Bottom row: different `z`, same `y` — style varies, identity stays.

Exercises

EXECUTE I.

Train the CVAE on MNIST and inspect every visualisation

conditional_vae.py is the complete pipeline: download MNIST (~11 MB, automatic via torchvision), train for 50 epochs (~5 min CPU, ~2 min GPU), and produce four PNGs covering training history, generated samples, conditional generation, and a latent-space scatter.

conditional_vae.py — full training pipeline
bash
python conditional_vae.py
Fig. 4 Training dynamics over 50 epochs. Reconstruction loss falls steadily; KL stabilises around a non-zero plateau (a healthy sign — going to zero would mean posterior collapse).
Fig. 5 2D projection of the trained latent space, coloured by digit class. The clusters overlap heavily — and they're *supposed* to. The label channel handles identity, leaving the latent free to encode style across all classes.

Reflection

  1. Looking at Figure 1, which digits look most realistic, which look most challenging, and what does that tell you about the training data?
  2. In Figure 3’s top row, the same z produces ten different digits with the same style. What style attributes do you see preserved?
  3. Why do the latent clusters in Figure 5 overlap so heavily — and why is that a good sign for a CVAE rather than a problem?
  4. How would a non-conditional VAE’s latent-space scatter look different from Figure 5?
MODIFY II.

Three knobs: latent dim, label leakage, longer training

Modify conditional_vae.py one knob at a time and rerun. Save the PNG outputs between runs to compare.

Goal 1 — Latent dimension. Change LATENT_DIM:

LATENT_DIM = 2      # tiny — limited style capacity
LATENT_DIM = 16     # default
LATENT_DIM = 64     # large — risks posterior collapse

Goal 2 — Force label leakage. Multiply the KL weight by zero to see what happens when the regulariser stops doing its job:

loss = recon_loss + 0.0 * kl_loss   # KL disabled

Goal 3 — Extended training. Increase NUM_EPOCHS from 50 to 200. Plot reconstruction loss per epoch; identify the point of diminishing returns.

CREATE III.

Build SimpleCVAE from scratch with TODOs

The starter has the layer skeleton; you fill in the dimensions and the three core methods.

python · cvae_starter.py
import torch
import torch.nn as nn
import torch.nn.functional as F

class SimpleCVAE(nn.Module):
    def __init__(self, latent_dim=16, num_classes=10):
        super().__init__()
        self.latent_dim = latent_dim
        self.num_classes = num_classes

        # TODO: 784 (image) + 10 (label) = 794 input features
        self.encoder_fc1 = nn.Linear(???, 256)
        self.encoder_fc2 = nn.Linear(256, 128)
        self.fc_mu      = nn.Linear(128, latent_dim)
        self.fc_logvar  = nn.Linear(128, latent_dim)

        # TODO: latent_dim + 10 (label) input features
        self.decoder_fc1 = nn.Linear(???, 128)
        self.decoder_fc2 = nn.Linear(128, 256)
        self.decoder_fc3 = nn.Linear(256, 784)

    def encode(self, x, y):
        combined = ???                  # TODO: concat along dim=1
        h = F.relu(self.encoder_fc1(combined))
        h = F.relu(self.encoder_fc2(h))
        return self.fc_mu(h), self.fc_logvar(h)

    def reparameterize(self, mu, logvar):
        # TODO: std = exp(0.5*logvar); z = mu + std * eps
        pass

    def decode(self, z, y):
        combined = ???                  # TODO: concat along dim=1
        h = F.relu(self.decoder_fc1(combined))
        h = F.relu(self.decoder_fc2(h))
        return torch.sigmoid(self.decoder_fc3(h))

def cvae_loss(recon, x, mu, logvar):
    # TODO: BCE reconstruction + KL divergence
    pass

Make it your own

Add a second conditioning variable for digit thickness — thin, medium, thick. You’ll need to derive thickness labels from the training images (one way: count active pixels per image, threshold into three buckets), then concatenate a 3-dimensional one-hot beside the digit label. The encoder input becomes 784 + 10 + 3 = 797, the decoder input becomes latent_dim + 10 + 3. At generation time you specify both a digit and a thickness.

Summary

Common pitfalls

  • Concatenating along dim=0 (batch) instead of dim=1 (features) — silently doubles the batch and breaks shape-matching downstream.
  • Posterior collapse — KL weight too high, model stops using the latent space and produces the average digit for every label.
  • Label leakage — KL weight too low, encoder hides digit identity in the latent vector, label conditioning becomes ineffective. The top-row test in Figure 3 is the diagnostic.
  • Mismatched one-hot encoding — using labels.float() instead of F.one_hot(labels, 10).float() feeds a scalar where the decoder expects a 10-vector; the resulting weight matrix dimension errors are sometimes hard to read.
  • Forgetting model.eval() before generation — leaves BatchNorm and Dropout in training mode and produces noisier outputs than the model is actually capable of.

References

  1. [1] Sohn, K., Lee, H. & Yan, X. (2015). Learning Structured Output Representation Using Deep Conditional Generative Models. Advances in Neural Information Processing Systems, 28. papers.nips.cc/paper/5775
  2. [2] LeCun, Y., Bottou, L., Bengio, Y. & Haffner, P. (1998). Gradient-based learning applied to document recognition. Proceedings of the IEEE, 86(11), 2278–2324. doi.org/10.1109/5.726791
  3. [3] Kingma, D. P. & Welling, M. (2014). Auto-Encoding Variational Bayes. International Conference on Learning Representations (ICLR). arxiv:1312.6114
  4. [4] Doersch, C. (2016). Tutorial on Variational Autoencoders. arXiv preprint. arxiv:1606.05908
  5. [5] Goodfellow, I., Bengio, Y. & Courville, A. (2016). Deep Learning, Chapter 20: Deep Generative Models. MIT Press. deeplearningbook.org
  6. [6] Perez, E., Strub, F., de Vries, H., Dumoulin, V. & Courville, A. (2018). FiLM: Visual Reasoning with a General Conditioning Layer. AAAI Conference on Artificial Intelligence. arxiv:1709.07871
  7. [7] Rezende, D. J., Mohamed, S. & Wierstra, D. (2014). Stochastic Backpropagation and Approximate Inference in Deep Generative Models. International Conference on Machine Learning (ICML). arxiv:1401.4082
  8. [8] Mirza, M. & Osindero, S. (2014). Conditional Generative Adversarial Nets. arXiv preprint. arxiv:1411.1784