# Lecture 3: Score Matching and Diffusion Models Chapter 3 of the notes builds a generator out of corruption. A stochastic differential equation pushes a clean sample toward a standard Gaussian, Ito's lemma and the Fokker-Planck equation turn that equation into one for the density, and the variance-preserving choice of drift and noise scale makes the density Gaussian in closed form at every level. To reverse the motion we need the gradient of the log density, the score, and we learn it by regression on the noise that was drawn. Discretize the reverse equation on a grid and you get DDPM; rewrite the Laplacian as a divergence and you get the probability-flow ODE; and one extra gradient inside either sampler gives classifier, classifier-free and reward guidance. The last file runs the whole chain in the latent space of a protein language model. The clock runs from the data in every file here: `t = 0` and `k = 0` hold a clean sample and corruption grows with `t` and with `k`, up to a standard Gaussian at the terminal level. Chapter 2 runs its clock the other way, and we keep this one throughout. | File | Command | What it does | | --- | --- | --- | | `sde.py` | `python lecture_3/sde.py` | The forward process before any schedule: Brownian increments, the Euler-Maruyama step, the spreading cloud, a restoring drift, Ito's lemma on `phi(x) = x^2`, and the Fokker-Planck equation checked on `N(0, 1+t)`. | | `forward_diffusion.py` | `python lecture_3/forward_diffusion.py` | The variance-preserving schedule and the Gaussian corruption it drives: the four noise levels located by inverting `a_t`, the thousand-step grid against the continuous curve, one pixel at step 207, the worked training batch at `abar = 0.8`, and the corruption law measured against `N(a_k x_0, sigma_k^2 I)`. | | `score_matching.py` | `python lecture_3/score_matching.py` | Fits explicit, implicit and denoising score matching to the same one-dimensional Gaussian mixture, checks the integration by parts, locates the minimizer of each objective, and estimates a 64-dimensional trace from random directions. | | `ddpm.py` | `python lecture_3/ddpm.py` | Trains the chapter's U-Net on 28-by-28 images with `L_simple` and samples with the K-step reverse chain. `--dataset mnist` downloads the digits. | | `samplers.py` | `python lecture_3/samplers.py` | Ancestral DDPM, the probability-flow ODE and annealed Langevin on a four-Gaussian target whose corrupted score is exact, so nothing is trained and we compare the three on equal evaluation budgets. | | `guidance.py` | `python lecture_3/guidance.py` | Classifier, classifier-free and reward guidance on the same target with one label per mode, with weights on the simplex and a schedule on the strength. | | `latent_diffusion.py` | `python lecture_3/latent_diffusion.py` | AMP-Diffusion: masked corruption of residue embeddings, a transformer denoiser, reverse latent sampling, decoding through a language-model head, and a polar-composition constraint at decoding. `--dataset esm2` uses the real encoder. | Every file takes the shared flags of `cis6270.runner.common_parser`. In `sde.py`, `forward_diffusion.py`, `samplers.py` and `guidance.py` we work with a process we already know, so `--steps` sets the number of simulation steps, measured levels or reverse steps; the other three train a network, so there `--steps` sets the number of optimizer steps. ## What a default run prints `sde.py` is deterministic arithmetic plus a Monte Carlo check. The Euler-Maruyama step from `x = 2` lands at `2.11` with a drift displacement of `-0.04` against a noise displacement of `0.15`, and the typical noise is `2.5` times the drift at this step size. The driftless cloud ends with variance `0.2521` against the exact `g^2 T = 0.25`. The Ornstein-Uhlenbeck law at `t = log 2` has mean `1` and variance `0.75`, which `16384` simulated paths reproduce as `0.9860` and `0.7458`. Ito's lemma returns `E[W_t^2] = 0.2504` at `t = 0.25` against the exact `0.25`, and the same sum without the curvature term returns `0.0004`. The Fokker-Planck residual on `N(0, 1+t)` is zero to float precision, and the peak of the Gaussian falls from `0.3989` at the rate `-0.1995`, so the one-step estimate `0.3790` sits `0.0014` below the exact `0.3804`. `forward_diffusion.py` locates the four levels of the chapter's table at `t = 0.0589`, `0.2068`, `0.4544` and `0.7994`, with `sigma_t` equal to `0.199`, `0.600`, `0.9367` and `0.9992` and `beta(t)` equal to `1.2721`, `4.2157`, `9.1420` and `16.0073`; the two curves cross at `t = 0.25896`, where both are `0.7071`. On the thousand-step grid `abar_207 = 0.6402`, so `a_207 = 0.8001` against the continuous `0.7997` and `sigma_207 = 0.5999` against `0.6004`, and `a_1000 = 0.0064` against `0.0066`. One pixel at `x_0 = 0.5` and `eps = -1` lands at `-0.1998`, and by step `500` the signal is down to `0.2803`. The worked batch at `abar = 0.8` reproduces `(2.0125, -0.9839, 0.8944)`, a loss of `0.02`, score targets `(-1.118, 0.447, -2.236)` and clean estimates `(2.05, -1.05, 0.60)`. Corrupting one image `1024` times at each of `16` levels leaves the per-pixel sample mean within `0.0257` of `a_k x_0` and the pooled sample variance within `0.0026` of `sigma_k^2`, and both gaps fall as one over the square root of the number of draws. `score_matching.py` fits three networks from the same initialization on the same batches. The score errors against the exact field are `0.0337`, `0.1976` and `0.2073` for the explicit, implicit and denoising objectives: the explicit fit regresses on the exact target and comes out an order of magnitude closer, while the other two land near each other, each paying for the variance its own estimator adds, and their ordering moves with the seed. Along the family `a s_exact` the three minimizers come out `1.0000`, `0.9908` and `1.0053`, which is the claim that the three objectives agree on a minimizer, measured here in the code. The integration-by-parts identity closes to about one percent of its own size on a batch of 4096, the exact 64-dimensional trace takes 64 backward passes and returns `-64.00` against the Hutchinson estimate `-64.67` from 32 passes, and the posterior at `x_t = 7.4` has weights `0.2156` and `0.7844`, clean mean `9.2156`, marginal score `-0.0765` by both routes and an irreducible residual of `0.835`. `ddpm.py` on the offline images drops `L_simple` from `0.169` over the first hundred steps to `0.033` over the last hundred, and the generated images land in `[-1.60, 1.50]` with a standard deviation of `0.59`, against training data in `[-1, 1]`. The reverse posterior variance at `k = 500` is `0.01003` and the ELBO weight it carries is `0.005508`, against `0.625` on the two-step hand schedule. That two-step transition gives `8.6461` from both forms of the mean, a clean estimate of `10` with interpolation weights `0.4969` and `0.4969`, variance `1/9`, and `x_1 = 8.746`. `samplers.py` gives all three samplers 200 score evaluations. Each finds all four modes with mode entropy `1.3854`, `1.3848` and `1.3852` against `log 4 = 1.386`, and the mean distance to the nearest mode is `0.4162` for ancestral DDPM, `0.4446` for the probability-flow ODE and `0.4662` for annealed Langevin, against `0.4435` for the target itself. The mean log densities are `-2.01`, `-2.15` and `-2.24` against the target's own `-2.15`. The worked backward step prints a score of `-1.6`, a deterministic velocity of `0.4` reaching `1.16`, and a reverse drift of `2.0` reaching `1.0` plus a Gaussian of standard deviation `0.4472`. `guidance.py` sweeps the weight on the mode at `(2, 2)`. The target fraction runs `0.256`, `0.334`, `0.506`, `0.899`, `1.000` at `w = 0, 0.1, 0.25, 0.5, 1`, and the scheduled version of `w = 1` reaches `0.991`, slightly weaker because `zeta(t) = 4t(1-t)` switches the condition off at both ends of the interval. Reward steering with `lambda = (1, 0)` moves the two right-hand modes from `0.491` of the mass unguided to `0.719` at `eta = 0.5` and `0.994` at `eta = 2`, and `lambda = (0.7, 0.3)` concentrates `0.740` on the top-right mode. The classifier route and the classifier-free route agree on the guided noise to float precision, which is the content of the rearrangement of Bayes' rule, and the guided backward step gives the velocity `-0.8` and the state `1.28` from the velocity form and the same `-0.8` from the guided noise `0.2`. `latent_diffusion.py` trains the transformer denoiser on 128 toy embeddings of 16 residues and 32 coordinates. The masked loss falls from `1.038` to `0.817`. The two numbers that bracket it are `1.0`, which is what a denoiser that always returns zero scores on a standardized latent, and `0.725`, the posterior variance of `Z_0` given `Z_k` under a Gaussian latent, averaged over a uniform step index. Raising `--steps` to 2000 reaches `0.746`, close to that floor, for about three times the running time. We decode the generated latents and get a polar fraction of `0.3047`, and the constraint raises it to exactly `0.50` by rewriting the cheapest positions at each sequence until eight are polar. The chapter's latent coordinate `z_0 = 1.2` corrupts to `0.7204` at `k = 500`, a prediction of `1.05` implies `eps = 0.4438`, and the reverse mean is `0.7193` with standard deviation `0.1002`. ## What is simplified - `ddpm.py` uses a U-Net of base width 8, where the notes uses 64, and trains for a few hundred steps at a learning rate of `2e-3` in place of 20 MNIST epochs at `2e-4`. Both the width and the training budget are flags, so you can raise either one. The class, the schedule, the forward corruption and the reverse step are the chapter's code with its names kept. - `forward_diffusion.py` and `ddpm.py` are the two halves of the chapter's implementation section, split so that neither file runs long. Each lecture file stands on its own, so each rebuilds the schedule tensors; the two copies are identical, and `tests/test_lecture_3.py` asserts that they stay so. - `samplers.py` and `guidance.py` evaluate the exact score of the corrupted four-Gaussian mixture in place of a trained network, so you can compare the three samplers and the guidance weights with no training error in the way. A counter on the score stands in for the count of network evaluations. Both read the schedule off the continuous curve, `abar_k = exp(-B(t_k))`, in place of a linear sequence of betas, so that every `beta_k` stays below one on the coarse grid these files run; at `K = 1000` the two agree to about `1e-3`. - The conditional score in `guidance.py` is exact, so at `w = 1` the condition pins the mode and the mode fractions saturate. The graded part of the sweep therefore sits below one, where a trained conditional model would place it. - `score_matching.py` fits a time-independent field at the single level `a_t = 0.8`, `sigma_t = 0.6`, the chapter's running level, in place of one field across all levels. We write the Hutchinson estimator as a vector-Jacobian product; a score field has a symmetric Jacobian, so this equals the Jacobian-vector product of the notes at the same cost. - `latent_diffusion.py` runs offline on `cis6270.data.toy_embeddings`, which have 32 coordinates in place of the 1280 of ESM-2. Offline, a fixed random projection stands in for the language-model head; the decoding path and the composition constraint run unchanged, and the residues they read out are arbitrary. `--dataset esm2` uses the real encoder, the real head and the real tokenizer, at `MAX_LEN = 32` in place of the chapter's 64. That path downloads a 2.5 GB checkpoint and widens the denoiser to 1280, which is far too slow for the default `--steps` on a laptop CPU; try 20 first. Generation there fills all `MAX_LEN` positions, including the ones that held padding during training, exactly as the chapter's `sample_latents` does. - The reward model of Section 3.46 is a small network on the latent. In `guidance.py` the state is a point in the plane, so we read the two properties off its coordinates directly and keep the same signature, which leaves nothing to train.