Diffusion Models (Introduction)
A diffusion model is trained to do one narrow thing: given a noisy image and the amount of noise in it, predict the noise. That makes it the easiest generative family to verify, because the forward process — the corruption the model learns to undo — has a closed form. Every claim about it can be checked with arithmetic instead of trained.
One equation gets you to any timestep in a single step — no simulation, no loop. The model’s whole job is to invert it.
The first version of this page produced blobs, and it had two plausible explanations. Both were tested. One was wrong.
| Run | Training loss | Signal weight at last step | Mean ink | Classes | KL from uniform |
|---|---|---|---|---|---|
| Dense, original schedule | 0.8804 | 0.3636 | 0.4737 | 2 | 12.9494 |
| Dense, terminal-SNR fixed | 0.8738 | 0.0466 | 0.4780 | 2 | 14.4285 |
| Convolutional, same fix | 0.0314 | 0.0466 | 0.0503 | 7 | 2.5506 |
| Real digits | — | — | 0.1192 | 10 | 0.0360 |
The schedule fix — the obvious suspect, and a real published problem — changed nothing. The architecture change was worth a 28× reduction in training loss.
What you’ll learn
Section titled “What you’ll learn”- The forward process in closed form, and how to read a noise schedule from its numbers.
- The terminal SNR problem: why a schedule that never fully destroys the image is broken, and why fixing it here still did not fix the samples.
- Why a dense network is the wrong shape for denoising, measured.
- Why more reverse steps made coverage worse — 0.2495 at 20 steps against 2.5506 at 200 — while costing 11× more time.
The forward process
Section titled “The forward process”The schedule is a design choice, and every property of it is arithmetic:
| t | β | Signal weight | Noise weight | SNR | |
|---|---|---|---|---|---|
| 0 | 0.00010 | 0.99990 | 0.9999 | 0.0100 | 9999.0000 |
| 20 | 0.00210 | 0.97715 | 0.9885 | 0.1512 | 42.7609 |
| 60 | 0.00610 | 0.82738 | 0.9096 | 0.4155 | 4.7931 |
| 120 | 0.01210 | 0.47659 | 0.6904 | 0.7235 | 0.9105 |
| 199 | 0.02000 | 0.13218 | 0.3636 | 0.9316 | 0.1523 |
That last row is a genuine defect, known in the literature as the terminal signal-to-noise problem. At the final timestep the image is still a third of the signal, so the training distribution at and the sampling distribution (pure noise) do not match. Raising from 0.02 to 0.06 fixes it: the final signal weight drops to 0.0466 and SNR to 0.0022.
The fix that did not work
Section titled “The fix that did not work”timesteps = rng.integers(0, STEPS, len(clean))
noisy, noise = add_noise(clean, timesteps) # the closed form above
model.fit([noisy, embed(timesteps)], noise, ...) # target is the NOISEWith the schedule corrected and everything else unchanged, the samples did not improve — they got marginally worse, KL 12.9494 → 14.4285, both at 2 of 10 classes. The tell was in a column that is easy to skip:
Mean ink. Real MNIST digits average 0.1192 per pixel. The dense model’s samples averaged 0.4737 and 0.4780 — four times as much. That is not a subtly wrong distribution, it is a model smearing brightness across the whole canvas, which is what a network with no spatial structure does when asked to remove spatially structured noise.
The convolutional denoiser is a small U-Net: two downsampling stages, a bottleneck, two upsampling stages with skip connections, and the timestep embedding added as a per-channel bias at each scale.
first = keras.layers.Conv2D(32, 3, padding="same", activation="relu")(x)
first = add_time(first, 32) # timestep as a channel bias
down = keras.layers.MaxPooling2D()(first)
...
up = keras.layers.Concatenate()([up, first]) # skip: fine detail comes backDenoising is a local operation — each pixel’s correct value depends mostly on its neighbours. A dense layer must learn that neighbourhood relationship from scratch out of a flat vector of 784 inputs; a convolution is handed it. With fewer parameters (160,225 against 538,128) the U-Net reached a training loss of 0.0314 against 0.8738.
It cost 2,723 seconds against 69, which is the honest other side of that trade.
More steps made it worse
Section titled “More steps made it worse”The reverse process runs the model once per step. Standard advice is that more steps give better samples, and the cost is linear. The cost part is true:
| Reverse steps | Seconds | Classes | KL from uniform | Mean ink | Judge confidence |
|---|---|---|---|---|---|
| 2 | 0.8 | 3 | 11.2875 | 0.4939 | 0.9183 |
| 5 | 2.1 | 10 | 0.3567 | 0.1076 | 0.7547 |
| 20 | 9.3 | 10 | 0.2495 | 0.0812 | 0.7833 |
| 50 | 23.0 | 9 | 0.5740 | 0.0686 | 0.7774 |
| 200 | 106.1 | 7 | 2.5506 | 0.0503 | 0.7449 |
Coverage peaks at 20 steps and then degrades. Going from 20 to 200 steps costs 11× the time and loses three classes.
Two things are happening, and the mean-ink column separates them:
- Two steps is genuinely too few. Ink 0.4939, three classes — the sampler has not had time to resolve anything, and the judge’s high confidence (0.9183) is confidence about blobs.
- Two hundred steps over-denoises. Ink falls monotonically with step count — 0.1076, 0.0812, 0.0686, 0.0503 — dropping below real data’s 0.1192. Each step re-estimates the clean image and clips it to a valid range, so many small steps accumulate a bias towards thin, faint strokes, and the variety of digits collapses with it.
That second effect is specific to this sampler (predict , re-noise with fresh noise) and this model. The general lesson is not “use 20 steps” — it is that step count is a hyperparameter with a measurable optimum, not a dial where more is always better. Nothing on the sample grid announces that 200 steps was worse than 20.
flowchart LR X0["clean image"] -->|"forward: closed form,
one step to any t"| XT["x_t"] XT -->|"model predicts the noise"| E["epsilon-hat"] E --> X0H["implied clean image"] X0H -->|"re-noise to t-1"| XT XT -->|"t = 0"| OUT["sample"] E -.->|"dense: loss 0.8738"| BAD["ink 0.4780, 2 classes"] E -.->|"convolutional: loss 0.0314"| GOOD["ink 0.0503, 7 classes"]
What the model actually predicts
Section titled “What the model actually predicts”Predicting the noise rather than the image looks arbitrary and is not. The two are related by a rearrangement, so they carry the same information — but the noise target has constant scale across all timesteps, while the clean-image target does not. That keeps the loss comparable at every noise level, which is what lets one network handle the whole schedule.
Pitfalls
Section titled “Pitfalls”- A schedule that does not end near zero. The original ended with the image at 0.3636 of the signal, so sampling started off-distribution. Check before anything else.
- Assuming the schedule is the problem. Fixing it here moved KL from 12.9494 to 14.4285 — the wrong direction. The architecture was the bottleneck.
- Using a dense network as the denoiser. With 3.4× more parameters it reached a 28× higher training loss.
- Believing more reverse steps are always better. Coverage peaked at 20 steps (KL 0.2495) and degraded to 2.5506 at 200, for 11× the cost.
- Reading classifier confidence as sample quality. It was highest at 2 steps (0.9183), the worst setting on every other measure.
- Timing the sampler on its first call. The first version of this module reported 2 steps as the slowest setting (56.3s against 1.0s for 5 steps) because model training happened inside the timed block.
- Comparing these numbers with published diffusion results. This is MNIST at 28×28 with a 160k-parameter U-Net on a CPU.
- The forward process has a closed form, so the schedule can be verified with arithmetic instead of experiments.
- Terminal SNR matters — a schedule ending at signal weight 0.3636 is broken — but fixing it did not fix these samples.
- The denoiser’s architecture did: a 160,225-parameter U-Net reached training loss 0.0314 where a 538,128-parameter dense network reached 0.8738.
- Mean pixel value was the diagnostic that identified the cause: 0.4780 for dense samples against 0.1192 for real digits.
- Sampling cost is linear in steps (0.8s to 106.1s), and coverage peaked at 20 steps rather than at the maximum.
- The model predicts the noise because that target has constant scale across timesteps.
Diffusion, GANs and VAEs are all judged by the same three measurements, and each of those can be gamed — including by a model that copies its training data: Evaluating Generative Models.
-
Why must a noise schedule's alpha-bar end near zero?
The original schedule here ended with the image at 0.3636 of the signal. That is a real defect, even though fixing it did not fix these particular samples.
pch.quizShowAnswer
B — Because sampling starts at the last timestep from pure Gaussian noise — if the image still contributes signal there, the sampler begins from a distribution the model never saw in training — The original schedule here ended with the image at 0.3636 of the signal. That is a real defect, even though fixing it did not fix these particular samples.
-
Correcting the terminal SNR moved coverage KL from 12.9494 to 14.4285. What was the right conclusion?
A fix that does not move the outcome has not been shown to help. The architecture change moved training loss from 0.8738 to 0.0314.
pch.quizShowAnswer
B — The hypothesis was wrong — the schedule was not what was limiting the samples, so the next suspect had to be tested rather than the fix being credited — A fix that does not move the outcome has not been shown to help. The architecture change moved training loss from 0.8738 to 0.0314.
-
The convolutional denoiser had 160,225 parameters against the dense network's 538,128, yet reached a far lower loss. Why?
Parameter count is not capability. Matching the architecture to the structure of the problem beat having three times as many weights.
pch.quizShowAnswer
B — Denoising is a local operation, and a convolution is given the neighbourhood structure that a dense layer has to learn from a flat 784-value vector — Parameter count is not capability. Matching the architecture to the structure of the problem beat having three times as many weights.
-
Coverage KL was 0.2495 at 20 reverse steps and 2.5506 at 200, while sampling time rose from 9.3s to 106.1s. What does this show?
Each step re-estimates and clips the clean image, so many steps accumulate a bias towards faint, thin strokes — and no sample grid would announce that.
pch.quizShowAnswer
B — Step count has a measurable optimum rather than being a more-is-better dial; here the extra steps over-denoised, with mean ink falling to 0.0503 against real data's 0.1192 — Each step re-estimates and clips the clean image, so many steps accumulate a bias towards faint, thin strokes — and no sample grid would announce that.
-
Why is the training target the noise rather than the clean image?
One network has to serve every noise level, so a target whose scale does not change with t is what makes a single loss meaningful.
pch.quizShowAnswer
B — The two are equivalent by rearrangement, but the noise target has constant scale at every timestep, which keeps the loss comparable across the whole schedule — One network has to serve every noise level, so a target whose scale does not change with t is what makes a single loss meaningful.
🧪 Try It Yourself
Section titled “🧪 Try It Yourself”pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading