Skip to content
AILinkDeepTech
Go back
Reinforcement Learning Medium

Diffusion Models: Mathematical Foundations, Score Matching, and PyTorch Architecture

Abstract

Master diffusion models: forward Markov perturbations, reverse ELBO derivations, score-based SDE unifications, and production PyTorch training pipelines.

Generative Paradigms and Non-Equilibrium Diffusion

Generative modeling addresses the fundamental task of modeling a high-dimensional probability distribution given empirical samples. Prior frameworks exhibit distinct trade-offs:

  1. Generative Adversarial Networks (GANs): Train a minimax game . While achieving fast single-step inference, adversarial optimization suffers from mode collapse, gradient vanishing, and non-convergent limit cycles.
  2. Variational Autoencoders (VAEs): Maximize the Evidence Lower Bound (ELBO) using a single-step encoder and decoder . Single-step decoders average multimodal distributions, causing visual blurriness.
  3. Normalizing Flows: Learn bijective mappings via exact change-of-variables . Enforcing tractable Jacobian determinants severely restricts model expressivity.

Diffusion Probabilistic Models (Sohl-Dickstein et al., ICML 2015; Ho et al., NeurIPS 2020) resolve these limitations by casting synthesis as the time-reversal of a continuous Gaussian noise perturbation process. By decomposing the generative task across discrete timesteps (or continuous time ), the model learns a sequence of tractable, locally Gaussian denoising transitions.


Architectural Comparison

