Purpose
Diffusion models are easiest to understand if you separate three layers:
- a fixed forward process that gradually destroys data with noise
- a learned reverse process that denoises
- a training objective that makes denoising equivalent to a tractable regression problem
The DDPM paper is the canonical reference for that setup.
flowchart LR x0["x0: data"] -- "q, fixed noising" --> x1["x1"] x1 -- "q" --> mid["..."] mid -- "q" --> xT["xT: pure noise"] xT -. "p, learned denoising" .-> mid mid -. "p" .-> x1 x1 -. "p" .-> x0
The solid direction is fixed and has no parameters. All learning happens in the dotted direction.
Forward Process
Start from clean data . Define a Markov chain:
with Gaussian step
where is a variance schedule.
Let
Then DDPM uses the closed form
which yields the useful sampling identity
This is why training can sample arbitrary timesteps directly instead of simulating the entire noising chain every time.
Variational Objective
The paper starts from the usual variational bound on negative log likelihood:
That objective can be decomposed into timestep-wise terms. The paper’s key contribution is not only writing down the bound. It is showing how to parameterize the reverse process so training becomes simple and stable.
Reverse Process and Noise Prediction
The reverse model is
parameterized as a Gaussian with learned mean. DDPM shows that a particularly convenient approach is to predict the noise instead:
This leads to the simplified training objective
That is the practical heart of the method. The model is trained as a noise regressor, not as a direct pixel generator.
Why training is cheap even though sampling is slow
The closed form means a training step samples a random , produces from in one shot, and regresses the known — no simulation of the noising chain, no sequential dependence between timesteps. Every minibatch trains the denoiser at randomly scattered noise levels simultaneously. The -step sequential cost only appears at sampling time.
Sampling Algorithm
The paper’s Algorithm 2 is the reverse-time sampler:
- start from
- for , predict noise with
- form the mean of
- add Gaussian noise when
The extracted update in the paper is
with for .
This explains why diffusion generation is slow. One sample requires many denoising steps.
Why U-Nets Became the Default Backbone
Image diffusion models need:
- local spatial processing
- large receptive fields
- high-resolution detail preservation
- conditioning on timestep
U-Nets already solve most of that. They combine:
- a contracting path
- an expanding path
- skip connections across scales
So diffusion models inherited a backbone that was already strong for dense image-to-image problems.
Results from the DDPM Paper
The paper reports:
- Inception score 9.46 on unconditional CIFAR-10
- FID 3.17 on unconditional CIFAR-10
- sample quality on 256x256 LSUN similar to ProgressiveGAN
The paper also argues that diffusion admits a progressive lossy decompression view, which is a useful intuition: early reverse steps recover coarse structure, later ones add detail.
NumPy Schedule and Noising Step
import numpy as np
def make_schedule(T, beta_start=1e-4, beta_end=2e-2):
betas = np.linspace(beta_start, beta_end, T, dtype=np.float32)
alphas = 1.0 - betas
alpha_bars = np.cumprod(alphas)
return betas, alphas, alpha_bars
def q_sample(x0, t, alpha_bars, noise):
return np.sqrt(alpha_bars[t]) * x0 + np.sqrt(1.0 - alpha_bars[t]) * noise
def predict_x0_from_eps(xt, t, eps_hat, alpha_bars):
return (xt - np.sqrt(1.0 - alpha_bars[t]) * eps_hat) / np.sqrt(alpha_bars[t])PyTorch Noise-Predictor Sketch
import math
import torch
import torch.nn as nn
from einops import rearrange
class TimeEmbedding(nn.Module):
def __init__(self, dim: int):
super().__init__()
self.dim = dim
def forward(self, t: torch.Tensor) -> torch.Tensor:
half = self.dim // 2
freqs = torch.exp(-math.log(10000) * torch.arange(half, device=t.device) / half)
args = t[:, None].float() * freqs[None]
return torch.cat([args.sin(), args.cos()], dim=-1)
class ConvBlock(nn.Module):
def __init__(self, cin: int, cout: int, tdim: int):
super().__init__()
self.conv1 = nn.Conv2d(cin, cout, 3, padding=1)
self.conv2 = nn.Conv2d(cout, cout, 3, padding=1)
self.time_proj = nn.Linear(tdim, cout)
self.act = nn.SiLU()
def forward(self, x, t_emb):
h = self.act(self.conv1(x))
h = h + rearrange(self.time_proj(t_emb), "b c -> b c 1 1")
h = self.act(self.conv2(h))
return h
class TinyDenoiser(nn.Module):
def __init__(self, channels: int = 64, tdim: int = 128):
super().__init__()
self.time = TimeEmbedding(tdim)
self.in_block = ConvBlock(3, channels, tdim)
self.out = nn.Conv2d(channels, 3, 1)
def forward(self, x_t: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
t_emb = self.time(t)
h = self.in_block(x_t, t_emb)
return self.out(h)The point here is the parameterization. The model takes and predicts the noise.