Back to the map
Deep LearningFrontier20h

Diffusion & generative models

Generating by repeatedly removing noise.

Start with a square of random static, the kind an untuned television shows. A diffusion model has been trained to do one narrow thing: look at a noisy picture and produce a slightly less noisy one. Run that step fifty or a hundred times, each pass removing a little more, and a picture is what is left. Training works the process backwards — take real photographs, add noise in small steps until they are static, and teach the model to undo one step at a time.

Producing a million correct pixels in one shot is a target nothing trains reliably against. Splitting it into a hundred small corrections turns an impossible problem into an easy one repeated. That stability is why diffusion displaced GANs, the previous approach, which produced sharp images when training worked and collapsed unpredictably when it did not. The price is speed: one image is many passes through the network rather than one.

It sits on neural networks, and it is the generative story for images, audio and video in the way transformers are the one for text. The text prompt reaches it through embeddings, so the retrieval half of this map is nearer than it looks. Relevant if you work on media rather than text.

It only ever learns to remove a little noise; the picture is what a hundred of those steps leave behind.

In more detailDiffusion models learn to reverse a gradual noising process, producing images, audio and video. They displaced GANs for most generative work because training is far more stable, at the cost of much slower sampling.

Learn first

Section 1 of 5, 4h

Generating by removing noise

By the end: You can explain diffusion models and why they displaced GANs.

  • The forward and reverse process
  • Why it trains stably
  • Conditioning on text
  • The sampling cost

Image, video and audio generation is nearly all done by diffusion models now, and they work by a route that sounds backwards: learn to take noise away, then start from nothing but noise. This section covers how that training works, why it is so much steadier than the generative adversarial networks it displaced, how a text prompt steers it, and the price it pays, which is that one sample takes many passes through the model instead of one.

The idea#

Take an image and add a little noise. Repeat until it is pure noise. That is the forward process and it requires no learning. Now train a model to reverse one step: given a noisy image, predict the noise that was added. Apply it repeatedly starting from pure noise and you generate an image.

forward   image -> slightly noisy -> ... -> pure noise     (fixed)
reverse   pure noise -> ... -> slightly noisy -> image    (learned)

The forward process is one line of arithmetic#

The word process makes the noising sound like something you have to run. You do not. Adding Gaussian noise a thousand times is itself adding Gaussian noise once, with a known scale, so there is a closed form for the noisy image at any step. Call the surviving fraction of the signal alphabar_t. Then x_t = sqrt(alphabar_t) times the clean image plus sqrt(1 - alphabar_t) times a fresh draw of standard noise. A training example is a random image, a random t and one line.

import torch

T = 1000
betas = torch.linspace(1e-4, 0.02, T)      # the schedule: how much noise each step adds
alphas = 1 - betas
abar = torch.cumprod(alphas, dim=0)        # how much signal survives steps 1..t

for t in (49, 199, 499, 799, 999):
    print(f"t={t+1:4d}  alphabar={abar[t]:.6f}  "
          f"signal x{abar[t].sqrt():.4f}  noise x{(1-abar[t]).sqrt():.4f}")
# t=  50  alphabar=0.971016  signal x0.9854  noise x0.1702
# t= 200  alphabar=0.659039  signal x0.8118  noise x0.5839
# t= 500  alphabar=0.078587  signal x0.2803  noise x0.9599
# t= 800  alphabar=0.001532  signal x0.0391  noise x0.9992
# t=1000  alphabar=0.000040  signal x0.0064  noise x1.0000
Read the last row: by step 1000 the original image is multiplied by 0.0064, which is why sampling can start from pure noise without knowing anything about the image it will become.

It is worth checking that the shortcut is really equal to the long way round, because everything downstream depends on it. Noising 200,000 copies of the value 0.8 one step at a time for 500 steps gives mean 0.2222 and standard deviation 0.9617; the one-line version gives 0.2253 and 0.9622, against a predicted 0.2243 and 0.9599. Same distribution, 500 times cheaper.

What the model predicts, and why it is the noise#

Given x_t you could ask the network for either of two things: the clean image x_0, or the noise that was mixed in. They are algebraically the same request, because the forward equation rearranges to x_0 = (x_t - sqrt(1 - alphabar_t) * eps) / sqrt(alphabar_t). Knowing one gives you the other exactly. What differs is how hard the target is to hit at each noise level, and that turns out to matter.

At t = 1000 the signal multiplier is 0.0064. Asking for x_0 there is asking the network to reconstruct an image from something that retains 0.6 per cent of it: the best any model can do is output the dataset average, and the squared error is enormous. Asking for the noise at the same step is nearly free, because x_t is almost exactly the noise. The noise target is well scaled at every t, so the loss is not dominated by the steps where the task is impossible. Training the same small network on the same data with the two targets, and sampling from each, gives a mean distance from generated points to the real data of 0.0767 for noise prediction and 0.0975 for image prediction — the same model, a quarter worse, for choosing the harder target.

Why it beat GANs#

A GAN trains a generator against a discriminator, and that adversarial setup is famously unstable: mode collapse, oscillation, sensitivity to hyperparameters. Diffusion has a simple regression objective, predict the noise, which trains stably and scales predictably. Stability is why the field moved.

Stability is a claim you can measure rather than assert, and the way to measure it is to vary something you should not have to think about and see whether the result survives. Below, a small GAN and a small diffusion model are trained on the same two-crescent dataset across four learning rates, two Adam beta settings and three seeds each — 48 runs — and scored by the mean distance from a generated point to the nearest real one, where the data itself scores about 0.011.

   lr  beta1     GAN, three seeds          diffusion, three seeds
0.0003    0.5   0.112  0.080  0.058        0.110  0.107  0.114
0.0003    0.9   0.098  0.145  0.124        0.110  0.108  0.110
0.0010    0.5   0.046  0.042  0.045        0.074  0.080  0.083
0.0010    0.9   0.067  0.115  0.100        0.082  0.081  0.084
0.0030    0.5   0.017  0.019  0.027        0.035  0.055  0.058
0.0030    0.9   0.042  0.129  0.070        0.046  0.059  0.055
0.0100    0.5   0.020  0.079  0.080        0.065  0.060  0.068
0.0100    0.9   0.392  4.181  0.059        0.032  0.037  0.046
Lower is better; real data scores 0.011 and pure noise about 0.48. Same architecture size, same data, same number of steps, 10,000 updates each.