Dimension / MetricGAN (Goodfellow et al.)VAE (Kingma & Welling)Normalizing Flow (Dinh et al.)Autoregressive (PixelCNN)Diffusion Models (DDPM/Score-SDE)
Density EstimationImplicit (No Density)Tractable Lower Bound (ELBO)Exact Analytical LikelihoodExact via Chain RuleTractable Bound / Exact ODE Likelihood
Training StabilityUnstable (Minimax Game)Stable (ELBO Optimization)Stable (Max-Likelihood)Stable (Cross-Entropy)Extremely Stable (L2 Noise Regression)
Mode CoverageSusceptible to Mode CollapseFull Support CoverageFull Support CoverageFull Support CoverageComplete Mode Coverage (No Collapse)
Sampling Latency Single Step Single Step Single Step Sequential Pixels Iterative Steps (
Architectural DesignDiscriminator-CoupledEncoder-Decoder BottleneckStrictly Invertible LayersCausal Masked ConvolutionsUnconstrained (U-Net / Transformer DiT)

Mathematical Foundations

flowchart LR X0["Clean Image x_0\nDistribution q(x_0)"] -->|q(x_1 | x_0)| X1["Noisy State x_1"] X1 -->|q(x_t | x_t-1)| XT_MINUS["Noisy State x_t-1"] XT_MINUS -->|q(x_t | x_t-1)| XT["Noisy State x_t"] XT -->|q(x_T | x_t)| X_FINAL["Pure Noise x_T ~ N(0, I)"] X_FINAL -->|p_theta(x_T-1 | x_T)| P_XT["Denoised Step x_t"] P_XT -->|p_theta(x_t-1 | x_t)\nReverse Denoising Step| P_X0["Synthesized Output x_0"]

Figure 1: Forward perturbation Markov chain and learned reverse generative denoising chain .

1. Forward Gaussian Perturbation Chain

Given data , the forward diffusion process is defined as a fixed Gaussian Markov chain parameterized by a variance schedule :

Let and . Evaluating recursive Gaussian convolutions yields the Closed-Form Marginal Distribution at any timestep :

As , , and the distribution asymptotically converges to standard isotropic Gaussian noise .


2. Reverse Process and Posterior Derivation

The reverse trajectory parameters Gaussian transitions:

When conditioned on the clean data , the true reverse step is analytically solvable via Bayes’ expansion:

where:


3. Variational Lower Bound (ELBO) and Objective

Optimizing negative log-likelihood yields the variational bound:

Expressing clean data allows parameterizing the neural network mean via a noise predictor :

Substituting into the KL divergence term produces the Simplified Noise-Prediction Loss:

where . Discarding the analytical weighting factor places higher emphasis on intermediate perceptual scales, substantially improving sample fidelity.


4. Continuous-Time SDEs and Score Matching Duality

In continuous time (), the forward perturbation converges to an Itô Stochastic Differential Equation (SDE):

where is standard Brownian motion. For Variance Preserving (VP) diffusion (DDPM equivalent):

By Anderson’s Theorem (1982) and Song et al. (ICLR 2021), the Reverse-Time SDE is:

where is reverse Brownian motion and is the Stein Score Function. By Tweedie’s Identity, the noise prediction directly parameterizes the score:

Associated with every SDE is a deterministic Probability-Flow ODE sharing identical marginal probability densities :

Discretizing this deterministic ODE yields modern accelerated samplers: DDIM, DPM-Solver++, and EDM Heun solvers.


5. Classifier-Free Guidance (CFG)

To direct conditional generation without a separate classifier, Classifier-Free Guidance (Ho & Salimans, 2022) randomly replaces conditioning tokens with an empty token during training (). At inference, the guided noise prediction extrapolates away from the unconditional estimate:

where is the guidance scale, trading off sample diversity for sharp alignment with conditioning constraints.


Production PyTorch Implementation

Below is a complete, modular PyTorch implementation of the Gaussian Diffusion engine, U-Net with FiLM Time-Modulation & Spatial Self-Attention, and accelerated DDIM deterministic sampling.

Step 1: Forward/Reverse Gaussian Diffusion Engine

from __future__ import annotations

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


class GaussianDiffusionEngine(nn.Module):
    """Core mathematical engine for Gaussian forward perturbation and reverse sampling."""

    def __init__(self, timesteps: int = 1000, beta_schedule: str = "cosine") -> None:
        super().__init__()
        self.timesteps = timesteps

        if beta_schedule == "linear":
            betas = torch.linspace(1e-4, 0.02, timesteps, dtype=torch.float32)
        elif beta_schedule == "cosine":
            steps = timesteps + 1
            s = 0.008
            x = torch.linspace(0, timesteps, steps, dtype=torch.float32)
            alphas_cumprod = torch.cos(((x / timesteps) + s) / (1.0 + s) * math.pi * 0.5) ** 2
            alphas_cumprod = alphas_cumprod / alphas_cumprod[0]
            betas = torch.clip(1.0 - alphas_cumprod[1:] / alphas_cumprod[:-1], 1e-4, 0.999)
        else:
            raise NotImplementedError(f"Schedule {beta_schedule} not implemented.")

        alphas = 1.0 - betas
        alphas_cumprod = torch.cumprod(alphas, dim=0)
        alphas_cumprod_prev = F.pad(alphas_cumprod[:-1], (1, 0), value=1.0)

        # Register buffers for device auto-transfer
        self.register_buffer("betas", betas)
        self.register_buffer("alphas", alphas)
        self.register_buffer("alphas_cumprod", alphas_cumprod)
        self.register_buffer("sqrt_alphas_cumprod", torch.sqrt(alphas_cumprod))
        self.register_buffer("sqrt_one_minus_alphas_cumprod", torch.sqrt(1.0 - alphas_cumprod))
        
        # Analytical posterior variance \tilde{\beta}_t
        posterior_variance = betas * (1.0 - alphas_cumprod_prev) / (1.0 - alphas_cumprod)
        self.register_buffer("posterior_variance", posterior_variance.clamp(min=1e-20))

    def q_sample(self, x_0: torch.Tensor, t: torch.Tensor, noise: torch.Tensor | None = None) -> tuple[torch.Tensor, torch.Tensor]:
        """Diffuse data x_0 to timestep t: x_t = sqrt(\bar{\alpha}_t)*x_0 + sqrt(1 - \bar{\alpha}_t)*\epsilon."""
        if noise is None:
            noise = torch.randn_like(x_0)
        
        sqrt_alpha_bar = self.sqrt_alphas_cumprod[t].view(-1, 1, 1, 1)
        sqrt_one_minus_alpha_bar = self.sqrt_one_minus_alphas_cumprod[t].view(-1, 1, 1, 1)
        x_t = sqrt_alpha_bar * x_0 + sqrt_one_minus_alpha_bar * noise
        return x_t, noise

    @torch.no_grad()
    def ddim_sample(
        self,
        model: nn.Module,
        shape: tuple[int, ...],
        num_inference_steps: int = 50,
        eta: float = 0.0,
        device: str = "cuda",
    ) -> torch.Tensor:
        """Deterministic ODE / DDIM reverse sampling over subsampled timesteps."""
        step_ratio = self.timesteps // num_inference_steps
        timesteps = (torch.arange(0, num_inference_steps) * step_ratio).round().flip(0).long().to(device)
        
        x = torch.randn(shape, device=device)

        for i, t in enumerate(timesteps):
            t_prev = timesteps[i + 1] if i + 1 < len(timesteps) else -1
            t_tensor = torch.full((shape[0],), t, device=device, dtype=torch.long)
            
            eps_pred = model(x, t_tensor)
            
            alpha_bar_t = self.alphas_cumprod[t]
            alpha_bar_t_prev = self.alphas_cumprod[t_prev] if t_prev >= 0 else torch.tensor(1.0, device=device)

            # Predict original sample x_0
            x_0_pred = (x - torch.sqrt(1.0 - alpha_bar_t) * eps_pred) / torch.sqrt(alpha_bar_t)

            # Variance calculation
            sigma_t = eta * torch.sqrt((1.0 - alpha_bar_t_prev) / (1.0 - alpha_bar_t) * (1.0 - alpha_bar_t / alpha_bar_t_prev))
            dir_xt = torch.sqrt((1.0 - alpha_bar_t_prev - sigma_t**2).clamp(min=0.0)) * eps_pred

            x = torch.sqrt(alpha_bar_t_prev) * x_0_pred + dir_xt
            if eta > 0.0:
                x = x + sigma_t * torch.randn_like(x)

        return x

Step 2: U-Net Backbone with FiLM Conditioning & Self-Attention

from __future__ import annotations

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


class SinusoidalPositionalEmbedding(nn.Module):
    def __init__(self, dim: int) -> None:
        super().__init__()
        self.dim = dim

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


class ResBlockFiLM(nn.Module):
    """Residual Block with Feature-wise Linear Modulation (FiLM)."""

    def __init__(self, in_ch: int, out_ch: int, time_dim: int, groups: int = 8) -> None:
        super().__init__()
        self.norm1 = nn.GroupNorm(groups, in_ch)
        self.conv1 = nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1)
        self.norm2 = nn.GroupNorm(groups, out_ch)
        self.conv2 = nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1)
        
        self.time_proj = nn.Linear(time_dim, out_ch * 2) # Outputs Scale & Shift
        self.shortcut = nn.Conv2d(in_ch, out_ch, kernel_size=1) if in_ch != out_ch else nn.Identity()

    def forward(self, x: torch.Tensor, t_emb: torch.Tensor) -> torch.Tensor:
        h = self.conv1(F.silu(self.norm1(x)))
        
        # FiLM Scale & Shift
        scale, shift = self.time_proj(F.silu(t_emb)).chunk(2, dim=-1)
        h = h * (1.0 + scale[:, :, None, None]) + shift[:, :, None, None]
        
        h = self.conv2(F.silu(self.norm2(h)))
        return h + self.shortcut(x)


class SpatialSelfAttention(nn.Module):
    def __init__(self, channels: int, num_heads: int = 4) -> None:
        super().__init__()
        self.norm = nn.GroupNorm(8, channels)
        self.qkv = nn.Conv2d(channels, channels * 3, kernel_size=1)
        self.proj = nn.Conv2d(channels, channels, kernel_size=1)
        self.num_heads = num_heads

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        b, c, h, w = x.shape
        norm_x = self.norm(x)
        qkv = self.qkv(norm_x).reshape(b, 3, self.num_heads, c // self.num_heads, h * w)
        q, k, v = qkv[:, 0], qkv[:, 1], qkv[:, 2] # [B, num_heads, head_dim, H*W]
        
        # Scaled dot-product attention
        q = q.transpose(-1, -2) # [B, num_heads, H*W, head_dim]
        k = k.transpose(-1, -2)
        v = v.transpose(-1, -2)
        
        attn = F.scaled_dot_product_attention(q, k, v)
        attn = attn.transpose(-1, -2).reshape(b, c, h, w)
        return x + self.proj(attn)


class DiffusionUNet(nn.Module):
    def __init__(self, in_channels: int = 3, out_channels: int = 3, base_dim: int = 64) -> None:
        super().__init__()
        time_dim = base_dim * 4
        self.time_embed = nn.Sequential(
            SinusoidalPositionalEmbedding(base_dim),
            nn.Linear(base_dim, time_dim),
            nn.SiLU(),
            nn.Linear(time_dim, time_dim),
        )

        # Encoder
        self.conv_in = nn.Conv2d(in_channels, base_dim, kernel_size=3, padding=1)
        self.down1 = ResBlockFiLM(base_dim, base_dim, time_dim)
        self.pool1 = nn.Conv2d(base_dim, base_dim, kernel_size=4, stride=2, padding=1)
        
        self.down2 = ResBlockFiLM(base_dim, base_dim * 2, time_dim)
        self.pool2 = nn.Conv2d(base_dim * 2, base_dim * 2, kernel_size=4, stride=2, padding=1)
        
        # Bottleneck with Self-Attention
        self.mid1 = ResBlockFiLM(base_dim * 2, base_dim * 2, time_dim)
        self.mid_attn = SpatialSelfAttention(base_dim * 2)
        self.mid2 = ResBlockFiLM(base_dim * 2, base_dim * 2, time_dim)

        # Decoder with Skip Connections
        self.up2 = ResBlockFiLM(base_dim * 4, base_dim, time_dim)
        self.up1 = ResBlockFiLM(base_dim * 2, base_dim, time_dim)
        self.conv_out = nn.Conv2d(base_dim, out_channels, kernel_size=3, padding=1)

    def forward(self, x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
        t_emb = self.time_embed(t)
        
        x_in = self.conv_in(x)
        d1 = self.down1(x_in, t_emb)
        p1 = self.pool1(d1)
        
        d2 = self.down2(p1, t_emb)
        p2 = self.pool2(d2)
        
        m = self.mid2(self.mid_attn(self.mid1(p2, t_emb)), t_emb)
        
        u2 = F.interpolate(m, scale_factor=2.0, mode="nearest")
        u2 = self.up2(torch.cat([u2, d2], dim=1), t_emb)
        
        u1 = F.interpolate(u2, scale_factor=2.0, mode="nearest")
        u1 = self.up1(torch.cat([u1, d1], dim=1), t_emb)
        
        return self.conv_out(u1)

Step 3: PyTorch Diffusion Training Step

from __future__ import annotations

import torch
import torch.nn.functional as F
from diffusion_engine import GaussianDiffusionEngine
from unet_architecture import DiffusionUNet


def train_step(
    model: DiffusionUNet,
    diffusion: GaussianDiffusionEngine,
    optimizer: torch.optim.Optimizer,
    images: torch.Tensor,
) -> float:
    model.train()
    optimizer.zero_grad(set_to_none=True)
    
    b, *_, device = *images.shape, images.device
    
    # 1. Sample uniform timesteps t
    t = torch.randint(0, diffusion.timesteps, (b,), device=device).long()
    
    # 2. Add Gaussian noise via forward marginal q(x_t | x_0)
    noise = torch.randn_like(images)
    x_t, ground_truth_noise = diffusion.q_sample(x_0=images, t=t, noise=noise)
    
    # 3. Model predicts the injected noise
    predicted_noise = model(x_t, t)
    
    # 4. Compute L2 Loss || epsilon - epsilon_theta ||^2
    loss = F.mse_loss(predicted_noise, ground_truth_noise)
    loss.backward()
    
    # Gradient clipping prevents divergence
    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    optimizer.step()
    
    return loss.item()

Empirical Benchmark Evaluation

Quantitative evaluation comparing generation quality and likelihood bounds across standardized benchmark datasets:

Dataset / BenchmarkModel TopologyTraining StepsFID ()Inception Score ()Negative Log-Likelihood ()
CIFAR-10 ()StyleGAN2-ADAN/A (Implicit)
VAE (Hierarchical)
DDPM (Linear Schedule)
Improved DDPM (Cosine)
ImageNet BigGAN-deepN/A
ADM (U-Net Baseline)N/A
ADM-G (+ Classifier Guidance)
DiT-XL/2 (+ CFG)
FFHQ ()StyleGAN3N/AN/A
EDM (Heun 2nd-Order)N/AN/A

Troubleshooting Common Diffusion Faults

1. Contrast Burn and Boundary Saturation

  • Symptom: Generated images exhibit harsh high-contrast lines, color banding, or dark edges.
  • Root Cause: Training data was normalized to instead of , breaking zero-mean isotropic Gaussian assumptions at .
  • Remedy: Normalize datasets via transforms.Normalize((0.5,), (0.5,)) and clamp sampled outputs using torch.clamp(sample, -1.0, 1.0).

2. Early Loss Plateau with Structural Blurring

  • Symptom: Loss plateaus at high values within the first steps; outputs remain blurred.
  • Root Cause: Timestep embedding projection weights disconnected or missing sinusoidal frequency mappings.
  • Remedy: Verify sinusoidal positional encodings are injected into every residual block via FiLM modulation, and evaluate model sampling strictly using an Exponential Moving Average (EMA) shadow copy ().

3. Sampling Divergence Under High CFG Scales ()

  • Symptom: Samples generated with classifier-free guidance become oversaturated or degenerate into abstract high-frequency patterns.
  • Root Cause: Unconditional and conditional vector variance disparity under aggressive linear extrapolation.
  • Remedy: Apply CFG Rescaling (Lin et al., 2024): rescale guided noise standard deviation to match conditional vector norm, or reduce guidance scale to .

References

  1. Ho, J., Jain, A., & Abbeel, P. (2020). Denoising Diffusion Probabilistic Models. Advances in Neural Information Processing Systems (NeurIPS 2020).
  2. Sohl-Dickstein, J., Weiss, E., Khan, N., & Sompolinsky, H. (2015). Deep Unsupervised Learning using Nonequilibrium Thermodynamics. ICML 2015.
  3. Song, Y., Sohl-Dickstein, J., Kingma, D. P., Kumar, A., Ermon, S., & Poole, B. (2021). Score-Based Generative Modeling through Stochastic Differential Equations. ICLR 2021.
  4. Song, J., Meng, C., & Ermon, S. (2021). Denoising Diffusion Implicit Models. ICLR 2021.
  5. Ho, J., & Salimans, T. (2022). Classifier-Free Diffusion Guidance. NeurIPS Workshop.
  6. Karras, T., Aittala, M., Aila, T., & Laine, S. (2022). Elucidating the Design Space of Diffusion-Based Generative Models. NeurIPS 2022.


Cite this Explanation

@article{ailinkdeeptech2025diffusion,
  title={Diffusion Models: Mathematical Foundations, Score Matching, and PyTorch Architecture},
  author={AILinkDeepTech},
  journal={AILinkDeepTech Algorithm Explanations},
  year={2025},
  url={https://ailinkdeeptech.com/research/diffusion}
}

Related Explanations