What to diffuse? The basics!

diffusion models
My own notes on how diffusion models work, and why they are so cool.
Published

July 21, 2026

Open In Colab
What to diffuse? The basics!
Probabilistic Graphical Model Diagram
Diagram from Denoising Diffusion Probabilistic Models
“A diffusion model is a parameterised Markov Chain trained using variational inference to produce samples matching the data after finite time.”

This is a direct quote from the paper, that introduced diffusion models, but how do you make one and what is the intuition behind these diffusion models? That is what I want to explain in this post. It consist out of the following parts;

What can we do with diffusion models?
Probabilistic Graphical Model Diagram

At its core, diffusion models are generative models used to generate new samples of a distribution. The idea behind generative modelling is to derive for a given dataset \(\mathcal{D}\) of observations \(x^{(1)}, \cdots x^{(N)}\) to learn or derive the underlying distribution from which the data is generated. Thus derive \(x \sim p_{data}\). Which enables us to sample from this distribution and thus generate more samples, which is the goal of diffusion models.

The sample space for images is \(x \in \mathbb{R}^{H\times W}\), is incredibly big, while most of this space is occupied mostly be noisy samples. As I attempted to visualise in the picture above.

Generating samples

As mentioned before, we want to sample previously unseen examples, thus we need a mapping that produces a sample inside the space \(x \in \mathbb{R}^{H\times W}\). A Generative Adversarial Network (GAN), is a technique that produces such a mapping (the generator). This generator takes as input noise and outputs an image

GAN model Diagram

\[ g_\theta(x_{noise}) = x_{img} \]

GANs however have two major problems;

Diffusion models solve these problems by modeling the underlying data distribution in a sequential way. Probabilistic Graphical Model Diagram

In this way the training objective becomes more stable, since the whole problem is split into many smaller problems. And it doesn’t require the training of a discriminator, since the it just learns to denoise samples.

A different way to look at diffusion models that I found very useful is that of the perspective of score models, that basically says that diffusion models try to a score function \(s(x)\), which is the gradient of the log likelihood and points, for each point \(x \in \mathbb{R}^{H\times W}\)to the direction that makes your sample more likely.

Probabilistic Graphical Model Diagram

\[ s(x) \approx \nabla_x \log{p(x)} \Big(= \epsilon_\theta(x, t) \Big) \]

Diffusion models - Forward process

This part explain the math behind diffusion models and how to get a loss function we will employ to train a model. Most of the math comes from the blogpost What are Diffusion Models? and from the paper link. I have rewritten the equations and added the schematics in way that it makes more sense to me, and I hope it makes it helps you as well.

As just mentioned, diffusion models make the assumption that samples are sequentially constructed from noise. This means that there is a sequence of hidden latent variables that starts with \(x_T \sim \mathcal{N}(0, \sigma^2)\) where gradually information is added (or de-noised), until there is a clean sample without noise \(x_0\).

Before I can explain how to generate data, I need to start with the forward process, the process where data is destroyed.

This is defined by the approximate posterior.

\[ q(x_{1:T}|x_0) = \prod_{t=1}^T q(x_t|x_{t-1}) \]

With the transition probabilities.

\[ q(x_t|x_{t-1}) = \mathcal{N}(x_t|\sqrt{1-\beta_t} x_{t-1}, \beta_tI) \]

Where the sequence \(\beta_0, \cdots, \beta_{T}\) is the variance schedule that controls how fast information is destroyed in the process. Using the reparameterization trick we can expess \(x_t\)

\[ x_{t} = \sqrt{1-\beta_{t-1}} \cdot x_{t-1} + \sqrt{\beta_{t-1}} \cdot \epsilon_{t-1} \]

Were

\[ \epsilon \sim \mathcal{N}(0, I) \]

The reason why this setup is so convenient is that we can use this property to derive a tractable form for \(x_t\)

\[ \begin{align*} x_{t} &= \sqrt{1-\beta_{t-1}} \cdot x_{t-1} + \sqrt{\beta_{t-1}} \cdot \epsilon_{t-1} \\ x_{t} &= \sqrt{1-\beta_{t-1}} \cdot (\sqrt{1-\beta_{t-2}} \cdot x_{t-2} + \sqrt{\beta_{t-2}} \cdot \epsilon_{t-2}) + \sqrt{\beta_{t-1}} \cdot \epsilon_{t-1} \\ x_{t} &= \sqrt{1-\beta_{t-1}} \sqrt{1-\beta_{t-2}} \cdot x_{t-2} + \sqrt{1-\beta_{t-1}}\sqrt{\beta_{t-2}} \cdot \epsilon_{t-2} + \sqrt{\beta_{t-1}} \cdot \epsilon_{t-1} \\ \vdots \\ x_{t} &= \sqrt{\bar{\alpha}_t} x_0 + \sqrt{(1-\bar{\alpha}_t)}\cdot\epsilon_{combined} \end{align*} \]

Gaussian distribution to derive a tractable form;

\[ q(x_t|x_0) = \mathcal{N}(x_t; \sqrt{\bar{\alpha}_t} x_0, (1-\bar{\alpha}_t)I) \]

Where we use the identities

\[ \alpha_t = 1-\beta_t \\ \bar{\alpha}=\prod_{s=1}^t\alpha_t \]

This simply means that we can sample \(x_t\) for arbitrary \(t\) using the reparameterization trick.

Before we are finished with the forward process, I want to define one more probability, the forward process posteriors;

\[ q(x_{t-1}|x_t ,x_0) = \mathcal{N}(x_{t-1};\hat{\mu}_t(x_t, x_0), \hat{\beta}_t) \]

Where

\[ \hat{\mu}_t(x_t, x_0) = \frac{\sqrt{\bar{\alpha}_{t-1}}\beta_t}{1-\bar\alpha_t}x_0 + \frac{\sqrt{\alpha_t}(1-\bar\alpha_{t-1})}{{1-\bar\alpha}_t}x_t \]

And

\[ \hat{\beta}_t = \frac{1-\bar{\alpha}_{t-1}}{1-\bar\alpha_t}\beta_t \]