Read the last row first. At a learning rate of 0.01 with Adam's first beta at 0.9, one GAN seed scored 4.181 — eight times worse than generating pure noise, which is what divergence looks like — while another seed at the identical setting scored 0.059, among its better results. The diffusion model's three seeds at that same setting were 0.032, 0.037 and 0.046, its best cell in the table. Nothing in the GAN's loss curve tells you in advance which of those two outcomes you are about to get.

The honest reading is not that GANs are bad. In its good configurations the GAN beat diffusion outright, 0.017 against 0.035, and a GAN samples in one forward pass rather than dozens. The problem is that the hyperparameter search is part of the method, and the search is not guided by anything you can see while it runs. Diffusion's worst cell across all 24 runs is within a factor of two of its best, and no run failed. On a two-dimensional toy that is a mild inconvenience. At image scale, where one configuration is days of cluster time and a failed seed is discovered at the end, it is the entire argument, and it is why the field moved.

The trade

Generation needs many reverse steps, so diffusion is far slower to sample than a GAN's single forward pass. Much of the engineering effort since has gone into reducing the number of steps.

Conditioning#

Text-to-image works by conditioning each denoising step on a text embedding, so the model is predicting the noise given both the noisy image and the description. Classifier-free guidance strengthens the influence of the text by contrasting conditioned and unconditioned predictions.

The mechanism is one line and no extra model. During training the condition is dropped at random, typically 10 per cent of the time, and replaced by a null token, so the same network learns both the conditional and the unconditional prediction. At sampling time you evaluate both and extrapolate: eps = eps_uncond + w * (eps_cond - eps_uncond). At w = 1 that is exactly the conditional prediction. Above 1 you are pushing further along the direction the prompt moved the answer than the model itself would have gone, which is why guidance is described as a strength rather than a switch, and why it costs two forward passes per step.

Latent diffusion, and why it was the unlock#

Latent diffusion, which made this practical at consumer scale, runs the process in a compressed latent space rather than on pixels, so each step is far cheaper. The size of the saving is worth doing on paper once, because it explains why text-to-image went from a research cluster to a laptop in about a year.

