Ch. 18

Generative Models

Autoencoders → VAEs → GANs → Diffusion → flow matching. Latent diffusion explained from the math up.

VAEGANdiffusion

FIG 18 · Explainer video


A generative model is a function from noise to something that looks like data. That sentence does most of the work, and the rest of this chapter is about which noise, which function, and which training loss. Autoencoders compress and then decompress, and you sample by walking around in the space. GANs throw two networks at each other and the equilibrium happens to look like real images. Diffusion models add Gaussian noise to your data a thousand times until it is pure static, and then learn to undo one step at a time. The thousand-step thing sounds insane on first read. It is the most stable training objective in generative modeling and it is the reason Stable Diffusion exists. By the end of this chapter you will know why.


FIG 18.1 · Learning outcomes

By the end of this chapter you will be able to:

  • Implement a vanilla autoencoder in PyTorch on MNIST and explain why the bottleneck width controls reconstruction sharpness.
  • Derive the VAE evidence lower bound from scratch, label every term, and explain the reparameterization trick in one sentence (it moves the randomness out of the gradient path).
  • Train a small GAN on MNIST, diagnose mode collapse from samples alone, and fix it by switching to a Wasserstein loss with gradient penalty.
  • Write the DDPM forward and reverse processes in 80 lines of NumPy and trace the role of $\alpha_t$, $\bar\alpha_t$, and the noise prediction $\epsilon_\theta$.
  • Explain classifier-free guidance and why a guidance scale of 7.5 is the de-facto default for Stable Diffusion.
  • Draw the latent diffusion architecture from memory: VAE encoder, U-Net denoiser in latent space, VAE decoder, and the text conditioning paths.
  • Articulate three failure modes a deployed image generator inherits from its training data and which family of techniques (alignment, dataset filtering, classifier guidance) addresses each.

FIG 18.2 · What you need first

  • Ch 11 — Training Deep Neural Networksyou need the AdamW + LR warmup + gradient clipping vocabulary. GANs are unstable. Diffusion is finicky. The training loop in this chapter takes those for granted.
  • Ch 12 — CNNsthe U-Net we use as a denoiser is a CNN with skip connections. If nn.Conv2d is unfamiliar, do another chapter first.
  • Ch 7 — Dim Reductionautoencoders are the nonlinear cousin of PCA. The framing carries over.
  • Ch 8 — Unsupervised LearningGMMs are the simplest generative models. The EM-on-GMM derivation is the prereq for understanding variational inference in modern generative models.
  • Ch 16 — Multimodal Transformersfor the latent diffusion + Stable Diffusion section. CLIP-conditioning is how text-to-image works; you need another chapter's framing to read the cross-attention math.
  • Ch 17 — Efficient Inferencediffusion sampling shares the same bottlenecks as LLM inference (memory bandwidth, batching). The sampling section reuses another chapter's vocabulary.
  • Ch 0 — Math & Python prereqs §probabilityyou need KL divergence, the multivariate Gaussian density, and Jensen's inequality. We re-derive the ELBO, but only after pointing at those three.

If you skipped another chapter: the VAE sub-section will still make sense, but the framing "compression with a probabilistic twist" will land harder if you remember what PCA was doing.


FIG 18.3.1

Autoencoders as compression

An autoencoder is two functions stacked back-to-back. An encoder gϕ:RdRkg_\phi : \mathbb{R}^d \to \mathbb{R}^k with k<dk < d, and a decoder fθ:RkRdf_\theta : \mathbb{R}^k \to \mathbb{R}^d. You train both jointly to make fθ(gϕ(x))xf_\theta(g_\phi(\mathbf{x})) \approx \mathbf{x}, with squared error or as the reconstruction loss. The interesting thing is the middle. The kk-dimensional is forced to retain whatever information about x\mathbf{x} is necessary to reconstruct it, and discard everything else. After training, the bottleneck activations z=gϕ(x)\mathbf{z} = g_\phi(\mathbf{x}) are a learned representation.

This is dimensionality reduction the way PCA is dimensionality reduction. PCA finds the linear subspace that captures the most variance. An autoencoder finds a nonlinear manifold that captures the most reconstructable structure. PCA is a special case where the encoder and decoder are both linear and tied. The autoencoder generalizes the framing.

Two things to remember. First, a deep enough autoencoder with an unconstrained bottleneck just learns the identity function and is useless. The bottleneck width is the regularizer. Second, a vanilla autoencoder is not a generative model. The space has no structure. If you sample a random z\mathbf{z} and decode it, you get garbage almost always. The VAE fixes this. We will get there.

Library path (PyTorch):

Python
import torch
import torch.nn as nn

