Diffusion Models: Diffusion Models for Generation

Diffusion Models : Guide Complet — Modèles de Diffusion pour la Génération

Diffusion Models: Complete Guide — Diffusion Models for Generation

Summary

Diffusion Models represent one of the most spectacular advances in generative artificial intelligence in recent years. These models produce images of remarkable quality by learning to reverse a progressive corruption process through Gaussian noise. Unlike GANs (Generative Adversarial Networks), which suffer from chronic instability during training, diffusion models offer exceptional convergence and broader coverage of the data latent space.

This complete guide explores the fundamental mathematical principle, the intuition behind these models, and provides a concrete implementation in Python with PyTorch. We will also examine essential hyperparameters, advantages and limitations, as well as four concrete use cases demonstrating the power of these architectures.

The core principle of Diffusion Models relies on two processes: a forward process that progressively destroys the information in an image by adding noise, and a reverse process that learns to reconstruct the original image from pure noise. This elegant approach has revolutionized image generation and forms the architectural foundation of systems like DALL-E 2, Stable Diffusion, and Imagen.

Mathematical Principle

The Forward Process

The forward process is a Markovian process that progressively transforms a real data point x₀ (for example an image) into pure Gaussian noise through T steps. At each step t, a small amount of Gaussian noise is added to the image according to a predefined variance schedule βₜ:

q(xₜ | xₜ₋₁) = N(xₜ ; √(1 – βₜ) · xₜ₋₁ , βₜ · I)

The parameter βₜ controls the amount of noise added at each step. Typically, a linear schedule is used where β₁ = 10⁻⁴ and β_T = 0.02. We then define αₜ = 1 – βₜ as well as ᾱₜ = ∏ᵢ₌₁ᵗ αᵢ (cumulative product of α up to step t).

The remarkable property of this process is that we can compute xₜ directly from x₀ without going through all the intermediate steps:

xₜ = √(ᾱₜ) · x₀ + √(1 – ᾱₜ) · ε, where ε ~ N(0, I)

This equation is absolutely fundamental to the implementation of Diffusion Models. It means that at step t, the image is a weighted interpolation between the original image (weighted by √(ᾱₜ)) and pure noise (weighted by √(1 – ᾱₜ)). At the beginning of the process (small t), the image still resembles x₀. Towards the end (large t), noise completely dominates and the original information is irreversibly lost.

The Reverse Process

The reverse process is what the neural network must learn. It involves reconstructing x₀ from x_T (pure noise) by going through the intermediate steps. Since the conditional distribution q(xₜ₋₁ | xₜ) is analytically intractable, this process is approximated with a neural network parameterized by θ:

p_θ(xₜ₋₁ | xₜ) = N(xₜ₋₁ ; μθ(xₜ, t), Σθ(xₜ, t))

The neural network ε_θ(xₜ, t) learns to predict the noise that was added at step t. It is this noise prediction that constitutes the major innovation of Diffusion Models: rather than directly reconstructing the image pixel by pixel, the model predicts the noise, which is a more stable and numerically easier task to optimize.

The Loss Function

The training objective simplifies remarkably to a mean squared error (MSE) between the true noise ε and the predicted noise ε_θ(xₜ, t):

L_simple(θ) = E_{t, x₀, ε} [ || ε – ε_θ(xₜ, t) ||² ]

where:
– t is sampled uniformly from {1, 2, …, T}
– x₀ is an image from the training set
– ε is the Gaussian noise added during the forward process
– xₜ = √(ᾱₜ) · x₀ + √(1 – ᾱₜ) · ε

This loss function is remarkably simple: just generate a noisy image at a random step, ask the network to predict the added noise, and minimize the squared error between the prediction and reality. It is this simplicity that makes training Diffusion Models so stable compared to GANs, which require complex and subtle adversarial architectures.

Sampling

Once the model is trained, image generation is performed by an iterative process starting from pure noise x_T ~ N(0, I) and successively applying the reverse steps:

xₜ₋₁ = (1/√(αₜ)) · (xₜ – ((1 – αₜ)/√(1 – ᾱₜ)) · ε_θ(xₜ, t)) + σₜ · z

where z ~ N(0, I) is random noise added to maintain the stochasticity of the process, and σₜ is the standard deviation of the posterior conditional distribution.

