Flow-Based Generative Models · UFRJ · 2026.2
| Session | Role |
|---|---|
| U2.T1 | the adjoint theorem, and a table of two gradient strategies whose cells were arguments |
| U2.L1 — today | every cell of that table measured, on a classifier where nothing hurts |
| U2.L2 | the same instruments, pointed at a generative model |
You learn the instruments on a problem where nothing is at stake, so that the next reading means something.
Nothing today is about generative models. The instruments come first.
| Module | Built in | Used today |
|---|---|---|
common/toy2d.py |
U1.L1, frozen | two_moons(n, seed) |
common/odesolve.py |
U0.L5, frozen | odeint, with .nfe |
common/train.py |
U0.L1, U0.L2 | set_seed only |
The labels come from the frozen contract (first n // 2 rows = upper moon):
The training loop is written out: the NFE counter must be read between the forward solve and the backward pass of every step.
MLflow, deployed locally: sqlite:///mlflow.db beside the notebook.
U2; one run per block.session=U2.L1 and block=<n>; the resolved configuration as parameters.rtol dominate, and where atol?dopri5?Four or more right: go on. Fewer: read U0.T5, Adaptive stepping and Stability, before the sweep block.
\[ \begin{aligned} \dot x(t) &= f_\theta\big(t, x(t)\big),\quad x(0) = x_0,\quad t \in [0,1],\\ \text{logits} &= W\,\psi_1(x_0) + b \end{aligned} \]
\[ \ell(\theta) = \mathbb{E}_{(x_0, y) \sim \mathcal{D}_{\mathrm{train}}} \Big[ -\log \operatorname{softmax}\big(W\,\psi_1(x_0) + b\big)_{y} \Big] \]
\(\mathcal{D}_{\mathrm{train}}\): the empirical (uniform) distribution of the 512 labelled training points. Each term is U2.T1’s terminal loss \(L_y\big(x(1)\big)\), with \(L_y\) = head + cross-entropy against \(y\).
The head alone, on the raw inputs: 89.4% test accuracy. No line separates the moons; the rest is the flow’s work.
A module holding the field, so its parameters are the field’s. It counts every call: stages, start-up calls, and the adjoint’s backward calls. Checkpoint 1.1: Euler with \(N\) steps must report \(N\); torchdiffeq’s rk4 must report \(4N\).
dopri5 at its defaults: rtol \(= 10^{-7}\), atol \(= 10^{-9}\). Full batch, 512 points. Record the loss, the accuracy, the forward NFE, the backward NFE and the seconds of every step.
The U0.T2 recipe card: AdamW, weight decay 0.01, 3% warmup, cosine decay, per step. Base rate: \(3 \times 10^{-3}\), not the card’s \(3 \times 10^{-4}\).
| Base rate | Steps | CPU time | Loss | Accuracy |
|---|---|---|---|---|
| \(3 \times 10^{-4}\) (card) | 1500 | 77 s | 0.0275 | 99.6% |
| \(3 \times 10^{-3}\) | 300 | 15.1 s | 0.0032 | 100.0% |
Loss and accuracy on the training set; CPU time on one thread. At step 300 of the card-rate run the loss is still 0.287. The card rate, with five times the steps, ends 8.5 times higher. Test accuracy of the \(3 \times 10^{-3}\) run: 100.0%.
Test NFE at the defaults: 116. Backward NFE: zero, at every step.
Card item 3: zero the last layer where “output \(\approx 0\) at start” is sensible. Two last layers qualify.
Measured: with PyTorch’s default head, seeds 2 and 3 of five stalled at every rate tried (final training loss 0.23 or above; every other run 0.048 or below). With the head at zero, 10 seeds of 10 reached 100%.
Left: every fifth test input; regions from one solve per grid point. Right: hollow \(t = 0\), filled \(t = 1\), dashed the head’s line.
Under U2.T1’s Tier 1 (Picard–Lindelöf), two distinct trajectories never occupy the same state at the same time. Read over all 2,096,128 test pairs and 41 times:
\[ 0.41 \;\le\; \frac{\lvert x^{(i)}(t) - x^{(j)}(t)\rvert}{\lvert x^{(i)}(0) - x^{(j)}(0)\rvert} \;\le\; 21.4 . \]
Paths drawn in the plane may still cross: the field depends on \(t\), and a crossing of paths happens at different times. None of the 40 drawn paths do; yours may, and that is not a contradiction.
| \(N\) | odesolve RK4 |
torchdiffeq rk4 |
the two RK4s |
|---|---|---|---|
| 8 | \(5.4 \times 10^{-3}\) | \(5.0 \times 10^{-3}\) | \(3.4 \times 10^{-4}\) |
| 16 | \(4.3 \times 10^{-4}\) | \(4.1 \times 10^{-4}\) | \(2.5 \times 10^{-5}\) |
| 32 | \(3.1 \times 10^{-5}\) | \(2.9 \times 10^{-5}\) | \(1.6 \times 10^{-6}\) |
| 64 | \(2.1 \times 10^{-6}\) | \(2.0 \times 10^{-6}\) | \(9.9 \times 10^{-8}\) |
Largest coordinate error of the endpoints against a tight dopri5 reference; the last column is the difference between the two RK4s. torchdiffeq’s rk4 is the 3/8 rule, not U0.T5’s classical tableau: both order 4, both four calls per step, two different computations.
euler, rk4 with \(N \in \{4, 8, 16, 32\}\) fixed steps.dopri5 with rtol \(\in \{10^{-1}, \dots, 10^{-7}\}\), atol \(=\) rtol\(\,\times 10^{-2}\).Evaluation only, in float64, against a reference at rtol \(= 10^{-12}\).
The task metric may saturate before the trajectory error does. Then tighter tolerances spend evaluations without changing the answer.
rk4, 16 steps, 64 calls, error \(7.5 \times 10^{-5}\); dopri5 at rtol \(= 10^{-6}\), 74 calls, \(1.6 \times 10^{-6}\). Fifth-order adaptive against fixed-step fourth-order: the order alone is not isolated.On this task, accuracy is nearly blind to solver error: Euler with 4 steps is off by 1.18 in RMS (3.34 at worst) and misses 1 point.
A log-log fit over rtol \(\le 10^{-3}\) gives slope \(-0.16\). It describes this model at this checkpoint, and predicts nothing about the next.
torchdiffeq’s controller reduces the error estimate to one number: an RMS over every element of the batched state.
| Solve | NFE at the defaults |
|---|---|
| the 2048 test points, one batch | 116 |
| 64 of them, both moons, one at a time | 50 to 134 (median 74) |
60 of 64 points pay more in the batch than alone, and the hardest gets fewer evaluations than it asks for alone. The batch NFE is a property of the batch.
A solver setting fixes a tolerance, not a cost; the cost follows the field.
A description of three runs, not a law about Neural ODEs.
torch.cuda.synchronize(); torch.cuda.empty_cache()
torch.cuda.reset_peak_memory_stats()
base = torch.cuda.memory_allocated()
loss = F.cross_entropy(model(x, adjoint=adj,
method="rk4", options={"step_size": 1 / N}), y)
loss.backward()
torch.cuda.synchronize()
peak = torch.cuda.max_memory_allocated() - basetracemalloc is not a tensor meter: it sees Python’s allocator, not the native one PyTorch uses. Without a GPU: the process’s resident set size (RSS), sampled, coarse.
U2.T1’s schematic, with numbers: peak over one forward-and-backward pass. RTX 4070 Laptop GPU, batch 512, float32.
\(\ell_B\): the loss of the exact flow on a fixed batch \(B\) of 256 test points, the loss of the slide The loss, and what the flow must do with \(\mathcal{D}_{\mathrm{train}}\) replaced by \(B\) (uniform over \(B\)). \(\ell_N\): the same through \(N\) steps of rk4, the loss as the computer evaluates it. Float64; one unit direction \(v\) over the field’s parameters \(\theta_f\); \(\varepsilon = 10^{-6}\).
\[ \begin{aligned} d_{\mathrm{FD}} &= \frac{\ell_N(\theta + \varepsilon v) - \ell_N(\theta - \varepsilon v)}{2\varepsilon}, & d_{\mathrm{AD}} &= v^{\top}\nabla_{\theta_f} \ell_N,\\ d_{\mathrm{ADJ}} &= v^{\top} \hat g_N \ (\text{adjoint's estimate of } \nabla_{\theta_f} \ell_B), & d_{\mathrm{cont}} &= v^{\top}\nabla_{\theta_f} \ell_B \end{aligned} \]
\(d_{\mathrm{cont}}\) is computed two ways that agree to \(1.5 \times 10^{-15}\).
| \(N\) | AD vs FD (rel.) | ADJ vs FD (rel.) | AD vs cont (abs.) | ADJ vs cont (abs.) |
|---|---|---|---|---|
| 8 | \(6 \times 10^{-9}\) | \(2 \times 10^{-4}\) | \(1.3 \times 10^{-7}\) | \(8.9 \times 10^{-8}\) |
| 32 | \(5 \times 10^{-9}\) | \(3 \times 10^{-6}\) | \(7.8 \times 10^{-10}\) | \(9.6 \times 10^{-11}\) |
Relative gaps divide by \(\lvert d_{\mathrm{FD}} \rvert \approx 2.34 \times 10^{-4}\). Absolute gaps to FD: AD \(1.4 \times 10^{-12}\) and \(1.2 \times 10^{-12}\); ADJ \(4.5 \times 10^{-8}\) and \(6.8 \times 10^{-10}\).
The ritual accepts \(d\) from either route when \(\lvert d - d_{\mathrm{FD}}\rvert \le 10^{-8} + 10^{-6}\max(\lvert d \rvert, \lvert d_{\mathrm{FD}}\rvert)\).
On this trained model the directional derivative is small and the \(10^{-8}\) floor dominates: the adjoint fails at \(N = 8\) and passes at \(N = 32\).
Neither verdict says it is wrong: the ritual catches a wrong gradient, and here its verdict on a legitimate route turns on \(N\). The relative columns separate the routes, and they are well conditioned here: \(|d_{\mathrm{FD}}|\) is eight orders of magnitude above the witness’s own error.
U2.T1: where the forward flow contracts, the backward reconstruction amplifies error. Nothing in the table isolates that effect; on a harder field it is one way the adjoint’s column grows.
Mitigations, named and not oversold:
The same model in JAX and Diffrax, about thirty lines, loaded with today’s weights. Two checks first, both measured:
jax.debug.callback counts 49 calls for 8 steps of Dopri5: six per step, one at the start.Two of the routes behind one argument, adjoint=: RecursiveCheckpointAdjoint (the default) and BacksolveAdjoint.
| Adjoint | \(N\) | trace + lower + compile | steady, per call |
|---|---|---|---|
RecursiveCheckpointAdjoint |
8 | 1.43 s | 9.3 ms |
BacksolveAdjoint |
8 | 1.68 s | 8.0 ms |
RecursiveCheckpointAdjoint |
32 | 1.40 s | 42.1 ms |
BacksolveAdjoint |
32 | 1.55 s | 31.9 ms |
Value and gradient, 256 points, float64, Intel Core Ultra 9 185H CPU, one run. The up-front price is the whole first-call overhead; at \(N = 8\) it equals about 153 steady calls of the checkpointed route. Do not rank frameworks with this table.
| \(N\) | RecursiveCheckpointAdjoint vs FD |
BacksolveAdjoint vs FD |
|---|---|---|
| 8 | \(2.1 \times 10^{-8}\) | \(3.4 \times 10^{-5}\) |
| 32 | \(3.8 \times 10^{-10}\) | \(2.7 \times 10^{-8}\) |
Relative gaps, same direction \(v\), same batch, fixed-step Dopri5.
odeint or odeint_adjoint.Neither is wrong; they weigh one trade differently. Know which route you run before you read “we trained with the adjoint”.
The numerics behind Diffrax’s defaults: Kidger (2022, ch. 5), optional.
| Instrument | What it read today |
|---|---|
| NFE counter | every call, forward and backward |
| NFE-vs-quality axes | accuracy flat; error across 6 decades |
| NFE drift in training | 20 at step 4, 122 at step 300 |
| Peak-memory protocol | direct grows; the adjoint stays flat |
| Gradient-fidelity table | two routes, two different numbers |
| Honest timing | compile apart from steady state |
Every gauge on this panel gets pointed at a generative model in the FFJORD lab — and there, the needles move. Watch them.
Sources for today: torchdiffeq (Chen 2018), Diffrax (Kidger 2021), the Neural ODE paper (Chen et al. 2018, sec. 2), and Massaroli et al. (2020, App. A.1).