class Autoencoder(nn.Module):
    def __init__(self, d_in: int = 784, d_latent: int = 32):
        super().__init__()
        self.encoder = nn.Sequential(
            nn.Linear(d_in, 256), nn.ReLU(),
            nn.Linear(256, d_latent),
        )
        self.decoder = nn.Sequential(
            nn.Linear(d_latent, 256), nn.ReLU(),
            nn.Linear(256, d_in), nn.Sigmoid(),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.decoder(self.encoder(x))

# Training loop sketch (MNIST, flattened to 784)
model = Autoencoder()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
for x, _ in loader:
    x = x.view(-1, 784)
    recon = model(x)
    loss = nn.functional.mse_loss(recon, x)
    opt.zero_grad(); loss.backward(); opt.step()

From-scratch path (NumPy, just to see that nothing is hidden):

Python
import numpy as np

def relu(x): return np.maximum(0, x)
def sigmoid(x): return 1.0 / (1.0 + np.exp(-x))

class AE:
    def __init__(self, d_in=784, d_h=256, d_z=32, seed=0):
        rng = np.random.default_rng(seed)
        scale = lambda a, b: rng.standard_normal((a, b)) / np.sqrt(a)
        self.W1, self.W2 = scale(d_in, d_h), scale(d_h, d_z)
        self.W3, self.W4 = scale(d_z, d_h), scale(d_h, d_in)

    def forward(self, x):
        h1 = relu(x @ self.W1)
        z  = h1 @ self.W2
        h2 = relu(z @ self.W3)
        return sigmoid(h2 @ self.W4), z

You can write the gradients by hand if you want to see why backprop through a bottleneck is well-conditioned. Most people use PyTorch.

FIG 18.3.2

Denoising autoencoders

A denoising autoencoder is the same architecture trained with a different objective. You corrupt x\mathbf{x} to get x~\tilde{\mathbf{x}} (mask some pixels, add Gaussian noise, set some values to zero) and train the network to reconstruct the clean x\mathbf{x} from the corrupted input. The loss is now xfθ(gϕ(x~))2\|\mathbf{x} - f_\theta(g_\phi(\tilde{\mathbf{x}}))\|^2. The intuition Vincent et al. give in the 2008 paper: to repair a partially destroyed input, the network has to learn the joint structure of the input dimensions. It cannot just copy values across the . It has to know that if those pixels are missing, the neighborhood looks like an eye, so the missing pixels are probably skin.

This is more than a trick. The denoising objective is mathematically connected to score matching — training a network to estimate the score xlogq(x)\nabla_{\mathbf{x}} \log q(\mathbf{x}), the of the log data-density, by the detour of adding a little noise and learning to predict it away — which is in turn connected to diffusion models, where the same noise-then-predict move is run at many noise levels. We come back to this connection in the diffusion section. For now, two practical points. First, the denoising autoencoder pre-dates by four years and is morally the same idea (Lilian Weng makes this observation; it shows up in every careful history of the area). Second, the corruption can be salt-and-pepper, masking, Gaussian, or learned. Each gives a different .

Library path:

Python
# Same Autoencoder as before, plus a corruption op in the loop
for x, _ in loader:
    x = x.view(-1, 784)
    x_noisy = x + 0.3 * torch.randn_like(x)
    x_noisy = x_noisy.clamp(0, 1)
    recon = model(x_noisy)
    loss = nn.functional.mse_loss(recon, x)   # target is clean x
    opt.zero_grad(); loss.backward(); opt.step()

A sparse autoencoder adds an L1 penalty (or a KL-to-Bernoulli penalty in the original 2008 framing) on the hidden activations to force most units to be near zero on any given input. The model is encouraged to use a small set of features per input, even when the hidden layer is much wider than the input. This is the trick that makes the modern mech-interp dashboard possible. If you train a sparse autoencoder on the of a transformer and the L1 penalty is set right, the features the SAE learns tend to be approximately monosemantic. One lights up on "function calls in Python," another on "tokens followed by a date," and so on. Anthropic's Towards Monosemanticity and Monosemanticity hang the entire post-2023 interpretability program on this hook.

We will not belabor this here, because another chapter is the whole story. The point for this chapter is that the sparse autoencoder is a generative-model technique that turned out to be the right tool for a non-generative problem. The same encoder--decoder schema, plus an L1 on the bottleneck, plus enough scale, gives you features. The first time I saw the dashboard I thought it was magic. It is not magic. It is L1 plus scale.

Library path:

Python
class SparseAE(nn.Module):
    def __init__(self, d_in: int, d_features: int, l1_coeff: float = 1e-3):
        super().__init__()
        self.encoder = nn.Linear(d_in, d_features)
        self.decoder = nn.Linear(d_features, d_in, bias=False)
        self.l1_coeff = l1_coeff

    def forward(self, x: torch.Tensor):
        z = torch.relu(self.encoder(x))
        x_hat = self.decoder(z)
        recon_loss = (x_hat - x).pow(2).mean()
        sparsity_loss = z.abs().mean()
        return x_hat, z, recon_loss + self.l1_coeff * sparsity_loss

Note: in modern SAE training the decoder weights are normalized per-feature, and the sparsity penalty is sometimes a top-k or JumpReLU activation rather than L1. The principle is the same.

FIG 18.3.4

VAE: the reparameterization trick and the ELBO

A turns the encoder into a distribution over latents, not a single point. Instead of z=gϕ(x)\mathbf{z} = g_\phi(\mathbf{x}), you have qϕ(zx)=N(z;μϕ(x),σϕ2(x)I)q_\phi(\mathbf{z} \mid \mathbf{x}) = \mathcal{N}(\mathbf{z}; \boldsymbol{\mu}_\phi(\mathbf{x}), \boldsymbol{\sigma}_\phi^2(\mathbf{x}) \mathbf{I}), where the encoder network outputs a mean and a log-variance per dimension. To decode, you sample zqϕ(zx)\mathbf{z} \sim q_\phi(\mathbf{z} \mid \mathbf{x}) and pass it through the decoder.

The training objective is the evidence lower bound (ELBO):

LVAE(θ,ϕ;x)=Eqϕ(zx)[logpθ(xz)]reconstructionDKL(qϕ(zx)p(z))regularizer to prior\mathcal{L}_\text{VAE}(\theta, \phi; \mathbf{x}) = \underbrace{\mathbb{E}_{q_\phi(\mathbf{z}\mid\mathbf{x})}[\log p_\theta(\mathbf{x}\mid\mathbf{z})]}_\text{reconstruction} - \underbrace{D_\text{KL}(q_\phi(\mathbf{z}\mid\mathbf{x}) \| p(\mathbf{z}))}_\text{regularizer to prior}

The first term is reconstruction: how well does the decoder reproduce x\mathbf{x} when given a sample z\mathbf{z} from the encoder. The second term pulls the encoder distribution qϕ(zx)q_\phi(\mathbf{z}\mid\mathbf{x}) toward the prior p(z)=N(0,I)p(\mathbf{z}) = \mathcal{N}(\mathbf{0}, \mathbf{I}). Without the KL, the encoder collapses to delta-function distributions and you have an ordinary autoencoder. With the KL too strong, the encoder ignores x\mathbf{x} and the decoder learns nothing. The balance is the whole game.

The reparameterization trick. Sampling zN(μ,σ2)\mathbf{z} \sim \mathcal{N}(\mu, \sigma^2) is not differentiable in μ,σ\mu, \sigma. The trick: rewrite z=μ+σϵ\mathbf{z} = \boldsymbol{\mu} + \boldsymbol{\sigma} \odot \boldsymbol{\epsilon} where ϵN(0,I)\boldsymbol{\epsilon} \sim \mathcal{N}(\mathbf{0}, \mathbf{I}). Now the stochasticity is in ϵ\boldsymbol{\epsilon}, which is -free, and z\mathbf{z} is a deterministic function of μ,σ,ϵ\mu, \sigma, \epsilon. Gradients flow through μ\mu and σ\sigma. This is the single trick that made VAEs trainable in 2013. Every neural generative model that involves a sampled latent uses some version of it.

torch.distributions rsample + kl_divergence vs. from-scratch reparameterize + closed-form KL

DL glue
LIBRARY
dist = Normal(mu, torch.exp(0.5 * logvar))
z = dist.rsample()                              # rsample = reparameterized, gradients flow
prior = Normal(0.0, 1.0)
kld = kl_divergence(dist, prior).sum()
# reconstruction term is still your own BCE/MSE
FROM SCRATCH
def reparameterize(self, mu, logvar):
    std = torch.exp(0.5 * logvar)
    eps = torch.randn_like(std)
    return mu + eps * std

def vae_loss(recon, x, mu, logvar):
    bce = F.binary_cross_entropy(recon, x, reduction="sum")
    kld = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())
    return bce + kld

from scratch: draft.md §4 (VAE.reparameterize and vae_loss); numpy twins reparameterize_numpy / kl_to_unit_gaussian

  1. 1Normal(mu, exp(0.5*logvar)).rsample() mu + torch.randn_like(std) * std (std=exp(0.5*logvar))
  2. 2rsample() (vs sample()) keeps gradients through mu and std eps = randn_like(std) is parameter-free, so grads flow through mu, std
  3. 3kl_divergence(Normal(mu,sigma), Normal(0,1)).sum() -0.5 * sum(1 + logvar - mu^2 - exp(logvar))
  4. 4the reconstruction term is still your own BCE/MSE bce = F.binary_cross_entropy(recon, x, reduction='sum')