The reason why this guy is important will be clear at the end of the blogpost. Now we know, how we can derive, given a sample \(x \in \mathcal{D}\), a latent \(x_t\) for an arbitrary \(t\). |Probabilistic Graphical Model Diagram

Diffusion models - Reverse process

So, now we have defined the forward process, lets look at what we actually want to learn, namely the reverse process. This process is defined by

\[ p_\theta(x_0) = \int p_\theta(x_0, x_1, \cdots, x_{T-1}) d{x_{1:T}} \]

\[ p_\theta(x_0, \cdots, x_{T-1}) = p_\theta(x_0)\prod_{t=1}^Tp_\theta(x_{t-1}|x_t) \]

In this reverse process the transitions are assumed to be

\[ \begin{align*} p_{\theta}(x_{t-1}|x_t) &= \mathcal{N}\Big(x_{t-1};\mu_\theta(x_t, t), \hat{\beta}_t \mathbf{I}\Big ) \end{align*} \]

Where \(\mu_\theta(x_t, t)\) is the function distribution we want to learn! This function we can rewrite to;

\[ \mu_\theta(x_t, t) = \frac{1}{\sqrt{\alpha_t}} \Big(x_t - \frac{\beta_t}{\sqrt{1-\hat\alpha_t}}\epsilon_\theta(x_t, t) \Big) \]

When we have this function \(p_\theta(x_{t-1}|x_t)\), we can use the resample trick to sequentially reconstruct samples from noise.

Diffusion models - From definition to loss function

So we just defined the diffusion process as a Markov chain, now we need to come up with a loss which we can use to optimise the parameters of our model \(\theta\).

Starting with the joint distribution can be done by minimising the log-likelihood for the model;

\[ \begin{align*} & -\log \big( p_{\theta}(x) \big) = -\log \big( \int p_\theta(x_{0:T})dx_{1:T} \big) \\ &=-\log \big( \int p_\theta(x_{0:T})\cdot \frac{q(x_{1:T}|x_0)}{q(x_{1:T}|x_0)} dx_{1:T} \big) \\ &= -\log \big(\mathbb{E}_q\Big[ \frac{p_\theta(x_{0:T})}{q(x_{1:T}|x_0)} \Big] \big)\leq -\mathbb{E}_q\Big[\log \big( \frac{p_\theta(x_{0:T})}{q(x_{1:T}|x_0)} \Big) \Big] \\ &=-\mathbb{E}_q\Big[-\log p(x_T) - \sum_{t\geq 1} \log \{\frac{p_\theta(x_{t-1}|x_t)}{q(x_t|x_{t-1})}\} \Big] \end{align*} \]

The problem with this is that we are now comparing \(p_\theta(x_{t-1}|x_t)\) with \(q(x_t|x_{t-1})\). It would be prefered if we could compare for each timestep \(t\), the approximate reverse forward process with the posterior forward process, \(q(x_{t-1}|x_t, x_0)\) (which we just derived). How this works can be seen if we realise that we can rewrite;

\[ q(x_t|x_{t-1}) = \frac{q(x_t|x_0)}{q(x_{t-1}|x_0)} \cdot q(x_{t-1}|x_t, x_0)\\ \]

To obtain

