Flow-Based Generative Models · UFRJ · 2026.2
| Artifact | Who imports it |
|---|---|
common/odesolve.py |
U3.L1 — ODE sampling |
jax-primer.ipynb |
the JAX moments of U1.L1, U2.L1, U3.L1 |
Both also ship in the PESC self-study packet.
The first samples of this course’s first Flow Matching model will be drawn by the file you write in the next 25 minutes.
Some readers of your notebook will be PESC students who were not in this room, with no lecturer to ask.
So: full prose between cells, a self-check after every TODO, and the solutions ship with the packet.
Write for colleagues you have not met.
grad / vmap / jit make several course constructions clearer than the PyTorch versionPyTorch stays primary. JAX moments are mirrors, never the only path.
f(t, x) — time firstx0 is (B, d) — batched by constructiontorchdiffeq and diffrax both take f(t, y).
The two orderings are equally arbitrary in isolation. Agreeing with the ecosystem is not.
A right-hand side you write today is one the professional libraries accept unchanged — which makes U2.L1 a change of library, not a rewrite.
A generative model is sampled in batches, so the solver takes batches.
Notice what this does not require:
vmap in this file, and none is neededNFE is this course’s cost currency.
A solver that hides its NFE cannot be compared with another solver — and a comparison you cannot make is a comparison you will not make.
It is a Tensor — prints, indexes, broadcasts like one.
A warning in a docstring does not survive contact with a results table.
| TODO | Method | NFE / step |
|---|---|---|
| 1 | Euler | 1 |
| 2 | Heun | 2 |
| 3 | RK4 (transcribe the tableau) | 4 |
| 4 | Adaptive Heun(2)/Euler(1) | 2 / attempt |
A tableau is the table of coefficients defining a Runge–Kutta method. We are consumers of tableaux, not designers.
TODOs 1–3 are mechanical on purpose — U0.T5 derived them, and re-deriving them here spends the only 90 minutes in which the code gets written.
\(k_1\) alone is the Euler step:
\[ \hat{x}_{n+1} = x_n + h k_1 \qquad x_{n+1} = x_n + \tfrac{h}{2}(k_1 + k_2) \]
Their difference estimates the local error; the step is judged against \(\mathrm{sc}_i = \texttt{atol} + \texttt{rtol}\cdot\max(|x_{n,i}|,|x_{n+1,i}|)\).
Two things that are easy to get wrong, both checked: propagate the higher-order value, and update \(h\) after a rejection too.
The U0.T5 icon, now self-made. \(h = 2\pi/40\).
The assert is not on the picture: Euler’s energy grows by exactly \(1 + h^2\) per step, and the notebook checks that ratio.
This module integrates forward. No adjoint, no differentiation through the solve.
Sampling is inference: no gradient is needed to draw a sample.
Nothing blocks autograd — and what it would cost you in memory is precisely the problem U2.T1 exists to solve.
Exercise 2 asked you to predict the three slopes before making the plot. The answer was 1, 2, 4.
Now you make the plot with your own solvers.
Measuring the order is how you find a bug in a Runge–Kutta method. A tableau typo usually leaves a method that still converges — at the wrong rate.
| Method | Steps at NFE \(=4000\) | Error at \(t=2\pi\) |
|---|---|---|
| Euler | 4000 | \(4.9 \times 10^{-3}\) |
| Heun | 2000 | \(1.0 \times 10^{-5}\) |
| RK4 | 1000 | \(8.2 \times 10^{-11}\) |
Almost eight orders of magnitude — a factor of \(6\times10^{7}\) — and nobody spent more than anybody else.
The table on the previous slide assumes you want high accuracy.
At a loose tolerance the three methods sit much closer together. At one or two evaluations total, a high-order method has no advantage at all — it has not taken enough steps for its order to express itself.
Sampling a generative model at 4 NFE lives in exactly that regime.
rtol from \(10^{-1}\) to \(10^{-8}\). (a) NFE against error achieved, fitted slope \(-0.50\). (b) The outcome against the request, with the diagonal.
Panel (b) is a straight line parallel to the diagonal.
Nothing guarantees that. The controller bounds a local error estimate; panel (b) plots the global error at the end.
“I set rtol to \(10^{-6}\)” is a statement about what you asked for, never about what you got.
Fitted slope \(-0.50\); an order-2 method predicts \(-1/2\).
Halving the error costs \(\sqrt{2}\) times the work here. With a fifth-order method it would cost \(2^{1/5}\).
Block E measures exactly that difference.
Across all eight runs: \(0, 0, 1, 1, 2, 3, 3, 4\) rejected steps — out of up to 48 561 accepted.
The safety factor \(0.9\) is what buys that.
A rejected step costs its evaluations and makes no progress. A controller that rejects constantly is misconfigured.
Left end: 32 evaluations, error \(0.2\) on an orbit of radius \(1\). Fast, smooth, plausible, wrong. Nothing complained.
Right end: 97 130 evaluations, error \(1.8\times10^{-8}\), cost \(\times 3035\). You notice immediately; it costs only time.
JAX is NumPy, plus function transformations, plus explicit randomness.
Scope, declared. Five moves and stop. No flax, no optax beyond a mention. No sharding, no TPU.
The primer optimizes for reading research JAX and mirroring this course. Not for writing production JAX.
jnpYou give up in-place mutation. What you buy is that the function can be transformed — differentiated, vectorized, compiled, or all three.
A transformation must know that calling your function twice with the same arguments does the same thing twice.
Purity is the price of transformability.
grad returns a functionNo tape. No .backward(). No .grad attribute on anything.
\[ \frac{\partial f}{\partial \theta_i} \;\approx\; \frac{f(\theta + \varepsilon e_i) - f(\theta - \varepsilon e_i)}{2\varepsilon} \]
U0.L1 introduced it. U0.L2 repeated it. By now it should be a reflex.
Three lines, one second, and it is the only thing that distinguishes a gradient from a plausible array of numbers.
vmap, and why it existsloss_one is the loss of one sample — no batch dimension anywhere in its body.
in_axes=(None, 0, 0): parameters shared, inputs and targets mapped.
Dotted: mean of the norms, \(7.48\). Solid: norm of the mean, \(1.56\).
The gap is cancellation, and it is what averaging a batch is. But the number the optimizer sees is not a typical sample’s number.
U1.L1 computes a coupling layer’s Jacobian log-determinant, per sample, with jax.jacfwd.
Same composition, different inner transformation.
This TODO is its warm-up.
jit, in three sentencesEager is the other mode: each operation runs as the interpreter reaches it. That is the baseline block E times against.
Run the cell. Read the error. Then fix it with jnp.where.
An error message you have met once is a different thing from an error message you have read about.
No global seed. No hidden state. Same key, same value, always.
U0.L2 made reproducibility a discipline: seed three generators, record the seed, remember not to disturb the stream.
Here it is not a discipline. A key you forgot to split is a bug you can see in the code, and a function’s randomness is visible in its signature.
JAX makes the seeding discipline structural.
Every argument is one you now understand (Kidger 2022).
Two independent solvers, one problem. If they disagree, one of them is wrong.
diffrax Tsit5 at rtol \(=10^{-12}\), against your RK4 at 4000 fixed steps:
\[ \bigl\| x^{\text{diffrax}}_1 - x^{\text{yours}}_1 \bigr\| \;=\; 4.1 \times 10^{-13} \]
Your solver is correct. Now the rest of the news.
The adaptive Heun of block B against diffrax’s Tsit5, same problem, same tolerances. At error \(1.8\times10^{-8}\): 97 130 evaluations against about 360.
The lines are diverging — an order-5 cost curve is flatter than an order-2 one.
You now know what Tsit5, rtol and PIDController mean — and you know it because you built the smaller version of each.
The from-scratch pass is how you learn the object. It is not how you integrate an equation you care about.
CPU, JAX 0.10.2 — one machine, one run.
Report compile time separately from steady-state time. Never amortise the compile into the speedup.
Time the first call, and jit looks four times slower.
Time only the steady state, and you have hidden a fixed cost a short script never earns back.
Together: \(1.8\times\) faster per call, \(87\) ms up front — break-even after about eight calls, a loss below that.
\(1.8\times\) is modest, and it is the real number. This workload is matrix multiplications — already tuned kernels, little for a compiler to fuse. XLA earns its large factors on many small operations.
Wall clock does not reproduce. Every other number today comes back identical. These three move by milliseconds between runs, and will differ on your machine.
common/odesolve.py and jax-primer.ipynb, tagged u0l5-freeze.
U3.L1 imports odeint under today’s contract. A change to that signature after today is a breaking change.
ode-solver-notes (U0.T5 — written standalone-first for this)jax-primer.ipynb + lab-u0l5.ipynb, with solutionsIt exists because of a rule from the top of the course: no joint session may require having attended U0.
Your VAE (U0.L3) with your U-Net (U0.L4) as encoder, trained with the hygiene stack (U0.L2), evaluated with your harness (U0.L4): FID at the course-standard \(n\), plus panels.
Graded on correctness and reproducibility, not on sample quality.
A run that is reproducible and mediocre scores above a run that is beautiful and cannot be repeated.
“Reproducible” is operational — the hygiene stack already enforces it:
config · seed · commit hash + dirty flag · the MLflow run
Compute ceiling: a single consumer GPU, a small number of epochs. More than that has misread the exercise.
| Artifact | Built | Consumed by |
|---|---|---|
training-loop-template |
L1 | every lab |
hygiene-stack |
L2 | every lab; PS0 |
elbo-derivation |
T3 | U3.T2, U5.T1 |
vae-mnist-scratch |
L3 | L4, PS0 |
time-conditioning-patterns |
T4 | L4, U3 labs, U5.T1 |
fid-definition |
T4 | L4 |
unet-skeleton |
L4 | U3.L2, U3.L3, PS0 |
eval-harness |
L4 | U1.L2, U3.L2/L3, PS0 |
ode-solver-notes |
T5 | U2.L1, U3.L1, packet |
ode-solvers-scratch |
L5 | U3.L1, packet |
jax-primer |
L5 | JAX moments, packet |
The next live session is UP.T1, where the course proves the one result it uses three times:
conditional expectation as an \(L^2\) projection — the projection Lemma.
Everything after it leans on that page.
Rest during the async window. The PESC students are just arriving.