Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 19

Training Latent Diffusion Models

Recommended resources

Hello! Welcome back.

In our last lesson, we constructed the first essential component of a latent diffusion model: a high-fidelity variational autoencoder (VAE). We saw how a combination of perceptual and adversarial losses allows it to compress images into a compact latent space while preserving the crucial details needed for realistic reconstructions.

Today, we'll put that VAE to work. Our learning outcome is to implement a latent diffusion model by adapting the training loop to operate on latent vectors. We will take our pre-trained VAE, treat it as a fixed feature extractor, and train a U-Net model to perform the diffusion process entirely within this compressed, efficient latent space. This is the second and final stage of building our model.

1. The Full Picture: Diffusion in Latent Space

Let's start with a high-level overview. The entire process now consists of two distinct, sequential stages:

  1. Perceptual Compression (VAE): An image is passed through the VAE's encoder to get a compressed latent vector. This is a one-time, feed-forward step.
  2. Latent Diffusion (U-Net): The diffusion process (both forward noising during training and reverse denoising during sampling) happens entirely in the latent space.
  3. Image Generation (VAE): Once the U-Net has produced a "clean" latent vector, it's passed through the VAE's decoder to generate the final, full-resolution image.

The diagram below perfectly illustrates this workflow. The VAE encoder and decoder act as portals between the pixel world and the latent world, where the U-Net operates.

Latent Diffusion Forward and Reverse Process
The training (forward) process involves encoding an image to a latent, adding noise, and training the U-Net. The generation (reverse) process involves the U-Net denoising a random latent, which is then decoded back to an image.

Your previous experience implementing a DDPM in pixel space is the perfect foundation. Today, we're essentially modifying that existing recipe to work on latents instead of pixels.


2. Adapting the Data Pipeline: From Pixels to Latents

The most significant change is in how we handle our data. In a standard DDPM, the training loop consumes batches of images. In a Latent Diffusion Model (LDM), the training loop consumes batches of latent vectors.

A naive approach would be to encode images to latents on-the-fly inside the training loop. However, since the VAE is frozen, this encoding step is deterministic and computationally redundant. A much more efficient strategy, especially for large datasets, is to pre-process the entire dataset.

This involves a one-time pass where you:

  1. Load every image from your original dataset.
  2. Pass it through the trained VAE's encoder.
  3. Save the resulting latent vector.
  4. Create a new dataset composed entirely of these latent vectors.

Your diffusion model's training loop then loads data directly from this new, pre-computed latent dataset.

Training a Latent Diffusion Model From Scratch

The blog post 'Training a Latent Diffusion Model From Scratch' by Geronimo provides a clear, practical example of this preprocessing step.

Read the section titled 'Latent Dataset'. It explains why pre-computing latents is beneficial and links to a HuggingFace dataset created using this exact method.


3. Adapting the Training Loop

With our latent dataset ready, let's adapt the training loop. The core logic of diffusion—adding noise and training a model to predict it—remains identical. The only thing that changes is the domain in which it operates.

The video below provides a fantastic, concise walkthrough of the necessary code modifications. It starts with a standard DDPM training script and shows the exact lines that need to be changed to convert it into an LDM.

Stable Diffusion from Scratch in PyTorch | Unconditional Latent Diffusion Models

This segment from the 'ExplainingAI' channel is the most direct illustration of today's learning outcome. It demonstrates how to adapt a DDPM training loop for latent diffusion.

Watch from 37:52 to 41:59. Pay close attention to these key steps: Loading the pre-trained and frozen VAE. Using the VAE encoder to get the latent representation of the input images. Adjusting the U-Net's input channels to match the VAE's latent dimension. Running the exact same diffusion training logic, but on the latent vectors.

Let's solidify this with a step-by-step breakdown of the training code. The overall structure will look very familiar.

  1. Load Models:

    • Load your pre-trained VAE and freeze its parameters (vae.eval() and for param in vae.parameters(): param.requires_grad = False).
    • Instantiate your U-Net. Crucially, the in_channels and out_channels must now match the channel dimension of your VAE's latent space (e.g., 4 or 32, not 3 for RGB).
  2. The Training Step: For each batch from your latent dataset:

    • You receive a batch of clean latent vectors, latents.
    • Scaling: It's common practice to scale the latents by a constant factor before adding noise. This helps match the variance of the latents to the expected input variance of the U-Net (often near unit variance).
    • Forward Diffusion:
      • Sample random timesteps t.
      • Sample random noise noise from a standard normal distribution, with the same shape as latents.
      • Create noisy latents: noisy_latents = scheduler.add_noise(latents, noise, t).
    • Noise Prediction: Pass the noisy_latents and t through the U-Net to get predicted_noise.
    • Loss Calculation: Compute the loss, typically MSE, between the predicted_noise and the original noise.
      loss = F.mse_loss(predicted_noise, noise)
    • Backpropagation: loss.backward() and optimizer.step() to update the U-Net's weights. The VAE weights remain frozen.