\[ \begin{align*} &=-\mathbb{E}_q\Big[-\log p(x_T) - \sum_{t\geq 1} \log \{\frac{p_\theta(x_{t-1} |x_t)}{q(x_t|x_{t-1})}\} \Big] \\ &=-\mathbb{E}_q\Big[-\log p(x_T) - \sum_{t>1} \log \{\frac{p_\theta(x_{t-1}|x_t)}{q(x_t|x_{t-1})}\} -\log \big\{ \frac{p_\theta(x_0|x_1)}{q(x_1|x_0)}\big\}\Big] \\ &=-\mathbb{E}_q\Big[-\log p(x_T) - \sum_{t>1} \log \Bigg\{\frac{p_\theta(x_{t-1}|x_t}{q(x_{t-1}|x_t, x_0)}\cdot\frac{q(x_{t-1}|x_0)} {q(x_t|x_0)}\Bigg\} -\log \big\{ \frac{p_\theta(x_0|x_1)}{q(x_1|x_0)}\big\}\Big] \\ \end{align*} \]

Rewritting

\[ -\sum_{t>1}\log\Bigg\{\frac{q(x_{t-1}|x_0)} {q(x_t|x_0)}\Bigg\} = \log q(x_1|x_0) - \log q(x_T|x_0) \]

And substitution gives us

\[ \begin{align*} &=-\mathbb{E}_q\Big[-\log p(x_T) - \sum_{t>1} \log \Bigg\{\frac{p_\theta(x_{t-1}|x_t)}{q(x_{t-1}|x_t, x_0)}\cdot\frac{q(x_{t-1}|x_0)} {q(x_t|x_0)}\Bigg\} -\log \big\{ \frac{p_\theta(x_0|x_1)}{q(x_1|x_0)}\big\}\Big] \\ &=-\mathbb{E}_q\Big[ - \log \Big\{\frac{p(x_T)}{q(x_T|x_0)}\Big\} - \sum_{t>1} \log \Bigg\{\frac{p_\theta(x_{t-1}|x_t)}{q(x_{t-1}|x_t, x_0)}\Bigg\} - \log\big\{p_\theta(x_0|x_1)\big\}\Big] \end{align*} \]

This means that if we want parameters \(\theta\) that minimises the log-likelihood, we need to make sure that these parameters minimises a lower bound which consist out of the following terms;

\[ \begin{align*} \mathbb{E}_q\Big[&D_{KL}\big[q(x_1|x_0)||p(x_1|x_0)\big] \\ &+ \sum_{t>1}D_{KL}\big[q(x_{t-1}|x_t, x_0) || p_\theta(x_{t-1}|x_t) \big] \\ &-\log p_\theta(x_0|x_1) \Big] \end{align*} \]

Which we can decompose into;

\[ \begin{align*} &L_T = D_{KL}\big[q(x_T|x_0)||p(x_T)\big] \\ &L_{t-1} = \sum_{t>1}D_{KL}\big[q(x_{t-1}|x_t, x_0) || p_\theta(x_{t-1}|x_t) \big]\\ &L_0 = - \log p_\theta(x_0|x_1) \end{align*} \]

This basically says that we can optimise the lower bound by only looking at \(L_{t-1}\), because \(L_T\) is constant during training and can be ignored.

Because both \(p_\theta\) and \(q\) are both Gaussians, the divergence becomes;

\[ L_{t-1} = \mathbb{E}_q\Big[\frac{1}{2\sigma^2_t}||\hat\mu(x_t, x_0) - \mu_\theta(x_t, t)||^2\Big] \]

This means that the most straightforward way to implement the reverse diffusion process is to simply predict \(\mu(x_t, x_0)\). This is the first way to implement a diffusion model.

The second way is rewriting to;

\[ \mu_\theta(x_t, t) = \frac{1}{\sqrt{\alpha_t}} \Big(x_t - \frac{\beta_t}{\sqrt{1-\hat\alpha_t}}\epsilon_\theta(x_t, t) \Big) \]

The authors of the paper found this more convenient. Which leads to an even simpler version of the lower bound \[ L_{simple}(\theta) = \mathbb{E}\Big[||\epsilon - \epsilon_\theta(\sqrt{\hat\alpha_t}x_0 +\sqrt{1-\hat\alpha_t}\epsilon, t)||^2\Big] \]

In this way we don’t directly predict, given a variable \(x_t\) what was the previous \(x_{t-1}\), but we predict, given \(x_t\), what the noise \(\epsilon_{\theta}(x_t, t)\) at that given timestep. Intuitively, we predict which direction we have to move to make our sample less noisy. |Probabilistic Graphical Model Diagram

UNET architecture

|Probabilistic Graphical Model Diagram|

UNET diagram from U-Net: Convolutional Networks for Biomedical Image Segmentation

So now we know what we want, namely \(\epsilon_\theta(x, t)\), a model that takes as input a distorted sample \(x_t\) and outputs the noise \(\epsilon\) that was added for timestep \(t\) and we know that we want to apply a mse loss on this model. So for this we use an UNET, just like the authors of the original diffusion models, and we use a convolution with a shift scale operation to condition this on the timestep.

This UNET consist out of a few building blocks which I will explain from small to big, starting with a basic convolutional block;

class ConvBlock(torch.nn.Module):
    def __init__(self, 
                 in_channels: int, 
                 out_channels: int, 
                 kernel_size: int = 3, 
                 stride: int = 1, 
                 padding: int = 1,
                 bias: bool = False,
                 nrm: bool = True,
                 act: bool = True,
                ) -> None:
        """A basic convolutional block, convolution, activation and normalization. Uses Groupnorm.
        """
        super().__init__()
        self.conv = torch.nn.Conv2d(
            in_channels=in_channels, 
            out_channels=out_channels, 
            kernel_size=kernel_size, 
            stride=stride, 
            padding=padding, 
            bias=bias)
        
        self.nrm_fn = torch.nn.GroupNorm(num_groups=8, num_channels=out_channels) if nrm else torch.nn.Identity()
        self.act_fn = torch.nn.LeakyReLU(inplace=True) if act else torch.nn.Identity()

    def forward(self, x) -> torch.Tensor:
        return self.act_fn(self.nrm_fn(self.conv(x)))

However, we want a model that can be conditioned on time \(t\), we use a shift-scale operation on the output of a normal convolution layer.

class TimeConvBlock(ConvBlock):
    def forward(self, x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
        t_scale, t_shift = t.chunk(2, dim=1)
        x = self.nrm_fn(self.conv(x))
        return self.act_fn(x * (1 + t_scale[..., None, None]) + t_shift[..., None, None])

These TimeConvBlocks will be combined to make a TimeResidualBlock.

class TimeResidualBlock(torch.nn.Module):
    """Basic residual block with time conditioning.
    Time conditioning is done by employing a scale/shift operator on the projected features before the activation.
    Contains 3 convolutional blocks, the last one without activation.
    The residual connection is added to the last output.
    """
    def __init__(self, channels: int) -> None:
        super().__init__()
        self.conv_block_1 = TimeConvBlock(in_channels=channels, out_channels=channels)
        self.conv_block_2 = TimeConvBlock(in_channels=channels, out_channels=channels)
        self.conv_block_3 = TimeConvBlock(in_channels=channels, out_channels=channels, act=False)
    def forward(self, x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
        r = x.clone()
        x = self.conv_block_1(x=x,t=t)
        x = self.conv_block_2(x=x,t=t)
        return self.conv_block_3(x=x,t=t) + x

Both an an EncoderBlock and a DecoderBlock consist out of one TimeResidualBlock and a Resample Block. In the EncoderBlock the resample operation is done first, while in the DecoderBlock the resample operation is done last, such that the TimeResidualBlock operates on features maps of low resolution.

class ResidualUnetBlock(torch.nn.Module):
    def __init__(
        self, 
        in_channels: int, 
        out_channels: int, 
        upsample: bool, 
        time_channels: int) -> None:
        super().__init__()
        self.upsample = upsample
        if self.upsample:
            #This means it is an decoder block, so we do the first the convolution block, then the resample block
            self.residual_block = TimeResidualBlock(channels=in_channels)
            self.resample_block = ConvUpsample(in_channels=in_channels, out_channels=out_channels)
        else:
            #This means that it is an encoder block so first resample, then convolutional block
            self.resample_block = ConvDownSample(in_channels=in_channels, out_channels=out_channels)
            self.residual_block = TimeResidualBlock(channels=out_channels)
        self.timestep_block = torch.nn.Sequential(
                torch.nn.Linear(time_channels, out_channels*2 if not upsample else in_channels*2),
                torch.nn.SiLU(),
            )
        
    def forward(self, x: torch.Tensor, t: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
        h = x.clone()
        t = self.timestep_block(t)
        if not self.upsample:
            x = self.resample_block(x)
        x = self.residual_block(x=x, t=t)
        if self.upsample:
            x = self.resample_block(x)        
        return x, h

Combining these blocks gives our model

class UNET(torch.nn.Module):
    
    def __init__(self, in_channels: int, channels: list[int]) -> None:
        super().__init__()
        self.in_channels = in_channels
        self.channels = channels
        self.init_cnv = ConvBlock(in_channels=in_channels, out_channels=channels[0])
        time_channels = channels[0] * 4
        self.time_enc = torch.nn.Sequential(
            SinusoidalPositionEmbeddings(time_channels=channels[0]*4),
            torch.nn.Linear(channels[0]*4, channels[0]*4),
            torch.nn.SiLU(),
        )

        #Encoder/decoder
        self.encoder = torch.nn.ModuleList([])
        self.decoder = torch.nn.ModuleList([])
        for in_channels, out_channels in zip(channels[:-1], channels[1:]):
            self.encoder.append(ResidualUnetBlock(in_channels, out_channels, time_channels=time_channels, upsample=False))
            self.decoder.append(ResidualUnetBlock(out_channels*2, in_channels,time_channels=time_channels,  upsample=True))
        
        self.attn_block = Attention2D(in_channels=channels[-1], out_channels=channels[-1]*2, head_channels=16, n_heads=4)
        self.attn_norm = torch.nn.GroupNorm(num_groups=1, num_channels=channels[-1]*2)
        self.final_cnv = ConvBlock(in_channels=channels[0]*2, out_channels=self.in_channels, nrm=False, act=False, )
        
    def forward(self, x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
        x = self.init_cnv(x)
        t = self.time_enc(t)

        skip_connection = []
        for layer in self.encoder:
            x, h = layer(x=x, t=t)
            skip_connection.append(h)
        x = self.attn_block(x)
        x = self.attn_norm(x)
        
        for layer in self.decoder[::-1]:
            x, _ = layer(x=x, t=t)
            x = torch.cat((x, skip_connection.pop(-1)), dim=1)
            
        return self.final_cnv(x)
import matplotlib.pyplot as plt
import numpy as np
import torch
import torchvision
import einops
from tqdm import tqdm
batch_size = 64
eval_steps = 500
learning_rate = 0.0001
beta_start = 0.0001
beta_end = 0.015
diffusion_timesteps = 1000
n_trn_steps = 5_000
transforms = torchvision.transforms.Compose([
    torchvision.transforms.ToTensor(),
    torchvision.transforms.Normalize((0.0,), (1.0,))])
dataset = torchvision.datasets.MNIST(root=".", transform=transforms, train=True, download=True)

loader = torch.utils.data.DataLoader(
    dataset=dataset,
    batch_size=batch_size,
    drop_last=True,
    shuffle=True)
def get_diffusion_dict_np(
    beta_start: float, beta_end: float, diffusion_timesteps: int
) -> dict[str, np.ndarray]:
    betas = np.linspace(beta_start, beta_end, diffusion_timesteps)
    alphas = 1.0 - betas
    alphas_cumprod = np.cumprod(alphas, axis=0)
    alphas_cumprod_prev = np.pad(alphas_cumprod[:-1], (1, 0), constant_values=1.0)
    sqrt_recip_alphas = np.sqrt(1.0 / alphas)
    sqrt_alphas_cumprod = np.sqrt(alphas_cumprod)
    sqrt_one_minus_alphas_cumprod = np.sqrt(1.0 - alphas_cumprod)
    posterior_variance = betas * (1.0 - alphas_cumprod_prev) / (1.0 - alphas_cumprod)
    return {
        "betas": betas,
        "alphas": alphas,
        "alphas_cumprod": alphas_cumprod,
        "alphas_cumprod_prev": alphas_cumprod_prev,
        "sqrt_recip_alphas": sqrt_recip_alphas,
        "sqrt_alphas_cumprod": sqrt_alphas_cumprod,
        "sqrt_one_minus_alphas_cumprod": sqrt_one_minus_alphas_cumprod,
        "posterior_variance": posterior_variance,
    }
def q_sample(
    gaussian_dict_np: dict[str, np.ndarray],
    x_start: np.ndarray,
    timesteps: np.ndarray,
    noise: np.ndarray,
) -> np.ndarray:
    """Forward process, this we add noise to x_start=x0.
    The amount of noise we add is defined by the variance schedule defined in the gaussian_dict_np.

    Args:
        x_start (torch.Tensor): Start 
        t (int): Diffusion timestep, this value determines the scale of the noise we will add.
        noise (torch.Tensor, optional): The noise to add, this is the amount of noise the network should predict.
    Returns:
        torch.Tensor: x_t
    """

    sqrt_alphas_cumprod_t = gaussian_dict_np["sqrt_alphas_cumprod"][timesteps]
    sqrt_one_minus_alphas_cumprod_t = gaussian_dict_np["sqrt_one_minus_alphas_cumprod"][
        timesteps
    ]

    return sqrt_alphas_cumprod_t.reshape(-1, 1, 1, 1) * x_start + sqrt_one_minus_alphas_cumprod_t.reshape(-1, 1, 1, 1) * noise
class ConvBlock(torch.nn.Module):
    def __init__(self, 
                 in_channels: int, 
                 out_channels: int, 
                 kernel_size: int = 3, 
                 stride: int = 1, 
                 padding: int = 1,
                 bias: bool = False,
                 nrm: bool = True,
                 act: bool = True,
                ) -> None:
        """A basic convolutional block, convolution, activation and normalization. Uses Groupnorm.
        """
        super().__init__()
        self.conv = torch.nn.Conv2d(
            in_channels=in_channels, 
            out_channels=out_channels, 
            kernel_size=kernel_size, 
            stride=stride, 
            padding=padding, 
            bias=bias)
        
        self.nrm_fn = torch.nn.GroupNorm(num_groups=8, num_channels=out_channels) if nrm else torch.nn.Identity()
        self.act_fn = torch.nn.LeakyReLU(inplace=True) if act else torch.nn.Identity()

    def forward(self, x) -> torch.Tensor:
        return self.act_fn(self.nrm_fn(self.conv(x)))
class TimeConvBlock(ConvBlock):
    def forward(self, x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
        t_scale, t_shift = t.chunk(2, dim=1)
        x = self.nrm_fn(self.conv(x))
        return self.act_fn(x * (1 + t_scale[..., None, None]) + t_shift[..., None, None])
class ConvDownSample(torch.nn.Module):
    def __init__(self, in_channels: int, out_channels: int) -> None:
        super().__init__()
        self.conv = torch.nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3, stride=2, padding=1, )
        self.nrm_fn = torch.nn.GroupNorm(num_groups=8, num_channels=out_channels)
        self.act_fn = torch.nn.LeakyReLU(inplace=True)
        

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.act_fn(self.nrm_fn(self.conv(x)))
class ConvUpsample(torch.nn.Module):
    def __init__(self, in_channels: int, out_channels:int, ) -> None:
        super().__init__()
        self.conv = torch.nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3, stride=1, padding=1, )
        self.nrm_fn = torch.nn.GroupNorm(num_groups=8, num_channels=out_channels)
        self.act_fn = torch.nn.LeakyReLU(inplace=True)
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = torch.nn.functional.interpolate(x, scale_factor=2, )
        return self.act_fn(self.nrm_fn(self.conv(x)))
class TimeResidualBlock(torch.nn.Module):
    """Basic residual block with time conditioning.
    Time conditioning is done by employing a scale/shift operator on the projected features before the activation.
    Contains 3 convolutional blocks, the last one without activation.
    The residual connection is added to the last output.
    """
    def __init__(self, channels: int) -> None:
        super().__init__()
        self.conv_block_1 = TimeConvBlock(in_channels=channels, out_channels=channels)
        self.conv_block_2 = TimeConvBlock(in_channels=channels, out_channels=channels)
        self.conv_block_3 = TimeConvBlock(in_channels=channels, out_channels=channels, act=False)
    def forward(self, x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
        r = x.clone()
        x = self.conv_block_1(x=x,t=t)
        x = self.conv_block_2(x=x,t=t)
        return self.conv_block_3(x=x,t=t) + x
class ResidualUnetBlock(torch.nn.Module):
    def __init__(self, 
                 in_channels: int, 
                 out_channels: int, 
                 upsample: bool, 
                 time_channels: int) -> None:
        super().__init__()
        self.upsample = upsample
        if self.upsample:
            #This means it is an decoder block, so we do the first the convolution block, then the resample block
            self.residual_block = TimeResidualBlock(channels=in_channels)
            self.resample_block = ConvUpsample(in_channels=in_channels, out_channels=out_channels)
        else:
            #This means that it is an encoder block so first resample, then convolutional block
            self.resample_block = ConvDownSample(in_channels=in_channels, out_channels=out_channels)
            self.residual_block = TimeResidualBlock(channels=out_channels)
        self.timestep_block = torch.nn.Sequential(
                torch.nn.Linear(time_channels, out_channels*2 if not upsample else in_channels*2),
                torch.nn.SiLU(),
            )
        
    def forward(self, x: torch.Tensor, t: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
        h = x.clone()
        t = self.timestep_block(t)
        if not self.upsample:
            x = self.resample_block(x)
        x = self.residual_block(x=x, t=t)
        if self.upsample:
            x = self.resample_block(x)        
        return x, h
class Attention2D(torch.nn.Module):
    def __init__(self, 
                 in_channels: int, 
                 out_channels: int, 
                 head_channels: int, n_heads: int, ) -> None:
        """Attention block that computes the self attention for each feature.
        
        First projects the features to Q, K, V embeddings.
        Reshapes these blocks by collapsing the H, W axis.
        Then computes a similarity score.
        Then computes the output values.
        """
        super().__init__()
        
        self.embedding_dim = head_channels * n_heads
        self.q_scale = head_channels ** -0.5
        self.n_heads = n_heads
        self.out_channels = out_channels
        self.conv_q = torch.nn.Conv2d(in_channels=in_channels, out_channels=self.embedding_dim, kernel_size=1, stride=1, padding=0, bias=False)
        self.conv_k = torch.nn.Conv2d(in_channels=in_channels, out_channels=self.embedding_dim, kernel_size=1, stride=1, padding=0, bias=False)
        self.conv_v = torch.nn.Conv2d(in_channels=in_channels, out_channels=self.embedding_dim, kernel_size=1, stride=1, padding=0, bias=False)

        self.conv_out = torch.nn.Conv2d(in_channels=self.embedding_dim, out_channels=self.out_channels, kernel_size=1, stride=1, padding=0, bias=False)
        
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        N, C, H, W = x.shape
        q = self.conv_q(x)
        k = self.conv_k(x)
        v = self.conv_v(x)

        # re-arrange the q, k, v from (N, C, H, W) to (N, n_heads, head_channels, HxW)
        q = einops.rearrange(q, pattern="N (a b) H W -> N a b (H W)", a=self.n_heads) * self.q_scale
        k = einops.rearrange(k, pattern="N (a b) H W -> N a b (H W)", a=self.n_heads)
        v = einops.rearrange(v, pattern="N (a b) H W -> N a b (H W)", a=self.n_heads)

        similarity_matrix = torch.einsum("N a b i, N a b j -> N a i j", q, k)
        similarity_matrix = torch.nn.functional.softmax(similarity_matrix, dim=1)

        output = torch.einsum("N a i j, N a d j -> N a i d", similarity_matrix, v)
        output = einops.rearrange(output, "N a (H W) d->N (a d) H W",  H=H, W=W)
        return self.conv_out(output)
class SinusoidalPositionEmbeddings(torch.nn.Module):
    def __init__(self, time_channels: int) -> None:
        super().__init__()
        self.time_channels = time_channels
        self.embeddings = torch.log(torch.Tensor([10_000])) / (self.time_channels // 2 - 1)

    def forward(self, t: torch.Tensor) -> torch.Tensor:
        embeddings = torch.exp(torch.arange(self.time_channels // 2, device=t.device) * -self.embeddings.to(t.device))
        embeddings = t[:, None] * embeddings[None, :]
        return torch.cat((embeddings.sin(), embeddings.cos()), dim=-1)
class UNET(torch.nn.Module):
    
    def __init__(self, 
                 in_channels: int, 
                 channels: list[int]) -> None:
        super().__init__()
        self.in_channels = in_channels
        self.channels = channels
        self.init_cnv = ConvBlock(in_channels=in_channels, out_channels=channels[0])
        time_channels = channels[0] * 4
        self.time_enc = torch.nn.Sequential(
            SinusoidalPositionEmbeddings(time_channels=channels[0]*4),
            torch.nn.Linear(channels[0]*4, channels[0]*4),
            torch.nn.SiLU(),
        )

        #Encoder/decoder
        self.encoder = torch.nn.ModuleList([])
        self.decoder = torch.nn.ModuleList([])
        for in_channels, out_channels in zip(channels[:-1], channels[1:]):
            self.encoder.append(ResidualUnetBlock(in_channels, out_channels, time_channels=time_channels, upsample=False))
            self.decoder.append(ResidualUnetBlock(out_channels*2, in_channels,time_channels=time_channels,  upsample=True))
        
        self.attn_block = Attention2D(in_channels=channels[-1], out_channels=channels[-1]*2, head_channels=16, n_heads=4)
        self.attn_norm = torch.nn.GroupNorm(num_groups=1, num_channels=channels[-1]*2)
        self.final_cnv = ConvBlock(in_channels=channels[0]*2, out_channels=self.in_channels, nrm=False, act=False, )
        
    def forward(self, x: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
        x = self.init_cnv(x)
        t = self.time_enc(t)

        skip_connection = []
        for layer in self.encoder:
            x, h = layer(x=x, t=t)
            skip_connection.append(h)
        x = self.attn_block(x)
        x = self.attn_norm(x)
        
        for layer in self.decoder[::-1]:
            x, _ = layer(x=x, t=t)
            x = torch.cat((x, skip_connection.pop(-1)), dim=1)
            
        return self.final_cnv(x)
def batch_distort(x_trn: torch.Tensor, batch_size: int, t: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
    if t == None:
        t = torch.randint(0, diffusion_timesteps-1, (batch_size,))
    noise = torch.randn_like(x_trn)
    xt = q_sample(gaussian_dict_np=diffusion_dict_np, x_start = x_trn.numpy(), timesteps=t.numpy(), noise=noise.numpy())
    return torch.from_numpy(xt).to(torch.float32), noise, t
def plot_q_sample(x: torch.Tensor, xt: torch.Tensor, ) -> None:
    fig, axs = plt.subplots(1, 2)
    axs[0].imshow(xt, cmap="binary")
    axs[1].imshow(x, cmap="binary")
    axs[0].set_title(r"$x_t$")
    axs[1].set_title(r"$x_0$")
    _ = [ax.axis("off") for ax in axs.flatten()]
#
Diffusion models - From definition to loss function

Okey, now we have everything defined we can finally train our model.

unet = UNET(in_channels=1, channels=[16, 32, 64])
optimizer = torch.optim.Adam(params=unet.parameters(), lr=learning_rate, )
diffusion_dict_np = get_diffusion_dict_np(beta_start=beta_start, beta_end=beta_end, diffusion_timesteps=diffusion_timesteps)
iter_loader = iter(loader)
for step in range(n_trn_steps):
    try: 
        x_trn, _ = next(iter_loader)
    except StopIteration:
        iter_loader = iter(loader)
        x_trn, _ = next(iter_loader)
    xt, noise, t = batch_distort(x_trn=x_trn, batch_size=batch_size, t=None)
    predicted_noise = unet(x=xt, t=t)
    loss = torch.nn.functional.mse_loss(predicted_noise, noise)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    if step % eval_steps == 0:
        print(f"{step}/{n_trn_steps} loss: {loss.item():.4f}")
0/5000 loss: 0.9969
500/5000 loss: 0.0757
1000/5000 loss: 0.0661
1500/5000 loss: 0.0395
2000/5000 loss: 0.0466
2500/5000 loss: 0.0342
3000/5000 loss: 0.0432
3500/5000 loss: 0.0368
4000/5000 loss: 0.0354
4500/5000 loss: 0.0391
#
Diffusion models - Sampling steps

Okey, now we have our model, we can sample some digits!

with torch.no_grad():
    xt = torch.randn_like(xt)
    for timestep in tqdm(reversed(range(0, diffusion_timesteps-1))):
        t = torch.ones_like(xt[:, 0, 0, 0]) * timestep
        et = unet(x=xt.float(), t=t.float())
        model_mean = diffusion_dict_np["sqrt_recip_alphas"][timestep] * (xt - diffusion_dict_np["betas"][timestep] * et.detach().numpy() /diffusion_dict_np["sqrt_one_minus_alphas_cumprod"][timestep])
        if timestep != 0:
            xt = model_mean + np.sqrt(
                    diffusion_dict_np["posterior_variance"][timestep]
                ) * torch.randn_like(model_mean)
        else:
            xt = model_mean

0it [00:00, ?it/s]/tmp/ipykernel_35124/127156701.py:6: DeprecationWarning: __array_wrap__ must accept context and return_scalar arguments (positionally) in the future. (Deprecated NumPy 2.0)
  model_mean = diffusion_dict_np["sqrt_recip_alphas"][timestep] * (xt - diffusion_dict_np["betas"][timestep] * et.detach().numpy() /diffusion_dict_np["sqrt_one_minus_alphas_cumprod"][timestep])

3it [00:00, 28.05it/s]
7it [00:00, 29.94it/s]
11it [00:00, 30.51it/s]
15it [00:00, 30.71it/s]
19it [00:00, 30.87it/s]
23it [00:00, 30.94it/s]
27it [00:00, 31.01it/s]
31it [00:01, 31.04it/s]
35it [00:01, 31.06it/s]
39it [00:01, 31.11it/s]
43it [00:01, 31.13it/s]
47it [00:01, 31.06it/s]
51it [00:01, 31.05it/s]
55it [00:01, 30.91it/s]
59it [00:01, 30.45it/s]
63it [00:02, 30.58it/s]
67it [00:02, 30.75it/s]
71it [00:02, 30.81it/s]
75it [00:02, 30.90it/s]
79it [00:02, 30.91it/s]
83it [00:02, 30.99it/s]
87it [00:02, 31.03it/s]
91it [00:02, 31.04it/s]
95it [00:03, 30.39it/s]
99it [00:03, 30.52it/s]
103it [00:03, 30.75it/s]
107it [00:03, 30.76it/s]
111it [00:03, 30.85it/s]
115it [00:03, 30.93it/s]
119it [00:03, 30.95it/s]
123it [00:03, 30.51it/s]
127it [00:04, 30.63it/s]
131it [00:04, 30.87it/s]
135it [00:04, 30.87it/s]
139it [00:04, 30.98it/s]
143it [00:04, 30.93it/s]
147it [00:04, 31.01it/s]
151it [00:04, 31.04it/s]
155it [00:05, 31.05it/s]
159it [00:05, 30.16it/s]
163it [00:05, 29.95it/s]
167it [00:05, 30.32it/s]
171it [00:05, 30.52it/s]
175it [00:05, 30.66it/s]
179it [00:05, 30.78it/s]
183it [00:05, 30.86it/s]
187it [00:06, 30.60it/s]
191it [00:06, 29.76it/s]
194it [00:06, 29.63it/s]
198it [00:06, 29.96it/s]
202it [00:06, 30.30it/s]
206it [00:06, 30.53it/s]
210it [00:06, 30.25it/s]
214it [00:06, 30.43it/s]
218it [00:07, 30.58it/s]
222it [00:07, 30.74it/s]
226it [00:07, 30.76it/s]
230it [00:07, 30.87it/s]
234it [00:07, 30.86it/s]
238it [00:07, 30.92it/s]
242it [00:07, 30.94it/s]
246it [00:08, 30.87it/s]
250it [00:08, 30.96it/s]
254it [00:08, 30.63it/s]
258it [00:08, 30.44it/s]
262it [00:08, 30.58it/s]
266it [00:08, 30.63it/s]
270it [00:08, 30.70it/s]
274it [00:08, 30.77it/s]
278it [00:09, 30.81it/s]
282it [00:09, 30.84it/s]
286it [00:09, 30.85it/s]
290it [00:09, 30.53it/s]
294it [00:09, 30.50it/s]
298it [00:09, 30.63it/s]
302it [00:09, 30.73it/s]
306it [00:09, 30.86it/s]
310it [00:10, 30.90it/s]
314it [00:10, 30.89it/s]
318it [00:10, 30.94it/s]
322it [00:10, 30.42it/s]
326it [00:10, 30.62it/s]
330it [00:10, 30.79it/s]
334it [00:10, 30.86it/s]
338it [00:11, 30.95it/s]
342it [00:11, 30.99it/s]
346it [00:11, 30.97it/s]
350it [00:11, 31.09it/s]
354it [00:11, 31.07it/s]
358it [00:11, 30.55it/s]
362it [00:11, 30.76it/s]
366it [00:11, 30.56it/s]
370it [00:12, 30.39it/s]
374it [00:12, 30.61it/s]
378it [00:12, 30.79it/s]
382it [00:12, 30.91it/s]
386it [00:12, 30.62it/s]
390it [00:12, 30.66it/s]
394it [00:12, 30.81it/s]
398it [00:12, 31.00it/s]
402it [00:13, 30.99it/s]
406it [00:13, 31.06it/s]
410it [00:13, 31.05it/s]
414it [00:13, 31.14it/s]
418it [00:13, 31.12it/s]
422it [00:13, 31.19it/s]
426it [00:13, 31.09it/s]
430it [00:13, 31.14it/s]
434it [00:14, 31.13it/s]
438it [00:14, 31.03it/s]
442it [00:14, 31.04it/s]
446it [00:14, 31.07it/s]
450it [00:14, 30.87it/s]
454it [00:14, 30.44it/s]
458it [00:14, 30.63it/s]
462it [00:15, 30.73it/s]
466it [00:15, 30.86it/s]
470it [00:15, 30.77it/s]
474it [00:15, 30.61it/s]
478it [00:15, 30.73it/s]
482it [00:15, 30.27it/s]
486it [00:15, 30.56it/s]
490it [00:15, 30.67it/s]
494it [00:16, 30.80it/s]
498it [00:16, 30.87it/s]
502it [00:16, 30.97it/s]
506it [00:16, 30.97it/s]
510it [00:16, 30.98it/s]
514it [00:16, 31.08it/s]
518it [00:16, 30.50it/s]
522it [00:16, 30.57it/s]
526it [00:17, 30.75it/s]
530it [00:17, 30.87it/s]
534it [00:17, 30.98it/s]
538it [00:17, 30.97it/s]
542it [00:17, 30.53it/s]
546it [00:17, 29.72it/s]
550it [00:17, 30.16it/s]
554it [00:18, 30.43it/s]
558it [00:18, 30.59it/s]
562it [00:18, 30.70it/s]
566it [00:18, 30.84it/s]
570it [00:18, 30.90it/s]
574it [00:18, 30.89it/s]
578it [00:18, 30.91it/s]
582it [00:18, 30.52it/s]
586it [00:19, 29.68it/s]
589it [00:19, 29.48it/s]
593it [00:19, 29.93it/s]
597it [00:19, 30.27it/s]
601it [00:19, 30.47it/s]
605it [00:19, 30.62it/s]
609it [00:19, 30.75it/s]
613it [00:19, 30.79it/s]
617it [00:20, 30.86it/s]
621it [00:20, 30.87it/s]
625it [00:20, 30.95it/s]
629it [00:20, 30.91it/s]
633it [00:20, 31.03it/s]
637it [00:20, 31.00it/s]
641it [00:20, 31.05it/s]
645it [00:20, 31.11it/s]
649it [00:21, 30.69it/s]
653it [00:21, 29.75it/s]
657it [00:21, 30.22it/s]
661it [00:21, 30.53it/s]
665it [00:21, 29.88it/s]
669it [00:21, 30.30it/s]
673it [00:21, 30.64it/s]
677it [00:22, 30.84it/s]
681it [00:22, 30.93it/s]
685it [00:22, 31.01it/s]
689it [00:22, 31.01it/s]
693it [00:22, 31.13it/s]
697it [00:22, 31.12it/s]
701it [00:22, 31.16it/s]
705it [00:22, 31.15it/s]
709it [00:23, 31.14it/s]
713it [00:23, 30.91it/s]
717it [00:23, 29.25it/s]
721it [00:23, 29.88it/s]
725it [00:23, 30.26it/s]
729it [00:23, 30.60it/s]
733it [00:23, 30.78it/s]
737it [00:23, 30.93it/s]
741it [00:24, 30.99it/s]
745it [00:24, 30.97it/s]
749it [00:24, 31.02it/s]
753it [00:24, 31.01it/s]
757it [00:24, 31.12it/s]
761it [00:24, 31.09it/s]
765it [00:24, 31.11it/s]
769it [00:25, 31.12it/s]
773it [00:25, 31.12it/s]
777it [00:25, 31.07it/s]
781it [00:25, 30.54it/s]
785it [00:25, 30.75it/s]
789it [00:25, 30.47it/s]
793it [00:25, 30.32it/s]
797it [00:25, 30.62it/s]
801it [00:26, 30.83it/s]
805it [00:26, 30.87it/s]
809it [00:26, 30.96it/s]
813it [00:26, 30.94it/s]
817it [00:26, 31.06it/s]
821it [00:26, 30.69it/s]
825it [00:26, 30.82it/s]
829it [00:26, 30.89it/s]
833it [00:27, 30.98it/s]
837it [00:27, 30.99it/s]
841it [00:27, 31.05it/s]
845it [00:27, 30.59it/s]
849it [00:27, 30.63it/s]
853it [00:27, 30.69it/s]
857it [00:27, 30.18it/s]
861it [00:28, 30.51it/s]
865it [00:28, 30.59it/s]
869it [00:28, 30.77it/s]
873it [00:28, 30.94it/s]
877it [00:28, 31.00it/s]
881it [00:28, 31.04it/s]
885it [00:28, 31.07it/s]
889it [00:28, 30.44it/s]
893it [00:29, 30.66it/s]
897it [00:29, 30.80it/s]
901it [00:29, 30.86it/s]
905it [00:29, 31.07it/s]
909it [00:29, 30.75it/s]
913it [00:29, 30.61it/s]
917it [00:29, 30.15it/s]
921it [00:29, 30.47it/s]
925it [00:30, 30.24it/s]
929it [00:30, 29.65it/s]
933it [00:30, 30.04it/s]
937it [00:30, 30.40it/s]
941it [00:30, 29.99it/s]
945it [00:30, 30.37it/s]
949it [00:30, 30.56it/s]
953it [00:31, 30.70it/s]
957it [00:31, 30.83it/s]
961it [00:31, 30.88it/s]
965it [00:31, 30.94it/s]
969it [00:31, 31.00it/s]
973it [00:31, 30.89it/s]
977it [00:31, 30.50it/s]
981it [00:31, 30.70it/s]
985it [00:32, 30.83it/s]
989it [00:32, 30.81it/s]
993it [00:32, 30.90it/s]
997it [00:32, 30.92it/s]
999it [00:32, 30.72it/s]
fig, axs = plt.subplots(1, 5, figsize=(10, 5))
axs[0].imshow(xt[0, 0].numpy(), cmap="binary")
axs[1].imshow(xt[1, 0].numpy(), cmap="binary")
axs[2].imshow(xt[2, 0].numpy(), cmap="binary")
axs[3].imshow(xt[3, 0].numpy(), cmap="binary")
axs[4].imshow(xt[4, 0].numpy(), cmap="binary")

#
Diffusion models - Sources

This amazing blogpost helped me understand (most of) the math behind diffusion models and inspired me to make my own blogpost combining both the math and python code.

@article{weng2021diffusion,
  title   = "What are diffusion models?",
  author  = "Weng, Lilian",
  journal = "lilianweng.github.io",
  year    = "2021",
  month   = "Jul",
  url     = "https://lilianweng.github.io/posts/2021-07-11-diffusion-models/"
}

This blogpost explained the reparameterization trick.

@article{weng2018VAE,
  title   = "From Autoencoder to Beta-VAE",
  author  = "Weng, Lilian",
  journal = "lilianweng.github.io",
  year    = "2018",
  url     = "https://lilianweng.github.io/posts/2018-08-12-vae/"
}

This work and the blogpost inspired most of the schematics

@inproceedings{
  song2021scorebased,
  title={Score-Based Generative Modeling through Stochastic Differential Equations},
  author={Yang Song and Jascha Sohl-Dickstein and Diederik P Kingma and Abhishek Kumar and Stefano Ermon and Ben Poole},
  booktitle={International Conference on Learning Representations},
  year={2021},
  url={https://openreview.net/forum?id=PxTIG12RRHS}
}

The pytorch code I wrote for this blog was written from scratch to help me explain the algorithm, but it was inspired by the code from this work

@article{DBLP:journals/corr/abs-2102-09672,
  author       = {Alex Nichol and
                  Prafulla Dhariwal},
  title        = {Improved Denoising Diffusion Probabilistic Models},
  journal      = {CoRR},
  volume       = {abs/2102.09672},
  year         = {2021},
  url          = {https://arxiv.org/abs/2102.09672},
  eprinttype    = {arXiv},
  eprint       = {2102.09672},
  timestamp    = {Wed, 24 Feb 2021 15:42:45 +0100},
  biburl       = {https://dblp.org/rec/journals/corr/abs-2102-09672.bib},
  bibsource    = {dblp computer science bibliography, https://dblp.org}
}

And ofcourse, to the work on diffusion models;

@article{DBLP:journals/corr/abs-2006-11239,
  author       = {Jonathan Ho and
                  Ajay Jain and
                  Pieter Abbeel},
  title        = {Denoising Diffusion Probabilistic Models},
  journal      = {CoRR},
  volume       = {abs/2006.11239},
  year         = {2020},
  url          = {https://arxiv.org/abs/2006.11239},
  eprinttype    = {arXiv},
  eprint       = {2006.11239},
  timestamp    = {Tue, 23 Jun 2020 17:57:22 +0200},
  biburl       = {https://dblp.org/rec/journals/corr/abs-2006-11239.bib},
  bibsource    = {dblp computer science bibliography, https://dblp.org}
}