H = W = 512
pixel = H * W * 3
latent = (H // 8) * (W // 8) * 4     # a downsample of 8 into 4 channels, the usual VAE

print(f"pixel space : {H}x{W}x3 = {pixel:,} values")
print(f"latent space: {H//8}x{W//8}x4 = {latent:,} values")
print(f"{pixel/latent:.0f}x fewer values per denoising step")
print(f"attention pairs: {(H*W)**2:,} in pixels vs {((H//8)*(W//8))**2:,} in latents")
# pixel space : 512x512x3 = 786,432 values
# latent space: 64x64x4 = 16,384 values
# 48x fewer values per denoising step
# attention pairs: 68,719,476,736 in pixels vs 16,777,216 in latents
48x on the elementwise work, and 4,096x on any self-attention over spatial positions, because attention is quadratic in the number of positions. An autoencoder is trained once to move between the two spaces, and the diffusion model never sees a pixel.

The cost is that the autoencoder is now part of the model's ceiling. Anything it cannot reconstruct, the diffusion model cannot generate, which is why small text and fine regular patterns were bad in early latent models and why later versions widened the latent from four channels to sixteen. If you are debugging poor fine detail, encode and decode a real image without any diffusion at all and look at what comes back; if the detail is already gone there, no amount of sampling will bring it back.

What to skip#

The variational derivation of the training objective — the evidence lower bound, the term-by-term reduction to a weighted squared error — is a page of algebra that ends at the loss stated above, and nobody will ask you to reproduce it. Read it once if you enjoy that, and skip the score-matching and stochastic-differential-equation formulations entirely until you need them; they are a different set of words for the same model. What you are expected to hold is the closed-form forward process, the noise-prediction loss, one sampling step, and the guidance formula. Those four fit on an index card and carry every question in this area.

Where people go wrong

Assuming diffusion is only for images. The same framework applies to audio, video and molecules, and the interesting recent work is often outside images.

Build this: Implement the forward noising process on a small image dataset and visualise the steps. Then train a small model to predict the noise at one step.

Open this section on its own page
Section 2 of 5, 4h

Using diffusion models in practice

By the end: You can control a diffusion model and reason about its cost.

  • Samplers and step count
  • Guidance strength
  • Conditioning beyond text
  • Fine-tuning approaches

Generating an image is a loop that runs a fixed number of times, and nearly every practical decision is about that loop. This section covers the controls you actually have: how many steps to run and what running fewer costs you, how hard to push the output towards the prompt before it goes stiff, and the ways of conditioning on something other than text when a prompt cannot describe what you want. It ends on adapting a model to a style, and on the fact that most of the engineering in a real system sits around the model rather than in it.

Steps and samplers#

The reverse process was originally many small steps. Better samplers reach comparable quality in far fewer, which is where most of the practical speedup since has come from. Step count is the main quality-versus-latency dial.

~50 steps    high quality, slow
~20 steps    close, much faster, the usual default
~4 steps     distilled models, real quality cost

Latency scales roughly linearly with steps, so this
is the first thing to tune when generation is too slow.

The sampler is the setting behind that dial, and it is chosen at generation time from the same trained weights. A DDPM-style loop that simply runs every twentieth step falls apart, because its update assumes a single small move; a DDIM-style sampler, which converts the noise prediction into an estimate of the finished image and re-noises it to whatever level comes next, takes the same jump without a quality loss you can measure. In the 2-D experiment in the diffusion maths topic, running every 50th DDPM step scored 0.4323 on a distance-to-data metric where pure noise scores 0.4810, while DDIM at the same 20 steps scored 0.0744 against 0.0717 for the full 1,000-step loop. That is the whole story, and it is the reason to change the sampler before the step count.

The names you will meet are DDIM, DPM-Solver and its multistep and second-order variants, Euler and Euler ancestral, and UniPC. They differ in how cleverly they extrapolate between the levels they visit, and the practical ordering is dull: solver-based samplers such as DPM-Solver++ and UniPC are good at 15 to 25 steps, Euler ancestral is stochastic so it will not reproduce across runs, and DDIM is the deterministic baseline every implementation has. Ancestral samplers add fresh noise at each step, which means a fixed seed does not give you a fixed image if you change the step count — a reproducibility trap worth knowing before you build an evaluation grid.

Guidance#

Classifier-free guidance controls how strongly the output follows the prompt, by contrasting the conditioned and unconditioned predictions and pushing away from the latter. Low values drift from the prompt; high values produce oversaturated, rigid images. There is a usable middle and it is worth finding empirically.

What the scale actually trades is measurable, and on a toy problem where every quantity can be computed exactly it comes out like this: adherence to the condition saturates almost immediately, diversity falls steadily, and fidelity holds flat and then collapses. In that experiment every sample was already on the requested class by a scale of 2, while the spread between samples fell eightfold between scale 1 and scale 5, and distance-to-data was flat from 2 to 10 before jumping twentyfold by scale 20. Map that onto what you see in images and it explains the familiar pattern exactly: raising guidance past the point where the prompt is being followed does not buy more adherence, it buys less variety and eventually the saturated, plastic look, which is samples leaving the region real images occupy.

Two consequences for practice. Distilled few-step models are usually trained with the guidance already baked in, so they want a guidance scale of 0 or 1 and will produce garbage at the 7 you use elsewhere — read the model card for the intended value rather than carrying yours over. And because guidance costs a second forward pass per step, turning it off is not a small saving: it halves the work.

What generation actually costs#

Latency and price are the same calculation, and the only measurement you need is one forward pass of the network at your resolution. Everything else is multiplication, and doing it before you choose settings is what stops a pilot becoming an unaffordable product.

unet_ms = 55       # one forward pass at your resolution, measured on your own hardware

for steps, guided in ((50, True), (20, True), (8, True), (4, False)):
    passes = steps * (2 if guided else 1)
    latency = passes * unet_ms / 1000
    print(f"{steps:3d} steps, guidance {'on ' if guided else 'off'}: "
          f"{passes:3d} passes  {latency:5.2f} s/image  {3600/latency:7.0f} images/GPU-hour")
#  50 steps, guidance on : 100 passes   5.50 s/image      655 images/GPU-hour
#  20 steps, guidance on :  40 passes   2.20 s/image     1636 images/GPU-hour
#   8 steps, guidance on :  16 passes   0.88 s/image     4091 images/GPU-hour
#   4 steps, guidance off:   4 passes   0.22 s/image    16364 images/GPU-hour
55 ms is a placeholder for your own measurement, not a benchmark. The shape is what matters: guidance doubles the passes, and the 50-step setting is 25 times the work of the distilled one.
gpu_hour = 2.50    # dollars, whatever you are actually paying

for steps, guided in ((50, True), (20, True), (4, False)):
    passes = steps * (2 if guided else 1)
    per_image = passes * unet_ms / 1000 / 3600 * gpu_hour
    print(f"{steps:3d} steps: {per_image*100:5.2f} cents/image, "
          f"{per_image*100_000:7.0f} dollars per 100k images")
#  50 steps:  0.38 cents/image,     382 dollars per 100k images
#  20 steps:  0.15 cents/image,     153 dollars per 100k images
#   4 steps:  0.02 cents/image,      15 dollars per 100k images
Same arithmetic in money. The spread between the careful setting and the fast one is 25x, which is the difference between a feature you can give away and one you have to charge for.

Conditioning on more than text#

A prompt is a poor way to say where things go. When a user wants this pose, this layout, or this face, the answer is almost never a longer prompt; it is conditioning the process on something other than text. These are the three that cover most real requirements.

  • Image-to-image: start from a noisy version of an existing image rather than pure noise, so the output keeps its structure.
  • Inpainting: fix part of the image and generate only the masked region.
  • Structural conditioning: guide generation with an edge map, depth map or pose, which gives far more control than a prompt alone.

Image-to-image has one number that decides everything and is usually misunderstood: the strength, which sets how far up the noise schedule you start. At strength 0.3 you noise the input to about a third of the way and run only the remaining steps, so the composition survives and the style changes; at 0.9 almost nothing of the original is left and the prompt is effectively generating from scratch. It also means image-to-image is cheaper than text-to-image in proportion to the strength: strength 0.3 with a 20-step setting runs 6 steps, not 20. Structural conditioning, which is what ControlNet-style adapters do, is the opposite trade: it costs an extra network evaluation per step but gives you geometry a prompt cannot express.

Adapting a model#

Full fine-tuning is expensive. LoRA adapters work here as they do for language models and are the common approach for adding a style or a subject. Textual inversion learns a new token embedding for a concept without touching the model, which is cheaper still and less flexible.

method              trains              typical data   artefact size   composes
prompt engineering  nothing             0 images       0               n/a
textual inversion   one token embedding 3-5 images     a few KB        yes
LoRA                low-rank adapters   10-50 images   10-200 MB       yes, several
full fine-tune      every weight        1000+ images   the whole model no
Work down the list and stop where the result is good enough. Most requests that sound like fine-tuning are a LoRA, and a good number of those are a better prompt plus structural conditioning.

Two things decide whether an adapter works, and neither is the training code. The first is the captions: a subject LoRA trained on twenty photographs captioned identically will learn the background and the lighting along with the subject, so caption what varies and leave out what should be baked in. The second is knowing what you are adapting — a LoRA trained against one base model does not transfer to another, and the commonest support question in this area is somebody loading an adapter built for a different base and reporting that the model is broken.

The engineering is in the pipeline

Most production value comes from what surrounds the model: prompt construction, structural conditioning, safety filtering, upscaling and caching. Treating the model as one component in a pipeline is the difference between a demo and a product.

What to skip#

You do not need to know the derivation behind each sampler to choose one, and you will not be asked. Skip the taxonomy of solver orders, skip memorising which scheduler pairs with which model, and skip the prompt-keyword folklore entirely — the lists of quality words that circulate were fitted to one base model and mostly do nothing on another. What is worth holding is the cost arithmetic above, the fact that guidance is two passes, the strength parameter for image-to-image, and the ordering of adaptation methods.

Where people go wrong

Raising guidance to make output follow the prompt more closely. Past a moderate value it produces saturated, stiff images, and the real fix is usually a better prompt or structural conditioning.

Build this: Generate the same prompt at 4, 20 and 50 steps and across a range of guidance values. Build the grid and find the settings you would actually ship.

Open this section on its own page
Section 3 of 5, 4h

The maths of denoising diffusion, just enough

By the end: You can write down the forward process, the training objective and one sampling step, and explain each symbol.

  • The noise schedule
  • Jumping straight to any noise level
  • Why predicting the noise is enough
  • One step of sampling
  • Guidance as arithmetic

Diffusion models are trained by destroying data with noise and learning to undo it. This section is the four equations that say exactly that, with every symbol explained: the schedule of noise, the closed form that lets you jump to any noise level in one line, the training loss, which is plain squared error on the noise that was added, and a single step of sampling. The closed form is the piece everything else rests on, because it is why training is cheap: you never simulate a thousand noising steps to make one training example.

A schedule of small noise steps#

beta_t        small amount of noise added at step t, e.g. from 0.0001 up to 0.02 over T = 1000 steps
alpha_t       1 - beta_t, how much signal survives step t
alphabar_t    alpha_1 x alpha_2 x ... x alpha_t, how much signal survives all steps up to t

One step of the forward process is x_t = sqrt(alpha_t) * x_(t-1) + sqrt(beta_t) * z. The square roots are there so that variance is preserved: if x_(t-1) has unit variance, alpha_t of it survives, beta_t of fresh noise is added, and alpha_t + beta_t = 1, so x_t has unit variance too. That is the whole reason the schedule is written in terms of beta rather than a plain noise standard deviation, and it is why data is normalised to roughly zero mean and unit variance before any of this starts.

Skip to any step directly#

x_t = sqrt(alphabar_t) * x_0  +  sqrt(1 - alphabar_t) * noise        noise ~ Normal(0, 1)

Because adding Gaussian noise repeatedly is still Gaussian, you do not need to run t noising steps to get x_t. One line produces a noisy version of a clean image at any level. This is what makes training cheap: pick a random image, a random t, and noise it in one go.

Do not take that on trust. Running the chain 500 times on 200,000 copies of a single value and comparing with the one-line version is four lines of code, and it is the check that catches a wrong schedule before it costs you a training run.

import torch
torch.manual_seed(0)

T = 1000
betas = torch.linspace(1e-4, 0.02, T)
alphas = 1 - betas
abar = torch.cumprod(alphas, dim=0)

N, t_check, x0 = 200_000, 500, 0.8
x = torch.full((N,), x0)
for t in range(t_check):                                   # the long way
    x = alphas[t].sqrt() * x + betas[t].sqrt() * torch.randn(N)
print(f"chain, {t_check} steps : mean {x.mean():.4f}  std {x.std():.4f}")

a = abar[t_check - 1]                                      # the one-line way
y = a.sqrt() * x0 + (1 - a).sqrt() * torch.randn(N)
print(f"closed form, 1 line  : mean {y.mean():.4f}  std {y.std():.4f}")
print(f"what it should be    : mean {a.sqrt()*x0:.4f}  std {(1-a).sqrt():.4f}")
# chain, 500 steps : mean 0.2222  std 0.9617
# closed form, 1 line  : mean 0.2253  std 0.9622
# what it should be    : mean 0.2243  std 0.9599
Same distribution, 500 times cheaper. If your two numbers disagree, the schedule or the cumulative product is wrong, and every downstream symptom will look like the model needing more training.

The numbers the schedule actually produces are worth having in your head, because they are what makes the reverse process possible at all. With the linear schedule above, alphabar is 0.971 at t = 50, 0.659 at t = 200, 0.0786 at t = 500 and 0.00004 at t = 1000. So at the last step the original image is multiplied by 0.0064 — it is gone, to any precision that matters, which is exactly what lets sampling begin from a fresh draw of noise that has never seen a dataset.

The training objective#

loss = mean( (noise - model(x_t, t))^2 )

The model sees the noisy image and the step number and predicts the noise that was mixed in. It is plain regression with a squared error. If you know the noise, you know the clean image, since the equation above can be solved for x_0.

x_0 = ( x_t  -  sqrt(1 - alphabar_t) * eps )  /  sqrt(alphabar_t)
The rearrangement. Predicting the noise and predicting the clean image are the same request; they differ only in how the error is scaled at each t, and that is what makes noise the better target.

That scaling is the whole argument for the noise target. At t = 1000, sqrt(alphabar_t) is 0.0064, so recovering x_0 means dividing by 0.0064 — amplifying the model's error by a factor of 156. A network asked for x_0 directly at that step can do no better than output the dataset mean, and the squared error there swamps the loss from every step where the task was learnable. Predicting the noise keeps the target at unit scale for every t, so no part of the schedule dominates. Trained identically on the same data, noise prediction beat image prediction by about a quarter on sample quality in the 2-D experiment in the next section.

The full derivation from the variational bound puts a t-dependent weight in front of this squared error. The result that made diffusion practical is that dropping the weight entirely and using the plain mean squared error above works better in practice. So the loss you implement is simpler than the loss the theory hands you, and knowing that saves you looking for the weighting term in someone's code and concluding you have misread it.

One sampling step#

x_(t-1) = ( x_t - beta_t / sqrt(1 - alphabar_t) * model(x_t, t) ) / sqrt(alpha_t)  +  sqrt(beta_t) * z

z is fresh noise (none on the final step)

Subtract the predicted noise, scaled for this step, rescale, and add a little new noise. Repeat from pure noise down to step one. Faster samplers take bigger, smarter steps using the same trained model.

Two details in that line trip people up. The first is that the coefficient on the predicted noise is beta_t / sqrt(1 - alphabar_t) and not sqrt(1 - alphabar_t): you are removing only the noise this step added, not all the noise accumulated so far, so the subtraction is small. At t = 500 that coefficient is 0.0105, against a total accumulated noise scale of 0.96. Each step nudges. The second is the fresh noise z added back afterwards. Removing it makes the sampler deterministic and, with the DDPM formula, makes the samples worse, because the update is an approximation of a distribution rather than a point and the variance is part of what is being modelled. Deterministic sampling is a real option, but it needs the DDIM update rather than this one.

That DDIM update is worth writing down alongside, because it is what every fast sampler is a variant of. Predict the noise, convert it to an estimate of the clean image, then re-noise that estimate to the next noise level you want — which need not be t - 1. It is the freedom to choose the next level that lets a sampler run 20 steps instead of 1000.

x0_hat  = ( x_t - sqrt(1 - alphabar_t) * model(x_t, t) ) / sqrt(alphabar_t)
x_prev  = sqrt(alphabar_prev) * x0_hat  +  sqrt(1 - alphabar_prev) * model(x_t, t)

alphabar_prev is the noise level of the next step you have chosen, not necessarily t - 1
The deterministic DDIM step. Same trained network, no retraining, and the step count becomes a free parameter at sampling time.

Classifier-free guidance#

guided = model(x_t, t, empty_prompt) + w * ( model(x_t, t, prompt) - model(x_t, t, empty_prompt) )

Train one model that sometimes sees the prompt and sometimes an empty one. At sampling time, take the direction the prompt pushes the prediction, and exaggerate it by a factor w. It costs two model evaluations per step.

Read the endpoints of that formula. At w = 0 the prompt term cancels and you get the unconditional model: a sample from the data distribution, ignoring what you asked for. At w = 1 the two unconditional terms cancel and you get the plain conditional model, which is what an honest sample from p(image given prompt) looks like. Every value above 1 is extrapolation past the model's own conditional prediction, which is why the usual range of 5 to 8 is not a mild setting: the samples produced there are not draws from the conditional distribution at all. They are draws from a sharpened version of it, which is exactly why they look better and exactly why diversity falls.

The name is historical and actively misleading. The original method, classifier guidance, ran a separately trained classifier on the noisy image and pushed the sample along the gradient of the class score. Classifier-free guidance gets the same effect from one model trained with the condition dropped at random, typically 10 per cent of the time, and needs no classifier. Nothing in the formula above involves a classifier; the word only records what it replaced.

Where the field went next#

Several recent image models use flow matching, a closely related formulation that learns a velocity carrying noise to data along straighter paths, which allows fewer sampling steps. The intuitions here carry over directly.

Concretely, flow matching interpolates linearly between noise and data, x_t = (1 - t) * x_0 + t * noise with t running from 0 to 1, and trains the network to predict the velocity noise - x_0 rather than the noise alone. Sampling is then integrating an ordinary differential equation, and because the paths are close to straight, a crude integrator with few steps gets close to the answer. The forward process, the regression objective and guidance all keep their shape; what changes is the interpolation and what the network is asked to output. If you have understood the four equations above you can read a flow-matching paper the same afternoon.

Where people go wrong

Memorising the sampling equation without the closed form of the forward process. The closed form is the idea everything else rests on.

Build this: Implement the forward process on one image and plot it at ten values of t. Then check numerically that the one-line jump and running the steps one by one give samples with the same mean and variance.

Open this section on its own page
Section 4 of 5, 5h

Train a tiny diffusion model

By the end: You have trained a diffusion model from scratch on a toy dataset and watched noise turn into data.

  • A 2D dataset you can see
  • The denoiser network
  • The training loop
  • Sampling
  • What to try next

You have the equations; this is running them. The dataset is a few thousand points arranged in two crescents on a plane, which trains in about a minute on a laptop and lets you watch structure appear out of noise in a scatter plot. Everything here transfers to images unchanged except the network. Starting in two dimensions is deliberate: on images a diffusion bug looks exactly like needing more training for several hours, while on points it is visible immediately.

Start in two dimensions#

Images take hours to train. Points on a plane take a minute on a laptop CPU and you can see every step. Everything here transfers to images; only the network changes.

import torch, torch.nn as nn
from sklearn.datasets import make_moons
torch.manual_seed(0)

X = torch.tensor(make_moons(10_000, noise=0.05, random_state=0)[0], dtype=torch.float32)

T = 1000
betas = torch.linspace(1e-4, 0.02, T)
alphas = 1 - betas
abar = torch.cumprod(alphas, dim=0)

class Denoiser(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(3, 128), nn.SiLU(),
            nn.Linear(128, 128), nn.SiLU(),
            nn.Linear(128, 2),
        )

    def forward(self, x, t):                        # x: [n, 2], t: [n]
        return self.net(torch.cat([x, (t / T).unsqueeze(-1)], dim=-1))

Train#

model = Denoiser()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)