What the one call hides
  • rsample() vs sample(): the library distinguishes the differentiable reparameterized draw from the plain non-differentiable one; calling the wrong one silently blocks gradients.
  • kl_divergence has a registered closed form for Normal||Normal that is exactly the -0.5*sum(1+logvar-mu^2-exp(logvar)) identity, computed in a numerically stable way.
  • The library works in std (scale) space; the scratch works in log-variance space (the exp(0.5*logvar) conversion), the convention that keeps sigma positive.
  • Reduction convention: the scratch uses reduction='sum' so KL and recon are on the same per-batch scale; mixing 'mean' and 'sum' silently reweights the ELBO.
  • Gotcha: Using .sample() instead of .rsample() trains nothing (gradients are detached) and is a classic silent VAE bug.
  • Gotcha: Mixing reduction='mean' for recon and the summed KL changes the effective beta and can cause posterior collapse.
  • Gotcha: The encoder outputs logvar, not std or var; feeding std into the closed-form KL gives a wrong (often negative) loss.

In practice you hand-write the reparameterize line and the closed-form KL because the whole point of a VAE is owning the ELBO terms; torch.distributions.rsample + kl_divergence is the principled stand-in when you want numerical stability or want to swap the prior.

On the job: You write the reparameterize line, the closed-form KL, and the recon term yourself in the loss; you'd only reach for torch.distributions when you need a non-Gaussian prior or a generic kl_divergence.

The closed-form KL between a diagonal Gaussian and the standard normal is one of the most copy-pasted formulas in the field. Memorize it once.

FIG 18.3.5

β-VAE and disentanglement

β-VAE (Higgins et al., 2017) is one number changed. Multiply the KL term by a constant β>1\beta > 1:

Lβ-VAE=Eqϕ[logpθ(xz)]βDKL(qϕ(zx)p(z))\mathcal{L}_{\beta\text{-VAE}} = \mathbb{E}_{q_\phi}[\log p_\theta(\mathbf{x}\mid\mathbf{z})] - \beta \cdot D_\text{KL}(q_\phi(\mathbf{z}\mid\mathbf{x}) \| p(\mathbf{z}))

The claim is that for the right β\beta, the dimensions become disentangled: each axis of z\mathbf{z} becomes responsible for one independent generative factor of the data (rotation, color, scale, etc.). The mechanism is information-theoretic: a tighter KL forces the encoder to be more efficient, and the efficient code happens to use independent axes for independent factors.

Caveat: the disentanglement claim is delicate. Locatello et al. (2019) showed that unsupervised disentanglement is impossible without or supervision. β-VAE works empirically on some datasets and not others, and the precise hyperparameters matter more than the authors' framing implied. The lesson is more pragmatic: the KL coefficient is a knob that trades off reconstruction sharpness against latent regularity, and "what β\beta should I use" is mostly an empirical question.

For practical generation, β<1\beta < 1 also makes sense and is often what people use under the name "": start with a small KL so the decoder learns something, then ramp up. The HuggingFace diffusers library uses this pattern internally for some of its VAE pre-training.

FIG 18.3.6

VQ-VAE and discrete latents

VQ-VAE (van den Oord et al., 2017) makes the space discrete. Instead of a continuous z\mathbf{z}, the encoder output is snapped to the nearest entry in a learned codebook eRK×D\mathbf{e} \in \mathbb{R}^{K \times D}. The decoder sees the codebook vector. Because argmin is not differentiable, gradients are passed through with a straight-through estimator (the of the decoder input is copied to the encoder output). The codebook is updated with an EMA or a separate loss term.

Why bother? Discrete latents are a natural fit for priors. Once you have a VQ-VAE with KK codebook entries, you can train an autoregressive model (a PixelCNN, a Transformer) over the discrete latent grid. Sampling becomes: autoregressively sample latent codes, then decode. This is how DALL-E (the original 2021 version) worked and how most modern audio generative models work. The codebook structure shows up again in modern image and audio tokenizers, and it is the reason "image tokens" are a coherent phrase.

FIG 18.3.7

GAN: the min-max game

A Generative Adversarial Network is two networks trained against each other. A generator Gθ:RkRdG_\theta : \mathbb{R}^k \to \mathbb{R}^d maps noise zN(0,I)\mathbf{z} \sim \mathcal{N}(\mathbf{0}, \mathbf{I}) to fake samples. A discriminator Dϕ:Rd[0,1]D_\phi : \mathbb{R}^d \to [0, 1] outputs a that an input is real. The training objective is a saddle point:

minθmaxϕExpdata[logDϕ(x)]+Ez[log(1Dϕ(Gθ(z)))]\min_\theta \max_\phi \mathbb{E}_{\mathbf{x} \sim p_\text{data}}[\log D_\phi(\mathbf{x})] + \mathbb{E}_{\mathbf{z}}[\log(1 - D_\phi(G_\theta(\mathbf{z})))]

The discriminator maximizes log-likelihood that it correctly classifies real vs fake. The generator minimizes the discriminator's ability to do so. At the Nash equilibrium of this game, pG=pdatap_{G} = p_\text{data} and D=1/2D = 1/2 everywhere. Goodfellow's 2014 paper proves the equilibrium exists under idealized assumptions. Whether you can find it with SGD is a different question.

In practice, the generator's at the start of training is poor when log(1D(G(z)))\log(1 - D(G(\mathbf{z}))) saturates. The non-saturating loss replaces the minimization with maxθEz[logDϕ(Gθ(z))]\max_\theta \mathbb{E}_\mathbf{z}[\log D_\phi(G_\theta(\mathbf{z}))]. It is the same equilibrium, with better gradients near the start. This is the loss used in basically every GAN you have heard of.

Library path:

Python
class Generator(nn.Module):
    def __init__(self, d_z: int = 100, d_out: int = 784):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(d_z, 256), nn.LeakyReLU(0.2),
            nn.Linear(256, 512), nn.LeakyReLU(0.2),
            nn.Linear(512, d_out), nn.Tanh(),
        )
    def forward(self, z): return self.net(z)

class Discriminator(nn.Module):
    def __init__(self, d_in: int = 784):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(d_in, 512), nn.LeakyReLU(0.2),
            nn.Linear(512, 256), nn.LeakyReLU(0.2),
            nn.Linear(256, 1),
        )
    def forward(self, x): return self.net(x)

def train_step(G, D, x_real, opt_G, opt_D, d_z=100):
    bs = x_real.size(0)
    z = torch.randn(bs, d_z)
    x_fake = G(z)

    # D step
    d_real = D(x_real); d_fake = D(x_fake.detach())
    loss_D = (nn.functional.binary_cross_entropy_with_logits(d_real, torch.ones_like(d_real))
            + nn.functional.binary_cross_entropy_with_logits(d_fake, torch.zeros_like(d_fake)))
    opt_D.zero_grad(); loss_D.backward(); opt_D.step()

    # G step (non-saturating)
    d_fake = D(x_fake)
    loss_G = nn.functional.binary_cross_entropy_with_logits(d_fake, torch.ones_like(d_fake))
    opt_G.zero_grad(); loss_G.backward(); opt_G.step()
    return loss_D.item(), loss_G.item()

FIG 18.3.8

Mode collapse and the instability of GAN training