The following reading provides a complete, runnable code example that puts all these pieces together.

Training a Latent Diffusion Model From Scratch

Let's return to the blog post from Geronimo to see a full training loop implementation.

First, read the section 'Predicting the Reverse Process'. It shows how to instantiate the model and implement the core training logic. Then, skim the 'Train!' subsection within the 'ImageNet' section to see the same training loop applied to a larger dataset. Notice how latents *= dcae.config["scaling_factor"] is the practical implementation of the scaling step mentioned above.

Test your understanding!

You have a working pixel-space DDPM training script. A simplified version of its training step looks like this:

# Simplified DDPM training step
def train_step(images, unet_model, scheduler, optimizer):
    # images shape: [batch_size, 3, 64, 64]
    
    # 1. Sample timesteps
    t = torch.randint(0, scheduler.num_timesteps, (images.shape[0],), device=images.device)
    
    # 2. Create noisy images (forward process)
    noise = torch.randn_like(images)
    noisy_images = scheduler.add_noise(images, noise, t)
    
    # 3. Predict noise with U-Net
    predicted_noise = unet_model(noisy_images, t)
    
    # 4. Calculate loss
    loss = F.mse_loss(predicted_noise, noise)
    
    # 5. Backpropagate
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    
    return loss

You are given a pre-trained vae_model with a latent dimension of 4 and a scaling_factor. What are the three minimal changes you need to make to this function to adapt it for latent diffusion?

Show answer

Here are the three essential changes:

  1. Encode images to latents: The function should take images as input, but the diffusion process must happen on latents. The first step inside the function should be to encode the images and apply the scaling factor.
  2. Apply diffusion to latents: All subsequent operations (randn_like, add_noise, unet_model call) must use the latents tensor, not the original images tensor.
  3. Freeze VAE gradients: The encoding step should not compute gradients with respect to the VAE model.

Here is the modified code:

# Adapted LDM training step
def train_step_ldm(images, vae_model, scaling_factor, unet_model, scheduler, optimizer):
    # images shape: [batch_size, 3, 64, 64]
    
    # CHANGE 1: Encode images into latents and disable VAE gradients
    with torch.no_grad():
        latents = vae_model.encode(images).latent_dist.sample() * scaling_factor
    # latents shape is now something like: [batch_size, 4, 8, 8]
    
    # 1. Sample timesteps
    t = torch.randint(0, scheduler.num_timesteps, (latents.shape[0],), device=latents.device)
    
    # CHANGE 2: Create noisy LATENTS, not images
    noise = torch.randn_like(latents)
    noisy_latents = scheduler.add_noise(latents, noise, t)
    
    # 3. Predict noise with U-Net from noisy LATENTS
    predicted_noise = unet_model(noisy_latents, t)
    
    # CHANGE 3: Calculate loss against the latent noise
    loss = F.mse_loss(predicted_noise, noise)
    
    # 5. Backpropagate (only updates unet_model)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    
    return loss

4. Adapting the Inference/Sampling Loop

The adaptation for inference mirrors the training loop. You start with a random tensor in the latent space and use the U-Net to iteratively denoise it.

The key difference is the very last step: once the scheduler has produced the final "clean" latent vector latent_0, you must pass it through the VAE's decoder to get the final pixel-space image.

final_image = vae_model.decode(latent_0 / scaling_factor).sample

Note the division by the scaling_factor to reverse the scaling operation we applied during training.


Conclusion

In this lesson, we completed the second stage of our two-stage model. By adapting the familiar DDPM training process to operate in a compressed latent space, we've built a full Latent Diffusion Model.

Key Takeaways:

  • Two-Stage Process: LDMs separate perceptual compression (VAE) from the generative diffusion process (U-Net).
  • Frozen VAE: During diffusion training, the pre-trained VAE is frozen and used solely to convert images to latents. Its weights are not updated.
  • Latent Space Operations: The entire diffusion process—adding noise, U-Net prediction, and calculating loss—occurs on latent vectors, not pixels.
  • Adaptations: The primary code changes involve:
    1. A data preprocessing step to encode images into a latent dataset.
    2. Modifying the U-Net's channel configuration to match the latent dimension.
    3. A final decoding step during inference to convert the generated latent back into an image.

Next Lesson Preview:

We've now seen how to build both a pixel-space DDPM and a latent-space LDM. But was the added complexity of the LDM worth it? In the next lesson, we will compare the computational efficiency and generation quality of pixel-space vs. latent-space diffusion. We'll conduct practical experiments to quantify the trade-offs in terms of training time, memory usage, and the quality of the final generated images, solidifying the motivation behind this powerful architectural shift.