for step in range(10_000):
    x0 = X[torch.randint(len(X), (256,))]
    t = torch.randint(T, (256,))
    noise = torch.randn_like(x0)
    a = abar[t].unsqueeze(-1)
    xt = a.sqrt() * x0 + (1 - a).sqrt() * noise
    loss = ((model(xt, t.float()) - noise) ** 2).mean()
    opt.zero_grad(); loss.backward(); opt.step()
    if step % 2000 == 0 or step == 9999:
        print(f"step {step:5d}  loss {loss.item():.4f}")

# step     0  loss 1.0144
# step  2000  loss 0.1892
# step  4000  loss 0.2067
# step  6000  loss 0.1869
# step  8000  loss 0.2063
# step  9999  loss 0.1632     all 10,000 steps in a few seconds on a laptop CPU
The loss is noisy because each batch draws random t, and the task is far harder at some t than others. Do not read a flat loss curve as a stalled run; read the samples.

The loss will not go to zero and should not. At large t the model is being asked to predict a noise vector from something that is almost entirely that noise vector, which is easy; at small t it is being asked to identify which specific noise was added to a nearly clean point, which is close to impossible, and the irreducible error there sets the floor. A converged run on this dataset sits around 0.16 to 0.20. The number itself tells you very little — that is why the next part measures the samples instead.