Mode collapse is when the generator finds one mode of the data distribution that fools the discriminator and stays there. You train on faces and every face looks the same. You train on digits and every sample is a 3. The generator is stuck producing a slice of the data because the discriminator's field has a local minimum, and the discriminator chases the generator to that mode rather than spreading out.

A second pathology: the generator and discriminator oscillate. The discriminator wins, the generator's loss explodes, the generator updates and outpaces the discriminator, and the cycle repeats. The samples never improve.

Three diagnostic habits. First, look at samples, not at the loss curves. GAN losses are uninformative because of the adversarial dynamics. Second, track FID (Fréchet Inception Distance) or KID (Kernel Inception Distance) across training: both summarize a of generated images by the statistics of their features under a fixed pretrained Inception network and report a distance to the same statistics computed on real images, so lower means the two image distributions are closer (the precise distance — Fréchet for FID, a kernel two-sample test for KID — is not derived here). These are the only loss-like signals that correlate with subjective sample quality. Third, plot a 2D projection of generated samples against real samples. If the generated points cluster in a sub-region, you have mode collapse. The ganlab playground from Polo Club is the best in-browser way to see this happen on toy data.

FIG 18.3.9

WGAN, WGAN-GP, and the Wasserstein fix

Wasserstein GAN (Arjovsky et al., 2017) replaces the Jensen-Shannon divergence with the Wasserstein-1 (earth mover's) distance. JS is the symmetric, [0,1][0,1]-bounded divergence the §7 value function is implicitly minimizing when the discriminator is optimal; its problem is that for two distributions with no overlap (common early in training) it sits flat at log2\log 2 and gives no usable , whereas the Wasserstein-1 distance (informally, the minimum cost of moving one pile of mass to match the shape of the other) stays smooth even then. The Kantorovich-Rubinstein duality rewrites that intractable transport cost as

W1(p,q)=supfL1Ep[f]Eq[f]W_1(p, q) = \sup_{\|f\|_L \leq 1} \mathbb{E}_p[f] - \mathbb{E}_q[f]

a supremum over all 1-Lipschitz functions ff (functions whose output changes by at most x1x2|x_1 - x_2| between any two inputs, so the slope is bounded by 1 everywhere). The discriminator (now called a critic) outputs a real-valued score, not a probability, and approximates the supremum. Training minimizes W1(pdata,pG)W_1(p_\text{data}, p_G) as estimated by the critic.

The original WGAN enforced the Lipschitz constraint by clipping (bound every critic to [0.01,0.01][-0.01, 0.01]). This works but is crude. WGAN-GP (Gulrajani et al., 2017) replaces clipping with a gradient penalty: at random interpolated points x^=αx+(1α)x^fake\hat{\mathbf{x}} = \alpha \mathbf{x} + (1-\alpha)\hat{\mathbf{x}}_\text{fake}, penalize (x^D(x^)1)2(\|\nabla_{\hat{\mathbf{x}}} D(\hat{\mathbf{x}})\| - 1)^2. The 1-Lipschitz constraint is enforced softly via the penalty.

WGAN-GP is the workhorse of stable GAN training. It still mode-collapses sometimes. It still requires tuning. It is much more forgiving than the original GAN, and it is the you should reach for if you have to train a GAN from scratch in 2026.

Library path (the critical bits):

Python
def gradient_penalty(D, x_real, x_fake, device):
    alpha = torch.rand(x_real.size(0), 1, device=device)
    x_hat = (alpha * x_real + (1 - alpha) * x_fake).requires_grad_(True)
    d_hat = D(x_hat)
    grads = torch.autograd.grad(
        outputs=d_hat, inputs=x_hat,
        grad_outputs=torch.ones_like(d_hat),
        create_graph=True, retain_graph=True,
    )[0]
    return ((grads.norm(2, dim=1) - 1) ** 2).mean()

# WGAN-GP critic step
loss_D = D(x_fake).mean() - D(x_real).mean() + 10.0 * gradient_penalty(D, x_real, x_fake, device)

FIG 18.3.10

StyleGAN and the conditional generator pattern

StyleGAN (Karras et al., 2019, NVIDIA) is the architectural high-water mark of pre-diffusion image generation. Two ideas worth carrying forward. First, the mapping network: instead of feeding the noise z\mathbf{z} directly into the convolutional generator, pass it through an 8-layer MLP to produce a "style" vector w\mathbf{w} first. The style vector is then used to modulate the convolutional layers via AdaIN (adaptive instance normalization). This decouples the noise prior from the generator's input distribution and improves disentanglement empirically.

Second, progressive growing (StyleGAN1) and style-mixing (StyleGAN2+). Growing trains the model at 4x4, then 8x8, then 16x16, doubling resolution until 1024x1024. Style mixing samples two latents during training and applies them to different resolution blocks of the generator, which encourages independent control across scales.

You will not train a StyleGAN today, because diffusion replaces it. But the pattern of "decouple the from the generator input via a learned mapping" reappears everywhere. Text-to-image diffusion models do the same thing with CLIP embeddings.

FIG 18.3.11

Diffusion: the forward process

A diffusion model is a sequence of TT steps. The forward process takes a clean image x0\mathbf{x}_0 and progressively adds Gaussian noise:

q(xtxt1)=N(xt;1βtxt1,βtI)q(\mathbf{x}_t \mid \mathbf{x}_{t-1}) = \mathcal{N}(\mathbf{x}_t; \sqrt{1 - \beta_t}\, \mathbf{x}_{t-1}, \beta_t \mathbf{I})

where {βt}t=1T\{\beta_t\}_{t=1}^T is a variance schedule fixed ahead of time. Typical T=1000T = 1000, β1=104\beta_1 = 10^{-4}, βT=0.02\beta_T = 0.02, linearly interpolated. The forward process is not learned. It is a known stochastic recipe for destroying data.

The key trick: you can sample xt\mathbf{x}_t directly from x0\mathbf{x}_0 in closed form. Define αt=1βt\alpha_t = 1 - \beta_t and αˉt=s=1tαs\bar\alpha_t = \prod_{s=1}^t \alpha_s. Then

xt=αˉtx0+1αˉtϵ,ϵN(0,I)\mathbf{x}_t = \sqrt{\bar\alpha_t}\, \mathbf{x}_0 + \sqrt{1 - \bar\alpha_t}\, \boldsymbol{\epsilon}, \quad \boldsymbol{\epsilon} \sim \mathcal{N}(\mathbf{0}, \mathbf{I})

This is reparameterization again. You do not have to actually simulate TT steps to get xt\mathbf{x}_t. One Gaussian draw and a does it. This is what makes diffusion training tractable: at training time, you pick a random tt, sample xt\mathbf{x}_t in one shot, and ask the network to denoise it.

As tTt \to T, αˉt0\bar\alpha_t \to 0 and xT\mathbf{x}_T becomes indistinguishable from N(0,I)\mathcal{N}(\mathbf{0}, \mathbf{I}). The forward process has erased everything about x0\mathbf{x}_0. The reverse process is going to learn to climb back up the same chain.

DDPMScheduler.add_noise vs. from-scratch q_sample

DL glue
LIBRARY
sched = DDPMScheduler(num_train_timesteps=1000, beta_start=1e-4, beta_end=0.02, beta_schedule="linear")
t = torch.randint(0, sched.config.num_train_timesteps, (x0.size(0),))
noise = torch.randn_like(x0)
x_t = sched.add_noise(x0, noise, t)   # = sqrt(bar_alpha_t)*x0 + sqrt(1-bar_alpha_t)*noise
FROM SCRATCH
class Schedule:
    def __init__(self, T=1000, beta_min=1e-4, beta_max=0.02):
        self.T = T
        self.betas = torch.linspace(beta_min, beta_max, T)
        self.alphas = 1.0 - self.betas
        self.bar_alphas = torch.cumprod(self.alphas, dim=0)

    def q_sample(self, x0, t, noise):
        bar = self.bar_alphas.to(x0.device).gather(0, t)
        bar = bar.view(-1, 1, 1, 1)
        return torch.sqrt(bar) * x0 + torch.sqrt(1 - bar) * noise

from scratch: lab/solution.py: Schedule.__init__ and Schedule.q_sample

  1. 1DDPMScheduler(beta_start=1e-4, beta_end=0.02, beta_schedule="linear") self.betas = torch.linspace(beta_min, beta_max, T)
  2. 2internal self.alphas = 1.0 - betas self.alphas = 1.0 - self.betas
  3. 3internal self.alphas_cumprod = torch.cumprod(alphas, 0) self.bar_alphas = torch.cumprod(self.alphas, dim=0)
  4. 4sched.add_noise(x0, noise, t) torch.sqrt(bar) * x0 + torch.sqrt(1 - bar) * noise
  5. 5add_noise broadcasts alphas_cumprod[t] over image dims internally bar.view(-1, 1, 1, 1) to broadcast against (B,1,28,28)
What the one call hides
  • The choice of beta_schedule: 'linear' matches the scratch, but many diffusers pipelines default to 'scaled_linear' or 'squaredcos_cap_v2' (cosine), which produce a completely different bar_alpha curve.
  • It precomputes and stores alphas_cumprod, sqrt_alphas_cumprod, and sqrt_one_minus_alphas_cumprod as buffers, so add_noise is just two gathers and a multiply.
  • It handles dtype/device alignment of the timestep index and the schedule tensors for you (the scratch has to .to(x0.device) and .gather manually).
  • It carries clip_sample, prediction_type, and thresholding config flags that silently change the reverse/sampling steps even though add_noise itself looks identical.
  • Gotcha: The default beta_schedule is NOT plain 'linear' in many configs; pass beta_schedule='linear' explicitly or your bar_alpha curve will not match the from-scratch one.
  • Gotcha: add_noise expects integer timesteps in [0, num_train_timesteps); passing the continuous/sigma timesteps used by some schedulers gives wrong noising.
  • Gotcha: diffusers assumes data roughly in [-1, 1]; feeding [0, 1] MNIST silently produces a wrong signal-to-noise ratio at each t.

Use DDPMScheduler in any real project so you can swap schedules and samplers in one line; the from-scratch Schedule exists to show that add_noise is literally one reparameterized Gaussian draw, not a thousand-step simulation.

On the job: You configure the scheduler (betas/schedule/prediction_type) and choose how to sample timesteps in your training script, but you let diffusers own the precomputed buffers and the add_noise math.

FIG 18.3.12

Diffusion: the reverse process and the DDPM loss

The reverse process is a learned Markov chain pθ(xt1xt)=N(xt1;μθ(xt,t),Σθ(xt,t))p_\theta(\mathbf{x}_{t-1} \mid \mathbf{x}_t) = \mathcal{N}(\mathbf{x}_{t-1}; \boldsymbol{\mu}_\theta(\mathbf{x}_t, t), \Sigma_\theta(\mathbf{x}_t, t)). The network has to predict the parameters of the Gaussian that walks one step back up the chain. Ho et al. (2020) showed two simplifying moves that make this practical.

First, fix the variance Σθ\Sigma_\theta to a constant schedule (either βt\beta_t or β~t\tilde\beta_t). Don't learn it. The samples are almost as good and the training is much more stable.

Second, reparameterize the mean prediction. Instead of predicting μθ\boldsymbol{\mu}_\theta directly, predict the noise ϵθ(xt,t)\boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t) that was added at step tt. The mean is then recovered by

