Implement a VAE for image generation using the MNIST dataset. Estimate the latent mean and variance, apply the reparameterization trick, decode latent vectors, calculate reconstruction and KL-divergence losses, and visualize newly generated samples.
What's actually being asked
Same VAE mechanics as Q69, but the emphasis here is the generation side: after training, sample fresh latent vectors from the prior and decode them into brand-new digit images — not reconstructions of real inputs. This answer uses a convolutional encoder/decoder (vs the fully-connected one in Q69) and keeps the training loop brief since it's identical in structure to Q69's.
Answer
The key conceptual point
During training, z comes from the encoder: z = μ(x) + σ(x) ⊙ ε. The KL term in the loss specifically pushes q(z|x) toward matching the prior p(z) = N(0, I). That's exactly what makes it valid, after training, to skip the encoder entirely and sample z ~ N(0, I) directly — decoding that gives a genuinely new image, not a reconstruction of anything that existed in the training set.
Model (convolutional encoder/decoder this time)
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
from torchvision.utils import make_grid
import matplotlib.pyplot as plt
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
latent_dim = 20
class ConvVAE(nn.Module):
def __init__(self, latent_dim=20):
super().__init__()
# Encoder: 1x28x28 -> 32x14x14 -> 64x7x7
self.enc_conv1 = nn.Conv2d(1, 32, kernel_size=4, stride=2, padding=1)
self.enc_conv2 = nn.Conv2d(32, 64, kernel_size=4, stride=2, padding=1)
self.fc_mu = nn.Linear(64 * 7 * 7, latent_dim)
self.fc_logvar = nn.Linear(64 * 7 * 7, latent_dim)
# Decoder: latent -> 64x7x7 -> 32x14x14 -> 1x28x28
self.fc_dec = nn.Linear(latent_dim, 64 * 7 * 7)
self.dec_conv1 = nn.ConvTranspose2d(64, 32, kernel_size=4, stride=2, padding=1)
self.dec_conv2 = nn.ConvTranspose2d(32, 1, kernel_size=4, stride=2, padding=1)
def encode(self, x):
h = F.relu(self.enc_conv1(x))
h = F.relu(self.enc_conv2(h))
h = h.view(h.size(0), -1)
return self.fc_mu(h), self.fc_logvar(h) # estimated mean and log-variance
def reparameterize(self, mu, logvar):
std = torch.exp(0.5 * logvar)
eps = torch.randn_like(std)
return mu + std * eps
def decode(self, z):
h = F.relu(self.fc_dec(z))
h = h.view(-1, 64, 7, 7)
h = F.relu(self.dec_conv1(h))
return torch.sigmoid(self.dec_conv2(h))
def forward(self, x):
mu, logvar = self.encode(x)
z = self.reparameterize(mu, logvar)
return self.decode(z), mu, logvar
def vae_loss(x_hat, x, mu, logvar):
recon = F.binary_cross_entropy(x_hat, x, reduction="sum") # -E[log p(x|z)]
kl = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp()) # D_KL(q(z|x) || N(0,I))
return recon + klTraining (same shape as Q69 — brief here on purpose)
transform = transforms.ToTensor()
train_data = datasets.MNIST(root="./data", train=True, download=True, transform=transform)
train_loader = DataLoader(train_data, batch_size=128, shuffle=True)
model = ConvVAE(latent_dim).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
model.train()
for epoch in range(10):
for images, _ in train_loader:
images = images.to(device) # keep as [B, 1, 28, 28], no flattening needed
x_hat, mu, logvar = model(images)
loss = vae_loss(x_hat, images, mu, logvar)
optimizer.zero_grad()
loss.backward()
optimizer.step()Generation: sampling brand-new digits from the prior
model.eval()
with torch.no_grad():
z_sample = torch.randn(64, latent_dim).to(device) # z ~ N(0, I) -- no encoder involved
generated = model.decode(z_sample).cpu()
grid = make_grid(generated, nrow=8)
plt.figure(figsize=(6, 6))
plt.imshow(grid.permute(1, 2, 0).squeeze(), cmap="gray")
plt.axis("off")
plt.title("Newly generated digits, z ~ N(0, I)")
plt.show()Bonus: latent-space interpolation (shows the space is smooth)
model.eval()
with torch.no_grad():
img_a, img_b = train_data[0][0].unsqueeze(0).to(device), train_data[1][0].unsqueeze(0).to(device)
mu_a, _ = model.encode(img_a)
mu_b, _ = model.encode(img_b)
steps = torch.linspace(0, 1, 8).to(device)
z_path = torch.stack([mu_a.squeeze(0) * (1 - t) + mu_b.squeeze(0) * t for t in steps])
interpolated = model.decode(z_path).cpu()
grid = make_grid(interpolated, nrow=8)
plt.imshow(grid.permute(1, 2, 0).squeeze(), cmap="gray")
plt.axis("off")
plt.title("Interpolation between two digits in latent space")
plt.show()Why this works — tying it back to the maths
The loss trains q_φ(z|x) to approximate N(0,I) on average over the whole dataset via the KL term D_KL(q(z|x)‖p(z)). Once that's converged, the decoder p_θ(x|z) has effectively learned "what any plausible z from that standard normal region should look like as an image" — so feeding it fresh noise samples z ~ N(0,I) (never seen, never derived from a real x) still decodes to a plausible-looking digit, which is the entire point of calling a VAE a generative model rather than just an autoencoder.
What to expect visually
The grid of 64 freshly-sampled digits should show recognizable (if slightly blurry) digit shapes with natural variety across the grid — some clearer, some ambiguous between two digit classes (e.g., a shape between a 4 and a 9), which is expected: the latent space is continuous, so regions between well-formed clusters decode to in-between-looking digits.
Exam tip
If the question asks to distinguish "reconstruction" from "generation" explicitly: reconstruction encodes a real x to get z, then decodes it back (tests how well the model compresses/rebuilds known data); generation samples z directly from the prior N(0,I) with no real x involved at all (tests whether the model learned a usable generative latent space). Mixing these two up is the single most common mistake on this topic.