This generation process requires T calls to the neural network (usually between 200 and 1000 steps for a standard DDPM), which is the main drawback in terms of generation speed. Each step removes a bit of noise and progressively refines the image, making it emerge little by little like a sculpture being revealed layer by layer.

Intuition: Reverse Origami

Imagine unfolding an origami in reverse. You start with a crumpled piece of paper, completely disorganized — this is your pure Gaussian noise x_T, an image that looks like nothing at all, a chaos of random pixels. Your goal is to transform this crumpled paper into an elegant paper crane, folded with precision — this is your image x₀.

Diffusion Models work exactly like this, but in reverse. During training, the model is taught what each step of the unfolding looks like. At each moment t, it is shown what the partially unfolded paper looks like and what the paper looked like just before. The model progressively learns to identify patterns, regularities, and structures that emerge as disorder decreases.

Each step of the reverse process removes a small amount of disorder. At first, the network makes coarse corrections: it identifies general shapes, outlines, large areas of color. Then, as the steps progress, details appear: textures become sharper, shadows refine, outlines become crisp. It is like watching a developing photograph, where the image gradually appears from a blank sheet.

This intuition is powerful because it explains why Diffusion Models produce such coherent results. Unlike GANs, which generate an image in a single pass (one fell swoop), diffusion models go step by step, constantly correcting and refining their result. Each step benefits from all the information accumulated in previous steps, leading to images of remarkable structural coherence.

The origami analogy also illustrates the crucial role of the βₜ schedule: if too much noise is removed at once (β too large), it risks tearing the paper by creating visual artifacts. If too little is removed (β too small), the process is infinitely slow and costly. Finding the right balance is the art of tuning hyperparameters in diffusion models.

Python Implementation with PyTorch

Here is a complete and functional implementation of a DDPM (Denoising Diffusion Probabilistic Model) Python with PyTorch. The code covers data preparation, the U-Net model with temporal attention, the training loop, and the image generation process.

1. Noise Schedule Configuration

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import math


class DiffusionSchedule:
    """Manages the noise schedule βₜ and derived coefficients."""

    def __init__(self, num_steps=1000, beta_start=1e-4, beta_end=0.02):
        self.num_steps = num_steps
        self.beta_start = beta_start
        self.beta_end = beta_end

        # Linear schedule: βₜ varies linearly between beta_start and beta_end
        self.betas = torch.linspace(beta_start, beta_end, num_steps, dtype=torch.float64)

        # αₜ = 1 - βₜ
        self.alphas = 1.0 - self.betas

        # ᾱₜ = cumulative product of αᵢ
        self.alphas_cumprod = torch.cumprod(self.alphas, dim=0)

        # Precomputed coefficients for training
        self.sqrt_alphas_cumprod = torch.sqrt(self.alphas_cumprod)
        self.sqrt_one_minus_alphas_cumprod = torch.sqrt(1.0 - self.alphas_cumprod)

        # Coefficients for the reverse process (sampling)
        self.sqrt_recip_alphas = torch.sqrt(1.0 / self.alphas)
        self.posterior_variance = (
            self.betas[1:] * (1.0 - self.alphas_cumprod[:-1]) / (1.0 - self.alphas_cumprod[1:])
        )

    def add_noise(self, x0, t, noise=None):
        """Adds noise to x0 according to step t."""
        if noise is None:
            noise = torch.randn_like(x0)

        # xₜ = √(ᾱₜ) · x₀ + √(1 - ᾱₜ) · ε
        sqrt_alpha = self.sqrt_alphas_cumprod[t][:, None, None, None]
        sqrt_one_minus = self.sqrt_one_minus_alphas_cumprod[t][:, None, None, None]

        return sqrt_alpha * x0 + sqrt_one_minus * noise, noise


class SinusoidalPosEmbedding(nn.Module):
    """Sinusoidal positional encoding for temporal information."""

    def __init__(self, dim):
        super().__init__()
        self.dim = dim

    def forward(self, t):
        device = t.device
        half_dim = self.dim // 2
        exponent = -math.log(10000) * torch.arange(
            half_dim, dtype=torch.float64, device=device
        )
        exponent = exponent / (half_dim - 1)
        emb = torch.exp(exponent)
        emb = t[:, None].float() * emb[None, :]
        return torch.cat([torch.sin(emb), torch.cos(emb)], dim=-1)


