Denoising Diffusion Probabilistic Models
Explained as a for loop you learn to run backwards, because the variational bound in the paper hid that from me for a year.
Download diffusion_from_scratch.py — everything below runs from it. NumPy for the mechanism, plus a tiny CPU PyTorch model for the training step.
I'd run Stable Diffusion on my own GPU, swapped samplers by name, argued
about guidance scales. Then I opened the paper all of that descends from and
stalled at equation 3, a variational bound with three kinds of KL term in it.
Nobody told me the bound is scaffolding. The method is: corrupt data with a
for loop, learn a one-step undo, run the loop backwards.
Learn to undo a for loop
You know this — a loop that ruins something a little at a time:
x = x0
for t in range(1, T + 1): # T = 1000 in the paper
x = sqrt(1 - beta[t]) * x + sqrt(beta[t]) * randn() # shrink, add noise
# x is static now. No trace of x0 left.
Two facts turn that into a generative model. First, you never have to run the loop: Gaussians compose, so a one-liner jumps to any step t. Second, the only thing that gets learned is a one-step undo:
x_t = sqrt(alpha_bar[t]) * x0 + sqrt(1 - alpha_bar[t]) * eps # jump to step t
eps_hat = model(x_t, t) # the ONE learned thing: "what noise was added?"
x = randn() # start from static
for t in range(T, 0, -1): # the same loop, backwards
x = undo(x, t, model(x, t)) # subtract predicted noise, add a little back
That's the paper. A corruption function with random access to any stage,
and a model that is ctrl-z for one stage.
| Term | Means | Programmer's analogy |
|---|---|---|
| βt | How much noise step t adds | The corruption rate for one loop iteration |
| ᾱt | How much of x0 survives to step t | A prefix product, precomputed once, like a prefix-sum table |
| q(xt | x0) | The forward jump | git checkout any commit without replaying the log |
| εθ(xt, t) | The model: which noise was added? | A one-step undo, told how deep into the loop it is |
| pθ(xt−1 | xt) | One reverse step | The loop body, run backwards |
The one formula
Read it inside-out, as pipeline stages:
| Step | Shape | What it does |
|---|---|---|
t ~ Uniform{1..T} | one int per sample | Pick a random depth into the loop |
ε ~ N(0, I) | (n, d) | Draw the noise you are about to add. This is also the answer key |
√ᾱt x0 + √(1−ᾱt) ε | (n, d) | The jump. xt in one line, no loop |
εθ(xt, t) | (n, d) | The model's guess at ε |
‖ε − εθ‖2 | scalar | Plain MSE. Adam does the rest |
d is 2 in my script, 32×32×3 in the paper's CIFAR-10 runs. It's a regression: it trains like a classifier where a GAN trains like a fight. The variational bound in §3 shows this MSE is a weighted version of it; §3.4 then drops the weights because the samples got better without them. That is the paper's key practical finding. Equation 14.
The schedule: three arrays, nothing learned
βt goes linearly from 0.0001 to 0.02 over T = 1000 steps (§4 of the paper). αt = 1 − βt. ᾱt is the running product of the alphas: how much of the original signal is still standing at step t. Precomputed once, indexed by t.
From the running code:
how much of x_0 survives to step t (alpha_bar_t):
t = 0 alpha_bar = 1.000000 sqrt(alpha_bar) = 1.0000
t = 250 alpha_bar = 0.524085 sqrt(alpha_bar) = 0.7239
t = 500 alpha_bar = 0.078587 sqrt(alpha_bar) = 0.2803
t = 1000 alpha_bar = 0.000040 sqrt(alpha_bar) = 0.0064
x_T built by the closed-form jump, 20000 draws from ONE x_0 each:
x_0 mean of x_T std of x_T
[1. 0.] [ 0.015 -0.009] [1.002 1. ]
[-1. 0.] [-0.005 -0.004] [1. 1.]
[3. 3.] [0.018 0.02 ] [1.001 1.005] ✓ even from off the ring
That second table is the contract. Whatever x0 you start from,
xT is N(0, 1), so sampling can start from N(0, 1)
without ever seeing the data. Break it (make_schedule(5),
in the table below) and sampling starts in the wrong place.
The shortcut: any step in one line
Add two independent Gaussians and the variances add. Chain t shrink-and-noise steps and they collapse to one Gaussian centred on √ᾱt x0 with variance 1 − ᾱt. Equation 4. That is why training never runs the loop: pick a t, jump.
Don't take my word for it. The loop and the jump, side by side:
x_0 = (1, 0). Compare the x-coordinate of x_t, 20000 draws each.
t loop mean std | jump mean std | theory sqrt(ab) sqrt(1-ab)
10 0.999 0.043 | 0.999 0.043 | 0.999 0.044
100 0.946 0.321 | 0.948 0.320 | 0.947 0.321
500 0.286 0.972 | 0.285 0.969 | 0.280 0.960
1000 0.013 0.994 | 0.018 0.997 | 0.006 1.000 ✓
The square roots are the part people get wrong. ᾱt and
1 − ᾱt are variances; the coefficient on a sample is the
standard deviation. Forget the np.sqrt:
t correct mean std | no-sqrt mean std | theory mean std
100 0.947 0.320 | 0.897 0.103 | 0.947 0.321
300 0.626 0.781 | 0.394 0.607 | 0.630 0.777
Training: predict the noise, not the image
Algorithm 1 in the paper is four lines, and my train loop is
those four lines with the paper's line number as a comment on each. Draw
x0, t and ε, jump to xt, ask the model for ε, MSE.
The model is a 3-layer MLP here and a U-Net in the paper. Same job.
baseline -- always predict zero noise -- scores 1.0000. Beat that.
step 500 loss 0.2552
step 1000 loss 0.2279
step 3000 loss 0.2000
step 4000 loss 0.1924
trained 4000 steps on CPU in 4.6s
held-out loss: 0.1918 ✓
The model gets t as an input, through the same sine/cosine embedding the transformer uses for position (the attention page). It needs it: the same xt could be a ring point plus a little noise or mostly noise, and how much to remove depends on which. Take t away:
held-out loss, all t: with t 0.1918 without t 0.2358
held-out loss, t <= 50 only: with t 0.8107 without t 1.1272 ← worse than zero
Sampling: the loop, backwards
Algorithm 2. Start from N(0, I). For t = T down to 1: ask the model for ε̂, subtract βt / √(1−ᾱt) of it, divide by √αt, then add fresh noise with variance βt — none on the last step. Watch the batch tighten from a blob onto the ring:
the batch, mid-flight:
t = 1000 mean radius 1.250 radius spread 0.659
t = 500 mean radius 1.230 radius spread 0.636
t = 100 mean radius 1.004 radius spread 0.318
t = 1 mean radius 0.991 radius spread 0.123
real data (ring) mean radius 1.001 radius spread 0.100
generated, 1000 DDPM steps mean radius 0.991 radius spread 0.123 ✓
The model never sees a ring at sampling time; it only learned "what noise was added", and undoing that a thousand times makes the ring fall out. The "add fresh noise back" step looks like a bug and isn't. Drop it:
real data (ring) mean radius 1.001 radius spread 0.100
DDPM, with noise (correct) mean radius 0.979 radius spread 0.129
DDPM, noise term removed mean radius 0.705 radius spread 0.031
DDIM, 50 steps, eta = 0 mean radius 0.967 radius spread 0.135 ✓
Without the noise the samples shrink toward the centre and lose their spread. The last row is the fix for the thousand-call problem: DDIM (Song et al., 2020) is also deterministic but with corrected coefficients — guess x0 from ε̂, re-noise that guess to a much earlier step. Same model, 50 calls. Every fast sampler in a Stable Diffusion dropdown descends from it.
Where Stable Diffusion fits
Latent diffusion (Rombach et al., 2022) is this exact algorithm with two changes and one consequence.
Run it in latent space. First train a lightly regularised VAE that squeezes a 512×512×3 image into a 64×64×4 latent, 48 times fewer numbers. Run Algorithms 1 and 2 on the latents, not the pixels, and decode once at the end.
Condition on text. A text encoder (CLIP's, in the released model) turns the prompt into token vectors. The U-Net gets cross-attention layers: query from the image features, keys and values from the text tokens. The dictionary lookup again, with the image asking the prompt what it should contain.
The consequence is why it runs on a gaming card: the loop runs over 16k latent values instead of 786k pixels. The guidance-scale slider (classifier-free guidance) is a third paper; the loop is unchanged.
நான் Stable Diffusion-ஐ என் சொந்த GPU-வில் ஓட்டியிருக்கிறேன், sampler-களைப்
பெயர் வைத்து மாற்றியிருக்கிறேன், guidance scale பற்றி வாதிட்டிருக்கிறேன்.
பிறகு அவை எல்லாவற்றுக்கும் மூலமான paper-ஐத் திறந்தேன் — equation 3-லேயே
நின்றுவிட்டேன். மூன்று வகை KL term-கள் கொண்ட ஒரு variational bound. அந்த
bound வெறும் சாரம் (scaffolding) என்று யாரும் சொல்லவில்லை. உண்மையான
முறை இதுதான்: ஒரு for loop-ஆல் data-வைக் கெடுக்கவும், ஒரு
படி undo செய்யக் கற்றுக்கொள்ளவும், loop-ஐத் திருப்பி ஓட்டவும்.
ஒரு for loop-ஐ undo செய்யக் கற்றுக்கொள்ளுங்கள்
இது உங்களுக்குத் தெரியும் — கொஞ்சம் கொஞ்சமாக ஒன்றைக் கெடுக்கும் loop:
x = x0
for t in range(1, T + 1): # paper-ல் T = 1000
x = sqrt(1 - beta[t]) * x + sqrt(beta[t]) * randn() # சுருக்கு, noise சேர்
# x இப்போது static. x0-வின் தடமே இல்லை.
இரண்டு உண்மைகள் இதை ஒரு generative model ஆக்குகின்றன. முதலாவது, loop-ஐ நீங்கள் ஓட்டவே வேண்டியதில்லை: Gaussian-கள் கூடிச் சேர்வதால், ஒரே வரியில் எந்த step t-க்கும் தாவலாம். இரண்டாவது, கற்றுக்கொள்ளப்படுவது ஒரே ஒரு விஷயம் மட்டும் — ஒரு step undo:
x_t = sqrt(alpha_bar[t]) * x0 + sqrt(1 - alpha_bar[t]) * eps # step t-க்கு தாவல்
eps_hat = model(x_t, t) # கற்பது இதுமட்டும்: "எந்த noise சேர்ந்தது?"
x = randn() # static-லிருந்து தொடங்கு
for t in range(T, 0, -1): # அதே loop, தலைகீழாக
x = undo(x, t, model(x, t)) # கணித்த noise-ஐக் கழி, கொஞ்சம் திரும்பச் சேர்
இதுதான் அந்த paper. எந்த நிலைக்கும் random access தரும் ஒரு corruption
function, மற்றும் ஒரே ஒரு நிலைக்கு ctrl-z-ஆக இருக்கும் ஒரு model.
Data-va oru for loop kondu kedukkuroam. Andha loop-la endha step-kkum ore line-la thaavalaam. Model kaththukkarathu onnu dhaan: oru step undo.
| சொல் | அர்த்தம் | Programmer-க்கான உதாரணம் |
|---|---|---|
| βt | step t எவ்வளவு noise சேர்க்கிறது | ஒரு loop iteration-இன் corruption rate |
| ᾱt | x0-வில் எவ்வளவு step t வரை தாக்குப்பிடிக்கிறது | ஒருமுறை precompute செய்யப்பட்ட prefix product, prefix-sum table போல |
| q(xt | x0) | Forward தாவல் | log-ஐ மறுபடி ஓட்டாமல் எந்த commit-க்கும் git checkout |
| εθ(xt, t) | Model: எந்த noise சேர்க்கப்பட்டது? | loop-ல் எவ்வளவு ஆழத்தில் இருக்கிறோம் என்று சொல்லப்பட்ட ஒரு step undo |
| pθ(xt−1 | xt) | ஒரு reverse step | loop body, தலைகீழாக ஓடுகிறது |
அந்த ஒரு சூத்திரம்
உள்ளிருந்து வெளியே, pipeline stage-களாகப் படியுங்கள்:
| Step | Shape | என்ன செய்கிறது |
|---|---|---|
t ~ Uniform{1..T} | ஒரு sample-க்கு ஒரு int | loop-ல் ஒரு random ஆழத்தைத் தேர்ந்தெடு |
ε ~ N(0, I) | (n, d) | சேர்க்கப்போகும் noise-ஐ எடு. இதுவே விடைத்தாளும் கூட |
√ᾱt x0 + √(1−ᾱt) ε | (n, d) | அந்தத் தாவல். ஒரே வரியில் xt, loop இல்லை |
εθ(xt, t) | (n, d) | ε பற்றி model-இன் ஊகம் |
‖ε − εθ‖2 | scalar | சாதாரண MSE. மீதியை Adam பார்த்துக்கொள்ளும் |
என் script-ல் d = 2, paper-இன் CIFAR-10 run-களில் 32×32×3. இது ஒரு regression: GAN ஒரு சண்டை போலப் பயிற்சி பெற்றால், இது ஒரு classifier போலப் பயிற்சி பெறுகிறது. §3-இன் variational bound இந்த MSE அதன் ஒரு weighted வடிவம் என்று காட்டுகிறது; §3.4 அந்த weight-களைத் தூக்கி எறிகிறது, ஏனெனில் அவை இல்லாமல் sample-கள் மேம்பட்டன. அதுதான் paper-இன் முக்கியமான நடைமுறைக் கண்டுபிடிப்பு. Equation 14.
Schedule: மூன்று array-கள், கற்பது எதுவும் இல்லை
βt என்பது T = 1000 step-களில் 0.0001-லிருந்து 0.02 வரை நேர்கோட்டில் செல்கிறது (paper-இன் §4). αt = 1 − βt. ᾱt என்பது alpha-க்களின் ஓடும் பெருக்கல்: step t-ல் அசல் signal எவ்வளவு இன்னும் நிற்கிறது. ஒருமுறை precompute, t-ஆல் index.
ஓடும் code-லிருந்து:
how much of x_0 survives to step t (alpha_bar_t):
t = 0 alpha_bar = 1.000000 sqrt(alpha_bar) = 1.0000
t = 250 alpha_bar = 0.524085 sqrt(alpha_bar) = 0.7239
t = 500 alpha_bar = 0.078587 sqrt(alpha_bar) = 0.2803
t = 1000 alpha_bar = 0.000040 sqrt(alpha_bar) = 0.0064
x_T built by the closed-form jump, 20000 draws from ONE x_0 each:
x_0 mean of x_T std of x_T
[1. 0.] [ 0.015 -0.009] [1.002 1. ]
[-1. 0.] [-0.005 -0.004] [1. 1.]
[3. 3.] [0.018 0.02 ] [1.001 1.005] ✓ even from off the ring
அந்த இரண்டாவது அட்டவணைதான் ஒப்பந்தம். நீங்கள் எந்த x0-லிருந்து
தொடங்கினாலும் xT என்பது N(0, 1) — எனவே data-வைப் பார்க்காமலேயே
sampling N(0, 1)-லிருந்து தொடங்க முடியும். இதைக் கெடுத்துப்
பாருங்கள் (make_schedule(5), கீழே உள்ள அட்டவணையில்) —
sampling தவறான இடத்தில் தொடங்கும்.
குறுக்குவழி: எந்த step-ஆனாலும் ஒரே வரியில்
இரண்டு சார்பற்ற Gaussian-களைக் கூட்டினால் variance-கள் கூடும். t shrink-and-noise step-களைத் தொடராக்கினால், அவை √ᾱt x0-ஐ மையமாகக் கொண்ட, 1 − ᾱt variance உள்ள ஒரே Gaussian ஆகச் சுருங்குகின்றன. Equation 4. அதனால்தான் training loop-ஐ ஒருபோதும் ஓட்டுவதில்லை: ஒரு t-ஐத் தேர்ந்தெடு, தாவு.
என் வார்த்தையை நம்பாதீர்கள். loop-ம் தாவலும் அருகருகே:
x_0 = (1, 0). Compare the x-coordinate of x_t, 20000 draws each.
t loop mean std | jump mean std | theory sqrt(ab) sqrt(1-ab)
10 0.999 0.043 | 0.999 0.043 | 0.999 0.044
100 0.946 0.321 | 0.948 0.320 | 0.947 0.321
500 0.286 0.972 | 0.285 0.969 | 0.280 0.960
1000 0.013 0.994 | 0.018 0.997 | 0.006 1.000 ✓
வர்க்கமூலங்கள்தான் மக்கள் தவறவிடும் பகுதி. ᾱt-ம்
1 − ᾱt-ம் variance-கள்; ஒரு sample-இன் மேல் வரும் coefficient
என்பது standard deviation. np.sqrt-ஐ மறந்துவிட்டால்:
t correct mean std | no-sqrt mean std | theory mean std
100 0.947 0.320 | 0.897 0.103 | 0.947 0.321
300 0.626 0.781 | 0.394 0.607 | 0.630 0.777
Alpha-bar-um 1 minus alpha-bar-um variance. Sample mela varra coefficient standard deviation. Adhanaala sqrt kandippa venum — illena noise pathi dhaan.
Training: image-ஐ அல்ல, noise-ஐக் கணியுங்கள்
Paper-இன் Algorithm 1 நான்கே வரிகள். என் train loop அந்த
நான்கு வரிகள்தான் — ஒவ்வொன்றிலும் paper-இன் வரி எண் comment-ஆக உள்ளது.
x0, t, ε எடு; xt-க்குத் தாவு; model-இடம் ε கேள்; MSE.
இங்கே model ஒரு 3-layer MLP, paper-ல் ஒரு U-Net. வேலை ஒன்றுதான்.
baseline -- always predict zero noise -- scores 1.0000. Beat that.
step 500 loss 0.2552
step 1000 loss 0.2279
step 3000 loss 0.2000
step 4000 loss 0.1924
trained 4000 steps on CPU in 4.6s
held-out loss: 0.1918 ✓
Model-க்கு t ஒரு input-ஆகத் தரப்படுகிறது — transformer position-க்குப் பயன்படுத்தும் அதே sine/cosine embedding வழியாக (attention பக்கம்). அது தேவை: ஒரே xt என்பது கொஞ்சம் noise சேர்ந்த ring புள்ளியாகவோ அல்லது பெரும்பாலும் noise-ஆகவோ இருக்கலாம்; எவ்வளவு நீக்க வேண்டும் என்பது அதைப் பொறுத்தது. t-ஐ எடுத்துவிடுங்கள்:
held-out loss, all t: with t 0.1918 without t 0.2358
held-out loss, t <= 50 only: with t 0.8107 without t 1.1272 ← zero-விட மோசம்
Sampling: loop, தலைகீழாக
Algorithm 2. N(0, I)-லிருந்து தொடங்கு. t = T-லிருந்து 1 வரை: model-இடம் ε̂ கேள், அதில் βt / √(1−ᾱt) அளவைக் கழி, √αt-ஆல் வகு, பிறகு βt variance கொண்ட புதிய noise சேர் — கடைசி step-ல் மட்டும் சேர்க்காதே. batch ஒரு உருண்டையிலிருந்து ring-ஆக இறுகுவதைப் பாருங்கள்:
the batch, mid-flight:
t = 1000 mean radius 1.250 radius spread 0.659
t = 500 mean radius 1.230 radius spread 0.636
t = 100 mean radius 1.004 radius spread 0.318
t = 1 mean radius 0.991 radius spread 0.123
real data (ring) mean radius 1.001 radius spread 0.100
generated, 1000 DDPM steps mean radius 0.991 radius spread 0.123 ✓
Sampling நேரத்தில் model ring-ஐப் பார்ப்பதே இல்லை; அது "என்ன noise சேர்ந்தது" என்பதை மட்டுமே கற்றது — அதை ஆயிரம் முறை undo செய்தால் ring தானாக விழுகிறது. "புதிய noise-ஐத் திரும்பச் சேர்" என்ற step ஒரு bug போலத் தெரியும், ஆனால் இல்லை. அதை நீக்கிப் பாருங்கள்:
real data (ring) mean radius 1.001 radius spread 0.100
DDPM, with noise (correct) mean radius 0.979 radius spread 0.129
DDPM, noise term removed mean radius 0.705 radius spread 0.031
DDIM, 50 steps, eta = 0 mean radius 0.967 radius spread 0.135 ✓
Noise இல்லாமல் sample-கள் மையத்தை நோக்கிச் சுருங்கி, தங்கள் விரிவை இழக்கின்றன. கடைசி வரிதான் "ஆயிரம் call" பிரச்சனைக்கான தீர்வு: DDIM (Song et al., 2020) இதுவும் deterministic-தான், ஆனால் திருத்தப்பட்ட coefficient-களுடன் — ε̂-லிருந்து x0-ஐ ஊகி, அந்த ஊகத்தை மிக முந்தைய ஒரு step-க்கு மறுபடி noise ஏற்று. அதே model, 50 call-கள். ஒரு Stable Diffusion dropdown-ல் இருக்கும் ஒவ்வொரு வேகமான sampler-ம் இதிலிருந்து வந்ததுதான்.
Stable Diffusion எங்கே பொருந்துகிறது
Latent diffusion (Rombach et al., 2022) என்பது இதே algorithm-தான் — இரண்டு மாற்றங்கள், ஒரு விளைவு.
Latent space-ல் ஓட்டுங்கள். முதலில், 512×512×3 image-ஐ 64×64×4 latent-ஆகச் சுருக்கும் ஒரு லேசாக regularise செய்யப்பட்ட VAE-ஐப் பயிற்றுவியுங்கள் — 48 மடங்கு குறைவான எண்கள். Algorithm 1-ஐயும் 2-ஐயும் pixel-கள் மேல் அல்ல, latent-கள் மேல் ஓட்டுங்கள்; கடைசியில் ஒரே ஒரு முறை decode செய்யுங்கள்.
Text-ஐ வைத்து condition செய்யுங்கள். ஒரு text encoder (வெளியான model-ல் CLIP-இனுடையது) prompt-ஐ token vector-களாக மாற்றுகிறது. U-Net-க்கு cross-attention layer-கள் வருகின்றன: query image feature-களிலிருந்து, key-ம் value-ம் text token-களிலிருந்து. மீண்டும் அதே dictionary lookup — image, prompt-இடம் தான் என்ன கொண்டிருக்க வேண்டும் என்று கேட்கிறது.
அந்த விளைவுதான் ஒரு gaming card-ல் இது ஓடுவதற்குக் காரணம்: loop 786k pixel-களுக்குப் பதிலாக 16k latent மதிப்புகள் மேல் ஓடுகிறது. guidance-scale slider (classifier-free guidance) என்பது வேறொரு paper; loop மாறவே இல்லை.
मैंने Stable Diffusion अपने GPU पर चलाया है, sampler नाम देखकर बदले हैं,
guidance scale पर बहस की है। फिर मैंने वह paper खोला जिससे यह सब निकला है
और equation 3 पर अटक गया — तीन तरह के KL term वाला एक variational bound।
किसी ने नहीं बताया कि वह bound सिर्फ़ scaffolding है। तरीक़ा यह है:
एक for loop से data बिगाड़ो, एक step का undo सीखो, और loop
को उल्टा चला दो।
एक for loop को undo करना सीखिए
यह आप जानते हैं — एक loop जो थोड़ा-थोड़ा करके कुछ बिगाड़ता है:
x = x0
for t in range(1, T + 1): # paper में T = 1000
x = sqrt(1 - beta[t]) * x + sqrt(beta[t]) * randn() # सिकोड़ो, noise डालो
# अब x static है। x0 का कोई निशान नहीं बचा।
दो बातें इसे generative model बना देती हैं। पहली, loop चलाने की ज़रूरत ही नहीं: Gaussian आपस में जुड़ जाते हैं, इसलिए एक लाइन में किसी भी step t तक छलाँग लगाई जा सकती है। दूसरी, सीखी सिर्फ़ एक चीज़ जाती है — एक step का undo:
x_t = sqrt(alpha_bar[t]) * x0 + sqrt(1 - alpha_bar[t]) * eps # step t तक छलाँग
eps_hat = model(x_t, t) # सीखी हुई एकमात्र चीज़: "कौन-सा noise डला था?"
x = randn() # static से शुरू करो
for t in range(T, 0, -1): # वही loop, उल्टा
x = undo(x, t, model(x, t)) # अनुमानित noise घटाओ, थोड़ा वापस डालो
यही पूरा paper है। एक corruption function जिसमें किसी भी stage तक random
access है, और एक model जो एक stage के लिए ctrl-z है।
| शब्द | मतलब | Programmer वाली उपमा |
|---|---|---|
| βt | step t कितना noise जोड़ता है | एक loop iteration की corruption rate |
| ᾱt | x0 का कितना हिस्सा step t तक बचता है | एक बार precompute किया prefix product, prefix-sum table जैसा |
| q(xt | x0) | Forward छलाँग | log दोबारा चलाए बिना किसी भी commit पर git checkout |
| εθ(xt, t) | Model: कौन-सा noise डाला गया था? | एक step का undo, जिसे बताया गया है कि वह loop में कितना गहरा है |
| pθ(xt−1 | xt) | एक reverse step | loop body, उल्टा चलता हुआ |
वह एक सूत्र
इसे अंदर से बाहर की ओर, pipeline stage की तरह पढ़िए:
| Step | Shape | यह क्या करता है |
|---|---|---|
t ~ Uniform{1..T} | हर sample के लिए एक int | loop में एक random गहराई चुनो |
ε ~ N(0, I) | (n, d) | जो noise डालने वाले हो उसे निकालो। यही answer key भी है |
√ᾱt x0 + √(1−ᾱt) ε | (n, d) | वह छलाँग। एक लाइन में xt, कोई loop नहीं |
εθ(xt, t) | (n, d) | ε पर model का अनुमान |
‖ε − εθ‖2 | scalar | सीधा MSE। बाक़ी Adam सँभाल लेगा |
मेरी script में d = 2 है, paper के CIFAR-10 run में 32×32×3। यह एक regression है: GAN एक लड़ाई की तरह train होता है, यह classifier की तरह। §3 का variational bound दिखाता है कि यही MSE उसका एक weighted रूप है; §3.4 फिर weight हटा देता है, क्योंकि उनके बिना sample बेहतर आए। यही paper की असली व्यावहारिक खोज है। Equation 14।
Schedule: तीन array, सीखा कुछ नहीं
βt T = 1000 step में 0.0001 से 0.02 तक सीधी रेखा में जाता है (paper का §4)। αt = 1 − βt। ᾱt alpha का चलता हुआ गुणनफल है: step t पर मूल signal कितना बचा है। एक बार precompute, फिर t से index।
चलते हुए code से:
how much of x_0 survives to step t (alpha_bar_t):
t = 0 alpha_bar = 1.000000 sqrt(alpha_bar) = 1.0000
t = 250 alpha_bar = 0.524085 sqrt(alpha_bar) = 0.7239
t = 500 alpha_bar = 0.078587 sqrt(alpha_bar) = 0.2803
t = 1000 alpha_bar = 0.000040 sqrt(alpha_bar) = 0.0064
x_T built by the closed-form jump, 20000 draws from ONE x_0 each:
x_0 mean of x_T std of x_T
[1. 0.] [ 0.015 -0.009] [1.002 1. ]
[-1. 0.] [-0.005 -0.004] [1. 1.]
[3. 3.] [0.018 0.02 ] [1.001 1.005] ✓ even from off the ring
वह दूसरी table ही असली अनुबंध है। आप किसी भी x0 से शुरू करें,
xT N(0, 1) ही होता है — इसलिए sampling data देखे बिना
N(0, 1) से शुरू हो सकती है। इसे तोड़िए
(make_schedule(5), नीचे वाली table में) और sampling ग़लत
जगह से शुरू होगी।
शॉर्टकट: कोई भी step, एक लाइन में
दो स्वतंत्र Gaussian जोड़िए तो variance जुड़ते हैं। t shrink-and-noise step को जोड़िए तो वे सिमटकर एक ही Gaussian बन जाते हैं, जिसका केंद्र √ᾱt x0 और variance 1 − ᾱt है। Equation 4। इसीलिए training कभी loop नहीं चलाती: एक t चुनो, छलाँग लगाओ।
मेरी बात पर भरोसा मत कीजिए। loop और छलाँग, आमने-सामने:
x_0 = (1, 0). Compare the x-coordinate of x_t, 20000 draws each.
t loop mean std | jump mean std | theory sqrt(ab) sqrt(1-ab)
10 0.999 0.043 | 0.999 0.043 | 0.999 0.044
100 0.946 0.321 | 0.948 0.320 | 0.947 0.321
500 0.286 0.972 | 0.285 0.969 | 0.280 0.960
1000 0.013 0.994 | 0.018 0.997 | 0.006 1.000 ✓
वर्गमूल ही वह हिस्सा है जहाँ लोग ग़लती करते हैं। ᾱt और
1 − ᾱt variance हैं; sample पर लगने वाला coefficient standard
deviation है। np.sqrt भूल जाइए:
t correct mean std | no-sqrt mean std | theory mean std
100 0.947 0.320 | 0.897 0.103 | 0.947 0.321
300 0.626 0.781 | 0.394 0.607 | 0.630 0.777
Training: image नहीं, noise का अनुमान लगाइए
paper का Algorithm 1 चार लाइनों का है, और मेरा train loop
वही चार लाइनें हैं — हर एक पर paper की लाइन संख्या comment में। x0,
t और ε निकालो, xt तक छलाँग लगाओ, model से ε पूछो, MSE।
यहाँ model एक 3-layer MLP है और paper में U-Net। काम वही है।
baseline -- always predict zero noise -- scores 1.0000. Beat that.
step 500 loss 0.2552
step 1000 loss 0.2279
step 3000 loss 0.2000
step 4000 loss 0.1924
trained 4000 steps on CPU in 4.6s
held-out loss: 0.1918 ✓
Model को t एक input के रूप में मिलता है, उसी sine/cosine embedding से जो transformer position के लिए इस्तेमाल करता है (attention वाला पन्ना)। उसे इसकी ज़रूरत है: वही xt या तो थोड़े noise वाला ring point हो सकता है या लगभग पूरा noise, और कितना हटाना है यह इसी पर निर्भर है। t हटा दीजिए:
held-out loss, all t: with t 0.1918 without t 0.2358
held-out loss, t <= 50 only: with t 0.8107 without t 1.1272 ← zero से भी बुरा
Sampling: वही loop, उल्टा
Algorithm 2। N(0, I) से शुरू करो। t = T से 1 तक: model से ε̂ माँगो, उसमें से βt / √(1−ᾱt) घटाओ, √αt से भाग दो, फिर βt variance वाला ताज़ा noise जोड़ो — आख़िरी step पर नहीं। देखिए batch एक धब्बे से सिकुड़कर ring पर कैसे बैठता है:
the batch, mid-flight:
t = 1000 mean radius 1.250 radius spread 0.659
t = 500 mean radius 1.230 radius spread 0.636
t = 100 mean radius 1.004 radius spread 0.318
t = 1 mean radius 0.991 radius spread 0.123
real data (ring) mean radius 1.001 radius spread 0.100
generated, 1000 DDPM steps mean radius 0.991 radius spread 0.123 ✓
Sampling के समय model ring कभी देखता ही नहीं; उसने सिर्फ़ "कौन-सा noise डाला गया था" सीखा है, और उसे हज़ार बार undo करने से ring अपने-आप निकल आता है। "ताज़ा noise वापस जोड़ो" वाला step bug जैसा दिखता है, है नहीं। उसे हटाकर देखिए:
real data (ring) mean radius 1.001 radius spread 0.100
DDPM, with noise (correct) mean radius 0.979 radius spread 0.129
DDPM, noise term removed mean radius 0.705 radius spread 0.031
DDIM, 50 steps, eta = 0 mean radius 0.967 radius spread 0.135 ✓
noise के बिना sample केंद्र की ओर सिकुड़ जाते हैं और अपना फैलाव खो देते हैं। आख़िरी पंक्ति हज़ार-call वाली समस्या का हल है: DDIM (Song et al., 2020) भी deterministic है, पर सही किए गए coefficient के साथ — ε̂ से x0 का अनुमान लगाओ, फिर उस अनुमान को कहीं पहले के step तक दोबारा noise करो। वही model, 50 call। Stable Diffusion के dropdown में दिखने वाला हर तेज़ sampler इसी से निकला है।
Stable Diffusion कहाँ बैठता है
Latent diffusion (Rombach et al., 2022) यही algorithm है, दो बदलाव और एक नतीजे के साथ।
इसे latent space में चलाइए। पहले एक हल्का regularised VAE train कीजिए जो 512×512×3 image को 64×64×4 latent में दबा दे — 48 गुना कम संख्याएँ। Algorithm 1 और 2 pixel पर नहीं, latent पर चलाइए, और आख़िर में एक बार decode कीजिए।
Text पर condition कीजिए। एक text encoder (release हुए model में CLIP का) prompt को token vector में बदलता है। U-Net को cross-attention layer मिलती हैं: query image features से, key और value text token से। वही dictionary lookup दोबारा, जिसमें image prompt से पूछती है कि उसमें क्या होना चाहिए।
वह नतीजा ही वजह है कि यह एक gaming card पर चल जाता है: loop 786k pixel के बजाय 16k latent values पर चलता है। guidance-scale slider (classifier-free guidance) एक तीसरा paper है; loop वही का वही है।
The code
NumPy for the mechanism: schedule, forward loop, jump and reverse step are closed-form and need no gradients. A tiny PyTorch MLP, CPU only, trains in about five seconds for the one part that does.
Download the full script — 220 lines plus five runnable demos and six deliberate breaks. Then:
python3 diffusion_from_scratch.py
def make_schedule(T=1000, beta_start=1e-4, beta_end=0.02):
betas = np.concatenate([[0.0], np.linspace(beta_start, beta_end, T)])
alphas = 1.0 - betas
alpha_bar = np.cumprod(alphas) # alpha_bar[0] = 1: no noise yet
return betas, alphas, alpha_bar
def q_step(x_prev, t, betas):
noise = RNG.normal(size=x_prev.shape)
return np.sqrt(1 - betas[t]) * x_prev + np.sqrt(betas[t]) * noise
# x_t = sqrt(alpha_bar_t) * x_0 + sqrt(1 - alpha_bar_t) * eps, eps ~ N(0, I)
def q_sample(x0, t, alpha_bar, eps):
ab = alpha_bar[t] # scalar, or (n,) per-sample
ab = ab.reshape(-1, 1) if np.ndim(ab) else ab
return np.sqrt(ab) * x0 + np.sqrt(1 - ab) * eps
def train(model, data, alpha_bar, steps=4000, batch=256, lr=1e-3,
target="eps", log_every=500):
T = len(alpha_bar) - 1
opt = torch.optim.Adam(model.parameters(), lr=lr)
running = 0.0
for step in range(1, steps + 1):
x0 = data[RNG.integers(0, len(data), batch)] # 2: x_0 ~ q(x_0)
t = RNG.integers(1, T + 1, batch) # 3: t ~ Uniform{1..T}
eps = RNG.normal(size=x0.shape) # 4: eps ~ N(0, I)
xt = q_sample(x0, t, alpha_bar, eps) # the jump, no loop
y = eps if target == "eps" else x0 # x0 = break-it target
pred = model(torch.tensor(xt, dtype=torch.float32), torch.tensor(t))
loss = ((pred - torch.tensor(y, dtype=torch.float32)) ** 2).mean() # 5
opt.zero_grad(); loss.backward(); opt.step()
running += loss.item()
if step % log_every == 0:
print(f" step {step:5d} loss {running / log_every:.4f}")
running = 0.0
return model
def p_step(x_t, t, eps_hat, betas, alphas, alpha_bar, add_noise=True):
undo = betas[t] / np.sqrt(1 - alpha_bar[t]) * eps_hat
mean = (x_t - undo) / np.sqrt(alphas[t])
if t == 1 or not add_noise:
return mean
return mean + np.sqrt(betas[t]) * RNG.normal(size=x_t.shape)
def sample(model, n, sched, add_noise=True, log_at=(), x_init=None):
betas, alphas, alpha_bar = sched
T = len(betas) - 1
x = RNG.normal(size=(n, 2)) if x_init is None else x_init # 1: x_T ~ N(0, I)
for t in range(T, 0, -1): # 2: for t = T..1
eps_hat = predict_noise(model, x, t) # 3: ask the model
x = p_step(x, t, eps_hat, betas, alphas, alpha_bar, add_noise) # 4
if t in log_at:
m, s = ring_stats(x)
print(f" t = {t:4d} mean radius {m:.3f} radius spread {s:.3f}")
return x
Now break it
This is the part that actually makes it stick. Reading won't do it. Every row
below is already wired into demo_5_break_it, so the correct
number prints beside the broken one.
| Break this | Watch for |
|---|---|
In q_sample, drop the roots: ab * x0 + (1 - ab) * eps | At t = 100 the std of xt is 0.103 instead of 0.321. A third of the noise. The model trains at one noise level and samples at another. |
make_schedule(T, beta_end=0.2) | 740 of 1000 steps have ᾱt < 0.001. At t = 500, corr(xt, x0) = 0.0100: the training pair is (ε, ε) and the model learns to copy its input. The paper's own schedule wastes its last 175 steps this way; the cosine schedule in Improved DDPM fixes it. |
sample(model, 2000, sched, add_noise=False) | Mean radius 0.705, spread 0.031, against 1.001 and 0.100. Samples shrink toward the centre. Not broken, a different sampler: DDIM is this with corrected coefficients, and gets 0.967 / 0.135 in 50 steps. |
make_schedule(5) | ᾱT = 0.9506, noise fraction 0.222. xT still looks like the ring, but sampling starts from N(0, 1) anyway: spread 0.216 versus 0.100. Start the same model from the true xT and it gets 0.134. The starting point was the bug, not the sampler. |
NoisePredictor(use_t=False) | Held-out loss plateaus at 0.2358 instead of 0.1918. At t ≤ 50 it scores 1.1272, worse than predicting zero. Samples: mean radius 0.679, spread 0.286. Smeared. |
train(model, data, alpha_bar, target="x0"), sampler unchanged | Mean radius 62.063. The sampler subtracts what it's handed as noise; hand it a guess at x0 and every step removes the wrong thing. Convert x̂0 to ε̂ first and it works again (1.024 / 0.076). |
On the maths anxiety
The one fact this paper runs on: add two independent Gaussians and the
variances add. That is the entire closed-form jump, and why the square roots
are there. Past that it's a prefix product, an MSE and a for loop.
What you don't need: the variational bound in §2 and §3 (three pages of KL terms whose punchline is "use MSE"), score matching, Langevin dynamics, the SDE view that came a year later. All real; none needed to ship a diffusion model or explain one in an interview.
What separates engineers on diffusion work is elsewhere: sampler and step count against a latency budget, guidance scale against prompt fidelity, VAE artefacts, and knowing the noise schedule is a hyperparameter you may change.
Where to go next
This page gave you one thing: diffusion as a loop you learn to reverse, in code you can break. Two other people cover what I skipped.
-
Denoising Diffusion Probabilistic Models
§2 and §3.4 first: equation 4, Algorithms 1 and 2, equation 14. Then the ablation table in §4, the evidence behind "predict ε with the simple loss".
-
What are Diffusion Models?
Everything I called scaffolding, derived line by line: the posterior q(xt−1 | xt, x0), the bound, how the simple loss falls out of it. Go here when "trust me, the variances add" isn't enough.
-
The Illustrated Stable Diffusion
My Stable Diffusion section as pictures: text encoder, latent grid, U-Net with cross-attention inside it, decoder at the end, drawn as boxes with tensors flowing between them.