Diffusion Models are the cornerstone of Image Generation in modern Generative AI. From being used in applications like Video generation in Runway, to high quality open-source generation such as Stable Diffusion and FLUX. With the advent of GPT 4o's new autoregressive image gen, it's time to look at diffusion models and why they aren't going away anytime soon. This is a very surface level introduction to diffusion models, from data setup to training and testing. We will be training the model on the CelebA dataset, an image dataset with over 200k+ images. For part 1, we will just be covering the theory, so if you aren't a math person (you can still explore this), or want to jump straight to the code, you can skip to part 2 here. The code will be available on my GitHub as well.
Groundwork
Let's say our goal is to generate a sample, such that it mimics a given dataset that could be comprised of anything. So how do diffusion models, or more specifically, the diffusion process achieve this?
Here's a high level overview of how it works:
We take a sample from our dataset, calling it the "clean" image
We label this image as being at timestep 0, meaning this is the original image
To go to timestep 1, we take this image, and add some "noise"
You can think of noise as a bunch of randomly generated pixels for now
We keep adding noise to the image until a max timestep, say 1000
Now our image looks like pure randomness
How can we mathematically express this?
Let's say we are given the clean image at timestep 0, calling it x0. Now we can try to get to x1.
x1=x0+βϵ
Here, β is just some small value, so we add a little bit of noise to our original sample. But this doesn't work. What we are doing here is just taking the original image, and adding some noise to it, disregarding the information of the clean image. Also this means we are adding the same amount of noise very early on and later on. We want to add less noise initially and gradually start adding more and more noise. So what we could do is instead of having one β, we have multiple across our timesteps, and this value increases as we go through our timesteps, so we get:
x1=(1−βt)x0+βtϵ.
From here, we will replace βt with 1−αt as needed, so we essentially have a property: βt=1−αt.
But where are we getting ϵ from, which denotes our noise? We commonly get it from what is called a gaussian distribution, which is very common in applications involving random errors and uncertainty. It has the shape of a bell curve, with a mean of 0, and a standard deviation of 1 (so variance is also 1). The distribution has essentially, an infinite number of values, so to get one value, we randomly pick a value, with the probability of our value being in an interval, being equal to the area under that interval, so values closer to 0 are more likely to get chosen, but values further away can still be chosen. So we have: ϵ∼N(0,1)
So now along with the weighted sum we get to go from xt given xt−1, we do:
xt=αtxt−1+1−αtϵ, we will explain the square root later.
Here βt scales positively linear as the timesteps increase, so 1−αt does as well.
To get the noise schedule, we take in some pre-initialized values, like our maximum beta value, minimum beta value and number of timesteps. With this we can craft an equation as such:
βt=βmin+Tt⋅(βmax−βmin)
Here both the timesteps are 0-indexed, so if the max timesteps is 1000, T=999, and t=0,1,2,...,999
The equation above tells us one possibility of xt, that is given xt−1, give me one possible configuration of xt. To get all the possibilities of xt, let's express it as a distribution (will make sense later), more specifically a gaussian distribution. So we have:
xt∼N(μxt,Var(xt))
Now we need to find μxt and Var(xt), in terms of our known variables, which are xt−1 and bt.
To calculate μxt, we essentially have to calculate the expected value of xt, as μxt=E[xt]. We get that:
E[xt]=E[αtxt−1+1−αtϵ]. Then:
E[αtxt−1+1−αtϵ]=E[αtxt−1]+E[1−αtϵ].
Since the first part is deterministic, and the second part, the expected value of ϵ, is the mean of the gaussian distribution, we get that:
E[αtxt−1]+E[1−αtϵ]=αtxt−1. So we get:
μxt=αtxt−1.
Now for the other part, we need the variance, so let's find Var(xt), which we get
Var(xt)=Var(αtxt−1)+Var(1−αtϵ).
Since the first part is deterministic, there is no variance so that part is 0, and the second part, we get:
Var=Var(1−αtϵ).Var=1−αt.
For variance, if we take a constant outside, we square it, because for the standard deviation we would multiply it, because for standard deviation we can multiply it, or simply: Var(aX)=a2Var(X). So finally we get:
xt∼N(αtxt−1,1−αt).
Now you realize why we added that square root. Here what is happening as we are progressively noising our clean sample, the variance of our next distribution scales linearly instead of quadratically, and this leads to better model generalization and training.
So why did we do all this math? Now what we can do is model this process as what is called a markov chain. We want to use this markov chain and prove the following:
xt=αˉtx0+1−αˉtϵ, where we have
αˉt=α1⋅α2⋅...⋅αt=n=1∏tαn
Once we prove this, for sampling we can directly compute with one formula as the cumulative alpha products can be pre-computed, and allow for faster forward diffusion rather than looping through.
Forward Process with Markov Chains
Let's say we have a multiple number of "states", like x0,x1,...,xt−1. Using these we use a conditional probability distribution, which is basically the distribution of a given variable, given some variables that are already there, or the probability distribution of one event, given that certain events have happened. This is written as P(xt∣x0,x1,...,xt−1), telling us what is the distribution of xt, given x0,x1,...,xt−1.
The special property of markov chains is that the next state only depends on the current state, which in our case is xt−1, which means we can discard the other states. This is written as P(xt∣x0,x1,...,xt−1)=P(xt∣xt−1), or that the distribution of xt only depends on xt−1. For diffusion models this is just:
q(xt∣xt−1)=N(xt;αtxt−1,1−αt)
This is also known as the transition distribution, or the distribution of xt given xt−1.
Now for the equation we introduced earlier, let's prove it. We can use induction to get this:
Base case (t=1):
q(x1∣x0)=N(x1;α1x0,1−α1)
Assume for t−1:
q(xt−1∣x0)=N(xt−1;αˉt−1x0,1−αˉt−1)
Then we get:
q(xt∣xt−1)=N(xt;αtxt−1,1−αt)
Using these we use the Chapman-Kolmogorov equation to get:
q(xt∣x0)=∫q(xt∣xt−1)⋅q(xt−1∣x0)dxt−1
Here we are convolving 2 gaussian distributions, and the result will also be a gaussian distribution.
We are able to do the process above, as if you have any distribution sampled: x∼N(μ,σ2), this is the same as:
xμ+σ⋅ϵ, where ϵ∼N(0,1).
Training
The training breakdown for a diffusion model is very surprisingly easy. Here are some pre-requisites to it:
So far, we have a clean sample and use some noise to corrupt the sample further and further on every timestep
Now if we want to retrieve the clean sample, we can do so, but that would require us to know the noise at every timestep
So instead, we build a neural network to predict the noise in the backward process, more on that later
Training process:
Sample an image from the dataset: x0∼X
Sample a timestep: t∼U(0,T)
Sample noise: ϵ∼N(0,1)
Get the corrupt sample: xt=αˉtx0+1−αˉtϵ
Get the model prediction: ϵθ=fθ(xt,t), accepts the noisy sample and timestep
Compute the loss: L=∣∣ϵ−ϵθ∣∣2
Backpropagate this loss into the model, update the model weights with an optimizer
Do steps 1-7 until satisfied, then return fθ
Now that we have the model, let's learn about the backward process
Backward Process
Let's say we want to get the image at xt−1. For this we need the image at xt and x0. Since during sampling we will not be given the clean sample, in our derivation we will remove it. But for now we want to find q(xt−1∣xt,x0), this means find the image at t-1, given the images at t and 0. Using bayes' rule we get:
Now that we have the backward process, we can write out the gaussian as:
xt−1=αt1(xt−1−αˉt(1−αt)ϵ)+1−αˉt(1−αˉt−1)(1−αt)⋅z, where z∼N(0,1)
This is what is known as a stochastic or non-deterministic sampling process. This is because we have randomness in the generation of our sample, with variable z, which means that different generations can lead to different outputs. Now we can sample using this equation, but have to change one thing. Since we do not know the added noise ϵ, we can replace it with the model prediction fθ(xt,t)=ϵθ, and plug that in for our noise.
So our final equation is:
xt−1=αt1(xt−1−αˉt(1−αt)ϵθ)+1−αˉt(1−αˉt−1)(1−αt)⋅z, where z∼N(0,1), and ϵθ=fθ(xt,t)
Here is the sampling process:
Sample xt∼N(0,1)
Iterate steps 3 - 5 from T to 0, where T is the max timestep
Sample z∼N(0,1)
Get the model prediction: ϵθ=fθ(xt,t)
Get the new sample: xt−1=αt1(xt−1−αˉt(1−αt)ϵθ)+1−αˉt(1−αˉt−1)(1−αt)⋅z
Return x0
This sample process is good and works in many cases, but there are some issues:
You have to sample across all timesteps, and in cases where you have many, it is very slow & inefficient
The process is stochastic, so different outputs for the same input, which is not always desired
This makes it bad for image editing and inversion, and more
So we will look at another technique called DDIM, which is a deterministic sampling method, and it allows for faster sampling, allowing it to be more efficient.
DDIM Sampling
Alright, let's go way back to the forward process equation, which is:
xt=αˉtx0+1−αˉtϵ, where ϵ∼N(0,1).
Now we are trying to get xt−1
Let's write: xt−1=αˉt−1x0+1−αˉt−1ϵ
If we remember:
xt=αˉtx0+1−αˉtϵ, so
x0=αˉtxt−1−αˉtϵ
Now plugging this into the equation for xt−1, we get:
xt−1=αˉt−1αˉtxt−1−αˉtϵ+1−αˉt−1ϵ
For both the ϵ terms, replace them with fθ(xt,t)=ϵθ, and we get:
One more interesting aspect, is that during sampling, we do not have to sample at every timestep, and this is because since the process is deterministic, we are traversing the same path, just in fewer and bigger steps. This can sometimes cause a slight loss in quality, but it is negligible in many cases and is outweighed by the major speedup.
Sampling process for DDIM
Let's say we will have only S sampling steps, instead of T, here S is one less than the number of sampling steps. So we can compute the equivalent timestep and sample using that
Sample xt∼N(0,1)
Iterate steps 3 - 6 from 0 to S, with the current iteration being s
Calculate: t=ST⋅(S−s)
Get the model prediction: ϵθ=fθ(xt,t)
Get the clean sample prediction x^0=αˉtxt−1−αˉtϵθ
Get xt−1=αˉt−1x0+1−αˉt−1ϵθ
Return x0
Conclusion
Wow, that was a lot! Don't worry I was very confused writing this as well, but the main thing to keep in mind is the difference between distributions and samples, as a distribution just shows you all the possible samples. I know I said it was a surface-level introduction, but this covers a lot of the mathematical background behind diffusion models, and I hope you learned something new. In part 2, we will be looking at the code and how to implement this in PyTorch with the CelebA dataset, so stay tuned for that!