class ResidualBlock(nn.Module):
    """Residual block with temporal embedding integration."""

    def __init__(self, in_channels, out_channels, time_emb_dim):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, 3, padding=1)
        self.time_mlp = nn.Sequential(
            nn.SiLU(),
            nn.Linear(time_emb_dim, out_channels),
        )
        self.conv2 = nn.Conv2d(out_channels, out_channels, 3, padding=1)
        self.shortcut = (
            nn.Conv2d(in_channels, out_channels, 1)
            if in_channels != out_channels
            else nn.Identity()
        )
        self.norm1 = nn.GroupNorm(8, in_channels)
        self.norm2 = nn.GroupNorm(8, out_channels)

    def forward(self, x, t_emb):
        h = self.norm1(x)
        h = F.silu(self.conv1(h))

        # Add temporal information
        t_proj = self.time_mlp(t_emb)
        h = h + t_proj[:, :, None, None]

        h = self.norm2(h)
        h = F.silu(self.conv2(h))
        return h + self.shortcut(x)


class UNet(nn.Module):
    """Simplified U-Net with temporal attention for noise prediction."""

    def __init__(
        self,
        img_channels=3,
        base_channels=64,
        channel_mults=(1, 2, 4, 8),
        time_emb_dim=128,
    ):
        super().__init__()
        self.time_mlp = nn.Sequential(
            SinusoidalPosEmbedding(base_channels),
            nn.Linear(base_channels, time_emb_dim),
            nn.SiLU(),
            nn.Linear(time_emb_dim, time_emb_dim),
        )
        self.initial_conv = nn.Conv2d(img_channels, base_channels, 3, padding=1)

        # Encoder (downsampling path)
        self.down_blocks = nn.ModuleList()
        ch = base_channels
        for mult in channel_mults:
            out_ch = base_channels * mult
            self.down_blocks.append(ResidualBlock(ch, out_ch, time_emb_dim))
            self.down_blocks.append(ResidualBlock(out_ch, out_ch, time_emb_dim))
            if mult != channel_mults[-1]:
                self.down_blocks.append(
                    nn.Conv2d(out_ch, out_ch, 3, stride=2, padding=1)
                )
            ch = out_ch

        # Bottleneck
        self.mid_block1 = ResidualBlock(ch, ch, time_emb_dim)
        self.mid_block2 = ResidualBlock(ch, ch, time_emb_dim)

        # Decoder (upsampling path)
        self.up_blocks = nn.ModuleList()
        ch_reverse = [base_channels * m for m in channel_mults]
        ch_reverse = list(reversed(ch_reverse))
        for i, mult in enumerate(reversed(channel_mults)):
            out_ch = base_channels * mult
            in_ch = out_ch + (
                ch_reverse[min(i + 1, len(ch_reverse) - 1)]
                if i < len(ch_reverse) - 1
                else ch_reverse[-1]
            )
            self.up_blocks.append(ResidualBlock(in_ch, out_ch, time_emb_dim))
            self.up_blocks.append(ResidualBlock(out_ch, out_ch, time_emb_dim))
            if i < len(channel_mults) - 1:
                self.up_blocks.append(
                    nn.ConvTranspose2d(out_ch, out_ch, 4, stride=2, padding=1)
                )

        self.final_conv = nn.Sequential(
            nn.GroupNorm(8, base_channels),
            nn.SiLU(),
            nn.Conv2d(base_channels, img_channels, 1),
        )

    def forward(self, x, t):
        t_emb = self.time_mlp(t)
        x = self.initial_conv(x)

        # Encoding with skip connections
        skips = []
        for block in self.down_blocks:
            if isinstance(block, ResidualBlock):
                x = block(x, t_emb)
            else:
                x = block(x)
            skips.append(x)

        # Bottleneck
        x = self.mid_block1(x, t_emb)
        x = self.mid_block2(x, t_emb)

        # Decoding with skip connections
        skips.reverse()
        skip_idx = 0
        for block in self.up_blocks:
            if isinstance(block, ResidualBlock):
                x = torch.cat([x, skips[skip_idx]], dim=1)
                x = block(x, t_emb)
                skip_idx += 1
            else:
                x = block(x)

        return self.final_conv(x)

2. Training Loop