Sample#

@torch.no_grad()
def sample(n=2000):
    x = torch.randn(n, 2)
    for t in reversed(range(T)):
        eps = model(x, torch.full((n,), float(t)))
        x = (x - betas[t] / (1 - abar[t]).sqrt() * eps) / alphas[t].sqrt()
        if t > 0:
            x = x + betas[t].sqrt() * torch.randn_like(x)
    return x

points = sample()      # scatter plot these: two interleaved moons should appear

Score it, do not squint at it#

A scatter plot will tell you whether the crescents are there. It will not tell you whether a change helped, and by the third experiment you will be comparing plots from memory. One number fixes that: for each generated point, the distance to the nearest real point, averaged. It is cheap, it has an obvious floor, and it has an obvious ceiling, so a result is interpretable without any further apparatus.

real = X[torch.randperm(len(X))[:2000]]
fit = lambda p: torch.cdist(p, real).min(dim=1).values.mean().item()

print(f"generated  {fit(sample()):.4f}")
print(f"real data  {fit(X[torch.randperm(len(X))[:2000]]):.4f}")   # the floor
print(f"noise      {fit(torch.randn(2000, 2)):.4f}")               # the ceiling
# generated  0.0758
# real data  0.0107
# noise      0.4810
0.0758 against a floor of 0.0107 and a ceiling of 0.4810. The model has clearly learned the shape and is still visibly looser than the data, which is the honest state of a small network trained for seven seconds.