μθ(xt,t)=1αt(xt1αt1αˉtϵθ(xt,t))\boldsymbol{\mu}_\theta(\mathbf{x}_t, t) = \frac{1}{\sqrt{\alpha_t}} \left( \mathbf{x}_t - \frac{1 - \alpha_t}{\sqrt{1 - \bar\alpha_t}} \boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t) \right)

And the training loss collapses to a stunningly simple form:

Lsimple=Et,x0,ϵ[ϵϵθ(αˉtx0+1αˉtϵ,t)2]L_\text{simple} = \mathbb{E}_{t, \mathbf{x}_0, \boldsymbol{\epsilon}}\Big[\|\boldsymbol{\epsilon} - \boldsymbol{\epsilon}_\theta(\sqrt{\bar\alpha_t}\mathbf{x}_0 + \sqrt{1 - \bar\alpha_t}\boldsymbol{\epsilon}, t)\|^2\Big]

Read that loss carefully. You sample a random tt, a random clean image x0\mathbf{x}_0, and a random Gaussian noise ϵ\boldsymbol{\epsilon}. You construct xt\mathbf{x}_t by the closed-form forward sample. You ask a network to look at xt\mathbf{x}_t and tt and predict the noise you added. It is a regression problem. The network is usually a U-Net with timestep conditioning via sinusoidal embeddings added to the residual blocks. There is no adversarial training. There is no KL constraint on a . The only loss is MSE between the actual noise and the predicted noise. This is why diffusion training is so much more stable than GAN training.

diffusers train step (add_noise + MSE) vs. from-scratch train_step

DL glue
LIBRARY
t = torch.randint(0, sched.config.num_train_timesteps, (x0.size(0),), device=x0.device)
noise = torch.randn_like(x0)
x_t = sched.add_noise(x0, noise, t)
noise_pred = model(x_t, t).sample        # UNet2DModel returns .sample
loss = F.mse_loss(noise_pred, noise)
opt.zero_grad(); loss.backward(); opt.step()
FROM SCRATCH
def train_step(model, schedule, x0, optimizer):
    B = x0.size(0)
    t = torch.randint(0, schedule.T, (B,), device=x0.device)
    eps = torch.randn_like(x0)
    x_t = schedule.q_sample(x0, t, eps)
    eps_hat = model(x_t, t)
    loss = F.mse_loss(eps_hat, eps)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    return float(loss.item())

from scratch: lab/solution.py: train_step

  1. 1torch.randint(0, num_train_timesteps, (B,)) t = torch.randint(0, schedule.T, (B,))
  2. 2sched.add_noise(x0, noise, t) x_t = schedule.q_sample(x0, t, eps)
  3. 3model(x_t, t).sample (UNet2DModel forward) eps_hat = model(x_t, t) # TinyUNet forward
  4. 4F.mse_loss(noise_pred, noise) (L_simple) loss = F.mse_loss(eps_hat, eps)
  5. 5prediction_type='epsilon' default makes the target the raw noise target is eps, the noise actually added
