An interactive course · built for interview mastery
Patches All the Way Down
Everything about Vision Transformers: the reasoning behind every design decision, a from-scratch PyTorch implementation, a pure-NumPy version with every gradient derived by hand, and the research lineage that followed — with live widgets and drills along the way.
Module 00
Start here
This course has one goal: after finishing it, you can walk to a whiteboard and rebuild a Vision Transformer from an empty file — in PyTorch or in raw NumPy — while explaining why every line is there. That combination (implementation fluency + design reasoning) is exactly what ML research interviews probe.
How to use it
Work through the modules in order. You said you know deep learning well but are rusty on attention, so modules 02–03 rebuild attention from first principles before anything vision-specific appears. Every module ends with a short quiz; your score and completion state are saved in this browser, so you can leave and come back. The interactive panels aren't decoration — each one exists because the concept it shows is a common interview probe (softmax temperature, quadratic cost, patch-size trade-offs, parameter counting).
Two rules that make this stick:
- Type the code yourself. Reading module 05 is not the same as reproducing it. After each implementation module, close the page and rewrite the module from memory. The button on code blocks is for checking your version, not for skipping the typing.
- Say the shapes out loud. Interviewers watch whether you track tensor shapes. Every code block here annotates shapes; module 04's shape-tracer drills them.
The route
01 motivates the whole enterprise (inductive bias, and why "just use a transformer" was a radical bet). 02–03 build scaled dot-product attention, multi-head attention, and the pre-norm transformer block. 04 assembles ViT itself — patch embedding, the [CLS] token, position embeddings — with a full parameter-count walkthrough. 05 is the complete PyTorch implementation; 06 re-derives it in NumPy including backprop through softmax, LayerNorm, and attention. 07 covers why ViTs are data-hungry and how they're actually trained. 08 collects the "interesting details" that separate good candidates from great ones. 09 traces DeiT → Swin → MAE → DINO. 10 is pure interview practice.
86,567,656 parameters, and the NumPy backward pass matches PyTorch autograd to 1e-16. You are not studying pseudocode.Warm-up · calibrate yourself
An image is 224×224. With 16×16 patches, how many patch tokens does a ViT see?
224/16 = 14 patches per side, so 14 × 14 = 196 tokens (plus one [CLS] token → 197 in the sequence). If this felt automatic, great — the whole course keeps arithmetic like this at your fingertips.
Module 01
Why throw away convolutions?
ViT is best understood as a bet about inductive bias: bake less knowledge into the architecture, and let scale buy it back — with interest.
By 2020, a decade of computer vision rested on two assumptions hard-wired into CNNs: locality (nearby pixels are related, so use small kernels) and translation equivariance (a cat is a cat wherever it sits, so share weights across positions). These are inductive biases — prior knowledge the architecture enforces so the model doesn't need to learn it from data.
The ViT paper (Dosovitskiy et al., "An Image is Worth 16×16 Words", ICLR 2021) asked: what if we remove almost all of that? Cut the image into patches, treat the patches as a sequence of tokens, and feed them to a plain transformer encoder — nearly unchanged from the NLP transformer of Attention Is All You Need (2017). A transformer layer has no notion that patch 15 sits next to patch 16. Every token can attend to every other token from layer one; where things are is told to the model only through a learned position embedding.
The paper's central empirical result confirms the trade exactly. Trained only on ImageNet-1k (1.3M images), ViT loses to comparable ResNets — it overfits, because nothing stops it. Trained on ImageNet-21k (14M) it pulls even. Trained on JFT-300M (303M images, Google-internal) it wins decisively, and it keeps improving where CNNs saturate. And crucially, it was cheaper to train per unit of accuracy at scale, because dense matmuls over tokens map onto accelerators better than convolution pipelines.
What ViT keeps, and the one place convolution survives
ViT is not "zero inductive bias." Three vision priors survive: the patchification itself assumes pixels arrive in a 2D grid and that a 16×16 tile is a sensible atomic unit; the position embeddings are learned per-location, so the model can rediscover 2D structure (and does — see module 08); and the whole model is applied to a fixed resolution. Also — a fact interviewers enjoy — the patch projection in every practical implementation is a convolution: a Conv2d with kernel = stride = patch size, which is mathematically identical to "slice and apply a shared Linear." Weight sharing across patches is a conv-style bias ViT quietly retains.
Checkpoint 01
Which inductive bias does a plain ViT encoder layer NOT have?
The encoder treats tokens as an unordered set (position info comes only from added embeddings); nothing prefers near over far. Weight sharing survives in the patch projection, and the 2D grid assumption lives in patchification.
Pre-trained on ImageNet-1k only, ViT vs. a comparable ResNet — who wins?
This is the paper's core finding: below ~14M images the missing priors hurt; at JFT-300M scale ViT overtakes and keeps scaling.
Module 02
Attention from first principles
Self-attention is a differentiable dictionary lookup: every token asks a question, every token offers an answer, and softmax decides how much of each answer to blend.
Forget images for two modules. You have a sequence of N vectors x₁ … x_N, each of dimension D, stacked into a matrix X of shape (N, D). We want each token's new representation to be built from the tokens that are relevant to it — where relevance is learned, not hard-coded.
Step 1 — three roles, three projections
Each token plays three roles at once, produced by three learned linear maps:
The retrieval metaphor is exact and worth saying in an interview: a query is what this token is looking for; a key is how a token advertises what it contains; a value is the content it hands over if selected. Why three separate matrices instead of comparing raw x vectors? Because relevance is asymmetric and task-dependent: "what a verb wants" ≠ "what a verb offers." Separate projections let the model place queries and keys in a space where dot product = usefulness, independent of what content gets transmitted.
Step 2 — similarity by dot product
Step 3 — softmax rows into mixing weights
Each row of A is a probability distribution: non-negative, sums to 1. Row i of the output is a weighted average of all value vectors, weighted by how relevant each token is to token i. That's the whole mechanism. Note what it costs: S is an N×N matrix, so compute and memory are O(N²·d) — the fact that drives half the follow-up research in module 09.
Self-attention, seen whole
"Self" just means Q, K, V all come from the same sequence. The full einsum chain, with shapes:
Q = X @ W_q # (N, D) @ (D, d) -> (N, d) K = X @ W_k # (N, d) V = X @ W_v # (N, d) A = softmax(Q @ K.T / sqrt(d)) # (N, N) rows sum to 1 out = A @ V # (N, d) each row: blend of values
Checkpoint 02
If q, k have i.i.d. entries with mean 0 and variance 1, the variance of q·k is:
Each of the d terms qᵢkᵢ has variance 1 and they're independent, so variances add: Var(q·k) = d. Hence dividing by √d gives variance 1.
What breaks when softmax inputs are very large in magnitude?
Softmax's Jacobian entries look like pᵢ(δᵢⱼ − pⱼ); when one p ≈ 1 and the rest ≈ 0, all entries ≈ 0. The rows always sum to 1 — that's never the failure.
Why separate W_Q and W_K rather than scoring with x_i · x_j directly?
x·x scoring forces symmetric relevance (i cares about j ⇔ j cares about i) and makes every token maximally attend to itself. Projections remove both constraints — and cost the same O(N²) as the score matrix anyway.
Module 03
Heads, blocks & residual streams
One attention pattern per layer is too few opinions. Multi-head attention runs several cheap attentions in parallel — then a block wraps attention and an MLP around a residual stream that gradients can flow through untouched.
Multi-head attention: parallel subspaces, same price
A single softmax produces a single mixing pattern per token — but a patch may simultaneously need "the other patches of this object," "the texture context," and "the global gist." So we split the model dimension D into H heads of size d_h = D/H (ViT-Base: D = 768, H = 12, d_h = 64). Each head gets its own Q, K, V projections into its 64-dim subspace, runs attention independently, and the H outputs are concatenated back to D and mixed by a final linear W_O.
In code, nobody loops over heads. The trick — worth performing smoothly on a whiteboard — is one fused projection to 3D followed by a reshape so the head axis sits beside the batch axis, making every head's attention a single batched matmul:
qkv = self.qkv(x) # (B, N, D) -> (B, N, 3D) one fused Linear qkv = qkv.reshape(B, N, 3, H, D // H) # split: which-of-qkv, head, head_dim qkv = qkv.permute(2, 0, 3, 1, 4) # (3, B, H, N, d_h) q, k, v = qkv[0], qkv[1], qkv[2] # each (B, H, N, d_h) attn = (q @ k.transpose(-2, -1)) * self.scale # (B, H, N, N) batched over B*H out = (attn.softmax(-1) @ v) # (B, H, N, d_h) out = out.transpose(1, 2).reshape(B, N, D) # concat heads back -> (B, N, D)
The MLP: where most of the parameters live
After attention mixes information across tokens, a two-layer MLP processes each token independently: expand D → 4D, apply GELU, project 4D → D. The 4× ratio is inherited from the original transformer and survives because it keeps working. Two-thirds of a ViT block's parameters sit here, not in attention — a fact that surprises people and therefore gets asked.
Pre-norm and the residual stream
The block wires these together with two residual connections and LayerNorms placed before each sublayer (pre-norm) — a deliberate departure from the 2017 post-norm transformer:
Checkpoint 03
ViT-Base: D = 768, H = 12. Compared to one 768-dim head, using 12 heads of 64 dims changes projection parameter count how?
Each projection is still a D×D map overall (12 slices of 768×64). What changes is expressivity: 12 independent attention distributions instead of 1, each in a lower-rank subspace.
The main practical reason pre-norm replaced post-norm:
Same number of LNs (plus one final). The win is optimization: no normalization Jacobian on the trunk path.
Roughly what fraction of a ViT block's parameters are in the MLP?
MLP: 2·D·4D = 8D². Attention: 4D² (QKV + output proj). 8/12 ≈ ⅔ in the MLP.
Module 04
The ViT architecture
Everything vision-specific happens in the first three lines: cut, flatten, project, add position. From there it's a text transformer that never finds out it's looking at an image.
Patch embedding: images become sentences
Take an image (3, 224, 224), slice it into a 14×14 grid of 16×16 patches, flatten each patch to a 768-vector (16·16·3), and multiply by a learned (768, D) matrix. The result: 196 tokens of dimension D. A "word" is a 16×16 tile of pixels — hence the paper's title.
Every real implementation does the slicing and projection in one op: nn.Conv2d(3, D, kernel_size=P, stride=P). With kernel = stride, windows never overlap, so the convolution is "shared Linear applied to each patch" — same math, one fused kernel. Being able to state this equivalence precisely is a small but reliable interview win.
The [CLS] token: a learned blank slate for the answer
Borrowed from BERT: prepend one learned D-dim vector to the 196 patch tokens. It participates in every attention layer like any token, and at the end the classification head reads only its final state. Sequence length becomes 197.
Position embeddings: telling a set it's a grid
Attention is permutation-equivariant — shuffle the 196 patches and the outputs shuffle identically, so ViT would classify a jigsaw-scrambled image the same as the original. The fix: add a learned vector p_i to token i before the first block. One (197, D) table, trained end-to-end.
The whole machine
Checkpoint 04
Why is the patch projection implemented as Conv2d(kernel=P, stride=P)?
kernel = stride ⇒ each output position sees one disjoint patch ⇒ identical math to a shared Linear on flattened patches. Overlap would change the model (some hybrids do that deliberately).
Remove the position embeddings entirely. What can the trained model no longer do?
Self-attention is permutation-equivariant and [CLS] readout is permutation-invariant over patch tokens; without positional information ViT is a bag-of-patches model. (It still works surprisingly okay — texture carries a lot — but layout is gone.)
Same image, patch size halved (16 → 8). Sequence length and attention cost change by:
Tokens scale with (224/P)²: 196 → 784. The N×N attention matrix scales with N², so ~16×. This quadratic is why patch size is the main compute dial.
Module 05
PyTorch, from scratch
The complete model in ~90 lines, no timm, no shortcuts. Every block below was executed before publishing; the assembled model prints 86,567,656 parameters and a training loop drives its loss down.
Build order matters: leaf modules first, assembly last. That's also the order to use at a whiteboard, because each piece is independently testable — after writing Attention, you can already assert its output shape equals its input shape.
1 · Patch embedding
class PatchEmbed(nn.Module):
"""(B, 3, 224, 224) -> (B, 196, D): cut into P×P patches, project to D."""
def __init__(self, img_size=224, patch_size=16, in_chans=3, embed_dim=768):
super().__init__()
self.num_patches = (img_size // patch_size) ** 2
# kernel = stride = P == 'flatten each patch, apply shared Linear'
self.proj = nn.Conv2d(in_chans, embed_dim,
kernel_size=patch_size, stride=patch_size)
def forward(self, x): # (B, 3, 224, 224)
x = self.proj(x) # (B, D, 14, 14) one dot product per patch
x = x.flatten(2) # (B, D, 196) merge the two grid axes
x = x.transpose(1, 2) # (B, 196, D) tokens-last-dim, like NLP
return xLine by line. flatten(2) merges dims 2 and 3 (the 14×14 grid) into 196 — row-major, so token order is "reading order." The transpose exists purely for convention: transformers everywhere expect (batch, sequence, features). No parameters beyond the conv's D·3·P² + D.
2 · Multi-head self-attention
class Attention(nn.Module):
def __init__(self, dim, num_heads=12, attn_drop=0., proj_drop=0.):
super().__init__()
assert dim % num_heads == 0 # heads must tile the model dim
self.num_heads = num_heads
self.head_dim = dim // num_heads
self.scale = self.head_dim ** -0.5 # 1/sqrt(d_k): the variance fix
self.qkv = nn.Linear(dim, dim * 3) # fused Q,K,V — one matmul, not three
self.attn_drop = nn.Dropout(attn_drop)
self.proj = nn.Linear(dim, dim) # W_O: re-mix concatenated heads
self.proj_drop = nn.Dropout(proj_drop)
def forward(self, x): # (B, N, D)
B, N, D = x.shape
qkv = self.qkv(x) # (B, N, 3D)
qkv = qkv.reshape(B, N, 3, self.num_heads, self.head_dim)
qkv = qkv.permute(2, 0, 3, 1, 4) # (3, B, H, N, d_h)
q, k, v = qkv[0], qkv[1], qkv[2] # each (B, H, N, d_h)
attn = (q @ k.transpose(-2, -1)) * self.scale # (B, H, N, N)
attn = attn.softmax(dim=-1) # rows sum to 1, per head
attn = self.attn_drop(attn)
x = attn @ v # (B, H, N, d_h)
x = x.transpose(1, 2).reshape(B, N, D) # concat heads
return self.proj_drop(self.proj(x))Why the fused qkv? Three separate Linears would produce identical math; one (D, 3D) matmul is simply faster (one kernel launch, better GEMM shape) and is the idiom you'll see in timm, nanoGPT, and every serious codebase. Why scale before softmax, not after the matmul chain? It must hit the logits; scaling attention outputs later wouldn't fix softmax saturation. Note also transpose(1, 2) before the final reshape — forgetting it silently interleaves head outputs across tokens, a classic bug that still type-checks. (In production you'd let F.scaled_dot_product_attention pick a FlashAttention kernel — see module 08 — but interviews want this explicit version.)
3 · MLP and the block
class MLP(nn.Module):
"""Per-token: expand 4×, gate with GELU, project back."""
def __init__(self, dim, hidden_dim, drop=0.):
super().__init__()
self.fc1 = nn.Linear(dim, hidden_dim)
self.act = nn.GELU()
self.fc2 = nn.Linear(hidden_dim, dim)
self.drop = nn.Dropout(drop)
def forward(self, x):
x = self.drop(self.act(self.fc1(x)))
x = self.drop(self.fc2(x))
return x
class Block(nn.Module):
"""Pre-norm: the stream x is only ever added to."""
def __init__(self, dim, num_heads, mlp_ratio=4., drop=0., attn_drop=0.):
super().__init__()
self.norm1 = nn.LayerNorm(dim, eps=1e-6) # ViT uses eps=1e-6, not the 1e-5 default
self.attn = Attention(dim, num_heads, attn_drop, drop)
self.norm2 = nn.LayerNorm(dim, eps=1e-6)
self.mlp = MLP(dim, int(dim * mlp_ratio), drop)
def forward(self, x):
x = x + self.attn(self.norm1(x)) # tokens talk to each other
x = x + self.mlp(self.norm2(x)) # each token thinks alone
return xThe two comments in Block.forward are the sentence to say in interviews: attention is the only cross-token operation in the entire network; everything else is per-token. That factorization — communicate, then compute — is the transformer.
4 · Assembly
class VisionTransformer(nn.Module):
def __init__(self, img_size=224, patch_size=16, in_chans=3,
num_classes=1000, embed_dim=768, depth=12,
num_heads=12, mlp_ratio=4., drop=0.):
super().__init__()
self.patch_embed = PatchEmbed(img_size, patch_size, in_chans, embed_dim)
num_patches = self.patch_embed.num_patches # 196
self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
self.pos_embed = nn.Parameter(torch.zeros(1, num_patches + 1, embed_dim))
self.pos_drop = nn.Dropout(drop)
self.blocks = nn.ModuleList([
Block(embed_dim, num_heads, mlp_ratio, drop) for _ in range(depth)
])
self.norm = nn.LayerNorm(embed_dim, eps=1e-6) # final LN (pre-norm needs it)
self.head = nn.Linear(embed_dim, num_classes)
nn.init.trunc_normal_(self.pos_embed, std=0.02) # paper/timm init
nn.init.trunc_normal_(self.cls_token, std=0.02)
self.apply(self._init_weights)
def _init_weights(self, m):
if isinstance(m, nn.Linear):
nn.init.trunc_normal_(m.weight, std=0.02)
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.LayerNorm):
nn.init.ones_(m.weight); nn.init.zeros_(m.bias)
def forward(self, x): # (B, 3, 224, 224)
B = x.shape[0]
x = self.patch_embed(x) # (B, 196, D)
cls = self.cls_token.expand(B, -1, -1) # broadcast, no copy per image
x = torch.cat([cls, x], dim=1) # (B, 197, D)
x = self.pos_drop(x + self.pos_embed) # position is *added*, not concatenated
for blk in self.blocks:
x = blk(x)
x = self.norm(x)
return self.head(x[:, 0]) # read the CLS row only -> (B, 1000)Details that get probed. (1) expand vs repeat: expand creates a broadcast view — no memory copy; gradients from all B images still accumulate into the single shared [CLS] parameter. (2) Position embedding is added, not concatenated — it keeps D fixed and lets position and content share the same subspaces; concatenation would work but grows every weight matrix downstream. (3) Why a final norm? In pre-norm nets the stream itself is never normalized, so its magnitude grows with depth; the last LN tames it before the head reads. (4) trunc_normal_(std=0.02): small init keeps early attention logits small (near-uniform attention at start), which stabilizes the first steps.
5 · Prove it works
model = VisionTransformer() # ViT-B/16 defaults
print(sum(p.numel() for p in model.parameters())) # 86567656 ✓ canonical count
out = model(torch.randn(2, 3, 224, 224))
assert out.shape == (2, 1000) # ✓
# overfit sanity: a tiny ViT on one fixed batch — loss must collapse
tiny = VisionTransformer(img_size=32, patch_size=8, num_classes=10,
embed_dim=64, depth=2, num_heads=4)
opt = torch.optim.AdamW(tiny.parameters(), lr=1e-3)
xb, yb = torch.randn(8, 3, 32, 32), torch.randint(0, 10, (8,))
for _ in range(30):
loss = F.cross_entropy(tiny(xb), yb)
opt.zero_grad(); loss.backward(); opt.step()
# loss: 2.259 -> 0.411 ✓ the wiring is correctCheckpoint 05
You forget transpose(1, 2) before the final reshape in Attention. What happens?
(B, H, N, d_h) reshaped straight to (B, N, D) has the right element count, so it silently misassigns which token owns which features. Shape-compatible bugs are the dangerous ones — hence the overfit test.
Why must the final LayerNorm exist in a pre-norm ViT?
Every block adds into x without normalizing it. After 12 additions the stream's magnitude is depth-dependent; norm-then-read makes the head's input well-scaled. (Logits summing to one is softmax's job, at loss time.)
Module 06
NumPy: forward & backward, by hand
Autograd is a luxury. This module rebuilds ViT with every gradient derived on paper and checked against PyTorch to 10⁻¹⁶ — the deepest possible answer to "do you actually understand backprop?"
The architecture is the one you just built; what's new is the backward pass. Three derivations do all the work — softmax, LayerNorm, and attention — and everything else is the chain rule over matmuls. One convention first: for Y = XW + b, the three gradients every interviewer expects instantly:
Memorize by shape: GWᵀ is the only product with X's shape; XᵀG the only one with W's. Shapes are the error-correcting code of backprop.
Derivation 1 — softmax
For p = softmax(z): ∂pᵢ/∂zⱼ = pᵢ(δᵢⱼ − pⱼ). Contract that Jacobian with the upstream gradient g and it collapses to a one-liner:
def softmax(x, axis=-1):
x = x - x.max(axis=axis, keepdims=True) # shift-invariance -> numerical stability
e = np.exp(x)
return e / e.sum(axis=axis, keepdims=True)
def softmax_grad(p, dp, axis=-1):
# dL/dz = p * (dp - sum(dp * p)) : the full Jacobian, without materializing it
return p * (dp - (dp * p).sum(axis=axis, keepdims=True))Note the max-subtraction: softmax is invariant to shifting its input, and shifting by the max means the largest exponent is e⁰ = 1 — no overflow ever. Interviewers ask for exactly this trick.
Derivation 2 — LayerNorm
Forward: x̂ = (x − μ)/√(σ² + ε), y = γ·x̂ + β, with μ, σ² over the feature axis. The subtlety: x affects y through three routes — directly, through μ, and through σ². Collecting all three terms (the full derivation is a standard exercise; do it once on paper) gives a closed form worth memorizing:
class LayerNorm:
def __init__(self, dim, eps=1e-6):
self.g = np.ones(dim); self.b = np.zeros(dim); self.eps = eps
def forward(self, x):
self.mu = x.mean(-1, keepdims=True)
self.var = x.var(-1, keepdims=True) # biased variance, like torch
self.xhat = (x - self.mu) / np.sqrt(self.var + self.eps)
return self.g * self.xhat + self.b
def backward(self, dout):
D = dout.shape[-1]
self.dg = (dout * self.xhat).reshape(-1, D).sum(0) # per-feature, over all tokens
self.db = dout.reshape(-1, D).sum(0)
dxhat = dout * self.g
istd = 1.0 / np.sqrt(self.var + self.eps)
return istd * (dxhat
- dxhat.mean(-1, keepdims=True) # via mu
- self.xhat * (dxhat * self.xhat).mean(-1, keepdims=True)) # via varRead the return as: "the naive gradient, corrected so the output can't change the mean or the variance the layer just removed." The two subtracted means are exactly the projections onto the directions LayerNorm quotients out.
Derivation 3 — attention backward
Forward chain: S = QKᵀ/√d → A = softmax(S) → O = AV. Reverse each arrow, remembering the product rule at both matmuls:
def backward(self, dout): # dout: (B, N, D)
B, N, D = dout.shape
d_out = self.proj.backward(dout) # back through W_O
d_out = d_out.reshape(B, N, self.H, self.dh).transpose(0, 2, 1, 3) # un-concat heads
dA = d_out @ self.v.transpose(0, 1, 3, 2) # O = A V (product rule, half 1)
dV = self.A.transpose(0, 1, 3, 2) @ d_out # (product rule, half 2)
dS = softmax_grad(self.A, dA) / np.sqrt(self.dh) # through softmax, then the 1/√d
dQ = dS @ self.k # S = Q Kᵀ/√d
dK = dS.transpose(0, 1, 3, 2) @ self.q
dqkv = np.stack([dQ, dK, dV]) # (3, B, H, N, d_h)
dqkv = dqkv.transpose(1, 3, 0, 2, 4).reshape(B, N, 3 * D) # exact inverse of fwd reshape
return self.qkv.backward(dqkv)Two things to internalize. Every transpose in backward is the inverse of one in forward — write the forward reshapes down and mirror them, don't improvise. And the residual connections in the block make backward almost embarrassingly simple: x + f(x) means the incoming gradient is copied to both paths and summed:
def backward(self, dout):
dout = dout + self.ln2.backward(self.mlp.backward(dout)) # skip + branch
dout = dout + self.ln1.backward(self.attn.backward(dout)) # skip + branch
return dout # note: dout reaches the previous block *undiminished* via the bare '+'There is the vanishing-gradient story in executable form: whatever the branches contribute, the identity term guarantees the gradient that leaves this block is at least the gradient that entered it, unmodified. That's the sentence to say when asked "why do residuals help?"
Patchify without a conv, and the check that makes it true
def patchify(self, x): # (B, C, H, W) -> (B, N, P*P*C)
B, C, H, W = x.shape; p = self.patch
x = x.reshape(B, C, H // p, p, W // p, p) # factor each spatial axis into (grid, within)
x = x.transpose(0, 2, 4, 3, 5, 1) # (B, gh, gw, p, p, C): grid axes out front
return x.reshape(B, self.N, p * p * C) # flatten grid -> tokens, patch -> featuresloss numpy 2.344725 torch 2.344725 # identical forward embed.W max|Δgrad| = 4.4e-17 # every hand gradient vs autograd cls max|Δgrad| = 5.6e-16 pos max|Δgrad| = 5.6e-16 blk0.qkv.W max|Δgrad| = 6.9e-17 blk0.ln1.g max|Δgrad| = 4.1e-18 blk1.fc1.W max|Δgrad| = 6.2e-17 head.W max|Δgrad| = 3.9e-16 all gradients match torch autograd — OK step 0 loss 2.3447 ... step 50 loss 0.1728 # plain SGD, pure numpy, it trains
softmax_grad and the LayerNorm backward cold, you are ahead of most candidates. The gradient-checking habit itself is also a talking point: compare against autograd (or finite differences: (L(θ+ε)−L(θ−ε))/2ε) on a tiny model in float64 — checking in float32 at real scale produces false alarms from rounding alone.Checkpoint 06
Backprop through y = x + f(x): the gradient flowing to x is…
Addition's Jacobian w.r.t. each input is the identity — gradients copy, they don't split. The bare dy term is why depth stops being an optimization obstacle.
Why subtract the row max inside softmax?
exp(z−c) appears in both numerator and denominator, so any constant c cancels exactly. Choosing c = max makes the arithmetic safe without changing a single output value.
In ∂L/∂W = XᵀG for a Linear layer applied to (B·N, D) inputs, what does the matrix product implicitly do?
The contraction over the row axis is a sum over every (batch, token) that used W. "Shared parameter ⇒ summed gradient" is a rule worth saying explicitly; averaging happens only if the loss itself averages.
Module 07
Training recipes
Architecture is a third of the story. ViTs live or die by their training recipe — and the recipe's logic follows directly from the missing inductive biases of module 01.
The optimizer: AdamW, weight decay done right
Every serious ViT trains with AdamW. Two reasons it beats SGD+momentum here (while ResNets are happy with SGD): transformer gradients are far less isotropic — embedding tables, LayerNorm gains, and attention projections have wildly different curvature, and Adam's per-parameter scaling absorbs that; and the "W" matters: classic Adam entangles L2 regularization with the adaptive scaling (the penalty gets divided by √v̂, so high-variance weights are barely regularized). AdamW decouples decay — w ← w − lr·(update + λ·w) — restoring true shrinkage. ViT recipes use unusually large decay (0.1–0.3) as an extra regularizer, and exempt biases, LayerNorm parameters, [CLS], and position embeddings from it (decaying a bias or a norm gain toward zero fights their job, not overfitting).
The schedule: warmup, then cosine
Regularization scales opposite to data
The paper's key recipe table reads like a see-saw. Pre-training on JFT-300M: almost no regularization (no dropout, light decay) — 300M images regularize by existing. Training on ImageNet-1k alone: everything at once — RandAugment, Mixup, CutMix, random erasing, label smoothing 0.1, stochastic depth, dropout, heavy decay. The DeiT paper (module 09) is essentially the discovery of the exact augmentation cocktail that replaces 300M images.
Fine-tuning and the resolution trick
Standard practice: pre-train at 224, fine-tune at 384+. Higher resolution with the same patch size means more tokens (224→384: 196→576 patches) — and a position-embedding table of the wrong length. The fix is elegant and a guaranteed interview question: reshape the 196 learned embeddings back to their 14×14 grid, bicubically interpolate to 24×24, flatten to 576. This works precisely because (module 08) the learned table encodes a smooth 2D layout — smooth signals interpolate well. The [CLS] embedding is carried over unchanged, and a brief fine-tune lets the model adapt.
def interpolate_pos_embed(pos, new_grid): # pos: (1, 1+N, D)
cls_pe, patch_pe = pos[:, :1], pos[:, 1:] # split off CLS
g = int(patch_pe.shape[1] ** 0.5) # old grid side, e.g. 14
patch_pe = patch_pe.reshape(1, g, g, -1).permute(0, 3, 1, 2) # (1, D, 14, 14)
patch_pe = F.interpolate(patch_pe, size=new_grid,
mode="bicubic", align_corners=False) # (1, D, 24, 24)
patch_pe = patch_pe.permute(0, 2, 3, 1).reshape(1, new_grid**2, -1)
return torch.cat([cls_pe, patch_pe], dim=1) # (1, 1+576, D)The reference recipes, condensed
| Setting | ViT paper (JFT pre-train) | DeiT (ImageNet-only) |
|---|---|---|
| Optimizer | Adam β=(0.9, 0.999) | AdamW |
| Base LR · schedule | ~8e-4 · warmup 10k + cosine (pre-train); SGD for fine-tune | 5e-4 × bs/512 · warmup 5 ep + cosine |
| Weight decay | 0.1 (high) | 0.05 |
| Batch | 4096 | 1024 |
| Epochs | 7–14 (JFT is huge) | 300 |
| Augment / reg | minimal — data is the regularizer | RandAugment, Mixup, CutMix, erasing, stoch. depth, label smoothing, repeated aug |
| Extra trick | fine-tune at 384, interpolate pos-embed | distillation token from a CNN teacher |
Checkpoint 07
Why does AdamW's decoupled decay matter more for transformers than Adam's L2?
That inverse-√v̂ interaction is the entire content of the AdamW paper, and ViT recipes lean on large decay values — which only behave as intended when decoupled.
Fine-tuning 224 → 384 with patch 16. What must change, and how?
Patch projection is per-patch and doesn't care how many patches there are; attention handles any N. Only the learned (1+196, D) table has the wrong length — and its emergent 2D smoothness is what makes interpolation legitimate.
Module 08
Details interviewers love
The findings, quirks, and systems facts that turn a correct answer into a memorable one.
1 · Position embeddings secretly learn the grid
Compute cosine similarity between the learned embedding of each patch position and every other. The result (ViT paper, Fig 7 center): each position is most similar to its 2D neighbors, with visible row and column bands — although training never told the model the tokens came from a grid. The removed inductive bias was re-derived from data. This is also, as module 07 showed, why bicubic interpolation of the table is sound.
2 · Attention distance: CNN-like from the bottom, global from the start
Define a head's "attention distance" as the average image-space distance between a query patch and the patches it attends to, weighted by attention — a receptive-field size for attention. The paper's measurement: in early layers, heads split — some are local (small distance, conv-like), others are already global. By mid-depth, nearly all heads are global. So ViT learns a local-to-global processing hierarchy where CNNs have it imposed — but keeps a few global shortcuts from layer one, which a CNN cannot have. With small datasets the local heads fail to emerge, which is one lens on why ViT under-performs there.
3 · O(N²) in practice, and FlashAttention
For ViT-B/16 at 224², N = 197 — the attention matrix is 197×197 ≈ 39k entries per head: trivial. The quadratic only bites at high resolution (1024² with patch 16 → N = 4096 → 16.8M entries per head per layer). Two responses coexist: change the architecture (Swin's windows, module 09) or change the kernel — FlashAttention computes exact softmax attention without ever materializing the N×N matrix in slow GPU memory, tiling the computation through on-chip SRAM with the online-softmax trick (a running max and running sum let you renormalize incrementally). Memory drops from O(N²) to O(N); the math is unchanged. The one-liner: "FlashAttention is an IO optimization, not an approximation."
4 · [CLS] vs global average pooling
Both work; the paper's appendix shows equal accuracy with separately tuned LRs — a nice example of hyperparameter coupling masquerading as an architecture effect. Practical modern note: for dense tasks (segmentation, depth) you use the patch tokens anyway, and models like DINOv2 expose both a [CLS] summary and patch-level features.
5 · Registers: the attention-sink discovery
"Vision Transformers Need Registers" (Darcet et al., 2023/24): large ViTs spontaneously repurpose a few low-information background patches as scratch storage — those tokens get abnormally high attention norms and pollute attention maps with spiky artifacts. The fix is almost comic: append a few extra learned tokens (registers) with no input and no output role, giving the model somewhere to put its global scratch data. Artifacts vanish, dense-task features improve. Lesson to quote: the [CLS] idea generalizes — transformers benefit from explicit "memory" tokens, and if you don't provide them the model improvises them out of your image.
6 · Robustness and what ViTs actually look at
ViTs lean less on high-frequency texture than CNNs of their era: they're notably more robust to patch-level occlusion and adversarial patches, and attention lets a single layer route around a masked region. But the cliché "CNNs = texture, ViTs = shape" oversimplifies — training recipe and augmentation move this dial more than architecture does (heavily-augmented CNNs also become shape-biased). Saying that nuance is exactly the kind of calibrated claim interviewers reward.
7 · Small numbers worth having memorized
| Model | D | Depth | Heads | MLP | Params |
|---|---|---|---|---|---|
| ViT-Ti/16 | 192 | 12 | 3 | 768 | 5.7M |
| ViT-S/16 | 384 | 12 | 6 | 1536 | 22M |
| ViT-B/16 | 768 | 12 | 12 | 3072 | 86.6M |
| ViT-L/16 | 1024 | 24 | 16 | 4096 | 307M |
| ViT-H/14 | 1280 | 32 | 16 | 5120 | 632M |
Also: head_dim is 64 in every variant (D/H = 64 throughout — a de-facto constant across transformers, big and small); sequence length 197 at 224/patch-16; ViT-L/16 fine-tuned from JFT hit 87.76% ImageNet top-1 in the original paper, and ViT-H/14 88.55%.
Checkpoint 08
FlashAttention speeds up exact attention primarily by…
It's IO-aware scheduling of the same math — bitwise-equivalent results (up to float order), O(N) memory. Approximation-based methods (Performer, Linformer) are a different family.
The "registers" finding shows that large ViTs, without extra tokens, will…
The model needs somewhere to store global computation; denied dedicated slots, it overwrites boring patches. Adding a few input-free register tokens removes the artifacts.
Measured "attention distance" in early ViT layers shows…
Early-layer heads span the local↔global range; depth pushes the population global. Learned hierarchy, plus day-one global shortcuts.
Module 09
The lineage: DeiT → Swin → MAE → DINO
Each successor answers one specific weakness of the original. Learn them as answers to questions, and you can reconstruct the whole timeline on demand.
DeiT (2021) — "must it really take 300M images?"
Data-efficient Image Transformers made ViT trainable on ImageNet-1k alone, in ~3 days on one 8-GPU node — no JFT. Two ingredients. First, the aggressive augmentation/regularization cocktail from module 07 (RandAugment, Mixup, CutMix, erasing, stochastic depth, repeated augmentation) — supplying synthetically the sample diversity that JFT supplied literally. Second, distillation through a token: alongside [CLS], append a learned distillation token whose head is trained to match a RegNet CNN teacher's predictions (hard-label distillation worked best). The CNN's inductive biases leak into the ViT through the distillation loss — the student gets convolutional priors without convolutional architecture. DeiT-B reached ~83.4% ImageNet top-1 with distillation; the "data hunger" objection to ViT died here.
Swin (2021) — "how do we afford dense prediction?"
Detection and segmentation need high-resolution feature maps, where global attention's O(N²) is unpayable (module 08's arithmetic). Swin restores the two CNN structural ideas ViT discarded, in transformer form. Windowed attention: compute self-attention only within 7×7-patch local windows — cost becomes linear in image size. Shifted windows: alternate layers offset the window grid by half a window, so information crosses window borders every other layer (otherwise windows would be permanently isolated islands). Hierarchy: stages of patch-merging (concatenate each 2×2 neighborhood of tokens, project down) halve resolution and double channels — a feature pyramid, /4 → /8 → /16 → /32, that drops straight into detection frameworks like a ResNet backbone. Swin also swaps learned absolute positions for relative position biases added to attention logits. Trade to articulate: Swin re-buys CNN-like efficiency by re-imposing locality — a deliberate step back from ViT's purity, and the price is that plain ViT scales better with data while Swin wins at dense tasks per FLOP.
MAE (2022) — "what's the BERT moment for vision?"
Masked Autoencoders answer: mask 75% of patches (BERT masks 15% of words — images are far more redundant, so the task must be made much harder to force semantic learning). The asymmetric design is the engineering jewel: the encoder (a plain ViT) sees only the 25% visible patches — no mask tokens — so pre-training compute drops ~4×; a lightweight decoder receives the encoded patches plus shared learnable [MASK] tokens with position embeddings and reconstructs raw pixels (per-patch-normalized, MSE on masked patches only). After pre-training, throw the decoder away. Self-supervised ViT-Huge fine-tuned to 87.8% ImageNet — proof that ViTs can pre-train from unlabeled pixels alone, and that the "needs labels at scale" era was over.
DINO (2021) & DINOv2 (2023) — "can features be useful without any fine-tuning?"
DINO is self-distillation: a student ViT sees aggressive local+global crops, a teacher — an exponential moving average of the student's own weights — sees mild global crops, and the student matches the teacher's soft output distribution (cross-entropy after centering + sharpening, the two tricks that prevent representational collapse). No labels anywhere. The astonishing emergent result: the [CLS] attention maps of the final layer segment objects — clean foreground masks nobody asked for — and k-NN classification on frozen features works nearly as well as linear probing. DINOv2 scaled the recipe (curated 142M-image dataset, ViT-g, plus an iBOT-style masked-token loss and engineering like KoLeo regularization) into general-purpose frozen backbones whose features transfer to depth, segmentation, and retrieval without fine-tuning — the closest vision has to a foundation-model feature extractor, and (with registers added) the standard baseline today.
Checkpoint 09
Why does MAE mask 75% when BERT masks 15%?
Redundancy sets the difficulty dial; the encoder-only-sees-visible-patches design turns the high ratio into a compute win simultaneously.
Remove the window shifting from Swin. What breaks?
Cost stays linear either way; the shift is purely the communication mechanism (patch merging between stages would still mix a little, but within-stage receptive fields freeze at window size).
In DINO, what prevents the student and teacher from collapsing to a constant output?
Centering fights one-dimension dominance, sharpening fights uniformity; balanced against each other (with the slow EMA teacher) they keep the output distribution informative with no labels and no negative pairs.
Module 10
Interview drills
Spaced repetition for the whole course. Read the question, answer out loud in full sentences, then open the card. If you hesitated, you haven't finished the module it points to.
Rapid-fire cards
M02Derive why attention scores are divided by √d.
For i.i.d. zero-mean unit-variance entries, q·k = Σqᵢkᵢ has variance d (variances of independent terms add). Std √d grows with head size; softmax of ±√d-scale logits saturates toward one-hot, and its Jacobian p(δ−p) then vanishes. Dividing by √d restores unit variance for any d, keeping softmax in its responsive regime.
M02Complexity of self-attention in sequence length N — time and memory, and where it bites for images.
Time O(N²·d) (score matrix + value mixing), memory O(N²) per head for the attention matrix (O(N) with FlashAttention). N = (H/P)²: at 224/16, N=197 — negligible; doubling resolution quadruples N and ×16s the matrix. High-res dense prediction is where it hurts — hence Swin's windows and FlashAttention.
M03Multi-head attention: what does it cost and what does it buy versus one big head?
Parameters and FLOPs are identical — H heads of dim D/H tile the same D×D projections. It buys H independent attention distributions per layer (different relevance criteria simultaneously); it costs per-head rank — each head compares in only D/H dimensions. Head dim is ~64 in virtually every deployed transformer.
M03Pre-norm vs post-norm — the gradient argument.
Pre-norm: out = x + f(LN(x)), so ∂out/∂x = I + (branch terms) — an unattenuated identity path from loss to layer 1; stable at depth without warmup gymnastics. Post-norm: out = LN(x + f(x)) puts a normalization Jacobian on the trunk itself, compounding per block. That is why ViT (and GPT-2 onward) are pre-norm, with one final LN to rescale the stream before readout.
M04Walk the shapes: batch 32 of 224×224 through ViT-B/16 to logits.
(32,3,224,224) → conv P16 → (32,768,14,14) → flatten+transpose → (32,196,768) → +[CLS] → (32,197,768) → +pos (1,197,768 broadcast) → 12 blocks, shape-invariant → final LN → take row 0 → (32,768) → head → (32,1000).
M04Count ViT-B/16's parameters from the config (768/12/12, patch 16, 1000 classes).
Patch embed 3·256·768+768 = 590,592. Pos 197·768 = 151,296; CLS 768. Block: QKV 768·2304+2304; proj 768²+768; MLP 768·3072+3072 + 3072·768+768; 2 LN = 4·768 → 7,087,872; ×12 = 85,054,464. Final LN 1,536; head 769,000. Total 86,567,656 ≈ 86.6M — two-thirds of each block in the MLP.
M04Why is patch embedding a Conv2d, and when is that equivalence exact?
Conv2d(C→D, kernel=P, stride=P): non-overlapping windows mean each output is one dot product between a flattened patch and shared weights — exactly flatten-then-Linear. Equivalence is exact iff kernel = stride (no overlap, no padding). One fused op, better kernels, same math.
M06Write softmax backward from memory.
dz = p * (dp − (dp*p).sum(-1, keepdims=True)). From the Jacobian pᵢ(δᵢⱼ−pⱼ): the diagonal gives p⊙dp, the rank-one part subtracts p·⟨dp,p⟩. Sanity: if dp is constant across the row, dz = 0 — softmax ignores uniform shifts.
M06Backprop through a residual y = x + f(x): what reaches x?
dy flows to x twice: identically through the skip (Jacobian I) and transformed through the branch — dx = dy + f′ᵀdy, summed, not split. The bare dy term is the vanishing-gradient cure: gradient at block 1 includes the loss gradient verbatim, regardless of depth.
M07Fine-tune 224 → 384: what breaks and what's the fix?
Token count changes (196 → 576), so the learned pos-embed table has the wrong length — everything else is length-agnostic. Fix: split off [CLS], reshape 196 → 14×14×D, bicubic-interpolate to 24×24, flatten back, re-append [CLS], fine-tune briefly. Valid because the learned table is 2D-smooth (it self-organized into a grid).
M07Which parameters are excluded from weight decay, and why?
Biases, LayerNorm γ/β, [CLS], position embeddings. Decay fights overfitting via shrinking effective capacity of multiplicative weight matrices; shrinking a bias or norm gain toward zero just distorts activations without regularizing anything meaningful. In code: two optimizer param-groups.
M08What are attention sinks/registers in large ViTs?
Trained large ViTs repurpose a few low-information patches as global scratch: outlier-norm tokens, spiky attention maps, degraded dense features. Adding a handful of learned, input-free register tokens gives that computation a home; artifacts disappear (DINOv2-with-registers is the standard modern config). Moral: transformers want explicit memory slots.
M09DeiT's distillation token — mechanism and why it beats plain logit distillation.
A second learned token alongside [CLS] with its own head trained on the CNN teacher's (hard) labels; at inference, average both heads. Through attention, the token can gather teacher-relevant evidence differently from [CLS], and the ViT inherits convolutional inductive bias through the loss instead of the architecture. Outperformed distilling into the [CLS] head directly.
M09Swin in three mechanisms.
(1) Attention within 7×7 windows → linear cost in image area. (2) Shift the window grid by half a window on alternate layers → cross-window communication. (3) Patch-merging stages (2×2 concat + project) → pyramid /4→/32 that plugs into detection heads. Plus relative position biases in the attention logits.
M09MAE's two asymmetries and what each buys.
Masking asymmetry: 75% hidden — high redundancy of images demands a hard task for semantic features. Compute asymmetry: heavyweight encoder sees only visible 25% (no mask tokens); tiny decoder handles masks+positions and reconstructs normalized pixels, then is discarded. Result: BERT-style self-supervision at ~4× lower pre-training cost.
Coding drills — do these cold, in order of difficulty
- Drill 1.
scaled_dot_product_attention(q, k, v)for shape (B, H, N, d), NumPy or PyTorch, with stable softmax. Target: under 4 minutes, no reference. - Drill 2. Full
Attentionmodule including the qkv-fusion reshape dance. The permute indices must come out right first try — narrate shapes as you type. - Drill 3.
patchifyas pure reshape/transpose (no conv, no loop), plus its exact inverseunpatchify. This is also MAE-interview territory. - Drill 4. Entire ViT forward in PyTorch from an empty file, then verify 86,567,656 on the B/16 config. Target: under 25 minutes.
- Drill 5. NumPy LayerNorm forward + backward, gradient-checked against finite differences in float64.
- Drill 6. Position-embedding interpolation for a resolution change (module 07's snippet) — from memory.
- Drill 7 (boss level). NumPy attention backward, checked against autograd. When this matches to ~1e-16, you own this architecture.
Discussion questions to rehearse out loud
- Design a ViT for 1024×1024 medical images on one A100 — walk through patch size, windowing vs FlashAttention, pos-embed strategy, and what you'd pre-train on given 50k labeled + 5M unlabeled scans. (Good answers combine modules 04, 07, 08, 09.)
- Your ViT trains fine but attention maps are noisy spikes on background patches — what's happening and what are two fixes? (Registers; or train smaller/shorter — module 08.)
- Argue both sides: "Swin's return to locality was the right call" vs "plain ViT + scale was the right call." (The 2023+ evidence — DINOv2, MAE-scaled plain ViTs, FlashAttention erasing much of the efficiency gap — favors plain ViT for representation learning; Swin persists in latency-bound dense pipelines.)
- Why did BERT-style masked pre-training take two years longer to work in vision than in NLP? (Redundancy → needed 75% masking + pixel targets; also tokenization — words are discrete and semantic, patches aren't, which is why BEiT tried discrete visual tokens first and MAE showed raw pixels suffice with the right ratio.)
Final checkpoint · the classic curveballs
A colleague replaces [CLS] readout with mean-pooling over patch tokens and accuracy drops 5 points. The most likely culprit?
A known trap: the ViT appendix shows GAP ≈ [CLS] with separately tuned LR. Attributing recipe effects to architecture is exactly the sloppiness interviews screen for.
At inference you feed a 448×448 image to a stock ViT-B/16 trained at 224, changing nothing. What happens mechanically?
Conv and attention are size-agnostic; the single length-dependent parameter is pos_embed (1, 197, D) vs 785 tokens. Hence interpolation as the canonical fix.
Batch 1, ViT-B at 1024² with patch 16 (N = 4097): the dominant memory consumer in a naive implementation is…
4097² ≈ 16.8M entries × 12 heads × 4 bytes ≈ 0.8GB per layer pre-softmax in fp32. This arithmetic — activations dominate at scale — motivates FlashAttention/windowing and is a favorite systems probe.
Where to go next
Primary sources, in reading order: Attention Is All You Need (Vaswani 2017) · An Image is Worth 16×16 Words (Dosovitskiy 2020) · Training data-efficient image transformers (Touvron 2021) · Swin Transformer (Liu 2021) · Masked Autoencoders Are Scalable Vision Learners (He 2021) · Emerging Properties in Self-Supervised Vision Transformers (Caron 2021) · DINOv2 (Oquab 2023) · Vision Transformers Need Registers (Darcet 2023). For code archaeology, read timm's vision_transformer.py — the field's de-facto reference implementation.
The other course on this page
Diffusion models · refined edition · 16 lessons
The Long Walk Back
Learn the idea. Build the generator. Know why it works.
A clear route from adding noise to training your own diffusion model—with worked exercises, practical recipes, and a full lesson on ControlNet. CNNs and transformers assumed; diffusion explained from the beginning.
Lesson 00 · Diffusion models
Start here
A practical route from “what is being learned?” to a complete image generator.
You already know CNNs and transformers. The new material is the learning problem: how to turn examples of real images into a model that can create new ones. We will learn one concrete version first—an image-space denoising diffusion probabilistic model, or DDPM—and then change one component at a time.
By the end, you should be able to explain diffusion in two minutes, implement training and sampling from an empty file, diagnose a failing run, and reason about guidance, latent diffusion, and ControlNet. You do not need stochastic calculus to reach that point.
Use the course actively
Each lesson has a concrete outcome and an exercise. Try the exercise before opening the hint or solution. Checkpoints test distinctions that often cause implementation bugs. Mark a lesson complete when you can explain it without reading. Progress and quiz answers are stored in this browser when local storage is available; the downloaded file contains the course, not your personal progress.
First pass: read 01–06 and run the lab. Second pass: add 07–10 and implement one extension. Interview pass: use 11–15 to connect the math, trade-offs, and failure modes. Time estimates beside exercises are practice budgets, not promises about training time.
One notation, everywhere
| Symbol | Meaning | In the lab |
|---|---|---|
| x₀ | A clean image, scaled to [−1, 1] | [B, 1, 32, 32] |
| xₜ | The image after corruption to noise level t | Same shape as x₀ |
| ε | Fresh independent standard Gaussian noise | Same shape as x₀ |
| ε̂ or εθ | The network's prediction of ε | One unconstrained number per pixel |
| βₜ / αₜ | One-step noise variance / 1 − βₜ | One scalar per noise level |
| Aₜ = ᾱₜ | Cumulative signal fraction: ∏ₛ₌₁ᵗ αₛ | diffusion.ab[i] |
What you will build
The lab trains on freshly generated rings and squares. This makes the data distribution inspectable and removes setup friction. The model, loss, reverse process, exponential moving average, validation, and checkpointing are real. The small dataset is a teaching choice; attractive samples from it do not establish performance on photographs.
The entire course, including runnable code and interactive explanations, works offline in this file. External reading links and installing PyTorch require an internet connection. The vision transformer course remains available in the course selector.
Checkpoint · test the distinction
What is the first practical milestone?
The core training step is small and testable. Advanced theory and large-model tooling become easier once those mechanics are clear.
Lesson 01 · Diffusion models
Make the training problem
The useful trick: we manufacture supervised examples by corrupting data ourselves.
Suppose you have many images of handwritten digits. Training a network to map arbitrary noise directly to a digit leaves a difficult question: which digit is the correct target for this particular noise? Diffusion gives us a much easier supervised task. Take a real digit, add noise that you generated yourself, and ask a network to predict that noise.
At low noise, the network can use local edges. At high noise, it must use what it has learned about plausible images. At generation time, we start with noise and repeatedly use those predictions to make a slightly cleaner image. The same network is reused at every noise level.
Corrupting an image in one line
There are only two coefficients: how much signal remains and how much noise we add. If Aₜ = 1, we have the original image. If Aₜ is near zero, we have almost pure noise. A noise schedule is the table that determines Aₜ at each time.
Why square roots? Scaling a random variable by a multiplies its variance by a². The coefficients therefore specify a signal variance fraction Aₜ and a noise variance 1−Aₜ. This does not mean every real dataset or every corrupted image has variance exactly one; that also depends on the data.
A numerical anchor: when Aₜ = 0.64, the signal multiplier is 0.8 and the noise multiplier is 0.6. For one pixel with x₀ = 0.5 and ε = −1, the corrupted value is 0.8×0.5 + 0.6×(−1) = −0.2. Negative values are normal. Do not clip xₜ to the original image range.
Why a “process” if we can jump directly?
The original construction adds a small amount of noise repeatedly:
Independent Gaussian noises combine into another Gaussian. After t steps, the original signal is multiplied by √(α₁α₂…αₜ), and the accumulated noise variance is 1−α₁α₂…αₜ. That gives the one-line formula above. It is the distribution of xₜ that agrees; separately drawn noise does not reproduce one particular simulated trajectory.
During training, use the direct formula. There is no need to run t corruption steps or store a chain. For each batch of B images, draw B times and B noise tensors. The computational cost of the training step is essentially one denoiser forward and backward pass, whether the schedule has 200 or 1,000 entries.
Why use this kind of generator?
Diffusion offers a straightforward supervised training objective and flexible conditioning, with an expensive iterative sampling loop. Other model families make different trade-offs. These are broad tendencies, not a ranking that holds for every modern system.
| Family | Useful distinction |
|---|---|
| GAN | A generator competes with a discriminator; generation is often one forward pass, while adversarial optimization can be delicate. |
| Autoregressive | Factorizes a distribution into successive conditional predictions; the ordering and sequential generation cost are central design choices. |
| VAE | Learns an encoder/decoder with a latent-variable bound; reconstruction quality depends on its representation and likelihood/training choices. |
| Diffusion | Learns denoising across noise levels; standard sampling trades repeated network evaluations for progressive refinement. |
A schedule is part of the model contract
A linear β schedule is simple, but its behavior depends on T. Copying the same endpoints while reducing T can leave a lot of image signal at the terminal step. The lab uses a cosine-shaped cumulative signal schedule, converted into valid β values. Later sampling must use the schedule the model was trained with; you may choose fewer sample times using a suitable update.
Exercise 01 · Do the corruption arithmetic 5 min
Let β₁ = 0.1 and β₂ = 0.2. Find A₂ and the two coefficients for directly sampling x₂. Then explain why using α₂ in place of A₂ is a bug.
Hint
Retaining 90% of signal variance, then 80% of what remains, means multiplying.
Worked answer
A₂ = 0.9×0.8 = 0.72. Thus x₂ ≈ 0.8485 x₀ + 0.5292 ε. Using α₂ = 0.8 forgets the first corruption step and gives the wrong noise level to the network.
Foundation: DDPM, forward-process formulation; Improved DDPM, cosine schedule.
Checkpoint · test the distinction
At Aₜ = 0.64, which corruption formula is correct?
Use square roots: √0.64=0.8 and √0.36=0.6. The two coefficients do not need to add to one.
Lesson 02 · Diffusion models
What the network learns
Predicting noise is ordinary regression—with unusually useful consequences.
We created xₜ from x₀ and a known ε. Feed the network xₜ and t, and minimize mean squared error between its prediction and ε. The clean image is used to construct the example; it is not an input to this unconditional denoiser.
That expectation just describes repeated random training batches: choose data, choose a noise level, choose noise, compute the error. The mean in the lab averages over images, channels, and pixels. A single training example needs only one randomly selected time; across updates, the model sees the whole range.
i = torch.randint(T, (B,), device=device) # different level per image noise = torch.randn_like(x0) # independent Gaussian entries A = alpha_bar[i][:, None, None, None] # [B,1,1,1] xt = A.sqrt() * x0 + (1 - A).sqrt() * noise noise_hat = model(xt, i) # [B,C,H,W] loss = F.mse_loss(noise_hat, noise)
The time input is necessary: a faint shape at a low noise level and a faint shape at a high noise level call for different corrections. Think of t as telling the network how much to trust the input.
Can it really recover the exact random noise?
Not in general. Several clean images and noise realizations can produce the same noisy observation. Under squared error, the best possible predictor is the conditional average E[ε | xₜ,t]. It uses learned image structure to estimate which part of the observation is noise.
This distinction explains two apparent paradoxes. First, nonzero loss can remain even for a very good model. Second, a one-step clean-image estimate at a high noise level can be blurry or unrecognizable. It averages over possibilities. Generation works by following a sequence of intermediate states, instead of treating that uncertain average as a final image.
Turn a noise prediction into an image estimate
Rearrange the corruption formula:
At Aₜ = 0.64, the earlier pixel had xₜ = −0.2. If the network predicts ε̂ = −1, then x̂₀ = (−0.2 + 0.6)/0.8 = 0.5, exactly the clean value. A prediction error gets amplified when Aₜ is tiny because we divide by √Aₜ. This is why you should not judge the entire model by clean-image reconstructions at its noisiest time.
Three targets, one corruption equation
You will encounter networks that predict ε, x₀, or a quantity called v. They can represent the same information at interior noise levels, but their losses emphasize noise levels differently.
| Prediction | Training target | Convert to x̂₀ |
|---|---|---|
| Noise, ε | The noise we added | (xₜ − σ ε̂) / a |
| Clean image, x₀ | The original image | Already x̂₀ |
| Velocity, v | a ε − σ x₀ | a xₜ − σ v̂ |
Here a = √Aₜ and σ = √(1−Aₜ), so a²+σ² = 1. For v-prediction, ε̂ = σ xₜ + a v̂ as well. This diffusion v is not automatically the same target as the straight-path velocity used in flow matching in lesson 12.
Exercise 02 · Find the silent broadcasting bug 8 min
An image batch has shape [8,3,32,32]. Someone uses A = alpha_bar[i], with shape [8], directly in the corruption formula. What is wrong? How can this bug sometimes run without an error?
Hint
Broadcasting aligns dimensions from the right.
Worked answer
[8] is compared with the final width dimension, 32, so this batch fails. If the image width happens to equal B, it can silently apply coefficients by image column instead of by batch element. Reshape to [B,1,1,1]. Test with a batch size different from every spatial dimension.
Checkpoint · test the distinction
A training step should use which noise target?
The label for ε prediction is the actual noise used in this example. A separately drawn target destroys the supervised relationship.
Lesson 03 · Diffusion models
Build a time-aware U-Net
You know convolutions. The new ingredient is telling every block which denoising task it is doing.
A denoiser consumes an image-shaped tensor and produces an image-shaped tensor. A U-Net is a convenient choice: downsampling gives a broad view of the image, and skip connections bring fine spatial information back into the upsampling path. Its output is a noise prediction, so it has the same number of channels as the input and no sigmoid or softmax.
Turn t into something a block can use
We turn the scalar time into sine and cosine features at several frequencies, then pass those features through a small MLP. Every residual block projects that shared embedding into its own channel width and adds it to the feature map. The addition broadcasts over height and width.
emb = time_mlp(sinusoidal_features(i)) # [B,D] h = conv1(F.silu(norm1(x))) # [B,C,H,W] h = h + time_projection(emb)[:, :, None, None] h = conv2(F.silu(norm2(h))) return shortcut(x) + h
This is an explanatory block sketch; the executable implementation is in lesson 05. A more expressive variant predicts a scale and a shift and applies (1 + scale) * norm(h) + shift. The important part is that time affects the computation throughout the network, not just the final layer.
The lab's shape contract
| Operation | Output | Why it exists |
|---|---|---|
| Input + stem | [B,32,32,32] | Project grayscale pixels into features |
| First encoder, save s₁ | [B,32,32,32] | Keep fine spatial information |
| Downsample + encoder, save s₂ | [B,64,16,16] | Build larger-scale features |
| Downsample + middle | [B,128,8,8] | Reason with a broader receptive field |
| Upsample + concat s₂ | [B,128,16,16] | 64 upsampled + 64 skip channels |
| Decoder, upsample + concat s₁ | [B,64,32,32] | 32 upsampled + 32 skip channels |
| Decoder + output projection | [B,1,32,32] | Predict one noise value per input pixel |
Design choices you should be able to defend
- GroupNorm: does not pool statistics across images at different noise levels and behaves consistently at small batch sizes. It is a convenient choice, not a theorem that BatchNorm cannot work.
- SiLU: a smooth nonlinearity with a simple implementation. Its exact choice is less important than correct conditioning and a sound training loop.
- Nearest-neighbor upsampling followed by a convolution: makes spatial sizes easy to track. Other upsampling layers are possible.
- No attention in this first model: the 32×32 shape task is enough to learn the diffusion mechanics with a small convolutional U-Net. Larger image generators often add attention for long-range interactions and text conditioning.
Before training, pass a batch through the network, assert that input and output shapes match, compute a scalar loss, and verify finite gradients. A batch-size-one test is useful because accidental dimension squeezing often appears there.
Exercise 03 · Design a class-conditioned block 10 min
You want the same network to generate rings or squares on request. Where would you put a learned two-class embedding? Which shapes need to match? How would you leave room for an unconditional prediction later?
Hint
The time embedding already reaches every block.
Worked answer
Use an embedding table of shape [3,D]: ring, square, and a learned null class. Add class_embedding(label) to the [B,D] time embedding before the block-specific projections. Preserve the image output shape. During training, sometimes replace the real label with the null label; lesson 08 explains why.
Checkpoint · test the distinction
Why is there no sigmoid on an ε-predicting output?
The prediction is an unconstrained real-valued tensor. A sigmoid would impose the wrong range.
Lesson 04 · Diffusion models
Generate by taking reverse steps
A noise prediction is not itself a sampling algorithm. This lesson connects the two.
We have a trained denoiser and a noisy image. How do we make it a little cleaner? First estimate x₀ from the predicted noise. Then use xₜ and that estimate to construct the next state. Repeat from T down to 1.
There is one helpful mathematical fact: if we knew the clean image x₀, the distribution of the previous noisy state given xₜ would be a Gaussian with known coefficients. We cannot use the true x₀ when generating, but we can insert the network's estimate into the mean.
c₀,ₜ = √Aₜ₋₁ βₜ / (1−Aₜ)
cₜ,ₜ = √αₜ (1−Aₜ₋₁) / (1−Aₜ)
β̃ₜ = βₜ (1−Aₜ₋₁) / (1−Aₜ)
Do not memorize four lines before understanding their job. The first two weights mix the predicted clean image with the current state to get a less noisy state. The variance tells us how much fresh uncertainty to add. The derivation can wait until lesson 11.
The DDPM sampling loop
- Draw xT from standard Gaussian noise. This matches the training endpoint approximately when AT is sufficiently close to zero.
- Predict ε̂ = εθ(xₜ,t), then compute x̂₀.
- Set the reverse mean to c₀,ₜ x̂₀ + cₜ,ₜ xₜ.
- For t > 1, sample xₜ₋₁ = mean + √β̃ₜ z with fresh Gaussian z. At t = 1, return the mean.
The model in the lab uses fixed posterior variance β̃ₜ. Learning a reverse variance is a different design choice. The formula above is the exact forward posterior variance conditioned on x₀, not a claim that the true reverse distribution given only xₜ is always that Gaussian.
Three distinctions that prevent most sampler bugs
- Predicted x₀ versus next xₜ₋₁: x̂₀ aims at the clean endpoint. The sampler usually moves only partway there. Replacing xₜ by x̂₀ on every iteration is not DDPM.
- βₜ versus β̃ₜ: the forward noise variance and the posterior variance are different. Use the variance your sampler was designed for.
- Standard deviation versus variance: multiply fresh noise by √β̃ₜ, not β̃ₜ. Suppress fresh noise on the final iteration.
For pixel images normalized to [−1,1], clipping x̂₀ into that range can stabilize sampling; the lab does so. This is a heuristic and changes the exact un-clipped update. Do not blindly clip latent tensors to [−1,1]; their representation has a different range.
Exercise 04 · Prove the last step is clean 8 min
Substitute A₀ = 1 and A₁ = α₁ into the posterior coefficients. What are c₀,₁, cₜ,₁, and β̃₁? Which Python index handles this step?
Worked answer
c₀,₁ = β₁/(1−α₁) = 1; cₜ,₁ = 0; β̃₁ = 0. Thus the update returns x̂₀ and adds no noise. In the lab this is i = 0. If you initialize the previous cumulative signal to zero instead of one, all three boundary conditions break.
Checkpoint · test the distinction
What should the last fixed-posterior DDPM iteration do?
At mathematical t=1 (code i=0), posterior variance is zero and the mean is the clean estimate.
Lesson 05 · Diffusion models
The complete PyTorch lab
One file: data, model, training, validation, two samplers, and resumable checkpoints.
Save the code below as diffusion_lab.py, or use the download button. It requires Python 3.10+ and PyTorch 2.x. It uses no pretrained model, diffusion library, or downloaded dataset. If you need PyTorch, follow its official installation selector for your operating system and accelerator.
Part 1 · Data and the time-aware U-Net
"""A small pixel-space DDPM. Requires Python 3.10+ and PyTorch 2.x.
Run: python diffusion_lab.py --steps 3000 --out run
Smoke check: python diffusion_lab.py --steps 2 --batch 4 --T 20 --out smoke
The default data are generated locally; no dataset download is needed.
"""
# PART 1: imports, data, and the denoiser
import argparse
import copy
import math
from pathlib import Path
import torch
from torch import nn
import torch.nn.functional as F
def shapes(batch, rng):
"""Fresh 32x32 rings and filled squares in [-1, 1], shape [B,1,32,32]."""
axis = torch.linspace(-1, 1, 32)
yy, xx = torch.meshgrid(axis, axis, indexing="ij")
center = torch.rand(batch, 2, 1, 1, generator=rng) * 0.8 - 0.4
size = torch.rand(batch, 1, 1, generator=rng) * 0.20 + 0.22
dx, dy = xx - center[:, 0], yy - center[:, 1]
radius = (dx.square() + dy.square()).sqrt()
ring = torch.sigmoid((0.055 - (radius - size).abs()) * 80)
square = torch.sigmoid((size - torch.maximum(dx.abs(), dy.abs())) * 80)
kind = torch.randint(0, 2, (batch, 1, 1), generator=rng).bool()
return (torch.where(kind, ring, square) * 2 - 1).unsqueeze(1)
class TimeEmbedding(nn.Module):
def __init__(self, width=64):
super().__init__()
freq = torch.exp(-math.log(10000) * torch.arange(width // 2) / (width // 2 - 1))
self.register_buffer("freq", freq)
self.mlp = nn.Sequential(nn.Linear(width, width), nn.SiLU(), nn.Linear(width, width))
def forward(self, t):
phase = t.float()[:, None] * self.freq[None]
return self.mlp(torch.cat([phase.sin(), phase.cos()], dim=-1))
class ResBlock(nn.Module):
def __init__(self, cin, cout, time_dim=64):
super().__init__()
self.norm1 = nn.GroupNorm(8, cin)
self.conv1 = nn.Conv2d(cin, cout, 3, padding=1)
self.time = nn.Linear(time_dim, cout)
self.norm2 = nn.GroupNorm(8, cout)
self.conv2 = nn.Conv2d(cout, cout, 3, padding=1)
self.skip = nn.Conv2d(cin, cout, 1) if cin != cout else nn.Identity()
def forward(self, x, emb):
h = self.conv1(F.silu(self.norm1(x)))
h = h + self.time(F.silu(emb))[:, :, None, None]
h = self.conv2(F.silu(self.norm2(h)))
return self.skip(x) + h
class TinyUNet(nn.Module):
def __init__(self):
super().__init__()
self.time = TimeEmbedding()
self.stem = nn.Conv2d(1, 32, 3, padding=1)
self.enc1 = ResBlock(32, 32)
self.down1 = nn.Conv2d(32, 64, 4, stride=2, padding=1)
self.enc2 = ResBlock(64, 64)
self.down2 = nn.Conv2d(64, 128, 4, stride=2, padding=1)
self.mid = ResBlock(128, 128)
self.up2 = nn.Conv2d(128, 64, 3, padding=1)
self.dec2 = ResBlock(128, 64)
self.up1 = nn.Conv2d(64, 32, 3, padding=1)
self.dec1 = ResBlock(64, 32)
self.out = nn.Sequential(nn.GroupNorm(8, 32), nn.SiLU(), nn.Conv2d(32, 1, 3, padding=1))
def forward(self, x, t):
emb = self.time(t) # [B,64]
s1 = self.enc1(self.stem(x), emb) # [B,32,32,32]
s2 = self.enc2(self.down1(s1), emb) # [B,64,16,16]
h = self.mid(self.down2(s2), emb) # [B,128,8,8]
h = self.up2(F.interpolate(h, scale_factor=2, mode="nearest"))
h = self.dec2(torch.cat([h, s2], dim=1), emb)
h = self.up1(F.interpolate(h, scale_factor=2, mode="nearest"))
h = self.dec1(torch.cat([h, s1], dim=1), emb)
return self.out(h) # [B,1,32,32], no sigmoidPart 2 · Schedule and the training loss
# PART 2: schedules and one training loss
class Diffusion(nn.Module):
def __init__(self, T=200):
super().__init__()
if T < 2:
raise ValueError("T must be at least 2")
self.T = T
# Float64 for construction, float32 buffers for the training arithmetic.
u = torch.linspace(0, 1, T + 1, dtype=torch.float64)
f = torch.cos((u + 0.008) / 1.008 * math.pi / 2).square()
target_ab = f / f[0]
beta = (1 - target_ab[1:] / target_ab[:-1]).clamp(max=0.999)
alpha = 1 - beta
ab = alpha.cumprod(0) # index i means math time t=i+1
prev = torch.cat([torch.ones(1, dtype=ab.dtype), ab[:-1]])
values = dict(beta=beta, alpha=alpha, ab=ab,
posterior_var=beta * (1 - prev) / (1 - ab),
c0=beta * prev.sqrt() / (1 - ab),
ct=alpha.sqrt() * (1 - prev) / (1 - ab))
for name, value in values.items():
self.register_buffer(name, value.float())
@staticmethod
def at(values, i):
return values[i][:, None, None, None] # [B,1,1,1]
def q_sample(self, x0, i, eps):
ab = self.at(self.ab, i)
return ab.sqrt() * x0 + (1 - ab).sqrt() * eps
def loss_batch(model, diffusion, batch, rng, device):
x0 = shapes(batch, rng).to(device)
i = torch.randint(diffusion.T, (batch,), generator=rng).to(device)
eps = torch.randn(x0.shape, generator=rng).to(device)
xt = diffusion.q_sample(x0, i, eps)
pred = model(xt, i)
assert pred.shape == eps.shape
return F.mse_loss(pred.float(), eps.float())Part 3 · DDPM and DDIM sampling
# PART 3: two samplers; both reuse the same trained denoiser
@torch.no_grad()
def sample(model, diffusion, n=16, method="ddim", steps=50, seed=123):
model.eval()
device = next(model.parameters()).device
rng = torch.Generator().manual_seed(seed)
x = torch.randn(n, 1, 32, 32, generator=rng).to(device)
if method == "ddpm":
indices = list(range(diffusion.T - 1, -1, -1))
elif method == "ddim":
steps = min(max(2, steps), diffusion.T)
indices = torch.linspace(diffusion.T - 1, 0, steps).round().long().tolist()
else:
raise ValueError("method must be ddpm or ddim")
for k, i in enumerate(indices):
t = torch.full((n,), i, device=device, dtype=torch.long)
eps = model(x, t)
ab = diffusion.ab[i]
x0 = ((x - (1 - ab).sqrt() * eps) / ab.sqrt()).clamp(-1, 1)
if method == "ddpm":
mean = diffusion.c0[i] * x0 + diffusion.ct[i] * x
if i > 0:
noise = torch.randn(x.shape, generator=rng).to(device)
x = mean + diffusion.posterior_var[i].sqrt() * noise
else:
x = mean # final step adds no noise
else:
# Recompute eps after clipping so x = sqrt(ab)*x0 + sqrt(1-ab)*eps.
eps = (x - ab.sqrt() * x0) / (1 - ab).sqrt()
j = indices[k + 1] if k + 1 < len(indices) else -1
ab_next = diffusion.ab[j] if j >= 0 else x.new_tensor(1.0)
x = ab_next.sqrt() * x0 + (1 - ab_next).sqrt() * eps
return x
def save_grid(x, path):
"""Portable grayscale PGM: no image library required. Open in an image viewer."""
x = ((x.detach().cpu().clamp(-1, 1) + 1) * 127.5).round().to(torch.uint8)
n, _, h, w = x.shape
cols = min(4, n)
rows = (n + cols - 1) // cols
grid = torch.full((rows * h, cols * w), 255, dtype=torch.uint8)
for k in range(n):
r, c = divmod(k, cols)
grid[r*h:(r+1)*h, c*w:(c+1)*w] = x[k, 0]
Path(path).write_bytes(f"P5\n{cols*w} {rows*h}\n255\n".encode() + bytes(grid.flatten().tolist()))Part 4 · Training, validation, and checkpoints
# PART 4: validation, EMA, checkpointing, and the executable entry point
@torch.no_grad()
def validation(model, diffusion, batch, device):
model.eval()
rng = torch.Generator().manual_seed(9001) # identical held-out draws each time
return sum(loss_batch(model, diffusion, batch, rng, device).item() for _ in range(8)) / 8
@torch.no_grad()
def update_ema(ema, model, decay):
for avg, current in zip(ema.parameters(), model.parameters()):
avg.lerp_(current, 1 - decay)
for avg, current in zip(ema.buffers(), model.buffers()):
avg.copy_(current)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--steps", type=int, default=3000, help="total steps, including before resume")
parser.add_argument("--batch", type=int, default=64)
parser.add_argument("--T", type=int, default=200)
parser.add_argument("--lr", type=float, default=2e-4)
parser.add_argument("--out", default="run")
parser.add_argument("--resume", default=None)
parser.add_argument("--device", default="auto", choices=["auto", "cpu", "cuda", "mps"])
args = parser.parse_args()
if args.steps < 1 or args.batch < 1:
parser.error("steps and batch must be positive")
device = args.device
if device == "auto":
device = "cuda" if torch.cuda.is_available() else ("mps" if torch.backends.mps.is_available() else "cpu")
torch.manual_seed(7)
torch.set_num_threads(4)
rng = torch.Generator().manual_seed(42)
model = TinyUNet().to(device)
ema = copy.deepcopy(model).requires_grad_(False)
diffusion = Diffusion(args.T).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=0.0)
start = 0
if args.resume:
state = torch.load(args.resume, map_location="cpu", weights_only=True)
if state["T"] != args.T or state["batch"] != args.batch:
raise ValueError("Resume with the checkpoint's T and batch size")
model.load_state_dict(state["model"])
ema.load_state_dict(state["ema"])
diffusion.load_state_dict(state["diffusion"])
optimizer.load_state_dict(state["optimizer"]) # includes saved learning rate
rng.set_state(state["rng"])
start = state["step"]
out = Path(args.out)
out.mkdir(parents=True, exist_ok=True)
save_grid(shapes(16, torch.Generator().manual_seed(2026)), out / "data.pgm")
print(f"device={device}; parameters={sum(p.numel() for p in model.parameters()):,}; terminal alpha_bar={diffusion.ab[-1].item():.3g}")
if start >= args.steps:
print("Checkpoint already reached requested total steps; evaluating it.")
for step in range(start + 1, args.steps + 1):
model.train()
optimizer.zero_grad(set_to_none=True)
loss = loss_batch(model, diffusion, args.batch, rng, device)
if not torch.isfinite(loss):
raise RuntimeError("Non-finite loss: inspect the data, schedule, and activations")
loss.backward()
grad_norm = nn.utils.clip_grad_norm_(model.parameters(), 1.0)
if not torch.isfinite(grad_norm):
raise RuntimeError("Non-finite gradient")
optimizer.step()
decay = min(0.999, (1 + step) / (10 + step)) # faster EMA adaptation early on
update_ema(ema, model, decay)
if step == 1 or step % 100 == 0:
print(f"step={step} train_mse={loss.item():.5f} grad_norm={grad_norm.item():.3f}")
if step % 500 == 0 or step == args.steps:
torch.save(dict(step=step, T=args.T, batch=args.batch, model=model.state_dict(),
ema=ema.state_dict(), diffusion=diffusion.state_dict(),
optimizer=optimizer.state_dict(), rng=rng.get_state()), out / "checkpoint.pt")
print(f"heldout_ema_mse={validation(ema, diffusion, args.batch, device):.5f}")
for method in ("ddpm", "ddim"):
samples = sample(ema, diffusion, n=16, method=method, steps=50)
save_grid(samples, out / f"samples_{method}.pgm")
print(f"Saved data, samples, and checkpoint in {out.resolve()}")
if __name__ == "__main__":
main()Run in three stages
# 1. Verify the program runs; these two steps do not train a useful generator. python diffusion_lab.py --steps 2 --batch 4 --T 20 --out smoke # 2. Train the default 200-level model. Use a smaller batch if memory is limited. python diffusion_lab.py --steps 3000 --batch 64 --out run # 3. Continue to 6000 total updates. Keep T and batch size consistent. python diffusion_lab.py --resume run/checkpoint.pt --steps 6000 --batch 64 --out run
The script selects CUDA, then Apple MPS, then CPU when available; you can override with --device cpu, --device cuda, or --device mps. Runtime depends strongly on hardware. Every 500 updates and at the end, it writes a checkpoint. It evaluates the EMA model and generates sample grids after training.
| Output | What to inspect |
|---|---|
| data.pgm | Examples from the target distribution: rings and filled squares |
| samples_ddpm.pgm | 16 uncurated samples using all reverse steps |
| samples_ddim.pgm | The same initial seed with up to 50 deterministic DDIM steps |
| checkpoint.pt | Model, EMA, schedule, optimizer, update count, batch size, and data/noise generator state |
PGM is a simple grayscale image format; open it in an image viewer such as Preview or convert it with your preferred image tool. The format keeps the Python lab dependent only on PyTorch. Resume restores the saved optimizer learning rate. Random draws for training are separate from validation and sampling, so changing when you inspect samples does not consume training randomness. Exact cross-device reproducibility is not guaranteed.
Why the extra training machinery matters
Exponential moving average (EMA) maintains a slowly changing copy of the weights. It often produces steadier samples than the latest optimization step. The lab starts with a faster-moving average and gradually increases its smoothing. Sampling uses the EMA copy in evaluation mode with gradients disabled.
Validation uses a separate, fixed stream of generated images, times, and noise. This makes comparisons less noisy. It measures held-out denoising error, not perceptual image quality. Both matter. For a real image dataset, make a proper split by source or subject to avoid near-duplicate leakage.
Checkpointing saves enough to continue optimization. Saving only the model weights is sufficient for inference but loses the optimizer moments, EMA, and training-stream position. In a larger system, also store the data sampler state, full experiment configuration, library versions, and mixed-precision scaler if used.
What the executed training run produced
A CPU run with 3,000 updates, batch size 32, T=200, and PyTorch 2.9.1 reached a fixed held-out EMA noise MSE of 0.00861. The grids below contain all 16 samples for seed 123; no samples were selected or removed. Both samplers use the same EMA checkpoint and initial Gaussian noise.
Target data
DDPM · 200 evaluations
DDIM · 50 evaluations
Read the images, not just the loss. The 200-step DDPM grid contains recognizable rings and squares. The 50-step DDIM grid is faster in model evaluations but retains visible background noise in several samples. This is a useful measured limitation: the faster sampler is not quality-equivalent in this run. Compare more DDIM steps or improved training before claiming a speedup at equal quality. These small grids are an implementation demonstration, not a statistical quality benchmark.
Exercise 05 · Rebuild the six-line training step 20 min
Hide the implementation. Starting from x₀, write the timestep draw, noise draw, corruption, prediction, loss, and optimizer update. Then add one assertion that would catch a shape bug and one check that would catch a numerical failure.
Hint
The optimizer update needs zeroing, backward, and stepping. The labels for this regression problem are generated noise.
Acceptance criteria
The target must be the same noise tensor used in corruption; each image gets one time; schedule values broadcast as [B,1,1,1]; the model receives the time; prediction shape equals input shape; loss and gradients are finite. A one-step run must change at least one model parameter. Compare against parts 2 and 4 only after your version runs.
Checkpoint · test the distinction
Which checkpoint supports continued optimization?
Weights alone support inference. Continuing optimization also needs optimizer and training-state information. The lab saves its own training generator state.
Lesson 06 · Diffusion models
Debug before scaling
A falling training loss is necessary evidence, but it is not enough.
A sensible order of checks
- Inspect the data. Visualize a clean batch. Confirm the intended range, channel order, and spatial dimensions. Normalization should happen exactly once.
- Check the schedule. Verify 0<β<1, decreasing A, finite posterior coefficients, and a sufficiently small terminal A. Check the final-step identities from lesson 04.
- Check noising and inversion. Construct xₜ using known ε. Reconstruct x₀ with that same ε, before clipping. At moderate noise levels the error should be close to floating-point precision.
- Overfit a fixed corruption batch. Fix x₀, t, and ε, and optimize on that one batch. Error should decrease substantially. This tests whether the network and optimizer can fit the exact targets.
- Overfit a tiny image set with fresh corruption. This is harder: error need not reach zero because the denoising task remains ambiguous. Inspect reconstructions at low, medium, and high noise.
- Generate from pure noise. Use fixed initial seeds, first with the full DDPM sampler. Only introduce faster sampling after the baseline behaves sensibly.
Match symptoms to tests
| Symptom | Likely explanations | Next useful test |
|---|---|---|
| Loss stays near 1 | Optimizer not updating; time or targets wrong; learning rate unsuitable | Fixed-corruption overfit; check gradient and parameter changes |
| Loss falls, samples remain noise | Training/sampling mismatch; off-by-one time; wrong target conversion | Trace one reverse step numerically; try full DDPM |
| Samples become black or white | Range error; unstable x̂₀; wrong coefficient or clipping | Log xₜ and x̂₀ ranges by time; inspect schedule endpoint |
| Good reconstruction, poor generation | Clean-image leakage; insufficient terminal corruption; sampler errors | Generate with no access to a real image; inspect AT |
| NaNs at late reverse times | Division by zero or invalid variance; precision underflow | Use float32 schedule math; inspect all buffers and endpoints |
| Good few examples, little diversity | Memorization; biased data; excessive guidance | Inspect uncurated grids and nearest training neighbors |
Run these checks before your first long training job
Save the lab file first, then run this in the same folder. These checks use deliberately unequal batch and spatial sizes and avoid the numerically sensitive terminal time.
import torch
from diffusion_lab import Diffusion, TinyUNet
d = Diffusion(T=200)
assert (d.ab[1:] < d.ab[:-1]).all()
assert d.ab[-1] < 1e-5
assert d.posterior_var[0] == 0
assert torch.allclose(d.c0[0], torch.tensor(1.0))
assert d.ct[0] == 0
x0 = torch.randn(3, 1, 32, 32)
eps = torch.randn_like(x0)
i = torch.tensor([20, 50, 100])
A = d.at(d.ab, i)
xt = d.q_sample(x0, i, eps)
reconstructed = (xt - (1 - A).sqrt() * eps) / A.sqrt()
assert torch.allclose(reconstructed, x0, atol=1e-6)
model = TinyUNet()
pred = model(xt, i)
assert pred.shape == x0.shape
(pred - eps).square().mean().backward()
assert all(p.grad is not None and torch.isfinite(p.grad).all()
for p in model.parameters())
print("Schedule, endpoint, inversion, shapes, and gradients pass.")Evaluate by noise level
A single average hides where a model struggles. Divide times into low-, middle-, and high-noise bands and report MSE separately. For reconstructions, convert predictions to x̂₀ and compare at fixed times. For generation, use the same seed set and sampler across checkpoints. Keep random sample grids, not just the prettiest images.
For real-image research, FID compares the mean and covariance of generated and real features from a chosen network. It depends on the feature extractor, preprocessing, sample count, and reference set. It is not a direct test of prompt following, diversity in every subgroup, or absence of memorization. Pair it with task-specific measurements and human inspection. For ControlNet, assess both image quality and agreement with the supplied structure.
Exercise 06 · Diagnose a deceptively good loss 12 min
A colleague reports loss 0.03 but bad samples. They changed T from 1,000 to 50, kept linear β endpoints 0.0001 and 0.02, and started sampling from standard Gaussian noise. Propose a specific measurement before changing the U-Net.
Worked answer
Compute the terminal cumulative signal. Roughly, log AT ≈ −Σβ ≈ −0.5, so AT is around 0.60. The training endpoint retains substantial image signal, whereas sampling starts from pure noise. Measure the exact product, then train with a schedule whose endpoint is close to Gaussian noise. This is one concrete mismatch; still inspect the sampler and loss distribution rather than assuming it explains every failure.
Checkpoint · test the distinction
A low average noise MSE proves that…
Generation and task quality need separate checks. A loss average can hide noise-level failures and a mismatched sampler.
Lesson 07 · Diffusion models
Fewer steps: understand DDIM
Reuse the trained denoiser, but change how predictions become the next state.
Training with T = 200 does not force us to evaluate the denoiser 200 times for every image. The network has learned to make predictions at many noise levels. A sampler can choose a smaller decreasing sequence of those levels and use an update that connects them.
Do not simply skip entries in the adjacent-step DDPM loop. Its coefficients describe t→t−1 under the original schedule. A jump from t to s<t needs a matching transition.
Deterministic DDIM in two lines
At the current time t, predict ε̂ and reconstruct x̂₀. Then form a state at the next chosen time s using less noise:
xₛ = √Aₛ x̂₀ + √(1−Aₛ) ε̂
Because s is less noisy, Aₛ is larger. The update preserves the current predicted image/noise decomposition while changing its proportions. At the next iteration the network makes a new prediction, so the whole trajectory does not rely on one initial estimate.
This is DDIM with η = 0: no fresh noise is injected during the trajectory. Different initial noise seeds still give different outputs. With a fixed initial noise, fixed conditions, and deterministic numerical execution, the path is deterministic.
What faster sampling costs
Fewer evaluations usually reduce latency, but large jumps ask more of an imperfect denoiser and increase discretization error. Compare 10, 25, 50, and all training levels with the same seeds. There is no universal best count. More steps are not guaranteed to improve every model under every solver and guidance setting.
Optional · DDIM with stochasticity
For a jump t→s, define σt→s = η √[(1−Aₛ)/(1−Aₜ)] √[1−Aₜ/Aₛ]. Then:
η = 0 gives the deterministic update. With adjacent original schedule times, η = 1 gives the fixed-posterior DDPM transition under consistent predictions and clipping conventions. The clean endpoint has A₀ = 1, so its fresh-noise term is zero.
DDPM, DDIM, and ODE solvers are not synonyms
DDPM is stochastic ancestral sampling. Deterministic DDIM has a connection to a continuous ordinary differential equation (ODE). Other methods, such as DPM-Solver, exploit the structure of diffusion ODEs to take efficient numerical steps. Some solvers evaluate the denoiser multiple times per step, so count model evaluations as well as steps. Lesson 12 separates changing a solver from training a model to generate in very few steps.
Exercise 07 · Implement a valid jump 20 min
Add a 25-step deterministic DDIM sampler to your DDPM-only implementation. Use strictly decreasing original indices. At the last transition use Aclean = 1. Test the formula with a known x₀ and ε at a moderate t.
Acceptance criteria
The known decomposition produces xₛ = √Aₛx₀ + √(1−Aₛ)ε within floating-point tolerance. Two runs with the same initial seed agree on the same deterministic device. A different seed changes the result. The model still receives its original training indices, not a newly numbered range 0–24.
Read next: DDIM and DPM-Solver.
Checkpoint · test the distinction
A 25-step DDIM run from a T=200 model should pass which times?
The denoiser was trained under the original time-to-noise mapping. Subsampling selects that mapping; it does not renumber it.
Lesson 08 · Diffusion models
Conditioning and guidance
A condition asks for an image; guidance changes how strongly sampling favors it.
An unconditional model learns a distribution over all training images. A conditional model also sees a label, text prompt, or another signal and learns the distribution associated with that condition. The corruption equation and noise target need not change: the prediction becomes εθ(xₜ,t,c).
How the condition reaches the model
For a class label, add a learned embedding to the time embedding. For text, a text encoder produces token embeddings. In a U-Net with cross-attention, image features supply the queries and text embeddings supply keys and values. You already know attention; the new point is that spatial image locations can retrieve different pieces of the prompt.
Training needs image/condition pairs. Giving an unconditional model a prompt at inference does not create text understanding. The architecture needs an input path for the condition, and training must teach it how to use that path.
Classifier-free guidance (CFG)
During training, randomly remove the condition for some examples, substituting a learned null label or an encoded empty prompt. The same network then learns both conditional and condition-absent predictions. During sampling, combine them:
In this convention, w = 0 is unconditional and w = 1 is the ordinary conditional prediction. Values above 1 extrapolate in the conditional direction. Stronger guidance often improves adherence at a cost to diversity and can introduce saturation or artifacts. It is a sampling control, not a guarantee that larger is better.
Different papers write the same expression with a shifted scale: (1+s)ε̂cond − s ε̂uncond. Their s is w−1 here. State your convention before discussing a number.
A small implementation sketch
# Training: label K is a dedicated null entry, not a real class. drop = torch.rand(B, device=device) < 0.1 used_label = torch.where(drop, K, label) noise_hat = model(xt, i, used_label) loss = F.mse_loss(noise_hat, noise) # Sampling: same xt and time for both branches. xt2 = torch.cat([xt, xt], dim=0) t2 = torch.cat([i, i], dim=0) y2 = torch.cat([torch.full_like(label, K), label], dim=0) uncond, cond = model(xt2, t2, y2).chunk(2) noise_hat = uncond + w * (cond - uncond)
This is an extension sketch, not a call signature supported by the unmodified lab. Add the class embedding from exercise 03 first. A 10% condition-drop probability is a reasonable starting experiment for the shape task; tune it rather than treating it as universal.
What about classifier guidance?
Classifier guidance uses the gradient of a separate noisy-image classifier, ∇xₜ log p(c|xₜ,t), to steer the diffusion score. That classifier must work at the relevant noise levels; a clean-image classifier is not automatically suitable. CFG obtains a related steering signal from the two denoiser predictions and needs no separate classifier.
Exercise 08 · Make guidance falsifiable 25 min
Extend the lab to class conditioning. Evaluate w = 0, 1, 2, and 4 on the same 16 seeds for each class. What should remain unchanged when you request different classes at w = 0? What should you measure besides visual appeal?
Worked answer
At w = 0, the final prediction is the null-label branch, so changing the requested class should not change a deterministic sampling result. At w = 1, use only the conditional prediction. Measure ring/square correctness, diversity of sizes and positions, malformed shapes, and runtime. If larger w improves class accuracy but collapses variation, report that trade-off.
Source: Classifier-Free Diffusion Guidance.
Checkpoint · test the distinction
Under εu + w(εc−εu), what does w=1 mean?
w=0 uses εu, w=1 uses εc, and w>1 extrapolates. Another paper may use an offset convention.
Lesson 09 · Diffusion models
Latent diffusion, text, and DiT
Change the representation or the backbone while keeping the learning problem recognizable.
Why denoise a compressed image?
Running a large denoiser on every pixel of a high-resolution image is expensive. A latent diffusion model first encodes the image into a smaller spatial tensor z₀. Diffusion happens there; a decoder converts the final clean latent back into pixels.
For example, reducing 512×512 pixels to a 64×64 latent grid reduces the number of spatial positions by 64×. It does not promise a 64× end-to-end speedup: channel widths, attention, the autoencoder, and other components still cost compute.
The encoder and decoder are commonly pretrained and frozen while the denoiser is trained. Autoencoder reconstruction puts a ceiling on fidelity: details it discards cannot be recovered merely by training a better denoiser on its latents.
Latent scaling is not cosmetic
Use the exact encoder preprocessing, latent scale or shift, and decoder convention associated with the chosen autoencoder and checkpoint. If you change the latent magnitude, you change its effective signal-to-noise ratio under the same noise schedule. Do not copy a scaling constant from an unrelated model family.
Text-to-image training adds encoded captions as a condition. If the text encoder is frozen, its outputs can sometimes be cached, but caching reduces flexibility for caption changes and augmentations. Likewise, caching latents locks in the image preprocessing and any chosen crop. Keep those trade-offs explicit.
What changes with a diffusion transformer?
A diffusion transformer, or DiT, uses a transformer as the denoiser. Patchify the noisy latent, process tokens with time and condition information, and project back to an image-shaped prediction. Unlike a classification ViT, it needs an output for every patch position, not one class vector.
The original DiT design uses adaptive LayerNorm conditioning and initially zeroed modulation/output pathways. Other diffusion transformers use cross-attention or joint image/text attention. “Diffusion” describes the generative formulation; “transformer” describes the network that predicts the required quantity. They are different choices.
Image editing is a change in the starting problem
Image-to-image: encode or normalize a source image, corrupt it to a chosen starting noise level, then denoise under the desired condition. More initial noise generally permits larger changes and preserves less of the source. “Strength” is a UI mapping onto this choice and depends on the pipeline.
Inpainting: supply a mask and preserve known regions while generating unknown regions. A simple sampler can reinsert appropriately noised known pixels at every step, although boundaries can be inconsistent. Dedicated inpainting models are trained to use masks and visible image content. A mask convention—whether 1 means keep or regenerate—must be stated explicitly.
Exercise 09 · Trace a text-to-image batch 12 min
For B = 2, assume RGB 512×512 images, a 4-channel 64×64 latent, 77 text tokens of width 768, and 2×2 latent patches. List the shapes of z₀, ε, the text embeddings, image tokens before projection, and the denoiser's image-shaped output.
Worked answer
z₀ and ε: [2,4,64,64]. Text: [2,77,768]. Raw flattened patches: [2,1024,16] because 4 channels×2×2 = 16. After a learned projection, tokens are [2,1024,D]. The noise prediction is [2,4,64,64]. These are example dimensions, not requirements for every latent diffusion model.
Primary reading: Latent Diffusion Models and DiT.
Checkpoint · test the distinction
Which statement about a latent DiT is correct?
A latent is a representation; DiT is a backbone. Prediction target and sampler must still be specified and kept compatible.
Lesson 10 · Diffusion models
ControlNet: specify the structure
Text describes what you want. A spatial condition describes where it should go.
A prompt can ask for “a person raising their left arm,” but it does not specify exact joint coordinates. ControlNet adds an aligned image-like condition—such as a pose skeleton, edge map, depth map, or segmentation map—to a pretrained image generator. The model can vary appearance while following that structure.
The architecture, without the mystery
In the original U-Net-based ControlNet, the pretrained denoiser remains frozen. A trainable copy of its encoder and middle block receives the noisy latent, time, text, and an encoded spatial condition. Its multiscale features pass through zero-initialized 1×1 convolutions. The resulting residuals are added into the frozen U-Net's skip and middle features, influencing the decoder.
At initialization those residuals are zero, so attaching the branch leaves the base model's output unchanged. During training the branch learns useful corrections. A condition encoder reduces the control image to an appropriate feature resolution. This is more than concatenating an edge map to an arbitrary frozen model's input.
Architecture source: Adding Conditional Control to Text-to-Image Diffusion Models. The diagram is a simplified explanation of its U-Net design; control mechanisms in other backbones may differ.
Why zero convolutions can learn
Consider one output bridge y = Wh+b with W = 0 and b = 0. Its output is zero, but the gradient with respect to W contains the incoming loss gradient multiplied by h. If h is nonzero and the loss is sensitive to this output, W can receive a nonzero update on the first step.
Initially the gradient through this bridge into h is Wᵀ times the incoming gradient, so it is zero. After the bridge weights move away from zero, gradients can reach the earlier branch. Zero-initializing a bridge is different from zero-initializing every weight in a network. The copied features give the bridge something useful to learn from.
Exercise 10A · Verify the zero-bridge gradient 10 min
Before running this, predict which tensors have nonzero gradients on the first backward pass.
import torch from torch import nn h = torch.ones(2, 4, 8, 8, requires_grad=True) bridge = nn.Conv2d(4, 4, 1) nn.init.zeros_(bridge.weight) nn.init.zeros_(bridge.bias) loss = (bridge(h) - 1).square().mean() loss.backward() assert bridge.weight.grad.abs().sum() > 0 assert bridge.bias.grad.abs().sum() > 0 assert h.grad.abs().sum() == 0
Worked answer
Weight and bias gradients are nonzero. The input-feature gradient is zero because it is multiplied by the current zero bridge weights. After an optimizer step on the bridge, a new backward pass can produce nonzero gradients for h. This isolates one output bridge; a full architecture can contain additional paths and bridges.
A training example has three aligned parts
Prepare a target image, a spatial condition, and an optional caption. Derive an edge/depth/pose condition from the training image when appropriate, but make sure inference conditions use compatible conventions. Jointly transform image and control when cropping or flipping. Independently cropping a pose map and its target teaches contradictory supervision.
- Freeze the pretrained autoencoder, text encoder, and base denoiser weights. Initialize the control branch from the matching base encoder and middle block; initialize its output bridges to zero.
- Encode the target image into correctly scaled z₀. Encode the caption. Keep the control map in the format and range expected by its condition encoder; it is not simply another VAE latent unless the architecture says so.
- Choose t and ε, then create zₜ using the base model's schedule.
- Run the control branch to produce multiscale residuals. Pass those residuals into the frozen base U-Net.
- Compute the base model's expected target loss—noise MSE for an ε-predicting base, or the matching alternative. Update only the control branch and bridges.
# Architecture-level sketch: these interfaces depend on the chosen backbone.
base.requires_grad_(False)
control.requires_grad_(True)
with torch.no_grad():
z0 = encode_and_scale(images)
text = text_encoder(captions)
zt = add_noise(z0, noise, t)
residuals = control(zt, t, text, control_maps)
prediction = base(zt, t, text, control_residuals=residuals)
loss = F.mse_loss(prediction.float(), noise.float())
loss.backward() # gradients pass through the frozen decoder into residuals
control_optimizer.step()torch.no_grad() or detach the control residuals. You still need derivatives through the frozen decoder to teach the control branch. It is fine to disable gradients for frozen preprocessing outputs that do not depend on the branch.Control strength is different from text guidance
A control scale multiplies the branch residuals. CFG combines conditional and condition-absent denoiser outputs. They act at different places. Holding the initial noise fixed, sweep control scale while leaving CFG unchanged, then do the reverse. Strong control can preserve geometry at the expense of texture or flexibility; weak control can let the model ignore the supplied structure.
You can also enable control for only part of the denoising trajectory. Early high-noise steps often have a large influence on layout, but the best schedule depends on the task and control type. With multiple ControlNets, sum compatible residuals with separate scales and inspect conflicts rather than assuming every condition can be satisfied simultaneously.
Be explicit about CFG routing: one implementation may feed spatial control into both the prompt-present and prompt-absent branches; another may apply it only to the prompt-present branch. In the first case, the “unconditional” branch is text-unconditional but still spatially conditioned. The same numeric scales can behave differently across these choices.
ControlNet versus LoRA
LoRA parameterizes a weight update using low-rank matrices, reducing the number of trainable parameters. ControlNet adds a pathway for a spatial input. They solve different design problems and can be combined. Fine-tuning appearance with a LoRA does not, by itself, teach a model to interpret an arbitrary pose or depth map.
What to test before a large run
- Initialization equivalence: in evaluation mode, the newly attached zero-bridge branch produces the same output as the base model for identical inputs.
- Gradient flow: base parameter gradients stay absent; bridge gradients are present; earlier branch gradients emerge as the bridges learn.
- Alignment: overlay control maps on target images after all preprocessing. Check orientation, cropping, resizing, depth convention, and skeleton format.
- A tiny controlled overfit: on a small paired set, inspect whether generated structures move when the control changes at a fixed seed. Also test held-out layouts.
- Quality and adherence: measure both. Edge similarity, pose keypoint distance, or depth agreement can assess structure, but each depends on the estimator used and is not a complete quality metric.
Exercise 10B · A spatial-control capstone 60–120 min + training
After training the base shape model, add a small branch that accepts a one-channel desired outline. Use copied encoder features and zero-initialized projections into matching skip/middle features. Freeze the base weights. This is a teaching adaptation of ControlNet, not a reproduction of its large text-to-image system.
Hint
First refactor TinyUNet.forward to accept optional residuals for s₁, s₂, and the middle activation. Verify that all-zero residuals preserve the output before implementing the branch. The branch should see the noisy image and time as well as the control map.
Acceptance criteria
Record initialization equivalence, gradient checks, and paired preprocessing overlays. Generate a fixed-seed grid across at least four held-out locations or sizes and three control strengths including zero. Compare outline agreement and appearance diversity. If strength zero changes the base output, inspect the refactor before training longer. If all strengths look the same after training, inspect residual norms, gradient flow, and pairing.
For a real pretrained model, use the authors' implementation or inspect the matching Diffusers ControlNet training example. Architectural interfaces, preprocessing, and memory needs must match your selected checkpoint.
Checkpoint · test the distinction
During ControlNet training, can you wrap the entire frozen base call in no_grad?
Freezing parameters prevents their updates. Disabling the computation graph through the decoder also blocks the derivatives needed by the injected control residuals.
Lesson 11 · Optional theory
The theory that explains the code
Optional depth: understand the objective and score without getting lost in notation.
You can build the lab without this lesson. Read it when you want to explain why noise regression is connected to a probabilistic model, or when an interviewer asks for a derivation. We keep the main argument short and put the algebra behind expandable panels.
Why does a noise loss train a generator?
The generative model starts with a simple prior p(xT) and multiplies learned reverse transitions pθ(xₜ₋₁|xₜ). To assign a probability to x₀, it must account for all possible intermediate states. That sum or integral is impractical. We instead use the known forward corruption process q as a way to construct a tractable lower bound on log pθ(x₀), called the evidence lower bound, or ELBO.
Minimizing the negative bound separates into a terminal prior term, a reconstruction term at the data boundary, and terms that make learned reverse transitions match known forward posteriors. With fixed Gaussian reverse variances, those intermediate terms reduce to weighted squared errors between means. Expressing the means using a predicted ε turns them into weighted noise-prediction errors.
The commonly used simple noise MSE drops those particular ELBO weights. It is motivated by the bound but is not, in general, numerically equal to the exact negative ELBO. A training MSE value is not an image log-likelihood.
Derivation A · The bound and its three pieces
Write x1:T for the latent trajectory. Multiply and divide by q(x1:T|x₀), then apply Jensen's inequality to the logarithm:
≥ Eq[log pθ(x₀:T) − log q(x₁:T|x₀)]
Rearranging the negative bound gives:
+ Σt=2…T Eq KL(q(xₜ₋₁|xₜ,x₀) ‖ pθ(xₜ₋₁|xₜ))
+ KL(q(xT|x₀) ‖ p(xT))
With a fixed forward schedule and prior, the last term is independent of θ. The first is a separate data-likelihood boundary term. The middle terms train the reverse model using a posterior we can calculate because training supplies x₀.
Derivation B · Where the Gaussian posterior comes from
By Bayes' rule, q(xₜ₋₁|xₜ,x₀) is proportional to q(xₜ|xₜ₋₁)q(xₜ₋₁|x₀). Both factors are Gaussian as functions of xₜ₋₁. Their precisions—the reciprocals of their variances—add:
Using Aₜ = αₜAₜ₋₁ simplifies this to the β̃ₜ in lesson 04. Multiply that variance by the summed precision-weighted means to obtain:
Expanding gives c₀,ₜx₀+cₜ,ₜxₜ. This derivation is for t>1; use the clean boundary directly at t=1 instead of dividing by 1−A₀=0.
Derivation C · From mean error to noise MSE
Substituting x₀ = (xₜ−√(1−Aₜ)ε)/√Aₜ into the posterior mean gives:
Parameterize μθ with the same formula using εθ. For a fixed reverse variance σ²ₜ, the θ-dependent Gaussian KL contribution is:
This is an intermediate-time term, not a valid t=1 formula with σ²₁=β̃₁=0. The simple objective uses an unweighted noise error under its chosen timestep sampling. Learned variance adds other θ-dependent terms and cannot be justified by this fixed-variance argument alone.
The score: a direction, not a quality rating
The score of a noisy image distribution is s(xₜ,t) = ∇xₜ log pₜ(xₜ). It points toward increasing density locally. It is not a scalar confidence score and not a gradient with respect to network weights.
For the conditional corruption Gaussian, ∇xₜ log q(xₜ|x₀) = −ε/√(1−Aₜ). Average this over possible x₀ given xₜ and use the optimal MSE predictor:
This is the bridge between denoising and score estimation. The clean-data distribution can be complicated or concentrated on a thin subset of image space; adding Gaussian noise smooths it. Learning directions at several noise levels is more useful for generation than trying to jump directly to one final clean-image average.
Optional · Continuous-time language in one place
An SDE describes continuous drift plus random noise: dx = f(x,t)dt + g(t)dW. In the variance-preserving diffusion case, f = −β(t)x/2 and g = √β(t). The score-based reverse SDE has drift f−g²s and is integrated from noisy time toward clean time, with negative time increments. The associated probability-flow ODE uses drift f−g²s/2 and no stochastic term.
With an exact score and matching endpoint distribution, these dynamics have matching time marginals under the usual regularity assumptions; their individual trajectories differ. Approximate networks and numerical solvers introduce error. This is why “all diffusion samplers are ODE solvers” is too broad: stochastic samplers remain a separate choice.
Exercise 11 · Give a four-minute derivation 15 min
Explain the chain from the latent-variable likelihood to the bound, Gaussian reverse means, and noise MSE. Name two qualifications that prevent an oversimplified answer.
Worked answer
Use q as the auxiliary trajectory distribution; Jensen gives a lower bound; the negative bound decomposes into prior, boundary reconstruction, and posterior-matching KL terms; fixed Gaussian variance turns mean matching into weighted ε regression. Qualifications: simple MSE drops the bound's specific weights, and the clean boundary is treated separately. Also distinguish q(xₜ₋₁|xₜ,x₀), known in training, from the reverse conditional without x₀, which must be learned.
Foundations: DDPM and Score-Based Generative Modeling through SDEs.
Checkpoint · test the distinction
Is unweighted noise MSE exactly the negative ELBO?
The variational derivation motivates weighted intermediate denoising terms. The simple loss changes that weighting and is not a likelihood value.
Lesson 12 · Diffusion models
Modern methods, organized by what changes
Keep the data path, prediction target, sampler, and training procedure separate.
| Method | Main change | What it does not imply |
|---|---|---|
| Latent diffusion | Representation: denoise compressed tensors | Does not require a transformer |
| DiT | Architecture: use a transformer denoiser | Does not uniquely specify target or sampler |
| DDIM / DPM-Solver | Inference update / numerical method | Does not by itself train a one-step generator |
| Flow matching | Learn a vector field along a chosen probability path | A straight training path does not make every learned sampling trajectory straight |
| Distillation / consistency | Train for fewer or larger generation steps | Not equivalent to deleting steps from any checkpoint |
| ControlNet | Add learned spatial conditioning pathways | Not the same operation as increasing CFG |
Flow matching with a straight path
Here we deliberately introduce a new time symbol u to make its direction explicit: u = 0 is noise, u = 1 is data. Draw independent z∼N(0,I), data x₀, and u∼Uniform(0,1). Interpolate and predict the interpolation velocity:
target velocity = x₀ − z
L = E ‖vθ(x(u),u) − (x₀−z)‖²
At generation time start with z and integrate dx/du = vθ(x,u) from 0 to 1. One Euler update is x ← x + Δu·vθ(x,u). The network learns an average velocity for the possible pairs passing through each x,u; that is why independent straight training pairs need not yield straight model trajectories.
Do not confuse this target x₀−z with diffusion v = aε−σx₀ from lesson 02. Some implementations reverse time or use another path, changing signs and formulas. Before borrowing code, write the two endpoints and differentiate the path.
How very few-step models become possible
Better solvers use the same learned field more accurately per evaluation. Distillation trains a student to reproduce a teacher's larger movement in fewer steps. For example, progressive distillation can train one student step to match two teacher steps, then repeat. Consistency models learn mappings that agree on an endpoint for states along a shared trajectory; they can be trained by distillation or through suitable direct training objectives.
These choices change compute and error in different places. A distilled checkpoint may expect particular noise levels and guidance settings. A generic solver may not deliver the advertised result if those assumptions are ignored. Count teacher-generation cost, student-training cost, and inference cost separately.
Where advanced training refinements fit
Noise-level sampling and loss weighting decide which tasks receive more optimization effort. Preconditioning rescales model inputs, outputs, and targets to make those tasks numerically better behaved. Learned variance changes the reverse distribution. None should be introduced as a magic fix before the baseline noising and sampler tests pass.
For example, with SNR = A/(1−A), one Min-SNR formulation weights ε MSE by min(SNR,γ)/SNR. The corresponding v-prediction weighting is different. Use the formula for your parameterization, and handle endpoint limits rather than dividing by zero. This is optional refinement, not part of the lab's first-run recipe.
Exercise 12 · Catch a reversed flow update 8 min
A network is trained on x(u) = (1−u)z+ux₀ with target x₀−z. Sampling begins at u=0 but updates x -= du * model(x,u) for positive du. Diagnose it with the one-dimensional pair z=−1 and x₀=3.
Worked answer
The target velocity is +4. Correct motion is x←x+4du, toward 3. The subtraction moves toward more negative values. For this convention integrate u upward with a plus sign. A different convention could use a minus sign, but its path, target, and time direction must agree.
Primary reading: Flow Matching, Progressive Distillation, Consistency Models, EDM, and Min-SNR weighting.
Checkpoint · test the distinction
Why can a distilled model use very few steps?
The student is trained for the few-step behavior. Simply omitting steps from an arbitrary checkpoint is a different experiment.
Lesson 13 · Diffusion models
Training recipes you can adapt
Starting experiments with a purpose, a budget, and a definition of success.
These are practical starting points, not universal optimal settings. Keep a record of data preprocessing, prediction target, schedule, optimizer, EMA, sampler, and random seeds. Change one major component at a time, and compare against a saved baseline.
Recipe A · Learn the mechanics on the shape lab
Setup: 32×32 grayscale, 823,841-parameter U-Net, cosine schedule with T=200, ε prediction, uniform times, AdamW at 2×10⁻⁴ with zero weight decay, batch 32–64, float32, gradient clipping at norm 1, and EMA. Begin with a two-step smoke run, then try 3,000 updates. CPU is viable for experimentation; accelerator training is preferable for iteration speed.
Success: finite gradients; strong reduction on a fixed-corruption overfit test; improving held-out noise error; recognizable and varied uncurated samples. Compare the full DDPM sampler with 25–50 DDIM steps only after inspecting the baseline. A step count alone is not evidence of convergence.
Recipe B · Replace synthetic shapes with MNIST or small images
Setup: keep ε prediction and the model first. For MNIST, pad 28×28 images to 32×32, turn pixels into [0,1], then map to [−1,1]. Use a shuffled DataLoader and a fixed held-out split. Start with batch 64, learning rate 2×10⁻⁴, and T=200 or 1,000 with a freshly constructed cosine schedule. Evaluate at regular update counts and stop based on validation and sample behavior rather than a copied epoch number.
# Optional dataset replacement: requires a compatible torchvision installation.
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
transform = transforms.Compose([
transforms.Pad(2), # 28x28 -> 32x32
transforms.ToTensor(), # [0,255] -> [0,1]
transforms.Normalize((0.5,), (0.5,)),
])
train_data = datasets.MNIST("data", train=True, download=True, transform=transform)
loader = DataLoader(train_data, batch_size=64, shuffle=True, num_workers=0)
# Replace shapes(...) in the training path with x0 from the loader.
# Keep the held-out validation loader separate; labels are unused unless conditioning.Next extension: for 32×32 RGB images, change input and output channels to 3 and inspect whether the small model has enough capacity. Consider a wider U-Net and low-resolution attention. Expect a much harder task than synthetic shapes. Avoid geometrically or semantically invalid augmentation, such as arbitrary flips for digits.
Recipe C · Fine-tune a pretrained latent model
Setup: choose one documented checkpoint family and retain its autoencoder preprocessing, latent scaling, text encoding, prediction target, and scheduler contract. Freeze the autoencoder and usually the text encoder. Use image/caption pairs and a held-out prompt set. For a modest adaptation, a denoiser learning-rate sweep around 10⁻⁶–10⁻⁵ is a conservative initial experiment; adapter-only training often needs a different range.
Memory: mixed precision, gradient accumulation, and activation checkpointing are practical tools. Keep numerically sensitive losses and schedule calculations in float32. CUDA float16 commonly needs loss scaling; bfloat16 has a wider exponent range where supported. Effective batch size is microbatch×accumulation×number of workers participating in distributed data-parallel training. Normalize accumulated losses consistently.
Success: improvement on held-out target prompts with reasonable diversity, and no unacceptable loss of baseline capabilities. Evaluate checkpoints on fixed seeds and both target-domain and general prompts. Overfitting can improve training examples while damaging broader behavior.
Recipe D · Train spatial control
Setup: use a ControlNet architecture matched to the base checkpoint; freeze base components; initialize the copied blocks and zero output bridges correctly. Inspect paired image/control overlays before training. Start with a small paired overfit experiment, then expand. A learning-rate sweep around 10⁻⁵ for a copied control branch is a starting experiment, not a hardware-independent prescription.
Conditioning: decide whether and how often to drop text based on whether inference must work with weak or absent prompts. Document whether control enters both CFG branches. Keep control-map processing consistent: edge thresholds, pose drawing, depth direction and normalization, and segmentation label conventions can all change the task.
Success: held-out structure is followed when control strength is nonzero, scale zero recovers the base path, gradients reach the intended trainable parameters, and visual quality remains acceptable. Report separate structure and appearance assessments. Test conflicting prompts and controls to understand the model's limits.
What a useful experiment record contains
Write down a hypothesis, the single main change, compute used, held-out metric, fixed-seed sample grid, failure cases, and whether you keep the change. “It looked better” without fixed seeds or a comparable sampler is not a reliable experiment.
Exercise 13 · Plan a constrained run 15 min
You can afford four short training runs and need stronger pose adherence from an existing ControlNet setup. Propose a sequence that distinguishes data problems from optimization problems.
Worked answer
First inspect overlays and verify zero-scale/base equivalence and bridge gradients without a full run. Run 1: overfit a tiny aligned paired set. If that fails, fix implementation before spending the rest. Run 2: baseline on the training split with fixed evaluation poses. Runs 3–4: controlled learning-rate or conditioning-drop variants, keeping data and evaluation settings fixed. Separately sweep inference control strength on each checkpoint; that sweep need not consume a training run.
Checkpoint · test the distinction
What is a good first ControlNet experiment?
First show that the architecture preserves the base at initialization, receives gradients, and can use aligned conditions. Then scale.
Lesson 14 · Diffusion models
Interview drills that test understanding
Practice explanations, code, diagnosis, and trade-offs—not just definitions.
Answer aloud before revealing each response. A strong answer names its assumptions, tracks shapes, explains the mechanism, and proposes a way to test it. A useful self-score is 0 = cannot explain, 1 = definition only, 2 = mechanism correct, 3 = mechanism plus a concrete failure mode or test.
2 minExplain diffusion to someone who knows neural networks.
Train a time-conditioned network to predict noise that we deliberately add to data. We can create supervised pairs at arbitrary noise levels in one step. To generate, start from Gaussian noise and repeatedly turn the network's predictions into cleaner states with a defined sampler. The same weights are reused. Training normally uses one random time per example; inference is iterative.
3 minWhy do outputs not collapse to the average training image?
A single MSE prediction is a conditional average, and at high noise its clean-image estimate can be ambiguous. The sampler starts from a random state and follows many state-dependent updates, with additional randomness in ancestral DDPM. It does not output one global MSE average. Different initial states and paths can select different modes. This does not guarantee perfect diversity or rule out memorization.
3 minIs a 50-step CFG sampler 50 forward passes or 100?
State the counting convention. Standard CFG needs conditional and condition-absent predictions at each time, so 100 batch-B-equivalent denoiser predictions for a one-evaluation-per-branch sampler. It can be implemented as 50 calls with batch 2B. A higher-order solver may need more evaluations per step, and specialized distilled guidance can change the setup.
4 minWhy can DDIM be deterministic and still generate diverse images?
Its η=0 path is deterministic conditional on the initial noise, conditions, and numerical execution. The initial noise is random. It defines a transformation of that random input, not one constant output. Diversity and quality depend on the learned model and sampler; determinism alone does not guarantee either.
4 minWhat is frozen during ControlNet training, and where do gradients go?
The original base denoiser and typically the autoencoder/text encoder weights remain fixed. The copied control branch, condition encoder, and output bridges train. Gradients must flow from the loss through the base decoder operations into the injected residuals. Setting base parameters' requires_grad to false is compatible with that; wrapping the entire base call in no_grad is not.
4 minWhy doesn't zero initialization prevent ControlNet from learning?
The zero bridge has a weight gradient proportional to incoming loss gradient times its nonzero input features. It can update immediately. Its input gradient is initially zero because the bridge weights are zero, then can become nonzero after an update. Zeroing this interface is not zeroing all copied feature-producing layers.
5 minYour ε-prediction checkpoint is loaded into a v-prediction sampler. What happens?
Tensor shapes can still match, but the sampler interprets the output with the wrong conversion. It reconstructs incorrect clean images or noise directions, often destroying samples. Check the training target and scheduler prediction type before modifying weights or learning rate. A known-decomposition conversion test exposes the mismatch.
5 minCan a lower denoising loss coexist with worse FID?
Yes. Denoising loss averages a chosen target and noise distribution; FID measures feature statistics of generated samples. Differences in timestep weighting, conditioning, data, sampler, or guidance can change their relationship. First ensure comparable evaluation settings, then inspect per-noise error, samples, and task-specific quality. Neither number is a complete assessment.
5 minWhat changes if you swap a U-Net for DiT?
The denoising backbone changes from a multiscale convolutional structure to token processing. It still takes a noisy state and time/conditions and predicts an image-shaped target. Patch size trades token count against spatial detail, and dense attention cost grows quadratically with token count. The diffusion objective and representation need not change with the backbone.
A 45-minute coding mock
- 0–8 min: specify tensor shapes and an indexing convention; construct a valid schedule.
- 8–18 min: implement noising, a time-aware denoiser interface, and one optimizer step.
- 18–30 min: implement fixed-posterior DDPM sampling and the clean endpoint.
- 30–38 min: write a known-noise inversion check and one finite-gradient check.
- 38–45 min: explain how to add DDIM and CFG, and diagnose a training/sampling mismatch.
Rubric: correct mechanics 40%, shapes and boundary conditions 25%, explanation 20%, useful tests and diagnosis 15%. You need not type a large production U-Net in 45 minutes; use a small time-aware model and state the architectural contract. Do not claim an implementation works because it merely produces an image-shaped tensor.
Checkpoint · test the distinction
Which answer best demonstrates interview fluency?
Definitions help, but mechanisms, boundary cases, and tests reveal whether you could implement and debug the system.
Lesson 15 · Diffusion models
Your capstone and field guide
Leave with an implementation you can defend, not just a notebook you can rerun.
The capstone: build, compare, explain
- Rebuild the unconditional lab. Reproduce noising, training, EMA, checkpointing, and DDPM sampling. Pass the mathematical boundary and inversion checks.
- Compare samplers. Evaluate full DDPM and DDIM at 10, 25, and 50 steps on the same initial seeds. Record image quality, diversity, and model evaluations.
- Add class conditioning and CFG. Produce class-by-seed-by-guidance grids. Verify the w=0 and w=1 limits.
- Add spatial control. Use the small ControlNet adaptation from lesson 10, or a matching pretrained setup. Prove initialization equivalence and gradient flow; evaluate held-out conditions.
- Write a one-page report. Describe the model contract, two failures you diagnosed, one controlled ablation, and one remaining limitation.
Completion standard: another person should be able to run your code, understand the conventions, resume training, reproduce a fixed-seed comparison on the same setup, and see why you trust the result. If only the training loss is convincing, keep investigating.
The five equations to reproduce from memory
xₜ = √Aₜ x₀ + √(1−Aₜ) ε
Lsimple = E mean((εθ(xₜ,t)−ε)²)
x̂₀ = (xₜ−√(1−Aₜ) ε̂) / √Aₜ
ε̂CFG = ε̂uncond + w(ε̂cond−ε̂uncond)
Also be able to reconstruct the DDPM mean and variance with the reference in lesson 04, and write the deterministic DDIM jump in lesson 07. Understanding the boundary cases matters more than memorizing every coefficient.
A small glossary
| Term | Plain meaning |
|---|---|
| Denoiser | The network that predicts noise, a clean image, or a related target from a noisy state |
| Noise schedule | The relationship between time and corruption strength |
| SNR | Signal-to-noise variance ratio A/(1−A) under this parameterization |
| Sampler | The procedure that turns predictions into a sequence of generated states |
| Ancestral sampling | Drawing successive states from the learned reverse conditional transitions |
| Score | The gradient of log density with respect to the noisy state |
| ELBO | A tractable lower bound on a latent-variable model's log-likelihood |
| EMA | A smoothed copy of learned weights, often used for sampling |
| CFG | Combining conditional and condition-absent predictions during sampling |
| ControlNet | A trainable branch that adds spatial conditioning to a pretrained generator |
| NFE | Number of function evaluations; specify how you count batched guidance branches |
Primary sources, in a useful reading order
These links are further reading, not prerequisites for following the course. The historical formulations are identified deliberately; this is not a leaderboard of current models.
- Ho et al. · Denoising Diffusion Probabilistic Models — connect the original objective and sampling algorithm to the lab.
- Nichol & Dhariwal · Improved DDPM — schedule design and learned reverse variance.
- Song et al. · DDIM — use fewer sampling steps with a trained diffusion model.
- Ho & Salimans · Classifier-Free Diffusion Guidance — the conditional/unconditional combination.
- Rombach et al. · Latent Diffusion Models — compression and conditional image synthesis.
- Zhang et al. · ControlNet — learn spatial control with a frozen base model.
- Peebles & Xie · DiT — a transformer denoising backbone.
- Song et al. · Score-Based Generative Modeling through SDEs — the continuous-time connection.
- Lipman et al. · Flow Matching — learn velocity fields along probability paths.
- Lu et al. · DPM-Solver — fast numerical sampling.
- Salimans & Ho · Progressive Distillation and Song et al. · Consistency Models — learn to use fewer generation steps.
- Karras et al. · EDM and Hang et al. · Min-SNR — numerical design and noise-level weighting.
Checkpoint · test the distinction
Which artifact best demonstrates capstone completion?
A convincing result connects code, assumptions, tests, and evaluation rather than relying on an isolated sample.