This metric only rewards being close to the data, so a model that produced two thousand copies of the same real point would score a perfect 0. Pair it with a spread measure — the mean distance from each generated point to its nearest other generated point — before you trust it. That pairing is the whole of generative evaluation in miniature: one number for fidelity, one for coverage, and neither is meaningful alone. Reported means and standard deviations agree too: generated points came out at mean (0.436, 0.286) with standard deviations (0.902, 0.496), against (0.500, 0.250) and (0.867, 0.498) for the data.

Fewer steps, and why the sampler is not a detail#

The obvious way to make sampling ten times faster is to run every tenth step of the same loop. Try it and the result is not ten times faster at slightly worse quality; it is ten times faster at no quality. The DDPM update assumes you are moving one step, and its coefficients are wrong for a jump. Swapping in the DDIM update, which converts the noise prediction into an estimate of the clean point and re-noises it to whatever level you ask for next, fixes it with no retraining at all.

@torch.no_grad()
def ddim(n, steps):
    ts = torch.linspace(T - 1, 0, steps).long().tolist()
    x = torch.randn(n, 2)
    for i, t in enumerate(ts):
        eps = model(x, torch.full((n,), float(t)))
        a = abar[t]
        x0 = (x - (1 - a).sqrt() * eps) / a.sqrt()               # estimate the clean point
        a_prev = abar[ts[i + 1]] if i + 1 < len(ts) else torch.tensor(1.0)
        x = a_prev.sqrt() * x0 + (1 - a_prev).sqrt() * eps       # re-noise to the next level
    return x
steps    DDPM loop, every kth step    DDIM
 1000        0.0717  (0.87 s)          0.0766  (0.67 s)
  100        0.3098  (0.07 s)          0.0787  (0.07 s)
   50        0.3933  (0.04 s)          0.0765  (0.11 s)
   20        0.4323  (0.02 s)          0.0744  (0.02 s)
   10        0.4608  (0.01 s)          0.0819  (0.01 s)

pure noise scores 0.4810, real data scores 0.0107
Measured on the model trained above, 2,000 samples per cell. Skipping steps in the DDPM loop at 20 steps lands at 0.4323 against 0.4810 for pure noise — close to not having a model at all. DDIM at 10 steps matches DDPM at 1,000, a hundredfold saving in forward passes from changing four lines of sampling code.

That table is the reason sampler names appear in every image-generation interface and why people argue about them. It is also the reason a diffusion result is not reproducible without saying which sampler produced it. If you take one habit from this section, take this: when generation is too slow, change the sampler before you change anything else, and measure rather than assume.

Conditioning and guidance, measured#

make_moons returns a label saying which crescent each point came from, so the same dataset supports conditional generation. Add an embedding for the label, reserve one extra index for no label, and drop the real label 10 per cent of the time during training. That single change is all classifier-free guidance requires: one network that has learned both the conditional and the unconditional prediction.

Xn, Yn = make_moons(10_000, noise=0.05, random_state=0)   # this time keep the labels
X = torch.tensor(Xn, dtype=torch.float32)
Y = torch.tensor(Yn, dtype=torch.long)
real = X[torch.randperm(len(X))[:2000]]

class Cond(nn.Module):
    def __init__(self):
        super().__init__()
        self.emb = nn.Embedding(3, 16)          # labels 0 and 1, plus 2 meaning no label
        self.net = nn.Sequential(
            nn.Linear(3 + 16, 128), nn.SiLU(),
            nn.Linear(128, 128), nn.SiLU(),
            nn.Linear(128, 2),
        )

    def forward(self, x, t, y):
        return self.net(torch.cat([x, (t / T).unsqueeze(-1), self.emb(y)], dim=-1))

cond = Cond()
opt = torch.optim.Adam(cond.parameters(), lr=1e-3)
for step in range(20_000):
    i = torch.randint(len(X), (256,))
    x0, y = X[i], Y[i].clone()
    y[torch.rand(256) < 0.1] = 2                # drop the label 10% of the time
    t = torch.randint(T, (256,))
    noise = torch.randn_like(x0)
    a = abar[t].unsqueeze(-1)
    xt = a.sqrt() * x0 + (1 - a).sqrt() * noise
    loss = ((cond(xt, t.float(), y) - noise) ** 2).mean()
    opt.zero_grad(); loss.backward(); opt.step()
@torch.no_grad()
def guided(n, cls, w, steps=100):
    ts = torch.linspace(T - 1, 0, steps).long().tolist()
    x = torch.randn(n, 2)
    for i, t in enumerate(ts):
        tt = torch.full((n,), float(t))
        eps_uncond = cond(x, tt, torch.full((n,), 2))
        eps_cond   = cond(x, tt, torch.full((n,), cls))
        eps = eps_uncond + w * (eps_cond - eps_uncond)          # the whole of guidance
        a = abar[t]
        x0 = (x - (1 - a).sqrt() * eps) / a.sqrt()
        a_prev = abar[ts[i + 1]] if i + 1 < len(ts) else torch.tensor(1.0)
        x = a_prev.sqrt() * x0 + (1 - a_prev).sqrt() * eps
    return x
