Ch. 18
Generative Models
Autoencoders → VAEs → GANs → Diffusion → flow matching. Latent diffusion explained from the math up.
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 A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → 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 Networks — you 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 — CNNs — the U-Net we use as a denoiser is a CNN with skip connections. If
nn.Conv2dis unfamiliar, do another chapter first. - Ch 7 — Dim Reduction — autoencoders are the nonlinear cousin of PCA. The framing carries over.
- Ch 8 — Unsupervised Learning — GMMs are the simplest generative models. The EM-on-GMM derivation is the prereq for understanding variational inference in modern generative models.
- Ch 16 — Multimodal Transformers — for 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 Inference — diffusion sampling shares the same bottlenecks as LLM inference (memory bandwidth, batching). The sampling section reuses another chapter's vocabulary.
- Ch 0 — Math & Python prereqs §probability — you 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 with , and a decoder . You train both jointly to make , with squared error or A loss that measures how far a model's predicted chances are from the true answer.Full glossary → as the reconstruction loss. The interesting thing is the middle. The -dimensional A spot in a model where its width is squeezed small on purpose, forcing the information to compress before it continues.Full glossary → is forced to retain whatever information about is necessary to reconstruct it, and discard everything else. After training, the bottleneck activations 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 A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → space has no A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → structure. If you sample a random and decode it, you get garbage almost always. The VAE fixes this. We will get there.
Library path (PyTorch):
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):
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), zYou 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 to get (mask some pixels, add Gaussian noise, set some values to zero) and train the network to reconstruct the clean from the corrupted input. The loss is now . 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 A spot in a model where its width is squeezed small on purpose, forcing the information to compress before it continues.Full glossary →. 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 A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → trick. The denoising objective is mathematically connected to score matching — training a network to estimate the score , the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → 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 A training trick where the model randomly switches off some of its own pieces each pass, so it can't lean too hard on any one of them.Full glossary → 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 A built-in assumption that nudges a model toward certain kinds of patterns.Full glossary →.
Library path:
# 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()FIG 18.3.3
Sparse autoencoders (the link to mech interp)
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 The main running tally of information that flows through a deep model, with each layer reading from it and adding its bit back in.Full glossary → of a transformer and the L1 penalty is set right, the features the SAE learns tend to be approximately monosemantic. One One piece of information about an example that the model looks at when making a guess.Full glossary → lights up on "function calls in Python," another on "tokens followed by a date," and so on. Anthropic's Towards Monosemanticity and Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → 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-A spot in a model where its width is squeezed small on purpose, forcing the information to compress before it continues.Full glossary →-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:
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_lossNote: 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 A model that squeezes images down to tiny codes and can rebuild images back from those codes.Full glossary → turns the encoder into a A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → distribution over latents, not a single point. Instead of , you have , where the encoder network outputs a mean and a log-variance per A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → dimension. To decode, you sample and pass it through the decoder.
The training objective is the evidence lower bound (ELBO):
The first term is reconstruction: how well does the decoder reproduce when given a sample from the encoder. The second term pulls the encoder distribution toward the prior . Without the KL, the encoder collapses to delta-function distributions and you have an ordinary autoencoder. With the KL too strong, the encoder ignores and the decoder learns nothing. The balance is the whole game.
The reparameterization trick. Sampling is not differentiable in . The trick: rewrite where . Now the stochasticity is in , which is One of the model's internal numbers that gets adjusted as it learns.Full glossary →-free, and is a deterministic function of . Gradients flow through and . 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 gluedist = 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/MSEdef 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 + kldfrom scratch: draft.md §4 (VAE.reparameterize and vae_loss); numpy twins reparameterize_numpy / kl_to_unit_gaussian
- 1
Normal(mu, exp(0.5*logvar)).rsample()mu + torch.randn_like(std) * std (std=exp(0.5*logvar)) - 2
rsample() (vs sample()) keeps gradients through mu and stdeps = randn_like(std) is parameter-free, so grads flow through mu, std - 3
kl_divergence(Normal(mu,sigma), Normal(0,1)).sum()-0.5 * sum(1 + logvar - mu^2 - exp(logvar)) - 4
the reconstruction term is still your own BCE/MSEbce = 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 :
The claim is that for the right , the A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → dimensions become disentangled: each axis of 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 A built-in assumption that nudges a model toward certain kinds of patterns.Full glossary → 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 should I use" is mostly an empirical question.
For practical generation, also makes sense and is often what people use under the name "Starting training with tiny steps that grow for a little while before the main plan kicks in.Full glossary →": start with a small KL A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → 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 A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → space discrete. Instead of a continuous , the encoder output is snapped to the nearest entry in a learned codebook . The decoder sees the codebook vector. Because argmin is not differentiable, gradients are passed through with a straight-through estimator (the A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → 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 Generating text one piece at a time, where each new piece is chosen based on everything written so far.Full glossary → priors. Once you have a VQ-VAE with 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 maps noise to fake samples. A discriminator outputs a A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → that an input is real. The training objective is a saddle point:
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, and 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 A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → at the start of training is poor when saturates. The non-saturating loss replaces the minimization with . 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:
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 A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → 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 A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → 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, -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 and gives no usable A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary →, whereas the Wasserstein-1 distance (informally, the minimum cost of moving one pile of A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → mass to match the shape of the other) stays smooth even then. The Kantorovich-Rubinstein duality rewrites that intractable transport cost as
a supremum over all 1-Lipschitz functions (functions whose output changes by at most 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 as estimated by the critic.
The original WGAN enforced the Lipschitz constraint by A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary → clipping (bound every critic One of the model's internal numbers that gets adjusted as it learns.Full glossary → to ). This works but is crude. WGAN-GP (Gulrajani et al., 2017) replaces clipping with a gradient penalty: at random interpolated points , penalize . 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 A setting you pick yourself before training starts, like the learning rate, batch size, or number of layers, which the model does not learn on its own.Full glossary → tuning. It is much more forgiving than the original GAN, and it is the A simple reference method you compare against to see whether a fancier approach is actually worth it.Full glossary → you should reach for if you have to train a GAN from scratch in 2026.
Library path (the critical bits):
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 directly into the convolutional generator, pass it through an 8-layer MLP to produce a "style" vector 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 A penalty added to the wrongness score that discourages the model from leaning on big, fussy numbers, nudging it to stay simple.Full glossary → (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 A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → 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 steps. The forward process takes a clean image and progressively adds Gaussian noise:
where is a variance schedule fixed ahead of time. Typical , , , linearly interpolated. The forward process is not learned. It is a known stochastic recipe for destroying data.
The key trick: you can sample directly from in closed form. Define and . Then
This is reparameterization again. You do not have to actually simulate steps to get . One Gaussian draw and a Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → does it. This is what makes diffusion training tractable: at training time, you pick a random , sample in one shot, and ask the network to denoise it.
As , and becomes indistinguishable from . The forward process has erased everything about . The reverse process is going to learn to climb back up the same chain.
DDPMScheduler.add_noise vs. from-scratch q_sample
DL gluesched = 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)*noiseclass 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) * noisefrom scratch: lab/solution.py: Schedule.__init__ and Schedule.q_sample
- 1
DDPMScheduler(beta_start=1e-4, beta_end=0.02, beta_schedule="linear")self.betas = torch.linspace(beta_min, beta_max, T) - 2
internal self.alphas = 1.0 - betasself.alphas = 1.0 - self.betas - 3
internal self.alphas_cumprod = torch.cumprod(alphas, 0)self.bar_alphas = torch.cumprod(self.alphas, dim=0) - 4
sched.add_noise(x0, noise, t)torch.sqrt(bar) * x0 + torch.sqrt(1 - bar) * noise - 5
add_noise broadcasts alphas_cumprod[t] over image dims internallybar.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 . 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 to a constant schedule (either or ). 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 directly, predict the noise that was added at step . The mean is then recovered by
And the training loss collapses to a stunningly simple form:
Read that loss carefully. You sample a random , a random clean image , and a random Gaussian noise . You construct by the closed-form forward sample. You ask a network to look at and 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 A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary →. 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 gluet = 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()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
torch.randint(0, num_train_timesteps, (B,))t = torch.randint(0, schedule.T, (B,)) - 2
sched.add_noise(x0, noise, t)x_t = schedule.q_sample(x0, t, eps) - 3
model(x_t, t).sample (UNet2DModel forward)eps_hat = model(x_t, t) # TinyUNet forward - 4
F.mse_loss(noise_pred, noise) (L_simple)loss = F.mse_loss(eps_hat, eps) - 5
prediction_type='epsilon' default makes the target the raw noisetarget 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 and outputs a A chunk of numbers arranged in a grid, or many grids stacked on top of each other.Full glossary → of the same shape as , interpreted as the noise prediction .
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 and iterate
for . The stochastic term is what makes this DDPM. With 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 and parameterize the update as
where is the network's implicit estimate of the clean image at step . 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 A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary →-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. A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → Diffusion (Rombach et al., 2022) does the diffusion process in the latent space of a separately-trained VAE. The pipeline:
- A VAE encodes 512x512x3 images into a 64x64x4 latent (a factor of 48x compression).
- The diffusion U-Net is trained to denoise in this 64x64x4 latent space, not in pixel space.
- At Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →, 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-A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → 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 A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → during training. At Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → time, run the network twice per step: once with the prompt to get and once with no condition (or a null prompt) to get . Combine:
The guidance scale is the CFG knob. recovers the conditional model. amplifies the conditional direction. Stable Diffusion's de-facto default is .
The high-CFG samples are visibly more "prompt-faithful" but also more saturated, more contrasty, and lose diversity. Very high CFG () produces samples that look burnt. There is no theoretical reason is the right number. It is what the community converged on through eyeballing.
Library path (the single sampling step):
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 (the noise). v-prediction (Salimans & Ho, 2022) predicts a velocity-like quantity . 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-When hints about the answers sneak into the studying, making the model look smarter than it really is.Full glossary →. 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 When a model memorizes the quirks and flukes of its study examples instead of the real pattern, so it flops on anything new.Full glossary → on duplicated training data plus the deterministic A compressed bundle of numbers that captures the essence of some data without being readable on its own.Full glossary → 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 batch of examples the model actually studies and learns from.Full glossary →. The Carlini paper's methodology is reproducible. Mitigation: deduplicate training data aggressively, randomize captions during training, and add membership-Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → detectors at deployment.
Specification gaming through the reward model in image RLHF. Taking a model that already learned a lot of general skills and training it a bit more on your own specific data.Full glossary → 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 A small grid of weights that slides across an image to spot a particular pattern.Full glossary → 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 gluesched = 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)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
sched.set_timesteps(50)timesteps = torch.linspace(T-1, 0, steps+1).long() - 2
x = torch.randn(n, ...) prior samplex = torch.randn(n, *shape) - 3
step(...) internal: pred_x0 = (x - sqrt(1-bar_t)*eps)/sqrt(bar_t)pred_x0 = (x - sqrt(1 - bar_t) * eps) / sqrt(bar_t) - 4
step(...) internal re-noise: sqrt(bar_prev)*pred_x0 + sqrt(1-bar_prev)*epsx = sqrt(bar_prev)*pred_x0 + sqrt(1 - bar_prev)*eps - 5
eta=0.0 makes step deterministic (no sigma_t * z)no stochastic term in the update (DDIM, not DDPM) - 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 primitiveemb = 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)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 embfrom scratch: lab/solution.py: SinusoidalTimeEmbed.forward
- 1
embedding_dim=64self.dim (half = dim // 2) - 2
geometric frequency band exp(-log(max_period)*arange(half)/(half-shift))freqs = torch.exp(-math.log(10000) * arange(0,half) / (half-1)) - 3
outer product t * freqsargs = t[:, None] * freqs[None, :] - 4
concat of sin and cos (flip_sin_to_cos=False -> sin first)torch.cat([torch.sin(args), torch.cos(args)], -1) - 5
zero-pad when embedding_dim is oddF.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-diffusionthe
denoising-diffusion-pytorchrepo 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-diffusionJay 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-videothe 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-explainerin-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-ioStefano 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-gansARENA'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_modelsGéron's chapter, idiomatic Keras. Useful for the breadth of the autoencoder family (sparse, contractive, undercomplete) that we did not cover here.
FIG 18.8 · What this enables
Chapters you can now read, with the connecting idea written out.
FIG 18.9 · 21 sources
- - `01-explorables/jalammar-illustrated-stable-diffusion`
- - `08-geron-notebooks/17_autoencoders_gans_and_diffusion_models`
- - `11-polo-club/diffusion-explainer`
- - `11-polo-club/ganlab`
- - `14-arena-notebooks/chapter0-part5-vaes-and-gans`
- - `16-d2l-sections/chapter_generative-adversarial-networks__dcgan`
- - `16-d2l-sections/chapter_generative-adversarial-networks__gan`
- - `18-lilian-weng/2017-08-20-gan`
- - `18-lilian-weng/2018-08-12-vae`
- - `18-lilian-weng/2021-07-11-diffusion-models`
- - `18-lilian-weng/2024-04-12-diffusion-video`
- - `18-lilian-weng/2024-11-28-reward-hacking`
- - `20-aisafetybook/organizational-risks`
- - `22-anthropic-recent/2023-monosemantic-features-index`
- - `25-alignment-canon/www-anthropic-com-research`
- - `26-pentest-redteam/owasp-org-www-project-top-10-for-large-language-model-applications`
- - `28-uni-courses/cs236-deepgenerativemodels-github-io`
- - `29-practice-engineering/lucidrains-dalle`
- - `29-practice-engineering/lucidrains-diffusion`
- - `29-practice-engineering/lucidrains-imagen`
- - `05-safety/neelnanda-mechanistic-interpretability`