Flow-Based Generative Models · UFRJ · 2026.2
The previous theory session (U0.T3) assembled the VAE on paper: latent variables, the ELBO and its gap, reparameterization.
Today it becomes ~80 lines of code, trained with the hygiene stack.
Then we look at it — and a \(d_z = 2\) VAE is one of the few generative models whose entire internal representation fits on one sheet of paper.
Produces vae-mnist-scratch — frozen at the end, because PS0 extends this exact code: your U0.L4 U-Net replaces the encoder.
U0.L2 was scripts, because the object of study was the run.
Today is lab-u0l3.ipynb, because the object of study is code you are still shaping — and half the session is pictures.
Training still calls the hygiene-stack Trainer and still logs to MLflow. The notebook imports common/; it never copies it.
d_z: 2 for the pictures · d_z: 16 for sample quality.
U0.T3 wrote \(p_\theta(x \mid z)\) and never chose a family. You must.
Bernoulli on binary pixels: decoder outputs one probability per pixel; \(-\log p_\theta(x\mid z)\) is a binary cross-entropy.
Gaussian: squared error, plus a \(\sigma^2\) you fix by hand (and silently reweight the ELBO) or learn (and watch run to zero).
We choose Bernoulli — so the data must be binary, and MNIST is not.
enc: Conv 1->32 s2 -> 32->64 s2 -> 64->128 s2
-> flatten -> two heads: mu (d_z), logvar (d_z)
dec: linear -> 128x4x4 -> ConvT x3 -> 784 logits
Three details are the U0.T2 recipe card, obeyed, not re-decided:
That last one pays out two slides from now.
The encoder returns \(\log \sigma_\varphi^2\), never \(\sigma_\varphi\).
A network head is an unconstrained linear map — it can emit any real number. But \(\sigma\) must be positive.
Parameterize the logarithm: every output is legal, and positivity lives in a single exp where it cannot be violated.
softplus/relu head \(\to\) can hand you \(\sigma = 0\), then a division,The pattern, which recurs: parameterize the unconstrained quantity and transform. (Variances, rates, mixing weights.)
\[ z = \mu_\varphi(x) + \exp\!\big(\tfrac12 \log\sigma^2_\varphi(x)\big) \odot \varepsilon, \qquad \varepsilon \sim \mathcal{N}(0, I_{d_z}) \]
The whole derivation of U0.T3 has collapsed into one line with a randn_like in it.
TODO B.3 (decoder part): return logits, not probabilities — pair with the with-logits BCE. Same rule as log_softmax in U0.T1, second costume.
Zero-init heads \(\Rightarrow\) \(\mu_\varphi \equiv 0\), \(\sigma_\varphi \equiv 1\), so \(z = \varepsilon\) exactly: the encoder starts as the prior.
The KL term starts at exactly zero and has to be earned — which is what makes the training curves readable later.
\[ \mathcal{L}(\theta,\varphi;x) = \underbrace{-\,\mathbb{E}_{\varepsilon \sim \mathcal{N}(0,I)} \big[\log p_\theta(x \mid z_\varphi(x,\varepsilon))\big]}_{\text{reconstruction}} \;+\; \underbrace{\mathrm{KL}\big(q_\varphi(z\mid x)\,\|\,p(z)\big)}_{\text{regularization}} \]
Both terms are computable. The session’s hardest bug is in how you add them up.
The loss is a per-example quantity: both terms are sums over their own dimensions. Only the batch gets a mean.
Reduce reconstruction by mean over pixels — PyTorch’s default — and you divided that term by \(784\) and left the KL alone.
That is not a rescaling. It is a different objective: a \(\beta\)-VAE with \(\beta = 784\), which nobody asked for.
Same model, same seed, only the reduction changed. KL falls to \(0.000\) nats in five epochs; all sixteen samples are one image — and nothing crashed: the loss fell smoothly, \(0.274 \to 0.264\).
\[ \mathrm{KL}\big(q_\varphi(z\mid x)\,\|\,p(z)\big) = \frac12 \sum_{j=1}^{d_z}\big(\mu_j^2 + \sigma_j^2 - 1 - \log\sigma_j^2\big) \]
Derived in UP.A; today it is a tool.
Return the (B, d_z) tensor. Sum at the call site.
Summing inside would be tidier and would throw away exactly what the last block of this session needs.
A single scalar cannot distinguish “reconstruction improved” from “the KL collapsed” — opposite events, same effect on the total.
Stated in U0.T2’s recipe card; from here it is enforced by the interface. The Trainer logs every key.
In U3 your loss is \(\mathbb{E}_{t,\,x_0,\,x_1}[\,\cdot\,]\) and this decomposition is the only cheap instrument you will have.
U0.L1’s rule: check a hand-written loss against finite differences in float64 before trusting a training step. But the ELBO is a random function — differencing two independent draws measures noise.
Reparameterization is why this is legal: randomness is an input.
Measured: max relative error \(1.6\times10^{-7}\). Good. But:
Zero-init heads \(\Rightarrow\) the gradient reaching any encoder body parameter is exactly \(0\) at step \(0\). Analytic \(0\), numeric \(0\), error reported as \(0.0\) — a perfect score from measuring nothing.
Take a few optimizer steps first; assert the gradient is nonzero before believing it agrees with anything.
A check that has never failed is not known to work. Drop the \(\tfrac12\) from the KL — a plausible typo — and re-run: relative error \(1.0\). Total disagreement, instantly.
Now you have seen it fire.
TODO D.1 (logging part): two per-epoch mlflow.log_figure calls — a reconstruction grid on a fixed validation batch, and a prior-sample grid from a fixed bank of \(\varepsilon\).
Both “fixed” are load-bearing: a fresh draw each epoch gives a slideshow where improvement and reshuffling look identical.
U0.T3’s tug-of-war, measured. The KL rises from zero — the encoder starts equal to the prior and buys information only as far as reconstruction pays for it.
\(d_z=2\): \(142.6 + 6.0\) nats · \(d_z=16\): \(73.1 + 24.6\) nats.
Each point is \(\mu_\varphi(x)\), colored by digit. The labels were never shown to the model — the only pressure is that the decoder must rebuild \(x\) from \(z\), so similar images land near each other.
Decoder on a lattice of Gaussian quantiles — no data involved.
Generative modelling in one sentence: learn a map from a simple noise distribution to the data distribution.
The previous slide is that map, drawn in full for \(d_z = 2\).
Everything ahead is a different answer to how to build the map:
The VAE’s map is a single feed-forward decoder, trained through a bound, and it is blurry. Keep the picture; we spend a semester on the mechanism.
Folklore: use slerp — the straight line dips toward the origin, through a low-norm region the decoder never saw. Half right: \(\|z\|\) concentrates with spread \(\sim\!1/\sqrt2\) for every \(d\), so the deficit must be measured in those units.
| midpoint of the path | \(d_z = 2\) | \(d_z = 16\) |
|---|---|---|
| straight line (lerp) | \(0.53\sigma\) below | \(2.07\sigma\) below |
| spherical (slerp) | \(2.10\sigma\) above | \(0.32\sigma\) above |
At \(d_z = 16\) the folklore holds exactly. At \(d_z = 2\) it inverts: our endpoints lie far out, slerp faithfully holds the whole path at that atypical radius.
Use slerp in high dimensions — and know the reason, not the rule.
\(d_z=16\): more of them are digits, strokes sharper — and its latent space cannot be drawn.
Interpretability is a property of small latent spaces; sample quality is not. No setting reconciles them; you pick a dimension.
Pull up the per-dimension KL you kept in TODO C.2, \(d_z = 16\). Several dimensions should sit at \(\approx 0\), ignored by the decoder.
It does not happen. Not one of the sixteen is unused — the quietest carries \(1.06\) nats, a hundred times the \(0.01\)-nat threshold for calling a dimension dead.
At this width, on this data, \(d_z = 16\) is not enough latent space to waste any.
Left: the session’s \(d_z=16\) run — every dimension active. Right: \(d_z=64\) — \(31\) of \(64\) below \(0.1\) nats, \(2\) fully dead.
| \(d_z\) | dead | nearly dead | total KL | recon |
|---|---|---|---|---|
| 16 | 0 | 0 | 24.6 | 73.1 |
| 32 | 0 | 0 | 30.1 | 68.5 |
| 64 | 2 | 31 | 30.5 | 69.3 |
| 128 | 34 | 99 | 30.3 | 70.0 |
| 64, decoder \(2.5\times\) wider | 10 | 32 | 30.2 | 66.9 |
Total KL saturates at \(\approx 30\) nats and stays there. The model has an information budget; beyond it, extra dimensions are not used badly — they are not used at all.
Posterior collapse is what surplus capacity looks like from inside.
If the decoder reconstructs just as well ignoring coordinate \(j\), then \(j\) earns nothing in the reconstruction term — and the KL is a cost, minimized at \(q_\varphi(z_j\mid x) = p(z_j)\), so the encoder switches it off.
Not pathological optimization: the objective, minimized correctly.
Second lever, isolated: hold the latent at \(d_z=64\) and widen only the decoder (\(2.5\times\)) — dead dimensions go from \(2\) to \(10\). A stronger decoder needs less help from \(z\).
Named, not implemented: KL annealing, free bits. Scope: one architecture, MNIST, 15 epochs, one seed. The direction is mechanical; the thresholds are not portable.
The aggregate KL of the \(d_z = 64\) run is \(30.5\) nats.
The \(d_z = 32\) run’s is \(30.1\).
Indistinguishable — and half of one model is inert.
You could only see this because you logged the KL per dimension. The instrument was one line in TODO C.2, kept rather than summed away.
vae-mnist-scratchRecon summed over pixels; KL summed over dimensions; both averaged over the batch. Data dynamically binarized. Tag u0l3-freeze.
PS0 replaces encode and nothing else — your U0.L4 U-Net returns the same pair, with the same shapes, into the same loss.
Take vae-mnist-scratch and:
unet-skeleton (U0.L4),hygiene-stack (U0.L2),eval-harness (U0.L4) — FID at the course-standard sample count, plus panels.Graded on correctness and reproducibility, not sample quality. A weak model that is fully reconstructible from its logged run scores above a pretty one that is not.
Attention in one pass · U-Net anatomy · time and positional embeddings — every model from U2 on is conditioned on a time \(t\), and feeding a scalar time into a convolutional network is a named technique, not an implementation detail.
It also defines FID, which is how U0.L4 finally puts a number on those blurry samples.
Two threads run from today to the end of the course: the map from noise to data — and a composite loss logs its parts.