Flow-Based Generative Models · UFRJ · 2026.2
| Session | What happened |
|---|---|
| U1.T1 | a coupling layer has a cheap inverse and a log-det that is a sum |
| U1.L1 — today | you write it, check it, train it, look at it, read the library’s version |
| U1.T2 | what such a stack can and cannot represent |
Today is the first lab of the joint phase, and the first time a generative model is written from nothing and trained.
AffineCoupling, and a Flow that stacks it.Flow.log_prob, the change-of-variables theorem as code.And one thing deliberately not on the list: an explanation of the last picture of the day.
| Module | Built in | What this lab uses |
|---|---|---|
common/train.py |
U0.L1, U0.L2 | set_seed; Trainer and its .fit |
common/toy2d.py |
today | three frozen generators, two picture helpers |
common/coupling.py |
today | the reference you rebuild |
The Trainer: five-step loop, schedule stepped per step, validation under no_grad every epoch, last.pt and best.pt, a CSV log. It expects (x, y) batches; a flow has no labels, so y is a placeholder and the accuracy reads nan — an interface preserved, not a defect.
MLflow, deployed locally: sqlite:///mlflow.db next to the notebook, plus mlartifacts/. Portable — an offline bundle stands alone. Not a production recommendation.
mlflow ui --backend-store-uri sqlite:///mlflow.db
One departure, measured: per-step logging turned a 14 s training into 48 s (a step is under a millisecond; a SQLite write is a few). So the loop runs without the tracker, and the two NLL curves (negative log-likelihood: the loss, on train and on validation) are logged per epoch, after the loop, with the config and the tags.
\[ v^{\top}\nabla_\theta L \;\approx\; \frac{L(\theta+\varepsilon v)-L(\theta-\varepsilon v)}{2\varepsilon}, \qquad \lVert v \rVert = 1 \]
Float64, one fixed unit direction, mixed tolerance \(\texttt{atol} + \texttt{rtol}\cdot\max(|d_{\mathrm{AD}}|, |d_{\mathrm{FD}}|)\). Two extra forward evaluations, whatever the parameter count.
Today’s sibling: the suspect is a log-determinant. Analytic sum of scales, against a Jacobian that autodiff assembles independently, per example.
toy2d: the datasets, frozen todaytwo_moons / checkerboard / rings (n, seed)
-> Tensor (n, 2), float32; EXTENT = (-4.0, 4.0)
density_heatmap(log_prob_fn, extent, n, ax)
-> (xs, ys, logp)
warped_grid(generative_fn, extent, ax) -> lines
(n, 2).Downstream sessions import this module by name and never redefine a generator.
That is a mathematical statement about literal support, and it is there because U1.T2 needs one. Today you only observe what a fitted density does in the gaps.
\[ \begin{aligned} \text{forward:}\; & y_A = x_A,\quad y_B = x_B \odot e^{s_\theta(x_A)} + t_\theta(x_A), \\ & \log|\det J_\psi| = \textstyle\sum_j s_\theta(x_A)_j \\[4pt] \text{inverse:}\; & x_A = y_A,\quad x_B = (y_B - t_\theta(y_A)) \odot e^{-s_\theta(y_A)}, \\ & \log|\det J_{\psi^{-1}}| = -\textstyle\sum_j s_\theta(y_A)_j \end{aligned} \]
Both sums come off the same conditioner pass: \(x_A = y_A\) is on both sides. In code, z is the source-side input (\(z_{k-1}\) of the T1 chain; \(z_0 = x_0\)), x the data-side output, mask marks \(x_A\) with True.
AffineCoupling(d, mask, hidden=(64, 64), s_cap=2.0)
forward(z) -> (x, logdet_fwd) # psi: source -> data
inverse(x) -> (z, logdet_inv) # psi^-1: data -> src
# logdet_inv == -logdet_fwd, PER EXAMPLE
Flow(layers): forward / inverse / log_prob(x) / sample(n)
Permute(perm): both log-dets exactly 0
TODO 2.1 writes _heads, forward, inverse; the constructor and the conditioner (an MLP \(\mathbb{R}^{d_A} \to \mathbb{R}^{2 d_B}\), two heads) are given. Checkpoint: x[:, mask] == z[:, mask], and the log-det at init is not zero.
\[ s \;=\; s_{\max}\tanh\!\big(\text{raw}/s_{\max}\big), \qquad s_{\max} = 2 \]
So \(|s| < 2\): every layer’s volume factor \(e^{s}\) stays in \([0.14,\, 7.4]\). Near zero the map is the identity; far from zero it saturates, so a random conditioner can neither overflow \(e^{s}\) nor divide the inverse by \(\approx 0\).
U1.T1 promised to show what the loss looks like when you forget. Next slide, measured.
A check that cannot fail has verified nothing. The notebook asserts the log-det is nonzero before trusting any agreement.
single sends one point (d,) to one point (d,). Accept under atol = 1e-10, rtol = 1e-8 on the largest log-det; assert sign > 0.
Measured, 64 points: gap \(5.3 \times 10^{-16}\) (tol \(3.0 \times 10^{-10}\)) untrained; \(5.1 \times 10^{-15}\) (tol \(2.1 \times 10^{-8}\)) after training, where \(\max|\log|\det J|| = 2.11\).
torch.autograd.functional.jacobian(f, z) on the whole batch returns the Jacobian of the batched map \(\mathbb{R}^{Bd} \to \mathbb{R}^{Bd}\), shape (B, d, B, d).
Measured, \(B = 64\):
| Quantity | Value |
|---|---|
| largest off-diagonal block entry | \(0.0\) |
slogdet of the batched map |
\(0.24336826533634\) |
| sum of the 64 per-example log-dets | \(0.24336826533634\) |
One number for the whole batch. It cannot say which example is wrong; its plain det is a product of \(B\) factors and overflows for a trained flow; it costs \(B\) times the memory.
Per example, float64:
inverse(forward(z)) \(\approx\) zforward(inverse(x)) \(\approx\) xlogdet_fwd + logdet_inv \(\approx 0\) at matching pointsMeasured, untrained: \(8.9 \times 10^{-16}\), \(8.9 \times 10^{-16}\), \(2.8 \times 10^{-17}\). Trained: \(9.1 \times 10^{-15}\), \(2.7 \times 10^{-15}\), \(1.0 \times 10^{-15}\).
The third line is U1.T1’s Exercise 3, executed: the inverse pass reads the same sum with the opposite sign.
Flow.forward: layers in order, log-dets add. Flow.inverse: layers reversed, each through its own inverse, log-dets add. Masks alternate.
\[ \log p_\theta(x) \;=\; \log p_0(z) \;+\; \log\big|\det J_{\psi^{-1}}(x)\big| , \qquad z = \psi^{-1}(x) \]
In code: z, logdet_inv = inverse(x); return log_p0(z) + logdet_inv. That is U1.T1’s \(\log p_0(z_0) - \sum_k \log|\det J_{\psi_k}(z_{k-1})|\) with the minus sign absorbed into logdet_inv. Written next to the implementation, so the sign is never implicit.
U1.T1’s Exercise 1 asked you to check in your head that a coordinate swap contributes \(0\) and undoes itself. Checkpoint 3.1 checks it in code:
Alternating masks and inserted permutations are two spellings of the same idea: after two layers every coordinate has moved.
\[ \mathcal{L}(\theta) = -\,\mathbb{E}_{x_1 \sim q}\big[\log p_\theta(x_1)\big] \approx -\frac{1}{B}\sum_{i=1}^{B} \log p_\theta\big(x_1^{(i)}\big) \]
Everything else is the given Trainer.
two_moons(4096, 0), validation two_moons(1024, 1). Selection on validation; there is no test set.Pass if: the validation NLL at the end beats the first epoch’s by more than one nat, and the trained stack still passes the witness and the round trips.
| Run | Time | Val NLL, epoch 1 | Epoch 500 |
|---|---|---|---|
| two-moons | 69 s | 4.674 | 2.099 |
| gapped checkerboard | 69 s | 6.927 | 3.387 |
No absolute threshold: an absolute number is a promise about your hardware that nobody validated. TODO 3.3: same code, same budget, the checkerboard — one line changes.
Keep that last sentence for the next figure.
Important
What density does the model place in the hard-zero gaps, and what happens to the grid near those regions?
Measured on the reference model, \(400 \times 400\) grid:
Explained in U1.T2. The solution records the observation and says exactly that. Do not read the size of the bridge as a theorem: what is exactly impossible for this model, and what is an effect of finite capacity or of optimization, are separated in that session.
course \(\psi \colon z \to x\) · library \(f = \psi^{-1} \colon x \to z\)
| Operation | Direction of \(\psi^{\theta}\) | Needs, per layer |
|---|---|---|
| Sampling | forward, \(x_0 \to x_1\) | \(\psi_k\) |
| Density / training | inverse, \(x_1 \to x_0\) | \(\psi_k^{-1}\) and \(\log\lvert\det J_{\psi_k}\rvert\) |
Zuko exposes the density transform as its primary object. Never flip the course convention to match a library; write \(f = \psi^{-1}\) and translate.
Trainer, one token changesZuko flows are nn.Modules whose call returns a distribution: flow() is \(p_\theta\).
Same data, same seeds, same optimizer, same schedule, same Trainer, same budget. That is the point of a cross-check.
| Map | Direction | Used by | |
|---|---|---|---|
| course | \(\psi\) | \(z \to x\) | sample, your forward |
flow.transform() |
\(f = \psi^{-1}\) | \(x \to z\) | log_prob |
flow.transform().inv |
\(\psi\) | \(z \to x\) | sample |
f.log_abs_det_jacobian(x, z) is \(\log|\det J_{\psi^{-1}}(x)|\) — your logdet_inv. Checked, not trusted: log_p0(f(x)) + ladj against flow().log_prob(x) on 1024 points, gap \(9.5 \times 10^{-7}\) (float32); f.inv(f(x)) returns \(x\) to \(3.3 \times 10^{-6}\).
Zuko splits the layer in two:
GeneralCouplingTransform — owns the mask and the conditioner (the hypernetwork), builds a transform on demand;CouplingTransform — does the arithmetic, and delegates the per-coordinate map to the affine map, MonotonicAffineTransform.Your one class maps onto the three of them. The next slide says what to find in each.
| Your code | In Zuko | Find |
|---|---|---|
mask |
lazy: __init__; transform: split, merge |
which value is the untouched half; how NICE alternates it |
_heads |
lazy: meta, hyper |
what meta returns |
inverse |
transform and affine map: _inverse |
which of _call / _inverse is \(\psi\) |
| the two log-dets | log_abs_det_jacobian; call_and_ladj |
where the sum is taken; how the log-scale is bounded |
Line anchors live in the notebook, generated from the pinned zuko 1.5.0 at build time. TODO 5.3 is reserved (only if U1.L2 is cut).
def psi(z): # ONE example
x = z
for first_is_a, ws, s_cap in params:
ia, ib = (0, 1) if first_is_a else (1, 0)
raw = mlp(ws, x[ia:ia + 1])
s = s_cap * jnp.tanh(raw[0] / s_cap)
x = x.at[ib].set(x[ib] * jnp.exp(s) + raw[1])
return x
J = jax.vmap(jax.jacfwd(psi))(z) # (B, 2, 2)
_, logabsdet = jnp.linalg.slogdet(J)Trained torch weights, copied over. Agreement with torch on 64 points: log-dets to \(1.1 \times 10^{-13}\), forward maps to \(3.9 \times 10^{-15}\).
| Object | Checked by |
|---|---|
a bijection: AffineCoupling + Flow |
both round trips, per example, before and after training |
exact density evaluation: Flow.log_prob |
the sign check; the training curves; a library within \(0.002\) nats |
| an independent Jacobian witness | torch.func and JAX, per example, float64 |
And one thing you do not own yet: an explanation of the checkerboard’s gaps. \(29.8\%\) of the mass outside the support; \(0.50\) nats above \(\log 18\). U1.T2 opens there.
Flow.log_prob to \(1.8 \times 10^{-15}\).Sources for today: (Dinh, Sohl-Dickstein, and Bengio 2017, secs. 3.2–3.3, §3.5), (Papamakarios et al. 2021, sec. 3.1.2), (Rozet et al. 2022).