What the one call hides
  • The loss target depends on prediction_type: 'epsilon' (target=noise, matches the scratch), 'v_prediction', or 'sample' (target=x0) change what model() must output.
  • diffusers leaves the actual MSE/backprop/optimizer to you; there is no .fit(), so this 'library' version is barely shorter. The real win is the tested scheduler and the UNet2DModel block library, not the loop.
  • Optional loss weighting by SNR (min-SNR-gamma) that production training scripts add on top of the plain MSE.
  • Timestep sampling is uniform here; the library does not impose importance sampling, so you keep responsibility for that.
  • Gotcha: If you switch the scheduler's prediction_type to 'v_prediction' but keep target=noise, the loss is silently wrong and the model learns garbage.
  • Gotcha: UNet2DModel returns an object; you must take .sample, unlike the bare-tensor return of the scratch TinyUNet.
  • Gotcha: Diffusion needs many epochs; a decreasing loss for a few steps (as in the __main__ smoke test) does not mean samples are good.

On the job you keep this loop hand-written even when using diffusers, because diffusion training is just MSE on predicted noise; the library gives you the scheduler and a tested UNet, not a .fit().

On the job: You write this exact training loop yourself (timestep sampling, add_noise, MSE, optionally SNR weighting), wiring diffusers' scheduler and UNet into it.

The U-Net model here takes (xt,t)(\mathbf{x}_t, t) and outputs a of the same shape as xt\mathbf{x}_t, interpreted as the noise prediction ϵθ\boldsymbol{\epsilon}_\theta.

FIG 18.3.13

Sampling: DDPM, DDIM, and the speed-vs-quality tradeoff

Sampling from a trained diffusion model means walking the reverse chain. You start with xTN(0,I)\mathbf{x}_T \sim \mathcal{N}(\mathbf{0}, \mathbf{I}) and iterate

xt1=1αt(xt1αt1αˉtϵθ(xt,t))+σtz,zN(0,I)\mathbf{x}_{t-1} = \frac{1}{\sqrt{\alpha_t}}\left(\mathbf{x}_t - \frac{1 - \alpha_t}{\sqrt{1 - \bar\alpha_t}} \boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t)\right) + \sigma_t \mathbf{z}, \quad \mathbf{z} \sim \mathcal{N}(\mathbf{0}, \mathbf{I})

for t=T,T1,,1t = T, T-1, \dots, 1. The stochastic term σtz\sigma_t \mathbf{z} is what makes this DDPM. With T=1000T = 1000 steps, a single sample takes 1000 forward passes through the network. That is too slow for any serious application.

DDIM (Song et al., 2020) is a deterministic sampler that uses the same trained model but skips steps. Set σt=0\sigma_t = 0 and parameterize the update as

xt1=αˉt1x^0(xt,t)+1αˉt1ϵθ(xt,t)\mathbf{x}_{t-1} = \sqrt{\bar\alpha_{t-1}}\, \hat{\mathbf{x}}_0(\mathbf{x}_t, t) + \sqrt{1 - \bar\alpha_{t-1}}\, \boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t)

where x^0\hat{\mathbf{x}}_0 is the network's implicit estimate of the clean image at step tt. You can subsample the timestep schedule (use 50 steps instead of 1000) and quality holds up surprisingly well. DDIM is the default sampler in most production diffusion code paths because it is the simplest sub-100-step option. Modern competitors (DPM-Solver, UniPC, Heun's method, Euler-ancestral) get to 20-step sampling at similar quality. The bookkeeping varies. The principle is the same: solve the underlying -flow ODE more accurately, take fewer steps.

FIG 18.3.14

Latent diffusion and Stable Diffusion

Diffusion on raw pixels is expensive: 512x512x3 inputs at 1000 steps with a U-Net. Diffusion (Rombach et al., 2022) does the diffusion process in the latent space of a separately-trained VAE. The pipeline:

  1. A VAE encodes 512x512x3 images into a 64x64x4 latent (a factor of 48x compression).
  2. The diffusion U-Net is trained to denoise in this 64x64x4 latent space, not in pixel space.
  3. At , sample a latent via the reverse process, then decode through the VAE decoder.

The compression makes the whole thing tractable on a single GPU. Stable Diffusion is the open-weights instantiation of this design (latent diffusion + CLIP text conditioning + the VAE that was trained on LAION). The U-Net has cross- layers that attend to text embeddings produced by a frozen CLIP text encoder. This is how you go from a prompt to a sample.

Three production details that matter. First, the VAE is not particularly good as a standalone generative model. Its job is to be a high-fidelity, low-distortion compressor. You sample in latent space, not from the VAE prior. Second, the text conditioning is injected through cross-attention at multiple resolutions of the U-Net, not just at the start. This is what gives the model fine-grained control. Third, the VAE decoder is the source of most of the "Stable Diffusion looks" texture. Different VAEs (the original "EMA" VAE, the "MSE" VAE, the SDXL VAE) produce visibly different outputs from the same latent.

FIG 18.3.15

Classifier-free guidance

Classifier-free guidance (Ho & Salimans, 2022) is the single trick that makes text-to-image diffusion follow prompts well. Train the diffusion model jointly on conditional (text-conditioned) and unconditional inputs by dropping the text condition with some p0.1p \approx 0.1 during training. At time, run the network twice per step: once with the prompt cc to get ϵθ(xt,t,c)\boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t, c) and once with no condition (or a null prompt) to get ϵθ(xt,t,)\boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t, \varnothing). Combine:

ϵ~=ϵθ(xt,t,)+w(ϵθ(xt,t,c)ϵθ(xt,t,))\tilde{\boldsymbol{\epsilon}} = \boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t, \varnothing) + w \cdot (\boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t, c) - \boldsymbol{\epsilon}_\theta(\mathbf{x}_t, t, \varnothing))

The guidance scale ww is the CFG knob. w=1w = 1 recovers the conditional model. w>1w > 1 amplifies the conditional direction. Stable Diffusion's de-facto default is w=7.5w = 7.5.

The high-CFG samples are visibly more "prompt-faithful" but also more saturated, more contrasty, and lose diversity. Very high CFG (w>15w > 15) produces samples that look burnt. There is no theoretical reason w=7.5w = 7.5 is the right number. It is what the community converged on through eyeballing.

Library path (the single sampling step):

Python
def classifier_free_guidance_step(model, x_t, t, c_pos, c_null, guidance_scale=7.5):
    eps_uncond = model(x_t, t, c_null)
    eps_cond   = model(x_t, t, c_pos)
    return eps_uncond + guidance_scale * (eps_cond - eps_uncond)

FIG 18.3.16

v-prediction, eps-prediction, and flow matching