Two forward passes instead of one, and one extra line. Everything else is the DDIM step unchanged.

Now the guidance scale stops being folklore. Train that model for 20,000 steps, sample 2,000 points asking for crescent 0 at each of several values of w, and score three things: what fraction of the samples a nearest-neighbour classifier assigns to the crescent you asked for, the fit-to-data number from above, and the spread.

   w   on-class   fit-to-data   spread
 0.0      57.4%        0.0753   0.0230     unconditional: it ignores what you asked for
 1.0      99.5%        0.0189   0.0119     the honest conditional model
 2.0     100.0%        0.0115   0.0046
 3.0     100.0%        0.0107   0.0026
 5.0     100.0%        0.0109   0.0014     8x less varied than at w = 1
10.0     100.0%        0.0130   0.0014
20.0     100.0%        0.2340   0.0017     off the data entirely
40.0     100.0%        0.7531   0.0066     worse than pure noise, which scores 0.4810
Measured, 2,000 samples per row, asking for crescent 0. spread is the mean distance from each sample to its nearest other sample, so smaller means the outputs are more alike. The w = 0 row covers both crescents, so read the diversity story from w = 1 downwards.

Three things fall out of that table that are usually stated as opinions. First, guidance buys adherence to the condition almost immediately: by w = 2 every sample is already on the requested crescent, so the adherence argument for a higher scale is finished there. Second, what continues past that point is the collapse in diversity, a factor of eight between w = 1 and w = 5, which on images is why heavily guided prompts return near-identical compositions across seeds. Third, fidelity holds for a while and then goes suddenly: fit is flat from w = 2 to w = 10 and then jumps to 0.2340 at w = 20 and 0.7531 at w = 40, worse than pure noise, because extrapolating that far past the conditional prediction pushes samples off the data entirely. On images that same failure is the oversaturated, high-contrast, plastic look, and now you know it is not a stylistic quirk of the model but points leaving the region the data occupies.

Things to try#

  • Plot intermediate x at several t to watch structure appear from noise.
  • Sample with only 100 of the 1,000 steps by skipping, then with DDIM at 100 steps. The gap is the table above, reproduced on your own run.
  • Train a second model with the identical loop but the target set to x0 instead of noise, and compare fit-to-data. Noise prediction came out at 0.0767 against 0.0975 for image prediction.
  • Break the schedule deliberately: set betas to linspace(1e-4, 0.002) so alphabar at T is 0.37 rather than 0.00004, retrain, and watch sampling fail because the starting noise is nothing like the end of the forward process.
  • Move to small images such as MNIST with a small convolutional network.

Where people go wrong

Starting with images. Bugs in a diffusion implementation look like 'needs more training' for a long time. Get it right on 2D points first.

Build this: Train the 2D model, plot samples at five points in the reverse process, then add class conditioning with classifier-free guidance on two datasets mixed together and show that guidance weight controls which one you get.

Open this section on its own page
Section 5 of 5, 3h

Working with pretrained image models

By the end: You can run, tune and adapt a pretrained text-to-image pipeline, and know what to check before using one in a product.

  • Running a pipeline
  • Swapping the sampler
  • Steps and guidance, measured
  • Adapters for a style or subject
  • Licences and safety

Most image generation work is not training a model but running someone else's well. This section covers a diffusers pipeline from a few lines of code through the settings that actually change the output: the sampler, which can be swapped without retraining, and the step count and guidance scale, which trade quality against latency. The method that matters is measuring rather than eyeballing, with a fixed grid of prompts, seeds and settings, plus the two checks before anything ships, which are the licence and what the safety filter does and does not catch.

A pipeline in a few lines#

import torch
from diffusers import AutoPipelineForText2Image

pipe = AutoPipelineForText2Image.from_pretrained(
    "stabilityai/sdxl-turbo", torch_dtype=torch.float16, variant="fp16"
).to("cuda")

image = pipe("a lighthouse on a cliff at dusk, watercolour",
             num_inference_steps=1, guidance_scale=0.0).images[0]
image.save("lighthouse.png")
A distilled model built for very few steps. Standard models use 20 to 50 steps and a guidance scale above 1. Not run here — diffusers and a GPU are needed.

The sampler is swappable#

from diffusers import DPMSolverMultistepScheduler

pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
Not run here; it needs the pipeline above.

The trained model predicts noise; the scheduler decides how to step. Changing it changes speed and look without retraining.

That separation is the single most useful thing to understand about the library. A pipeline is a bag of independently loaded components — a text encoder and its tokenizer, the denoising network, the variational autoencoder that moves between pixels and latents, and a scheduler — and the scheduler is the only one that holds no weights. Swapping it is free and reversible. Swapping anything else means loading different weights, and mixing components trained against different bases is the cause of most inexplicably bad output people report.

The three arguments that are not interchangeable

num_inference_steps, guidance_scale and the generator seed interact. Ancestral schedulers draw fresh noise at every step, so the same seed at 20 steps and at 30 steps gives unrelated images; deterministic ones such as DDIM and DPM-Solver do not. If you are building an evaluation grid, use a deterministic scheduler or the grid compares noise rather than settings.

Measure, do not eyeball#

Pick twenty prompts that represent your use case and render a grid across step counts and guidance values with fixed seeds. Choose from the grid, and record latency for each cell. Casual testing on one prompt picks settings that fail on others.

The reason to be formal about this is that the choice is a three-way trade — quality, latency and cost — and eyeballing optimises the one you happened to be looking at. Build the grid as data, not as a contact sheet, and score it with something you wrote down before you looked.

import itertools

prompts = [f"prompt {i}" for i in range(20)]      # your own, representative of real use
seeds = [0, 1, 2]
steps = [8, 20, 30, 50]
scales = [3.0, 5.0, 7.5]

cells = list(itertools.product(steps, scales))
renders = len(cells) * len(prompts) * len(seeds)
unet_ms = 55                                       # measure one forward pass yourself
seconds = sum(s * 2 * unet_ms / 1000 for s, _ in cells) * len(prompts) * len(seeds)

