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,\, x_1,\, x_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 discussion assumes you know what autograd stores), every lab.
In U0.T1 we proved: reverse-mode differentiation is right-to-left accumulation of VJPs along the computational 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 ritual — the gradient-check — compares it against finite differences:
\[ \big[\nabla f(x)\big]_i \;\approx\; \frac{f(x + \varepsilon e_i) - f(x - \varepsilon e_i)}{2\varepsilon}, \qquad \text{error } O(\varepsilon^2). \]
No calculus, no tape — nothing shared with autograd. That independence is what makes it a witness.
\(O(\text{numel})\) forward passes — a witness, never a training method. (The cost table in U0.T1 said exactly this about forward-mode columns.)
The float64 trick — in float32, machine epsilon \(\approx 10^{-7}\) makes the FD quotient itself noisy; the check would fail even on correct gradients. Run the check in float64, train in float32.
“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.