There are several equivalent parameterizations of what the network predicts at each step. The original DDPM predicts ϵ\boldsymbol{\epsilon} (the noise). v-prediction (Salimans & Ho, 2022) predicts a velocity-like quantity vt=αˉtϵ1αˉtx0\mathbf{v}_t = \sqrt{\bar\alpha_t}\boldsymbol{\epsilon} - \sqrt{1 - \bar\alpha_t}\mathbf{x}_0. The loss surface is conditioned differently and v-prediction is more numerically stable at the endpoints of the noise schedule. Imagen-Video, Stable Diffusion XL, and several others use v-prediction.

Flow matching (Lipman et al., 2023; Esser et al., 2024) is the 2024+ replacement direction. The idea: instead of a Markov chain of noisy variables, parameterize a continuous-time flow between data and noise. The training objective is to predict the conditional vector field that pushes a noise sample toward a data sample along a straight line. The sampling step is a single ODE integration. Rectified flow (the variant used in Stable Diffusion 3 and FLUX) makes the conditional flow straight by design. Empirically, flow models train more stably, sample with fewer steps, and scale better.

The takeaway: the "diffusion" framing of 2020 is one parameterization of a broader family. The math is the same up to substitutions. If you are starting a new generative project in 2026, look at flow matching first. The reason is empirical: the loss surface is nicer.


FIG 18.4 · Safety lens · this chapter

Generative models inherit their failure modes from the data they were trained on, the loss they were trained against, and the way they are sampled. The three modes that matter for deployed image generators:

Memorization and training-. Carlini et al. (2023, Extracting Training Data from Diffusion Models) showed that Stable Diffusion v1.4 will reproduce specific training images verbatim when prompted with the exact captions used for those images. The mechanism is on duplicated training data plus the deterministic geometry of CFG-guided sampling. The same finding holds for the Imagen and GLIDE replications. For a deployed model this is a copyright, privacy, and consent problem in one. Detection: prompt the model with captions from LAION-2B and compare nearest-neighbor distances against the . The Carlini paper's methodology is reproducible. Mitigation: deduplicate training data aggressively, randomize captions during training, and add membership- detectors at deployment.