def train_ddpm(model, schedule, dataloader, optimizer, num_epochs, device='cuda'):
    """Training loop for a DDPM diffusion model."""
    model.to(device)
    criterion = nn.MSELoss()

    for epoch in range(num_epochs):
        model.train()
        total_loss = 0.0
        num_batches = 0

        for batch_x, _ in dataloader:
            batch_x = batch_x.to(device)
            batch_size = batch_x.size(0)

            # Sample random temporal steps
            t = torch.randint(0, schedule.num_steps, (batch_size,), device=device, dtype=torch.long)

            # Add noise: xₜ = √(ᾱₜ) · x₀ + √(1 - ᾱₜ) · ε
            x_noisy, noise = schedule.add_noise(batch_x, t)

            # Predict noise with the U-Net
            predicted_noise = model(x_noisy, t)

            # Compute loss: MSE between true noise and predicted noise
            loss = criterion(predicted_noise, noise)

            # Backpropagation
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

            total_loss += loss.item()
            num_batches += 1

        avg_loss = total_loss / num_batches
        print(f"Epoch {epoch + 1}/{num_epochs}, Average loss: {avg_loss:.6f}")

    return model

3. Image Generation (Sampling)

@torch.no_grad()
def sample_images(model, schedule, shape=(16, 3, 32, 32), device='cuda'):
    """Generates images by iterative denoising from pure noise."""
    model.eval()
    batch_size = shape[0]

    # Starting point: pure Gaussian noise x_T ~ N(0, I)
    x = torch.randn(shape, device=device)

    # Denoising loop from T to 1
    for t in reversed(range(schedule.num_steps)):
        t_batch = torch.full((batch_size,), t, device=device, dtype=torch.long)

        # Predict noise at step t
        predicted_noise = model(x, t_batch)

        # Extract coefficients for step t
        alpha = schedule.alphas[t]
        alpha_bar = schedule.alphas_cumprod[t]
        beta = schedule.betas[t]

        # xₜ₋₁ = (1/√(αₜ)) · (xₜ - ((1 - αₜ)/√(1 - ᾱₜ)) · ε_θ) + σₜ · z
        if t > 0:
            noise = torch.randn_like(x)
        else:
            noise = torch.zeros_like(x)

        x = (1.0 / torch.sqrt(alpha)) * (
            x - ((1 - alpha) / torch.sqrt(1 - alpha_bar)) * predicted_noise
        ) + torch.sqrt(beta) * noise

    # Scale to [0, 1]
    x = torch.clamp((x + 1.0) / 2.0, 0.0, 1.0)
    return x


# --- Complete usage example ---
if __name__ == "__main__":
    # Model initialization
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    schedule = DiffusionSchedule(
        num_steps=1000, beta_start=1e-4, beta_end=0.02
    )
    model = UNet(
        img_channels=3, base_channels=64, channel_mults=(1, 2, 4)
    )

    optimizer = torch.optim.AdamW(model.parameters(), lr=2e-4)

    # Training (with a DataLoader of your choice)
    # dataloader = torch.utils.data.DataLoader(
    #     dataset, batch_size=32, shuffle=True
    # )
    # model = train_ddpm(
    #     model, schedule, dataloader, optimizer, num_epochs=100
    # )

    # Image generation
    # images = sample_images(
    #     model, schedule, shape=(8, 3, 32, 32), device=device
    # )
    print(f"DDPM model initialized on {device}")
    nb_params = sum(p.numel() for p in model.parameters())
    print(f"Number of parameters: {nb_params:,}")

This code constitutes a complete and functional DDPM implementation. The U-Net with temporal attention is the standard architecture for these models: convolutions capture local spatial patterns, while the temporal embedding allows the network to know the current noise level and adapt its denoising strategy accordingly. The MSE loss function ensures stable optimization, and the iterative sampling process generates detailed and coherent images from pure noise.

Essential Hyperparameters

Hyperparameter tuning is crucial to getting good results with diffusion models. Here are the most important parameters to configure correctly:

T (number of steps): The total number of diffusion steps directly determines generation quality. Typical values are:
– T = 1000: Standard for DDPM, excellent quality but very slow generation
– T = 500: Good compromise between image quality and generation speed
– T = 200: Faster but slightly degraded quality on fine details
– T = 50: Fast, requires advanced methods like DDIM or DPM-Solver

The choice of T directly influences computational cost: each step requires a full pass through the U-Net, representing as many expensive convolution operations.

