Auto-Encoding Variational Bayes (the VAE)
An autoencoder whose bottleneck is a bell curve instead of a number. Explained as code, because that's how I finally read it.
Download vae_from_scratch.py — everything below runs from it. NumPy for the mechanism, plus a tiny CPU PyTorch model for the training step.
I'm an AI Engineer. I've shipped a VAE without noticing — Stable Diffusion's pixels come out of a VAE decoder — and I still couldn't read this paper. Pages of Bayesian notation, and the two ideas that matter are three lines of arithmetic each. Nobody told me the rest is a proof that those three lines are allowed.
A VAE is lossy compression with a rule about the codes
You've built this:
code = compress(x) # squeeze the input down to a few numbers
x_hat = decompress(code) # rebuild it
loss = how_far_off(x, x_hat)
That's an autoencoder. It compresses fine. What it cannot do is generate: hand the decompressor a random code and you get junk, because the codes live wherever the compressor felt like putting them — a shape nobody wrote down, in a region nobody can find again.
The VAE makes two changes:
mu, log_var = encoder(x) # not a code: a bell curve of codes
z = mu + exp(0.5 * log_var) * randn() # pick one code from that curve
x_hat = decoder(z)
loss = how_far_off(x, x_hat) + kl(mu, log_var) # + "codes must look like N(0,1)"
The compressor returns a distribution, and there's a tax whenever that distribution doesn't look like standard normal noise. Pay the tax all through training and afterwards any standard normal noise you draw is a valid code: decode it and you get a new, plausible input. That's the generative trick.
| Term | Means | Programmer's analogy |
|---|---|---|
| Encoder q(z|x) | Takes x, returns (μ, log σ²): the centre and width of a bell curve | A compressor that returns a range, not a value |
| Decoder p(x|z) | Takes a code z, rebuilds x | The decompressor. An ordinary MLP |
| Latent z | One code, sampled from the bell curve | The compressed bytes |
| Prior p(z) = N(0, I) | What codes are supposed to look like | The schema the codes must validate against |
| KL term | How far the encoder's bell curve is from N(0, I) | The linter that fails the build when a code breaks the schema |
The one formula
The right-hand side is the ELBO, the bound in section 2.2 of the paper. Training maximises it, so the loss is its negative: reconstruction plus tax. Two helpers make it computable:
Read it as pipeline stages:
| Step | Shape | What it does |
|---|---|---|
encoder(x) | (n, dz) twice | Two heads on one MLP: μ and log σ². A bell curve per input |
ε ~ N(0, I) | (n, dz) | The dice. Drawn outside the model. No parameters, no gradient |
z = μ + exp(½ log σ²) ε | (n, dz) | The trick. Add and multiply. Backprop walks through this |
decoder(z) | (n, dx) | Rebuild the input from the code |
‖x − x̂‖² / 2σx² | (n,) | Reconstruction. −log p(x|z) for a Gaussian decoder — MSE is a likelihood in disguise |
−½ Σ(1 + log σ² − μ² − σ²) | (n,) | The tax. KL to N(0, I), closed form, the paper's Appendix B |
mean(recon + KL) | scalar | −ELBO. Then loss.backward() |
One thing the table hides. A decoder whose output is the mean of a Gaussian with fixed std σx has −log p(x|z) = squared error / 2σx² plus a constant. So "MSE" is the paper's likelihood term, and σx is the exchange rate between reconstruction and tax. I fixed it at 0.1; change it and you've changed the KL weight without touching the KL.
The trick: move the dice outside the function
You want z drawn from N(μ, σ²), and you want loss.backward() to
reach μ and σ. But rng.normal(mu, sigma) is a black box: the
randomness happens inside it, so there's no function of μ to
differentiate. Nudge μ and z moves by a random amount, not by a derivative.
The fix is to draw the randomness first, from a distribution with no parameters,
then turn it into z with arithmetic: eps = randn(), then
z = mu + sigma * eps. Same distribution for z. But now dz/dμ = 1
and dz/dσ = ε, and backprop walks straight through. That's section 2.4, and it's
the reason the paper exists: the older score-function estimator "exhibits very
high variance", in the paper's own words, and this replaces it with plain
backprop. The other contribution the introduction lists is the encoder itself —
a recognition model that answers inference in one forward pass, instead
of an optimisation loop per datapoint. A cache for the expensive per-item
computation.
Don't take my word for it. Here's the actual output:
want N( 1.50, 0.40) 100000 draws of mu + sigma*eps: mean 1.500 std 0.401
want N(-2.00, 3.00) 100000 draws of mu + sigma*eps: mean -1.998 std 3.003
now the gradient. dz/dmu by finite difference, (z(mu+h) - z(mu)) / h
'direct' = two separate rng.normal(mu, sigma) calls, fresh dice each
h=0.1 reparam: 1.000 direct once: -2.786 direct rms/1000: 5.9
h=0.01 reparam: 1.000 direct once: 4.828 direct rms/1000: 57.0
h=0.001 reparam: 1.000 direct once: -247.014 direct rms/1000: 554.6
dz/dsigma, reparameterised: -1.224 and eps was -1.224
Shrink h and the direct version gets worse, not better — it's noise of
size σ divided by h; there is no derivative there to find. Reparameterised, it's
exactly 1, and dz/dσ is exactly the ε that was rolled. And the reason every
implementation says exp(0.5 * log_var): a raw network output can be
negative, a standard deviation can't, so the encoder emits log σ² and the exp
makes σ positive by construction.
Normal(mu, sigma).sample() and .rsample().
sample() returns a tensor with no grad_fn;
rsample() returns mu + sigma * eps with an
AddBackward0. The r is this paper.
The tax: KL to a standard normal, in one line
KL(q ‖ p) is "how surprised p would be by q's samples", in nats. For q = N(μ, σ²) against p = N(0, 1) it's the one-liner in the formula above: zero at μ = 0, σ = 1, bigger the further you move, summed over latent dimensions.
The closed form, checked against a 200,000-sample estimate:
mu sigma closed form monte carlo
0.0 1.0 0.0000 0.0000
1.0 1.0 0.5000 0.5001
0.0 2.0 0.8069 0.8105
0.0 0.5 0.3181 0.3172
2.0 0.5 2.3181 2.3174
0.0 0.1 1.8076 1.8064
3.0 1.0 4.5000 4.5004
The row to stare at is (0.0, 0.1). A narrow bell curve is taxed too — 1.8 nats for shrinking σ to 0.1. That's what stops the VAE quietly turning itself back into a plain autoencoder with σ = 0. It has to pay for precision, and it will only pay where precision buys reconstruction.
Training: watch the two terms fight
The toy data is 512 points on a unit ring with a little radial noise — one-dimensional structure living in 2-D. Two-layer MLPs, a 2-D latent, Adam, full batch, 1500 steps on a CPU. One ε per datapoint per step, which section 2.3 of the paper says is enough when the batch is large.
step 0 loss 53.213 recon 53.199 kl 0.014 mse 1.0640
step 250 loss 4.206 recon 0.922 kl 3.284 mse 0.0184
step 500 loss 4.069 recon 0.917 kl 3.152 mse 0.0183
step 1000 loss 4.077 recon 0.886 kl 3.191 mse 0.0177
step 1500 loss 3.982 recon 0.750 kl 3.232 mse 0.0150
reconstruction mse, z = mu (no noise): 0.0033
reconstruction mse, z sampled from q(z|x): 0.0165
encoder's mean sigma 0.231, mean |mu| 0.878, KL per point 3.232 nats
Step 0 is worth a look. A fresh encoder already outputs roughly N(0, 1), so the tax is nearly zero (0.014) and the decoder predicts the origin for everything: mse 1.064, about the data's own spread. Then the encoder buys information — the KL climbs to about 3.2 nats and stays there, and reconstruction falls over 300× to 0.0033. The tax never reaches zero; it settles where the next nat of precision stops paying for itself.
The generative claim: noise in, ring out
This is the test a plain autoencoder fails. Draw 2000 z's from N(0, I) — numbers that have never seen the data — decode them, and measure the radius of what comes out. "on ring" is the fraction within 0.1 of radius 1.
radius mean radius std on ring
the data 0.996 0.047 0.969
raw noise, no decoder 1.260 0.666 0.126
decoded prior noise 0.947 0.122 0.856
Raw 2-D noise lands on the ring 12.6% of the time, by accident. Pass the same noise through the decoder and 85.6% lands. The decoder has only ever seen codes that came from real points; it works on fresh noise because the tax forced those codes to look like fresh noise.
β: the dial between compression and fidelity
The paper has no β; the KL weight is 1 because that's what the bound says. β-VAE (Higgins et al., 2017) made it a dial, and turning it is the fastest way to feel what the tax is for. Same model, data and steps. "mse" is reconstruction with z = μ; "recon std" is how spread out the reconstructions are.
mse kl sigma |mu| recon std on ring
beta=1 (the paper) 0.0033 3.232 0.231 0.88 0.703 0.856
beta=0, no KL 0.0002 11.069 0.005 1.07 0.704 0.189
beta=4 (beta-VAE) 0.0047 2.319 0.334 0.86 0.690 0.826
beta=50, huge KL 0.9084 0.036 0.981 0.14 0.033 0.000
(data itself) 0.704 0.969
(raw noise, undecoded) 0.126
β = 0 is the plain autoencoder: best reconstruction, and prior noise lands on the ring 18.9% of the time — barely above the 12.6% with no decoder at all. β = 4 is the β-VAE regime: samples still land, at the price of a 40% blurrier reconstruction; on this toy, push past about β = 8 and the samples go too. β = 50 is posterior collapse: σ → 1, μ → 0, and every reconstruction is nearly the same point.
நான் ஒரு AI Engineer. ஒரு VAE-ஐ கவனிக்காமலேயே ship செய்திருக்கிறேன் — Stable Diffusion-ன் pixels ஒரு VAE decoder-லிருந்துதான் வருகின்றன — இருந்தும் இந்தக் கட்டுரையைப் படிக்க முடியவில்லை. பக்கம் பக்கமாக Bayesian notation, ஆனால் முக்கியமான இரண்டு கருத்துகளும் தலா மூன்று வரிக் கணக்கு. மீதி எல்லாம் அந்த மூன்று வரிகள் அனுமதிக்கப்பட்டவை என்பதற்கான ஒரு நிரூபணம் என்று யாரும் சொல்லவில்லை.
VAE என்பது codes பற்றி ஒரு விதியுடன் கூடிய lossy compression
இதை நீங்கள் எழுதியிருக்கிறீர்கள்:
code = compress(x) # input-ஐ சில எண்களாகச் சுருக்கு
x_hat = decompress(code) # மீண்டும் கட்டியெழுப்பு
loss = how_far_off(x, x_hat)
இதுதான் ஒரு autoencoder. சுருக்குவது நன்றாகவே செய்யும். ஆனால் இதனால் generate செய்ய முடியாது: decompressor-க்கு ஒரு random code கொடுத்தால் குப்பைதான் வரும், ஏனெனில் codes எங்கே இருக்கின்றன என்பதை compressor-ன் விருப்பம்தான் தீர்மானிக்கிறது — யாரும் எழுதி வைக்காத ஒரு வடிவம், யாரும் மீண்டும் கண்டுபிடிக்க முடியாத ஒரு பகுதி.
VAE இரண்டு மாற்றங்களைச் செய்கிறது:
mu, log_var = encoder(x) # code அல்ல: codes-ன் ஒரு மணி வளைவு
z = mu + exp(0.5 * log_var) * randn() # அந்த வளைவிலிருந்து ஒரு code எடு
x_hat = decoder(z)
loss = how_far_off(x, x_hat) + kl(mu, log_var) # + "codes N(0,1) போல் இருக்கணும்"
Compressor ஒரு distribution-ஐத் திருப்பித் தருகிறது; அந்த distribution standard normal noise போல் இல்லாதபோதெல்லாம் ஒரு வரி விழுகிறது. Training முழுக்க அந்த வரியைக் கட்டுங்கள் — பிறகு நீங்கள் எடுக்கும் எந்த standard normal noise-ம் ஒரு செல்லுபடியான code: அதை decode செய்தால் புதிய, நம்பத்தகுந்த ஒரு input கிடைக்கும். இதுதான் generative தந்திரம்.
Autoencoder compress pannum, aana generate panna mudiyaadhu — codes engeyo kidakkum. VAE oru bell curve-ai return pannudhu, plus "codes N(0,1) maadhiri irukkanum"-nu oru tax. Adhanaala random noise-um valid code aagidum.
| சொல் | அர்த்தம் | Programmer-ன் உவமை |
|---|---|---|
| Encoder q(z|x) | x-ஐ எடுத்து (μ, log σ²) தருகிறது: ஒரு மணி வளைவின் மையமும் அகலமும் | ஒரு மதிப்பை அல்ல, ஒரு வரம்பைத் திருப்பித் தரும் compressor |
| Decoder p(x|z) | ஒரு code z-ஐ எடுத்து x-ஐ மீண்டும் கட்டுகிறது | Decompressor. ஒரு சாதாரண MLP |
| Latent z | மணி வளைவிலிருந்து sample செய்யப்பட்ட ஒரு code | சுருக்கப்பட்ட bytes |
| Prior p(z) = N(0, I) | Codes எப்படி இருக்க வேண்டும் என்பது | Codes validate ஆக வேண்டிய schema |
| KL term | Encoder-ன் மணி வளைவு N(0, I)-லிருந்து எவ்வளவு தூரம் | Code schema-வை மீறினால் build-ஐ தோற்கடிக்கும் linter |
அந்த ஒரு சூத்திரம்
வலது பக்கம்தான் ELBO — கட்டுரையின் section 2.2-ல் உள்ள bound. Training அதை அதிகரிக்கிறது, எனவே loss என்பது அதன் எதிர்மறை: reconstruction கூட்டல் வரி. இதைக் கணக்கிடக்கூடியதாக்க இரண்டு உதவியாளர்கள்:
இதை pipeline நிலைகளாகப் படியுங்கள்:
| நிலை | Shape | என்ன செய்கிறது |
|---|---|---|
encoder(x) | (n, dz) இரு முறை | ஒரே MLP-க்கு இரண்டு heads: μ மற்றும் log σ². ஒவ்வொரு input-க்கும் ஒரு மணி வளைவு |
ε ~ N(0, I) | (n, dz) | பகடை. Model-க்கு வெளியே உருட்டப்படுகிறது. Parameters இல்லை, gradient இல்லை |
z = μ + exp(½ log σ²) ε | (n, dz) | அந்தத் தந்திரம். கூட்டல், பெருக்கல். Backprop இதன் வழியாக நடந்து செல்கிறது |
decoder(z) | (n, dx) | Code-லிருந்து input-ஐ மீண்டும் கட்டு |
‖x − x̂‖² / 2σx² | (n,) | Reconstruction. Gaussian decoder-க்கு −log p(x|z) — MSE என்பது மாறுவேடத்தில் ஒரு likelihood |
−½ Σ(1 + log σ² − μ² − σ²) | (n,) | வரி. N(0, I)-க்கான KL, closed form, கட்டுரையின் Appendix B |
mean(recon + KL) | scalar | −ELBO. பிறகு loss.backward() |
Table மறைக்கும் ஒரு விஷயம். நிலையான std σx கொண்ட ஒரு Gaussian-ன் சராசரியை output ஆகத் தரும் decoder-க்கு −log p(x|z) = வர்க்கப் பிழை / 2σx² கூட்டல் ஒரு மாறிலி. எனவே "MSE" என்பதுதான் கட்டுரையின் likelihood term, மேலும் σx என்பது reconstruction-க்கும் வரிக்கும் இடையிலான மாற்று விகிதம். நான் அதை 0.1-ல் நிலைநிறுத்தினேன்; அதை மாற்றினால் KL-ஐத் தொடாமலேயே KL weight-ஐ மாற்றிவிட்டீர்கள்.
தந்திரம்: பகடையை function-க்கு வெளியே நகர்த்து
z-ஐ N(μ, σ²)-லிருந்து எடுக்க வேண்டும், மேலும் loss.backward()
μ மற்றும் σ வரை போக வேண்டும். ஆனால் rng.normal(mu, sigma) ஒரு
கருப்புப் பெட்டி: randomness அதற்கு உள்ளேயே நடக்கிறது, எனவே
differentiate செய்ய μ-வின் function எதுவும் இல்லை. μ-ஐ சிறிது நகர்த்தினால்
z ஒரு தற்செயலான அளவு நகரும், derivative-ன்படி அல்ல.
தீர்வு: முதலில் randomness-ஐ parameters இல்லாத ஒரு distribution-லிருந்து
எடுங்கள், பிறகு அதைக் கணக்கால் z ஆக மாற்றுங்கள்: eps = randn(),
பிறகு z = mu + sigma * eps. z-க்கு அதே distribution. ஆனால் இப்போது
dz/dμ = 1, dz/dσ = ε, மேலும் backprop நேராக நடந்து செல்கிறது. இதுதான் section
2.4, இதற்காகத்தான் இந்தக் கட்டுரையே இருக்கிறது: பழைய score-function estimator
"exhibits very high variance" என்று கட்டுரையே சொல்கிறது, இது அதற்குப் பதிலாக
சாதாரண backprop-ஐ வைக்கிறது. Introduction பட்டியலிடும் மற்றொரு பங்களிப்பு
encoder-ஏதான் — ஒவ்வொரு datapoint-க்கும் ஒரு optimisation loop ஓட்டுவதற்குப்
பதிலாக, ஒரே forward pass-ல் inference-க்குப் பதில் சொல்லும் ஒரு
recognition model. விலையுயர்ந்த per-item கணக்கீட்டுக்கு ஒரு cache.
Random number generator-oda ulla backprop panna mudiyaadhu. So dice-ai veliya urutti, z = mu + sigma*eps-nu arithmetic-la maathunga. dz/dmu = 1, dz/dsigma = eps. Idhu dhaan reparameterisation trick.
என் வார்த்தையை நம்ப வேண்டாம். இதோ உண்மையான output:
want N( 1.50, 0.40) 100000 draws of mu + sigma*eps: mean 1.500 std 0.401
want N(-2.00, 3.00) 100000 draws of mu + sigma*eps: mean -1.998 std 3.003
now the gradient. dz/dmu by finite difference, (z(mu+h) - z(mu)) / h
'direct' = two separate rng.normal(mu, sigma) calls, fresh dice each
h=0.1 reparam: 1.000 direct once: -2.786 direct rms/1000: 5.9
h=0.01 reparam: 1.000 direct once: 4.828 direct rms/1000: 57.0
h=0.001 reparam: 1.000 direct once: -247.014 direct rms/1000: 554.6
dz/dsigma, reparameterised: -1.224 and eps was -1.224
h-ஐச் சுருக்கினால் direct பதிப்பு மோசமாகிறது, நன்றாக ஆவதில்லை —
அது σ அளவுள்ள noise-ஐ h-ஆல் வகுத்தது; அங்கே கண்டுபிடிக்க derivative எதுவும்
இல்லை. Reparameterise செய்தால் அது சரியாக 1, மேலும் dz/dσ என்பது உருட்டப்பட்ட
அதே ε. ஒவ்வொரு implementation-ம் exp(0.5 * log_var) என்று
சொல்வதற்குக் காரணம்: network-ன் raw output எதிர்மறையாக இருக்கலாம், standard
deviation எதிர்மறையாக இருக்க முடியாது; எனவே encoder log σ²-ஐத் தருகிறது,
exp அதை அமைப்பாலேயே நேர்மறையாக்குகிறது.
Normal(mu, sigma).sample()-க்கும்
.rsample()-க்கும் உள்ள வேறுபாடு.
sample() என்பது grad_fn இல்லாத ஒரு tensor-ஐத்
தருகிறது; rsample() என்பது AddBackward0-உடன்
mu + sigma * eps-ஐத் தருகிறது. அந்த r இந்தக் கட்டுரைதான்.
வரி: standard normal-க்கான KL, ஒரே வரியில்
KL(q ‖ p) என்பது "q-ன் samples-ஐப் பார்த்து p எவ்வளவு ஆச்சரியப்படும்", nats-ல். q = N(μ, σ²)-ஐ p = N(0, 1)-க்கு எதிராக வைத்தால் அது மேலே உள்ள ஒரு-வரி சூத்திரம்: μ = 0, σ = 1-ல் பூஜ்யம், நகர நகர பெரிதாகும், latent dimensions முழுக்கக் கூட்டப்படுகிறது.
Closed form, 200,000-sample மதிப்பீட்டுக்கு எதிராகச் சரிபார்க்கப்பட்டது:
mu sigma closed form monte carlo
0.0 1.0 0.0000 0.0000
1.0 1.0 0.5000 0.5001
0.0 2.0 0.8069 0.8105
0.0 0.5 0.3181 0.3172
2.0 0.5 2.3181 2.3174
0.0 0.1 1.8076 1.8064
3.0 1.0 4.5000 4.5004
உற்றுப் பார்க்க வேண்டிய வரிசை (0.0, 0.1). ஒரு குறுகிய மணி வளைவுக்கும் வரி உண்டு — σ-ஐ 0.1 ஆகச் சுருக்கியதற்கு 1.8 nats. VAE அமைதியாகத் தன்னை σ = 0 கொண்ட சாதாரண autoencoder ஆக மாற்றிக்கொள்வதை இதுதான் தடுக்கிறது. துல்லியத்துக்கு அது விலை கொடுக்க வேண்டும், மேலும் துல்லியம் reconstruction-ஐ வாங்கித் தரும் இடத்தில் மட்டுமே அது கொடுக்கும்.
Training: இரு terms சண்டையிடுவதைப் பாருங்கள்
Toy data என்பது ஒரு unit ring-ல் சிறிது radial noise-உடன் உள்ள 512 புள்ளிகள் — 2-D-ல் வாழும் ஒரு-பரிமாண அமைப்பு. இரண்டு-layer MLP-கள், 2-D latent, Adam, full batch, CPU-ல் 1500 steps. ஒவ்வொரு datapoint-க்கும் ஒவ்வொரு step-க்கும் ஒரு ε — batch பெரியதாக இருக்கும்போது அது போதும் என்று கட்டுரையின் section 2.3 சொல்கிறது.
step 0 loss 53.213 recon 53.199 kl 0.014 mse 1.0640
step 250 loss 4.206 recon 0.922 kl 3.284 mse 0.0184
step 500 loss 4.069 recon 0.917 kl 3.152 mse 0.0183
step 1000 loss 4.077 recon 0.886 kl 3.191 mse 0.0177
step 1500 loss 3.982 recon 0.750 kl 3.232 mse 0.0150
reconstruction mse, z = mu (no noise): 0.0033
reconstruction mse, z sampled from q(z|x): 0.0165
encoder's mean sigma 0.231, mean |mu| 0.878, KL per point 3.232 nats
Step 0-ஐப் பார்ப்பது மதிப்புள்ளது. புதிய encoder ஏற்கனவே ஏறக்குறைய N(0, 1)-ஐத் தருகிறது, எனவே வரி கிட்டத்தட்ட பூஜ்யம் (0.014), மேலும் decoder எல்லாவற்றுக்கும் origin-ஐக் கணிக்கிறது: mse 1.064, அதாவது data-வின் சொந்த பரவல் அளவு. பிறகு encoder தகவலை விலைக்கு வாங்குகிறது — KL சுமார் 3.2 nats வரை ஏறி அங்கேயே நிற்கிறது, reconstruction 300× மேல் குறைந்து 0.0033 ஆகிறது. வரி ஒருபோதும் பூஜ்யத்தை அடைவதில்லை; அடுத்த nat துல்லியம் தன் விலைக்கு ஈடாகாத இடத்தில் அது நிலைபெறுகிறது.
Generative உரிமைகோரல்: noise உள்ளே, ring வெளியே
சாதாரண autoencoder தோற்கும் சோதனை இதுதான். N(0, I)-லிருந்து 2000 z-களை எடுங்கள் — data-வையே பார்த்திராத எண்கள் — அவற்றை decode செய்து, வெளிவருவதன் ஆரத்தை அளவிடுங்கள். "on ring" என்பது ஆரம் 1-லிருந்து 0.1-க்குள் இருக்கும் பங்கு.
radius mean radius std on ring
the data 0.996 0.047 0.969
raw noise, no decoder 1.260 0.666 0.126
decoded prior noise 0.947 0.122 0.856
Raw 2-D noise தற்செயலாக 12.6% நேரம் ring-ல் விழுகிறது. அதே noise-ஐ decoder வழியாக அனுப்பினால் 85.6% விழுகிறது. Decoder இதுவரை உண்மையான புள்ளிகளிலிருந்து வந்த codes-ஐ மட்டுமே பார்த்திருக்கிறது; புதிய noise-ல் அது வேலை செய்கிறது, ஏனெனில் அந்த codes புதிய noise போலவே இருக்க வேண்டும் என்று வரி கட்டாயப்படுத்தியது.
β: compression-க்கும் fidelity-க்கும் இடையிலான dial
கட்டுரையில் β இல்லை; KL weight 1, ஏனெனில் bound அப்படித்தான் சொல்கிறது. β-VAE (Higgins et al., 2017) அதை ஒரு dial ஆக்கியது, அதைத் திருப்புவதுதான் வரி எதற்கு என்பதை உணர மிக விரைவான வழி. அதே model, data, steps. "mse" என்பது z = μ வைத்த reconstruction; "recon std" என்பது reconstructions எவ்வளவு பரவியுள்ளன என்பது.
mse kl sigma |mu| recon std on ring
beta=1 (the paper) 0.0033 3.232 0.231 0.88 0.703 0.856
beta=0, no KL 0.0002 11.069 0.005 1.07 0.704 0.189
beta=4 (beta-VAE) 0.0047 2.319 0.334 0.86 0.690 0.826
beta=50, huge KL 0.9084 0.036 0.981 0.14 0.033 0.000
(data itself) 0.704 0.969
(raw noise, undecoded) 0.126
β = 0 என்பது சாதாரண autoencoder: சிறந்த reconstruction, ஆனால் prior noise 18.9% நேரம் மட்டுமே ring-ல் விழுகிறது — decoder-ஏ இல்லாத 12.6%-ஐ விடச் சற்றே அதிகம். β = 4 என்பது β-VAE நிலை: samples இன்னும் விழுகின்றன, ஆனால் 40% மங்கலான reconstruction விலையில்; இந்த toy-ல் β = 8-ஐத் தாண்டித் தள்ளினால் samples-ம் போய்விடும். β = 50 என்பது posterior collapse: σ → 1, μ → 0, மேலும் ஒவ்வொரு reconstruction-ம் கிட்டத்தட்ட ஒரே புள்ளிதான்.
मैं एक AI Engineer हूँ। मैंने बिना जाने ही एक VAE ship कर दिया था — Stable Diffusion के pixels एक VAE decoder से ही निकलते हैं — फिर भी यह paper मुझसे पढ़ा नहीं जाता था। पन्नों भर Bayesian notation, और जो दो विचार असल में मायने रखते हैं वे तीन-तीन लाइन का गणित हैं। किसी ने नहीं बताया कि बाक़ी सब इस बात का प्रमाण है कि वे तीन लाइनें जायज़ हैं।
VAE यानी lossy compression, codes पर एक नियम के साथ
यह आपने बनाया है:
code = compress(x) # input को कुछ संख्याओं में निचोड़ो
x_hat = decompress(code) # उसे फिर से बनाओ
loss = how_far_off(x, x_hat)
यह एक autoencoder है। compress ठीक करता है। जो यह नहीं कर सकता वह है generate: decompressor को कोई random code दीजिए और कचरा मिलेगा, क्योंकि codes वहीं पड़े हैं जहाँ compressor का मन हुआ — एक ऐसा आकार जो किसी ने लिखा नहीं, एक ऐसे इलाक़े में जिसे कोई दोबारा ढूँढ नहीं सकता।
VAE दो बदलाव करता है:
mu, log_var = encoder(x) # code नहीं: codes का एक घंटी-वक्र
z = mu + exp(0.5 * log_var) * randn() # उस वक्र से एक code चुनो
x_hat = decoder(z)
loss = how_far_off(x, x_hat) + kl(mu, log_var) # + "codes N(0,1) जैसे दिखें"
compressor एक distribution लौटाता है, और जब भी वह distribution standard normal noise जैसा नहीं दिखता, एक टैक्स लगता है। पूरी training में वह टैक्स चुकाइए, और उसके बाद आप जो भी standard normal noise निकालें वह एक वैध code है: उसे decode कीजिए और एक नया, विश्वसनीय input मिलेगा। यही generative चाल है।
| शब्द | मतलब | Programmer की उपमा |
|---|---|---|
| Encoder q(z|x) | x लेता है, (μ, log σ²) लौटाता है: घंटी-वक्र का केंद्र और चौड़ाई | ऐसा compressor जो एक मान नहीं, एक रेंज लौटाए |
| Decoder p(x|z) | एक code z लेकर x को फिर से बनाता है | decompressor। एक साधारण MLP |
| Latent z | घंटी-वक्र से sample किया गया एक code | compress हुए bytes |
| Prior p(z) = N(0, I) | codes को कैसा दिखना चाहिए | वह schema जिस पर codes को खरा उतरना है |
| KL term | encoder का घंटी-वक्र N(0, I) से कितनी दूर है | वह linter जो schema टूटने पर build fail कर देता है |
वह एक सूत्र
दाईं ओर वाला हिस्सा ELBO है, paper के section 2.2 वाला bound. Training उसे अधिकतम करती है, इसलिए loss उसका ऋणात्मक है: reconstruction जमा टैक्स। इसे गणनीय बनाने के लिए दो सहायक:
इसे pipeline के चरणों की तरह पढ़िए:
| चरण | Shape | यह क्या करता है |
|---|---|---|
encoder(x) | (n, dz) दो बार | एक ही MLP पर दो heads: μ और log σ². हर input के लिए एक घंटी-वक्र |
ε ~ N(0, I) | (n, dz) | पासा। model के बाहर फेंका गया। कोई parameter नहीं, कोई gradient नहीं |
z = μ + exp(½ log σ²) ε | (n, dz) | वह चाल। जोड़ और गुणा। Backprop इसमें से होकर चलता है |
decoder(z) | (n, dx) | code से input को फिर से बनाओ |
‖x − x̂‖² / 2σx² | (n,) | Reconstruction. Gaussian decoder के लिए −log p(x|z) — MSE भेस बदले हुए likelihood है |
−½ Σ(1 + log σ² − μ² − σ²) | (n,) | टैक्स। N(0, I) तक KL, closed form, paper का Appendix B |
mean(recon + KL) | scalar | −ELBO। फिर loss.backward() |
एक बात यह table छिपा जाती है। जिस decoder का output किसी Gaussian का माध्य है और जिसका std σx स्थिर है, उसके लिए −log p(x|z) = वर्ग त्रुटि / 2σx² जमा एक स्थिरांक। यानी "MSE" ही paper का likelihood term है, और σx reconstruction तथा टैक्स के बीच की विनिमय दर है। मैंने उसे 0.1 पर स्थिर रखा; उसे बदलिए और आपने KL को छुए बिना KL weight बदल दिया।
वह चाल: पासे को function के बाहर ले जाओ
आप चाहते हैं कि z, N(μ, σ²) से निकले, और loss.backward() μ तथा σ
तक पहुँचे। लेकिन rng.normal(mu, sigma) एक black box है:
randomness उसके अंदर होती है, इसलिए differentiate करने के लिए μ का
कोई function ही नहीं। μ को हिलाइए और z किसी यादृच्छिक मात्रा से हिलता है,
किसी derivative से नहीं।
हल यह है कि randomness पहले निकालो, ऐसे distribution से जिसमें कोई parameter
न हो, फिर उसे अंकगणित से z बनाओ: eps = randn(), फिर
z = mu + sigma * eps। z का distribution वही रहता है। पर अब
dz/dμ = 1 और dz/dσ = ε, और backprop सीधे इसमें से गुज़र जाता है। यही section
2.4 है, और यही वजह है कि यह paper मौजूद है: पुराना score-function estimator
"exhibits very high variance" — paper के अपने शब्दों में — और यह उसकी जगह
सादा backprop रख देता है। introduction में गिनाया गया दूसरा योगदान खुद
encoder है — एक recognition model जो हर datapoint पर एक optimisation
loop चलाने के बजाय एक ही forward pass में inference का जवाब दे देता है।
महँगी per-item गणना के लिए एक cache।
मेरी बात पर मत जाइए। यह रहा असली output:
want N( 1.50, 0.40) 100000 draws of mu + sigma*eps: mean 1.500 std 0.401
want N(-2.00, 3.00) 100000 draws of mu + sigma*eps: mean -1.998 std 3.003
now the gradient. dz/dmu by finite difference, (z(mu+h) - z(mu)) / h
'direct' = two separate rng.normal(mu, sigma) calls, fresh dice each
h=0.1 reparam: 1.000 direct once: -2.786 direct rms/1000: 5.9
h=0.01 reparam: 1.000 direct once: 4.828 direct rms/1000: 57.0
h=0.001 reparam: 1.000 direct once: -247.014 direct rms/1000: 554.6
dz/dsigma, reparameterised: -1.224 and eps was -1.224
h को छोटा कीजिए और direct वाला संस्करण और बिगड़ता है, सुधरता नहीं —
वह σ आकार का noise है जिसे h से भाग दिया गया है; वहाँ खोजने को कोई derivative
है ही नहीं। reparameterised रूप में वह ठीक 1 है, और dz/dσ ठीक वही ε है जो
फेंका गया था। और हर implementation exp(0.5 * log_var) इसलिए
लिखता है: network का कच्चा output ऋणात्मक हो सकता है, standard deviation नहीं;
इसलिए encoder log σ² देता है और exp उसे रचना से ही धनात्मक बना देता है।
Normal(mu, sigma).sample() और
.rsample() का अंतर है।
sample() ऐसा tensor लौटाता है जिसमें grad_fn नहीं
होता; rsample() AddBackward0 के साथ
mu + sigma * eps लौटाता है। वह r यही paper है।
टैक्स: standard normal तक KL, एक लाइन में
KL(q ‖ p) यानी "q के samples देखकर p कितना चौंकेगा", nats में। q = N(μ, σ²) बनाम p = N(0, 1) के लिए यह ऊपर वाला एक-लाइन सूत्र है: μ = 0, σ = 1 पर शून्य, जितना दूर जाइए उतना बड़ा, और latent dimensions पर जोड़ा हुआ।
closed form, 200,000-sample अनुमान से मिलाकर देखा गया:
mu sigma closed form monte carlo
0.0 1.0 0.0000 0.0000
1.0 1.0 0.5000 0.5001
0.0 2.0 0.8069 0.8105
0.0 0.5 0.3181 0.3172
2.0 0.5 2.3181 2.3174
0.0 0.1 1.8076 1.8064
3.0 1.0 4.5000 4.5004
घूरकर देखने लायक पंक्ति है (0.0, 0.1)। एक सँकरे घंटी-वक्र पर भी टैक्स लगता है — σ को 0.1 तक सिकोड़ने के 1.8 nats। यही VAE को चुपचाप σ = 0 वाला सादा autoencoder बन जाने से रोकता है। उसे परिशुद्धता की क़ीमत चुकानी पड़ती है, और वह सिर्फ़ वहीं चुकाएगा जहाँ परिशुद्धता से reconstruction मिले।
Training: दोनों terms को लड़ते देखिए
Toy data है एक unit ring पर 512 बिंदु, थोड़े radial noise के साथ — 2-D में रहती एक-आयामी संरचना। दो-layer MLPs, 2-D latent, Adam, full batch, CPU पर 1500 steps। हर datapoint के लिए हर step पर एक ε — paper का section 2.3 कहता है कि batch बड़ा हो तो इतना काफ़ी है।
step 0 loss 53.213 recon 53.199 kl 0.014 mse 1.0640
step 250 loss 4.206 recon 0.922 kl 3.284 mse 0.0184
step 500 loss 4.069 recon 0.917 kl 3.152 mse 0.0183
step 1000 loss 4.077 recon 0.886 kl 3.191 mse 0.0177
step 1500 loss 3.982 recon 0.750 kl 3.232 mse 0.0150
reconstruction mse, z = mu (no noise): 0.0033
reconstruction mse, z sampled from q(z|x): 0.0165
encoder's mean sigma 0.231, mean |mu| 0.878, KL per point 3.232 nats
Step 0 देखने लायक है। नया-नवेला encoder पहले से ही लगभग N(0, 1) देता है, इसलिए टैक्स लगभग शून्य है (0.014) और decoder हर चीज़ के लिए origin बताता है: mse 1.064, यानी क़रीब-क़रीब data का अपना फैलाव। फिर encoder जानकारी ख़रीदता है — KL चढ़कर लगभग 3.2 nats पर टिक जाता है, और reconstruction 300× से ज़्यादा गिरकर 0.0033 हो जाता है। टैक्स कभी शून्य नहीं होता; वह वहाँ ठहरता है जहाँ परिशुद्धता का अगला nat अपनी क़ीमत नहीं निकाल पाता।
Generative दावा: noise अंदर, ring बाहर
यही वह परीक्षा है जिसमें सादा autoencoder फेल होता है। N(0, I) से 2000 z निकालिए — ऐसी संख्याएँ जिन्होंने data कभी देखा ही नहीं — उन्हें decode कीजिए, और जो निकले उसकी त्रिज्या नापिए। "on ring" यानी वह अंश जो त्रिज्या 1 के 0.1 के भीतर है।
radius mean radius std on ring
the data 0.996 0.047 0.969
raw noise, no decoder 1.260 0.666 0.126
decoded prior noise 0.947 0.122 0.856
कच्चा 2-D noise संयोग से 12.6% बार ring पर गिरता है। वही noise decoder से गुज़ारिए तो 85.6% गिरता है। decoder ने आज तक सिर्फ़ ऐसे codes देखे हैं जो असली बिंदुओं से आए थे; वह ताज़े noise पर इसलिए काम करता है क्योंकि टैक्स ने उन codes को ताज़े noise जैसा दिखने पर मजबूर किया।
β: compression और fidelity के बीच का dial
paper में β है ही नहीं; KL का weight 1 है क्योंकि bound यही कहता है। β-VAE (Higgins et al., 2017) ने उसे एक dial बना दिया, और उसे घुमाना ही यह महसूस करने का सबसे तेज़ तरीक़ा है कि टैक्स किस लिए है। वही model, data और steps। "mse" यानी z = μ के साथ reconstruction; "recon std" यानी reconstructions कितनी फैली हुई हैं।
mse kl sigma |mu| recon std on ring
beta=1 (the paper) 0.0033 3.232 0.231 0.88 0.703 0.856
beta=0, no KL 0.0002 11.069 0.005 1.07 0.704 0.189
beta=4 (beta-VAE) 0.0047 2.319 0.334 0.86 0.690 0.826
beta=50, huge KL 0.9084 0.036 0.981 0.14 0.033 0.000
(data itself) 0.704 0.969
(raw noise, undecoded) 0.126
β = 0 सादा autoencoder है: सबसे अच्छा reconstruction, और prior noise 18.9% बार ring पर गिरता है — बिना किसी decoder वाले 12.6% से बस थोड़ा ऊपर। β = 4 β-VAE वाला इलाक़ा है: samples अब भी गिरते हैं, क़ीमत यह कि reconstruction 40% ज़्यादा धुँधला; इस toy पर β = 8 से आगे धकेलिए तो samples भी चले जाते हैं। β = 50 posterior collapse है: σ → 1, μ → 0, और हर reconstruction लगभग एक ही बिंदु।
The code
NumPy for the mechanism: the trick and the KL, three lines each. A tiny PyTorch
model, CPU only, trains seven variants in about 15 seconds. The torch version of
the trick is one line inside train_vae, with the deliberate bugs next
to it behind a flag.
Download the full script — about 200 lines plus four runnable demos. Then:
python3 vae_from_scratch.py
# ---------------------------------------------------------------------------
# STAGE 1 -- THE TRICK. Section 2.4 of the paper.
#
# You want z ~ N(mu, sigma^2), and you want gradients to flow back into mu and
# sigma. A call like rng.normal(mu, sigma) is a black box: the randomness
# happens INSIDE it, so there is no function of mu to differentiate.
#
# Fix: draw the randomness first, from a distribution that has no parameters,
# then turn it into z with plain arithmetic.
#
# eps ~ N(0, 1) # the dice roll. No parameters. No gradient.
# z = mu + sigma * eps # add and multiply. Backprop walks through this.
#
# dz/dmu = 1, dz/dsigma = eps. Same distribution for z, but now differentiable.
# The encoder outputs log(sigma^2), not sigma, so sigma = exp(0.5 * log_var)
# is positive by construction and can never go negative.
# ---------------------------------------------------------------------------
def reparameterise(mu, log_var, eps):
sigma = np.exp(0.5 * log_var)
return mu + sigma * eps
# ---------------------------------------------------------------------------
# STAGE 2 -- THE TAX. KL( N(mu, sigma^2) || N(0, 1) ), closed form, Appendix B.
#
# KL = -1/2 * sum( 1 + log(sigma^2) - mu^2 - sigma^2 )
#
# Zero when mu=0 and sigma=1. Grows as the bell curve moves or changes width.
# This is the penalty for a code not looking like standard normal noise.
# Summed over latent dimensions, because the dims are independent.
# ---------------------------------------------------------------------------
def kl_standard_normal(mu, log_var):
return -0.5 * np.sum(1 + log_var - mu ** 2 - np.exp(log_var), axis=-1)
SIGMA_X = 0.1
def elbo_loss(x, x_hat, mu, log_var, beta=1.0):
recon = ((x - x_hat) ** 2).sum(dim=-1) / (2 * SIGMA_X ** 2)
kl = -0.5 * (1 + log_var - mu ** 2 - log_var.exp()).sum(dim=-1)
return (recon + beta * kl).mean(), recon.mean(), kl.mean()
Now break it
This is the part that actually makes it stick. Reading won't do it. You debug for a living — reverse-engineering from broken behaviour is your native mode. Each row is one edit and the numbers it printed; demo 4 runs them all, side by side.
| Break this | Watch for |
|---|---|
Set beta=0.0 in train_vae | mse drops to 0.0002 — the best in the table — and "on ring" drops to 0.189. Perfect reconstructions, useless samples. σ has gone to 0.005. |
Replace z = mu + torch.exp(0.5 * log_var) * eps with z = mu | A plain autoencoder in a VAE costume. σ prints exactly 1.000, because it's unused and the KL parks it there for free. On ring: 0.013. |
Forget the exp: z = mu + log_var * eps | The thing multiplying ε is now a raw output. The network drives it to 0 to kill the noise: min −0.0049, mean −0.0007, a negative "standard deviation". The KL column reads 0.027 as if σ were 1. Two halves of the loss now disagree. |
Set beta=50.0 | Posterior collapse. KL 0.036, σ 0.981, |μ| 0.14: the encoder says nothing. Reconstruction spread 0.033 against the data's 0.704 — every output is nearly the same point. mse 0.9084. |
Draw ε once and reuse it: bug="fixed_eps" | mse 0.0130 with the ε it trained on, 0.0377 with any other — it fit the dice roll, not the distribution. The paper's estimator is unbiased only because ε is fresh every step. On this toy the prior samples still land (0.942); the 3× gap is the tell. |
On the maths anxiety
This paper needs four things. An expectation is an average over samples —
a for loop and a mean, which is all "Monte Carlo" means. A
log turns products into sums, which is why everything is a log-likelihood
and why MSE shows up. A Gaussian is two numbers per dimension. And
KL is a distance-ish score between two distributions which, for two
Gaussians, is a one-liner you copy from Appendix B. What you don't need is to
derive the bound: that's one step, Jensen's inequality, "log of an average ≥
average of logs", and you can use the loss for the rest of your career without
reproving it — the same way you use loss.backward() without deriving
backprop.
The interview question you'll be asked is "why the reparameterisation trick?" One sentence: you can't backprop through a random number generator, so move the sampling out to a parameter-free ε and make z a deterministic function of μ, σ and ε. The follow-up is "why are VAE samples blurry?" — the likelihood objective averages over everything consistent with a code. You've now seen both happen.
Where to go next
This page gave you one angle: the VAE as compression with a tax, in code you can break. Two people cover what I skipped — the notation and the derivation.
-
Auto-Encoding Variational Bayes
§2.4 is the trick, half a page. §2.2 and §2.3 are the bound and the estimator; Appendix B is the KL you printed; §3 is the model you just trained, on MNIST and Frey Face instead of a ring.
-
From Autoencoder to Beta-VAE
The derivation I skipped — where the ELBO comes from and why it's a lower bound — then keeps going past this paper to β-VAE, VQ-VAE and the rest of the family, so the dial I called β becomes a research line.
-
What is a variational autoencoder?
The same model explained twice, in deep-learning words (encoder, decoder, loss) and in probability words (inference, posterior), each mapped to the other. If the notation was the wall, this is the translation table.