print(f"{len(cells)} settings x {len(prompts)} prompts x {len(seeds)} seeds = {renders} images")
print(f"about {seconds/60:.0f} minutes of GPU time for the whole grid")
for s, g in cells:
    print(f"  steps={s:2d} scale={g:3.1f}  {s*2*unet_ms/1000:.2f} s/image")
# 12 settings x 20 prompts x 3 seeds = 720 images
# about 36 minutes of GPU time for the whole grid
#   steps= 8 scale=3.0  0.88 s/image
#   steps=20 scale=3.0  2.20 s/image
#   steps=30 scale=3.0  3.30 s/image
#   steps=50 scale=3.0  5.50 s/image
Run the arithmetic before the grid. Thirty-six minutes is affordable; the same grid at five seeds and eight step counts is four hours, and that is the version people abandon halfway and then choose settings by eye anyway.

Score each cell on three things and keep them separate: whether the image matches the prompt, whether it is free of the failures you care about, and what it cost. The first two need human judgement on a sample or an automated proxy you have checked against human judgement on that same sample — a CLIP-style image-text similarity score is the usual proxy for prompt adherence and it is only worth using once you have confirmed it agrees with you on fifty pairs. Then pick the cheapest cell that is not distinguishably worse than the best one, which is almost never the highest step count.

Before a product#

  • Check the model licence. Some restrict commercial use, and terms differ between versions.
  • Add input and output safety filtering. Pipelines often include a checker; know what it does and does not catch.
  • Plan for cost: images per second per GPU at your chosen settings decides the bill.

The licence point is the one that ends projects rather than delaying them, and it is not uniform across a family: successive releases of the same model line have shipped under different terms, including ones that gate commercial use behind a subscription or a revenue threshold. Read the licence file in the repository for the exact revision you are pulling, record the revision hash in your deployment, and do not infer the terms from a sibling model. The same care applies to any LoRA or adapter you load, which carries its own licence and often its own training-data provenance question.

On safety filtering, the built-in checkers are classifiers on the generated image with a fixed threshold, and they are neither the beginning nor the end of the problem. They do not see the prompt, so they cannot catch a request that is harmful in intent but innocuous as pixels; they are tuned for one category of content and miss others entirely; and they produce false positives that your users will experience as the product being broken. Filter on the input as well as the output, log what was blocked so you can measure the false-positive rate, and decide deliberately what happens on a block, because silently returning a blank image is the worst of the options.

What to skip#

Skip collecting prompt-keyword lists, skip the argument about which scheduler looks best in the abstract, and skip fine-tuning until you have a measured grid showing the base model cannot do the thing. What is worth your time is the measurement harness, because it is the part that keeps working when the model underneath you is replaced, which on current timescales is about twice a year.

Where people go wrong

Shipping a model without reading its licence because the weights were free to download.

Build this: Render a grid of twenty prompts by four step counts by three guidance values with fixed seeds, record latency per cell, and pick settings with a short written justification.

Open this section on its own page

Now actually do it

Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.

1Build the steps and guidance grid

20h

Generate the same prompt at 4, 20 and 50 steps across a range of guidance values. Find the settings you would actually ship.

Done when: You chose settings from a grid you produced rather than from a default.

2Implement the forward process

19h

Write the noising process on a small image dataset and visualise each step. Then train a small model to predict the noise at one step.

Done when: You can see the destruction and the model learning to reverse one step of it.

39 hours of practice

Interview questions

9 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.

Generating by removing noise

How do diffusion models work?

A fixed forward process gradually adds noise to data. A model learns to reverse one step by predicting the noise added. Generation starts from pure noise and applies the learned reverse step repeatedly.

Why did diffusion displace GANs?

Training stability. A GAN's adversarial objective suffers mode collapse and oscillation, while diffusion has a simple regression objective that trains reliably and scales predictably. The cost is much slower sampling.

What is latent diffusion?

Running the diffusion process in a compressed latent space rather than on raw pixels, so each denoising step is far cheaper. It is what made high-resolution text-to-image practical on ordinary hardware.

Using diffusion models in practice

How do you trade quality against latency in a diffusion model?

Step count, primarily, since latency scales roughly with it. Modern samplers reach good quality in around twenty steps, and distilled models go much lower at a real quality cost. Guidance strength is the second dial.

What is classifier-free guidance?

Contrasting the model's conditioned and unconditioned predictions and pushing away from the unconditioned one, which strengthens how closely output follows the prompt. Too low drifts from the prompt, too high produces oversaturated rigid results.

How would you add a specific style to a diffusion model?

A LoRA adapter for most cases, since it is cheap and composable. Textual inversion if you only need a new concept token and want to avoid touching the weights. Full fine-tuning only with substantial data and a reason.

The maths of denoising diffusion, just enough

Write down the diffusion training objective and explain it.

Sample a clean example, a random step t and Gaussian noise, form x_t as sqrt(alphabar_t) times the clean example plus sqrt(1 minus alphabar_t) times the noise, and train the model to predict that noise with a squared error loss.

Then they push further

Why can training jump straight to any step?

The sum of Gaussian noise steps is itself Gaussian with a known variance, so x_t has a closed form given x_0. There is no need to simulate the chain during training.

How does classifier-free guidance work?

The model is trained with and without the condition. At sampling, the unconditional prediction is pushed further in the direction of the conditional one by a guidance weight, strengthening adherence to the prompt at the cost of diversity.

What gets marked up. Marked up for the closed-form forward process, the noise-prediction objective and the guidance formula.

Train a tiny diffusion model

You trained a diffusion model and samples look like blurry blobs. What might be wrong?

Too little training, a network too small for the data, a wrong noise schedule so the final step is not near pure noise, a bug in the sampling formula, or the time step not reaching the network properly. Checking that the forward process at the last step is indistinguishable from noise and plotting intermediate samples narrow it down fast.

Working with pretrained image models

You need image generation under one second per image. What are your options?

Fewer steps with a better sampler, a distilled few-step model, smaller resolution with upscaling, 16-bit weights and compiled models, batching, and caching repeated prompts. Measure quality on representative prompts at each setting rather than choosing by eye.

These questions on their own page, one link per question

Resources

Free to watch and read, picked for this topic.