Flow-Based Generative Models · UFRJ · 2026.2
By the end of these 90 minutes, every one of you owns a runnable, seeded, GPU-aware training loop template — under version control.
Everything trained later — flows, FFJORD, CFM, DDPM — is a filling of this template.
Lab format (this sets the standard for all labs):
Reused in: every lab — this cell is the top of every notebook this course.
set_seed is introduced now and used forever — three RNGs, one call.One convention for the whole course:
your-course-repo/
labs/ # one notebook per lab session
common/ # shared code -- today's template lives here
runs/ # checkpoints, logs (gitignored)
common/train.py — the file we build in the training-loop block — is imported by every later lab. U0.L2 adds to it; nothing ever rewrites it.
Reused in: everywhere — broadcasting bugs are the #1 silent killer in generative-model code.
A shape bug in PyTorch usually does not crash. It broadcasts, produces a tensor of the wrong shape with plausible values, and trains to convergence on the wrong loss.
Our defense, from day one:
The trio shape / dtype / device answers 90% of debugging questions.
dtype: float32 is the default and the workhorse; float64 appears today only inside the autograd block’s gradient check.device: where the data lives. Operations require all operands on the same device — the rule we exercise at the end of this block.view vs reshape vs permuteview: reinterprets the same memory — never copies, fails if the layout is incompatible.reshape: view when possible, silent copy when not.permute (and .t()): reorders strides, not memory — the result is usually non-contiguous.The notebook’s tensors part plants one permute-then-view trap for you (TODO B.1). Read the error, understand why, then fix it.
To combine two tensors elementwise:
That third line is the useful one: broadcasting fails loudly only when no dimension matches. The dangerous cases are the ones that succeed.
TODO B.2 (tensors part) — no loops allowed. For \(X \in \mathbb{R}^{n \times d}\), \(Y \in \mathbb{R}^{m \times d}\), compute the \((n, m)\) matrix \(D_{ij} = \lVert x_i - y_j \rVert^2\).
Checkpoint B:
None-indexing inserts the size-1 dimensions; the broadcast does the double loop for you, vectorized.
The shape discipline (batch, d) vs (batch, 1) vs (batch,) is exactly what makes the course’s losses implementable.
In U3.L1 you will meet losses of the form \[
\mathbb{E}_{t\sim\mathrm{Unif}[0,1],\; x_1\sim q,\; x_0\sim p_0}\!\left[\,
\big\lVert u_t^\theta(x_t) - (x_1 - x_0) \big\rVert^2 \,\right]
\] where time \(t\) is a (batch,) tensor broadcast against (batch, d) states — one \(t\) per sample, stretched across \(d\) coordinates.
Course convention, fixed today: in every network signature, t is a (B,) float tensor with values in \([0,1]\). Today’s X[:, None, :] is that line of code, practiced early.
The rule: everything that touches in an operation lives on the same device. Model and data — you move both, explicitly.
TODO B.3 (tensors part) plants this error for you to read:
RuntimeError: Expected all tensors to be on the same device,
but found at least two devices, cuda:0 and cpu!
Diagnosis ritual: print .device of every operand, find the one that never got moved. It is the data loader’s output, roughly always.
Reused in U1.L1 (log-det checks), U2 (the adjoint assumes you know what autograd stores), and every lab.
In U0.T1 we proved: reverse-mode differentiation is right-to-left accumulation of VJPs along the graph, one cached activation per node.
Today you meet the same object as an API:
requires_grad=True — “record operations on this tensor.”.backward() — run the cotangent sweep; results land in .grad..grad accumulates — backward() adds into it, never overwrites:
zero_grad() exists — and why it is a mandatory step of the canonical training loop, not an optional flourish.The notebook’s autograd part (TODO C.1) hands you a training loop with the zero_grad() line deleted. Predict what the loss curve does, then run it.
no_grad and detachno_grad(): no graph \(\to\) no activation caching \(\to\) less memory, faster. Baked into the template’s evaluate().detach returns later (EMA weights, target networks). Today: know it exists and what it severs.Rule of thumb: if no .backward() will ever be called on it, it should not be building a graph.
Autograd is code, and code is doubted. The course-wide gradient-check ritual compares it against the one estimator with no calculus and no tape — central differences, taken along one fixed direction:
\[ \big\langle \nabla f(\theta),\, v \big\rangle \;\approx\; \frac{f(\theta + \varepsilon v) - f(\theta - \varepsilon v)}{2\varepsilon}, \qquad \lVert v \rVert = 1 . \]
Two forward evaluations, whatever the parameter count. That is why the same ritual still works on U0.L4’s CNN and U3.L1’s vector field. It is also U0.T1’s cost table again: one direction is one forward-mode pass, so the right-hand side is a numerical JVP and the left is a VJP contracted with \(v\).
float64 for the check. Measured on today’s model, the identical check in float32 returns \(d_{\mathrm{FD}} = 5.96\times10^{-2}\) against \(d_{\mathrm{AD}} = 7.64\times10^{-3}\) — wrong by a factor of eight, on a gradient that is exactly right. Train in float32; promote the check.
Freeze every random draw across \(\theta\), \(\theta + \varepsilon v\) and \(\theta - \varepsilon v\): same batch, same noise. A draw renewed between probes changes the function, and the quotient measures the redraw.
Mixed tolerance, never a raw relative error: \[ \lvert d_{\mathrm{AD}} - d_{\mathrm{FD}} \rvert \;\le\; \texttt{atol} + \texttt{rtol}\cdot\max\big(\lvert d_{\mathrm{AD}}\rvert, \lvert d_{\mathrm{FD}}\rvert\big) \] A bare ratio is ill-conditioned exactly where the gradient is small.
mlp = ... # 3-layer MLP, .double()
W = mlp[0].weight
W0 = W.detach().clone()
g_ad = torch.autograd.grad(mse(mlp(x), y), W)[0].clone()
v = torch.randn_like(W0)
v /= v.norm() # one fixed unit direction
def loss_at(w): # same x, y: draws frozen
with torch.no_grad():
W.copy_(w)
return float(mse(mlp(x), y))x and y never change between probes — that is condition 2, in code.
Measured: gap \(6.9\times10^{-11}\) against a tolerance of \(1.9\times10^{-8}\). Double d_ad on purpose and the check must reject — it does.
“autograd computes exactly the VJP composition proved in U0.T1; finite differences is our independent witness. We will use autodiff-as-proof-assistant again in U1.L1 (log-det via
jax.jacfwd).”
nn.Module and optimizersModuleReused in: every model of the course.
What it buys: parameter registration (model.parameters()), device movement (model.to(device)), serialization — next slide.
state_dict: the model as datastate_dict is a plain dict of name \(\to\) tensor. Portable, inspectable..step()The API is three calls, and today that is all they are:
| call | does |
|---|---|
opt.zero_grad() |
clear accumulated .grad (autograd block!) |
loss.backward() |
fill .grad via the VJP sweep |
opt.step() |
update parameters from .grad |
Which optimizer, and why, is the next theory session’s topic (U0.T2). Today they are black boxes with a .step().
Reused in: everything — this is the artifact.
for epoch in range(epochs):
model.train()
for x, y in train_loader:
x, y = x.to(device), y.to(device) # devices (tensors)
loss = loss_fn(model(x), y) # forward
opt.zero_grad() # clear (autograd)
loss.backward() # VJP sweep (autograd)
opt.step() # update (optimizers)
val_loss = evaluate(val_loader) # no_grad (autograd)Five steps: forward \(\to\) loss \(\to\) zero_grad \(\to\) backward \(\to\) step. Every block of this lab reappears as one line of this loop — you have seen the whole lab converge to this slide.
training-loop-templatecommon/train.py
set_seed(seed)
class Trainer:
__init__(model, opt, loss_fn, device, ckpt_dir, log_every)
fit(train_loader, val_loader, epochs)
# loop: forward -> loss -> zero_grad -> backward -> step
evaluate(loader) # runs under no_grad
save_ckpt(tag) / load_ckpt(tag)
minimal CSV/stdout logger
Baked in from day one: seeding, device handling, checkpoint round-trip test, val evaluation under no_grad. MLflow integration arrives in U0.L2 — the logger stays minimal today.
Deliberately the same dataset family that returns in U1.L1 (RealNVP), U2.L2 (FFJORD), U3.L1 (CFM) — you will watch four generations of models handle these two half-circles.
Today it is plain supervised classification: \(x \in \mathbb{R}^2\), \(y \in \{0, 1\}\), cross-entropy loss — the U0.T1 \(\widehat{R}_n(\theta) = \frac{1}{n} \sum_{i=1}^{n} \big(-\log \pi_{y_i}(x_i; \theta)\big)\), now as code:
Notebook TODOs E.1–E.4 (training-loop part): E.1 data + loaders, E.2 finish fit, E.3 evaluate, E.4 the checkpoint round trip. I live-code fit’s five-step core; you do the rest.
Plotting helper is given (plot_decision_boundary in the notebook) — today is not a matplotlib lab.
Commit common/train.py now. Literally now — this is the lab’s last checkpoint:
Homework-lite (unmarked, ~10 min): re-run the two-moons training with seeds 0, 1, 2. Look at the three final val accuracies. Eyeball the variance — no statistics required, just notice it is not zero.
That spread is the next session’s opening question (U0.T2).
Bring the committed template to both.