Specification gaming through the reward model in image RLHF. a generator against a learned scorer that stands in for "good output" — a reward model, the proxy objective at the heart of RLHF, covered fully in another chapter — invites the failure where the model satisfies the literal scorer without delivering the intended quality. Aesthetic-score-fine-tuned diffusion models (e.g., the LAION-Aesthetics V2 fine-tune) over-produce samples that score high on the aesthetic predictor: oversaturated colors, smooth skin, bokeh backgrounds. This is reward hacking translated to images, exploiting flaws in the reward signal to win a high score without genuinely doing the task. The aesthetic predictor was trained on Pinterest-style images. The fine-tune learned to produce Pinterest-style images. The reward signal is a proxy and the model exploits it (Lilian Weng's reward-hacking taxonomy applies directly here, see another chapter for the RL framing). Detection: hold-out aesthetic predictors and compare scores. Mitigation: regularize toward the pretraining distribution via KL penalty (the DPO-for-diffusion approach), or change the reward signal entirely.

Misuse via conditional generation. A text-to-image model that follows prompts well is, by construction, a system that produces specified images on demand. The same capability that lets you generate "a cat astronaut" lets you generate non-consensual imagery, copyright infringements, and weaponizable content. Mitigations split into model-level (training-time content filtering, watermarking such as Stable Signature or invisible perturbations) and deployment-level (prompt classifiers, output classifiers, NSFW filters). None of them are robust. The OWASP LLM Top 10 and Anthropic's red-teaming work both note that chains can be bypassed with rephrased prompts or via fine-tuned open-weights forks.

Two practical habits when you ship a generative model. First, log the inputs and outputs. You cannot detect misuse you do not see. Second, dedup the training set against known-private and known-copyright sources before training, not after deployment. Membership-inference is much easier than removal.


FIG 18.5 · Under the hood

The library call, and the lines it hides

You don't have to choose between “use the library” and “build it from scratch.” Here is the one library call, the exact lines it stands in for, and when to reach for which on the job.

DDIMScheduler.step (eta=0) vs. from-scratch ddim_sample

DL glue
LIBRARY
sched = DDIMScheduler(num_train_timesteps=1000, beta_start=1e-4, beta_end=0.02, beta_schedule="linear", clip_sample=False)
sched.set_timesteps(50)
x = torch.randn(n, 1, 28, 28)
for t in sched.timesteps:
    eps = model(x, t.expand(n)).sample
    x = sched.step(eps, t, x, eta=0.0).prev_sample   # eta=0 -> deterministic DDIM
x = x.clamp(-1, 1)
FROM SCRATCH
timesteps = torch.linspace(T-1, 0, steps+1, dtype=torch.long).tolist()
x = torch.randn(n, *shape, device=device)
bar = schedule.bar_alphas
for i in range(len(timesteps) - 1):
    t, t_prev = timesteps[i], timesteps[i+1]
    t_vec = torch.full((n,), t, dtype=torch.long, device=device)
    eps = model(x, t_vec)
    bar_t = bar[t]
    bar_prev = bar[max(0, t_prev)] if t_prev > 0 else torch.tensor(1.0, device=device)
    pred_x0 = (x - torch.sqrt(1 - bar_t) * eps) / torch.sqrt(bar_t)
    x = torch.sqrt(bar_prev) * pred_x0 + torch.sqrt(1 - bar_prev) * eps
return x.clamp(-1, 1)

from scratch: lab/solution.py: ddim_sample

  1. 1sched.set_timesteps(50) timesteps = torch.linspace(T-1, 0, steps+1).long()
  2. 2x = torch.randn(n, ...) prior sample x = torch.randn(n, *shape)
  3. 3step(...) internal: pred_x0 = (x - sqrt(1-bar_t)*eps)/sqrt(bar_t) pred_x0 = (x - sqrt(1 - bar_t) * eps) / sqrt(bar_t)
  4. 4step(...) internal re-noise: sqrt(bar_prev)*pred_x0 + sqrt(1-bar_prev)*eps x = sqrt(bar_prev)*pred_x0 + sqrt(1 - bar_prev)*eps
  5. 5eta=0.0 makes step deterministic (no sigma_t * z) no stochastic term in the update (DDIM, not DDPM)
  6. 6.prev_sample is the returned x_{t_prev} the reassigned x at the end of the loop
What the one call hides
  • The eta knob: eta>0 interpolates back toward stochastic DDPM by adding sigma_t*noise; the scratch hard-codes eta=0.
  • Clipping/thresholding of the predicted x0 (clip_sample) before re-noising; the scratch only clamps once at the very end via clamp(-1,1).
  • Handling of the final step (t_prev<0): diffusers uses final_alpha_cumprod (=1.0 with set_alpha_to_one) exactly as the scratch's bar_prev=1.0 fallback.
  • Timestep spacing strategy ('leading'/'trailing'/'linspace'): the scratch uses linspace(T-1,0,steps+1) while diffusers defaults to 'leading', so the visited timesteps differ slightly at low step counts even though the per-step update is identical.
  • Gotcha: DDIMScheduler's default eta is 0 (deterministic) but its default beta_schedule may not be 'linear'; a train/sample schedule mismatch silently degrades samples.
  • Gotcha: You must call set_timesteps before the loop and iterate over sched.timesteps (descending), not range(steps).
  • Gotcha: Leaving clip_sample on (its default) clamps predicted x0 to [-1,1] each step and will not match the scratch unless you pass clip_sample=False.

Use DDIMScheduler/DPMSolver in production so you can drop to 20-50 steps and swap samplers freely; the from-scratch loop exists to prove DDIM is just 'predict x0, then re-noise to the previous timestep' with the stochastic term zeroed.

On the job: You write the sampling loop (set_timesteps, the for-loop over timesteps, the model call and clamp) and pick the sampler/step-count; the per-step update math is the library's.

diffusers Timesteps / get_timestep_embedding vs. from-scratch SinusoidalTimeEmbed

DL primitive
LIBRARY
emb = get_timestep_embedding(t, embedding_dim=64, flip_sin_to_cos=False,
                            downscale_freq_shift=1, max_period=10000)
# module form:
# te = Timesteps(num_channels=64, flip_sin_to_cos=False, downscale_freq_shift=1)
# emb = te(t)
FROM SCRATCH
def forward(self, t):
    half = self.dim // 2
    freqs = torch.exp(
        -math.log(10000) * torch.arange(0, half, device=t.device).float() / max(1, half - 1)
    )
    args = t.float()[:, None] * freqs[None, :]
    emb = torch.cat([torch.sin(args), torch.cos(args)], dim=-1)
    if emb.shape[-1] < self.dim:
        emb = F.pad(emb, (0, self.dim - emb.shape[-1]))
    return emb

from scratch: lab/solution.py: SinusoidalTimeEmbed.forward

  1. 1embedding_dim=64 self.dim (half = dim // 2)
  2. 2geometric frequency band exp(-log(max_period)*arange(half)/(half-shift)) freqs = torch.exp(-math.log(10000) * arange(0,half) / (half-1))
  3. 3outer product t * freqs args = t[:, None] * freqs[None, :]
  4. 4concat of sin and cos (flip_sin_to_cos=False -> sin first) torch.cat([torch.sin(args), torch.cos(args)], -1)
  5. 5zero-pad when embedding_dim is odd F.pad(emb, (0, self.dim - emb.shape[-1]))
What the one call hides
  • flip_sin_to_cos: diffusers defaults to cos-then-sin in many configs, whereas the scratch is sin-then-cos; the embedding is equally valid but not bit-identical unless you set flip_sin_to_cos=False.
  • downscale_freq_shift controls the exact frequency normalization (the off-by-one in the denominator); the scratch fixes it as (half-1), which equals diffusers' downscale_freq_shift=1.
  • An optional scale and max_period that shift/rescale the whole frequency band.
  • The library separates the raw embedding (Timesteps) from the learned MLP projection (TimestepEmbedding); the scratch folds the MLP into a separate nn.Sequential after this layer.
  • Gotcha: The sin/cos ordering (flip_sin_to_cos) differs across implementations; a model trained with one ordering will not accept weights from the other.
  • Gotcha: downscale_freq_shift=1 vs 0 changes the lowest frequency and is a silent source of train/inference mismatch.
  • Gotcha: These embeddings are unlearned; you still need the 2-layer MLP head after them (the scratch's t_embed Sequential) for them to be useful.

Reach for diffusers' Timesteps/get_timestep_embedding to guarantee the canonical frequencies, but know the exact formula because the sin/cos ordering and freq-shift flags bite when porting weights between codebases.

On the job: You usually drop in the stock op and just feed it through your own MLP head; you only reimplement it (or subclass) when you need a non-standard ordering, max_period, or scale.


FIG 18.6 · Chapter notebook

Build this chapter with your own hands

A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.

What you'll build

  • PCA reframed as a linear autoencoder, checked numerically against a torch autoencoder you train: the two agree on reconstruction error.
  • A stacked (deep) autoencoder on an MNIST subset, plus a denoising variant, plus the closed-form VAE KL term computed both ways.
  • A GAN on a known 2D mixture of Gaussians, so "did it recover the distribution?" is an assertion, not a vibe. Then a deliberate mode collapse, diagnosed from samples, then fixed.
  • A mini DDPM (a few hundred steps, not 700k) on the same 2D mixture, and the one-line proof that the regress-to-noise loss is what makes diffusion stable.

~6 min on CPU · 105 cells · 15 checked exercises · runs in Colab


FIG 18.7 · Going further

  • 29-practice-engineering/lucidrains-diffusion

    the denoising-diffusion-pytorch repo is the cleanest, most-readable production implementation of DDPM, DDIM, EDM, and v-prediction. If you want to extend the lab, start here.

  • 01-explorables/jalammar-illustrated-stable-diffusion

    Jay Alammar's visual walkthrough of latent diffusion + CLIP + cross-attention. Read it once before reading any Stable Diffusion code.

  • 18-lilian-weng/2024-04-12-diffusion-video

    the modern extension of diffusion to video. Same math, more dimensions, more architecture choices. Worth reading once after you have DDPM working on images.

  • 11-polo-club/diffusion-explainer

    in-browser explorable for Stable Diffusion's full pipeline (text encoder → U-Net steps → VAE decode). Best for building intuition.

  • 28-uni-courses/cs236-deepgenerativemodels-github-io

    Stefano Ermon's Stanford generative modeling course. The single most coherent set of lecture notes on the score-matching / SDE / flow family.

  • 14-arena-notebooks/chapter0-part5-vaes-and-gans

    ARENA's hands-on notebook for VAE + GAN + DDPM. Pair it with the lab in this chapter for two days of work.

  • 08-geron-notebooks/17_autoencoders_gans_and_diffusion_models

    Géron's chapter, idiomatic Keras. Useful for the breadth of the autoencoder family (sparse, contractive, undercomplete) that we did not cover here.



FIG 18.9 · 21 sources
  1. - `01-explorables/jalammar-illustrated-stable-diffusion`
  2. - `08-geron-notebooks/17_autoencoders_gans_and_diffusion_models`
  3. - `11-polo-club/diffusion-explainer`
  4. - `11-polo-club/ganlab`
  5. - `14-arena-notebooks/chapter0-part5-vaes-and-gans`
  6. - `16-d2l-sections/chapter_generative-adversarial-networks__dcgan`
  7. - `16-d2l-sections/chapter_generative-adversarial-networks__gan`
  8. - `18-lilian-weng/2017-08-20-gan`
  9. - `18-lilian-weng/2018-08-12-vae`
  10. - `18-lilian-weng/2021-07-11-diffusion-models`
  11. - `18-lilian-weng/2024-04-12-diffusion-video`
  12. - `18-lilian-weng/2024-11-28-reward-hacking`
  13. - `20-aisafetybook/organizational-risks`
  14. - `22-anthropic-recent/2023-monosemantic-features-index`
  15. - `25-alignment-canon/www-anthropic-com-research`
  16. - `26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications`
  17. - `28-uni-courses/cs236-deepgenerativemodels-github-io`
  18. - `29-practice-engineering/lucidrains-dalle`
  19. - `29-practice-engineering/lucidrains-diffusion`
  20. - `29-practice-engineering/lucidrains-imagen`
  21. - `05-safety/neelnanda-mechanistic-interpretability`