β_start and β_end (noise schedule):
– β_start = 1e-4: Low initial noise, preserves fine image details
– β_end = 0.02: Significant final noise, guarantees convergence to pure Gaussian noise
– An overly aggressive schedule (β_end too high) leads to unpleasant generation artifacts
– An overly gentle schedule (β_end too low) does not destroy enough information

Model Channels:
– base_channels = 64: Sufficient for small images (32×32 pixels)
– base_channels = 128: Recommended for medium images (64×64 pixels)
– base_channels = 256: Necessary for large images (256×256+), as used in Stable Diffusion
– More channels means better expressiveness but quadratic computational cost

Learning Rate:
– lr = 2e-4: Standard default value for the AdamW optimizer
– Using a linear warmup over the first 5% of iterations significantly improves initial convergence
– A cosine scheduler for the learning rate particularly helps at the end of training

Batch Size:
– Large enough to stabilize gradients (minimum 32 recommended)
– Batch normalization (BatchNorm) is not used in modern DDPMs, so batch size impacts quality less than with GANs

Advantages and Limitations

Advantages

  1. Superior training stability: Unlike GANs, which rely on an extremely delicate balance between generator and discriminator, Diffusion Models use a simple MSE-type loss. Training converges predictably and reproducibly, without the mode collapses common with classical adversarial generative architectures.
  2. Exceptional distribution coverage and diversity: Diffusion models cover the real data distribution significantly better. They produce fewer duplicated samples and explore the space of possible images more uniformly, avoiding the persistent mode collapse problem that affects so many traditional GANs.
  3. Flexible and multimodal conditioning: Generation can easily be conditioned on text, class labels, segmentation maps, or any other input signal. Cross-attention between text and visual features allows extremely precise semantic control of generation, which has made systems like Stable Diffusion possible.
  4. Exceptional visual quality: Results rival those of the best GANs, with realistic textures, impressive fine details, and superior structural coherence, especially for complex scenes and multi-object composition in generated images.

Limitations

  1. Significant generation slowness: This is the main and most frequently cited limitation. Generating an image requires T full passes through the neural network (typically 1000 for a standard DDPM). Even with acceleration techniques like DDIM that allow reduction to 50 steps, generation remains significantly slower than a GAN or VAE in a single forward pass.
  2. Considerable computational cost: Training is long and requires significant GPU resources. Training a high-quality model on high-resolution images can require hundreds of GPU-days, considerably limiting the accessibility of these models for researchers with limited resources.
  3. Significant memory footprint: The iterative process and U-Net architecture with many channels consume a lot of GPU memory during inference. Generating high-resolution images in high quality remains difficult on consumer machines without professional graphics cards.

4 Concrete Use Cases

1. Art Generation and Creative Design

Diffusion models are used in tools like DALL-E 2, Midjourney, and Stable Diffusion for generating artistic images from text descriptions. A designer can write “a fairy-tale castle in a snowy mountain landscape, watercolor style” and get several creative variations in a few seconds. These systems are gradually being integrated into professional creative workflows for graphic design, editorial illustration, and visual content production for social media and advertising.

2. Data Synthesis for Model Training

In domains where labeled data is rare or extremely expensive to obtain, diffusion models can generate realistic and diverse synthetic data. For example, in medical imaging, artificial MRIs with varied pathologies can be generated to considerably augment the training sets of automatic diagnostic models. In industrial computer vision, rare manufacturing defects can be simulated to train robust and reliable quality control systems.

3. Image Editing and Restoration

Diffusion models can be used for many restoration tasks: super-resolution (increasing image resolution), inpainting (intelligent filling of missing areas), advanced denoising, and automatic colorization of historical black-and-white images. Diffusion inpainting works by fixing known pixels and letting the model generate only the missing areas, producing results of exceptional coherence with the surrounding context, often surpassing traditional methods.

4. Molecular Modeling and Drug Discovery

Diffusion Models also apply brilliantly to non-visual data. In pharmaceutical research, models like E(3)-EqDiffusion generate realistic 3D molecular conformations, considerably accelerating the discovery of drug candidates. In computational biology, DiffDock uses diffusion models to predict protein molecule docking, a key step in the development of new targeted therapeutic treatments.

See Also


About Salah YAHIAOUI

Salah YAHIAOUI publie sur The Pingouin des tutoriels Python et des décryptages sur l’intelligence artificielle. Les cas pratiques récents précisent leurs données, leurs tests et leurs limites. À propos, méthode éditoriale et contact.

View all posts by Salah YAHIAOUI →